Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions .githooks/prepare-commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@
# without a sign-off is still a commit you are certifying.
msg_file="$1"

name="$(git config user.name)"
email="$(git config user.email)"
if [ -z "$name" ] || [ -z "$email" ]; then
# No identity: let git produce its own "please tell me who you are" error.
exit 0
fi
# The committer, as `git commit -s` signs: it honours GIT_COMMITTER_* (set by
# the git Feature's identity, or for a rebase or cherry-pick), where reading
# user.name and user.email would sign as whoever the config names instead.
ident="$(git var GIT_COMMITTER_IDENT 2>/dev/null)" || exit 0 # no identity: git reports it
ident="${ident% * *}" # drop the timestamp and zone

# addIfDifferent: keep existing sign-offs from other people, add ours once.
git interpret-trailers --in-place --if-exists addIfDifferent \
--trailer "Signed-off-by: $name <$email>" "$msg_file"
--trailer "Signed-off-by: $ident" "$msg_file"
8 changes: 6 additions & 2 deletions src/devhost/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ The runner reads the hook's JSON on stdin (`session_id`, `transcript_path`, `cwd

- **scribe** (`SCRIBE_MODEL`, haiku): on `Stop` and `SubagentStop`, once 1500 new condensed characters are in (or a stake names it), and not more than once every 120 s unless four times that is new. Reads the new stretch of the transcript, the tail of the ledger and the units it held back last time; prints entries for finished units of work; the job fills each entry's Measures line with the cost from the transcripts (`devhost-costs --measures`; "not measured" when that tool is absent), passes everything through the name guard and appends to `$DEVHOST_LEDGER_DIR/YYYY-MM.md` (created with a `# Ledger, YYYY-MM` line). Append-only by construction: the model never touches the file. No guard needed.
- **secretary** (`SECRETARY_MODEL`, haiku): on `Stop` only, same thresholds. The model edits `$DEVHOST_BACKLOG` itself with `Read` and `Edit` on that one file (`Bash` off): adds items (`- [ ] **slug Title** (owner). ... Added YYYY-MM-DD.`) under Now/Next, decisions under `## Decisions for the Operator`, ticks finished items. The backlog guard (`guards.py backlog`) undoes a run that deleted, reworded or reordered a line; then the name guard runs on the file.
- **librarian** (`LIBRARIAN_MODEL`, sonnet): on `Stop` and `SubagentStop`, after 8000 new characters and ten minutes between runs (a stake wakes it). Edits `$DEVHOST_LEXICON` and `$DEVHOST_ADR_DIR/**` with `Read`, `Glob`, `Grep`, `Edit` (`Bash` off): new terms in the lexicon's style, new ADRs from the folder's `0000-template.md`, an index row in its `README.md`, a `superseded by` status on a replaced ADR. The docs guard (`guards.py docs`) restores the snapshot when an existing lexicon line or ADR changed beyond its Status row; the name guard runs on the lexicon and every new ADR.
- **tester** (`TESTER_MODEL`, none: cost 0 on its metrics line): on every `Stop`. Hashes the code under `TESTER_PATHS` (HEAD, the diff and untracked files; names, sizes and times outside git) and, when it changed since its last run, runs `TESTER_CMD` (`task test` when the root Taskfile has a `test` task; nothing otherwise, logged once) with `DEVHOST_JOB_CHILD=1` under `TESTER_TIMEOUT`. Speaks only when the suite is red (`tests red (...): N failed, first: ...`) or green again after red; a timed-out run keeps no hash and tries again after the next turn.
- **librarian** (`LIBRARIAN_MODEL`, sonnet): on `Stop` and `SubagentStop`, after 8000 new characters and ten minutes between runs for a session, after 8000 characters for a con's `SubagentStop` (`JOB_MIN_CHARS_SUB`; a stake wakes it). Edits `$DEVHOST_LEXICON` and `$DEVHOST_ADR_DIR/**` with `Read`, `Glob`, `Grep`, `Edit` (`Bash` off): new terms in the lexicon's style, new ADRs from the folder's `0000-template.md`, an index row in its `README.md`, a `superseded by` status on a replaced ADR. The docs guard (`guards.py docs`) restores the snapshot when an existing lexicon line or ADR changed beyond its Status row; the name guard runs on the lexicon and every new ADR.
- **tester** (`TESTER_MODEL`, none: cost 0 on its metrics line): on every `Stop`. Hashes the code under `TESTER_PATHS` (HEAD, the diff and untracked files; names, sizes and times outside git) and, when it changed since its last run, runs `TESTER_CMD` (`task test` when the root Taskfile has a `test` task; nothing otherwise, logged once) with `DEVHOST_JOB_CHILD=1` under `TESTER_TIMEOUT`. Speaks only when the suite is red (`tests red (...): N failed, first: ...`) or green again after red. A suite the environment cannot run (the Taskfile's "precondition not met" / "no Docker daemon here" output, or no Docker daemon for the default `task test`) is `skipped`, not red: one quiet notice when it first becomes skipped, then silence, and a previous red stays red (`last_real`); any other `TESTER_CMD` failure is red; a timed-out run keeps no hash and tries again after the next turn.

All four: one `claude -p --model <model> --settings '{"disableAllHooks": true}' --no-session-persistence --output-format json` run under a 540 s timeout (none for the tester), the stretch condensed by `devhost-condense` (the engineer's words, the inter's visible replies, which tools ran with their paths or commands, cons' hand-backs; never thinking or tool output; the latest 40 000 characters), the offset advanced only when the run ended `changed` or `nothing`, one metrics line per run in `runs.jsonl` (time, job, mode, agent type, session, engineer, model, stretch size, stakes, status, guard undos, duration, cost, tokens). Sub-agents of the kinds in `DEVHOST_JOB_SKIP_AGENTS` (read-only lookups) are not jobs.

## Commit-time guards

`devhost-verify` runs the guards listed in the docstring of `share/python/verify.py` (ledger append-only and shape, secrets, forbidden words, ADR shape, Taskfile descriptions, skill frontmatter, hook modes). `hook_modes` refuses any tracked file in `core.hooksPath` whose index mode is not 100755, naming the file and the fix (`git update-index --chmod=+x <file>` plus `chmod +x`); no hooks path, or an untracked one, passes quietly.

## State

`DEVHOST_STATE` (`/var/lib/devcontainer-features/devhost`, the named volume `devhost-state-${devcontainerId}`) holds `<job>/offsets/<session id>` (how far each back has read each transcript), `<job>/lock`, `<job>/job.log`, `<job>/pending-<session id>` and `since-<session id>` (the scribe), `tester/code_hash` and `last_result`, and `runs.jsonl`. Nothing of it belongs in the checkout, so a synced checkout never carries it and the ledger diff is only the entries. The post-create hook (`devhost-setup`, also `task devhost:setup`) creates the directory and hands it to the remote user through `dcf-persist`, and reports what backs it; for the host disk, bind-mount a host folder on the same target as the other Features of this collection do. `task devhost:log` tails every `job.log`; `task devhost:jobs:state` lists sessions read, pending items, held locks and the tester's last verdict.
Expand Down
8 changes: 6 additions & 2 deletions src/devhost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,15 @@ The runner reads the hook's JSON on stdin (`session_id`, `transcript_path`, `cwd

- **scribe** (`SCRIBE_MODEL`, haiku): on `Stop` and `SubagentStop`, once 1500 new condensed characters are in (or a stake names it), and not more than once every 120 s unless four times that is new. Reads the new stretch of the transcript, the tail of the ledger and the units it held back last time; prints entries for finished units of work; the job fills each entry's Measures line with the cost from the transcripts (`devhost-costs --measures`; "not measured" when that tool is absent), passes everything through the name guard and appends to `$DEVHOST_LEDGER_DIR/YYYY-MM.md` (created with a `# Ledger, YYYY-MM` line). Append-only by construction: the model never touches the file. No guard needed.
- **secretary** (`SECRETARY_MODEL`, haiku): on `Stop` only, same thresholds. The model edits `$DEVHOST_BACKLOG` itself with `Read` and `Edit` on that one file (`Bash` off): adds items (`- [ ] **slug Title** (owner). ... Added YYYY-MM-DD.`) under Now/Next, decisions under `## Decisions for the Operator`, ticks finished items. The backlog guard (`guards.py backlog`) undoes a run that deleted, reworded or reordered a line; then the name guard runs on the file.
- **librarian** (`LIBRARIAN_MODEL`, sonnet): on `Stop` and `SubagentStop`, after 8000 new characters and ten minutes between runs (a stake wakes it). Edits `$DEVHOST_LEXICON` and `$DEVHOST_ADR_DIR/**` with `Read`, `Glob`, `Grep`, `Edit` (`Bash` off): new terms in the lexicon's style, new ADRs from the folder's `0000-template.md`, an index row in its `README.md`, a `superseded by` status on a replaced ADR. The docs guard (`guards.py docs`) restores the snapshot when an existing lexicon line or ADR changed beyond its Status row; the name guard runs on the lexicon and every new ADR.
- **tester** (`TESTER_MODEL`, none: cost 0 on its metrics line): on every `Stop`. Hashes the code under `TESTER_PATHS` (HEAD, the diff and untracked files; names, sizes and times outside git) and, when it changed since its last run, runs `TESTER_CMD` (`task test` when the root Taskfile has a `test` task; nothing otherwise, logged once) with `DEVHOST_JOB_CHILD=1` under `TESTER_TIMEOUT`. Speaks only when the suite is red (`tests red (...): N failed, first: ...`) or green again after red; a timed-out run keeps no hash and tries again after the next turn.
- **librarian** (`LIBRARIAN_MODEL`, sonnet): on `Stop` and `SubagentStop`, after 8000 new characters and ten minutes between runs for a session, after 8000 characters for a con's `SubagentStop` (`JOB_MIN_CHARS_SUB`; a stake wakes it). Edits `$DEVHOST_LEXICON` and `$DEVHOST_ADR_DIR/**` with `Read`, `Glob`, `Grep`, `Edit` (`Bash` off): new terms in the lexicon's style, new ADRs from the folder's `0000-template.md`, an index row in its `README.md`, a `superseded by` status on a replaced ADR. The docs guard (`guards.py docs`) restores the snapshot when an existing lexicon line or ADR changed beyond its Status row; the name guard runs on the lexicon and every new ADR.
- **tester** (`TESTER_MODEL`, none: cost 0 on its metrics line): on every `Stop`. Hashes the code under `TESTER_PATHS` (HEAD, the diff and untracked files; names, sizes and times outside git) and, when it changed since its last run, runs `TESTER_CMD` (`task test` when the root Taskfile has a `test` task; nothing otherwise, logged once) with `DEVHOST_JOB_CHILD=1` under `TESTER_TIMEOUT`. Speaks only when the suite is red (`tests red (...): N failed, first: ...`) or green again after red. A suite the environment cannot run (the Taskfile's "precondition not met" / "no Docker daemon here" output, or no Docker daemon for the default `task test`) is `skipped`, not red: one quiet notice when it first becomes skipped, then silence, and a previous red stays red (`last_real`); any other `TESTER_CMD` failure is red; a timed-out run keeps no hash and tries again after the next turn.

All four: one `claude -p --model <model> --settings '{"disableAllHooks": true}' --no-session-persistence --output-format json` run under a 540 s timeout (none for the tester), the stretch condensed by `devhost-condense` (the engineer's words, the inter's visible replies, which tools ran with their paths or commands, cons' hand-backs; never thinking or tool output; the latest 40 000 characters), the offset advanced only when the run ended `changed` or `nothing`, one metrics line per run in `runs.jsonl` (time, job, mode, agent type, session, engineer, model, stretch size, stakes, status, guard undos, duration, cost, tokens). Sub-agents of the kinds in `DEVHOST_JOB_SKIP_AGENTS` (read-only lookups) are not jobs.

## Commit-time guards

`devhost-verify` runs the guards listed in the docstring of `share/python/verify.py` (ledger append-only and shape, secrets, forbidden words, ADR shape, Taskfile descriptions, skill frontmatter, hook modes). `hook_modes` refuses any tracked file in `core.hooksPath` whose index mode is not 100755, naming the file and the fix (`git update-index --chmod=+x <file>` plus `chmod +x`); no hooks path, or an untracked one, passes quietly.

## State

`DEVHOST_STATE` (`/var/lib/devcontainer-features/devhost`, the named volume `devhost-state-${devcontainerId}`) holds `<job>/offsets/<session id>` (how far each back has read each transcript), `<job>/lock`, `<job>/job.log`, `<job>/pending-<session id>` and `since-<session id>` (the scribe), `tester/code_hash` and `last_result`, and `runs.jsonl`. Nothing of it belongs in the checkout, so a synced checkout never carries it and the ledger diff is only the entries. The post-create hook (`devhost-setup`, also `task devhost:setup`) creates the directory and hands it to the remote user through `dcf-persist`, and reports what backs it; for the host disk, bind-mount a host folder on the same target as the other Features of this collection do. `task devhost:log` tails every `job.log`; `task devhost:jobs:state` lists sessions read, pending items, held locks and the tester's last verdict.
Expand Down
2 changes: 1 addition & 1 deletion src/devhost/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "devhost",
"version": "1.1.1",
"version": "1.2.3",
"name": "Devhost (host session, backs, ledger)",
"description": "The way of working of this collection's consumer repositories as an installable Feature: one host Claude Code session per repository (claude-host, from the claude-code Feature), cons (sub-agents) and backs - background agents that run from Claude Code's Stop and SubagentStop hooks after every turn and keep the ledger (scribe), the backlog (secretary) and the lexicon and ADRs (librarian) without the person waiting, plus a model-free tester that runs the suite when code changed. Installs devhost-run-job (the runner), the four jobs with their briefs and guards, the name guard (devhost-redact), the transcript condenser (devhost-condense), the briefing, cost report, ADR, verify and init tools, a named volume at DEVHOST_STATE (/var/lib/devcontainer-features/devhost) for the jobs' offsets, locks, logs and metrics, a post-create hook (devhost-setup) that makes it usable and reports whether the repository is wired, and the Feature Taskfile (task devhost:init scaffolds a repository). Every path and model is a DEVHOST_* / <JOB>_MODEL variable with a default. Needs python3 (standard library only; installed with apt when missing) and claude on PATH.",
"documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/devhost",
Expand Down
40 changes: 38 additions & 2 deletions src/devhost/share/bin/condense.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,42 @@ def clip(text, n=CLIP):
return text if len(text) <= n else text[:n] + " [...]"


HAND_HEAD = re.compile(r"^[#*_\s>-]*(Outcome|Outputs?|Friction|For the repository|Decisions needed)\b", re.I | re.M)
KEEP = ("friction", "for the repository", "decisions needed")
HANDOFF_WHOLE = 3000 # a hand-off this short is kept as it is
HANDOFF_REST = 8000 # the rest of a longer one, after its key sections


def handoff(text):
"""A hand-off, with Friction, For the repository and Decisions needed never cut.

Hand-offs put a long Outputs section first, so a plain clip loses exactly the sections the
ledger is for. Over HANDOFF_WHOLE chars those three sections come first, in full, then
the rest, clipped at HANDOFF_REST with a "[clipped N chars]" marker.
"""
text = text.strip()
if len(text) <= HANDOFF_WHOLE:
return text
marks = list(HAND_HEAD.finditer(text))
if not marks:
return clip_marked(text, HANDOFF_REST)
pieces = [text[:marks[0].start()]]
keep, rest = [], [pieces[0]]
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
(keep if m.group(1).lower() in KEEP else rest).append(text[m.start():end])
rest = "".join(rest).strip()
if not keep:
return clip_marked(text, HANDOFF_REST)
out = "\n".join(k.strip() for k in keep)
return out + "\n[rest of the hand-off, key sections above]\n" + clip_marked(rest, HANDOFF_REST)


def clip_marked(text, n):
text = text.strip()
return text if len(text) <= n else text[:n] + f" [clipped {len(text) - n} chars]"


def blocks(content):
if isinstance(content, str):
return [{"type": "text", "text": content}]
Expand All @@ -44,7 +80,7 @@ def tool_line(block, answerer):
name = block.get("name", "?")
inp = block.get("input") or {}
if name == "SubagentHandback":
return f"{answerer} FINAL REPLY: " + clip(str(inp.get("message", "")), 3000)
return f"{answerer} FINAL REPLY: " + handoff(str(inp.get("message", "")))
for key in ("description", "file_path", "command", "skill", "prompt"):
if key in inp and isinstance(inp[key], str):
return f"[tool {name}: {clip(inp[key], 200)}]"
Expand Down Expand Up @@ -78,7 +114,7 @@ def handbacks(text):
hb = re.sub(r"\n\s+", "\n", hb).strip()
if hb not in seen:
seen.add(hb)
out.append("CON REPLY: " + clip(hb, 2500))
out.append("CON REPLY: " + handoff(hb))

for raw in data.splitlines():
try:
Expand Down
21 changes: 17 additions & 4 deletions src/devhost/share/bin/run-job.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,17 @@ mkdir -p "$state/offsets" 2>/dev/null || { echo "devhost-run-job: cannot write $
log() { printf '%s %s %s\n' "$(date -u +%FT%TZ)" "$SID" "$*" >>"$state/job.log"; }

exec 9>"$state/lock"
flock -n 9 || exit 0 # a run of this job is busy; the next turn catches up
# A con's SubagentStop fires once and its stretch is never offered again by a later event
# of the same con, so a run lost to the lock would lose its entry for good: wait for the
# lock, bounded (DEVHOST_LOCK_WAIT, 600 s, longer than the 540 s a back run may take, and a hook that
# waits blocks nothing, it is async). A Stop (host turn) does not wait: its offset has not
# moved, so the next turn reads the same stretch again, and waiting runs would pile up behind
# a slow one. Every skipped run says so in the job log.
if [ "$EVENT" = "SubagentStop" ]; then
flock -w "${DEVHOST_LOCK_WAIT:-600}" 9 || { log "skipped: lock busy, $EVENT $SID"; exit 0; }
else
flock -n 9 || { log "skipped: lock busy, $EVENT $SID"; exit 0; }
fi

offset="$(cat "$state/offsets/$SID" 2>/dev/null || echo 0)"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
Expand Down Expand Up @@ -111,7 +121,7 @@ job_claude() {
# stdin. Backs run it on everything they write.
redact() { DEVHOST_ROOT="$ROOT" python3 "$BIN/redact.py" "$@"; }

JOB_STATUS=error; JOB_NOTICE=""; JOB_PENDING=""; JOB_UNDONE=0
JOB_STATUS=error; JOB_NOTICE=""; JOB_PENDING=""; JOB_UNDONE=0; JOB_ADVANCE=0
started="$(date +%s.%N)"
job_run "$TMP/stretch" "$TMP"
log "status=$JOB_STATUS model=$MODEL size=$size notice=$JOB_NOTICE${JOB_PENDING:+ pending=$JOB_PENDING}"
Expand Down Expand Up @@ -155,9 +165,12 @@ case "$JOB_STATUS" in
nothing)
echo "$new_offset" >"$state/offsets/$SID"; exit 0 ;;
pending)
# A job that undid its own run and set JOB_ADVANCE marks the stretch handled, so the same
# stretch is not retried every turn; the notice still says what was undone.
[ "$JOB_ADVANCE" = 1 ] && echo "$new_offset" >"$state/offsets/$SID"
python3 -c 'import json,sys; print(json.dumps({"hookSpecificOutput": {"hookEventName": sys.argv[3],
"additionalContext": "Back " + sys.argv[1] + " (quiet notice): still pending, not yet done: " + sys.argv[2]}}))' \
"$JOB_NAME" "${JOB_PENDING:-$JOB_NOTICE}" "$event"
"additionalContext": "Back " + sys.argv[1] + " (quiet notice): " + sys.argv[2]}}))' \
"$JOB_NAME" "$( [ "$JOB_ADVANCE" = 1 ] && echo "$JOB_NOTICE (the stretch is marked read, the request is not retried)" || echo "still pending, not yet done: ${JOB_PENDING:-$JOB_NOTICE}" )" "$event"
exit 0 ;;
*) exit 0 ;;
esac
Loading
Loading