Skip to content

Commit 4f89436

Browse files
authored
Merge pull request #647 from escoffier-labs/fix/628-artifact-id-signals
fix(outcome): attribute capture to the exercised skill, not brigade-work
2 parents 3f8938c + a561695 commit 4f89436

5 files changed

Lines changed: 383 additions & 11 deletions

File tree

src/brigade/claude_hooks/runtime.py

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,11 +1614,97 @@ def _session_fingerprint(session_id: str) -> str:
16141614
return localio.stable_hash({"claude_session_id": session_id})
16151615

16161616

1617-
def _verify_replacement(target: Path, command: str, session_fingerprint: str) -> str:
1617+
def _skill_id_from_skill_path(target: Path, raw_path: object) -> str | None:
1618+
"""Return an installed target skill id when ``raw_path`` names its ``SKILL.md``."""
1619+
if not isinstance(raw_path, str) or not raw_path:
1620+
return None
1621+
path = Path(raw_path).expanduser()
1622+
if not path.is_absolute():
1623+
path = target / path
1624+
parts = path.parts
1625+
for index, part in enumerate(parts):
1626+
if part == "skills" and index + 2 < len(parts) and parts[index + 2] == "SKILL.md":
1627+
skill_id = parts[index + 1]
1628+
if skill_id and skill_id not in {".", ".."}:
1629+
from .. import outcome_cmd
1630+
1631+
if outcome_cmd._artifact_known(target, skill_id, "skill"):
1632+
return skill_id
1633+
return None
1634+
1635+
1636+
def _parse_capture_flag(command: object) -> str | None:
1637+
"""Extract ``--capture <id>`` from a verify command string when present."""
1638+
if not isinstance(command, str) or not command.strip():
1639+
return None
1640+
try:
1641+
tokens = shlex.split(command, posix=os.name != "nt")
1642+
except ValueError:
1643+
return None
1644+
for index, token in enumerate(tokens):
1645+
if token == "--capture" and index + 1 < len(tokens):
1646+
value = tokens[index + 1]
1647+
if value and not value.startswith("-"):
1648+
return value
1649+
if token.startswith("--capture="):
1650+
value = token.split("=", 1)[1]
1651+
if value:
1652+
return value
1653+
return None
1654+
1655+
1656+
def _record_exercised_artifact(state: dict[str, Any], artifact_id: str | None, *, kind: str = "skill") -> bool:
1657+
"""Persist the most specific non-generic exercised artifact on session state."""
1658+
from .. import outcome_cmd
1659+
1660+
if not isinstance(artifact_id, str):
1661+
return False
1662+
trimmed = artifact_id.strip()
1663+
if not trimmed:
1664+
return False
1665+
current = state.get("exercised_artifact_id")
1666+
if (
1667+
trimmed == outcome_cmd.DEFAULT_CAPTURE_ARTIFACT_ID
1668+
and isinstance(current, str)
1669+
and current.strip()
1670+
and current.strip() != outcome_cmd.DEFAULT_CAPTURE_ARTIFACT_ID
1671+
):
1672+
return False
1673+
if current == trimmed and state.get("exercised_artifact_kind") == kind:
1674+
return False
1675+
state["exercised_artifact_id"] = trimmed
1676+
state["exercised_artifact_kind"] = kind
1677+
return True
1678+
1679+
1680+
def exercised_artifact_for_fingerprint(target: Path, session_fingerprint: str) -> str | None:
1681+
"""Look up an exercised artifact id for a Claude session fingerprint."""
1682+
for state in iter_session_states(target, limit=MAX_RECENT_SESSION_STATES):
1683+
if state.get("session_fingerprint") != session_fingerprint:
1684+
continue
1685+
artifact_id = state.get("exercised_artifact_id")
1686+
if isinstance(artifact_id, str) and artifact_id.strip():
1687+
return artifact_id.strip()
1688+
return None
1689+
1690+
1691+
def _verify_replacement(
1692+
target: Path,
1693+
command: str,
1694+
session_fingerprint: str,
1695+
*,
1696+
capture_artifact_id: str | None = None,
1697+
) -> str:
1698+
from .. import outcome_cmd
1699+
1700+
artifact_id = outcome_cmd.resolve_capture_artifact_id(
1701+
capture_artifact_id,
1702+
exercised_artifact_for_fingerprint(target, session_fingerprint),
1703+
)
16181704
return (
16191705
f"{CLAUDE_SESSION_ENV}={shlex.quote(session_fingerprint)} "
16201706
f"brigade work verify run --target {shlex.quote(str(target))} "
1621-
f"--command {shlex.quote(command)} --capture brigade-work"
1707+
f"--command {shlex.quote(command)} --capture {shlex.quote(artifact_id)}"
16221708
)
16231709

16241710

@@ -1684,6 +1770,11 @@ def _normalize_state(target: Path, session_id: str, payload: dict[str, Any] | No
16841770
session_repos = payload.get("session_repos")
16851771
if isinstance(session_repos, list) and all(isinstance(item, str) for item in session_repos):
16861772
normalized["session_repos"] = list(session_repos)
1773+
exercised = payload.get("exercised_artifact_id")
1774+
if isinstance(exercised, str) and exercised.strip():
1775+
normalized["exercised_artifact_id"] = exercised.strip()
1776+
kind = payload.get("exercised_artifact_kind")
1777+
normalized["exercised_artifact_kind"] = kind if isinstance(kind, str) and kind.strip() else "skill"
16871778
return normalized
16881779

16891780

@@ -1769,17 +1860,24 @@ def handle_payload(event: str, payload: dict[str, Any]) -> dict[str, Any] | None
17691860
return None
17701861
state["verify_denied_count"] = int(state.get("verify_denied_count") or 0) + 1
17711862
write_session_state(target, session_id, state)
1863+
capture_artifact_id = state.get("exercised_artifact_id")
1864+
if not isinstance(capture_artifact_id, str):
1865+
capture_artifact_id = None
17721866
if _has_unsupported_verifier_structure(str(command)):
1867+
from .. import outcome_cmd
1868+
1869+
capture_id = outcome_cmd.resolve_capture_artifact_id(capture_artifact_id)
17731870
reason = (
17741871
"Route verification through Brigade so failed, rejected, and passing results create receipts.\n"
17751872
"Split shell grouping, command substitution, pipelines, redirection, or complex directory changes "
1776-
"from the verifier, then run that verifier with `brigade work verify run --capture brigade-work`."
1873+
f"from the verifier, then run that verifier with `brigade work verify run --capture {capture_id}`."
17771874
)
17781875
else:
17791876
replacement = _verify_replacement(
17801877
target,
17811878
_first_verifier_command(str(command)),
17821879
str(state["session_fingerprint"]),
1880+
capture_artifact_id=capture_artifact_id,
17831881
)
17841882
reason = (
17851883
"Route verification through Brigade so failed, rejected, and passing results create receipts.\n"
@@ -1798,7 +1896,14 @@ def handle_payload(event: str, payload: dict[str, Any]) -> dict[str, Any] | None
17981896
raw_post_tool_input = payload.get("tool_input")
17991897
post_tool_input: dict[str, Any] = raw_post_tool_input if isinstance(raw_post_tool_input, dict) else {}
18001898
command = post_tool_input.get("command")
1899+
if tool_name == "Read":
1900+
skill_id = _skill_id_from_skill_path(target, post_tool_input.get("file_path"))
1901+
if _record_exercised_artifact(state, skill_id):
1902+
write_session_state(target, session_id, state)
1903+
return None
18011904
if tool_name == "Bash" and (_is_routed_verify(command) or _is_brigade_run(command)):
1905+
if _is_routed_verify(command):
1906+
_record_exercised_artifact(state, _parse_capture_flag(command))
18021907
state.pop("pending_bash_fingerprint", None)
18031908
state.pop("pending_bash_started_at", None)
18041909
write_session_state(target, session_id, state)
@@ -1841,9 +1946,16 @@ def handle_payload(event: str, payload: dict[str, Any]) -> dict[str, Any] | None
18411946
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
18421947
command = tool_input.get("command")
18431948
if payload.get("tool_name") == "Bash" and (is_raw_verification(command) or _is_routed_verify(command)):
1949+
from .. import outcome_cmd
1950+
1951+
capture_id = outcome_cmd.resolve_capture_artifact_id(
1952+
_parse_capture_flag(command),
1953+
state.get("exercised_artifact_id") if isinstance(state.get("exercised_artifact_id"), str) else None,
1954+
)
18441955
return _additional_context(
18451956
"PostToolUseFailure",
1846-
"The failed or rejected verification must remain recorded in Brigade before retrying. Inspect the receipt, fix the cause, then rerun through `brigade work verify run --capture brigade-work`.",
1957+
"The failed or rejected verification must remain recorded in Brigade before retrying. Inspect the receipt, fix the cause, then rerun through "
1958+
f"`brigade work verify run --capture {capture_id}`.",
18471959
)
18481960
return None
18491961

@@ -1868,7 +1980,13 @@ def handle_payload(event: str, payload: dict[str, Any]) -> dict[str, Any] | None
18681980
or stop_state.get("started_at")
18691981
)
18701982
if not _receipt_since(stop_target, receipt_threshold, session_fingerprint=fingerprint):
1871-
replacement = _verify_replacement(stop_target, "<test>", fingerprint)
1983+
exercised = stop_state.get("exercised_artifact_id")
1984+
replacement = _verify_replacement(
1985+
stop_target,
1986+
"<test>",
1987+
fingerprint,
1988+
capture_artifact_id=exercised if isinstance(exercised, str) else None,
1989+
)
18721990
blocking_failures.append(f"{stop_target}: run `{replacement}`")
18731991
elif not _handoff_since(stop_target, stop_state.get("started_at")):
18741992
handoff_target = stop_target

src/brigade/outcome_cmd.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,25 @@
2626
_LOCK_WAIT_SECONDS = 30.0
2727
_LOCK_SENTINEL_BYTE = b"\0"
2828

29+
# Generic fallback when no exercised skill/card is known. Prefer a real artifact
30+
# id at capture time so distinct skills do not collapse into one rank bucket.
31+
DEFAULT_CAPTURE_ARTIFACT_ID = "brigade-work"
32+
2933

3034
class OutcomeLedgerError(RuntimeError):
3135
"""Outcome ledger persistence failed; callers must not assume the append succeeded."""
3236

3337

38+
def resolve_capture_artifact_id(*candidates: str | None) -> str:
39+
"""Return the first non-empty capture id, else the generic brigade-work fallback."""
40+
for candidate in candidates:
41+
if isinstance(candidate, str):
42+
trimmed = candidate.strip()
43+
if trimmed:
44+
return trimmed
45+
return DEFAULT_CAPTURE_ARTIFACT_ID
46+
47+
3448
def _records_lock_path(target: Path) -> Path:
3549
return _records_path(target).parent / ".records.lock"
3650

src/brigade/work_cmd/verification.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -803,18 +803,53 @@ def _find_uncaptured_failed_verify_receipt(target: Path, planned_identity: list[
803803
return None
804804

805805

806-
def _capture_before_retry_message(run_id: str) -> str:
807-
return f"brigade outcome capture brigade-work --run-id {run_id}"
806+
def _receipt_capture_artifact_id(receipt: dict[str, Any] | None) -> str | None:
807+
"""Return the artifact id stamped on a verify receipt for outcome capture, if any."""
808+
if not isinstance(receipt, dict):
809+
return None
810+
capture = receipt.get("outcome_capture")
811+
if not isinstance(capture, dict):
812+
return None
813+
artifact_id = capture.get("artifact_id")
814+
if isinstance(artifact_id, str) and artifact_id.strip():
815+
return artifact_id.strip()
816+
return None
808817

809818

810-
def _enforce_capture_before_retry(target: Path, planned_identity: list[str], *, mode: str) -> int | None:
819+
def _stamp_outcome_capture(receipt: dict[str, Any], capture: str | None, capture_kind: str) -> None:
820+
"""Record the intended outcome artifact on the receipt before digests are sealed."""
821+
if not capture:
822+
return
823+
receipt["outcome_capture"] = {
824+
"artifact_id": capture,
825+
"artifact_kind": capture_kind,
826+
}
827+
828+
829+
def _capture_before_retry_message(run_id: str, *, artifact_id: str) -> str:
830+
return f"brigade outcome capture {artifact_id} --run-id {run_id}"
831+
832+
833+
def _enforce_capture_before_retry(
834+
target: Path,
835+
planned_identity: list[str],
836+
*,
837+
mode: str,
838+
capture_artifact_id: str | None = None,
839+
) -> int | None:
811840
"""Block or warn when the latest matching failed receipt has no outcome capture."""
812841
if mode == "off":
813842
return None
814843
failed = _find_uncaptured_failed_verify_receipt(target, planned_identity)
815844
if failed is None:
816845
return None
817-
message = _capture_before_retry_message(str(failed.get("run_id") or ""))
846+
from .. import outcome_cmd
847+
848+
artifact_id = outcome_cmd.resolve_capture_artifact_id(
849+
_receipt_capture_artifact_id(failed),
850+
capture_artifact_id,
851+
)
852+
message = _capture_before_retry_message(str(failed.get("run_id") or ""), artifact_id=artifact_id)
818853
if mode == "block":
819854
print(f"error: {message}", file=sys.stderr)
820855
return 1
@@ -1130,6 +1165,8 @@ def _run_verify_commands(
11301165
*,
11311166
graphtrail_timeout: float,
11321167
manifest: verify_manifest.VerifyManifest | None = None,
1168+
capture: str | None = None,
1169+
capture_kind: str = "skill",
11331170
) -> tuple[dict[str, Any], int]:
11341171
started = helpers._now()
11351172
run_id = f"{started.strftime('%Y%m%d-%H%M%S')}-work-verify-{uuid4().hex[:6]}"
@@ -1155,6 +1192,7 @@ def _run_verify_commands(
11551192
}
11561193
receipt.update(identity)
11571194
_stamp_harness_session(receipt)
1195+
_stamp_outcome_capture(receipt, capture, capture_kind)
11581196
try:
11591197
graph_delta_before = graphtrail_delta.capture_before(target, run_dir, timeout=graphtrail_timeout)
11601198
except KeyboardInterrupt:
@@ -1288,6 +1326,9 @@ def _write_reused_receipt(
12881326
latest: dict[str, Any],
12891327
planned_display: list[str],
12901328
timeout: int,
1329+
*,
1330+
capture: str | None = None,
1331+
capture_kind: str = "skill",
12911332
) -> tuple[dict[str, Any], int]:
12921333
"""Write a fresh receipt dir that records a reused passing run (no commands executed)."""
12931334
started = helpers._now()
@@ -1310,6 +1351,7 @@ def _write_reused_receipt(
13101351
}
13111352
receipt.update(identity)
13121353
_stamp_harness_session(receipt)
1354+
_stamp_outcome_capture(receipt, capture, capture_kind)
13131355
reused_from = latest.get("run_id")
13141356
if isinstance(reused_from, str) and reused_from:
13151357
receipt["reused_from"] = reused_from
@@ -1573,7 +1615,12 @@ def verify_run(
15731615
except ValueError as exc:
15741616
print(f"error: {exc}", file=sys.stderr)
15751617
return 2
1576-
blocked_rc = _enforce_capture_before_retry(target, planned_identity, mode=capture_before_retry)
1618+
blocked_rc = _enforce_capture_before_retry(
1619+
target,
1620+
planned_identity,
1621+
mode=capture_before_retry,
1622+
capture_artifact_id=capture,
1623+
)
15771624
if blocked_rc is not None:
15781625
return blocked_rc
15791626
try:
@@ -1588,14 +1635,23 @@ def verify_run(
15881635
and latest.get("tree_fingerprint") == fingerprint
15891636
and latest.get("planned_commands") == planned_display
15901637
):
1591-
receipt, rc = _write_reused_receipt(target, latest, planned_display, timeout)
1638+
receipt, rc = _write_reused_receipt(
1639+
target,
1640+
latest,
1641+
planned_display,
1642+
timeout,
1643+
capture=capture,
1644+
capture_kind=capture_kind,
1645+
)
15921646
if receipt is None:
15931647
receipt, rc = _run_verify_commands(
15941648
target,
15951649
planned,
15961650
timeout,
15971651
graphtrail_timeout=effective_graphtrail_timeout,
15981652
manifest=manifest,
1653+
capture=capture,
1654+
capture_kind=capture_kind,
15991655
)
16001656
except KeyboardInterrupt:
16011657
print("error: verification canceled by user", file=sys.stderr)

0 commit comments

Comments
 (0)