diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3f3f2c7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# ⛔ THE EXECUTABLE ARTIFACTS ARE LF, IN THE WORKING TREE, ON EVERY PLATFORM. +# +# Measured on a Windows 11 / Git Bash install (#502 B3): `scripts/fleet-preflight.sh: +# line 10: $'\r': command not found`. The committed blobs were already LF — but with +# `core.autocrlf=true`, which is the Git-for-Windows DEFAULT, the WORKING TREE is CRLF, +# and bash executes the working tree, not the blob. +# +# ⇒ Two consequences, and the second is why a one-time `dos2unix` is not the fix: +# · the estate's own clone is CRLF for the same reason, so a plain `cp` during +# onboard.md step 2 carries CR into the target repository +# · without this file the NEXT checkout re-breaks it, silently +# +# ⚠ This pins the working tree, not just the blob. `text eol=lf` is what does that; +# `-text` (binary) would also stop conversion but would break diffs. +*.sh text eol=lf +*.py text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/prompts/README.md b/prompts/README.md index df69961..08baf25 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -435,7 +435,7 @@ prints the expected roster as a checklist. It exists because **nine agents asserting they are ready is not the same as nine agents being ready**. The preflight establishes by execution what the agent panes each claim in prose, so the -claims have something to be checked against. It reports and never gates: exit code is always 0. +claims have something to be checked against. It does not gate the fleet — no pane waits on it — but **its exit code carries a verdict**: `0` clean · `1` blocking failures · `2` it could not establish its own. ⚠ This sentence read *"exit code is always 0"* for as long after the change as it took an outside installer to notice (#502 C4); `onboard.md` makes this the acceptance test for an install, and an acceptance test that cannot fail is worse than none. ### What this recipe cannot do diff --git a/scripts/check-goal-conformance.py b/scripts/check-goal-conformance.py index 007afef..95e8be8 100755 --- a/scripts/check-goal-conformance.py +++ b/scripts/check-goal-conformance.py @@ -72,6 +72,20 @@ """ import json, os, re, subprocess, sys +# ⛔ THESE SCRIPTS CRASH EXACTLY WHEN THEY FIND SOMETHING. Windows Python defaults stdout +# to cp1252, and the ⛔/⚠/★ glyphs appear almost only on FAIL branches — so a checker runs +# clean when all is well and dies with UnicodeEncodeError when it detects a defect, and a +# crashed checker reports nothing at all (#502 B4, measured on a Windows 11 install). +# ⇒ errors="replace" rather than a hard switch: a mangled glyph is a legible finding, an +# exception is not. +# ⚠ EACH STREAM GUARDED SEPARATELY. `hasattr(sys.stdout, ...)` says nothing about +# sys.stderr — a harness may replace one and not the other (this repo's own stubbed +# suites capture streams), and the guarded form would then raise AttributeError from +# inside the guard meant to prevent one. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + # The six, from goals/README.md "What a role goal must contain". Each entry is # (label, regex over headings). ⚠ Matched on HEADINGS, not on body text: a file # that merely discusses "reserved actions" in prose has not stated them where every diff --git a/scripts/check-handoff-rows.py b/scripts/check-handoff-rows.py index db95862..3cdf39e 100755 --- a/scripts/check-handoff-rows.py +++ b/scripts/check-handoff-rows.py @@ -61,6 +61,20 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tools")) from runmarker import guard, result # noqa: E402 +# ⛔ THESE SCRIPTS CRASH EXACTLY WHEN THEY FIND SOMETHING. Windows Python defaults stdout +# to cp1252, and the ⛔/⚠/★ glyphs appear almost only on FAIL branches — so a checker runs +# clean when all is well and dies with UnicodeEncodeError when it detects a defect, and a +# crashed checker reports nothing at all (#502 B4, measured on a Windows 11 install). +# ⇒ errors="replace" rather than a hard switch: a mangled glyph is a legible finding, an +# exception is not. +# ⚠ EACH STREAM GUARDED SEPARATELY. `hasattr(sys.stdout, ...)` says nothing about +# sys.stderr — a harness may replace one and not the other (this repo's own stubbed +# suites capture streams), and the guarded form would then raise AttributeError from +# inside the guard meant to prevent one. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + HANDOFF = "docs/HANDOFF.md" # ⇒ The command starts at the first shell head. This is what makes the row splittable diff --git a/scripts/check-onboard.py b/scripts/check-onboard.py index e55f823..dd86908 100755 --- a/scripts/check-onboard.py +++ b/scripts/check-onboard.py @@ -29,6 +29,20 @@ import sys from pathlib import Path +# ⛔ THESE SCRIPTS CRASH EXACTLY WHEN THEY FIND SOMETHING. Windows Python defaults stdout +# to cp1252, and the ⛔/⚠/★ glyphs appear almost only on FAIL branches — so a checker runs +# clean when all is well and dies with UnicodeEncodeError when it detects a defect, and a +# crashed checker reports nothing at all (#502 B4, measured on a Windows 11 install). +# ⇒ errors="replace" rather than a hard switch: a mangled glyph is a legible finding, an +# exception is not. +# ⚠ EACH STREAM GUARDED SEPARATELY. `hasattr(sys.stdout, ...)` says nothing about +# sys.stderr — a harness may replace one and not the other (this repo's own stubbed +# suites capture streams), and the guarded form would then raise AttributeError from +# inside the guard meant to prevent one. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + ROOT = Path(__file__).resolve().parent.parent DOC = ROOT / "onboard.md" RECIPE = ROOT / ".daintree" / "recipes" / "nforma-fleet.json" diff --git a/scripts/check-orientation.py b/scripts/check-orientation.py index bb130e7..0993606 100755 --- a/scripts/check-orientation.py +++ b/scripts/check-orientation.py @@ -26,6 +26,20 @@ import sys from pathlib import Path +# ⛔ THESE SCRIPTS CRASH EXACTLY WHEN THEY FIND SOMETHING. Windows Python defaults stdout +# to cp1252, and the ⛔/⚠/★ glyphs appear almost only on FAIL branches — so a checker runs +# clean when all is well and dies with UnicodeEncodeError when it detects a defect, and a +# crashed checker reports nothing at all (#502 B4, measured on a Windows 11 install). +# ⇒ errors="replace" rather than a hard switch: a mangled glyph is a legible finding, an +# exception is not. +# ⚠ EACH STREAM GUARDED SEPARATELY. `hasattr(sys.stdout, ...)` says nothing about +# sys.stderr — a harness may replace one and not the other (this repo's own stubbed +# suites capture streams), and the guarded form would then raise AttributeError from +# inside the guard meant to prevent one. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + ROOT = Path(__file__).resolve().parent.parent DOC = ROOT / "CLAUDE.md" diff --git a/scripts/check-tools-index.py b/scripts/check-tools-index.py index f95f1a6..b656ea8 100755 --- a/scripts/check-tools-index.py +++ b/scripts/check-tools-index.py @@ -72,6 +72,20 @@ import sys from pathlib import Path +# ⛔ THESE SCRIPTS CRASH EXACTLY WHEN THEY FIND SOMETHING. Windows Python defaults stdout +# to cp1252, and the ⛔/⚠/★ glyphs appear almost only on FAIL branches — so a checker runs +# clean when all is well and dies with UnicodeEncodeError when it detects a defect, and a +# crashed checker reports nothing at all (#502 B4, measured on a Windows 11 install). +# ⇒ errors="replace" rather than a hard switch: a mangled glyph is a legible finding, an +# exception is not. +# ⚠ EACH STREAM GUARDED SEPARATELY. `hasattr(sys.stdout, ...)` says nothing about +# sys.stderr — a harness may replace one and not the other (this repo's own stubbed +# suites capture streams), and the guarded form would then raise AttributeError from +# inside the guard meant to prevent one. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + # ⇒ DEV5 wrote this block for #348; DEVOPS owns the file and this is the import site # offered for review. The shared predicate lives in tools/ so the two guards cannot # disagree about the same file — one module, referenced, never copied. diff --git a/scripts/fleet-preflight.sh b/scripts/fleet-preflight.sh index c535cf8..1ad4a4f 100755 --- a/scripts/fleet-preflight.sh +++ b/scripts/fleet-preflight.sh @@ -6,7 +6,9 @@ # panes will each *claim* in their ROLE-READY line — so those claims have # something to be checked against. # -# Exit code is always 0: this pane reports, it does not gate. +# ⛔ EXIT CODE CARRIES THE VERDICT: 0 clean · 1 blocking failures · 2 the script could +# not establish its own verdict. It said "always 0" here for as long as that was true and +# for a while after it stopped being (#502 C4) — see the reasoning at the Summary section. ROLES=(TEAMLEAD ARCHITECT DEVOPS DX DEV1 DEV2 DEV3 DEV4 DEV5) @@ -31,7 +33,10 @@ else # ⛔ NOT basename "$toplevel" — in a worktree that is the worktree's directory # name ("devops"), not the repository ("nForma-NEXT"). The main tree is always # the first entry of `git worktree list --porcelain`. - main_tree=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') + # ⛔ NOT `awk '{print $2}'` — `--porcelain` puts the path in the REST of the line, so + # field 2 truncates at the first space (#502 C2). Fixed in fleet-worktree.sh first; + # this second site was found by sweeping for the pattern rather than the file. + main_tree=$(git worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p') repo=$(basename "${main_tree:-$toplevel}") branch=$(git branch --show-current 2>/dev/null) ok "repo=$repo branch=${branch:-} — THIS TREE ONLY" @@ -233,7 +238,9 @@ section 'Repository self-checks' # them — and that one was a fixture whose header says "Not run; scanned." A set of # instruments none of which is ever called is a citation network, not a toolchain. # These two are cheap, deterministic, and answer questions no reviewer reliably -# answers by eye. ⚠ This pane still does not gate: exit code is always 0. +# answers by eye. ⚠ This pane does not gate the FLEET, but it DOES carry a verdict in its +# exit code (0/1/2) — a caller may branch on it. #502 C4 found this line and two others +# still claiming "always 0" after the behaviour was deliberately changed. # ⇒ check-handoff-rows.py joins this loop because THE DEFECT IT CATCHES SHIPPED # (#637): a snapshot row named `--by-state`, a flag that prints no totals, so three # correct numbers sat under a command that cannot produce any of them. A review bot diff --git a/scripts/fleet-worktree.sh b/scripts/fleet-worktree.sh index c835cd9..11e7d89 100755 --- a/scripts/fleet-worktree.sh +++ b/scripts/fleet-worktree.sh @@ -25,7 +25,12 @@ set -u ROLES="architect devops dx dev1 dev2 dev3 dev4 dev5" -main_tree=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') +# ⛔ `awk '{print $2}'` TRUNCATES AT THE FIRST SPACE. `--porcelain` emits `worktree ` +# with the path as the REST of the line, not as field 2 — so `C:\Program Files\repo` +# becomes `C:\Program`, silently, and every path derived from it is wrong. Not +# hypothetical on Windows, where that directory is a normal location (#502 C2). +# ⇒ `sed` strips the known prefix and keeps everything after it. +main_tree=$(git worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p') [ -n "$main_tree" ] || { echo "not inside a git repository" >&2; exit 2; } WT_DIR="$main_tree/.claude/worktrees" @@ -52,7 +57,8 @@ WT_DIR="$main_tree/.claude/worktrees" # MISSING no tree at all -> create it where() { git worktree list --porcelain | awk -v want="$WT_DIR/$1" -v role="$1" ' - /^worktree /{ p = $2 + # ⛔ substr, NOT $2 — same truncation as the main_tree parse above, third site. + /^worktree /{ p = substr($0, 10) if (p == want) { found = 1; next } # HEURISTIC, and it is one: a path whose last element contains the role # token is PROBABLY that tree. Nothing binds a worktree to a role, so this diff --git a/scripts/validate-recipe.py b/scripts/validate-recipe.py index 566b847..036ce6b 100755 --- a/scripts/validate-recipe.py +++ b/scripts/validate-recipe.py @@ -45,6 +45,16 @@ def validate(path): except (OSError, json.JSONDecodeError) as exc: return [f"{path}: unreadable or invalid JSON: {exc}"], [], None + # ⛔ VALID JSON IS NOT AN OBJECT. `json.load` accepts any JSON *value* — a top-level + # list, string, number or null all parse cleanly and then reach `recipe.get`, which + # raises AttributeError. Measured 2026-09-07 on `[]`: traceback, exit 1, no report. + # ⇒ For a validator whose entire purpose is REFUSING malformed input, crashing + # instead of reporting is the wrong failure mode — the caller cannot tell a rejected + # recipe from a broken validator. (#502 C1, raised by an external reviewer.) + if not isinstance(recipe, dict): + return [f"{path}: top-level JSON is {type(recipe).__name__}, not an object — " + f"a recipe must be a JSON object"], [], None + for field in ("id", "name"): if not isinstance(recipe.get(field), str) or not recipe[field]: errs.append(f"{field} must be a non-empty string")