diff --git a/.github/capsule-pipeline/task-runner.dot b/.github/capsule-pipeline/task-runner.dot index 9e85bbd..b8c6d2d 100644 --- a/.github/capsule-pipeline/task-runner.dot +++ b/.github/capsule-pipeline/task-runner.dot @@ -396,12 +396,12 @@ digraph BacklogTaskRunner { package [shape=box, class="maker", prompt="Both gates passed. Prepare the work for handoff in $target_dir: ensure runner artifacts (.ai/) are excluded from version control via .git/info/exclude (do NOT commit them); create or reuse a branch named task/ (the id is in the task file's frontmatter at $task_file); commit all task changes with a clear conventional commit message stating WHAT changed and WHY (draw on .ai/brief.md and .ai/critique.md; include the standard Amplifier co-authored-by attribution); and write .ai/SHIPPED.md summarizing the goal, what shipped, where the evidence lives (.ai/verify.log, .ai/convergence.jsonl, run events), and anything a reviewer should know."] - // TRACKED-.ai LEAK CHANNEL (T1-7 council): the porcelain grep excludes - // only UNTRACKED .ai entries (^?? .ai); a worker that git-adds/commits - // .ai/ mid-run evades that filter and ships runner artifacts. First - // assertion: `git ls-files .ai/` must be EMPTY, else dirty -> escalate. + // TRACKED-.ai LEAK CHANNEL (T1-7 council): assert first that + // `git ls-files -- .ai/` is EMPTY, then use an explicit pathspec to ask + // porcelain about every path except `.ai/`; either nonempty result means + // dirty -> escalate. ship_check [shape=parallelogram, class="gate", max_retries=0, - tool_command="[ -z \"$(git ls-files .ai/)\" ] && [ -z \"$(git status --porcelain | grep -v -E '^\\?\\? \\.ai')\" ] && git log --oneline -1 | grep -q . && printf shipped || printf dirty"] + tool_command="tracked=$(git ls-files -- .ai/) || { printf dirty; exit 0; }; [ -z \"$tracked\" ] || { printf dirty; exit 0; }; status=$(git status --porcelain -- . ':(exclude).ai/') || { printf dirty; exit 0; }; [ -z \"$status\" ] && git rev-parse --verify -q HEAD^{commit} >/dev/null 2>&1 && printf shipped || printf dirty"] // ---------- budget exhaustion: a decision point, not a fuse ---------- // MUST_WRITE + S2 END-STATE (T1-7 council, 4-1; sam's S3 dissent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 149b536..d81c3ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,13 +161,14 @@ jobs: # # THE MOVE HAS NOW HAPPENED (the P4 slim, attractor-28x), and Track A # did its job: loop-pipeline is gone from this repo and this job still - # runs the same guard corpus, green, having installed nothing but - # pytest. That is the design working, not a coincidence. + # runs the guard corpus without installing engine modules. Its test + # dependencies are pytest, PyYAML, and Graphviz for DOT render proof. # # Deliberately independent of every module/, by construction: every # moved guard was refactored (where needed) to assert FILE CONTENT -- # doc text, YAML/dot files, script logic loaded by path -- rather than - # importing engine code, so this job installs nothing but pytest itself. + # importing engine code. Graphviz checks the documented DOT syntax + # independently; it does not introduce an engine-module dependency. # A handful of guards that genuinely could not be decoupled from the # live engine parser/linter (e.g. the examples/ lint-clean sweep, the # shipped-graph structural contract checks) deliberately stayed behind @@ -185,6 +186,12 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v4 + - name: Install graphviz + run: | + sudo apt-get update + sudo apt-get install -y graphviz + dot -V + - name: Install pytest run: uv pip install --system pytest pyyaml diff --git a/AGENTS.md b/AGENTS.md index 409d81d..c1d559d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ That document also carries the per-change-class evidence table, the four-layer d ## Key directories - `modules/tool-report-outcome/` — the one module this repo owns. -- `tests/` — the root guard harness (the `opinionated-guards` CI job). Asserts on repo-root docs/, examples/, skills/, agents/, context/, bundles/, behaviors/ content, and installs nothing but pytest by construction. +- `tests/` — the root guard harness (the `opinionated-guards` CI job). Asserts on repo-root docs/, examples/, skills/, agents/, context/, bundles/, behaviors/ content, and installs only pytest plus Graphviz for DOT-render proof. - `examples/pipelines/` — canonical pipeline patterns. Useful as live test fixtures when verifying engine changes. - `specs/` — our spec extensions and the canonical attractor reference. - `docs/CONTRACTS.md` — engine-level contracts: M5 substitution, fail-fast policy, structural concurrency, and cross-consumer guidance. diff --git a/examples/pipelines/11-manager-child-dotfile-hitl/README.md b/examples/pipelines/11-manager-child-dotfile-hitl/README.md index bac8c7f..3def4c6 100644 --- a/examples/pipelines/11-manager-child-dotfile-hitl/README.md +++ b/examples/pipelines/11-manager-child-dotfile-hitl/README.md @@ -68,6 +68,7 @@ steps: ## DOT parser note Attribute keys containing dots (`manager.max_cycles`, `stack.child_dotfile`) are -written **without** surrounding double-quotes -- the attractor DOT parser stores a -quoted key with its quote characters, which breaks the bare-string lookups the -handlers use. Correct: `manager.max_cycles=1`. Wrong: `"manager.max_cycles"="1"`. +quoted Graphviz keys: `"manager.max_cycles"=1` is correct. The runtime also accepts +bare `manager.max_cycles=1`, but Graphviz rejects it. The parser strips the key +delimiters before handler lookup, so quoting keeps `manager.max_cycles` as the +lookup key. diff --git a/tests/test_doc_consistency.py b/tests/test_doc_consistency.py index 7f65b52..408fc8b 100644 --- a/tests/test_doc_consistency.py +++ b/tests/test_doc_consistency.py @@ -35,6 +35,7 @@ """ import re +import subprocess from pathlib import Path # Root of the bundle repo relative to this test file @@ -195,6 +196,97 @@ def test_house_llm_classification_is_indirect(): ) +# --------------------------------------------------------------------------- +# D-336: manager-child DOT parser guidance must match its adjacent DOT example +# --------------------------------------------------------------------------- + +_MANAGER_CHILD_README_REL = "examples/pipelines/11-manager-child-dotfile-hitl/README.md" +_MANAGER_CHILD_PARENT_REL = ( + "examples/pipelines/11-manager-child-dotfile-hitl/parent.dot" +) +_MANAGER_CHILD_PARSER_NOTE_HEADING = "## DOT parser note" +_QUOTED_MANAGER_MAX_CYCLES = '"manager.max_cycles"=1' +_BARE_MANAGER_MAX_CYCLES = "manager.max_cycles=1" + + +def _manager_child_parser_note() -> str: + """Return the example's one parser-note section, refusing heading dodges.""" + readme = _read(_MANAGER_CHILD_README_REL) + headings = re.findall( + rf"^{re.escape(_MANAGER_CHILD_PARSER_NOTE_HEADING)}$", + readme, + flags=re.MULTILINE, + ) + assert len(headings) == 1, ( + f"{_MANAGER_CHILD_README_REL}: expected exactly one " + f"'{_MANAGER_CHILD_PARSER_NOTE_HEADING}' heading, found {len(headings)}. " + "Keep the parser teaching in its named final section (D-336)." + ) + return readme.split(_MANAGER_CHILD_PARSER_NOTE_HEADING, 1)[1].strip() + + +def test_manager_child_parser_note_teaches_the_quoted_parent_attribute(): + """The README must teach the Graphviz-valid form its adjacent parent uses (D-336).""" + note = _manager_child_parser_note() + parent = _read(_MANAGER_CHILD_PARENT_REL) + + parent_attr = re.search( + r'^\s*(?P"manager\.max_cycles")=1,$', parent, flags=re.MULTILINE + ) + assert parent_attr is not None, ( + f"{_MANAGER_CHILD_PARENT_REL}: quoted manager.max_cycles fixture attribute " + "not found; the README's parser guidance needs an adjacent executable witness." + ) + assert _QUOTED_MANAGER_MAX_CYCLES in note, ( + f"{_MANAGER_CHILD_README_REL}: parser note must teach the exact quoted " + f"attribute used by parent.dot: `{_QUOTED_MANAGER_MAX_CYCLES}`." + ) + assert _BARE_MANAGER_MAX_CYCLES in note, ( + f"{_MANAGER_CHILD_README_REL}: parser note must retain the bare-form " + f"counterexample `{_BARE_MANAGER_MAX_CYCLES}`." + ) + assert re.search(r"runtime also accepts\s+bare", note), ( + f"{_MANAGER_CHILD_README_REL}: parser note must distinguish runtime " + "acceptance from Graphviz syntax validity." + ) + assert "Graphviz rejects it" in note, ( + f"{_MANAGER_CHILD_README_REL}: parser note must say the bare dotted key " + "is invalid Graphviz, not merely omit it." + ) + assert re.search(r"strips the key\s+delimiters", note), ( + f"{_MANAGER_CHILD_README_REL}: parser note must explain that quoted key " + "delimiters do not enter handler lookup keys." + ) + + +def test_manager_child_parser_note_rendering_witnesses_match_its_teaching(): + """Real Graphviz must render the documented positive form and reject the negative.""" + + def render(source: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["dot", "-Tsvg"], + input=source, + text=True, + capture_output=True, + check=False, + ) + + quoted = render(f"digraph {{ manager [{_QUOTED_MANAGER_MAX_CYCLES}] }}") + assert quoted.returncode == 0, ( + "Graphviz rejected the quoted dotted-key form the parser note teaches:\n" + f"{quoted.stderr}" + ) + assert "