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.
- package/CHANGELOG.md +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +125 -11
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
name: crew-session-manager
|
|
3
3
|
color: blue
|
|
4
4
|
description: |
|
|
5
|
-
|
|
6
|
-
status line
|
|
5
|
+
Use at a phase boundary or when the fill reads medium or more: audits fill, drafts the handover. The
|
|
6
|
+
per-turn status line stays the main thread's. Writes no code.
|
|
7
7
|
tools: Read, Grep, Glob, Bash, PowerShell
|
|
8
8
|
# No `model` field: the caller names it per call. Not `haiku`: the handover is a synthesis over an entire
|
|
9
9
|
# session and decides what the NEXT session knows, and a thin one fails silently.
|
|
@@ -18,7 +18,8 @@ metadata:
|
|
|
18
18
|
Trigger phrases: "session status", "session health", "context status", "is a handover needed", "is it time to clear", "handover", "hand over", "switch topic", "getting long", "running out of context"
|
|
19
19
|
|
|
20
20
|
Purpose: so the user never has to track context/token management by hand.
|
|
21
|
-
|
|
21
|
+
It is called at a **phase boundary** or when the measured fill reads medium or more; the per-turn 🔋 line itself
|
|
22
|
+
is the main thread's, from the hook, and needs no agent.
|
|
22
23
|
|
|
23
24
|
## Expertise stance (context/operations manager)
|
|
24
25
|
- **Measure, do NOT guess**: the assistant can't run `/context`; actual fill is read from the transcript via `context-usage.sh` (below).
|
|
@@ -27,13 +28,14 @@ Since proactive background alerts aren't possible, the trigger is **every task c
|
|
|
27
28
|
- **Token discipline:** on the delegate / summary / file-offload decision, apply the `token-budget` skill.
|
|
28
29
|
|
|
29
30
|
## When
|
|
30
|
-
- At
|
|
31
|
-
- When the user says "session status?".
|
|
31
|
+
- At a phase boundary: after crew-commit-agent, at the end of the chain, and as the first step of `/crew-handoff`.
|
|
32
|
+
- When the 🔋 line reads medium or more, or the user says "session status?".
|
|
32
33
|
|
|
33
34
|
## What it does
|
|
34
|
-
|
|
35
|
+
Reports the measured fill and the recommendation in the same form as the per-turn line, and, when the
|
|
36
|
+
recommendation is a handover, a draft of it (`handoff` skill) for `/crew-handoff` to write:
|
|
35
37
|
|
|
36
|
-
`🔋 Session
|
|
38
|
+
`🔋 Session %NN → [low/medium/high] fill · Recommendation: [continue / handoff+clear / new session]`
|
|
37
39
|
|
|
38
40
|
**Actual fill is MEASURED, guessing is FORBIDDEN.** The `UserPromptSubmit` hook runs `context-usage.sh` each turn,
|
|
39
41
|
automatically injecting the real `🔋 Session: %.. (token) → level` line into the context — use that value. If you want an
|
|
@@ -63,13 +65,13 @@ paused should be released with its handover note.
|
|
|
63
65
|
- Reports the decision; doesn't run `/clear` on the user's behalf.
|
|
64
66
|
|
|
65
67
|
## Output & context (token)
|
|
66
|
-
To the main thread:
|
|
68
|
+
To the main thread: the health line + recommendation, and the handover draft when it recommends one. Do no long analysis; read the `context-usage.sh` output, add no commentary.
|
|
67
69
|
|
|
68
70
|
## Errors/escalation
|
|
69
71
|
When you notice a topic change / threshold breach, **recommend but don't interrupt**; don't force a clear mid-task.
|
|
70
72
|
|
|
71
73
|
## Example delegation
|
|
72
|
-
- ✅
|
|
74
|
+
- ✅ A phase boundary, or the 🔋 line at medium or more
|
|
73
75
|
- ❌ Content/code generation (out of scope)
|
|
74
76
|
|
|
75
77
|
## Confidence
|
package/kit/eval/doctor.sh
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
set -uo pipefail
|
|
8
8
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
9
9
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
10
|
-
[ -f "$_crew_d/lib/crew-env.sh" ] && . "$_crew_d/lib/crew-env.sh"
|
|
10
|
+
[ -f "$_crew_d/lib/crew-env.sh" ] && . "$_crew_d/lib/crew-env.sh"
|
|
11
|
+
[ -f "$_crew_d/lib/version.sh" ] && . "$_crew_d/lib/version.sh"; unset _crew_d # an rc orders before its final
|
|
11
12
|
ROOT="${1:-.}"
|
|
12
13
|
cd "$ROOT" 2>/dev/null || { echo "doctor: cannot enter '$ROOT'"; exit 2; }
|
|
13
14
|
|
|
@@ -98,6 +99,21 @@ _mt() {
|
|
|
98
99
|
'settings.json is invalid JSON') s='settings.json geçersiz JSON' ;;
|
|
99
100
|
"Claude Code %s is older than 2.1.293 — the model alias haiku may not resolve to Haiku 5.5 there, so an agent called with model haiku can run on an older model (update Claude Code)") s="Claude Code %s, 2.1.293'ten eski — orada haiku model takma adı Haiku 5.5'e çözülmeyebilir; model haiku ile çağrılan bir ajan eski bir modelde koşabilir (Claude Code'u güncelleyin)" ;;
|
|
100
101
|
'approval by your own message is wired (auto / dontAsk: /crew-approve commit)') s='kendi mesajınızla onay bağlı (auto / dontAsk: /crew-approve commit)' ;;
|
|
102
|
+
'the published Crewforth version is not measured: no update check has run here yet (.claude/.state/update-check)') s='yayınlanmış Crewforth sürümü ölçülmedi: burada henüz hiç güncelleme denetimi koşmadı (.claude/.state/update-check)' ;;
|
|
103
|
+
'two editions at once: not measured (the plugin list could not be read)') s='iki edisyon birden: ölçülmedi (plugin listesi okunamadı)' ;;
|
|
104
|
+
'two editions at once: not measured (no claude CLI here to list the plugins)') s='iki edisyon birden: ölçülmedi (pluginleri listeleyecek claude CLI burada yok)' ;;
|
|
105
|
+
'the %s plugin is enabled and this project has Crewforth installed in .claude/ — every hook runs twice; turn one off (claude plugin disable %s, or remove the install)') s="%s plugin'i etkin ve bu projede Crewforth .claude/ altında da kurulu — her hook iki kez koşuyor; birini kapatın (claude plugin disable %s ya da kurulumu kaldırın)" ;;
|
|
106
|
+
'one edition: the Crewforth plugin is not enabled beside this install') s="tek edisyon: Crewforth plugin'i bu kurulumun yanında etkin değil" ;;
|
|
107
|
+
'advisor: off (CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1)') s='advisor: kapalı (CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1)' ;;
|
|
108
|
+
"status line: Crewforth's (from %s)") s="durum satırı: Crewforth'unki (%s içinden)" ;;
|
|
109
|
+
"status line: your own (from %s); Crewforth's is not used") s="durum satırı: seninki (%s içinden); Crewforth'unki kullanılmıyor" ;;
|
|
110
|
+
"verify commands never run, %s time(s), because nothing allows them; to let the hook run one, add its line to .claude/crew-model-rules: %s") s="doğrulama komutları %s kez hiç koşmadı, çünkü hiçbir şey izin vermiyor; hook'un koşturması için satırını .claude/crew-model-rules dosyasına ekle: %s" ;;
|
|
111
|
+
"status line: none; Crewforth's: bash .claude/eval/lib/statusline.sh --install (Bash tool, not PowerShell)") s="durum satırı: yok; Crewforth'unki: bash .claude/eval/lib/statusline.sh --install (Bash tool, not PowerShell)" ;;
|
|
112
|
+
"advisor: off (no advisorModel set; Crewforth never turns it on)") s='advisor: kapalı (advisorModel ayarlı değil; Crewforth onu hiç açmaz)' ;;
|
|
113
|
+
'advisorModel is %s in %s, but CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 turns the advisor off') s="advisorModel %s (%s içinde), ama CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 advisor'ı kapatıyor" ;;
|
|
114
|
+
'advisorModel is %s in %s, but %s is set and the advisor needs the Anthropic API — it is not used') s="advisorModel %s (%s içinde), ama %s ayarlı ve advisor Anthropic API ister — kullanılmıyor" ;;
|
|
115
|
+
"advisor: on, %s (from %s); not a gate, and its tokens are in /usage and in Studio's session cost") s="advisor: açık, %s (%s içinden); bir kapı değil, token'ları /usage'da ve Studio'nun oturum maliyetinde" ;;
|
|
116
|
+
'the advisor (%s) is below the critical tier (opus): Claude Code does not attach an advisor below the model it would advise, so critical work runs without one') s="advisor (%s) kritik seviyenin (opus) altında: Claude Code danışacağı modelin altındaki bir advisor'ı eklemiyor, yani kritik iş advisor'sız koşuyor" ;;
|
|
101
117
|
"settings.json wires prompt-approval.sh but the script is missing — in auto and dontAsk '/crew-approve commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)") s="settings.json prompt-approval.sh'i bağlıyor ama betik yok — auto ve dontAsk modunda '/crew-approve commit' hiçbir şey kaydetmez; Shift+Tab ile mod değiştirin ya da kendi terminalinizden commit edin (Crewforth'u güncelleyin)" ;;
|
|
102
118
|
"prompt-approval.sh is not wired on UserPromptSubmit — in auto and dontAsk '/crew-approve commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)") s="prompt-approval.sh UserPromptSubmit'e bağlı değil — auto ve dontAsk modunda '/crew-approve commit' hiçbir şey kaydetmez; Shift+Tab ile mod değiştirin ya da kendi terminalinizden commit edin (Crewforth'u güncelleyin)" ;;
|
|
103
119
|
"worktree %s carries Crewforth %s, this one %s — a session there runs that version's gates and approval path (update Crewforth there)") s="%s worktree'sinde Crewforth %s var, burada %s — orada açılan oturum o sürümün kapılarını ve onay yolunu koşar (Crewforth'u orada güncelleyin)" ;;
|
|
@@ -204,10 +220,11 @@ if [ -f .claude/VERSION ] && [ -f .claude/.state/update-check ]; then
|
|
|
204
220
|
read -r DLATEST _ < .claude/.state/update-check 2>/dev/null || DLATEST=""
|
|
205
221
|
DLATEST="$(printf '%s' "${DLATEST:-}" | tr -cd '0-9A-Za-z.-')"
|
|
206
222
|
DCUR="$(head -1 .claude/VERSION 2>/dev/null | tr -cd '0-9A-Za-z.-')"
|
|
207
|
-
if [ -n "$DLATEST" ] && awk -v a="$DLATEST" -v b="$DCUR" 'BEGIN{
|
|
208
|
-
for(i=1;i<=3;i++){if(x[i]+0>y[i]+0)exit 0; if(x[i]+0<y[i]+0)exit 1} exit 1}'; then
|
|
223
|
+
if [ -n "$DLATEST" ] && awk -v a="$DLATEST" -v b="$DCUR" "${CREW_VCMP_AWK:-}"' BEGIN { exit !(vcmp(a, b) > 0) }' 2>/dev/null; then
|
|
209
224
|
warn "Crewforth v%s installed, v%s published — update with /crew-update" "$DCUR" "$DLATEST"
|
|
210
225
|
fi
|
|
226
|
+
elif [ -f .claude/VERSION ]; then
|
|
227
|
+
skip "the published Crewforth version is not measured: no update check has run here yet (.claude/.state/update-check)"
|
|
211
228
|
fi
|
|
212
229
|
|
|
213
230
|
# 1c) Install trace. An update leaves VERSION, the install manifest and the components on disk agreeing; when they do
|
|
@@ -643,9 +660,9 @@ for _v in $(compgen -e); do
|
|
|
643
660
|
esac
|
|
644
661
|
done
|
|
645
662
|
if [ "$FAIL" -eq 0 ]; then _mt "DOCTOR: healthy ✅%s" "$PANEL_NOTE"; echo "$_M"
|
|
646
|
-
# Healthy verdict: the star line, once per kit version
|
|
647
|
-
#
|
|
648
|
-
[ -f "$(dirname "$0")/lib/star.sh" ] && bash "$(dirname "$0")/lib/star.sh" --once .
|
|
663
|
+
# Healthy verdict: the star line, once per kit version under doctor's own marker (the installers keep theirs).
|
|
664
|
+
# /crew-update runs this with CREW_NO_STAR=1, so an update shows it once, not twice. Text/URL/silence: lib/star.sh.
|
|
665
|
+
[ -f "$(dirname "$0")/lib/star.sh" ] && bash "$(dirname "$0")/lib/star.sh" --once . doctor
|
|
649
666
|
else _mt "DOCTOR: %s issue(s) ❌ — apply the fixes above%s" "$FAIL" "$PANEL_NOTE"; echo "$_M"; fi
|
|
650
667
|
|
|
651
668
|
# 9) The auto-mode classifier. Since 2026-08-14 auto mode is the default permission mode on Pro/Max/Team, so a
|
|
@@ -684,6 +701,100 @@ EOF_CCV
|
|
|
684
701
|
fi ;;
|
|
685
702
|
esac
|
|
686
703
|
fi
|
|
704
|
+
# 9c) Two editions at once, and the advisor. Advisory, never a failure.
|
|
705
|
+
# PLUGIN AND INSTALL BOTH ON: the plugin edition and this .claude/ install each wire every hook, so with both
|
|
706
|
+
# enabled each gate runs twice on every call (and a gate's own refusal is printed twice). Asked of the CLI —
|
|
707
|
+
# `claude plugin list --json`, whose `enabled` is "enabled in your merged settings" — because the settings files
|
|
708
|
+
# alone cannot say what a managed or a --plugin-dir layer added. No CLI: not measured, and said so.
|
|
709
|
+
# THE ADVISOR (advisorModel; documented: fable, opus, sonnet or a full model id, unset = off; Anthropic API only;
|
|
710
|
+
# CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 turns it off). Crewforth never sets it. What is reported is whether it is on
|
|
711
|
+
# and on what, read from the same three settings files as the skill-listing budget above (a managed layer is not
|
|
712
|
+
# read), and a note when it sits below the critical tier: Claude Code does not attach an advisor that ranks below
|
|
713
|
+
# the model it would advise (code.claude.com/docs/en/advisor), so an agent on opus gets none.
|
|
714
|
+
# Its usage is not reported here. It is in /usage, and in the transcript (usage.iterations, `advisor_message`,
|
|
715
|
+
# measured on a real advisor session), which is where Studio's session cost reads it.
|
|
716
|
+
if [ -f .claude/hooks/guard-bash.sh ]; then
|
|
717
|
+
if command -v claude >/dev/null 2>&1 && _pl="$(claude plugin list --json 2>/dev/null)" && [ -n "$_pl" ]; then
|
|
718
|
+
_pon=""; _pn="$(printf '{"p":%s}' "$_pl" | awk -v op=len -v path=p -f "${SJ:-.claude/eval/lib/settings-json.awk}" - 2>/dev/null)" || _pn=""
|
|
719
|
+
case "$_pn" in ''|*[!0-9]*) skip "two editions at once: not measured (the plugin list could not be read)" ;;
|
|
720
|
+
*) _pi=0
|
|
721
|
+
while [ "$_pi" -lt "$_pn" ]; do
|
|
722
|
+
_pid="$(printf '{"p":%s}' "$_pl" | awk -v op=get -v path="p.$_pi.id" -f "${SJ:-.claude/eval/lib/settings-json.awk}" - 2>/dev/null)"
|
|
723
|
+
# crewforth@… is 3.x; claude-starter-kit@… is the 2.x plugin, which wires the same hooks and is often still
|
|
724
|
+
# enabled at user scope after a 3.x install (measured in a real project).
|
|
725
|
+
case "$_pid" in '"crewforth@'*|'"claude-starter-kit@'*)
|
|
726
|
+
_pen="$(printf '{"p":%s}' "$_pl" | awk -v op=get -v path="p.$_pi.enabled" -f "${SJ:-.claude/eval/lib/settings-json.awk}" - 2>/dev/null)"
|
|
727
|
+
[ "$_pen" = true ] && _pon="${_pid//\"/}" ;;
|
|
728
|
+
esac
|
|
729
|
+
_pi=$((_pi+1))
|
|
730
|
+
done
|
|
731
|
+
if [ -n "$_pon" ]; then
|
|
732
|
+
warn "the %s plugin is enabled and this project has Crewforth installed in .claude/ — every hook runs twice; turn one off (claude plugin disable %s, or remove the install)" "$_pon" "$_pon"
|
|
733
|
+
else ok "one edition: the Crewforth plugin is not enabled beside this install"; fi ;;
|
|
734
|
+
esac
|
|
735
|
+
else
|
|
736
|
+
skip "two editions at once: not measured (no claude CLI here to list the plugins)"
|
|
737
|
+
fi
|
|
738
|
+
fi
|
|
739
|
+
_adv=""; _advf=""
|
|
740
|
+
for sf in .claude/settings.local.json .claude/settings.json "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; do
|
|
741
|
+
[ -f "$sf" ] || continue
|
|
742
|
+
v="$(awk -v op=get -v path=advisorModel -f "${SJ:-.claude/eval/lib/settings-json.awk}" "$sf" 2>/dev/null)" || v=""
|
|
743
|
+
v="${v#\"}"; v="${v%\"}"
|
|
744
|
+
[ -n "$v" ] && { _adv="$v"; _advf="$sf"; break; }
|
|
745
|
+
done
|
|
746
|
+
_advprov=""; for _ev in CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX; do
|
|
747
|
+
eval "_evv=\"\${$_ev:-}\""; case "$_evv" in ''|0|false) ;; *) _advprov="$_ev"; break ;; esac
|
|
748
|
+
done
|
|
749
|
+
if [ -z "$_adv" ]; then
|
|
750
|
+
if [ "${CLAUDE_CODE_DISABLE_ADVISOR_TOOL:-}" = 1 ]; then ok "advisor: off (CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1)"
|
|
751
|
+
else ok "advisor: off (no advisorModel set; Crewforth never turns it on)"; fi
|
|
752
|
+
elif [ "${CLAUDE_CODE_DISABLE_ADVISOR_TOOL:-}" = 1 ]; then
|
|
753
|
+
warn "advisorModel is %s in %s, but CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 turns the advisor off" "$_adv" "$_advf"
|
|
754
|
+
elif [ -n "$_advprov" ]; then
|
|
755
|
+
warn "advisorModel is %s in %s, but %s is set and the advisor needs the Anthropic API — it is not used" "$_adv" "$_advf" "$_advprov"
|
|
756
|
+
else
|
|
757
|
+
ok "advisor: on, %s (from %s); not a gate, and its tokens are in /usage and in Studio's session cost" "$_adv" "$_advf"
|
|
758
|
+
case "$_adv" in *haiku*|*sonnet*)
|
|
759
|
+
warn "the advisor (%s) is below the critical tier (opus): Claude Code does not attach an advisor below the model it would advise, so critical work runs without one" "$_adv" ;;
|
|
760
|
+
esac
|
|
761
|
+
fi
|
|
762
|
+
# 9d) The status line. Advisory, never a failure. The installers set Crewforth's only where none is set; a line of
|
|
763
|
+
# the user's own is never replaced (the project's setting would win over it). A managed layer is not read.
|
|
764
|
+
_slj="${SJ:-.claude/eval/lib/settings-json.awk}"; _sl=""; _slf=""
|
|
765
|
+
for _f in .claude/settings.json .claude/settings.local.json "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; do
|
|
766
|
+
[ -f "$_f" ] && [ -f "$_slj" ] || continue
|
|
767
|
+
_v="$(awk -v op=get -v path=statusLine.command -f "$_slj" "$_f" 2>/dev/null)" || _v=""
|
|
768
|
+
[ -n "$_v" ] && { _sl="$_v"; _slf="$_f"; break; }
|
|
769
|
+
done
|
|
770
|
+
case "$_sl" in
|
|
771
|
+
*eval/lib/statusline.sh*) ok "status line: Crewforth's (from %s)" "${_slf/#$HOME/~}" ;;
|
|
772
|
+
?*) ok "status line: your own (from %s); Crewforth's is not used" "${_slf/#$HOME/~}" ;;
|
|
773
|
+
*) [ -f .claude/eval/lib/statusline.sh ] && ok "status line: none; Crewforth's: bash .claude/eval/lib/statusline.sh --install (Bash tool, not PowerShell)" ;;
|
|
774
|
+
esac
|
|
775
|
+
|
|
776
|
+
# 9e) What model routing costs. Advisory. A verify command the hook was never allowed to run cannot vouch for
|
|
777
|
+
# cheaper work: the ones refused for that reason are listed with the line that allows them (measured on one
|
|
778
|
+
# project: 23 of 71 refusals).
|
|
779
|
+
_vbn=0; _vbl=""
|
|
780
|
+
for _f in .claude/state/crew-model/results/*; do
|
|
781
|
+
[ -f "$_f" ] || continue
|
|
782
|
+
_vb=0; _vw=""; _vc=""
|
|
783
|
+
while IFS= read -r _l || [ -n "$_l" ]; do
|
|
784
|
+
case "$_l" in verify=blocked) _vb=1 ;; whynot=*) _vw="${_l#whynot=}" ;; cmd=*) _vc="${_l#cmd=}" ;; esac
|
|
785
|
+
done < "$_f"
|
|
786
|
+
[ "$_vb" = 1 ] || continue
|
|
787
|
+
case "$_vw" in *"not a test or build command on this hook's list"*) ;; *) continue ;; esac
|
|
788
|
+
_vc="${_vc:0:80}"; [ -n "$_vc" ] || continue
|
|
789
|
+
_vbn=$((_vbn+1))
|
|
790
|
+
case "$_vc" in *'('*|*' + '*) continue ;; esac # a sentence, not a command: counted, not offered as a rule
|
|
791
|
+
case "$_vbl" in *"| verify $_vc |"*) ;; *) _vbl="$_vbl| verify $_vc |" ;; esac
|
|
792
|
+
done
|
|
793
|
+
if [ "$_vbn" -gt 0 ]; then
|
|
794
|
+
_vsep=' || '; _vdot=' · '; _vbs="${_vbl//"$_vsep"/$_vdot}"; _vbs="${_vbs#| }"; _vbs="${_vbs% |}"; _vbs="${_vbs:0:300}"
|
|
795
|
+
warn "verify commands never run, %s time(s), because nothing allows them; to let the hook run one, add its line to .claude/crew-model-rules: %s" "$_vbn" "$_vbs"
|
|
796
|
+
fi
|
|
797
|
+
|
|
687
798
|
# 10) Gate activity. The suite proves the gates CAN fire; this reports whether anything actually tripped them.
|
|
688
799
|
# Recording is on by default (rule names only, never the command), so "no log" here means no gate has
|
|
689
800
|
# fired yet — a measured zero, not a gap. Never a failure either way.
|
|
@@ -225,3 +225,12 @@ we picked the wrong language for this service, write an adr|!i18n-integrity
|
|
|
225
225
|
write the README in english for the public repo|!i18n-integrity
|
|
226
226
|
the connection strings are missing from appsettings|!i18n-integrity
|
|
227
227
|
add language support for kotlin to the parser|!i18n-integrity
|
|
228
|
+
try to refute this finding: the token check at src/auth.js:42 can be bypassed|crew-refuter
|
|
229
|
+
disprove this claim before it goes in the report|crew-refuter
|
|
230
|
+
run a security audit of the login flow|!crew-refuter
|
|
231
|
+
review my diff before I commit|!crew-refuter
|
|
232
|
+
update the privacy policy text for the new data retention period|crew-docs-agent
|
|
233
|
+
write the release notes and the changelog entry for this version|crew-docs-agent
|
|
234
|
+
rewrite the app store description for the new release|crew-docs-agent
|
|
235
|
+
add an endpoint that returns the privacy settings|!crew-docs-agent
|
|
236
|
+
fix the failing unit test in the payment module|!crew-docs-agent
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# crew-map.sh — the shell entry to scan.mjs. It does one thing: find a Node that RUNS and hand over.
|
|
3
|
+
#
|
|
4
|
+
# bash crew-map.sh [--root DIR] [--stdout | --check] the arguments and exit codes of scan.mjs (0, 1, 2)
|
|
5
|
+
# rc 3 = no usable Node here, so nothing was scanned. Not a pass: a caller must not read 3 as a fresh map.
|
|
6
|
+
#
|
|
7
|
+
# The same resolution as findings.sh beside it: ask node for its version (the Windows Store's stub resolves and
|
|
8
|
+
# then exits 49), and when PATH has none, ask the panel's resolver. This file never installs anything.
|
|
9
|
+
set -u
|
|
10
|
+
# Cut at either separator: on Windows a caller may name this file with backslashes.
|
|
11
|
+
d="${BASH_SOURCE%[/\\]*}"; [ "$d" = "${BASH_SOURCE}" ] && d=.
|
|
12
|
+
FLOOR=18
|
|
13
|
+
|
|
14
|
+
usable(){ local v; v="$("$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null)" || return 1
|
|
15
|
+
case "$v" in ''|*[!0-9]*) return 1 ;; esac; [ "$v" -ge "$FLOOR" ]; }
|
|
16
|
+
|
|
17
|
+
NODE=""
|
|
18
|
+
if usable node; then NODE=node
|
|
19
|
+
elif [ -f "$d/../../../studio/ensure-node.sh" ]; then
|
|
20
|
+
n="$(bash "$d/../../../studio/ensure-node.sh" 2>/dev/null)" && [ -n "$n" ] && usable "$n" && NODE="$n"
|
|
21
|
+
fi
|
|
22
|
+
if [ -z "$NODE" ]; then
|
|
23
|
+
echo "crew-map: no Node $FLOOR+ that runs was found, so nothing was scanned (this is not a pass)" >&2
|
|
24
|
+
exit 3
|
|
25
|
+
fi
|
|
26
|
+
exec "$NODE" "$d/scan.mjs" "$@"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// detect.mjs — the ten gap detectors. Each returns findings in the shape of the findings core
|
|
2
|
+
// (kit/eval/lib/findings): every one cites the lines it was read from.
|
|
3
|
+
//
|
|
4
|
+
// Two classes, and the class is the rule's, not the finding's:
|
|
5
|
+
// proven the cited lines show it on their own (a cycle, an import with no declaration, a read with no key)
|
|
6
|
+
// inferred concluded from absence in THIS repository — an external caller, reflection, a CLI or a framework can use
|
|
7
|
+
// what the scan did not see. These are written with data.defaultHidden = true: a reader sees them only
|
|
8
|
+
// when asking for them, until their false-positive rate has been measured in the field.
|
|
9
|
+
// The wording describes what was searched, never who is at fault: "no caller found in this repo".
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { canon } from './surface.mjs';
|
|
13
|
+
import { sortEv } from './project.mjs';
|
|
14
|
+
|
|
15
|
+
export const RULES = ['cycle', 'module-orphan', 'version-drift', 'dep-undeclared', 'dep-unused', 'config-missing',
|
|
16
|
+
'config-unread', 'endpoint-missing', 'endpoint-unused', 'external-unowned'];
|
|
17
|
+
// module-orphan and config-unread conclude from absence too: a service started from a CLI, or settings bound by
|
|
18
|
+
// name (pydantic BaseSettings), leave nothing in the lines for a scan to see.
|
|
19
|
+
export const INFERRED = new Set(['dep-unused', 'endpoint-unused', 'external-unowned', 'module-orphan', 'config-unread']);
|
|
20
|
+
const HIDDEN_REASON = 'inferred from absence in this repository; shown on request until its false-positive rate is measured';
|
|
21
|
+
const MAX_EV = 5;
|
|
22
|
+
|
|
23
|
+
// Collected raw, built once: the same rule and subject seen twice (a missing endpoint called from two places)
|
|
24
|
+
// is ONE finding citing both places, never two findings with one id.
|
|
25
|
+
function finding(rule, subject, title, evidence, data = {}) { return { rule, subject, title, evidence, data }; }
|
|
26
|
+
function build(raw) {
|
|
27
|
+
const by = new Map();
|
|
28
|
+
for (const r of raw) {
|
|
29
|
+
const k = `${r.rule}\u0000${r.subject}`;
|
|
30
|
+
if (by.has(k)) by.get(k).evidence.push(...r.evidence); else by.set(k, { ...r, evidence: [...r.evidence] });
|
|
31
|
+
}
|
|
32
|
+
return [...by.values()].map(({ rule, subject, title, evidence, data }) => {
|
|
33
|
+
const slug = subject.toLowerCase().replace(/[^a-z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120) || 'x';
|
|
34
|
+
const h = crypto.createHash('sha1').update(`${rule}\u0000${subject}`).digest('hex').slice(0, 8);
|
|
35
|
+
const all = sortEv(evidence);
|
|
36
|
+
const f = { id: `${rule}:${slug}:${h}`, rule, title: title.slice(0, 200),
|
|
37
|
+
confidence: INFERRED.has(rule) ? 'inferred' : 'proven', evidence: all.slice(0, MAX_EV) };
|
|
38
|
+
const d = { subject, ...data };
|
|
39
|
+
if (all.length > MAX_EV) d.evidenceTotal = all.length;
|
|
40
|
+
if (INFERRED.has(rule)) { d.defaultHidden = true; d.hiddenReason = HIDDEN_REASON; }
|
|
41
|
+
f.data = d;
|
|
42
|
+
return f;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Endpoint match: same method or either is '*'; same segment count; segments equal or either is a parameter.
|
|
47
|
+
function epMatch(a, b) {
|
|
48
|
+
if (a.method !== b.method && a.method !== '*' && b.method !== '*') return false;
|
|
49
|
+
const x = a.path.split('/'); const y = b.path.split('/');
|
|
50
|
+
return x.length === y.length && x.every((s, i) => s === y[i] || s === '{}' || y[i] === '{}');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function detect(ctx) {
|
|
54
|
+
const { st, cfg, eps, names, stdlib, notMeasured } = ctx;
|
|
55
|
+
const out = [];
|
|
56
|
+
const mods = st.modules;
|
|
57
|
+
const byId = new Map(mods.map((m) => [m.id, m]));
|
|
58
|
+
const refs = mods.flatMap((m) => m.refs.filter((r) => byId.has(r.to)).map((r) => ({ from: m.id, ...r })));
|
|
59
|
+
|
|
60
|
+
// ---- cycle (evidence edges only: a cycle through an inferred edge is not shown by the lines) ----
|
|
61
|
+
{
|
|
62
|
+
const adj = new Map(mods.map((m) => [m.id, []]));
|
|
63
|
+
for (const r of refs) if (r.confidence === 'evidence' && r.from !== r.to) adj.get(r.from).push(r.to);
|
|
64
|
+
let idx = 0; const index = new Map(); const low = new Map(); const stack = []; const on = new Set(); const sccs = [];
|
|
65
|
+
const strong = (v) => {
|
|
66
|
+
index.set(v, idx); low.set(v, idx); idx++; stack.push(v); on.add(v);
|
|
67
|
+
for (const w of [...new Set(adj.get(v))].sort()) {
|
|
68
|
+
if (!index.has(w)) { strong(w); low.set(v, Math.min(low.get(v), low.get(w))); }
|
|
69
|
+
else if (on.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
|
|
70
|
+
}
|
|
71
|
+
if (low.get(v) === index.get(v)) { const c = []; let w; do { w = stack.pop(); on.delete(w); c.push(w); } while (w !== v); if (c.length > 1) sccs.push(c.sort()); }
|
|
72
|
+
};
|
|
73
|
+
for (const id of [...adj.keys()].sort()) if (!index.has(id)) strong(id);
|
|
74
|
+
for (const c of sccs) {
|
|
75
|
+
const inC = new Set(c);
|
|
76
|
+
const e = refs.filter((r) => r.confidence === 'evidence' && inC.has(r.from) && inC.has(r.to)).map((r) => r.ev);
|
|
77
|
+
const names2 = c.map((id) => byId.get(id).name);
|
|
78
|
+
out.push(finding('cycle', c.join(' '), `Modules reference each other in a cycle: ${names2.join(' → ')} → ${names2[0]}`, e, { modules: c }));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- module-orphan ----
|
|
83
|
+
if (mods.length >= 2) {
|
|
84
|
+
const incoming = new Set(refs.map((r) => r.to));
|
|
85
|
+
for (const m of mods) {
|
|
86
|
+
if (m.dir === '.' || m.entry || m.deployed || incoming.has(m.id)) continue;
|
|
87
|
+
if (m.tech.has('maven:pom')) continue; // an aggregator is a build entry point
|
|
88
|
+
out.push(finding('module-orphan', m.id, `No module references ${m.name}, and it has no entry point`, m.evidence, { module: m.id }));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- version-drift ----
|
|
93
|
+
{
|
|
94
|
+
const by = new Map();
|
|
95
|
+
for (const m of mods) for (const d of m.deps) {
|
|
96
|
+
// A peer range is a range by design, and a URL is not a version.
|
|
97
|
+
if (!d.version || d.central || d.peer || d.version === 'url' || /^(workspace:|file:|link:|\*$)/.test(d.version)) continue;
|
|
98
|
+
const k = `${d.eco}\u0000${d.name}`;
|
|
99
|
+
if (!by.has(k)) by.set(k, []);
|
|
100
|
+
by.get(k).push({ m, d });
|
|
101
|
+
}
|
|
102
|
+
for (const [k, list] of [...by.entries()].sort()) {
|
|
103
|
+
const versions = [...new Set(list.map((x) => x.d.version))].sort();
|
|
104
|
+
if (versions.length < 2 || new Set(list.map((x) => x.m.id)).size < 2) continue; // drift is between modules
|
|
105
|
+
const name = k.split('\u0000')[1];
|
|
106
|
+
out.push(finding('version-drift', k.replace('\u0000', ':'), `${name} is declared at ${versions.length} versions: ${versions.join(', ')}`,
|
|
107
|
+
list.map((x) => x.d.ev), { package: name, versions, modules: [...new Set(list.map((x) => x.m.id))].sort() }));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- dep-undeclared / dep-unused (node, python, go) ----
|
|
112
|
+
const builtins = new Set(names.nodeBuiltins.names);
|
|
113
|
+
const pyAlias = names.pythonImportNames.map;
|
|
114
|
+
const never = names.neverImported ?? { python: [], node: {} };
|
|
115
|
+
const pyNever = never.python.map((g) => new RegExp(`^${g.toLowerCase().replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`));
|
|
116
|
+
const pyImportName = (dist) => (pyAlias[dist.toLowerCase()] || dist).toLowerCase().replace(/-/g, '_');
|
|
117
|
+
const pyLocal = new Set(mods.filter((m) => m.stack === 'python').flatMap((m) => [...(m.localNames || []), ...m.selfNames.map((n) => n.toLowerCase().replace(/-/g, '_'))]));
|
|
118
|
+
for (const m of mods) {
|
|
119
|
+
if (!['node', 'python', 'go'].includes(m.stack) || !m.manifest) continue;
|
|
120
|
+
const ancestors = mods.filter((o) => o.stack === m.stack && o !== m && (o.dir === '.' || m.dir.startsWith(o.dir + '/')));
|
|
121
|
+
const declared = new Set([...m.deps, ...ancestors.flatMap((o) => o.deps)].map((d) => d.name));
|
|
122
|
+
const roots = new Map();
|
|
123
|
+
for (const im of m.imports) { if (!roots.has(im.root)) roots.set(im.root, []); roots.get(im.root).push(im.ev); }
|
|
124
|
+
if (m.stack === 'node') {
|
|
125
|
+
const aliases = [...m.aliases, ...ancestors.flatMap((o) => o.aliases), ...st.globalAliases].filter(Boolean);
|
|
126
|
+
for (const [root, evs] of roots) {
|
|
127
|
+
const bare = root.replace(/^node:/, '');
|
|
128
|
+
if (root.startsWith('node:') || builtins.has(bare) || builtins.has(bare.split('/')[0])) continue;
|
|
129
|
+
if (/^(@\/|~\/|#|virtual:|bun:|data:|https?:)|[!?]/.test(root) || root === '@' || root === '~') continue;
|
|
130
|
+
if (aliases.some((a) => root === a.replace(/\/$/, '') || root.startsWith(a))) continue;
|
|
131
|
+
if (declared.has(root) || m.selfNames.includes(root) || declared.has(`@types/${root}`)) continue;
|
|
132
|
+
out.push(finding('dep-undeclared', `${m.id} ${root}`, `${root} is imported but not declared in ${m.manifest}`, evs, { module: m.id, package: root }));
|
|
133
|
+
}
|
|
134
|
+
const used = new Set(roots.keys());
|
|
135
|
+
for (const d of m.deps) {
|
|
136
|
+
if (d.dev || d.name.startsWith('@types/') || used.has(d.name)) continue;
|
|
137
|
+
if (never.node[d.name] && declared.has(never.node[d.name])) continue;
|
|
138
|
+
out.push(finding('dep-unused', `${m.id} ${d.name}`, `${d.name} is declared but no import of it was found in this repo`, [d.ev], { module: m.id, package: d.name }));
|
|
139
|
+
}
|
|
140
|
+
} else if (m.stack === 'python') {
|
|
141
|
+
const decl = new Set([...declared].map(pyImportName));
|
|
142
|
+
for (const [root, evs] of roots) {
|
|
143
|
+
const r = root.toLowerCase();
|
|
144
|
+
if (stdlib.has(root) || root === '__future__' || pyLocal.has(root) || pyLocal.has(r) || decl.has(r)) continue;
|
|
145
|
+
out.push(finding('dep-undeclared', `${m.id} ${root}`, `${root} is imported but not declared in ${m.manifest}`, evs, { module: m.id, package: root }));
|
|
146
|
+
}
|
|
147
|
+
const used = new Set([...roots.keys()].map((x) => x.toLowerCase()));
|
|
148
|
+
for (const d of m.deps) {
|
|
149
|
+
if (d.dev || used.has(pyImportName(d.name))) continue;
|
|
150
|
+
if (pyNever.some((re) => re.test(d.name.toLowerCase()))) continue;
|
|
151
|
+
out.push(finding('dep-unused', `${m.id} ${d.name}`, `${d.name} is declared but no import of it was found in this repo`, [d.ev], { module: m.id, package: d.name }));
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
const req = m.deps.map((d) => d.name);
|
|
155
|
+
for (const [root, evs] of roots) {
|
|
156
|
+
if (req.some((r) => root === r || root.startsWith(r + '/'))) continue;
|
|
157
|
+
out.push(finding('dep-undeclared', `${m.id} ${root}`, `${root} is imported but not required in ${m.manifest}`, evs, { module: m.id, package: root }));
|
|
158
|
+
}
|
|
159
|
+
for (const d of m.deps) {
|
|
160
|
+
if (d.indirect || [...roots.keys()].some((r) => r === d.name || r.startsWith(d.name + '/'))) continue;
|
|
161
|
+
out.push(finding('dep-unused', `${m.id} ${d.name}`, `${d.name} is required but no import of it was found in this repo`, [d.ev], { module: m.id, package: d.name }));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- config-missing / config-unread ----
|
|
167
|
+
{
|
|
168
|
+
const platform = new Set(names.platformEnv.names.map(canon));
|
|
169
|
+
const framework = names.frameworkConfig.names;
|
|
170
|
+
const defined = new Set(cfg.defs.map((d) => canon(d.key)));
|
|
171
|
+
const exactReads = new Set(cfg.reads.filter((r) => !r.prefix).map((r) => canon(r.key)));
|
|
172
|
+
const prefixReads = cfg.reads.filter((r) => r.prefix).map((r) => canon(r.key));
|
|
173
|
+
const missing = new Map();
|
|
174
|
+
const fileDefs = cfg.defs.filter((d) => ['env', 'appsettings', 'properties', 'yaml'].includes(d.source)).length;
|
|
175
|
+
if (!fileDefs && cfg.reads.some((r) => !r.prefix && !r.hasDefault)) notMeasured.push({ what: 'config-missing', reason: 'this repository has no configuration file (.env, appsettings, application.properties/yml), so where its keys are meant to come from is not on disk' });
|
|
176
|
+
for (const r of fileDefs ? cfg.reads : []) {
|
|
177
|
+
if (r.prefix || r.hasDefault) continue;
|
|
178
|
+
const c = canon(r.key);
|
|
179
|
+
if (defined.has(c) || platform.has(c)) continue;
|
|
180
|
+
if (!missing.has(c)) missing.set(c, { key: r.key, evs: [] });
|
|
181
|
+
missing.get(c).evs.push(r.ev);
|
|
182
|
+
}
|
|
183
|
+
for (const [c, x] of [...missing.entries()].sort()) out.push(finding('config-missing', c, `${x.key} is read but defined in no configuration file`, x.evs, { key: x.key }));
|
|
184
|
+
if (mods.length) {
|
|
185
|
+
for (const d of cfg.defs) {
|
|
186
|
+
if (!['env', 'appsettings', 'properties', 'yaml'].includes(d.source)) continue;
|
|
187
|
+
const c = canon(d.key);
|
|
188
|
+
if (exactReads.has(c) || prefixReads.some((p) => c === p || c.startsWith(p + '.'))) continue;
|
|
189
|
+
if (framework.some((fw) => (/[:.]$/.test(fw) ? d.key.startsWith(fw) || d.key === fw.slice(0, -1) : d.key === fw))) continue;
|
|
190
|
+
out.push(finding('config-unread', `${d.file} ${d.key}`, `${d.key} is configured in ${d.file} but no read of it was found`, [d.ev], { key: d.key, file: d.file }));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- endpoint-missing / endpoint-unused ----
|
|
196
|
+
{
|
|
197
|
+
const prov = eps.provides.filter((e) => e.resolved); const cons = eps.consumes.filter((e) => e.resolved);
|
|
198
|
+
if (eps.provides.length) {
|
|
199
|
+
// A call is judged only against a provider it could belong to: one that shares its first path segment. A
|
|
200
|
+
// path no provider here begins with is most likely another service's, and is left to that service's map.
|
|
201
|
+
const firsts = new Set(prov.map((p) => p.path.split('/')[1]));
|
|
202
|
+
// Under a router mounted elsewhere the provider's full path is not known, so a call there is not judged.
|
|
203
|
+
const under = (c) => eps.mounts.some((mt) => c.path === mt || c.path.startsWith(mt === '/' ? '/' : mt + '/'));
|
|
204
|
+
for (const c of cons) {
|
|
205
|
+
if (prov.some((p) => epMatch(p, c))) continue;
|
|
206
|
+
if (!firsts.has(c.path.split('/')[1]) || under(c)) continue;
|
|
207
|
+
// Its host is a run-time value: the call may well be to another service (a model server's /api/tags).
|
|
208
|
+
if (c.base) continue;
|
|
209
|
+
out.push(finding('endpoint-missing', `${c.module} ${c.method} ${c.path}`, `${c.method} ${c.path} is called but no endpoint in this repo provides it`, c.evidence, { method: c.method, path: c.path, module: c.module }));
|
|
210
|
+
}
|
|
211
|
+
} else if (eps.consumes.length) {
|
|
212
|
+
notMeasured.push({ what: 'endpoint-missing', reason: 'this repository provides no endpoint, so the paths it calls can only be matched against the provider\'s own map' });
|
|
213
|
+
}
|
|
214
|
+
if (eps.consumes.length) {
|
|
215
|
+
for (const p of prov) {
|
|
216
|
+
if (cons.some((c) => epMatch(p, c))) continue;
|
|
217
|
+
// A healthcheck or a probe calls it: the platform is its caller.
|
|
218
|
+
if ((eps.probes ?? []).some((path) => epMatch(p, { method: 'GET', path }))) continue;
|
|
219
|
+
out.push(finding('endpoint-unused', `${p.module} ${p.method} ${p.path}`, `${p.method} ${p.path} is provided but no caller found in this repo`, p.evidence, { method: p.method, path: p.path, module: p.module }));
|
|
220
|
+
}
|
|
221
|
+
} else if (eps.provides.length) {
|
|
222
|
+
notMeasured.push({ what: 'endpoint-unused', reason: 'this repository calls none of its own endpoints, so their callers are elsewhere' });
|
|
223
|
+
}
|
|
224
|
+
const unresolved = [...eps.provides, ...eps.consumes].filter((e) => !e.resolved).length;
|
|
225
|
+
if (unresolved) notMeasured.push({ what: `endpoints with a run-time path (${unresolved})`, reason: 'the start of the path is built at run time, so they are listed but never matched' });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- external-unowned ----
|
|
229
|
+
{
|
|
230
|
+
const readCanon = new Set(cfg.reads.map((r) => canon(r.key)));
|
|
231
|
+
for (const u of ctx.configUrls) {
|
|
232
|
+
if (!u.host || u.host === '[masked]') continue;
|
|
233
|
+
if (readCanon.has(canon(u.key)) || ctx.codeHosts.has(u.host)) continue;
|
|
234
|
+
out.push(finding('external-unowned', `${u.ev.file} ${u.key}`, `${u.host} is configured under ${u.key} but no client using it was found`, [u.ev], { key: u.key, host: u.host }));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return build(out);
|
|
238
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// narrative.mjs — what the model may add to a crew map, and the rule it is held to. One definition, read by the
|
|
2
|
+
// scanner (--write keeps it, --check ignores it, --validate judges it) and by its tests.
|
|
3
|
+
//
|
|
4
|
+
// The scanner writes every node with `narrative: null` and the document with `overview: null`. /crew-map lets the
|
|
5
|
+
// model fill them, and nothing else:
|
|
6
|
+
// node.narrative { text, cites } what the module is for, in at most two sentences; `cites` are "file:line"
|
|
7
|
+
// strings, each one a line the scanner itself cited for THAT node (its evidence)
|
|
8
|
+
// doc.overview { text, cites } a short orientation for someone new to the project; its cites may be any
|
|
9
|
+
// node's evidence
|
|
10
|
+
// A narrative with no citation, or one citing a line the scan did not read, is refused: the model may describe
|
|
11
|
+
// what the scan found, not add to it. A scanner field changed by hand is refused too (validate).
|
|
12
|
+
|
|
13
|
+
const MAX_NODE = 400;
|
|
14
|
+
const MAX_OVERVIEW = 2000;
|
|
15
|
+
|
|
16
|
+
/** The "file:line" a node's evidence lets a narrative cite. */
|
|
17
|
+
export function citesOf(node) {
|
|
18
|
+
const out = new Set();
|
|
19
|
+
for (const e of Array.isArray(node?.evidence) ? node.evidence : []) {
|
|
20
|
+
if (e && typeof e.file === 'string' && Number.isInteger(e.line)) out.add(`${e.file}:${e.line}`);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Every line any node of the document cites: what an overview may point at. */
|
|
26
|
+
export function allCites(doc) {
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const n of doc?.nodes ?? []) for (const c of citesOf(n)) out.add(c);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sentences = (t) => (String(t).trim().match(/[.!?](\s|$)/g) ?? []).length;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why a narrative is not acceptable, or null when it is (null itself is: not written yet).
|
|
36
|
+
* @param allowed the set of "file:line" it may cite
|
|
37
|
+
*/
|
|
38
|
+
export function problemOf(n, allowed, { max = MAX_NODE, twoSentences = true } = {}) {
|
|
39
|
+
if (n === null || n === undefined) return null;
|
|
40
|
+
if (typeof n !== 'object' || Array.isArray(n)) return 'is not { text, cites }';
|
|
41
|
+
const keys = Object.keys(n).sort().join();
|
|
42
|
+
if (keys !== 'cites,text') return `has fields other than text and cites (${keys})`;
|
|
43
|
+
if (typeof n.text !== 'string' || !n.text.trim()) return 'has no text';
|
|
44
|
+
if (n.text.length > max) return `is ${n.text.length} characters, more than ${max}`;
|
|
45
|
+
if (twoSentences && sentences(n.text) > 2) return 'is more than two sentences';
|
|
46
|
+
if (!Array.isArray(n.cites) || !n.cites.length) return 'cites nothing: a description must point at a line the scan read';
|
|
47
|
+
const bad = n.cites.filter((c) => typeof c !== 'string' || !allowed.has(c));
|
|
48
|
+
if (bad.length) return `cites ${bad.slice(0, 3).map(String).join(', ')}, which the scan did not cite here`;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The document without what the model may add: the scanner's fields alone. */
|
|
53
|
+
export function strip(doc) {
|
|
54
|
+
if (!doc || typeof doc !== 'object') return doc;
|
|
55
|
+
return { ...doc, nodes: (doc.nodes ?? []).map((n) => ({ ...n, narrative: null })), overview: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A new scan with the narratives of the old map carried over, where they still hold: a node with the same id
|
|
60
|
+
* whose narrative still cites only lines this scan cites for it. One that no longer holds is dropped, and counted.
|
|
61
|
+
*/
|
|
62
|
+
export function carry(old, fresh) {
|
|
63
|
+
const was = new Map((old?.nodes ?? []).map((n) => [n.id, n.narrative]));
|
|
64
|
+
let kept = 0; let dropped = 0;
|
|
65
|
+
const nodes = fresh.nodes.map((n) => {
|
|
66
|
+
const nar = was.get(n.id);
|
|
67
|
+
if (nar === null || nar === undefined) return n;
|
|
68
|
+
if (problemOf(nar, citesOf(n)) === null) { kept += 1; return { ...n, narrative: nar }; }
|
|
69
|
+
dropped += 1; return n;
|
|
70
|
+
});
|
|
71
|
+
const doc = { ...fresh, nodes };
|
|
72
|
+
if (old?.overview != null) {
|
|
73
|
+
if (problemOf(old.overview, allCites(doc), { max: MAX_OVERVIEW, twoSentences: false }) === null) { doc.overview = old.overview; kept += 1; }
|
|
74
|
+
else dropped += 1;
|
|
75
|
+
}
|
|
76
|
+
return { doc, kept, dropped };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Hold a map the model wrote to the rules: its scanner fields are what a scan of the tree writes now, and every
|
|
81
|
+
* narrative and the overview pass `problemOf`. Returns a list of { at, message }; empty means it holds.
|
|
82
|
+
* @param serialize how the scanner writes a document (so the comparison is of its own bytes)
|
|
83
|
+
*/
|
|
84
|
+
export function validate(file, fresh, serialize) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const bare = (d) => { const { source, ...rest } = strip(d); return serialize(rest); };
|
|
87
|
+
if (bare(file) !== bare(fresh)) {
|
|
88
|
+
out.push({ at: 'scan', message: 'the map\'s scanner fields differ from a scan of this tree: a field was changed by hand, or the tree changed since (scan again first)' });
|
|
89
|
+
}
|
|
90
|
+
const byId = new Map((fresh.nodes ?? []).map((n) => [n.id, n]));
|
|
91
|
+
for (const n of file.nodes ?? []) {
|
|
92
|
+
const p = problemOf(n.narrative, citesOf(byId.get(n.id) ?? n));
|
|
93
|
+
if (p) out.push({ at: n.id, message: `the narrative ${p}` });
|
|
94
|
+
}
|
|
95
|
+
const p = problemOf(file.overview, allCites(fresh), { max: MAX_OVERVIEW, twoSentences: false });
|
|
96
|
+
if (p) out.push({ at: 'overview', message: `the overview ${p}` });
|
|
97
|
+
return out;
|
|
98
|
+
}
|