110 Commits

  • 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(observer): auto-scale max_turns by analysis batch size (#2062)
    * fix(observer): auto-scale max_turns by analysis batch size (#2035)
    
    The hardcoded default of MAX_TURNS=20 is insufficient when
    MAX_ANALYSIS_LINES=500 (also the default). Claude exhausts its turn
    budget before it can write all discovered instinct files, producing:
    
      Error: Reached max turns (20)
    
    Fix: when ECC_OBSERVER_MAX_TURNS is not explicitly set, compute
    max_turns proportionally to the actual analysis batch size:
      max_turns = clamp(analysis_count / 10, 20, 100)
    
    This gives:
      - 20–199 lines → 20 turns  (existing floor, unchanged)
      - 500 lines    → 50 turns  (resolves the reported failure)
      - 1000 lines   → 100 turns (cap)
    
    Explicitly setting ECC_OBSERVER_MAX_TURNS still overrides the
    auto-scaled value, preserving the existing escape hatch.
    
    * test(observer): update max_turns test for auto-scaling; document validation
    
    The max-turns budget test in tests/hooks/hooks.test.js still asserted the removed literal max_turns="${ECC_OBSERVER_MAX_TURNS:-20}", which would fail against the new auto-scaling logic. Assert the auto-scale formula and the 20/100 clamp bounds instead.
    
    Also add the explanatory comment CodeRabbit requested above the max_turns sanitization block, clarifying it guards the explicit ECC_OBSERVER_MAX_TURNS override path.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.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
  • fix(hooks): avoid escaped quotes in plugin bootstrap
    Generate the inline hook root resolver with single-quoted JavaScript literals so Windows Git Bash does not choke on nested escaped double quotes before Node starts. Refresh hooks.json and add regression coverage for parsed hook commands and installed hook manifests.
  • 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.
  • Merge pull request #1495 from ratorin/fix/session-end-transcript-path-isolation
    fix(hooks): isolate session-end.js filename using transcript_path UUID (#1494)
  • fix(hooks): wrap SessionStart summary with stale-replay guard (#1536)
    The SessionStart hook injects the most recent *-session.tmp as
    additionalContext labelled only with 'Previous session summary:'.
    After a /compact boundary, the model frequently re-executes stale
    slash-skill invocations it finds inside that summary, re-running
    ARGUMENTS-bearing skills (e.g. /fw-task-new, /fw-raise-pr) with the
    last ARGUMENTS they saw.
    
    Observed on claude-opus-4-7 with ECC v1.9.0 on a firmware project:
    after compaction resume, the model spontaneously re-enters the prior
    skill with stale ARGUMENTS, duplicating GitHub issues, Notion tasks,
    and branches for work that is already merged.
    
    ECC cannot fix Claude Code's skill-state replay across compactions,
    but it can stop amplifying it. Wrap the injected summary in an
    explicit HISTORICAL REFERENCE ONLY preamble with a STALE-BY-DEFAULT
    contract and delimit the block with BEGIN/END markers so the model
    treats everything inside as frozen reference material.
    
    Tests: update the two hooks.test.js cases that asserted on the old
    'Previous session summary' literal to assert on the new guard
    preamble, the STALE-BY-DEFAULT contract, and both delimiters. 219/219
    tests pass locally.
    
    Tracked at: #1534
  • review: apply sanitizeSessionId to UUID shortId, fix test comment
    - Route the transcript-derived shortId through sanitizeSessionId so the
      fallback and transcript branches remain byte-for-byte equivalent for any
      non-UUID session IDs that still land in CLAUDE_SESSION_ID (greptile P1).
    - Clarify the inline comment in the first regression test: clearing
      CLAUDE_SESSION_ID exercises the transcript_path branch, not the
      getSessionIdShort() fallback (coderabbit P2).
    
    Refs #1494
  • review: address P1/P2 bot feedback on shortId derivation
    - Use last-8 chars of transcript UUID instead of first-8, matching
      getSessionIdShort()'s .slice(-8) convention. Same session now produces the
      same filename whether shortId comes from CLAUDE_SESSION_ID or transcript_path,
      so existing .tmp files are not orphaned on upgrade.
    - Normalize extracted hex prefix to lowercase to avoid case-driven filename
      divergence from sanitizeSessionId()'s lowercase output.
    - Explicitly clear CLAUDE_SESSION_ID in the first regression test so the env
      leak from parent test runs cannot hide the fallback path.
    - Add regression tests for the lowercase-normalization path and for the case
      where CLAUDE_SESSION_ID and transcript_path refer to the same UUID (backward
      compat guarantee).
    
    Refs #1494
  • fix(hooks): isolate session-end.js filename using transcript_path UUID
    When session-end.js runs and CLAUDE_SESSION_ID is unset, getSessionIdShort()
    falls back to the project/worktree name. If any other Stop-hook in the chain
    spawns a claude subprocess (e.g. an AI-summary generator using 'claude -p'),
    the subprocess also fires the full Stop chain and writes to the same project-
    name-based filename, clobbering the parent's valid session summary with a
    summary of the summarization prompt itself.
    
    Fix: when stdin JSON (or CLAUDE_TRANSCRIPT_PATH) provides a transcript_path,
    extract the first 8 hex chars of the session UUID from the filename and use
    that as shortId. Falls back to the original getSessionIdShort() when no
    transcript_path is available, so existing behavior is preserved for all
    callers that do not set it.
    
    Adds a regression test in tests/hooks/hooks.test.js.
    
    Refs #1494
  • fix: extract inline SessionStart bootstrap to separate file (#1035)
    Inline `node -e "..."` in hooks.json contained `!` characters (e.g.
    `!org.isDirectory()`) that bash history expansion in certain shell
    environments would misinterpret, producing syntax errors and the
    "SessionStart:startup hook error" banner in the Claude Code CLI header.
    
    Extract the bootstrap logic to `scripts/hooks/session-start-bootstrap.js`
    so the shell never sees the JS source. Behaviour is identical: the script
    reads stdin, resolves the ECC plugin root via CLAUDE_PLUGIN_ROOT or a set
    of well-known fallback paths, then delegates to run-with-flags.js.
    
    Update the test that asserted the old inline pattern to verify the new
    file-based approach instead.
    
    Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
  • fix: harden Trae install ownership (#1013)
    * fix: harden trae install ownership
    
    * fix: unblock unicode safety CI lint
    
    * fix: unblock shared CI regressions
    
    * test: isolate package-manager dependent hooks and formatter tests
  • fix(tests): resolve Windows CI test failures (#701)
    * fix(tests): skip bash tests on Windows and fix USERPROFILE in resolve-ecc-root
    
    - hooks.test.js: add SKIP_BASH guard for 8 bash-dependent tests
      (detect-project.sh, observe.sh) while keeping 207 Node.js tests running
    - resolve-ecc-root.test.js: add USERPROFILE to env overrides in 2
      INLINE_RESOLVE tests so os.homedir() resolves correctly on Windows
    
    Generated with [Claude Code](https://claude.ai/code)
    via [Happy](https://happy.engineering)
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    Co-Authored-By: Happy <yesreply@happy.engineering>
    
    * fix(tests): handle BOM in shebang stripping and skip worktree tests on Windows
    
    - validators.test.js: replace regex stripShebang with character-code
      approach that handles UTF-8 BOM before shebang line
    - detect-project-worktree.test.js: skip entire file on Windows since
      tests invoke bash scripts directly
    
    Generated with [Claude Code](https://claude.ai/code)
    via [Happy](https://happy.engineering)
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    Co-Authored-By: Happy <yesreply@happy.engineering>
    
    ---------
    
    Co-authored-by: Claude <noreply@anthropic.com>
    Co-authored-by: Happy <yesreply@happy.engineering>
  • fix: resolve ESLint errors and add npx command support in hook tests
    Remove unused loadInstallManifests import and prefix unused result
    variable with underscore in selective-install tests. Add npx as an
    approved command prefix in hook validation tests.
  • Add PowerShell installer wrapper and update documentation (#532)
    * Add install.ps1 PowerShell wrapper and tests
    
    Add a Windows-native PowerShell wrapper (install.ps1) that resolves symlinks and delegates to the Node-based installer runtime. Update README with PowerShell usage examples and cross-platform npx entrypoint guidance. Point the ecc-install bin to the Node installer (scripts/install-apply.js) in package.json (and refresh package-lock), include install.ps1 in package files, and add tests: a new install-ps1.test.js and a tweak to install-sh.test.js to skip on Windows. These changes provide native Windows installer support while keeping npm-compatible cross-platform invocation.
    
    * Improve tests for Windows HOME/USERPROFILE
    
    Make tests more cross-platform by ensuring HOME and USERPROFILE are kept in sync and by normalizing test file paths for display.
    
    - tests/lib/session-adapters.test.js: set USERPROFILE when temporarily setting HOME and restore previous USERPROFILE on teardown.
    - tests/run-all.js: use a normalized displayPath (forward-slash separated) for logging and error messages so output is consistent across platforms.
    - tests/scripts/ecc.test.js & tests/scripts/session-inspect.test.js: build envOverrides from options.env and add HOME <-> USERPROFILE fallbacks so spawned child processes receive both variables when only one is provided.
    
    These changes prevent test failures and inconsistent logs on Windows where USERPROFILE is used instead of HOME.
    
    * Fix Windows paths and test flakiness
    
    Improve cross-platform behavior and test stability.
    
    - Remove unused createLegacyInstallPlan import from install-lifecycle.js.
    - Change resolveInstallConfigPath to use path.normalize(path.join(cwd, configPath)) to produce normalized relative paths.
    - Tests: add toBashPath and normalizedRelativePath helpers to normalize Windows paths for bash and comparisons.
    - Make cleanupTestDir retry rmSync on transient Windows errors (EPERM/EBUSY/ENOTEMPTY) with short backoff using sleepMs.
    - Ensure spawned test processes receive USERPROFILE and convert repo/detect paths to bash format when invoking bash.
    
    These changes reduce Windows-specific failures and flakiness in the test suite and tidy up a small unused import.
  • Merge remote-tracking branch 'origin/main' into feat/optimize-biome-hooks
    # Conflicts:
    #	tests/hooks/hooks.test.js
    #	tests/run-all.js