crewforth 3.1.0-rc.3 → 3.1.0-rc.4

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.
@@ -1843,6 +1843,62 @@ _appr_named
1843
1843
  [ "$_APN" = 2 ] && block "the approval hook fed by a command (only Claude Code runs it, with the user's message)" "4.4" tamper
1844
1844
  [ "$_APN" = 3 ] && block "a Claude Code session started from a command to continue this one (it could approve for it)" "4.4" tamper
1845
1845
 
1846
+ # §4.4: A SESSION STARTED FROM A COMMAND IS NOT HANDED AN APPROVAL. `claude -p "/crew-approve commit"` starts a session
1847
+ # whose only message is the approval; that session would record it and commit, and this one would have approved for
1848
+ # the user. hooks/prompt-approval.sh refuses to record in a headless session and at a session's first message, which
1849
+ # is the protection; this is the second line, for the forms a command spells out. A command that names claude (so
1850
+ # also `npx @anthropic-ai/claude-code`, `bunx`, `node …/claude-code/cli.js`) is read word by word, the way the shell
1851
+ # splits it, and refused when one WORD is an approval: a quoted argument, an escaped one, the value of an option
1852
+ # (`--prompt=…`) or of a variable (`p='…'`), a text echoed into it. Which text is an approval is asked of the one
1853
+ # function that reads the user's prompt, _crew_appr_op, sourced from prompt-approval.sh. The prompt of an ordinary
1854
+ # `claude -p "…"` is not an approval and nothing here touches it.
1855
+ # HONEST SCOPE: a prompt read from a file, built by printf, or written with an escape the shell decodes (`$'\x2f…'`)
1856
+ # is not spelled in the command, and is past this rule.
1857
+ _appr_word(){ # $1 = a word of the command -> 0 when it, or what follows its first `=`, is an approval
1858
+ local w="$1"
1859
+ [ "${#w}" -le 160 ] || return 1
1860
+ _crew_appr_op "$w"; [ -n "$OP" ] && return 0
1861
+ case "$w" in *=*) _crew_appr_op "${w#*=}"; [ -n "$OP" ] && return 0 ;; esac
1862
+ return 1
1863
+ }
1864
+ _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names it)
1865
+ # The command AS IT WAS SENT: the prompt of a plain `claude -p "…"` has been taken out of CMD by now (it is text for
1866
+ # another session, not a command of this one), and it is exactly the word this rule has to read.
1867
+ local s="${CMD_REAL:-$CMD}" w="" pre c body inw=0 nc=0 here="${BASH_SOURCE%/*}"
1868
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
1869
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
1870
+ case "$s" in *claude*) [ "$nc" = 0 ] && shopt -u nocasematch ;; *) [ "$nc" = 0 ] && shopt -u nocasematch; return 1 ;; esac
1871
+ [ -f "$here/prompt-approval.sh" ] || return 1 # no recording hook: no approval can be recorded at all
1872
+ declare -F _crew_appr_op >/dev/null 2>&1 || . "$here/prompt-approval.sh"
1873
+ declare -F _crew_appr_op >/dev/null 2>&1 || return 1
1874
+ OP=""
1875
+ while :; do
1876
+ pre="${s%%[\"\'\\$' \t\n;|&()<>']*}"
1877
+ [ -n "$pre" ] && { w="$w$pre"; inw=1; }
1878
+ [ "$pre" = "$s" ] && break
1879
+ c="${s:${#pre}:1}"; s="${s:${#pre}+1}"
1880
+ case "$c" in
1881
+ \\) case "$s" in $'\n'*) s="${s:1}" ;; *) w="$w${s:0:1}"; s="${s:1}"; inw=1 ;; esac ;;
1882
+ \') [ "${w%\$}" != "$w" ] && w="${w%\$}" # $'…': the text is kept as written
1883
+ body="${s%%\'*}"; if [ "$body" = "$s" ]; then s=""; else s="${s:${#body}+1}"; fi
1884
+ w="$w$body"; inw=1 ;;
1885
+ \") inw=1
1886
+ while :; do
1887
+ body="${s%%[\"\\]*}"; w="$w$body"
1888
+ [ "$body" = "$s" ] && { s=""; break; }
1889
+ c="${s:${#body}:1}"; s="${s:${#body}+1}"
1890
+ [ "$c" = '"' ] && break
1891
+ w="$w${s:0:1}"; s="${s:1}"
1892
+ done ;;
1893
+ *) if [ "$inw" = 1 ]; then _appr_word "$w" && return 0; fi
1894
+ w=""; inw=0 ;;
1895
+ esac
1896
+ done
1897
+ [ "$inw" = 1 ] && _appr_word "$w" && return 0
1898
+ return 1
1899
+ }
1900
+ _appr_in_claude_call && block "a command that hands a Claude Code session an approval as its prompt (only the user's own message approves)" "4.4" tamper
1901
+
1846
1902
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
1847
1903
  # `cat .env` would surface them. Block the direct-file readers/copiers and a `< .env` input redirect on a
1848
1904
  # .env / .env.<env> file; the templates (.env.example/.sample/.template/.dist) stay readable. Arg-taking readers
@@ -2155,7 +2211,7 @@ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktre
2155
2211
  }
2156
2212
  # ---- /CREW-APPROVAL-PATH ---------------------------------------------------------------------------------
2157
2213
  # §4.4 IN `auto` AND `dontAsk`: THE USER'S OWN MESSAGE IS THE APPROVAL. hooks/prompt-approval.sh records it when the
2158
- # whole message is `approve: commit` / `push` / `commit+push` (or `onay: …`), with what git reported at that moment.
2214
+ # whole message is `/crew-approve commit` / `push` / `commit+push` (or the older `approve: …`), with what git reported at that moment.
2159
2215
  # This reads the record back and answers one question: does it cover THIS call, and nothing else?
2160
2216
  # commit the index writes the recorded tree and HEAD is the recorded one. A commit moves HEAD, so the same
2161
2217
  # record cannot allow a second one; a commit that FAILED (a hook refused it) moved nothing and may be retried.
@@ -3167,7 +3223,7 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
3167
3223
  if ! _appr_wired; then
3168
3224
  # The record is written by one hook. Where it is not wired, a message from the user records nothing, and
3169
3225
  # telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
3170
- echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A message 'approve: …' records nothing here." >&2
3226
+ echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A '/crew-approve' message records nothing here." >&2
3171
3227
  echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3172
3228
  echo "Do not create an approval any other way." >&2
3173
3229
  exit 2
@@ -3175,9 +3231,9 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
3175
3231
  _aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
3176
3232
  echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
3177
3233
  echo " 1. the commit message, whole, in ONE code block;" >&2
3178
- echo " 2. under it ONE line: If you approve, write only this: approve: $_aps" >&2
3179
- echo " (in Turkish: Onaylıyorsan yalnız şunu yaz: onay: $_aps). Name the one that fits: commit, push, or commit+push when a push follows the commit." >&2
3180
- echo "Their reply has to be that text alone: a sentence such as 'go ahead and commit' is not an approval. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
3234
+ echo " 2. under it ONE line, the sentence in their language and the command as it is (only the user can type it): If you approve, send only this: /crew-approve $_aps" >&2
3235
+ echo " Name the one the user can type for this call: /crew-approve commit, /crew-approve push, or /crew-approve commit+push when a push follows the commit." >&2
3236
+ echo "Their message has to be that command alone, typed by them: a sentence such as 'go ahead and commit' is not an approval, and you cannot run the command for them. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
3181
3237
  echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
3182
3238
  echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3183
3239
  echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # guard-schedule.sh - a prompt a tool schedules is not the user's (§4.4).
3
+ #
4
+ # WHY. In `auto` and `dontAsk` the approval for a commit or a push is what the user types: `/crew-approve commit`,
5
+ # recorded by hooks/prompt-approval.sh from UserPromptSubmit. A prompt that a tool SCHEDULES raises the same event
6
+ # when it fires. So a session that scheduled `/crew-approve commit` would be approving its own commit.
7
+ #
8
+ # WHAT. Wired on PreToolUse for the tools that schedule a prompt (CronCreate, ScheduleWakeup, RemoteTrigger, and an
9
+ # MCP tool whose name holds trigger, schedule, cron or later). The call is refused when ANY string in its input is
10
+ # an approval, by the function that reads the user's own prompt: prompt-approval.sh is sourced for it, so there is
11
+ # one definition of what an approval is. An ordinary scheduled prompt holds no such string and passes untouched.
12
+ #
13
+ # COST. One awk on these few tools, to take the strings out of the payload; nothing on any other tool call.
14
+ # HONEST SCOPE. A tool this hook is not wired on, or a prompt put together only when it fires, is past it.
15
+ set -uo pipefail
16
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
17
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
18
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
19
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
20
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
21
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
22
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
23
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
24
+ # staged key all passed.
25
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
26
+ # scan for private strings read that as "no match".
27
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
28
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
29
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
30
+ # Byte-identical in every gate; the suite pins it.
31
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
32
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
33
+ export LC_ALL=C
34
+ # ---- /CREW-LOCALE
35
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
36
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
37
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
38
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
39
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
40
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
41
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
42
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
43
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
44
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
45
+ # that is not found (status 127, the script continues inside the same command).
46
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
47
+ _crew_stop(){ # $1 = what happened
48
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
49
+ 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
50
+ 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
51
+ exit 2
52
+ }
53
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
54
+ # ---- /CREW-FAILCLOSED
55
+ _gate_main(){
56
+ local here="${BASH_SOURCE%/*}" strs sv tn sch=0
57
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
58
+ IFS= read -r -d '' INPUT || true
59
+ if [ ! -f "$here/prompt-approval.sh" ]; then
60
+ echo "GUARD (§4.4): prompt-approval.sh is not beside this gate, so a scheduled prompt cannot be checked for an approval and the call is refused. Update Crewforth." >&2
61
+ exit 2
62
+ fi
63
+ . "$here/prompt-approval.sh"
64
+ declare -F _crew_appr_op >/dev/null 2>&1 || { echo "GUARD (§4.4): prompt-approval.sh did not give the approval check, so the call is refused. Update Crewforth." >&2; exit 2; }
65
+ _json_slice "$INPUT" tool_name >/dev/null; tn="$_JS"
66
+ shopt -s nocasematch
67
+ case "$tn" in *cron*|*schedul*|*trigger*|*later*|*wakeup*) sch=1 ;; esac
68
+ shopt -u nocasematch
69
+ [ "$sch" = 1 ] || exit 0
70
+ # Every JSON string of at most 120 bytes, still escaped, one per line (a JSON string holds no raw newline). An
71
+ # approval is a few words: a longer string is never one, and is not kept.
72
+ strs="$(printf '%s' "$INPUT" | LC_ALL=C awk '{ buf = buf $0 " " } END { n = length(buf); ins = 0; esc = 0; s = ""; big = 0
73
+ for (i = 1; i <= n; i++) { c = substr(buf, i, 1)
74
+ if (!ins) { if (c == "\"") { ins = 1; s = ""; big = 0 }; continue }
75
+ if (esc) { esc = 0; if (!big) s = s c; continue }
76
+ if (c == "\\") { esc = 1; if (!big) s = s c; continue }
77
+ if (c == "\"") { ins = 0; if (!big) print s; continue }
78
+ if (!big) { s = s c; if (length(s) > 120) big = 1 } } }')" || {
79
+ echo "GUARD (§4.4): the prompt this call schedules could not be read (awk did not run), so the call is refused." >&2; exit 2; }
80
+ while IFS= read -r sv; do
81
+ [ -n "$sv" ] || continue
82
+ _json_unescape "$sv" >/dev/null; _crew_appr_op "$_JU"
83
+ if [ -n "$OP" ]; then
84
+ echo "GUARD (§4.4): this call schedules a prompt that is an approval for a commit or a push ($OP). An approval is what the user types; a prompt a tool schedules would approve in their place, so the call is refused." >&2
85
+ echo "Schedule the work without it. For the approval itself, show the commit message and ask: the user can type /crew-approve $OP when they agree." >&2
86
+ exit 2
87
+ fi
88
+ done <<< "$strs"
89
+ exit 0
90
+ }
91
+ _gate_main "$@"
92
+ _crew_stop "a command of the gate was abandoned"
@@ -8,8 +8,8 @@
8
8
  # yes, and this hook is the only thing that turns it into a record.
9
9
  #
10
10
  # WHAT COUNTS. The WHOLE message, trimmed, is one of (letter case does not matter):
11
- # approve: commit approve: push approve: commit+push
12
- # onay: commit onay: push onay: commit+push
11
+ # /crew-approve commit /crew-approve push /crew-approve commit+push
12
+ # and, as the release candidates of 3.1.0 had it, `approve: <what>` or `onay: <what>`.
13
13
  # A sentence that contains those words is not an approval, and neither is a message with a second line: text
14
14
  # that was pasted, quoted or relayed from somewhere else never has that shape by accident.
15
15
  #
@@ -51,8 +51,13 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
51
51
  case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
52
52
  export LC_ALL=C
53
53
  # ---- /CREW-LOCALE
54
- IFS= read -r -d '' INPUT || true
55
- case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
54
+ # guard-schedule.sh SOURCES this file for one thing, _crew_appr_op: which text is an approval is decided here and
55
+ # nowhere else. Sourced, the file defines its functions and returns before anything is read or written.
56
+ _PA_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _PA_LIB=1
57
+ if [ "$_PA_LIB" = 0 ]; then
58
+ IFS= read -r -d '' INPUT || true
59
+ case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
60
+ fi
56
61
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
57
62
  _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
58
63
  # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
@@ -351,6 +356,38 @@ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktre
351
356
  }
352
357
  # ---- /CREW-APPROVAL-PATH ---------------------------------------------------------------------------------
353
358
 
359
+ # ---- WHICH TEXT IS AN APPROVAL: one function, asked here of the user's prompt and by guard-schedule.sh of a
360
+ # prompt a tool is about to schedule ---------------------------------------------------------------------------
361
+ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push, or "" when the text is not an approval
362
+ local P="$1" _k _v LC_ALL=C
363
+ OP=""
364
+ P="${P//$'\r'/}"
365
+ while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
366
+ while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
367
+ [ "${#P}" -le 40 ] || return 0
368
+ case "$P" in
369
+ *$'\n'*) return 0 ;;
370
+ # THE COMMAND: `/crew-approve <what>`, or `/<plugin>:crew-approve <what>` as a plugin names its commands. Measured
371
+ # on Claude Code 2.1.284: a command the user types reaches UserPromptSubmit with `prompt` as typed, and a skill
372
+ # the model starts with the Skill tool raises PreToolUse and PostToolUse only, never that event.
373
+ /*) _k="${P%%[$' \t']*}"; [ "$_k" != "$P" ] || return 0
374
+ _v="${P:${#_k}}"; _k="${_k#/}"; _k="@${_k##*:}" ;; # `@`: a command, so `crew-approve: commit` as text is not one
375
+ # The text form of 3.1.0's release candidates, kept: `approve: <what>` and `onay: <what>`.
376
+ *:*) _k="${P%%:*}"; _v="${P#*:}" ;;
377
+ *) return 0 ;;
378
+ esac
379
+ _k="${_k//[$' \t']/}"; _v="${_v//[$' \t']/}"
380
+ # In the C locale: under tr_TR an upper-case I does not fold to i, and `ONAY: COMMIT` was not an approval (review).
381
+ shopt -s nocasematch
382
+ case "$_k" in
383
+ @crew-approve|approve|onay)
384
+ case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
385
+ esac
386
+ shopt -u nocasematch
387
+ }
388
+
389
+ [ "$_PA_LIB" = 1 ] && return 0
390
+
354
391
  # A second "prompt" key cannot come from the text of a prompt (inside a JSON string every quote is escaped), so
355
392
  # more than one means the payload is not the shape this hook knows, and nothing is read from it.
356
393
  _json_keycount "$INPUT" prompt; _n_prompt=$_KC
@@ -384,23 +421,8 @@ if [ -n "$REC" ] && [ -s "$REC" ]; then : > "$REC" 2>/dev/null || true; fi
384
421
  # ---- is this message an approval? ------------------------------------------------------------------------
385
422
  [ "$_n_prompt" = 1 ] || exit 0
386
423
  [ "${#RAW}" -le 120 ] || exit 0 # an approval is a few words; a pasted page is not decoded to find that out
387
- _json_unescape "$RAW" >/dev/null; P="$_JU"
388
- P="${P//$'\r'/}"
389
- while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
390
- while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
391
- [ "${#P}" -le 40 ] || exit 0
392
- case "$P" in *$'\n'*) exit 0 ;; *:*) ;; *) exit 0 ;; esac
393
- _k="${P%%:*}"; _v="${P#*:}"
394
- _k="${_k//[$' \t']/}"; _v="${_v//[$' \t']/}"
395
- OP=""
396
- # In the C locale: under tr_TR an upper-case I does not fold to i, and `ONAY: COMMIT` was not an approval (review).
397
- _pa_match(){ local LC_ALL=C; shopt -s nocasematch
398
- case "$_k" in
399
- approve|onay)
400
- case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
401
- esac
402
- shopt -u nocasematch; }
403
- _pa_match
424
+ _json_unescape "$RAW" >/dev/null
425
+ _crew_appr_op "$_JU"
404
426
  [ -n "$OP" ] || exit 0
405
427
 
406
428
  # From here on the message IS an approval, so every way of not recording it says why - to the user and to the
@@ -420,6 +442,31 @@ case "$PM" in
420
442
  esac
421
443
  [ -n "$REC" ] || no "this directory is not inside a git repository."
422
444
 
445
+ # A PERSON, IN A SESSION THAT HAS SHOWN THEM SOMETHING. A session can start another one and hand it the approval as
446
+ # its prompt: `claude -p "/crew-approve commit"`, or an interactive one driven through a pseudo-terminal. That
447
+ # session's only message is the approval, this hook would record it, and the session would then commit: the first
448
+ # session has approved for the user. Measured on Claude Code 2.1.294 (macOS), from a hook that printed what it saw:
449
+ # started how CLAUDE_CODE_SESSION_ATTENDED the transcript file when this hook runs
450
+ # claude -p, from a session's Bash 0 (set to 1 by the caller: still 0) not there (first message)
451
+ # claude -p, from an emptied environment 0 not there (first message)
452
+ # interactive, in a pty, from a session 1 not there, and never written: a
453
+ # session started inside another keeps none
454
+ # interactive, in a pty, emptied environment 1 not there at the first message; there after
455
+ # the desktop app, a conversation under way 1 there
456
+ # Nothing else told them apart: the payload has the same fields, CLAUDECODE and CLAUDE_CODE_CHILD_SESSION are 1 in
457
+ # all of them, CLAUDE_CODE_ENTRYPOINT is inherited from the outer session, and no hook has a terminal on stdin.
458
+ # So two things are asked. Neither is a documented contract, and both fail towards NOT recording:
459
+ # 1. the session is not a headless one;
460
+ # 2. the session's transcript is already on disk when the approval arrives, so the approval is not the session's
461
+ # first message and the session is not one that keeps no transcript.
462
+ # HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
463
+ # message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
464
+ case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
465
+ 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." ;;
466
+ esac
467
+ _json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
468
+ { [ -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."
469
+
423
470
  # The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
424
471
  # command can be handed any prompt.
425
472
  _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
package/kit/settings.json CHANGED
@@ -86,6 +86,17 @@
86
86
  "timeout": 600
87
87
  }
88
88
  ]
89
+ },
90
+ {
91
+ "matcher": "CronCreate|ScheduleWakeup|RemoteTrigger|mcp__.*([Tt]rigger|[Ss]chedul|[Cc]ron|[Ll]ater).*",
92
+ "hooks": [
93
+ {
94
+ "type": "command",
95
+ "shell": "bash",
96
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/guard-schedule.sh",
97
+ "timeout": 600
98
+ }
99
+ ]
89
100
  }
90
101
  ],
91
102
  "UserPromptSubmit": [
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: crew-approve
3
+ description: Approve the commit or push Claude just showed you (auto and dontAsk modes).
4
+ argument-hint: "[commit | push | commit+push]"
5
+ disable-model-invocation: true
6
+ metadata:
7
+ kind: command
8
+ ---
9
+ # /crew-approve
10
+ Argument: $ARGUMENTS
11
+
12
+ The user typed this command. Typing it is the approval: `hooks/prompt-approval.sh` read the message itself and
13
+ recorded it, tied to what is staged, to `HEAD` and to this session, for 30 minutes or until the user's next
14
+ message. Nothing in this text records anything, and you cannot give this approval for the user.
15
+
16
+ Read the line Crewforth added to this turn and do what it says:
17
+
18
+ - **"approval recorded"**: run exactly what it names, each command alone in its call. A commit is
19
+ `git commit -m '…'` with the message you showed (single-quoted, or from a here-document with a quoted delimiter);
20
+ a push is `git push <remote> <branch>`. The review record of §4.6 is still required.
21
+ - **"approval NOT recorded"**: tell the user the reason it gives, and run nothing.
22
+ - **No such line**: the session is in a mode where the gate asks the user itself (`default`, `acceptEdits`), or
23
+ the argument was not `commit`, `push` or `commit+push`. Say which, and in the first case run the command and let
24
+ the gate's own prompt ask.
25
+
26
+ Force-push and the rest of §4.5 are not opened by this.
@@ -67,6 +67,7 @@ function scanMain(records) {
67
67
  agentCalls: new Map(), // toolUseId -> { subagentType, description }
68
68
  links: new Map(), // toolUseId -> { agentId, status, model }
69
69
  completions: new Map(), // task id (an agent's, or a background command's) -> { status, at }
70
+ waves: new Map(), // assistant message id -> wave number, in the order the messages were written
70
71
  commands: new Map(), // toolUseId -> the command a Bash call ran
71
72
  background: new Map(), // background task id -> { toolUseId, startedAt }
72
73
  owners: new Map(), // toolUseId -> owner node id
@@ -97,10 +98,18 @@ function scanMain(records) {
97
98
  if (c?.type === 'tool_use' && c.id) {
98
99
  out.owners.set(c.id, SESSION_NODE);
99
100
  if (typeof c.input?.command === 'string') out.commands.set(c.id, c.input.command);
100
- if (c.name === 'Agent') {
101
+ if (c.name === 'Agent' || c.name === 'Task') {
102
+ // The order the session called its agents in, and which calls were made together: the calls of one
103
+ // assistant message are one wave. A message is written as several records, one per block, and they
104
+ // share its id — so the wave is the message id, not the record.
105
+ const mid = r.message?.id ?? `record:${out.agentCalls.size}`;
106
+ if (!out.waves.has(mid)) out.waves.set(mid, out.waves.size + 1);
101
107
  out.agentCalls.set(c.id, {
102
108
  subagentType: c.input?.subagent_type ?? null,
103
109
  description: c.input?.description ?? null,
110
+ order: out.agentCalls.size + 1,
111
+ wave: out.waves.get(mid),
112
+ calledAt: at,
104
113
  });
105
114
  }
106
115
  }
@@ -119,7 +128,8 @@ function scanMain(records) {
119
128
  if (tur && typeof tur === 'object' && tur.agentId) {
120
129
  const id = r.message?.content?.find?.((c) => c?.type === 'tool_result')?.tool_use_id
121
130
  ?? r.toolUseID ?? null;
122
- const entry = { agentId: tur.agentId, status: tur.status ?? null, model: tur.resolvedModel ?? null };
131
+ // `at` is when the answer was written: for a call the session waited on, that is when the agent reported.
132
+ const entry = { agentId: tur.agentId, status: tur.status ?? null, model: tur.resolvedModel ?? null, at };
123
133
  if (id) out.links.set(id, entry);
124
134
  else out.links.set(`agent:${tur.agentId}`, entry);
125
135
  }
@@ -314,6 +324,8 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
314
324
  // toolUseId -> agentId, from the returning result record.
315
325
  const byToolUse = new Map();
316
326
  for (const [k, v] of main.links) if (!k.startsWith('agent:')) byToolUse.set(k, v);
327
+ const callOf = new Map();
328
+ for (const [k, v] of byToolUse) callOf.set(v.agentId, k);
317
329
 
318
330
  const nodes = [{
319
331
  id: SESSION_NODE,
@@ -345,8 +357,10 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
345
357
  const edges = [];
346
358
 
347
359
  for (const a of agents) {
348
- const call = a.toolUseId ? main.agentCalls.get(a.toolUseId) : null;
349
- const link = a.toolUseId ? byToolUse.get(a.toolUseId) : null;
360
+ // The call that started it: named by its own meta file, or found from the answer that named the agent.
361
+ const callId = a.toolUseId ?? callOf.get(a.agentId) ?? null;
362
+ const call = callId ? main.agentCalls.get(callId) : null;
363
+ const link = callId ? byToolUse.get(callId) : null;
350
364
  const notice = main.completions.get(a.agentId) ?? null;
351
365
  const completion = noticeStands(notice, a.stats?.endedAt ?? null) ? notice.status : null;
352
366
 
@@ -398,6 +412,15 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
398
412
  endedAt: a.stats?.endedAt ?? null,
399
413
  durationMs: a.stats?.durationMs ?? null,
400
414
  updatedAt: a.mtime,
415
+ // Where it stands among the agents the session called: its place in the order of the calls, the wave it
416
+ // was called in, and when. Null for an agent the session's own transcript did not call (one a workflow
417
+ // started): nothing is known about its place from here.
418
+ order: call?.order ?? null,
419
+ wave: call?.wave ?? null,
420
+ calledAt: call?.calledAt ?? null,
421
+ // When it handed its result back: the answer to a call the session waited on, or the notice that ended a
422
+ // background agent. Null while it has not, and when no time was recorded.
423
+ reportedAt: link?.status === 'completed' ? (link.at ?? null) : (completion ? (notice.at ?? null) : null),
401
424
  });
402
425
 
403
426
  edges.push({ id: `${parentId}->${a.agentId}`, source: parentId, target: a.agentId, kind: 'spawn' });
@@ -419,7 +419,7 @@ el.newSession.addEventListener('click', () => {
419
419
 
420
420
  /* The toolbar says what the canvas is doing. It is painted from the canvas's own
421
421
  state, so a choice restored from storage shows without being re-made. */
422
- const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', none: 'None' };
422
+ const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', order: 'Order', none: 'None' };
423
423
  const DENSITY_WORD = { auto: 'Auto', comfortable: 'Comfortable', compact: 'Compact' };
424
424
  const SHOW = {
425
425
  all: { word: 'All', statuses: null },
@@ -11,10 +11,11 @@
11
11
  // A status is never colour alone: every dot has its word beside it.
12
12
  // A poll that changed nothing writes nothing. Each element remembers what it last drew.
13
13
  import {
14
- plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES,
14
+ plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
15
15
  } from './graph-plan.js';
16
+ import { clock } from './timeline-plan.js';
16
17
  import { agentStatus } from './nav.js';
17
- import { roleName, shownType, hoverOf } from './names.js';
18
+ import { roleName, shownType, hoverOf, orderTag } from './names.js';
18
19
 
19
20
  // How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
20
21
  // and anything a viewer has to wait through is a cost they pay on every spawn.
@@ -213,6 +214,10 @@ export class Canvas {
213
214
  const rows = mk('div', 'cv-legend-grid');
214
215
  rows.append(line('live', 'working now'), line('waiting', 'needs you'), line('done', 'finished'), line('failed', 'failed'));
215
216
  box.append(rows);
217
+ // The one wire that is not a call: in the Order view, from an agent to the wave called after it reported.
218
+ const inferred = line('inferred', 'after it reported (inferred, not a call)');
219
+ inferred.title = INFERRED_NOTE;
220
+ box.append(inferred);
216
221
  const marks = mk('div', 'cv-legend-grid');
217
222
  this.legendBuiltin = mk('span', 'cv-tile cv-tile-sm');
218
223
  const crew = mk('span', 'cv-tile cv-tile-sm');
@@ -647,7 +652,7 @@ export class Canvas {
647
652
  el.classList.toggle('cv-pinned', Boolean(it.pinned));
648
653
  if (it.kind === 'session') this.#fillSession(el, it);
649
654
  else if (it.kind === 'group') this.#fillGroup(el, it, liveCells);
650
- else this.#fillAgent(el, it.node, it.compact);
655
+ else this.#fillAgent(el, it.node, it.compact, it.after ?? null);
651
656
  }
652
657
 
653
658
  /** The skeleton of a card: the parts it will fill. */
@@ -686,18 +691,24 @@ export class Canvas {
686
691
  } else if (it.compact) {
687
692
  el.classList.add('cv-chip');
688
693
  p.dot = mk('span', 'dot');
694
+ p.order = mk('span', 'cv-order');
689
695
  p.name = mk('span', 'cv-name');
690
696
  p.task = mk('span', 'cv-task');
691
697
  p.word = mk('span', 'cv-word');
692
- el.append(p.dot, p.name, p.task, p.word);
698
+ el.append(p.dot, p.order, p.name, p.task, p.word);
693
699
  } else {
694
700
  p.tile = mk('span', 'cv-tile');
695
701
  p.name = mk('span', 'cv-name');
702
+ p.order = mk('span', 'cv-order');
696
703
  p.pill = this.#pill();
697
704
  const row = mk('div', 'cv-row');
698
705
  row.append(p.tile, p.name, mk('span', 'cv-fill'), p.pill.el);
699
- p.task = mk('div', 'cv-task cv-indent');
700
- el.append(row, p.task);
706
+ // The second line: the task, and at its end where the card stands in the order of the calls. The name
707
+ // keeps the first line to itself.
708
+ p.task = mk('span', 'cv-task');
709
+ const under = mk('div', 'cv-under cv-indent');
710
+ under.append(p.task, p.order);
711
+ el.append(row, under);
701
712
  }
702
713
  el.parts = p;
703
714
  }
@@ -768,13 +779,15 @@ export class Canvas {
768
779
  el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
769
780
  }
770
781
 
771
- #fillAgent(el, n, compact) {
782
+ #fillAgent(el, n, compact, after = null) {
772
783
  const st = this.#statusOf(n);
773
784
  const who = compact ? this.#identity(n) : this.#fillTile(el.parts.tile, n);
774
785
  const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : shownType(n);
775
786
  const task = n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? '');
776
787
  const dim = this.dimmed(n);
777
- const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status]);
788
+ // Its place in the order the session called its agents, and when: "#3 · 09:14". A chip has room for the number.
789
+ const tag = orderTag(n, compact ? null : clock);
790
+ const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after]);
778
791
  if (el.sig === sig) return;
779
792
  el.sig = sig;
780
793
  const p = el.parts;
@@ -789,6 +802,11 @@ export class Canvas {
789
802
  // Both are cut to the card's width; hovering says them whole, and the type as it is declared.
790
803
  p.name.title = n.kind === 'workflow' ? '' : (n.agentType ?? '');
791
804
  p.task.title = task;
805
+ p.order.textContent = tag;
806
+ p.order.hidden = !tag;
807
+ p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
808
+ // In the Order view: what had reported before this wave was called. A reading of the order of events.
809
+ el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
792
810
  if (compact) {
793
811
  p.dot.dataset.tone = st.tone ?? 'none';
794
812
  p.word.textContent = st.word;
@@ -806,7 +824,7 @@ export class Canvas {
806
824
  const p = el.parts;
807
825
  const counts = it.counts;
808
826
  const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
809
- const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status]);
827
+ const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status, it.first?.order ?? null]);
810
828
  if (el.sig !== sig) {
811
829
  el.sig = sig;
812
830
  el.dataset.state = stateOf(it.status);
@@ -820,7 +838,9 @@ export class Canvas {
820
838
  if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
821
839
  else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
822
840
  // Several agents of one type: the number is a badge beside the role. Any other group says how many it holds.
823
- p.sub.textContent = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
841
+ const size = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
842
+ // The group stands where its first member does in the order of the calls.
843
+ p.sub.textContent = [orderTag(it.first), size].filter(Boolean).join(' · ');
824
844
  p.sub.classList.toggle('cv-times', it.type === 'type');
825
845
  p.fold.innerHTML = it.boxed ? MARK.down : MARK.chevron;
826
846
  // Read aloud, the badge is part of the name.
@@ -856,8 +876,9 @@ export class Canvas {
856
876
  c.tabIndex = 0;
857
877
  const q = {};
858
878
  if (!it.grid) { q.tile = mk('span', 'cv-tile'); c.append(q.tile); } else { q.dot = mk('span', 'dot'); c.append(q.dot); }
879
+ q.order = mk('span', 'cv-order');
859
880
  q.name = mk('span', 'cv-name');
860
- c.append(q.name);
881
+ c.append(q.order, q.name);
861
882
  if (!it.grid) { c.append(mk('span', 'cv-fill')); q.dot = mk('span', 'dot'); c.append(q.dot); }
862
883
  q.word = mk('span', 'cv-word');
863
884
  c.append(q.word);
@@ -879,7 +900,8 @@ export class Canvas {
879
900
  // Inside a group of one type the type is the group's name, so a member is told apart by its task.
880
901
  const text = it.type === 'type' ? (n.description || shownType(n) || n.id) : shownType(n);
881
902
  const dim = this.dimmed(n);
882
- const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id]);
903
+ const ctag = orderTag(n, it.grid ? null : clock);
904
+ const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id, ctag]);
883
905
  if (c.sig === csig) continue;
884
906
  c.sig = csig;
885
907
  c.dataset.state = st.state;
@@ -887,6 +909,8 @@ export class Canvas {
887
909
  c.classList.toggle('cv-dim', dim);
888
910
  c.classList.toggle('cv-selected', this.selected === n.id);
889
911
  c.parts.name.textContent = text;
912
+ c.parts.order.textContent = ctag;
913
+ c.parts.order.hidden = !ctag;
890
914
  c.title = hoverOf(n);
891
915
  c.parts.dot.dataset.tone = st.tone ?? 'none';
892
916
  c.parts.word.textContent = st.word;
@@ -894,6 +918,30 @@ export class Canvas {
894
918
  }
895
919
  }
896
920
 
921
+ /** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
922
+ #edgeWords(path, e, c) {
923
+ const title = e.title ?? '';
924
+ if (path.said !== title) {
925
+ path.said = title;
926
+ path.querySelector?.('title')?.remove();
927
+ if (title) {
928
+ const t = document.createElementNS(SVG, 'title');
929
+ t.textContent = title;
930
+ path.append(t);
931
+ }
932
+ }
933
+ if (!e.label) { path.label?.remove(); path.label = null; return; }
934
+ if (!path.label) {
935
+ path.label = document.createElementNS(SVG, 'text');
936
+ path.label.setAttribute('class', 'cv-edge-label');
937
+ this.edgeG.append(path.label);
938
+ }
939
+ path.label.textContent = e.label;
940
+ // Just off the reporter's edge, above the wire, where it does not sit on a card.
941
+ path.label.setAttribute('x', String(e.from.x + 8));
942
+ path.label.setAttribute('y', String(e.from.y - 6));
943
+ }
944
+
897
945
  // Wires are kept and updated, never rebuilt.
898
946
  //
899
947
  // Replacing the whole layer each pass was the reason motion could not live here: a CSS animation restarts when
@@ -932,9 +980,13 @@ export class Canvas {
932
980
 
933
981
  const target = this.nodes.get(e.target);
934
982
  const waiting = target && this.waiting.has(target.id);
935
- const state = waiting ? 'waiting' : e.state;
983
+ // A wire that is a reading of the order of events says nothing about what its target is doing now.
984
+ const inferred = e.kind === 'inferred';
985
+ const state = inferred ? 'inferred' : (waiting ? 'waiting' : e.state);
936
986
  path.dataset.status = e.status;
937
987
  path.dataset.state = state;
988
+ path.dataset.kind = inferred ? 'inferred' : 'call';
989
+ this.#edgeWords(path, e, c);
938
990
  path.classList.toggle('cv-dim', Boolean(target) && this.dimmed(target));
939
991
  path.classList.toggle('cv-path', onPath.has(e.target));
940
992
 
@@ -950,6 +1002,7 @@ export class Canvas {
950
1002
 
951
1003
  for (const [key, path] of this.edgeEls) {
952
1004
  if (alive.has(key)) continue;
1005
+ path.label?.remove();
953
1006
  path.remove();
954
1007
  this.edgeEls.delete(key);
955
1008
  }