Compare commits
17
Commits
v1.5.0
...
98a8322010
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a8322010 | ||
|
|
91ea02d990 | ||
|
|
2e94538698 | ||
|
|
0909f38742 | ||
|
|
24066d761c | ||
|
|
1663b09bc5 | ||
|
|
09727c7f8a | ||
|
|
785e7922c5 | ||
|
|
1da870bd67 | ||
|
|
d8e516e976 | ||
|
|
66012a97a7 | ||
|
|
b2ec97889f | ||
|
|
28211a78ea | ||
|
|
a4565c80c3 | ||
|
|
f3919b6ace | ||
|
|
5efc506197 | ||
|
|
a53a5a26a8 |
@@ -37,6 +37,8 @@ rules:
|
||||
| BLK-015 | 2026-07-03 | `gitflow_finish` ignored its `<type> <name>` args → merged the CHECKED-OUT branch not the one named → wrong-branch merge (audit LOT3) | resolved |
|
||||
| BLK-016 | 2026-07-04 | rtk compression PATH-dead 30 days — 6/5070 Bash commands compressed (~460K tokens missed); installer sources cargo env so its own check passes, Claude tool shell never gets ~/.cargo/bin | resolved |
|
||||
| BLK-017 | 2026-07-17 | Bing Webmaster API unusable for a multi-client agency: OAuth swamp (localhost redirect refused, rotated single-use refresh tokens race our parallel dispatch), API key = wrong model (client-owned sites) | open/deferred |
|
||||
| BLK-021 | 2026-09-13 | gstack Chromium install hangs forever on macOS: Playwright 1.58.2 deadlocks on Node 26 mid-extraction (39/333 files, all threads idle) | resolved |
|
||||
| BLK-022 | 2026-09-13 | macOS bash 3.2 + BSD userland: six silent defects, most fail-OPEN (SSRF guard, commit scope guards, gate criteria) | resolved |
|
||||
|
||||
---
|
||||
|
||||
@@ -116,6 +118,7 @@ rules:
|
||||
- **2026-06-23 UPDATE — Solution REVERTED, status downgraded to UPSTREAM/open** (commit b9c3937): the `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE` solution above does NOT work on 26.04. The fallback build downloads to 100% then HANGS at extraction (chrome binary never appears, no headless-shell download starts; reproduced on real machine + sandbox) → turned a 0.5s fast-fail into an install-blocking hang (user Ctrl+C). Reverted to the fast-fail (non-fatal; gstack OFF by default, browser only for /browse,/qa,screenshots). The earlier "verified ldd + headless render" was an isolated test on a sibling already-extracted build (rev 1228) — it masked the rev-1208 install-path hang. **Real fix = upstream**: gstack bumps Playwright to a version that lists ubuntu26.04. Until then gstack's browser is unavailable on 26.04, install completes cleanly. See [[LRN-038]] correction.
|
||||
|
||||
- **2026-06-23 FINAL — RESOLVED** (commit 3b8ffb1): gstack browser now works on Ubuntu 26.04. Two layers fixed: (1) bumped gstack's pinned Playwright 1.58.2 → 1.61 (`bun add playwright@latest` in the submodule; 1.61 ships a native ubuntu26.04 build — chromium rev 1228), automated in the installer (`gstack_bump_playwright_if_unsupported`, idempotent, OS-gated); (2) `GSTACK_CHROMIUM_NO_SANDBOX=1` to work around the AppArmor userns restriction (`sysctl kernel.apparmor_restrict_unprivileged_userns=1`), persisted to `.bashrc` + installer Step 9 (sysctl-gated). Verified end-to-end: `browse goto https://example.com` → "Navigated (200)". Caveat: the Playwright bump is a local submodule edit, reset by `git submodule update`, re-applied by the next install. See [[BDR-029]], [[LRN-040]].
|
||||
- **2026-09-13 CORRECTION — the diagnosis above is wrong, the fix was right**: the rev-1208 "downloads 100% then HANGS at extraction" was imputed to the `ubuntu24.04` FALLBACK build. REFUTED on macOS arm64, where Playwright 1.58.2 has a NATIVE build and no fallback exists: the SAME hang reproduces with the SAME signature (39/333 files, every thread idle). The real variable is the NODE version — 1.58.2 deadlocks on a runtime newer than itself, platform-independently. The 1.58.2→1.61 bump did resolve 26.04, but for a reason not recorded here: it also cleared that Node incompatibility. See [[BLK-021]] / [[LRN-150]].
|
||||
|
||||
---
|
||||
|
||||
@@ -242,3 +245,25 @@ rules:
|
||||
- **Status**: resolved (A: ext hooks only terminals born after activation → install ext THEN start/re-attach session; B: Code app volume 0 in Windows mixer).
|
||||
- **Lesson**: two independent client faults presented as one symptom ("nothing works"). Splitting probe = run signal in FRESH terminal + play VS Code's own sound preview. Preview bypasses terminal/BEL/hook/dtach/ext → isolates renderer audio in one step. Do that FIRST next time, before any server-side archaeology.
|
||||
- **Reference**: [[BLK-019]] bell-only variant (resolved differently — setting alone insufficient here), [[LRN-145]] terminalSequence-not-/dev/tty pattern. Silent-degradation class [[LRN-047]].
|
||||
|
||||
## BLK-021 — gstack Chromium install hangs forever on macOS (Playwright 1.58.2 x Node 26) — 2026-09-13
|
||||
- **Friction**: `make plugin` froze at step 2/10. Log ends mid-Chromium install: 100% of 162.3 MiB downloaded, then nothing — no error, no timeout, no progress. Steps 3-10 (RTK, GSD, marketplace plugins, link.sh, shell profile) never ran.
|
||||
- **Real cause**: gstack's lockfile froze Playwright **1.58.2** (published 2026-02-06) → chromium rev 1208. Its extraction DEADLOCKS under **Node 26.5.0**: both processes (`playwright install` + child `oopDownloadBrowserMain.js`) fully idle — main thread in `kevent`, V8 AND libuv workers in `__psynch_cvwait`, 0% CPU, 2.5s CPU total — stuck at exactly 39/333 files. Zip fully downloaded and intact (170206961 B). NOT network, disk (396Gi free), Gatekeeper, quarantine (none set), nor Intego VirusBarrier — an AV block parks a thread in `write`; none was. PW 1.58.2 declares `engines: node >=18`, so Node 26 is formally SUPPORTED: the incompatibility is undeclared upstream.
|
||||
- **Proof (3-way, one variable moved)**: Node 26 x PW 1.58.2 = hang (2/2 reproductions); Node 22.23.1 x PW 1.58.2, same command + same rev = OK (336 files); Node 26 x PW 1.63.0 = OK (347 files, 360MB, Chrome 153.0.8010.12).
|
||||
- **Solution**: bump gstack Playwright 1.58.2 → 1.63.0 (rev 1243). In-range, not a pin break — `package.json` declares `"playwright": "^1.58.2"`; only `bun.lock` froze it. Installer now pre-installs the browser under a deadline with bump-retry ([[BDR-088]]). The submodule edit stays LOCAL (reset by `git submodule update`, re-applied by the next install — the [[BDR-029]] pattern).
|
||||
- **Status**: resolved. Gate verified: `chromium.launch()` → `LAUNCH OK — Chromium 153.0.8010.12`, rc 0.
|
||||
- **Corrects upstream record**: [[BLK-008]] / [[LRN-038]] imputed this exact signature on Ubuntu to the `ubuntu24.04` FALLBACK build. REFUTED: macOS arm64 has a NATIVE 1.58.2 build, no fallback exists there, and the same hang reproduces with the same signature. The real variable is the NODE version. The 1.58.2→1.61 bump did fix 26.04, but for a reason not recorded there — it also cleared the Node incompatibility.
|
||||
- **Cost of the wrong record**: it sent the 2026-09-13 investigation hunting fallback builds first. A fix that WORKS can freeze a WRONG cause.
|
||||
|
||||
## BLK-022 — macOS bash 3.2 + BSD userland: six fail-OPEN or silent-no-op defects — 2026-09-13
|
||||
- **Friction**: repo moved to macOS (Darwin 25.6, arm64). `make test` red across 5 suites, and several guards PASSED while doing nothing at all.
|
||||
- **Real cause**: `/bin/bash` is **3.2.57** and `#!/usr/bin/env bash` resolves to it (no Homebrew bash on PATH). Every failure is silent:
|
||||
- `${1,,}` (bash 4.0+) in `lib/url-guard.sh` → "bad substitution", subshell exits 1 = "not local" → the SSRF guard returned rc 0 for localhost, 127.x, 10.x, 192.168.x, 172.16-31.x, **169.254.169.254** and metadata.google.internal. FAIL-OPEN on every Mac; `url-guard.test.sh` recorded it as 13x "got[0] want[2]".
|
||||
- `mapfile` in the 3 surgical-commit helpers → empty arrays → scope guards fail-OPEN, commits degrade to "nothing pending — no-op" while reporting success.
|
||||
- `declare -A` in `hooks/session-start.sh` → every plugin cost read 0 → the >50%-budget warning could never fire.
|
||||
- `timeout` (coreutils, absent from a stock macOS) in `lib/gates.sh` → exit 127 → EVERY criterion recorded NOT-MET whatever the check did.
|
||||
- GNU `sed -i` x3 in `install-plugins.sh` → BSD sed errors → aborts the installer under `set -euo pipefail`.
|
||||
- Test-side GNU-isms: `touch -d`, BSD `wc -l` padding (`got[ 48] want[48]`), `/bin/grep` (does not exist on macOS), `stat -c`, `sed -i` + `\n` in the replacement.
|
||||
- **Solution**: `_read_lines_into` (portable mapfile), `shopt -s nocasematch` (bash 3.1+, keeps the no-fork property), `case` for plugin costs, resolved timeout binary + pure-bash fallback, `_sed_inplace` + awk. Split over 5 branches.
|
||||
- **Status**: resolved. Every suite 0 failures except `gitflow-test.sh`, which is FLAKY: three consecutive runs on one unchanged tree gave 90/16, 92/14, 92/14. Its failures are pre-existing and unattributed, and this port is not distinguishable from that noise — an earlier "92/14 pristine vs 93/13 after, one case better" reading was false precision, comparing two single samples of a non-deterministic suite. shellcheck 1 finding before and after (pre-existing SC2016).
|
||||
- **Bonus found while porting**: the orphan-comment cleanup `{N; /^\n$/d;}` in `install-plugins.sh` was a no-op on EVERY platform — after `N` the pattern space starts with '#', so the `^\n$` anchor pair never applied. Rewritten in awk and tested.
|
||||
|
||||
@@ -97,6 +97,7 @@ rules:
|
||||
| BDR-085 | 2026-08-25 | User permanent rules: writing-style always-on in rules/, web build+security path-scoped | accepted |
|
||||
| BDR-086 | 2026-08-26 | darwin: threshold gates full loops; verified defects fixed regardless of unit score (paired-validated, batched checkpoint) | accepted |
|
||||
| BDR-087 | 2026-09-03 | Stop hook = attention signal only, never control flow; one script for Notification + Stop | accepted |
|
||||
| BDR-088 | 2026-09-13 | gstack browser: guarded pre-install (900s deadline + Playwright bump-retry), not a pinned Node | accepted |
|
||||
|
||||
---
|
||||
|
||||
@@ -1123,3 +1124,11 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate).
|
||||
- **Guard vs prior refusal**: [[BDR-083]] (unlazy review, GATE 0) REFUSED a Stop hook using `decision:"block"` (forces continuation, inverts human gates). THIS Stop hook returns `terminalSequence` + `suppressOutput` only, exit 0, zero control-flow effect. Signal ≠ control. Do not read the refusal as banning Stop outright.
|
||||
- **Status**: accepted.
|
||||
- **Reference**: [[LRN-146]] event-coverage gap, [[BLK-020]] client-side faults, [[LRN-145]] terminalSequence pattern. Verified live: turn-end + AskUserQuestion both ring; `permission_prompt` unexercisable under `defaultMode: auto`.
|
||||
|
||||
## BDR-088 — gstack browser: guarded pre-install (deadline + bump-retry), not a pinned Node
|
||||
- **Date**: 2026-09-13
|
||||
- **Decision**: `install-plugins.sh` pre-installs gstack's Chromium BEFORE `./setup`, wrapped in a portable timeout (`_run_with_timeout`, `GSTACK_BROWSER_TIMEOUT=900`). On deadline: `bun add playwright@latest` in the submodule, retry once, then WARN (non-fatal — gstack is OFF by default; only `/browse`, `/qa` and screenshots depend on it). New helpers: `_run_with_timeout` (pure bash — coreutils `timeout` is absent from a stock macOS), `_gstack_bun_install` (extracted, shared with `gstack_bump_playwright_if_unsupported`), `_gstack_pw_install`.
|
||||
- **Why**: REACT to the observed failure (a hang) instead of PREDICTING it — no version table to maintain, and it self-heals for future Node x Playwright pairs. The deadline alone fixes the worst defect: an installer that hangs forever with no message (it silently cut step 2/10 here, and forced a Ctrl+C on Ubuntu per [[BLK-008]]).
|
||||
- **Alternatives rejected**: pin `node@22` for gstack's setup (freezes Chrome at 145, EIGHT majors behind stable 153, adds a node@22 dep, and only masks the symptom — this was the FIRST recommendation, reversed once the browser-staleness was priced, see [[EVAL-029]]); widen `gstack_bump_playwright_if_unsupported`'s `/etc/os-release` gate with a Node-version table (brittle: `engines` declares no upper bound, so there is nothing authoritative to compare); document only (a clean cache re-hangs with no signal).
|
||||
- **Status**: accepted.
|
||||
- **Reference**: `install-plugins.sh` `_run_with_timeout` / `gstack_install_browser_guarded`. Guard proven by FORCED failure (hang → rc 124 in 6s, no orphaned `oopDownloadBrowserMain`, stderr clean after fd-park) and by a real run (browser present → `ok` in 5s, idempotent). [[BLK-021]], [[LRN-150]].
|
||||
|
||||
@@ -39,6 +39,7 @@ rules:
|
||||
| EVAL-025 | 2026-07-17 | opening seo/geo inventory (subagents): 7/7 verifiable claims false or overstated; real contact corrected all, 6 plan corrections + 4 features killed at measurement | keep |
|
||||
| EVAL-027 | 2026-08-24 | contract-gates behavioral RED: 16/16 fresh unprimed runs followed new doctrine (GATE 0 order, vacuous oracle, ABANDONED routing, scope temptation resisted) | keep |
|
||||
| EVAL-028 | 2026-08-26 | darwin v2.1 paired run 54 units: 60 paired verdicts 0 revert/tie; skeptics found 3 real residuals — engaged, not rubber-stamp | keep |
|
||||
| EVAL-029 | 2026-09-13 | macOS port: recommendation reversed by one user question (browser staleness unpriced); grep detector returned empty twice | keep |
|
||||
|
||||
---
|
||||
|
||||
@@ -267,3 +268,9 @@ Dogfood: 3 blind lenses attacked the v1 plan for the plan-challenge feature itse
|
||||
- **Method**: paired same-judge 3-majority per round (v2.1); judges live-exec where artifact executable (5 units: skills-perso, profile, plugin-pair, status-reporter, gitflow). Absolute scores triage-only. Totals main-thread (LRN-018 applied).
|
||||
- **Anomalies**: (1) 0 reverts/ties in 60 verdicts — homogeneous-better checked: skeptic lens found real residuals 3x (doctor.sh cost source, hotfix RULES leftover restore, FILE(S) new-marker) → judges engaged. (2) census lock RED on line-rewrap, make test caught → LRN-144. (3) head-pipe masked grep exit 2x → LRN-143.
|
||||
- **Action**: v2.1 paired = standard. Post-run absolute rescore skipped by design (would be judge-noise theater).
|
||||
|
||||
## EVAL-029 — macOS port: recommendation reversed by one user question; detector failed twice
|
||||
- **Date**: 2026-09-13/15. **Output**: 5 branches — SSRF guard restored, bash 3.2 portability, GNU/BSD coreutils, installer unblocked, BDR-019 sweep. 14 files.
|
||||
- **Method**: portability scan → the repo's OWN `make test` as oracle (each defect surfaced as a named assertion); hang → reproduce, `sample` BOTH pids, then a 3-way discriminating matrix (runtime x dep version). Fallback paths exercised by FORCING them (`GATES_TIMEOUT_BIN=""`, an injected hang).
|
||||
- **Anomalies**: (1) I recommended pinning node@22 and ranked the Playwright bump SECOND. The user asked only "est-ce la dernière version de chromium ?" — checking showed rev 1208 = Chrome 145 vs stable 153, AND `package.json` already declaring `^1.58.2` (bump in-range; only the lockfile froze it). Recommendation reversed. I had scoped the decision to "does it install" and never priced browser staleness, nor read the declared range before calling the bump a pin-break. (2) My grep sweep returned EMPTY twice and I nearly read it as clean — `set -e` killing the loop, then a pattern demanding a letter after `${` that missed `${1,,}`, the SSRF bug. Both caught by accident. (3) [[BLK-008]]'s recorded cause misdirected the first hour. (4) I over-investigated `gitflow-test.sh`'s 13 pre-existing failures instead of bounding the question early; the user had to redirect.
|
||||
- **Action**: when choosing BETWEEN fixes, read what the dep DECLARES vs what the lockfile froze, and price the side-effects of freezing a version (staleness, unpatched CVEs, render fidelity) — not just "does it unblock". A scan that finds NOTHING must first be proven able to find something known-present. Bound archaeology on pre-existing failures: establish "not caused by me, not worsened" and move on.
|
||||
|
||||
@@ -460,3 +460,9 @@ rules:
|
||||
- Post-merge regression: toast dead again after re-attach from a RESTORED terminal, bell fine. Root cause [[LRN-147]]: ext hooks only terminals born after its activation; `enablePersistentSessions` restores terminals before it. Fix = disable persistent sessions, or fresh terminal + `dtach -a`. Verified: 3/3 toasts on fresh pty.
|
||||
- Same-day counter-example broke that cause: second session's terminal deaf though created LATER, same window, ext global, shells identical. Trigger unknown; [[LRN-148]] adds the 5s pre-flight test + demotes LRN-147's mechanism claim.
|
||||
- Attention signal refined: per-event labels (BDR-087 follow-on), silence on non-attention events, and no turn-end signal while `background_tasks` non-empty ([[LRN-149]]). Payload dump beat the docs: `background_tasks` undocumented for Stop but present on the wire. Branch bugfix/notify-subagent-spawn.
|
||||
|
||||
## 2026-09-15
|
||||
- macOS port of the fork, on develop (repo moved from `/home/bchanot-ubuntu/…`; origin switched to git.bchanot.fr/bmottin/claude_mac). Root of everything: `/bin/bash` is 3.2.57 and `#!/usr/bin/env bash` resolves to it. Six defect classes, all SILENT ([[BLK-022]]) — worst is `${1,,}` turning `lib/url-guard.sh` into a pass-through for localhost/127.x/10.x/192.168.x/169.254.169.254 (SSRF guard fail-OPEN); then `mapfile` making the 3 commit guards fail-open + commits silent no-ops, `declare -A` zeroing the budget warning, missing `timeout` making EVERY gate criterion NOT-MET, GNU `sed -i` aborting the installer.
|
||||
- gstack Chromium hang = [[BLK-021]]: PW 1.58.2 deadlocks on Node 26 mid-extraction (39/333 files, ALL threads idle). Proven by a 3-way matrix moving one variable. Fixed by bump to 1.63.0 → Chrome 145 → 153. Installer now bounds that step ([[BDR-088]]). [[BLK-008]]/[[LRN-038]] diagnosis corrected — cause was Node, not the ubuntu24.04 fallback build.
|
||||
- 5 branches cut, NOT merged (gitflow human gate): bugfix/url-guard-ssrf-bash32, bugfix/macos-bash32-portability, bugfix/macos-gnu-coreutils, bugfix/macos-installer, chore/sweep-bdr019-makefile. Submodule resynced to develop's pointer (11de390), Playwright bump re-applied locally per [[BDR-029]].
|
||||
- Open: `gitflow-test.sh` failures PRE-EXISTING, unattributed, and FLAKY (90/16, 92/14, 92/14 on three runs of one unchanged tree) — separate chantier. My earlier "one case better" was noise read as signal. gitleaks absent from this machine (installer does not provide it) → T16a + `make scan-secrets` unavailable. Nothing pushed.
|
||||
|
||||
@@ -139,6 +139,8 @@ rules:
|
||||
| LRN-134 | 2026-07-17 | resolve-then-pin in stdlib http.client beats monkeypatching getaddrinfo — dual-stack, thread-safe, no requests; classify the OS-resolved IP not the URL text | closing SSRF/DNS-rebinding on any Python HTTP egress |
|
||||
| LRN-135 | 2026-07-17 | a prefix-only scan for a dangerous construct is bypassable by padding — scan the WHOLE document | refusing any hostile construct (DTD/directive/marker) before parse |
|
||||
| LRN-143 | 2026-08-26 | `cmd \| head \|\| fallback` — pipeline rc is head's (0), fallback dead; bounded output → drop head, else pipefail | any probe/fallback bash in skills before trusting `\|\|` |
|
||||
| LRN-150 | 2026-09-13 | Lockfile-pinned dep vs fast runtime: undeclared incompatibility HANGS, never errors; `engines` has no upper bound | any pinned tool that stalls — check dep publish date vs runtime release, and the DECLARED range vs the lock |
|
||||
| LRN-151 | 2026-09-13 | Porting to macOS: bash 3.2 makes guards fail-OPEN, not abort; and a scan finding nothing proves nothing | after any OS migration — run the suite first, audit guards before cosmetics, self-check the detector |
|
||||
|
||||
---
|
||||
|
||||
@@ -624,6 +626,7 @@ rules:
|
||||
- **Future application**: any pinned tool that hardcodes an OS allowlist breaks on a fresh OS upgrade. Look for a host-platform override env before bumping/forking the dep. Prove the fallback binary actually runs (`ldd` = no missing libs + a real headless render), not just that the download resolves.
|
||||
- **Reference**: `install-plugins.sh` `playwright_platform_override()`, commit 211c7d4. Linked to [[BLK-008]].
|
||||
- **2026-06-23 CORRECTION (override REVERTED, commit b9c3937)**: the override is NOT a usable fix on Ubuntu 26.04. It makes `playwright install` switch to the ubuntu24.04 fallback build, which downloads to 100% then HANGS at extraction (chrome binary never materializes; real machine + sandbox). Turned a 0.5s fast-fail into an install-blocking hang. The isolated proof (`ldd` + headless render) PASSED but used an already-extracted sibling build (rev 1228) — it masked the install-path hang in the real flow (rev 1208). **Sharpened lesson**: proving the binary launches in isolation is NOT proving the install path works — run the ACTUAL install command end-to-end (it must COMPLETE, not just "download resolves" nor "a binary launches"). The override technique stays valid in general, but the EXTRACTION/COMPLETE step is part of "does it work".
|
||||
- **2026-09-13 CORRECTION**: "the ubuntu24.04 fallback build hangs at extraction" is REFUTED as the cause. Same hang, same signature, on macOS arm64 where 1.58.2 ships a native build and no fallback is involved — so the cause is Playwright 1.58.2 deadlocking on a too-new Node, not the build. This entry cost real time: it sent the 2026-09-13 macOS investigation hunting fallback builds first. A fix that WORKS can freeze a WRONG cause. See [[BLK-021]] / [[LRN-150]].
|
||||
|
||||
---
|
||||
|
||||
@@ -1421,3 +1424,18 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s
|
||||
- **Fail-open**: field absent (older client) → still signal. Missed notification worse than extra one.
|
||||
- **Cross-session gotcha**: hook is user-scope, so EVERY session runs it. A single-file dump (`> file`) gets overwritten by another project's session — append JSONL and filter on `.cwd`. That accident proved `permission_prompt` fires with `message="Claude needs your permission"` (unexercisable in this session under `defaultMode: auto`).
|
||||
- **Future**: any hook needing turn-completion semantics must check background_tasks; "turn ended" ≠ "work done". Verified live: Stop with 0 tasks signals, Stop with 1 running subagent silent.
|
||||
|
||||
## LRN-150 — a lockfile-pinned dep vs a fast runtime: the undeclared incompatibility HANGS, it does not error
|
||||
- **Context**: 2026-09-13. gstack's lockfile-frozen Playwright 1.58.2 (Feb 2026) under Node 26.5.0 (Sept 2026). Chromium extraction deadlocks at 39/333 files, silently, forever. `engines: node >=18` claims support.
|
||||
- **Pattern**: `engines` is a CLAIM, not a test — an UPPER bound is almost never declared, so "too new" reads as "supported" and fails as a HANG, not an error. Diagnose with `sample <pid>` (macOS) or any stack dump: ALL threads idle (`kevent` + `__psynch_cvwait`, 0% CPU, libuv workers INCLUDED) = deadlock, nothing in flight; a thread parked in `write`/`read` would mean AV/FS/network instead — that one measurement ruled out Intego VirusBarrier in seconds. Then discriminate by moving ONE variable: same command + same revision under another runtime (`brew` keeps node@22 beside node@26).
|
||||
- **Read the declared range before calling it a pin**: `package.json` said `^1.58.2`, so 1.63.0 was already in range — only `bun.lock` froze it. A "bump" that needs no fork and breaks no contract was available the whole time.
|
||||
- **Corollary**: a fix that WORKS can freeze a WRONG cause in the registry. [[BLK-008]] blamed a fallback build; that record misdirected this investigation three months later. When a fix lands, record which variable was PROVEN, not the one suspected.
|
||||
- **Future**: pinned dep + hang → compare dep publish date vs runtime release date BEFORE blaming platform/network/AV. Any unattended install step that can hang needs a DEADLINE: silent-forever is strictly worse than failing loudly ([[BDR-088]]).
|
||||
|
||||
## LRN-151 — porting to macOS: the danger is fail-OPEN, and a scan that finds nothing proves nothing
|
||||
- **Context**: 2026-09-13. Repo moved Linux → macOS. `/bin/bash` = 3.2.57 = what `#!/usr/bin/env bash` resolves to. Six distinct defect classes ([[BLK-022]]).
|
||||
- **Pattern — the failure direction is what matters**: bash 3.2 does not abort on a bash-4 construct, it makes the SUBSHELL fail, and a guard whose "block" path is an exit code then reads as "allow". `${1,,}` turned an SSRF allowlist into a pass-through; `mapfile` turned scope guards into empty-array no-ops that still reported success; missing `timeout` turned every gate criterion into NOT-MET. Audit order: find the guards FIRST, ask what an errored subshell returns there, and only then chase cosmetics.
|
||||
- **Empirically catalogued surface** (bash 3.2 + BSD userland): `${var,,}`/`${var^^}`, `mapfile`/`readarray`, `declare -A`, `timeout` (coreutils), `sed -i` (needs a suffix; no `\n` in the replacement), `touch -d`, `stat -c`, `wc -l` (pads with spaces — breaks string compares), `/bin/grep` (macOS has only /usr/bin/grep), `readlink -f` (OK since Monterey), `sort -V` (OK).
|
||||
- **The test suite is the oracle**: the repo's own `make test` located every one of these faster than reading code, because each defect surfaced as a specific assertion. Port = run the suite, fix what reddens, re-run.
|
||||
- **Self-check the detector**: my grep sweep returned EMPTY twice and I nearly read it as "clean" — once because `set -e` killed the loop on the first no-match grep, once because the pattern demanded a letter after `${` and so missed `${1,,}` (a digit). A scan that finds NOTHING must first be shown to find something known-present. Both misses were caught by accident, not by method.
|
||||
- **Future**: after any OS migration, run the full suite before trusting any static sweep, and treat a 100%-of-a-category warning as a stale check rather than 100% non-compliance ([[BDR-019]] sweep, `doctor.sh` + `Makefile`).
|
||||
|
||||
@@ -1272,3 +1272,35 @@ dans un runner; capitalize reste main-loop.
|
||||
- [x] T3 BDR-084 + CHANGELOG + journal.
|
||||
- [x] T4 make test rc 0 + shellcheck clean (SC2016 silencé, littéral
|
||||
voulu). Merge NON fait — gate humain.
|
||||
|
||||
## 2026-09-13/15 — portage macOS du fork (sur develop)
|
||||
Contexte: repo migré Linux → macOS (Darwin 25.6 arm64), origin basculé sur
|
||||
git.bchanot.fr/bmottin/claude_mac. Racine unique: `/bin/bash` = **3.2.57**, et
|
||||
`#!/usr/bin/env bash` y résout (pas de bash Homebrew). Détail: [[BLK-022]], [[BLK-021]].
|
||||
Oracle = `make test` du repo: chaque défaut est apparu comme une assertion nommée.
|
||||
- [x] T1 `bugfix/url-guard-ssrf-bash32` — `${1,,}` (bash 4+) → `shopt -s nocasematch`.
|
||||
Garde SSRF qui renvoyait rc 0 pour localhost/127.x/10.x/192.168.x/172.16-31.x/
|
||||
169.254.169.254. 10 cibles → rc 2, hôtes légitimes → rc 0.
|
||||
- [x] T2 `bugfix/macos-bash32-portability` — `mapfile` → `_read_lines_into` (3 libs de
|
||||
commit + 2 tests), `declare -A` → `case`, commentaires de `source-scope.sh`.
|
||||
deploy-commit 4→16/16, source-scope 31→34/34, run-reconcile 23G/1R→25G/0R.
|
||||
- [x] T3 `bugfix/macos-gnu-coreutils` — `timeout` résolu + repli bash pur (prouvé via
|
||||
`GATES_TIMEOUT_BIN=""`, 64/64), `touch -d`→perl utime, padding `wc -l`,
|
||||
`sed -i`+`\n`→awk, `/bin/grep`, `stat -c`. fast-libs 7→11/11, seo-data 217→221/221.
|
||||
- [x] T4 `bugfix/macos-installer` — `_sed_inplace` (BSD) + awk pour le commentaire
|
||||
orphelin (no-op même sur GNU) + `gstack_install_browser_guarded` (deadline 900s,
|
||||
bump Playwright + 1 retry, warn non fatal). Hang forcé → rc 124 en 6s, 0 orphelin.
|
||||
- [x] T5 `chore/sweep-bdr019-makefile` — gabarit `new-skill` réinjectait
|
||||
`disable-model-invocation` que BDR-019 avait retiré. `doctor.sh` déjà corrigé amont.
|
||||
- [x] T6 sous-module resynchronisé sur le pointeur de develop (11de390), bump
|
||||
Playwright 1.63.0 réappliqué en local ([[BDR-029]]). `chromium.launch()` OK (Chrome 153).
|
||||
- [x] T7 capitalisation — BLK-021/022, LRN-150/151, BDR-088, EVAL-029, journal, index à jour.
|
||||
- [ ] MERGE — les 5 branches attendent le gate humain gitflow. Rien n'est poussé.
|
||||
- [ ] OUVERT — `gitflow-test.sh` échoue de façon INSTABLE: 90/16, 92/14, 92/14 sur
|
||||
trois runs d'un arbre identique. Échecs préexistants, non attribués, et ce
|
||||
portage est indiscernable de ce bruit. Chantier distinct. Corollaire: toute
|
||||
comparaison avant/après sur cette suite exige plusieurs runs, pas un échantillon.
|
||||
- [ ] OUVERT — gitleaks absent de la machine, non fourni par l'installeur →
|
||||
T16a et `make scan-secrets` indisponibles.
|
||||
- [ ] OUVERT — dérive d'index préexistante: blockers BLK-018..020 et learnings
|
||||
LRN-144..149 absents de leur Index (non backfillés — résumés non écrits par moi).
|
||||
|
||||
@@ -71,7 +71,7 @@ new-skill: ## Create a new skill scaffold (usage: make new-skill name=myskill)
|
||||
echo "✅ Created agents/$(name).md"; \
|
||||
else echo "⚠️ agents/$(name).md already exists"; fi
|
||||
@if [ ! -f skills/$(name)/SKILL.md ]; then \
|
||||
printf -- '---\nname: $(name)\ndescription: <what this skill does — front-load key use case, max 250 chars>\nargument-hint: <what to pass>\ndisable-model-invocation: true\nallowed-tools: Read, Grep, Glob, Bash\n---\n\nLoad and follow strictly:\n- .claude/agents/$(name).md\n\nExecute on:\n\n$$ARGUMENTS\n' > skills/$(name)/SKILL.md; \
|
||||
printf -- '---\nname: $(name)\ndescription: <what this skill does — front-load key use case, max 250 chars>\nargument-hint: <what to pass>\nallowed-tools: Read, Grep, Glob, Bash\n---\n\nLoad and follow strictly:\n- .claude/agents/$(name).md\n\nExecute on:\n\n$$ARGUMENTS\n' > skills/$(name)/SKILL.md; \
|
||||
echo "✅ Created skills/$(name)/SKILL.md"; \
|
||||
else echo "⚠️ skills/$(name)/SKILL.md already exists"; fi
|
||||
@echo " Edit both files, then run: bash link.sh"
|
||||
|
||||
+14
-10
@@ -102,17 +102,21 @@ esac
|
||||
_passive_t=0
|
||||
detect_superpowers 2>/dev/null && _passive_t=$((_passive_t + 800))
|
||||
|
||||
# Token costs for toggle plugins — map display name to cost
|
||||
declare -A _plugin_costs=(
|
||||
[gstack]=2750
|
||||
[ui-ux-pro-max]=400
|
||||
[plugin-dev]=100
|
||||
[context7]=200
|
||||
[graphify]=300
|
||||
)
|
||||
# Token cost per toggle plugin, by display name. A `case`, not an associative
|
||||
# array: macOS ships bash 3.2 as /bin/bash, where `declare -A` is rejected —
|
||||
# every cost then read as 0 and the budget warning below never fired.
|
||||
_plugin_cost() {
|
||||
case "$1" in
|
||||
gstack) echo 2750 ;;
|
||||
ui-ux-pro-max) echo 400 ;;
|
||||
plugin-dev) echo 100 ;;
|
||||
context7) echo 200 ;;
|
||||
graphify) echo 300 ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
}
|
||||
for _p in "${TOGGLE_ACTIVE[@]}"; do
|
||||
_cost="${_plugin_costs[$_p]:-0}"
|
||||
_passive_t=$((_passive_t + _cost))
|
||||
_passive_t=$((_passive_t + $(_plugin_cost "$_p")))
|
||||
done
|
||||
_budget_pct=$((_passive_t * 100 / _budget))
|
||||
if [ "$_budget_pct" -gt 50 ]; then
|
||||
|
||||
+93
-5
@@ -16,6 +16,16 @@ err() { echo -e "${RED}✗${NC} $1"; }
|
||||
|
||||
REPO="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# In-place file edit that works on both GNU and BSD sed: `sed -i` needs a backup
|
||||
# suffix argument on macOS and forbids one on Linux, so go through a temp file
|
||||
# instead. Writing back with `cat >` (not `mv`) keeps the original mode/owner.
|
||||
_sed_inplace() {
|
||||
local expr="$1" file="$2" tmp
|
||||
tmp="$(mktemp)" || return 1
|
||||
sed "$expr" "$file" > "$tmp" && cat "$tmp" > "$file"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# Log to file for post-mortem debugging (terminal output unchanged)
|
||||
LOG_FILE="$REPO/install-$(date +%Y%m%d-%H%M%S).log"
|
||||
if touch "$LOG_FILE" 2>/dev/null; then
|
||||
@@ -291,6 +301,67 @@ fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Portable `timeout`: GNU coreutils' timeout is absent from a stock macOS, and
|
||||
# this has to run on a machine where nothing is installed yet. Returns 124 when
|
||||
# the deadline is hit, otherwise the command's own exit status.
|
||||
_run_with_timeout() {
|
||||
local secs="$1" waited=0 pid
|
||||
shift
|
||||
"$@" &
|
||||
pid=$!
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
if [ "$waited" -ge "$secs" ]; then
|
||||
# Park the shell's own stderr: bash announces a signal-killed job on ITS
|
||||
# stderr, so redirecting kill/wait alone does not suppress it — and that
|
||||
# line in the install log reads like a real error.
|
||||
exec 3>&2 2>/dev/null
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
# Playwright downloads in a child process that survives a TERM aimed at
|
||||
# its parent; left alone it keeps holding the browser-cache lock.
|
||||
pkill -f oopDownloadBrowserMain 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
exec 2>&3 3>&-
|
||||
return 124
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
# Populate gstack's node_modules at the locked versions so `bunx playwright`
|
||||
# resolves the LOCAL Playwright. Without it bunx pulls the latest from npm,
|
||||
# which wants a different browser revision than the one gstack imports.
|
||||
_gstack_bun_install() {
|
||||
( cd "$GSTACK_DIR" && { bun install --frozen-lockfile >/dev/null 2>&1 ||
|
||||
bun install >/dev/null 2>&1; } )
|
||||
}
|
||||
|
||||
_gstack_pw_install() { ( cd "$GSTACK_DIR" && bunx playwright install chromium ); }
|
||||
|
||||
# Install gstack's Chromium here instead of leaving it to ./setup, so the
|
||||
# download can be bounded. A Playwright older than the running Node deadlocks
|
||||
# mid-extraction — both processes idle, no error, no progress, forever — which
|
||||
# silently stalls the whole installer. On a timeout, bump Playwright (gstack
|
||||
# declares "playwright": "^1.x", so a minor bump is within its own range) and
|
||||
# retry once. A second failure warns rather than aborts: gstack is OFF by
|
||||
# default and only its browser (/browse, /qa, screenshots) depends on this.
|
||||
gstack_install_browser_guarded() {
|
||||
[ -d "$GSTACK_DIR" ] && command -v bun >/dev/null 2>&1 || return 0
|
||||
_gstack_bun_install || return 0
|
||||
if _run_with_timeout "$GSTACK_BROWSER_TIMEOUT" _gstack_pw_install; then
|
||||
ok "gstack Chromium ready"
|
||||
return 0
|
||||
fi
|
||||
warn "Chromium install stalled >${GSTACK_BROWSER_TIMEOUT}s — bumping gstack's Playwright, retrying"
|
||||
( cd "$GSTACK_DIR" && bun add playwright@latest >/dev/null 2>&1 ) || true
|
||||
if _run_with_timeout "$GSTACK_BROWSER_TIMEOUT" _gstack_pw_install; then
|
||||
ok "gstack Chromium ready after Playwright bump (./setup rebuilds browse)"
|
||||
else
|
||||
warn "gstack Chromium unavailable — /browse, /qa and screenshots stay off"
|
||||
fi
|
||||
}
|
||||
|
||||
# gstack pins Playwright (1.58.x) which only ships browser builds for
|
||||
# ubuntu<=24.04. On a newer distro the browser install fails ("does not
|
||||
# support chromium on ubuntuXX.04"). Bump gstack's Playwright to a version
|
||||
@@ -307,7 +378,7 @@ gstack_bump_playwright_if_unsupported() {
|
||||
[ -n "$ostag" ] || return 0 # only the known Ubuntu case
|
||||
pwlib="$GSTACK_DIR/node_modules/playwright-core/lib"
|
||||
# populate node_modules at the pinned version so we can read its support list
|
||||
( cd "$GSTACK_DIR" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null 2>&1; } ) || return 0
|
||||
_gstack_bun_install || return 0
|
||||
if grep -rqs "$ostag" "$pwlib" 2>/dev/null; then
|
||||
return 0 # pinned Playwright already supports this OS
|
||||
fi
|
||||
@@ -337,6 +408,10 @@ echo ""
|
||||
# git add skills-external/gstack && git commit -m "chore: update gstack"
|
||||
|
||||
GSTACK_DIR="$REPO/skills-external/gstack"
|
||||
# Deadline for gstack's Chromium download+extract. Generous on purpose: a real
|
||||
# install runs 1-3 min, overshooting only delays the fallback, and the failure
|
||||
# it catches would otherwise hang the installer forever.
|
||||
GSTACK_BROWSER_TIMEOUT=900
|
||||
|
||||
if [ ! -d "$GSTACK_DIR/.git" ] && [ ! -f "$GSTACK_DIR/.git" ]; then
|
||||
info "Initializing GStack submodule..."
|
||||
@@ -369,6 +444,10 @@ if [ -d "$GSTACK_DIR" ]; then
|
||||
# chromium" fail). Non-fatal if it can't — gstack is OFF by default.
|
||||
gstack_bump_playwright_if_unsupported
|
||||
|
||||
# Then fetch the browser under a deadline. ./setup would do it itself, but
|
||||
# unbounded — and this is the step that hangs when Playwright trails Node.
|
||||
gstack_install_browser_guarded
|
||||
|
||||
info "Running GStack setup..."
|
||||
_gstack_setup_ok=0
|
||||
if [ -x "$GSTACK_DIR/setup" ]; then
|
||||
@@ -993,14 +1072,23 @@ fi
|
||||
# `claude --effort max` alias (the alias would even override settings.json).
|
||||
EFFORT_CLEANED=0
|
||||
if grep -qF 'export CLAUDE_EFFORT=max' "$SHELL_PROFILE" 2>/dev/null; then
|
||||
sed -i '/export CLAUDE_EFFORT=max/d' "$SHELL_PROFILE"; EFFORT_CLEANED=1
|
||||
_sed_inplace '/export CLAUDE_EFFORT=max/d' "$SHELL_PROFILE"; EFFORT_CLEANED=1
|
||||
fi
|
||||
if grep -qF "alias claude='claude --effort max'" "$SHELL_PROFILE" 2>/dev/null; then
|
||||
sed -i "\#alias claude='claude --effort max'#d" "$SHELL_PROFILE"; EFFORT_CLEANED=1
|
||||
_sed_inplace "\#alias claude='claude --effort max'#d" "$SHELL_PROFILE"; EFFORT_CLEANED=1
|
||||
fi
|
||||
if [ "$EFFORT_CLEANED" -eq 1 ]; then
|
||||
# Remove orphaned comment lines left before the deleted entries
|
||||
sed -i '/^# Claude Code — added by install-plugins.sh$/{ N; /^\n$/d; }' "$SHELL_PROFILE"
|
||||
# Drop the header comment left stranded above a deleted entry (the marker
|
||||
# followed by a blank line, or by end-of-file). awk, not sed: the previous
|
||||
# `{N; /^\n$/d;}` could never match — after N the pattern space starts with
|
||||
# '#', so the ^\n$ anchor pair never applied, and it was a silent no-op.
|
||||
_tmp_profile="$(mktemp)"
|
||||
awk -v marker='# Claude Code — added by install-plugins.sh' '
|
||||
$0 == marker { stranded = 1; next }
|
||||
stranded { stranded = 0; if ($0 == "") next; print marker }
|
||||
{ print }
|
||||
' "$SHELL_PROFILE" > "$_tmp_profile" && cat "$_tmp_profile" > "$SHELL_PROFILE"
|
||||
rm -f "$_tmp_profile"
|
||||
info "Removed obsolete effort alias/env from $SHELL_PROFILE (effort set in settings.json)"
|
||||
fi
|
||||
|
||||
|
||||
+22
-4
@@ -15,6 +15,19 @@
|
||||
# not just stderr, so it can't share rc 1's "nothing to do" (J4-22).
|
||||
set -uo pipefail
|
||||
|
||||
# bash 3.2 (macOS /bin/bash) predates the `mapfile` builtin; this is the portable
|
||||
# equivalent. Reads stdin's lines into the array named by $1, space-safe
|
||||
# (IFS= read -r). The array is reset first, so empty input yields an empty array
|
||||
# rather than a stale or unset one — `set -u` on bash < 4.4 trips on expanding
|
||||
# an array that was never assigned.
|
||||
_read_lines_into() {
|
||||
local _name="$1" _line
|
||||
eval "$_name=()"
|
||||
while IFS= read -r _line; do
|
||||
eval "$_name+=(\"\$_line\")"
|
||||
done
|
||||
}
|
||||
|
||||
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
|
||||
|
||||
_unsafe_state() { # 0 = unsafe
|
||||
@@ -48,7 +61,8 @@ _in_git_repo || { echo "deploy-commit: not a git repo" >&2; exit 2; }
|
||||
case "$cmd" in
|
||||
pending)
|
||||
[ "$#" -gt 0 ] || { echo "deploy-commit: pending needs file args" >&2; exit 2; }
|
||||
mapfile -t violations < <(_scope_violations "$@")
|
||||
violations=()
|
||||
_read_lines_into violations < <(_scope_violations "$@")
|
||||
if [ "${#violations[@]}" -gt 0 ]; then
|
||||
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
|
||||
printf ' - %s\n' "${violations[@]}";
|
||||
@@ -59,21 +73,25 @@ case "$cmd" in
|
||||
commit)
|
||||
msg="${1:-}"; shift || true
|
||||
[ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs <msg> <file>..." >&2; exit 2; }
|
||||
mapfile -t violations < <(_scope_violations "$@")
|
||||
violations=()
|
||||
_read_lines_into violations < <(_scope_violations "$@")
|
||||
if [ "${#violations[@]}" -gt 0 ]; then
|
||||
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
|
||||
printf ' - %s\n' "${violations[@]}";
|
||||
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
|
||||
exit 4
|
||||
fi
|
||||
mapfile -t ignored_paths < <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done)
|
||||
ignored_paths=()
|
||||
_read_lines_into ignored_paths \
|
||||
< <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done)
|
||||
if [ "${#ignored_paths[@]}" -gt 0 ]; then
|
||||
{ echo "deploy-commit: REFUSED — path(s) are git-ignored and will NOT persist; \`.claude/deploy/\` must be committable in this project:";
|
||||
printf ' - %s\n' "${ignored_paths[@]}"; } >&2
|
||||
exit 5
|
||||
fi
|
||||
_unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; }
|
||||
mapfile -t changed < <(_changed_only "$@")
|
||||
changed=()
|
||||
_read_lines_into changed < <(_changed_only "$@")
|
||||
[ "${#changed[@]}" -gt 0 ] || exit 1
|
||||
git add -- "${changed[@]}"
|
||||
if git diff --cached --quiet -- "${changed[@]}"; then
|
||||
|
||||
+15
-2
@@ -25,6 +25,19 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# bash 3.2 (macOS /bin/bash) predates the `mapfile` builtin; this is the portable
|
||||
# equivalent. Reads stdin's lines into the array named by $1, space-safe
|
||||
# (IFS= read -r). The array is reset first, so empty input yields an empty array
|
||||
# rather than a stale or unset one — `set -u` on bash < 4.4 trips on expanding
|
||||
# an array that was never assigned.
|
||||
_read_lines_into() {
|
||||
local _name="$1" _line
|
||||
eval "$_name=()"
|
||||
while IFS= read -r _line; do
|
||||
eval "$_name+=(\"\$_line\")"
|
||||
done
|
||||
}
|
||||
|
||||
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
|
||||
|
||||
# True (0) when the repo is in a state where we must NOT auto-commit:
|
||||
@@ -89,7 +102,7 @@ commit_docs() {
|
||||
# (doc-syncer must never patch .claude/ or CLAUDE.md). Abort the WHOLE commit and
|
||||
# name the offenders — never filter-and-commit-the-rest (that masks the bug).
|
||||
local violations
|
||||
mapfile -t violations < <(_scope_violations "$@")
|
||||
_read_lines_into violations < <(_scope_violations "$@")
|
||||
if [ "${#violations[@]}" -gt 0 ]; then
|
||||
{
|
||||
echo "doc-commit: REFUSED — out-of-scope path(s) in the doc list (upstream BDR-022 violation):"
|
||||
@@ -100,7 +113,7 @@ commit_docs() {
|
||||
return 4
|
||||
fi
|
||||
local changed
|
||||
mapfile -t changed < <(_changed_paths "$@")
|
||||
_read_lines_into changed < <(_changed_paths "$@")
|
||||
if [ "${#changed[@]}" -eq 0 ]; then
|
||||
echo "doc-commit: nothing pending — no-op" >&2
|
||||
return 0
|
||||
|
||||
+33
-1
@@ -30,6 +30,13 @@
|
||||
set -uo pipefail
|
||||
|
||||
TIMEOUT="${GATES_TIMEOUT:-120}"
|
||||
# GNU coreutils' `timeout` ships on Linux but NOT on a stock macOS (Homebrew
|
||||
# installs it as both `timeout` and `gtimeout`). Resolve it once: without it
|
||||
# every check exits 127 and reports NOT-MET whatever the check actually did.
|
||||
# Overridable, and with `-` not `:-` so an explicitly EMPTY value forces the
|
||||
# pure-bash path — that is how the fallback gets exercised on a machine that
|
||||
# does have the binary.
|
||||
GATES_TIMEOUT_BIN="${GATES_TIMEOUT_BIN-$(command -v timeout || command -v gtimeout || true)}"
|
||||
EVIDENCE_CAP=140
|
||||
|
||||
# Module-level parse tables, index-aligned. Bash has no record type; threading
|
||||
@@ -165,9 +172,34 @@ _decisive() { # _decisive <combined-output>
|
||||
|
||||
# Fail-closed: exit 0 AND the marker. A nonzero process never passes because
|
||||
# its error text happens to contain the expected token.
|
||||
# Same contract as `timeout`: run the command, return 124 if it outruns <secs>.
|
||||
# Pure-bash stand-in for a platform shipping neither binary, so the deadline
|
||||
# stays real instead of silently degrading into "every gate NOT-MET".
|
||||
_gates_timeout() { # _gates_timeout <secs> <cmd>...
|
||||
local secs="$1"; shift
|
||||
[ -n "$GATES_TIMEOUT_BIN" ] && { "$GATES_TIMEOUT_BIN" "$secs" "$@"; return $?; }
|
||||
local waited=0 pid
|
||||
"$@" &
|
||||
pid=$!
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
if [ "$waited" -ge "$secs" ]; then
|
||||
# Park the shell's stderr: bash announces a signal-killed job on ITS
|
||||
# stderr, which the caller captures with 2>&1 and would read as output.
|
||||
exec 3>&2 2>/dev/null
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
exec 2>&3 3>&-
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
_run_one() { # _run_one <idx>
|
||||
local i="$1" out rc
|
||||
out="$(timeout "$TIMEOUT" bash -c "${_CHECK[i]}" 2>&1)"
|
||||
out="$(_gates_timeout "$TIMEOUT" bash -c "${_CHECK[i]}" 2>&1)"
|
||||
rc=$?
|
||||
_STATUS[i]="NOT-MET"
|
||||
if [ "$rc" -eq 124 ]; then
|
||||
|
||||
+14
-1
@@ -19,6 +19,19 @@ set -uo pipefail
|
||||
|
||||
MC_PATHS=(".claude/memory" ".claude/tasks")
|
||||
|
||||
# bash 3.2 (macOS /bin/bash) predates the `mapfile` builtin; this is the portable
|
||||
# equivalent. Reads stdin's lines into the array named by $1, space-safe
|
||||
# (IFS= read -r). The array is reset first, so empty input yields an empty array
|
||||
# rather than a stale or unset one — `set -u` on bash < 4.4 trips on expanding
|
||||
# an array that was never assigned.
|
||||
_read_lines_into() {
|
||||
local _name="$1" _line
|
||||
eval "$_name=()"
|
||||
while IFS= read -r _line; do
|
||||
eval "$_name+=(\"\$_line\")"
|
||||
done
|
||||
}
|
||||
|
||||
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
|
||||
|
||||
# True (0) when the repo is in a state where we must NOT auto-commit:
|
||||
@@ -59,7 +72,7 @@ commit_memory() {
|
||||
return 3
|
||||
fi
|
||||
local changed
|
||||
mapfile -t changed < <(_changed_paths)
|
||||
_read_lines_into changed < <(_changed_paths)
|
||||
if [ "${#changed[@]}" -eq 0 ]; then
|
||||
echo "memory-commit: nothing pending — no-op" >&2
|
||||
return 0
|
||||
|
||||
@@ -9,6 +9,9 @@ no() { echo " FAIL $1 — $2"; FAIL=$((FAIL+1)); }
|
||||
# assert stdout of a command contains / omits a fixed string
|
||||
has() { if printf '%s' "$2" | grep -qF -- "$3"; then ok "$1"; else no "$1" "missing: $3"; fi; }
|
||||
hasnt(){ if printf '%s' "$2" | grep -qF -- "$3"; then no "$1" "forbidden: $3"; else ok "$1"; fi; }
|
||||
# Octal permission bits. GNU stat spells it -c %a, BSD stat (macOS) -f %OLp;
|
||||
# neither accepts the other's flag, so try one then the other.
|
||||
perm() { stat -c '%a' "$1" 2>/dev/null || stat -f '%OLp' "$1"; }
|
||||
|
||||
echo "── tokenstore ──"
|
||||
TMP="$(mktemp -d)"; STORE="$TMP/tokens.json"
|
||||
@@ -23,9 +26,9 @@ has "list shows client-a" "$LIST" '"client-a"'
|
||||
has "list shows client-b" "$LIST" '"client-b"'
|
||||
has "list shows a property" "$LIST" 'sc-domain:a.com'
|
||||
hasnt "list redacts refresh tokens" "$LIST" 'RT_AAA'
|
||||
PERM="$(stat -c '%a' "$STORE")"
|
||||
PERM="$(perm "$STORE")"
|
||||
[ "$PERM" = "600" ] && ok "store file is 0600" || no "store file 0600" "got $PERM"
|
||||
DPERM="$(stat -c '%a' "$(dirname "$STORE")")"
|
||||
DPERM="$(perm "$(dirname "$STORE")")"
|
||||
[ "$DPERM" = "700" ] && ok "store dir is 0700" || no "store dir 0700" "got $DPERM"
|
||||
rm -rf "$TMP"
|
||||
|
||||
@@ -136,7 +139,7 @@ import safe_fetch as sf
|
||||
try: sf.safe_fetch("file:///etc/passwd"); print("OK")
|
||||
except sf.UnsafeTarget: print("REFUSED")')"
|
||||
has "non-http scheme refused" "$SCHEME" 'REFUSED'
|
||||
IMP="$(/bin/grep -E "^(import|from) " "$SD/safe_fetch.py" | /bin/grep -cvE "gzip|http\.client|ipaddress|socket|ssl|urllib\.parse")"
|
||||
IMP="$(grep -E "^(import|from) " "$SD/safe_fetch.py" | grep -cvE "gzip|http\.client|ipaddress|socket|ssl|urllib\.parse" | tr -d ' ')"
|
||||
[ "$IMP" = "0" ] && ok "safe_fetch is stdlib-only" || no "safe_fetch is stdlib-only" "$IMP non-stdlib imports"
|
||||
hasnt "no requests dependency" "$(cat "$SD/safe_fetch.py")" 'import requests'
|
||||
|
||||
@@ -477,7 +480,7 @@ has "clear reports ok" "$CL" '"status": "ok"'
|
||||
has "clear reports count" "$CL" '"cleared": 1'
|
||||
L7="$(python3 "$SD/tokenstore.py" list --file "$S6")"
|
||||
has "clear empties store" "$L7" '"accounts": []'
|
||||
PERM6="$(stat -c '%a' "$S6")"
|
||||
PERM6="$(perm "$S6")"
|
||||
[ "$PERM6" = "600" ] && ok "store stays 0600 after clear" || no "store 0600 after clear" "got $PERM6"
|
||||
# via the real fetch.sh dispatch layer
|
||||
python3 "$SD/tokenstore.py" set --file "$S6" --label back --refresh-token RT_BACK \
|
||||
|
||||
+5
-2
@@ -4,7 +4,9 @@
|
||||
# EXCL="$(bash ~/.claude/lib/source-scope.sh grep)"
|
||||
# grep -rl "gtag" $EXCL --include="*.html" . # note: $EXCL unquoted
|
||||
#
|
||||
# mapfile -t FEXCL < <(bash ~/.claude/lib/source-scope.sh findargs)
|
||||
# FEXCL=(); while IFS= read -r t; do FEXCL+=("$t"); done \
|
||||
# < <(bash ~/.claude/lib/source-scope.sh findargs)
|
||||
# (a read loop, not mapfile: macOS /bin/bash is 3.2 and has no mapfile)
|
||||
# find . "${FEXCL[@]}" -iname '*.jpg' -printf '%s %p\n' # quoted array!
|
||||
#
|
||||
# findargs emits ONE TOKEN PER LINE and MUST be consumed through a quoted
|
||||
@@ -72,7 +74,8 @@ case "${1:-}" in
|
||||
list) _list ;;
|
||||
# Safe unquoted: --exclude-dir=NAME carries no glob character.
|
||||
grep) _list | while read -r d; do printf -- '--exclude-dir=%s ' "$d"; done; echo ;;
|
||||
# One token per line — consume with mapfile + a QUOTED array, never a flat
|
||||
# One token per line — consume with a read loop into a QUOTED array (see
|
||||
# the header: mapfile is bash 4+), never a flat
|
||||
# string (see header: the shell would glob */dist/* against the CWD).
|
||||
findargs) _list | while read -r d; do printf '!\n-path\n*/%s/*\n' "$d"; done ;;
|
||||
*) _die "usage: source-scope.sh {list|grep|findargs}" ;;
|
||||
|
||||
@@ -36,17 +36,20 @@ check T4-none "$(bash "$L" detect "$tmp/cpp" >/dev/null 2>&1; echo $?)" 1
|
||||
check T5-missing "$(bash "$L" cache-status "$tmp/js" || true)" missing
|
||||
mkdir -p "$tmp/js/.ctx7-cache"; touch "$tmp/js/.ctx7-cache/react-core.md"
|
||||
check T6-fresh "$(bash "$L" cache-status "$tmp/js")" fresh
|
||||
touch -d '10 days ago' "$tmp/js/.ctx7-cache/react-core.md"
|
||||
# `touch -d '10 days ago'` is GNU-only; BSD touch (macOS) wants -t with an
|
||||
# absolute stamp. perl's utime is the one spelling both platforms ship.
|
||||
perl -e 'my $t = time - 10*86400; utime $t, $t, $ARGV[0]' \
|
||||
"$tmp/js/.ctx7-cache/react-core.md"
|
||||
check T7-stale "$(bash "$L" cache-status "$tmp/js" || true)" stale
|
||||
|
||||
# --- hook: fires once per session, silent on stable projects ---
|
||||
hook() { printf '{"prompt":"add a hook","session_id":"%s","cwd":"%s"}' \
|
||||
"$1" "$2" | TMPDIR="$tmp" bash "$H"; }
|
||||
check H1-fires "$(hook s1 "$tmp/js" | grep -c 'Fast-moving')" 1
|
||||
check H2-once "$(hook s1 "$tmp/js" | wc -l)" 0
|
||||
check H3-cpp-quiet "$(hook s2 "$tmp/cpp" | wc -l)" 0
|
||||
check H2-once "$(hook s1 "$tmp/js" | wc -l | tr -d ' ')" 0
|
||||
check H3-cpp-quiet "$(hook s2 "$tmp/cpp" | wc -l | tr -d ' ')" 0
|
||||
check H4-notif-quiet \
|
||||
"$(printf '{"prompt":"<task-notification>x","session_id":"s3","cwd":"%s"}' \
|
||||
"$tmp/js" | TMPDIR="$tmp" bash "$H" | wc -l)" 0
|
||||
"$tmp/js" | TMPDIR="$tmp" bash "$H" | wc -l | tr -d ' ')" 0
|
||||
|
||||
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
|
||||
|
||||
@@ -21,7 +21,10 @@ has() { printf '%s\n' "$1" | $GREP -qF -- "$2"; } # substring present in multi
|
||||
|
||||
echo "=== T1 recursive coherence — enumerate from BODY, never the ## Index ==="
|
||||
DRIFT="$FIX/registry-index-drift.md"
|
||||
mapfile -t IDS < <(reconcile_enumerate_ids "$DRIFT" LRN)
|
||||
# bash 3.2 (macOS /bin/bash) has no mapfile; read the ids explicitly. IDS is
|
||||
# seeded empty so `set -u` cannot trip on an unset array below.
|
||||
IDS=(); while IFS= read -r _id; do IDS+=("$_id"); done \
|
||||
< <(reconcile_enumerate_ids "$DRIFT" LRN)
|
||||
if [ "${#IDS[@]}" -eq 72 ]; then ok "T1a enumerated 72 body ids"; else no "T1a got ${#IDS[@]}, expected 72 (an Index-reader gives 51)"; fi
|
||||
if printf '%s\n' "${IDS[@]}" | $GREP -qx "LRN-020"; then ok "T1b includes body-only canary LRN-020"; else no "T1b dropped LRN-020 — read the Index, not the body"; fi
|
||||
# teeth: an Index-based enumerator would RED here (the fixture discriminates)
|
||||
|
||||
@@ -38,7 +38,11 @@ echo
|
||||
( cd "$WORK" || exit 1
|
||||
bash "$GITFLOW" start release 4.0.0 >/dev/null # base develop → release/4.0.0 (lib L49/L71)
|
||||
printf '4.0.0\n' > version.txt # prep: version bump
|
||||
sed -i 's/## \[Unreleased\]/## [Unreleased]\n\n## [4.0.0] — 2026-06-30/' CHANGELOG.md
|
||||
# awk, not `sed -i`: BSD sed (macOS) needs a suffix argument after -i AND
|
||||
# does not expand \n in the replacement — the edit silently did nothing
|
||||
# there, so the CHANGELOG assertion below failed for the wrong reason.
|
||||
awk '{ print; if ($0 == "## [Unreleased]") { print ""; print "## [4.0.0] — 2026-06-30" } }' \
|
||||
CHANGELOG.md > CHANGELOG.tmp && cat CHANGELOG.tmp > CHANGELOG.md && rm -f CHANGELOG.tmp
|
||||
git commit -qam "chore(release): 4.0.0 — version.txt + CHANGELOG"
|
||||
bash "$GITFLOW" finish >/dev/null # fan-out main+develop+delete (lib L108-111)
|
||||
# TAG = the gap. Lives in the SKILL (lib untouched). RED skips it, GREEN does it.
|
||||
|
||||
@@ -45,8 +45,10 @@ case "$G" in *"*"*) check C2-grep-has-no-glob "has-glob" ok ;;
|
||||
*) check C2-grep-has-no-glob ok ok ;; esac
|
||||
|
||||
# --- findargs: one token per line, 3 tokens per dir ---
|
||||
N="$(cd "$TMP/plain" && bash "$S" findargs | wc -l)"
|
||||
D="$(cd "$TMP/plain" && bash "$S" list | wc -l)"
|
||||
# BSD wc -l pads its count with leading spaces, GNU does not — strip them
|
||||
# or the string compare below fails on macOS with got[ 48] want[48].
|
||||
N="$(cd "$TMP/plain" && bash "$S" findargs | wc -l | tr -d ' ')"
|
||||
D="$(cd "$TMP/plain" && bash "$S" list | wc -l | tr -d ' ')"
|
||||
check D1-findargs-3-tokens-per-dir "$N" "$((D * 3))"
|
||||
check D2-findargs-first-token "$(cd "$TMP/plain" && bash "$S" findargs | head -1)" '!'
|
||||
|
||||
@@ -58,12 +60,14 @@ W="$TMP/work"; mkdir -p "$W/src" "$W/dist" "$W/public" "$W/node_modules"
|
||||
: > "$W/src/a.png"; : > "$W/dist/a.png"; : > "$W/public/favicon.ico"
|
||||
: > "$W/node_modules/dep.png"
|
||||
cd "$W" || exit 1
|
||||
mapfile -t FEXCL < <(bash "$S" findargs)
|
||||
# bash 3.2 (macOS /bin/bash) has no mapfile — read the lines explicitly.
|
||||
FEXCL=(); while IFS= read -r _tok; do FEXCL+=("$_tok"); done \
|
||||
< <(bash "$S" findargs)
|
||||
check E1-excludes-dist "$(find . "${FEXCL[@]}" -name 'a.png' | grep -c '/dist/')" 0
|
||||
check E2-keeps-src "$(find . "${FEXCL[@]}" -name 'a.png' | grep -c '/src/')" 1
|
||||
check E3-excludes-nodem "$(find . "${FEXCL[@]}" -name '*.png' | grep -c 'node_modules')" 0
|
||||
# public/ survives: the audit's own resource checks live there
|
||||
check E4-keeps-public "$(find . "${FEXCL[@]}" -name 'favicon.ico' | wc -l)" 1
|
||||
check E4-keeps-public "$(find . "${FEXCL[@]}" -name 'favicon.ico' | wc -l | tr -d ' ')" 1
|
||||
cd / || exit 1
|
||||
|
||||
# --- usage ---
|
||||
|
||||
+8
-3
@@ -41,9 +41,14 @@ _rest_charset_ok() ( LC_ALL=C; case "$1" in
|
||||
# Literal local/private/metadata targets. This is a LITERAL check, not a DNS
|
||||
# one: it stops the obvious, not a hostname that resolves inward.
|
||||
_host_is_local() ( LC_ALL=C
|
||||
# ${1,,} not tr: no fork, and no SC2018/SC2019 noise. Safe because the
|
||||
# charset guard has already run — the string is [A-Za-z0-9.-] by here.
|
||||
case "${1,,}" in
|
||||
# `nocasematch` not ${1,,}: the lowercase expansion is bash 4.0+, and macOS
|
||||
# ships bash 3.2 as /bin/bash — there it raised "bad substitution" and the
|
||||
# subshell exited 1, i.e. "not local", so EVERY local/private/metadata host
|
||||
# was allowed through. Keeps the no-fork property the lowercase form had.
|
||||
# Safe because the charset guard has already run — the string is
|
||||
# [A-Za-z0-9.-] by here, and LC_ALL=C keeps the folding ASCII-only.
|
||||
shopt -s nocasematch
|
||||
case "$1" in
|
||||
localhost|*.localhost|*.local|0.0.0.0|broadcasthost) exit 0 ;;
|
||||
127.*|10.*|169.254.*|192.168.*) exit 0 ;;
|
||||
172.1[6-9].*|172.2[0-9].*|172.3[01].*) exit 0 ;;
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.9.15
|
||||
0.9.16
|
||||
Reference in New Issue
Block a user