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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
916 changes: 916 additions & 0 deletions hooks/scripts/cc-task-gate.impl.sh

Large diffs are not rendered by default.

944 changes: 45 additions & 899 deletions hooks/scripts/cc-task-gate.sh

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions hooks/scripts/escape-grant.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,38 @@
# Repo root for `python3 -m shared.governance.coord_capabilities`. SCRIPT_DIR is
# set by the sourcing shim (it lives in hooks/scripts); fall back to this file's
# own directory when sourced standalone.
#
# Reform FM-6 (gate-shim collapse): the canonical-deployed gate runs this from
# ~/.local/lib/hapax/hooks, where ../.. carries no shared/ tree. So when the
# in-tree ../.. resolution doesn't land on a checkout that has shared/, fall back
# to an explicit override or a known stable full checkout. Every branch is gated
# on shared/ presence so a wrong dir is never selected, and the original ../..
# output is preserved as the last resort — escape_grant_allows still degrades
# CLOSED (returns 1) if none carries shared/, exactly as before this fallback.
_escape_grant_repo_root() {
local d="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
(cd "$d/../.." 2>/dev/null && pwd)
# 1. Explicit override (deploy / operator), only if it carries shared/.
if [[ -n "${HAPAX_COORD_REPO_ROOT:-}" && -d "$HAPAX_COORD_REPO_ROOT/shared" ]]; then
printf '%s\n' "$HAPAX_COORD_REPO_ROOT"
return 0
fi
# 2. In-repo invocation: this file lives at <repo>/hooks/scripts/ (unchanged).
local d r
d="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
r="$(cd "$d/../.." 2>/dev/null && pwd)"
if [[ -n "$r" && -d "$r/shared" ]]; then
printf '%s\n' "$r"
return 0
fi
# 3. Canonical-deployed gate: prefer a stable checkout kept fresh by the
# rebuild/source-activation timer; the interactive worktree is a last resort.
local c
for c in \
"${XDG_CACHE_HOME:-$HOME/.cache}/hapax/rebuild/worktree" \
"$HOME/projects/hapax-council"; do
[[ -d "$c/shared" ]] && { printf '%s\n' "$c"; return 0; }
done
# 4. Last resort: original ../.. output (degrade-closed if it lacks shared/).
printf '%s\n' "$r"
}

# Record that a grant was honored (the audit's "recorded" property). Best-effort;
Expand Down
274 changes: 274 additions & 0 deletions hooks/scripts/hooks-doctor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
#!/usr/bin/env bash
# hooks-doctor.sh — gate-drift detector + canonical deployer (reform FM-6).
#
# The cc-task-gate used to be physically copied into every worktree, so the fleet
# carried SIX gate versions (427/651/779/786/832/905 lines) and the oldest lanes
# silently violated INV-5 (cognition-always-writable) because their 427-line gate
# predates is_cognition_path. The fix is one canonical impl + thin shims; this
# tool is the drift detector the design promised, plus the deployer/fanout that
# makes "update the gate" a one-file change.
#
# Modes:
# --session (default) advisory fleet report; ALWAYS exit 0 (never
# wedges a SessionStart); prints CRITICAL/WARN drift lines.
# --check | --strict strict: exit 1 on CRITICAL drift (CI + manual). The
# committed cc-task-gate.sh MUST be a shim and the impl
# MUST carry is_cognition_path, else CI refuses.
# --deploy-canonical copy the gate closure (impl + agent-role + escape-grant +
# this doctor) to $HAPAX_CANONICAL_HOOKS and write a
# sha256 MANIFEST; symlink ~/.local/bin/hapax-hooks-doctor.
# --fanout rewrite every lane worktree's cc-task-gate.sh to the
# canonical shim (the one-shot that fixes INV-5 on stale
# lanes immediately, without waiting for them to rebase).
# --classify FILE print a single gate file's classification; exit nonzero
# if it is drifted (used by the test-suite + ad-hoc).
#
# Options: --from DIR (deploy source, default this repo), --root DIR (override the
# repo/worktree root for --check/fleet, for tests), --dry-run, --verbose|-v,
# --notify (best-effort ntfy on drift, for the timer/service).
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CANONICAL_DIR="${HAPAX_CANONICAL_HOOKS:-$HOME/.local/lib/hapax/hooks}"
SHIM_MARKER="HAPAX-GATE-SHIM"
COGNITION_MARKER="is_cognition_path()"

MODE=session
FROM=""
ROOT_OVERRIDE=""
DRY=0
VERBOSE=0
NOTIFY=0
CLASSIFY_FILE=""

while [[ $# -gt 0 ]]; do
case "$1" in
--session) MODE=session ;;
--check|--strict) MODE=check ;;
--deploy-canonical|--deploy) MODE=deploy ;;
--fanout) MODE=fanout ;;
--classify) MODE=classify; CLASSIFY_FILE="${2:?--classify needs a FILE}"; shift ;;
--from) FROM="${2:?--from needs a DIR}"; shift ;;
--root) ROOT_OVERRIDE="${2:?--root needs a DIR}"; shift ;;
--dry-run) DRY=1 ;;
--verbose|-v) VERBOSE=1 ;;
--notify) NOTIFY=1 ;;
-h|--help) grep -E '^#( |$)' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "hooks-doctor: unknown argument: $1" >&2; exit 64 ;;
esac
shift
done

# Repo/worktree root: --root wins (tests), else this file's repo, else its dir.
if [[ -n "$ROOT_OVERRIDE" ]]; then
REPO_ROOT="$ROOT_OVERRIDE"
else
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd || echo "$SCRIPT_DIR")"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the doctor symlink before deriving repo root

After --deploy-canonical creates ~/.local/bin/hapax-hooks-doctor as a symlink to this script, invoking that documented command without --root makes SCRIPT_DIR resolve to the symlink directory (~/.local/bin), so this fallback root becomes ~/.local instead of the council checkout. In that installed path, hapax-hooks-doctor --check, --fanout, or the warning-suggested --deploy-canonical inspect ~/.local/hooks/scripts and fail/miss the worktree fleet unless the caller knows to pass overrides; resolve the script path or use a stable checkout default before computing ../...

Useful? React with 👍 / 👎.

fi

# classify_gate <gate-file> — echo a label; return 0 shim, 2 warn-drift,
# 3 critical-drift, 4 missing. A "shim" carries the HAPAX-GATE-SHIM marker and
# resolves to canonical (so it inherits INV-5). A non-shim full gate copy is
# drift; it is CRITICAL when it also lacks is_cognition_path (the stale-lane
# INV-5 violation), else a WARN (drifted but carve-out present).
classify_gate() {
local f="$1"
if [[ ! -e "$f" ]]; then echo "missing"; return 4; fi
if grep -q "$SHIM_MARKER" "$f" 2>/dev/null; then echo "shim"; return 0; fi
if grep -q "$COGNITION_MARKER" "$f" 2>/dev/null; then echo "drift-warn"; return 2; fi
echo "drift-critical"; return 3
}

# check_canonical — the deployed impl all shims resolve to must exist, be the impl
# (not a shim), and carry INV-5. Echoes a status line; return 0 ok, 3 critical.
check_canonical() {
local c="$CANONICAL_DIR/cc-task-gate.sh" s
if [[ ! -r "$c" ]]; then echo "CRITICAL canonical impl missing: $c"; return 3; fi
if grep -q "$SHIM_MARKER" "$c" 2>/dev/null; then echo "CRITICAL canonical is a shim, not the impl: $c"; return 3; fi
if ! grep -q "$COGNITION_MARKER" "$c" 2>/dev/null; then echo "CRITICAL canonical impl lacks INV-5 is_cognition_path: $c"; return 3; fi
for s in agent-role.sh escape-grant.sh; do
[[ -r "$CANONICAL_DIR/$s" ]] || { echo "CRITICAL canonical closure missing sibling: $CANONICAL_DIR/$s"; return 3; }
done
echo "ok canonical healthy: $c"; return 0
}

# check_repo_self — the committed gate in REPO_ROOT must be a shim, and the impl
# must exist with INV-5. This is the CI regression guard: nobody commits a full
# gate copy back into cc-task-gate.sh. Echoes findings; return 0 ok, 3 critical.
check_repo_self() {
local rc=0
local shim="$REPO_ROOT/hooks/scripts/cc-task-gate.sh"
local impl="$REPO_ROOT/hooks/scripts/cc-task-gate.impl.sh"
if [[ ! -e "$shim" ]]; then
echo "CRITICAL $shim missing"; rc=3
elif ! grep -q "$SHIM_MARKER" "$shim" 2>/dev/null; then
echo "CRITICAL $shim is NOT a shim (regressed to a physical gate copy)"; rc=3
fi
if [[ ! -r "$impl" ]]; then
echo "CRITICAL $impl missing"; rc=3
elif ! grep -q "$COGNITION_MARKER" "$impl" 2>/dev/null; then
echo "CRITICAL $impl lacks INV-5 is_cognition_path"; rc=3
fi
return "$rc"
}

# list_lane_worktrees — lane worktrees only (basename hapax-council[--*]); the
# SHA-named source-activation release snapshots and the rebuild/worktree (which
# self-heals on rebuild) are intentionally excluded.
list_lane_worktrees() {
git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null \
| awk '/^worktree /{sub(/^worktree /,""); print}' \
| while IFS= read -r wt; do
case "$(basename "$wt")" in
hapax-council|hapax-council--*) printf '%s\n' "$wt" ;;
esac
done
}

# check_fleet — classify each lane worktree's gate. Echoes WARN/CRITICAL lines
# (and ok lines when --verbose). Return 0 clean, 2 warn-only, 3 any-critical.
check_fleet() {
local rc=0 wt gate label crc
while IFS= read -r wt; do
[[ -n "$wt" ]] || continue
gate="$wt/hooks/scripts/cc-task-gate.sh"
[[ -e "$gate" ]] || continue
label="$(classify_gate "$gate")"; crc=$?
case "$crc" in
0) [[ "$VERBOSE" = 1 ]] && echo "ok $wt [$label]" ;;
2) echo "WARN $wt [$label] — drifted full gate (INV-5 carve-out present)"; (( rc < 2 )) && rc=2 ;;
3) echo "CRITICAL $wt [$label] — stale full gate WITHOUT INV-5 carve-out"; rc=3 ;;
4) [[ "$VERBOSE" = 1 ]] && echo "ok $wt [no gate]" ;;
esac
done < <(list_lane_worktrees)
return "$rc"
}

_notify() {
[[ "$NOTIFY" = 1 ]] || return 0
local msg="$1"
if command -v hapax-notify >/dev/null 2>&1; then
hapax-notify "hooks-doctor" "$msg" >/dev/null 2>&1 && return 0
fi
( cd "$REPO_ROOT" 2>/dev/null \
&& python3 -c 'import sys
try:
from shared.notify import send_notification
send_notification("hooks-doctor: gate drift", sys.argv[1], priority="high", tags=["warning"])
except Exception:
pass' "$msg" ) >/dev/null 2>&1 || true
}

deploy_canonical() {
local from="${FROM:-$REPO_ROOT}" src
src="$from/hooks/scripts"
if [[ ! -r "$src/cc-task-gate.impl.sh" ]]; then
echo "deploy: source impl missing: $src/cc-task-gate.impl.sh" >&2
return 1
fi
if ! grep -q "$COGNITION_MARKER" "$src/cc-task-gate.impl.sh" 2>/dev/null; then
echo "deploy: REFUSING to deploy an impl that lacks INV-5 is_cognition_path: $src/cc-task-gate.impl.sh" >&2
return 1
fi
if [[ "$DRY" = 1 ]]; then
echo "[dry-run] would deploy gate closure: $src -> $CANONICAL_DIR"
return 0
fi
mkdir -p "$CANONICAL_DIR"
# impl deploys AS cc-task-gate.sh (the name shims + settings.json resolve to).
install -m 0755 "$src/cc-task-gate.impl.sh" "$CANONICAL_DIR/cc-task-gate.sh"
Comment on lines +177 to +179
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail deployment/fanout on write errors instead of reporting success.

Line 177, Line 179, Line 188, and Line 210 perform state-changing writes without checking success. Since the script is not running with set -e, failures can be silently ignored while printing successful deploy/shim messages.

Suggested hard-fail guards for mutating writes
-  mkdir -p "$CANONICAL_DIR"
+  mkdir -p "$CANONICAL_DIR" || { echo "deploy: failed to create $CANONICAL_DIR" >&2; return 1; }
   # impl deploys AS cc-task-gate.sh (the name shims + settings.json resolve to).
-  install -m 0755 "$src/cc-task-gate.impl.sh" "$CANONICAL_DIR/cc-task-gate.sh"
+  install -m 0755 "$src/cc-task-gate.impl.sh" "$CANONICAL_DIR/cc-task-gate.sh" \
+    || { echo "deploy: failed to install canonical gate impl" >&2; return 1; }
@@
-  ln -sf "$CANONICAL_DIR/hooks-doctor.sh" "$bindir/hapax-hooks-doctor"
+  ln -sf "$CANONICAL_DIR/hooks-doctor.sh" "$bindir/hapax-hooks-doctor" \
+    || { echo "deploy: failed to create hapax-hooks-doctor symlink" >&2; return 1; }
@@
-    install -m 0755 "$shim_src" "$gate"
+    install -m 0755 "$shim_src" "$gate" || { echo "fanout: failed to write $gate" >&2; return 1; }
     echo "shimmed  $gate"
     n=$((n + 1))

Also applies to: 188-189, 210-212

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/scripts/hooks-doctor.sh` around lines 177 - 179, The script currently
performs mutating operations (e.g., mkdir -p "$CANONICAL_DIR" and install -m
0755 "$src/cc-task-gate.impl.sh" "$CANONICAL_DIR/cc-task-gate.sh") and other
state-changing writes at the other noted locations (lines around 188-189 and
210-212) without checking their exit status; update each mutating command so
failures are detected and cause the script to fail the deployment/fanout instead
of printing success—for each command (mkdir, install and the other write/replace
operations referenced) check the command's exit code and on non-zero log an
error and exit non-zero (or use a strict error handler like set -e preceded by
explicit error messages) so writes cannot be silently ignored.

local s
for s in agent-role.sh escape-grant.sh hooks-doctor.sh; do
[[ -r "$src/$s" ]] && install -m 0755 "$src/$s" "$CANONICAL_DIR/$s"
done
Comment on lines +181 to +183
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include the bootstrap helper in the canonical gate closure

When the canonical gate is deployed, this closure only installs the impl and three shell siblings, but cc-task-gate.impl.sh now executes $SCRIPT_DIR/cc-task-gate-bootstrap.py on every protected mutation before claim validation. In the canonical layout (~/.local/lib/hapax/hooks) that Python helper is absent, so python3 returns an error, the *) exit 2 branch is taken, and all non-cognition mutating tool calls are blocked even for valid claimed sessions. The post-merge staging list also omits this file, so the helper needs to be staged and installed with the rest of the closure.

Useful? React with 👍 / 👎.

Comment on lines +181 to +183
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Refuse deploy if any gate-closure sibling is missing in source.

Line 181 currently treats closure siblings as optional. That can deploy a partial or mixed-version canonical closure when source inputs are incomplete.

Require full source closure before deploy
-  local s
-  for s in agent-role.sh escape-grant.sh hooks-doctor.sh; do
-    [[ -r "$src/$s" ]] && install -m 0755 "$src/$s" "$CANONICAL_DIR/$s"
-  done
+  local s
+  for s in agent-role.sh escape-grant.sh hooks-doctor.sh; do
+    [[ -r "$src/$s" ]] || { echo "deploy: source closure missing $src/$s" >&2; return 1; }
+    install -m 0755 "$src/$s" "$CANONICAL_DIR/$s" || { echo "deploy: failed to install $s" >&2; return 1; }
+  done
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for s in agent-role.sh escape-grant.sh hooks-doctor.sh; do
[[ -r "$src/$s" ]] && install -m 0755 "$src/$s" "$CANONICAL_DIR/$s"
done
for s in agent-role.sh escape-grant.sh hooks-doctor.sh; do
[[ -r "$src/$s" ]] || { echo "deploy: source closure missing $src/$s" >&2; return 1; }
install -m 0755 "$src/$s" "$CANONICAL_DIR/$s" || { echo "deploy: failed to install $s" >&2; return 1; }
done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/scripts/hooks-doctor.sh` around lines 181 - 183, The loop that installs
closure siblings (for s in agent-role.sh escape-grant.sh hooks-doctor.sh)
currently treats missing source files as optional; change it to require all
siblings exist and are readable in $src before proceeding: iterate the same
list, test [[ -r "$src/$s" ]] and if any check fails, print a clear error
mentioning the missing $s and exit 1 (refuse deploy); only when all checks pass
run the install -m 0755 "$src/$s" "$CANONICAL_DIR/$s" for each file.

( cd "$CANONICAL_DIR" && sha256sum cc-task-gate.sh agent-role.sh escape-grant.sh hooks-doctor.sh 2>/dev/null ) \
> "$CANONICAL_DIR/MANIFEST.sha256" 2>/dev/null || true
local bindir="${HAPAX_LOCAL_BIN:-$HOME/.local/bin}"
mkdir -p "$bindir"
ln -sf "$CANONICAL_DIR/hooks-doctor.sh" "$bindir/hapax-hooks-doctor"
echo "deployed gate closure -> $CANONICAL_DIR (from $src)"
check_canonical
}

fanout() {
local shim_src="$REPO_ROOT/hooks/scripts/cc-task-gate.sh" wt gate n=0
if ! grep -q "$SHIM_MARKER" "$shim_src" 2>/dev/null; then
echo "fanout: $shim_src is not the shim; aborting" >&2
return 1
fi
while IFS= read -r wt; do
[[ -n "$wt" ]] || continue
gate="$wt/hooks/scripts/cc-task-gate.sh"
[[ -d "$wt/hooks/scripts" ]] || continue
if grep -q "$SHIM_MARKER" "$gate" 2>/dev/null; then
[[ "$VERBOSE" = 1 ]] && echo "skip $gate (already shim)"
continue
fi
if [[ "$DRY" = 1 ]]; then
echo "[dry-run] would shim $gate"; n=$((n + 1)); continue
fi
install -m 0755 "$shim_src" "$gate"
echo "shimmed $gate"
n=$((n + 1))
done < <(list_lane_worktrees)
echo "fanout: $n gate(s) updated$([[ "$DRY" = 1 ]] && echo ' (dry-run)')"
}

case "$MODE" in
classify)
classify_gate "$CLASSIFY_FILE"
exit $?
;;
deploy)
deploy_canonical
exit $?
;;
fanout)
fanout
exit $?
;;
check)
rc=0
if out="$(check_repo_self)"; then :; else rc=3; fi
[[ -n "${out:-}" ]] && echo "$out"
if [[ -e "$CANONICAL_DIR/cc-task-gate.sh" ]]; then
if cout="$(check_canonical)"; then :; else rc=3; fi
echo "$cout"
else
echo "note: no deployed canonical at $CANONICAL_DIR (ok in CI / pre-deploy)"
fi
if git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
fout="$(check_fleet)"; fc=$?
[[ -n "$fout" ]] && echo "$fout"
(( fc >= 3 )) && rc=3
fi
if (( rc >= 3 )); then
echo "hooks-doctor: CRITICAL gate drift — REFUSE"
_notify "CRITICAL gate drift detected"
exit 1
fi
echo "hooks-doctor: gate fleet clean (no critical drift)"
exit 0
;;
session)
# Advisory: never wedge a session. Print canonical + fleet drift, exit 0.
crit=0
if [[ -e "$CANONICAL_DIR/cc-task-gate.sh" ]]; then
cout="$(check_canonical)" || crit=1
[[ "$VERBOSE" = 1 || "$crit" = 1 ]] && echo "$cout"
else
echo "hooks-doctor: no deployed canonical gate at $CANONICAL_DIR — run 'hapax-hooks-doctor --deploy-canonical'"
crit=1
fi
if git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
fout="$(check_fleet)"; fc=$?
[[ -n "$fout" ]] && echo "$fout"
(( fc >= 3 )) && crit=1
fi
if (( crit == 1 )); then
echo "hooks-doctor: gate drift present (advisory) — run 'hapax-hooks-doctor --check' / '--fanout' / '--deploy-canonical'"
_notify "gate drift present (advisory)"
fi
exit 0
;;
esac
4 changes: 3 additions & 1 deletion scripts/check-peer-glob-coherence.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ class PeerGroup:
members=(
"scripts/cc-claim",
"scripts/cc-close",
"hooks/scripts/cc-task-gate.sh",
# The gate's single-task lookup lives in the impl behind the
# stable-abs-path shim (reform FM-6); the shim carries no vault globs.
"hooks/scripts/cc-task-gate.impl.sh",
),
description=(
"single-task lookup under hapax-cc-tasks/active/ — "
Expand Down
40 changes: 40 additions & 0 deletions scripts/hapax-post-merge-deploy
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
# systemd/logrotate.d/* → logrotate configs; require sudo install
# systemd/system/** → system-scoped configs; require sudo install
# scripts/hapax-* → ensure ~/.local/bin/ symlink
# hooks/scripts/{cc-task-gate.impl.sh,agent-role.sh,escape-grant.sh,
# hooks-doctor.sh,cc-task-gate.sh}
# → redeploy the ONE canonical gate closure to
# ~/.local/lib/hapax/hooks via hooks-doctor
# --deploy-canonical (reform FM-6: every
# worktree's cc-task-gate.sh is a shim that
# resolves here, so "update the gate" is a
# one-file change, not a 26-worktree fan-out)
#
# Usage:
# hapax-post-merge-deploy <commit-sha>
Expand Down Expand Up @@ -315,6 +323,7 @@ else
fi

PW=(); HAPAX_CONF=(); WP=(); WP_LUA=(); SYSTEMD=(); SYSTEMD_SYSTEM=(); SYSTEMD_DROPINS=(); USER_PRESETS=(); WATCHDOGS=(); SCRIPTS=(); SCRIPT_PY=()
GATE_CLOSURE=()
SYSTEMD_UNCLASSIFIED=()
AUDIO_SAFE_RESTARTS=()
DID_ANYTHING=0
Expand Down Expand Up @@ -444,6 +453,8 @@ while IFS= read -r f; do
systemd/udev/*.rules) ;; # udev rules; require sudo install + udevadm reload (operator action)
systemd/logrotate.d/*) ;; # logrotate configs; require sudo install
systemd/system/*) ;; # system-scoped configs; require sudo install
hooks/scripts/cc-task-gate.impl.sh|hooks/scripts/agent-role.sh|hooks/scripts/escape-grant.sh|hooks/scripts/hooks-doctor.sh|hooks/scripts/cc-task-gate.sh)
GATE_CLOSURE+=("$f") ;;
scripts/hapax-*) SCRIPTS+=("$f") ;;
scripts/*.py|scripts/*.sh) SCRIPT_PY+=("$f") ;;
systemd/expected-timers.yaml) ;; # frozen-baseline doc, not deployable
Expand Down Expand Up @@ -746,6 +757,35 @@ if [ "${#SCRIPT_PY[@]}" -gt 0 ]; then
done
fi

# --- canonical gate closure (reform FM-6 shim collapse) ---
# When the gate impl or its sourced siblings change, redeploy the ONE canonical
# copy that every worktree's cc-task-gate.sh shim resolves to. Materialize the
# closure from $SHA (commit-time content, decoupled from the working tree like
# every other deploy here), then hand it to hooks-doctor --deploy-canonical, which
# renames the impl to cc-task-gate.sh, writes a sha256 MANIFEST, symlinks
# ~/.local/bin/hapax-hooks-doctor, and REFUSES any impl missing the INV-5
# is_cognition_path carve-out (so a bad gate never lands fleet-wide).
if [ "${#GATE_CLOSURE[@]}" -gt 0 ]; then
echo "gate closure changed (${#GATE_CLOSURE[@]}): redeploying canonical gate"
GATE_STAGE="$(mktemp -d)"
mkdir -p "$GATE_STAGE/hooks/scripts"
for f in hooks/scripts/cc-task-gate.impl.sh hooks/scripts/agent-role.sh \
hooks/scripts/escape-grant.sh hooks/scripts/hooks-doctor.sh; do
if git cat-file -e "$SHA:$f" 2>/dev/null; then
git show "$SHA:$f" > "$GATE_STAGE/$f"
chmod +x "$GATE_STAGE/$f" 2>/dev/null || true
fi
done
if [ -x "$GATE_STAGE/hooks/scripts/hooks-doctor.sh" ]; then
bash "$GATE_STAGE/hooks/scripts/hooks-doctor.sh" --deploy-canonical --from "$GATE_STAGE" \
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fan out the shim when deploying the gate collapse

For the merge that converts cc-task-gate.sh into a shim, this deploy path only refreshes the canonical implementation; it never runs the new hooks-doctor --fanout path. Existing lane worktrees whose hooks/scripts/cc-task-gate.sh is still a full stale gate do not resolve the canonical copy at all, and the added SessionStart/timer doctor paths are advisory-only, so those lanes continue running the old gate until someone manually fanouts/rebases them. The post-merge deploy should rewrite lane gates to the shim after a successful canonical deploy so the stable-abs-path collapse actually reaches active worktrees.

Useful? React with 👍 / 👎.

|| echo "warning: canonical gate deploy failed" >&2
Comment on lines +780 to +781
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate canonical deploy failures

If hooks-doctor --deploy-canonical refuses the staged gate or cannot install into the canonical directory, this || echo converts the failure into a successful post-merge deploy and the script continues to mark work as done. For commits that changed the gate closure, that leaves the live canonical gate stale while the deploy trace reports completion, contrary to this script's contract that deploy-step failures exit nonzero; let the failure propagate after printing context.

Useful? React with 👍 / 👎.

else
echo "warning: hooks-doctor.sh absent at $SHA; skipping canonical gate deploy" >&2
Comment on lines +779 to +783
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not swallow canonical gate deploy failure in post-merge path.

Line 780-783 converts deploy errors into warnings and continues. That allows a “successful” post-merge deploy with stale canonical gate state.

Propagate deployment failure to caller
-    if [ -x "$GATE_STAGE/hooks/scripts/hooks-doctor.sh" ]; then
-        bash "$GATE_STAGE/hooks/scripts/hooks-doctor.sh" --deploy-canonical --from "$GATE_STAGE" \
-            || echo "warning: canonical gate deploy failed" >&2
-    else
-        echo "warning: hooks-doctor.sh absent at $SHA; skipping canonical gate deploy" >&2
-    fi
+    if [ -x "$GATE_STAGE/hooks/scripts/hooks-doctor.sh" ]; then
+        bash "$GATE_STAGE/hooks/scripts/hooks-doctor.sh" --deploy-canonical --from "$GATE_STAGE"
+    else
+        echo "canonical gate deploy unavailable: hooks-doctor.sh absent at $SHA" >&2
+        rm -rf "$GATE_STAGE" 2>/dev/null || true
+        exit 1
+    fi
@@
-    DID_ANYTHING=1
+    DID_ANYTHING=1

Also applies to: 786-786

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/hapax-post-merge-deploy` around lines 779 - 783, The post-merge
deploy currently swallows failures by appending "|| echo ..." when invoking
hooks-doctor.sh; change that so any non-zero exit from the canonical gate deploy
propagates to the caller instead of being downgraded to a warning. Specifically,
in the block that checks executable "$GATE_STAGE/hooks/scripts/hooks-doctor.sh"
and in the other similar block near the later occurrence, replace the "bash ...
|| echo 'warning: canonical gate deploy failed' >&2" pattern with logic that
runs bash, captures its exit status, prints a meaningful error to stderr, and
then exits with that same non-zero status (or simply let the script exit
non-zero), ensuring failures bubble up to the caller.

fi
rm -rf "$GATE_STAGE" 2>/dev/null || true
DID_ANYTHING=1
fi

if [ "$DID_ANYTHING" -eq 0 ]; then
echo "merge $SHA: no manual deploys needed (only auto-rebuild paths changed)"
fi
Expand Down
Loading
Loading