feat(brief): render the nightly cross-repo briefing without a model
The operator has more repos than they can hold in their head, and the question that actually costs them is "who is waiting on me, and what does answering cost". board.sh already scans for it; nothing rendered it in a form an unattended job could leave behind. --brief is a second RENDERING of that scan, never a second scan. It prints NESTE uncut, because the 38-character cut is the table column's property and not the record's - the value used to be truncated at record-build time, which left the cut string as the only copy. Each startup command is derived by CALLING route.sh with that repo's own four traits; next-cost alone cannot produce it, since the advisor flag is a property of the ROW and two rows can share a model/effort pair while differing on it. A repo with no route line is told so rather than handed a guess. It cross-checks itself against coord-count.sh, and that is the substance of the change rather than a nicety. The repo scan and the mailbox are two different populations: a mailbox can carry a name no scan will ever produce - a declared non-git surface (CLAUDE_COORD_REPO, e.g. ~/repos) or a checkout outside the roots. Measured on the real mailbox: 11 repos / 21 messages in the briefing against coord-count's 12 / 22, the missing one being the declared surface `repos`. A briefing that only walked the scan would answer "who is waiting on you" with a number it quietly knew was short. Zero model calls, which was the deciding property. Measured against 2.1.220 under subscription auth: --max-budget-usd DOES bite (terminal_reason budget_exhausted, exit 1), but it aborts AFTER turn one - floor ~0.25 USD-equivalent per turn on claude-opus-5[1m]. It is a runaway brake, not a pre-flight gate, so a nightly claude -p job would draw on the same quota pool as interactive work every night. Determinism removes the question. board.sh stays read-only: the file write lives in brief-nightly.sh, which renders to a temp file and renames it into place, and treats an EMPTY render as a FAILED one - board prints nothing when its scan roots do not exist, which is what a mistyped path or a moved home looks like, and a plain `> file` redirect would destroy yesterday's briefing on a bad launchd environment. The launchd template carries placeholders, not absolute paths: this repo is mirrored publicly and a plist is the one file here that would need a home directory in it. It points at a checkout rather than the plugin cache, which is version-pinned and would break silently on the next bump. board-selftest 36 -> 49. One check pins a defect caught only by eye against the real tree: fold copies its input's missing trailing newline, which ran the command onto the tail of the NESTE prose and produced a briefing whose commands could not be copied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017orCFDkmp88fLnqDR3chdJ
This commit is contained in:
parent
459c9feec0
commit
9dd24c3446
11 changed files with 435 additions and 18 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "repo-mailbox",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
|
|
|
|||
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -54,7 +54,7 @@ marketplace plugin. Three components, one boundary:
|
|||
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
|
||||
next-step blocks + board lines, `git status`, and mailbox pending counts, and
|
||||
prints one line per repo. Read-only by construction: it writes to no repo, no
|
||||
STATE.md and no mailbox. Pinned by `board-selftest.sh` (36 checks).
|
||||
STATE.md and no mailbox. Pinned by `board-selftest.sh` (49 checks).
|
||||
|
||||
**It lives here because the mailbox is one of its three inputs, and it carries
|
||||
the same axis distinction the mailbox does.** A pending count means *others
|
||||
|
|
@ -69,6 +69,46 @@ marketplace plugin. Three components, one boundary:
|
|||
one measured effect was an accidental fallback target for Claude sessions'
|
||||
own Bash tool calls (the bug 0.12.1 fixed) — once that fallback was gone they
|
||||
had no remaining function and were deleted.
|
||||
**`board.sh --brief` is a second RENDERING of that scan, never a second
|
||||
scan, and `brief-nightly.sh` is the only writer in that path.** The briefing
|
||||
answers the narrower question an unattended job can answer without judgement:
|
||||
which repos have an unhandled inbox, what their next step says in full, and
|
||||
the exact command to start a session in each. It prints NESTE uncut because
|
||||
the 38-character cut is the table column's property, not the record's — the
|
||||
value used to be truncated at record-build time, which left the cut string as
|
||||
the only copy. Each command is derived by CALLING `route.sh` with that repo's
|
||||
own four traits; `next-cost` alone cannot yield it, since the advisor flag is
|
||||
a property of the ROW and two rows can share a model/effort pair while
|
||||
differing on it. A repo with no route line is told so rather than handed a
|
||||
guess, because a guessed command reads as authoritative.
|
||||
|
||||
It also cross-checks itself against `coord-count.sh`, and that is not
|
||||
belt-and-braces. The repo scan and the mailbox are two different populations:
|
||||
a mailbox can carry a name no scan will ever produce — a declared non-git
|
||||
surface (`CLAUDE_COORD_REPO`, e.g. `~/repos` itself) or a checkout outside the
|
||||
roots. Measured on the real mailbox at 0.15.0: the briefing found 11 repos /
|
||||
21 messages where `coord-count` saw 12 mailboxes / 22 pending, the missing one
|
||||
being the declared surface `repos`. A briefing that only walks the scan
|
||||
answers "who is waiting on you" with a number it quietly knows is short.
|
||||
|
||||
**Zero model calls, and that is the load-bearing property, not an
|
||||
implementation detail.** The operator authenticates by subscription, so a
|
||||
headless `claude -p` job draws from the same quota pool as interactive work.
|
||||
Measured against 2.1.220: `--max-budget-usd` DOES bite under subscription auth
|
||||
(`terminal_reason: budget_exhausted`, exit 1), but it aborts AFTER turn one,
|
||||
never before it — floor ~0.25 USD-equivalent per turn on `claude-opus-5[1m]`.
|
||||
It is a runaway brake, not a pre-flight gate. Making the briefing deterministic
|
||||
removes the question entirely.
|
||||
|
||||
`board.sh` stays read-only, which is why the file write lives in the wrapper
|
||||
instead of behind a `--brief --out FILE` flag. The wrapper renders to a temp
|
||||
file in the target directory and renames it into place, and treats an EMPTY
|
||||
render as a FAILED one: board prints nothing at all when its scan roots do not
|
||||
exist, which is what a mistyped path or a moved home directory looks like, and
|
||||
a plain `> file` redirect would destroy yesterday's briefing on a bad launchd
|
||||
environment. A tree where nobody owes anything is a different case — that is a
|
||||
valid, non-empty briefing saying so, and is written normally.
|
||||
|
||||
- **Route (`scripts/route.sh`):** pure calculator for the next session's model
|
||||
and effort. Takes four scored traits of the next task plus a required
|
||||
rationale, and prints one block of `key=value` lines: the rubric row, the rule
|
||||
|
|
@ -169,7 +209,7 @@ obligations in another repo.
|
|||
builtins only in hook and tests.
|
||||
- TDD: no behavior change without a failing selftest check first.
|
||||
`bash scripts/coord-selftest.sh` must exit 0 (182/182),
|
||||
`bash scripts/board-selftest.sh` must exit 0 (36/36) and
|
||||
`bash scripts/board-selftest.sh` must exit 0 (49/49) and
|
||||
`bash scripts/route-selftest.sh` must exit 0 (73/73).
|
||||
- English for all code, docs, and commit messages (public repo). Norwegian
|
||||
trigger aliases in the skill description are deliberate.
|
||||
|
|
@ -181,6 +221,11 @@ obligations in another repo.
|
|||
`bash scripts/route-selftest.sh` (or `npm test`, the Node wrapper around all three)
|
||||
- Hook smoke test: `node hooks/scripts/session-start.mjs` (expects JSON on stdout)
|
||||
- Board smoke test: `bash scripts/board.sh` (read-only, ~3s over the real tree)
|
||||
- Briefing smoke test: `bash scripts/board.sh --brief` (read-only, writes
|
||||
nothing). `brief-nightly.sh` DOES write — it overwrites `$CLAUDE_BRIEF_FILE`
|
||||
(default `~/.claude/briefing.md`), so point that at a scratch path when
|
||||
testing. Installed as a launchd agent from `launchd/`, which points at the
|
||||
SOURCE repo, never the version-pinned plugin cache.
|
||||
- Route smoke test: `bash scripts/route.sh --path known --verification strong
|
||||
--reversibility cheap --scope local --rationale x` (writes nothing, instant)
|
||||
- Sweep smoke test: `bash scripts/coord-sweep.sh` (dry-run is the default, so
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development.*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
|
@ -83,7 +83,7 @@ It lives here because it is the **writer** for the cost field the board already
|
|||
|
||||
Scoring is judgement and belongs to the skill; turning scores into a row is a lookup and costs no model calls. One deliberate side effect is worth more than the tokens saved: a next step that cannot be scored `known` or `partial`, with no design phase planned, is an **underspecified task description** — the answer is to rewrite the step, not to upgrade the model.
|
||||
|
||||
**CLI.** The engine is six user-facing bash scripts in the plugin's `scripts/` directory (plus three selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
|
||||
**CLI.** The engine is eight user-facing bash scripts in the plugin's `scripts/` directory (plus three selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
|
||||
|
||||
coord-send.sh --to <repo> --subject "<subject>" [--message "<text>"] # or body on stdin
|
||||
coord-send.sh --to <repo> --subject "<subject>" --fyi # a notice: no reply expected
|
||||
|
|
@ -94,7 +94,8 @@ Scoring is judgement and belongs to the skill; turning scores into a row is a lo
|
|||
coord-done.sh <filename>... | --all # archive without replying
|
||||
coord-count.sh [--exclude <mailbox>] # per mailbox: pending + replies owed, delivering nothing
|
||||
coord-sweep.sh [--write] [--days <n>] [--log <path>] # close aged notices machine-wide (dry-run by default)
|
||||
board.sh [--roots <dir>[,<dir>...]] # cross-repo attention board (read-only)
|
||||
board.sh [--roots <dir>[,<dir>...]] [--brief] # cross-repo attention board (read-only)
|
||||
brief-nightly.sh # render the briefing to a file, atomically
|
||||
route.sh --path <v> --verification <v> --reversibility <v> \
|
||||
--scope <v> --rationale "<why>" # model + effort for the next session
|
||||
|
||||
|
|
@ -102,6 +103,10 @@ The reply/resolve hints the hook injects (`-> reply: coord-send --reply-to …
|
|||
|
||||
**`coord-sweep.sh` is the only script that closes a message without a human in the loop**, and it is bounded to one mechanically decidable class: a directed message whose sender declared `reply-expected: no`, older than a grace window (default 14 days). A message that owes a reply is never touched, at any age, with any flag — answering it would mean deciding something on the receiving repo's behalf. Dry-run is the default, inverted from every other script here, because this is the one that destroys pending state. Every closure appends a line naming the sender and subject: a directed message has no seen-tracking, so the sweep cannot tell "seen and ignored" from "never delivered", and a notice to a repo left unopened for the whole window is closed *unread*. The log is what keeps that from being silent.
|
||||
|
||||
**`board.sh --brief` renders the nightly briefing**, a second rendering of the scan the board already does rather than a second scan: the repos with an unhandled inbox, each one's next step *in full* (the 38-character cut belongs to the table column, not to the record), and the exact command to start a session there — derived by calling `route.sh` with that repo's own four traits, since `next-cost` alone cannot produce the advisor flag. A repo with no route line is told so rather than handed a guessed command. It also cross-checks itself against `coord-count.sh`, because the repo scan and the mailbox are different populations: a mailbox can carry a name no scan will ever produce, such as a declared non-git surface (`CLAUDE_COORD_REPO`) or a checkout outside the roots, and a briefing that only walked the scan would answer "who is waiting on you" with a number it quietly knew was short.
|
||||
|
||||
It makes **zero model calls**, which is the point rather than a detail. Under subscription auth a headless session draws from the same quota pool as interactive work, and `--max-budget-usd` is a runaway brake rather than a pre-flight gate — measured against 2.1.220, it aborts *after* the first turn, never before it. `board.sh --brief` writes nothing; the file write lives in `brief-nightly.sh`, which renders to a temp file and renames it into place, and refuses to replace a good briefing with an empty render. `launchd/` holds a sample agent that runs it nightly; it points at a checkout, never at the version-pinned plugin cache.
|
||||
|
||||
## Security Model
|
||||
|
||||
Cross-repo message content is untrusted input by design:
|
||||
|
|
|
|||
61
launchd/com.ktg.repo-mailbox-brief.plist
Normal file
61
launchd/com.ktg.repo-mailbox-brief.plist
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
Nightly cross-repo briefing. Renders ~/.claude/briefing.md from the same scan
|
||||
board.sh already does: which repos have an unhandled inbox, their next step
|
||||
in full, and the exact command to start a session in each.
|
||||
|
||||
ZERO MODEL CALLS. It runs two shell scripts and nothing else. This was the
|
||||
deciding property, not an implementation detail: the operator authenticates
|
||||
by subscription, so a headless `claude -p` job would draw from the same quota
|
||||
pool as interactive work - measured floor ~0.25 USD-equivalent per turn on
|
||||
claude-opus-5[1m], which --max-budget-usd cannot prevent because it aborts
|
||||
AFTER turn one, never before it.
|
||||
|
||||
PATH: every binary this touches (bash, git, awk, sed, fold, stat, find, date)
|
||||
lives in /usr/bin or /bin, so launchd's minimal default PATH is sufficient
|
||||
and no EnvironmentVariables block is needed.
|
||||
|
||||
The program path points at the SOURCE REPO, deliberately, not at the plugin
|
||||
cache. The cache path is version-pinned
|
||||
(~/.claude/plugins/cache/ktg-plugin-marketplace/repo-mailbox/0.13.0/...), so
|
||||
a plist pointing there would break silently on the next version bump - and a
|
||||
second copy of these scripts on disk is the exact defect class that produced
|
||||
the 0.12.1 stale-fallback bug.
|
||||
|
||||
This file is a TEMPLATE. It carries no absolute home path on purpose: the
|
||||
repo is mirrored publicly, and a plist is the one file here that would need
|
||||
one. Substitute both placeholders at install time.
|
||||
|
||||
Install: sed -e "s|__CHECKOUT__|$PWD|g" -e "s|__HOME__|$HOME|g" \
|
||||
launchd/com.ktg.repo-mailbox-brief.plist \
|
||||
> ~/Library/LaunchAgents/com.ktg.repo-mailbox-brief.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.ktg.repo-mailbox-brief.plist
|
||||
Run now: launchctl start com.ktg.repo-mailbox-brief
|
||||
Remove: launchctl unload ~/Library/LaunchAgents/com.ktg.repo-mailbox-brief.plist
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.ktg.repo-mailbox-brief</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>__CHECKOUT__/scripts/brief-nightly.sh</string>
|
||||
</array>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>__HOME__/Library/Logs/repo-mailbox-brief.log</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>__HOME__/Library/Logs/repo-mailbox-brief.log</string>
|
||||
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>6</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "repo-mailbox",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -183,6 +183,22 @@ mkdir -p "$CLAUDE_COORD_DIR/repo-a/inbox" "$CLAUDE_COORD_DIR/repo-a/archive"
|
|||
for n in 1 2 3; do echo "msg" > "$CLAUDE_COORD_DIR/repo-a/inbox/2026-msg$n-from-x.md"; done
|
||||
echo "old" > "$CLAUDE_COORD_DIR/repo-a/archive/2026-old-from-x.md"
|
||||
|
||||
# repo-owes: unhandled inbox AND a route line, so the briefing can derive the
|
||||
# EXACT startup command - advisor flag included - from the repo's own four
|
||||
# traits instead of guessing from next-cost alone. Its NESTE runs deliberately
|
||||
# past the table's 38-character column: carrying that line whole is the reason
|
||||
# the briefing exists at all.
|
||||
mkrepo "$ROOT/repo-owes"
|
||||
{
|
||||
echo "# STATE - repo-owes"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->"
|
||||
echo "<!-- route: path=known; verification=strong; reversibility=cheap; scope=local; rationale=x -->"
|
||||
echo "Svar org-ops om badge-formatet, deretter bump katalogens ref og verifiser check-versions."
|
||||
} > "$ROOT/repo-owes/STATE.md"
|
||||
mkdir -p "$CLAUDE_COORD_DIR/repo-owes/inbox"
|
||||
echo "msg" > "$CLAUDE_COORD_DIR/repo-owes/inbox/2026-msg1-from-y.md"
|
||||
|
||||
OUT="$("$BOARD" --roots "$ROOT" 2>/dev/null)"
|
||||
|
||||
# --- 1. Discovery ---------------------------------------------------------
|
||||
|
|
@ -296,6 +312,96 @@ n_stale="$(printf '%s\n' "$OUT" | grep -n '^repo-stale ' | cut -d: -f1)"
|
|||
[ -n "$n_old" ] && [ -n "$n_stale" ] && [ "$n_old" -lt "$n_stale" ]
|
||||
check "SISTE is evidence, not a ranking input (order still follows ALDER)" $?
|
||||
|
||||
# --- 8. Briefing rendering (--brief) --------------------------------------
|
||||
# A second rendering of the SAME scan, never a second scan: the briefing
|
||||
# answers "who is waiting on me and what does answering cost", where the table
|
||||
# answers "what is the state of every repo".
|
||||
BRIEF="$("$BOARD" --roots "$ROOT" --brief 2>/dev/null)"
|
||||
|
||||
printf '%s' "$BRIEF" | grep -q 'repo-owes'
|
||||
check "brief includes a repo with an unhandled inbox" $?
|
||||
|
||||
printf '%s' "$BRIEF" | grep -q 'repo-b'; [ $? -ne 0 ]
|
||||
check "brief excludes a repo that owes nothing" $?
|
||||
|
||||
# The table cuts NESTE at 38 characters. The briefing carries the whole line,
|
||||
# so the tail past the cut is the thing being proved here.
|
||||
printf '%s' "$BRIEF" | grep -q 'check-versions'
|
||||
check "brief prints the full NESTE line, not the 38-char table excerpt" $?
|
||||
|
||||
# Derived by CALLING route.sh with the repo's own four traits - deliberately
|
||||
# not spelled out in board.sh - so the rubric keeps exactly one copy. Row 1
|
||||
# carries the advisor, which is what makes the quota fallback safe to take.
|
||||
printf '%s' "$BRIEF" | grep -q 'claude --model sonnet --effort high --advisor opus'
|
||||
check "brief derives the exact startup command from the repo's route line" $?
|
||||
|
||||
# The command must start its OWN line. `fold` copies its input's missing
|
||||
# trailing newline, which ran the command onto the tail of the NESTE prose and
|
||||
# produced a briefing whose commands could not be copied - caught by eye
|
||||
# against the real tree, which is exactly what a check is for.
|
||||
printf '%s' "$BRIEF" | grep -qE '^ \$ claude --model'
|
||||
check "startup command starts on its own line, not appended to NESTE prose" $?
|
||||
|
||||
# repo-a owes 3 messages and has no route line. A fabricated command would be
|
||||
# worse than none: it would read as authoritative while being a guess.
|
||||
printf '%s' "$BRIEF" | grep -A4 'repo-a' | grep -qi 'route'
|
||||
check "repo owing mail but lacking a route line is marked, never guessed at" $?
|
||||
|
||||
# Read-only by construction, and the briefing must not erode it. Reading IS
|
||||
# delivering in this engine, so a survey that consumed a backlog would be
|
||||
# precisely the defect the whole design forbids.
|
||||
snap() { find "$1" -type f -exec stat -f '%N %m %z' {} \; 2>/dev/null | sort; }
|
||||
b4="$(snap "$CLAUDE_COORD_DIR")"
|
||||
"$BOARD" --roots "$ROOT" --brief >/dev/null 2>&1
|
||||
[ "$b4" = "$(snap "$CLAUDE_COORD_DIR")" ]
|
||||
check "brief writes nothing to the mailbox (reading is delivering)" $?
|
||||
|
||||
# A mailbox can carry a name the repo scan will NEVER produce: a declared
|
||||
# non-git surface (CLAUDE_COORD_REPO, e.g. ~/repos itself) or a checkout
|
||||
# outside the scan roots. board.sh discovers git repos, so such a mailbox is
|
||||
# invisible to every column it prints - and the briefing exists to answer "who
|
||||
# is waiting on you", where dropping one silently is the loss-wearing-the-
|
||||
# shape-of-normal class this engine keeps naming. Measured against the real
|
||||
# mailbox before this check existed: 11 repos / 21 messages in the briefing
|
||||
# against 12 mailboxes / 22 pending in coord-count.
|
||||
mkdir -p "$CLAUDE_COORD_DIR/orphan-surface/inbox"
|
||||
echo "msg" > "$CLAUDE_COORD_DIR/orphan-surface/inbox/2026-msg1-from-z.md"
|
||||
BRIEF2="$("$BOARD" --roots "$ROOT" --brief 2>/dev/null)"
|
||||
|
||||
printf '%s' "$BRIEF2" | grep -q 'orphan-surface'
|
||||
check "brief surfaces a pending mailbox that has no repo in the scan tree" $?
|
||||
|
||||
# It must be reported as its own class, not silently folded in among repos the
|
||||
# scan actually found - there is no STATE.md behind it and so no next step.
|
||||
printf '%s' "$BRIEF2" | grep -qi 'utenfor repo-skannen\|uten repo'
|
||||
check "orphan mailbox is reported as its own class, not as a scanned repo" $?
|
||||
|
||||
# --- 9. brief-nightly.sh: the only writer, and it writes atomically -------
|
||||
# board.sh stays read-only, so the file write lives in a separate wrapper.
|
||||
# Unattended overwrite must never expose a half-written briefing: the wrapper
|
||||
# renders to a temp file in the same directory and renames it into place.
|
||||
NIGHTLY="$DIR/brief-nightly.sh"
|
||||
OUTFILE="$ROOT/briefing.md"
|
||||
CLAUDE_BRIEF_FILE="$OUTFILE" BOARD_ROOTS="$ROOT" bash "$NIGHTLY" >/dev/null 2>&1
|
||||
check "brief-nightly exits 0" $?
|
||||
|
||||
[ -f "$OUTFILE" ] && grep -q 'repo-owes' "$OUTFILE"
|
||||
check "brief-nightly writes the briefing to CLAUDE_BRIEF_FILE" $?
|
||||
|
||||
ls "$OUTFILE".tmp.* >/dev/null 2>&1; [ $? -ne 0 ]
|
||||
check "brief-nightly leaves no temp file behind" $?
|
||||
|
||||
# An EMPTY render is a failed render, and must leave the previous briefing
|
||||
# standing rather than truncating it to nothing - the failure mode a plain
|
||||
# `> file` redirect has, and the reason the temp-then-rename exists. A repo
|
||||
# tree where nobody owes anything is NOT this case: that renders a valid,
|
||||
# non-empty briefing saying so, and is written normally.
|
||||
echo "FORRIGE BRIEFING" > "$OUTFILE"
|
||||
CLAUDE_BRIEF_FILE="$OUTFILE" BOARD_ROOTS="$ROOT/does-not-exist" \
|
||||
bash "$NIGHTLY" >/dev/null 2>&1
|
||||
grep -q 'FORRIGE BRIEFING' "$OUTFILE"
|
||||
check "empty render never overwrites the previous briefing" $?
|
||||
|
||||
echo ""
|
||||
echo "board-selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
|
|
|
|||
149
scripts/board.sh
149
scripts/board.sh
|
|
@ -51,7 +51,21 @@
|
|||
# the register, not here. Conflating the two axes is a real defect class - the
|
||||
# board reads only its own axis, so keep them separate.
|
||||
#
|
||||
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain]
|
||||
# --brief is a SECOND RENDERING of the same scan, never a second scan. The
|
||||
# table answers "what is the state of every repo"; the briefing answers the
|
||||
# narrower question an unattended nightly job can answer without judgement:
|
||||
# which repos have an unhandled inbox, what their next step says IN FULL, and
|
||||
# the exact command to start a session there. The 38-char cut is the table
|
||||
# column's property, not the record's, so the briefing prints NESTE uncut. Each
|
||||
# command is derived by CALLING route.sh with that repo's own four traits -
|
||||
# next-cost alone cannot yield it, since the advisor flag is a property of the
|
||||
# ROW. A repo with no route line is told so rather than handed a guess.
|
||||
#
|
||||
# --brief is still read-only: it writes nothing. The file write lives in
|
||||
# brief-nightly.sh, which renders to a temp file and renames it into place, and
|
||||
# refuses to overwrite a good briefing with an empty render.
|
||||
#
|
||||
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain] [--brief]
|
||||
# Env: CLAUDE_COORD_DIR overrides the mailbox root.
|
||||
# BOARD_ROOTS overrides the default scan roots.
|
||||
# ASCII only, bash 3.2 safe.
|
||||
|
|
@ -61,12 +75,20 @@ export LC_ALL=C
|
|||
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
|
||||
ROOTS="${BOARD_ROOTS:-$HOME/repos}"
|
||||
NESTE_WIDTH=38
|
||||
BRIEF=0
|
||||
|
||||
# Sibling calculator, invoked rather than reimplemented: the rubric that turns
|
||||
# four traits into a model has exactly one copy, and it is route.sh's row
|
||||
# table. Bare form on purpose - a ${VAR:-fallback} here is the 0.12.1 defect.
|
||||
SELFDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROUTE="$SELFDIR/route.sh"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
|
||||
--roots) [ $# -ge 2 ] || { echo "board: --roots requires a value" >&2; exit 2; }
|
||||
ROOTS="$2"; shift 2 ;;
|
||||
--brief) BRIEF=1; shift ;;
|
||||
--plain) shift ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "board: unknown argument: $1 (ignored)" >&2; shift ;;
|
||||
|
|
@ -148,8 +170,8 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
|
|||
# No plan file, so no plan age: ALDER is blank rather than quietly showing
|
||||
# the commit age under a heading that means something else everywhere else
|
||||
# in the table. The sort key keeps using it - order is unchanged.
|
||||
printf '5|%06d|%s|-|-|%s|%s|-|%s|(ingen STATE.md)\n' \
|
||||
"$lastd" "$name" "$inbox" "$dirty" "$lastcol"
|
||||
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|(ingen STATE.md)\n' \
|
||||
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
|
@ -190,8 +212,11 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
|
|||
}' "$state" 2>/dev/null \
|
||||
| sed -e 's/^[[:space:]]*>[[:space:]]*//' -e 's/\*\*//g' \
|
||||
-e 's/^[[:space:]]*[-*][[:space:]]*//' -e 's/^[[:space:]]*//' -e 's/`//g')"
|
||||
# Stored WHOLE. Truncation is a property of the table's 38-char column, so it
|
||||
# belongs to that renderer alone - the briefing is a second rendering of this
|
||||
# same record and exists precisely to carry the line uncut. Cutting here once
|
||||
# meant the only copy of the text was the cut one.
|
||||
[ -n "$neste" ] || neste="(tom NESTE-blokk)"
|
||||
neste="$(trunc "$neste" "$NESTE_WIDTH")"
|
||||
|
||||
disp="$status"
|
||||
if [ "$status" = "blocked" ] && [ -n "$blockedon" ] && [ "$blockedon" != "-" ]; then
|
||||
|
|
@ -207,8 +232,8 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
|
|||
*) bucket=2 ;;
|
||||
esac
|
||||
|
||||
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s\n' \
|
||||
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$neste"
|
||||
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s\n' \
|
||||
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" "$neste"
|
||||
done > "${TMPDIR:-/tmp}/board.$$"
|
||||
|
||||
RECORDS="${TMPDIR:-/tmp}/board.$$"
|
||||
|
|
@ -222,12 +247,120 @@ hdr() {
|
|||
|
||||
rows() {
|
||||
awk -F'|' -v b="$1" '$1==b' "$RECORDS" | sort -t'|' -k2,2n | \
|
||||
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last neste; do
|
||||
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir neste; do
|
||||
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
|
||||
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$neste"
|
||||
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$(trunc "$neste" "$NESTE_WIDTH")"
|
||||
done
|
||||
}
|
||||
|
||||
# --- Briefing rendering (--brief) ------------------------------------------
|
||||
# The startup command for one repo, derived from that repo's OWN route line by
|
||||
# calling route.sh. Deriving it from next-cost instead would not work even in
|
||||
# principle: the advisor flag is a property of the ROW, and two rows can share
|
||||
# a model/effort pair while differing on it. A repo with no route line gets its
|
||||
# next-cost printed and is told where the command comes from - a guessed
|
||||
# command would read as authoritative while being a guess, which is worse than
|
||||
# no command at all.
|
||||
brief_cmd() {
|
||||
bc_state="$1/STATE.md"
|
||||
bc_line="$(grep -m1 '^<!-- route:' "$bc_state" 2>/dev/null)"
|
||||
if [ -z "$bc_line" ]; then
|
||||
printf 'KOST: %s (ingen route-linje - kjor route-skillen i det repoet)' "$2"
|
||||
return
|
||||
fi
|
||||
bc_p="$(printf '%s' "$bc_line" | sed -n 's/.*path=\([a-z-]*\).*/\1/p')"
|
||||
bc_v="$(printf '%s' "$bc_line" | sed -n 's/.*verification=\([a-z-]*\).*/\1/p')"
|
||||
bc_r="$(printf '%s' "$bc_line" | sed -n 's/.*reversibility=\([a-z-]*\).*/\1/p')"
|
||||
bc_s="$(printf '%s' "$bc_line" | sed -n 's/.*scope=\([a-z-]*\).*/\1/p')"
|
||||
bc_cmd="$(bash "$ROUTE" --path "$bc_p" --verification "$bc_v" \
|
||||
--reversibility "$bc_r" --scope "$bc_s" --rationale brief 2>/dev/null \
|
||||
| sed -n 's/^command=//p')"
|
||||
if [ -n "$bc_cmd" ]; then
|
||||
printf '$ %s' "$bc_cmd"
|
||||
else
|
||||
printf 'KOST: %s (route-linjen kunne ikke tolkes)' "$2"
|
||||
fi
|
||||
}
|
||||
|
||||
# The repo scan and the mailbox are two different populations, and the gap
|
||||
# between them is silent by default. board.sh discovers git REPOS; a mailbox
|
||||
# can carry a name no scan will ever produce - a declared non-git surface
|
||||
# (CLAUDE_COORD_REPO, e.g. ~/repos itself) or a checkout outside the roots.
|
||||
# Such a mailbox is invisible in every column this script prints, so a briefing
|
||||
# that only walks the scan answers "who is waiting on you" with a number it
|
||||
# quietly knows is short. Measured on the real mailbox: 11 repos / 21 messages
|
||||
# against coord-count's 12 mailboxes / 22 pending, the missing one being the
|
||||
# declared surface `repos`.
|
||||
#
|
||||
# coord-count.sh is the right source and the only safe one: it counts without
|
||||
# delivering, where coord-inbox.sh would mark broadcasts seen just by looking.
|
||||
brief_orphans() {
|
||||
bo_count="$SELFDIR/coord-count.sh"
|
||||
[ -f "$bo_count" ] || return 0
|
||||
bo_tab="$(printf '\t')"
|
||||
bo_out="$(bash "$bo_count" 2>/dev/null \
|
||||
| awk -F"$bo_tab" '$2+0>0 {print $1"'"$bo_tab"'"$2}' \
|
||||
| while IFS="$bo_tab" read -r bo_name bo_n; do
|
||||
[ -n "$bo_name" ] || continue
|
||||
awk -F'|' -v n="$bo_name" '$3==n {f=1} END{exit !f}' "$RECORDS" \
|
||||
|| printf ' %-32s INN %s\n' "$bo_name" "$bo_n"
|
||||
done)"
|
||||
[ -n "$bo_out" ] || return 0
|
||||
echo ""
|
||||
echo "UTENFOR REPO-SKANNEN - postkasser uten et repo i treet:"
|
||||
printf '%s\n' "$bo_out"
|
||||
echo "Disse har ingen STATE.md og derfor intet neste steg. En deklarert flate"
|
||||
echo "(CLAUDE_COORD_REPO) eller et checkout utenfor scan-roten."
|
||||
}
|
||||
|
||||
brief() {
|
||||
n_owe="$(awk -F'|' '$6+0 > 0' "$RECORDS" | wc -l | tr -d ' ')"
|
||||
tot_msg="$(awk -F'|' '$6+0 > 0 {s+=$6} END{print s+0}' "$RECORDS")"
|
||||
|
||||
echo "BRIEFING $(date '+%Y-%m-%d %H:%M') - repo som skylder et svar"
|
||||
echo "Kilder: STATE.md (NESTE + route-linje), git, coord-innboks. 0 modellkall."
|
||||
echo ""
|
||||
|
||||
if [ "${n_owe:-0}" -eq 0 ]; then
|
||||
echo "Ingen repo har uhaandtert innboks. Ingen skylder noen et svar i dag."
|
||||
# Still checked: "no repo owes" and "no mailbox is pending" are different
|
||||
# claims, and only the second one is the good news it reads as.
|
||||
brief_orphans
|
||||
echo ""
|
||||
echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA"
|
||||
echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke"
|
||||
echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Most-owed first: the repo holding up the most other sessions is read first.
|
||||
awk -F'|' '$6+0 > 0' "$RECORDS" | sort -t'|' -k6,6nr | \
|
||||
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir neste; do
|
||||
printf ' %-32s INN %-4s %s\n' "$name" "$inbox" "$status"
|
||||
# Wrapped, not cut - the whole line is the point, but a 500-character one
|
||||
# is unreadable in a file nobody watched being written. Locale is set for
|
||||
# the same reason trunc sets it: under LC_ALL=C fold counts BYTES and can
|
||||
# split a multibyte character into mojibake.
|
||||
# Trailing newline via '%s\n': fold copies its input's lack of one, and the
|
||||
# command line below would then start on the tail of the NESTE text.
|
||||
printf '%s\n' "$neste" | LC_ALL=en_US.UTF-8 fold -s -w 84 \
|
||||
| sed -e '1s/^/ NESTE: /' -e '2,$s/^/ /'
|
||||
printf ' %s\n\n' "$(brief_cmd "$dir" "$cost")"
|
||||
done
|
||||
|
||||
printf '%s repo skylder svar, %s meldinger totalt.\n' "$n_owe" "$tot_msg"
|
||||
brief_orphans
|
||||
echo ""
|
||||
echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA"
|
||||
echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke"
|
||||
echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra."
|
||||
}
|
||||
|
||||
if [ "$BRIEF" -eq 1 ]; then
|
||||
brief
|
||||
exit 0
|
||||
fi
|
||||
|
||||
count() { awk -F'|' -v b="$1" '$1==b' "$RECORDS" | wc -l | tr -d ' '; }
|
||||
|
||||
echo "BOARD - tverr-repo oppmerksomhetstavle ($(awk 'END{print NR}' "$RECORDS") repo)"
|
||||
|
|
|
|||
67
scripts/brief-nightly.sh
Executable file
67
scripts/brief-nightly.sh
Executable file
|
|
@ -0,0 +1,67 @@
|
|||
#!/bin/bash
|
||||
# brief-nightly.sh - render the cross-repo briefing to a file, atomically.
|
||||
#
|
||||
# This is the ONLY writer in the briefing path, and it exists so that board.sh
|
||||
# does not become one. board.sh is read-only by construction - it writes to no
|
||||
# repo, no STATE.md and no mailbox - and a `--brief --out FILE` flag would have
|
||||
# ended that for the sake of one redirect.
|
||||
#
|
||||
# Why not just `board.sh --brief > file` from launchd:
|
||||
#
|
||||
# 1. A plain redirect TRUNCATES the target before the renderer has produced
|
||||
# a byte. An unattended job that fails, or is read mid-run, then leaves
|
||||
# the operator an empty or half-written briefing - and the briefing is
|
||||
# read exactly when nobody is watching it being made. Rendering to a temp
|
||||
# file in the same directory and rename()-ing it into place means a reader
|
||||
# sees either the old briefing or the new one, never a partial one.
|
||||
# 2. An EMPTY render is treated as a FAILED render and never replaces a good
|
||||
# briefing. Board prints nothing at all when its scan roots do not exist,
|
||||
# which is exactly what a mistyped path or a moved home directory looks
|
||||
# like - silent truncation to zero would destroy yesterday's briefing on
|
||||
# a bad launchd environment. A repo tree where nobody owes anything is a
|
||||
# different case entirely: that renders a valid, non-empty briefing saying
|
||||
# so, and is written normally.
|
||||
#
|
||||
# Zero model calls, by construction: it runs two shell scripts. That is the
|
||||
# whole point - a nightly job on subscription auth draws from the same quota
|
||||
# pool as interactive work, and every turn it would spend is a turn the
|
||||
# operator does not get. Measured floor for one headless turn on
|
||||
# claude-opus-5[1m]: ~0.25 USD-equivalent, which --max-budget-usd cannot
|
||||
# prevent (it aborts AFTER turn one, never before it).
|
||||
#
|
||||
# ASCII only, bash 3.2 safe.
|
||||
set -u
|
||||
|
||||
SELFDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BOARD="$SELFDIR/board.sh"
|
||||
OUT="${CLAUDE_BRIEF_FILE:-$HOME/.claude/briefing.md}"
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
esac
|
||||
|
||||
OUTDIR="$(dirname "$OUT")"
|
||||
mkdir -p "$OUTDIR" 2>/dev/null || {
|
||||
echo "brief-nightly: cannot create $OUTDIR" >&2; exit 1; }
|
||||
|
||||
# Same directory as the target: rename() is only atomic within one filesystem.
|
||||
TMP="$OUT.tmp.$$"
|
||||
trap '/bin/rm -f "$TMP" 2>/dev/null' EXIT
|
||||
|
||||
bash "$BOARD" --brief "$@" > "$TMP" 2>/dev/null
|
||||
rc=$?
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "brief-nightly: board.sh --brief exited $rc, keeping previous briefing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$TMP" ]; then
|
||||
echo "brief-nightly: empty render, keeping previous briefing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv -f "$TMP" "$OUT" || {
|
||||
echo "brief-nightly: could not install $OUT" >&2; exit 1; }
|
||||
|
||||
exit 0
|
||||
|
|
@ -14,7 +14,7 @@ description: >-
|
|||
user names no repo and no tool — choosing *between* repos is this skill. Not for
|
||||
"where were we" inside the current repo: that is this repo's own STATE.md,
|
||||
already injected at session start.
|
||||
version: "0.14.0"
|
||||
version: "0.15.0"
|
||||
---
|
||||
|
||||
# board — which repo deserves the next session
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ description: >-
|
|||
covers retiring a broadcast that has become wrong or obsolete: "retract that
|
||||
broadcast", "that announcement is outdated, pull it", "trekk tilbake kringkastingen",
|
||||
"den broadcasten er utdatert".
|
||||
version: "0.14.0"
|
||||
version: "0.15.0"
|
||||
---
|
||||
|
||||
# coord-send — natural-language front door for inter-repo messages
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ description: >-
|
|||
the operator names no model and no tool — choosing the model for the next
|
||||
session IS this skill. Not for choosing which REPO gets the next session:
|
||||
that is the `board` skill.
|
||||
version: "0.14.0"
|
||||
version: "0.15.0"
|
||||
---
|
||||
|
||||
# route — what the next session should run with
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue