crewforth 3.4.0 → 3.5.0-rc.1

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 (153) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +3 -2
  3. package/VERSION +1 -1
  4. package/adopt.sh +40 -5
  5. package/bin/cli.js +138 -2
  6. package/kit/AGENT_TEMPLATE.md +15 -0
  7. package/kit/CLAUDE.md +49 -17
  8. package/kit/agents/crew-backend-expert.md +4 -3
  9. package/kit/agents/crew-commit-agent.md +2 -0
  10. package/kit/agents/crew-database-expert.md +2 -1
  11. package/kit/agents/crew-devops-expert.md +1 -0
  12. package/kit/agents/crew-docs-agent.md +3 -1
  13. package/kit/agents/crew-frontend-expert.md +2 -0
  14. package/kit/agents/crew-performance-expert.md +7 -2
  15. package/kit/agents/crew-planner.md +15 -3
  16. package/kit/agents/crew-privacy-agent.md +2 -0
  17. package/kit/agents/crew-review-agent.md +8 -1
  18. package/kit/agents/crew-security-expert.md +4 -3
  19. package/kit/agents/crew-test-expert.md +6 -2
  20. package/kit/eval/doctor.sh +201 -1
  21. package/kit/eval/gate-report.sh +28 -0
  22. package/kit/eval/golden-holdout.txt +2 -0
  23. package/kit/eval/golden-routing.txt +17 -0
  24. package/kit/eval/lib/crew-map/contracts.mjs +77 -0
  25. package/kit/eval/lib/crew-map/crew-map.sh +2 -1
  26. package/kit/eval/lib/crew-map/detect.mjs +2 -1
  27. package/kit/eval/lib/crew-map/narrative.mjs +14 -3
  28. package/kit/eval/lib/crew-map/project.mjs +45 -6
  29. package/kit/eval/lib/crew-map/scan.mjs +153 -14
  30. package/kit/eval/lib/findings/findings.schema.json +11 -1
  31. package/kit/eval/lib/findings/findings.sh +24 -1
  32. package/kit/eval/lib/hook-inject.sh +159 -0
  33. package/kit/eval/lib/lockfiles.sh +71 -0
  34. package/kit/eval/lib/payload-check.sh +29 -0
  35. package/kit/eval/lib/preview.sh +3 -3
  36. package/kit/eval/lib/review-bench/bench.mjs +14 -2
  37. package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
  38. package/kit/eval/lib/settings-json.awk +18 -0
  39. package/kit/eval/lib/untrusted-text.awk +38 -0
  40. package/kit/eval/scan-skill.sh +67 -20
  41. package/kit/eval/smoke-test.sh +1700 -40
  42. package/kit/eval/utilization.sh +77 -3
  43. package/kit/hooks/agent-outcome.sh +9 -1
  44. package/kit/hooks/board-sync.sh +49 -3
  45. package/kit/hooks/board.sh +8 -8
  46. package/kit/hooks/branch-fresh.sh +101 -0
  47. package/kit/hooks/floor-blocklist.txt +19 -1
  48. package/kit/hooks/guard-bash.sh +92 -8
  49. package/kit/hooks/guard-question.sh +2 -1
  50. package/kit/hooks/loop-guard.sh +445 -0
  51. package/kit/hooks/map-stale.sh +437 -0
  52. package/kit/hooks/pre-commit +48 -7
  53. package/kit/hooks/precompact-snapshot.sh +102 -6
  54. package/kit/hooks/prompt-approval.sh +45 -9
  55. package/kit/hooks/route-hint.sh +36 -7
  56. package/kit/hooks/secret-blocklist.txt +55 -1
  57. package/kit/hooks/session-guard.sh +45 -15
  58. package/kit/hooks/session-rehydrate.sh +34 -11
  59. package/kit/hooks/session-stats.sh +169 -8
  60. package/kit/hooks/session-update-check.sh +5 -1
  61. package/kit/hooks/skill-trust.sh +23 -14
  62. package/kit/hooks/turn-check.sh +190 -0
  63. package/kit/owned-blobs.tsv +4 -4
  64. package/kit/settings.json +64 -0
  65. package/kit/skills/a11y/SKILL.md +50 -3
  66. package/kit/skills/a11y/references/recipes.md +41 -0
  67. package/kit/skills/adr/SKILL.md +14 -2
  68. package/kit/skills/api-design/SKILL.md +6 -1
  69. package/kit/skills/api-design/references/idempotency.md +39 -0
  70. package/kit/skills/brainstorm/SKILL.md +4 -2
  71. package/kit/skills/brainstorm/references/asking.md +47 -0
  72. package/kit/skills/ci-pipeline/SKILL.md +11 -1
  73. package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
  74. package/kit/skills/commit-message/SKILL.md +9 -3
  75. package/kit/skills/confidence-check/SKILL.md +9 -7
  76. package/kit/skills/confidence-check/references/source-check.md +37 -0
  77. package/kit/skills/crew-approve/SKILL.md +3 -0
  78. package/kit/skills/crew-audit/SKILL.md +63 -0
  79. package/kit/skills/crew-board/SKILL.md +2 -2
  80. package/kit/skills/crew-code-review/SKILL.md +27 -4
  81. package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
  82. package/kit/skills/crew-code-review/references/simplify.md +28 -0
  83. package/kit/skills/crew-doctor/SKILL.md +3 -2
  84. package/kit/skills/crew-gates/SKILL.md +3 -0
  85. package/kit/skills/crew-gates/references/enforcers.md +30 -0
  86. package/kit/skills/crew-interview/SKILL.md +42 -0
  87. package/kit/skills/crew-map/SKILL.md +26 -6
  88. package/kit/skills/crew-plan/SKILL.md +9 -2
  89. package/kit/skills/crew-pr/SKILL.md +57 -0
  90. package/kit/skills/crew-pr/references/checks.md +35 -0
  91. package/kit/skills/crew-pr/references/threads.md +23 -0
  92. package/kit/skills/crew-review/SKILL.md +34 -1
  93. package/kit/skills/crew-ship/SKILL.md +5 -2
  94. package/kit/skills/crew-skill/SKILL.md +26 -1
  95. package/kit/skills/crew-update/SKILL.md +4 -1
  96. package/kit/skills/db-migration/SKILL.md +15 -0
  97. package/kit/skills/db-migration/references/expand-contract.md +52 -0
  98. package/kit/skills/dependency-audit/SKILL.md +34 -1
  99. package/kit/skills/dependency-audit/scripts/record.sh +37 -0
  100. package/kit/skills/dependency-upgrade/SKILL.md +29 -1
  101. package/kit/skills/deploy/SKILL.md +18 -0
  102. package/kit/skills/deploy/references/topology-a.md +7 -0
  103. package/kit/skills/deploy/references/topology-b.md +2 -2
  104. package/kit/skills/docs-writer/SKILL.md +37 -2
  105. package/kit/skills/eval-grader/SKILL.md +9 -0
  106. package/kit/skills/eval-grader/references/method.md +26 -0
  107. package/kit/skills/frontend/SKILL.md +5 -2
  108. package/kit/skills/frontend/references/from-design.md +29 -0
  109. package/kit/skills/frontend/references/page-metadata.md +17 -0
  110. package/kit/skills/frontend/references/verify-contract.md +18 -1
  111. package/kit/skills/frontend/references/worst-data.md +35 -0
  112. package/kit/skills/frontend-design/SKILL.md +20 -4
  113. package/kit/skills/frontend-design/references/design-review.md +21 -0
  114. package/kit/skills/frontend-design/references/detection.md +44 -0
  115. package/kit/skills/frontend-design/references/motion.md +54 -0
  116. package/kit/skills/frontend-design/references/ui-copy.md +35 -0
  117. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  118. package/kit/skills/handoff/SKILL.md +28 -3
  119. package/kit/skills/handoff/references/cold-reader.md +13 -6
  120. package/kit/skills/i18n-integrity/SKILL.md +3 -0
  121. package/kit/skills/iterate/SKILL.md +7 -2
  122. package/kit/skills/mcp-builder/SKILL.md +21 -4
  123. package/kit/skills/mcp-builder/references/building.md +74 -13
  124. package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
  125. package/kit/skills/observability/SKILL.md +13 -1
  126. package/kit/skills/performance/SKILL.md +25 -1
  127. package/kit/skills/performance/references/bottlenecks.md +11 -0
  128. package/kit/skills/performance/references/web-vitals.md +31 -0
  129. package/kit/skills/red-team/SKILL.md +25 -3
  130. package/kit/skills/reflect/SKILL.md +7 -2
  131. package/kit/skills/release/SKILL.md +13 -0
  132. package/kit/skills/security-scan/SKILL.md +6 -2
  133. package/kit/skills/security-scan/references/ai-agents.md +16 -0
  134. package/kit/skills/security-scan/references/fronts.md +59 -2
  135. package/kit/skills/security-scan/references/reporting.md +13 -0
  136. package/kit/skills/security-scan/references/verify.md +9 -0
  137. package/kit/skills/sonarqube-check/SKILL.md +32 -2
  138. package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
  139. package/kit/skills/spec-planning/SKILL.md +25 -8
  140. package/kit/skills/systematic-debugging/SKILL.md +15 -7
  141. package/kit/skills/systematic-debugging/references/techniques.md +5 -0
  142. package/kit/skills/teamboard/SKILL.md +3 -3
  143. package/kit/skills/testing/SKILL.md +18 -3
  144. package/kit/skills/testing/references/browser-headless.md +21 -0
  145. package/kit/skills/token-budget/SKILL.md +26 -5
  146. package/kit/skills/worktree/SKILL.md +13 -5
  147. package/kit/studio/server/lib/session.js +23 -2
  148. package/kit/studio/server/lib/worktree-changes.js +64 -0
  149. package/kit/studio/web/convo.js +14 -0
  150. package/kit/studio/web/map-plan.js +54 -8
  151. package/kit/studio/web/map.js +15 -3
  152. package/package.json +5 -4
  153. package/start.sh +11 -2
@@ -0,0 +1,71 @@
1
+ # Sourced, not run. The ONE list of lockfiles, and the ONE way to find the staged ones, for the two sides of §4.7:
2
+ # guard-bash.sh (reads the audit record back) and skills/dependency-audit/scripts/record.sh (writes it). Both hash
3
+ # what crew_staged_lockfiles returns, so a second copy of the list would let them disagree on which files were
4
+ # audited and turn a correct record into a refusal (or the reverse).
5
+ #
6
+ # Lockfiles, not manifests: a lockfile changes when the RESOLVED set changes, which is what an audit judges. A
7
+ # manifest also changes for a script, a description or a version field, and gating those would ask for an audit
8
+ # that has nothing new to read. (npm's and uv's lockfiles carry the project's own version, so a release bump does
9
+ # trip the gate — the one moment an audit is due anyway.) Left out on purpose: flake.lock and .terraform.lock.hcl,
10
+ # which pin no package ecosystem dependency-audit can read, so every commit touching them would be `unverified`.
11
+ #
12
+ # Every function takes the WORK-TREE TOP. git prints staged paths from the top, and they are handed back to git as
13
+ # pathspecs, so the two must agree: run from a subfolder, the pathspecs named nothing and the diff hashed to the
14
+ # empty blob (found in review: a valid record refused).
15
+
16
+ # rc 0 when the path's file name is a lockfile.
17
+ crew_is_lockfile(){
18
+ case "${1##*/}" in
19
+ package-lock.json|npm-shrinkwrap.json|yarn.lock|pnpm-lock.yaml|bun.lock|bun.lockb|deno.lock) return 0 ;;
20
+ uv.lock|poetry.lock|Pipfile.lock|pdm.lock|pixi.lock|renv.lock|conan.lock) return 0 ;;
21
+ Cargo.lock|go.sum|Gemfile.lock|composer.lock|mix.lock|pubspec.lock) return 0 ;;
22
+ packages.lock.json|paket.lock|*gradle.lockfile|Package.resolved|Podfile.lock|Cartfile.resolved) return 0 ;;
23
+ esac
24
+ return 1
25
+ }
26
+
27
+ # crew_staged_lockfiles <top> -> CREW_LOCKS: the staged lockfiles, an array in git's order. One git process.
28
+ # -z, because without it git QUOTES a path with a non-ASCII byte in it ("\305\237/package-lock.json"): the file name
29
+ # then ends in `"` and is not a lockfile, so a lockfile in a folder named in Turkish passed the gate unaudited.
30
+ # --diff-filter=d: a deleted lockfile has nothing left to audit (deleting one is §4.5's business).
31
+ crew_staged_lockfiles(){
32
+ local p
33
+ CREW_LOCKS=()
34
+ while IFS= read -r -d '' p; do
35
+ crew_is_lockfile "$p" && CREW_LOCKS+=("$p")
36
+ done < <(git -C "${1:-.}" -c core.fsmonitor= diff --cached --name-only --no-renames --diff-filter=d -z 2>/dev/null)
37
+ return 0
38
+ }
39
+
40
+ # crew_lockfile_pending <top> -> rc 0 when a lockfile differs from HEAD anywhere: staged, changed in the work tree,
41
+ # or new and untracked. For a command that stages AND commits in one call (`git add -A && git commit`): the hook
42
+ # reads the index before the command runs, so what is staged then is not what lands. One git process.
43
+ crew_lockfile_pending(){
44
+ local e
45
+ while IFS= read -r -d '' e; do
46
+ # A porcelain entry is "XY path"; a rename's second field is the bare original path. Both are tested.
47
+ crew_is_lockfile "${e:3}" && return 0
48
+ crew_is_lockfile "$e" && return 0
49
+ done < <(git -C "${1:-.}" -c core.fsmonitor= status --porcelain=v1 -z --untracked-files=all 2>/dev/null)
50
+ return 1
51
+ }
52
+
53
+ # crew_lock_oid <top> -> CREW_LOCK_OID: the object id of the staged diff of CREW_LOCKS alone, or "" when there is no
54
+ # such diff to hash. Code staged beside the lockfiles does not change it, so an audit stays valid while the rest of
55
+ # the commit is still being edited.
56
+ # --literal-pathspecs: a lockfile under a folder named `[x]` is a path, not a glob.
57
+ # --full-index: the `index` lines carry full object ids, so core.abbrev or the repository's size cannot change the
58
+ # text, and with it the id, between the audit and the commit.
59
+ # pipefail and the empty blob: a diff that FAILS still hands hash-object empty input, and both sides would then
60
+ # agree on e69de29… (found in review). A staged lockfile change is never an empty diff, so that id means "none".
61
+ crew_lock_oid(){
62
+ CREW_LOCK_OID="$( set -o pipefail
63
+ git -C "${1:-.}" -c core.fsmonitor= --literal-pathspecs diff --cached --full-index --no-ext-diff --no-textconv -- "${CREW_LOCKS[@]}" 2>/dev/null \
64
+ | git hash-object --stdin 2>/dev/null )" || CREW_LOCK_OID=""
65
+ case "$CREW_LOCK_OID" in e69de29bb2d1d6434b8b29ae775ad8c2e48c5391|473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813) CREW_LOCK_OID="" ;; esac # the empty blob, SHA-1 and SHA-256
66
+ return 0
67
+ }
68
+
69
+ # The record's file: crew_git_dir (gate-log.sh) must be loaded. Inside the git directory, so it is never committed,
70
+ # needs no .gitignore line on any install, and each linked worktree keeps its own.
71
+ CREW_DEPS_RECORD=crewforth-deps-audit.json
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # payload-check.sh KIT_DIR — is this payload whole enough to install? start.sh and adopt.sh run it before their first
3
+ # write, so a broken download (a truncated archive, a copy that lost a folder, a hook settings.json names but the
4
+ # payload does not carry) stops with "nothing installed" instead of leaving a half install whose gates are missing.
5
+ # Read-only and quick: file tests and one read of settings.json, no node, no network. Prints one problem per line
6
+ # (paths relative to KIT_DIR) and exits 1 when there is any, 0 when there is none, 2 on bad usage.
7
+ # CREW_PAYLOAD_UNCHECKED=1 skips it (the installers say so); it exists for a payload someone trimmed on purpose.
8
+ set -uo pipefail
9
+ K="${1:-}"
10
+ [ -n "$K" ] && [ -d "$K" ] || { echo "usage: payload-check.sh KIT_DIR" >&2; exit 2; }
11
+ P=""
12
+ # The files every install copies and every gate depends on.
13
+ for f in CLAUDE.md settings.json hooks/pre-commit hooks/commit-msg hooks/guard-bash.sh hooks/guard-write.sh \
14
+ eval/doctor.sh eval/lib/settings-json.awk; do
15
+ [ -s "$K/$f" ] || P="$P"$'\n'"missing or empty: $f"
16
+ done
17
+ for d in agents skills hooks eval; do [ -d "$K/$d" ] || P="$P"$'\n'"missing folder: $d/"; done
18
+ # Every hook settings.json wires is in the payload: a missing one is a gate that fails on every tool call.
19
+ if [ -f "$K/settings.json" ]; then
20
+ while IFS= read -r h; do
21
+ [ -s "$K/hooks/$h" ] || P="$P"$'\n'"settings.json names hooks/$h, which the payload does not carry"
22
+ done < <(grep -o '\.claude/hooks/[A-Za-z0-9._-]*' "$K/settings.json" | sed 's|^\.claude/hooks/||' | LC_ALL=C sort -u)
23
+ fi
24
+ # A skill folder without its SKILL.md and an empty agent file load as nothing, silently.
25
+ for s in "$K"/skills/*/; do [ -d "$s" ] || continue; [ -s "${s}SKILL.md" ] || P="$P"$'\n'"skills/$(basename "$s")/SKILL.md is missing or empty"; done
26
+ for a in "$K"/agents/*.md; do [ -e "$a" ] || continue; [ -s "$a" ] || P="$P"$'\n'"agents/$(basename "$a") is empty"; done
27
+ [ -z "$P" ] && exit 0
28
+ printf '%s\n' "${P#$'\n'}"
29
+ exit 1
@@ -66,7 +66,7 @@ _crew_preview(){
66
66
  esac
67
67
  case "$kind" in
68
68
  reset)
69
- n="$(git status --porcelain --untracked-files=no 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
69
+ n="$(git -c core.fsmonitor= status --porcelain --untracked-files=no 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
70
70
  _pv_words "$cmd" reset; t=""
71
71
  for w in $_PV_W; do case "$w" in -*) ;; *) t="$w"; break ;; esac; done
72
72
  a=""
@@ -82,7 +82,7 @@ _crew_preview(){
82
82
  -*) case "$w" in --*) ;; *) case "$w" in *d*) flags="$flags -d" ;; esac; case "$w" in *x*) flags="$flags -x" ;; *X*) flags="$flags -X" ;; esac ;; esac ;;
83
83
  esac; done
84
84
  # shellcheck disable=SC2086 # the flags are words of our own
85
- n="$(git clean $flags 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
85
+ n="$(git -c core.fsmonitor= clean $flags 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
86
86
  _CREW_PV="Preview: a dry run (git clean $flags) lists ${n:-0} path(s) it would delete."
87
87
  ;;
88
88
  push)
@@ -118,7 +118,7 @@ _crew_preview(){
118
118
  [ -n "$out" ] && _CREW_PV="Preview: $out."
119
119
  ;;
120
120
  commit)
121
- case " $cmd " in *' -a'*|*' --all'*|*' -[A-Za-z]*a'*) line="$(git diff HEAD --shortstat 2>/dev/null)" ;; *) line="$(git diff --cached --shortstat 2>/dev/null)" ;; esac
121
+ case " $cmd " in *' -a'*|*' --all'*|*' -[A-Za-z]*a'*) line="$(git -c core.fsmonitor= diff --no-ext-diff --no-textconv HEAD --shortstat 2>/dev/null)" ;; *) line="$(git -c core.fsmonitor= diff --no-ext-diff --no-textconv --cached --shortstat 2>/dev/null)" ;; esac
122
122
  line="${line# }"
123
123
  _CREW_PV="Preview: ${line:-nothing staged}."
124
124
  ;;
@@ -209,7 +209,7 @@ export function run(o) {
209
209
  const rows = []; let spent = 0;
210
210
  try {
211
211
  for (const c of cases) {
212
- if (o.arm === 'agent' && spent + o.caseBudget > o.maxCost) { rows.push({ id: c.id, skipped: `the run's cap ($${o.maxCost}) would be passed` }); continue; }
212
+ if (o.arm === 'agent' && spent + o.caseBudget > o.maxCost) { rows.push({ id: c.id, kind: c.kind, stratum: c.stratum, skipped: `the run's cap ($${o.maxCost}) would be passed` }); continue; }
213
213
  const b = build(c, tmp);
214
214
  let out;
215
215
  if (c.kind === 'task') {
@@ -236,6 +236,10 @@ export function summarise(rows, o, spent = 0) {
236
236
  const sum = (k) => done.reduce((n, r) => n + (Number(r[k]) || 0), 0);
237
237
  return { arm: o.arm, model: o.arm === 'agent' ? o.model : null, advisor: o.arm === 'agent' ? o.advisor ?? null : null, split: o.split, cases: rows.length, ran: done.length,
238
238
  tasks: { done: tasks.filter((r) => r.done).length, of: tasks.length }, ms: sum('ms'), advisorCalls: sum('advisorCalls'),
239
+ // Worst case: a defect row that did not run counts as missed. Recall over the rows that ran can clear a floor the
240
+ // whole set would miss, because the cap decides which rows run; this is the figure that cannot.
241
+ recallWorstCase: rec([...defects, ...rows.filter((r) => r.skipped && r.kind === 'defect')]),
242
+ byStratumWorstCase: Object.fromEntries(['pattern', 'judgement'].map((s) => [s, rec([...defects, ...rows.filter((r) => r.skipped && r.kind === 'defect')].filter((r) => r.stratum === s))])),
239
243
  recall: rec(defects), recallNoHint: rec(defects.filter((r) => !r.hint)), recallHint: rec(defects.filter((r) => r.hint)),
240
244
  byStratum: Object.fromEntries(['pattern', 'judgement'].map((s) => [s, rec(defects.filter((r) => r.stratum === s))])),
241
245
  precision: { matched, of: claims, pct: pct(matched, claims) },
@@ -249,6 +253,7 @@ function main(argv) {
249
253
  const line = (n, x) => `${n} ${x.caught}/${x.of}${x.pct === null ? '' : ` (${x.pct}%)`}`;
250
254
  console.log(`review-bench · arm ${s.arm}${s.model ? ` (${s.model}${s.advisor ? ` + ${s.advisor} advisor` : ''})` : ''} · split ${s.split} · ${s.ran}/${s.cases} cases`);
251
255
  console.log(` ${line('recall', s.recall)} · ${line('without a hint', s.recallNoHint)} · ${line('with one', s.recallHint)}`);
256
+ if (s.recallWorstCase.of > s.recall.of) console.log(` ${line('recall counting the defect rows that did not run as missed', s.recallWorstCase)}`);
252
257
  console.log(` ${line('pattern', s.byStratum.pattern)} · ${line('judgement', s.byStratum.judgement)}`);
253
258
  console.log(` precision ${s.precision.matched}/${s.precision.of}${s.precision.pct === null ? ' (no claim made)' : ` (${s.precision.pct}%)`} · ${s.falseAlarms.length} false alarm(s)${s.arm === 'agent' ? ` · cost $${s.cost}` : ''}`);
254
259
  if (s.tasks.of) console.log(` tasks done ${s.tasks.done}/${s.tasks.of}`);
@@ -260,12 +265,19 @@ function main(argv) {
260
265
  let rc = s.errors.length && o.arm === 'agent' ? 1 : 0;
261
266
  if (o.floors) {
262
267
  const fl = JSON.parse(fs.readFileSync(o.floors, 'utf8'))[o.arm] ?? {};
268
+ let skippedPass = false;
263
269
  for (const [k, v] of Object.entries(fl)) {
264
270
  const got = k === 'precision' ? s.precision.pct : k === 'falseAlarms' ? s.falseAlarms.length : (k === 'recall' ? s.recall : s.byStratum[k.replace(/^recall-/, '')])?.pct;
265
271
  const ok = k === 'falseAlarms' ? got <= v : got !== null && got !== undefined && got >= v;
266
- console.log(` floor ${k}: ${got} ${k === 'falseAlarms' ? '≤' : '≥'} ${v} ${ok ? 'ok' : 'BELOW THE FLOOR'}`);
272
+ // A recall floor that holds only because rows were skipped is not a pass: the worst case is checked beside it.
273
+ const worst = k === 'recall' ? s.recallWorstCase.pct : k.startsWith('recall-') ? s.byStratumWorstCase[k.replace(/^recall-/, '')]?.pct : null;
274
+ const onlySkipped = ok && worst !== null && worst !== undefined && worst < v;
275
+ console.log(` floor ${k}: ${got} ${k === 'falseAlarms' ? '≤' : '≥'} ${v} ${!ok ? 'BELOW THE FLOOR' : onlySkipped ? `NOT A PASS — met only because rows did not run (worst case ${worst})` : 'ok'}`);
267
276
  if (!ok) rc = 1;
277
+ if (onlySkipped) skippedPass = true;
268
278
  }
279
+ // 3: no floor failed on what ran, but one held only because the cap skipped rows. Not 0, and not a measured 1.
280
+ if (rc === 0 && skippedPass) rc = 3;
269
281
  }
270
282
  return rc;
271
283
  }
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // node prepass.mjs [--root DIR] [--base REF] [--stdout]
6
6
  // default the staged change (git diff --cached), what the next commit will hold
7
- // --base the working tree against REF (a branch under review: --base origin/next)
7
+ // --base the working tree against REF (a branch under review: --base origin/next), and every untracked,
8
+ // not ignored file as added in full: git diff never lists a file git does not track
8
9
  // --stdout print the document, write nothing; otherwise .crewforth/review-prepass.json, through findings.mjs
9
10
  // rc 0 = written (or printed), whatever it found · 1 = the document did not verify, nothing written ·
10
11
  // 2 = usage, or not a git repository
@@ -41,6 +42,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
41
42
  export const NAME = 'review-prepass';
42
43
  const LARGE = 500;
43
44
  const QUOTE = 200;
45
+ const MAX_UNTRACKED = 2 * 1024 * 1024; // the size crew-map stops reading at, too
44
46
 
45
47
  const DEBUG = [
46
48
  { files: /\.(m?[jt]sx?|cjs|cts|vue|svelte)$/, re: /\bconsole\.(log|debug|trace)\(|^\s*debugger\s*;?\s*$/, what: 'a console call or debugger statement' },
@@ -63,7 +65,8 @@ const LOCKS = {
63
65
  const GENERATED = /(^|\/)(dist|build|vendor|node_modules|__snapshots__)\/|\.(g|freezed|gr|pb)\.dart$|\.min\.(js|css)$|\.snap$|\.lock$|-lock\.(json|yaml)$/;
64
66
 
65
67
  const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
66
- const git = (root, args) => spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
68
+ // core.fsmonitor= : a review of someone else's branch must not start the program its .git/config names.
69
+ const git = (root, args) => spawnSync('git', ['-C', root, '-c', 'core.fsmonitor=', '-c', 'core.quotePath=false', ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
67
70
 
68
71
  // The added lines of a unified diff with no context: { file, line, text }[] and the files it touches.
69
72
  export function addedLines(diff) {
@@ -95,6 +98,29 @@ export function prepass(root = '.', { base = null } = {}) {
95
98
  const d = git(rootReal, base ? [...diffArgs, base, '--'] : [...diffArgs, '--cached', '--']);
96
99
  if (d.status !== 0) throw new Error(`git diff failed: ${(d.stderr || '').trim().split('\n')[0]}`);
97
100
  const { added, files, binary } = addedLines(d.stdout);
101
+ // Under --base the change is the working tree, and a new file nobody has `git add`ed yet is part of it.
102
+ let untracked = 0; const unread = [];
103
+ if (base) {
104
+ // Paths relative to the root, the way every citation here is read back; a subdirectory root lists its own.
105
+ const u = git(rootReal, ['ls-files', '--others', '--exclude-standard', '-z']);
106
+ if (u.status !== 0) throw new Error(`git ls-files failed: ${(u.stderr || '').trim().split('\n')[0]}`);
107
+ // .crewforth/ holds the tools' own documents (this one included), not the change under review.
108
+ for (const f of u.stdout.split('\0').filter((x) => x && !x.startsWith('.crewforth/')).sort()) {
109
+ // Never followed, never read whole when large, never dropped without a word.
110
+ const skip = (reason) => { untracked++; unread.push({ what: f, reason }); };
111
+ let st; try { st = fs.lstatSync(path.join(rootReal, f)); } catch { skip('untracked, could not be read'); continue; }
112
+ if (st.isSymbolicLink()) { skip('an untracked symbolic link: not followed'); continue; }
113
+ if (!st.isFile()) { skip('untracked, not a regular file'); continue; }
114
+ if (st.size > MAX_UNTRACKED) { skip('untracked, over 2 MiB: not read'); continue; }
115
+ let buf; try { buf = fs.readFileSync(path.join(rootReal, f)); } catch { skip('untracked, could not be read'); continue; }
116
+ untracked++; files.push(f);
117
+ if (buf.subarray(0, 8000).includes(0)) { binary.push(f); continue; }
118
+ const text = buf.toString('utf8').split('\n');
119
+ if (text.length > 1 && text[text.length - 1] === '') text.pop();
120
+ text.forEach((t, i) => added.push({ file: f, line: i + 1, text: t.replace(/\r$/, '') }));
121
+ }
122
+ files.sort(); binary.sort();
123
+ }
98
124
 
99
125
  const doc = { schema: SCHEMA_ID, producer: { name: NAME, version: '1' }, findings: [], notMeasured: [] };
100
126
  const seen = new Set();
@@ -114,6 +140,7 @@ export function prepass(root = '.', { base = null } = {}) {
114
140
  const lines = added.filter((a) => { const t = linesOf(a.file); if (t && t[a.line - 1] === a.text) return true; stale.add(a.file); return false; });
115
141
  for (const f of [...stale].sort()) doc.notMeasured.push({ what: f, reason: 'edited again after it was staged: its staged lines are not the ones on disk, so they were not read' });
116
142
  for (const f of binary) doc.notMeasured.push({ what: f, reason: 'a binary file: no line to read' });
143
+ doc.notMeasured.push(...unread);
117
144
 
118
145
  // ---- secrets: the pre-commit gate's list, minus the project's allowlist ----
119
146
  let allow = new Set();
@@ -172,6 +199,8 @@ export function prepass(root = '.', { base = null } = {}) {
172
199
  }
173
200
  }
174
201
  doc.findings.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
202
+ // Reported on the summary line, not in the document: the findings schema has no field for it.
203
+ Object.defineProperty(doc, 'untracked', { value: untracked, enumerable: false });
175
204
  return doc;
176
205
  }
177
206
 
@@ -188,7 +217,7 @@ function main(argv) {
188
217
  let doc;
189
218
  try { doc = prepass(root, { base }); } catch (e) { console.error(`prepass: ${e.message}`); return 2; }
190
219
  const count = (s) => doc.findings.filter((f) => f.severity === s).length;
191
- const line = `review-prepass: ${doc.findings.length} finding(s) · high ${count('high')} · medium ${count('medium')} · low ${count('low')} · info ${count('info')} · ${doc.notMeasured.length} not measured`;
220
+ const line = `review-prepass: ${doc.findings.length} finding(s) · high ${count('high')} · medium ${count('medium')} · low ${count('low')} · info ${count('info')} · ${doc.notMeasured.length} not measured${base ? ` · ${doc.untracked} untracked read` : ''}`;
192
221
  if (stdout) {
193
222
  const r = validate(doc, root);
194
223
  if (!r.ok) { for (const p of r.problems) console.error(`prepass: ${p.at} ${p.code}: ${p.message}`); return 1; }
@@ -17,6 +17,11 @@
17
17
  # awk -v op=overlay -v key=K [-v setk=M -v setv=RAWJSON] -f settings-json.awk FILE1 FILE2
18
18
  # FILE1 with FILE1.K shallow-merged from FILE2.K (FILE2 wins, FILE1's other members and order kept), then
19
19
  # member M set to RAWJSON. rc 10 = FILE1 (or its K) is not an object, 11 = FILE2 has no object at K.
20
+ # awk -v op=hookcheck -f settings-json.awk FILE the "hooks" block holds the shape Claude Code accepts: an object of
21
+ # arrays, each entry an object whose "matcher" (when present) is a string and whose "hooks" is an array of
22
+ # objects with a string "type", and a string "command" when the type is "command". Claude Code discards the
23
+ # WHOLE file on a mismatch, every gate with it. rc 0 = fine (or no "hooks"), 1 = one problem per line on
24
+ # stdout, as hooks.EVENT.N[.hooks.M]: what is wrong, 10 = FILE is not a JSON object.
20
25
  # awk -v op=merge -v retired='R1|R2' -f settings-json.awk KIT PROJECT > OUT
21
26
  # rc 0 = merged JSON on stdout · 10 = PROJECT is not a JSON object · 11 = KIT is not · 12 = "hooks" has a
22
27
  # shape the merge cannot place (not an object of arrays). Nothing is printed on a non-zero rc. The codes sit
@@ -134,6 +139,19 @@ END{
134
139
  print (it && !isnull(it) ? V[it] : 0) + V[cr] + (cc && !isnull(cc) ? V[cc] : 0); exit 0 }
135
140
  exit 0 }
136
141
  if(op=="validate") exit !doc(1)
142
+ if(op=="hookcheck"){ if(!(r=doc(1))) exit 10; h=get(r,"\"hooks\""); if(!h) exit 0; bad=0
143
+ if(T[h]!="o"){ print "hooks: not an object"; exit 1 }
144
+ for(i=1;i<=N[h];i++){ ev=substr(K[h,i],2,length(K[h,i])-2); a=C[h,i]
145
+ if(T[a]!="a"){ print "hooks." ev ": not an array"; bad=1; continue }
146
+ for(j=1;j<=N[a];j++){ e=C[a,j]; w="hooks." ev "." (j-1)
147
+ if(T[e]!="o"){ print w ": not an object"; bad=1; continue }
148
+ m=get(e,"\"matcher\""); if(m && !(T[m]=="s" && substr(V[m],1,1)=="\"")){ print w ": matcher is not a string"; bad=1 }
149
+ hs=get(e,"\"hooks\""); if(!hs || T[hs]!="a"){ print w ": no \"hooks\" array"; bad=1; continue }
150
+ for(k=1;k<=N[hs];k++){ o=C[hs,k]; ww=w ".hooks." (k-1)
151
+ if(T[o]!="o"){ print ww ": not an object"; bad=1; continue }
152
+ t=get(o,"\"type\""); if(!t || !(T[t]=="s" && substr(V[t],1,1)=="\"")){ print ww ": no string \"type\""; bad=1; continue }
153
+ if(V[t]=="\"command\""){ c=get(o,"\"command\""); if(!c || !(T[c]=="s" && substr(V[c],1,1)=="\"")){ print ww ": type command without a string \"command\""; bad=1 } } } } }
154
+ exit bad }
137
155
  # gets: several paths in one read (path = "a.b|c.d"), their values on one line, tab-separated; an absent one is
138
156
  # empty. One process for what a hot caller (the status line) would otherwise read with one awk per field.
139
157
  if(op=="gets"){ if(!(r=doc(1))) exit 10; n=split(path,pp,"|"); o=""
@@ -0,0 +1,38 @@
1
+ # untrusted-text.awk — make text other people wrote safe to put into a model's context, line by line.
2
+ #
3
+ # LC_ALL=C awk -f untrusted-text.awk < text
4
+ #
5
+ # Text a hook injects (a teammate's board title, a decision's author) reaches the model with the authority of
6
+ # the hook that carried it. This does not decide what the text means; it removes what can hide or disguise it:
7
+ # - C0 controls other than tab, DEL, and the C1 controls (U+0080-U+009F), which a terminal or a renderer acts on;
8
+ # - zero-width and direction marks: U+200B-U+200F, U+202A-U+202E, U+2066-U+2069 (a bidi override shows a line
9
+ # in an order other than the order the model reads it in);
10
+ # - `<` and `>`, so no line can open or close a tag-shaped block, which become ‹ and ›;
11
+ # - a line that starts with `#`, three backticks or `---`, which could pass for a heading, a fence or a frontmatter
12
+ # break, gets a leading `| `.
13
+ # Run under LC_ALL=C: every pattern is a byte sequence of UTF-8, so the result does not depend on the locale.
14
+ BEGIN {
15
+ bad = ""
16
+ # U+200B-U+200F: E2 80 8B..8F · U+202A-U+202E: E2 80 AA..AE · U+2066-U+2069: E2 81 A6..A9
17
+ for (b = 139; b <= 143; b++) bad = bad (bad == "" ? "" : "|") sprintf("%c%c%c", 226, 128, b)
18
+ for (b = 170; b <= 174; b++) bad = bad "|" sprintf("%c%c%c", 226, 128, b)
19
+ for (b = 166; b <= 169; b++) bad = bad "|" sprintf("%c%c%c", 226, 129, b)
20
+ # C1 controls as UTF-8: C2 80..9F
21
+ for (b = 128; b <= 159; b++) bad = bad "|" sprintf("%c%c", 194, b)
22
+ ctl = ""
23
+ for (b = 1; b <= 31; b++) if (b != 9) ctl = ctl (ctl == "" ? "" : "|") sprintf("%c", b)
24
+ ctl = ctl "|" sprintf("%c", 127)
25
+ lt = sprintf("%c%c%c", 226, 128, 185) # ‹
26
+ gt = sprintf("%c%c%c", 226, 128, 186) # ›
27
+ }
28
+ {
29
+ sub(/\r$/, "")
30
+ # Control bytes first, then the marks until none is left: removing one can join the bytes around it into
31
+ # another (E2 01 80 AE, or E2 · E2 80 8B · 80 AE, both become U+202E after one pass).
32
+ gsub(ctl, "")
33
+ while (gsub(bad, "") > 0) ;
34
+ gsub(/</, lt); gsub(/>/, gt)
35
+ t = $0; sub(/^[ \t]+/, "", t)
36
+ if (t ~ /^#/ || t ~ /^```/ || t ~ /^---/) $0 = "| " $0
37
+ print
38
+ }
@@ -15,6 +15,8 @@
15
15
  # are different answers, and only one of them belongs on a security surface.
16
16
  # Usage: bash scan-skill.sh [path] (default: .claude)
17
17
  set -uo pipefail
18
+ # The C locale, so the byte patterns below are matched as bytes and `-i` folds ASCII only (as in every other gate).
19
+ export LC_ALL=C
18
20
  TARGET="${1:-.claude}"
19
21
 
20
22
  # --- pattern groups (ERE) ------------------------------------------------------------------------------------
@@ -31,11 +33,51 @@ CRIT='(curl|wget|fetch)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|python
31
33
  # an ordinary outbound URL stays LOW: measured against Crewforth's own payload, this flags 0 of 59 files, and the
32
34
  # two places Crewforth itself names WebFetch (a `tools:` line and a `Requires-tool` marker) are untouched.
33
35
  HIGH='ignore[[:space:]]+(all[[:space:]]+)?(the[[:space:]]+)?(previous|prior|above)[[:space:]]+(instruction|prompt)|disregard[[:space:]]+(the[[:space:]]+|all[[:space:]]+)?(previous|above|prior)|ignore[[:space:]]+your[[:space:]]+(system[[:space:]]+)?(prompt|instruction)|(cat|less|more|tail|head|base64|xxd|od|strings|curl|wget|scp|cp|rsync)[^|]*(~/\.ssh|id_rsa|/etc/(passwd|shadow)|\.aws/credentials|\.netrc|\.git-credentials)|(~/\.ssh|id_rsa|/etc/(passwd|shadow)|\.aws/credentials|\.netrc|\.git-credentials)[^|]{0,80}(curl|wget|scp|nc |netcat|base64|exfiltrat)|base64[[:space:]]+-[A-Za-z]*d[^|]*\||(curl|wget)[[:space:]][^|]*https?://[^[:space:]]*\.(md|txt|ya?ml|json)|(WebFetch|web_fetch)[^|]{0,120}(instruction|prompt|then follow|steps to follow)'
36
+ # A HIDDEN INSTRUCTION in an HTML comment: it renders as nothing in a Markdown preview and in a PR's rich diff, and the
37
+ # model reads it like any other line. Only a comment that OPENS on an imperative is flagged, so Crewforth's own
38
+ # machine-readable markers (`<!-- routing-eval …`, `<!-- Requires-tool …`) and explanatory comments stay clean.
39
+ HIGH="$HIGH"'|<!--[[:space:]]*(ignore|disregard|forget|override|run|execute|send|post|upload|fetch|download|install|delete|remove|exfiltrate|you[[:space:]]+(must|should|are|will)|do[[:space:]]+not|always|never)([^a-z]|$)'
40
+ # INVISIBLE TEXT, as UTF-8 bytes (one more grep, in the C locale): Unicode tag characters (U+E0000-E007F, which spell
41
+ # ASCII nobody sees), bidi overrides and isolates (U+202A-202E, U+2066-2069, which reorder what an editor shows), and a
42
+ # zero-width character (U+200B-200D, U+2060, U+FEFF) BETWEEN two letters, where it splits a word a pattern would catch
43
+ # (`ign<ZWSP>ore previous instructions`). A BOM at the start of a file is not between letters and is not flagged.
44
+ # Reported as a count only: printing the decoded text would put the hidden instruction in front of the reader.
45
+ _ZW='('$'\xe2\x80\x8b''|'$'\xe2\x80\x8c''|'$'\xe2\x80\x8d''|'$'\xe2\x81\xa0''|'$'\xef\xbb\xbf'')'
46
+ INVIS=$'\xf3\xa0[\x80\x81]|\xe2\x80[\xaa\xab\xac\xad\xae]|\xe2\x81[\xa6\xa7\xa8\xa9]|[A-Za-z]'"$_ZW"'+[A-Za-z]'
34
47
  # MED: named cloud/CI secret env vars, process.env secret access, chmod 777, code eval/exec.
35
48
  MED='(GITHUB_TOKEN|AWS_SECRET|AWS_ACCESS_KEY|NPM_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|SLACK_TOKEN)|process\.env\.[A-Za-z_]*(TOKEN|SECRET|KEY|PASSWORD)|chmod[[:space:]]+(-R[[:space:]]+)?0?777|[^a-zA-Z](eval|exec)[[:space:]]*\('
36
49
  # LOW: an outbound URL fetch or a raw socket — common in benign deploy/example prose, hence low weight.
37
50
  LOW='(^|[^a-zA-Z])(curl|wget)[[:space:]]+[^|]*https?://|/dev/tcp/|[^a-zA-Z]nc[[:space:]]+-[A-Za-z]*e'
38
51
 
52
+ # FRONTMATTER: what a component asks the HOST for, read from its YAML header — one awk over all files. A `hooks:` or
53
+ # `mcpServers:` key (it brings its own code to run), a `permissionMode:` other than default/plan/manual, and an
54
+ # `allowed-tools:` entry that is a bare `Bash` or a `Bash(…)` rule with `*` in it (any command, or any arguments).
55
+ # The one `*` rule not counted is a call of the component's OWN script: `Bash(bash ${CLAUDE_SKILL_DIR}/…)` reaches
56
+ # only files that ship inside the skill, and the scan already reads those. Anywhere in the file, a `!`cmd`` line
57
+ # counts too: it runs when the component loads, before anyone reads it.
58
+ FM_AWK='
59
+ function tools(v, n, m, inner) {
60
+ n = 0; sub(/^[[:space:]]*-[[:space:]]*/, "", v)
61
+ if (v ~ /(^|[^A-Za-z])Bash([^A-Za-z(]|$)/) n++
62
+ if (v ~ /(^|[,[:space:]])\*([,[:space:]]|$)/) n++
63
+ while (match(v, /Bash\([^)]*\)/)) {
64
+ inner = substr(v, RSTART + 5, RLENGTH - 6); v = substr(v, RSTART + RLENGTH)
65
+ if (inner ~ /\*/ && inner !~ /^((bash|sh)[[:space:]]+)?\$\{CLAUDE_SKILL_DIR\}\//) n++
66
+ }
67
+ return n }
68
+ FNR == 1 { if (NR > 1) print f ":" c; f = FILENAME; c = 0; key = ""; sub(/^\357\273\277/, ""); fm = ($0 ~ /^---[[:space:]]*$/); if (fm) next }
69
+ fm && /^---[[:space:]]*$/ { fm = 0; next }
70
+ fm {
71
+ if ($0 ~ /^[A-Za-z]/) { key = $0; sub(/:.*/, "", key) }
72
+ if ($0 ~ /^(hooks|mcpServers):/) c++
73
+ if ($0 ~ /^permissionMode:/) { v = $0; sub(/^permissionMode:/, "", v); gsub(/["\047[:space:]]/, "", v)
74
+ if (v != "" && v != "default" && v != "plan" && v != "manual") c++ }
75
+ if (key == "allowed-tools") { v = $0; sub(/^allowed-tools:/, "", v); c += tools(v) }
76
+ }
77
+ /(^|[[:space:]])!`[^`]+`/ { c++ }
78
+ END { if (NR > 0) print f ":" c }'
79
+ fm_counts(){ awk "$FM_AWK" "$@" 2>/dev/null; return 0; } # `path:count` per non-empty file, in the order given
80
+
39
81
  FAIL=0; N=0
40
82
  # The score ranks; the SEVERITY decides. A single HIGH costs 10 points and lands on exactly 90 — the SAFE line —
41
83
  # so one credential-exfil line or one prompt-injection directive used to pass as safe on arithmetic alone. That
@@ -81,36 +123,38 @@ emit(){ # prints one line per file, in FILES order, from the four count a
81
123
  while [ "$i" -lt "${#FILES[@]}" ]; do
82
124
  f="${FILES[$i]}"
83
125
  c="${CNT_C[$i]:-0}"; h="${CNT_H[$i]:-0}"; m="${CNT_M[$i]:-0}"; l="${CNT_L[$i]:-0}"
126
+ report "$f" "$c" "$h" "${CNT_I[$i]:-0}" "${CNT_F[$i]:-0}" "$m" "$l"
84
127
  i=$((i+1))
85
- score=$((100 - c*20 - h*10 - m*3 - l*1)); [ "$score" -lt 0 ] && score=0
86
- v="$(verdict "$score" "$c" "$h")"
87
- N=$((N+1))
88
- case "$v" in
89
- SAFE) printf ' ✅ %-3s %s\n' "$score" "$f" ;;
90
- REVIEW) printf ' ⚠️ %-3s %s (crit:%s high:%s med:%s low:%s) — REVIEW\n' "$score" "$f" "$c" "$h" "$m" "$l"; FAIL=1 ;;
91
- DANGER) printf ' ❌ %-3s %s (crit:%s high:%s med:%s low:%s) — DANGER\n' "$score" "$f" "$c" "$h" "$m" "$l"; FAIL=1 ;;
92
- esac
93
128
  done
94
129
  }
95
130
 
131
+ # One file's line. Invisible text and frontmatter requests score as HIGH and are named beside the counts: the reader
132
+ # cannot see the first in an editor, and should not have to guess which header line the second is.
133
+ report(){ # $1 file, $2 crit, $3 high, $4 invisible, $5 frontmatter, $6 med, $7 low
134
+ local f="$1" c="$2" h=$(( $3 + $4 + $5 )) m="$6" l="$7" score v x=""
135
+ [ "$4" -gt 0 ] && x="$x · invisible characters: $4"
136
+ [ "$5" -gt 0 ] && x="$x · frontmatter requests: $5"
137
+ score=$((100 - c*20 - h*10 - m*3 - l*1)); [ "$score" -lt 0 ] && score=0
138
+ v="$(verdict "$score" "$c" "$h")"
139
+ N=$((N+1))
140
+ case "$v" in
141
+ SAFE) printf ' ✅ %-3s %s\n' "$score" "$f" ;;
142
+ REVIEW) printf ' ⚠️ %-3s %s (crit:%s high:%s med:%s low:%s%s) — REVIEW\n' "$score" "$f" "$c" "$h" "$m" "$l" "$x"; FAIL=1 ;;
143
+ DANGER) printf ' ❌ %-3s %s (crit:%s high:%s med:%s low:%s%s) — DANGER\n' "$score" "$f" "$c" "$h" "$m" "$l" "$x"; FAIL=1 ;;
144
+ esac
145
+ }
146
+
96
147
  # A single file keeps the old path: grep prints a bare count with one argument, so the batch parser would have to
97
148
  # special-case it anyway, and one file means one process either way.
98
149
  scan_file(){
99
150
  local f="$1"
100
- local c h m l
151
+ local c h m l iv fm
101
152
  # grep -c prints the count (0 on no match) and exits 1 on no match; that non-zero exit is fine here (no set -e),
102
153
  # and a `|| echo 0` would DOUBLE the printed 0 and break the arithmetic — so don't add one.
103
154
  c=$(grep -icE "$CRIT" "$f" 2>/dev/null); h=$(grep -icE "$HIGH" "$f" 2>/dev/null)
104
155
  m=$(grep -icE "$MED" "$f" 2>/dev/null); l=$(grep -icE "$LOW" "$f" 2>/dev/null)
105
- c=${c:-0}; h=${h:-0}; m=${m:-0}; l=${l:-0}
106
- local score=$((100 - c*20 - h*10 - m*3 - l*1)); [ "$score" -lt 0 ] && score=0
107
- local v; v="$(verdict "$score" "$c" "$h")"
108
- N=$((N+1))
109
- case "$v" in
110
- SAFE) printf ' ✅ %-3s %s\n' "$score" "$f" ;;
111
- REVIEW) printf ' ⚠️ %-3s %s (crit:%s high:%s med:%s low:%s) — REVIEW\n' "$score" "$f" "$c" "$h" "$m" "$l"; FAIL=1 ;;
112
- DANGER) printf ' ❌ %-3s %s (crit:%s high:%s med:%s low:%s) — DANGER\n' "$score" "$f" "$c" "$h" "$m" "$l"; FAIL=1 ;;
113
- esac
156
+ iv=$(grep -cE "$INVIS" "$f" 2>/dev/null); fm=$(fm_counts "$f"); fm="${fm##*:}"
157
+ report "$f" "${c:-0}" "${h:-0}" "${iv:-0}" "${fm:-0}" "${m:-0}" "${l:-0}"
114
158
  }
115
159
 
116
160
  echo "== skill/agent security scan: $TARGET =="
@@ -141,12 +185,15 @@ elif [ -d "$TARGET" ]; then
141
185
  ST="$(mktemp -d)"
142
186
  counts_of "$CRIT" "$ST/c"; counts_of "$HIGH" "$ST/h"
143
187
  counts_of "$MED" "$ST/m"; counts_of "$LOW" "$ST/l"
188
+ grep -cHE "$INVIS" -- "${FILES[@]}" > "$ST/i" 2>/dev/null; fm_counts "${FILES[@]}" > "$ST/f"
144
189
  read_counts "$ST/c" CNT_C; NC=$RC_N; read_counts "$ST/h" CNT_H; NH=$RC_N
145
190
  read_counts "$ST/m" CNT_M; NM=$RC_N; read_counts "$ST/l" CNT_L; NL=$RC_N
191
+ read_counts "$ST/i" CNT_I; NI=$RC_N; read_counts "$ST/f" CNT_F; NF=$RC_N
146
192
  rm -rf "$ST"
147
193
  # If grep skipped a file (unreadable, vanished mid-run) the rows no longer line up with FILES, and a silently
148
- # misaligned count would score the wrong file. Fall back to the per-file path rather than report a guess.
149
- if [ "$NC" = "${#FILES[@]}" ] && [ "$NH" = "$NC" ] && [ "$NM" = "$NC" ] && [ "$NL" = "$NC" ]; then
194
+ # misaligned count would score the wrong file. Fall back to the per-file path rather than report a guess. awk
195
+ # prints nothing for an empty file, so one empty file sends the whole run down that path: correct, only slower.
196
+ if [ "$NC" = "${#FILES[@]}" ] && [ "$NH" = "$NC" ] && [ "$NM" = "$NC" ] && [ "$NL" = "$NC" ] && [ "$NI" = "$NC" ] && [ "$NF" = "$NC" ]; then
150
197
  emit
151
198
  else
152
199
  for f in "${FILES[@]}"; do scan_file "$f"; done