19 Commits
Author SHA1 Message Date
bmottin 740c3d8c3e Merge chore/gitleaks-allowlist-runtime-state into develop 2026-09-15 22:04:36 -04:00
bmottin e59e26890e chore(gitleaks): allowlist Claude Code daemon roster + per-session keys
`make scan-secrets` flagged 4 generic-api-key hits in ~/.claude, all written
by Claude Code itself: roster.json's rendezvousSock/ptySock unix-socket paths
and sessionId UUID (long, high-entropy, not credentials), and two per-worker
session keys (0600). Same class as the ide/*.lock entry above — machine-local,
ephemeral, and unreachable from a commit: link.sh exposes exactly seven repo
symlinks under ~/.claude and neither daemon/ nor sessions/ is among them, so
`git ls-files` can never see them.

Left unfixed they would redden every sweep, which is the failure mode the
.env entry already argues against — a permanently red scan stops being read.

Scoped to the two exact filenames rather than the directories, and verified:
fake secrets planted beside them in the same dirs are still caught, and the
repo's own history stays clean (745 commits, no leaks).
2026-09-15 22:01:50 -04:00
bmottin 98a8322010 Merge chore/correct-blk022-flaky-baseline into develop 2026-09-15 21:47:19 -04:00
bmottin 91ea02d990 chore(memory): correct BLK-022 — gitflow-test.sh is flaky, the before/after was noise
I recorded "92/14 on pristine develop vs 93/13 after — nothing worsened, one
case better". Three consecutive runs on one unchanged tree give 90/16, 92/14,
92/14: the suite is non-deterministic and both figures were single samples of
that spread. The port is indistinguishable from the noise; claiming an
improvement was false precision.

Surfaced because the post-merge run reported 90/16 and looked like a
regression. It was not — but the only reason the question could be settled
was re-running, which is exactly what the original claim had skipped.
2026-09-15 21:45:47 -04:00
bmottin 2e94538698 Merge chore/macos-port-capitalize into develop 2026-09-15 21:43:09 -04:00
bmottin 0909f38742 Merge chore/graphify-0.9.16 into develop 2026-09-15 21:43:09 -04:00
bmottin 24066d761c Merge chore/sweep-bdr019-makefile into develop 2026-09-15 21:43:08 -04:00
bmottin 1663b09bc5 Merge bugfix/macos-installer into develop 2026-09-15 21:43:07 -04:00
bmottin 09727c7f8a Merge bugfix/macos-gnu-coreutils into develop 2026-09-15 21:43:07 -04:00
bmottin 785e7922c5 Merge bugfix/macos-bash32-portability into develop 2026-09-15 21:43:06 -04:00
bmottin 1da870bd67 Merge bugfix/url-guard-ssrf-bash32 into develop 2026-09-15 21:42:56 -04:00
bmottin d8e516e976 chore(graphify): sync skill marker to 0.9.16
`graphify install` regenerated the skill files against the installed 0.9.16
CLI; only `.graphify_version` moved — SKILL.md and both references are
byte-identical to what 0.9.15 already shipped, so this clears the CLI's
"skill is from graphify 0.9.15, package is 0.9.16" warning with no content
change to review.

CLAUDE.md was clobbered by the installer (it appends its own `# graphify`
section) and restored from a pre-run snapshot, per BDR-028.
2026-09-15 21:40:07 -04:00
bmottin 66012a97a7 chore(memory): BLK-021/022, LRN-150/151, BDR-088, EVAL-029 — macOS port
Capitalizes the macOS port and the gstack Chromium deadlock, plus an
append-only correction to BLK-008 / LRN-038: their "ubuntu24.04 fallback
build" cause is refuted — macOS arm64 has a native Playwright 1.58.2 build,
no fallback, and the same hang reproduces. The real variable was the Node
version, and that wrong record misdirected this investigation for an hour.

EVAL-029 records two process failures worth keeping: the first fix
recommendation (pin node@22) was reversed only because the user asked
whether the browser was current — staleness had gone unpriced; and the grep
sweep returned empty twice while defects were present, once to `set -e`,
once to a pattern that could not match `${1,,}`.

Index rows added for all four registries. Pre-existing index drift
(BLK-018..020, LRN-144..149) left alone — backfilling means summarising
entries someone else wrote.
2026-09-15 21:39:22 -04:00
bmottin b2ec97889f chore(make): drop disable-model-invocation from the new-skill template
BDR-019 stripped the key from all 27 skills because `true` blocks model AND
orchestrator routing — silently breaking the routing CLAUDE.md describes —
while `false` was a no-op noise line. The scaffold kept injecting `true`, so
every skill created with `make new-skill` would have shipped unroutable.

doctor.sh already covers the other half upstream: its check was inverted to
warn on REINTRODUCTION rather than on absence, which is what surfaced this —
a new skill from the template would now trip that very guard.
2026-09-13 17:21:30 -04:00
bmottin 28211a78ea fix(install): unblock the installer on macOS — BSD sed, and a deadline on Chromium
Two independent failures, both of which stopped `make plugin` outright here.

GNU `sed -i` takes no suffix argument; BSD sed requires one and reads the
script as that suffix, so all three calls errored — and under `set -euo
pipefail` that aborts the installer. They go through a temp file now, written
back with `cat >` so the profile keeps its mode and owner.

While rewriting them: the orphan-comment cleanup `{N; /^\n$/d;}` could never
match on ANY platform — after N the pattern space starts with '#', so the
^\n$ anchor pair never applied. It was a silent no-op, now awk, and tested on
/bin/bash 3.2: both obsolete entries and their stranded headers go, the live
entry and the user's own lines stay, idempotent on re-run.

The second failure was worse because it was invisible: gstack's ./setup runs
`bunx playwright install chromium` unbounded, and Playwright 1.58.2 deadlocks
on Node 26 mid-extraction — both processes idle at 39/333 files, no error, no
progress, forever. That silently cut the 2026-09-13 run at step 2 of 10.
Chromium is now installed here instead, under a 900s deadline; on timeout the
installer bumps gstack's Playwright (its package.json declares "^1.x", so a
minor bump is in range) and retries once, then warns rather than aborts —
gstack is OFF by default and only its browser depends on this.

`timeout` is not in a stock macOS, so the deadline is pure bash. Proven by a
forced hang: rc 124 in 6s, no orphaned oopDownloadBrowserMain, stderr clean.
2026-09-13 17:21:23 -04:00
bmottin a4565c80c3 fix(portability): stop assuming GNU coreutils flags on macOS
BSD userland rejects or silently ignores several GNU spellings the repo used:

- `timeout` is not in a stock macOS at all (Homebrew installs it, and also as
  `gtimeout`). Without it every gates.sh check exited 127 and was recorded
  NOT-MET whatever the check actually did — a systematic false negative.
  The binary is now resolved once, with a pure-bash deadline behind it so the
  124 contract still holds where neither binary exists. GATES_TIMEOUT_BIN is
  overridable with `-` not `:-`, so an empty value forces that fallback: the
  suite passes 64/64 both ways, timeout cases included.
- `touch -d '10 days ago'` is GNU-only; perl's utime is the one spelling both
  platforms ship.
- BSD `wc -l` pads its count with leading spaces, so string compares failed as
  got[      48] want[48].
- `sed -i` needs a suffix argument on BSD AND does not expand \n in the
  replacement, so the release-candidate CHANGELOG edit silently did nothing
  and the assertion failed for the wrong reason. Rewritten in awk; the RC_TAG=0
  mode still REDs on the absent tag, so the test keeps its teeth.
- `/bin/grep` does not exist on macOS (grep lives in /usr/bin), and `stat -c`
  is GNU-only.

fast-libs 11/11, seo-data 221/221, release-candidate 5 GREEN / 0 RED.
2026-09-13 17:21:09 -04:00
bmottin f3919b6ace fix(portability): replace bash 4 builtins absent from macOS bash 3.2
macOS ships bash 3.2 as /bin/bash, which `#!/usr/bin/env bash` resolves to
when no newer bash is on PATH. Two builtins the repo relies on do not exist
there, and both failed SILENTLY:

- `mapfile` in the three surgical-commit helpers left every array empty, so
  the scope guards passed on nothing (fail-OPEN) and the commits degraded to
  "nothing pending — no-op" while reporting success. deploy-commit.test.sh
  went 4/16; memory and doc commits simply never happened.
- `declare -A` in the session-start hook errored on every session and left
  each plugin cost at 0, so the passive-budget warning could never fire.

`_read_lines_into` is the portable equivalent of `mapfile`, space-safe and
resetting its target first — expanding a never-assigned array trips `set -u`
on bash < 4.4, which is how the empty arrays surfaced as "unbound variable".
Plugin costs move to a `case`.

source-scope.sh's header prescribed `mapfile` to its callers; it now shows
the read loop, and its own test plus run-reconcile.sh stop using the builtin.

deploy-commit 16/16, source-scope 34/34, run-reconcile 25 GREEN / 0 RED,
session-start stderr empty.
2026-09-13 17:20:57 -04:00
bmottin 5efc506197 fix(url-guard): restore the SSRF guard on bash 3.2
`${1,,}` is bash 4.0+. macOS ships bash 3.2 as /bin/bash, where it raises
"bad substitution"; the subshell then exited 1 — read as "not local" — so
`url-guard.sh host` returned rc 0 for localhost, 127.x, 10.x, 192.168.x,
172.16-31.x, 169.254.169.254 and metadata.google.internal. The guard failed
OPEN on every Mac, and url-guard.test.sh recorded it as 13 "got[0] want[2]".

`shopt -s nocasematch` (bash 3.1+) keeps both the ASCII-only folding that
LC_ALL=C gives and the no-fork property the lowercase expansion had.

Verified on /bin/bash 3.2: the ten local/private/metadata targets now return
rc 2 (case-folded variants included), legitimate hosts still rc 0.
2026-09-13 17:20:43 -04:00
Bastien Chanot a53a5a26a8 Merge release/1.5.0 into develop 2026-09-13 21:26:44 +02:00
22 changed files with 351 additions and 44 deletions
+25
View File
@@ -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.
+9
View File
@@ -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]].
+7
View File
@@ -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.
+6
View File
@@ -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.
+18
View File
@@ -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`).
+32
View File
@@ -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).
+17
View File
@@ -84,3 +84,20 @@ stopwords = ['''publish/reject''']
[[allowlists]]
description = "Claude Code IDE lock files"
paths = ['''(^|/)ide/[0-9]+\.lock$''']
# ── 2026-09-15 — Claude Code daemon/session runtime state, triaged on macOS.
# Same class as the IDE locks above: written by Claude Code itself, machine-
# local, ephemeral. Verified before allowlisting — roster.json's hits are the
# rendezvousSock/ptySock unix-socket paths and the sessionId UUID (paths, not
# credentials); sessions/*.key IS a real per-worker key, but 0600 and outside
# git. Neither directory is reachable from a commit: link.sh exposes exactly
# seven repo symlinks under ~/.claude (CLAUDE.md, settings.json, hooks, agents,
# skills, lib, templates) and these are not among them, so `git ls-files` can
# never see them. Scoped to the two exact filenames, NOT to the directories —
# a stray copy landing beside them stays detected.
[[allowlists]]
description = "Claude Code daemon roster + per-session keys (machine-local runtime state)"
paths = [
'''(^|/)\.claude/daemon/roster\.json$''',
'''(^|/)\.claude/sessions/[0-9]+\.[0-9a-f]{64}\.key$''',
]
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -4
View File
@@ -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
View File
@@ -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}" ;;
+7 -4
View File
@@ -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 ]
+4 -1
View File
@@ -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)
+5 -1
View File
@@ -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.
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
0.9.15
0.9.16