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.
- package/CHANGELOG.md +35 -9
- package/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +3 -3
- package/kit/eval/doctor.sh +7 -7
- package/kit/eval/smoke-test.sh +215 -26
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +61 -5
- package/kit/hooks/guard-schedule.sh +92 -0
- package/kit/hooks/prompt-approval.sh +68 -21
- package/kit/settings.json +11 -0
- package/kit/skills/crew-approve/SKILL.md +26 -0
- package/kit/studio/server/lib/graph.js +27 -4
- package/kit/studio/web/app.js +1 -1
- package/kit/studio/web/canvas.js +66 -13
- package/kit/studio/web/graph-plan.js +106 -1
- package/kit/studio/web/names.js +11 -0
- package/kit/studio/web/style.css +16 -0
- package/kit/studio/web/timeline-plan.js +8 -0
- package/package.json +1 -1
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
3179
|
-
echo "
|
|
3180
|
-
echo "Their
|
|
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
|
|
12
|
-
#
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
const
|
|
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' });
|
package/kit/studio/web/app.js
CHANGED
|
@@ -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 },
|
package/kit/studio/web/canvas.js
CHANGED
|
@@ -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
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|