Orchestrata Ultimate › Plans › briefs › qwen › Q2a-snapshot-argv-capture

Q2a — The AST snapshot records literal program names at process-spawn sites

Source: plans/briefs/qwen/Q2a-snapshot-argv-capture.md at 8a2cb42ad · last changed 2026-10-11 · first committed 2026-10-11 · 490 lines.

Prose pending.

Plan text

Q2a — The AST snapshot records literal program names at process-spawn sites

Field Value
Executor Qwen3.8-max (openrouter/qwen/qwen3.8-max-0902) via opencode run
Worktree /home/mohamed/workspace/wt-ARGV-GATE — work ONLY inside this directory
Branch feat/bare-toolchain-gate, base origin/master @ 762935ab6
Spec author orchestrator, 2026-10-11
Follow-on tasks (NOT yours) Q2b: Prolog facts + bare_toolchain rule + INVALID guard. Q3: fix every site Q2b reports.
Your deliverable code + tests + regenerated snapshot, uncommitted but staged, plus plans/briefs/qwen/Q2a-report.md

1. Directive and why this task exists

Mohamed's directive (verbatim): "fix bare python make it prolog and atoms".

A bare interpreter or compiler name in a spawned command (subprocess.run(["python", "x.py"]), os.system("gcc -c a.c")) is resolved from PATH at runtime. Measured on this host on 2026-10-11:

The fix is enforced in Prolog: the gate (scripts/gate.py run) derives violations from facts extracted from the AST snapshot (plans/ast-ultimate.json). Rule REQ-GATE-001: the fact extractor never opens a .py file; every structural fact comes from the snapshot. Today the snapshot records calls only as bare callee strings, without arguments, so the program literal is unrecoverable. This is the same gap EnvAccessInfo closed for environment keys (read it in §3.1; you are building its sibling).

Your job: the snapshot records facts; it never classifies them. Do NOT write any list of "bad" program names (python, gcc, …) in Python. Which programs count as bare toolchain invocations is decided by Prolog atoms in Q2b. You record every literal program name at a spawn site, verbatim.


2. Rules you must follow (project law — violations are rejected)

  1. Interpreter. Never run bare python, python3, pytest or pip. Always run, from the worktree root: env -u PYTHONPATH .venv/bin/python .... (Bare python resolves to the stale checkout described above.)
  2. uv only, no new dependencies, no edits to pyproject.toml / uv.lock.
  3. Tests: no unittest.mock, Mock, MagicMock, patch, monkeypatch, no Fake/Stub/Dummy classes, no skip/skipif/xfail, no pytest-xdist. Function-scope fixtures only; use tmp_path for files. Parse real source text with the real CodebaseAnalyzer.
  4. Absolute imports only. Never from . import x.
  5. Language: never write the word "should" in code, comments or docstrings. State facts.
  6. Environment access: never os.environ["X"]; use os.environ.get("X", default) (you do not need env access here).
  7. Git: do NOT commit, push, merge, rebase, reset, stash, or check out another branch. In step 8 you git add the files you changed so the review gate can run; the orchestrator reviews and commits.
  8. Scope: change ONLY the files in §5. If another file must change, STOP and report why.
  9. Isolation: never read or write outside /home/mohamed/workspace/wt-ARGV-GATE except reading /home/mohamed/.cache/uv. Other worktrees hold other agents' live work.
  10. Infrastructure: never connect to 10.53.1.x (production). Tests pin themselves to the test fleet 10.53.2.x via tests/conftest.py; do not override any endpoint.
  11. Test runs: never run the full suite. Never run scripts/run_tests.py without explicit test paths. Never use -n/xdist. Use exactly the commands in §7 and §8.
  12. Disagreement: if the code contradicts this spec, or a design choice in §4 looks wrong, STOP and write the reason in your report. Do not redesign.

3. Context you need (filtered; verbatim from origin/master @ 762935ab6)

To read anything else, use the AST toolkit (a warm daemon is already running for this worktree): env -u PYTHONPATH .venv/bin/python scripts/serialize_codebase_ast.py source <Name or module.Class.method>. Opening the files you edit is fine.

3.1 The precedent you mirror — src/orchestrata_ast/models.py:136-163

@dataclass
class EnvAccessInfo:
    """One read of the process environment, located.

    WHY THIS IS A SNAPSHOT FACT. `os.environ.get("X", default)` appears in the
    `calls` list as the bare string `os.environ.get`, usually without its
    arguments, and bare `os.environ["X"]` is a SUBSCRIPT that never appears in
    `calls` at all. So neither the KEY nor the FORM is recoverable from the
    snapshot as it stood, while RUL-003 is stated entirely in terms of both:
    ...
    `key` is the literal env var name, or "" when the subscript/argument is a
    computed expression rather than a literal — an unknown key is reported as
    unknown rather than guessed.
    """

    file_path: str
    line: int
    key: str
    form: str
    has_default: bool = False

3.2 ModuleInfo (models.py:166-182) and CodebaseAST (models.py:185-203)

@dataclass
class ModuleInfo:
    path: str
    file_path: str
    line_count: int = 0
    classes: list[ClassInfo] = field(default_factory=list)
    functions: list[FunctionInfo] = field(default_factory=list)
    imports: list[ImportInfo] = field(default_factory=list)
    module_vars: list[ClassVarInfo] = field(default_factory=list)
    docstring: str | None = None
    all_exports: list[str] = field(default_factory=list)  # __all__ contents
    # Every environment read in the module, at any nesting depth — see
    # EnvAccessInfo for why `calls` cannot answer this.
    env_accesses: list[EnvAccessInfo] = field(default_factory=list)
    residue_hash: str = ""

@dataclass
class CodebaseAST:
    name: str
    root_path: str
    modules: list[ModuleInfo] = field(default_factory=list)
    inheritance_graph: dict[str, list[str]] = field(default_factory=dict)
    dependency_graph: dict[str, list[str]] = field(default_factory=dict)
    total_classes: int = 0
    total_functions: int = 0
    total_methods: int = 0
    total_loc: int = 0
    extra_roots: list[str] = field(default_factory=list)

3.3 Where the precedent is wired — src/orchestrata_ast/analyzer.py

In CodebaseAnalyzer._analyze_file (analyzer.py:273-330), after the top-level loop:

        # Whole-tree, not the top-level loop above: an env read inside a
        # function body is exactly as much a RUL-003 subject as one at module
        # scope, and the loop above only walks direct children of the Module.
        module.env_accesses = self._analyze_env_accesses(tree, str(file_path))

CodebaseAnalyzer._analyze_env_accesses (analyzer.py:667-761) is a @staticmethod that walks the WHOLE tree with ast.walk, records only literal keys, and returns a list. Read it in full before writing your function; match its style (docstring explaining WHY, inline comments with measured reasons).

_DATACLASS_LIST_FIELDS (analyzer.py:~983). Its entries rebuild nested dataclass lists on deserialize:

_DATACLASS_LIST_FIELDS: dict[type, dict[str, type]] = {
    FunctionInfo: {"parameters": ParamInfo},
    ClassInfo: {"methods": FunctionInfo, "class_vars": ClassVarInfo},
    ModuleInfo: {
        "classes": ClassInfo,
        "functions": FunctionInfo,
        "imports": ImportInfo,
        "module_vars": ClassVarInfo,
        "env_accesses": EnvAccessInfo,
    },
}

_dataclass_from_dict (analyzer.py:988-1007) drops unknown keys and lets missing keys take their defaults. That is the hazard in §4.4: a cached module dict without argv_literals deserializes as argv_literals=[], indistinguishable from "captured, found none".

3.4 Warm start — load_or_analyze (analyzer.py:1115-1232)

The snapshot is regenerated INCREMENTALLY: unchanged files reuse their cached module dict verbatim. Identity checks that force a full reparse today:

    if cached.get("name") != name or not same_root:
        return _full_fallback()
    # The set of trees is part of the cache's identity, not a detail of how it
    # was built. ...
    if list(cached.get("extra_roots", [])) != [str(extra) for extra in extra_roots]:
        return _full_fallback()

    file_meta = cached.get("file_meta")
    if not isinstance(file_meta, dict):
        return _full_fallback()
    ...
        if (
            isinstance(prior, dict)
            and prior.get("sha1") == sha1
            and prior_dict is not None
        ):
            live_modules.append(module_info_from_dict(prior_dict))
            stats["reused"].append(module_path)
            stats["cached_modules"][module_path] = prior_dict
    ...
    analyzer.result = CodebaseAST(
        name=name,
        root_path=str(root),
        modules=live_modules,
        extra_roots=[str(extra) for extra in extra_roots],
    )

And scripts/serialize_codebase_ast.py::_cmd_serialize (:12343-12426) writes the cached raw dict for every reused module (out_modules.append(cached_modules[mpath])). So after your model change, an incremental run with no identity change would write every unchanged module WITHOUT the argv_literals key. §4.4 closes this.

3.5 The tests projection — derive_subtree (analyzer.py:1016-1112)

plans/ast-tests.json is derived from the wide snapshot. It builds a NEW CodebaseAST(name=..., root_path=..., modules=[...]), serializes it, then replaces derived["modules"] with the projected dicts. Any new top-level field on CodebaseAST is therefore reset to its default in the projection unless copied from the source snapshot.

3.6 How the snapshot is produced

The entry point is the ast-sync console script (pyproject.toml: ast-sync = "orchestrata_ultimate.tools.ast_pipeline:main"); run env -u PYTHONPATH uv run --frozen ast-sync --snapshot-only from the worktree root. It calls src/orchestrata_ultimate/tools/ast_pipeline.py::main, which runs scripts/serialize_codebase_ast.py serialize --source <6 trees> --name orchestrata-ultimate --output plans/ast-ultimate.json --incremental and then derives plans/ast-tests.json with derive_subtree. Trees (ast_pipeline.EXTRA_SOURCE_TREES): src/orchestrata_ultimate (primary), tests, scripts, src/orchestrata_ast, src/orchestrata_tools, claude. The run prints Incremental: fallback (full reparse) or Incremental: warm — reused N, reparsed M, removed K.

3.7 Consumers of the snapshot's module dicts (must keep working; their tests are in §8)

3.8 Test precedents to read before writing tests


4. Design (decided — implement exactly)

4.1 Model — src/orchestrata_ast/models.py

Add directly after EnvAccessInfo:

@dataclass
class ArgvLiteralInfo:
    """<docstring: WHY THIS IS A SNAPSHOT FACT — calls carry no arguments, so the
    program a spawn runs is unrecoverable; Q2b's Prolog rule decides which
    programs are bare toolchain names; this records every literal program
    verbatim and classifies nothing. Include the forms table from §4.2.>"""

    file_path: str
    line: int      # line of the LITERAL that names the program (where a fix edits)
    site: str      # dotted callee text of the spawning call, e.g. "subprocess.run", "_run", "os.system"
    form: str      # "argv" | "exec" | "shell"
    program: str   # the literal program token, verbatim: "python3", "gcc", "/usr/bin/gcc", "uv", "env"

Add to ModuleInfo, directly after env_accesses, with a two-line comment pointing at ArgvLiteralInfo: argv_literals: list[ArgvLiteralInfo] = field(default_factory=list)

Add to CodebaseAST, after extra_roots, with a comment explaining §4.4: captures: list[str] = field(default_factory=list)

4.2 Capture — CodebaseAnalyzer._analyze_argv_literals(tree, file_path) -> list[ArgvLiteralInfo]

A @staticmethod, placed directly after _analyze_env_accesses. Literals only: a head that is not an ast.Constant of type str is never recorded (computed heads such as sys.executable, PY, [*PY, ...], argv parameters are skipped, not guessed).

Callee text (site): render call.func as a dotted chain: Name gives its id; Attribute gives <render(value)>.<attr>; any other node in the chain renders as ? (e.g. factory().run gives ?.run). The last segment is the text after the final ..

Form AST shape that is captured program line
argv (direct) any Call (any callee) whose first positional argument — or, when it has no positional arguments, its keyword args, cmd, command or argv — is a List/Tuple whose element 0 is a str Constant, OR a BinOp with op=Add whose left is such a List/Tuple element 0, verbatim the List/Tuple node's lineno
argv (named) inside ONE scope: an Assign with a single Name target (or AnnAssign with a Name target and a value) whose value is a List/Tuple (or BinOp Add with such a left) with a str Constant element 0, AND a Call in the same scope whose first positional argument (or keyword args/cmd/command/argv) is a Name with that id element 0, verbatim the assigned List/Tuple node's lineno
exec a Call whose last segment is create_subprocess_exec, or whose dotted text is os.execv, os.execve, os.execvp, os.execvpe, os.execl, os.execle, os.execlp, os.execlpe — first positional arg is a str Constant. For os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe the program is the SECOND positional arg (the first is the mode). that string, verbatim the string Constant's lineno
shell a Call whose dotted text is os.system or os.popen, or whose last segment is getoutput, getstatusoutput, create_subprocess_shell, run, Popen, call, check_call or check_output and whose base text is subprocess or asyncio (create_subprocess_shell only) — and whose first positional argument is a str Constant, or a JoinedStr whose values[0] is a str Constant containing whitespace the first whitespace-separated token of that string (s.strip().split()[0]), verbatim; skip if empty the string node's lineno

Scope for the named form: the module body, or one function body (FunctionDef/AsyncFunctionDef); a nested function or class body is its own scope and is NOT searched from the outer one. Walk each scope's statements with ast.walk but do not descend into nested FunctionDef/AsyncFunctionDef/ClassDef/Lambda nodes (they are scopes of their own; Lambda bodies are expressions and are visited as part of their own walk). Emit ONE record per assigned literal that at least one call in the same scope consumes; site is the callee of the FIRST consuming call in source order (lineno, then col_offset).

Not captured (by design): a literal list that no call consumes (e.g. LANGS = ("python", "bash") never passed to a call — a REPL language table is not a spawn); a str first argument to a call outside the shell row; any non-literal head; a Starred element 0.

Over-capture is intended. The argv rows match ANY callee, so sorted(["python", "b"]) or frozenset(("gcc", "cc")) IS recorded (site="sorted" / "frozenset"). That is deliberate: a project wrapper such as _run([...]) or self._spawn([...]) is a spawn that no fixed callee list can name, so the snapshot records every literal-headed argument list together with its callee, and Q2b's Prolog rule decides which callees spawn processes. Do NOT add a callee allowlist or denylist for the argv rows.

Determinism: return records sorted by (line, site, form, program) and de-duplicated on that 4-tuple. The snapshot is committed; unstable ordering creates churn.

Known limitation to state in the docstring: a shell string prefixed by env ("env -u X python y") records env. Looking past env is a later refinement (Q2b may handle it in Prolog); do not do it here.

4.3 Wiring — src/orchestrata_ast/analyzer.py

4.4 Capture identity (closes the half-captured-snapshot hole)

4.5 What you do NOT change

src/orchestrata_ultimate/gates/*, src/orchestrata_ultimate/prolog/*, scripts/gate.py, any fact or rule — that is Q2b. No site fixes — that is Q3.


5. Files you may change or create

File Change
src/orchestrata_ast/models.py §4.1
src/orchestrata_ast/analyzer.py §4.2–§4.4
tests/test_orchestrata_ast_argv_literals.py NEW — §6
plans/ast-ultimate.json, plans/ast-tests.json regenerated by §7 commands only; never hand-edited
plans/briefs/qwen/Q2a-report.md NEW — your report (§9)

6. Tests — tests/test_orchestrata_ast_argv_literals.py

Shape: copy tests/test_orchestrata_ast_env_access.py — a header docstring (WHY these facts exist; the Test Standards Compliance block: "T2 no os.environ mutation — sources are PARSED, never executed; T3 function-scope fixtures — tmp_path only; no mocking / no stubs / no monkeypatch / no skips"). Each test writes source text to a file under tmp_path, runs the real CodebaseAnalyzer over it, and asserts on module.argv_literals. Assert the FULL record (file_path suffix, line, site, form, program), not just the count.

Required tests (one function each):

  1. subprocess.run(["python3", "x.py"]) gives (site="subprocess.run", form="argv", program="python3") on the list's line.
  2. Tuple literal, and keyword-only subprocess.Popen(args=("gcc", "-c", "a.c")).
  3. Wrapper callee: _run(["python", "scripts/x.py"]) gives site="_run"; self.runner.run(["uv", "sync"]) gives site="self.runner.run"; factory().run(["gcc"]) gives site="?.run".
  4. Named form: cmd = ["gcc", "-c", "x.c"] then subprocess.check_call(cmd) in the same function: one record at the ASSIGNMENT line, site="subprocess.check_call". Annotated cmd: list[str] = ["python", "-m", "x"] is captured the same way.
  5. Named form scope boundary: an assignment in function f and a call passing the same name in a nested function g inside f gives NO record; the same at module scope consumed at module scope gives one.
  6. BinOp: subprocess.run(["python"] + extra) captured; cmd = ["python3", "-c"] + [code] then subprocess.run(cmd) captured.
  7. NOT captured: [sys.executable, "x"], [*PY, "x"], [interpreter, "x"], subprocess.run(argv) with argv a parameter, LANGS = ("python", "bash") never passed to a call, print("python x").
  8. exec form: asyncio.create_subprocess_exec("python3", "-c", "...") and os.execvp("gcc", ["gcc", "a.c"]) (the second arg is a list: assert the exec record AND the separate argv record for that list, since both shapes match); os.spawnv(os.P_WAIT, "/usr/bin/gcc", ["gcc"]) gives program="/usr/bin/gcc".
  9. shell form: os.system("python x.py"), subprocess.run("gcc -o a a.c", shell=True), subprocess.getoutput("clang --version"), f-string os.system(f"python {s}") gives "python"; os.system(f"{py} x") gives NO record; os.system("") gives NO record.
  10. Absolute and non-toolchain heads are recorded verbatim ("/usr/bin/gcc", "uv", "git", "env"), and a non-spawning callee is recorded too: sorted(["python", "b"]) gives (site="sorted", form="argv", program="python") — classification is not this layer's job (§4.2 "Over-capture is intended").
  11. Determinism: two calls on one line in reverse alphabetical callee order come back sorted by (line, site, form, program); analysing the same file twice gives identical lists.
  12. Round trip: serialize(module) → module_info_from_dict(...) gives equal argv_literals (list of ArgvLiteralInfo, not dicts).
  13. Capture identity: CodebaseAnalyzer(...).analyze().captures == ["env_accesses", "argv_literals"].
  14. Warm-start fallback: write a 2-file tree; produce a cache JSON with the real serialize path used by test_incremental_serialize_matches_full_on_unchanged_tree (read it), then rewrite that JSON's top-level "captures" to ["env_accesses"] and delete "argv_literals" from one module dict. load_or_analyze(...) then reports stats["fallback"] is True and every module in the result carries a populated argv_literals where the source has a spawn. With an untouched cache, the same call reports fallback is False (warm) — proves the identity is stable, so routine refreshes stay incremental.
  15. derive_subtree carries captures from the source snapshot dict (build a minimal snapshot dict with captures set and one tests. module; assert derived["captures"]).

7. Build — exact commands, in order, from /home/mohamed/workspace/wt-ARGV-GATE

  1. Baseline — already taken by the orchestrator; do not redo it. The snapshot committed on origin/master was produced in a different worktree (its module paths are absolute paths under /home/mohamed/workspace/wt-GRAPH-DEFECTS/), so against this worktree the gate reported gate: INVALID … fact base does not describe the working tree: 6 file(s) (rc 2). To get a real baseline the orchestrator regenerated the snapshot HERE with the UNMODIFIED base code, under your exact environment:
    • regen 1: Incremental: fallback (full reparse) — reused 0, reparsed 3327, removed 0, 93 s
    • regen 2: Incremental: warm — reused 3327, reparsed 0, removed 0, 10 s
    • plans/ast-tests.json derived: 2078 modules
    • scripts/gate.py run: gate: PASS — 5 rule clause(s), no violations; 426,176 facts across 23 predicates; rc 0
    • sizes: plans/ast-ultimate.json 89,569,413 bytes; plans/ast-tests.json 60,514,609 bytes That is why git status shows both snapshot files as M before you start: they are the base-code regeneration, and you regenerate them again in steps 4–5. Before step 1, confirm the base state with git status --porcelain (expect exactly M plans/ast-tests.json, M plans/ast-ultimate.json, ?? plans/briefs/qwen/) and paste it into the report.
  2. Implement §4.1–§4.4.
  3. Write §6 tests. Iterate with ONLY your module: env -u PYTHONPATH .venv/bin/python -m pytest -q -p no:cacheprovider tests/test_orchestrata_ast_argv_literals.py (this module needs no infrastructure; it parses files in tmp_path).
  4. Lint/format your changed .py files: env -u PYTHONPATH .venv/bin/python -m ruff check --fix <files> then env -u PYTHONPATH .venv/bin/python -m black <files>.
  5. Regenerate the snapshot — FIRST run: env -u PYTHONPATH uv run --frozen ast-sync --snapshot-only. It MUST print Incremental: fallback (full reparse). The base snapshot already has this worktree's root and was warm before your change (step 0), so the ONLY thing that can force this fallback is your §4.4 captures check — this run is the end-to-end proof that the check fires. If it prints warm, §4.4 is not wired: STOP, fix, and repeat. Expect ~90 s. Record its wall time.
  6. Regenerate — SECOND run, no edits in between: env -u PYTHONPATH uv run --frozen ast-sync --snapshot-only. It MUST print Incremental: warm — reused 3328, reparsed 0, removed 0 (3327 base modules + your new test module; ~10 s). If it falls back, the captures identity is unstable between runs: STOP and report.
  7. Measure, and paste every number into the report:
    env -u PYTHONPATH .venv/bin/python - <<'EOF'
    import json, collections
    for p in ("plans/ast-ultimate.json", "plans/ast-tests.json"):
        d = json.load(open(p))
        mods = d["modules"]
        missing = [m["path"] for m in mods if "argv_literals" not in m]
        recs = [r for m in mods for r in m.get("argv_literals", [])]
        print(p, "captures=", d.get("captures"), "modules=", len(mods),
              "missing_key=", len(missing), "records=", len(recs))
        print("  by form:", collections.Counter(r["form"] for r in recs))
        print("  top programs:", collections.Counter(r["program"] for r in recs).most_common(30))
    EOF
    
    Required: captures == ['env_accesses', 'argv_literals'] in BOTH files and missing_key == 0 in BOTH.
  8. env -u PYTHONPATH .venv/bin/python scripts/gate.py run; echo "gate rc=$?" — MUST print gate: PASS, 5 rule clause(s), no violations, rc 0, exactly as the step-0 baseline. The fact count changes only by the facts of your new test module (this task adds snapshot fields, not facts or rules — Q2b adds the facts); paste the new count. Any other verdict means you broke an existing consumer: STOP and report.

8. Verify — the checks that prove the task is complete

Run in this order; each must pass before the next.

  1. Focused tests (no infrastructure, fast):
    env -u PYTHONPATH .venv/bin/python -m pytest -q -p no:cacheprovider \
      tests/test_orchestrata_ast_argv_literals.py tests/test_orchestrata_ast_env_access.py \
      tests/scripts/test_ast_warm_cache.py tests/test_orchestrata_ast_derive_subtree.py \
      tests/test_orchestrata_ast_analyzer.py tests/test_orchestrata_ast_scope.py
    
    All pass, 0 skipped. (Measured by the orchestrator on 2026-10-11 under your exact environment: tests/test_orchestrata_ast_env_access.py alone = 21 passed in 0.7 s, 7.4 s wall including startup.)
  2. Stage your files (exactly the §5 list, nothing else): git add <files>; then git status --porcelain must show only those paths as staged (A /M ). At dispatch the worktree was clean except plans/briefs/qwen/ (untracked; this spec and your report live there; do NOT stage it). Any other unstaged change you did not make is NOT yours: leave it unstaged and untouched, and name it in your report.
  3. The pre-commit review, exactly as the hook runs it: env -u PYTHONPATH .venv/bin/python scripts/gate.py review-staged; echo "review rc=$?" — must be 0. rc 1 = blocking findings: fix them and repeat. rc 2 = no verdict: report the reasons, do not work around it.
  4. The bounded run over the blast radius — ONE run, last, after everything above is green. First resolve the modules your change can affect (the resolver reads the snapshot's import/reference edges; it runs in ~1 s and runs no tests):
    env -u PYTHONPATH .venv/bin/python scripts/affected_tests.py --json --files \
      src/orchestrata_ast/models.py src/orchestrata_ast/analyzer.py \
      tests/test_orchestrata_ast_argv_literals.py plans/ast-ultimate.json plans/ast-tests.json \
      > /home/mohamed/workspace/wt-ARGV-GATE/.q2a-affected.json
    
    Required: "escalate": false and "untested": []. If escalate is true, STOP and paste the reasons (an escalation means a full-suite run, which is not yours to start). Expect ~33 modules; the orchestrator resolved the same file set minus your new test before dispatch and got these 32: tests/factory/test_mutation.py, tests/gates/{test_differential_vs_legacy,test_fact_extractor,test_freshness_inference,test_gate_explain,test_gate_rules}.py, tests/generated_recovery/test_verification_spec_completeness.py, tests/infrastructure, tests/scripts/{test_affected_manifest_rules,test_ast_branches,test_ast_live_sync,test_ast_live_sync_main,test_ast_warm_cache,test_run_ast_pipeline_cli,test_scan_implementation}.py, tests/test_orchestrata_ast_{analyzer,derive_subtree,env_access,inheritance,scope}.py, tests/test_orchestrata_tools_testgen_{adapter_coverage,generator_coverage,ranking_coverage,structural_coverage,units}.py, tests/tools/{test_ast_pipeline,test_ast_pipeline_coverage}.py, tests/verification/{test_implementation_scanner,test_non_python_scan_coverage,test_spec_completeness,test_spec_completeness_extended,test_spec_completeness_js_evidence}.py. Then run exactly those paths through the project runner (it runs the infrastructure preflight against the test fleet 10.53.2.x with the kernel required, the safety gates, then the modules on 8 threads):
    env -u PYTHONPATH .venv/bin/python scripts/run_tests.py \
      $(env -u PYTHONPATH .venv/bin/python -c 'import json; print(" ".join(json.load(open(".q2a-affected.json"))["tests"]))'); \
      echo "run rc=$?"
    
    Then delete .q2a-affected.json (rm .q2a-affected.json). Do not start a second run while one is in progress. It must end with 0 failed, 0 errors, rc 0. Paste its final summary lines. Do NOT use scripts/run_tests.py --affected origin/master and do NOT run scripts/run_tests.py with no paths (the second is the full ~30-minute suite). If it reports an infrastructure failure (graph/kernel/redis unreachable), do NOT retry in a loop: run env -u PYTHONPATH .venv/bin/python scripts/start_test_services.py --status once, paste the output, stop.

9. Done when — all true, then write plans/briefs/qwen/Q2a-report.md

Report contents (plain facts, no "should"): files changed with a one-line purpose each; the step-0 baseline and step-7 gate output; both regen outputs with wall times; the §7.6 numbers (record count, by form, top 30 programs, both JSON sizes before/after); the §8 outputs (test counts, review rc, affected run summary); any STOP condition you hit and why; anything in this spec you found wrong.