diff --git a/.claude/memory/decisions.md b/.claude/memory/decisions.md index d02ae3b..9eea649 100644 --- a/.claude/memory/decisions.md +++ b/.claude/memory/decisions.md @@ -1215,6 +1215,13 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate). - **Reference**: `lib/gitflow.sh`, `lib/gitflow-test.sh` T22/T23, `githooks/reference-transaction`, `.githooks/reference-transaction`, `settings.json`, `doctor.sh`, `skills/gitflow/SKILL.md`, `CLAUDE.global.md`, `templates/settings/SETTINGS.md`. Extends [[BDR-095]]; links [[LRN-161]], [[LRN-114]]. - **Amendment 2026-09-24 (user go: "nettoie aussi les branches distantes une fois mergées")**: `_gitflow_delete_remote` runs after the local delete — `ls-remote --exit-code` reads the remote tip, `gitflow_merged_into_base ` re-checks it (unknown or unmerged sha → remote copy KEPT, loud), then `push origin --delete`. Best effort like the pushes: no origin / `GITFLOW_NO_PUSH=1` / `gitflow.autopush false` → skip; unreachable or refused → "NOT removed" + the hand command, rc 0. Explicit protected-base guard inside the helper too. Static deny on hand `git push --delete` UNCHANGED: it matches the Bash tool's command string, the lib's sub-process is the sanctioned path (prose says so). Rejected: making a failed remote delete fail `finish` (merge done, local gone → nothing to roll back; loud is enough); deleting without re-checking the remote tip (a push from another clone would be lost). T24 9/9, suite 161/163 (2 pre-existing T16a). Live: origin/feature/branch-delete-guard + origin/feature/destructive-guardrails removed by `gitflow.sh delete` (both tips verified merged), bases untouched. On feature/remote-branch-cleanup, UNMERGED (human gate). +## BDR-097 — graphify from 200 tracked code files: the banner informs, the user decides +- **Date**: 2026-09-24 +- **Decision**: deterministic threshold, not AI judgment. `lib/graphify-gate.sh`: `git ls-files` code extensions (graphify's AST set), vendored trees (`vendor|node_modules|third_party|dist|build`) excluded, ≥ 200 AND no `graphify-out/graph.json` → one banner-sized line `graphify? N code files ≥ 200, no graph` + `→ /graphify (AST, seconds) — you decide` in session-start. Nothing built, installed or updated by the hook. Doctrine: CLAUDE.global.md graphify § carries the threshold + "never `graphify claude install` without a go"; plugin-advisor thresholds stop pre-enabling graphify at scaffold time. `GRAPHIFY_MIN_CODE_FILES` overrides (tests). +- **Why**: user question "when is graphify worth it, can the AI suggest it, even set it up". Measured first ([[LRN-162]]): value = localisation (who calls what), not editing (the file is read anyway); code-only build is free (AST), docs cost session tokens; a query costs 2-3k tokens ≈ two file reads. Below ~200 files grep beats the graph. User's own words: "tu informes, je décide" — an AI-estimated trigger is judgment (irreproducible, invisible when silent), a count is a rule. Fits `graphify-out/` being a per-project write the user owns. +- **Alternatives rejected**: AI "estimates the project needs graphify" → not reproducible. Auto-build at threshold → writes ~8 MB into the project, user's decision. Interconnection metric (import graph density) → needs the graph itself to compute; file count is the honest proxy. Post-commit `graphify update` in the gitflow hooks → deferred to a pilot (user picked the inform-only compromise); note `update` refuses a smaller graph without `--force`, and `graphify hook install` is inert under the global `core.hooksPath`. `graphify claude install` → rejected again (PreToolUse nudges on every Read/Glob = the context tax, [[BDR-028]]). +- **Status**: accepted, on feature/graphify-threshold-banner, UNMERGED (human gate). Test 11/11, shellcheck clean; live: this repo silent (74 files), robin_petier fires (214). +- **Reference**: `lib/graphify-gate.sh`, `lib/tests/graphify-gate.test.sh`, `hooks/session-start.sh`, `CLAUDE.global.md` § graphify, `agents/plugin-advisor.md`, CHANGELOG. Links [[LRN-162]], [[BDR-028]], [[BDR-021]] (conditional graphify rules). ## BDR-098 — CLAUDE.global.md density pass 352 → 270: compression only, three name-obvious routing lines dropped - **Date**: 2026-09-24 - **Decision**: user go "fais la passe de densité". [[BDR-031]] principle kept (compression, no path-scoping, no externalization, no caveman); [[BDR-062]]'s 320 guard kept. Method: prose tightened section by section, blank lines after headings removed, the 6 classic Security subsections folded into one bold-labelled bullet list (`### Destructive tools & data loss` kept as a heading, referenced from Workflow), Session-start / Planning / After-code numbered lists collapsed, Memory-registries prose rewritten (routing list → one sentence, language + format paragraphs merged, close ritual → one sentence), radical-honesty tenets paired two per bullet, gitflow paragraphs re-flowed. Deliberately dropped: routing lines `release-candidate`, `audit-delta`, `init-project`/`onboard` (name-obvious, the skill descriptions carry them — BDR-031's own criterion), rationale clauses (why English, why caveman), `~/.claude/githooks` literal, `T22a` cite, pa11y/HTML-CSS detail in the web-validate line. Every `##` heading verbatim (`Design work — full toolchain (tiered by scope)` is matched by the design-toolchain hook). graphify section left byte-identical: `feature/graphify-threshold-banner` (unmerged) edits it, a clean merge matters more than 2 lines. diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index 3218a03..bfe1555 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -499,4 +499,5 @@ rules: - feature/branch-delete-guard merged into develop on user go, `gitflow finish` → b2e252e, pushed by the lib + post-merge hook (develop == origin/develop, no hand push). First live run of `gitflow_delete`: branch verified merged → deleted. User asked "push automatically after every merge": already the case since [[BDR-095]] (`_gitflow_merge_into` pushes the target, post-merge hook, T18f) — evidenced, nothing added. Remote `origin/feature/{branch-delete-guard,destructive-guardrails}` remain (`push --delete` denied) — user's call. - User go: remote copy cleaned too. `_gitflow_delete_remote` (tip re-checked against the bases before `push --delete`, best effort, loud KEPT/NOT removed), T24 9 checks, prose + doctrine + SKILL + docs. 161/163. Live run through the lib on the two stale merged remotes: origin/feature/branch-delete-guard + origin/feature/destructive-guardrails removed by `gitflow.sh delete` (both tips verified merged), bases untouched. Branch feature/remote-branch-cleanup UNMERGED — human gate. BDR-096 amended. - feature/remote-branch-cleanup merged into develop on user go, `gitflow finish` → 91859fe, pushed (develop == origin/develop). First `finish` with the remote step live: it removed `origin/feature/remote-branch-cleanup` itself (tip verified merged). origin holds no `feature/*` any more. BDR-096 fully shipped. +- graphify: user asked when it is worth it + whether to automate suggestion/setup/update. Measured on a scratch copy of robin_petier ([[LRN-162]]): AST build 2.3 s / 0 tokens, query 2-3k tokens, `.claude/` noise, SQL grammar missing, `update` refuses smaller graphs, `hook install` inert under global hooksPath. Opinion given: value = localisation not editing; real context eaters are registries + always-on rules. User rule: from 200 code files, inform only ([[BDR-097]]) → `lib/graphify-gate.sh` + session-start banner line + doctrine + advisor, test 11/11. Branch feature/graphify-threshold-banner UNMERGED — human gate. - Density pass on CLAUDE.global.md, user go: 352 → 270 lines, 2694 → 2302 words, compression only ([[BDR-098]]); 3 name-obvious routing lines dropped, every heading kept, graphify section untouched for the pending feature branch. Banner warning gone, tests unchanged. chore/claude-global-density UNMERGED — human gate. diff --git a/.claude/memory/learnings.md b/.claude/memory/learnings.md index 3e3321b..03d65df 100644 --- a/.claude/memory/learnings.md +++ b/.claude/memory/learnings.md @@ -1520,3 +1520,10 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s - **Pattern**: (a) a safety check whose reference point is configurable changes meaning when config moves elsewhere — auto-push broke `-d` with zero diff in the delete code. Verify "merged" explicitly against the NAMED base: `git merge-base --is-ancestor
`. (b) probing a guardrail inline gets blocked BY the guardrail: deny strings (`core.hooksPath`, `GIT_CONFIG_GLOBAL=`, `rm -rf "$VAR"`, `branch -D develop`) are matched in the command text, heredocs included → 4 denials this session. Probe = a test in the suite (file, run via `make test`), the TDD path anyway; file content via the Write tool, command line clean. (c) `reference-transaction` hook: line ` ` in `prepared`; `branch -d` passes an all-zero old oid ("force" semantics) → ref NAME + all-zero NEW is the only reliable deletion signal; a merged check cannot live there. - **Future application**: any change to upstream/push config → re-read every `-d`, `--ff-only`, `@{u}`-relative guard. New destructive capability → static deny + mechanical check + prose, in that order ([[LRN-160]]). Guardrail probes → test file, never inline; a denied probe is the guard working, not a bug to route around. - **Reference**: [[BDR-096]], [[BDR-095]], `lib/gitflow-test.sh` T22a/T23, git-branch(1), githooks(5) reference-transaction. + +## LRN-162 — graphify measured: free AST map, paid semantic pass, 2-3k tokens per query, noise from `.claude/` +- **Date**: 2026-09-24 +- **Context**: user asked whether graphify saves context ("agents re-read the whole codebase per feature"). Built the graph of a scratch copy of robin_petier (PHP, 295 files) with the CLI only: `graphify update .` (no skill pipeline, no LLM). +- **Pattern**: (a) code-only build 2.3 s, 0 tokens, 3141 nodes / 7241 edges / 199 communities; hubs correct without any LLM (Auth, Database, Router, PDO, PHPMailer). Incremental update 1.9 s. `graphify-out/` = 8 MB (graph.json 4.4 + graph.html 3.5) → gitignore it. (b) one `graphify query` ≈ 2000-3000 tokens (default budget 2000, over-budget answers spill; truncation at 70/245 nodes on broad questions) = the price of two file reads; it maps (name, file:line), it does not replace reading the file you edit. Value = localisation, not editing. (c) it indexed `.claude/` (contracts, PROCEDURE.md, registries): an "authentication" query surfaced a mobile-nav contract → `.graphifyignore` (`.claude/`, `docs/superpowers/`; gitignore semantics, can only exclude more). (d) 13 `.sql` files contributed nothing: `tree_sitter_sql` missing → `pipx inject graphifyy "graphifyy[sql]"`. (e) `update` refuses to write a graph with FEWER nodes unless `--force`/`GRAPHIFY_FORCE=1` → after a refactor that deletes code, an automated update goes stale silently. (f) `graphify hook install` targets the repo's hooks dir; under our global `core.hooksPath` it is inert → our generated post-commit hook is the only integration point. (g) `graphify claude install` = PreToolUse nudges on every Read/Glob + CLAUDE.md rewrite — the context tax itself. (h) the semantic pass (docs/papers) runs on the host agent = session tokens; code-only stays free. +- **Future application**: measure a "context saver" before adopting it — build time, artifact size, tokens per use, noise sources. Threshold rule [[BDR-097]]: propose from 200 tracked code files, never below. Pilot recipe when the user says go: `graphify update .` + `.graphifyignore` + gitignore `graphify-out/` + `GRAPHIFY_FORCE=1 graphify update .` in the post-commit hook, guarded by `[ -f graphify-out/graph.json ]`. +- **Reference**: [[BDR-097]], [[BDR-028]], `lib/graphify-gate.sh`, graphify 0.9.65 (`detect.py` `_SKIP_DIRS`, `.graphifyignore`; `hooks.py` core.hooksPath handling; `__main__.py` PreToolUse nudge payloads). diff --git a/.claude/tasks/TODO.md b/.claude/tasks/TODO.md index 49a64eb..6dbcbc6 100644 --- a/.claude/tasks/TODO.md +++ b/.claude/tasks/TODO.md @@ -6,6 +6,25 @@ pending). Dropped on purpose: release-candidate / audit-delta / init-project+onboard routing lines. Vocabulary diff audited: no rule lost. make test unchanged, banner clean, doctor 0 errors. BDR-098. UNMERGED. +## 2026-09-24 — graphify threshold signal: inform from 200 code files, user decides (feature/graphify-threshold-banner) +User: "graphify seulement à partir de 200 fichiers de code… tu informes, je décide". +Grounded in LRN-162 measurements (robin_petier scratch copy: AST 2.3 s, 0 tokens, +query 2-3k tokens, `.claude/` noise). Alternatives rejected in BDR-097. +- [x] G1 `lib/graphify-gate.sh`: tracked code-file count (AST extension set, + vendored trees excluded), ≥ 200 + no graph → one banner-sized line, rc 0; + silent rc 1 otherwise. `GRAPHIFY_MIN_CODE_FILES` override. +- [x] G2 `lib/tests/graphify-gate.test.sh` 11 checks: not-a-repo, 199/200, + graph present, vendored, untracked, override, subdirectory, non-code. +- [x] G3 `hooks/session-start.sh`: compute after the gitflow reconcile, print + after the hooks-refreshed line: `🕸️ graphify? N code files ≥ 200, no graph` + + `→ /graphify (AST, seconds) — you decide`. +- [x] G4 doctrine: CLAUDE.global.md § graphify threshold sentence; plugin-advisor + thresholds no longer pre-enable graphify; CHANGELOG. +- [x] G5 BDR-097, LRN-162, journal. shellcheck clean. Live: this repo silent (74), + robin_petier fires (214). UNMERGED — human gate. +Pilot (not started, user's call): robin_petier graph + `.graphifyignore` + +gitignore `graphify-out/` + `GRAPHIFY_FORCE=1 graphify update .` in the +gitflow post-commit hook when a graph exists. ## 2026-09-24 — branch deletion guard: never main/develop, never unmerged (feature/branch-delete-guard) User rule (after the 21/09 wipe, same family as BDR-095): auto-delete of a branch diff --git a/CHANGELOG.md b/CHANGELOG.md index b937534..8077537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- **graphify threshold signal** — `lib/graphify-gate.sh` counts tracked code + files (vendored trees excluded) and, from 200 with no + `graphify-out/graph.json`, the session-start banner shows one line + (`graphify? N code files ≥ 200, no graph`) plus the `/graphify` hint. It + informs, the user decides: nothing is built or installed. Doctrine and the + plugin-advisor thresholds follow the same rule; measured on a 295-file PHP + project: AST build 2.3 s, zero LLM tokens, one query 2 to 3k tokens. + Test `lib/tests/graphify-gate.test.sh` (11 checks). - **Branch deletion guard** — `gitflow_delete` (also `gitflow.sh delete `) is the only path that deletes a branch: it refuses `main` and `develop` (rc 6) and any branch not merged into develop or main (rc 5), diff --git a/CLAUDE.global.md b/CLAUDE.global.md index 74954d6..724d651 100644 --- a/CLAUDE.global.md +++ b/CLAUDE.global.md @@ -261,8 +261,10 @@ CLI (`npm i -g @21st-dev/cli`, `21st login`), no MCP, no key; search free, ## graphify -ALL rules apply only if `graphify-out/graph.json` exists — else read files -directly. +Threshold: graphify from 200 tracked code files, never below (banner line +`graphify? N code files ≥ 200, no graph` informs, the user decides; never +build or `graphify claude install` without that go). ALL rules below apply +only if `graphify-out/graph.json` exists — else read files directly. - Codebase-wide question → `graphify query`; relationships → `path A B`; concept → `explain`. Scoped subgraph beats raw grep. - Known file / small task → read directly, no graphify. diff --git a/agents/plugin-advisor.md b/agents/plugin-advisor.md index 5388a5d..5ff0df1 100644 --- a/agents/plugin-advisor.md +++ b/agents/plugin-advisor.md @@ -79,9 +79,9 @@ Factors (weighted): **Score thresholds:** - **0-30% (simple)**: superpowers only. No gstack, no gsd, no ctx7, no graphify. _Examples: site vitrine, landing page, script CLI, simple CRUD._ -- **30-60% (moderate)**: + context7 if fast-libs, + graphify after implementation. +- **30-60% (moderate)**: + context7 if fast-libs. graphify only once the codebase passes 200 tracked code files (session-start banner informs, the user decides — BDR-097), never at scaffold. _Examples: blog with auth, dashboard with charts, API with validation._ -- **60-85% (complex)**: + gstack if browser-QA, + gsd if multi-session, + graphify both passes. +- **60-85% (complex)**: + gstack if browser-QA, + gsd if multi-session. graphify: same 200-file rule, likely reached — say so, do not pre-enable. _Examples: SaaS with billing, game with social features, e-commerce._ - **85-100% (enterprise)**: all tools justified. _Examples: multi-service platform, real-time collab app, marketplace._ diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 029fafc..4bc1c2e 100644 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -55,6 +55,14 @@ if [ -f "$_gf_lib" ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; the fi unset _gf_lib +# ── graphify threshold signal (BDR-097) ── +# Informs, never acts: one banner line when the repo holds ≥ 200 tracked code +# files and no graph. The user decides whether to build one. +GRAPHIFY_HINT="" +_gg_lib="$(dirname "${BASH_SOURCE[0]}")/../lib/graphify-gate.sh" +if [ -f "$_gg_lib" ]; then GRAPHIFY_HINT=$(bash "$_gg_lib" "$PWD" 2>/dev/null); fi +unset _gg_lib + # ── Toggle plugin detection ── TOGGLE_ACTIVE=() @@ -215,6 +223,10 @@ if [ -n "$GF_REFRESHED" ]; then printf "│ 🪝 %-44s│\n" "${_gf_line:0:44}" unset _gf_line fi +if [ -n "$GRAPHIFY_HINT" ]; then + printf "│ 🕸️ %-44s│\n" "${GRAPHIFY_HINT:0:44}" + printf "│ %-40s│\n" "→ /graphify (AST, seconds) — you decide" +fi # CLAUDE.global.md line-count guard (anti-regression). BDR-062 supersedes # BDR-031's 275 target: 305 is the assumed reality (extraction done at # job1; further compression costs clarity > token gain) — warn past 320. diff --git a/lib/graphify-gate.sh b/lib/graphify-gate.sh new file mode 100644 index 0000000..ba858f0 --- /dev/null +++ b/lib/graphify-gate.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# graphify-gate.sh — deterministic "propose graphify" signal (BDR-097). +# +# Rule (user, 2026-09-24): graphify only from 200 tracked code files. Below, +# grep + read is cheaper than a graph. The signal INFORMS, the user DECIDES: +# nothing here builds, installs or updates a graph. +# +# Sourced (functions) or executed: `graphify-gate.sh [dir]` prints one short +# line (banner-sized) and exits 0 when 's repo passes the threshold and has +# no graphify-out/graph.json; silent, rc 1 otherwise. GRAPHIFY_MIN_CODE_FILES +# overrides the threshold (tests). + +GRAPHIFY_MIN_CODE_FILES="${GRAPHIFY_MIN_CODE_FILES:-200}" +# Extensions graphify extracts by AST (tree-sitter): the proxy for "code file". +GRAPHIFY_CODE_EXT='py|js|mjs|cjs|ts|tsx|jsx|vue|svelte|astro|php|go|rs|java|kt|c|h|cpp|hpp|cc|cs|rb|swift|scala|sh|bash|lua|sql' +# Vendored trees sometimes committed; never the project's own code. +GRAPHIFY_VENDOR_DIRS='vendor|node_modules|third_party|dist|build' + +# graphify_code_file_count [dir] → tracked code files, vendored trees excluded. +# Tracked only (git ls-files): gitignored deps and build output never count. +graphify_code_file_count() { + git -C "${1:-.}" ls-files 2>/dev/null \ + | grep -v -E "(^|/)($GRAPHIFY_VENDOR_DIRS)/" \ + | grep -E -c "\.($GRAPHIFY_CODE_EXT)$" +} + +# graphify_gate [dir] → "graphify? N code files ≥ T, no graph" + rc 0 when the +# repo passes the threshold without a graph; silent rc 1 otherwise. +graphify_gate() { + local root n + root=$(git -C "${1:-.}" rev-parse --show-toplevel 2>/dev/null) || return 1 + [ -f "$root/graphify-out/graph.json" ] && return 1 # graph exists — nothing to propose + n=$(graphify_code_file_count "$root") + [ "$n" -ge "$GRAPHIFY_MIN_CODE_FILES" ] || return 1 + printf 'graphify? %s code files ≥ %s, no graph\n' "$n" "$GRAPHIFY_MIN_CODE_FILES" +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + set -uo pipefail + graphify_gate "${1:-.}" +fi diff --git a/lib/tests/graphify-gate.test.sh b/lib/tests/graphify-gate.test.sh new file mode 100644 index 0000000..221d2fb --- /dev/null +++ b/lib/tests/graphify-gate.test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# lib/tests/graphify-gate.test.sh — "propose graphify" threshold signal (BDR-097). +set -u +LIB="$(cd "$(dirname "$0")/../.." && pwd)/lib/graphify-gate.sh" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +pass=0; fail=0 +check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); + printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; } +# gate(dir) -> the signal line, or "silent" +gate() { bash "$LIB" "$1" 2>/dev/null || echo silent; } +has() { case "$1" in *"$2"*) echo yes ;; *) echo no ;; esac; } +# mkrepo [] +mkrepo() { + local d="$WORK/$1" i + git init -q "$d"; git -C "$d" config user.email t@t; git -C "$d" config user.name t + git -C "$d" config core.hooksPath /dev/null + for i in $(seq 1 "$2"); do echo " "$d/f$i.php"; done + if [ "${3:-0}" -gt 0 ]; then + mkdir -p "$d/vendor/lib" + for i in $(seq 1 "$3"); do echo " "$d/vendor/lib/v$i.php"; done + fi + git -C "$d" add -A; git -C "$d" commit -q -m init + echo "$d" +} + +mkdir -p "$WORK/plain" +check T1-not-a-repo "$(gate "$WORK/plain")" silent + +d=$(mkrepo below 199) +check T2-199-files-silent "$(gate "$d")" silent + +d=$(mkrepo at 200) +check T3-200-files-fires "$(has "$(gate "$d")" "200 code files")" yes +check T3b-line-is-banner-sized "$([ "$(gate "$d" | wc -m)" -le 45 ] && echo yes || echo no)" yes +mkdir -p "$d/graphify-out"; echo '{}' > "$d/graphify-out/graph.json" +check T4-graph-exists-silent "$(gate "$d")" silent + +d=$(mkrepo vendored 190 60) +check T5-vendored-not-counted "$(gate "$d")" silent +for i in $(seq 191 200); do echo " "$d/f$i.php"; done +git -C "$d" add -A; git -C "$d" commit -q -m more +check T5b-own-files-reach-200 "$(has "$(gate "$d")" "200 code files")" yes + +d=$(mkrepo untracked 199) +for i in $(seq 200 210); do echo " "$d/f$i.php"; done # left untracked +check T6-untracked-not-counted "$(gate "$d")" silent + +d=$(mkrepo tiny 10) +check T7-threshold-override "$(has "$(GRAPHIFY_MIN_CODE_FILES=5 bash "$LIB" "$d" 2>/dev/null)" "≥ 5")" yes + +d=$(mkrepo subdir 200); mkdir -p "$d/app/sub" +check T8-from-subdirectory "$(has "$(gate "$d/app/sub")" "200 code files")" yes + +d=$(mkrepo docs 10) +for i in $(seq 1 300); do echo "# $i" > "$d/doc$i.md"; done +git -C "$d" add -A; git -C "$d" commit -q -m docs +check T9-non-code-not-counted "$(gate "$d")" silent + +echo "PASS=$pass FAIL=$fail" +[ "$fail" -eq 0 ]