From 2b088aa63e282e23499d8426a1eba266e47bdbcf Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:08:58 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(sync):=20sync=20v2=20offline=20checks?= =?UTF-8?q?=20=E2=80=94=20deploy-lag,=20environment,=20labels,=20observabi?= =?UTF-8?q?lity=20(issue=20#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends sync.sh with four report-only, offline, self-hosting-safe checks that close the genuine gaps in issue #141 left after #134's decision (stop vendoring, detect stale copies) was already implemented: (1) reads .claude/state/loop-runs.log to tell the operator whether a driver looks active or idle before they re-arm/restart the loop units, (2) verifies .env/GH_BOT_TOKEN presence (never the value) and the installed plugin version against the #136 release floor, printing the live bot-identity advisory for the agent to run via bot-gh.sh, (3) derives expected module: + needs-human labels from the target's adapter and prints the exact bot-gh.sh create commands as advisories, and (4) detects whether worker-tools.jsonl/events.jsonl observability state exists, referencing issue #137 for the gap without attempting to fix it. Extends sync-managed-files.test.sh with offline coverage for every new check plus self-hosting quieting, and documents the paired live steps in SKILL.md. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/sync-managed-files.test.sh | 160 +++++++++++++++ .claude/skills/sync/SKILL.md | 51 ++++- .claude/skills/sync/sync.sh | 214 +++++++++++++++++++++ 3 files changed, 422 insertions(+), 3 deletions(-) diff --git a/.claude/scripts/sync-managed-files.test.sh b/.claude/scripts/sync-managed-files.test.sh index 03bcbb2..caf25b1 100755 --- a/.claude/scripts/sync-managed-files.test.sh +++ b/.claude/scripts/sync-managed-files.test.sh @@ -41,6 +41,27 @@ # silently ignored just because no directories triggered the main check — and the # marker is left on disk untouched. # +# Issue #141 ("sync v2") adds four more offline DETECT-AND-REPORT checks to sync.sh — +# scenarios 11-16 cover them: +# 11. environment check: .env/GH_BOT_TOKEN presence (found / missing key / no .env at +# all — and the token VALUE itself is never echoed into the output) and installed +# plugin version vs. the issue #136 floor (>= ok, < too-old, both compared off a +# synthetic `.claude-plugin/plugin.json` fixture). +# 12. missing-labels check: expected `module:` labels + `needs-human` are derived +# from the TARGET's `.claude/gates.json` `modules[]` and printed as exact +# `bot-gh.sh label create` advisory commands (never run); no adapter present +# degrades to "cannot derive labels" rather than a crash. +# 13. observability-plumbing check: `.claude/state/{worker-tools,events}.jsonl` reported +# absent / present-but-empty / present-with-activity, referencing issue #137 for the +# gap case, without ever attempting to create/fix those files. +# 14. deploy-lag check: `.claude/state/loop-runs.log` absent / empty / a recent ts= +# (looks ACTIVE, wait) / an old ts= (looks idle, safe to restart) all produce the +# expected re-arm/restart guidance without sync.sh touching the file or any unit. +# 15. self-hosting quiets the remediation-flavored advisories (module/needs-human label +# creation) while still running the plain informational reads (env, observability, +# deploy-lag) with no crash and exit 0 — mirrors the self_hosting short-circuit +# scenario 9 already asserts for stale-vendor. +# # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/sync-managed-files.test.sh set -uo pipefail @@ -98,6 +119,16 @@ build_plugin_fixture() { cp -a "$repo_root/.claude/$entry" "$dest/$entry" done } +build_plugin_fixture_with_version() { + # $1 = destination dir, $2 = version string to stamp into a synthetic + # .claude-plugin/plugin.json (issue #141's environment check reads plugin_root's + # .claude-plugin/plugin.json "version" field — real plugin roots always carry one, + # build_plugin_fixture alone does not, so tests that need a specific version use this). + local dest="$1" version="$2" + build_plugin_fixture "$dest" + mkdir -p "$dest/.claude-plugin" + printf '{\n "name": "orchestrator",\n "version": "%s"\n}\n' "$version" >"$dest/.claude-plugin/plugin.json" +} # --------------------------------------------------------------------------- # Scenario 1: fresh scaffold — managed files only, no vendoring. @@ -318,6 +349,135 @@ check_output "s10: still reports none found for the directories themselves" "$ou check_no_output "s10: no migration caveat for a bare stray marker (nothing to migrate)" "$out10" "MIGRATION CAVEAT" check "s10: marker file left untouched — sync never deletes it" test -f "$t10/.claude/.orchestrator-vendor" +# --------------------------------------------------------------------------- +# Scenario 11: environment check (issue #141 item 2) — .env/GH_BOT_TOKEN + plugin +# version, both offline. Never echoes the token VALUE. +# --------------------------------------------------------------------------- +t11="$work/consumer11" +mkdir -p "$t11/.claude" +plugin_v_ok="$work/plugin-v-ok" +build_plugin_fixture_with_version "$plugin_v_ok" "0.2.2" +plugin_v_old="$work/plugin-v-old" +build_plugin_fixture_with_version "$plugin_v_old" "0.2.1" + +out11a="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11a: no .env reported" "$out11a" "env: .env — not found at $t11/.env" +check_output "s11a: version at floor reported OK" "$out11a" "env: plugin version — v0.2.2 >= required v0.2.2" + +printf 'GH_BOT_TOKEN=super-secret-value-should-never-appear\n' >"$t11/.env" +out11b="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11b: GH_BOT_TOKEN= key found" "$out11b" "env: .env — GH_BOT_TOKEN= assignment found" +check_no_output "s11b: token VALUE never echoed" "$out11b" "super-secret-value-should-never-appear" + +printf 'SOME_OTHER_VAR=1\n' >"$t11/.env" +out11c="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11c: .env present but no GH_BOT_TOKEN key" "$out11c" "no GH_BOT_TOKEN= assignment found" + +out11d="$(bash "$plugin_v_old/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11d: below-floor plugin version flagged" "$out11d" \ + "env: plugin version — v0.2.1 is BELOW required v0.2.2" + +check_output "s11: live bot-identity advisory printed, not run" "$out11a" \ + "ADVISORY (live, network — not run by sync.sh) — verify bot identity + repo access: bash \${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh api user --jq .login" + +# --------------------------------------------------------------------------- +# Scenario 12: missing-labels check (issue #141 item 3) — derived from the target's +# .claude/gates.json modules[], printed as exact bot-gh.sh advisory commands. +# --------------------------------------------------------------------------- +t12="$work/consumer12" +mkdir -p "$t12/.claude" +cat >"$t12/.claude/gates.json" <<'JSON' +{"project":{"name":"x"},"modules":[{"name":"foo","description":"Foo module"},{"name":"bar","description":"Bar module"}],"gates":{}} +JSON +out12="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t12" 2>&1)" +check_output "s12: module:foo label advisory derived" "$out12" \ + 'bot-gh.sh label create "module:foo" --description "Foo module" --force' +check_output "s12: module:bar label advisory derived" "$out12" \ + 'bot-gh.sh label create "module:bar" --description "Bar module" --force' +check_output "s12: needs-human label advisory with color" "$out12" \ + 'bot-gh.sh label create "needs-human" --description "Loop is blocked on owner judgment -- see the issue/PR body/comments" --color b60205 --force' +check "s12: no labels actually created (advisory only, no network call)" test 1 -eq 1 + +t12b="$work/consumer12b" +mkdir -p "$t12b" +out12b="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t12b" 2>&1)" +check_output "s12b: no adapter -> cannot derive labels, no crash" "$out12b" \ + "labels: cannot derive labels — no adapter found" + +# --------------------------------------------------------------------------- +# Scenario 13: observability-plumbing check (issue #141 item 4, cf. #137) — detect only, +# absent / empty / present-with-activity, never creates the files itself. +# --------------------------------------------------------------------------- +t13="$work/consumer13" +mkdir -p "$t13/.claude/state" +out13a="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t13" 2>&1)" +check_output "s13a: worker-tools.jsonl absent -> issue #137 gap called out" "$out13a" \ + "observability: .claude/state/worker-tools.jsonl — absent; this is the observability gap tracked by issue #137" +check_output "s13a: events.jsonl absent -> issue #137 gap called out" "$out13a" \ + "observability: .claude/state/events.jsonl — absent; this is the observability gap tracked by issue #137" + +: >"$t13/.claude/state/worker-tools.jsonl" +out13b="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t13" 2>&1)" +check_output "s13b: present-but-empty reported distinctly" "$out13b" \ + "observability: .claude/state/worker-tools.jsonl — present but empty" + +printf '{"tool":"Read"}\n' >>"$t13/.claude/state/worker-tools.jsonl" +out13c="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t13" 2>&1)" +check_output "s13c: present-with-activity reported distinctly" "$out13c" \ + "observability: .claude/state/worker-tools.jsonl — present, 1 line(s)" +check "s13: sync never creates the observability state files itself" \ + test ! -e "$t13/.claude/state/events.jsonl" + +# --------------------------------------------------------------------------- +# Scenario 14: deploy-lag check (issue #141 item 5) — .claude/state/loop-runs.log +# absent / empty / recent (looks active) / old (looks idle, safe to restart). +# --------------------------------------------------------------------------- +t14="$work/consumer14" +mkdir -p "$t14/.claude/state" +out14a="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" +check_output "s14a: no loop-runs.log -> loop does not look armed" "$out14a" \ + "deploy-lag: .claude/state/loop-runs.log — not found; loop does not look armed here" + +: >"$t14/.claude/state/loop-runs.log" +out14b="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" +check_output "s14b: empty ledger -> safe to restart now" "$out14b" \ + "deploy-lag: .claude/state/loop-runs.log — present but empty" + +recent_ts="$(date -u +%FT%TZ)" +printf 'pid=123 session=abc verdict=advance-issue=1 ts=%s result=exit rc=0\n' "$recent_ts" \ + >"$t14/.claude/state/loop-runs.log" +out14c="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" +check_output "s14c: recent run -> may still be ACTIVE, restart ONLY between drivers" "$out14c" \ + "may still be ACTIVE; re-arm/restart the pr-loop/claude-rc systemd units ONLY BETWEEN drivers" + +old_ts="$(date -u -d '-3600 seconds' +%FT%TZ)" +printf 'pid=123 session=abc verdict=advance-issue=1 ts=%s result=exit rc=0\n' "$old_ts" \ + >"$t14/.claude/state/loop-runs.log" +out14d="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" +check_output "s14d: old run -> looks idle, safe to restart" "$out14d" \ + "loop looks idle — looks safe to re-arm/restart" + +# --------------------------------------------------------------------------- +# Scenario 15: self-hosting safety for the new sync v2 checks — reuses the t9 +# self-hosting fixture from scenario 9 (plugin_root == target_root/.claude). The +# remediation-flavored label advisory must go quiet (same philosophy as +# detect_stale_vendor_copies' self-hosting short-circuit); the plain informational +# reads (env, observability, deploy-lag) must still run, without error, exit 0. +# --------------------------------------------------------------------------- +cat >"$t9/.claude/gates.json" <<'JSON' +{"project":{"name":"recode"},"modules":[{"name":"harness","description":"Orchestrator machinery"}],"gates":{}} +JSON +out15="$(bash "$t9/.claude/skills/sync/sync.sh" "$t9" 2>&1)" +rc15=$? +check "s15: self-hosting sync still exits 0 with the new checks wired in" test "$rc15" -eq 0 +check_output "s15: self-hosting quiets the module-label creation advisory" "$out15" \ + "labels: self-hosting — skipping the module/needs-human label advisory" +check_no_output "s15: self-hosting prints no module:harness label-create command" "$out15" \ + 'label create "module:harness"' +check_output "s15: self-hosting still runs the plain env read" "$out15" "env: .env — not found" +check_output "s15: self-hosting still runs the plain observability read" "$out15" "observability: .claude/state/" +check_output "s15: self-hosting still runs the plain deploy-lag read" "$out15" "deploy-lag: .claude/state/loop-runs.log" + echo if [ "$fail" -ne 0 ]; then echo "sync-managed-files.test.sh: FAILED" diff --git a/.claude/skills/sync/SKILL.md b/.claude/skills/sync/SKILL.md index e2864e2..a17f0f4 100644 --- a/.claude/skills/sync/SKILL.md +++ b/.claude/skills/sync/SKILL.md @@ -102,10 +102,38 @@ version-marked managed files, not the "create if absent" ci files. lines — see "Stale-vendor detection" above — for any leftover local copy of the pre-#134 vendored tree, or `stale-vendor: none found` if there is none. + It then prints four more offline sections (issue #141, "sync v2") — each report-only, never mutates + anything, always exits 0 regardless of what it finds: + - `deploy-lag:` — reads `.claude/state/loop-runs.log` (the loop daemon's run ledger) and reports whether + the last driver run looks recent (**may still be ACTIVE**) or idle (**looks safe to restart**). This is + the offline half of the "re-arm/restart ONLY between drivers" guidance the migration caveat above + already gives — use it to decide *when*, not *whether*, to restart. `not found` means the loop doesn't + look armed here at all. + - `env:` — (a) whether `.env` exists and carries a `GH_BOT_TOKEN=` key (the token's **value** is never + read or printed, only whether the key is present); (b) whether the installed plugin version (read from + `.claude-plugin/plugin.json`) is at least the floor this sync ships (currently the issue #136 release, + `0.2.2`) — `OK` / `BELOW required` / unparseable. It also prints one **ADVISORY** line for the live, + network bot-identity check — sync.sh never runs it; see "Live steps the agent performs" below. + - `labels:` — derives the expected `module:` labels (one per `.claude/gates.json` `modules[].name`, + honoring `$GATES_FILE` the same way `gate.sh` does) plus `needs-human`, and prints the exact + `bot-gh.sh label create ...` commands as **ADVISORY** lines — it never queries or creates labels itself. + No adapter parses -> `cannot derive labels`. Self-hosting quiets this section entirely (see below). + - `observability:` — stats `.claude/state/worker-tools.jsonl` and `.claude/state/events.jsonl` and reports + `absent` (referencing issue #137, the tracked gap — sync does **not** attempt to fix it), `present but + empty`, or `present, N line(s)` (looks wired up). Detection only. + + **Self-hosting:** when this plugin's own repo runs sync.sh against itself, `deploy-lag`/`env`/ + `observability` still run as plain informational reads (there's nothing misleading about reporting this + repo's own state), but the `labels:` section — which *implies* remediation ("go create these") — goes + quiet with a one-line "already managed by the owner" note instead of repeating advisory commands for + labels the repo obviously already has, the same way `detect_stale_vendor_copies` goes quiet on its own + canonical tree. + 3. **Report a diff summary.** Relay the script's per-file summary verbatim to the user (it's already in the created/up-to-date/restamped/conflict/kept/skipped/error vocabulary above). Call out clearly which line, if any, changed on disk (`restamped`) versus which are informational only, and call out `error` lines as a - plugin-install problem rather than a repo problem. + plugin-install problem rather than a repo problem. Relay the four `deploy-lag`/`env`/`labels`/ + `observability` sections too — they're display order and can be summarized after the managed-file table. 4. **Handle conflicts explicitly — never silently overwrite.** For every `conflict / needs-merge` line, tell the user which file it is, that it has local edits diverging from the last pristine version it was stamped @@ -119,12 +147,29 @@ version-marked managed files, not the "create if absent" ci files. Under no circumstances write to a `conflict` file without the user's explicit go-ahead in this step — `sync.sh` itself never does, and neither should you. -5. **Hand off.** Summarize: which managed files were checked, which were restamped, which need a human +5. **Live steps the agent performs (issue #141).** sync.sh stays offline by design — these two steps pair + with its `env:`/`labels:` ADVISORY lines and are the ONLY parts of sync v2 that touch the network. Run + them yourself, as the agent, via `bot-gh.sh` (never bare `gh`): + - **Bot identity/repo access** — run the exact command sync.sh printed: + `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh api user --jq .login`, plus a `repo view` on this + repo. If either fails, don't fail the whole sync — report "action needed" and point at the one-time + setup notes in `.claude/scripts/bot-gh.sh` (same treatment as setup's step 7). + - **Label existence + create-missing** — query which labels already exist (e.g. + `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh label list`), diff that against sync.sh's derived + `module:` + `needs-human` set, and run the create command **only** for the ones actually missing + — sync.sh already printed the exact command for each; don't invent new ones or run them unconditionally + (most repos will have most labels already; a good sync run creates zero-to-few). + +6. **Hand off.** Summarize: which managed files were checked, which were restamped, which need a human merge decision (and what you did about it, if anything), and which are already current. Remind the user that user-owned files (`gates.json`, `CLAUDE.md`, `settings.local.json`, `.claude/state/`) are never touched by sync — those stay exactly as the human left them. If any `stale-vendor` line was printed, surface it prominently (don't bury it in the managed-file summary) along with the migration caveat about - restarting the armed systemd units after the human cleans up a stale local copy. + restarting the armed systemd units after the human cleans up a stale local copy. Also summarize the sync + v2 sections: deploy-lag verdict (active vs. idle — and whether you actually restarted anything, and when), + the environment check (token key present? plugin version floor met?), which labels (if any) you created + after the live existence check, and the observability-plumbing verdict (flag issue #137 by number if + either state file is absent/empty — don't attempt to fix it here). ## Reference - `.claude/skills/setup/templates/MANIFEST.md` — the template -> destination map and ownership classes this diff --git a/.claude/skills/sync/sync.sh b/.claude/skills/sync/sync.sh index 657d688..e9488b9 100755 --- a/.claude/skills/sync/sync.sh +++ b/.claude/skills/sync/sync.sh @@ -9,6 +9,14 @@ # files. This script does ONLY non-interactive, non-network comparison/copy work — the # prose flow (explaining results, offering to merge a conflict) lives in SKILL.md. # +# Issue #141 ("sync v2") extends this offline contract with four more DETECT-AND-REPORT +# checks (deploy-lag, environment, missing-labels, observability-plumbing) — same +# philosophy as detect_stale_vendor_copies below: read-only, never mutate anything that +# isn't sync's to manage, exit 0 for every diagnostic. Anything that needs the network +# (bot identity, label creation) is NOT run here — sync.sh only prints the exact advisory +# command; the live step is documented in SKILL.md and executed by the agent via +# bot-gh.sh, never inline in this script. +# # Usage: # sync.sh [target-repo-root] # @@ -70,6 +78,11 @@ MANAGED_FILES=( "arm-loop.sh|.claude/scripts/arm-loop.sh|@orchestrator-managed arm-loop v" ) +# Minimum plugin version the issue #141 environment check requires — this IS "the #136 +# release" (issue #136 shipped as plugin version 0.2.2). Bump this alongside any future +# release that the sync v2 environment check should start requiring. +MIN_PLUGIN_VERSION="0.2.2" + # --- stale-vendor detection (issue #134) -------------------------------------------- # Prior to issue #134, this plugin vendored its own runtime subtrees — `agents/`, # `commands/`, `hooks/`, `scripts/`, `skills/` — wholesale into a consumer's local @@ -203,6 +216,197 @@ detect_stale_vendor_copies() { return $((1 - any_found)) } +# --- deploy-lag / loop-runs.log check (issue #141 item 5) --------------------------- +# The stale-vendor migration caveat above tells the operator to restart the pr-loop/ +# claude-rc systemd units after cleaning up — but ONLY between drivers (a running driver +# holds its old script in memory; killing it mid-run wastes/loses work). This function +# gives that decision an offline signal: read $target_root/.claude/state/loop-runs.log +# (the run ledger loop-daemon.sh appends one line per driver to, `ts=` field) +# and report how long ago the last driver ran. Report-only — never mutates anything, +# never restarts anything itself — exit 0 always. +check_deploy_lag() { + local log="$target_root/.claude/state/loop-runs.log" + if [ ! -f "$log" ]; then + echo " deploy-lag: .claude/state/loop-runs.log — not found; loop does not look armed here (nothing to check before a restart, but confirm with the operator before assuming that)" + return 0 + fi + local last_line + last_line="$(tail -n 1 "$log" 2>/dev/null || true)" + if [ -z "$last_line" ]; then + echo " deploy-lag: .claude/state/loop-runs.log — present but empty; no driver runs recorded yet, restarting the loop units now should be safe" + return 0 + fi + echo " deploy-lag: .claude/state/loop-runs.log — last recorded run: $last_line" + local last_ts + last_ts="$(printf '%s' "$last_line" | grep -o 'ts=[^ ]*' | head -1 | cut -d= -f2)" + if [ -z "$last_ts" ]; then + echo " deploy-lag: could not parse a ts= field from the last entry — inspect the file yourself before restarting" + return 0 + fi + local last_epoch now_epoch age_s + last_epoch="$(date -u -d "$last_ts" +%s 2>/dev/null || true)" + if [ -z "$last_epoch" ]; then + echo " deploy-lag: could not parse timestamp \"$last_ts\" — inspect the file yourself before restarting" + return 0 + fi + now_epoch="$(date -u +%s)" + age_s=$((now_epoch - last_epoch)) + if [ "$age_s" -lt 0 ]; then age_s=0; fi + if [ "$age_s" -lt 300 ]; then + echo " deploy-lag: last driver run started ~${age_s}s ago — a driver may still be ACTIVE; re-arm/restart the pr-loop/claude-rc systemd units ONLY BETWEEN drivers (issue #131) — wait and re-check loop-runs.log before restarting" + else + echo " deploy-lag: last driver run started ~${age_s}s ago and the loop looks idle — looks safe to re-arm/restart the pr-loop/claude-rc systemd units now (issue #131); still worth a final glance at loop-runs.log before pulling the trigger" + fi +} + +# --- version compare helper (issue #141 item 2) -------------------------------------- +version_ge() { + # $1 = actual dotted version (e.g. "0.2.2"), $2 = minimum required (e.g. "0.2.2"). + # Component-wise numeric compare, reusing is_sane_version's bounded-digit discipline + # per component so a malformed/non-numeric component (e.g. a "-beta" suffix) degrades + # to "unknown" (return 2) instead of a wrong lexical/numeric compare. + # Returns: 0 = actual >= min, 1 = actual < min, 2 = unparseable. + local actual="$1" min="$2" + local -a a_parts m_parts + IFS='.' read -r -a a_parts <<<"$actual" + IFS='.' read -r -a m_parts <<<"$min" + local len=${#a_parts[@]} + [ "${#m_parts[@]}" -gt "$len" ] && len=${#m_parts[@]} + local i=0 av mv + while [ "$i" -lt "$len" ]; do + av="${a_parts[$i]:-0}" + mv="${m_parts[$i]:-0}" + is_sane_version "$av" || return 2 + is_sane_version "$mv" || return 2 + if [ "$av" -gt "$mv" ]; then return 0; fi + if [ "$av" -lt "$mv" ]; then return 1; fi + i=$((i + 1)) + done + return 0 +} + +# --- environment check (issue #141 item 2) -------------------------------------------- +# Offline parts only: (a) does $target_root/.env exist and carry a GH_BOT_TOKEN= +# assignment (grep for the KEY only — the value is NEVER read/printed); (b) is the +# installed plugin (plugin_root, resolved above — works identically in self-host and +# plugin-cache layouts) at >= MIN_PLUGIN_VERSION. The live bot-identity/repo-access check +# needs the network, so this function only PRINTS the exact advisory command for the +# agent to run via bot-gh.sh (SKILL.md documents that live step) — it never runs it here. +check_environment() { + local env_file="$target_root/.env" + if [ ! -f "$env_file" ]; then + echo " env: .env — not found at $target_root/.env; GH_BOT_TOKEN cannot be verified offline (see .claude/scripts/bot-gh.sh setup notes)" + elif grep -qE '^GH_BOT_TOKEN=' "$env_file" 2>/dev/null; then + echo " env: .env — GH_BOT_TOKEN= assignment found (value not inspected)" + else + echo " env: .env — present but no GH_BOT_TOKEN= assignment found; bot-gh.sh calls will fail until it's added" + fi + + local plugin_json="$plugin_root/.claude-plugin/plugin.json" + if [ ! -f "$plugin_json" ]; then + echo " env: plugin version — cannot read $plugin_json; plugin install looks unusual" + else + local installed_version + installed_version="$(node -e ' + try { + const p = require(process.argv[1]); + process.stdout.write(typeof p.version === "string" ? p.version : ""); + } catch (e) { process.stdout.write(""); } + ' "$plugin_json" 2>/dev/null || true)" + if [ -z "$installed_version" ]; then + echo " env: plugin version — could not parse a \"version\" field from $plugin_json" + else + local vge_rc=0 + version_ge "$installed_version" "$MIN_PLUGIN_VERSION" || vge_rc=$? + case "$vge_rc" in + 0) echo " env: plugin version — v$installed_version >= required v$MIN_PLUGIN_VERSION (the issue #136 release) — OK" ;; + 1) echo " env: plugin version — v$installed_version is BELOW required v$MIN_PLUGIN_VERSION (the issue #136 release) — update the plugin before relying on sync v2 behavior" ;; + *) echo " env: plugin version — could not compare \"$installed_version\" against \"$MIN_PLUGIN_VERSION\" (unexpected format) — verify manually" ;; + esac + fi + fi + + echo " env: ADVISORY (live, network — not run by sync.sh) — verify bot identity + repo access: bash \${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh api user --jq .login (and a 'repo view' on this repo)" +} + +# --- missing-labels check (issue #141 item 3) ----------------------------------------- +# Offline: derive the expected label set from the TARGET's adapter (module: per +# .claude/gates.json modules[].name, plus needs-human) and print the exact create +# commands — same pattern as .claude/skills/setup/SKILL.md's "Create the module + +# approval labels" step. Never queries GitHub and never creates anything itself; SKILL.md +# documents the agent-performed live step (query which labels exist via bot-gh.sh, +# create only the missing ones). +derive_module_labels() { + # Prints "module:" per module, or nothing (exit 1) if no + # adapter parses. Honors $GATES_FILE (same convention as gate.sh), relative to + # $target_root; falls back to .claude/gates.json. + local gates_ref="${GATES_FILE:-.claude/gates.json}" + local gates_path + case "$gates_ref" in + /*) gates_path="$gates_ref" ;; + *) gates_path="$target_root/$gates_ref" ;; + esac + [ -f "$gates_path" ] || return 1 + node -e ' + try { + const g = require(process.argv[1]); + if (!g || !Array.isArray(g.modules)) process.exit(1); + for (const m of g.modules) { + if (m && m.name) process.stdout.write("module:" + m.name + "\t" + (m.description || "") + "\n"); + } + } catch (e) { process.exit(1); } + ' "$gates_path" 2>/dev/null +} + +check_missing_labels() { + local labels rc=0 + labels="$(derive_module_labels)" || rc=$? + if [ "$rc" -ne 0 ] || [ -z "$labels" ]; then + echo " labels: cannot derive labels — no adapter found (checked \$GATES_FILE, .claude/gates.json)" + return 0 + fi + if [ "$self_hosting" -eq 1 ]; then + # Self-hosting: this repo's own module/needs-human labels are already owner-managed — + # printing "create these" advisories every run would be pure noise (design intent: + # an advisory that implies remediation degrades to a quiet verdict in self-host). + echo " labels: self-hosting — skipping the module/needs-human label advisory (this repo's own labels are already managed by the owner)" + return 0 + fi + echo " labels: ADVISORY (live, network — not run by sync.sh) — query existing labels via bot-gh.sh, then create only what's missing:" + local name desc + while IFS=$'\t' read -r name desc; do + [ -n "$name" ] || continue + echo " labels: bash \${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh label create \"$name\" --description \"$desc\" --force" + done <<<"$labels" + echo " labels: bash \${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh label create \"needs-human\" --description \"Loop is blocked on owner judgment -- see the issue/PR body/comments\" --color b60205 --force" +} + +# --- observability-plumbing check (issue #141 item 4, cf. issue #137) ----------------- +# DETECT don't fix: stat the two state files worker-tool/event observability lands in and +# report present-with-activity / present-but-empty / absent. Never attempts to ship the +# #137 hook itself — that's out of scope here, this only tells the human the gap exists. +check_observability_plumbing() { + local f path lines mtime now age_h + for f in worker-tools.jsonl events.jsonl; do + path="$target_root/.claude/state/$f" + if [ ! -f "$path" ]; then + echo " observability: .claude/state/$f — absent; this is the observability gap tracked by issue #137 (worker-tool-mirror hook not yet wired into this repo) — sync does not fix this, only reports it" + elif [ -s "$path" ]; then + lines="$(wc -l <"$path" 2>/dev/null | tr -d '[:space:]')" + mtime="$(stat -c %Y "$path" 2>/dev/null || true)" + if [ -n "$mtime" ]; then + now="$(date -u +%s)" + age_h=$(( (now - mtime) / 3600 )) + echo " observability: .claude/state/$f — present, $lines line(s), last modified ~${age_h}h ago — looks wired up" + else + echo " observability: .claude/state/$f — present, $lines line(s) — looks wired up" + fi + else + echo " observability: .claude/state/$f — present but empty; no events recorded yet (freshly created, or the issue #137 gap) — worth a human glance" + fi + done +} + # --- 1. managed files: compare marker version + content, act per the ladder below -- had_broken_install=0 @@ -294,6 +498,16 @@ else echo " stale-vendor: none found — .claude/{agents,commands,hooks,scripts,skills} are not vendored locally (as expected; served from \${CLAUDE_PLUGIN_ROOT})" fi +# --- 1c. deploy-lag / environment / labels / observability checks (issue #141) ------- +echo " --- deploy-lag ---" +check_deploy_lag +echo " --- environment ---" +check_environment +echo " --- labels ---" +check_missing_labels +echo " --- observability ---" +check_observability_plumbing + # --- 2. user-owned files: report only, never write ---------------------------------- for f in "${USER_OWNED_FILES[@]}"; do echo " user-owned — skipped by design: $f" From 6cccc90f48c39eacae470f6a144640feb4690406 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:22:41 +0200 Subject: [PATCH 2/3] fix(sync): guard deploy-lag ts= parse + isolate sync v2 tests from ambient GATES_FILE (issue #141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking review findings on the sync v2 offline checks: - sync.sh's deploy-lag last_ts parse was unguarded under set -euo pipefail — a loop-runs.log last line with no ts= token made grep exit 1 and pipefail abort the whole script before the "could not parse a ts=" fallback ever ran. Add the missing `|| true`, matching the sibling parses in the same function. - sync-managed-files.test.sh scenarios 12/12b/15 read the target's adapter via sync.sh's default $GATES_FILE resolution without pinning it to their own fixture, so an ambient GATES_FILE (e.g. the real self-hosted test gate exporting GATES_FILE=.claude/self/gates.json before running *.test.sh) leaked in and broke 4 checks. Pin GATES_FILE explicitly per fixture, same convention as gate.test.sh's write_gates_file/run_gate. Also folds in the non-blocking asks: explicit coverage for an above-floor plugin version and a malformed/missing version field in the environment check, and present-but-empty / present-with-activity coverage for events.jsonl (previously only worker-tools.jsonl was exercised beyond the absent case). Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/sync-managed-files.test.sh | 97 +++++++++++++++++++--- .claude/skills/sync/sync.sh | 2 +- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/.claude/scripts/sync-managed-files.test.sh b/.claude/scripts/sync-managed-files.test.sh index caf25b1..228fe61 100755 --- a/.claude/scripts/sync-managed-files.test.sh +++ b/.claude/scripts/sync-managed-files.test.sh @@ -45,22 +45,30 @@ # scenarios 11-16 cover them: # 11. environment check: .env/GH_BOT_TOKEN presence (found / missing key / no .env at # all — and the token VALUE itself is never echoed into the output) and installed -# plugin version vs. the issue #136 floor (>= ok, < too-old, both compared off a -# synthetic `.claude-plugin/plugin.json` fixture). +# plugin version vs. the issue #136 floor (>= at floor ok, above floor ok, < too-old, +# a malformed non-numeric component degrades to "verify manually" rather than a wrong +# compare, and a plugin.json missing the "version" field entirely degrades to "could +# not parse" — all compared off synthetic `.claude-plugin/plugin.json` fixtures). # 12. missing-labels check: expected `module:` labels + `needs-human` are derived # from the TARGET's `.claude/gates.json` `modules[]` and printed as exact # `bot-gh.sh label create` advisory commands (never run); no adapter present -# degrades to "cannot derive labels" rather than a crash. +# degrades to "cannot derive labels" rather than a crash. Both scenario 12 and 12b +# pin `GATES_FILE` to their own fixture's adapter path (absolute) so the assertions +# hold regardless of an ambient `GATES_FILE` leaking in from the caller's environment. # 13. observability-plumbing check: `.claude/state/{worker-tools,events}.jsonl` reported -# absent / present-but-empty / present-with-activity, referencing issue #137 for the -# gap case, without ever attempting to create/fix those files. +# absent / present-but-empty / present-with-activity (both files, not just +# worker-tools.jsonl), referencing issue #137 for the gap case, without ever +# attempting to create/fix those files. # 14. deploy-lag check: `.claude/state/loop-runs.log` absent / empty / a recent ts= -# (looks ACTIVE, wait) / an old ts= (looks idle, safe to restart) all produce the +# (looks ACTIVE, wait) / an old ts= (looks idle, safe to restart) / a last line with +# no parseable `ts=` field at all (must still exit 0 and print the "could not parse" +# fallback, not abort the whole script under `set -euo pipefail`) all produce the # expected re-arm/restart guidance without sync.sh touching the file or any unit. # 15. self-hosting quiets the remediation-flavored advisories (module/needs-human label # creation) while still running the plain informational reads (env, observability, # deploy-lag) with no crash and exit 0 — mirrors the self_hosting short-circuit -# scenario 9 already asserts for stale-vendor. +# scenario 9 already asserts for stale-vendor. Pins `GATES_FILE` to its own fixture's +# adapter for the same ambient-env-isolation reason as scenario 12. # # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/sync-managed-files.test.sh @@ -380,6 +388,34 @@ check_output "s11d: below-floor plugin version flagged" "$out11d" \ check_output "s11: live bot-identity advisory printed, not run" "$out11a" \ "ADVISORY (live, network — not run by sync.sh) — verify bot identity + repo access: bash \${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh api user --jq .login" +# 11e. Above-floor plugin version (e.g. a later release than the #136 MIN_PLUGIN_VERSION +# floor) is reported OK too, not just the exact-floor case s11a already covers. +plugin_v_above="$work/plugin-v-above" +build_plugin_fixture_with_version "$plugin_v_above" "0.3.0" +out11e="$(bash "$plugin_v_above/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11e: above-floor plugin version reported OK" "$out11e" \ + "env: plugin version — v0.3.0 >= required v0.2.2" + +# 11f. Malformed version field (non-numeric component) degrades to "verify manually" +# rather than a wrong lexical/numeric compare (version_ge's return-2 "unparseable" path). +plugin_v_malformed="$work/plugin-v-malformed" +build_plugin_fixture "$plugin_v_malformed" +mkdir -p "$plugin_v_malformed/.claude-plugin" +printf '{\n "name": "orchestrator",\n "version": "0.2.2-beta"\n}\n' \ + >"$plugin_v_malformed/.claude-plugin/plugin.json" +out11f="$(bash "$plugin_v_malformed/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11f: malformed version component -> verify manually, no crash" "$out11f" \ + "could not compare \"0.2.2-beta\" against \"0.2.2\"" + +# 11g. Missing "version" field in plugin.json entirely -> "could not parse" message. +plugin_v_missing="$work/plugin-v-missing" +build_plugin_fixture "$plugin_v_missing" +mkdir -p "$plugin_v_missing/.claude-plugin" +printf '{\n "name": "orchestrator"\n}\n' >"$plugin_v_missing/.claude-plugin/plugin.json" +out11g="$(bash "$plugin_v_missing/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11g: missing version field -> could not parse message, no crash" "$out11g" \ + "env: plugin version — could not parse a \"version\" field from" + # --------------------------------------------------------------------------- # Scenario 12: missing-labels check (issue #141 item 3) — derived from the target's # .claude/gates.json modules[], printed as exact bot-gh.sh advisory commands. @@ -389,7 +425,14 @@ mkdir -p "$t12/.claude" cat >"$t12/.claude/gates.json" <<'JSON' {"project":{"name":"x"},"modules":[{"name":"foo","description":"Foo module"},{"name":"bar","description":"Bar module"}],"gates":{}} JSON -out12="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t12" 2>&1)" +# Pin GATES_FILE to this fixture's OWN adapter (absolute path — sync.sh/derive_module_labels +# takes an absolute GATES_FILE as-is, same convention as gate.sh, see gate.test.sh's +# write_gates_file/run_gate). Scenarios 12/12b/15 must be deterministic regardless of an +# ambient GATES_FILE leaking in from the caller's environment (e.g. the self-hosted test +# gate runs *.test.sh with GATES_FILE=.claude/self/gates.json already exported) — without +# this pin, derive_module_labels would resolve the ambient adapter relative to $t12 instead +# of this fixture's own gates.json and silently fail to derive any labels. +out12="$(GATES_FILE="$t12/.claude/gates.json" bash "$plugin_v_ok/skills/sync/sync.sh" "$t12" 2>&1)" check_output "s12: module:foo label advisory derived" "$out12" \ 'bot-gh.sh label create "module:foo" --description "Foo module" --force' check_output "s12: module:bar label advisory derived" "$out12" \ @@ -400,7 +443,10 @@ check "s12: no labels actually created (advisory only, no network call)" test 1 t12b="$work/consumer12b" mkdir -p "$t12b" -out12b="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t12b" 2>&1)" +# Pin GATES_FILE to a path that deliberately doesn't exist under this fixture, so "no +# adapter found" is asserted regardless of what the ambient environment's GATES_FILE points +# at (same isolation rationale as scenario 12 above). +out12b="$(GATES_FILE="$t12b/.claude/gates.json" bash "$plugin_v_ok/skills/sync/sync.sh" "$t12b" 2>&1)" check_output "s12b: no adapter -> cannot derive labels, no crash" "$out12b" \ "labels: cannot derive labels — no adapter found" @@ -428,6 +474,19 @@ check_output "s13c: present-with-activity reported distinctly" "$out13c" \ check "s13: sync never creates the observability state files itself" \ test ! -e "$t13/.claude/state/events.jsonl" +# 13d/13e. events.jsonl gets the same present-but-empty / present-with-activity coverage +# worker-tools.jsonl already has above — the loop over both files in +# check_observability_plumbing is otherwise only exercised via the "absent" branch (s13a). +: >"$t13/.claude/state/events.jsonl" +out13d="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t13" 2>&1)" +check_output "s13d: events.jsonl present-but-empty reported distinctly" "$out13d" \ + "observability: .claude/state/events.jsonl — present but empty" + +printf '{"event":"phase-change"}\n' >>"$t13/.claude/state/events.jsonl" +out13e="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t13" 2>&1)" +check_output "s13e: events.jsonl present-with-activity reported distinctly" "$out13e" \ + "observability: .claude/state/events.jsonl — present, 1 line(s)" + # --------------------------------------------------------------------------- # Scenario 14: deploy-lag check (issue #141 item 5) — .claude/state/loop-runs.log # absent / empty / recent (looks active) / old (looks idle, safe to restart). @@ -457,6 +516,19 @@ out14d="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" check_output "s14d: old run -> looks idle, safe to restart" "$out14d" \ "loop looks idle — looks safe to re-arm/restart" +# 14e. Last line has no parseable ts= field at all — a correctness regression fixed by +# this issue #141 review round: under `set -euo pipefail`, the `grep -o 'ts=...' | ... | +# cut` pipeline used to be unguarded, so a last line with no ts= token made `grep` exit 1 +# and pipefail abort the WHOLE script before the "could not parse a ts=" fallback message +# ever ran. Assert sync still exits 0 and prints that fallback message instead of dying. +printf 'pid=123 session=abc result=exit rc=0 (no timestamp field at all)\n' \ + >"$t14/.claude/state/loop-runs.log" +out14e="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" +rc14e=$? +check "s14e: unparseable ts= field -> sync still exits 0" test "$rc14e" -eq 0 +check_output "s14e: unparseable ts= field -> could not parse message printed" "$out14e" \ + "deploy-lag: could not parse a ts= field from the last entry" + # --------------------------------------------------------------------------- # Scenario 15: self-hosting safety for the new sync v2 checks — reuses the t9 # self-hosting fixture from scenario 9 (plugin_root == target_root/.claude). The @@ -467,7 +539,12 @@ check_output "s14d: old run -> looks idle, safe to restart" "$out14d" \ cat >"$t9/.claude/gates.json" <<'JSON' {"project":{"name":"recode"},"modules":[{"name":"harness","description":"Orchestrator machinery"}],"gates":{}} JSON -out15="$(bash "$t9/.claude/skills/sync/sync.sh" "$t9" 2>&1)" +# Pin GATES_FILE to this self-hosting fixture's OWN adapter (absolute path — same isolation +# rationale as scenario 12) so this scenario is deterministic regardless of an ambient +# GATES_FILE (e.g. the real self-hosted test gate exports GATES_FILE=.claude/self/gates.json +# before invoking this file, which would otherwise make derive_module_labels look in the +# wrong place relative to $t9 and change which advisory lines print). +out15="$(GATES_FILE="$t9/.claude/gates.json" bash "$t9/.claude/skills/sync/sync.sh" "$t9" 2>&1)" rc15=$? check "s15: self-hosting sync still exits 0 with the new checks wired in" test "$rc15" -eq 0 check_output "s15: self-hosting quiets the module-label creation advisory" "$out15" \ diff --git a/.claude/skills/sync/sync.sh b/.claude/skills/sync/sync.sh index e9488b9..7862e2c 100755 --- a/.claude/skills/sync/sync.sh +++ b/.claude/skills/sync/sync.sh @@ -238,7 +238,7 @@ check_deploy_lag() { fi echo " deploy-lag: .claude/state/loop-runs.log — last recorded run: $last_line" local last_ts - last_ts="$(printf '%s' "$last_line" | grep -o 'ts=[^ ]*' | head -1 | cut -d= -f2)" + last_ts="$(printf '%s' "$last_line" | grep -o 'ts=[^ ]*' | head -1 | cut -d= -f2 || true)" if [ -z "$last_ts" ]; then echo " deploy-lag: could not parse a ts= field from the last entry — inspect the file yourself before restarting" return 0 From 6bb3d4e947081122661bf92971ef0a83ed1b8118 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:38 +0200 Subject: [PATCH 3/3] fix(sync): deploy-lag check must never claim safe-to-restart from ledger alone (issue #141) loop-daemon.sh only appends a loop-runs.log line AFTER a driver exits, so the ledger has zero signal for an in-flight driver -- a stale OR missing OR just- appended entry is equally consistent with a new driver running right now. check_deploy_lag's recent/old-ts branching claimed the opposite ("may still be ACTIVE" / "looks safe to re-arm/restart"), which is exactly backwards. All three branches now report the ledger state plainly and defer to an independent liveness check (systemctl --user status) instead of asserting a verdict the log can't actually support. Also strengthens the s12 "labels are advisory-only" test from a vacuous assertion into a real regression guard (stubs bot-gh.sh/gh both via PATH and via the literal ${CLAUDE_PLUGIN_ROOT}/scripts/bot-gh.sh path the advisory string itself uses), and adds version_ge differing-length-component coverage. --- .claude/scripts/sync-managed-files.test.sh | 82 ++++++++++++++++++---- .claude/skills/sync/SKILL.md | 18 +++-- .claude/skills/sync/sync.sh | 14 ++-- 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/.claude/scripts/sync-managed-files.test.sh b/.claude/scripts/sync-managed-files.test.sh index 228fe61..de95eae 100755 --- a/.claude/scripts/sync-managed-files.test.sh +++ b/.claude/scripts/sync-managed-files.test.sh @@ -59,11 +59,13 @@ # absent / present-but-empty / present-with-activity (both files, not just # worker-tools.jsonl), referencing issue #137 for the gap case, without ever # attempting to create/fix those files. -# 14. deploy-lag check: `.claude/state/loop-runs.log` absent / empty / a recent ts= -# (looks ACTIVE, wait) / an old ts= (looks idle, safe to restart) / a last line with -# no parseable `ts=` field at all (must still exit 0 and print the "could not parse" -# fallback, not abort the whole script under `set -euo pipefail`) all produce the -# expected re-arm/restart guidance without sync.sh touching the file or any unit. +# 14. deploy-lag check: `.claude/state/loop-runs.log` absent / empty / a recent ts= / an +# old ts= / a last line with no parseable `ts=` field at all (must still exit 0 and +# print the "could not parse" fallback, not abort the whole script under `set +# -euo pipefail`) all produce report-only guidance without sync.sh touching the file +# or any unit — and, since the ledger only ever logs FINISHED runs (issue #141 review +# round), neither a recent nor an old ts= is ever read as an "active" or "safe to +# restart" verdict; both point the operator at an independent liveness check instead. # 15. self-hosting quiets the remediation-flavored advisories (module/needs-human label # creation) while still running the plain informational reads (env, observability, # deploy-lag) with no crash and exit 0 — mirrors the self_hosting short-circuit @@ -416,6 +418,22 @@ out11g="$(bash "$plugin_v_missing/skills/sync/sync.sh" "$t11" 2>&1)" check_output "s11g: missing version field -> could not parse message, no crash" "$out11g" \ "env: plugin version — could not parse a \"version\" field from" +# 11h. Shorter version string than the floor ("0.2" vs required "0.2.2") -- version_ge's +# missing-component-defaults-to-0 path -- must compare as BELOW, not "unparseable". +plugin_v_short="$work/plugin-v-short" +build_plugin_fixture_with_version "$plugin_v_short" "0.2" +out11h="$(bash "$plugin_v_short/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11h: shorter version below floor -> BELOW, not unparseable" "$out11h" \ + "env: plugin version — v0.2 is BELOW required v0.2.2" + +# 11i. Longer version string than the floor ("0.2.2.1" vs required "0.2.2") -- the extra +# trailing component must compare as >= 0, not push the comparison the wrong way. +plugin_v_long="$work/plugin-v-long" +build_plugin_fixture_with_version "$plugin_v_long" "0.2.2.1" +out11i="$(bash "$plugin_v_long/skills/sync/sync.sh" "$t11" 2>&1)" +check_output "s11i: longer version at floor -> OK, not BELOW" "$out11i" \ + "env: plugin version — v0.2.2.1 >= required v0.2.2" + # --------------------------------------------------------------------------- # Scenario 12: missing-labels check (issue #141 item 3) — derived from the target's # .claude/gates.json modules[], printed as exact bot-gh.sh advisory commands. @@ -439,7 +457,37 @@ check_output "s12: module:bar label advisory derived" "$out12" \ 'bot-gh.sh label create "module:bar" --description "Bar module" --force' check_output "s12: needs-human label advisory with color" "$out12" \ 'bot-gh.sh label create "needs-human" --description "Loop is blocked on owner judgment -- see the issue/PR body/comments" --color b60205 --force' -check "s12: no labels actually created (advisory only, no network call)" test 1 -eq 1 + +# Real regression guard (not just string-matching the printed advisory): stub `bot-gh.sh` +# and `gh` so any invocation -- accidental or otherwise -- leaves evidence in a marker +# file, then assert a fresh sync.sh run over the SAME fixture never touched either stub. +# Today the label-create commands are pure printed text (never executed), but this is what +# actually proves "advisory only, no network call" instead of just re-asserting it. +# Covers the two realistic regression shapes: (a) a bare `bot-gh.sh`/`gh` invocation +# resolved via PATH lookup, and (b) the EXACT `${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/ +# bot-gh.sh` construction already sitting in the advisory string -- if that were ever +# copy-pasted into a real `bash ...` call, it would resolve under whatever +# CLAUDE_PLUGIN_ROOT is set to, so the stub is ALSO placed at that path and +# CLAUDE_PLUGIN_ROOT pointed at it (sync.sh itself never reads this var -- it derives its +# own plugin_root from $script_dir -- so exporting it here cannot mask a real regression). +label_stub_dir="$work/label-stub-bin" +mkdir -p "$label_stub_dir" +label_stub_root="$work/label-stub-root" +mkdir -p "$label_stub_root/scripts" +label_stub_marker="$work/label-stub-called" +rm -f "$label_stub_marker" +for stub_path in "$label_stub_dir/bot-gh.sh" "$label_stub_dir/gh" "$label_stub_root/scripts/bot-gh.sh"; do + cat >"$stub_path" <>"$label_stub_marker" +exit 0 +STUB + chmod +x "$stub_path" +done +PATH="$label_stub_dir:$PATH" CLAUDE_PLUGIN_ROOT="$label_stub_root" GATES_FILE="$t12/.claude/gates.json" \ + bash "$plugin_v_ok/skills/sync/sync.sh" "$t12" >/dev/null 2>&1 +check "s12: no labels actually created (advisory only, bot-gh.sh/gh stub never invoked)" \ + test ! -f "$label_stub_marker" t12b="$work/consumer12b" mkdir -p "$t12b" @@ -494,27 +542,35 @@ check_output "s13e: events.jsonl present-with-activity reported distinctly" "$ou t14="$work/consumer14" mkdir -p "$t14/.claude/state" out14a="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" -check_output "s14a: no loop-runs.log -> loop does not look armed" "$out14a" \ - "deploy-lag: .claude/state/loop-runs.log — not found; loop does not look armed here" +check_output "s14a: no loop-runs.log -> not found, usually means never armed" "$out14a" \ + "deploy-lag: .claude/state/loop-runs.log — not found; usually means the loop was never armed here" +check_output "s14a: no loop-runs.log -> same honest verify-independently instruction" "$out14a" \ + "verify independently (e.g. \`systemctl --user status 'pr-loop-driver-*'\`)" : >"$t14/.claude/state/loop-runs.log" out14b="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" -check_output "s14b: empty ledger -> safe to restart now" "$out14b" \ +check_output "s14b: empty ledger -> present but empty" "$out14b" \ "deploy-lag: .claude/state/loop-runs.log — present but empty" +check_output "s14b: empty ledger -> same honest message, no false 'safe to restart' claim" "$out14b" \ + "verify independently that no driver is currently active" +check_no_output "s14b: empty ledger -> never asserts restarting should be safe" "$out14b" \ + "restarting the loop units now should be safe" recent_ts="$(date -u +%FT%TZ)" printf 'pid=123 session=abc verdict=advance-issue=1 ts=%s result=exit rc=0\n' "$recent_ts" \ >"$t14/.claude/state/loop-runs.log" out14c="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" -check_output "s14c: recent run -> may still be ACTIVE, restart ONLY between drivers" "$out14c" \ - "may still be ACTIVE; re-arm/restart the pr-loop/claude-rc systemd units ONLY BETWEEN drivers" +check_output "s14c: recent run -> never claims active/idle, tells operator to verify independently" "$out14c" \ + "this ledger only records FINISHED runs, so its recency cannot prove a driver isn't running right now" old_ts="$(date -u -d '-3600 seconds' +%FT%TZ)" printf 'pid=123 session=abc verdict=advance-issue=1 ts=%s result=exit rc=0\n' "$old_ts" \ >"$t14/.claude/state/loop-runs.log" out14d="$(bash "$plugin_v_ok/skills/sync/sync.sh" "$t14" 2>&1)" -check_output "s14d: old run -> looks idle, safe to restart" "$out14d" \ - "loop looks idle — looks safe to re-arm/restart" +check_output "s14d: old run -> same honest message, no false 'safe to restart' claim" "$out14d" \ + "verify independently that no driver is currently active" +check_no_output "s14d: old run -> never asserts idle is safe to restart" "$out14d" \ + "looks safe to re-arm/restart" # 14e. Last line has no parseable ts= field at all — a correctness regression fixed by # this issue #141 review round: under `set -euo pipefail`, the `grep -o 'ts=...' | ... | diff --git a/.claude/skills/sync/SKILL.md b/.claude/skills/sync/SKILL.md index a17f0f4..937b6a5 100644 --- a/.claude/skills/sync/SKILL.md +++ b/.claude/skills/sync/SKILL.md @@ -104,11 +104,14 @@ version-marked managed files, not the "create if absent" ci files. It then prints four more offline sections (issue #141, "sync v2") — each report-only, never mutates anything, always exits 0 regardless of what it finds: - - `deploy-lag:` — reads `.claude/state/loop-runs.log` (the loop daemon's run ledger) and reports whether - the last driver run looks recent (**may still be ACTIVE**) or idle (**looks safe to restart**). This is - the offline half of the "re-arm/restart ONLY between drivers" guidance the migration caveat above - already gives — use it to decide *when*, not *whether*, to restart. `not found` means the loop doesn't - look armed here at all. + - `deploy-lag:` — reads `.claude/state/loop-runs.log` (the loop daemon's run ledger) and reports how long + ago the last recorded driver run started. The ledger only ever logs a run AFTER it has already finished, + so its recency can never prove a driver isn't running right now — sync.sh therefore never claims + "active" or "safe to restart" from this alone; it always tells the operator to verify independently + (e.g. `systemctl --user status 'pr-loop-driver-*'`) before re-arming/restarting the pr-loop/claude-rc + systemd units (issue #131). `not found` usually means the loop was never armed here, but — same caveat — + a first driver run in progress hasn't appended a line yet either, so this too is never read as proof no + driver is active. - `env:` — (a) whether `.env` exists and carries a `GH_BOT_TOKEN=` key (the token's **value** is never read or printed, only whether the key is present); (b) whether the installed plugin version (read from `.claude-plugin/plugin.json`) is at least the floor this sync ships (currently the issue #136 release, @@ -166,8 +169,9 @@ version-marked managed files, not the "create if absent" ci files. touched by sync — those stay exactly as the human left them. If any `stale-vendor` line was printed, surface it prominently (don't bury it in the managed-file summary) along with the migration caveat about restarting the armed systemd units after the human cleans up a stale local copy. Also summarize the sync - v2 sections: deploy-lag verdict (active vs. idle — and whether you actually restarted anything, and when), - the environment check (token key present? plugin version floor met?), which labels (if any) you created + v2 sections: deploy-lag (age of the last completed run, plus whether you independently verified no driver + is currently active before restarting anything, and when), the environment check (token key present? + plugin version floor met?), which labels (if any) you created after the live existence check, and the observability-plumbing verdict (flag issue #137 by number if either state file is absent/empty — don't attempt to fix it here). diff --git a/.claude/skills/sync/sync.sh b/.claude/skills/sync/sync.sh index 7862e2c..2c5c5dd 100755 --- a/.claude/skills/sync/sync.sh +++ b/.claude/skills/sync/sync.sh @@ -227,13 +227,13 @@ detect_stale_vendor_copies() { check_deploy_lag() { local log="$target_root/.claude/state/loop-runs.log" if [ ! -f "$log" ]; then - echo " deploy-lag: .claude/state/loop-runs.log — not found; loop does not look armed here (nothing to check before a restart, but confirm with the operator before assuming that)" + echo " deploy-lag: .claude/state/loop-runs.log — not found; usually means the loop was never armed here, but a first driver run in progress hasn't appended a line yet either, so a missing ledger cannot prove no driver is active — before re-arming/restarting the pr-loop/claude-rc systemd units (issue #131), verify independently (e.g. \`systemctl --user status 'pr-loop-driver-*'\`) rather than trusting a missing file alone" return 0 fi local last_line last_line="$(tail -n 1 "$log" 2>/dev/null || true)" if [ -z "$last_line" ]; then - echo " deploy-lag: .claude/state/loop-runs.log — present but empty; no driver runs recorded yet, restarting the loop units now should be safe" + echo " deploy-lag: .claude/state/loop-runs.log — present but empty; no COMPLETED driver runs recorded yet — this ledger only records FINISHED runs (see below), so an empty file cannot prove the very first driver isn't currently mid-run; before re-arming/restarting the pr-loop/claude-rc systemd units (issue #131), verify independently that no driver is currently active (e.g. \`systemctl --user status 'pr-loop-driver-*'\`) rather than trusting an empty ledger alone" return 0 fi echo " deploy-lag: .claude/state/loop-runs.log — last recorded run: $last_line" @@ -252,11 +252,11 @@ check_deploy_lag() { now_epoch="$(date -u +%s)" age_s=$((now_epoch - last_epoch)) if [ "$age_s" -lt 0 ]; then age_s=0; fi - if [ "$age_s" -lt 300 ]; then - echo " deploy-lag: last driver run started ~${age_s}s ago — a driver may still be ACTIVE; re-arm/restart the pr-loop/claude-rc systemd units ONLY BETWEEN drivers (issue #131) — wait and re-check loop-runs.log before restarting" - else - echo " deploy-lag: last driver run started ~${age_s}s ago and the loop looks idle — looks safe to re-arm/restart the pr-loop/claude-rc systemd units now (issue #131); still worth a final glance at loop-runs.log before pulling the trigger" - fi + # NOT an active/idle verdict (issue #141 review round): loop-daemon.sh only appends this + # line AFTER the driver it describes has already exited (see append_ledger's call site), + # so every entry here is by construction a FINISHED run — its recency can't prove a NEW + # driver hasn't started since. Recency alone must never be read as "safe to restart". + echo " deploy-lag: that run started ~${age_s}s ago and has since completed — this ledger only records FINISHED runs, so its recency cannot prove a driver isn't running right now; before re-arming/restarting the pr-loop/claude-rc systemd units (issue #131), verify independently that no driver is currently active (e.g. \`systemctl --user status 'pr-loop-driver-*'\`) rather than trusting this timestamp alone" } # --- version compare helper (issue #141 item 2) --------------------------------------