crewforth 3.1.1 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -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"'*|*'"hook_event_name"'*'"Stop"'*) ;; *) 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,368 @@ _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
+ # THE ANSWERED QUESTION, IN ANY KEY ORDER. Claude Code hands PostToolUse the question back either as it was asked or
430
+ # re-serialised with its keys sorted (`header,options[description,label],question`, sometimes without multiSelect):
431
+ # 30 of 30 answers in the asked order were recorded and 7 of 7 sorted ones refused, on a byte match (real sessions,
432
+ # 2.1.299 and 2.1.302, 2026-10-18). So the answer side checks every "key":"text" pair of the fixed question, wherever
433
+ # it sits; the exact match stays on PreToolUse, and the tool id binds the answer to the call that passed it.
434
+ _crew_q_is(){ # $1 = a payload -> 0 when it carries every "key":"text" pair of _CREW_Q_JSON
435
+ local rest="$_CREW_Q_JSON" re='("[A-Za-z]+":"[^"]*")' p
436
+ while [[ $rest =~ $re ]]; do
437
+ p="${BASH_REMATCH[1]}"
438
+ case "$1" in *"$p"*) ;; *) return 1 ;; esac
439
+ rest="${rest#*"$p"}"
440
+ done
441
+ }
442
+ _crew_q_op(){ # $1 = the label the user chose -> OP: commit | commit+push | push | no, or "" for anything else
443
+ case "$1" in
444
+ 'Commit only') OP=commit ;; 'Commit + push') OP=commit+push ;; 'Push only') OP=push ;; 'No') OP=no ;; *) OP="" ;;
445
+ esac
446
+ }
447
+
448
+ # ---- IS A PermissionRequest HOOK WIRED WHERE THIS CAN SEE? -------------------------------------------------------
449
+ # A PermissionRequest hook answers a permission prompt before the person does: the one `ask` raises for the approval
450
+ # question, and the one guard-bash.sh raises for a commit in default / acceptEdits (both measured on 2.1.294, with
451
+ # `-p` and in the terminal). Nothing in the answer tells it from a click. So the question is closed, and the gate's
452
+ # prompt with it, whenever such a hook may be wired; the typed /crew-approve stays, because no hook can type.
453
+ # Read here, as text (not parsed, so it errs towards "found"):
454
+ # settings user (CLAUDE_CONFIG_DIR or ~/.claude: settings.json, settings.local.json), project (.claude/
455
+ # settings.json, settings.local.json), the managed-settings.json files where guard-agent-model.sh
456
+ # reads them, and Crewforth's own plugin hooks.json. FOUND: the event's name in any letter case, or a
457
+ # `\u` escape in a file that has hooks (an escaped name is still the name).
458
+ # front matter SKILL.md, agents/*.md and commands/*.md of the project, the user and every installed plugin.
459
+ # Only the block between the first two `---` lines is read. FOUND: the event's name, or a backslash
460
+ # in a front matter that has hooks (YAML spells a letter as \x52 or \u0052 in double quotes).
461
+ # plugins every installed plugin (plugins/cache/<marketplace>/<plugin>/<version>): the .json files at its top,
462
+ # in hooks/ and in .claude-plugin/ (hooks.json, a plugin.json with inline hooks). Same rule as settings.
463
+ # NOT READ, and written as the limit in the docs: `--settings` given on the command line, managed settings a server
464
+ # delivers, a plugin loaded with --plugin-dir. A hook added while the session runs is read at the next question:
465
+ # the scan runs at every question and every commit, never cached.
466
+ # _PRH names the first file found, for the message. No process is started; this runs only when a question or a
467
+ # commit is decided, never on an ordinary prompt.
468
+ _crew_prh_text(){ # $1 = json | fm, $2 = text -> 0 when it may wire a PermissionRequest hook
469
+ local t="$2" nc=0 r=1
470
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
471
+ case "$t" in *permissionrequest*) r=0 ;; esac
472
+ if [ "$r" = 1 ]; then
473
+ case "$t" in *hooks*)
474
+ if [ "$1" = json ]; then
475
+ t="${t//\\\\/}" # an escaped backslash is not the start of an escape
476
+ case "$t" in *'\u'*|*'\U'*) r=0 ;; esac
477
+ else
478
+ case "$t" in *\\*) r=0 ;; esac
479
+ fi ;;
480
+ esac
481
+ fi
482
+ [ "$nc" = 0 ] && shopt -u nocasematch
483
+ return "$r"
484
+ }
485
+ _crew_prh_fm(){ # $1 = a markdown file -> _FM: its front matter, "" when it has none
486
+ local l n=0
487
+ _FM=""
488
+ { IFS= read -r l || [ -n "$l" ]; l="${l%$'\r'}"; [ "$l" = --- ] || return 0
489
+ while IFS= read -r l || [ -n "$l" ]; do
490
+ l="${l%$'\r'}"; [ "$l" = --- ] && return 0
491
+ _FM="$_FM$l"$'\n'; n=$((n+1)); [ "$n" -le 400 ] || return 0
492
+ done; } < "$1"
493
+ }
494
+ _crew_prh_scan(){ # -> 0 when a PermissionRequest hook may be wired; _PRH names where
495
+ local p="${CLAUDE_PROJECT_DIR:-${CWD:-${_CWD:-.}}}" u="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}" f t list
496
+ local LC_ALL=C
497
+ p="${p//\\//}"; p="${p%/}"; u="${u//\\//}"; u="${u%/}"
498
+ _PRH=""
499
+ # The managed files are the ones guard-agent-model.sh reads (_am_settings_files), CREW_MANAGED_SETTINGS included:
500
+ # it can only add a file to look in, so it can only close the question. smoke-test.sh pins the two lists equal.
501
+ for f in "$u/settings.json" "$u/settings.local.json" "$p/.claude/settings.json" "$p/.claude/settings.local.json" \
502
+ "/Library/Application Support/ClaudeCode/managed-settings.json" "/etc/claude-code/managed-settings.json" \
503
+ "/c/Program Files/ClaudeCode/managed-settings.json" "/c/ProgramData/ClaudeCode/managed-settings.json" \
504
+ "${CREW_MANAGED_SETTINGS:-}" "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}"; do
505
+ [ -n "$f" ] && [ -f "$f" ] || continue
506
+ t=""; IFS= read -r -d '' t < "$f" || true
507
+ _crew_prh_text json "$t" && { _PRH="$f"; return 0; }
508
+ done
509
+ # Globs, not `find`: no process is started, and this runs before every commit prompt (on Git Bash a process is
510
+ # 62-135 ms). A plugin is cache/<marketplace>/<plugin>/<version>/; a command or an agent may sit one folder down.
511
+ local ng=0 r=1
512
+ shopt -q nullglob && ng=1; shopt -s nullglob
513
+ for f in "$u"/plugins/cache/*/*/*/*.json "$u"/plugins/cache/*/*/*/hooks/*.json "$u"/plugins/cache/*/*/*/.claude-plugin/*.json; do
514
+ t=""; IFS= read -r -d '' t < "$f" || true
515
+ _crew_prh_text json "$t" && { _PRH="$f"; r=0; break; }
516
+ done
517
+ if [ "$r" = 1 ]; then
518
+ for f in "$p"/.claude/skills/*/SKILL.md "$p"/.claude/agents/*.md "$p"/.claude/agents/*/*.md "$p"/.claude/commands/*.md "$p"/.claude/commands/*/*.md \
519
+ "$u"/skills/*/SKILL.md "$u"/agents/*.md "$u"/agents/*/*.md "$u"/commands/*.md "$u"/commands/*/*.md \
520
+ "$u"/plugins/cache/*/*/*/skills/*/SKILL.md "$u"/plugins/cache/*/*/*/agents/*.md "$u"/plugins/cache/*/*/*/agents/*/*.md \
521
+ "$u"/plugins/cache/*/*/*/commands/*.md "$u"/plugins/cache/*/*/*/commands/*/*.md; do
522
+ _crew_prh_fm "$f"
523
+ [ -n "$_FM" ] && _crew_prh_text fm "$_FM" && { _PRH="$f"; r=0; break; }
524
+ done
525
+ fi
526
+ [ "$ng" = 0 ] && shopt -u nullglob
527
+ return "$r"
528
+ }
529
+
530
+ # ---- IS THE QUESTION OPEN HERE? One answer for every hook that offers it ------------------------------------------
531
+ # guard-bash.sh offers it in a refusal; agent-outcome.sh offers it when crew-commit-agent is called in `auto`, so the
532
+ # click is on offer before any commit is tried (B-2, a real session: with the refusal as the only way to it, a session
533
+ # that asked for /crew-approve first never showed it). The text the user is asked stays _CREW_Q_JSON, here only.
534
+ _CREW_Q_HERE="${BASH_SOURCE[0]%/*}"; [ "$_CREW_Q_HERE" = "${BASH_SOURCE[0]}" ] && _CREW_Q_HERE=.
535
+ _crew_q_wired(){ # 0 = a settings file wires both halves on AskUserQuestion (guard-question.sh, then this file)
536
+ local f t d="${CLAUDE_PROJECT_DIR:-.}"
537
+ d="${d//\\//}"; d="${d%/}"
538
+ for f in "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}" "$d/.claude/settings.json" "$d/.claude/settings.local.json" "${HOME:+${HOME//\\//}/.claude/settings.json}"; do
539
+ [ -n "$f" ] && [ -f "$f" ] || continue
540
+ t=""; IFS= read -r -d '' t < "$f" || true
541
+ case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
542
+ done
543
+ return 1
544
+ }
545
+ _crew_q_open(){ # $1 = permission mode -> 0 when the click may be offered: auto, both halves wired and beside this file, no PermissionRequest hook
546
+ [ "$1" = auto ] && [ -f "$_CREW_Q_HERE/guard-question.sh" ] && _crew_q_wired && ! _crew_prh_scan
547
+ }
548
+ # What the session is told to do, the same words wherever it is offered.
549
+ _CREW_Q_ASK='ASK THE USER WITH ONE CLICK (stage first: the approval covers what is staged at that moment): show the commit message whole, in ONE code block, then call AskUserQuestion with exactly this input, nothing added or changed:'
550
+
551
+ # ---- the approval question's context, and its PreToolUse half -----------------------------------------------
552
+ # Shared by prompt-approval.sh (PostToolUse: it records the answer) and guard-question.sh (PreToolUse: it asks). The
553
+ # PreToolUse half is a GATE and lives in a gate's file, so an error of its own refuses the call (CREW-FAILCLOSED)
554
+ # instead of letting the question through with no `ask`; this file also answers UserPromptSubmit, where a refusal
555
+ # would erase the user's message.
556
+ _crew_q_dir(){ # $1 = cwd -> _QAD (the state folder), ASKED (the call PreToolUse let through), TRIED (asked since the user's last message)
557
+ _QAD="${CLAUDE_PROJECT_DIR:-$1}"; _QAD="${_QAD//\\//}"; _QAD="${_QAD%/}/.claude/state"
558
+ ASKED="$_QAD/crew-approval-asked"; TRIED="$_QAD/crew-approval-tried"
559
+ }
560
+ _crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no decision) when the call is not the approval question
561
+ _json_keycount "$INPUT" hook_event_name; [ "$_KC" = 1 ] || exit 0
562
+ _json_keycount "$INPUT" tool_name; [ "$_KC" = 1 ] || exit 0
563
+ _json_slice "$INPUT" tool_name >/dev/null; [ "$_JS" = AskUserQuestion ] || exit 0
564
+ case "$INPUT" in *'Crewforth approval'*) ;; *) exit 0 ;; esac
565
+ _json_slice "$INPUT" cwd >/dev/null; _json_unescape "$_JS" >/dev/null; CWD="$_JU"
566
+ [ -n "$CWD" ] || CWD="${CLAUDE_PROJECT_DIR:-.}"
567
+ CWD="${CWD//\\//}"
568
+ _crew_appr_path "$CWD"; REC="$_AP"
569
+ _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
570
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
571
+ _json_slice "$INPUT" tool_use_id >/dev/null; _QTU="$_JS"
572
+ _crew_q_dir "$CWD"
573
+ 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
574
+ }
575
+ _crew_q_pre(){ # the PreToolUse decision: `ask`, or a refusal (exit 2) that says why
576
+ _crew_q_ctx
577
+ # Asked at all, whatever the decision below: a question refused here, or dismissed by the user, leaves the typed
578
+ # way as the one that is left, and the Stop half (_pa_stop) must not send the session back to ask it again.
579
+ [ -n "$SID" ] && [ -d "$_QAD" ] && { printf 'sid=%s\n' "$SID" > "$TRIED"; } 2>/dev/null
580
+ qno(){ # $1 = why, for the model. The call does not run; where it applies, the typed approval is named as the way left.
581
+ echo "GUARD (§4.4): the approval question was not asked: $1" >&2
582
+ [ "${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
583
+ exit 2
584
+ }
585
+ # The answers are the person's to give. The model's own are ignored by Claude Code (measured), and a call that
586
+ # carries some is not the plain question this hook asks.
587
+ _json_keycount "$INPUT" answers; [ "$_KC" = 0 ] || qno "the call carries answers; ask it with none."
588
+ _json_keycount "$INPUT" questions; [ "$_KC" = 1 ] || qno "the call carries more than one list of questions."
589
+ case "$INPUT" in *'"questions":['"$_CREW_Q_JSON"']'*) ;;
590
+ *) qno "it is not the fixed question. Ask exactly this AskUserQuestion input, nothing added or changed: {\"questions\":[$_CREW_Q_JSON]}" ;;
591
+ esac
592
+ case "$PM" in
593
+ auto) ;;
594
+ dontAsk) qno "in dontAsk Claude Code refuses a question that needs a person, so it would never reach the user." ;;
595
+ 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
596
+ qno "in $PM, with a PermissionRequest hook that may be wired ($_PRH), only the typed approval counts." ;;
597
+ *) qno "in ${PM:-an unknown mode} this approval is not open (bypassPermissions and plan fail closed)." ;;
598
+ esac
599
+ 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
600
+ 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
601
+ { [ -n "$SID" ] && [ -n "$_QTU" ]; } || qno "the call carries no session or tool id this hook can bind the answer to."
602
+ _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."
603
+ mkdir -p "$_QAD" 2>/dev/null && { printf 'sid=%s\ntool=%s\nts=%s\n' "$SID" "$_QTU" "$_QNOW" > "$ASKED"; } 2>/dev/null \
604
+ || qno "the state folder could not be written, so the answer could not be bound to this question."
605
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' \
606
+ "Crewforth §4.4: only the person in front of this session answers the approval question."
607
+ exit 0
608
+ }
609
+
406
610
  [ "$_PA_LIB" = 1 ] && return 0
407
611
 
612
+ # Whatever records an approval says why when it does not - to the user and to the model - instead of leaving a
613
+ # commit to fail later with no explanation. EVN is the event this hook answers, SRC what the user gave.
614
+ EVN=UserPromptSubmit; SRC="message"; SRC_BY="in their own message"
615
+ say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text and values checked below.
616
+ printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' "$1" "$EVN" "$2"
617
+ exit 0
618
+ }
619
+ 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."; }
620
+
621
+ # ---- THE RECORD: what was approved, measured from git now, never taken from the message or the answer ---------
622
+ # Every route ends here (the typed approval, the bare /crew-approve, the approval question); each one has already
623
+ # checked the permission mode it records in, and sets OP, CWD, REC and SRC_BY.
624
+ _pa_record(){
625
+ [ -n "$REC" ] || no "this directory is not inside a git repository."
626
+
627
+ # A PERSON, IN A SESSION THAT HAS SHOWN THEM SOMETHING. A session can start another one and hand it the approval as
628
+ # its prompt: `claude -p "/crew-approve commit"`, or an interactive one driven through a pseudo-terminal. That
629
+ # session's only message is the approval, this hook would record it, and the session would then commit: the first
630
+ # session has approved for the user. Measured on Claude Code 2.1.294 (macOS), from a hook that printed what it saw:
631
+ # started how CLAUDE_CODE_SESSION_ATTENDED the transcript file when this hook runs
632
+ # claude -p, from a session's Bash 0 (set to 1 by the caller: still 0) not there (first message)
633
+ # claude -p, from an emptied environment 0 not there (first message)
634
+ # interactive, in a pty, from a session 1 not there, and never written: a
635
+ # session started inside another keeps none
636
+ # interactive, in a pty, emptied environment 1 not there at the first message; there after
637
+ # the desktop app, a conversation under way 1 there
638
+ # Nothing else told them apart: the payload has the same fields, CLAUDECODE and CLAUDE_CODE_CHILD_SESSION are 1 in
639
+ # all of them, CLAUDE_CODE_ENTRYPOINT is inherited from the outer session, and no hook has a terminal on stdin.
640
+ # So two things are asked. Neither is a documented contract, and both fail towards NOT recording:
641
+ # 1. the session is not a headless one;
642
+ # 2. the session's transcript is already on disk when the approval arrives, so the approval is not the session's
643
+ # first message and the session is not one that keeps no transcript.
644
+ # HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
645
+ # message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
646
+ case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
647
+ 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." ;;
648
+ esac
649
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
650
+ { [ -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."
651
+
652
+ # The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
653
+ # command can be handed any prompt.
654
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
655
+ case "$SID" in ''|*[!A-Za-z0-9._-]*) no "this prompt carries no session id to bind the approval to." ;; esac
656
+
657
+ HEAD_NOW="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
658
+ case "$HEAD_NOW" in NONE) ;; *[!0-9a-f]*|'') no "git could not name HEAD here." ;; esac
659
+ TREE=""; BR=""; RM=""; URL=""
660
+ case "$OP" in commit*)
661
+ # THE TREE THE INDEX WRITES, not the text of the staged diff: what `git diff` prints is configurable
662
+ # (`diff.external`, a textconv filter), and with it two different staged changes can print the same text.
663
+ TREE="$(git -C "$CWD" write-tree 2>/dev/null)"
664
+ case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
665
+ if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
666
+ else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
667
+ [ "$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." ;;
668
+ esac
669
+ case "$OP" in *push)
670
+ [ "$OP" = push ] && [ "$HEAD_NOW" = NONE ] && no "there is no commit to push yet."
671
+ BR="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null || true)"
672
+ [ -n "$BR" ] || no "HEAD is detached, so there is no branch to bind the push to."
673
+ RM="$(git -C "$CWD" config --get "branch.$BR.pushRemote" 2>/dev/null)" \
674
+ || RM="$(git -C "$CWD" config --get remote.pushDefault 2>/dev/null)" \
675
+ || RM="$(git -C "$CWD" config --get "branch.$BR.remote" 2>/dev/null)" || RM=""
676
+ case "$RM" in ''|.)
677
+ RM="$(git -C "$CWD" remote 2>/dev/null)"
678
+ 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 ;;
679
+ esac
680
+ # Both values go into JSON and into a line-per-field record, so anything outside this set is refused, not escaped.
681
+ case "$BR$RM" in *[!A-Za-z0-9._/@+-]*) no "the branch or remote name has a character this record does not carry." ;; esac
682
+ # Where that remote pushes TO, with pushurl and pushInsteadOf applied. The name alone can be pointed elsewhere
683
+ # after the approval (`git config remote.origin.pushurl …`), so the address is part of what was approved.
684
+ URL="$(git -C "$CWD" remote get-url --push "$RM" 2>/dev/null)"
685
+ case "$URL" in ''|*$'\n'*) no "the remote $RM does not push to exactly one address." ;; esac ;;
686
+ esac
687
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
688
+ printf -v TS '%(%s)T' -1
689
+ else TS="$(date +%s)"; fi
690
+ case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
691
+
692
+ { 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 \
693
+ || no "the record could not be written in the git directory."
694
+ # What was waiting has been approved: it is not approved a second time. Only here, after every proof and the record
695
+ # itself: a bare command that recorded nothing (a headless session, a first message) leaves it for the user.
696
+ [ "$BARE" = 1 ] && { : > "$_bp"; } 2>/dev/null
697
+
698
+ _h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
699
+ # THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
700
+ # already in what git reports, so the record and the push agree and only the person can tell that "origin" is not
701
+ # where they think (review). Shown to the user alone, with any `user:secret@` taken out, and never put in front of
702
+ # the model.
703
+ _u="$URL"
704
+ case "$_u" in *://*@*) _ua="${_u#*://}"; case "${_ua%%/*}" in *@*) _u="${_u%%://*}://${_ua#*@}" ;; esac ;; esac
705
+ case "$_u" in *[\"\\]*|*[![:print:]]*) _u="an address this line cannot print; check it with: git remote get-url --push $RM" ;; esac
706
+ case "$OP" in
707
+ commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
708
+ 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." ;;
709
+ push) WHAT="push of $BR at $_h to $RM"
710
+ HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained). A tag on that commit goes the same way: git push $RM <tag>." ;;
711
+ commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
712
+ 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 (a tag on the new commit: git push $RM <tag>)" ;;
713
+ esac
714
+ [ -n "$_u" ] && _u=" ($RM pushes to $_u)"
715
+ _bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
716
+ say "Crewforth: ${_bw}approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
717
+ "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."
718
+ }
719
+
720
+ # ---- THE APPROVAL QUESTION (PreToolUse and PostToolUse of AskUserQuestion) -----------------------------------
721
+ # Only the fixed question is this hook's business: any other question gets no decision from it, here or later.
722
+ _pa_question(){
723
+ local _as="" _at="" _au="" _al _an
724
+ _crew_q_ctx
725
+ # PostToolUse: the user has answered. Recorded only if THIS call was asked by the half above.
726
+ SRC="answer to the approval question"; SRC_BY="by answering the approval question"; OP="a commit or a push"
727
+ [ -n "$SID" ] && [ -n "$_QTU" ] || no "the answer carries no session or tool id."
728
+ if [ -f "$ASKED" ]; then
729
+ while IFS= read -r _al || [ -n "$_al" ]; do
730
+ case "$_al" in sid=*) _as="${_al#sid=}" ;; tool=*) _au="${_al#tool=}" ;; ts=*) _at="${_al#ts=}" ;; esac
731
+ done < "$ASKED"
732
+ fi
733
+ { [ "$_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."
734
+ : > "$ASKED" 2>/dev/null # one question, one answer
735
+ case "$_at$_QNOW" in *[!0-9]*|'') no "the time of the question could not be read." ;; esac
736
+ [ $((_QNOW - _at)) -ge 0 ] && [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."
737
+ _crew_q_is "$INPUT" || no "the question answered is not the fixed one."
738
+ [ "$PM" = auto ] || no "the approval question is recorded in auto only, and this session is in ${PM:-an unknown mode}."
739
+ _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."
740
+ _json_keycount "$INPUT" "$_CREW_Q_TEXT"; _an=$_KC
741
+ case "$_an" in 1|2) ;; *) no "the answer could not be read." ;; esac
742
+ _json_slice "$INPUT" "$_CREW_Q_TEXT" >/dev/null
743
+ _crew_q_op "$_JS"
744
+ case "$OP" in
745
+ '') OP="a commit or a push"; no "the answer was none of the four the question offers." ;;
746
+ no) [ -n "$REC" ] && { : > "$REC"; } 2>/dev/null
747
+ 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." ;;
748
+ esac
749
+ 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
750
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
751
+ { [ -n "$TP" ] && [ -s "$TP" ]; } || no "the session keeps no transcript on disk (one started from inside another session keeps none)."
752
+ BARE=0
753
+ _pa_record
754
+ }
755
+ # ---- THE CLICK BEFORE THE TYPED WAY (Stop) -------------------------------------------------------------------
756
+ # The click reached the user only from a refused commit or a call to crew-commit-agent, and a session that wrote the
757
+ # message itself asked the user to type /crew-approve without trying either: 9 of 18 typed asks in two real `auto`
758
+ # sessions on rc.4 (2026-10-18) came in a turn where no commit was tried, most right after crew-review-agent. So the
759
+ # turn's end is where it is caught: the reply names /crew-approve, the question is open, and it was not asked since
760
+ # the user's last message. Then the session is told, once, to ask the click; it never blocks, and stop_hook_active
761
+ # lets the turn end the next time whatever was said.
762
+ _pa_stop(){
763
+ case "$INPUT" in *'"stop_hook_active":true'*|*'"stop_hook_active": true'*) exit 0 ;; esac
764
+ _json_slice "$INPUT" permission_mode >/dev/null; [ "$_JS" = auto ] || exit 0
765
+ _json_slice "$INPUT" last_assistant_message >/dev/null
766
+ case "$_JS" in *'/crew-approve'*) ;; *) exit 0 ;; esac
767
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"; [ -n "$SID" ] || exit 0
768
+ _json_slice "$INPUT" cwd >/dev/null; _json_unescape "$_JS" >/dev/null; _crew_q_dir "${_JU:-.}"
769
+ _crew_q_open auto || exit 0
770
+ _al=""; [ -f "$TRIED" ] && { IFS= read -r _al < "$TRIED"; } 2>/dev/null
771
+ [ "$_al" = "sid=$SID" ] && exit 0
772
+ _ao="Crewforth: your reply asks for a typed approval, and the one-click approval question was not asked since the user's last message. If that is a commit or a push waiting for approval, ask with one click now instead: $_CREW_Q_ASK {\"questions\":[$_CREW_Q_JSON]} Their answer is recorded by Crewforth itself (the line it adds says so). If the question is refused or dismissed, the user can type /crew-approve instead. If you only mentioned the command, end your turn."
773
+ _ao="${_ao//\\/\\\\}"; _ao="${_ao//\"/\\\"}" # fixed ASCII text on one line: a backslash and a quote are all it holds
774
+ printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"%s"}}\n' "$_ao"
775
+ exit 0
776
+ }
777
+ _json_keycount "$INPUT" hook_event_name
778
+ if [ "$_KC" = 1 ]; then _json_slice "$INPUT" hook_event_name >/dev/null; [ "$_JS" = Stop ] && _pa_stop; fi
779
+ case "$INPUT" in *'"hook_event_name"'*'"Stop"'*) exit 0 ;; esac # a Stop is never a prompt: it must not end an approval below
780
+
781
+ case "$INPUT" in *'"AskUserQuestion"'*)
782
+ _json_slice "$INPUT" hook_event_name >/dev/null
783
+ case "$_JS" in PostToolUse) EVN="$_JS"; _pa_question ;; esac ;;
784
+ esac
785
+
408
786
  # A second "prompt" key cannot come from the text of a prompt (inside a JSON string every quote is escaped), so
409
787
  # more than one means the payload is not the shape this hook knows, and nothing is read from it.
410
788
  _json_keycount "$INPUT" prompt; _n_prompt=$_KC
@@ -434,6 +812,8 @@ _crew_appr_path "$CWD"; REC="$_AP"
434
812
  # THE USER'S NEXT MESSAGE ENDS THE APPROVAL, whatever it says. Emptied with a redirection, which is a builtin:
435
813
  # `rm` would be a process on a hook that runs on every prompt.
436
814
  if [ -n "$REC" ] && [ -s "$REC" ]; then : > "$REC" 2>/dev/null || true; fi
815
+ # ...and starts a new turn for the Stop half: the question has not been asked since this message.
816
+ _crew_q_dir "$CWD"; [ -s "$TRIED" ] && { : > "$TRIED"; } 2>/dev/null
437
817
 
438
818
  # ---- is this message an approval? ------------------------------------------------------------------------
439
819
  [ "$_n_prompt" = 1 ] || exit 0
@@ -443,12 +823,7 @@ _crew_appr_op "$_JU"
443
823
  [ -n "$OP" ] || exit 0
444
824
 
445
825
  # 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."; }
826
+ # model - instead of leaving a commit to fail later with no explanation (say / no, above).
452
827
 
453
828
  # ---- /crew-loosen: a floor model routing raised is lowered by the user, and by nobody else ----------------------
454
829
  # hooks/agent-outcome.sh raises a class's floor when its first try fails too often, and nothing lowers it again but
@@ -498,7 +873,7 @@ BARE=0
498
873
  if [ "$OP" = pending ]; then
499
874
  BARE=1
500
875
  _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
876
+ 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
877
  _json_slice "$INPUT" session_id >/dev/null; _bsid="$_JS"
503
878
  _bp="${CLAUDE_PROJECT_DIR:-$CWD}"; _bp="${_bp//\\//}"; _bp="${_bp%/}/.claude/state/crew-approval-pending"
504
879
  _bs=""; _bo=""; _bt=""
@@ -519,100 +894,11 @@ fi
519
894
  _json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
520
895
  case "$PM" in
521
896
  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.
897
+ # Where a prompt reaches the user the gate asks them itself, and nothing is recorded or said here. Except where a
898
+ # PermissionRequest hook may be wired (3.2.0): it can answer that prompt before the person does, so guard-bash.sh
899
+ # does not raise it and the typed approval is the one way left, in these modes too. In plan and bypassPermissions
900
+ # this route is not open (a decision, 3.1.0): guard-bash.sh explains when it refuses.
901
+ default|acceptEdits) _crew_prh_scan || exit 0 ;;
524
902
  *) exit 0 ;;
525
903
  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."
904
+ _pa_record