198 Commits

  • docs+chore: add README Security section; fix lint regressions on main
    - README: add a visible ## Security section (official sources, vuln reporting via SECURITY.md, GateGuard/IOC/AgentShield guardrails, security guide); make stats line a plain paragraph to clear MD028
    - eslint: empty catch comment in run-with-flags.js; drop unneeded escape in github-coordination/parsing.js; remove unused execFileSync import in its test (#2236 follow-ups)
    - markdownlint: wrap bare URLs in rules/vue/*.md (#2250 follow-up)
    
    npm run lint green; full suite 2836/2836.
  • fix: detect destructive find -exec commands in gateguard (#2267)
    * fix: detect destructive find exec commands in gateguard
    
    * chore: ignore aider local files
  • feat: add dry-run mode for hook execution (#2116) (#2188)
    - Global --dry-run flag and ECC_DRY_RUN=1 env var
    - Enriched preview: shows target file path, tool name, and command
    - --dry-run stripped from argv so command routing works correctly
    - Handles non-JSON and empty stdin gracefully (session/stop hooks)
    - 10 tests covering isDryRun(), hook gating, enriched output, CLI routing
  • fix(hooks): stop pre/post Bash dispatcher from echoing the input event (#2240)
    runHooks() returned the unmodified raw stdin (the PreToolUse/PostToolUse
    input event) on stdout whenever no sub-hook produced additionalContext.
    Claude Code parses a hook's stdout as JSON and validates it against the
    hook-output schema, so echoing the input object
    ({session_id, hook_event_name, tool_name, tool_input, ...}) fails with
    "Hook JSON output validation failed — (root): Invalid input" on nearly
    every Bash command.
    
    Track whether a sub-hook deliberately set stdout (string / {stdout}, e.g.
    GateGuard) via a rawModified flag and emit '' in the pass-through case
    instead of the echoed input. Preserves GateGuard pass-through and
    block-no-verify's exit-2 blocking.
    
    Update the three dispatcher tests that codified the buggy echo behavior to
    expect empty stdout, and add a regression test for a plain pass-through
    command.
    
    Fixes #2239
    
    Co-authored-by: WOZCODE <contact@withwoz.com>
  • fix: context-size /compact trigger, Codex marketplace plugin path, live README badges (#2237)
    - suggest-compact hook now reads the latest usage record from the session
      transcript and suggests /compact at a window-scaled token threshold
      (160k/200k window, 250k/1M window; COMPACT_CONTEXT_THRESHOLD and
      COMPACT_CONTEXT_INTERVAL overridable), re-firing per 60k-token growth
      bucket; tool-call count stays as the secondary signal (#2155)
    - Codex repo marketplace now points at ./plugins/ecc instead of ./ — Codex
      never discovers plugins whose local marketplace source.path is the
      marketplace root (verified on Codex CLI 0.137.0); plugins/ecc is a thin
      folder referencing root skills/.mcp.json per maintainer direction on
      #2097; docs flag plugin mode as experimental with the upstream blocker
      openai/codex#26037 linked (#2128)
    - README badges for installs/stars/forks now use shields endpoint badges
      backed by api.ecc.tools (live install count 3,712 vs the stale static
      150), which also eliminates shields' 'Unable to select next GitHub token
      from pool' render in the stars badge
    
    Closes #2155
    Closes #2128
  • fix: stability batch — hook stdin truncation, Codex exa TOML, Stop hook JSON, GateGuard repetition (#2227)
    * fix(hooks): fail open on oversized stdin instead of echoing truncated JSON (#2222)
    
    run-with-flags.js capped stdin at 1MB but every fallthrough path still
    echoed the truncated string to stdout. The harness parses hook stdout as
    JSON, got a document cut mid-stream, and blocked the tool call — so any
    Edit/Write with a >1MB hook payload was permanently blocked by every
    registered pre-write hook, before ECC_HOOK_PROFILE / ECC_DISABLED_HOOKS
    gating could run.
    
    - Exit 0 with empty stdout (no opinion) when the stdin cap trips, before
      any echo or gating logic.
    - Flush stdout via write callback before process.exit: exiting right
      after stdout.write() dropped everything past the ~64KB pipe buffer,
      cutting even sub-cap pass-through payloads mid-JSON.
    
    Regression tests cover the enabled, disabled, and missing-arg paths for
    oversized payloads plus full echo of sub-cap >64KB payloads.
    
    * fix(codex): stop emitting invalid exa url entry, align merge with connector policy (#2224)
    
    The Codex MCP merge declared exa with a url key, but Codex's
    [mcp_servers.*] TOML schema is stdio-only — the url key makes the
    entire config.toml fail to load, bricking both the codex CLI and the
    desktop app. Every install/update re-injected the line because the
    urlEntry branch treated the broken entry as present.
    
    - ECC_SERVERS now emits only the current default set per
      docs/MCP-CONNECTOR-POLICY.md: chrome-devtools (stdio, command/args).
      Retired servers (supabase, playwright, context7, exa, github, memory,
      sequential-thinking) are never re-emitted; existing user-managed
      entries are untouched.
    - The merge now repairs the exact ECC-emitted broken form (url-only
      exa entry) on every run so re-running the installer fixes broken
      configs instead of preserving them. User stdio exa entries
      (command + mcp-remote) are left alone.
    - check-codex-global-state.sh requires chrome-devtools instead of the
      retired set, and flags url-only exa entries with a repair hint.
    
    Tests cover repair, re-run idempotence, stdio-entry preservation, and
    no-retired-server emission in add, update, dry-run, and disabled modes.
    
    * fix(hooks): never echo truncated stdin from Stop hooks (#2090)
    
    Stop hooks follow the ECC pass-through convention (echo stdin on
    stdout), but every echoing Stop hook capped stdin and echoed the capped
    string. The Stop payload carries last_assistant_message, so a long
    final assistant message produced a JSON document cut mid-stream on
    stdout, which the harness reports as 'Stop hook error: JSON validation
    failed' across the whole Stop chain.
    
    Reproduced: a Stop payload with a >64KB last_assistant_message run
    through run-with-flags + cost-tracker emitted exactly 65536 bytes of
    invalid JSON (cost-tracker capped stdin at 64KB — far below realistic
    Stop payloads).
    
    - cost-tracker: raise the cap to 1MB (matching all other hooks) and
      suppress the pass-through echo when stdin was truncated.
    - check-console-log, stop-format-typecheck, desktop-notify: suppress
      the echo when stdin was truncated; flush stdout before process.exit
      so sub-cap payloads are not cut at the ~64KB pipe buffer.
    - All hooks keep exiting 0 (fail-open); diagnostics go to stderr.
    
    New stop-hooks-stdout test asserts the contract for every registered
    Stop hook: stdout is empty or valid JSON, exit code 0 — for realistic
    100KB payloads and oversized >1MB payloads, via the production runner
    and via direct invocation. Updated the old hooks.test.js case that
    codified the truncated-echo behavior.
    
    * fix(hooks): dampen GateGuard fact-force repetition in long sessions (#2142)
    
    In long autonomous sessions the fact-force gate produced 10+
    near-identical 'state facts -> blocked -> restate -> retry' blocks in
    one context window, which measurably raises the odds of the model
    collapsing into a degenerate single-token repetition loop.
    
    - Track a per-session fact_force_denials counter in GateGuard state
      (merged max across concurrent writers, reset with the session, robust
      to malformed on-disk values).
    - The first GATEGUARD_FACT_FORCE_FULL_DENIALS denials (default 3) keep
      the full four-fact block; later denials emit a condensed single-line
      message that carries the denial ordinal, so consecutive denials are
      structurally different and never textually identical.
    - True retries of the same target remain allowed without re-prompting
      (unchanged). Destructive-Bash and routine-Bash gates are unchanged,
      as are the ECC_GATEGUARD=off / ECC_DISABLED_HOOKS escape hatches.
    
    Eight new tests cover budget counting, condensed format, ordinal
    advancement, retry pass-through, env tuning, malformed state, MultiEdit
    dampening, and destructive-gate exemption.
    
    * fix(hooks): keep security hooks able to block on oversized stdin (#2222)
    
    Refine the truncation fail-open: instead of skipping the hook entirely,
    the runner now suppresses only its own raw-echo when stdin was
    truncated. The hook still executes and receives the truncated flag
    (run() context / ECC_HOOK_INPUT_TRUNCATED), so config-protection keeps
    blocking truncated protected-config payloads (its test requires exit 2)
    while pass-through hooks fail open with empty stdout as before.
    
    * style: apply repo formatter to touched hook files
  • fix: make plugin hooks run on Node 21+ and green the suite under modern Node (#2184)
    ROOT CAUSE: hooks load plugin-hook-bootstrap.js via
    `node -e "...; process.argv.splice(1,0,s); require(s)"`. On Node 21+,
    require.main is `undefined` under --eval, so the `if (require.main === module)`
    guard was false and main() never ran — every plugin hook silently no-op'd
    (e.g. the MCP-health PreToolUse hook stopped blocking). CI (Node 18/20) hid
    this; it only surfaces on Node 21+. Fix: also run main() when require.main is
    undefined (the eval-bootstrap case), while staying dormant on real imports.
    
    Also clears pre-existing main debt the full local suite enforces:
    - catalog:sync — README/docs agent+skill counts drifted after recent merges
    - tests/ci/supply-chain-watch-workflow: update checkout SHA to the merged v6.0.3 (#2183)
    - markdownlint + check-unicode-safety --write across docs/skills
    
    Suite: 2683/2683 green under Node v25; lint + unicode clean.
    
    Co-authored-by: ECC Test <ecc@example.test>
  • feat: Cursor-independent ECC memory via ECC_AGENT_DATA_HOME (#2066)
    * feat: auto-isolate ECC memory data for Cursor via ECC_AGENT_DATA_HOME
    
    Add ECC_AGENT_DATA_HOME (defaults to ~/.claude) with Cursor-aware resolution,
    sessionStart env injection, install scaffolds, and hook bootstrap so memory
    hooks do not collide with Claude Code when both harnesses are used.
    
    Closes #2065
    
    Co-authored-by: Cursor <cursoragent@cursor.com>
    
    * fix: log agent-data config errors and ship cursor sessionStart deps
    
    Address CodeRabbit review: log invalid .cursor/ecc-agent-data.json parse
    failures, and copy cursor-session-env.js plus lib deps on legacy Cursor
    install so sessionStart hook path exists without hooks-runtime alone.
    
    Co-authored-by: Cursor <cursoragent@cursor.com>
    
    * fix: resolve relative agentDataHome paths from project root
    
    Project config values like ".ecc-data" now resolve against the
    repository root (parent of .cursor/), not process.cwd(), so Cursor
    hooks persist memory in the intended directory regardless of hook cwd.
    
    Addresses cubic review on PR #2066.
    
    Co-authored-by: Cursor <cursoragent@cursor.com>
    
    * docs: explain getHomeDir duplicate and docstring policy
    
    Document why agent-data-home keeps a local home-dir helper (circular
    require with utils.js) and list consolidation options for maintainers.
    Note that CodeRabbit JSDoc coverage warnings are informational relative
    to ECC's usual script documentation style.
    
    Addresses cubic P2 context on PR #2066.
    
    Co-authored-by: Cursor <cursoragent@cursor.com>
    
    * test: isolate agent-data-home tests from dogfooded .cursor config
    
    Use isolated temp cwd for default-resolution cases and assert
    resolveAgentDataHome({ projectDir }) reads ecc-agent-data.json.
    Document cwd/project caveats in the test file header.
    
    Co-authored-by: Cursor <cursoragent@cursor.com>
    
    ---------
    
    Co-authored-by: Cursor <cursoragent@cursor.com>
  • fix(context-monitor): make cost warnings informational, not commands (#2091)
    The PostToolUse cost warnings emit imperative text via additionalContext
    ("Stop and inform the user...", "Review whether...", "Consider whether...").
    Subagents read additionalContext as an instruction and obey the "Stop",
    abandoning their task and returning a prompt-for-direction instead of their
    result — derailing multi-agent workflows. The main loop is also nudged to
    halt mid-task.
    
    Reword all three severities to pure-informational data: keep the
    CRITICAL/WARNING/NOTICE label + the dollar figure (and the threshold), drop
    the imperative sentence, and state plainly it is informational. No logic,
    severity, or threshold change. Existing tests pass (they assert the labels +
    severities, which are preserved).
    
    Before: `COST CRITICAL: Session cost is $X. Stop and inform the user about high cost before continuing.`
    After:  `COST CRITICAL: session total ~$X (over $50). Informational only — not an instruction to stop.`
    
    Co-authored-by: OrenG Tools <tools@orengacademy.com>
  • feat(desktop-notify): route OSC 9 notifications through Ghostty (#2114)
    Ghostty natively supports the OSC 9 desktop-notification escape
    (ESC ] 9 ; <message> BEL), the same sequence already used for iTerm2.
    Previously only TERM_PROGRAM === 'iTerm.app' took the escape path, so
    Ghostty users fell through to the osascript path. That makes Script
    Editor the notification owner, and clicking the notification just
    launches Script Editor instead of focusing the terminal.
    
    Adding 'ghostty' to the OSC 9-capable check makes Ghostty the owner,
    so clicking the notification focuses the Ghostty window/tab where
    Claude Code is running. Verified on Ghostty (TERM_PROGRAM=ghostty).
    
    Co-authored-by: 高野智史 <satoshitakano@takanosatoshinoMacBook-Pro-522.local>
    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • fix(hooks): stop false loop warnings and repeated identical context warnings (#2121)
    * fix(hooks): stop false loop warnings and repeated identical context warnings
    
    Two PostToolUse monitor defects surfaced during a long single-turn session:
    
    1. ecc-metrics-bridge hashToolCall fingerprinted Edit/Write/MultiEdit on
       file_path ONLY, so several distinct edits to the same file produced the
       same hash and tripped the loop detector ("stuck loop") even though every
       edit was different. Now the hash includes the edit content
       (old_string/new_string/content/edits) so distinct edits to one file hash
       differently; identical edits still collide as intended.
    
    2. ecc-context-monitor re-emitted the SAME warning every DEBOUNCE_CALLS (5)
       tool calls even when nothing changed. Because the cost figure only refreshes
       at Stop (turn) boundaries, a single stale value printed the identical
       warning ~20 times within one turn. Dedupe on message content instead: a
       warning surfaces only when its text changes (cost moved, new file count, new
       loop) or on first escalation to critical, and is otherwise suppressed.
    
    Adds regression tests for the same-file/different-content hash case.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    
    * fix(hooks): address CodeRabbit review (#2121)
    
    - ecc-context-monitor: clear dedupe state when warnings resolve, so the same
      warning text recurring in a later turn (context dips/recovers/dips, a loop
      that stops then restarts) is surfaced again instead of suppressed as a
      duplicate. Guarded so the no-warning hot path stays write-free.
    - ecc-metrics-bridge: hash the FULL serialized edit payload and truncate the
      digest, not the input. Slicing the serialized string to HASH_INPUT_LIMIT
      first could collapse large edits sharing their first 2048 chars, reviving the
      false-loop collision for big Write/edit payloads.
    - Add regression test for >2048-char edit divergence.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • fix(gateguard): gate force/path git checkout as destructive (#2158)
    * fix(gateguard): gate force/path git checkout as destructive
    
    The destructive-command gate's `checkout` handler only flagged
    `git checkout -- <path>`. It missed `git checkout --force` / `-f <branch>`
    and `git checkout .`, all of which discard uncommitted working-tree changes,
    so they bypassed the gate (once the once-per-session routine-Bash gate is
    satisfied, they ran with no challenge). The sibling `switch` handler already
    covers these force forms; mirror it for `checkout`.
    
    * test(gateguard): document Test 7b force-checkout case
    
    ---------
    
    Co-authored-by: bymle <229636660+bymle@users.noreply.github.com>
  • fix(dev-server-block): stop blocking dev-<suffix> scripts (#2179)
    `DEV_PATTERN`'s trailing `\b` treats a hyphen as a word boundary, so
    `dev\b` matched the `dev` prefix of distinct npm scripts like
    `dev-setup` / `dev-docs` / `dev-build` and blocked them with exit 2.
    Replace the trailing `\b` with `(?![\w-])` so the dev server still
    matches (`dev`, `dev;`, `dev:ssr`) but `dev-<suffix>` scripts pass.
    
    Adds regression tests for dev-setup/dev-docs/dev-build (allowed) and
    dev:ssr (still blocked).
    
    Co-authored-by: bymle <229636660+bymle@users.noreply.github.com>
  • fix(session-end): preserve $-sequences in user messages when rewriting summary (#2180)
    The regenerated summary block embeds raw user-message text and was passed
    as the *replacement* argument to String.prototype.replace, where $-sequences
    ($&, $$, $`, $') are special. A user message containing $& re-injected the
    entire matched block (duplicating the summary markers) and $$ collapsed to $,
    silently corrupting the persisted session summary. buildSummarySection only
    escapes newlines and backticks, not $.
    
    Fix: use function replacers (() => summaryBlock) at both rewrite sites so the
    replacement text is treated literally. Adds an end-to-end regression test.
    
    Co-authored-by: bymle <229636660+bymle@users.noreply.github.com>
  • fix(session-start): support ECC_SESSION_RETENTION_DAYS opt-out + document env var (#2151) (#2163)
    * fix(session-start): support ECC_SESSION_RETENTION_DAYS opt-out + document env var
    
    The retention pass for *-session.tmp files (issue #2151) landed previously,
    but the env var that controls it was undocumented in the README and rejected
    falsy values (0, off, disabled), silently falling back to the 30-day default.
    Users who want to keep all sessions for forensic or research workflows had no
    way to opt out.
    
    This patch:
    
    - Extends getSessionRetentionDays() so 0|off|false|disabled|never|none disables
      pruning entirely (returns null sentinel; default behavior unchanged).
    - Updates the call site in main() to skip pruneExpiredSessions when retention
      is null and emits a clear "[SessionStart] Pruning disabled via
      ECC_SESSION_RETENTION_DAYS" log line so the operator can tell pruning is off.
    - Documents ECC_SESSION_RETENTION_DAYS in the README "Hook Runtime Controls"
      section alongside the other ECC_SESSION_* knobs.
    - Adds three regression tests in tests/hooks/hooks.test.js covering opt-out
      via 0, opt-out via off, and garbage-value fallback to default 30.
    
    Verification:
    - node tests/hooks/hooks.test.js  — 240/240 green (incl. 3 new retention tests)
    - node tests/run-all.js           — 2622/2622 green
    - npx eslint scripts/hooks/session-start.js tests/hooks/hooks.test.js — clean
    - node scripts/ci/validate-no-personal-paths.js — clean
    - node scripts/ci/check-unicode-safety.js       — clean
    - node scripts/ci/validate-hooks.js — 28 matchers validated
    - node scripts/ci/validate-rules.js — 115 files validated
    
    Fixes #2151
    
    * docs(readme): list all ECC_SESSION_RETENTION_DAYS opt-out values + add Windows example
    
    Address reviewer feedback on PR #2163:
    - CodeRabbit and cubic both flagged that the README docs only listed 3 of 6
      opt-out values accepted by getSessionRetentionDays() (0, off, disabled),
      while the implementation also accepts false, never, none.
    - cubic also flagged the missing Windows PowerShell example for the new
      variable, breaking the parallel structure of the existing
      ECC_CONTEXT_MONITOR_COST_WARNINGS example block.
    
    Updated the README to:
    - Spell out all six opt-out values (0, off, false, disabled, never, none)
      and clarify they "keep all sessions (disable pruning)".
    - Add an ECC_SESSION_RETENTION_DAYS line to the Windows PowerShell example.
    
    No behavior change. README only.
    
    Verification:
    - npx markdownlint README.md — clean
    - npx eslint scripts/hooks/session-start.js tests/hooks/hooks.test.js — clean
  • feat(gateguard): add env knobs for routine bash gate + extra destructive patterns (#2161)
    * feat(gateguard): add env knobs for routine bash gate + extra destructive patterns
    
    The JS port of gateguard-fact-force has two bash gates: a destructive
    gate (rm -rf, drop table, git push --force, etc.) that operators want
    to keep, and a once-per-session routine gate that fires on the very
    first bash invocation regardless of intent. Operators on hosts where
    the routine gate is friction without signal (Cursor, OpenCode, etc.)
    have been maintaining local patches that get clobbered on every plugin
    update; the Python upstream gateguard-ai already exposes equivalent
    config via .gateguard.yml.
    
    Adds two env vars, both off-by-default so existing behavior is
    preserved:
    
    - GATEGUARD_BASH_ROUTINE_DISABLED — truthy values (1, true, on, yes,
      enabled) skip the routine bash gate. Destructive gate is unaffected.
    - GATEGUARD_BASH_EXTRA_DESTRUCTIVE — regex source string for additional
      destructive patterns. Matches against the same quote-stripped,
      subshell-flattened command the built-in DESTRUCTIVE_SQL_DD regex sees,
      so a custom phrase inside $(...) or backticks is also caught. A
      malformed regex is logged once to stderr and treated as not configured
      rather than crashing the hook (hooks must never block tool execution
      unexpectedly).
    
    Twelve new tests pin both env vars (truthy aliases, falsy values, unset
    baseline, destructive-gate-still-fires, alternation members, malformed
    regex degrades safely, custom phrase inside command substitution).
    Existing 2619/2619 tests still pass; eslint clean.
    
    Fixes #2078
    
    * fix(gateguard): reset extra-destructive warn-once gate when env value changes
    
    Both reviewers (CodeRabbit + cubic) flagged that
    extraDestructiveWarnLogged was never reset when GATEGUARD_BASH_EXTRA_DESTRUCTIVE
    flipped from one invalid regex to a different invalid regex. The
    sticky boolean meant a long-running process saw bad-pattern-a's
    warning then silently swallowed bad-pattern-b's parse failure.
    
    Fix: clear extraDestructiveWarnLogged whenever the cache key changes
    (i.e. before the regex compile attempt). The warn-once-per-distinct-
    pattern invariant now matches the per-key cache invariant.
    
    Adds a same-process regression test via loadDirectHook() that spies on
    process.stderr.write and asserts: same bad pattern warns once across
    multiple invocations; switching to a different bad pattern emits a
    second warning; switching to a valid regex emits zero warnings.
  • fix(suggest-compact): clean up old counter temp files (#2159)
    * fix(suggest-compact): clean up old counter temp files
    
    claude-tool-count-<sessionId> files were written into the OS temp dir
    on every hook run and never removed, accumulating one orphan per
    session indefinitely.
    
    Sweep stale counter files at the top of main() before opening the
    active counter. Retention is env-tunable via COMPACT_STATE_TTL_DAYS
    (default 14 days); invalid values fall back to the default. The
    active session's counter file is preserved unconditionally even if
    its mtime is past the cutoff. Failures during the sweep are swallowed
    to preserve the always-exit-0 hook contract.
    
    Adds 7 regression tests covering the sweep, env-var validation, and
    the always-exit-0 invariant under a populated temp dir.
    
    Fixes #2156
    
    * fix(suggest-compact): preserve counter files at the TTL cutoff boundary
    
    The cleanup sweep used `mtimeMs > cutoffMs` to short-circuit, which
    matched files whose mtime sits exactly on the cutoff boundary and
    deleted them. The cleanupOldCounters docstring promises only files
    *older than* retentionDays are removed; a file at age == retentionDays
    is not older than retentionDays, so it must survive.
    
    Switch the comparison to `>=` so only strictly older files fall
    through to deletion. Add a regression test that pins boundary-aged
    files (mtimeMs sitting just past the projected cutoff) are preserved.
    
    Refs #2156
  • fix(hooks): prefer fresh harness cost cache (#2054)
    Uses a fresh harness cost cache when available and keeps transcript pricing as the fallback. Focused cost-tracker tests passed locally before merge.
  • fix(hooks): use shared renameWithRetry in writeWarnState (ecc-context-monitor)
    Mirror the previous commit's Windows-EPERM retry on the companion
    `writeWarnState` in `scripts/hooks/ecc-context-monitor.js`. Same
    race: two PostToolUse subprocesses writing concurrent debounce
    state racing on `MoveFileExW`, target-in-use throwing EPERM on
    Windows even though each writer's tmp path is now unique.
    
    Implementation: import `renameWithRetry` from `scripts/lib/session-bridge.js`
    (exported in the previous commit) instead of duplicating the helper.
    The retry policy, backoff schedule, and main-thread `Atomics.wait`
    strategy stay identical to `writeBridgeAtomic`.
    
    Three writers in the repo now share the same atomic-write contract:
    - `writeBridgeAtomic` (scripts/lib/session-bridge.js) — round 1 +
      this round's retry
    - `writeWarnState` (this file) — round 1 + this round's retry via shared helper
    - `writeCostWarningIfChanged` (scripts/hooks/ecc-metrics-bridge.js) —
      out of scope for this PR (already uses unique tmp suffix; a future
      consolidation could move it to the shared helper too).
    
    Local: `yarn test` green, `yarn lint` clean. The companion test
    suite for `ecc-context-monitor.js` does not currently exercise
    concurrent `writeWarnState` writes, but the helper it now uses is
    covered by the `tests/lib/session-bridge.test.js` concurrent-write
    regression added in round 1's last commit.
  • fix(hooks): use unique tmp suffix in writeWarnState (ecc-context-monitor)
    Mirror the previous commit's `writeBridgeAtomic` fix on the
    companion `writeWarnState` in `ecc-context-monitor.js`. Same shape:
    fixed `${target}.tmp` → `${target}.${process.pid}.${randomNonce}.tmp`,
    plus best-effort cleanup of the tmp file on `renameSync` failure
    (throws original error after cleanup).
    
    `writeWarnState` debounces the context-monitor's threshold alarms
    (`COST_NOTICE_USD`, `COST_WARNING_USD`, `COST_CRITICAL_USD`, plus the
    context-remaining and loop-detection ones). Without unique suffixes,
    two PostToolUse subprocesses racing on the warn-state file produce
    either a corrupted JSON debounce-state on disk or an ENOENT throw
    that the hook catches and swallows — either way the next warn-state
    read returns the default `{callsSinceWarn: 0, lastSeverity: null}`
    and the threshold alarms re-fire or stop firing erratically. Users
    see warning messages flicker or vanish; debounce no longer works.
    
    Three call sites in this repo now share the same atomic-write
    contract:
    - `writeBridgeAtomic` (scripts/lib/session-bridge.js) — primary
    - `writeCostWarningIfChanged` (scripts/hooks/ecc-metrics-bridge.js) — cost cache
    - `writeWarnState` (this file) — debounce state
    
    `yarn lint` clean. Regression test covering both `writeBridgeAtomic`
    and `writeWarnState` under concurrent load lands in the next commit.
  • docs(hooks): correct PreToolUse → PostToolUse in readSessionCost docblock
    greptile P2 nitpick: the previous commit's docblock said "on every
    PreToolUse hook" but the module header (and the actual hook wiring
    in `hooks/hooks.json`) identifies this script as a PostToolUse
    hook — it runs *after* each tool invocation to update the running
    session aggregate. One-word typo, no behavior change.
  • fix(hooks): log fail-open breadcrumb on parse/read errors in metrics bridge
    coderabbitai flagged: the two `catch` blocks in `readSessionCost`
    silently swallowed every failure mode. A malformed `costs.jsonl`
    row, a permission error opening the file, or any other unexpected
    I/O failure would silently return zero cost — masking real
    problems and feeding stale or zero numbers into
    `ecc-context-monitor.js` (which then injects them as
    `additionalContext` into the live model turn).
    
    Fix two things, both fail-open-preserving:
    
    1. **Inner JSON.parse catch** — count malformed lines and write
       one aggregated breadcrumb per call:
    
         [ecc-metrics-bridge] skipped N malformed line(s) in <path>
    
       Aggregating (rather than per-line) keeps a log-flooded
       `costs.jsonl` diagnosable without overwhelming stderr.
    
    2. **Outer fs.readFileSync catch** — write a breadcrumb on real
       errors, but stay silent on `ENOENT`. The "no costs.jsonl yet"
       case is genuinely normal (no Stop event has fired this session)
       and producing noise on every PreToolUse before the first Stop
       would be reviewer-visible spam. All other error codes
       (`EACCES`, `EISDIR`, `EMFILE`, …) get:
    
         [ecc-metrics-bridge] failing open after <name> reading <path>: <msg>
    
    In both cases the function still returns the zero-cost fallback
    so the bridge never breaks tool execution — only the
    diagnosability changes.
    
    Two new regression tests in
    `tests/hooks/ecc-metrics-bridge.test.js`:
    
      ✓ readSessionCost writes a stderr breadcrumb when malformed
        lines are skipped — feeds 4 rows (2 valid, 2 malformed),
        asserts the last valid row still wins AND captured stderr
        contains "skipped 2 malformed line(s)".
    
      ✓ readSessionCost stays silent when costs.jsonl does not exist
        (ENOENT) — uses a fresh tmp HOME with no metrics dir, asserts
        zero return AND empty stderr.
    
    Test count: 16 → 18; `npm test` green; `yarn lint` clean.
  • fix(hooks): scan full costs.jsonl when locating session row
    `readSessionCost` read only the trailing 8 KiB of
    `~/.claude/metrics/costs.jsonl` to "avoid scanning entire file".
    That ceiling is the opposite-sign sibling of the double-count bug
    fixed in the previous commit: once a session's most recent
    cumulative row gets pushed past the 8 KiB window by newer rows
    from other sessions, the bridge silently reports `totalCost: 0`,
    `totalIn: 0`, `totalOut: 0` for that session — same false signal
    to `ecc-context-monitor.js`, same wrong number injected into the
    live model turn as `additionalContext`.
    
    `cost-tracker.js` has no rotation policy, so on any non-trivial
    workstation costs.jsonl grows past 8 KiB within minutes of normal
    use. For users who keep multiple concurrent sessions, this means
    the second-and-later sessions silently report zero almost
    immediately.
    
    Reproduced before this commit:
    
      $ HOME=/tmp/eccc node -e '
          const fs = require("fs");
          const m = require("./scripts/hooks/ecc-metrics-bridge.js");
          // S1 row at file start, then 200 rows of OTHER-session noise (~16 KiB).
          // S1 is the row we want, but it sits past the 8 KiB tail.
          const s1 = `{"session_id":"S1","estimated_cost_usd":0.5,"input_tokens":500,"output_tokens":250}`;
          const other = `{"session_id":"OTHER","estimated_cost_usd":1,"input_tokens":100,"output_tokens":50}`;
          fs.mkdirSync("/tmp/eccc/.claude/metrics", { recursive: true });
          fs.writeFileSync("/tmp/eccc/.claude/metrics/costs.jsonl",
            [s1, ...Array(200).fill(other)].join("\\n") + "\\n");
          console.log(JSON.stringify(m.readSessionCost("S1")));'
      {"totalCost":0,"totalIn":0,"totalOut":0}
    
    Expected: `{"totalCost":0.5, "totalIn":500, "totalOut":250}` (the
    S1 row that exists in the file).
    Actual: zero — the row is past the 8 KiB tail.
    
    Fix: drop the `fs.openSync` + bounded `fs.readSync` + position
    arithmetic in favour of `fs.readFileSync(costsPath, 'utf8')` and
    iterate every line. Each row is ~150 bytes; even 100k rows is
    ~15 MB and a single sync read on PreToolUse is in the low ms.
    If file rotation lands in `cost-tracker.js` later, this scan
    becomes proportionally cheaper.
    
    After this commit the reproduction above returns
    `{"totalCost":0.5, "totalIn":500, "totalOut":250}`.
    
    Regression test in `tests/hooks/ecc-metrics-bridge.test.js`:
    `readSessionCost finds session row beyond the old 8 KiB tail
    boundary`. The test asserts the costs.jsonl fixture is > 8 KiB
    before reading so any reintroduction of a bounded tail would
    re-fail the test (i.e. the assertion is the contract, not the
    specific number 8192).
    
    Together with the previous commit, both directions of the
    metrics-bridge cost-reporting bug are closed.
  • fix(hooks): use last cumulative row for session cost in metrics bridge
    `ecc-metrics-bridge.js#readSessionCost` summed the
    `estimated_cost_usd`, `input_tokens`, and `output_tokens` of
    every matching row in `~/.claude/metrics/costs.jsonl`. That breaks
    the documented contract of `scripts/hooks/cost-tracker.js`, which
    explicitly states (in its module docblock):
    
      Cumulative behavior: Stop fires per assistant response, not
      per session. Each row therefore represents the cumulative
      session total up to that point. To get per-session cost, take
      the last row per session_id.
    
    Summing N cumulative rows over-counts by roughly (N+1)/2 ×. For a
    session with 3 rows at 0.01, 0.02, 0.03 USD (true running total
    0.03), the bridge today reports 0.06 USD. The over-counted value
    feeds `ecc-context-monitor.js`, which then trips its
    COST_NOTICE_USD / COST_WARNING_USD / COST_CRITICAL_USD thresholds
    on phantom spend AND injects the inflated number as
    `additionalContext` into the live model turn — so the agent
    itself is told a wrong cost.
    
    Reproduced on `main` before this commit:
    
      $ cat > /tmp/eccc/.claude/metrics/costs.jsonl <<EOF
      {"session_id":"S1","estimated_cost_usd":0.01,"input_tokens":333,"output_tokens":166}
      {"session_id":"S1","estimated_cost_usd":0.02,"input_tokens":666,"output_tokens":333}
      {"session_id":"S1","estimated_cost_usd":0.03,"input_tokens":1000,"output_tokens":500}
      EOF
    
      $ HOME=/tmp/eccc node -e 'const m = require("./scripts/hooks/ecc-metrics-bridge.js"); \
          console.log(JSON.stringify(m.readSessionCost("S1")))'
      {"totalCost":0.06,"totalIn":1999,"totalOut":999}
    
    Expected: `{"totalCost":0.03,"totalIn":1000,"totalOut":500}` (the
    last cumulative row).
    Actual: 2× over-count.
    
    Fix: replace `+=` with `=` in the matching branch so the assigned
    values reflect the most recent row encountered. The iteration
    order is file order, which is also event time order, so the last
    assignment wins — exactly the contract cost-tracker writes
    against.
    
    After this commit the reproduction above returns
    `{"totalCost":0.03,"totalIn":1000,"totalOut":500}`.
    
    Regression test in `tests/hooks/ecc-metrics-bridge.test.js`:
    `readSessionCost returns the LAST cumulative row, not the sum
    (cost-tracker contract)`. The existing
    `readSessionCost does not include unrelated default-session rows`
    test happened to pass even with the bug because it only had one
    target-session row — single-row sessions are coincidentally
    correct under both formulas. The new test uses three rows so the
    two formulas diverge.
    
    A second issue in the same function — the 8 KiB tail-only read
    silently drops older rows once a session's recent cumulative
    totals scroll past that window — is fixed in the next commit.
  • chore: update statusline ANSI color palette
    - Replace blinking red (5;31m) with bold red (1;31m) for critical context bar
    - Replace cyan metrics (36m) with sky blue (38;5;117m)
    - Replace plain bold task (1m) with bold bright white (1;97m)
    - Update test assertion to match new bold red code
  • fix(hooks): close grouped command bypasses in gateguard (#1912)
    Inspect executable bodies inside plain subshells and brace groups before applying destructive command classifiers.\n\nCo-authored-by: Jamkris <82251632+Jamkris@users.noreply.github.com>
  • fix: integrate recent hook and docs PRs (#1905)
    Integrates useful changes from #1882, #1884, #1889, #1893, #1898, #1899, and #1903:
    - fix rule install docs to preserve language directories
    - correct Ruby security command examples
    - harden dev-server hook command-substitution parsing
    - add Prisma patterns skill and catalog/package surfaces
    - allow first-time protected config creation while blocking existing configs
    - read cost metrics from Stop hook transcripts
    - emit suggest-compact additionalContext on stdout
    
    Co-authored-by: Jamkris <dltmdgus1412@gmail.com>
    Co-authored-by: Levi-Evan <levishantz@gmail.com>
    Co-authored-by: gaurav0107 <gauravdubey0107@gmail.com>
    Co-authored-by: richm-spp <richard.millar@salarypackagingplus.com.au>
    Co-authored-by: zomia <zomians@outlook.jp>
    Co-authored-by: donghyeun02 <donghyeun02@gmail.com>
  • fix: close block-no-verify bypass holes
    Backport Jamkris's fix for case-insensitive core.hooksPath overrides and the git commit -tn template-path false positive. Verified locally on current main with 25/25 block-no-verify tests and node tests/run-all.js passing 2369/2369.
  • feat: add ECC statusline observability hooks
    Salvages the useful statusline/context monitor work from stale PR #1504 while preserving the current continuous-learning hook runner wiring.
    
    Adds the metrics bridge, context monitor, statusline script, shared cost/session bridge utilities, and tests. Fixes the reviewed false loop-detection hash collision for non-file tools, avoids default-session cost inflation, sanitizes statusline task lookup, and records hook payload session IDs in cost-tracker.
  • fix: port continuous-learning observer fixes
    Ports continuous-learning observer signal, storage, remote normalization, and v1 deprecation fixes onto current main.
  • fix: port hook session and dashboard safety fixes
    Ports suggest-compact session_id isolation and dashboard terminal/document launch safety onto current main.
  • fix(hooks): resolve MCP health-check spawn ENOENT on Windows (#1456)
    * fix(hooks): resolve MCP health-check spawn ENOENT on Windows
    
    On Windows, commands like 'npx' are batch files (npx.cmd) that require
    shell expansion to resolve via PATH. Without shell: true, Node.js
    spawn() fails with ENOENT.
    
    However, absolute paths (e.g. C:\Program Files\nodejs\node.exe) must
    NOT use shell mode because cmd.exe misparses paths containing spaces.
    
    Fix: enable shell mode only for non-absolute commands on Windows, using
    path.isAbsolute() to distinguish. This matches how attemptReconnect()
    already handles the shell option.
    
    Fixes #1455
    
    * fix(hooks): harden Windows shell spawn — validate command for metacharacters
    
    Addresses bot review feedback on PR #1456:
    
    - Add UNSAFE_SHELL_CHARS regex to guard against shell injection when
      needsShell=true: cmd.exe operators (&, |, <, >, ^, %, !, (), ;,
      whitespace) are rejected before shell mode is enabled
    - Add typeof command === 'string' check so path.isAbsolute() cannot
      throw on malformed non-string command values
    - Rename test to 'via PATH resolution' (not Windows-only; runs all platforms)
    - Fix misleading test comment: 'node' resolves via PATH like npx.cmd but
      does not itself use .cmd; comment now accurately reflects the intent
    
    * fix(hooks): kill full process tree on Windows when shell mode is used
    
    When needsShell=true, the spawned child is cmd.exe. Calling child.kill()
    only terminates the shell, leaving the real server process orphaned.
    
    Use taskkill /PID <pid> /T /F on Windows+shell to kill the entire
    process tree rooted at cmd.exe. Fall back to SIGTERM+SIGKILL on all
    other platforms or when shell mode is not active.
    
    * fix(hooks): fall back to child.kill() when taskkill fails
    
    Windows taskkill can fail if it's not on PATH, the process already
    exited, or permissions are denied. Previously the failure was silently
    ignored and no kill signal reached the child.
    
    Now: capture the spawnSync result and fall back to child.kill('SIGKILL')
    on any taskkill error or non-zero status. This still may leak a
    detached server process but at least guarantees the cmd.exe shell is
    signaled.