crewforth 3.1.1 → 3.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +56 -5
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +14 -13
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +117 -6
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-default.sh +36 -0
  30. package/kit/eval/lib/settings-json.awk +15 -4
  31. package/kit/eval/lib/star.sh +23 -7
  32. package/kit/eval/lib/statusline.settings.json +6 -0
  33. package/kit/eval/lib/statusline.sh +100 -0
  34. package/kit/eval/lib/version.sh +41 -0
  35. package/kit/eval/smoke-test.sh +576 -41
  36. package/kit/hooks/agent-outcome.sh +26 -12
  37. package/kit/hooks/guard-agent-model.sh +109 -9
  38. package/kit/hooks/guard-bash.sh +240 -182
  39. package/kit/hooks/guard-question.sh +73 -0
  40. package/kit/hooks/guard-write.sh +21 -0
  41. package/kit/hooks/prompt-approval.sh +320 -103
  42. package/kit/hooks/refuter-ledger.sh +80 -0
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/owned-blobs.tsv +4 -4
  45. package/kit/settings.json +33 -0
  46. package/kit/skills/crew-approve/SKILL.md +7 -0
  47. package/kit/skills/crew-doctor/SKILL.md +5 -1
  48. package/kit/skills/crew-handoff/SKILL.md +3 -2
  49. package/kit/skills/crew-map/SKILL.md +44 -0
  50. package/kit/skills/crew-review/SKILL.md +2 -0
  51. package/kit/skills/crew-update/SKILL.md +1 -1
  52. package/kit/skills/incident-runbook/SKILL.md +2 -2
  53. package/kit/skills/security-scan/references/verify.md +6 -3
  54. package/kit/studio/README.md +12 -0
  55. package/kit/studio/server/index.js +125 -11
  56. package/kit/studio/server/lib/code-stamp.js +35 -0
  57. package/kit/studio/server/lib/crew-map.js +113 -0
  58. package/kit/studio/server/lib/model-outcomes.js +130 -15
  59. package/kit/studio/server/lib/owned-stream.js +56 -0
  60. package/kit/studio/server/lib/pricing.js +3 -0
  61. package/kit/studio/server/lib/projects.js +11 -4
  62. package/kit/studio/server/lib/remote.js +99 -0
  63. package/kit/studio/server/lib/usage.js +74 -14
  64. package/kit/studio/web/app.js +223 -15
  65. package/kit/studio/web/canvas.js +44 -120
  66. package/kit/studio/web/chat.js +82 -17
  67. package/kit/studio/web/graph-plan.js +10 -4
  68. package/kit/studio/web/index.html +10 -5
  69. package/kit/studio/web/liveness.js +14 -0
  70. package/kit/studio/web/map-plan.js +341 -0
  71. package/kit/studio/web/map.js +503 -0
  72. package/kit/studio/web/models-plan.js +61 -7
  73. package/kit/studio/web/models.js +19 -3
  74. package/kit/studio/web/style.css +131 -0
  75. package/kit/studio/web/usage.js +12 -0
  76. package/kit/studio/web/viewport.js +160 -0
  77. package/package.json +6 -5
  78. package/start.sh +10 -0
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # guard-question.sh - the approval question reaches the person, not a hook (§4.4, 3.2.0).
3
+ #
4
+ # WHY. In `auto` the user can approve a commit or a push by answering one fixed question (AskUserQuestion), and
5
+ # hooks/prompt-approval.sh records the answer on PostToolUse. An answer is only worth recording if a person gave it.
6
+ # Two things can give it instead: another PreToolUse hook that answers `allow` with the answers filled in, and a
7
+ # PermissionRequest hook that answers the prompt before the person does (both measured on Claude Code 2.1.294).
8
+ #
9
+ # WHAT. Wired on PreToolUse for AskUserQuestion. Any question but the approval question gets no decision. The
10
+ # approval question must be the fixed text with no answers, asked in `auto` in an attended session; then this
11
+ # hook answers `ask`, which beats the first kind of hook in every mode, and only when no PermissionRequest hook
12
+ # may be wired (the second kind; _crew_prh_scan). It also notes the call it asked, and prompt-approval.sh records
13
+ # an answer to that call only. Everything it decides is defined in prompt-approval.sh (_crew_q_pre), sourced.
14
+ #
15
+ # A GATE, so an error of its own refuses the call (CREW-FAILCLOSED): a question let through with no `ask` is one a
16
+ # hook can answer.
17
+ # COST. Builtins on every AskUserQuestion; on the approval question the scan reads files and starts no process.
18
+ set -uo pipefail
19
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
20
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
21
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
22
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
23
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
24
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
25
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
26
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
27
+ # staged key all passed.
28
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
29
+ # scan for private strings read that as "no match".
30
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
31
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
32
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
33
+ # Byte-identical in every gate; the suite pins it.
34
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
35
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
36
+ export LC_ALL=C
37
+ # ---- /CREW-LOCALE
38
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
39
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
40
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
41
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
42
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
43
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
44
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
45
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
46
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
47
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
48
+ # that is not found (status 127, the script continues inside the same command).
49
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
50
+ _crew_stop(){ # $1 = what happened
51
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
52
+ echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
53
+ echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
54
+ exit 2
55
+ }
56
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
57
+ # ---- /CREW-FAILCLOSED
58
+ _gate_main(){
59
+ local here="${BASH_SOURCE%/*}"
60
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
61
+ IFS= read -r -d '' INPUT || true
62
+ case "$INPUT" in *'Crewforth approval'*) ;; *) exit 0 ;; esac # not the approval question: nothing to decide
63
+ if [ ! -f "$here/prompt-approval.sh" ]; then
64
+ echo "GUARD (§4.4): prompt-approval.sh is not beside this gate, so the approval question cannot be checked and is not asked. Update Crewforth; the user can type /crew-approve instead." >&2
65
+ exit 2
66
+ fi
67
+ . "$here/prompt-approval.sh"
68
+ declare -F _crew_q_pre >/dev/null 2>&1 || { echo "GUARD (§4.4): prompt-approval.sh beside this gate is an older one, so the approval question is not asked. Update Crewforth; the user can type /crew-approve instead." >&2; exit 2; }
69
+ _crew_q_pre
70
+ exit 0
71
+ }
72
+ _gate_main "$@"
73
+ _crew_stop "a command of the gate was abandoned"
@@ -388,6 +388,7 @@ WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/s
388
388
  WHY_DISC="This file is Crewforth's discipline document — it IS the text of §4.1-§4.5, so editing it empties the rules the gates enforce."
389
389
  WHY_LINK="A parent directory of this path is a symlink and it resolves into a gate directory, so the write would land on a gate file."
390
390
  WHY_MODEL="This file is what model routing decides from: the user's rules, a class's floor, the critical paths, or the record of a call and its result. Only the user and Crewforth's own hooks write it, so a session cannot lower its own floor or erase its own failure."
391
+ WHY_REFUTER="This file is the refuter ledger: what crew-refuter concluded about a finding, written by Crewforth's hook from the agent's own task and answer. A findings document is held to it, so a session that writes it would verify its own findings."
391
392
  WHY_APPR="This file records the user's own approval for a commit or a push (§4.4). Only the user's message writes it, so a session cannot approve its own commit."
392
393
  WHY_GITCFG="This file holds core.hooksPath: writing it can switch the git hooks off without touching one of them. Settings go through git config, which the Bash guard reads."
393
394
  WHY_LONG="The path in this payload is longer than any filesystem accepts. It is refused rather than parsed, because parsing it is the slow path an attacker would aim at."
@@ -545,6 +546,10 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
545
546
  # Matched by name wherever it is: a linked worktree keeps its git directory elsewhere.
546
547
  */[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]|[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll])
547
548
  GATE_RULE="approval-record edit (Write/Edit tools)"; GATE_WHY="$WHY_APPR"; return 0 ;;
549
+ # The refuter ledger (hooks/refuter-ledger.sh): what crew-refuter concluded, as the hook read it from the agent's
550
+ # own task and answer. findings.mjs --ledger trusts it; a session that writes it verifies its own findings.
551
+ */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-[Rr][Ee][Ff][Uu][Tt][Ee][Rr].[Tt][Ss][Vv]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-[Rr][Ee][Ff][Uu][Tt][Ee][Rr].[Tt][Ss][Vv])
552
+ GATE_RULE="gate-file edit (refuter ledger)"; GATE_WHY="$WHY_REFUTER"; return 0 ;;
548
553
  # What model routing decides from (hooks/guard-agent-model.sh, hooks/agent-outcome.sh): the user's own rules,
549
554
  # the floors raised from the record of outcomes, the list of critical paths, and the records of calls and
550
555
  # results. A session that writes one lowers its own floor or wipes its own failure.
@@ -631,6 +636,22 @@ case "$INPUT" in *'"agent_id"'*)
631
636
  esac
632
637
  fi
633
638
  fi ;;
639
+ *)
640
+ # The session's own write to a critical path (no agent_id: the main thread). Not refused (K7: the user may be
641
+ # doing exactly this, and an agent is a choice, not a rule); recorded, so it is seen: a NOTE line in the gate log,
642
+ # which Studio's Gates tab and the status line read. The same definition of "critical path" the agents are held
643
+ # to; a file outside the project is not the project's critical path.
644
+ if [ "${CREW_MODEL_ROUTING:-}" != off ] && [ -n "${FP:-}" ]; then
645
+ _gwm="${BASH_SOURCE%/*}"; [ "$_gwm" = "${BASH_SOURCE}" ] && _gwm=.
646
+ if [ -f "$_gwm/guard-agent-model.sh" ]; then
647
+ . "$_gwm/guard-agent-model.sh"
648
+ if declare -F _am_crit_path >/dev/null 2>&1 && _am_crit_path "$FP"; then
649
+ case "$_AMRP" in /*|[A-Za-z]:*) ;; *)
650
+ declare -F _am_log >/dev/null 2>&1 && _am_log "note: the session wrote a critical path itself, not through an agent: $_AMRP ($_AMW)" ;;
651
+ esac
652
+ fi
653
+ fi
654
+ fi ;;
634
655
  esac
635
656
 
636
657
  # ---- team board: you may not start work nobody knows you started -----------------------------------------------
@@ -31,6 +31,24 @@
31
31
  #
32
32
  # COST. This runs on every prompt, so the ordinary one is builtins only: no process is started unless the
33
33
  # message is an approval (then git is asked what is staged). eval/smoke-test.sh pins both.
34
+ #
35
+ # THE SAME APPROVAL BY A CLICK (3.2.0). In `auto` the user can also answer ONE fixed question, asked with
36
+ # AskUserQuestion: "Commit only" / "Commit + push" / "Push only" / "No". Two hooks on that tool share what is below:
37
+ # PreToolUse guard-question.sh (_crew_q_pre): the question must be the fixed one (_CREW_Q_JSON), carry no
38
+ # answers, and be asked in `auto`; then it answers `ask`. A hook's `ask` beats another PreToolUse
39
+ # hook's `allow` that comes with filled-in answers, from every source and in every mode (measured:
40
+ # 34 runs on Claude Code 2.1.294, and in the terminal by hand), so the question reaches the user.
41
+ # PostToolUse this hook: the answer is read from the payload and recorded exactly as a typed approval would
42
+ # be, with the same proofs (an attended session, a transcript on disk, the session id) - and only
43
+ # when guard-question.sh asked THIS call (.claude/state/crew-approval-asked carries its tool_use_id).
44
+ # WHAT `ask` DOES NOT STOP: a `PermissionRequest` hook. It answers the permission prompt that `ask` raises, before
45
+ # the person does (measured, with `-p` and in the terminal), and it can do the same to the Bash prompt of the
46
+ # §4.4 gate in `default` / `acceptEdits`. So both halves, and guard-bash.sh before it asks, first look for one
47
+ # (_crew_prh_scan). Found, the click is closed and so is the gate's own prompt: the one approval left is the
48
+ # typed one, which no hook can type. HONEST SCOPE: the scan reads the sources a hook can read (user, project and
49
+ # local settings, the file-based managed settings, skill, agent and command front matter, installed plugins).
50
+ # It cannot read settings handed over with `--settings`, managed settings delivered by a server, or a plugin
51
+ # loaded with `--plugin-dir`: a PermissionRequest hook there is past it.
34
52
  set -uo pipefail
35
53
  # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
36
54
  # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
@@ -56,7 +74,7 @@ export LC_ALL=C
56
74
  _PA_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _PA_LIB=1
57
75
  if [ "$_PA_LIB" = 0 ]; then
58
76
  IFS= read -r -d '' INPUT || true
59
- case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
77
+ case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*|*'"hook_event_name"'*'"AskUserQuestion"'*|*'"AskUserQuestion"'*'"hook_event_name"'*) ;; *) exit 0 ;; esac
60
78
  fi
61
79
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
62
80
  _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
@@ -403,8 +421,301 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pe
403
421
  fi
404
422
  }
405
423
 
424
+ # ---- THE APPROVAL QUESTION: one fixed text, asked by the model and answered by the user (3.2.0) ----------------
425
+ # What the user is asked is never the model's wording: PreToolUse refuses any other, so the person always reads the
426
+ # same four answers and what each one does. guard-bash.sh prints it from here for the model to ask.
427
+ _CREW_Q_TEXT='Crewforth approval: may Claude commit what is staged now, push this branch, or both?'
428
+ _CREW_Q_JSON='{"question":"'"$_CREW_Q_TEXT"'","header":"Approval","options":[{"label":"Commit only","description":"Commit exactly what is staged now. No push."},{"label":"Commit + push","description":"Commit what is staged now, then push that commit on this branch."},{"label":"Push only","description":"Push this branch as it is now. No new commit."},{"label":"No","description":"Neither commit nor push."}],"multiSelect":false}'
429
+ _crew_q_op(){ # $1 = the label the user chose -> OP: commit | commit+push | push | no, or "" for anything else
430
+ case "$1" in
431
+ 'Commit only') OP=commit ;; 'Commit + push') OP=commit+push ;; 'Push only') OP=push ;; 'No') OP=no ;; *) OP="" ;;
432
+ esac
433
+ }
434
+
435
+ # ---- IS A PermissionRequest HOOK WIRED WHERE THIS CAN SEE? -------------------------------------------------------
436
+ # A PermissionRequest hook answers a permission prompt before the person does: the one `ask` raises for the approval
437
+ # question, and the one guard-bash.sh raises for a commit in default / acceptEdits (both measured on 2.1.294, with
438
+ # `-p` and in the terminal). Nothing in the answer tells it from a click. So the question is closed, and the gate's
439
+ # prompt with it, whenever such a hook may be wired; the typed /crew-approve stays, because no hook can type.
440
+ # Read here, as text (not parsed, so it errs towards "found"):
441
+ # settings user (CLAUDE_CONFIG_DIR or ~/.claude: settings.json, settings.local.json), project (.claude/
442
+ # settings.json, settings.local.json), the managed-settings.json files where guard-agent-model.sh
443
+ # reads them, and Crewforth's own plugin hooks.json. FOUND: the event's name in any letter case, or a
444
+ # `\u` escape in a file that has hooks (an escaped name is still the name).
445
+ # front matter SKILL.md, agents/*.md and commands/*.md of the project, the user and every installed plugin.
446
+ # Only the block between the first two `---` lines is read. FOUND: the event's name, or a backslash
447
+ # in a front matter that has hooks (YAML spells a letter as \x52 or \u0052 in double quotes).
448
+ # plugins every installed plugin (plugins/cache/<marketplace>/<plugin>/<version>): the .json files at its top,
449
+ # in hooks/ and in .claude-plugin/ (hooks.json, a plugin.json with inline hooks). Same rule as settings.
450
+ # NOT READ, and written as the limit in the docs: `--settings` given on the command line, managed settings a server
451
+ # delivers, a plugin loaded with --plugin-dir. A hook added while the session runs is read at the next question:
452
+ # the scan runs at every question and every commit, never cached.
453
+ # _PRH names the first file found, for the message. No process is started; this runs only when a question or a
454
+ # commit is decided, never on an ordinary prompt.
455
+ _crew_prh_text(){ # $1 = json | fm, $2 = text -> 0 when it may wire a PermissionRequest hook
456
+ local t="$2" nc=0 r=1
457
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
458
+ case "$t" in *permissionrequest*) r=0 ;; esac
459
+ if [ "$r" = 1 ]; then
460
+ case "$t" in *hooks*)
461
+ if [ "$1" = json ]; then
462
+ t="${t//\\\\/}" # an escaped backslash is not the start of an escape
463
+ case "$t" in *'\u'*|*'\U'*) r=0 ;; esac
464
+ else
465
+ case "$t" in *\\*) r=0 ;; esac
466
+ fi ;;
467
+ esac
468
+ fi
469
+ [ "$nc" = 0 ] && shopt -u nocasematch
470
+ return "$r"
471
+ }
472
+ _crew_prh_fm(){ # $1 = a markdown file -> _FM: its front matter, "" when it has none
473
+ local l n=0
474
+ _FM=""
475
+ { IFS= read -r l || [ -n "$l" ]; l="${l%$'\r'}"; [ "$l" = --- ] || return 0
476
+ while IFS= read -r l || [ -n "$l" ]; do
477
+ l="${l%$'\r'}"; [ "$l" = --- ] && return 0
478
+ _FM="$_FM$l"$'\n'; n=$((n+1)); [ "$n" -le 400 ] || return 0
479
+ done; } < "$1"
480
+ }
481
+ _crew_prh_scan(){ # -> 0 when a PermissionRequest hook may be wired; _PRH names where
482
+ local p="${CLAUDE_PROJECT_DIR:-${CWD:-${_CWD:-.}}}" u="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}" f t list
483
+ local LC_ALL=C
484
+ p="${p//\\//}"; p="${p%/}"; u="${u//\\//}"; u="${u%/}"
485
+ _PRH=""
486
+ # The managed files are the ones guard-agent-model.sh reads (_am_settings_files), CREW_MANAGED_SETTINGS included:
487
+ # it can only add a file to look in, so it can only close the question. smoke-test.sh pins the two lists equal.
488
+ for f in "$u/settings.json" "$u/settings.local.json" "$p/.claude/settings.json" "$p/.claude/settings.local.json" \
489
+ "/Library/Application Support/ClaudeCode/managed-settings.json" "/etc/claude-code/managed-settings.json" \
490
+ "/c/Program Files/ClaudeCode/managed-settings.json" "/c/ProgramData/ClaudeCode/managed-settings.json" \
491
+ "${CREW_MANAGED_SETTINGS:-}" "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}"; do
492
+ [ -n "$f" ] && [ -f "$f" ] || continue
493
+ t=""; IFS= read -r -d '' t < "$f" || true
494
+ _crew_prh_text json "$t" && { _PRH="$f"; return 0; }
495
+ done
496
+ # Globs, not `find`: no process is started, and this runs before every commit prompt (on Git Bash a process is
497
+ # 62-135 ms). A plugin is cache/<marketplace>/<plugin>/<version>/; a command or an agent may sit one folder down.
498
+ local ng=0 r=1
499
+ shopt -q nullglob && ng=1; shopt -s nullglob
500
+ for f in "$u"/plugins/cache/*/*/*/*.json "$u"/plugins/cache/*/*/*/hooks/*.json "$u"/plugins/cache/*/*/*/.claude-plugin/*.json; do
501
+ t=""; IFS= read -r -d '' t < "$f" || true
502
+ _crew_prh_text json "$t" && { _PRH="$f"; r=0; break; }
503
+ done
504
+ if [ "$r" = 1 ]; then
505
+ for f in "$p"/.claude/skills/*/SKILL.md "$p"/.claude/agents/*.md "$p"/.claude/agents/*/*.md "$p"/.claude/commands/*.md "$p"/.claude/commands/*/*.md \
506
+ "$u"/skills/*/SKILL.md "$u"/agents/*.md "$u"/agents/*/*.md "$u"/commands/*.md "$u"/commands/*/*.md \
507
+ "$u"/plugins/cache/*/*/*/skills/*/SKILL.md "$u"/plugins/cache/*/*/*/agents/*.md "$u"/plugins/cache/*/*/*/agents/*/*.md \
508
+ "$u"/plugins/cache/*/*/*/commands/*.md "$u"/plugins/cache/*/*/*/commands/*/*.md; do
509
+ _crew_prh_fm "$f"
510
+ [ -n "$_FM" ] && _crew_prh_text fm "$_FM" && { _PRH="$f"; r=0; break; }
511
+ done
512
+ fi
513
+ [ "$ng" = 0 ] && shopt -u nullglob
514
+ return "$r"
515
+ }
516
+
517
+ # ---- the approval question's context, and its PreToolUse half -----------------------------------------------
518
+ # Shared by prompt-approval.sh (PostToolUse: it records the answer) and guard-question.sh (PreToolUse: it asks). The
519
+ # PreToolUse half is a GATE and lives in a gate's file, so an error of its own refuses the call (CREW-FAILCLOSED)
520
+ # instead of letting the question through with no `ask`; this file also answers UserPromptSubmit, where a refusal
521
+ # would erase the user's message.
522
+ _crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no decision) when the call is not the approval question
523
+ _json_keycount "$INPUT" hook_event_name; [ "$_KC" = 1 ] || exit 0
524
+ _json_keycount "$INPUT" tool_name; [ "$_KC" = 1 ] || exit 0
525
+ _json_slice "$INPUT" tool_name >/dev/null; [ "$_JS" = AskUserQuestion ] || exit 0
526
+ case "$INPUT" in *'Crewforth approval'*) ;; *) exit 0 ;; esac
527
+ _json_slice "$INPUT" cwd >/dev/null; _json_unescape "$_JS" >/dev/null; CWD="$_JU"
528
+ [ -n "$CWD" ] || CWD="${CLAUDE_PROJECT_DIR:-.}"
529
+ CWD="${CWD//\\//}"
530
+ _crew_appr_path "$CWD"; REC="$_AP"
531
+ _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
532
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
533
+ _json_slice "$INPUT" tool_use_id >/dev/null; _QTU="$_JS"
534
+ _QAD="${CLAUDE_PROJECT_DIR:-$CWD}"; _QAD="${_QAD//\\//}"; _QAD="${_QAD%/}/.claude/state"; ASKED="$_QAD/crew-approval-asked"
535
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _QNOW '%(%s)T' -1; else _QNOW="$(date +%s)"; fi
536
+ }
537
+ _crew_q_pre(){ # the PreToolUse decision: `ask`, or a refusal (exit 2) that says why
538
+ _crew_q_ctx
539
+ qno(){ # $1 = why, for the model. The call does not run; where it applies, the typed approval is named as the way left.
540
+ echo "GUARD (§4.4): the approval question was not asked: $1" >&2
541
+ [ "${2:-}" = nohint ] || echo "The way that always works is the user's own: the user can type /crew-approve (or /crew-approve commit, push, commit+push) as their whole message." >&2
542
+ exit 2
543
+ }
544
+ # The answers are the person's to give. The model's own are ignored by Claude Code (measured), and a call that
545
+ # carries some is not the plain question this hook asks.
546
+ _json_keycount "$INPUT" answers; [ "$_KC" = 0 ] || qno "the call carries answers; ask it with none."
547
+ _json_keycount "$INPUT" questions; [ "$_KC" = 1 ] || qno "the call carries more than one list of questions."
548
+ case "$INPUT" in *'"questions":['"$_CREW_Q_JSON"']'*) ;;
549
+ *) qno "it is not the fixed question. Ask exactly this AskUserQuestion input, nothing added or changed: {\"questions\":[$_CREW_Q_JSON]}" ;;
550
+ esac
551
+ case "$PM" in
552
+ auto) ;;
553
+ dontAsk) qno "in dontAsk Claude Code refuses a question that needs a person, so it would never reach the user." ;;
554
+ default|acceptEdits) _crew_prh_scan || qno "in $PM the commit or push itself asks the user: run the git command and the gate's own prompt appears." nohint
555
+ qno "in $PM, with a PermissionRequest hook that may be wired ($_PRH), only the typed approval counts." ;;
556
+ *) qno "in ${PM:-an unknown mode} this approval is not open (bypassPermissions and plan fail closed)." ;;
557
+ esac
558
+ case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) qno "this session has nobody in front of it (it was started with -p, or by a program)." ;; esac
559
+ case "$SID$_QTU" in *[!A-Za-z0-9._-]*) qno "the call carries no session or tool id this hook can bind the answer to." ;; esac
560
+ { [ -n "$SID" ] && [ -n "$_QTU" ]; } || qno "the call carries no session or tool id this hook can bind the answer to."
561
+ _crew_prh_scan && qno "a PermissionRequest hook may be wired ($_PRH). Such a hook can answer the question before the user does, and the answer cannot be told from a click."
562
+ mkdir -p "$_QAD" 2>/dev/null && { printf 'sid=%s\ntool=%s\nts=%s\n' "$SID" "$_QTU" "$_QNOW" > "$ASKED"; } 2>/dev/null \
563
+ || qno "the state folder could not be written, so the answer could not be bound to this question."
564
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' \
565
+ "Crewforth §4.4: only the person in front of this session answers the approval question."
566
+ exit 0
567
+ }
568
+
406
569
  [ "$_PA_LIB" = 1 ] && return 0
407
570
 
571
+ # Whatever records an approval says why when it does not - to the user and to the model - instead of leaving a
572
+ # commit to fail later with no explanation. EVN is the event this hook answers, SRC what the user gave.
573
+ EVN=UserPromptSubmit; SRC="message"; SRC_BY="in their own message"
574
+ say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text and values checked below.
575
+ printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' "$1" "$EVN" "$2"
576
+ exit 0
577
+ }
578
+ no(){ say "Crewforth: approval NOT recorded - $1" "The user's $SRC is an approval for $OP, but Crewforth could not record it: $1 Tell the user exactly that; the commit/push gate stays closed until they approve again."; }
579
+
580
+ # ---- THE RECORD: what was approved, measured from git now, never taken from the message or the answer ---------
581
+ # Every route ends here (the typed approval, the bare /crew-approve, the approval question); each one has already
582
+ # checked the permission mode it records in, and sets OP, CWD, REC and SRC_BY.
583
+ _pa_record(){
584
+ [ -n "$REC" ] || no "this directory is not inside a git repository."
585
+
586
+ # A PERSON, IN A SESSION THAT HAS SHOWN THEM SOMETHING. A session can start another one and hand it the approval as
587
+ # its prompt: `claude -p "/crew-approve commit"`, or an interactive one driven through a pseudo-terminal. That
588
+ # session's only message is the approval, this hook would record it, and the session would then commit: the first
589
+ # session has approved for the user. Measured on Claude Code 2.1.294 (macOS), from a hook that printed what it saw:
590
+ # started how CLAUDE_CODE_SESSION_ATTENDED the transcript file when this hook runs
591
+ # claude -p, from a session's Bash 0 (set to 1 by the caller: still 0) not there (first message)
592
+ # claude -p, from an emptied environment 0 not there (first message)
593
+ # interactive, in a pty, from a session 1 not there, and never written: a
594
+ # session started inside another keeps none
595
+ # interactive, in a pty, emptied environment 1 not there at the first message; there after
596
+ # the desktop app, a conversation under way 1 there
597
+ # Nothing else told them apart: the payload has the same fields, CLAUDECODE and CLAUDE_CODE_CHILD_SESSION are 1 in
598
+ # all of them, CLAUDE_CODE_ENTRYPOINT is inherited from the outer session, and no hook has a terminal on stdin.
599
+ # So two things are asked. Neither is a documented contract, and both fail towards NOT recording:
600
+ # 1. the session is not a headless one;
601
+ # 2. the session's transcript is already on disk when the approval arrives, so the approval is not the session's
602
+ # first message and the session is not one that keeps no transcript.
603
+ # HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
604
+ # message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
605
+ case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
606
+ 0) no "this session has nobody in front of it (it was started with -p, or by a program), and an approval is what a person types into their own session." ;;
607
+ esac
608
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
609
+ { [ -n "$TP" ] && [ -s "$TP" ]; } || no "this is the first message of this session, or the session keeps no transcript (one started from inside another session keeps none). An approval answers a commit message the session has shown: have it shown, then approve."
610
+
611
+ # The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
612
+ # command can be handed any prompt.
613
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
614
+ case "$SID" in ''|*[!A-Za-z0-9._-]*) no "this prompt carries no session id to bind the approval to." ;; esac
615
+
616
+ HEAD_NOW="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
617
+ case "$HEAD_NOW" in NONE) ;; *[!0-9a-f]*|'') no "git could not name HEAD here." ;; esac
618
+ TREE=""; BR=""; RM=""; URL=""
619
+ case "$OP" in commit*)
620
+ # THE TREE THE INDEX WRITES, not the text of the staged diff: what `git diff` prints is configurable
621
+ # (`diff.external`, a textconv filter), and with it two different staged changes can print the same text.
622
+ TREE="$(git -C "$CWD" write-tree 2>/dev/null)"
623
+ case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
624
+ if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
625
+ else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
626
+ [ "$TREE" != "$_ht" ] || no "nothing is staged, so there is nothing to approve. Stage the change with git add, show the commit message, then ask again." ;;
627
+ esac
628
+ case "$OP" in *push)
629
+ [ "$OP" = push ] && [ "$HEAD_NOW" = NONE ] && no "there is no commit to push yet."
630
+ BR="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null || true)"
631
+ [ -n "$BR" ] || no "HEAD is detached, so there is no branch to bind the push to."
632
+ RM="$(git -C "$CWD" config --get "branch.$BR.pushRemote" 2>/dev/null)" \
633
+ || RM="$(git -C "$CWD" config --get remote.pushDefault 2>/dev/null)" \
634
+ || RM="$(git -C "$CWD" config --get "branch.$BR.remote" 2>/dev/null)" || RM=""
635
+ case "$RM" in ''|.)
636
+ RM="$(git -C "$CWD" remote 2>/dev/null)"
637
+ case "$RM" in ''|*$'\n'*) no "this branch has no upstream and the repository does not have exactly one remote, so the push target is not known. Set the upstream, or approve the commit alone." ;; esac ;;
638
+ esac
639
+ # Both values go into JSON and into a line-per-field record, so anything outside this set is refused, not escaped.
640
+ case "$BR$RM" in *[!A-Za-z0-9._/@+-]*) no "the branch or remote name has a character this record does not carry." ;; esac
641
+ # Where that remote pushes TO, with pushurl and pushInsteadOf applied. The name alone can be pointed elsewhere
642
+ # after the approval (`git config remote.origin.pushurl …`), so the address is part of what was approved.
643
+ URL="$(git -C "$CWD" remote get-url --push "$RM" 2>/dev/null)"
644
+ case "$URL" in ''|*$'\n'*) no "the remote $RM does not push to exactly one address." ;; esac ;;
645
+ esac
646
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
647
+ printf -v TS '%(%s)T' -1
648
+ else TS="$(date +%s)"; fi
649
+ case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
650
+
651
+ { printf 'v=1\nop=%s\ntree=%s\nhead=%s\nbranch=%s\nremote=%s\nurl=%s\nsid=%s\nts=%s\n' "$OP" "$TREE" "$HEAD_NOW" "$BR" "$RM" "$URL" "$SID" "$TS" > "$REC"; } 2>/dev/null \
652
+ || no "the record could not be written in the git directory."
653
+ # What was waiting has been approved: it is not approved a second time. Only here, after every proof and the record
654
+ # itself: a bare command that recorded nothing (a headless session, a first message) leaves it for the user.
655
+ [ "$BARE" = 1 ] && { : > "$_bp"; } 2>/dev/null
656
+
657
+ _h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
658
+ # THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
659
+ # already in what git reports, so the record and the push agree and only the person can tell that "origin" is not
660
+ # where they think (review). Shown to the user alone, with any `user:secret@` taken out, and never put in front of
661
+ # the model.
662
+ _u="$URL"
663
+ case "$_u" in *://*@*) _ua="${_u#*://}"; case "${_ua%%/*}" in *@*) _u="${_u%%://*}://${_ua#*@}" ;; esac ;; esac
664
+ case "$_u" in *[\"\\]*|*[![:print:]]*) _u="an address this line cannot print; check it with: git remote get-url --push $RM" ;; esac
665
+ case "$OP" in
666
+ commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
667
+ HOW="Run git commit -m with the message, alone in its call: no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained. Single-quote the message, or read it from a here-document with a quoted delimiter. The review record of §4.6 is still required." ;;
668
+ push) WHAT="push of $BR at $_h to $RM"
669
+ HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained)." ;;
670
+ commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
671
+ HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
672
+ esac
673
+ [ -n "$_u" ] && _u=" ($RM pushes to $_u)"
674
+ _bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
675
+ say "Crewforth: ${_bw}approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
676
+ "The user approved this $SRC_BY and Crewforth recorded it: $WHAT. $HOW The approval covers what was staged and where HEAD was at that moment, in this session: if either changes it no longer applies and the user approves again. It ends in 30 minutes or at the user's next message. Force-push and the rest of §4.5 are not opened by it."
677
+ }
678
+
679
+ # ---- THE APPROVAL QUESTION (PreToolUse and PostToolUse of AskUserQuestion) -----------------------------------
680
+ # Only the fixed question is this hook's business: any other question gets no decision from it, here or later.
681
+ _pa_question(){
682
+ local _as="" _at="" _au="" _al _an
683
+ _crew_q_ctx
684
+ # PostToolUse: the user has answered. Recorded only if THIS call was asked by the half above.
685
+ SRC="answer to the approval question"; SRC_BY="by answering the approval question"; OP="a commit or a push"
686
+ [ -n "$SID" ] && [ -n "$_QTU" ] || no "the answer carries no session or tool id."
687
+ if [ -f "$ASKED" ]; then
688
+ while IFS= read -r _al || [ -n "$_al" ]; do
689
+ case "$_al" in sid=*) _as="${_al#sid=}" ;; tool=*) _au="${_al#tool=}" ;; ts=*) _at="${_al#ts=}" ;; esac
690
+ done < "$ASKED"
691
+ fi
692
+ { [ "$_as" = "$SID" ] && [ "$_au" = "$_QTU" ]; } || no "Crewforth did not ask this question (guard-question.sh, its PreToolUse half, is not wired or did not pass it), so the answer cannot be shown to have come from the user."
693
+ : > "$ASKED" 2>/dev/null # one question, one answer
694
+ case "$_at$_QNOW" in *[!0-9]*|'') no "the time of the question could not be read." ;; esac
695
+ [ $((_QNOW - _at)) -ge 0 ] && [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."
696
+ case "$INPUT" in *'"questions":['"$_CREW_Q_JSON"']'*) ;; *) no "the question answered is not the fixed one." ;; esac
697
+ [ "$PM" = auto ] || no "the approval question is recorded in auto only, and this session is in ${PM:-an unknown mode}."
698
+ _crew_prh_scan && no "a PermissionRequest hook may be wired ($_PRH), and it can answer the question before you do. The approval that still works is typed: the user can type /crew-approve instead."
699
+ _json_keycount "$INPUT" "$_CREW_Q_TEXT"; _an=$_KC
700
+ case "$_an" in 1|2) ;; *) no "the answer could not be read." ;; esac
701
+ _json_slice "$INPUT" "$_CREW_Q_TEXT" >/dev/null
702
+ _crew_q_op "$_JS"
703
+ case "$OP" in
704
+ '') OP="a commit or a push"; no "the answer was none of the four the question offers." ;;
705
+ no) [ -n "$REC" ] && { : > "$REC"; } 2>/dev/null
706
+ say "Crewforth: not approved - you answered No." "The user answered No to the approval question. Do not commit or push; ask what they want changed." ;;
707
+ esac
708
+ case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) no "this session has nobody in front of it (it was started with -p, or by a program)." ;; esac
709
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
710
+ { [ -n "$TP" ] && [ -s "$TP" ]; } || no "the session keeps no transcript on disk (one started from inside another session keeps none)."
711
+ BARE=0
712
+ _pa_record
713
+ }
714
+ case "$INPUT" in *'"AskUserQuestion"'*)
715
+ _json_slice "$INPUT" hook_event_name >/dev/null
716
+ case "$_JS" in PostToolUse) EVN="$_JS"; _pa_question ;; esac ;;
717
+ esac
718
+
408
719
  # A second "prompt" key cannot come from the text of a prompt (inside a JSON string every quote is escaped), so
409
720
  # more than one means the payload is not the shape this hook knows, and nothing is read from it.
410
721
  _json_keycount "$INPUT" prompt; _n_prompt=$_KC
@@ -443,12 +754,7 @@ _crew_appr_op "$_JU"
443
754
  [ -n "$OP" ] || exit 0
444
755
 
445
756
  # From here on the message IS an approval, so every way of not recording it says why - to the user and to the
446
- # model - instead of leaving a commit to fail later with no explanation.
447
- say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text and values checked below.
448
- printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$1" "$2"
449
- exit 0
450
- }
451
- no(){ say "Crewforth: approval NOT recorded - $1" "The user's message is an approval for $OP, but Crewforth could not record it: $1 Tell the user exactly that; the commit/push gate stays closed until they approve again."; }
757
+ # model - instead of leaving a commit to fail later with no explanation (say / no, above).
452
758
 
453
759
  # ---- /crew-loosen: a floor model routing raised is lowered by the user, and by nobody else ----------------------
454
760
  # hooks/agent-outcome.sh raises a class's floor when its first try fails too often, and nothing lowers it again but
@@ -498,7 +804,7 @@ BARE=0
498
804
  if [ "$OP" = pending ]; then
499
805
  BARE=1
500
806
  _json_slice "$INPUT" permission_mode >/dev/null
501
- case "$_JS" in auto|dontAsk) ;; *) exit 0 ;; esac # elsewhere the gate asks the user itself; nothing waits here
807
+ case "$_JS" in auto|dontAsk) ;; default|acceptEdits) _crew_prh_scan || exit 0 ;; *) exit 0 ;; esac # elsewhere the gate asks the user itself, unless a PermissionRequest hook may answer it
502
808
  _json_slice "$INPUT" session_id >/dev/null; _bsid="$_JS"
503
809
  _bp="${CLAUDE_PROJECT_DIR:-$CWD}"; _bp="${_bp//\\//}"; _bp="${_bp%/}/.claude/state/crew-approval-pending"
504
810
  _bs=""; _bo=""; _bt=""
@@ -519,100 +825,11 @@ fi
519
825
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
520
826
  case "$PM" in
521
827
  auto|dontAsk) ;;
522
- # Where a prompt reaches the user the gate asks them itself; in plan and bypassPermissions this route is not
523
- # open (a decision, 3.1.0). Nothing is recorded and nothing is said: guard-bash.sh explains when it refuses.
828
+ # Where a prompt reaches the user the gate asks them itself, and nothing is recorded or said here. Except where a
829
+ # PermissionRequest hook may be wired (3.2.0): it can answer that prompt before the person does, so guard-bash.sh
830
+ # does not raise it and the typed approval is the one way left, in these modes too. In plan and bypassPermissions
831
+ # this route is not open (a decision, 3.1.0): guard-bash.sh explains when it refuses.
832
+ default|acceptEdits) _crew_prh_scan || exit 0 ;;
524
833
  *) exit 0 ;;
525
834
  esac
526
- [ -n "$REC" ] || no "this directory is not inside a git repository."
527
-
528
- # A PERSON, IN A SESSION THAT HAS SHOWN THEM SOMETHING. A session can start another one and hand it the approval as
529
- # its prompt: `claude -p "/crew-approve commit"`, or an interactive one driven through a pseudo-terminal. That
530
- # session's only message is the approval, this hook would record it, and the session would then commit: the first
531
- # session has approved for the user. Measured on Claude Code 2.1.294 (macOS), from a hook that printed what it saw:
532
- # started how CLAUDE_CODE_SESSION_ATTENDED the transcript file when this hook runs
533
- # claude -p, from a session's Bash 0 (set to 1 by the caller: still 0) not there (first message)
534
- # claude -p, from an emptied environment 0 not there (first message)
535
- # interactive, in a pty, from a session 1 not there, and never written: a
536
- # session started inside another keeps none
537
- # interactive, in a pty, emptied environment 1 not there at the first message; there after
538
- # the desktop app, a conversation under way 1 there
539
- # Nothing else told them apart: the payload has the same fields, CLAUDECODE and CLAUDE_CODE_CHILD_SESSION are 1 in
540
- # all of them, CLAUDE_CODE_ENTRYPOINT is inherited from the outer session, and no hook has a terminal on stdin.
541
- # So two things are asked. Neither is a documented contract, and both fail towards NOT recording:
542
- # 1. the session is not a headless one;
543
- # 2. the session's transcript is already on disk when the approval arrives, so the approval is not the session's
544
- # first message and the session is not one that keeps no transcript.
545
- # HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
546
- # message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
547
- case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
548
- 0) no "this session has nobody in front of it (it was started with -p, or by a program), and an approval is what a person types into their own session." ;;
549
- esac
550
- _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
551
- { [ -n "$TP" ] && [ -s "$TP" ]; } || no "this is the first message of this session, or the session keeps no transcript (one started from inside another session keeps none). An approval answers a commit message the session has shown: have it shown, then approve."
552
-
553
- # The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
554
- # command can be handed any prompt.
555
- _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
556
- case "$SID" in ''|*[!A-Za-z0-9._-]*) no "this prompt carries no session id to bind the approval to." ;; esac
557
-
558
- HEAD_NOW="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
559
- case "$HEAD_NOW" in NONE) ;; *[!0-9a-f]*|'') no "git could not name HEAD here." ;; esac
560
- TREE=""; BR=""; RM=""; URL=""
561
- case "$OP" in commit*)
562
- # THE TREE THE INDEX WRITES, not the text of the staged diff: what `git diff` prints is configurable
563
- # (`diff.external`, a textconv filter), and with it two different staged changes can print the same text.
564
- TREE="$(git -C "$CWD" write-tree 2>/dev/null)"
565
- case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
566
- if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
567
- else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
568
- [ "$TREE" != "$_ht" ] || no "nothing is staged, so there is nothing to approve. Stage the change with git add, show the commit message, then ask again." ;;
569
- esac
570
- case "$OP" in *push)
571
- [ "$OP" = push ] && [ "$HEAD_NOW" = NONE ] && no "there is no commit to push yet."
572
- BR="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null || true)"
573
- [ -n "$BR" ] || no "HEAD is detached, so there is no branch to bind the push to."
574
- RM="$(git -C "$CWD" config --get "branch.$BR.pushRemote" 2>/dev/null)" \
575
- || RM="$(git -C "$CWD" config --get remote.pushDefault 2>/dev/null)" \
576
- || RM="$(git -C "$CWD" config --get "branch.$BR.remote" 2>/dev/null)" || RM=""
577
- case "$RM" in ''|.)
578
- RM="$(git -C "$CWD" remote 2>/dev/null)"
579
- case "$RM" in ''|*$'\n'*) no "this branch has no upstream and the repository does not have exactly one remote, so the push target is not known. Set the upstream, or approve the commit alone." ;; esac ;;
580
- esac
581
- # Both values go into JSON and into a line-per-field record, so anything outside this set is refused, not escaped.
582
- case "$BR$RM" in *[!A-Za-z0-9._/@+-]*) no "the branch or remote name has a character this record does not carry." ;; esac
583
- # Where that remote pushes TO, with pushurl and pushInsteadOf applied. The name alone can be pointed elsewhere
584
- # after the approval (`git config remote.origin.pushurl …`), so the address is part of what was approved.
585
- URL="$(git -C "$CWD" remote get-url --push "$RM" 2>/dev/null)"
586
- case "$URL" in ''|*$'\n'*) no "the remote $RM does not push to exactly one address." ;; esac ;;
587
- esac
588
- if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
589
- printf -v TS '%(%s)T' -1
590
- else TS="$(date +%s)"; fi
591
- case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
592
-
593
- { printf 'v=1\nop=%s\ntree=%s\nhead=%s\nbranch=%s\nremote=%s\nurl=%s\nsid=%s\nts=%s\n' "$OP" "$TREE" "$HEAD_NOW" "$BR" "$RM" "$URL" "$SID" "$TS" > "$REC"; } 2>/dev/null \
594
- || no "the record could not be written in the git directory."
595
- # What was waiting has been approved: it is not approved a second time. Only here, after every proof and the record
596
- # itself: a bare command that recorded nothing (a headless session, a first message) leaves it for the user.
597
- [ "$BARE" = 1 ] && { : > "$_bp"; } 2>/dev/null
598
-
599
- _h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
600
- # THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
601
- # already in what git reports, so the record and the push agree and only the person can tell that "origin" is not
602
- # where they think (review). Shown to the user alone, with any `user:secret@` taken out, and never put in front of
603
- # the model.
604
- _u="$URL"
605
- case "$_u" in *://*@*) _ua="${_u#*://}"; case "${_ua%%/*}" in *@*) _u="${_u%%://*}://${_ua#*@}" ;; esac ;; esac
606
- case "$_u" in *[\"\\]*|*[![:print:]]*) _u="an address this line cannot print; check it with: git remote get-url --push $RM" ;; esac
607
- case "$OP" in
608
- commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
609
- HOW="Run git commit -m with the message, alone in its call: no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained. Single-quote the message, or read it from a here-document with a quoted delimiter. The review record of §4.6 is still required." ;;
610
- push) WHAT="push of $BR at $_h to $RM"
611
- HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained)." ;;
612
- commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
613
- HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
614
- esac
615
- [ -n "$_u" ] && _u=" ($RM pushes to $_u)"
616
- _bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
617
- say "Crewforth: ${_bw}approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
618
- "The user approved this in their own message and Crewforth recorded it: $WHAT. $HOW The approval covers what was staged and where HEAD was at that moment, in this session: if either changes it no longer applies and the user approves again. It ends in 30 minutes or at the user's next message. Force-push and the rest of §4.5 are not opened by it."
835
+ _pa_record