jevsnes.git / tools / watch / CLAUDE.md

For agents, on top of README.md, which they read first.

  • Keep this shellcheck-clean (nix develop -c shellcheck tools/watch/*.sh, per ~/.claude/rules/code-bash.md) and re-run --self-test after any change to the parsing functions - a script that grows past the rule's size ceiling or gains real branching moves to tools/hotpatch's Rust tier, not into a longer bash file; this one has stayed under it by keeping ALL parsing/formatting logic in small, individually-testable functions rather than inline in main.
  • A variable set inside a function called via $(...) is invisible to the caller the instant the subshell exits. parse_tool_response/mcp_tool report their failure reason through last_error (a file, set_error writes it), not a plain variable, for exactly this reason - a first version used a plain TOOL_ERROR variable and it worked in every self-test case (each one calls the function directly, no subshell) and failed for real against the live window ("TOOL_ERROR: unbound variable") the first time a caller wrote budget="$(mcp_tool budget)". Any test that only exercises a function directly, never through $(...), does not exercise this path - the self-test's "missing tool" cases are written through parse_tool_response directly for readability, so they would NOT have caught this by themselves. What did catch it was running the script for real against the live window, which is why that stays part of verifying a change here, not a nice-to-have.
  • The MCP stream's first SSE event can be a bare keepalive - data: with nothing after it, id: 0, retry: 3000 - found for real against the live window 2026-09-20. grep -m1 '^data: ' (no trailing {) matches that line and returns nothing to parse; ^data: { is what skips it, the same pattern ../hotpatch/patch.sh's mcp_raw already used. --self-test's "leading keepalive" case is the regression test for this.
  • Every field this prints goes through get, which returns the literal string "unknown" for a missing OR a null field - never a number, never 0, never false. jq -e '.foo' alone cannot tell "missing" apart from "false" (both are falsy to -e), which is exactly the bug a watchdog cannot afford: stopped_for_good being unreadable must never print as stopped=no. Adding a new printed field means adding it through get/a format_* wrapper, never a bare jq -r '.foo // 0'.
  • If a new field is added to the budget or state MCP tool, it does not need to be added HERE - this prints a fixed, small set of fields for a glance, not everything the tools carry. budget.sh --json does not exist on purpose; a caller that wants the whole object calls the tool itself.
  • format_lifetime reads lifetime_remaining_usd, which the budget tool no longer has (the lifetime cap was removed 2026-09-22; the field is now lifetime_usd, spent so far). Against a live window it prints unknown, as get promises. The self-test's fixtures still carry the old field, so they pass; a fix changes both the script and its fixtures together.