Skip to content

Commit 0939de3

Browse files
authored
Merge pull request #552 from escoffier-labs/fix/verify-miseledger-auto-import
fix(evidence): index captured verify receipts
2 parents 0b18cd8 + 311597a commit 0939de3

8 files changed

Lines changed: 300 additions & 39 deletions

File tree

docs/technical-guide.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,7 +1041,9 @@ For scheduled local indexing, use the built-in pipeline:
10411041
brigade receipts export miseledger --target . --new-only --import
10421042
```
10431043

1044-
Put that export on your own timer when you want the loop to run without thinking about it. Brigade does not install the timer. The export sends new verify-run and Brigade-run receipts to MiseLedger, and the next `brigade run` can fetch them back as a compact evidence brief through the same read-only `miseledger evidence ... --source brigade --json` path. The direct command is:
1044+
`brigade work verify run --capture <skill>` also runs that new-only export/import automatically after outcome capture, so routine verification closes the receipts-to-MiseLedger loop without a separate command. Indexing is fail-open: a missing `miseledger` binary or import failure prints an explicit status, leaves pending receipts retryable, and does not change the verification exit code. Use the manual export command above for backlog catch-up or fleet targets (`.brigade/repos.toml` with `--fleet --json`).
1045+
1046+
The export sends new verify-run and Brigade-run receipts to MiseLedger, and the next `brigade run` can fetch them back as a compact evidence brief through the same read-only `miseledger evidence ... --source brigade --json` path. The direct command is:
10451047

10461048
```bash
10471049
brigade work import context --from-miseledger "auth receipts" --target .
@@ -1051,7 +1053,7 @@ The brief contains only bounded evidence lines: run id, status, code-graph delta
10511053

10521054
MiseLedger evidence is fail-open. Missing `miseledger`, a nonzero exit, timeout, malformed JSON, or zero usable results does not block `brigade run` or `brigade work import context --from-miseledger`. Run prompts proceed without the brief, and the import command reports the absence instead of writing a broken context note.
10531055

1054-
`--new-only` stores exported `raw.hash` values in `.brigade/work/miseledger-export-cursor.json`, so later runs only write receipt items that have not already been exported. The cursor is an optimization, not the identity boundary. MiseLedger uses content-hash identity for adapter items, so double-importing the same receipt file remains harmless if the cursor is deleted, copied late, or skipped.
1056+
`--new-only` stores exported `raw.hash` values in `.brigade/work/miseledger-export-cursor.json`, so later runs only write receipt items that have not already been exported. With `--import`, the cursor advances only after a successful import; failed imports leave receipts pending for the next run. Export-only runs advance the cursor after a successful write.
10551057

10561058
The export is deterministic and idempotent for unchanged receipts: records are sorted newest first, external ids derive from receipt ids, hashes derive from stored receipt digests or deterministic fallbacks, and JSONL rendering uses stable key order. When a receipt carries `digests.signature` and `digests.key_id`, the export includes them at `item.metadata.digest_signature`; unsigned receipts omit that metadata field. Re-importing the same file should update or skip the same MiseLedger adapter items rather than create duplicates.
10571059

scripts/windows-native-acceptance.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,10 @@ def call_greet():
691691
& brigade work verify run --target $workRepo --command "python $verifyScriptName" --capture brigade-work
692692
if ($LASTEXITCODE -ne 0) { throw "work verify run failed" }
693693

694+
Write-Step "brigade work verify run (manual export)"
695+
& brigade work verify run --target $workRepo --command "python $verifyScriptName" --no-reuse
696+
if ($LASTEXITCODE -ne 0) { throw "manual export verify run failed" }
697+
694698
$exportPath = Join-Path $acceptRoot "receipts.jsonl"
695699
Write-Step "receipts export miseledger"
696700
& brigade receipts export miseledger --target $workRepo --out $exportPath --new-only

src/brigade/receipts_cmd.py

Lines changed: 130 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
MISELEDGER_CURSOR_REL = Path(".brigade") / "work" / "miseledger-export-cursor.json"
3232
MISELEDGER_EXPORT_RESULT_SCHEMA = "brigade.miseledger_export_result.v1"
3333
MISELEDGER_FLEET_EXPORT_RESULT_SCHEMA = "brigade.miseledger_fleet_export_result.v1"
34+
MISELEDGER_INDEX_RESULT_SCHEMA = "brigade.miseledger_index_result.v1"
3435
_FLEET_STATUS_PRECEDENCE = ("failed", "exported", "nothing-new", "empty")
3536
_CODE_REFERENCE_LIMIT = 100
3637
_COMPACT_CODE_REFERENCE_NODE_LIMIT = 20
@@ -1405,6 +1406,17 @@ def _print_export_result(payload: dict[str, Any]) -> None:
14051406
print(json.dumps(payload, sort_keys=True, separators=(",", ":")))
14061407

14071408

1409+
def _advance_miseledger_cursor(target: Path, cursor_hashes: set[str], written_hashes: list[str]) -> int:
1410+
if not written_hashes:
1411+
return 0
1412+
try:
1413+
_write_miseledger_cursor_hashes(target, cursor_hashes | set(written_hashes))
1414+
except OSError as exc:
1415+
print(f"error: could not write cursor {_miseledger_cursor_path(target)}: {exc}", file=sys.stderr)
1416+
return 1
1417+
return 0
1418+
1419+
14081420
def _finalize_repo_cursors(
14091421
repo_results: list[dict[str, Any]],
14101422
*,
@@ -1423,14 +1435,71 @@ def _finalize_repo_cursors(
14231435
cursor_hashes = result.get("cursor_hashes")
14241436
if not isinstance(cursor_hashes, set):
14251437
cursor_hashes = set()
1426-
try:
1427-
_write_miseledger_cursor_hashes(target, cursor_hashes | set(written_hashes))
1428-
except OSError as exc:
1429-
print(f"error: could not write cursor {_miseledger_cursor_path(target)}: {exc}", file=sys.stderr)
1430-
return 1
1438+
exit_code = _advance_miseledger_cursor(target, cursor_hashes, written_hashes)
1439+
if exit_code != 0:
1440+
return exit_code
14311441
return 0
14321442

14331443

1444+
def _index_result_payload(result: dict[str, Any], *, status: str) -> dict[str, Any]:
1445+
return {
1446+
"schema": MISELEDGER_INDEX_RESULT_SCHEMA,
1447+
"status": status,
1448+
"candidate_count": int(result["candidate_count"]),
1449+
"exported_count": int(result["exported_count"]),
1450+
"skipped_count": int(result["skipped_count"]),
1451+
"error_count": int(result["error_count"]),
1452+
}
1453+
1454+
1455+
def index_miseledger_receipts(*, target: Path, quiet: bool = False) -> dict[str, Any]:
1456+
"""Export and import pending local receipts via the new-only adapter path.
1457+
1458+
Fail-open: always returns a status payload and never raises.
1459+
"""
1460+
result = _repository_export_result(target=target, limit=0, new_only=True)
1461+
lines = result["lines"]
1462+
candidate_count = int(result["candidate_count"])
1463+
exported_count = int(result["exported_count"])
1464+
skipped_count = int(result["skipped_count"])
1465+
1466+
if candidate_count == 0:
1467+
payload = _index_result_payload(result, status="empty")
1468+
payload["message"] = "miseledger indexing: no receipts"
1469+
return payload
1470+
1471+
if not lines:
1472+
status = "nothing-new" if skipped_count > 0 else "empty"
1473+
payload = _index_result_payload(result, status=status)
1474+
payload["message"] = f"miseledger indexing: {status}"
1475+
return payload
1476+
1477+
output_path = _temporary_miseledger_export_path(target)
1478+
exit_code, written_hashes = _write_miseledger_lines_to_path(output_path, lines)
1479+
if exit_code != 0:
1480+
payload = _index_result_payload(result, status="failed")
1481+
payload["message"] = "miseledger indexing: export write failed"
1482+
return payload
1483+
1484+
attempted, import_failed = _import_miseledger_file(output_path, strict=False, quiet=quiet, failed_on_error=True)
1485+
if attempted and import_failed:
1486+
payload = _index_result_payload(result, status="failed")
1487+
payload["message"] = "miseledger indexing: import failed (receipts pending)"
1488+
return payload
1489+
1490+
cursor_hashes = result.get("cursor_hashes")
1491+
if not isinstance(cursor_hashes, set):
1492+
cursor_hashes = set()
1493+
if _advance_miseledger_cursor(target, cursor_hashes, written_hashes) != 0:
1494+
payload = _index_result_payload(result, status="failed")
1495+
payload["message"] = "miseledger indexing: cursor write failed"
1496+
return payload
1497+
1498+
payload = _index_result_payload(result, status="indexed")
1499+
payload["message"] = f"miseledger indexing: indexed {exported_count} receipt(s)"
1500+
return payload
1501+
1502+
14341503
def _write_aggregate_lines(output_path: Path, lines: list[tuple[str, str]]) -> tuple[int, list[str]]:
14351504
return _write_miseledger_lines_to_path(output_path, lines)
14361505

@@ -1501,19 +1570,30 @@ def _export_miseledger_fleet(
15011570
if result_written:
15021571
written_hashes_by_target[result["target"]] = result_written
15031572

1504-
cursor_exit = _finalize_repo_cursors(
1505-
repo_results, new_only=new_only, written_hashes_by_target=written_hashes_by_target
1506-
)
1573+
cursor_exit = 0
1574+
if not import_miseledger:
1575+
cursor_exit = _finalize_repo_cursors(
1576+
repo_results, new_only=new_only, written_hashes_by_target=written_hashes_by_target
1577+
)
15071578
if cursor_exit != 0:
15081579
payload = _fleet_export_payload(repo_results, status="failed")
15091580
_print_export_result(payload)
15101581
return 1
15111582

15121583
import_error_count = 0
1584+
import_failed = False
15131585
if import_miseledger and aggregate_lines:
1514-
attempted, failed = _import_miseledger_file(output_path, strict=True, quiet=True)
1515-
if attempted and failed:
1586+
attempted, import_failed = _import_miseledger_file(output_path, strict=True, quiet=True)
1587+
if attempted and import_failed:
15161588
import_error_count = 1
1589+
elif not import_failed:
1590+
cursor_exit = _finalize_repo_cursors(
1591+
repo_results, new_only=new_only, written_hashes_by_target=written_hashes_by_target
1592+
)
1593+
if cursor_exit != 0:
1594+
payload = _fleet_export_payload(repo_results, status="failed")
1595+
_print_export_result(payload)
1596+
return 1
15171597

15181598
status = _fleet_export_status([str(result["status"]) for result in repo_results])
15191599
if import_error_count:
@@ -1618,31 +1698,49 @@ def export_miseledger(
16181698
if json_output:
16191699
json_output_path = Path(out).expanduser()
16201700
exit_code, written_hashes = _write_miseledger_lines_to_path(json_output_path, lines)
1621-
if new_only and written_hashes:
1622-
cursor_hashes = result["cursor_hashes"]
1623-
if not isinstance(cursor_hashes, set):
1624-
cursor_hashes = set()
1625-
try:
1626-
_write_miseledger_cursor_hashes(target, cursor_hashes | set(written_hashes))
1627-
except OSError as exc:
1628-
print(f"error: could not write cursor {_miseledger_cursor_path(target)}: {exc}", file=sys.stderr)
1629-
failed_result = dict(result)
1630-
failed_result["status"] = "failed"
1631-
failed_result["error_count"] = int(failed_result["error_count"]) + 1
1632-
_print_export_result(_single_repo_export_payload(failed_result))
1633-
return 1
16341701
if exit_code != 0:
16351702
failed_result = dict(result)
16361703
failed_result["status"] = "failed"
16371704
failed_result["error_count"] = int(failed_result["error_count"]) + 1
16381705
_print_export_result(_single_repo_export_payload(failed_result))
16391706
return 1
16401707

1708+
import_error_count = 0
16411709
if import_miseledger:
16421710
if lines:
16431711
attempted, import_failed = _import_miseledger_file(json_output_path, strict=True, quiet=True)
16441712
if attempted and import_failed:
16451713
import_error_count = 1
1714+
elif not import_failed and new_only and written_hashes:
1715+
cursor_hashes = result["cursor_hashes"]
1716+
if not isinstance(cursor_hashes, set):
1717+
cursor_hashes = set()
1718+
if _advance_miseledger_cursor(target, cursor_hashes, written_hashes) != 0:
1719+
failed_result = dict(result)
1720+
failed_result["status"] = "failed"
1721+
failed_result["error_count"] = int(failed_result["error_count"]) + 1
1722+
_print_export_result(_single_repo_export_payload(failed_result))
1723+
return 1
1724+
elif new_only and written_hashes:
1725+
cursor_hashes = result["cursor_hashes"]
1726+
if not isinstance(cursor_hashes, set):
1727+
cursor_hashes = set()
1728+
if _advance_miseledger_cursor(target, cursor_hashes, written_hashes) != 0:
1729+
failed_result = dict(result)
1730+
failed_result["status"] = "failed"
1731+
failed_result["error_count"] = int(failed_result["error_count"]) + 1
1732+
_print_export_result(_single_repo_export_payload(failed_result))
1733+
return 1
1734+
elif new_only and written_hashes:
1735+
cursor_hashes = result["cursor_hashes"]
1736+
if not isinstance(cursor_hashes, set):
1737+
cursor_hashes = set()
1738+
if _advance_miseledger_cursor(target, cursor_hashes, written_hashes) != 0:
1739+
failed_result = dict(result)
1740+
failed_result["status"] = "failed"
1741+
failed_result["error_count"] = int(failed_result["error_count"]) + 1
1742+
_print_export_result(_single_repo_export_payload(failed_result))
1743+
return 1
16461744

16471745
payload = _single_repo_export_payload(result)
16481746
if import_error_count:
@@ -1670,17 +1768,10 @@ def export_miseledger(
16701768
else:
16711769
output_path = _temporary_miseledger_export_path(target) if str(out) == "-" else Path(out).expanduser()
16721770
exit_code, written_hashes = _write_miseledger_lines_to_path(output_path, lines)
1673-
if new_only and written_hashes:
1674-
cursor_hashes = result["cursor_hashes"]
1675-
if not isinstance(cursor_hashes, set):
1676-
cursor_hashes = set()
1677-
try:
1678-
_write_miseledger_cursor_hashes(target, cursor_hashes | set(written_hashes))
1679-
except OSError as exc:
1680-
print(f"error: could not write cursor {_miseledger_cursor_path(target)}: {exc}", file=sys.stderr)
1681-
return 1
16821771
if exit_code != 0:
16831772
return exit_code
1773+
1774+
import_failed = False
16841775
if import_miseledger and output_path is not None:
16851776
if lines:
16861777
attempted, import_failed = _import_miseledger_file(output_path, failed_on_error=True)
@@ -1689,6 +1780,13 @@ def export_miseledger(
16891780
else:
16901781
print("nothing new; import skipped")
16911782
return 0
1783+
1784+
if new_only and written_hashes and (not import_miseledger or not import_failed):
1785+
cursor_hashes = result["cursor_hashes"]
1786+
if not isinstance(cursor_hashes, set):
1787+
cursor_hashes = set()
1788+
if _advance_miseledger_cursor(target, cursor_hashes, written_hashes) != 0:
1789+
return 1
16921790
if int(result["error_count"]) > 0:
16931791
return 1
16941792
return 0

src/brigade/templates/skills/brigade-work/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ This is the +1 (passed) or -1 (failed) the learning ratchet scores. Capture on f
4949
Do not invent a skill name you are not running just to have something to capture against.
5050

5151
### 4. Export receipts so the next run can reuse them (optional stations)
52-
When GraphTrail and MiseLedger are installed, close the receipts-to-context loop so the next run sees measured evidence, not only a handoff:
52+
When GraphTrail and MiseLedger are installed, close the receipts-to-context loop so the next run sees measured evidence, not only a handoff. `brigade work verify run --capture <skill>` auto-exports and imports pending receipts after capture (fail-open when `miseledger` is absent). For backlog catch-up or fleet repos, run manually:
5353
```bash
5454
brigade receipts export miseledger --target . --new-only --import # fail-open if miseledger is absent
5555
# next brigade run attaches a capped evidence brief from MiseLedger automatically

src/brigade/work_cmd/verification.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,27 @@ def verify_plan(
916916
return 0 if not blockers else 1
917917

918918

919+
def _attach_miseledger_indexing(
920+
receipt: dict[str, object],
921+
*,
922+
target: Path,
923+
json_output: bool,
924+
) -> None:
925+
import contextlib
926+
import io
927+
928+
from .. import receipts_cmd
929+
930+
if json_output:
931+
sink = io.StringIO()
932+
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
933+
indexing = receipts_cmd.index_miseledger_receipts(target=target, quiet=True)
934+
else:
935+
indexing = receipts_cmd.index_miseledger_receipts(target=target, quiet=False)
936+
print(indexing["message"])
937+
receipt["miseledger_indexing"] = {key: value for key, value in indexing.items() if key != "message"}
938+
939+
919940
def verify_run(
920941
*,
921942
target: Path,
@@ -982,6 +1003,7 @@ def verify_run(
9821003
run_id=receipt["run_id"],
9831004
json_output=False,
9841005
)
1006+
_attach_miseledger_indexing(receipt, target=target, json_output=True)
9851007
print(json.dumps(receipt, indent=2, sort_keys=True))
9861008
return rc
9871009
print(f"work verify run: {target}")
@@ -1002,6 +1024,7 @@ def verify_run(
10021024
run_id=receipt["run_id"],
10031025
json_output=False,
10041026
)
1027+
_attach_miseledger_indexing(receipt, target=target, json_output=False)
10051028
return rc
10061029

10071030

tests/test_ci_workflow.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,9 @@ def test_ci_windows_native_acceptance_script_covers_required_flow():
295295
assert 'if ($callersOutput -notmatch "call_greet")' in text
296296
assert "brigadewinacceptance" in text
297297
assert "brigade work verify run" in text
298+
assert "brigade work verify run (manual export)" in text
299+
assert "--capture brigade-work" in text
300+
assert "--no-reuse" in text
298301
assert "brigade receipts export miseledger" in text
299302
assert "import adapter" in text
300303
assert "$importPayload.inserted_items" in text

0 commit comments

Comments
 (0)