For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fold every function that produces, renders, merges or reads hil_report.json/hil_report.md into one module, test/hil/helper/hil_report.py, and take the two fixes that consolidation enables.
Architecture: A new leaf-ish module owns the report document. hil_test.py and hil_health.py both import it, which dissolves the circular-import constraint that forced write_timeout_report to compose its own markdown. The duplicated cell classifier (cell_kind in hil_test, cell_state in hil_summary) collapses into one. hil_summary.py is deleted and its CLI moves in.
Tech Stack: Python 3.13 stdlib only (json, argparse, pathlib); existing unit suites under test/hil/test/ run with plain unittest.
Spec: docs/superpowers/specs/2026-08-21-hil-report-module-design.md
hil_test.py's CLI, arguments, output and report format stay byte-identical. The two intended exceptions are named in the spec: the hil_summary.py → hil_report.py CLI path, and write_timeout_report rendering instead of concatenating.hil_report.py must work in two modes. It is imported as helper.hil_report by hil_test.py, and run as a script by the operator (python3 test/hil/helper/hil_report.py <config> -b BOARD). A script run puts test/hil/helper/ on sys.path, not test/hil/, so from helper import hil_health fails in that mode. Task 1 pins both modes with tests.mark_report_abandoned and write_timeout_report run while the interpreter is being torn down or on the way to os._exit; anything escaping hangs the process in multiprocessing's unbounded join(). Their existing broad handlers move with them unchanged.hil_ci.sh stages helpers by an explicit list (test/hil/hil_ci.sh:222-228). A helper module missing from it reaches the rig absent, and the run dies with ImportError after REMOTE_DIR has been wiped. RemoteStaging.test_import_closure_is_staged_to_the_rig in test_hil_bounded.py already enforces this from the AST import closure; Task 1 only has to add the file to the list.python3 -m unittest discover -s test/hil/test (~82 s) before each commit; pre-commit run --files <changed> before pushing.Files:
test/hil/helper/hil_report.pytest/hil/test/test_hil_report.pytest/hil/hil_test.py:110 (REPORT_CELL), :1715 (BOUNDARY_CELL), :1902-1903 (REPORT_MD/REPORT_JSON), :1921-1978 (render_matrix), :1981-2003 (render_report), :67 (imports)test/hil/hil_ci.sh:222-228 (scp list)test/hil/test/test_hil_bounded.py (move RenderReportIsPureFunctionOfTheDocument out)Interfaces:
Produces: helper.hil_report exposing REPORT_MD, REPORT_JSON, REPORT_CELL, BOUNDARY_CELL, LOCKED_CELL, cell_state(v) -> str, render_matrix(rows_all) -> str, render_report(doc) -> str.
hil_test.py re-exports nothing: call sites become hil_report.NAME.
[ ] Step 1: Write the failing tests
Create test/hil/test/test_hil_report.py:
#!/usr/bin/env python3 # SPDX-License-Identifier: MIT # Unit tests for the report document: the vocabulary, the one cell classifier, rendering, # the four writers, and the fold to per-board verdicts. Split out of test_hil_bounded.py # and test_hil_health.py when the report code moved into helper/hil_report.py. # Run directly: # python3 test/hil/test/test_hil_report.py import json import os import subprocess import sys import unittest from pathlib import Path from tempfile import TemporaryDirectory TEST_DIR = os.path.dirname(os.path.abspath(__file__)) HIL_DIR = os.path.dirname(TEST_DIR) sys.path.insert(0, HIL_DIR) from helper import hil_report class OneClassifierForBothArtifacts(unittest.TestCase): """The markdown tally and the agent's verdict used to classify cells with two separate copies of one rule -- hil_test's cell_kind against REPORT_CELL, and hil_summary's cell_state against its own re-typed '❌'/'⚪' literals. Change the icons and the table and the verdict silently disagree.""" def test_bare_states(self): self.assertEqual(hil_report.cell_state('fail'), 'fail') self.assertEqual(hil_report.cell_state('skip'), 'skip') self.assertEqual(hil_report.cell_state('pass'), 'pass') def test_icon_prefixed_metrics_carry_their_verdict(self): self.assertEqual(hil_report.cell_state(f'{hil_report.REPORT_CELL["fail"]} 29/30'), 'fail') self.assertEqual(hil_report.cell_state(f'{hil_report.REPORT_CELL["skip"]} board wedged'), 'skip') def test_an_unprefixed_metric_is_a_pass(self): """Load-bearing: a passing test may return a plain metric string. Classifying unknown shapes as fail would publish a green table as a red verdict.""" self.assertEqual(hil_report.cell_state('480.0 MBps'), 'pass') self.assertEqual(hil_report.cell_state('1103 KB/s'), 'pass') def test_a_non_string_cell_does_not_raise(self): """render_matrix's copy guarded with isinstance; hil_summary's did not, because its caller str()'d first. The merged one keeps the guard -- it is the safer superset.""" self.assertEqual(hil_report.cell_state(None), 'pass') def test_the_icons_come_from_REPORT_CELL(self): """No second copy of the emoji anywhere in the module.""" src = (Path(HIL_DIR) / 'helper' / 'hil_report.py').read_text(encoding='utf-8') for icon in ('❌', '⚪', '✅'): self.assertEqual(src.count(f"'{icon}'"), 1, f'{icon} is spelled as a literal more than once') class ModuleWorksImportedAndAsAScript(unittest.TestCase): """It is imported as helper.hil_report by hil_test, and run as a script by the operator (.claude/agents/hil-operator.md). A script run puts helper/ on sys.path, NOT test/hil, so a plain `from helper import hil_health` breaks the CLI and only the CLI.""" def test_importable_as_a_package_module(self): r = subprocess.run( [sys.executable, '-c', f'import sys; sys.path.insert(0, {HIL_DIR!r}); ' f'from helper import hil_report; print(hil_report.REPORT_JSON)'], capture_output=True, text=True, timeout=60) self.assertEqual(r.returncode, 0, r.stderr) self.assertIn('hil_report.json', r.stdout) def test_runnable_as_a_script(self): r = subprocess.run( [sys.executable, str(Path(HIL_DIR) / 'helper' / 'hil_report.py'), '--help'], capture_output=True, text=True, timeout=60) self.assertEqual(r.returncode, 0, r.stderr) class HilCiStagesEveryHelperTheRunImports(unittest.TestCase): """hil_ci.sh copies helper modules by an EXPLICIT list. One missing module reaches the rig absent and the run dies with ImportError -- after REMOTE_DIR has already been rm -rf'd, so the previous run's report and re-run spec are gone too.""" def test_the_scp_list_covers_what_hil_test_imports(self): sh = (Path(HIL_DIR) / 'hil_ci.sh').read_text(encoding='utf-8') staged = {line.split('helper/')[1].rstrip('" \\\n') for line in sh.splitlines() if '/test/hil/helper/' in line and '.py' in line} imported = set() for mod in (Path(HIL_DIR) / 'hil_test.py', Path(HIL_DIR) / 'helper' / 'hil_report.py'): src = mod.read_text(encoding='utf-8') for raw in src.splitlines(): line = raw.strip() # hil_report's own import is indented in a try if line.startswith('from helper import '): imported |= {f'{n.strip()}.py' for n in line.split('import', 1)[1].split(',')} elif line.startswith('from helper.'): imported.add(line.split('.')[1].split(' ')[0] + '.py') missing = imported - staged self.assertEqual(missing, set(), f'hil_ci.sh does not stage {missing}; a remote run will ImportError') if __name__ == '__main__': unittest.main()
Then move the class RenderReportIsPureFunctionOfTheDocument from test/hil/test/test_hil_bounded.py into this file verbatim, changing only hil_test.render_report → hil_report.render_report throughout.
Run: python3 test/hil/test/test_hil_report.py Expected: FAIL — ModuleNotFoundError: No module named 'helper.hil_report'
Create test/hil/helper/hil_report.py:
#!/usr/bin/env python3 # SPDX-License-Identifier: MIT """The HIL report document: one owner for hil_report.json and hil_report.md. The markdown IS a rendering of the sidecar -- every writer goes through render_report(), so a table can never contain something the JSON does not. This module owns the whole life of that document: the cell vocabulary, the one classifier both artifacts share, rendering, the four writers, and the fold to one machine-readable verdict per board. Dual-mode by design: imported as `helper.hil_report` by hil_test.py, and run as a script by the operator (see .claude/agents/hil-operator.md). A script run puts test/hil/helper on sys.path rather than test/hil, hence the guarded hil_health import below. """ import argparse import json import sys from pathlib import Path try: # imported as part of the helper package from helper.hil_health import _p except ImportError: # run as a script: helper/ is sys.path[0] from hil_health import _p REPORT_MD = 'hil_report.md' REPORT_JSON = 'hil_report.json' # The status vocabulary, shared by the code that WRITES a cell (hil_test's test runners) and # the code that reads one back (cell_state). One dict, so the human's table and the agent's # verdict cannot drift apart. REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '⚪'} BOUNDARY_CELL = 'same-PID boundary' LOCKED_CELL = 'board-locked' def cell_state(v) -> str: """'pass' | 'fail' | 'skip' for one report cell. THE classifier -- the markdown tally and the per-board verdict both call this, so they cannot disagree. 'fail' or a ❌ prefix is a failure, 'skip' or a ⚪ prefix is a skip, and EVERYTHING ELSE is a pass. That last arm is load-bearing: a passing test may return a plain metric string ('480.0 MBps') that lands in the cell unprefixed, while failures are guaranteed marked -- TestFail's docstring pins that its metric is icon-prefixed precisely so render and tally treat it as a failure. Classifying unknown shapes as fail here would publish a green table as a red verdict. isinstance-guarded: cells are usually str but a caller may hand over None or a number, and .startswith on those raises inside a report writer that must not raise.""" if v == 'fail' or (isinstance(v, str) and v.startswith(REPORT_CELL['fail'])): return 'fail' if v == 'skip' or (isinstance(v, str) and v.startswith(REPORT_CELL['skip'])): return 'skip' return 'pass'
Then move, verbatim, from hil_test.py:
render_matrix (hil_test.py:1921-1978) — with one change: delete its nested cell_kind definition and call the module-level cell_state instead. The line kinds = [cell_kind(v) for _, cells, _ in rows_all for v in cells.values()] becomes kinds = [cell_state(v) for _, cells, _ in rows_all for v in cells.values()].render_report (hil_test.py:1981-2003) — unchanged.Add a placeholder CLI so --help works (Task 4 fills in summarize):
def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument('config_file') ap.add_argument('-b', '--board', action='append', default=[], help='boards to report on; default: every board in the config') ap.add_argument('--report-dir', default='.', help=f'where {REPORT_JSON} lives (default: cwd)') ap.parse_args() raise SystemExit('hil_report: summarize() lands in Task 4') if __name__ == '__main__': sys.exit(main())
hil_test.py at the moduleIn hil_test.py:67, extend the import:
from helper import hil_health, hil_lock, hil_report, hil_util
Delete REPORT_CELL (:110), BOUNDARY_CELL (:1715), REPORT_MD/REPORT_JSON (:1902-1903), render_matrix and render_report from hil_test.py. Then rewrite every reference to the moved names as hil_report.<name>. Find them all with:
grep -n "REPORT_CELL\|BOUNDARY_CELL\|REPORT_MD\|REPORT_JSON\|render_matrix\|render_report" \ test/hil/hil_test.py
Known sites: :876, :1369, :1459, :1490, :1492, :1508, :1818, :1834, :2162, :2191-2192, :2209-2210, :2403, :2592.
In test/hil/hil_ci.sh:222-228, add the module to the scp list (keep alphabetical-ish order with the rest):
scp -q "$ROOT_DIR/test/hil/helper/__init__.py" \ "$ROOT_DIR/test/hil/helper/hil_util.py" \ "$ROOT_DIR/test/hil/helper/hil_health.py" \ "$ROOT_DIR/test/hil/helper/hil_lock.py" \ "$ROOT_DIR/test/hil/helper/hil_report.py" \ "$ROOT_DIR/test/hil/helper/hil_summary.py" \ "$ROOT_DIR/test/hil/helper/hil_select.py" \ "$REMOTE:$REMOTE_DIR/test/hil/helper/"
Run: python3 test/hil/test/test_hil_report.py → OK Run: python3 -m unittest discover -s test/hil/test → 274 OK (266 + 8 new: 5 classifier, 2 dual-mode, 1 scp guard; RenderReport… moves rather than adds)
git add test/hil/helper/hil_report.py test/hil/hil_test.py test/hil/hil_ci.sh \ test/hil/test/test_hil_report.py test/hil/test/test_hil_bounded.py git commit -m "hil_report: new module for the report vocabulary, classifier and rendering The markdown tally and the agent's verdict classified cells with two separate copies of one rule, the second documented as 'the EXACT classifier hil_test.py's own tally uses'. One cell_state now serves both, keyed off the one REPORT_CELL."
Files:
test/hil/helper/hil_report.py (add the writers)test/hil/hil_test.py:2005-2036 (write_report, mark_report_abandoned), :2149-2212 (accumulate_report)test/hil/test/test_hil_bounded.py (move three classes out), test/hil/test/test_hil_report.pyInterfaces:
Consumes: render_report, REPORT_MD, REPORT_JSON, BOUNDARY_CELL from Task 1.
Produces: hil_report.write_report(report_dir, doc), hil_report.mark_report_abandoned(report_dir, why), hil_report.accumulate_report(mret, report_dir, fresh, scope='', banner='') -> str.
[ ] Step 1: Move the tests
Move these classes from test/hil/test/test_hil_bounded.py into test/hil/test/test_hil_report.py, verbatim except hil_test.<name> → hil_report.<name> for the three moved functions:
ScopeSurvivesInTheJsonEveryExitPathLeavesBothArtifactsAbandonNoticeLandsInBothArtifactsCaveatSurvivesAccumulateMarkdownIsAlwaysARenderingOfTheJsonAbandonNoticeLandsInBothArtifacts.test_an_existing_abandon_caveat_is_not_overwritten calls hil_health.write_timeout_report; leave that call as-is — Task 3 moves it.
Run: python3 test/hil/test/test_hil_report.py Expected: FAIL — AttributeError: module 'helper.hil_report' has no attribute 'write_report'
Cut write_report (hil_test.py:2005-2014), mark_report_abandoned (:2016-2036) and accumulate_report (:2149-2212) from hil_test.py and paste them into hil_report.py below render_report, unchanged.
Add to accumulate_report's docstring, after the existing text, so the wart is recorded where a reader meets it:
`mret` is hil_test.py's worker-result shape (name, err, fts, rows, ...), so this one
function knows something about its caller that the rest of the module does not. Folding
mret into rows could live in hil_test and only the merge here, but that would rewrite
the subtle parts -- stale board-locked clearing, BOUNDARY_CELL dropping, duration=None
preservation -- for a tidier seam. Data-shape coupling, not an import cycle.
In hil_test.py, the three call sites become hil_report.*:
grep -n "accumulate_report(\|write_report(\|mark_report_abandoned(" test/hil/hil_test.py
Known sites: :2260 (inside _abandon_exit), :2351 (no-boards exit), :2486, :2525, :2618.
Run: python3 -m unittest discover -s test/hil/test → 274 OK (motion only, no count change)
git add test/hil/helper/hil_report.py test/hil/hil_test.py \ test/hil/test/test_hil_report.py test/hil/test/test_hil_bounded.py git commit -m "hil_report: move the report writers off hil_test write_report, mark_report_abandoned and accumulate_report join the renderer they already call. Pure motion; accumulate_report's knowledge of mret's tuple shape moves with it and is now documented rather than implicit."
write_timeout_report renders like everyone elseFiles:
test/hil/helper/hil_report.py (receive the function)test/hil/helper/hil_health.py:347-398 (remove it), :19 (drop import json)test/hil/hil_test.py:2498 (call site)test/hil/test/test_hil_health.py (move WriteTimeoutReport out), test/hil/test/test_hil_report.pyInterfaces:
Consumes: render_report, write_report from Tasks 1-2.
Produces: hil_report.write_timeout_report(report_dir, boards, secs, banner='', prefix=''). The md_name parameter is gone — the module owns REPORT_MD.
[ ] Step 1: Write the failing tests
Move WriteTimeoutReport from test/hil/test/test_hil_health.py into test/hil/test/test_hil_report.py, changing hil_health.write_timeout_report → hil_report.write_timeout_report and dropping the md_name argument from every call. Two of its tests change substantively:
def test_the_prior_attempts_rows_survive(self): """Was: the prior MARKDOWN TEXT survives below the banner. It now re-renders from the merged sidecar, so the guarantee is stated against rows -- one table with the stuck boards in it, rather than a banner stapled above a duplicate table.""" td = TemporaryDirectory() self.addCleanup(td.cleanup) rd = Path(td.name) hil_report.accumulate_report( [('done', 0, 0, [('done', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '') hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600) doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) self.assertEqual([r['board'] for r in doc['rows']], ['done', 'stuck']) md = (rd / hil_report.REPORT_MD).read_text() self.assertIn('done', md) self.assertIn('stuck', md) self.assertIn('abandoned', md) self.assertLess(md.index('abandoned'), md.index('done')) self.assertEqual(md.count('| Board'), 1, 'the prior table was duplicated, not merged') def test_prefix_carries_the_preflight_diagnosis(self): td = TemporaryDirectory() self.addCleanup(td.cleanup) rd = Path(td.name) hil_report.write_timeout_report(rd, [{'name': 'b1'}], 4200, prefix='> **wedged usb_hub_wq worker.**\n') out = (rd / hil_report.REPORT_MD).read_text() self.assertTrue(out.startswith('> **wedged usb_hub_wq worker.**')) self.assertIn('timed out after 4200s', out) self.assertIn('b1', out)
And in MarkdownIsAlwaysARenderingOfTheJson, delete test_the_pool_guard_fallback_agrees_even_if_it_does_not_render and add the fifth case in its place:
def test_the_pool_guard_fallback(self): """The last writer to join the invariant: it composed its own markdown only because hil_health could not import the renderer.""" td = TemporaryDirectory() self.addCleanup(td.cleanup) rd = Path(td.name) hil_report.accumulate_report( [('done', 0, 0, [('done', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '') hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600, prefix='> **wedged usb_hub_wq worker.**\n') self._check(rd)
Run: python3 test/hil/test/test_hil_report.py Expected: FAIL — AttributeError: module 'helper.hil_report' has no attribute 'write_timeout_report'
Add to hil_report.py, and delete hil_health.py:347-398 plus its now-unused import json at hil_health.py:19:
def write_timeout_report(report_dir: Path, boards, secs: int, banner: str = '', prefix: str = '') -> None: """Leave a report behind when the worker pool has to be abandoned. map_async is all-or-nothing, so a timeout loses every per-board result and the report dir would stay empty with no reason for the failure. Any prior attempt's rows are kept and the stuck boards are merged in beside them. `prefix` carries the preflight rig-health verdict: the timeout aborts before accumulate_report, so without it the report loses the one line saying WHY the pool never finished.""" try: # Built INSIDE the try: a roster entry without a 'name' key raises while assembling # the board list, and outside the try that escaped and stranded the runner -- which # is exactly what the broad handler below exists to prevent. caveat = (prefix + '\n' if prefix else '') + (banner or ( f'**HIL run abandoned: worker pool timed out after {secs}s.**\n\n' f'No per-board results could be collected for this attempt, so any rows below ' f'are from an earlier one. Boards dispatched:\n\n' + '\n'.join(f'- {b.get("name", "?")}' for b in boards) + '\n')) # Rows MERGE rather than replace: an earlier attempt's finished boards are real # results and this attempt has none of its own. Own handler, because a torn sidecar # must not cost the stuck rows -- losing the old table is a nicety, losing the # caveat is the failure. jpath = report_dir / REPORT_JSON try: doc = json.loads(jpath.read_text()) if jpath.is_file() else {} rows = list(doc.get('rows', [])) except (OSError, ValueError, TypeError, AttributeError): doc, rows = {}, [] done = {r.get('board') for r in rows if isinstance(r, dict)} rows += [{'board': b.get('name', '?'), 'cells': {'pool-timeout': 'fail'}, 'duration': None} for b in boards if b.get('name', '?') not in done] write_report(report_dir, {'rows': rows, 'banner': doc.get('banner', ''), 'scope': doc.get('scope', ''), 'caveat': caveat}) except Exception as e: # noqa: BLE001 # Deliberately broad: this is the first statement of the pool-abandon path, so ANY # escape skips kill_pool_children and os._exit and strands the runner. _p(f'warning: cannot write {REPORT_MD} to {report_dir}: {e}', flush=True)
Update hil_health.py's module docstring: its first line reads “Shutting a wedged HIL run down: kill what the workers spawned, then report.” — drop “, then report”.
hil_test.py:2498 becomes:
hil_report.write_timeout_report( report_dir, [b for b in config_boards if b['name'] in stuck], POOL_TIMEOUT, prefix=health_banner)
Run: python3 -m unittest discover -s test/hil/test → 274 OK (one deleted, one added)
git add test/hil/helper/hil_report.py test/hil/helper/hil_health.py test/hil/hil_test.py \ test/hil/test/test_hil_report.py test/hil/test/test_hil_health.py git commit -m "hil_report: the pool-guard fallback renders like every other writer It composed its own markdown for one reason: hil_health cannot import hil_test back, so it could not reach render_report. With the renderer in a module both import, that constraint is gone and all five writers are byte-identical -- MarkdownIsAlwaysARenderingOfTheJson covers the fifth, and the weaker 'agrees even if it does not render' promise is deleted. hil_health goes back to doing one thing: killing wedged processes."
hil_summary.py in and delete itFiles:
test/hil/helper/hil_report.py (real summarize + CLI)test/hil/helper/hil_summary.pytest/hil/hil_ci.sh (drop hil_summary.py from the scp list).claude/agents/hil-operator.md:71, .claude/workflows/hil-validate.js:14,17,54,58,67, .claude/workflows/test-hil-validate.mjs:7test/hil/test/test_hil_bounded.py (move SummaryFoldsReportToBoards out), test/hil/test/test_hil_report.pyInterfaces:
Consumes: cell_state, LOCKED_CELL, REPORT_JSON from Task 1.
Produces: hil_report.variants_of(cfg, board) -> list, hil_report.summarize(cfg, boards, report) -> dict returning {'results': [...], 'banner': str, 'caveat': str}; CLI python3 test/hil/helper/hil_report.py <config> [-b BOARD]... [--report-dir DIR].
[ ] Step 1: Move the tests
Move SummaryFoldsReportToBoards from test/hil/test/test_hil_bounded.py into test/hil/test/test_hil_report.py, changing the subprocess target from helper/hil_summary.py to helper/hil_report.py in both places (test_hil_bounded.py:1675 and :1757). Add one test pinning that the old entry point is gone:
def test_the_old_entry_point_is_gone(self): """hil_summary.py's CLI moved here. A leftover file would keep working while drifting from the module that now owns the fold.""" self.assertFalse((Path(HIL_DIR) / 'helper' / 'hil_summary.py').exists())
Run: python3 test/hil/test/test_hil_report.py Expected: FAIL — the subprocess exits non-zero with hil_report: summarize() lands in Task 4
summarize in and delete the old fileCopy variants_of (hil_summary.py:47-52) and summarize (:54-92) into hil_report.py verbatim, with two changes: cell_state(str(val)) becomes cell_state(val) (the merged classifier is isinstance-guarded, so the str() is dead), and the module‘s own FAIL_ICON/SKIP_ICON/LOCKED_CELL/cell_state definitions are NOT copied — Task 1’s already serve.
Replace the Task 1 placeholder main() with the real one from hil_summary.py:94-115, changing Path(a.report_dir) / 'hil_report.json' to Path(a.report_dir) / REPORT_JSON.
Then:
git rm test/hil/helper/hil_summary.py
test/hil/hil_ci.sh — remove the hil_summary.py line from the scp list added in Task 1.
.claude/agents/hil-operator.md:71:
python3 test/hil/helper/hil_report.py <config> -b BOARD [-b BOARD...] # from the report dir
.claude/workflows/hil-validate.js:58:
` python3 test/hil/helper/hil_report.py <the config you used> ${boards.map((b) => `-b ${b}`).join(' ')}\n` +
In .claude/workflows/hil-validate.js lines 14, 17, 54 and 67, and .claude/workflows/test-hil-validate.mjs line 7, replace the prose mentions of hil_summary.py with hil_report.py. Change nothing else in those files — the operator's return contract ({results, banner, wedged}) is untouched.
Run: python3 test/hil/test/test_hil_report.py → OK Run: python3 -m unittest discover -s test/hil/test → 275 OK Run: node .claude/workflows/test-hil-validate.mjs → OK Run: grep -rn "hil_summary" . --include=*.py --include=*.sh --include=*.js --include=*.mjs --include=*.md | grep -v docs/superpowers → no hits
git add test/hil/helper/hil_report.py test/hil/hil_ci.sh test/hil/test/ \ .claude/agents/hil-operator.md .claude/workflows/hil-validate.js \ .claude/workflows/test-hil-validate.mjs git rm --cached test/hil/helper/hil_summary.py 2>/dev/null || true git commit -m "hil_report: fold hil_summary in; one module owns the document end to end The fold to per-board verdicts is the read half of the artifact the rest of this module writes, and it carried the second copy of the cell classifier. The CLI keeps its arguments; only its path changes, which the two harness docs that invoke it by name follow."
python3 -m unittest discover -s test/hil/test # 275 OK pre-commit run --all-files
python3 - <<'EOF' import json, sys sys.path.insert(0, 'test/hil') from helper import hil_report doc = json.load(open('hil_report.json')) # the pair the rig produced pre-refactor assert open('hil_report.md').read() == hil_report.render_report(doc) + '\n', 'render drifted' print('render is byte-identical to the pre-refactor artifact') EOF
hil_report.py must reach the rig and the CLI must run there:bash test/hil/hil_ci.sh -b stm32f407disco -b nanoch32v203 ssh hathach@ci.lan 'cd /tmp/tinyusb-hil && python3 test/hil/helper/hil_report.py \ test/hil/tinyusb.json -b stm32f407disco -b nanoch32v203'
Expect a two-board table, md == render_report(json), and a summarize verdict naming both boards — nanoch32v203 proving the variant fold still works through the moved code.
Each its own follow-up, unchanged from the spec:
accumulate_report's mret folding from its merge.HIL_POOL_TIMEOUT that does not scale with board count.caveat through the operator/workflow return contract (hil-validate.js:34).