From 9f6fd6ce5e4216c1c63408c82e2ae18aff496d40 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 11:02:55 +0000 Subject: [PATCH 1/8] fix: the sign-off hook signs as the committer, like commit -s (bot-git-identity) It read user.name and user.email, so a rebase or cherry-pick under GIT_COMMITTER_* added a second sign-off pairing the global name with the repository-local address. git var GIT_COMMITTER_IDENT honours the environment and the config alike. Co-Authored-By: Claude Opus 5.5 Signed-off-by: devhost agent --- .githooks/prepare-commit-msg | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg index cab066d..faf4038 100755 --- a/.githooks/prepare-commit-msg +++ b/.githooks/prepare-commit-msg @@ -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" From bc1a8fed7d813376a75222dfa8b55f160fee52d0 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 01:58:15 +0000 Subject: [PATCH 2/8] fix(devhost): run devhost tasks in the caller's directory; a skipped guard is loud Tasks of an included Taskfile run in the include's folder, so devhost:verify ran against /usr/local/share/go-task/includes.d: no repository, no base ref, append-only guard skipped. Set dir to USER_WORKING_DIR on every task that works on the current repository. A skipped append-only guard now prints SKIP and a WARNING on stderr, also under --quiet. Bump devhost to 1.1.2. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: devhost agent --- src/devhost/devcontainer-feature.json | 2 +- src/devhost/share/python/verify.py | 15 ++++++++++---- src/devhost/taskfile.yml | 7 +++++++ test/devhost/python/test_verify.py | 29 ++++++++++++++++++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/devhost/devcontainer-feature.json b/src/devhost/devcontainer-feature.json index 62a973a..39c5f33 100644 --- a/src/devhost/devcontainer-feature.json +++ b/src/devhost/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devhost", - "version": "1.1.1", + "version": "1.1.2", "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_* / _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", diff --git a/src/devhost/share/python/verify.py b/src/devhost/share/python/verify.py index 026754f..9649eb3 100644 --- a/src/devhost/share/python/verify.py +++ b/src/devhost/share/python/verify.py @@ -63,12 +63,15 @@ def text_files(root: Path) -> list[Path]: # The ledger +SKIPPED_NOTE = ("ledger append-only guard NOT RUN: no base ref resolved (DEVHOST_BASE_REF, origin/main, main, " + "origin/develop, develop); this is not a full pass") + def ledger_append_only(root: Path, notes: list[str] | None = None) -> list[str]: base = base_ref(root) if base is None: if notes is not None: - notes.append("ledger append-only guard skipped: no base ref (DEVHOST_BASE_REF, origin/main, main, origin/develop, develop)") + notes.append(SKIPPED_NOTE) return [] folder = ledger_dir(root) names = {p.name for p in folder.glob("*.md") if MONTHLY.match(p.name)} if folder.is_dir() else set() @@ -281,16 +284,20 @@ def main(argv: list[str]) -> int: print(f"FAIL {name}") for finding in findings: print(f" {finding}") + elif SKIPPED_NOTE in notes and name == "ledger_append_only": + print(f"SKIP {name}", file=sys.stderr) elif not quiet: print(f"ok {name}") - if not quiet: - for note in notes: + for note in notes: + if note == SKIPPED_NOTE: # --quiet hides passes, never a guard that did not run + print(f"WARNING {note}", file=sys.stderr) + elif not quiet: print(f"note {note}") if failed: print(f"devhost-verify: {failed} finding(s)") return 1 if not quiet: - print("devhost-verify: all guards passed") + print("devhost-verify: all guards passed" + (" (except the skipped guard above)" if SKIPPED_NOTE in notes else "")) return 0 diff --git a/src/devhost/taskfile.yml b/src/devhost/taskfile.yml index d9a8929..7ee5410 100644 --- a/src/devhost/taskfile.yml +++ b/src/devhost/taskfile.yml @@ -14,6 +14,7 @@ tasks: echo "claude : $(command -v claude >/dev/null 2>&1 && claude --version 2>/dev/null | head -n1 || echo 'not on PATH')" setup: + dir: '{{.USER_WORKING_DIR}}' desc: 'Re-run the post-create setup: create DEVHOST_STATE (by default the Feature''s named volume), hand it to the current user and report whether the repository in the current directory is wired (hooks, ledger). Idempotent.' cmds: - devhost-setup @@ -54,12 +55,14 @@ tasks: echo "runs.jsonl: $(wc -l < "$state/runs.jsonl" 2>/dev/null || echo 0) lines" briefing: + dir: '{{.USER_WORKING_DIR}}' desc: 'Print the briefing an inter gets at session start: where the records stand (ledger entries since the last review, open backlog items, pending decisions). No model.' silent: true cmds: - devhost-briefing report: + dir: '{{.USER_WORKING_DIR}}' desc: 'Write the monthly cost report for MONTH (YYYY-MM, default the current month) from the transcripts and the backs'' metrics lines.' vars: MONTH: '{{.MONTH | default ""}}' @@ -67,22 +70,26 @@ tasks: - devhost-report {{.MONTH}} costs: + dir: '{{.USER_WORKING_DIR}}' desc: 'What the newest session cost, by model and by con, from Claude Code''s own transcripts (list-price estimate); SINCE= limits it.' silent: true cmds: - devhost-costs {{if .SINCE}}--since {{.SINCE}}{{end}} adr: + dir: '{{.USER_WORKING_DIR}}' desc: 'Create or list architecture decision records in DEVHOST_ADR_DIR; arguments after --, e.g. task devhost:adr -- new "Short title".' cmds: - devhost-adr {{.CLI_ARGS}} verify: + dir: '{{.USER_WORKING_DIR}}' desc: 'Run the repository checks the way of working relies on: the ledger append-only, no forbidden word or redacted name in what is committed, the records in shape.' cmds: - devhost-verify init: + dir: '{{.USER_WORKING_DIR}}' desc: 'Scaffold the devhost way of working in the repository in the current directory: the hooks in .claude/settings.json, the ledger folder, the backlog, the lexicon and the ADR folder (existing files are kept). Arguments after --.' interactive: true cmds: diff --git a/test/devhost/python/test_verify.py b/test/devhost/python/test_verify.py index 117faee..c27f7f1 100644 --- a/test/devhost/python/test_verify.py +++ b/test/devhost/python/test_verify.py @@ -43,7 +43,34 @@ def test_appending_an_entry_is_fine_and_no_base_ref_is_a_note(self): with h.devhost_env(self.root, DEVHOST_BASE_REF="no-such-branch"): self.assertEqual(self.v.ledger_append_only(self.root, notes), []) self.assertEqual(len(notes), 1) - self.assertIn("skipped", notes[0]) + self.assertIn("NOT RUN", notes[0]) + + def test_the_append_only_guard_runs_when_the_base_ref_exists(self): + notes = [] + self.assertEqual(self.v.ledger_append_only(self.root, notes), []) + self.assertEqual(notes, []) + done = h.run(["verify"], self.root, self.env) + self.assertIn("ok ledger_append_only", done.stdout) + self.assertNotIn("SKIP", done.stderr) + self.assertNotIn("NOT RUN", done.stdout + done.stderr) + + def test_a_skipped_guard_is_loud_even_when_quiet(self): + env = dict(self.env, DEVHOST_BASE_REF="no-such-branch") + for args in (["verify"], ["verify", "--quiet"]): + done = h.run(args, self.root, env) + self.assertEqual(done.returncode, 0, done.stdout) + self.assertIn("WARNING ledger append-only guard NOT RUN", done.stderr) + self.assertNotIn("ok ledger_append_only", done.stdout) + self.assertIn("SKIP ledger_append_only", h.run(["verify"], self.root, env).stderr) + + def test_the_task_runs_in_the_directory_it_was_called_from(self): + """The root cause: an included Taskfile's tasks run in the include's folder, outside the repository.""" + import re + from pathlib import Path + text = (Path(h.__file__).resolve().parents[3] / "src" / "devhost" / "taskfile.yml").read_text(encoding="utf-8") + for name in ("setup", "briefing", "report", "costs", "adr", "verify", "init"): + block = re.search(rf"^ {name}:\n(.*?)(?=^ \w|\Z)", text, re.S | re.M).group(1) + self.assertIn("dir: '{{.USER_WORKING_DIR}}'", block, name) def test_ledger_shape(self): folder = self.root / "docs" / "ledger" From d48bc5d7b2f2406bd249f4e59dee9af7c7e24507 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 02:02:34 +0000 Subject: [PATCH 3/8] fix(devhost): backs keep friction, never drop a job silently, amend instead of reword - condense: a con's hand-off keeps Friction, For the repository and Decisions needed whole and first; the rest is clipped at 8000 chars with a [clipped N chars] marker. - run-job: SubagentStop runs wait for the job lock (DEVHOST_LOCK_WAIT, 180 s); every run skipped for the lock is logged. - scribe brief: Friction: not reported when the hand-off has none or was clipped. - secretary brief: a stake to change an item adds an amend- item; a tick only ticks. - backlog guard: the finding names the line and the rule, the job log carries the capped diff, and an undone run marks its stretch read so it is not retried every turn. - templates: cons report worked-around friction. Feature version 1.2.0. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: devhost agent --- src/devhost/devcontainer-feature.json | 2 +- src/devhost/share/bin/condense.py | 40 ++++++++++++- src/devhost/share/bin/run-job.sh | 21 +++++-- src/devhost/share/jobs/scribe/brief.md | 4 ++ src/devhost/share/jobs/secretary/brief.md | 15 ++++- src/devhost/share/jobs/secretary/job.sh | 8 ++- src/devhost/share/python/guards.py | 21 ++++++- src/devhost/share/python/templates/PROCESS.md | 1 + .../templates/devhost/CLAUDE.devhost.md | 5 +- test/devhost/functional.sh | 42 +++++++++++++ test/devhost/python/test_condense.py | 59 +++++++++++++++++++ test/devhost/python/test_guards.py | 29 ++++++++- 12 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 test/devhost/python/test_condense.py diff --git a/src/devhost/devcontainer-feature.json b/src/devhost/devcontainer-feature.json index 39c5f33..e009df7 100644 --- a/src/devhost/devcontainer-feature.json +++ b/src/devhost/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devhost", - "version": "1.1.2", + "version": "1.2.0", "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_* / _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", diff --git a/src/devhost/share/bin/condense.py b/src/devhost/share/bin/condense.py index f08820b..e90082b 100644 --- a/src/devhost/share/bin/condense.py +++ b/src/devhost/share/bin/condense.py @@ -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}] @@ -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)}]" @@ -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: diff --git a/src/devhost/share/bin/run-job.sh b/src/devhost/share/bin/run-job.sh index eea70f0..10a7e0d 100644 --- a/src/devhost/share/bin/run-job.sh +++ b/src/devhost/share/bin/run-job.sh @@ -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 @@ -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}" @@ -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 diff --git a/src/devhost/share/jobs/scribe/brief.md b/src/devhost/share/jobs/scribe/brief.md index 60b3ed3..544c085 100644 --- a/src/devhost/share/jobs/scribe/brief.md +++ b/src/devhost/share/jobs/scribe/brief.md @@ -32,6 +32,10 @@ Rules: - Friction is what was missing, wrong or had to be worked out, each item with its own tag: [tooling], [docs], [skill: ], [skill: new], [plan] or [process]; "none" when there was nothing. Never a pointer ("see the reply") and never "unknown". + "none" only when the con or the inter explicitly reported none. A con's hand-off with no + Friction section, or with a "[clipped N chars]" marker before where its Friction would be, + gets `Friction: not reported`, never "none". A con's worked-around problems (a command it + retried another way) are friction too. - Decisions are what the Operator decided, in their words where short, with spelling and grammar corrected silently; "none" otherwise. A decision still to be made goes on "Decisions needed". diff --git a/src/devhost/share/jobs/secretary/brief.md b/src/devhost/share/jobs/secretary/brief.md index 502d19f..b8f29e8 100644 --- a/src/devhost/share/jobs/secretary/brief.md +++ b/src/devhost/share/jobs/secretary/brief.md @@ -23,10 +23,19 @@ Read the backlog first (Read tool). Then, with the Edit tool: 3. **Tick** an open item the stretch clearly finished: change its `- [ ]` to `- [x]` and add ` Done YYYY-MM-DD.` at the end of its last line. Only when it is clearly done. 4. A line beginning **STAKE for secretary** is an explicit request from an inter or an - engineer: act on it. A stake addressed to another back is not yours. + engineer: act on it, within the rule below. A stake addressed to another back is not yours. + A stake that asks to change an existing item (reword it, fix it, narrow it, reorder it) must + NOT edit that item: add a new item instead, `- [ ] **amend- Amend : ** + (host). One to three lines of the change wanted. Added YYYY-MM-DD.` (under Decisions for the + Operator, owner the Operator, when the stake is the Operator's own decision to record). The + inter or the Operator applies the amendment. -Never delete, reword, merge or reorder existing lines; a script undoes any run that does. That -includes re-wrapping: when a tick's ` Done YYYY-MM-DD.` makes the last line long, leave it long. +A tick changes `- [ ]` to `- [x]` and appends ` Done YYYY-MM-DD.` at the very end of the item's +last line; nothing else on the line changes (no new wording, no re-wrapping). + +Never delete, reword, merge or reorder existing lines, whatever a stake says; a script undoes any +run that does. That includes re-wrapping: when a tick's ` Done YYYY-MM-DD.` makes the last line +long, leave it long. Add a new item as its own lines between items, never inside one. If an existing item is wrong or a duplicate, say so on the PENDING line for the inter. Paths and ids, never content; never a person's name (the person an inter works with is "the Operator"), never a client's name, diff --git a/src/devhost/share/jobs/secretary/job.sh b/src/devhost/share/jobs/secretary/job.sh index a307473..4581360 100644 --- a/src/devhost/share/jobs/secretary/job.sh +++ b/src/devhost/share/jobs/secretary/job.sh @@ -27,8 +27,12 @@ job_run() { log "no backlog guard installed (share/python/guards.py); edit undone" JOB_STATUS=pending; JOB_NOTICE="the backlog guard is not installed, so the secretary's edit was undone" else - log "edit deleted, reworded or reordered lines; undone ($(head -c 200 "$tmp/guard"))" - JOB_STATUS=pending; JOB_NOTICE="a ${todo#"$ROOT"/} edit that changed existing lines was undone" + # The guard prints the finding (the line and the rule) first, then the rejected diff. + log "edit changed existing lines; undone: $(head -n1 "$tmp/guard" | head -c 400)" + sed -n '2,$p' "$tmp/guard" | head -c 1600 | sed 's/^/ | /' >>"$state/job.log" + # Handled: an undone stake is not retried every turn; the notice says it was undone. + JOB_STATUS=pending; JOB_ADVANCE=1 + JOB_NOTICE="a ${todo#"$ROOT"/} edit was undone ($(head -n1 "$tmp/guard" | head -c 300)). To change an existing item, add an amend- item instead" fi return fi diff --git a/src/devhost/share/python/guards.py b/src/devhost/share/python/guards.py index 9072a62..1eca0e7 100644 --- a/src/devhost/share/python/guards.py +++ b/src/devhost/share/python/guards.py @@ -19,10 +19,11 @@ whose status column may change; new rows and new files may be added. New ADR files are not this guard's business: `devhost-verify` checks their shape. -The CLI exits 1 and prints the finding, so the job can restore the snapshot and log it. +The CLI exits 1 and prints the finding (backlog: then the rejected diff), so the job can restore the snapshot and log it. """ from __future__ import annotations +import difflib import re import sys from pathlib import Path @@ -54,7 +55,16 @@ def same_or_ticked(old: str, new: str) -> bool: def backlog_guard(before: list[str], after: list[str]) -> str | None: lost = lost_line(before, after, same_or_ticked) - return None if lost is None else f"changed or lost: {lost.strip()[:160]}" + if lost is None: + return None + return (f"rule: an existing line may only be ticked ([ ] to [x], ' Done .' appended), " + f"never reworded, removed or reordered; changed or lost: {lost.strip()[:160]}") + + +def rejected_diff(before: list[str], after: list[str], cap: int = 1500) -> str: + """The unified diff of a refused run, capped, for the job log.""" + text = "\n".join(difflib.unified_diff(before, after, "before", "after", lineterm="", n=0)) + return text if len(text) <= cap else text[:cap] + f"\n[diff clipped, {len(text) - cap} chars more]" def status_change(old: str, new: str) -> bool: @@ -95,7 +105,12 @@ def main(argv: list[str]) -> int: print(__doc__) return 2 if argv[0] == "backlog": - finding = backlog_guard(lines(Path(argv[1])), lines(Path(argv[2]))) + before, after = lines(Path(argv[1])), lines(Path(argv[2])) + finding = backlog_guard(before, after) + if finding: + print(finding) + print(rejected_diff(before, after)) + return 1 else: finding = docs_guard(Path(argv[1]), Path(argv[2])) if finding: diff --git a/src/devhost/share/python/templates/PROCESS.md b/src/devhost/share/python/templates/PROCESS.md index 6fdea90..15efdc5 100644 --- a/src/devhost/share/python/templates/PROCESS.md +++ b/src/devhost/share/python/templates/PROCESS.md @@ -36,6 +36,7 @@ The words are defined in [LEXICON.md](LEXICON.md). What is open is in [TODO.md]( mechanism, a draft. Each brief names the job, what is out of bounds, the model (sized by blast radius) and the isolation (a worktree when the job changes shared tooling); each con replies once in the hand-off shape (Outcome, Outputs, Friction, For the repository, Decisions needed), + its Friction including what it worked around (a command retried another way), not only what blocked it, and its job gets its own ledger entry. - **Backs** never talk to anyone. After each inter turn and each con's job they read what was done and keep one record each: the **scribe** the ledger, the **secretary** `TODO.md`, the diff --git a/src/devhost/share/python/templates/devhost/CLAUDE.devhost.md b/src/devhost/share/python/templates/devhost/CLAUDE.devhost.md index f5899e2..4f763a8 100644 --- a/src/devhost/share/python/templates/devhost/CLAUDE.devhost.md +++ b/src/devhost/share/python/templates/devhost/CLAUDE.devhost.md @@ -11,8 +11,9 @@ Included from CLAUDE.md. The words are in [LEXICON.md](../LEXICON.md), the proce backs left pending, the open backlog items and decisions, the month's agent spend. Read `TODO.md`, then branch; never commit to the base branch directly. 2. **Work:** commands through the Taskfile (`task --list`); propose rather than decide where the - Operator's confirmation is needed; brief subagents (cons) with the hand-off shape and size - their models by blast radius; a permission counts only when the Operator grants it in their + Operator's confirmation is needed; brief subagents (cons) with the hand-off shape (its Friction + includes problems the con worked around, such as a command it retried another way, not only + what blocked it) and size their models by blast radius; a permission counts only when the Operator grants it in their own settings. Unattended work only under `/night-shift`. 3. **Close:** `/close-session`: the records checked (the scribe's entries, the backlog, the lexicon, the ADRs), `task devhost:verify` green, a Conventional Commit on a topic branch, the diff --git a/test/devhost/functional.sh b/test/devhost/functional.sh index e1afd5c..39987a9 100644 --- a/test/devhost/functional.sh +++ b/test/devhost/functional.sh @@ -22,6 +22,7 @@ devhost_functional() { n=$(ls "$FAKE_DIR"/prompt-*.txt 2>/dev/null | wc -l) printf '%s\n' "$@" > "$FAKE_DIR/args-$n.txt" cat > "$FAKE_DIR/prompt-$n.txt" +[ -f "$FAKE_DIR/edit.sh" ] && bash "$FAKE_DIR/edit.sh" python3 -c 'import json,os; print(json.dumps({"result": open(os.environ["FAKE_DIR"] + "/answer").read(), "total_cost_usd": 0.0012, "num_turns": 2, "usage": {"input_tokens": 100, "output_tokens": 50, "cache_read_input_tokens": 10, "cache_creation_input_tokens": 5}}))' STUB chmod 0755 "$tmp/bin/claude" @@ -65,6 +66,14 @@ PY PATH="$tmp/bin:$PATH" FAKE_DIR="$tmp" DEVHOST_STATE="$state" DEVHOST_CONTAINER_MARKERS="$marker" \ DEVHOST_ENGINEER=tester DEVHOST_JOB_QUIET_SECS=0 "$@" devhost-run-job "$job" <<<"$payload") } + run_sub() { # [VAR=value ...] -> a SubagentStop run of the same fake transcript + local job="$1" aid="$2"; shift 2 + local payload + payload="$(printf '{"session_id": "host", "transcript_path": "%s", "cwd": "%s", "hook_event_name": "SubagentStop", "agent_id": "%s", "agent_transcript_path": "%s", "agent_type": "general-purpose"}' "$tmp/s1.jsonl" "$root" "$aid" "$tmp/s1.jsonl")" + (cd "$root" && env -u DEVHOST_ROOT -u CLAUDE_PROJECT_DIR -u DEVHOST_JOB_CHILD \ + PATH="$tmp/bin:$PATH" FAKE_DIR="$tmp" DEVHOST_STATE="$state" DEVHOST_CONTAINER_MARKERS="$marker" \ + DEVHOST_ENGINEER=tester DEVHOST_JOB_QUIET_SECS=0 "$@" devhost-run-job "$job" <<<"$payload") + } calls() { find "$tmp" -maxdepth 1 -name 'args-*.txt' | wc -l | tr -d ' '; } # 1. A first run: the stub is called, an entry lands in the ledger, state is written, the @@ -114,5 +123,38 @@ PY check "tester is quiet without a code change" test -z "$out" check "tester never called claude" test "$(calls)" = 1 + # 6. The lock. A busy lock never drops a run silently: a Stop run (non-blocking) logs the + # skip; a SubagentStop run waits for the lock and then does its job. + before_calls="$(calls)" + ( flock "$state/scribe/lock" sleep 4 ) & + sleep 0.5 + out="$(run_job scribe s9)" + check "a Stop run under a busy lock skips and says so in the job log" bash -c "[ '$(calls)' = '$before_calls' ] && grep -q 'skipped: lock busy, Stop s9' '$state/scribe/job.log'" + out="$(run_sub scribe a1 DEVHOST_LOCK_WAIT=20)" + check "a SubagentStop run waits for the lock and runs" bash -c "[ '$(calls)' = '$((before_calls + 1))' ] && ! grep -q 'skipped: lock busy, SubagentStop' '$state/scribe/job.log'" + ( flock "$state/scribe/lock" sleep 4 ) & + sleep 0.5 + out="$(run_sub scribe a2 DEVHOST_LOCK_WAIT=1)" + check "a SubagentStop run past its wait logs the skip" grep -q 'skipped: lock busy, SubagentStop a2' "$state/scribe/job.log" + wait + + # 7. The secretary's edit is refused by the backlog guard (an existing line reworded): the + # file is restored, the diff and the rule reach the job log, the notice says it was + # undone, and the stretch is marked read, so the next turn does not retry it. + before_calls="$(calls)" + cat > "$tmp/edit.sh" <<'EDIT' +sed -i 's/First/First reworded/' TODO.md +EDIT + cp "$root/TODO.md" "$tmp/todo.orig" + run_job secretary sec1 >"$tmp/secretary.out" + check "the secretary called claude" test "$(calls)" = "$((before_calls + 1))" + check "the refused edit is undone" cmp -s "$tmp/todo.orig" "$root/TODO.md" + check "the job log carries the rule and the rejected diff" bash -c "grep -q 'edit changed existing lines; undone: rule: an existing line may only be ticked' '$state/secretary/job.log' && grep -q '^ | +- \[ \] \*\*first-item First reworded' '$state/secretary/job.log'" + check "the notice says it was undone and names the rule" bash -c "grep -q 'edit was undone' '$tmp/secretary.out' && grep -q 'first-item' '$tmp/secretary.out' && grep -q 'rule: an existing line' '$tmp/secretary.out'" + check "the undone stretch is marked read" test -s "$state/secretary/offsets/sec1" + out="$(run_job secretary sec1)" + check "the same stretch is not retried" bash -c "[ '$(calls)' = '$((before_calls + 1))' ] && [ -z '$out' ]" + rm -f "$tmp/edit.sh" + rm -rf "$tmp" } diff --git a/test/devhost/python/test_condense.py b/test/devhost/python/test_condense.py new file mode 100644 index 0000000..7350272 --- /dev/null +++ b/test/devhost/python/test_condense.py @@ -0,0 +1,59 @@ +"""condense.py: a con's hand-off keeps Friction, For the repository and Decisions needed whole.""" +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +CONDENSE = Path(__file__).resolve().parents[3] / "src" / "devhost" / "share" / "bin" / "condense.py" + + +def condense(records: list[dict], mode: str = "session") -> str: + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "t.jsonl" + path.write_text("".join(json.dumps(r) + "\n" for r in records), encoding="utf-8") + done = subprocess.run([sys.executable, str(CONDENSE), str(path), "0", mode], capture_output=True, text=True, check=True) + return done.stdout + + +def handback(message: str) -> dict: + return {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "tool_use", "name": "SubagentHandback", "input": {"message": message}}]}} + + +def handoff(outputs: int, rest_after: str = "") -> str: + return ("Outcome: done.\n\nOutputs:\n" + "- a/b/c.txt changed\n" * outputs + + "\nFriction: [tooling] the retry of the failed command worked around a missing flag.\n" + "For the repository: [docs] say so.\nDecisions needed: pick A or B.\n" + rest_after) + + +class Handoff(unittest.TestCase): + def test_friction_after_char_3000_is_kept_whole(self): + msg = handoff(250) + self.assertGreater(msg.index("Friction:"), 3000) + out = condense([handback(msg)], "sub") + self.assertIn("Friction: [tooling] the retry of the failed command worked around a missing flag.", out) + self.assertIn("For the repository: [docs] say so.", out) + self.assertIn("Decisions needed: pick A or B.", out) + self.assertLess(out.index("Friction:"), out.index("Outputs:"), "key sections come first") + + def test_the_rest_is_clipped_with_a_marker(self): + out = condense([handback(handoff(1000))], "sub") + self.assertRegex(out, r"\[clipped \d+ chars\]") + self.assertIn("Friction: [tooling]", out) + + def test_a_short_handoff_is_untouched(self): + out = condense([handback("Outcome: ok.\nFriction: none.")], "sub") + self.assertIn("FINAL REPLY: Outcome: ok.\nFriction: none.", out) + self.assertNotIn("clipped", out) + + def test_a_long_handoff_without_headings_is_clipped_with_a_marker(self): + out = condense([handback("x" * 9000)], "sub") + self.assertIn("[clipped 1000 chars]", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/devhost/python/test_guards.py b/test/devhost/python/test_guards.py index a5c78d7..ae0841d 100644 --- a/test/devhost/python/test_guards.py +++ b/test/devhost/python/test_guards.py @@ -24,21 +24,44 @@ def test_a_tick_and_new_lines_are_fine(self): def test_a_reorder_a_reword_and_a_removal_are_refused(self): reordered = BACKLOG[:4] + [BACKLOG[5], BACKLOG[4]] + BACKLOG[6:] # first-item is found after second-item; second-item is then the line that cannot follow it, so it is reported - self.assertEqual(self.g.backlog_guard(BACKLOG, reordered), "changed or lost: " + BACKLOG[5]) + self.assertTrue(self.g.backlog_guard(BACKLOG, reordered).endswith("changed or lost: " + BACKLOG[5])) reworded = BACKLOG[:5] + ["- [ ] **second-item Two reworded** (host). Why. Added 2026-10-01."] + BACKLOG[6:] - self.assertEqual(self.g.backlog_guard(BACKLOG, reworded), "changed or lost: " + BACKLOG[5]) + self.assertTrue(self.g.backlog_guard(BACKLOG, reworded).endswith("changed or lost: " + BACKLOG[5])) removed = BACKLOG[:5] + BACKLOG[6:] self.assertIsNotNone(self.g.backlog_guard(BACKLOG, removed)) heading_suffixed = BACKLOG[:2] + ["## Now and then"] + BACKLOG[3:] self.assertIsNotNone(self.g.backlog_guard(BACKLOG, heading_suffixed), "a heading may not grow a suffix") + def test_the_finding_names_the_line_and_the_rule(self): + reworded = BACKLOG[:5] + ["- [ ] **second-item Two reworded** (host). Why. Added 2026-10-01."] + BACKLOG[6:] + finding = self.g.backlog_guard(BACKLOG, reworded) + self.assertIn("rule: an existing line may only be ticked", finding) + self.assertIn("**second-item Two**", finding) + + def test_the_cli_prints_the_finding_then_a_capped_diff(self): + before, after = self.base / "b.md", self.base / "a.md" + h.write(before, "\n".join(BACKLOG) + "\n") + reworded = BACKLOG[:5] + ["- [ ] **second-item Two reworded** (host). Why. Added 2026-10-01."] + BACKLOG[6:] + h.write(after, "\n".join(reworded) + "\n") + done = h.run(["guards", "backlog", str(before), str(after)], self.root, self.env) + self.assertEqual(done.returncode, 1) + first, _, diff = done.stdout.partition("\n") + self.assertIn("rule:", first) + self.assertIn("-- [ ] **second-item Two** (host)", diff) + self.assertIn("+- [ ] **second-item Two reworded**", diff) + + def test_the_diff_is_capped(self): + diff = self.g.rejected_diff(["a"], ["b" * 5000], cap=1500) + self.assertLess(len(diff), 1700) + self.assertIn("[diff clipped", diff) + def test_the_cli_exits_one_on_a_change(self): before, after = self.base / "before.md", self.base / "after.md" h.write(before, "\n".join(BACKLOG) + "\n") h.write(after, "\n".join(BACKLOG[:5] + BACKLOG[6:]) + "\n") done = h.run(["guards", "backlog", str(before), str(after)], self.root, self.env) self.assertEqual(done.returncode, 1) - self.assertTrue(done.stdout.startswith("changed or lost: - [ ] **second-item")) + self.assertTrue("changed or lost: - [ ] **second-item" in done.stdout) h.write(after, "\n".join(BACKLOG) + "\n") self.assertEqual(h.run(["guards", "backlog", str(before), str(after)], self.root, self.env).returncode, 0) self.assertEqual(h.run(["guards", "nonsense", "a", "b"], self.root, self.env).returncode, 2) From d8cfbbf7eb75aa46fe316d8a6d21450d0aa589e2 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 02:39:41 +0000 Subject: [PATCH 4/8] feat(devhost): verify refuses a non-executable hook (hook-mode-guard) A hook committed as 100644 never runs on Linux, and core.fileMode=false on Windows hides it. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: devhost agent --- src/devhost/NOTES.md | 4 ++++ src/devhost/README.md | 4 ++++ src/devhost/devcontainer-feature.json | 2 +- src/devhost/share/python/verify.py | 19 ++++++++++++++++++- test/devhost/python/test_verify.py | 20 ++++++++++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/devhost/NOTES.md b/src/devhost/NOTES.md index 3dbee7e..283d775 100644 --- a/src/devhost/NOTES.md +++ b/src/devhost/NOTES.md @@ -48,6 +48,10 @@ The runner reads the hook's JSON on stdin (`session_id`, `transcript_path`, `cwd All four: one `claude -p --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 ` 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 `/offsets/` (how far each back has read each transcript), `/lock`, `/job.log`, `/pending-` and `since-` (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. diff --git a/src/devhost/README.md b/src/devhost/README.md index 540355c..b72aed6 100644 --- a/src/devhost/README.md +++ b/src/devhost/README.md @@ -67,6 +67,10 @@ The runner reads the hook's JSON on stdin (`session_id`, `transcript_path`, `cwd All four: one `claude -p --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 ` 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 `/offsets/` (how far each back has read each transcript), `/lock`, `/job.log`, `/pending-` and `since-` (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. diff --git a/src/devhost/devcontainer-feature.json b/src/devhost/devcontainer-feature.json index e009df7..6c1f187 100644 --- a/src/devhost/devcontainer-feature.json +++ b/src/devhost/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devhost", - "version": "1.2.0", + "version": "1.2.1", "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_* / _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", diff --git a/src/devhost/share/python/verify.py b/src/devhost/share/python/verify.py index 9649eb3..bab6d64 100644 --- a/src/devhost/share/python/verify.py +++ b/src/devhost/share/python/verify.py @@ -20,6 +20,8 @@ `# ADR-NNNN: `, carry the fixed sections and a known status word, and are in the index. - `taskfile`: every task in `Taskfile.yml` has a `desc` (`default` excepted). - `skills`: every `.claude/skills//SKILL.md` has frontmatter `name: ` and a description. +- `hook_modes`: every tracked file in the configured hooks path (`core.hooksPath`) has index mode + 100755; a 100644 hook never runs on Linux (`core.fileMode=false` on Windows hides the change). """ from __future__ import annotations @@ -262,7 +264,22 @@ def skills(root: Path) -> list[str]: return findings -GUARDS = (ledger_append_only, ledger_shape, secrets, forbidden_words, adrs, taskfile, skills) +def hook_modes(root: Path) -> list[str]: + hooks = (git(root, "config", "core.hooksPath") or "").strip() + if not hooks: + return [] + listing = git(root, "ls-files", "-s", "--", hooks) or "" + findings = [] + for line in listing.splitlines(): + meta, _, path = line.partition("\t") + mode = meta.split()[0] + if mode != "100755": + findings.append(f"{path}:1: hook is mode {mode} in the index, so it never runs on Linux; " + f"fix: git update-index --chmod=+x {path} && chmod +x {path}") + return findings + + +GUARDS = (ledger_append_only, ledger_shape, secrets, forbidden_words, adrs, taskfile, skills, hook_modes) def run(root: Path) -> tuple[dict[str, list[str]], list[str]]: diff --git a/test/devhost/python/test_verify.py b/test/devhost/python/test_verify.py index c27f7f1..5d10d6a 100644 --- a/test/devhost/python/test_verify.py +++ b/test/devhost/python/test_verify.py @@ -159,6 +159,26 @@ def test_skills_have_frontmatter_naming_their_directory(self): ".claude/skills/silent/SKILL.md:2: frontmatter needs a description saying what the skill does and when to use it", ]) + def test_a_non_executable_hook_is_refused(self): + h.write(self.root / ".githooks" / "pre-commit", "#!/bin/sh\nexit 0\n") + h.git(self.root, "config", "core.hooksPath", ".githooks") + h.git(self.root, "add", ".githooks/pre-commit") + self.assertEqual(self.v.hook_modes(self.root), [ + ".githooks/pre-commit:1: hook is mode 100644 in the index, so it never runs on Linux; " + "fix: git update-index --chmod=+x .githooks/pre-commit && chmod +x .githooks/pre-commit"]) + + def test_an_executable_hook_passes(self): + h.write(self.root / ".githooks" / "pre-commit", "#!/bin/sh\nexit 0\n") + h.git(self.root, "config", "core.hooksPath", ".githooks") + h.git(self.root, "add", "--chmod=+x", ".githooks/pre-commit") + self.assertEqual(self.v.hook_modes(self.root), []) + + def test_no_hooks_path_or_an_untracked_one_passes(self): + self.assertEqual(self.v.hook_modes(self.root), []) + h.git(self.root, "config", "core.hooksPath", ".githooks") + h.write(self.root / ".githooks" / "pre-commit", "x\n") + self.assertEqual(self.v.hook_modes(self.root), []) + def test_the_scaffolded_repository_passes_every_guard(self): init = h.load("init") init.scaffold(self.root) From 6616b1302b4632741715ec21aa1e6689407fec21 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 02:41:56 +0000 Subject: [PATCH 5/8] fix(devhost): the tester reports skipped, not red, without Docker (tester-skip-without-docker) A suite the environment cannot run (Taskfile precondition, no Docker daemon) gave no verdict on the code, so it was reported red on every run. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: devhost agent --- src/devhost/NOTES.md | 2 +- src/devhost/README.md | 2 +- src/devhost/devcontainer-feature.json | 2 +- src/devhost/share/jobs/tester/job.sh | 33 ++++++++++++++++++++++++--- test/devhost/functional.sh | 16 +++++++++++++ 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/devhost/NOTES.md b/src/devhost/NOTES.md index 283d775..8574b55 100644 --- a/src/devhost/NOTES.md +++ b/src/devhost/NOTES.md @@ -44,7 +44,7 @@ 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. +- **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 --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. diff --git a/src/devhost/README.md b/src/devhost/README.md index b72aed6..7313173 100644 --- a/src/devhost/README.md +++ b/src/devhost/README.md @@ -63,7 +63,7 @@ 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. +- **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 --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. diff --git a/src/devhost/devcontainer-feature.json b/src/devhost/devcontainer-feature.json index 6c1f187..44dda92 100644 --- a/src/devhost/devcontainer-feature.json +++ b/src/devhost/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devhost", - "version": "1.2.1", + "version": "1.2.2", "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_* / _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", diff --git a/src/devhost/share/jobs/tester/job.sh b/src/devhost/share/jobs/tester/job.sh index 3a9defb..c78c0f1 100644 --- a/src/devhost/share/jobs/tester/job.sh +++ b/src/devhost/share/jobs/tester/job.sh @@ -2,7 +2,8 @@ # Uses no model: after a turn, if code under TESTER_PATHS (src tests scripts) changed since # this job's last run, it runs the test command (TESTER_CMD: `task test` when the root # Taskfile has a test task, else nothing) and speaks only when the suite is red, or green -# again after being red. Its metrics line carries model "none" and cost 0 (TESTER_MODEL, +# again after being red (a suite the environment cannot run, e.g. no Docker, is `skipped`: one +# quiet notice, no verdict, a previous red kept). Its metrics line carries model "none" and cost 0 (TESTER_MODEL, # lib.sh). The runner's lock keeps two runs apart (the second exits quietly); # DEVHOST_JOB_CHILD is set for the suite, so nothing the suite starts can start a back. # shellcheck shell=bash disable=SC2034,SC2154 # JOB_* are read, ROOT/state/transcript set, by the runner @@ -39,6 +40,18 @@ tester_hash() { } | sha256sum | cut -d' ' -f1 } +# tester_cannot_run : true when the suite failed because this environment +# cannot run it (no verdict on the code): the Taskfile's precondition stopped it, or the +# command is the default `task test` / `task test:global` and there is no Docker daemon. +# Any other TESTER_CMD is judged by its output alone, so a real failure stays red. +tester_cannot_run() { + grep -Eqi 'precondition not met|no Docker daemon here' "$2" && return 0 + case "$1" in + "task test"|"task test:global") ! docker info >/dev/null 2>&1 ;; + *) return 1 ;; + esac +} + job_run() { local tmp="$2" now prev code first failed cmd last [ -n "${DEVHOST_JOB_CHILD:-}" ] && { JOB_STATUS=nothing; return; } @@ -53,8 +66,22 @@ job_run() { fi prev="$(cat "$state/last_result" 2>/dev/null || echo green)" echo "$now" >"$state/code_hash" + # Skipped is no verdict: last_result says skipped, last_real keeps the last red or green so a + # red is neither flipped to green by a skip nor announced again, and green-again still fires. + [ "$prev" = skipped ] && prev="$(cat "$state/last_real" 2>/dev/null || echo green)" + if [ "$code" != 0 ] && tester_cannot_run "$cmd" "$tmp/suite.out"; then + local was; was="$(cat "$state/last_result" 2>/dev/null || echo green)" + echo "$prev" >"$state/last_real" + echo skipped >"$state/last_result" + if [ "$was" = skipped ]; then + JOB_STATUS=nothing + else + JOB_STATUS=changed; JOB_NOTICE="tests skipped: \`$cmd\` cannot run here (no Docker daemon); no verdict, quiet until it can." + fi + return + fi if [ "$code" = 0 ]; then - echo green >"$state/last_result" + echo green >"$state/last_result"; echo green >"$state/last_real" if [ "$prev" = red ]; then JOB_STATUS=changed; JOB_NOTICE="tests green again." else @@ -62,7 +89,7 @@ job_run() { fi return fi - echo red >"$state/last_result" + echo red >"$state/last_result"; echo red >"$state/last_real" # pytest-style summaries are read when present; otherwise the last line of output. failed="$(grep -Eo '[0-9]+ failed' "$tmp/suite.out" | tail -n1 | cut -d' ' -f1)" first="$(sed -n 's/^FAILED \([^ ]*\).*/\1/p; s/^ERROR \([^ ]*\).*/\1/p' "$tmp/suite.out" | head -n1)" diff --git a/test/devhost/functional.sh b/test/devhost/functional.sh index 39987a9..070735d 100644 --- a/test/devhost/functional.sh +++ b/test/devhost/functional.sh @@ -123,6 +123,22 @@ PY check "tester is quiet without a code change" test -z "$out" check "tester never called claude" test "$(calls)" = 1 + # 5b. A suite the environment cannot run is skipped: one quiet notice, never red, a red kept. + echo 'x = 3' > "$root/src/a.py" + out="$(run_job tester t4 TESTER_CMD="echo 'task: Failed to run task \"test\": task: precondition not met'; exit 201")" + check "tester skips on precondition output with a quiet notice, not red" bash -c "printf '%s' '$out' | grep -q 'tests skipped' && ! printf '%s' '$out' | grep -q 'tests red' && [ \"\$(cat '$state/tester/last_result')\" = skipped ]" + echo 'x = 4' > "$root/src/a.py" + out="$(run_job tester t5 TESTER_CMD="echo 'no Docker daemon here'; exit 201")" + check "tester stays silent on a second skip" test -z "$out" + echo 'x = 5' > "$root/src/a.py" + out="$(run_job tester t6 TESTER_CMD="echo '1 failed'; exit 1")" + check "tester real failure after a skip is red" bash -c "printf '%s' '$out' | grep -q 'tests red'" + echo 'x = 6' > "$root/src/a.py" + run_job tester t7 TESTER_CMD="echo 'precondition not met'; exit 201" >/dev/null + echo 'x = 7' > "$root/src/a.py" + out="$(run_job tester t8 TESTER_CMD="echo '5 passed'")" + check "tester skip keeps a red: green after it is a recovery" bash -c "printf '%s' '$out' | grep -q 'tests green again'" + # 6. The lock. A busy lock never drops a run silently: a Stop run (non-blocking) logs the # skip; a SubagentStop run waits for the lock and then does its job. before_calls="$(calls)" From 9e2f392aa36544c1063a9ef196ce208162de1099 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 02:47:04 +0000 Subject: [PATCH 6/8] test(devhost): the redaction tests seed a placeholder name and can fail (redaction-fixture-placeholder) The old assertions checked a name no fixture contained, so they could never fail off the Operator's machine; they now seed a placeholder person as the git user.name and would fail if the identity leaked into the output. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: devhost agent --- test/devhost/python/helpers.py | 4 +++- test/devhost/python/test_briefing.py | 4 ++-- test/devhost/python/test_init.py | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test/devhost/python/helpers.py b/test/devhost/python/helpers.py index 7624d29..782c4a7 100644 --- a/test/devhost/python/helpers.py +++ b/test/devhost/python/helpers.py @@ -20,6 +20,8 @@ sys.path.insert(0, str(PYTHON_DIR)) LEDGER_MONTH = "2026-10" +# A made-up person, seeded where a real name would come from (the git identity); no output may carry it. +PERSON = "Guinevere Pendragon" ENV_VARS = ("DEVHOST_ROOT", "DEVHOST_STATE", "DEVHOST_LEDGER_DIR", "DEVHOST_BACKLOG", "DEVHOST_LEXICON", "DEVHOST_ADR_DIR", "DEVHOST_NIGHT_SHIFT_DIR", "DEVHOST_ENGINEER", "DEVHOST_REDACT_FILE", "DEVHOST_REDACT_WITH", "DEVHOST_FORBIDDEN_WORDS", "DEVHOST_BASE_REF", "CLAUDE_CONFIG_DIR", @@ -82,7 +84,7 @@ def make_repo(base: Path) -> Path: root = (base / "repo").resolve() root.mkdir() git(root, "init", "-q", "-b", "main") - git(root, "config", "user.name", "Test Engineer") + git(root, "config", "user.name", PERSON) git(root, "config", "user.email", "test@example.invalid") git(root, "config", "core.autocrlf", "false") git(root, "config", "commit.gpgsign", "false") diff --git a/test/devhost/python/test_briefing.py b/test/devhost/python/test_briefing.py index 062ddf4..1e09a6b 100644 --- a/test/devhost/python/test_briefing.py +++ b/test/devhost/python/test_briefing.py @@ -36,7 +36,7 @@ def test_counts_review_due_backlog_night_shift_and_pending(self): self.assertIn("Agent spend", text) self.assertIn("backs $0.02 in 1 runs, 1 undone by guards", text) self.assertIn("Details: devhost-report.", text) - self.assertNotIn("Nabil", text) + self.assertNotIn(h.PERSON, text) # seeded as the repo's git user.name by h.make_repo report = self.root / "docs" / "ledger" / f"costs-{self.b.month()}-tester.md" self.assertTrue(report.exists(), "the briefing rewrites the month's cost report") @@ -82,7 +82,7 @@ def test_the_report_uses_the_new_words_and_the_month(self): self.assertIn("| Explore | claude-haiku-5 | 1 | 1 |", text) self.assertIn("| scribe | claude-haiku-5 | 1 | 0 | 1 | 0 | 0 |", text) self.assertIn("decision for the Operator", text) - self.assertNotIn("Nabil", text) + self.assertNotIn(h.PERSON, text) # seeded as the repo's git user.name by h.make_repo self.assertEqual(self.report.render(self.root, "2026-11").count("| $"), 0, "another month has no rows") def test_summary_and_the_cli_write_the_file(self): diff --git a/test/devhost/python/test_init.py b/test/devhost/python/test_init.py index 89d6e42..275a928 100644 --- a/test/devhost/python/test_init.py +++ b/test/devhost/python/test_init.py @@ -25,6 +25,8 @@ def test_a_fresh_directory_gets_every_file(self): with tempfile.TemporaryDirectory() as tmp: fresh = Path(tmp).resolve() / "fresh-repo" fresh.mkdir() + h.git(fresh, "init", "-q") + h.git(fresh, "config", "user.name", h.PERSON) # the git identity, the channel a name could come through with h.devhost_env(fresh) as env: done = h.run(["init"], fresh, env) self.assertEqual(done.returncode, 0, done.stderr) @@ -48,9 +50,9 @@ def test_a_fresh_directory_gets_every_file(self): ["devhost-run-job scribe", "devhost-run-job librarian"]) self.assertIn("Bash(task *)", settings["permissions"]["allow"]) self.assertEqual(settings["permissions"]["deny"], ["Read(./.env)", "Read(./.env.*)"]) - # no person's name anywhere in what was written + # the git identity's name is nowhere in what was written for target in EXPECTED: - self.assertNotIn("Nabil", (fresh / target).read_text(encoding="utf-8"), target) + self.assertNotIn(h.PERSON, (fresh / target).read_text(encoding="utf-8"), target) def test_a_second_run_keeps_everything(self): self.init.scaffold(self.root) From 59d9d6dcbef4ea96e95bb0bd19bf5b4c596f5bf8 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 02:49:34 +0000 Subject: [PATCH 7/8] fix(devhost): backs make less noise (back-briefs-tidy) The librarian ran for nothing in most con stretches, the secretary filed in-session work as items and the scribe misused Corrects. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: devhost agent --- src/devhost/NOTES.md | 2 +- src/devhost/README.md | 2 +- src/devhost/devcontainer-feature.json | 2 +- src/devhost/share/jobs/librarian/job.sh | 7 +++++++ src/devhost/share/jobs/scribe/brief.md | 3 +++ src/devhost/share/jobs/scribe/entry-template.md | 2 +- src/devhost/share/jobs/secretary/brief.md | 5 ++++- test/devhost/python/test_common.py | 9 +++++++++ 8 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/devhost/NOTES.md b/src/devhost/NOTES.md index 8574b55..047d139 100644 --- a/src/devhost/NOTES.md +++ b/src/devhost/NOTES.md @@ -43,7 +43,7 @@ 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. +- **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 --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. diff --git a/src/devhost/README.md b/src/devhost/README.md index 7313173..18a8c3e 100644 --- a/src/devhost/README.md +++ b/src/devhost/README.md @@ -62,7 +62,7 @@ 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. +- **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 --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. diff --git a/src/devhost/devcontainer-feature.json b/src/devhost/devcontainer-feature.json index 44dda92..6f5bfb8 100644 --- a/src/devhost/devcontainer-feature.json +++ b/src/devhost/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devhost", - "version": "1.2.2", + "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_* / _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", diff --git a/src/devhost/share/jobs/librarian/job.sh b/src/devhost/share/jobs/librarian/job.sh index a3e67c0..b22a323 100644 --- a/src/devhost/share/jobs/librarian/job.sh +++ b/src/devhost/share/jobs/librarian/job.sh @@ -10,6 +10,13 @@ JOB_NAME=librarian # a longer stretch and ten minutes between runs; a stake for it always wakes it. JOB_MIN_CHARS=8000 JOB_QUIET_SECS=600 +# A con's stretch (SubagentStop) needs more than the 1500 default. First review, 31 runs: +# session stretches of 8847 to 10517 chars changed something, but 'nothing' runs spanned +# 8859 to 19552, so size cannot tell sessions apart and 8000 stays. For cons the one +# 'changed' run was 8426 chars; 13 of the 17 'nothing' runs (and 2 'pending') were 5995 to +# 8250. 8000 skips most of those with a margin below the one changed run (8426), since a +# skipped con stretch is never offered again. Re-check after the next review. +JOB_MIN_CHARS_SUB=8000 job_run() { local stretch="$1" tmp="$2" status summary changed lexicon adr_dir guard diff --git a/src/devhost/share/jobs/scribe/brief.md b/src/devhost/share/jobs/scribe/brief.md index 544c085..feef60b 100644 --- a/src/devhost/share/jobs/scribe/brief.md +++ b/src/devhost/share/jobs/scribe/brief.md @@ -39,6 +39,9 @@ Rules: - Decisions are what the Operator decided, in their words where short, with spelling and grammar corrected silently; "none" otherwise. A decision still to be made goes on "Decisions needed". +- Write a `Corrects:` line only when the entry replaces an earlier entry that was wrong, and + name that entry (date and title). Never `Corrects: none`: leave the line out. A later change + of plan (a rename, a reversed choice) is a new entry, not a correction. - Use the entry template below exactly. The Session, Engineer and Branch lines are given below. Leave the Measures line as the single word MEASURES: the job fills in the numbers. - Plain, short sentences. One entry per unit, eight to twelve lines. diff --git a/src/devhost/share/jobs/scribe/entry-template.md b/src/devhost/share/jobs/scribe/entry-template.md index a46b058..56df4a6 100644 --- a/src/devhost/share/jobs/scribe/entry-template.md +++ b/src/devhost/share/jobs/scribe/entry-template.md @@ -15,4 +15,4 @@ Then entries, oldest first, appended below; a review is a "## Review YYYY-MM-DD" - **For the repository:** - **Decisions needed:** - **Measures:** -- **Corrects:** +- **Corrects:** diff --git a/src/devhost/share/jobs/secretary/brief.md b/src/devhost/share/jobs/secretary/brief.md index b8f29e8..737794b 100644 --- a/src/devhost/share/jobs/secretary/brief.md +++ b/src/devhost/share/jobs/secretary/brief.md @@ -8,7 +8,10 @@ Read the backlog first (Read tool). Then, with the Edit tool: 1. **Add** an item for anything the stretch left open that the list does not already hold: work an engineer asked for that is not done yet, a follow-up an inter said it would do or needs, a `[tooling]`, `[docs]`, `[skill]` or `[plan]` item a con's reply raised, a check - that is still owed. Engineering to-dos only: anything that needs an answer from a client, a + that is still owed. An item is for work that stays open after the session, or a decision. + Do not add an item for work the session itself started and finished (a fix, a branch or a + diagnosis begun and completed in the stretch), nor to track a con's job in progress; the + ledger holds those. Engineering to-dos only: anything that needs an answer from a client, a customer or another party outside the repository is not yours. Put it under the fitting section (Now for this week's work, Next for later), as the last item of that section, in the existing style: diff --git a/test/devhost/python/test_common.py b/test/devhost/python/test_common.py index c7213f0..0afbe16 100644 --- a/test/devhost/python/test_common.py +++ b/test/devhost/python/test_common.py @@ -33,5 +33,14 @@ def test_redact_script_uses_the_shared_defaults(self): self.assertEqual(done.stdout, "the Operator's the OperatorCorp") +class LibrarianThresholds(h.RepoCase): + def test_librarian_waits_for_more_from_a_con_than_the_default(self): + text = (BIN_DIR.parent / "jobs" / "librarian" / "job.sh").read_text(encoding="utf-8") + sub = re.search(r"^JOB_MIN_CHARS_SUB=(\d+)", text, re.M) + self.assertIsNotNone(sub, "the librarian sets JOB_MIN_CHARS_SUB") + self.assertGreater(int(sub.group(1)), 1500) + self.assertIn("JOB_MIN_CHARS_SUB", (BIN_DIR / "run-job.sh").read_text(encoding="utf-8")) + + if __name__ == "__main__": unittest.main() From b114cdcec29f0e98da6194a10dda873ca0b15d00 Mon Sep 17 00:00:00 2001 From: devhost agent Date: Fri, 16 Oct 2026 02:32:19 +0000 Subject: [PATCH 8/8] feat(git): commitName and commitEmail options pin the agent commit identity (bot-git-identity) The wrapper exports GIT_AUTHOR_* and GIT_COMMITTER_* from a file written at install time, unless the caller set them, so a repository-local user.email in a shared git dir cannot leak into agent commits and commit -s signs off with the bot. Default empty leaves identity alone. Feature 1.1.0. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: devhost agent --- src/git/NOTES.md | 20 ++++++++++++++++++++ src/git/README.md | 22 ++++++++++++++++++++++ src/git/devcontainer-feature.json | 12 +++++++++++- src/git/git-wrapper.sh | 16 ++++++++++++++++ src/git/install.sh | 27 +++++++++++++++++++++++++-- test/git/commit_identity.sh | 27 +++++++++++++++++++++++++++ test/git/scenarios.json | 10 ++++++++++ test/git/test.sh | 5 +++++ 8 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 test/git/commit_identity.sh diff --git a/src/git/NOTES.md b/src/git/NOTES.md index ebbd50f..d746241 100644 --- a/src/git/NOTES.md +++ b/src/git/NOTES.md @@ -28,6 +28,26 @@ Everywhere else git runs unchanged, and without `GIT_DIR` the wrapper only `exec`s the real git. `GIT_WRAPPER_REAL_GIT` overrides the baked-in path to the real git. +## Commit identity for agents + +With `commitEmail` (and `commitName`) set, the wrapper exports +`GIT_AUTHOR_NAME/EMAIL` and `GIT_COMMITTER_NAME/EMAIL` for every git it runs, +each only when the caller has not set it. Environment variables beat every +config level, so a repository-local `user.email` (for example the Operator's +address in a git dir shared with the host checkout) cannot leak into an +agent's commit, `git commit -s` signs off with the same identity, and nothing +has to touch the shared config or pass `-c` overrides. `install.sh` writes the +values to `/usr/local/share/git-feature/identity.env`, which the wrapper +sources (one file read per call). The wrapper is installed for this even with +`worktreeAware: false`. Empty (the default) leaves identity alone. + +Why not the alternatives: `containerEnv` in a consumer's `devcontainer.json` +works but cannot be driven by a Feature option, so each repo would repeat four +variables; `profile.d` reaches only login shells, missing headless agents, +hooks and `docker exec` without a shell. The wrapper is on `PATH` for every +process. A caller that sets the variables itself (a human exporting their own +identity) keeps them. + ## What post-create does The Feature's `postCreateCommand` hook runs `git-setup` as the remote user diff --git a/src/git/README.md b/src/git/README.md index fd140b2..4c9afb5 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -19,6 +19,8 @@ Augments whichever git the image already has (base image, common-utils or ghcr.i | worktreeAware | Install the /usr/local/bin/git wrapper that unsets GIT_DIR and GIT_WORK_TREE when git runs inside a linked worktree or a nested repository (any directory whose nearest .git is not GIT_WORK_TREE). With false, no wrapper is installed and a previously installed one is removed. | boolean | true | | safeDirectory | What git-setup marks as safe.directory in the global git config at post-create: 'workspace' = the workspace folder and GIT_DIR (when set); '*' = every directory; empty = do nothing. Override per container with GIT_SETUP_SAFE_DIRECTORY in containerEnv. | string | workspace | | hooksPath | When set, git-setup runs `git config core.hooksPath ` in the workspace repository at post-create (e.g. '.githooks'); empty leaves core.hooksPath alone. Override per container with GIT_SETUP_HOOKS_PATH in containerEnv. | string | - | +| commitName | Commit identity name for agents and bots. When commitEmail is set, the git wrapper exports GIT_AUTHOR_NAME and GIT_COMMITTER_NAME with this value (default: the email) for every git invocation that has not set them, so no repository-local or global user.name decides who commits. Empty with commitEmail empty = feature off. | string | - | +| commitEmail | Commit identity email (e.g. 'bot@noreply.invalid'). When set, the git wrapper exports GIT_AUTHOR_EMAIL and GIT_COMMITTER_EMAIL with this value for every git invocation that has not set them: environment variables beat every config level, including a repository-local user.email in a shared git dir, and `git commit -s` signs off with it. Installs the wrapper even with worktreeAware=false. Empty = identity left alone. | string | - | ## Why a wrapper @@ -50,6 +52,26 @@ Everywhere else git runs unchanged, and without `GIT_DIR` the wrapper only `exec`s the real git. `GIT_WRAPPER_REAL_GIT` overrides the baked-in path to the real git. +## Commit identity for agents + +With `commitEmail` (and `commitName`) set, the wrapper exports +`GIT_AUTHOR_NAME/EMAIL` and `GIT_COMMITTER_NAME/EMAIL` for every git it runs, +each only when the caller has not set it. Environment variables beat every +config level, so a repository-local `user.email` (for example the Operator's +address in a git dir shared with the host checkout) cannot leak into an +agent's commit, `git commit -s` signs off with the same identity, and nothing +has to touch the shared config or pass `-c` overrides. `install.sh` writes the +values to `/usr/local/share/git-feature/identity.env`, which the wrapper +sources (one file read per call). The wrapper is installed for this even with +`worktreeAware: false`. Empty (the default) leaves identity alone. + +Why not the alternatives: `containerEnv` in a consumer's `devcontainer.json` +works but cannot be driven by a Feature option, so each repo would repeat four +variables; `profile.d` reaches only login shells, missing headless agents, +hooks and `docker exec` without a shell. The wrapper is on `PATH` for every +process. A caller that sets the variables itself (a human exporting their own +identity) keeps them. + ## What post-create does The Feature's `postCreateCommand` hook runs `git-setup` as the remote user diff --git a/src/git/devcontainer-feature.json b/src/git/devcontainer-feature.json index d8a200a..9278e07 100644 --- a/src/git/devcontainer-feature.json +++ b/src/git/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "git", - "version": "1.0.0", + "version": "1.1.0", "name": "git (devcontainer wrapper and setup)", "description": "Augments whichever git the image already has (base image, common-utils or ghcr.io/devcontainers/features/git) for devcontainers whose checkout keeps its git dir outside the workspace and reaches it through GIT_DIR and GIT_WORK_TREE in containerEnv: a /usr/local/bin/git wrapper drops those two variables inside linked worktrees (Claude Code's .claude/worktrees/) and nested repositories, so they are not sent to the main checkout, and a postCreateCommand hook (git-setup) does the per-container setup every repo otherwise scripts by hand - safe.directory and core.hooksPath - in a global git config (GIT_CONFIG_GLOBAL at /var/lib/devcontainer-features/git/gitconfig) kept in a named volume so identity and credential helper survive rebuilds.", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/git", @@ -25,6 +25,16 @@ "type": "string", "default": "", "description": "When set, git-setup runs `git config core.hooksPath ` in the workspace repository at post-create (e.g. '.githooks'); empty leaves core.hooksPath alone. Override per container with GIT_SETUP_HOOKS_PATH in containerEnv." + }, + "commitName": { + "type": "string", + "default": "", + "description": "Commit identity name for agents and bots. When commitEmail is set, the git wrapper exports GIT_AUTHOR_NAME and GIT_COMMITTER_NAME with this value (default: the email) for every git invocation that has not set them, so no repository-local or global user.name decides who commits. Empty with commitEmail empty = feature off." + }, + "commitEmail": { + "type": "string", + "default": "", + "description": "Commit identity email (e.g. 'bot@noreply.invalid'). When set, the git wrapper exports GIT_AUTHOR_EMAIL and GIT_COMMITTER_EMAIL with this value for every git invocation that has not set them: environment variables beat every config level, including a repository-local user.email in a shared git dir, and `git commit -s` signs off with it. Installs the wrapper even with worktreeAware=false. Empty = identity left alone." } }, "containerEnv": { diff --git a/src/git/git-wrapper.sh b/src/git/git-wrapper.sh index 66aec8d..41b7c21 100644 --- a/src/git/git-wrapper.sh +++ b/src/git/git-wrapper.sh @@ -9,6 +9,22 @@ # the main checkout: everywhere else git behaves as if they were not set. # @REAL_GIT@ is baked in by install.sh. real="${GIT_WRAPPER_REAL_GIT:-@REAL_GIT@}" + +# Commit identity (commitName / commitEmail options): install.sh writes the +# two values to a file. Environment variables beat every config level, so a +# repository-local user.email cannot override them, and `commit -s` signs off +# with the committer identity. Each variable the caller already set wins. +idfile="${GIT_WRAPPER_IDENTITY_FILE:-/usr/local/share/git-feature/identity.env}" +if [ -r "$idfile" ]; then + # shellcheck disable=SC1090 # written by install.sh + . "$idfile" + if [ -n "${DCF_COMMIT_EMAIL:-}" ]; then + : "${GIT_AUTHOR_NAME:=$DCF_COMMIT_NAME}" "${GIT_AUTHOR_EMAIL:=$DCF_COMMIT_EMAIL}" + : "${GIT_COMMITTER_NAME:=$DCF_COMMIT_NAME}" "${GIT_COMMITTER_EMAIL:=$DCF_COMMIT_EMAIL}" + export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL + fi +fi + [ -n "${GIT_DIR:-}" ] || exec "$real" "$@" # The directory that counts is the one git will run in: $PWD, moved by each diff --git a/src/git/install.sh b/src/git/install.sh index f361c44..c09c1cc 100644 --- a/src/git/install.sh +++ b/src/git/install.sh @@ -7,7 +7,7 @@ # - /usr/local/bin/git-setup, the per-container setup (safe.directory, # core.hooksPath) that this Feature's postCreateCommand hook runs. # Feature options arrive as upper-cased environment variables (see -# devcontainer-feature.json): WORKTREEAWARE, SAFEDIRECTORY, HOOKSPATH. +# devcontainer-feature.json): WORKTREEAWARE, SAFEDIRECTORY, HOOKSPATH, COMMITNAME, COMMITEMAIL. # # State: devcontainer-feature.json points GIT_CONFIG_GLOBAL at # /var/lib/devcontainer-features/git/gitconfig and mounts a named volume on @@ -21,6 +21,8 @@ WORKTREEAWARE="${WORKTREEAWARE:-true}" # hence "-" rather than ":-". SAFEDIRECTORY="${SAFEDIRECTORY-workspace}" HOOKSPATH="${HOOKSPATH-}" +COMMITNAME="${COMMITNAME-}" +COMMITEMAIL="${COMMITEMAIL-}" if [ "$(id -u)" -ne 0 ]; then echo "git: install.sh must run as root (the devcontainer Feature lifecycle does this)." >&2 @@ -47,8 +49,15 @@ mkdir -p "$share" install -m 0755 "$feature_dir/git-setup.sh" /usr/local/bin/git-setup +# The commit identity is applied by the wrapper, so it keeps the wrapper in +# place even with worktreeAware=false (the wrapper then only exports it). +if [ -n "$COMMITEMAIL" ] && [ -z "$COMMITNAME" ]; then COMMITNAME="$COMMITEMAIL"; fi +identity="$share/identity.env" +want_wrapper=false +if [ "$WORKTREEAWARE" = "true" ] || [ -n "$COMMITEMAIL" ]; then want_wrapper=true; fi + real_git="" -if [ "$WORKTREEAWARE" = "true" ]; then +if [ "$want_wrapper" = "true" ]; then # A git already at /usr/local/bin that is not our wrapper (built from source # by ghcr.io/devcontainers/features/git) is moved aside so the wrapper can # take its place and still forward to it; otherwise the real git is the @@ -82,6 +91,20 @@ else fi fi +# Commit identity for the wrapper: single-quoted shell assignments (sourced by +# a POSIX sh), present only when commitEmail is set. +if [ -n "$COMMITEMAIL" ]; then + sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + { + echo "# Written by the git Feature's install.sh (commitName, commitEmail); sourced by the wrapper." + printf 'DCF_COMMIT_NAME=%s\n' "$(sq "$COMMITNAME")" + printf 'DCF_COMMIT_EMAIL=%s\n' "$(sq "$COMMITEMAIL")" + } > "$identity" + chmod 0644 "$identity" +else + rm -f "$identity" +fi + # The options, as defaults for git-setup (the hook and `task git:setup` must # behave the same) and the resolved real git for the version report. { diff --git a/test/git/commit_identity.sh b/test/git/commit_identity.sh new file mode 100644 index 0000000..5b3a2d7 --- /dev/null +++ b/test/git/commit_identity.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Scenario 'commit_identity' (see scenarios.json): commitName and commitEmail set. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +name="devhost agent" +email="devhost-agent@noreply.invalid" +check "identity file written" grep -q "^DCF_COMMIT_EMAIL='$email'$" /usr/local/share/git-feature/identity.env + +tmp="$(mktemp -d)" +cd "$tmp" +export GIT_CONFIG_GLOBAL="$tmp/gitconfig" +unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL +git init -q -b main repo +# The repository-local identity that used to win over the global one. +git -C repo config user.name "Real Person" +git -C repo config user.email "real.person@example.com" +git -C repo commit -q -s --allow-empty -m "signed" +last() { git -C repo log -1 --format="$1"; } +check "author is the bot" bash -c "[ \"$(last '%an <%ae>')\" = '$name <$email>' ]" +check "committer is the bot" bash -c "[ \"$(last '%cn <%ce>')\" = '$name <$email>' ]" +check "Signed-off-by is the bot" bash -c "[ \"$(last '%(trailers:key=Signed-off-by,valueonly)' | head -1)\" = '$name <$email>' ]" +check "the real address appears nowhere in the commit" bash -c "! git -C repo cat-file -p HEAD | grep -q real.person" +check "a caller's own variables win" bash -c "GIT_AUTHOR_EMAIL=me@example.com git -C repo commit -q --allow-empty -m own && [ \"\$(git -C repo log -1 --format=%ae)\" = me@example.com ] && [ \"\$(git -C repo log -1 --format=%ce)\" = '$email' ]" + +reportResults diff --git a/test/git/scenarios.json b/test/git/scenarios.json index 14a67fa..6940384 100644 --- a/test/git/scenarios.json +++ b/test/git/scenarios.json @@ -17,5 +17,15 @@ } }, "remoteUser": "vscode" + }, + "commit_identity": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "git": { + "commitName": "devhost agent", + "commitEmail": "devhost-agent@noreply.invalid" + } + }, + "remoteUser": "vscode" } } diff --git a/test/git/test.sh b/test/git/test.sh index a61579a..c1670dd 100644 --- a/test/git/test.sh +++ b/test/git/test.sh @@ -63,4 +63,9 @@ check "main checkout config untouched (no core.worktree)" bash -c "! git -C '$tm # git dir either (git obeys GIT_WORK_TREE when the wrapper keeps it). check "git init at the checkout root leaves the shared config alone" bash -c "cd '$tmp/main' && git init -q >/dev/null 2>&1; ! git config --local --get core.worktree && [ \"\$(git rev-parse HEAD)\" = '$head_before' ]" +# Default (commitName/commitEmail unset): identity is left alone. +check "no identity file by default" bash -c '[ ! -e /usr/local/share/git-feature/identity.env ]' +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "default commit keeps the repository's own identity" bash -c 'unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_DIR GIT_WORK_TREE; d="$(mktemp -d)" && cd "$d" && git init -q && git config user.name Local && git config user.email local@example.com && git commit -q --allow-empty -m x && [ "$(git log -1 --format="%an <%ae> %cn <%ce>")" = "Local Local " ]' + reportResults