crewforth 3.1.1 → 3.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +56 -5
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +14 -13
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +117 -6
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-default.sh +36 -0
  30. package/kit/eval/lib/settings-json.awk +15 -4
  31. package/kit/eval/lib/star.sh +23 -7
  32. package/kit/eval/lib/statusline.settings.json +6 -0
  33. package/kit/eval/lib/statusline.sh +100 -0
  34. package/kit/eval/lib/version.sh +41 -0
  35. package/kit/eval/smoke-test.sh +576 -41
  36. package/kit/hooks/agent-outcome.sh +26 -12
  37. package/kit/hooks/guard-agent-model.sh +109 -9
  38. package/kit/hooks/guard-bash.sh +240 -182
  39. package/kit/hooks/guard-question.sh +73 -0
  40. package/kit/hooks/guard-write.sh +21 -0
  41. package/kit/hooks/prompt-approval.sh +320 -103
  42. package/kit/hooks/refuter-ledger.sh +80 -0
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/owned-blobs.tsv +4 -4
  45. package/kit/settings.json +33 -0
  46. package/kit/skills/crew-approve/SKILL.md +7 -0
  47. package/kit/skills/crew-doctor/SKILL.md +5 -1
  48. package/kit/skills/crew-handoff/SKILL.md +3 -2
  49. package/kit/skills/crew-map/SKILL.md +44 -0
  50. package/kit/skills/crew-review/SKILL.md +2 -0
  51. package/kit/skills/crew-update/SKILL.md +1 -1
  52. package/kit/skills/incident-runbook/SKILL.md +2 -2
  53. package/kit/skills/security-scan/references/verify.md +6 -3
  54. package/kit/studio/README.md +12 -0
  55. package/kit/studio/server/index.js +125 -11
  56. package/kit/studio/server/lib/code-stamp.js +35 -0
  57. package/kit/studio/server/lib/crew-map.js +113 -0
  58. package/kit/studio/server/lib/model-outcomes.js +130 -15
  59. package/kit/studio/server/lib/owned-stream.js +56 -0
  60. package/kit/studio/server/lib/pricing.js +3 -0
  61. package/kit/studio/server/lib/projects.js +11 -4
  62. package/kit/studio/server/lib/remote.js +99 -0
  63. package/kit/studio/server/lib/usage.js +74 -14
  64. package/kit/studio/web/app.js +223 -15
  65. package/kit/studio/web/canvas.js +44 -120
  66. package/kit/studio/web/chat.js +82 -17
  67. package/kit/studio/web/graph-plan.js +10 -4
  68. package/kit/studio/web/index.html +10 -5
  69. package/kit/studio/web/liveness.js +14 -0
  70. package/kit/studio/web/map-plan.js +341 -0
  71. package/kit/studio/web/map.js +503 -0
  72. package/kit/studio/web/models-plan.js +61 -7
  73. package/kit/studio/web/models.js +19 -3
  74. package/kit/studio/web/style.css +131 -0
  75. package/kit/studio/web/usage.js +12 -0
  76. package/kit/studio/web/viewport.js +160 -0
  77. package/package.json +6 -5
  78. package/start.sh +10 -0
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # refuter-ledger.sh - the record of what crew-refuter concluded, written by the hook and not by the session.
3
+ #
4
+ # WHY. A findings document can say a finding was verified. That sentence is the session's own; the ledger is not.
5
+ # When a crew-refuter agent stops, this hook reads the TASK it was given (the first record of its own transcript)
6
+ # and the ANSWER it ended with (last_assistant_message), and adds one line when the two agree on the finding and
7
+ # the lens:
8
+ #
9
+ # ts session agent_id finding lens verdict .claude/state/crew-refuter.tsv
10
+ #
11
+ # kit/eval/lib/findings/findings.mjs `validate --ledger` holds a document to it: a medium-or-higher finding needs
12
+ # verifiers, and every verifier it names must be a line here, with the same finding, lens and verdict.
13
+ #
14
+ # Wired to SubagentStop with a matcher on the agent type, so it starts for crew-refuter only; the type is checked
15
+ # again here. It works whatever CREW_MODEL_ROUTING says: this is not model routing.
16
+ # THIS NEVER BLOCKS. It exits 0 on every path: a stop hook that failed closed would keep an agent running.
17
+ # The ledger is protected from the session the way the routing records are (guard-bash.sh, guard-write.sh): a plain
18
+ # attempt to write it is refused; a determined one (an interpreter, a name built from pieces) is not, and that is
19
+ # the stated scope of those guards.
20
+ set -uo pipefail
21
+ export LC_ALL=C
22
+ _rl_here="${BASH_SOURCE%[/\\]*}"; [ "$_rl_here" = "${BASH_SOURCE}" ] && _rl_here=.
23
+ [ -f "$_rl_here/guard-agent-model.sh" ] || exit 0
24
+ . "$_rl_here/guard-agent-model.sh"
25
+ declare -F _json_slice >/dev/null 2>&1 || exit 0
26
+
27
+ IFS= read -r -d '' INPUT || true
28
+ _json_slice "$INPUT" hook_event_name >/dev/null; [ "$_JS" = SubagentStop ] || exit 0
29
+ _json_slice "$INPUT" agent_type >/dev/null; [ "${_JS##*:}" = crew-refuter ] || exit 0
30
+ _json_slice "$INPUT" agent_id >/dev/null; AID="$_JS"
31
+ case "$AID" in ''|*[!A-Za-z0-9._-]*) exit 0 ;; esac
32
+ _json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
33
+ case "$SID" in ''|*[!A-Za-z0-9._-]*) SID=- ;; esac
34
+
35
+ # One field of a text: the value after "<key>:" at the start of a line, first occurrence, trimmed.
36
+ _rl_field(){ # $1 text, $2 key (any case) -> _RLF
37
+ local line k; _RLF=""
38
+ while IFS= read -r line; do
39
+ line="${line%$'\r'}"; line="${line#"${line%%[![:space:]]*}"}"
40
+ k="${line%%:*}"; [ "$k" = "$line" ] && continue
41
+ shopt -s nocasematch
42
+ if [[ "$k" == "$2" ]]; then shopt -u nocasematch
43
+ _RLF="${line#*:}"; _RLF="${_RLF#"${_RLF%%[![:space:]]*}"}"; _RLF="${_RLF%"${_RLF##*[![:space:]]}"}"; return 0
44
+ fi
45
+ shopt -u nocasematch
46
+ done <<< "$1"
47
+ return 1
48
+ }
49
+
50
+ # The task: the first record of the agent's own transcript (a string, or a list whose first block holds the text).
51
+ _json_slice "$INPUT" agent_transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; ATP="${_JU//\\//}"
52
+ [ -f "$ATP" ] || exit 0
53
+ L1=""; IFS= read -r L1 < "$ATP" || true
54
+ _json_slice "$L1" content >/dev/null; TXT="$_JS"
55
+ [ -n "$TXT" ] || { _json_slice "$L1" text >/dev/null; TXT="$_JS"; }
56
+ _json_unescape "${TXT:0:8000}" >/dev/null; TASK="$_JU"
57
+ _rl_field "$TASK" finding || exit 0; F_TASK="$_RLF"
58
+ _rl_field "$TASK" lens || exit 0; L_TASK="$_RLF"
59
+
60
+ # The answer, and it must be about the same finding and lens.
61
+ _json_slice "$INPUT" last_assistant_message >/dev/null; _json_unescape "$_JS" >/dev/null; ANS="$_JU"
62
+ _rl_field "$ANS" finding || exit 0; F_ANS="$_RLF"
63
+ _rl_field "$ANS" lens || exit 0; L_ANS="$_RLF"
64
+ _rl_field "$ANS" verdict || exit 0; V="$_RLF"
65
+ [ "$F_TASK" = "$F_ANS" ] && [ "$L_TASK" = "$L_ANS" ] || exit 0
66
+ [[ "$F_TASK" =~ ^[a-z0-9][a-z0-9._:-]*$ ]] && [ "${#F_TASK}" -le 200 ] || exit 0
67
+ case "$L_TASK" in reachability|protection|reproduction|exclusion|blast-radius|claim) ;; *) exit 0 ;; esac
68
+ case "$V" in TRUE_POSITIVE|FALSE_POSITIVE|CANNOT_VERIFY) ;; *) exit 0 ;; esac
69
+
70
+ P="${CLAUDE_PROJECT_DIR:-$PWD}"; P="${P//\\//}"; P="${P%/}"
71
+ D="$P/.claude/state"; T="$D/crew-refuter.tsv"
72
+ mkdir -p "$D" 2>/dev/null || exit 0
73
+ # An agent that is resumed stops again: it answered once, and that answer stands.
74
+ if [ -f "$T" ]; then
75
+ while IFS=$'\t' read -r _ _ a _; do [ "$a" = "$AID" ] && exit 0; done < "$T"
76
+ fi
77
+ [ -s "$T" ] || printf 'ts\tsession\tagent_id\tfinding\tlens\tverdict\n' > "$T" 2>/dev/null
78
+ TS="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"; case "$TS" in [0-9][0-9][0-9][0-9]-*Z) ;; *) TS=- ;; esac
79
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$TS" "$SID" "$AID" "$F_TASK" "$L_TASK" "$V" >> "$T" 2>/dev/null
80
+ exit 0
@@ -44,7 +44,9 @@
44
44
  set -uo pipefail
45
45
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
46
46
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
47
- [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
47
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
48
+ # How an install's own version is read and ordered (an rc before its final): one definition, eval/lib/version.sh.
49
+ [ -f "$_crew_d/../eval/lib/version.sh" ] && . "$_crew_d/../eval/lib/version.sh"; unset _crew_d
48
50
 
49
51
  URL="${CREW_UPDATE_URL:-https://registry.npmjs.org/-/package/crewforth/dist-tags}"
50
52
  # plugin-stable, not main: the marketplace installs the plugin from that branch, and only an approved release
@@ -139,7 +141,10 @@ PJSON="$PR_ROOT/.claude-plugin/plugin.json"
139
141
  # plugin's copy steps aside whenever a project install is present, instead of asking the project's question too.
140
142
  if [ "$IS_PLUGIN" = 1 ] && [ -f "$CL/VERSION" ]; then exit 0; fi
141
143
  if [ -f "$CL/VERSION" ]; then
142
- read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""; CUR="$(sane_version "$_cv")" || exit 0
144
+ # The install's own file, so an rc (X.Y.Z-rc.N) is a version here; what npm answers is still held to X.Y.Z.
145
+ read -r _cv < "$CL/VERSION" 2>/dev/null || _cv=""
146
+ declare -F crew_local_version >/dev/null || exit 0
147
+ CUR="$(crew_local_version "$_cv")" || exit 0
143
148
  STATE="$CL/.state" # repo-local: the install lives in the repo
144
149
  FEED="${CREW_UPDATE_URL:-$URL}"; KEY=latest; EDITION=project
145
150
  ANSWER='bash .claude/hooks/session-update-check.sh --answer'
@@ -186,12 +191,8 @@ fi
186
191
  [ -n "$LATEST" ] || exit 0
187
192
  LATEST="$(sane_version "$LATEST")" || exit 0
188
193
 
189
- # Numeric field compare, one awk. Pre-release suffixes degrade to their numeric prefix (`1-rc2` -> 1), which is
190
- # the harmless direction: it can suppress a question, never invent one.
191
- awk -v a="$LATEST" -v b="$CUR" 'BEGIN{
192
- split(a,x,"."); split(b,y,".");
193
- for(i=1;i<=3;i++){ if(x[i]+0 > y[i]+0) exit 0; if(x[i]+0 < y[i]+0) exit 1 }
194
- exit 1 }' || exit 0
194
+ # One awk, eval/lib/version.sh's order: 3.2.0 is newer than an installed 3.2.0-rc.1, so the rc's user is asked.
195
+ awk -v a="$LATEST" -v b="$CUR" "${CREW_VCMP_AWK:-}"' BEGIN { exit !(vcmp(a, b) > 0) }' 2>/dev/null || exit 0
195
196
 
196
197
  # Skipped for good? Asked (or answered "later") within the last day?
197
198
  if [ -f "$STATE/update-skip" ]; then read -r _sk < "$STATE/update-skip" 2>/dev/null || _sk=""; [ "${_sk//[!0-9.]/}" = "$LATEST" ] && exit 0; fi
@@ -1,10 +1,10 @@
1
- # Generated by packaging/gen-legacy-blobs.sh from the final release tags v1.0.0..v3.1.0 (below VERSION 3.1.1) — do not edit by hand.
1
+ # Generated by packaging/gen-legacy-blobs.sh from the final release tags v1.0.0..v3.1.1 (below VERSION 3.2.0) — do not edit by hand.
2
2
  # The flat files Crewforth owns: a copy whose bytes (CR aside) are one of these ids was never edited.
3
3
  # install_path blob_sha1 first_tag last_tag
4
4
  .claude/AGENT_TEMPLATE.md 075e2c26706c06f56af01652cd24c36918513bee v3.0.0 v3.0.4
5
5
  .claude/AGENT_TEMPLATE.md 3fdc551d221ee3665b21ef8cc3563de861a9abe6 v1.4.0 v1.6.3
6
6
  .claude/AGENT_TEMPLATE.md 686fed306c873b45c245c77a95a8e6de6ee383d5 v1.7.0 v2.11.0
7
- .claude/AGENT_TEMPLATE.md 7ab19417b6ff3ac732e2c894be17ffb3ec091f7d v3.1.0 v3.1.0
7
+ .claude/AGENT_TEMPLATE.md 7ab19417b6ff3ac732e2c894be17ffb3ec091f7d v3.1.0 v3.1.1
8
8
  .claude/AGENT_TEMPLATE.md 981df9ea27b6f1c5ab5cb509d40875a07603858d v2.12.0 v2.13.0
9
9
  .claude/AGENT_TEMPLATE.md b38b9c9b799156e07280ca886230524563acc75c v1.0.5 v1.3.0
10
10
  .claude/AGENT_TEMPLATE.md faec09e45543b75763c43bb24114a7b6355649ec v1.0.0 v1.0.4
@@ -26,7 +26,7 @@
26
26
  .claude/DISCIPLINE.md 76d73a4e25c559ea361618862cde8480b1a6d8c2 v3.0.0 v3.0.0
27
27
  .claude/DISCIPLINE.md b1ab7549b571dd2fc80d5777e0ae3185d29bd7b6 v2.13.0 v2.13.0
28
28
  .claude/DISCIPLINE.md b7e1066451a8590e0a2607ad0a6fe80b21599c53 v1.1.3 v1.1.8
29
- .claude/DISCIPLINE.md b90a067ee323a08146862b889293f8feb49f8e7e v3.1.0 v3.1.0
29
+ .claude/DISCIPLINE.md b90a067ee323a08146862b889293f8feb49f8e7e v3.1.0 v3.1.1
30
30
  .claude/DISCIPLINE.md bf062c2b51a9cf3cc4d0caac389c148cf6fdc977 v2.12.0 v2.12.0
31
31
  .claude/DISCIPLINE.md c693f1bd5832ce570058c6eedcacb5dff13d89e8 v1.10.0 v1.10.0
32
32
  .claude/DISCIPLINE.md d63a01a183c9a80d739c840a511d338dc2765820 v1.4.0 v1.4.4
@@ -43,4 +43,4 @@
43
43
  .claude/README.md 97d4dd1f7f1695052cc1d06cc43f5bcdc603c989 v1.0.0 v1.0.4
44
44
  .claude/README.md a3419891cfda7385564008e4befc1007bc5364cb v1.1.1 v1.7.0
45
45
  .claude/README.md a3b76e0fa31267610d28b742c122a962f6db59c4 v3.0.1 v3.0.4
46
- .claude/README.md b6c9b57a67d25b7e4781e4eb61ff9637e6abe5ef v3.1.0 v3.1.0
46
+ .claude/README.md b6c9b57a67d25b7e4781e4eb61ff9637e6abe5ef v3.1.0 v3.1.1
package/kit/settings.json CHANGED
@@ -108,6 +108,17 @@
108
108
  "timeout": 600
109
109
  }
110
110
  ]
111
+ },
112
+ {
113
+ "matcher": "AskUserQuestion",
114
+ "hooks": [
115
+ {
116
+ "type": "command",
117
+ "shell": "bash",
118
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/guard-question.sh",
119
+ "timeout": 600
120
+ }
121
+ ]
111
122
  }
112
123
  ],
113
124
  "UserPromptSubmit": [
@@ -168,6 +179,17 @@
168
179
  "timeout": 600
169
180
  }
170
181
  ]
182
+ },
183
+ {
184
+ "matcher": "(.*:)?crew-refuter",
185
+ "hooks": [
186
+ {
187
+ "type": "command",
188
+ "shell": "bash",
189
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/refuter-ledger.sh",
190
+ "timeout": 30
191
+ }
192
+ ]
171
193
  }
172
194
  ],
173
195
  "PostToolUse": [
@@ -181,6 +203,17 @@
181
203
  "timeout": 60
182
204
  }
183
205
  ]
206
+ },
207
+ {
208
+ "matcher": "AskUserQuestion",
209
+ "hooks": [
210
+ {
211
+ "type": "command",
212
+ "shell": "bash",
213
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/prompt-approval.sh",
214
+ "timeout": 60
215
+ }
216
+ ]
184
217
  }
185
218
  ],
186
219
  "SessionStart": [
@@ -26,4 +26,11 @@ Read the line Crewforth added to this turn and do what it says:
26
26
  the argument was not `commit`, `push` or `commit+push`. Say which, and in the first case run the command and let
27
27
  the gate's own prompt ask.
28
28
 
29
+ In `auto` the same approval can be given with one click: the gate's refusal prints a fixed AskUserQuestion, and
30
+ the user's answer is recorded exactly like this command. Where a `PermissionRequest` hook may be wired (in the
31
+ user's, the project's or a plugin's settings, or in a skill's or an agent's front matter), that question is closed,
32
+ and so is the gate's own prompt in `default` / `acceptEdits`, because such a hook answers a prompt before the user
33
+ does. This command is then the one approval left, in those modes too. Settings passed with `--settings` and
34
+ managed settings delivered by a server cannot be read, so a hook there is past the check.
35
+
29
36
  Force-push and the rest of §4.5 are not opened by this.
@@ -24,7 +24,7 @@ Verify Crewforth is actually *active* in this project (not just present on disk)
24
24
  5. Summarise: **healthy**, or the precise fixes applied/needed, plus the readiness score. If it's not a git repo,
25
25
  note that the commit-time gates need `git init` + `git config core.hooksPath .claude/hooks`.
26
26
  If the output contains a line starting with ⭐, pass it to the user verbatim as the LAST line of your summary —
27
- it prints once per Crewforth version, and it is meant for the user, not for you.
27
+ it prints once per Crewforth version here (the installer has its own once), and it is meant for the user, not for you.
28
28
 
29
29
  If `.claude/eval/doctor.sh` doesn't exist, this is not a full (start.sh / adopt.sh) install — Crewforth is likely
30
30
  running as a **plugin**, whose hooks are managed by Claude Code itself; there's nothing for the doctor to check,
@@ -52,3 +52,7 @@ found no Git Bash: tell the user the gates do not run and that the fix is Git fo
52
52
  scripts are developer instruments: they inspect an installation from outside it, and the plugin edition has no
53
53
  installation to inspect. Say this plainly when someone asks why `/crew-doctor` reports only the listing (and, on Windows, Git Bash) on a plugin install,
54
54
  rather than treating it as a defect. The Studio panel ships in both editions (`/crew-studio`).
55
+
56
+ **On a plugin install, end with the star line** the full install's doctor prints: `bash "${CLAUDE_PLUGIN_ROOT}/eval/lib/star.sh" --once . doctor`
57
+ (Bash tool, not PowerShell). It prints once per plugin version, or nothing; if it prints a line starting with ⭐, pass it to the
58
+ user verbatim as the LAST line of your summary.
@@ -6,8 +6,9 @@ metadata:
6
6
  ---
7
7
  # /crew-handoff
8
8
  applies the `handoff` skill:
9
- 1. Read the measured fill: the `🔋 Session` line, or `bash .claude/hooks/context-usage.sh --verbose` (Bash tool, not PowerShell).
10
- 2. Write an actionable handover to `docs/SESSION_STATE.md`: Done · In progress (exactly where) · Next step · Open decisions · File pointers · Blockers.
9
+ 1. **crew-session-manager** (@agent-crew-session-manager) measures the fill (`context-usage.sh`, never a guess), gives the
10
+ recommendation and drafts the handover. The steps below work from its draft.
11
+ 2. Write the handover to `docs/SESSION_STATE.md`: Done · In progress (exactly where) · Next step · Open decisions · File pointers · Blockers.
11
12
  3. Preserve decision rationale; the next session should not start from scratch.
12
13
  4. **Cold-reader pass** (`handoff/references/cold-reader.md`): the questions are written from the work BEFORE the
13
14
  file, then answered from the file alone. Anything unanswerable goes back into the handover and is re-checked.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: crew-map
3
+ description: Map the project — modules, what they touch, the gaps between them — and describe each module from the lines the scan cites.
4
+ disable-model-invocation: true
5
+ metadata:
6
+ kind: command
7
+ ---
8
+ # /crew-map
9
+
10
+ Write `.crewforth/crew-map.json`, then describe it. The scanner draws the map from the files; you add only what
11
+ it cannot: a sentence or two on what each module is for, and a short overview. Every description points at lines
12
+ the scan itself read, and a gate holds it to that. **Never commit.** Show the diff and stop.
13
+
14
+ 1. **Resolve the scanner.** Both editions carry it, in different places:
15
+
16
+ ```
17
+ PLUGIN_MAP=${CLAUDE_PLUGIN_ROOT}/eval/lib/crew-map/crew-map.sh
18
+ ```
19
+
20
+ Claude Code replaces `${CLAUDE_PLUGIN_ROOT}` in this text before you read it, and only in the plugin. If the
21
+ line above names a real path, that is the scanner; if it still shows `${CLAUDE_PLUGIN_ROOT}`, this is a full
22
+ install and the scanner is `.claude/eval/lib/crew-map/crew-map.sh`. Below, `MAP` is whichever it is.
23
+
24
+ 2. **Scan:** `bash MAP` at the project root (Bash tool, not PowerShell). It writes the map and keeps the
25
+ descriptions of an earlier run that still cite only lines this scan cites; it says how many it kept and dropped.
26
+ rc 3 is no usable Node: say so and stop, it is not a pass.
27
+
28
+ 3. **Describe.** Read `.crewforth/crew-map.json`, and as much of the cited source as you need. Then edit that
29
+ file, changing ONLY these fields:
30
+ - each `module` node's `narrative`: `{ "text": "…", "cites": ["file:line", …] }`. What the module is for, in
31
+ at most two sentences. Every cite is a `file:line` from THAT node's own `evidence`; cite at least one. Leave a
32
+ node `null` when its evidence does not tell you what it is for: an honest gap beats a guess.
33
+ - the document's `overview`: the same shape, at most 2000 characters, for someone new to the project: the entry
34
+ points, where data goes, the risky places. Its cites may be any node's evidence.
35
+ Describe what the cited lines show. A connection, a purpose or a risk the scan did not find is not added here:
36
+ say it to the user instead.
37
+
38
+ 4. **Validate:** `bash MAP --validate` (Bash tool, not PowerShell). It refuses a description that cites a line
39
+ the scan did not read, cites nothing, or runs past two sentences, and a map whose scanner fields were changed.
40
+ Fix what it names, in the descriptions only, until it holds.
41
+
42
+ 5. **Hand over:** the scan's summary line, how many modules you described and how many you left `null` and why,
43
+ then `git diff --stat .crewforth/crew-map.json`. Studio's **Map** view (`/crew-studio`) draws the result.
44
+ Committing it is the user's call.
@@ -9,6 +9,8 @@ Run the change set through the review trio (read-only). **The audits go out in p
9
9
  1. **At once, in ONE message** — several `Agent` calls, because none of these writes code and so there is
10
10
  nothing to serialise:
11
11
  - @agent-crew-security-expert (security-scan) — auth/IDOR/injection/secret; findings with severity.
12
+ Each `medium`-or-higher one then goes to **crew-refuter** (@agent-crew-refuter; claim + `file:line`, one call per lens, in
13
+ parallel) before it is reported; `findings.sh write --verified` refuses one its ledger does not back.
12
14
  - @agent-crew-performance-expert (`performance`) — hot path, query/loop, render, payload. Reports
13
15
  **candidates** (reasoned) and **findings** (measured) separately; an unmeasured claim is never a verdict.
14
16
  - @agent-crew-privacy-agent (`privacy-compliance`) — **when the diff touches personal data**: legal basis,
@@ -32,7 +32,7 @@ Bring the Crewforth install up to the latest published version:
32
32
  (no flags) in **their own terminal**, where both the npx and the interactive prompts work.
33
33
  7. **After:** run `bash .claude/eval/update-guard.sh post` (Bash tool, not PowerShell) and show the user its lists — added, changed, moved,
34
34
  removed.
35
- 8. **Verify:** run `/crew-doctor` (or `bash .claude/eval/doctor.sh` — Bash tool, not PowerShell) so a bad/partial update surfaces immediately.
35
+ 8. **Verify:** run `CREW_NO_STAR=1 bash .claude/eval/doctor.sh` (Bash tool, not PowerShell) so a bad/partial update surfaces immediately; the updater has already shown the star line, if it was due.
36
36
  If the updater's or doctor's output contains a line starting with ⭐, pass it to the user verbatim as the LAST
37
37
  line of your summary — it prints once per Crewforth version, and it is meant for the user, not for you.
38
38
  9. **Report** old → new + the headline changes. Take them from `.claude/.state/whats-new.md` — the updater writes
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: incident-runbook
3
3
  description: |
4
- Production incident response: diagnose → mitigate → resolve, then a blameless postmortem and a repeatable
4
+ Incident response: diagnose → mitigate → resolve, then a blameless postmortem and a repeatable
5
5
  runbook. Stop the impact first, root cause second.
6
- Use when production is degraded or broken, and again for the postmortem afterwards.
6
+ Use when production is degraded or broken, an outage keeps recurring, or for the postmortem.
7
7
  ---
8
8
 
9
9
  # Incident Response & Runbook
@@ -19,9 +19,12 @@ audit). Each verifier:
19
19
  Three passes inside one context window are not three verifiers. The second pass has already read the first one's
20
20
  reasoning; the blindness above is then a wish, not a property, and the agreement it produces is one argument
21
21
  counted three times. So:
22
- - **Each verifier is its own subagent** (`Agent`, one call per verifier, run in parallel). A subagent has its own
23
- context window: it cannot see what the others concluded, which is the whole point. Read-only work, so no
24
- `worktree` is needed — nothing is being written.
22
+ - **Each verifier is its own `crew-refuter` call** (one call per verifier and lens, run in parallel). A subagent
23
+ has its own context window: it cannot see what the others concluded, which is the whole point. Read-only work,
24
+ so no `worktree` is needed — nothing is being written. Its task is the card, then `finding:`, `lens:`, `cite:`
25
+ and `claim:` (its agent file has the exact form); when it stops, `hooks/refuter-ledger.sh` records the verdict
26
+ in the refuter ledger, and `findings.sh write --verified` refuses a `medium`-or-higher finding whose verifiers
27
+ are not there. The ledger is the gate; the prompt that carries no reasoning is still discipline.
25
28
  - **Give it the finding and the file:line, never your reasoning.** Not "I think X is exploitable because Y" —
26
29
  just "claim: X at file:line. Try to refute it."
27
30
  - **If you did not isolate, say so.** Write `verifiers: 1 (single pass, not isolated)` in the verdict block. A
@@ -198,6 +198,18 @@ reported as unreachable, never as empty.
198
198
  Nothing listens on a network interface in this arrangement: both Studios stay on
199
199
  loopback and the tunnel does the crossing.
200
200
 
201
+ ## Remote viewers
202
+
203
+ A viewer is a second token that reads and changes nothing, for watching the
204
+ panel from another device without handing that device the panel. Add one in the
205
+ Machines tab; its link is shown once. The server refuses a viewer's every
206
+ request that is not a `GET`, and its reads of `/api/remote`, with 403 before any
207
+ handler runs (`server/lib/remote.js`). Revoke ends the token and closes a
208
+ stream it holds open. Viewers live in memory and end with the server.
209
+
210
+ The server still binds to loopback. Reaching it from a phone is a tunnel the
211
+ user runs, an ssh forward or `tailscale serve 7777`; Studio starts none.
212
+
201
213
  ## What Crewforth already measured
202
214
 
203
215
  The panel does not recompute any of it. Selecting the session node opens what
@@ -21,7 +21,10 @@ import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentM
21
21
  import { agentOrigins } from './lib/agent-origin.js';
22
22
  import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
23
23
  import { cachedUsage } from './lib/usage.js';
24
- import { readModels, readRecord, readCosts } from './lib/model-outcomes.js';
24
+ import { streamOwned } from './lib/owned-stream.js';
25
+ import { stampAtStart } from './lib/code-stamp.js';
26
+ import { readModels, readRecord, readCosts, readAboveCosts } from './lib/model-outcomes.js';
27
+ import { readMap, behindOf, compareMaps, mapSummary, MAP_FILE } from './lib/crew-map.js';
25
28
 
26
29
  const USAGE_BATCH = 12;
27
30
  import { palette } from './lib/palette.js';
@@ -35,11 +38,14 @@ import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
35
38
  import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
36
39
  import { writeState, clearStateSync, findRunning } from './lib/instance.js';
37
40
  import { remoteRoster } from './lib/roster.js';
41
+ import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, viewerMay, MAX_VIEWERS } from './lib/remote.js';
38
42
  import { randomUUID } from 'node:crypto';
39
43
  import { spawn } from 'node:child_process';
40
44
 
41
45
  const HERE = path.dirname(fileURLToPath(import.meta.url));
42
46
  const WEB_ROOT = path.resolve(HERE, '..', 'web');
47
+ // The server's own code as it was when it started: an update on disk since then is said to the page (code-stamp.js).
48
+ const codeChanged = stampAtStart(HERE);
43
49
  const LOOPBACK = '127.0.0.1';
44
50
 
45
51
  const MIME = {
@@ -108,15 +114,28 @@ function sendJson(res, status, value) {
108
114
  const TOKEN = process.env.CREW_STUDIO_TOKEN || randomUUID();
109
115
  const TOKEN_GENERATED = !process.env.CREW_STUDIO_TOKEN;
110
116
 
117
+ /** For the self-check, which asks a real server as the owner and as a viewer. */
118
+ export const _internals = { token: TOKEN };
119
+
111
120
  // Peers are other machines running Studio, reached over a forwarded port. This
112
121
  // server still listens on loopback only; a peer never widens that.
113
122
  let PEERS = [];
114
123
  let SELF_NAME = 'this machine';
115
124
 
116
- function authorised(req, url) {
125
+ /**
126
+ * Whose request this is: the owner's, a read-only viewer's (lib/remote.js), or nobody's.
127
+ * The token is read from the same two places for both, so a viewer's link works exactly as the owner's does.
128
+ */
129
+ function whoAsks(req, url) {
117
130
  const header = req.headers['authorization'];
118
- if (header === `Bearer ${TOKEN}`) return true;
119
- return url.searchParams.get('token') === TOKEN;
131
+ const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : null;
132
+ const given = [bearer, url.searchParams.get('token')].filter(Boolean);
133
+ if (given.includes(TOKEN)) return { role: 'owner' };
134
+ for (const t of given) {
135
+ const viewer = viewerByToken(t);
136
+ if (viewer) return { role: 'viewer', viewer };
137
+ }
138
+ return null;
120
139
  }
121
140
 
122
141
  /**
@@ -167,10 +186,16 @@ async function modelCosts(cwd) {
167
186
  for (const s of (await listSessions(dir)).filter((x) => x.modifiedAt >= since).slice(0, MODELS_SESSIONS_MAX)) {
168
187
  const u = await cachedUsage(s);
169
188
  read += 1;
170
- for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a.cost);
189
+ for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a);
171
190
  }
172
191
  }
173
- return { measured: true, costs: readCosts(cwd, (id) => (per.has(id) ? per.get(id) : null)), sessionsRead: read };
192
+ return {
193
+ measured: true,
194
+ costs: readCosts(cwd, (id) => per.get(id)?.cost ?? null,
195
+ (id) => (per.get(id)?.advisorCalls ? { calls: per.get(id).advisorCalls, cost: per.get(id).advisorCost } : null)),
196
+ above: readAboveCosts(cwd, (id) => (per.has(id) ? { cost: per.get(id).cost, atNormal: per.get(id).costAtNormal } : null)),
197
+ sessionsRead: read,
198
+ };
174
199
  }
175
200
  const MODELS_SESSIONS_MAX = 60;
176
201
 
@@ -214,23 +239,35 @@ async function serveStatic(res, urlPath) {
214
239
  }
215
240
  }
216
241
 
217
- async function handle(req, res) {
242
+ export async function handle(req, res) {
218
243
  const url = new URL(req.url, `http://${LOOPBACK}`);
219
244
 
220
245
  // The token guards data and actions, not the app shell. A browser does not
221
246
  // carry the page's query string into its sub-resource requests, so demanding
222
247
  // it for /style.css served an unstyled page and protected nothing: the shell
223
248
  // holds no secrets, and every /api/ path below is still gated.
224
- if (url.pathname.startsWith('/api/') && !authorised(req, url)) {
249
+ const who = url.pathname.startsWith('/api/') ? whoAsks(req, url) : null;
250
+ if (url.pathname.startsWith('/api/') && !who) {
225
251
  return sendJson(res, 403, { ok: false, reason: 'token required' });
226
252
  }
253
+ // A viewer is refused HERE, before any handler: no route below has to remember that a viewer exists, and a
254
+ // route added tomorrow is read-only for a viewer without anyone writing a line for it.
255
+ if (who?.role === 'viewer') {
256
+ const may = viewerMay(req.method, url.pathname);
257
+ if (!may.ok) return sendJson(res, 403, { ok: false, reason: may.reason });
258
+ noteRequest(who.viewer, req, res);
259
+ }
227
260
 
228
261
  if (url.pathname === '/api/health') {
229
262
  return sendJson(res, 200, {
230
263
  ok: true,
264
+ role: who.role,
231
265
  node: process.version,
232
266
  pid: process.pid,
233
267
  uptimeMs: Math.round(process.uptime() * 1000),
268
+ // `changed`: the server's files on disk are not the ones it started with. A page that finds no `code` at all
269
+ // is talking to a server older than this field, which is the same thing.
270
+ code: codeChanged(),
234
271
  });
235
272
  }
236
273
 
@@ -288,7 +325,12 @@ async function handle(req, res) {
288
325
  // dead time whenever the feed hung, measured. The refresh runs behind this response.
289
326
  const latest = latestVersionCached();
290
327
  const projects = await listProjects({ currentCwd: cwd, kitOf: (c) => kitStatus(c, latest) });
291
- for (const p of projects) { p.origin = SELF_NAME; p.local = true; }
328
+ for (const p of projects) {
329
+ p.origin = SELF_NAME; p.local = true;
330
+ // The navigator's Map entry and its count: the findings the map shows unasked. A stat per project; the map
331
+ // is parsed again only when its file changes (crew-map.js).
332
+ p.map = p.exists && p.cwd ? mapSummary(p.cwd) : null;
333
+ }
292
334
 
293
335
  const origins = [{ name: SELF_NAME, local: true, ok: true, projects: projects.length }];
294
336
  if (PEERS.length) {
@@ -339,7 +381,7 @@ async function handle(req, res) {
339
381
  const sid = url.searchParams.get('session');
340
382
  const session = sid ? await findSession(sid) : null;
341
383
  const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
342
- if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], byAgent: {} });
384
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], above: [], byAgent: {} });
343
385
  return sendJson(res, 200, readModels(cwd, { agents: await agentIdsOf(session) }));
344
386
  }
345
387
 
@@ -347,10 +389,29 @@ async function handle(req, res) {
347
389
  const sid = url.searchParams.get('session');
348
390
  const session = sid ? await findSession(sid) : null;
349
391
  const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
350
- if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, sessionsRead: 0 });
392
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, above: {}, sessionsRead: 0 });
351
393
  return sendJson(res, 200, await modelCosts(cwd));
352
394
  }
353
395
 
396
+ // The project's crew map, as Crewforth's scanner wrote it, and how far HEAD has moved since. Nothing is scanned here.
397
+ // `compare` names another project of this machine by its directory; only a project the panel lists, with a map
398
+ // of its own, is read, so the route cannot be pointed at an arbitrary path.
399
+ if (url.pathname === '/api/map') {
400
+ const sid = url.searchParams.get('session');
401
+ const session = sid ? await findSession(sid) : null;
402
+ const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
403
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', others: [] });
404
+ const map = readMap(cwd);
405
+ const others = (await listProjects({ currentCwd: cwd })).filter((p) => p.cwd && p.cwd !== cwd && readMap(p.cwd).measured)
406
+ .map((p) => ({ label: p.label, cwd: p.cwd }));
407
+ if (!map.measured) return sendJson(res, 200, { ...map, cwd, others });
408
+ const asked = url.searchParams.get('compare');
409
+ const peer = asked ? others.find((o) => o.cwd === asked) : null;
410
+ const compare = peer ? { label: peer.label, cwd: peer.cwd, clients: compareMaps(map.doc, readMap(peer.cwd).doc) }
411
+ : asked ? { label: null, cwd: asked, clients: [], reason: 'not a project of this machine with a crew map' } : null;
412
+ return sendJson(res, 200, { ...map, cwd, behind: await behindOf(cwd, map.doc.source?.commit), others, compare });
413
+ }
414
+
354
415
  if (url.pathname === '/api/kit') {
355
416
  const cwd = url.searchParams.get('cwd') || process.cwd();
356
417
  const sid = url.searchParams.get('session');
@@ -463,6 +524,30 @@ async function handle(req, res) {
463
524
  return stream(req, res, url);
464
525
  }
465
526
 
527
+ /* --------------------------------------------------------- remote ---
528
+ Read-only viewers: who holds a link, and the way to take it back. Only
529
+ the owner reaches this far; a viewer was refused above. */
530
+
531
+ if (url.pathname === '/api/remote' && req.method === 'GET') {
532
+ return sendJson(res, 200, { measured: true, viewers: listViewers(), max: MAX_VIEWERS, now: Date.now() });
533
+ }
534
+
535
+ const remoteMatch = url.pathname.match(/^\/api\/remote(?:\/([^/]+))?$/);
536
+ if (remoteMatch) {
537
+ const gate = writeAllowed(req);
538
+ if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
539
+ if (req.method === 'POST' && !remoteMatch[1]) {
540
+ const body = await readBody(req);
541
+ if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
542
+ const made = addViewer(body.label);
543
+ return sendJson(res, made.ok ? 201 : 409, { ...made, viewers: listViewers() });
544
+ }
545
+ if (req.method === 'DELETE' && remoteMatch[1]) {
546
+ return sendJson(res, 200, { ...revokeViewer(decodeURIComponent(remoteMatch[1])), viewers: listViewers() });
547
+ }
548
+ return sendJson(res, 405, { ok: false, reason: 'POST to add a viewer, or DELETE a viewer id' });
549
+ }
550
+
466
551
  /* ---------------------------------------------------------- owned ---
467
552
  Sessions the panel started. Their transcripts land in the usual place,
468
553
  so the graph endpoints already cover them; only the conversation needs
@@ -525,6 +610,10 @@ async function handle(req, res) {
525
610
  return sendJson(res, out.ok ? 200 : 400, out);
526
611
  }
527
612
 
613
+ // Every open conversation on ONE connection (lib/owned-stream.js). Reading, so it sits beside /events, not
614
+ // behind the write gate.
615
+ if (url.pathname === '/api/owned-events') return streamOwned(req, res, url, { getSession, stateAfter: STATE_AFTER });
616
+
528
617
  const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
529
618
  if (ownedMatch) {
530
619
  const s = getSession(decodeURIComponent(ownedMatch[1]));
@@ -830,6 +919,28 @@ async function selftest() {
830
919
  fleet.measured ? `${fleet.sessions.length} session(s)` : `not measured: ${fleet.reason}`,
831
920
  );
832
921
 
922
+ // The Map view: its files are installed, and the reader takes a crew map and refuses one of another schema. On a
923
+ // scratch map of its own, so the verdict does not depend on whether this project has one; this project's own map,
924
+ // when there is one, is reported beside it.
925
+ check('map view installed', ['map.js', 'map-plan.js', 'viewport.js'].every((f) => fs.existsSync(path.join(WEB_ROOT, f))), null);
926
+ {
927
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crew-studio-map-'));
928
+ try {
929
+ fs.mkdirSync(path.join(dir, '.crewforth'));
930
+ const doc = { schema: 'crewforth.crew-map/1', nodes: [{ id: 'mod:x:a', kind: 'module', name: 'a' }], edges: [], findings: [], notMeasured: [] };
931
+ fs.writeFileSync(path.join(dir, MAP_FILE), JSON.stringify(doc));
932
+ const read = readMap(dir);
933
+ // A different size, not only a different schema: the reader caches on mtime and size, and on Windows both writes
934
+ // land on one timestamp, so a same-size rewrite read back the first map (seen on a CI runner).
935
+ fs.writeFileSync(path.join(dir, MAP_FILE), JSON.stringify({ ...doc, schema: 'crewforth.crew-map/99' }));
936
+ const other = readMap(dir);
937
+ check('map read', read.measured === true && read.doc.nodes.length === 1 && other.measured === false,
938
+ read.measured ? `a scratch map read; one of another schema refused (${other.reason ?? 'read'})` : read.reason);
939
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
940
+ const own = readMap(process.cwd());
941
+ process.stdout.write(`INFO this project's map — ${own.measured ? `${own.doc.nodes.length} node(s), ${own.doc.findings.length} finding(s)` : own.reason}\n`);
942
+ }
943
+
833
944
  const cost = await measureSpawnCost(3);
834
945
  if (cost === null) skip('spawn cost measured', 'claude CLI absent');
835
946
  else check('spawn cost measured', true, `min ${cost.minMs}ms · median ${cost.medianMs}ms · max ${cost.maxMs}ms`);
@@ -877,6 +988,9 @@ async function main() {
877
988
  ' --help this text\n\n' +
878
989
  'Set CREW_STUDIO_TOKEN to require a bearer token, CREW_STUDIO_PEERS for a\n' +
879
990
  'comma-separated peer list.\n\n' +
991
+ 'To watch from another device, add a viewer under Machines in the panel\n' +
992
+ 'and open its link through a tunnel of your own. A viewer reads and\n' +
993
+ 'changes nothing, and the server stays on loopback.\n\n' +
880
994
  'Peers are normally reached over a forwarded port, which keeps every\n' +
881
995
  'Studio on loopback:\n' +
882
996
  ' ssh -N -L 7778:127.0.0.1:7777 other-machine\n' +