validateHookEntry handled command, http and prompt hooks in one function; it now dispatches to validateCommandHook, validateHttpHook and validatePromptHook with the inline node -e check in validateInlineJs. validateHooks reads the file, loads the sidecar and validates the object or legacy array shape through readHooksFile, loadMetadata, validateObjectFormat and validateArrayFormat, so the deepest nesting is gone.
Error messages, their order, exit codes and the --update-fingerprints flow are unchanged.
extractSessionSummary did user-message filtering and two kinds of tool_use collection in one loop body six blocks deep. The per-entry work now lives in userMessageText, recordToolUse and collectEntry. main() is down to the sequence of steps: resolveTranscriptPath, sessionShortId, maybeGenerateLlmSummary, then updateSessionFile or createSessionFile, with the shared footer and empty-session template as constants.
Call order, log lines, file names and the written markdown are unchanged, including the exact summary block replacement and the migration path for files without markers.
gate() handled Edit, MultiEdit and shell in one 220-line function. It now builds a small context and dispatches to gateSingleEdit, gateMultiEdit and gateShell, with the first-touch, sibling-collapse and routine/destructive shell steps as their own functions. isDestructiveGit and isReadOnlyGitIntrospection became subcommand tables; unwrapLeadWrappers got one handler per wrapper; ddLauncherCommandIndex, isDestructiveFindExec and sanitizePath lost their inline option and code point ladders. condensedGateMsg takes an options object instead of seven positional parameters.
Decision order, recorded metrics reasons, state keys and every denial message are unchanged, and the module exports are the same.
extractSubshellGroups and extractBraceGroups each carried their own copies of the quote-aware skipping for $(...), backtick and (...) spans, which is where the complexity and nesting warnings came from. The skipping now lives in findParenSpanEnd, findBacktickEnd and skipOuterSpan, with readSubshellBody and readBraceBody doing the body collection.
Behavior is unchanged: the same bodies come back in the same order, trailing backslashes and unterminated spans resolve the same way, and the three exports keep their signatures.
checkGitWords, executableWords, shellRole, updateShellState and checkCommand were each well over the complexity budget and checkCommand nested seven blocks deep. Split them into named helpers: git global option scanning and bypass flag search, per-wrapper handlers for command/exec/env/sudo, a shell option cluster classifier, declaration flag parsing, and a per-task/per-state/per-environment pipeline inside checkCommand.
Every budget.spend call keeps its amount and order so the work quota tests still land in the same window. The module still only requires ./lib/shell-scan and exports run unchanged. Wrapper option sets moved to module constants instead of being rebuilt on every call.
* test(lib): cover feedback-links routes and payload
Pins the three public issue routes to the ECC repository and checks each template name actually exists under .github/ISSUE_TEMPLATE. Also locks the ecc.feedback.v1 payload shape, the no-diagnostics-upload promise, and that every call returns fresh copies so a caller cannot alter the frozen route table.
* test(lib): cover cursor-agent-names prefixing and path handling
Pins the ecc- prefix rule the Cursor install applies to agent files: idempotent, lowercase-prefix only, empty names pass through. Covers the relative path variant across backslash, repeated, and trailing separators, and that a leading separator cannot re-root the destination under the target agents directory.
* test(lib): cover agent-tool-map Pi mapping and authority invariant
Pins the Claude to Pi tool table: six core tools, every target a real Pi built-in, and read-only tools never escalating to bash, edit, or write. Covers the unsupported path for MCP, web, unknown, empty, and non-string names, and that prototype member names like constructor are not treated as table hits.
* test(lib): cover compute-sponsor URL and passive copy
Pins the compute URL to the bare https route with no path, query, or credentials, and that the sponsor copy links it exactly once and nowhere else. Locks the wording that keeps the link passive, keeps the ecc ito find bridge opt-in and non-reserving, and does not claim managed inference is live. The existing surface test only reads docs and CLI output, so the module itself had no direct test.
* test(lib): cover invocation-environment precedence and isolation
Pins the three branches every install path relies on: an explicit env is copied and wins, a scoped homeDir yields an empty environment so ambient variables cannot leak into a contained install, and anything else snapshots process.env. Also checks the copy is shallow, own-keys only, and never writes back.
The multi-backend, multi-frontend, multi-plan, multi-execute and multi-workflow commands were copied from ccg-workflow's command templates, which carry {{LITE_MODE_FLAG}} and {{GEMINI_MODEL_FLAG}} for that project's installer to substitute. ECC has no substitution step, so bash passed the literal token to codeagent-wrapper as a positional argument, the backend name landed in the working-directory slot, and every call exited 1. Remove the tokens from the five commands and the ja-JP, tr and zh-CN docs copies so the wrapper receives a plain --backend invocation. A regression test scans every multi-* command and translated copy for codeagent-wrapper lines that still contain a {{TEMPLATE}} token.
Fixes#3595
Extend the PR burst guard with an open-PR cap. On every opened or reopened
PR by a non-collaborator, the guard lists the repository's open PRs, keeps
the author's oldest `maxOpen` (10) open and closes the newer ones with a
bot comment that names the count and the limit. No lock, no maintainer
alert: the cap is about review capacity, not spam. PRs the burst logic
already closed in the same run are excluded so nothing is mutated twice.
Collaborators with write access stay exempt, dry run reports without
mutating, and the comment is deduplicated on a bot-authored marker.
Haley asked for this on 2026-10-19: each contributor should only have 10
to 15 PRs open at a time. Existing open PRs are not bulk-closed; the cap
applies as each author opens or reopens their next PR.
Verification: 22 unit tests and the HTTP workflow test pass,
validate-workflow-security passes, eslint and markdownlint clean. Dry run
against affaan-m/ECC on #3621 reports openCount 19 and nine cap excess
PRs for that author, with no writes.
_parse_classification returned {} for garbage or non-dict output, so a failed classification looked like zero matched steps and graded as non-compliant. Raise ClassifierParseError (a RuntimeError, like the non-zero-rc path) instead.
- windows-test-env.js gains resolvePython: it probes each candidate with
`-c "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)"`, so the
Store stub is rejected.
- hermes-hooks.test.js launches its four hook spawns through the resolved
interpreter, falling back to python3 when none is found so a machine
without Python still fails loudly instead of skipping.
hermes-hooks goes from 0/14 to 14/14 on Windows 11; the other users of the
helper are unchanged. Product code is untouched: Hermes runs these hooks
itself and the installer writes no interpreter.
tests/hermes-hooks.test.js spawns python3, which on Windows is usually the
Microsoft Store alias stub: it exits 9009 instead of running Python, so all
14 Hermes cases fail with "hook must always exit 0 (got 9009)". python.org
installs provide python and the py launcher, not python3.
Pin resolvePython({ platform, probe }) in the shared windows-test-env
helper: the first candidate that actually runs Python 3 (Windows: python,
py -3, python3; elsewhere: python3, python), or null when none does, plus a
real smoke run of the resolved interpreter.
Against main: 4 of 8 windows-test-env cases fail (no resolvePython).
Snapshot the header and expected rows when the grader loads and pass
exportCsv copies, so an exporter that overwrites its input cannot change
what it is compared with. decoy-mutates now scores 0.75 with the other
two decoys; reference still scores 1.
The CSV grader reads its expected values from the same ROWS objects it
passes to exportCsv, so an exporter that overwrites the input fields and
exports them scores 1 (Greptile on #3519). decoy-mutates does exactly
that; graderIsSound reports the grader unsound on this head
(decoy-mutates=1). The grader-soundness test now prints every decoy
score through formatGraderScores.
normalizeIssueNumber parsed with Number.parseInt, which stops at the
first non-digit ("42oops" -> 42, "42.9" -> 42, "4e2" -> 4) and loses
precision past 2^53 (9007199254740993 became 9007199254740992). The CLI
shares this helper for --issue, --limit, and the positional issue number,
so malformed values silently targeted the wrong issue before any integer
guard downstream could notice.
Validate the entire trimmed decimal token and require
Number.isSafeInteger so malformed or out-of-range values throw
"Invalid issue number" during argument parsing, before the state store
is opened or GitHub is contacted. Whitespace, leading plus, and leading
zero tolerance is preserved. Regression coverage added at both the
library level (tests/lib/github-coordination-gh-api.test.js) and the CLI
argument parser (tests/scripts/github-coordination.test.js).
Fixes#3514