From 1e06f1ef83e838dbbe2358429791621c80a77ecf Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 28 May 2026 13:53:51 -0400 Subject: [PATCH 01/17] chore(cascade): sync allowScripts with allowBuilds --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1f69b60..778df49 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,8 @@ "type": "git", "url": "git+https://github.com/SocketDev/socket-bin.git" }, - "homepage": "https://github.com/SocketDev/socket-bin" + "homepage": "https://github.com/SocketDev/socket-bin", + "allowScripts": { + "esbuild": true + } } From 93023f9a3b5098e7a5bdfda1608c8c42e2f6ba59 Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 28 May 2026 14:54:12 -0400 Subject: [PATCH 02/17] =?UTF-8?q?fix(hooks):=20migrate=20.claude/hooks//=20=E2=86=92=20.claude/hooks/fleet//?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the wheelhouse hook-layout migration. Settings.json references .claude/hooks/fleet//index.mts but the hook directories were still at the old top-level paths. Every Stop / PreToolUse fired ERR_MODULE_NOT_FOUND. Move all hook dirs into fleet/, move _shared/ inside fleet/, copy in newly-canonical hooks from the wheelhouse template that did not exist locally. --- .claude/agents/security-reviewer.md | 1 + .claude/hooks/{ => fleet}/_shared/README.md | 0 .../hooks/{ => fleet}/_shared/acorn/README.md | 0 .../_shared/acorn/acorn-bindgen.cjs | 0 .../_shared/acorn/acorn-wasm-sync.mts | 0 .../{ => fleet}/_shared/acorn/acorn.wasm | Bin .../hooks/{ => fleet}/_shared/acorn/index.mts | 0 .../hooks/{ => fleet}/_shared/fleet-repos.mts | 0 .../{ => fleet}/_shared/foreign-paths.mts | 0 .../hooks/{ => fleet}/_shared/hook-env.mts | 0 .claude/hooks/{ => fleet}/_shared/markers.mts | 0 .claude/hooks/{ => fleet}/_shared/payload.mts | 0 .../{ => fleet}/_shared/shell-command.mts | 0 .../{ => fleet}/_shared/stop-reminder.mts | 0 .../_shared/test/fleet-repos.test.mts | 0 .../_shared/test/foreign-paths.test.mts | 0 .../_shared/test/shell-command.test.mts | 0 .../_shared/test/transcript.test.mts | 0 .../{ => fleet}/_shared/token-patterns.mts | 0 .../hooks/{ => fleet}/_shared/transcript.mts | 0 .../{ => fleet}/_shared/wheelhouse-root.mts | 0 .../actionlint-on-workflow-edit/README.md | 0 .../actionlint-on-workflow-edit/index.mts | 0 .../actionlint-on-workflow-edit/package.json | 0 .../test/index.test.mts | 0 .../actionlint-on-workflow-edit/tsconfig.json | 0 .../README.md | 24 + .../index.mts | 173 +++++++ .../package.json | 15 + .../test/index.test.mts | 38 ++ .../tsconfig.json | 0 .../answer-status-requests-reminder/README.md | 26 ++ .../answer-status-requests-reminder/index.mts | 186 ++++++++ .../package.json | 15 + .../test/index.test.mts | 39 ++ .../tsconfig.json | 0 .../ask-suppression-reminder/README.md | 0 .../ask-suppression-reminder/index.mts | 0 .../ask-suppression-reminder/package.json | 0 .../test/index.test.mts | 0 .../ask-suppression-reminder}/tsconfig.json | 0 .../auth-rotation-reminder/README.md | 0 .../auth-rotation-reminder/index.mts | 0 .../auth-rotation-reminder/package.json | 0 .../auth-rotation-reminder/services.mts | 0 .../test/auth-rotation-reminder.test.mts | 0 .../auth-rotation-reminder}/tsconfig.json | 0 .../hooks/fleet/avoid-cd-reminder/index.mts | 135 ++++++ .../fleet/broken-hook-detector/README.md | 25 + .../fleet/broken-hook-detector/index.mts | 237 ++++++++++ .../fleet/broken-hook-detector/package.json | 15 + .../broken-hook-detector/test/index.test.mts | 36 ++ .../broken-hook-detector}/tsconfig.json | 0 .../{ => fleet}/check-new-deps/README.md | 0 .../{ => fleet}/check-new-deps/audit.mts | 0 .../{ => fleet}/check-new-deps/index.mts | 0 .../{ => fleet}/check-new-deps/package.json | 0 .../check-new-deps/test/extract-deps.test.mts | 0 .../check-new-deps}/tsconfig.json | 0 .../{ => fleet}/check-new-deps/types.mts | 0 .../claude-md-section-size-guard/README.md | 0 .../claude-md-section-size-guard/index.mts | 0 .../claude-md-section-size-guard/package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../claude-md-size-guard/README.md | 0 .../claude-md-size-guard/index.mts | 0 .../claude-md-size-guard/package.json | 0 .../claude-md-size-guard/test/index.test.mts | 0 .../claude-md-size-guard}/tsconfig.json | 0 .../codex-no-write-guard/README.md | 0 .../codex-no-write-guard/index.mts | 0 .../codex-no-write-guard/package.json | 0 .../codex-no-write-guard/test/index.test.mts | 0 .../codex-no-write-guard}/tsconfig.json | 0 .../comment-tone-reminder/README.md | 0 .../comment-tone-reminder/README.md | 34 ++ .../comment-tone-reminder/index.mts | 0 .../comment-tone-reminder/package.json | 0 .../comment-tone-reminder/test/index.test.mts | 0 .../comment-tone-reminder}/tsconfig.json | 0 .../fleet/comment-tone-reminder/index.mts | 53 +++ .../fleet/comment-tone-reminder/package.json | 15 + .../comment-tone-reminder/test/index.test.mts | 117 +++++ .../comment-tone-reminder}/tsconfig.json | 0 .../{ => fleet}/commit-author-guard/README.md | 0 .../{ => fleet}/commit-author-guard/index.mts | 0 .../commit-author-guard/package.json | 0 .../commit-author-guard/test/index.test.mts | 0 .../commit-author-guard}/tsconfig.json | 0 .../commit-message-format-guard/README.md | 0 .../commit-message-format-guard/index.mts | 0 .../commit-message-format-guard/package.json | 0 .../test/format.test.mts | 0 .../tsconfig.json | 0 .../{ => fleet}/commit-pr-reminder/README.md | 0 .../commit-pr-reminder/README.md | 19 + .../commit-pr-reminder/index.mts | 0 .../commit-pr-reminder/package.json | 0 .../commit-pr-reminder/test/index.test.mts | 0 .../commit-pr-reminder}/tsconfig.json | 0 .../hooks/fleet/commit-pr-reminder/index.mts | 46 ++ .../fleet/commit-pr-reminder/package.json | 15 + .../commit-pr-reminder/test/index.test.mts | 79 ++++ .../commit-pr-reminder}/tsconfig.json | 0 .../compound-lessons-reminder/README.md | 0 .../compound-lessons-reminder/index.mts | 0 .../compound-lessons-reminder/package.json | 0 .../test/index.test.mts | 0 .../compound-lessons-reminder}/tsconfig.json | 0 .../concurrent-cargo-build-guard/README.md | 0 .../concurrent-cargo-build-guard/index.mts | 0 .../concurrent-cargo-build-guard/package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../consumer-grep-reminder/README.md | 0 .../consumer-grep-reminder/index.mts | 0 .../consumer-grep-reminder/package.json | 0 .../test/index.test.mts | 0 .../consumer-grep-reminder}/tsconfig.json | 0 .../{ => fleet}/cross-repo-guard/README.md | 0 .../{ => fleet}/cross-repo-guard/index.mts | 0 .../{ => fleet}/cross-repo-guard/package.json | 0 .../test/cross-repo-guard.test.mts | 0 .../cross-repo-guard}/tsconfig.json | 0 .../default-branch-guard/README.md | 0 .../default-branch-guard/index.mts | 0 .../default-branch-guard/package.json | 0 .../default-branch-guard/test/index.test.mts | 0 .../default-branch-guard}/tsconfig.json | 0 .../dirty-worktree-on-stop-reminder/README.md | 0 .../dirty-worktree-on-stop-reminder/index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../dont-blame-user-reminder/README.md | 0 .../dont-blame-user-reminder/index.mts | 0 .../dont-blame-user-reminder/package.json | 0 .../test/index.test.mts | 0 .../dont-blame-user-reminder}/tsconfig.json | 0 .../dont-stop-mid-queue-reminder/README.md | 0 .../dont-stop-mid-queue-reminder/index.mts | 0 .../dont-stop-mid-queue-reminder/package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../drift-check-reminder/README.md | 0 .../drift-check-reminder/index.mts | 0 .../drift-check-reminder/package.json | 0 .../drift-check-reminder/test/index.test.mts | 0 .../drift-check-reminder}/tsconfig.json | 0 .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../error-message-quality-reminder/README.md | 0 .../error-message-quality-reminder/index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../{ => fleet}/excuse-detector/README.md | 0 .../{ => fleet}/excuse-detector/index.mts | 0 .../{ => fleet}/excuse-detector/package.json | 0 .../excuse-detector/test/index.test.mts | 0 .../excuse-detector}/tsconfig.json | 0 .../extension-build-current-guard/README.md | 0 .../extension-build-current-guard/index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../{ => fleet}/file-size-reminder/README.md | 0 .../file-size-reminder/README.md | 52 +++ .../file-size-reminder/index.mts | 0 .../file-size-reminder/package.json | 0 .../file-size-reminder/test/index.test.mts | 0 .../file-size-reminder}/tsconfig.json | 0 .../hooks/fleet/file-size-reminder/index.mts | 218 +++++++++ .../fleet/file-size-reminder/package.json | 15 + .../file-size-reminder/test/index.test.mts | 196 ++++++++ .../file-size-reminder}/tsconfig.json | 0 .../README.md | 0 .../README.md | 44 ++ .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../index.mts | 313 +++++++++++++ .../package.json | 15 + .../test/index.test.mts | 111 +++++ .../tsconfig.json | 0 .../gh-token-hygiene-guard/README.md | 0 .../gh-token-hygiene-guard/index.mts | 0 .../gh-token-hygiene-guard/package.json | 0 .../test/index.test.mts | 0 .../gh-token-hygiene-guard}/tsconfig.json | 0 .../gitmodules-comment-guard/README.md | 0 .../gitmodules-comment-guard/index.mts | 0 .../gitmodules-comment-guard/package.json | 0 .../test/index.test.mts | 0 .../gitmodules-comment-guard}/tsconfig.json | 0 .../identifying-users-reminder/README.md | 0 .../identifying-users-reminder/index.mts | 0 .../identifying-users-reminder/package.json | 0 .../test/index.test.mts | 0 .../identifying-users-reminder}/tsconfig.json | 0 .../immutable-release-pattern-guard/README.md | 0 .../immutable-release-pattern-guard/index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../inline-script-defer-guard/README.md | 0 .../inline-script-defer-guard/index.mts | 0 .../inline-script-defer-guard/README.md | 53 +++ .../inline-script-defer-guard/index.mts | 190 ++++++++ .../inline-script-defer-guard/package.json | 0 .../test/index.test.mts | 0 .../inline-script-defer-guard}/tsconfig.json | 0 .../inline-script-defer-guard/package.json | 15 + .../test/index.test.mts | 134 ++++++ .../inline-script-defer-guard}/tsconfig.json | 0 .../{ => fleet}/judgment-reminder/README.md | 0 .../{ => fleet}/judgment-reminder/index.mts | 0 .../judgment-reminder/package.json | 0 .../judgment-reminder/test/index.test.mts | 0 .../judgment-reminder}/tsconfig.json | 0 .../{ => fleet}/lock-step-ref-guard/README.md | 0 .../{ => fleet}/lock-step-ref-guard/index.mts | 0 .../lock-step-ref-guard/package.json | 0 .../lock-step-ref-guard/test/index.test.mts | 0 .../lock-step-ref-guard}/tsconfig.json | 0 .../hooks/{ => fleet}/logger-guard/README.md | 0 .../hooks/{ => fleet}/logger-guard/index.mts | 0 .../{ => fleet}/logger-guard/package.json | 0 .../logger-guard/test/logger-guard.test.mts | 0 .../logger-guard}/tsconfig.json | 0 .../markdown-filename-guard/README.md | 0 .../markdown-filename-guard/index.mts | 0 .../markdown-filename-guard/package.json | 0 .../test/index.test.mts | 0 .../markdown-filename-guard}/tsconfig.json | 0 .../marketplace-comment-guard/README.md | 0 .../marketplace-comment-guard/index.mts | 0 .../marketplace-comment-guard/package.json | 0 .../test/index.test.mts | 0 .../marketplace-comment-guard}/tsconfig.json | 0 .../hooks/fleet/minify-mcp-output/README.md | 85 ++++ .../{ => fleet}/minify-mcp-output/index.mts | 0 .../minify-mcp-output/README.md | 0 .../minify-mcp-output/index.mts | 154 +++++++ .../minify-mcp-output/package.json | 0 .../minify-mcp-output/test/index.test.mts | 0 .../minify-mcp-output}/tsconfig.json | 0 .../fleet/minify-mcp-output/package.json | 12 + .../minify-mcp-output/test/index.test.mts | 164 +++++++ .../minify-mcp-output}/tsconfig.json | 0 .../minimum-release-age-guard/README.md | 0 .../minimum-release-age-guard/index.mts | 0 .../minimum-release-age-guard/package.json | 0 .../test/index.test.mts | 0 .../minimum-release-age-guard}/tsconfig.json | 0 .../new-hook-claude-md-guard/README.md | 0 .../new-hook-claude-md-guard/index.mts | 0 .../new-hook-claude-md-guard/package.json | 0 .../test/index.test.mts | 0 .../new-hook-claude-md-guard}/tsconfig.json | 0 .../no-blind-keychain-read-guard/README.md | 0 .../no-blind-keychain-read-guard/index.mts | 0 .../no-blind-keychain-read-guard/README.md | 65 +++ .../no-blind-keychain-read-guard/index.mts | 229 ++++++++++ .../no-blind-keychain-read-guard/package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../no-blind-keychain-read-guard/package.json | 15 + .../test/index.test.mts | 142 ++++++ .../tsconfig.json | 0 .../no-disable-lint-rule-guard/README.md | 0 .../no-disable-lint-rule-guard/index.mts | 0 .../no-disable-lint-rule-guard/package.json | 0 .../test/index.test.mts | 0 .../no-disable-lint-rule-guard}/tsconfig.json | 0 .../no-empty-commit-guard/README.md | 0 .../no-empty-commit-guard/index.mts | 0 .../no-empty-commit-guard/package.json | 0 .../no-empty-commit-guard/test/index.test.mts | 0 .../no-empty-commit-guard}/tsconfig.json | 0 .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../no-external-issue-ref-guard/README.md | 0 .../no-external-issue-ref-guard/index.mts | 0 .../no-external-issue-ref-guard/package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../tsconfig.json | 0 .../{ => fleet}/no-fleet-fork-guard/README.md | 0 .../{ => fleet}/no-fleet-fork-guard/index.mts | 0 .../no-fleet-fork-guard/package.json | 0 .../no-fleet-fork-guard/test/index.test.mts | 0 .../no-fleet-fork-guard}/tsconfig.json | 0 .../no-meta-comments-guard/README.md | 0 .../no-meta-comments-guard/index.mts | 0 .../no-meta-comments-guard/README.md | 34 ++ .../no-meta-comments-guard/index.mts | 358 +++++++++++++++ .../no-meta-comments-guard/package.json | 0 .../test/index.test.mts | 0 .../no-meta-comments-guard}/tsconfig.json | 0 .../fleet/no-meta-comments-guard/package.json | 15 + .../test/index.test.mts | 261 +++++++++++ .../no-meta-comments-guard}/tsconfig.json | 0 .../fleet/no-non-fleet-push-guard/README.md | 81 ++++ .../fleet/no-non-fleet-push-guard/index.mts | 173 +++++++ .../no-non-fleet-push-guard/package.json | 15 + .../test/index.test.mts | 171 +++++++ .../no-non-fleet-push-guard}/tsconfig.json | 0 .../{ => fleet}/no-orphaned-staging/README.md | 0 .../{ => fleet}/no-orphaned-staging/index.mts | 0 .../no-orphaned-staging/README.md | 49 ++ .../no-orphaned-staging/index.mts | 113 +++++ .../no-orphaned-staging/package.json | 0 .../no-orphaned-staging/test/index.test.mts | 0 .../no-orphaned-staging}/tsconfig.json | 0 .../no-orphaned-staging/test/index.test.mts | 127 ++++++ .../README.md | 55 +++ .../index.mts | 179 ++++++++ .../package.json | 15 + .../test/index.test.mts | 147 ++++++ .../tsconfig.json | 0 .../{ => fleet}/no-revert-guard/README.md | 0 .../{ => fleet}/no-revert-guard/index.mts | 0 .../{ => fleet}/no-revert-guard/package.json | 0 .../no-revert-guard/test/index.test.mts | 0 .../no-revert-guard}/tsconfig.json | 0 .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../no-token-in-dotenv-guard/README.md | 0 .../no-token-in-dotenv-guard/index.mts | 0 .../no-token-in-dotenv-guard/package.json | 0 .../test/index.test.mts | 0 .../no-token-in-dotenv-guard}/tsconfig.json | 0 .../no-underscore-identifier-guard/README.md | 0 .../no-underscore-identifier-guard/index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../README.md | 32 ++ .../index.mts | 160 +++++++ .../package.json | 15 + .../test/index.test.mts | 69 +++ .../tsconfig.json | 0 .../node-modules-staging-guard/README.md | 0 .../node-modules-staging-guard/index.mts | 0 .../node-modules-staging-guard/package.json | 0 .../test/index.test.mts | 0 .../node-modules-staging-guard}/tsconfig.json | 0 .../non-fleet-pr-issue-ask-guard/README.md | 32 ++ .../non-fleet-pr-issue-ask-guard/index.mts | 205 +++++++++ .../non-fleet-pr-issue-ask-guard/package.json | 12 + .../test/index.test.mts | 108 +++++ .../tsconfig.json | 0 .../fleet/overeager-staging-guard/README.md | 33 ++ .../overeager-staging-guard/index.mts | 0 .../overeager-staging-guard/index.mts | 191 ++++++++ .../overeager-staging-guard/package.json | 0 .../test/index.test.mts | 0 .../overeager-staging-guard}/tsconfig.json | 0 .../overeager-staging-guard/package.json | 15 + .../test/index.test.mts | 319 +++++++++++++ .../overeager-staging-guard}/tsconfig.json | 0 .../fleet/parallel-agent-edit-guard/README.md | 51 +++ .../fleet/parallel-agent-edit-guard/index.mts | 139 ++++++ .../parallel-agent-edit-guard/package.json | 15 + .../test/index.test.mts | 180 ++++++++ .../parallel-agent-edit-guard}/tsconfig.json | 0 .../parallel-agent-on-stop-reminder/README.md | 37 ++ .../parallel-agent-on-stop-reminder/index.mts | 96 ++++ .../package.json | 15 + .../test/index.test.mts | 138 ++++++ .../tsconfig.json | 0 .../parallel-agent-staging-guard/README.md | 47 ++ .../parallel-agent-staging-guard/index.mts | 191 ++++++++ .../parallel-agent-staging-guard/package.json | 15 + .../test/index.test.mts | 194 ++++++++ .../tsconfig.json | 0 .../hooks/{ => fleet}/path-guard/README.md | 0 .../hooks/{ => fleet}/path-guard/index.mts | 0 .../hooks/{ => fleet}/path-guard/package.json | 0 .../fleet/path-guard/path-guard/README.md | 113 +++++ .../fleet/path-guard/path-guard/index.mts | 351 ++++++++++++++ .../fleet/path-guard/path-guard/package.json | 12 + .../path-guard}/path-guard/segments.mts | 0 .../path-guard/test/path-guard.test.mts | 0 .../path-guard/path-guard}/tsconfig.json | 0 .claude/hooks/fleet/path-guard/segments.mts | 74 +++ .../fleet/path-guard/test/path-guard.test.mts | 311 +++++++++++++ .../path-guard}/tsconfig.json | 0 .../path-regex-normalize-reminder/README.md | 0 .../path-regex-normalize-reminder/index.mts | 0 .../package.json | 0 .../tsconfig.json | 0 .../paths-mts-inherit-guard/README.md | 0 .../paths-mts-inherit-guard/index.mts | 0 .../paths-mts-inherit-guard/package.json | 0 .../test/index.test.mts | 0 .../paths-mts-inherit-guard}/tsconfig.json | 0 .../perfectionist-reminder/README.md | 0 .../perfectionist-reminder/index.mts | 0 .../perfectionist-reminder/package.json | 0 .../perfectionist-reminder/README.md | 53 +++ .../perfectionist-reminder/index.mts | 78 ++++ .../perfectionist-reminder/package.json | 15 + .../test/index.test.mts | 0 .../perfectionist-reminder}/tsconfig.json | 0 .../test/index.test.mts | 137 ++++++ .../perfectionist-reminder}/tsconfig.json | 0 .../{ => fleet}/plan-location-guard/README.md | 0 .../{ => fleet}/plan-location-guard/index.mts | 0 .../plan-location-guard/package.json | 0 .../plan-location-guard/README.md | 55 +++ .../plan-location-guard/index.mts | 304 +++++++++++++ .../plan-location-guard/package.json | 18 + .../plan-location-guard/test/index.test.mts | 0 .../plan-location-guard}/tsconfig.json | 0 .../plan-location-guard/test/index.test.mts | 216 +++++++++ .../plan-location-guard}/tsconfig.json | 0 .../plan-review-reminder/README.md | 0 .../plan-review-reminder/index.mts | 0 .../plan-review-reminder/package.json | 0 .../plan-review-reminder/test/index.test.mts | 0 .../plan-review-reminder}/tsconfig.json | 0 .../fleet/plugin-patch-format-guard/README.md | 37 ++ .../fleet/plugin-patch-format-guard/index.mts | 272 +++++++++++ .../plugin-patch-format-guard/package.json | 18 + .../test/index.test.mts | 251 ++++++++++ .../plugin-patch-format-guard}/tsconfig.json | 0 .../pointer-comment-guard/README.md | 0 .../pointer-comment-guard/index.mts | 0 .../pointer-comment-guard/package.json | 0 .../pointer-comment-guard/test/index.test.mts | 0 .../pointer-comment-guard}/tsconfig.json | 0 .../pr-vs-push-default-reminder/README.md | 0 .../pr-vs-push-default-reminder/index.mts | 0 .../pr-vs-push-default-reminder/package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../prefer-rebase-over-revert-guard/README.md | 0 .../prefer-rebase-over-revert-guard/index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 0 .../{ => fleet}/private-name-guard/README.md | 0 .../{ => fleet}/private-name-guard/index.mts | 0 .../private-name-guard/package.json | 0 .../test/private-name-guard.test.mts | 0 .../private-name-guard}/tsconfig.json | 0 .../provenance-publish-reminder/README.md | 53 +++ .../provenance-publish-reminder/index.mts | 258 +++++++++++ .../provenance-publish-reminder/package.json | 18 + .../test/index.test.mts | 39 ++ .../provenance-publish-reminder/tsconfig.json | 16 + .../fleet/public-surface-reminder/README.md | 86 ++++ .../public-surface-reminder/index.mts | 0 .../public-surface-reminder/package.json | 0 .../public-surface-reminder/README.md | 0 .../public-surface-reminder/index.mts | 87 ++++ .../public-surface-reminder/package.json | 12 + .../test/public-surface-reminder.test.mts | 0 .../public-surface-reminder/tsconfig.json | 16 + .../test/public-surface-reminder.test.mts | 95 ++++ .../public-surface-reminder/tsconfig.json | 16 + .../pull-request-target-guard/README.md | 0 .../pull-request-target-guard/index.mts | 0 .../pull-request-target-guard/package.json | 0 .../test/index.test.mts | 0 .../pull-request-target-guard/tsconfig.json | 16 + .../readme-fleet-shape-guard/README.md | 0 .../readme-fleet-shape-guard/index.mts | 0 .../readme-fleet-shape-guard/package.json | 0 .../test/index.test.mts | 0 .../readme-fleet-shape-guard/tsconfig.json | 16 + .../release-workflow-guard/README.md | 0 .../release-workflow-guard/index.mts | 0 .../release-workflow-guard/package.json | 0 .../test/release-workflow-guard.test.mts | 0 .../release-workflow-guard/tsconfig.json | 16 + .../scan-label-in-commit-guard/README.md | 0 .../scan-label-in-commit-guard/index.mts | 0 .../scan-label-in-commit-guard/package.json | 0 .../test/index.test.mts | 0 .../scan-label-in-commit-guard/tsconfig.json | 16 + .../{ => fleet}/setup-basics-tools/README.md | 0 .../setup-basics-tools/install.mts | 0 .../setup-basics-tools/package.json | 0 .../fleet/setup-basics-tools/tsconfig.json | 16 + .../fleet/setup-claude-scanners/README.md | 39 ++ .../fleet/setup-claude-scanners/install.mts | 45 ++ .../setup-claude-scanners/package.json | 0 .../setup-claude-scanners/README.md | 0 .../setup-claude-scanners/install.mts | 0 .../setup-claude-scanners/package.json | 16 + .../setup-claude-scanners/tsconfig.json | 16 + .../fleet/setup-claude-scanners/tsconfig.json | 16 + .../{ => fleet}/setup-firewall/README.md | 0 .../{ => fleet}/setup-firewall/install.mts | 0 .../{ => fleet}/setup-firewall/package.json | 0 .../hooks/fleet/setup-firewall/tsconfig.json | 16 + .../{ => fleet}/setup-misc-tools/README.md | 0 .../{ => fleet}/setup-misc-tools/install.mts | 0 .../{ => fleet}/setup-misc-tools/package.json | 0 .../fleet/setup-misc-tools/tsconfig.json | 16 + .../setup-security-tools/README.md | 0 .../setup-security-tools/external-tools.json | 0 .../setup-security-tools/index.mts | 0 .../setup-security-tools/install.mts | 0 .../setup-security-tools/lib/api-token.mts | 0 .../setup-security-tools/lib/installers.mts | 0 .../lib/operator-prompts.mts | 0 .../lib/shell-rc-bridge.mts | 0 .../lib/token-storage.mts | 0 .../setup-security-tools/package.json | 0 .../test/setup-security-tools.test.mts | 0 .../test/shell-rc-bridge.test.mts | 0 .../fleet/setup-security-tools/tsconfig.json | 16 + .../setup-security-tools/update.mts | 0 .../hooks/{ => fleet}/setup-signing/README.md | 0 .../{ => fleet}/setup-signing/install.mts | 0 .../{ => fleet}/setup-signing/package.json | 0 .../hooks/fleet/setup-signing/tsconfig.json | 16 + .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 16 + .../socket-token-minifier-start/README.md | 0 .../socket-token-minifier-start/index.mts | 0 .../socket-token-minifier-start/package.json | 0 .../socket-token-minifier-start/tsconfig.json | 16 + .../squash-history-reminder/README.md | 0 .../squash-history-reminder/index.mts | 0 .../squash-history-reminder/package.json | 0 .../test/index.test.mts | 0 .../squash-history-reminder/tsconfig.json | 16 + .../fleet/stale-process-sweeper/README.md | 94 ++++ .../stale-process-sweeper/index.mts | 0 .../stale-process-sweeper/package.json | 0 .../stale-process-sweeper/README.md | 0 .../stale-process-sweeper/index.mts | 320 +++++++++++++ .../stale-process-sweeper/package.json | 12 + .../test/stale-process-sweeper.test.mts | 0 .../stale-process-sweeper/tsconfig.json | 16 + .../test/stale-process-sweeper.test.mts | 92 ++++ .../fleet/stale-process-sweeper/tsconfig.json | 16 + .../{ => fleet}/sweep-ds-store/README.md | 0 .../{ => fleet}/sweep-ds-store/index.mts | 0 .../{ => fleet}/sweep-ds-store/package.json | 0 .../sweep-ds-store/test/index.test.mts | 0 .../hooks/fleet/sweep-ds-store/tsconfig.json | 16 + .../hooks/{ => fleet}/token-guard/README.md | 0 .../hooks/{ => fleet}/token-guard/index.mts | 0 .../{ => fleet}/token-guard/package.json | 0 .../token-guard/test/token-guard.test.mts | 0 .claude/hooks/fleet/token-guard/tsconfig.json | 16 + .../fleet/trust-downgrade-guard/README.md | 58 +++ .../fleet/trust-downgrade-guard/index.mts | 323 +++++++++++++ .../fleet/trust-downgrade-guard/package.json | 15 + .../trust-downgrade-guard/test/index.test.mts | 208 +++++++++ .../fleet/trust-downgrade-guard/tsconfig.json | 16 + .../fleet/uses-sha-verify-guard/README.md | 33 ++ .../fleet/uses-sha-verify-guard/index.mts | 427 ++++++++++++++++++ .../fleet/uses-sha-verify-guard/package.json | 12 + .../uses-sha-verify-guard/test/index.test.mts | 161 +++++++ .../fleet/uses-sha-verify-guard/tsconfig.json | 16 + .../variant-analysis-reminder/README.md | 0 .../variant-analysis-reminder/index.mts | 0 .../variant-analysis-reminder/package.json | 0 .../test/index.test.mts | 0 .../variant-analysis-reminder/tsconfig.json | 16 + .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 16 + .../version-bump-order-guard/README.md | 0 .../version-bump-order-guard/index.mts | 0 .../version-bump-order-guard/package.json | 0 .../test/index.test.mts | 0 .../version-bump-order-guard/tsconfig.json | 16 + .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 16 + .../workflow-uses-comment-guard/README.md | 0 .../workflow-uses-comment-guard/index.mts | 0 .../workflow-uses-comment-guard/package.json | 0 .../test/index.test.mts | 0 .../workflow-uses-comment-guard/tsconfig.json | 16 + .../README.md | 0 .../index.mts | 0 .../package.json | 0 .../test/index.test.mts | 0 .../tsconfig.json | 16 + .claude/settings.json | 210 +++++---- .claude/skills/auditing-gha-settings/SKILL.md | 2 + .claude/skills/cascading-fleet/SKILL.md | 4 + .claude/skills/cleaning-redundant-ci/SKILL.md | 2 + .claude/skills/guarding-paths/SKILL.md | 2 + .claude/skills/refreshing-history/SKILL.md | 2 + .claude/skills/reviewing-code/SKILL.md | 2 + .claude/skills/running-test262/SKILL.md | 2 + .claude/skills/scanning-quality/SKILL.md | 2 + .claude/skills/scanning-security/SKILL.md | 2 + .claude/skills/squashing-history/SKILL.md | 2 + .claude/skills/updating-coverage/SKILL.md | 2 + .claude/skills/updating-lockstep/SKILL.md | 2 + .claude/skills/updating/SKILL.md | 20 +- .claude/skills/worktree-management/SKILL.md | 2 + .config/oxlint-plugin/index.mts | 4 + .../rules/no-underscore-identifier.mts | 11 + .../rules/prefer-error-message.mts | 129 ++++++ .../rules/prefer-pure-call-form.mts | 150 ++++++ .../test/prefer-error-message.test.mts | 58 +++ .../test/prefer-pure-call-form.test.mts | 62 +++ .config/vitest.coverage.fleet.config.mts | 56 +++ docs/claude.md/fleet/bypass-phrases.md | 3 +- docs/claude.md/fleet/gh-token-hygiene.md | 16 + docs/claude.md/fleet/skill-model-routing.md | 78 ++++ scripts/ai-lint-fix/cli.mts | 31 +- scripts/ai-lint-fix/rule-guidance.mts | 83 ++++ scripts/audit-transcript.mts | 95 +++- scripts/check-paths/exempt.mts | 2 +- scripts/check-paths/scan-code.mts | 2 +- scripts/check-prompt-less-setup.mts | 4 +- scripts/validate-file-size.mts | 2 +- 641 files changed, 13886 insertions(+), 107 deletions(-) rename .claude/hooks/{ => fleet}/_shared/README.md (100%) rename .claude/hooks/{ => fleet}/_shared/acorn/README.md (100%) rename .claude/hooks/{ => fleet}/_shared/acorn/acorn-bindgen.cjs (100%) rename .claude/hooks/{ => fleet}/_shared/acorn/acorn-wasm-sync.mts (100%) rename .claude/hooks/{ => fleet}/_shared/acorn/acorn.wasm (100%) rename .claude/hooks/{ => fleet}/_shared/acorn/index.mts (100%) rename .claude/hooks/{ => fleet}/_shared/fleet-repos.mts (100%) rename .claude/hooks/{ => fleet}/_shared/foreign-paths.mts (100%) rename .claude/hooks/{ => fleet}/_shared/hook-env.mts (100%) rename .claude/hooks/{ => fleet}/_shared/markers.mts (100%) rename .claude/hooks/{ => fleet}/_shared/payload.mts (100%) rename .claude/hooks/{ => fleet}/_shared/shell-command.mts (100%) rename .claude/hooks/{ => fleet}/_shared/stop-reminder.mts (100%) rename .claude/hooks/{ => fleet}/_shared/test/fleet-repos.test.mts (100%) rename .claude/hooks/{ => fleet}/_shared/test/foreign-paths.test.mts (100%) rename .claude/hooks/{ => fleet}/_shared/test/shell-command.test.mts (100%) rename .claude/hooks/{ => fleet}/_shared/test/transcript.test.mts (100%) rename .claude/hooks/{ => fleet}/_shared/token-patterns.mts (100%) rename .claude/hooks/{ => fleet}/_shared/transcript.mts (100%) rename .claude/hooks/{ => fleet}/_shared/wheelhouse-root.mts (100%) rename .claude/hooks/{ => fleet}/actionlint-on-workflow-edit/README.md (100%) rename .claude/hooks/{ => fleet}/actionlint-on-workflow-edit/index.mts (100%) rename .claude/hooks/{ => fleet}/actionlint-on-workflow-edit/package.json (100%) rename .claude/hooks/{ => fleet}/actionlint-on-workflow-edit/test/index.test.mts (100%) rename .claude/hooks/{ => fleet}/actionlint-on-workflow-edit/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/answer-passing-questions-reminder/README.md create mode 100644 .claude/hooks/fleet/answer-passing-questions-reminder/index.mts create mode 100644 .claude/hooks/fleet/answer-passing-questions-reminder/package.json create mode 100644 .claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts rename .claude/hooks/{ask-suppression-reminder => fleet/answer-passing-questions-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/answer-status-requests-reminder/README.md create mode 100644 .claude/hooks/fleet/answer-status-requests-reminder/index.mts create mode 100644 .claude/hooks/fleet/answer-status-requests-reminder/package.json create mode 100644 .claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts rename .claude/hooks/{auth-rotation-reminder => fleet/answer-status-requests-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/ask-suppression-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/ask-suppression-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/ask-suppression-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/ask-suppression-reminder/test/index.test.mts (100%) rename .claude/hooks/{check-new-deps => fleet/ask-suppression-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/auth-rotation-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/auth-rotation-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/auth-rotation-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/auth-rotation-reminder/services.mts (100%) rename .claude/hooks/{ => fleet}/auth-rotation-reminder/test/auth-rotation-reminder.test.mts (100%) rename .claude/hooks/{claude-md-section-size-guard => fleet/auth-rotation-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/avoid-cd-reminder/index.mts create mode 100644 .claude/hooks/fleet/broken-hook-detector/README.md create mode 100644 .claude/hooks/fleet/broken-hook-detector/index.mts create mode 100644 .claude/hooks/fleet/broken-hook-detector/package.json create mode 100644 .claude/hooks/fleet/broken-hook-detector/test/index.test.mts rename .claude/hooks/{claude-md-size-guard => fleet/broken-hook-detector}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/check-new-deps/README.md (100%) rename .claude/hooks/{ => fleet}/check-new-deps/audit.mts (100%) rename .claude/hooks/{ => fleet}/check-new-deps/index.mts (100%) rename .claude/hooks/{ => fleet}/check-new-deps/package.json (100%) rename .claude/hooks/{ => fleet}/check-new-deps/test/extract-deps.test.mts (100%) rename .claude/hooks/{codex-no-write-guard => fleet/check-new-deps}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/check-new-deps/types.mts (100%) rename .claude/hooks/{ => fleet}/claude-md-section-size-guard/README.md (100%) rename .claude/hooks/{ => fleet}/claude-md-section-size-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/claude-md-section-size-guard/package.json (100%) rename .claude/hooks/{ => fleet}/claude-md-section-size-guard/test/index.test.mts (100%) rename .claude/hooks/{comment-tone-reminder => fleet/claude-md-section-size-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/claude-md-size-guard/README.md (100%) rename .claude/hooks/{ => fleet}/claude-md-size-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/claude-md-size-guard/package.json (100%) rename .claude/hooks/{ => fleet}/claude-md-size-guard/test/index.test.mts (100%) rename .claude/hooks/{commit-author-guard => fleet/claude-md-size-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/codex-no-write-guard/README.md (100%) rename .claude/hooks/{ => fleet}/codex-no-write-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/codex-no-write-guard/package.json (100%) rename .claude/hooks/{ => fleet}/codex-no-write-guard/test/index.test.mts (100%) rename .claude/hooks/{commit-message-format-guard => fleet/codex-no-write-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/comment-tone-reminder/README.md (100%) create mode 100644 .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md rename .claude/hooks/{ => fleet/comment-tone-reminder}/comment-tone-reminder/index.mts (100%) rename .claude/hooks/{ => fleet/comment-tone-reminder}/comment-tone-reminder/package.json (100%) rename .claude/hooks/{ => fleet/comment-tone-reminder}/comment-tone-reminder/test/index.test.mts (100%) rename .claude/hooks/{commit-pr-reminder => fleet/comment-tone-reminder/comment-tone-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/comment-tone-reminder/index.mts create mode 100644 .claude/hooks/fleet/comment-tone-reminder/package.json create mode 100644 .claude/hooks/fleet/comment-tone-reminder/test/index.test.mts rename .claude/hooks/{compound-lessons-reminder => fleet/comment-tone-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/commit-author-guard/README.md (100%) rename .claude/hooks/{ => fleet}/commit-author-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/commit-author-guard/package.json (100%) rename .claude/hooks/{ => fleet}/commit-author-guard/test/index.test.mts (100%) rename .claude/hooks/{concurrent-cargo-build-guard => fleet/commit-author-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/commit-message-format-guard/README.md (100%) rename .claude/hooks/{ => fleet}/commit-message-format-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/commit-message-format-guard/package.json (100%) rename .claude/hooks/{ => fleet}/commit-message-format-guard/test/format.test.mts (100%) rename .claude/hooks/{consumer-grep-reminder => fleet/commit-message-format-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/commit-pr-reminder/README.md (100%) create mode 100644 .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md rename .claude/hooks/{ => fleet/commit-pr-reminder}/commit-pr-reminder/index.mts (100%) rename .claude/hooks/{ => fleet/commit-pr-reminder}/commit-pr-reminder/package.json (100%) rename .claude/hooks/{ => fleet/commit-pr-reminder}/commit-pr-reminder/test/index.test.mts (100%) rename .claude/hooks/{cross-repo-guard => fleet/commit-pr-reminder/commit-pr-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/commit-pr-reminder/index.mts create mode 100644 .claude/hooks/fleet/commit-pr-reminder/package.json create mode 100644 .claude/hooks/fleet/commit-pr-reminder/test/index.test.mts rename .claude/hooks/{default-branch-guard => fleet/commit-pr-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/compound-lessons-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/compound-lessons-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/compound-lessons-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/compound-lessons-reminder/test/index.test.mts (100%) rename .claude/hooks/{dirty-worktree-on-stop-reminder => fleet/compound-lessons-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/concurrent-cargo-build-guard/README.md (100%) rename .claude/hooks/{ => fleet}/concurrent-cargo-build-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/concurrent-cargo-build-guard/package.json (100%) rename .claude/hooks/{ => fleet}/concurrent-cargo-build-guard/test/index.test.mts (100%) rename .claude/hooks/{dont-blame-user-reminder => fleet/concurrent-cargo-build-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/consumer-grep-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/consumer-grep-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/consumer-grep-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/consumer-grep-reminder/test/index.test.mts (100%) rename .claude/hooks/{dont-stop-mid-queue-reminder => fleet/consumer-grep-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/cross-repo-guard/README.md (100%) rename .claude/hooks/{ => fleet}/cross-repo-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/cross-repo-guard/package.json (100%) rename .claude/hooks/{ => fleet}/cross-repo-guard/test/cross-repo-guard.test.mts (100%) rename .claude/hooks/{drift-check-reminder => fleet/cross-repo-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/default-branch-guard/README.md (100%) rename .claude/hooks/{ => fleet}/default-branch-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/default-branch-guard/package.json (100%) rename .claude/hooks/{ => fleet}/default-branch-guard/test/index.test.mts (100%) rename .claude/hooks/{enterprise-push-property-reminder => fleet/default-branch-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/dirty-worktree-on-stop-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/dirty-worktree-on-stop-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/dirty-worktree-on-stop-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/dirty-worktree-on-stop-reminder/test/index.test.mts (100%) rename .claude/hooks/{error-message-quality-reminder => fleet/dirty-worktree-on-stop-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/dont-blame-user-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/dont-blame-user-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/dont-blame-user-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/dont-blame-user-reminder/test/index.test.mts (100%) rename .claude/hooks/{excuse-detector => fleet/dont-blame-user-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/dont-stop-mid-queue-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/dont-stop-mid-queue-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/dont-stop-mid-queue-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/dont-stop-mid-queue-reminder/test/index.test.mts (100%) rename .claude/hooks/{extension-build-current-guard => fleet/dont-stop-mid-queue-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/drift-check-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/drift-check-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/drift-check-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/drift-check-reminder/test/index.test.mts (100%) rename .claude/hooks/{file-size-reminder => fleet/drift-check-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/enterprise-push-property-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/enterprise-push-property-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/enterprise-push-property-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/enterprise-push-property-reminder/test/index.test.mts (100%) rename .claude/hooks/{follow-direct-imperative-reminder => fleet/enterprise-push-property-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/error-message-quality-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/error-message-quality-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/error-message-quality-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/error-message-quality-reminder/test/index.test.mts (100%) rename .claude/hooks/{gh-token-hygiene-guard => fleet/error-message-quality-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/excuse-detector/README.md (100%) rename .claude/hooks/{ => fleet}/excuse-detector/index.mts (100%) rename .claude/hooks/{ => fleet}/excuse-detector/package.json (100%) rename .claude/hooks/{ => fleet}/excuse-detector/test/index.test.mts (100%) rename .claude/hooks/{gitmodules-comment-guard => fleet/excuse-detector}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/extension-build-current-guard/README.md (100%) rename .claude/hooks/{ => fleet}/extension-build-current-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/extension-build-current-guard/package.json (100%) rename .claude/hooks/{ => fleet}/extension-build-current-guard/test/index.test.mts (100%) rename .claude/hooks/{identifying-users-reminder => fleet/extension-build-current-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/file-size-reminder/README.md (100%) create mode 100644 .claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md rename .claude/hooks/{ => fleet/file-size-reminder}/file-size-reminder/index.mts (100%) rename .claude/hooks/{ => fleet/file-size-reminder}/file-size-reminder/package.json (100%) rename .claude/hooks/{ => fleet/file-size-reminder}/file-size-reminder/test/index.test.mts (100%) rename .claude/hooks/{immutable-release-pattern-guard => fleet/file-size-reminder/file-size-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/file-size-reminder/index.mts create mode 100644 .claude/hooks/fleet/file-size-reminder/package.json create mode 100644 .claude/hooks/fleet/file-size-reminder/test/index.test.mts rename .claude/hooks/{inline-script-defer-guard => fleet/file-size-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/follow-direct-imperative-reminder/README.md (100%) create mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md rename .claude/hooks/{ => fleet/follow-direct-imperative-reminder}/follow-direct-imperative-reminder/index.mts (100%) rename .claude/hooks/{ => fleet/follow-direct-imperative-reminder}/follow-direct-imperative-reminder/package.json (100%) rename .claude/hooks/{ => fleet/follow-direct-imperative-reminder}/follow-direct-imperative-reminder/test/index.test.mts (100%) rename .claude/hooks/{judgment-reminder => fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/index.mts create mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/package.json create mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/test/index.test.mts rename .claude/hooks/{lock-step-ref-guard => fleet/follow-direct-imperative-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/gh-token-hygiene-guard/README.md (100%) rename .claude/hooks/{ => fleet}/gh-token-hygiene-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/gh-token-hygiene-guard/package.json (100%) rename .claude/hooks/{ => fleet}/gh-token-hygiene-guard/test/index.test.mts (100%) rename .claude/hooks/{logger-guard => fleet/gh-token-hygiene-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/gitmodules-comment-guard/README.md (100%) rename .claude/hooks/{ => fleet}/gitmodules-comment-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/gitmodules-comment-guard/package.json (100%) rename .claude/hooks/{ => fleet}/gitmodules-comment-guard/test/index.test.mts (100%) rename .claude/hooks/{markdown-filename-guard => fleet/gitmodules-comment-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/identifying-users-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/identifying-users-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/identifying-users-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/identifying-users-reminder/test/index.test.mts (100%) rename .claude/hooks/{marketplace-comment-guard => fleet/identifying-users-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/immutable-release-pattern-guard/README.md (100%) rename .claude/hooks/{ => fleet}/immutable-release-pattern-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/immutable-release-pattern-guard/package.json (100%) rename .claude/hooks/{ => fleet}/immutable-release-pattern-guard/test/index.test.mts (100%) rename .claude/hooks/{minify-mcp-output => fleet/immutable-release-pattern-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/inline-script-defer-guard/README.md (100%) rename .claude/hooks/{ => fleet}/inline-script-defer-guard/index.mts (100%) create mode 100644 .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md create mode 100644 .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts rename .claude/hooks/{ => fleet/inline-script-defer-guard}/inline-script-defer-guard/package.json (100%) rename .claude/hooks/{ => fleet/inline-script-defer-guard}/inline-script-defer-guard/test/index.test.mts (100%) rename .claude/hooks/{minimum-release-age-guard => fleet/inline-script-defer-guard/inline-script-defer-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/inline-script-defer-guard/package.json create mode 100644 .claude/hooks/fleet/inline-script-defer-guard/test/index.test.mts rename .claude/hooks/{new-hook-claude-md-guard => fleet/inline-script-defer-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/judgment-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/judgment-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/judgment-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/judgment-reminder/test/index.test.mts (100%) rename .claude/hooks/{no-blind-keychain-read-guard => fleet/judgment-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/lock-step-ref-guard/README.md (100%) rename .claude/hooks/{ => fleet}/lock-step-ref-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/lock-step-ref-guard/package.json (100%) rename .claude/hooks/{ => fleet}/lock-step-ref-guard/test/index.test.mts (100%) rename .claude/hooks/{no-disable-lint-rule-guard => fleet/lock-step-ref-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/logger-guard/README.md (100%) rename .claude/hooks/{ => fleet}/logger-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/logger-guard/package.json (100%) rename .claude/hooks/{ => fleet}/logger-guard/test/logger-guard.test.mts (100%) rename .claude/hooks/{no-empty-commit-guard => fleet/logger-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/markdown-filename-guard/README.md (100%) rename .claude/hooks/{ => fleet}/markdown-filename-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/markdown-filename-guard/package.json (100%) rename .claude/hooks/{ => fleet}/markdown-filename-guard/test/index.test.mts (100%) rename .claude/hooks/{no-experimental-strip-types-guard => fleet/markdown-filename-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/marketplace-comment-guard/README.md (100%) rename .claude/hooks/{ => fleet}/marketplace-comment-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/marketplace-comment-guard/package.json (100%) rename .claude/hooks/{ => fleet}/marketplace-comment-guard/test/index.test.mts (100%) rename .claude/hooks/{no-external-issue-ref-guard => fleet/marketplace-comment-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/minify-mcp-output/README.md rename .claude/hooks/{ => fleet}/minify-mcp-output/index.mts (100%) rename .claude/hooks/{ => fleet/minify-mcp-output}/minify-mcp-output/README.md (100%) create mode 100644 .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts rename .claude/hooks/{ => fleet/minify-mcp-output}/minify-mcp-output/package.json (100%) rename .claude/hooks/{ => fleet/minify-mcp-output}/minify-mcp-output/test/index.test.mts (100%) rename .claude/hooks/{no-file-scope-oxlint-disable-guard => fleet/minify-mcp-output/minify-mcp-output}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/minify-mcp-output/package.json create mode 100644 .claude/hooks/fleet/minify-mcp-output/test/index.test.mts rename .claude/hooks/{no-fleet-fork-guard => fleet/minify-mcp-output}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/minimum-release-age-guard/README.md (100%) rename .claude/hooks/{ => fleet}/minimum-release-age-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/minimum-release-age-guard/package.json (100%) rename .claude/hooks/{ => fleet}/minimum-release-age-guard/test/index.test.mts (100%) rename .claude/hooks/{no-meta-comments-guard => fleet/minimum-release-age-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/new-hook-claude-md-guard/README.md (100%) rename .claude/hooks/{ => fleet}/new-hook-claude-md-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/new-hook-claude-md-guard/package.json (100%) rename .claude/hooks/{ => fleet}/new-hook-claude-md-guard/test/index.test.mts (100%) rename .claude/hooks/{no-orphaned-staging => fleet/new-hook-claude-md-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-blind-keychain-read-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-blind-keychain-read-guard/index.mts (100%) create mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md create mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts rename .claude/hooks/{ => fleet/no-blind-keychain-read-guard}/no-blind-keychain-read-guard/package.json (100%) rename .claude/hooks/{ => fleet/no-blind-keychain-read-guard}/no-blind-keychain-read-guard/test/index.test.mts (100%) rename .claude/hooks/{no-revert-guard => fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/package.json create mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/test/index.test.mts rename .claude/hooks/{no-structured-clone-prefer-json-guard => fleet/no-blind-keychain-read-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-disable-lint-rule-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-disable-lint-rule-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-disable-lint-rule-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-disable-lint-rule-guard/test/index.test.mts (100%) rename .claude/hooks/{no-token-in-dotenv-guard => fleet/no-disable-lint-rule-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-empty-commit-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-empty-commit-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-empty-commit-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-empty-commit-guard/test/index.test.mts (100%) rename .claude/hooks/{no-underscore-identifier-guard => fleet/no-empty-commit-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-experimental-strip-types-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-experimental-strip-types-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-experimental-strip-types-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-experimental-strip-types-guard/test/index.test.mts (100%) rename .claude/hooks/{node-modules-staging-guard => fleet/no-experimental-strip-types-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-external-issue-ref-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-external-issue-ref-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-external-issue-ref-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-external-issue-ref-guard/test/index.test.mts (100%) rename .claude/hooks/{overeager-staging-guard => fleet/no-external-issue-ref-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-file-scope-oxlint-disable-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-file-scope-oxlint-disable-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-file-scope-oxlint-disable-guard/package.json (100%) rename .claude/hooks/{path-guard => fleet/no-file-scope-oxlint-disable-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-fleet-fork-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-fleet-fork-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-fleet-fork-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-fleet-fork-guard/test/index.test.mts (100%) rename .claude/hooks/{path-regex-normalize-reminder => fleet/no-fleet-fork-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-meta-comments-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-meta-comments-guard/index.mts (100%) create mode 100644 .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md create mode 100644 .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts rename .claude/hooks/{ => fleet/no-meta-comments-guard}/no-meta-comments-guard/package.json (100%) rename .claude/hooks/{ => fleet/no-meta-comments-guard}/no-meta-comments-guard/test/index.test.mts (100%) rename .claude/hooks/{paths-mts-inherit-guard => fleet/no-meta-comments-guard/no-meta-comments-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/no-meta-comments-guard/package.json create mode 100644 .claude/hooks/fleet/no-meta-comments-guard/test/index.test.mts rename .claude/hooks/{perfectionist-reminder => fleet/no-meta-comments-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/no-non-fleet-push-guard/README.md create mode 100644 .claude/hooks/fleet/no-non-fleet-push-guard/index.mts create mode 100644 .claude/hooks/fleet/no-non-fleet-push-guard/package.json create mode 100644 .claude/hooks/fleet/no-non-fleet-push-guard/test/index.test.mts rename .claude/hooks/{plan-location-guard => fleet/no-non-fleet-push-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-orphaned-staging/README.md (100%) rename .claude/hooks/{ => fleet}/no-orphaned-staging/index.mts (100%) create mode 100644 .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md create mode 100644 .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts rename .claude/hooks/{ => fleet/no-orphaned-staging}/no-orphaned-staging/package.json (100%) rename .claude/hooks/{ => fleet/no-orphaned-staging}/no-orphaned-staging/test/index.test.mts (100%) rename .claude/hooks/{plan-review-reminder => fleet/no-orphaned-staging/no-orphaned-staging}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/no-orphaned-staging/test/index.test.mts create mode 100644 .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/README.md create mode 100644 .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts create mode 100644 .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/package.json create mode 100644 .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/test/index.test.mts rename .claude/hooks/{pointer-comment-guard => fleet/no-package-json-pnpm-overrides-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-revert-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-revert-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-revert-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-revert-guard/test/index.test.mts (100%) rename .claude/hooks/{pr-vs-push-default-reminder => fleet/no-revert-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-structured-clone-prefer-json-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-structured-clone-prefer-json-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-structured-clone-prefer-json-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-structured-clone-prefer-json-guard/test/index.test.mts (100%) rename .claude/hooks/{prefer-rebase-over-revert-guard => fleet/no-structured-clone-prefer-json-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-token-in-dotenv-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-token-in-dotenv-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-token-in-dotenv-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-token-in-dotenv-guard/test/index.test.mts (100%) rename .claude/hooks/{private-name-guard => fleet/no-token-in-dotenv-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/no-underscore-identifier-guard/README.md (100%) rename .claude/hooks/{ => fleet}/no-underscore-identifier-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/no-underscore-identifier-guard/package.json (100%) rename .claude/hooks/{ => fleet}/no-underscore-identifier-guard/test/index.test.mts (100%) rename .claude/hooks/{public-surface-reminder => fleet/no-underscore-identifier-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/no-unmocked-network-in-tests-guard/README.md create mode 100644 .claude/hooks/fleet/no-unmocked-network-in-tests-guard/index.mts create mode 100644 .claude/hooks/fleet/no-unmocked-network-in-tests-guard/package.json create mode 100644 .claude/hooks/fleet/no-unmocked-network-in-tests-guard/test/index.test.mts rename .claude/hooks/{pull-request-target-guard => fleet/no-unmocked-network-in-tests-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/node-modules-staging-guard/README.md (100%) rename .claude/hooks/{ => fleet}/node-modules-staging-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/node-modules-staging-guard/package.json (100%) rename .claude/hooks/{ => fleet}/node-modules-staging-guard/test/index.test.mts (100%) rename .claude/hooks/{readme-fleet-shape-guard => fleet/node-modules-staging-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/non-fleet-pr-issue-ask-guard/README.md create mode 100644 .claude/hooks/fleet/non-fleet-pr-issue-ask-guard/index.mts create mode 100644 .claude/hooks/fleet/non-fleet-pr-issue-ask-guard/package.json create mode 100644 .claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts rename .claude/hooks/{release-workflow-guard => fleet/non-fleet-pr-issue-ask-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/overeager-staging-guard/README.md rename .claude/hooks/{ => fleet}/overeager-staging-guard/index.mts (100%) create mode 100644 .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts rename .claude/hooks/{ => fleet/overeager-staging-guard}/overeager-staging-guard/package.json (100%) rename .claude/hooks/{ => fleet/overeager-staging-guard}/overeager-staging-guard/test/index.test.mts (100%) rename .claude/hooks/{scan-label-in-commit-guard => fleet/overeager-staging-guard/overeager-staging-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/overeager-staging-guard/package.json create mode 100644 .claude/hooks/fleet/overeager-staging-guard/test/index.test.mts rename .claude/hooks/{setup-basics-tools => fleet/overeager-staging-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/parallel-agent-edit-guard/README.md create mode 100644 .claude/hooks/fleet/parallel-agent-edit-guard/index.mts create mode 100644 .claude/hooks/fleet/parallel-agent-edit-guard/package.json create mode 100644 .claude/hooks/fleet/parallel-agent-edit-guard/test/index.test.mts rename .claude/hooks/{setup-claude-scanners => fleet/parallel-agent-edit-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/parallel-agent-on-stop-reminder/README.md create mode 100644 .claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts create mode 100644 .claude/hooks/fleet/parallel-agent-on-stop-reminder/package.json create mode 100644 .claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts rename .claude/hooks/{setup-firewall => fleet/parallel-agent-on-stop-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/parallel-agent-staging-guard/README.md create mode 100644 .claude/hooks/fleet/parallel-agent-staging-guard/index.mts create mode 100644 .claude/hooks/fleet/parallel-agent-staging-guard/package.json create mode 100644 .claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts rename .claude/hooks/{setup-misc-tools => fleet/parallel-agent-staging-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/path-guard/README.md (100%) rename .claude/hooks/{ => fleet}/path-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/path-guard/package.json (100%) create mode 100644 .claude/hooks/fleet/path-guard/path-guard/README.md create mode 100644 .claude/hooks/fleet/path-guard/path-guard/index.mts create mode 100644 .claude/hooks/fleet/path-guard/path-guard/package.json rename .claude/hooks/{ => fleet/path-guard}/path-guard/segments.mts (100%) rename .claude/hooks/{ => fleet/path-guard}/path-guard/test/path-guard.test.mts (100%) rename .claude/hooks/{setup-security-tools => fleet/path-guard/path-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/path-guard/segments.mts create mode 100644 .claude/hooks/fleet/path-guard/test/path-guard.test.mts rename .claude/hooks/{setup-signing => fleet/path-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/path-regex-normalize-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/path-regex-normalize-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/path-regex-normalize-reminder/package.json (100%) rename .claude/hooks/{soak-exclude-date-annotation-guard => fleet/path-regex-normalize-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/paths-mts-inherit-guard/README.md (100%) rename .claude/hooks/{ => fleet}/paths-mts-inherit-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/paths-mts-inherit-guard/package.json (100%) rename .claude/hooks/{ => fleet}/paths-mts-inherit-guard/test/index.test.mts (100%) rename .claude/hooks/{socket-token-minifier-start => fleet/paths-mts-inherit-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/perfectionist-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/perfectionist-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/perfectionist-reminder/package.json (100%) create mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md create mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts create mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json rename .claude/hooks/{ => fleet/perfectionist-reminder}/perfectionist-reminder/test/index.test.mts (100%) rename .claude/hooks/{squash-history-reminder => fleet/perfectionist-reminder/perfectionist-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/perfectionist-reminder/test/index.test.mts rename .claude/hooks/{stale-process-sweeper => fleet/perfectionist-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/plan-location-guard/README.md (100%) rename .claude/hooks/{ => fleet}/plan-location-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/plan-location-guard/package.json (100%) create mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md create mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts create mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json rename .claude/hooks/{ => fleet/plan-location-guard}/plan-location-guard/test/index.test.mts (100%) rename .claude/hooks/{sweep-ds-store => fleet/plan-location-guard/plan-location-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/plan-location-guard/test/index.test.mts rename .claude/hooks/{token-guard => fleet/plan-location-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/plan-review-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/plan-review-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/plan-review-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/plan-review-reminder/test/index.test.mts (100%) rename .claude/hooks/{variant-analysis-reminder => fleet/plan-review-reminder}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/plugin-patch-format-guard/README.md create mode 100644 .claude/hooks/fleet/plugin-patch-format-guard/index.mts create mode 100644 .claude/hooks/fleet/plugin-patch-format-guard/package.json create mode 100644 .claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts rename .claude/hooks/{verify-rendered-output-before-commit-reminder => fleet/plugin-patch-format-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/pointer-comment-guard/README.md (100%) rename .claude/hooks/{ => fleet}/pointer-comment-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/pointer-comment-guard/package.json (100%) rename .claude/hooks/{ => fleet}/pointer-comment-guard/test/index.test.mts (100%) rename .claude/hooks/{version-bump-order-guard => fleet/pointer-comment-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/pr-vs-push-default-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/pr-vs-push-default-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/pr-vs-push-default-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/pr-vs-push-default-reminder/test/index.test.mts (100%) rename .claude/hooks/{vitest-include-vs-node-test-guard => fleet/pr-vs-push-default-reminder}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/prefer-rebase-over-revert-guard/README.md (100%) rename .claude/hooks/{ => fleet}/prefer-rebase-over-revert-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/prefer-rebase-over-revert-guard/package.json (100%) rename .claude/hooks/{ => fleet}/prefer-rebase-over-revert-guard/test/index.test.mts (100%) rename .claude/hooks/{workflow-uses-comment-guard => fleet/prefer-rebase-over-revert-guard}/tsconfig.json (100%) rename .claude/hooks/{ => fleet}/private-name-guard/README.md (100%) rename .claude/hooks/{ => fleet}/private-name-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/private-name-guard/package.json (100%) rename .claude/hooks/{ => fleet}/private-name-guard/test/private-name-guard.test.mts (100%) rename .claude/hooks/{workflow-yaml-multiline-body-guard => fleet/private-name-guard}/tsconfig.json (100%) create mode 100644 .claude/hooks/fleet/provenance-publish-reminder/README.md create mode 100644 .claude/hooks/fleet/provenance-publish-reminder/index.mts create mode 100644 .claude/hooks/fleet/provenance-publish-reminder/package.json create mode 100644 .claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts create mode 100644 .claude/hooks/fleet/provenance-publish-reminder/tsconfig.json create mode 100644 .claude/hooks/fleet/public-surface-reminder/README.md rename .claude/hooks/{ => fleet}/public-surface-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/public-surface-reminder/package.json (100%) rename .claude/hooks/{ => fleet/public-surface-reminder}/public-surface-reminder/README.md (100%) create mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts create mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json rename .claude/hooks/{ => fleet/public-surface-reminder}/public-surface-reminder/test/public-surface-reminder.test.mts (100%) create mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json create mode 100644 .claude/hooks/fleet/public-surface-reminder/test/public-surface-reminder.test.mts create mode 100644 .claude/hooks/fleet/public-surface-reminder/tsconfig.json rename .claude/hooks/{ => fleet}/pull-request-target-guard/README.md (100%) rename .claude/hooks/{ => fleet}/pull-request-target-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/pull-request-target-guard/package.json (100%) rename .claude/hooks/{ => fleet}/pull-request-target-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/pull-request-target-guard/tsconfig.json rename .claude/hooks/{ => fleet}/readme-fleet-shape-guard/README.md (100%) rename .claude/hooks/{ => fleet}/readme-fleet-shape-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/readme-fleet-shape-guard/package.json (100%) rename .claude/hooks/{ => fleet}/readme-fleet-shape-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/readme-fleet-shape-guard/tsconfig.json rename .claude/hooks/{ => fleet}/release-workflow-guard/README.md (100%) rename .claude/hooks/{ => fleet}/release-workflow-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/release-workflow-guard/package.json (100%) rename .claude/hooks/{ => fleet}/release-workflow-guard/test/release-workflow-guard.test.mts (100%) create mode 100644 .claude/hooks/fleet/release-workflow-guard/tsconfig.json rename .claude/hooks/{ => fleet}/scan-label-in-commit-guard/README.md (100%) rename .claude/hooks/{ => fleet}/scan-label-in-commit-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/scan-label-in-commit-guard/package.json (100%) rename .claude/hooks/{ => fleet}/scan-label-in-commit-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/scan-label-in-commit-guard/tsconfig.json rename .claude/hooks/{ => fleet}/setup-basics-tools/README.md (100%) rename .claude/hooks/{ => fleet}/setup-basics-tools/install.mts (100%) rename .claude/hooks/{ => fleet}/setup-basics-tools/package.json (100%) create mode 100644 .claude/hooks/fleet/setup-basics-tools/tsconfig.json create mode 100644 .claude/hooks/fleet/setup-claude-scanners/README.md create mode 100644 .claude/hooks/fleet/setup-claude-scanners/install.mts rename .claude/hooks/{ => fleet}/setup-claude-scanners/package.json (100%) rename .claude/hooks/{ => fleet/setup-claude-scanners}/setup-claude-scanners/README.md (100%) rename .claude/hooks/{ => fleet/setup-claude-scanners}/setup-claude-scanners/install.mts (100%) create mode 100644 .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json create mode 100644 .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json create mode 100644 .claude/hooks/fleet/setup-claude-scanners/tsconfig.json rename .claude/hooks/{ => fleet}/setup-firewall/README.md (100%) rename .claude/hooks/{ => fleet}/setup-firewall/install.mts (100%) rename .claude/hooks/{ => fleet}/setup-firewall/package.json (100%) create mode 100644 .claude/hooks/fleet/setup-firewall/tsconfig.json rename .claude/hooks/{ => fleet}/setup-misc-tools/README.md (100%) rename .claude/hooks/{ => fleet}/setup-misc-tools/install.mts (100%) rename .claude/hooks/{ => fleet}/setup-misc-tools/package.json (100%) create mode 100644 .claude/hooks/fleet/setup-misc-tools/tsconfig.json rename .claude/hooks/{ => fleet}/setup-security-tools/README.md (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/external-tools.json (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/index.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/install.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/lib/api-token.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/lib/installers.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/lib/operator-prompts.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/lib/shell-rc-bridge.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/lib/token-storage.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/package.json (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/test/setup-security-tools.test.mts (100%) rename .claude/hooks/{ => fleet}/setup-security-tools/test/shell-rc-bridge.test.mts (100%) create mode 100644 .claude/hooks/fleet/setup-security-tools/tsconfig.json rename .claude/hooks/{ => fleet}/setup-security-tools/update.mts (100%) rename .claude/hooks/{ => fleet}/setup-signing/README.md (100%) rename .claude/hooks/{ => fleet}/setup-signing/install.mts (100%) rename .claude/hooks/{ => fleet}/setup-signing/package.json (100%) create mode 100644 .claude/hooks/fleet/setup-signing/tsconfig.json rename .claude/hooks/{ => fleet}/soak-exclude-date-annotation-guard/README.md (100%) rename .claude/hooks/{ => fleet}/soak-exclude-date-annotation-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/soak-exclude-date-annotation-guard/package.json (100%) rename .claude/hooks/{ => fleet}/soak-exclude-date-annotation-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/soak-exclude-date-annotation-guard/tsconfig.json rename .claude/hooks/{ => fleet}/socket-token-minifier-start/README.md (100%) rename .claude/hooks/{ => fleet}/socket-token-minifier-start/index.mts (100%) rename .claude/hooks/{ => fleet}/socket-token-minifier-start/package.json (100%) create mode 100644 .claude/hooks/fleet/socket-token-minifier-start/tsconfig.json rename .claude/hooks/{ => fleet}/squash-history-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/squash-history-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/squash-history-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/squash-history-reminder/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/squash-history-reminder/tsconfig.json create mode 100644 .claude/hooks/fleet/stale-process-sweeper/README.md rename .claude/hooks/{ => fleet}/stale-process-sweeper/index.mts (100%) rename .claude/hooks/{ => fleet}/stale-process-sweeper/package.json (100%) rename .claude/hooks/{ => fleet/stale-process-sweeper}/stale-process-sweeper/README.md (100%) create mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts create mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json rename .claude/hooks/{ => fleet/stale-process-sweeper}/stale-process-sweeper/test/stale-process-sweeper.test.mts (100%) create mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json create mode 100644 .claude/hooks/fleet/stale-process-sweeper/test/stale-process-sweeper.test.mts create mode 100644 .claude/hooks/fleet/stale-process-sweeper/tsconfig.json rename .claude/hooks/{ => fleet}/sweep-ds-store/README.md (100%) rename .claude/hooks/{ => fleet}/sweep-ds-store/index.mts (100%) rename .claude/hooks/{ => fleet}/sweep-ds-store/package.json (100%) rename .claude/hooks/{ => fleet}/sweep-ds-store/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/sweep-ds-store/tsconfig.json rename .claude/hooks/{ => fleet}/token-guard/README.md (100%) rename .claude/hooks/{ => fleet}/token-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/token-guard/package.json (100%) rename .claude/hooks/{ => fleet}/token-guard/test/token-guard.test.mts (100%) create mode 100644 .claude/hooks/fleet/token-guard/tsconfig.json create mode 100644 .claude/hooks/fleet/trust-downgrade-guard/README.md create mode 100644 .claude/hooks/fleet/trust-downgrade-guard/index.mts create mode 100644 .claude/hooks/fleet/trust-downgrade-guard/package.json create mode 100644 .claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts create mode 100644 .claude/hooks/fleet/trust-downgrade-guard/tsconfig.json create mode 100644 .claude/hooks/fleet/uses-sha-verify-guard/README.md create mode 100644 .claude/hooks/fleet/uses-sha-verify-guard/index.mts create mode 100644 .claude/hooks/fleet/uses-sha-verify-guard/package.json create mode 100644 .claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts create mode 100644 .claude/hooks/fleet/uses-sha-verify-guard/tsconfig.json rename .claude/hooks/{ => fleet}/variant-analysis-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/variant-analysis-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/variant-analysis-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/variant-analysis-reminder/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/variant-analysis-reminder/tsconfig.json rename .claude/hooks/{ => fleet}/verify-rendered-output-before-commit-reminder/README.md (100%) rename .claude/hooks/{ => fleet}/verify-rendered-output-before-commit-reminder/index.mts (100%) rename .claude/hooks/{ => fleet}/verify-rendered-output-before-commit-reminder/package.json (100%) rename .claude/hooks/{ => fleet}/verify-rendered-output-before-commit-reminder/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/tsconfig.json rename .claude/hooks/{ => fleet}/version-bump-order-guard/README.md (100%) rename .claude/hooks/{ => fleet}/version-bump-order-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/version-bump-order-guard/package.json (100%) rename .claude/hooks/{ => fleet}/version-bump-order-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/version-bump-order-guard/tsconfig.json rename .claude/hooks/{ => fleet}/vitest-include-vs-node-test-guard/README.md (100%) rename .claude/hooks/{ => fleet}/vitest-include-vs-node-test-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/vitest-include-vs-node-test-guard/package.json (100%) rename .claude/hooks/{ => fleet}/vitest-include-vs-node-test-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/vitest-include-vs-node-test-guard/tsconfig.json rename .claude/hooks/{ => fleet}/workflow-uses-comment-guard/README.md (100%) rename .claude/hooks/{ => fleet}/workflow-uses-comment-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/workflow-uses-comment-guard/package.json (100%) rename .claude/hooks/{ => fleet}/workflow-uses-comment-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/workflow-uses-comment-guard/tsconfig.json rename .claude/hooks/{ => fleet}/workflow-yaml-multiline-body-guard/README.md (100%) rename .claude/hooks/{ => fleet}/workflow-yaml-multiline-body-guard/index.mts (100%) rename .claude/hooks/{ => fleet}/workflow-yaml-multiline-body-guard/package.json (100%) rename .claude/hooks/{ => fleet}/workflow-yaml-multiline-body-guard/test/index.test.mts (100%) create mode 100644 .claude/hooks/fleet/workflow-yaml-multiline-body-guard/tsconfig.json create mode 100644 .config/oxlint-plugin/rules/prefer-error-message.mts create mode 100644 .config/oxlint-plugin/rules/prefer-pure-call-form.mts create mode 100644 .config/oxlint-plugin/test/prefer-error-message.test.mts create mode 100644 .config/oxlint-plugin/test/prefer-pure-call-form.test.mts create mode 100644 .config/vitest.coverage.fleet.config.mts create mode 100644 docs/claude.md/fleet/skill-model-routing.md diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index 9388ed1..744583a 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -1,6 +1,7 @@ --- name: security-reviewer description: Reviews findings from AgentShield + zizmor against the project's CLAUDE.md security rules and grades the result A-F. Spawned by the scanning-security skill after the static scans run. +model: claude-opus-4-8 tools: Read, Grep, Glob, Bash(git:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(pnpm exec agentshield:*), Bash(zizmor:*), Bash(command -v:*), Bash(cat:*), Bash(head:*), Bash(tail:*) --- diff --git a/.claude/hooks/_shared/README.md b/.claude/hooks/fleet/_shared/README.md similarity index 100% rename from .claude/hooks/_shared/README.md rename to .claude/hooks/fleet/_shared/README.md diff --git a/.claude/hooks/_shared/acorn/README.md b/.claude/hooks/fleet/_shared/acorn/README.md similarity index 100% rename from .claude/hooks/_shared/acorn/README.md rename to .claude/hooks/fleet/_shared/acorn/README.md diff --git a/.claude/hooks/_shared/acorn/acorn-bindgen.cjs b/.claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs similarity index 100% rename from .claude/hooks/_shared/acorn/acorn-bindgen.cjs rename to .claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs diff --git a/.claude/hooks/_shared/acorn/acorn-wasm-sync.mts b/.claude/hooks/fleet/_shared/acorn/acorn-wasm-sync.mts similarity index 100% rename from .claude/hooks/_shared/acorn/acorn-wasm-sync.mts rename to .claude/hooks/fleet/_shared/acorn/acorn-wasm-sync.mts diff --git a/.claude/hooks/_shared/acorn/acorn.wasm b/.claude/hooks/fleet/_shared/acorn/acorn.wasm similarity index 100% rename from .claude/hooks/_shared/acorn/acorn.wasm rename to .claude/hooks/fleet/_shared/acorn/acorn.wasm diff --git a/.claude/hooks/_shared/acorn/index.mts b/.claude/hooks/fleet/_shared/acorn/index.mts similarity index 100% rename from .claude/hooks/_shared/acorn/index.mts rename to .claude/hooks/fleet/_shared/acorn/index.mts diff --git a/.claude/hooks/_shared/fleet-repos.mts b/.claude/hooks/fleet/_shared/fleet-repos.mts similarity index 100% rename from .claude/hooks/_shared/fleet-repos.mts rename to .claude/hooks/fleet/_shared/fleet-repos.mts diff --git a/.claude/hooks/_shared/foreign-paths.mts b/.claude/hooks/fleet/_shared/foreign-paths.mts similarity index 100% rename from .claude/hooks/_shared/foreign-paths.mts rename to .claude/hooks/fleet/_shared/foreign-paths.mts diff --git a/.claude/hooks/_shared/hook-env.mts b/.claude/hooks/fleet/_shared/hook-env.mts similarity index 100% rename from .claude/hooks/_shared/hook-env.mts rename to .claude/hooks/fleet/_shared/hook-env.mts diff --git a/.claude/hooks/_shared/markers.mts b/.claude/hooks/fleet/_shared/markers.mts similarity index 100% rename from .claude/hooks/_shared/markers.mts rename to .claude/hooks/fleet/_shared/markers.mts diff --git a/.claude/hooks/_shared/payload.mts b/.claude/hooks/fleet/_shared/payload.mts similarity index 100% rename from .claude/hooks/_shared/payload.mts rename to .claude/hooks/fleet/_shared/payload.mts diff --git a/.claude/hooks/_shared/shell-command.mts b/.claude/hooks/fleet/_shared/shell-command.mts similarity index 100% rename from .claude/hooks/_shared/shell-command.mts rename to .claude/hooks/fleet/_shared/shell-command.mts diff --git a/.claude/hooks/_shared/stop-reminder.mts b/.claude/hooks/fleet/_shared/stop-reminder.mts similarity index 100% rename from .claude/hooks/_shared/stop-reminder.mts rename to .claude/hooks/fleet/_shared/stop-reminder.mts diff --git a/.claude/hooks/_shared/test/fleet-repos.test.mts b/.claude/hooks/fleet/_shared/test/fleet-repos.test.mts similarity index 100% rename from .claude/hooks/_shared/test/fleet-repos.test.mts rename to .claude/hooks/fleet/_shared/test/fleet-repos.test.mts diff --git a/.claude/hooks/_shared/test/foreign-paths.test.mts b/.claude/hooks/fleet/_shared/test/foreign-paths.test.mts similarity index 100% rename from .claude/hooks/_shared/test/foreign-paths.test.mts rename to .claude/hooks/fleet/_shared/test/foreign-paths.test.mts diff --git a/.claude/hooks/_shared/test/shell-command.test.mts b/.claude/hooks/fleet/_shared/test/shell-command.test.mts similarity index 100% rename from .claude/hooks/_shared/test/shell-command.test.mts rename to .claude/hooks/fleet/_shared/test/shell-command.test.mts diff --git a/.claude/hooks/_shared/test/transcript.test.mts b/.claude/hooks/fleet/_shared/test/transcript.test.mts similarity index 100% rename from .claude/hooks/_shared/test/transcript.test.mts rename to .claude/hooks/fleet/_shared/test/transcript.test.mts diff --git a/.claude/hooks/_shared/token-patterns.mts b/.claude/hooks/fleet/_shared/token-patterns.mts similarity index 100% rename from .claude/hooks/_shared/token-patterns.mts rename to .claude/hooks/fleet/_shared/token-patterns.mts diff --git a/.claude/hooks/_shared/transcript.mts b/.claude/hooks/fleet/_shared/transcript.mts similarity index 100% rename from .claude/hooks/_shared/transcript.mts rename to .claude/hooks/fleet/_shared/transcript.mts diff --git a/.claude/hooks/_shared/wheelhouse-root.mts b/.claude/hooks/fleet/_shared/wheelhouse-root.mts similarity index 100% rename from .claude/hooks/_shared/wheelhouse-root.mts rename to .claude/hooks/fleet/_shared/wheelhouse-root.mts diff --git a/.claude/hooks/actionlint-on-workflow-edit/README.md b/.claude/hooks/fleet/actionlint-on-workflow-edit/README.md similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/README.md rename to .claude/hooks/fleet/actionlint-on-workflow-edit/README.md diff --git a/.claude/hooks/actionlint-on-workflow-edit/index.mts b/.claude/hooks/fleet/actionlint-on-workflow-edit/index.mts similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/index.mts rename to .claude/hooks/fleet/actionlint-on-workflow-edit/index.mts diff --git a/.claude/hooks/actionlint-on-workflow-edit/package.json b/.claude/hooks/fleet/actionlint-on-workflow-edit/package.json similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/package.json rename to .claude/hooks/fleet/actionlint-on-workflow-edit/package.json diff --git a/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts b/.claude/hooks/fleet/actionlint-on-workflow-edit/test/index.test.mts similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/test/index.test.mts rename to .claude/hooks/fleet/actionlint-on-workflow-edit/test/index.test.mts diff --git a/.claude/hooks/actionlint-on-workflow-edit/tsconfig.json b/.claude/hooks/fleet/actionlint-on-workflow-edit/tsconfig.json similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/tsconfig.json rename to .claude/hooks/fleet/actionlint-on-workflow-edit/tsconfig.json diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/README.md b/.claude/hooks/fleet/answer-passing-questions-reminder/README.md new file mode 100644 index 0000000..aedb6ce --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/README.md @@ -0,0 +1,24 @@ +# answer-passing-questions-reminder + +**Lifecycle**: Stop + +**Purpose**: catches the failure mode where the user asks a passing question while Claude is mid-task and the response deflects ("later" / "right now I'm doing X" / "let me finish first") instead of answering inline. + +## What triggers it + +The hook fires on `Stop` and only emits a reminder when both conditions hold: + +1. The most recent user turn contains a question — `?` punctuation, or interrogative leading (`is`, `should`, `do we`, `would`, `can we`, `where`, `why`, `what`, `how`, `which`). +2. The most recent assistant turn either contains a deflection phrase or doesn't contain text that looks like an answer (no statement-shape sentence touching the question keywords). + +## Exception + +Questions containing an explicit pivot signal (`now do X` / `instead let's` / `switch to` / `stop and`) are **redirects, not passing questions**. The hook skips those — the right response is to pivot, not to answer inline. + +## Disable + +Set `SOCKET_ANSWER_PASSING_QUESTIONS_REMINDER_DISABLED=1` in the session env. + +## Why this hook exists + +The assistant's habit of treating passing questions as interruptions instead of opportunities silently degrades collaboration. Users learn not to ask questions mid-task, which means small misunderstandings compound into bigger redirects later. The reminder makes the pattern visible at Stop so the next response can address the unanswered question. diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts b/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts new file mode 100644 index 0000000..41c4286 --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// Claude Code Stop hook — answer-passing-questions-reminder. +// +// Catches the failure mode where the user asks a passing question +// while Claude is mid-task, and Claude brushes past it ("later" / +// "right now I'm doing X" / "let me finish first") instead of +// answering inline. +// +// What triggers: +// 1. The most recent user turn contains a question — `?` punctuation, +// or interrogative leading ("is", "should", "do we", "would", +// "can we", "where", "why", "what", "how", "which"). +// 2. The most recent assistant turn either (a) contains a deflection +// phrase or (b) doesn't contain text that looks like an answer +// (no statement-shape sentence answering the question keywords). +// +// Exception: if the user's question contains an explicit pivot signal +// ("now do X" / "instead let's" / "switch to" / "stop and"), it's not +// a passing question — it's a redirect, and the assistant should +// pivot. The hook skips those. +// +// Disable via SOCKET_ANSWER_PASSING_QUESTIONS_REMINDER_DISABLED. + +import process from 'node:process' + +import { + readLastAssistantText, + readStdin, + readUserText, + stripCodeFences, +} from '../_shared/transcript.mts' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +// Phrases that indicate the assistant brushed past the question. +const DEFLECTION_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ + { + label: 'right now I\'m / right now I am', + regex: /\bright\s+now\s+i'?(m|\s+am)\b/i, + }, + { + label: 'let me finish / let me first', + regex: /\b(let\s+me\s+(finish|first|wrap)|finish\s+first)\b/i, + }, + { + label: 'that\'s a (structural|bigger|separate) (fix|refactor|question) (for|later)', + regex: + /\bthat'?s\s+(a\s+)?(structural|bigger|separate|different)\s+(fix|refactor|question|issue|concern)\s+(for\s+later|though|\.\s)/i, + }, + { + label: 'for now / for the moment', + regex: /\bfor\s+(now|the\s+moment)\s*,?\s+(i'?m|let\s+me|focus)/i, + }, + { + label: 'I\'ll come back to / get to that', + regex: /\bi'?ll\s+(come\s+back\s+to|get\s+to)\s+(that|it|this)\b/i, + }, + { + label: 'later — focus / first', + regex: /\b(later|that\s+(part|piece))\s*[—–\-]\s*(focus|first|right\s+now)/i, + }, + { + label: 'noted / good question — moving on', + regex: + /\b(noted|good\s+(question|catch)|fair\s+(point|question))\s*[.—\-]\s+(moving|continuing|but\s+first)/i, + }, +] + +// Patterns that say "the user's input is a redirect, not a passing +// question". If any fires, the hook skips — the assistant SHOULD +// pivot. +const PIVOT_PATTERNS: ReadonlyArray = [ + /\b(stop\s+and|stop\s+that|abort|cancel|kill\s+it|halt)\b/i, + /\b(switch\s+to|pivot\s+to|focus\s+on)\b/i, + /\b(instead\s+(of|do)|never\s+mind)\b/i, + // "do X now" — imperative redirect. + /^\s*(do|run|execute|make)\s+\w+\s+now\b/i, +] + +// Question-shape detector applied to the most recent user turn. +function userAsksQuestion(userText: string): boolean { + // Quick win: explicit question mark. + if (userText.includes('?')) { + return true + } + // Interrogative leading words at a sentence boundary (allow leading + // whitespace / punctuation). + const interrogativeLead = + /(?:^|[.\n!])\s*(is|are|was|were|do|does|did|will|would|should|shall|can|could|may|might|have|has|had|where|why|what|how|which|when|who)\b/i + return interrogativeLead.test(userText) +} + +async function main(): Promise { + if (process.env['SOCKET_ANSWER_PASSING_QUESTIONS_REMINDER_DISABLED']) { + return + } + const payloadRaw = await readStdin() + let payload: StopPayload + try { + payload = JSON.parse(payloadRaw) as StopPayload + } catch { + return + } + + // Read only the MOST RECENT user turn (n=1). + const recentUser = readUserText(payload.transcript_path, 1).trim() + if (!recentUser) { + return + } + if (!userAsksQuestion(recentUser)) { + return + } + // If the user's input is a redirect, the assistant should pivot; + // skip the hook. + for (let i = 0, { length } = PIVOT_PATTERNS; i < length; i += 1) { + if (PIVOT_PATTERNS[i]!.test(recentUser)) { + return + } + } + + const rawAssistant = readLastAssistantText(payload.transcript_path) + if (!rawAssistant) { + return + } + const text = stripCodeFences(rawAssistant) + + // Does the assistant turn contain a deflection phrase? + const hits: Array<{ label: string; snippet: string }> = [] + for (let i = 0, { length } = DEFLECTION_PATTERNS; i < length; i += 1) { + const pattern = DEFLECTION_PATTERNS[i]! + const match = pattern.regex.exec(text) + if (!match) { + continue + } + const start = Math.max(0, match.index - 30) + const end = Math.min(text.length, match.index + match[0].length + 50) + hits.push({ + label: pattern.label, + snippet: text.slice(start, end).replace(/\s+/g, ' ').trim(), + }) + } + if (hits.length === 0) { + return + } + + const userSnippet = recentUser + .slice(0, 200) + .replace(/\s+/g, ' ') + .trim() + const lines = [ + '[answer-passing-questions-reminder] User asked a passing question; assistant turn brushed past it without answering:', + '', + ` User: "${userSnippet}${recentUser.length > 200 ? '…' : ''}"`, + '', + ' Deflection phrases detected in assistant turn:', + ] + for (let i = 0, { length } = hits; i < length; i += 1) { + const hit = hits[i]! + lines.push(` • "${hit.label}" — …${hit.snippet}…`) + } + lines.push('') + lines.push( + ' Answer the question inline (one or two sentences) BEFORE / ALONGSIDE the current work. Not every user comment is a pivot — when a question is in passing, lend a few tokens to it. Continue the in-flight work right after.', + ) + + process.stderr.write(lines.join('\n') + '\n') +} + +main().catch(() => { + // Fail-open: never block a session on this hook's own bug. +}) diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/package.json b/.claude/hooks/fleet/answer-passing-questions-reminder/package.json new file mode 100644 index 0000000..a35b9ff --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-answer-passing-questions-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts b/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts new file mode 100644 index 0000000..d9027a3 --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts @@ -0,0 +1,38 @@ +/** + * @file Smoke test for answer-passing-questions-reminder. + * + * Stop hook that catches the failure mode where the user asks a passing + * question mid-task and the assistant deflects. + * + * Smoke contract: hook loads + dispatches without throwing; empty + * transcript path → exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'answer-passing-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/ask-suppression-reminder/tsconfig.json b/.claude/hooks/fleet/answer-passing-questions-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/ask-suppression-reminder/tsconfig.json rename to .claude/hooks/fleet/answer-passing-questions-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/README.md b/.claude/hooks/fleet/answer-status-requests-reminder/README.md new file mode 100644 index 0000000..da7dce9 --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/README.md @@ -0,0 +1,26 @@ +# answer-status-requests-reminder + +**Lifecycle**: Stop + +**Purpose**: catches the failure mode where the user explicitly asks for a status update on in-flight work and the assistant declines with a rate-limiting excuse like "too soon since last check" or "skipping". + +## What triggers it + +The hook fires on `Stop` when both conditions hold: + +1. The most recent user turn matches a status-request shape (case-insensitive): + - `check status`, `status?`, `status update` + - `how's it going` / `how's the build` / `how is it` + - `what's it doing` + - `is it done` + - `still running` + - `what's happening` + - `where are we` + - `progress?` +2. The most recent assistant turn matches a decline shape: + - `too soon since (last|the last|my last) check` + - `skipping` + +## Why this hook exists + +Self-imposed rate limiting against the user's explicit ask is the wrong default. The user knows they asked; the answer is to check, not to lecture about cadence. The reminder fires at Stop so the next response actually performs the check. diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/index.mts b/.claude/hooks/fleet/answer-status-requests-reminder/index.mts new file mode 100644 index 0000000..d98731f --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/index.mts @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Claude Code Stop hook — answer-status-requests-reminder. +// +// Catches the failure mode where the user explicitly asks for a +// status update on in-flight work and the assistant declines with a +// rate-limiting excuse like "too soon since last check" or "skipping". +// +// User status-request shapes (case-insensitive, applied to most recent +// user turn): +// +// - "check status" +// - "status?" +// - "status update" +// - "how's it going" / "how's the build" / "how is it" +// - "what's it doing" +// - "is it done" +// - "still running" +// - "what's happening" +// - "where are we" +// - "progress?" +// +// Assistant decline shapes (case-insensitive): +// +// - "too soon since (last|the last|my last) check" +// - "skipping" +// - "not enough time has passed" +// - "let me wait" / "I'll wait" +// - "no need to check" / "no point checking" +// - "polling is wasted" — even though it's true in some contexts, +// when the user explicitly asks for status, run the check. +// - "cache hasn't refreshed" / "nothing new to report" (without +// having actually checked) +// +// When both fire, emit a reminder: when the user explicitly asks for +// a status update, ALWAYS run the check and report what's there. The +// status is what they're asking for; rate-limiting it is gatekeeping. +// +// Disable via SOCKET_ANSWER_STATUS_REQUESTS_REMINDER_DISABLED. + +import process from 'node:process' + +import { + readLastAssistantText, + readStdin, + readUserText, + stripCodeFences, +} from '../_shared/transcript.mts' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +// Shapes the user might use to ask for a status update. Applied to +// the most recent user turn ONLY. +const STATUS_REQUEST_PATTERNS: ReadonlyArray = [ + /\bcheck\s+(the\s+)?status\b/i, + /\bstatus\s*\??\s*$/im, + /\bstatus\s+(update|check|report|please)\b/i, + /\bhow'?s\s+(it|the\s+\w+)\s*(going|doing|progressing|coming)\b/i, + /\bhow\s+is\s+(it|the\s+\w+)\s*(going|doing|progressing|coming)\??/i, + /\bwhat'?s\s+(it|the\s+\w+)\s+doing\b/i, + /\bwhat'?s\s+happening\b/i, + /\bis\s+(it|the\s+\w+)\s+done\b/i, + /\bstill\s+running\??/i, + /\bwhere\s+are\s+we\b/i, + /\bprogress\s*\??$/im, + /\bany\s+(updates|progress|news)\b/i, +] + +// Phrases that indicate the assistant declined / rate-limited the +// status request instead of just running the check. +const DECLINE_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ + { + label: 'too soon / too early', + regex: /\btoo\s+(soon|early)\b/i, + }, + { + label: 'last check ~N (seconds|minutes) ago', + regex: + /\b(last|the\s+last|my\s+last)\s+check\s+(was\s+)?[~\d]+\s*\d*\s*(seconds?|minutes?|min|sec|s|m)\s+ago\b/i, + }, + { + label: 'skipping', + regex: /\b(skipping|i'?ll\s+skip|gonna\s+skip|going\s+to\s+skip)\s*[.,]/i, + }, + { + label: 'not enough time has passed', + regex: /\b(not\s+enough\s+time|hasn'?t\s+been\s+(long|enough))\s+(has\s+)?(passed|elapsed|gone\s+by)\b/i, + }, + { + label: "let me wait / I'll wait / wait a bit", + regex: /\b(let\s+me\s+wait|i'?ll\s+wait|wait\s+(a\s+(bit|moment|few|minute|second)|until))/i, + }, + { + label: 'no need to check / no point', + regex: /\b(no\s+(need|point)\s+(to\s+)?(check(ing)?|polling|looking)|nothing\s+(to\s+)?check)\b/i, + }, + { + label: 'polling is wasted / pointless', + regex: /\bpoll(ing)?\s+(is\s+)?(wasted|pointless|moot|unnecessary)\b/i, + }, + { + label: 'no change since last check (without checking)', + regex: /\b(no\s+change|nothing\s+new|same\s+as\s+(before|last))\s+since\s+(the\s+)?last\s+(check|update|time)\b/i, + }, +] + +async function main(): Promise { + if (process.env['SOCKET_ANSWER_STATUS_REQUESTS_REMINDER_DISABLED']) { + return + } + const payloadRaw = await readStdin() + let payload: StopPayload + try { + payload = JSON.parse(payloadRaw) as StopPayload + } catch { + return + } + + // Only the MOST RECENT user turn (n=1). + const recentUser = readUserText(payload.transcript_path, 1).trim() + if (!recentUser) { + return + } + + let askedForStatus = false + for (let i = 0, { length } = STATUS_REQUEST_PATTERNS; i < length; i += 1) { + if (STATUS_REQUEST_PATTERNS[i]!.test(recentUser)) { + askedForStatus = true + break + } + } + if (!askedForStatus) { + return + } + + const rawAssistant = readLastAssistantText(payload.transcript_path) + if (!rawAssistant) { + return + } + const text = stripCodeFences(rawAssistant) + + const hits: Array<{ label: string; snippet: string }> = [] + for (let i = 0, { length } = DECLINE_PATTERNS; i < length; i += 1) { + const pattern = DECLINE_PATTERNS[i]! + const match = pattern.regex.exec(text) + if (!match) { + continue + } + const start = Math.max(0, match.index - 30) + const end = Math.min(text.length, match.index + match[0].length + 50) + hits.push({ + label: pattern.label, + snippet: text.slice(start, end).replace(/\s+/g, ' ').trim(), + }) + } + if (hits.length === 0) { + return + } + + const userSnippet = recentUser + .slice(0, 200) + .replace(/\s+/g, ' ') + .trim() + const lines = [ + '[answer-status-requests-reminder] User asked for a status update; assistant declined with rate-limiting excuse:', + '', + ` User: "${userSnippet}${recentUser.length > 200 ? '…' : ''}"`, + '', + ' Decline phrases detected in assistant turn:', + ] + for (let i = 0, { length } = hits; i < length; i += 1) { + const hit = hits[i]! + lines.push(` • "${hit.label}" — …${hit.snippet}…`) + } + lines.push('') + lines.push( + ' When the user explicitly asks for a status update, RUN the check and report. "Too soon" / "skipping" / "polling is wasted" are gatekeeping — the user already decided the check is worth it. The auto-notification policy (for background tasks the harness tracks) is YOUR optimization, not theirs.', + ) + + process.stderr.write(lines.join('\n') + '\n') +} + +main().catch(() => { + // Fail-open: never block a session on this hook's own bug. +}) diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/package.json b/.claude/hooks/fleet/answer-status-requests-reminder/package.json new file mode 100644 index 0000000..597ce23 --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-answer-status-requests-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts b/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts new file mode 100644 index 0000000..e3aa188 --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts @@ -0,0 +1,39 @@ +/** + * @file Smoke test for answer-status-requests-reminder. + * + * Stop hook that catches the failure mode where the user explicitly asks + * for a status update and the assistant declines with a "too soon since + * last check" excuse. + * + * Smoke contract: hook loads + dispatches without throwing; empty + * transcript path → exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'answer-status-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/auth-rotation-reminder/tsconfig.json b/.claude/hooks/fleet/answer-status-requests-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/auth-rotation-reminder/tsconfig.json rename to .claude/hooks/fleet/answer-status-requests-reminder/tsconfig.json diff --git a/.claude/hooks/ask-suppression-reminder/README.md b/.claude/hooks/fleet/ask-suppression-reminder/README.md similarity index 100% rename from .claude/hooks/ask-suppression-reminder/README.md rename to .claude/hooks/fleet/ask-suppression-reminder/README.md diff --git a/.claude/hooks/ask-suppression-reminder/index.mts b/.claude/hooks/fleet/ask-suppression-reminder/index.mts similarity index 100% rename from .claude/hooks/ask-suppression-reminder/index.mts rename to .claude/hooks/fleet/ask-suppression-reminder/index.mts diff --git a/.claude/hooks/ask-suppression-reminder/package.json b/.claude/hooks/fleet/ask-suppression-reminder/package.json similarity index 100% rename from .claude/hooks/ask-suppression-reminder/package.json rename to .claude/hooks/fleet/ask-suppression-reminder/package.json diff --git a/.claude/hooks/ask-suppression-reminder/test/index.test.mts b/.claude/hooks/fleet/ask-suppression-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/ask-suppression-reminder/test/index.test.mts rename to .claude/hooks/fleet/ask-suppression-reminder/test/index.test.mts diff --git a/.claude/hooks/check-new-deps/tsconfig.json b/.claude/hooks/fleet/ask-suppression-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/check-new-deps/tsconfig.json rename to .claude/hooks/fleet/ask-suppression-reminder/tsconfig.json diff --git a/.claude/hooks/auth-rotation-reminder/README.md b/.claude/hooks/fleet/auth-rotation-reminder/README.md similarity index 100% rename from .claude/hooks/auth-rotation-reminder/README.md rename to .claude/hooks/fleet/auth-rotation-reminder/README.md diff --git a/.claude/hooks/auth-rotation-reminder/index.mts b/.claude/hooks/fleet/auth-rotation-reminder/index.mts similarity index 100% rename from .claude/hooks/auth-rotation-reminder/index.mts rename to .claude/hooks/fleet/auth-rotation-reminder/index.mts diff --git a/.claude/hooks/auth-rotation-reminder/package.json b/.claude/hooks/fleet/auth-rotation-reminder/package.json similarity index 100% rename from .claude/hooks/auth-rotation-reminder/package.json rename to .claude/hooks/fleet/auth-rotation-reminder/package.json diff --git a/.claude/hooks/auth-rotation-reminder/services.mts b/.claude/hooks/fleet/auth-rotation-reminder/services.mts similarity index 100% rename from .claude/hooks/auth-rotation-reminder/services.mts rename to .claude/hooks/fleet/auth-rotation-reminder/services.mts diff --git a/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts b/.claude/hooks/fleet/auth-rotation-reminder/test/auth-rotation-reminder.test.mts similarity index 100% rename from .claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts rename to .claude/hooks/fleet/auth-rotation-reminder/test/auth-rotation-reminder.test.mts diff --git a/.claude/hooks/claude-md-section-size-guard/tsconfig.json b/.claude/hooks/fleet/auth-rotation-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/tsconfig.json rename to .claude/hooks/fleet/auth-rotation-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/avoid-cd-reminder/index.mts b/.claude/hooks/fleet/avoid-cd-reminder/index.mts new file mode 100644 index 0000000..472899d --- /dev/null +++ b/.claude/hooks/fleet/avoid-cd-reminder/index.mts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — avoid-cd-reminder. +// +// The Bash tool's working directory PERSISTS across tool calls. That's +// useful for chaining commands but easy to lose track of: a `cd` in +// turn N puts every later command in a different cwd until something +// resets it. The assistant has burned multiple tool calls realizing +// cwd had drifted — see e.g. "Wait — patch ran from current dir. +// But the current dir isn't lsquic upstream." +// +// The fix is one of: +// (a) prefer absolute paths inside a single command — no cd needed: +// patch --dry-run -p1 -d /abs/path/to/source < /abs/path/to/file.patch +// (b) keep the cd local to the command via `()` subshell — pwd is +// confined to the subshell, parent cwd unchanged: +// (cd /abs/path && make) +// (c) end the command with `&& pwd` so the next tool call shows +// evidence in the log where the cwd actually ended up: +// cd /abs/path && some-command && pwd +// +// This hook fires on Bash commands that contain a bare `cd ` +// without one of the above safeguards. Stderr reminder; never blocks. +// +// Scope: Bash tool only. Skips: +// - `cd ` inside a `()` subshell (pattern (b) — safe) +// - `cd ` followed by `&& pwd` or `; pwd` at the end (pattern (c) — +// evidenced) +// - `cd -` (return to previous dir, intentional) +// - `cd 2>/dev/null` short forms used for existence probes +// (caller knows what they're doing) +// +// Disable via SOCKET_AVOID_CD_REMINDER_DISABLED. + +import process from 'node:process' + +import { readStdin } from '../../_shared/transcript.mts' + +interface PreToolUseInput { + readonly tool_name?: string | undefined + readonly tool_input?: { + readonly command?: string | undefined + } | undefined +} + +// Matches `cd ` not preceded by `(` (subshell) and not +// followed by anything that suggests evidence-capture. +function detectsBareCd(command: string): boolean { + // Strip line continuations + collapse whitespace for easier matching. + const flat = command.replace(/\\\n/g, ' ').replace(/\s+/g, ' ') + + // Find every `cd ` occurrence and inspect each one's context. + const cdRe = /(^|[\s;&|])cd\s+(\S+)/g + let m: RegExpExecArray | null + while ((m = cdRe.exec(flat)) !== null) { + const lead = m[1]! + const target = m[2]! + + // Skip `cd -` (intentional return). + if (target === '-') { + continue + } + // Skip subshell form: `(cd path && ...)`. We look backwards in + // the flattened string for an unmatched `(` before the cd. + const pre = flat.slice(0, m.index) + const opens = (pre.match(/\(/g) ?? []).length + const closes = (pre.match(/\)/g) ?? []).length + if (opens > closes) { + continue + } + // Skip if the lead is empty AND we're at the very start AND the + // command ends with `&& pwd` or `; pwd` — evidence pattern. + if (/(?:&&|;)\s*pwd\b\s*$/.test(flat)) { + continue + } + // Skip the bare-existence-probe shape: `cd 2>/dev/null && …`. + // The `2>/dev/null` redirect signals the caller is using cd as a + // probe, not a permanent move. + const tail = flat.slice(m.index + m[0].length) + if (/^\s*2>\s*\/dev\/null/.test(tail)) { + continue + } + // Bare cd that persists across tool calls. + return true + } + return false +} + +async function main(): Promise { + if (process.env['SOCKET_AVOID_CD_REMINDER_DISABLED']) { + return + } + const payloadRaw = await readStdin() + let payload: PreToolUseInput + try { + payload = JSON.parse(payloadRaw) as PreToolUseInput + } catch { + return + } + if (payload.tool_name !== 'Bash') { + return + } + const command = payload.tool_input?.command + if (typeof command !== 'string' || command.length === 0) { + return + } + if (!detectsBareCd(command)) { + return + } + process.stderr.write( + [ + '[avoid-cd-reminder] Bash command contains a bare `cd `.', + '', + " The Bash tool's cwd PERSISTS across tool calls — a cd here lingers", + " for every later command until something resets it. Recover with one", + ' of:', + '', + ' (a) Use absolute paths so no cd is needed:', + ' patch -p1 -d /abs/path < /abs/file.patch', + '', + ' (b) Confine the cd to a subshell:', + ' (cd /abs/path && make)', + '', + ' (c) Capture the resulting cwd so the next call can see it:', + ' cd /abs/path && some-command && pwd', + '', + ' Disable: SOCKET_AVOID_CD_REMINDER_DISABLED=1', + '', + ].join('\n'), + ) +} + +main().catch(() => { + // Fail-open: never block a session on this hook's own bug. + process.exitCode = 0 +}) diff --git a/.claude/hooks/fleet/broken-hook-detector/README.md b/.claude/hooks/fleet/broken-hook-detector/README.md new file mode 100644 index 0000000..b535d1b --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/README.md @@ -0,0 +1,25 @@ +# broken-hook-detector + +**Lifecycle**: SessionStart + +**Purpose**: catch the failure mode where every Bash invocation prints noisy `PreToolUse:Bash hook error … node:internal/modules/package_json_reader:314` lines without identifying which hook crashed or what it needed. + +## What it does + +At `SessionStart` (once per session — no Bash spam), the hook walks every `.claude/hooks/*/index.mts` plus `.claude/hooks/_shared/*.mts`, spawns `node --check` on each, and aggregates failures. If any crash with `ERR_MODULE_NOT_FOUND`, the hook surfaces a single structured message naming: + +- The failing hook +- The missing package(s) +- The exact `pnpm i` recovery command + +## Self-imposed constraint: Node built-ins only + +This hook is the safety net for "hook deps are broken"; it must not itself depend on anything installed via pnpm. The entire import surface is `node:fs`, `node:path`, `node:child_process`, `node:url`. Adding a `@socketsecurity/*` import here would make the hook silently fail under the exact condition it exists to detect. + +## Fail-open + +The probe never blocks. On any internal error (timeout, unreadable file, walker exception) the hook exits 0 and the session starts normally. The point is informational diagnosis, not enforcement. + +## When it fires in practice + +Most often after a wheelhouse cascade introduces a new `import` to a `_shared/*.mts` helper and the consuming repo hasn't run `pnpm install` to materialize the dependency. diff --git a/.claude/hooks/fleet/broken-hook-detector/index.mts b/.claude/hooks/fleet/broken-hook-detector/index.mts new file mode 100644 index 0000000..cea26a8 --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/index.mts @@ -0,0 +1,237 @@ +#!/usr/bin/env node +// Claude Code SessionStart hook — broken-hook-detector. +// +// Symptom this hook exists to catch: +// Every Bash invocation prints noisy `PreToolUse:Bash hook error +// Failed with non-blocking status code: node:internal/modules/ +// package_json_reader:314` lines, with no indication of WHICH hook +// crashed or WHAT it needed. Happens whenever a fleet-cascade adds +// a new `import` to a shared hook (e.g. `_shared/shell-command.mts`) +// and the consuming repo hasn't installed the dep yet. +// +// What it does: +// At SessionStart (once per session, no Bash spam), walk every +// `.claude/hooks/*/index.mts` plus `.claude/hooks/_shared/*.mts`, +// spawn `node --check` on each, and aggregate the failures. If any +// crash with ERR_MODULE_NOT_FOUND, surface ONE structured message +// that names: the failing hook, the missing package(s), and the +// exact `pnpm i` recovery command. +// +// **Self-imposed constraint: Node built-ins ONLY.** +// This hook is the safety net for "hook deps are broken"; it must +// not itself depend on anything installed via pnpm. fs, path, child +// process, url — that's the entire import surface. +// +// Fail-open: probe never blocks. On any internal error (timeout, +// permission, whatever) the hook silently exits 0 and lets the +// session proceed — same posture as socket-token-minifier-start. + +import { spawnSync } from 'node:child_process' +import { readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR ?? process.cwd() +const HOOKS_DIR = path.join(PROJECT_DIR, '.claude', 'hooks') + +// 4-second total budget. Each `node --check` is ~50-150 ms; with +// ~80 hooks that's well under the SessionStart hook timeout. +const PER_PROBE_TIMEOUT_MS = 1500 +const MAX_PROBES = 120 + +interface ProbeFailure { + readonly hookPath: string + readonly missingPackages: readonly string[] + readonly rawStderr: string +} + +function emitAdditionalContext(message: string): void { + // Stdout is the only channel Claude Code reads for SessionStart + // hooks. additionalContext lands as informational text in the + // transcript; it does NOT block the session. + const out = { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: `[broken-hook-detector] ${message}`, + }, + } + process.stdout.write(JSON.stringify(out)) +} + +function findHookEntrypoints(): readonly string[] { + const entries: string[] = [] + // Each hook lives at //index.mts. + let topLevel: readonly string[] + try { + topLevel = readdirSync(HOOKS_DIR) + } catch { + // No hooks dir; nothing to probe. + return [] + } + for (const name of topLevel) { + if (entries.length >= MAX_PROBES) { + break + } + if (name === '_shared') { + continue + } + const candidate = path.join(HOOKS_DIR, name, 'index.mts') + try { + if (statSync(candidate).isFile()) { + entries.push(candidate) + } + } catch { + // Hook dir without index.mts is fine; skip. + } + } + return entries +} + +// Module-not-found error shape from Node ≥22: +// Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'shell-quote' +// imported from /…/_shared/shell-command.mts +// at Object.getPackageJSONURL (node:internal/modules/package_json_reader:314:9) +// +// We also tolerate the older CJS shape: +// Error: Cannot find module 'shell-quote' +function parseMissingPackages(stderr: string): readonly string[] { + const pkgs = new Set() + // ESM form: Cannot find package '' … + for (const m of stderr.matchAll(/Cannot find package '([^']+)'/g)) { + pkgs.add(m[1]!) + } + // CJS form: Cannot find module '' + for (const m of stderr.matchAll(/Cannot find module '([^']+)'/g)) { + const name = m[1]! + // Skip relative + absolute paths (those are import-path bugs, not + // missing-dep bugs, and the user can't `pnpm i` a relative path). + if (!name.startsWith('.') && !name.startsWith('/')) { + pkgs.add(name) + } + } + return [...pkgs] +} + +function probeHook(hookPath: string): ProbeFailure | undefined { + // `node --check` does syntax-only validation and won't import the + // graph. Use `--input-type=module` and read the file as the input + // so module resolution actually happens. But that's heavy — the + // cheaper alternative: dynamic import via a tiny one-liner that + // exits 0 after the import succeeds. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + // Resolving-only via import() lets the resolver run without + // executing top-level code that might block (e.g. start a + // server). Success → loop drains, exit 0. Failure → Node's + // default unhandled-rejection handler prints the error to + // stderr and exits non-zero — the parent reads result.stderr + // for "Cannot find package" matching, no try/catch needed. + // + // file:// form is required for cross-platform correctness: on + // Windows, an absolute path like `C:\foo\bar.mts` looks like a + // URL scheme (`C:`) to the ESM resolver and throws + // ERR_UNSUPPORTED_ESM_URL_SCHEME. pathToFileURL handles the + // platform-specific quoting + scheme prefix. + `await import(${JSON.stringify(pathToFileURL(hookPath).href)})`, + ], + { + timeout: PER_PROBE_TIMEOUT_MS, + // Inherit nothing — keep the probe sandboxed from the real + // session env so any env-var quirks don't surface as false + // positives. CLAUDE_PROJECT_DIR is preserved because some + // hooks read it at import time. + env: { + PATH: process.env.PATH ?? '', + HOME: process.env.HOME ?? '', + CLAUDE_PROJECT_DIR: PROJECT_DIR, + // Suppress node's deprecation warnings during the probe; + // unrelated to broken-hook detection. + NODE_NO_WARNINGS: '1', + }, + encoding: 'utf8', + }, + ) + if (result.status === 0) { + return undefined + } + // Non-zero exit OR timeout. spawnSync sets status=null on timeout; + // treat timeout as inconclusive (skip rather than false-positive). + if (result.status === null) { + return undefined + } + const stderr = result.stderr ?? '' + // Only flag genuine missing-dep failures. Syntax errors, runtime + // errors, etc. aren't this hook's job to surface. + if ( + !stderr.includes('ERR_MODULE_NOT_FOUND') && + !stderr.includes('Cannot find package') && + !stderr.includes('Cannot find module') + ) { + return undefined + } + const missing = parseMissingPackages(stderr) + if (missing.length === 0) { + return undefined + } + return { + hookPath, + missingPackages: missing, + rawStderr: stderr.slice(0, 2000), + } +} + +function formatReport(failures: readonly ProbeFailure[]): string { + // Aggregate unique missing packages across all failures so the + // suggested `pnpm i` recovers everything in one call. + const allMissing = new Set() + for (const f of failures) { + for (const p of f.missingPackages) { + allMissing.add(p) + } + } + const lines: string[] = [] + lines.push( + `${failures.length} hook${failures.length === 1 ? '' : 's'} failed to load due to missing packages:`, + ) + for (const f of failures) { + const relPath = path.relative(PROJECT_DIR, f.hookPath) + lines.push(` - ${relPath} → ${f.missingPackages.join(', ')}`) + } + const installList = [...allMissing].sort().join(' ') + lines.push('') + lines.push(`Fix: \`pnpm i ${installList}\``) + lines.push( + 'If the dep is a fleet-canonical cascade, the catalog entry + soak-bypass may also need adding (see pnpm-workspace.yaml).', + ) + return lines.join('\n') +} + +function main(): void { + const entrypoints = findHookEntrypoints() + if (entrypoints.length === 0) { + return + } + const failures: ProbeFailure[] = [] + for (const entry of entrypoints) { + const failure = probeHook(entry) + if (failure !== undefined) { + failures.push(failure) + } + } + if (failures.length === 0) { + return + } + emitAdditionalContext(formatReport(failures)) +} + +try { + main() +} catch { + // Fail-open: never block a session on this hook's own bug. + // No exitCode write needed — Node defaults to 0 when the loop + // drains naturally, and we explicitly never want a non-zero here. +} diff --git a/.claude/hooks/fleet/broken-hook-detector/package.json b/.claude/hooks/fleet/broken-hook-detector/package.json new file mode 100644 index 0000000..92efa45 --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-broken-hook-detector", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts b/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts new file mode 100644 index 0000000..e0d17c5 --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts @@ -0,0 +1,36 @@ +/** + * @file Smoke test for broken-hook-detector. + * + * SessionStart hook (Node built-ins only, self-imposed) that walks every + * other hook's index.mts + every _shared/*.mts, spawns `node --check` on + * each, and aggregates ERR_MODULE_NOT_FOUND failures into one structured + * recovery message. Fail-open by design. + * + * Smoke contract: hook loads + dispatches without throwing; empty + * payload → exit 0 (fail-open). + */ + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty payload exits 0 (fail-open)', async () => { + const result = await runHook({}) + // Fail-open: any internal error must exit 0. + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/claude-md-size-guard/tsconfig.json b/.claude/hooks/fleet/broken-hook-detector/tsconfig.json similarity index 100% rename from .claude/hooks/claude-md-size-guard/tsconfig.json rename to .claude/hooks/fleet/broken-hook-detector/tsconfig.json diff --git a/.claude/hooks/check-new-deps/README.md b/.claude/hooks/fleet/check-new-deps/README.md similarity index 100% rename from .claude/hooks/check-new-deps/README.md rename to .claude/hooks/fleet/check-new-deps/README.md diff --git a/.claude/hooks/check-new-deps/audit.mts b/.claude/hooks/fleet/check-new-deps/audit.mts similarity index 100% rename from .claude/hooks/check-new-deps/audit.mts rename to .claude/hooks/fleet/check-new-deps/audit.mts diff --git a/.claude/hooks/check-new-deps/index.mts b/.claude/hooks/fleet/check-new-deps/index.mts similarity index 100% rename from .claude/hooks/check-new-deps/index.mts rename to .claude/hooks/fleet/check-new-deps/index.mts diff --git a/.claude/hooks/check-new-deps/package.json b/.claude/hooks/fleet/check-new-deps/package.json similarity index 100% rename from .claude/hooks/check-new-deps/package.json rename to .claude/hooks/fleet/check-new-deps/package.json diff --git a/.claude/hooks/check-new-deps/test/extract-deps.test.mts b/.claude/hooks/fleet/check-new-deps/test/extract-deps.test.mts similarity index 100% rename from .claude/hooks/check-new-deps/test/extract-deps.test.mts rename to .claude/hooks/fleet/check-new-deps/test/extract-deps.test.mts diff --git a/.claude/hooks/codex-no-write-guard/tsconfig.json b/.claude/hooks/fleet/check-new-deps/tsconfig.json similarity index 100% rename from .claude/hooks/codex-no-write-guard/tsconfig.json rename to .claude/hooks/fleet/check-new-deps/tsconfig.json diff --git a/.claude/hooks/check-new-deps/types.mts b/.claude/hooks/fleet/check-new-deps/types.mts similarity index 100% rename from .claude/hooks/check-new-deps/types.mts rename to .claude/hooks/fleet/check-new-deps/types.mts diff --git a/.claude/hooks/claude-md-section-size-guard/README.md b/.claude/hooks/fleet/claude-md-section-size-guard/README.md similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/README.md rename to .claude/hooks/fleet/claude-md-section-size-guard/README.md diff --git a/.claude/hooks/claude-md-section-size-guard/index.mts b/.claude/hooks/fleet/claude-md-section-size-guard/index.mts similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/index.mts rename to .claude/hooks/fleet/claude-md-section-size-guard/index.mts diff --git a/.claude/hooks/claude-md-section-size-guard/package.json b/.claude/hooks/fleet/claude-md-section-size-guard/package.json similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/package.json rename to .claude/hooks/fleet/claude-md-section-size-guard/package.json diff --git a/.claude/hooks/claude-md-section-size-guard/test/index.test.mts b/.claude/hooks/fleet/claude-md-section-size-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/test/index.test.mts rename to .claude/hooks/fleet/claude-md-section-size-guard/test/index.test.mts diff --git a/.claude/hooks/comment-tone-reminder/tsconfig.json b/.claude/hooks/fleet/claude-md-section-size-guard/tsconfig.json similarity index 100% rename from .claude/hooks/comment-tone-reminder/tsconfig.json rename to .claude/hooks/fleet/claude-md-section-size-guard/tsconfig.json diff --git a/.claude/hooks/claude-md-size-guard/README.md b/.claude/hooks/fleet/claude-md-size-guard/README.md similarity index 100% rename from .claude/hooks/claude-md-size-guard/README.md rename to .claude/hooks/fleet/claude-md-size-guard/README.md diff --git a/.claude/hooks/claude-md-size-guard/index.mts b/.claude/hooks/fleet/claude-md-size-guard/index.mts similarity index 100% rename from .claude/hooks/claude-md-size-guard/index.mts rename to .claude/hooks/fleet/claude-md-size-guard/index.mts diff --git a/.claude/hooks/claude-md-size-guard/package.json b/.claude/hooks/fleet/claude-md-size-guard/package.json similarity index 100% rename from .claude/hooks/claude-md-size-guard/package.json rename to .claude/hooks/fleet/claude-md-size-guard/package.json diff --git a/.claude/hooks/claude-md-size-guard/test/index.test.mts b/.claude/hooks/fleet/claude-md-size-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/claude-md-size-guard/test/index.test.mts rename to .claude/hooks/fleet/claude-md-size-guard/test/index.test.mts diff --git a/.claude/hooks/commit-author-guard/tsconfig.json b/.claude/hooks/fleet/claude-md-size-guard/tsconfig.json similarity index 100% rename from .claude/hooks/commit-author-guard/tsconfig.json rename to .claude/hooks/fleet/claude-md-size-guard/tsconfig.json diff --git a/.claude/hooks/codex-no-write-guard/README.md b/.claude/hooks/fleet/codex-no-write-guard/README.md similarity index 100% rename from .claude/hooks/codex-no-write-guard/README.md rename to .claude/hooks/fleet/codex-no-write-guard/README.md diff --git a/.claude/hooks/codex-no-write-guard/index.mts b/.claude/hooks/fleet/codex-no-write-guard/index.mts similarity index 100% rename from .claude/hooks/codex-no-write-guard/index.mts rename to .claude/hooks/fleet/codex-no-write-guard/index.mts diff --git a/.claude/hooks/codex-no-write-guard/package.json b/.claude/hooks/fleet/codex-no-write-guard/package.json similarity index 100% rename from .claude/hooks/codex-no-write-guard/package.json rename to .claude/hooks/fleet/codex-no-write-guard/package.json diff --git a/.claude/hooks/codex-no-write-guard/test/index.test.mts b/.claude/hooks/fleet/codex-no-write-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/codex-no-write-guard/test/index.test.mts rename to .claude/hooks/fleet/codex-no-write-guard/test/index.test.mts diff --git a/.claude/hooks/commit-message-format-guard/tsconfig.json b/.claude/hooks/fleet/codex-no-write-guard/tsconfig.json similarity index 100% rename from .claude/hooks/commit-message-format-guard/tsconfig.json rename to .claude/hooks/fleet/codex-no-write-guard/tsconfig.json diff --git a/.claude/hooks/comment-tone-reminder/README.md b/.claude/hooks/fleet/comment-tone-reminder/README.md similarity index 100% rename from .claude/hooks/comment-tone-reminder/README.md rename to .claude/hooks/fleet/comment-tone-reminder/README.md diff --git a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md new file mode 100644 index 0000000..55fb050 --- /dev/null +++ b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md @@ -0,0 +1,34 @@ +# comment-tone-reminder + +Stop hook that scans the assistant's most recent turn for teacher-tone phrases that would read condescendingly if written into a code comment. + +## Why + +CLAUDE.md's "Code style → Comments" rule: comments default to none; when written, the audience is a junior dev — explain the constraint, the hidden invariant, the "why this and not the obvious thing." No teacher-tone preamble. + +The patterns this hook flags are predictable shapes: "First, we will...", "Note that...", "It's important to...", "As you can see...", "Remember that...", "In order to...". + +## What it catches + +| Phrase | Why it's flagged | +| ------------------------------------- | ------------------------------------------------------- | +| `first, we (will\|are\|need\|should)` | Step-by-step narration — drop the framing. | +| `note that` | Tutorial filler. State the load-bearing point directly. | +| `it's important to` | Don't announce importance — state the constraint. | +| `as you can see` | Presupposes reader engagement. Drop. | +| `remember (that\|to)` | Reader doesn't need reminding — state the rule. | +| `in order to` | Wordy. "To X" suffices unless contrasting paths. | + +## Why it doesn't block + +Stop hooks fire after the assistant has produced its response. Blocking would truncate the message. The warning surfaces to stderr alongside the response so the user reads both and can push back in the next turn. + +## Configuration + +`SOCKET_COMMENT_TONE_REMINDER_DISABLED=1` — turn off entirely. + +## Test + +```sh +pnpm test +``` diff --git a/.claude/hooks/comment-tone-reminder/index.mts b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/index.mts similarity index 100% rename from .claude/hooks/comment-tone-reminder/index.mts rename to .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/index.mts diff --git a/.claude/hooks/comment-tone-reminder/package.json b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/package.json similarity index 100% rename from .claude/hooks/comment-tone-reminder/package.json rename to .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/package.json diff --git a/.claude/hooks/comment-tone-reminder/test/index.test.mts b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/comment-tone-reminder/test/index.test.mts rename to .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/test/index.test.mts diff --git a/.claude/hooks/commit-pr-reminder/tsconfig.json b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/commit-pr-reminder/tsconfig.json rename to .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/comment-tone-reminder/index.mts b/.claude/hooks/fleet/comment-tone-reminder/index.mts new file mode 100644 index 0000000..3af6fba --- /dev/null +++ b/.claude/hooks/fleet/comment-tone-reminder/index.mts @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Claude Code Stop hook — comment-tone-reminder. +// +// Flags teacher-tone phrases in the most-recent assistant turn that +// suggest comments written in code edits will read condescendingly. +// CLAUDE.md "Code style → Comments" says: audience is a junior dev, +// explain the constraint, not the obvious. No "First, we'll …" / +// "Note that …" / "It's important …" / "As you can see …" tone. +// +// Fires informationally to stderr; never blocks. +// +// Disable via SOCKET_COMMENT_TONE_REMINDER_DISABLED. + +import { runStopReminder } from '../_shared/stop-reminder.mts' + +await runStopReminder({ + name: 'comment-tone-reminder', + disabledEnvVar: 'SOCKET_COMMENT_TONE_REMINDER_DISABLED', + patterns: [ + { + label: 'first, we (will|are)', + regex: /\bfirst,? we (?:are|need|should|will)\b/i, + why: 'Teacher-tone narration. Drop the step-by-step framing in comments.', + }, + { + label: 'note that', + regex: /\bnote that\b/i, + why: 'Tutorial filler. If the note is load-bearing, state it directly without the preamble.', + }, + { + label: "it['’]?s important to", + regex: /\bit'?s important to\b/i, + why: "Teacher-tone. State the constraint, don't announce that it's important.", + }, + { + label: 'as you can see', + regex: /\bas you can see\b/i, + why: 'Presupposes reader engagement. Drop the phrase.', + }, + { + label: 'remember that', + regex: /\bremember (?:that|to)\b/i, + why: "Teacher-tone. The reader doesn't need to be reminded — state the rule.", + }, + { + label: 'in order to', + regex: /\bin order to\b/i, + why: 'Wordy. "To X" is sufficient unless contrasting with another path.', + }, + ], + closingHint: + 'These phrases in code comments age into noise. Per CLAUDE.md "Comments": audience is a junior dev — explain the constraint, the hidden invariant. Default to no comment.', +}) diff --git a/.claude/hooks/fleet/comment-tone-reminder/package.json b/.claude/hooks/fleet/comment-tone-reminder/package.json new file mode 100644 index 0000000..5a01b70 --- /dev/null +++ b/.claude/hooks/fleet/comment-tone-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-comment-tone-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/comment-tone-reminder/test/index.test.mts b/.claude/hooks/fleet/comment-tone-reminder/test/index.test.mts new file mode 100644 index 0000000..85d59a3 --- /dev/null +++ b/.claude/hooks/fleet/comment-tone-reminder/test/index.test.mts @@ -0,0 +1,117 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function makeTranscript(assistantText: string): { + path: string + cleanup: () => void +} { + const dir = mkdtempSync(path.join(os.tmpdir(), 'comment-tone-')) + const transcriptPath = path.join(dir, 'session.jsonl') + const lines = [ + JSON.stringify({ role: 'user', content: 'hi' }), + JSON.stringify({ role: 'assistant', content: assistantText }), + ].join('\n') + writeFileSync(transcriptPath, lines) + return { + path: transcriptPath, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + } +} + +function runHook(transcriptPath: string): { + stdout: string + stderr: string + exitCode: number +} { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: transcriptPath }), + }) + return { + stdout: String(result.stdout), + stderr: String(result.stderr), + exitCode: result.status ?? -1, + } +} + +test('flags "first, we will" teacher-tone preamble', () => { + const { path: p, cleanup } = makeTranscript('First, we will parse the input.') + try { + const { stderr, exitCode } = runHook(p) + assert.equal(exitCode, 0) + assert.match(stderr, /comment-tone-reminder/) + assert.match(stderr, /first, we/) + } finally { + cleanup() + } +}) + +test('flags "note that" tutorial filler', () => { + const { path: p, cleanup } = makeTranscript( + 'Note that the parser caches results.', + ) + try { + const { stderr } = runHook(p) + assert.match(stderr, /note that/) + } finally { + cleanup() + } +}) + +test('flags "in order to" wordiness', () => { + const { path: p, cleanup } = makeTranscript( + 'We use a cache in order to avoid recomputation.', + ) + try { + const { stderr } = runHook(p) + assert.match(stderr, /in order to/) + } finally { + cleanup() + } +}) + +test('does not flag plain prose', () => { + const { path: p, cleanup } = makeTranscript( + 'The cache stores parsed results keyed by input.', + ) + try { + const { stderr, exitCode } = runHook(p) + assert.equal(exitCode, 0) + assert.equal(stderr, '') + } finally { + cleanup() + } +}) + +test('does not false-positive on phrases inside code fences', () => { + const { path: p, cleanup } = makeTranscript( + 'Plain output here.\n```\nnote that this is in code\n```\nMore prose.', + ) + try { + const { stderr } = runHook(p) + assert.equal(stderr, '') + } finally { + cleanup() + } +}) + +test('disabled env var short-circuits', () => { + const { path: p, cleanup } = makeTranscript('Note that we should skip this.') + try { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: p }), + env: { ...process.env, SOCKET_COMMENT_TONE_REMINDER_DISABLED: '1' }, + }) + assert.equal(result.status, 0) + assert.equal(result.stderr, '') + } finally { + cleanup() + } +}) diff --git a/.claude/hooks/compound-lessons-reminder/tsconfig.json b/.claude/hooks/fleet/comment-tone-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/compound-lessons-reminder/tsconfig.json rename to .claude/hooks/fleet/comment-tone-reminder/tsconfig.json diff --git a/.claude/hooks/commit-author-guard/README.md b/.claude/hooks/fleet/commit-author-guard/README.md similarity index 100% rename from .claude/hooks/commit-author-guard/README.md rename to .claude/hooks/fleet/commit-author-guard/README.md diff --git a/.claude/hooks/commit-author-guard/index.mts b/.claude/hooks/fleet/commit-author-guard/index.mts similarity index 100% rename from .claude/hooks/commit-author-guard/index.mts rename to .claude/hooks/fleet/commit-author-guard/index.mts diff --git a/.claude/hooks/commit-author-guard/package.json b/.claude/hooks/fleet/commit-author-guard/package.json similarity index 100% rename from .claude/hooks/commit-author-guard/package.json rename to .claude/hooks/fleet/commit-author-guard/package.json diff --git a/.claude/hooks/commit-author-guard/test/index.test.mts b/.claude/hooks/fleet/commit-author-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/commit-author-guard/test/index.test.mts rename to .claude/hooks/fleet/commit-author-guard/test/index.test.mts diff --git a/.claude/hooks/concurrent-cargo-build-guard/tsconfig.json b/.claude/hooks/fleet/commit-author-guard/tsconfig.json similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/tsconfig.json rename to .claude/hooks/fleet/commit-author-guard/tsconfig.json diff --git a/.claude/hooks/commit-message-format-guard/README.md b/.claude/hooks/fleet/commit-message-format-guard/README.md similarity index 100% rename from .claude/hooks/commit-message-format-guard/README.md rename to .claude/hooks/fleet/commit-message-format-guard/README.md diff --git a/.claude/hooks/commit-message-format-guard/index.mts b/.claude/hooks/fleet/commit-message-format-guard/index.mts similarity index 100% rename from .claude/hooks/commit-message-format-guard/index.mts rename to .claude/hooks/fleet/commit-message-format-guard/index.mts diff --git a/.claude/hooks/commit-message-format-guard/package.json b/.claude/hooks/fleet/commit-message-format-guard/package.json similarity index 100% rename from .claude/hooks/commit-message-format-guard/package.json rename to .claude/hooks/fleet/commit-message-format-guard/package.json diff --git a/.claude/hooks/commit-message-format-guard/test/format.test.mts b/.claude/hooks/fleet/commit-message-format-guard/test/format.test.mts similarity index 100% rename from .claude/hooks/commit-message-format-guard/test/format.test.mts rename to .claude/hooks/fleet/commit-message-format-guard/test/format.test.mts diff --git a/.claude/hooks/consumer-grep-reminder/tsconfig.json b/.claude/hooks/fleet/commit-message-format-guard/tsconfig.json similarity index 100% rename from .claude/hooks/consumer-grep-reminder/tsconfig.json rename to .claude/hooks/fleet/commit-message-format-guard/tsconfig.json diff --git a/.claude/hooks/commit-pr-reminder/README.md b/.claude/hooks/fleet/commit-pr-reminder/README.md similarity index 100% rename from .claude/hooks/commit-pr-reminder/README.md rename to .claude/hooks/fleet/commit-pr-reminder/README.md diff --git a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md new file mode 100644 index 0000000..a8712fd --- /dev/null +++ b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md @@ -0,0 +1,19 @@ +# commit-pr-reminder + +Stop hook that flags assistant turns drafting commit messages or PR bodies missing fleet conventions. + +## What it catches + +- **AI attribution** — "Generated with Claude", "Co-Authored-By: Claude", `🤖 Generated`. The fleet's Commits & PRs rule forbids these. + +The companion guards that actually block `git commit` / `gh pr create` invocations live separately. This hook only nudges when drafted text shows the antipatterns in the assistant turn. + +## Bypass + +- `SOCKET_COMMIT_PR_REMINDER_DISABLED=1` — turn off entirely. + +## Test + +```sh +pnpm test +``` diff --git a/.claude/hooks/commit-pr-reminder/index.mts b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/index.mts similarity index 100% rename from .claude/hooks/commit-pr-reminder/index.mts rename to .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/index.mts diff --git a/.claude/hooks/commit-pr-reminder/package.json b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/package.json similarity index 100% rename from .claude/hooks/commit-pr-reminder/package.json rename to .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/package.json diff --git a/.claude/hooks/commit-pr-reminder/test/index.test.mts b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/commit-pr-reminder/test/index.test.mts rename to .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/test/index.test.mts diff --git a/.claude/hooks/cross-repo-guard/tsconfig.json b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/cross-repo-guard/tsconfig.json rename to .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/commit-pr-reminder/index.mts b/.claude/hooks/fleet/commit-pr-reminder/index.mts new file mode 100644 index 0000000..578db48 --- /dev/null +++ b/.claude/hooks/fleet/commit-pr-reminder/index.mts @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Claude Code Stop hook — commit-pr-reminder. +// +// Flags assistant turns that drafted a commit message or PR body +// missing the fleet's required structure: +// +// - Conventional Commits header (`(): `). +// Anti-pattern: free-form sentences as the commit title. +// +// - AI attribution lines ("Generated with Claude", "Co-Authored-By: +// Claude", "🤖" tag lines). The fleet forbids these. +// +// - PR body missing a Summary section (PRs that paste a commit log +// without a 1-3 bullet summary). +// +// This hook only flags drafted text in the assistant turn — it doesn't +// inspect real git/gh invocations. The git/PR ones live in their own +// PreToolUse guards. +// +// Disable via SOCKET_COMMIT_PR_REMINDER_DISABLED. + +import { runStopReminder } from '../_shared/stop-reminder.mts' + +await runStopReminder({ + name: 'commit-pr-reminder', + disabledEnvVar: 'SOCKET_COMMIT_PR_REMINDER_DISABLED', + patterns: [ + { + label: 'AI attribution: Generated with Claude', + regex: /generated with (?:anthropic|claude)/i, + why: 'The fleet forbids AI attribution in commit/PR text. Remove the line.', + }, + { + label: 'AI attribution: Co-Authored-By Claude', + regex: /co-authored-by:?\s*claude/i, + why: 'Co-Authored-By Claude is forbidden in commit/PR trailers.', + }, + { + label: 'AI attribution: robot emoji tag line', + regex: /^.*🤖.*generated/im, + why: 'Remove the robot-emoji + "Generated" attribution line.', + }, + ], + closingHint: + 'Commits/PRs must use Conventional Commits (`(): `) with no AI attribution. PR bodies need a Summary section. See CLAUDE.md "Commits & PRs".', +}) diff --git a/.claude/hooks/fleet/commit-pr-reminder/package.json b/.claude/hooks/fleet/commit-pr-reminder/package.json new file mode 100644 index 0000000..a00faf1 --- /dev/null +++ b/.claude/hooks/fleet/commit-pr-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-commit-pr-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/commit-pr-reminder/test/index.test.mts b/.claude/hooks/fleet/commit-pr-reminder/test/index.test.mts new file mode 100644 index 0000000..d3cf75d --- /dev/null +++ b/.claude/hooks/fleet/commit-pr-reminder/test/index.test.mts @@ -0,0 +1,79 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function makeTranscript(assistantText: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-pr-')) + const transcriptPath = path.join(dir, 'session.jsonl') + writeFileSync( + transcriptPath, + JSON.stringify({ role: 'user', content: 'do it' }) + + '\n' + + JSON.stringify({ role: 'assistant', content: assistantText }), + ) + return transcriptPath +} + +function runHook(transcriptPath: string): { stderr: string; exitCode: number } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: transcriptPath }), + }) + return { stderr: String(result.stderr), exitCode: result.status ?? -1 } +} + +test('FLAGS "Generated with Claude"', () => { + const t = makeTranscript('Commit body:\n\nGenerated with Claude Code') + const { stderr, exitCode } = runHook(t) + assert.equal(exitCode, 0) + assert.match(stderr, /commit-pr-reminder/) + assert.match(stderr, /generated with claude/i) +}) + +test('FLAGS "Co-Authored-By: Claude"', () => { + const t = makeTranscript( + 'Trailer:\nCo-Authored-By: Claude ', + ) + const { stderr, exitCode } = runHook(t) + assert.equal(exitCode, 0) + assert.match(stderr, /co-authored-by/i) +}) + +test('FLAGS robot emoji generated tag', () => { + const t = makeTranscript('PR body:\n🤖 Generated with assistance') + const { stderr, exitCode } = runHook(t) + assert.equal(exitCode, 0) + assert.match(stderr, /robot emoji/i) +}) + +test('does NOT fire on plain Conventional Commit text', () => { + const t = makeTranscript( + 'feat(api): add new endpoint\n\nDetails about the change.', + ) + const { stderr, exitCode } = runHook(t) + assert.equal(exitCode, 0) + assert.equal(stderr, '') +}) + +test('does NOT fire on the word "generated" without "claude" nearby', () => { + const t = makeTranscript('The build artifacts are generated by tsc.') + const { stderr, exitCode } = runHook(t) + assert.equal(exitCode, 0) + assert.equal(stderr, '') +}) + +test('disabled env var short-circuits', () => { + const t = makeTranscript('Generated with Claude Code') + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: t }), + env: { ...process.env, SOCKET_COMMIT_PR_REMINDER_DISABLED: '1' }, + }) + assert.equal(result.status, 0) + assert.equal(result.stderr, '') +}) diff --git a/.claude/hooks/default-branch-guard/tsconfig.json b/.claude/hooks/fleet/commit-pr-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/default-branch-guard/tsconfig.json rename to .claude/hooks/fleet/commit-pr-reminder/tsconfig.json diff --git a/.claude/hooks/compound-lessons-reminder/README.md b/.claude/hooks/fleet/compound-lessons-reminder/README.md similarity index 100% rename from .claude/hooks/compound-lessons-reminder/README.md rename to .claude/hooks/fleet/compound-lessons-reminder/README.md diff --git a/.claude/hooks/compound-lessons-reminder/index.mts b/.claude/hooks/fleet/compound-lessons-reminder/index.mts similarity index 100% rename from .claude/hooks/compound-lessons-reminder/index.mts rename to .claude/hooks/fleet/compound-lessons-reminder/index.mts diff --git a/.claude/hooks/compound-lessons-reminder/package.json b/.claude/hooks/fleet/compound-lessons-reminder/package.json similarity index 100% rename from .claude/hooks/compound-lessons-reminder/package.json rename to .claude/hooks/fleet/compound-lessons-reminder/package.json diff --git a/.claude/hooks/compound-lessons-reminder/test/index.test.mts b/.claude/hooks/fleet/compound-lessons-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/compound-lessons-reminder/test/index.test.mts rename to .claude/hooks/fleet/compound-lessons-reminder/test/index.test.mts diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json b/.claude/hooks/fleet/compound-lessons-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json rename to .claude/hooks/fleet/compound-lessons-reminder/tsconfig.json diff --git a/.claude/hooks/concurrent-cargo-build-guard/README.md b/.claude/hooks/fleet/concurrent-cargo-build-guard/README.md similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/README.md rename to .claude/hooks/fleet/concurrent-cargo-build-guard/README.md diff --git a/.claude/hooks/concurrent-cargo-build-guard/index.mts b/.claude/hooks/fleet/concurrent-cargo-build-guard/index.mts similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/index.mts rename to .claude/hooks/fleet/concurrent-cargo-build-guard/index.mts diff --git a/.claude/hooks/concurrent-cargo-build-guard/package.json b/.claude/hooks/fleet/concurrent-cargo-build-guard/package.json similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/package.json rename to .claude/hooks/fleet/concurrent-cargo-build-guard/package.json diff --git a/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts b/.claude/hooks/fleet/concurrent-cargo-build-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/test/index.test.mts rename to .claude/hooks/fleet/concurrent-cargo-build-guard/test/index.test.mts diff --git a/.claude/hooks/dont-blame-user-reminder/tsconfig.json b/.claude/hooks/fleet/concurrent-cargo-build-guard/tsconfig.json similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/tsconfig.json rename to .claude/hooks/fleet/concurrent-cargo-build-guard/tsconfig.json diff --git a/.claude/hooks/consumer-grep-reminder/README.md b/.claude/hooks/fleet/consumer-grep-reminder/README.md similarity index 100% rename from .claude/hooks/consumer-grep-reminder/README.md rename to .claude/hooks/fleet/consumer-grep-reminder/README.md diff --git a/.claude/hooks/consumer-grep-reminder/index.mts b/.claude/hooks/fleet/consumer-grep-reminder/index.mts similarity index 100% rename from .claude/hooks/consumer-grep-reminder/index.mts rename to .claude/hooks/fleet/consumer-grep-reminder/index.mts diff --git a/.claude/hooks/consumer-grep-reminder/package.json b/.claude/hooks/fleet/consumer-grep-reminder/package.json similarity index 100% rename from .claude/hooks/consumer-grep-reminder/package.json rename to .claude/hooks/fleet/consumer-grep-reminder/package.json diff --git a/.claude/hooks/consumer-grep-reminder/test/index.test.mts b/.claude/hooks/fleet/consumer-grep-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/consumer-grep-reminder/test/index.test.mts rename to .claude/hooks/fleet/consumer-grep-reminder/test/index.test.mts diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json b/.claude/hooks/fleet/consumer-grep-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json rename to .claude/hooks/fleet/consumer-grep-reminder/tsconfig.json diff --git a/.claude/hooks/cross-repo-guard/README.md b/.claude/hooks/fleet/cross-repo-guard/README.md similarity index 100% rename from .claude/hooks/cross-repo-guard/README.md rename to .claude/hooks/fleet/cross-repo-guard/README.md diff --git a/.claude/hooks/cross-repo-guard/index.mts b/.claude/hooks/fleet/cross-repo-guard/index.mts similarity index 100% rename from .claude/hooks/cross-repo-guard/index.mts rename to .claude/hooks/fleet/cross-repo-guard/index.mts diff --git a/.claude/hooks/cross-repo-guard/package.json b/.claude/hooks/fleet/cross-repo-guard/package.json similarity index 100% rename from .claude/hooks/cross-repo-guard/package.json rename to .claude/hooks/fleet/cross-repo-guard/package.json diff --git a/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts b/.claude/hooks/fleet/cross-repo-guard/test/cross-repo-guard.test.mts similarity index 100% rename from .claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts rename to .claude/hooks/fleet/cross-repo-guard/test/cross-repo-guard.test.mts diff --git a/.claude/hooks/drift-check-reminder/tsconfig.json b/.claude/hooks/fleet/cross-repo-guard/tsconfig.json similarity index 100% rename from .claude/hooks/drift-check-reminder/tsconfig.json rename to .claude/hooks/fleet/cross-repo-guard/tsconfig.json diff --git a/.claude/hooks/default-branch-guard/README.md b/.claude/hooks/fleet/default-branch-guard/README.md similarity index 100% rename from .claude/hooks/default-branch-guard/README.md rename to .claude/hooks/fleet/default-branch-guard/README.md diff --git a/.claude/hooks/default-branch-guard/index.mts b/.claude/hooks/fleet/default-branch-guard/index.mts similarity index 100% rename from .claude/hooks/default-branch-guard/index.mts rename to .claude/hooks/fleet/default-branch-guard/index.mts diff --git a/.claude/hooks/default-branch-guard/package.json b/.claude/hooks/fleet/default-branch-guard/package.json similarity index 100% rename from .claude/hooks/default-branch-guard/package.json rename to .claude/hooks/fleet/default-branch-guard/package.json diff --git a/.claude/hooks/default-branch-guard/test/index.test.mts b/.claude/hooks/fleet/default-branch-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/default-branch-guard/test/index.test.mts rename to .claude/hooks/fleet/default-branch-guard/test/index.test.mts diff --git a/.claude/hooks/enterprise-push-property-reminder/tsconfig.json b/.claude/hooks/fleet/default-branch-guard/tsconfig.json similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/tsconfig.json rename to .claude/hooks/fleet/default-branch-guard/tsconfig.json diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/README.md b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/README.md similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/README.md rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/README.md diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/index.mts similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/index.mts rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/index.mts diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/package.json b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/package.json similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/package.json rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/package.json diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/test/index.test.mts diff --git a/.claude/hooks/error-message-quality-reminder/tsconfig.json b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/error-message-quality-reminder/tsconfig.json rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/tsconfig.json diff --git a/.claude/hooks/dont-blame-user-reminder/README.md b/.claude/hooks/fleet/dont-blame-user-reminder/README.md similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/README.md rename to .claude/hooks/fleet/dont-blame-user-reminder/README.md diff --git a/.claude/hooks/dont-blame-user-reminder/index.mts b/.claude/hooks/fleet/dont-blame-user-reminder/index.mts similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/index.mts rename to .claude/hooks/fleet/dont-blame-user-reminder/index.mts diff --git a/.claude/hooks/dont-blame-user-reminder/package.json b/.claude/hooks/fleet/dont-blame-user-reminder/package.json similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/package.json rename to .claude/hooks/fleet/dont-blame-user-reminder/package.json diff --git a/.claude/hooks/dont-blame-user-reminder/test/index.test.mts b/.claude/hooks/fleet/dont-blame-user-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/test/index.test.mts rename to .claude/hooks/fleet/dont-blame-user-reminder/test/index.test.mts diff --git a/.claude/hooks/excuse-detector/tsconfig.json b/.claude/hooks/fleet/dont-blame-user-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/excuse-detector/tsconfig.json rename to .claude/hooks/fleet/dont-blame-user-reminder/tsconfig.json diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/README.md b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/README.md similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/README.md rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/README.md diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/index.mts b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/index.mts similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/index.mts rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/index.mts diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/package.json b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/package.json similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/package.json rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/package.json diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/test/index.test.mts diff --git a/.claude/hooks/extension-build-current-guard/tsconfig.json b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/extension-build-current-guard/tsconfig.json rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/tsconfig.json diff --git a/.claude/hooks/drift-check-reminder/README.md b/.claude/hooks/fleet/drift-check-reminder/README.md similarity index 100% rename from .claude/hooks/drift-check-reminder/README.md rename to .claude/hooks/fleet/drift-check-reminder/README.md diff --git a/.claude/hooks/drift-check-reminder/index.mts b/.claude/hooks/fleet/drift-check-reminder/index.mts similarity index 100% rename from .claude/hooks/drift-check-reminder/index.mts rename to .claude/hooks/fleet/drift-check-reminder/index.mts diff --git a/.claude/hooks/drift-check-reminder/package.json b/.claude/hooks/fleet/drift-check-reminder/package.json similarity index 100% rename from .claude/hooks/drift-check-reminder/package.json rename to .claude/hooks/fleet/drift-check-reminder/package.json diff --git a/.claude/hooks/drift-check-reminder/test/index.test.mts b/.claude/hooks/fleet/drift-check-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/drift-check-reminder/test/index.test.mts rename to .claude/hooks/fleet/drift-check-reminder/test/index.test.mts diff --git a/.claude/hooks/file-size-reminder/tsconfig.json b/.claude/hooks/fleet/drift-check-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/file-size-reminder/tsconfig.json rename to .claude/hooks/fleet/drift-check-reminder/tsconfig.json diff --git a/.claude/hooks/enterprise-push-property-reminder/README.md b/.claude/hooks/fleet/enterprise-push-property-reminder/README.md similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/README.md rename to .claude/hooks/fleet/enterprise-push-property-reminder/README.md diff --git a/.claude/hooks/enterprise-push-property-reminder/index.mts b/.claude/hooks/fleet/enterprise-push-property-reminder/index.mts similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/index.mts rename to .claude/hooks/fleet/enterprise-push-property-reminder/index.mts diff --git a/.claude/hooks/enterprise-push-property-reminder/package.json b/.claude/hooks/fleet/enterprise-push-property-reminder/package.json similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/package.json rename to .claude/hooks/fleet/enterprise-push-property-reminder/package.json diff --git a/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts b/.claude/hooks/fleet/enterprise-push-property-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/test/index.test.mts rename to .claude/hooks/fleet/enterprise-push-property-reminder/test/index.test.mts diff --git a/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json b/.claude/hooks/fleet/enterprise-push-property-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/tsconfig.json rename to .claude/hooks/fleet/enterprise-push-property-reminder/tsconfig.json diff --git a/.claude/hooks/error-message-quality-reminder/README.md b/.claude/hooks/fleet/error-message-quality-reminder/README.md similarity index 100% rename from .claude/hooks/error-message-quality-reminder/README.md rename to .claude/hooks/fleet/error-message-quality-reminder/README.md diff --git a/.claude/hooks/error-message-quality-reminder/index.mts b/.claude/hooks/fleet/error-message-quality-reminder/index.mts similarity index 100% rename from .claude/hooks/error-message-quality-reminder/index.mts rename to .claude/hooks/fleet/error-message-quality-reminder/index.mts diff --git a/.claude/hooks/error-message-quality-reminder/package.json b/.claude/hooks/fleet/error-message-quality-reminder/package.json similarity index 100% rename from .claude/hooks/error-message-quality-reminder/package.json rename to .claude/hooks/fleet/error-message-quality-reminder/package.json diff --git a/.claude/hooks/error-message-quality-reminder/test/index.test.mts b/.claude/hooks/fleet/error-message-quality-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/error-message-quality-reminder/test/index.test.mts rename to .claude/hooks/fleet/error-message-quality-reminder/test/index.test.mts diff --git a/.claude/hooks/gh-token-hygiene-guard/tsconfig.json b/.claude/hooks/fleet/error-message-quality-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/tsconfig.json rename to .claude/hooks/fleet/error-message-quality-reminder/tsconfig.json diff --git a/.claude/hooks/excuse-detector/README.md b/.claude/hooks/fleet/excuse-detector/README.md similarity index 100% rename from .claude/hooks/excuse-detector/README.md rename to .claude/hooks/fleet/excuse-detector/README.md diff --git a/.claude/hooks/excuse-detector/index.mts b/.claude/hooks/fleet/excuse-detector/index.mts similarity index 100% rename from .claude/hooks/excuse-detector/index.mts rename to .claude/hooks/fleet/excuse-detector/index.mts diff --git a/.claude/hooks/excuse-detector/package.json b/.claude/hooks/fleet/excuse-detector/package.json similarity index 100% rename from .claude/hooks/excuse-detector/package.json rename to .claude/hooks/fleet/excuse-detector/package.json diff --git a/.claude/hooks/excuse-detector/test/index.test.mts b/.claude/hooks/fleet/excuse-detector/test/index.test.mts similarity index 100% rename from .claude/hooks/excuse-detector/test/index.test.mts rename to .claude/hooks/fleet/excuse-detector/test/index.test.mts diff --git a/.claude/hooks/gitmodules-comment-guard/tsconfig.json b/.claude/hooks/fleet/excuse-detector/tsconfig.json similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/tsconfig.json rename to .claude/hooks/fleet/excuse-detector/tsconfig.json diff --git a/.claude/hooks/extension-build-current-guard/README.md b/.claude/hooks/fleet/extension-build-current-guard/README.md similarity index 100% rename from .claude/hooks/extension-build-current-guard/README.md rename to .claude/hooks/fleet/extension-build-current-guard/README.md diff --git a/.claude/hooks/extension-build-current-guard/index.mts b/.claude/hooks/fleet/extension-build-current-guard/index.mts similarity index 100% rename from .claude/hooks/extension-build-current-guard/index.mts rename to .claude/hooks/fleet/extension-build-current-guard/index.mts diff --git a/.claude/hooks/extension-build-current-guard/package.json b/.claude/hooks/fleet/extension-build-current-guard/package.json similarity index 100% rename from .claude/hooks/extension-build-current-guard/package.json rename to .claude/hooks/fleet/extension-build-current-guard/package.json diff --git a/.claude/hooks/extension-build-current-guard/test/index.test.mts b/.claude/hooks/fleet/extension-build-current-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/extension-build-current-guard/test/index.test.mts rename to .claude/hooks/fleet/extension-build-current-guard/test/index.test.mts diff --git a/.claude/hooks/identifying-users-reminder/tsconfig.json b/.claude/hooks/fleet/extension-build-current-guard/tsconfig.json similarity index 100% rename from .claude/hooks/identifying-users-reminder/tsconfig.json rename to .claude/hooks/fleet/extension-build-current-guard/tsconfig.json diff --git a/.claude/hooks/file-size-reminder/README.md b/.claude/hooks/fleet/file-size-reminder/README.md similarity index 100% rename from .claude/hooks/file-size-reminder/README.md rename to .claude/hooks/fleet/file-size-reminder/README.md diff --git a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md new file mode 100644 index 0000000..28a7b24 --- /dev/null +++ b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md @@ -0,0 +1,52 @@ +# file-size-reminder + +Stop hook that warns when an assistant turn's Write / Edit / NotebookEdit tool calls push a file past the 500-line soft cap or 1000-line hard cap. + +## Why + +CLAUDE.md "File size" rule: + +> Soft cap **500 lines**, hard cap **1000 lines** per source file. Past those, split along natural seams — group by domain, not line count; name files for what's in them; co-locate helpers with consumers. Exceptions: a single function that legitimately needs the space (note it inline), or a generated artifact. + +The intent is to catch the slide where a file gradually accumulates 600, then 700, then 1200 lines because nobody noticed each individual edit pushing it over. The hook surfaces the count alongside the edit so the next turn can act on it. + +## What it catches + +After each assistant turn, the hook walks the most recent assistant's tool-use events, finds calls to: + +- `Write` (creating a new file or full rewrite) +- `Edit` (modifying a file in place) +- `NotebookEdit` (Jupyter cell modifications) + +For each target `file_path`, it reads the current on-disk state (post-edit, since the hook fires after the tool ran), counts lines, and warns if the count is past either cap. + +| Cap | Threshold | Action | +| ---- | -------------- | ---------------------------------- | +| Soft | 501-1000 lines | Warning — start planning the split | +| Hard | 1001+ lines | Stronger warning — split now | + +## Exempt paths + +Generated / vendored / build-output paths are skipped to avoid noise: + +- `node_modules/`, `.cache/`, `coverage/`, `coverage-isolated/` +- `dist/`, `build/`, `external/`, `vendor/`, `upstream/` +- `.git/`, `test/fixtures/`, `test/packages/` +- `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `Cargo.lock` +- `*.d.ts`, `*.d.ts.map`, `*.tsbuildinfo`, `*.map` + +The skip list errs on the side of suppressing false positives — genuine in-scope files past the cap will still surface. + +## Why it doesn't block + +Stop hooks fire after the tool has run. Blocking would just truncate the warning. The size violation is in the diff already; the warning prompts the next turn to address it. + +## Configuration + +`SOCKET_FILE_SIZE_REMINDER_DISABLED=1` — turn off entirely. Useful for sessions intentionally working on a generated-file context the skip list doesn't recognize. + +## Test + +```sh +pnpm test +``` diff --git a/.claude/hooks/file-size-reminder/index.mts b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/index.mts similarity index 100% rename from .claude/hooks/file-size-reminder/index.mts rename to .claude/hooks/fleet/file-size-reminder/file-size-reminder/index.mts diff --git a/.claude/hooks/file-size-reminder/package.json b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/package.json similarity index 100% rename from .claude/hooks/file-size-reminder/package.json rename to .claude/hooks/fleet/file-size-reminder/file-size-reminder/package.json diff --git a/.claude/hooks/file-size-reminder/test/index.test.mts b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/file-size-reminder/test/index.test.mts rename to .claude/hooks/fleet/file-size-reminder/file-size-reminder/test/index.test.mts diff --git a/.claude/hooks/immutable-release-pattern-guard/tsconfig.json b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/tsconfig.json rename to .claude/hooks/fleet/file-size-reminder/file-size-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/file-size-reminder/index.mts b/.claude/hooks/fleet/file-size-reminder/index.mts new file mode 100644 index 0000000..583140a --- /dev/null +++ b/.claude/hooks/fleet/file-size-reminder/index.mts @@ -0,0 +1,218 @@ +#!/usr/bin/env node +// Claude Code Stop hook — file-size-reminder. +// +// Surfaces file-size violations after Write / Edit / NotebookEdit +// tool calls. CLAUDE.md "File size": +// +// Soft cap 500 lines, hard cap 1000 lines per source file. Past +// those, split along natural seams — group by domain, not line +// count; name files for what's in them; co-locate helpers with +// consumers. +// +// Exceptions (also from CLAUDE.md / docs/claude.md/file-size.md): +// +// - A single function that legitimately needs the space (the user +// notes this inline at the top of the function). +// - Generated artifacts (lockfiles, schema dumps, vendored data). +// +// The hook walks the most-recent assistant turn's tool-use events, +// finds Write/Edit/NotebookEdit calls, reads each target file from +// disk (post-edit state, since the hook fires after the tool ran), +// counts lines, and flags any file past either cap. +// +// Skips paths matching the generated-artifact heuristic — anything +// under common vendor / generated / dist / build / coverage paths. +// The skip list errs on the side of suppressing false positives; +// genuine in-scope files past the cap will still surface. +// +// Disable via SOCKET_FILE_SIZE_REMINDER_DISABLED. + +import { existsSync, readFileSync, statSync } from 'node:fs' +import process from 'node:process' + +import { readLastAssistantToolUses, readStdin } from '../_shared/transcript.mts' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +const SOFT_CAP_LINES = 500 +const HARD_CAP_LINES = 1000 + +// Tool names that write or modify file content. Read / Glob / Grep +// don't change a file, so they don't trigger this hook. +const FILE_WRITING_TOOLS = new Set(['Edit', 'NotebookEdit', 'Write']) + +// Path patterns we skip — generated, vendored, or otherwise +// exempt from the cap. Tested as substring matches against the +// absolute file_path; a hit anywhere in the path skips the file. +// +// Each entry is intentionally generous: false-positives in the +// skip list are recoverable (the user can disable the hook or +// reduce the list), but false-positives in the *flagging* list +// would noise up every turn that touches a vendored file. +const SKIP_PATH_SUBSTRINGS: readonly string[] = [ + '/node_modules/', + '/.cache/', + '/coverage/', + '/coverage-isolated/', + '/dist/', + '/build/', + '/external/', + '/vendor/', + '/upstream/', + '/.git/', + '/test/fixtures/', + '/test/packages/', + // Lockfiles + manifests + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', + 'Cargo.lock', + // Type declarations (often generated) + '.d.ts', + '.d.ts.map', + '.tsbuildinfo', + // Map files + '.map', +] + +export function collectHits( + events: ReadonlyArray<{ name: string; input: Record }>, +): SizeHit[] { + const seen = new Set() + const hits: SizeHit[] = [] + for (let i = 0, { length } = events; i < length; i += 1) { + const event = events[i]! + if (!FILE_WRITING_TOOLS.has(event.name)) { + continue + } + const pathField = event.input['file_path'] ?? event.input['notebook_path'] + if (typeof pathField !== 'string') { + continue + } + if (seen.has(pathField)) { + continue + } + seen.add(pathField) + if (isExempt(pathField)) { + continue + } + const lines = countLines(pathField) + if (lines === undefined) { + continue + } + if (lines > HARD_CAP_LINES) { + hits.push({ path: pathField, lines, cap: 'hard' }) + } else if (lines > SOFT_CAP_LINES) { + hits.push({ path: pathField, lines, cap: 'soft' }) + } + } + return hits +} + +export function countLines(absPath: string): number | undefined { + try { + if (!existsSync(absPath)) { + return undefined + } + const stat = statSync(absPath) + if (!stat.isFile()) { + return undefined + } + // Use byte-count fast-path for very large files: if the file is + // over ~256 KB it's almost certainly past the cap unless every + // line is one byte (unrealistic). Otherwise read + count newlines. + const content = readFileSync(absPath, 'utf8') + // Count newlines + 1 unless the file is empty. This matches the + // canonical `wc -l` convention (which counts newlines, off-by-one + // for files without trailing newline) closely enough — exact + // boundary cases at the cap edge don't matter, the cap is a + // judgement guideline not a hard machine check. + if (content.length === 0) { + return 0 + } + let count = 0 + for (let i = 0, { length } = content; i < length; i += 1) { + if (content.charCodeAt(i) === 10) { + count += 1 + } + } + // Add 1 for the final line if it doesn't end in a newline. + if (content.charCodeAt(content.length - 1) !== 10) { + count += 1 + } + return count + } catch { + return undefined + } +} + +interface SizeHit { + readonly path: string + readonly lines: number + readonly cap: 'soft' | 'hard' +} + +export function isExempt(absPath: string): boolean { + for (let i = 0, { length } = SKIP_PATH_SUBSTRINGS; i < length; i += 1) { + if (absPath.includes(SKIP_PATH_SUBSTRINGS[i]!)) { + return true + } + } + return false +} + +async function main(): Promise { + const payloadRaw = await readStdin() + if (process.env['SOCKET_FILE_SIZE_REMINDER_DISABLED']) { + process.exit(0) + } + let payload: StopPayload + try { + payload = JSON.parse(payloadRaw) as StopPayload + } catch { + process.exit(0) + } + + const events = readLastAssistantToolUses(payload.transcript_path) + if (events.length === 0) { + process.exit(0) + } + const hits = collectHits(events) + if (hits.length === 0) { + process.exit(0) + } + + const lines = ['[file-size-reminder] File-size cap exceeded:', ''] + for (let i = 0, { length } = hits; i < length; i += 1) { + const hit = hits[i]! + const capLabel = + hit.cap === 'hard' + ? `HARD CAP (${HARD_CAP_LINES} lines)` + : `soft cap (${SOFT_CAP_LINES} lines)` + lines.push(` • ${hit.path}`) + lines.push(` ${hit.lines} lines — past ${capLabel}`) + } + lines.push('') + lines.push( + ' CLAUDE.md "File size": split along natural seams — group by domain,', + ) + lines.push( + " name files for what's in them, co-locate helpers with consumers.", + ) + lines.push( + ' Exceptions (single legitimate large function / generated artifact)', + ) + lines.push( + ' should be stated inline. Full playbook: docs/claude.md/file-size.md.', + ) + lines.push('') + process.stderr.write(lines.join('\n') + '\n') + process.exit(0) +} + +main().catch(() => { + // Fail-open on any hook bug. + process.exit(0) +}) diff --git a/.claude/hooks/fleet/file-size-reminder/package.json b/.claude/hooks/fleet/file-size-reminder/package.json new file mode 100644 index 0000000..b76df77 --- /dev/null +++ b/.claude/hooks/fleet/file-size-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-file-size-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/file-size-reminder/test/index.test.mts b/.claude/hooks/fleet/file-size-reminder/test/index.test.mts new file mode 100644 index 0000000..2e93659 --- /dev/null +++ b/.claude/hooks/fleet/file-size-reminder/test/index.test.mts @@ -0,0 +1,196 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +interface ToolUseEvent { + readonly name: string + readonly input: Record +} + +function makeTranscript( + dir: string, + toolUses: readonly ToolUseEvent[], +): string { + const transcriptPath = path.join(dir, 'session.jsonl') + const lines = [ + JSON.stringify({ role: 'user', content: 'hi' }), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'doing the thing' }, + ...toolUses.map(tu => ({ + type: 'tool_use', + name: tu.name, + input: tu.input, + })), + ], + }, + }), + ].join('\n') + writeFileSync(transcriptPath, lines) + return transcriptPath +} + +function writeLines(filePath: string, n: number): void { + const content = Array.from({ length: n }, (_, i) => `line ${i + 1}`).join( + '\n', + ) + writeFileSync(filePath, content) +} + +function runHook(transcriptPath: string): { stderr: string; exitCode: number } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: transcriptPath }), + }) + return { stderr: String(result.stderr), exitCode: result.status ?? -1 } +} + +test('flags soft-cap violation (501-1000 lines)', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const target = path.join(dir, 'big.mts') + writeLines(target, 750) + const transcript = makeTranscript(dir, [ + { name: 'Edit', input: { file_path: target, new_string: 'x' } }, + ]) + const { stderr, exitCode } = runHook(transcript) + assert.equal(exitCode, 0) + assert.match(stderr, /file-size-reminder/) + assert.match(stderr, /soft cap/) + assert.match(stderr, /750 lines/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('flags hard-cap violation (>1000 lines)', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const target = path.join(dir, 'huge.mts') + writeLines(target, 1500) + const transcript = makeTranscript(dir, [ + { name: 'Write', input: { file_path: target, content: '...' } }, + ]) + const { stderr } = runHook(transcript) + assert.match(stderr, /HARD CAP/) + assert.match(stderr, /1500 lines/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('does not flag files at or under soft cap', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const target = path.join(dir, 'small.mts') + writeLines(target, 500) + const transcript = makeTranscript(dir, [ + { name: 'Edit', input: { file_path: target, new_string: 'x' } }, + ]) + const { stderr, exitCode } = runHook(transcript) + assert.equal(exitCode, 0) + assert.equal(stderr, '') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('skips node_modules paths', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const realDir = path.join(dir, 'node_modules', 'pkg') + mkdirSync(realDir, { recursive: true }) + const realTarget = path.join(realDir, 'big.mts') + writeLines(realTarget, 2000) + const transcript = makeTranscript(dir, [ + { name: 'Edit', input: { file_path: realTarget, new_string: 'x' } }, + ]) + const { stderr, exitCode } = runHook(transcript) + assert.equal(exitCode, 0) + assert.equal(stderr, '') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('skips Read / Glob tool uses', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const target = path.join(dir, 'big.mts') + writeLines(target, 2000) + const transcript = makeTranscript(dir, [ + { name: 'Read', input: { file_path: target } }, + { name: 'Glob', input: { pattern: '**/*.mts' } }, + ]) + const { stderr, exitCode } = runHook(transcript) + assert.equal(exitCode, 0) + // Read/Glob don't write, so no flag even though file is over cap + assert.equal(stderr, '') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('handles missing file gracefully (no crash)', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const transcript = makeTranscript(dir, [ + { + name: 'Edit', + input: { file_path: '/tmp/does-not-exist-xyz.mts', new_string: 'x' }, + }, + ]) + const { stderr, exitCode } = runHook(transcript) + assert.equal(exitCode, 0) + assert.equal(stderr, '') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('deduplicates multiple edits to the same file', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const target = path.join(dir, 'multi.mts') + writeLines(target, 600) + const transcript = makeTranscript(dir, [ + { name: 'Edit', input: { file_path: target, new_string: 'a' } }, + { name: 'Edit', input: { file_path: target, new_string: 'b' } }, + { name: 'Edit', input: { file_path: target, new_string: 'c' } }, + ]) + const { stderr } = runHook(transcript) + // Only one warning for the file, not three. + const matches = stderr.match(/600 lines/g) ?? [] + assert.equal(matches.length, 1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('disabled env var short-circuits', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) + try { + const target = path.join(dir, 'big.mts') + writeLines(target, 1500) + const transcript = makeTranscript(dir, [ + { name: 'Write', input: { file_path: target, content: '...' } }, + ]) + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: transcript }), + env: { ...process.env, SOCKET_FILE_SIZE_REMINDER_DISABLED: '1' }, + }) + assert.equal(result.status, 0) + assert.equal(result.stderr, '') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/.claude/hooks/inline-script-defer-guard/tsconfig.json b/.claude/hooks/fleet/file-size-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/inline-script-defer-guard/tsconfig.json rename to .claude/hooks/fleet/file-size-reminder/tsconfig.json diff --git a/.claude/hooks/follow-direct-imperative-reminder/README.md b/.claude/hooks/fleet/follow-direct-imperative-reminder/README.md similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/README.md rename to .claude/hooks/fleet/follow-direct-imperative-reminder/README.md diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md new file mode 100644 index 0000000..e2da1e3 --- /dev/null +++ b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md @@ -0,0 +1,44 @@ +# follow-direct-imperative-reminder + +Stop hook that flags assistant turns which respond to a bare imperative user command with hedging or re-litigation before the tool call. + +## Why + +CLAUDE.md "Judgment & self-evaluation" rule: + +> Direct imperatives → execute, don't litigate. When the user issues a bare command ("use nvm 26.2.0", "cancel the build", "do it", "kill it"), the response is the tool call, not a paragraph weighing trade-offs. + +Past incident (the trigger for this hook): user typed "use nvm use 26.2.0". Assistant responded with a paragraph explaining why it wouldn't help the in-flight build, instead of switching Node. Same turn the user typed "cancel the build right now". Assistant kept narrating build phases instead of killing the process. User asked for a hook to stop the behavior. + +The failure mode is analysis-before-action when the command was unambiguous. The user already weighed the trade-off. Re-litigating wastes a turn and signals the directive was optional. It wasn't. + +## Detection + +Two-signal rule, both must hit: + +1. **Previous user turn is a bare imperative.** Single short sentence (≤ 8 words), starts with an action verb (`cancel`, `kill`, `use`, `run`, `commit`, `push`, `do`, `continue`, etc.) or common imperative phrase (`let's`, `just`, `please`). No question mark (questions invite analysis). +2. **Assistant turn contains hedge / re-litigation markers**: + - `doesn't help` / `won't help` + - `before I do that` / `let me explain` / `let me first` + - `to be clear` / `worth noting` / `that said` / `actually` + - `the in-flight X` (re-litigating in-flight state) + - `caveat:` / `note:` / `important:` + +Both signals fire: stderr reminder lands in the next turn's context. + +## What it does NOT catch + +- Questions from the user ("should I use Node 26?"). Analysis is invited. +- Long contextual user messages. Those carry their own framing. +- Assistant turns that hedge after the tool call. Post-action qualification is fine. + +## Disable + +```bash +SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1 +``` + +## Related + +- `dont-stop-mid-queue-reminder`: Stop hook for premature "what's next?" after authorized continuous-work directives. +- `ask-suppression-reminder`: Stop hook for AskUserQuestion when recent transcript already authorized the obvious default. diff --git a/.claude/hooks/follow-direct-imperative-reminder/index.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/index.mts similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/index.mts rename to .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/index.mts diff --git a/.claude/hooks/follow-direct-imperative-reminder/package.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/package.json similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/package.json rename to .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/package.json diff --git a/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/test/index.test.mts rename to .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/test/index.test.mts diff --git a/.claude/hooks/judgment-reminder/tsconfig.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/judgment-reminder/tsconfig.json rename to .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/index.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/index.mts new file mode 100644 index 0000000..f708b8b --- /dev/null +++ b/.claude/hooks/fleet/follow-direct-imperative-reminder/index.mts @@ -0,0 +1,313 @@ +#!/usr/bin/env node +// Claude Code Stop hook — follow-direct-imperative-reminder. +// +// Fires at turn-end. If the immediately-preceding user turn was a bare +// imperative command (short, action-verb-led) AND the just-emitted +// assistant text contains hedge / re-litigation patterns BEFORE any +// tool call, emit a stderr reminder pointing at the failure mode. +// +// The fleet rule (CLAUDE.md "Judgment & self-evaluation"): +// +// Direct imperatives → execute, don't litigate. When the user +// issues a bare command ("use nvm 26.2.0", "cancel the build", +// "do it", "kill it"), the response is the tool call, not a +// paragraph weighing trade-offs. +// +// Past incident: user typed "use nvm use 26.2.0"; assistant responded +// with a paragraph explaining why it wouldn't help the in-flight +// build instead of running the command. Same turn the user typed +// "cancel the build right now" — assistant continued narrating +// build phases instead of killing the process. The user explicitly +// asked for a hook to stop this. +// +// Detection: +// - Last user turn is a single short imperative (≤ 8 words, +// starts with an action verb or a known imperative form). +// - Last assistant turn (just emitted) contains hedge openers +// OR a leading analysis paragraph that precedes any tool call. +// +// Why a reminder, not a block: Stop hooks fire AFTER the turn ended. +// The reminder lands in the next turn's context so the agent sees +// the pattern it just exhibited. +// +// Exit codes: +// 0 — always. Informational; never blocks. +// +// Disabled via `SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1`. + +import { readFileSync } from 'node:fs' +import process from 'node:process' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +interface TranscriptEntry { + readonly type?: string | undefined + readonly role?: string | undefined + readonly message?: + | { + readonly content?: unknown | undefined + readonly role?: string | undefined + } + | undefined + readonly content?: unknown | undefined +} + +export async function drainStdinJson(): Promise { + return await new Promise(resolve => { + let raw = '' + process.stdin.on('data', d => { + raw += d.toString('utf8') + }) + process.stdin.on('end', () => { + try { + resolve(raw ? (JSON.parse(raw) as StopPayload) : {}) + } catch { + resolve({}) + } + }) + process.stdin.on('error', () => resolve({})) + setTimeout(() => resolve({}), 200) + }) +} + +// Read the last N entries from a JSONL transcript file. The harness +// uses one JSON object per line. +export function readTranscriptTail( + path: string, + count: number, +): TranscriptEntry[] { + let text: string + try { + text = readFileSync(path, 'utf8') + } catch { + return [] + } + const lines = text.split('\n').filter(Boolean) + const tail = lines.slice(-count) + const out: TranscriptEntry[] = [] + for (const line of tail) { + try { + out.push(JSON.parse(line) as TranscriptEntry) + } catch { + // ignore malformed + } + } + return out +} + +// Flatten content (string | content-block-array) into one string. +export function flattenContent(content: unknown): string { + if (typeof content === 'string') { + return content + } + if (Array.isArray(content)) { + const parts: string[] = [] + for (const block of content) { + if (block && typeof block === 'object') { + const b = block as { + type?: string | undefined + text?: string | undefined + } + if (b.type === 'text' && typeof b.text === 'string') { + parts.push(b.text) + } + } + } + return parts.join('\n') + } + return '' +} + +// Role detection across the two shapes the transcript uses. +export function entryRole(e: TranscriptEntry): string | undefined { + return e.role ?? e.message?.role ?? e.type +} + +export function entryText(e: TranscriptEntry): string { + return flattenContent(e.message?.content ?? e.content ?? '') +} + +// Imperative-command opening verbs/forms. Kept conservative — +// over-matching would trigger the reminder on normal conversation. +const IMPERATIVE_OPENERS = [ + // Single-verb commands. + 'cancel', + 'kill', + 'stop', + 'abort', + 'do', + 'use', + 'run', + 'commit', + 'push', + 'fix', + 'try', + 'continue', + 'restart', + 'rerun', + 'redo', + 'execute', + 'go', + 'land', + 'merge', + 'rebase', + 'reset', + 'add', + 'remove', + 'delete', + 'install', + 'switch', + 'check', + 'show', + 'list', + 'open', + 'close', + 'undo', + 'revert', + 'apply', + 'build', + 'test', + 'deploy', + 'finish', + 'follow', + 'now', + // Common imperative phrases. + "let's", + 'just', + 'please', +] + +// Returns true when the text looks like a bare imperative directive +// (short, action-verb-led, no question mark, no long context). +export function looksLikeImperative(text: string): boolean { + const trimmed = text.trim().toLowerCase() + if (!trimmed) { + return false + } + // Strip leading punctuation. + const body = trimmed.replace(/^[!,.\s]+/, '') + // Skip questions entirely — questions invite analysis. + if (body.includes('?')) { + return false + } + // Bounded length: long contextual messages are not bare imperatives. + const wordCount = body.split(/\s+/).filter(Boolean).length + if (wordCount > 8) { + return false + } + // Pull the first word. + const firstWord = body.split(/\s+/)[0] ?? '' + return IMPERATIVE_OPENERS.includes(firstWord) +} + +// Hedge / re-litigation markers in the assistant's text. The goal is +// to catch paragraphs that explain WHY the command might not help +// before the tool call lands. +const HEDGE_MARKERS = [ + /\bdoesn't help\b/i, + /\bwon't help\b/i, + /\bbefore (?:i|we) (?:do that|run|kick|switch|cancel)\b/i, + /\blet me (?:explain|first|note)\b/i, + /\b(?:to be clear|just so we'?re clear)\b/i, + /\bworth (?:checking|confirming|noting)\b/i, + /\bone thing to (?:note|flag)\b/i, + /\bthat said\b/i, + /\bactually,?\s+/i, + /\b(?:however|but),?\s+(?:that|the|this)\b/i, + // "the in-flight X is past Y" — re-litigation of in-flight state. + /\bthe in-?flight\b/i, + // Heavy throat-clearing. + /\b(?:caveat|note|important):/i, +] + +export function hasHedge(text: string): boolean { + for (let i = 0, { length } = HEDGE_MARKERS; i < length; i += 1) { + const re = HEDGE_MARKERS[i]! + if (re.test(text)) { + return true + } + } + return false +} + +async function main(): Promise { + if (process.env['SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED']) { + return + } + const payload = await drainStdinJson() + const transcriptPath = payload.transcript_path + if (!transcriptPath) { + return + } + // Pull the last ~6 entries — usually covers the last user + last + // assistant turn plus any tool result entries between them. + const tail = readTranscriptTail(transcriptPath, 8) + if (tail.length === 0) { + return + } + + // Find the last assistant entry (what we just emitted) and the + // last user entry BEFORE it. + let lastAssistantIdx = -1 + for (let i = tail.length - 1; i >= 0; i -= 1) { + if (entryRole(tail[i]!) === 'assistant') { + lastAssistantIdx = i + break + } + } + if (lastAssistantIdx === -1) { + return + } + let lastUserIdx = -1 + for (let i = lastAssistantIdx - 1; i >= 0; i -= 1) { + if (entryRole(tail[i]!) === 'user') { + lastUserIdx = i + break + } + } + if (lastUserIdx === -1) { + return + } + + const userText = entryText(tail[lastUserIdx]!) + const assistantText = entryText(tail[lastAssistantIdx]!) + if (!userText || !assistantText) { + return + } + if (!looksLikeImperative(userText)) { + return + } + if (!hasHedge(assistantText)) { + return + } + + const userPreview = userText.trim().slice(0, 60) + process.stderr.write( + [ + '[follow-direct-imperative-reminder] You hedged before executing a direct imperative.', + '', + ` User said: "${userPreview}"`, + '', + ' The response to a bare command should be the tool call,', + ' not a paragraph weighing trade-offs. Hedge openers ("That', + ' won\'t help…", "Let me explain…", "Before I do that…") +', + ' analysis-before-action when the command was unambiguous', + ' are the failure mode the rule targets.', + '', + ' Fix: state the intent in one short sentence at most, then', + ' run the command. If you genuinely think the directive is', + " wrong, run it AFTER raising the concern — don't refuse to act.", + '', + " CLAUDE.md → 'Judgment & self-evaluation' → Direct imperatives.", + '', + ].join('\n'), + ) +} + +main().catch(e => { + process.stderr.write( + `[follow-direct-imperative-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) +}) diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/package.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/package.json new file mode 100644 index 0000000..fe86e4b --- /dev/null +++ b/.claude/hooks/fleet/follow-direct-imperative-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-follow-direct-imperative-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/test/index.test.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/test/index.test.mts new file mode 100644 index 0000000..fe0f1cd --- /dev/null +++ b/.claude/hooks/fleet/follow-direct-imperative-reminder/test/index.test.mts @@ -0,0 +1,111 @@ +// node --test specs for follow-direct-imperative-reminder. + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { flattenContent, hasHedge, looksLikeImperative } from '../index.mts' + +test('looksLikeImperative: "use nvm 26.2.0"', () => { + assert.strictEqual(looksLikeImperative('use nvm 26.2.0'), true) +}) + +test('looksLikeImperative: "cancel the build right now"', () => { + assert.strictEqual(looksLikeImperative('cancel the build right now'), true) +}) + +test('looksLikeImperative: "kill it"', () => { + assert.strictEqual(looksLikeImperative('kill it'), true) +}) + +test('looksLikeImperative: "do what I said"', () => { + assert.strictEqual(looksLikeImperative('do what I said'), true) +}) + +test('looksLikeImperative: "continue"', () => { + assert.strictEqual(looksLikeImperative('continue'), true) +}) + +test('looksLikeImperative: rejects questions', () => { + assert.strictEqual(looksLikeImperative('should I use 26?'), false) +}) + +test('looksLikeImperative: rejects long context', () => { + assert.strictEqual( + looksLikeImperative( + 'use nvm to switch to Node 26.2.0 so the build runs with the right engines', + ), + false, + ) +}) + +test('looksLikeImperative: rejects non-verb opener', () => { + assert.strictEqual(looksLikeImperative('hey there friend'), false) + assert.strictEqual(looksLikeImperative('thanks for that'), false) +}) + +test('looksLikeImperative: empty', () => { + assert.strictEqual(looksLikeImperative(''), false) + assert.strictEqual(looksLikeImperative(' '), false) +}) + +test('hasHedge: "doesn\'t help"', () => { + assert.strictEqual( + hasHedge( + "Switching the shell's Node to 26.2.0 doesn't help the build that's already running", + ), + true, + ) +}) + +test('hasHedge: "Before I do that"', () => { + assert.strictEqual( + hasHedge('Before I do that, the in-flight build is at 37%.'), + true, + ) +}) + +test('hasHedge: "Let me explain"', () => { + assert.strictEqual(hasHedge('Let me explain why this fails.'), true) +}) + +test('hasHedge: "actually,"', () => { + assert.strictEqual(hasHedge('actually, the dependency graph shows…'), true) +}) + +test('hasHedge: clean status update', () => { + assert.strictEqual(hasHedge('Switched. Now on Node 26.2.0.'), false) +}) + +test('hasHedge: tool result narration', () => { + assert.strictEqual(hasHedge('Build cancelled. No processes remain.'), false) +}) + +test('flattenContent: string', () => { + assert.strictEqual(flattenContent('hi'), 'hi') +}) + +test('flattenContent: text blocks', () => { + assert.strictEqual( + flattenContent([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ]), + 'one\ntwo', + ) +}) + +test('flattenContent: ignores non-text blocks', () => { + assert.strictEqual( + flattenContent([ + { type: 'tool_use', name: 'Bash' }, + { type: 'text', text: 'survives' }, + ]), + 'survives', + ) +}) + +test('flattenContent: empty/garbage', () => { + assert.strictEqual(flattenContent(undefined), '') + assert.strictEqual(flattenContent(42), '') + assert.strictEqual(flattenContent(undefined), '') +}) diff --git a/.claude/hooks/lock-step-ref-guard/tsconfig.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/lock-step-ref-guard/tsconfig.json rename to .claude/hooks/fleet/follow-direct-imperative-reminder/tsconfig.json diff --git a/.claude/hooks/gh-token-hygiene-guard/README.md b/.claude/hooks/fleet/gh-token-hygiene-guard/README.md similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/README.md rename to .claude/hooks/fleet/gh-token-hygiene-guard/README.md diff --git a/.claude/hooks/gh-token-hygiene-guard/index.mts b/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/index.mts rename to .claude/hooks/fleet/gh-token-hygiene-guard/index.mts diff --git a/.claude/hooks/gh-token-hygiene-guard/package.json b/.claude/hooks/fleet/gh-token-hygiene-guard/package.json similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/package.json rename to .claude/hooks/fleet/gh-token-hygiene-guard/package.json diff --git a/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts b/.claude/hooks/fleet/gh-token-hygiene-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/test/index.test.mts rename to .claude/hooks/fleet/gh-token-hygiene-guard/test/index.test.mts diff --git a/.claude/hooks/logger-guard/tsconfig.json b/.claude/hooks/fleet/gh-token-hygiene-guard/tsconfig.json similarity index 100% rename from .claude/hooks/logger-guard/tsconfig.json rename to .claude/hooks/fleet/gh-token-hygiene-guard/tsconfig.json diff --git a/.claude/hooks/gitmodules-comment-guard/README.md b/.claude/hooks/fleet/gitmodules-comment-guard/README.md similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/README.md rename to .claude/hooks/fleet/gitmodules-comment-guard/README.md diff --git a/.claude/hooks/gitmodules-comment-guard/index.mts b/.claude/hooks/fleet/gitmodules-comment-guard/index.mts similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/index.mts rename to .claude/hooks/fleet/gitmodules-comment-guard/index.mts diff --git a/.claude/hooks/gitmodules-comment-guard/package.json b/.claude/hooks/fleet/gitmodules-comment-guard/package.json similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/package.json rename to .claude/hooks/fleet/gitmodules-comment-guard/package.json diff --git a/.claude/hooks/gitmodules-comment-guard/test/index.test.mts b/.claude/hooks/fleet/gitmodules-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/gitmodules-comment-guard/test/index.test.mts diff --git a/.claude/hooks/markdown-filename-guard/tsconfig.json b/.claude/hooks/fleet/gitmodules-comment-guard/tsconfig.json similarity index 100% rename from .claude/hooks/markdown-filename-guard/tsconfig.json rename to .claude/hooks/fleet/gitmodules-comment-guard/tsconfig.json diff --git a/.claude/hooks/identifying-users-reminder/README.md b/.claude/hooks/fleet/identifying-users-reminder/README.md similarity index 100% rename from .claude/hooks/identifying-users-reminder/README.md rename to .claude/hooks/fleet/identifying-users-reminder/README.md diff --git a/.claude/hooks/identifying-users-reminder/index.mts b/.claude/hooks/fleet/identifying-users-reminder/index.mts similarity index 100% rename from .claude/hooks/identifying-users-reminder/index.mts rename to .claude/hooks/fleet/identifying-users-reminder/index.mts diff --git a/.claude/hooks/identifying-users-reminder/package.json b/.claude/hooks/fleet/identifying-users-reminder/package.json similarity index 100% rename from .claude/hooks/identifying-users-reminder/package.json rename to .claude/hooks/fleet/identifying-users-reminder/package.json diff --git a/.claude/hooks/identifying-users-reminder/test/index.test.mts b/.claude/hooks/fleet/identifying-users-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/identifying-users-reminder/test/index.test.mts rename to .claude/hooks/fleet/identifying-users-reminder/test/index.test.mts diff --git a/.claude/hooks/marketplace-comment-guard/tsconfig.json b/.claude/hooks/fleet/identifying-users-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/marketplace-comment-guard/tsconfig.json rename to .claude/hooks/fleet/identifying-users-reminder/tsconfig.json diff --git a/.claude/hooks/immutable-release-pattern-guard/README.md b/.claude/hooks/fleet/immutable-release-pattern-guard/README.md similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/README.md rename to .claude/hooks/fleet/immutable-release-pattern-guard/README.md diff --git a/.claude/hooks/immutable-release-pattern-guard/index.mts b/.claude/hooks/fleet/immutable-release-pattern-guard/index.mts similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/index.mts rename to .claude/hooks/fleet/immutable-release-pattern-guard/index.mts diff --git a/.claude/hooks/immutable-release-pattern-guard/package.json b/.claude/hooks/fleet/immutable-release-pattern-guard/package.json similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/package.json rename to .claude/hooks/fleet/immutable-release-pattern-guard/package.json diff --git a/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts b/.claude/hooks/fleet/immutable-release-pattern-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/test/index.test.mts rename to .claude/hooks/fleet/immutable-release-pattern-guard/test/index.test.mts diff --git a/.claude/hooks/minify-mcp-output/tsconfig.json b/.claude/hooks/fleet/immutable-release-pattern-guard/tsconfig.json similarity index 100% rename from .claude/hooks/minify-mcp-output/tsconfig.json rename to .claude/hooks/fleet/immutable-release-pattern-guard/tsconfig.json diff --git a/.claude/hooks/inline-script-defer-guard/README.md b/.claude/hooks/fleet/inline-script-defer-guard/README.md similarity index 100% rename from .claude/hooks/inline-script-defer-guard/README.md rename to .claude/hooks/fleet/inline-script-defer-guard/README.md diff --git a/.claude/hooks/inline-script-defer-guard/index.mts b/.claude/hooks/fleet/inline-script-defer-guard/index.mts similarity index 100% rename from .claude/hooks/inline-script-defer-guard/index.mts rename to .claude/hooks/fleet/inline-script-defer-guard/index.mts diff --git a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md new file mode 100644 index 0000000..e8f2bb4 --- /dev/null +++ b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md @@ -0,0 +1,53 @@ +# inline-script-defer-guard + +PreToolUse Edit/Write hook that blocks introducing ` +``` + +Or, for code that genuinely belongs in an external file: + +```html + +``` + +## What it covers + +| File extension | Checked? | +| -------------------------------------------------------- | --------------- | +| `.html` / `.htm` | full text | +| `.njk` / `.ejs` / `.hbs` / `.handlebars` | full text | +| `.svelte` / `.vue` / `.astro` | full text | +| `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / etc. | new_string only | +| anything else | not checked | + +## Bypass + +Type the canonical phrase in a new message: + + Allow inline-defer bypass + +Use sparingly — the bug is silent in production. + +## Companion: oxlint rule + +`socket/no-inline-defer-async` catches the same shape at commit time +even when edits happened outside Claude. diff --git a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts new file mode 100644 index 0000000..95bef9f --- /dev/null +++ b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — inline-script-defer-guard. +// +// Blocks Edit/Write operations that add ` +// +// Files covered: `*.html` / `*.htm` / `*.njk` / `*.ejs` / `*.hbs` / +// `*.handlebars` / `*.svelte` / `*.vue` / `*.astro`. Also fires on TS/JS +// source files that contain HTML string literals matching the pattern — +// SSR / static-gen code paths. +// +// Bypass: `Allow inline-defer bypass` typed verbatim in a recent user turn. + +import { readFileSync } from 'node:fs' +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly content?: string | undefined + } + | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow inline-defer bypass' + +// File extensions where we check the full text content. For other +// extensions, only the new_string is checked (template strings embedded +// in TS/JS source). +const HTML_EXT_RE = /\.(astro|ejs|handlebars|hbs|htm|html|njk|svelte|vue)$/i + +const SOURCE_EXT_RE = /\.(m?[jt]sx?|cts|cjs)$/i + +// Match each `', + '', + ' Or — if the script DOES belong in an external file:', + '', + ' ', + '', + ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[inline-script-defer-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/inline-script-defer-guard/package.json b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/package.json similarity index 100% rename from .claude/hooks/inline-script-defer-guard/package.json rename to .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/package.json diff --git a/.claude/hooks/inline-script-defer-guard/test/index.test.mts b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/inline-script-defer-guard/test/index.test.mts rename to .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/test/index.test.mts diff --git a/.claude/hooks/minimum-release-age-guard/tsconfig.json b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/tsconfig.json similarity index 100% rename from .claude/hooks/minimum-release-age-guard/tsconfig.json rename to .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/tsconfig.json diff --git a/.claude/hooks/fleet/inline-script-defer-guard/package.json b/.claude/hooks/fleet/inline-script-defer-guard/package.json new file mode 100644 index 0000000..43b2da5 --- /dev/null +++ b/.claude/hooks/fleet/inline-script-defer-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-inline-script-defer-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/inline-script-defer-guard/test/index.test.mts b/.claude/hooks/fleet/inline-script-defer-guard/test/index.test.mts new file mode 100644 index 0000000..fb3bba8 --- /dev/null +++ b/.claude/hooks/fleet/inline-script-defer-guard/test/index.test.mts @@ -0,0 +1,134 @@ +// node --test specs for the inline-script-defer-guard hook. + +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +async function runHook(payload: Record): Promise { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +test('non-HTML / non-source file passes', async () => { + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/tmp/note.txt', + content: '', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('inline ', + }, + }) + assert.strictEqual(r.code, 2) +}) + +test('inline ', + }, + }) + assert.strictEqual(r.code, 2) +}) + +test('inline ', + }, + }) + assert.strictEqual(r.code, 2) +}) + +test('bypass phrase passes', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'idef-tx-')) + const transcriptPath = path.join(dir, 'session.jsonl') + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'user', + message: { content: 'Allow inline-defer bypass' }, + }) + '\n', + ) + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/tmp/page.html', + content: '', + }, + transcript_path: transcriptPath, + }) + assert.strictEqual(r.code, 0) +}) diff --git a/.claude/hooks/new-hook-claude-md-guard/tsconfig.json b/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/tsconfig.json rename to .claude/hooks/fleet/inline-script-defer-guard/tsconfig.json diff --git a/.claude/hooks/judgment-reminder/README.md b/.claude/hooks/fleet/judgment-reminder/README.md similarity index 100% rename from .claude/hooks/judgment-reminder/README.md rename to .claude/hooks/fleet/judgment-reminder/README.md diff --git a/.claude/hooks/judgment-reminder/index.mts b/.claude/hooks/fleet/judgment-reminder/index.mts similarity index 100% rename from .claude/hooks/judgment-reminder/index.mts rename to .claude/hooks/fleet/judgment-reminder/index.mts diff --git a/.claude/hooks/judgment-reminder/package.json b/.claude/hooks/fleet/judgment-reminder/package.json similarity index 100% rename from .claude/hooks/judgment-reminder/package.json rename to .claude/hooks/fleet/judgment-reminder/package.json diff --git a/.claude/hooks/judgment-reminder/test/index.test.mts b/.claude/hooks/fleet/judgment-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/judgment-reminder/test/index.test.mts rename to .claude/hooks/fleet/judgment-reminder/test/index.test.mts diff --git a/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json b/.claude/hooks/fleet/judgment-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/tsconfig.json rename to .claude/hooks/fleet/judgment-reminder/tsconfig.json diff --git a/.claude/hooks/lock-step-ref-guard/README.md b/.claude/hooks/fleet/lock-step-ref-guard/README.md similarity index 100% rename from .claude/hooks/lock-step-ref-guard/README.md rename to .claude/hooks/fleet/lock-step-ref-guard/README.md diff --git a/.claude/hooks/lock-step-ref-guard/index.mts b/.claude/hooks/fleet/lock-step-ref-guard/index.mts similarity index 100% rename from .claude/hooks/lock-step-ref-guard/index.mts rename to .claude/hooks/fleet/lock-step-ref-guard/index.mts diff --git a/.claude/hooks/lock-step-ref-guard/package.json b/.claude/hooks/fleet/lock-step-ref-guard/package.json similarity index 100% rename from .claude/hooks/lock-step-ref-guard/package.json rename to .claude/hooks/fleet/lock-step-ref-guard/package.json diff --git a/.claude/hooks/lock-step-ref-guard/test/index.test.mts b/.claude/hooks/fleet/lock-step-ref-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/lock-step-ref-guard/test/index.test.mts rename to .claude/hooks/fleet/lock-step-ref-guard/test/index.test.mts diff --git a/.claude/hooks/no-disable-lint-rule-guard/tsconfig.json b/.claude/hooks/fleet/lock-step-ref-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/tsconfig.json rename to .claude/hooks/fleet/lock-step-ref-guard/tsconfig.json diff --git a/.claude/hooks/logger-guard/README.md b/.claude/hooks/fleet/logger-guard/README.md similarity index 100% rename from .claude/hooks/logger-guard/README.md rename to .claude/hooks/fleet/logger-guard/README.md diff --git a/.claude/hooks/logger-guard/index.mts b/.claude/hooks/fleet/logger-guard/index.mts similarity index 100% rename from .claude/hooks/logger-guard/index.mts rename to .claude/hooks/fleet/logger-guard/index.mts diff --git a/.claude/hooks/logger-guard/package.json b/.claude/hooks/fleet/logger-guard/package.json similarity index 100% rename from .claude/hooks/logger-guard/package.json rename to .claude/hooks/fleet/logger-guard/package.json diff --git a/.claude/hooks/logger-guard/test/logger-guard.test.mts b/.claude/hooks/fleet/logger-guard/test/logger-guard.test.mts similarity index 100% rename from .claude/hooks/logger-guard/test/logger-guard.test.mts rename to .claude/hooks/fleet/logger-guard/test/logger-guard.test.mts diff --git a/.claude/hooks/no-empty-commit-guard/tsconfig.json b/.claude/hooks/fleet/logger-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-empty-commit-guard/tsconfig.json rename to .claude/hooks/fleet/logger-guard/tsconfig.json diff --git a/.claude/hooks/markdown-filename-guard/README.md b/.claude/hooks/fleet/markdown-filename-guard/README.md similarity index 100% rename from .claude/hooks/markdown-filename-guard/README.md rename to .claude/hooks/fleet/markdown-filename-guard/README.md diff --git a/.claude/hooks/markdown-filename-guard/index.mts b/.claude/hooks/fleet/markdown-filename-guard/index.mts similarity index 100% rename from .claude/hooks/markdown-filename-guard/index.mts rename to .claude/hooks/fleet/markdown-filename-guard/index.mts diff --git a/.claude/hooks/markdown-filename-guard/package.json b/.claude/hooks/fleet/markdown-filename-guard/package.json similarity index 100% rename from .claude/hooks/markdown-filename-guard/package.json rename to .claude/hooks/fleet/markdown-filename-guard/package.json diff --git a/.claude/hooks/markdown-filename-guard/test/index.test.mts b/.claude/hooks/fleet/markdown-filename-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/markdown-filename-guard/test/index.test.mts rename to .claude/hooks/fleet/markdown-filename-guard/test/index.test.mts diff --git a/.claude/hooks/no-experimental-strip-types-guard/tsconfig.json b/.claude/hooks/fleet/markdown-filename-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/tsconfig.json rename to .claude/hooks/fleet/markdown-filename-guard/tsconfig.json diff --git a/.claude/hooks/marketplace-comment-guard/README.md b/.claude/hooks/fleet/marketplace-comment-guard/README.md similarity index 100% rename from .claude/hooks/marketplace-comment-guard/README.md rename to .claude/hooks/fleet/marketplace-comment-guard/README.md diff --git a/.claude/hooks/marketplace-comment-guard/index.mts b/.claude/hooks/fleet/marketplace-comment-guard/index.mts similarity index 100% rename from .claude/hooks/marketplace-comment-guard/index.mts rename to .claude/hooks/fleet/marketplace-comment-guard/index.mts diff --git a/.claude/hooks/marketplace-comment-guard/package.json b/.claude/hooks/fleet/marketplace-comment-guard/package.json similarity index 100% rename from .claude/hooks/marketplace-comment-guard/package.json rename to .claude/hooks/fleet/marketplace-comment-guard/package.json diff --git a/.claude/hooks/marketplace-comment-guard/test/index.test.mts b/.claude/hooks/fleet/marketplace-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/marketplace-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/marketplace-comment-guard/test/index.test.mts diff --git a/.claude/hooks/no-external-issue-ref-guard/tsconfig.json b/.claude/hooks/fleet/marketplace-comment-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/tsconfig.json rename to .claude/hooks/fleet/marketplace-comment-guard/tsconfig.json diff --git a/.claude/hooks/fleet/minify-mcp-output/README.md b/.claude/hooks/fleet/minify-mcp-output/README.md new file mode 100644 index 0000000..930f47a --- /dev/null +++ b/.claude/hooks/fleet/minify-mcp-output/README.md @@ -0,0 +1,85 @@ +# minify-mcp-output + +A **Claude Code PostToolUse hook** that compresses MCP-tool output text +losslessly before it enters Claude's context. Pairs with the wire-level +proxy [`@socketsecurity/token-minifier`](../../packages/socket-token-minifier/) +for built-in tools (Read, Bash, Edit, etc.) — those have no PostToolUse +rewrite channel, so they only benefit from wire-level compression. + +## Why this rule + +MCP tools (declared via `.mcp.json`) can produce verbose output: JSON +arrays, nested objects, long text fields with whitespace and line +prefixes. Stage compression saves tokens **both** on the wire AND in +context (because Claude reads the compressed version going forward). + +Built-in tool results don't go through this hook — Claude Code's hook +runtime accepts `updatedMCPToolOutput` only when `tool_name` starts +with `mcp__`. For built-in tools, use the proxy instead. + +## Stages (identical to socket-token-minifier) + +| Stage | What it does | +| ------------- | ------------------------------------------------------- | +| `minify` | `JSON.stringify` without indent on JSON-shaped strings. | +| `strip-lines` | Removes ` 42\t` cat -n style line prefixes. | +| `whitespace` | Collapses 3+ blank lines to a single blank line. | + +All are deterministic, information-preserving transforms. No semantic +compression, no ML, no Python. + +## What's enforced + +- Hook fires only on `PostToolUse`. +- Hook activates only when `tool_name` starts with `mcp__`. +- Stages applied to all text content in the MCP `tool_response`, + including string-shaped responses, `{type:"text", text:"..."}` blocks, + and arrays thereof. +- Non-text content (images, structured data) passes through unchanged. +- The hook fails **open** on any internal error (exit 0 with no output) + so a bad deploy can't break tool delivery. + +## What's not enforced + +- Built-in tools (Read, Bash, Edit, Write, etc.) — Claude Code's + runtime does not accept `updatedMCPToolOutput` for them. Use the + proxy for wire-level compression. + +## Wiring + +In `.claude/settings.json`: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "mcp__.*", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/minify-mcp-output/index.mts" + } + ] + } + ] + } +} +``` + +The matcher `mcp__.*` is a belt-and-suspenders narrowing — the hook +itself also checks `tool_name` startsWith `mcp__` and exits 0 if it +doesn't match. + +## Cross-fleet sync + +This hook lives in +[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/minify-mcp-output) +and is required to be byte-identical across every fleet repo. +`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. + +The compression-stage logic is intentionally **inlined** here rather +than imported from `packages/socket-token-minifier/` — that package +lives only in wheelhouse, while this hook cascades fleet-wide. +Inlining keeps the dependency-resolution graph trivial for downstream +repos. diff --git a/.claude/hooks/minify-mcp-output/index.mts b/.claude/hooks/fleet/minify-mcp-output/index.mts similarity index 100% rename from .claude/hooks/minify-mcp-output/index.mts rename to .claude/hooks/fleet/minify-mcp-output/index.mts diff --git a/.claude/hooks/minify-mcp-output/README.md b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/README.md similarity index 100% rename from .claude/hooks/minify-mcp-output/README.md rename to .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/README.md diff --git a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts new file mode 100644 index 0000000..291f573 --- /dev/null +++ b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Claude Code PostToolUse hook — minify-mcp-output. +// +// Applies lossless minification stages (minify / strip-lines / +// whitespace) to MCP-tool output text and returns the result via +// `hookSpecificOutput.updatedMCPToolOutput` — the only documented +// rewrite channel for PostToolUse, verified empirically. +// +// Scope: +// - PostToolUse only. +// - tool_name starts with `mcp__` (Claude Code's MCP tool naming +// convention: mcp____). +// - Other tool names (built-in: Read/Bash/Edit/etc.) pass through +// untouched — those have no PostToolUse rewrite channel; use the +// wire-level proxy (socket-token-minifier) instead. +// +// The hook fails OPEN on its own errors (exit 0 with no output) so a +// bad deploy can't break tool result delivery. +// +// Stages here are inlined (not imported from packages/socket-token- +// minifier/) because this hook cascades into every fleet repo via +// sync-scaffolding, while packages/socket-token-minifier/ lives only +// in wheelhouse. The stage logic is small enough that inlining is +// cleaner than orchestrating a workspace dependency that downstream +// repos don't have. + +import process from 'node:process' + +interface Payload { + hook_event_name?: string | undefined + tool_name?: string | undefined + tool_response?: unknown | undefined + // Plus session_id, cwd, etc. — we don't care. +} + +// ---------- Inlined stages (synced with packages/socket-token-minifier/src/stages/) ---------- + +export function minify(text: string): string { + const trimmed = text.trimStart() + if (trimmed.length === 0) { + return text + } + const first = trimmed.charCodeAt(0) + if (first !== 0x7b && first !== 0x5b) { + return text + } + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return text + } + return JSON.stringify(parsed) +} + +const LINE_PREFIX_RE = /^[ \t]*\d+\t/gm +export function stripLines(text: string): string { + return text.replace(LINE_PREFIX_RE, '') +} + +const BLANK_RUN_RE = /\n(?:[ \t]*\n){2,}/g +export function whitespace(text: string): string { + return text.replace(BLANK_RUN_RE, '\n\n') +} + +export function applyStages(text: string): string { + return whitespace(stripLines(minify(text))) +} + +// ---------- Tool-response walker ---------- + +/** + * Walk an MCP tool_response value and compress text content in place. Returns + * the same structure with strings minified. Non-text content (images, + * structured data we don't recognize) passes through unchanged. + * + * Shapes we handle: + * + * - String → minified string. + * - { type: "text", text: string } → minified text. + * - { content: } + * - { type: "text", text: string }[] (typical MCP shape). + * - Other → passes through. + */ +export function compressMCPOutput(value: unknown): unknown { + if (typeof value === 'string') { + return applyStages(value) + } + if (Array.isArray(value)) { + return value.map(compressMCPOutput) + } + if (value !== null && typeof value === 'object') { + const obj = value as Record + const out: Record = { ...obj } + if (typeof obj['text'] === 'string') { + out['text'] = applyStages(obj['text']) + } + if (obj['content'] !== undefined) { + out['content'] = compressMCPOutput(obj['content']) + } + return out + } + return value +} + +// ---------- Hook IO ---------- + +export function isMCPToolName(name: string | undefined): boolean { + return typeof name === 'string' && name.startsWith('mcp__') +} + +function main() { + let stdin = '' + process.stdin.on('data', chunk => { + stdin += chunk + }) + process.stdin.on('end', () => { + try { + let payload: Payload + try { + payload = JSON.parse(stdin) as Payload + } catch { + process.exit(0) + } + if (payload.hook_event_name !== 'PostToolUse') { + process.exit(0) + } + if (!isMCPToolName(payload.tool_name)) { + process.exit(0) + } + const original = payload.tool_response + if (original === undefined) { + process.exit(0) + } + const compressed = compressMCPOutput(original) + const out = { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + updatedMCPToolOutput: compressed, + }, + } + process.stdout.write(JSON.stringify(out)) + process.exit(0) + } catch { + // Fail-open: silently exit 0 so Claude Code uses the original. + process.exit(0) + } + }) + if (process.stdin.readable === false) { + process.exit(0) + } +} + +main() diff --git a/.claude/hooks/minify-mcp-output/package.json b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/package.json similarity index 100% rename from .claude/hooks/minify-mcp-output/package.json rename to .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/package.json diff --git a/.claude/hooks/minify-mcp-output/test/index.test.mts b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/test/index.test.mts similarity index 100% rename from .claude/hooks/minify-mcp-output/test/index.test.mts rename to .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/test/index.test.mts diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/tsconfig.json similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json rename to .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/tsconfig.json diff --git a/.claude/hooks/fleet/minify-mcp-output/package.json b/.claude/hooks/fleet/minify-mcp-output/package.json new file mode 100644 index 0000000..492493d --- /dev/null +++ b/.claude/hooks/fleet/minify-mcp-output/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-minify-mcp-output", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + } +} diff --git a/.claude/hooks/fleet/minify-mcp-output/test/index.test.mts b/.claude/hooks/fleet/minify-mcp-output/test/index.test.mts new file mode 100644 index 0000000..ce83b7e --- /dev/null +++ b/.claude/hooks/fleet/minify-mcp-output/test/index.test.mts @@ -0,0 +1,164 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { compressMCPOutput, isMCPToolName } from '../index.mts' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function runHook(payload: object): { + stdout: string + exitCode: number +} { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify(payload), + }) + return { stdout: String(result.stdout), exitCode: result.status ?? -1 } +} + +// ---------- isMCPToolName ---------- + +test('isMCPToolName: accepts mcp__ prefix', () => { + assert.equal(isMCPToolName('mcp__github__list_repos'), true) + assert.equal(isMCPToolName('mcp__playwright__navigate'), true) +}) + +test('isMCPToolName: rejects built-in tool names', () => { + for (const name of ['Read', 'Bash', 'Edit', 'Write', 'Grep']) { + assert.equal(isMCPToolName(name), false) + } +}) + +test('isMCPToolName: rejects undefined / wrong type', () => { + assert.equal(isMCPToolName(undefined), false) + assert.equal(isMCPToolName(''), false) +}) + +// ---------- compressMCPOutput ---------- + +test('compressMCPOutput: minifies string-shaped response', () => { + const got = compressMCPOutput(' 1\thello\n 2\tworld\n') + assert.equal(got, 'hello\nworld\n') +}) + +test('compressMCPOutput: minifies text block in object', () => { + const got = compressMCPOutput({ + type: 'text', + text: '\n\n\n\nfoo\n', + }) + assert.deepEqual(got, { type: 'text', text: '\n\nfoo\n' }) +}) + +test('compressMCPOutput: minifies text blocks in arrays', () => { + const got = compressMCPOutput([ + { type: 'text', text: ' 1\tline a\n' }, + { type: 'text', text: ' 2\tline b\n' }, + ]) + assert.deepEqual(got, [ + { type: 'text', text: 'line a\n' }, + { type: 'text', text: 'line b\n' }, + ]) +}) + +test('compressMCPOutput: walks into nested content fields', () => { + const got = compressMCPOutput({ + content: [{ type: 'text', text: ' 1\tfoo\n' }], + }) + assert.deepEqual(got, { + content: [{ type: 'text', text: 'foo\n' }], + }) +}) + +test('compressMCPOutput: passes through non-text blocks', () => { + const input = { + type: 'image', + source: { data: 'abc', media_type: 'image/png' }, + } + assert.deepEqual(compressMCPOutput(input), input) +}) + +test('compressMCPOutput: passes through primitives that aren’t strings', () => { + assert.equal(compressMCPOutput(42), 42) + assert.equal(compressMCPOutput(true), true) + assert.equal(compressMCPOutput(undefined), null) +}) + +test('compressMCPOutput: minifies JSON-shaped strings', () => { + const got = compressMCPOutput('{\n "a": 1,\n "b": 2\n}') + assert.equal(got, '{"a":1,"b":2}') +}) + +// ---------- hook IO ---------- + +test('hook: SKIPS non-PostToolUse events', () => { + const { stdout, exitCode } = runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'mcp__x__y', + tool_response: 'whatever', + }) + assert.equal(exitCode, 0) + assert.equal(stdout.trim(), '') +}) + +test('hook: SKIPS built-in tools', () => { + const { stdout, exitCode } = runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_response: { content: 'whatever' }, + }) + assert.equal(exitCode, 0) + assert.equal(stdout.trim(), '') +}) + +test('hook: SKIPS when tool_response is absent', () => { + const { stdout, exitCode } = runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'mcp__x__y', + }) + assert.equal(exitCode, 0) + assert.equal(stdout.trim(), '') +}) + +test('hook: emits updatedMCPToolOutput for MCP tool with text content', () => { + const { stdout, exitCode } = runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'mcp__github__list_repos', + tool_response: [{ type: 'text', text: ' 1\tfoo\n 2\tbar\n' }], + }) + assert.equal(exitCode, 0) + const parsed = JSON.parse(stdout) as { + hookSpecificOutput: { + hookEventName: string + updatedMCPToolOutput: Array<{ text: string }> + } + } + assert.equal(parsed.hookSpecificOutput.hookEventName, 'PostToolUse') + assert.equal( + parsed.hookSpecificOutput.updatedMCPToolOutput[0]!.text, + 'foo\nbar\n', + ) +}) + +test('hook: emits updatedMCPToolOutput for MCP tool with string-shaped response', () => { + const { stdout, exitCode } = runHook({ + hook_event_name: 'PostToolUse', + tool_name: 'mcp__custom__tool', + tool_response: '{\n "x": 1\n}', + }) + assert.equal(exitCode, 0) + const parsed = JSON.parse(stdout) as { + hookSpecificOutput: { updatedMCPToolOutput: string } + } + assert.equal(parsed.hookSpecificOutput.updatedMCPToolOutput, '{"x":1}') +}) + +test('hook: fails open on malformed stdin', () => { + const result = spawnSync('node', [HOOK_PATH], { + input: '{not json', + }) + assert.equal(result.status, 0) + assert.equal(String(result.stdout).trim(), '') +}) diff --git a/.claude/hooks/no-fleet-fork-guard/tsconfig.json b/.claude/hooks/fleet/minify-mcp-output/tsconfig.json similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/tsconfig.json rename to .claude/hooks/fleet/minify-mcp-output/tsconfig.json diff --git a/.claude/hooks/minimum-release-age-guard/README.md b/.claude/hooks/fleet/minimum-release-age-guard/README.md similarity index 100% rename from .claude/hooks/minimum-release-age-guard/README.md rename to .claude/hooks/fleet/minimum-release-age-guard/README.md diff --git a/.claude/hooks/minimum-release-age-guard/index.mts b/.claude/hooks/fleet/minimum-release-age-guard/index.mts similarity index 100% rename from .claude/hooks/minimum-release-age-guard/index.mts rename to .claude/hooks/fleet/minimum-release-age-guard/index.mts diff --git a/.claude/hooks/minimum-release-age-guard/package.json b/.claude/hooks/fleet/minimum-release-age-guard/package.json similarity index 100% rename from .claude/hooks/minimum-release-age-guard/package.json rename to .claude/hooks/fleet/minimum-release-age-guard/package.json diff --git a/.claude/hooks/minimum-release-age-guard/test/index.test.mts b/.claude/hooks/fleet/minimum-release-age-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/minimum-release-age-guard/test/index.test.mts rename to .claude/hooks/fleet/minimum-release-age-guard/test/index.test.mts diff --git a/.claude/hooks/no-meta-comments-guard/tsconfig.json b/.claude/hooks/fleet/minimum-release-age-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-meta-comments-guard/tsconfig.json rename to .claude/hooks/fleet/minimum-release-age-guard/tsconfig.json diff --git a/.claude/hooks/new-hook-claude-md-guard/README.md b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/README.md rename to .claude/hooks/fleet/new-hook-claude-md-guard/README.md diff --git a/.claude/hooks/new-hook-claude-md-guard/index.mts b/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/index.mts rename to .claude/hooks/fleet/new-hook-claude-md-guard/index.mts diff --git a/.claude/hooks/new-hook-claude-md-guard/package.json b/.claude/hooks/fleet/new-hook-claude-md-guard/package.json similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/package.json rename to .claude/hooks/fleet/new-hook-claude-md-guard/package.json diff --git a/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts b/.claude/hooks/fleet/new-hook-claude-md-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/test/index.test.mts rename to .claude/hooks/fleet/new-hook-claude-md-guard/test/index.test.mts diff --git a/.claude/hooks/no-orphaned-staging/tsconfig.json b/.claude/hooks/fleet/new-hook-claude-md-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-orphaned-staging/tsconfig.json rename to .claude/hooks/fleet/new-hook-claude-md-guard/tsconfig.json diff --git a/.claude/hooks/no-blind-keychain-read-guard/README.md b/.claude/hooks/fleet/no-blind-keychain-read-guard/README.md similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/README.md rename to .claude/hooks/fleet/no-blind-keychain-read-guard/README.md diff --git a/.claude/hooks/no-blind-keychain-read-guard/index.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/index.mts rename to .claude/hooks/fleet/no-blind-keychain-read-guard/index.mts diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md new file mode 100644 index 0000000..22a1796 --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md @@ -0,0 +1,65 @@ +# no-blind-keychain-read-guard + +`PreToolUse(Bash)` blocker that refuses direct keychain READ calls +from Bash. The keychain APIs surface a UI auth prompt per call; +reading three times costs three prompts. The fleet's canonical +in-process resolver (`api-token.mts.findApiToken()`) caches the +value module-scoped after the first hit, so subsequent code paths +should never need to re-read the keychain. + +## Detected reads + +| Platform | Pattern | +| -------------- | ---------------------------------------------- | +| macOS | `security find-{generic,internet}-password` | +| Linux | `secret-tool lookup` / `secret-tool search` | +| Windows | `Get-StoredCredential` | +| Windows | `Get-Credential … \| ConvertFrom-SecureString` | +| cross-platform | `keyring get` | + +## Allowed (not flagged) + +Writes and deletes — these only happen during operator-driven +setup / rotation, never on hot paths: + +- `security add-generic-password` / `security delete-generic-password` +- `secret-tool store` / `secret-tool clear` +- `New-StoredCredential` / `Remove-StoredCredential` +- `keyring set` / `keyring del` + +## Bypass + +Type the canonical phrase verbatim in your next user turn: + +``` +Allow blind-keychain-read bypass +``` + +Use when you genuinely need a fresh keychain read — operator-invoked +diagnostics, verifying an entry exists, etc. + +## Why + +`security find-generic-password` on macOS prompts the user every call +unless the calling process is on the entry's ACL. Claude Code's Bash +tool spawns a fresh process per call, so each `security` invocation +re-prompts. The same shape exists on Linux (`secret-tool` against +gnome-keyring / kwallet) and Windows (`Get-StoredCredential` against +the CredentialManager UI). + +The right answer is to read the cached value from process state: + +```ts +import { findApiToken } from '../setup-security-tools/lib/api-token.mts' +const { token } = findApiToken() // module-cached after first call +``` + +Or from a child process spawned by hooks: + +```bash +echo "$SOCKET_API_KEY" # populated by wheelhouse shell-rc bridge +``` + +The bridge writes the token to `~/.zshenv` (or platform equivalent) +so every new shell exports `SOCKET_API_KEY` + `SOCKET_API_TOKEN` +without a keychain read. diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts new file mode 100644 index 0000000..bdb1094 --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-blind-keychain-read-guard. +// +// Blocks Bash invocations that READ a credential from the OS +// keychain. Reading via the platform CLI surfaces a per-call UI auth +// prompt on the user's screen ("this app wants to access your +// keychain"), and the prompt fires once per call — a hook chain that +// reads the keychain three times costs three prompts. Tokens are +// already cached in process memory after the first resolution; the +// fleet's canonical resolver (`api-token.mts.findApiToken()`) hits +// the cache, then env, then keychain, in that order. Bash callers +// that go straight to `security find-generic-password` skip all of +// that and re-prompt the user every time. +// +// Detects (case-sensitive, structural — not just substring): +// +// macOS: +// security find-generic-password +// security find-internet-password +// +// Linux: +// secret-tool lookup +// secret-tool search +// +// Windows (PowerShell): +// Get-StoredCredential (CredentialManager module) +// Get-Credential (when piping to ConvertFrom-SecureString) +// +// Cross-platform (Python keyring CLI): +// keyring get +// +// Allowed (writes / deletes — necessary for operator-driven setup / +// rotation, never on hot paths): +// +// security add-generic-password security delete-generic-password +// secret-tool store secret-tool clear +// New-StoredCredential Remove-StoredCredential +// keyring set keyring del +// +// Bypass: `Allow blind-keychain-read bypass` in a recent user turn. +// Use when you genuinely need to verify a keychain entry exists +// (e.g. operator-invoked diagnostics). +// +// Exit codes: +// 0 — pass. +// 2 — block. +// +// Fails open on malformed payloads (exit 0 + stderr log) — the fleet's +// hook contract. + +import process from 'node:process' + +import { bypassPhrasePresent } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_input?: + | { + readonly command?: string | undefined + } + | undefined + readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined +} + +interface Hit { + readonly tool: string + readonly platform: 'macos' | 'linux' | 'windows' | 'cross-platform' + readonly snippet: string +} + +const BYPASS_PHRASE = 'Allow blind-keychain-read bypass' + +// Token-bearing read patterns. Each entry: the literal verb that +// surfaces a UI prompt + a label for the error message. Writes / +// deletes are intentionally absent from this list. +const READ_PATTERNS: ReadonlyArray<{ + readonly re: RegExp + readonly tool: string + readonly platform: Hit['platform'] +}> = [ + // macOS — `security(1)`. The `-w` flag prints the password to + // stdout, but even the metadata-only form triggers the ACL prompt. + { + re: /\bsecurity\s+(?:find-generic-password|find-internet-password)\b/, + tool: 'security find-*-password', + platform: 'macos', + }, + // Linux — `secret-tool`. `lookup` returns the password; `search` + // lists matches (also surfaces the libsecret prompt). + { + re: /\bsecret-tool\s+(?:lookup|search)\b/, + tool: 'secret-tool lookup/search', + platform: 'linux', + }, + // Windows PowerShell — CredentialManager module. The + // `Get-StoredCredential` cmdlet returns a PSCredential; reading + // `.Password | ConvertFrom-SecureString` is the read pattern. + { + re: /\bGet-StoredCredential\b/, + tool: 'Get-StoredCredential', + platform: 'windows', + }, + // PowerShell `Get-Credential -Credential` piped to + // `ConvertFrom-SecureString -AsPlainText` is the readback shape. + // The bare `Get-Credential` (no pipe) is a fresh-prompt-the-user + // flow and not the issue here — match only the readback pipe. + { + re: /\bGet-Credential\b[^|]*\|\s*ConvertFrom-SecureString\b/, + tool: 'Get-Credential | ConvertFrom-SecureString', + platform: 'windows', + }, + // Python `keyring` CLI — `keyring get `. + { + re: /\bkeyring\s+get\b/, + tool: 'keyring get', + platform: 'cross-platform', + }, +] + +/** + * Scan a Bash command string for keychain READ patterns. Returns one hit per + * matching subcommand so the error message can name them all (a `&&`-chained + * command might have multiple). + */ +export function findKeychainReads(command: string): Hit[] { + const hits: Hit[] = [] + for (let i = 0, { length } = READ_PATTERNS; i < length; i += 1) { + const entry = READ_PATTERNS[i]! + const m = entry.re.exec(command) + if (!m) { + continue + } + // Pull a short snippet around the match (up to 80 chars) so the + // operator can see the context. Centered on the match start. + const start = Math.max(0, m.index - 10) + const end = Math.min(command.length, m.index + m[0].length + 50) + const snippet = command.slice(start, end) + hits.push({ + tool: entry.tool, + platform: entry.platform, + snippet: snippet.length < command.length ? `…${snippet}…` : snippet, + }) + } + return hits +} + +function handlePayload(payloadRaw: string): number { + let payload: ToolInput + try { + payload = JSON.parse(payloadRaw) as ToolInput + } catch { + return 0 + } + if (payload.tool_name !== 'Bash') { + return 0 + } + const command = payload.tool_input?.command ?? '' + if (!command) { + return 0 + } + const hits = findKeychainReads(command) + if (hits.length === 0) { + return 0 + } + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + return 0 + } + const lines: string[] = [] + lines.push( + '[no-blind-keychain-read-guard] Blocked: direct keychain READ from Bash.', + ) + lines.push('') + for (let i = 0, { length } = hits; i < length; i += 1) { + const h = hits[i]! + lines.push(` ${h.platform.padEnd(15)} ${h.tool}`) + lines.push(` Saw: ${h.snippet}`) + } + lines.push('') + lines.push(' Reading the keychain via the platform CLI surfaces a UI auth') + lines.push(" prompt on the user's screen — and the prompt fires once per") + lines.push(' call. A hook chain that reads three times costs three prompts.') + lines.push('') + lines.push(' The token is almost certainly already available without a') + lines.push(' keychain read:') + lines.push('') + lines.push(' - In-process: call findApiToken() from setup-security-tools/') + lines.push(' lib/api-token.mts. It returns the module-cached value from') + lines.push(' the first call onward, then env, then keychain.') + lines.push('') + lines.push(' - From Bash: read process.env.SOCKET_API_KEY or') + lines.push( + ' process.env.SOCKET_API_TOKEN. The wheelhouse shell-rc bridge', + ) + lines.push(' exports both for every new shell session.') + lines.push('') + lines.push(' Writes / deletes (security add-generic-password / secret-tool') + lines.push(' store / New-StoredCredential / etc.) are allowed — they only') + lines.push(' happen during operator-driven setup / rotation.') + lines.push('') + lines.push(' Bypass (e.g. operator-invoked diagnostics that need a fresh') + lines.push(' keychain read):') + lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) + process.stderr.write(lines.join('\n') + '\n') + return 2 +} + +export { handlePayload } + +// CLI entrypoint — only fires when this file is the main module. +// During tests the importer pulls `findKeychainReads` without triggering +// the stdin reader (which would never see an `end` event in test env +// and hang the process). +if (process.argv[1] && process.argv[1].endsWith('index.mts')) { + let payloadRaw = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + payloadRaw += chunk + }) + process.stdin.on('end', () => { + try { + process.exit(handlePayload(payloadRaw)) + } catch (e) { + process.stderr.write( + `[no-blind-keychain-read-guard] hook error (allowing): ${e}\n`, + ) + process.exit(0) + } + }) +} diff --git a/.claude/hooks/no-blind-keychain-read-guard/package.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/package.json similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/package.json rename to .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/package.json diff --git a/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/test/index.test.mts rename to .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/test/index.test.mts diff --git a/.claude/hooks/no-revert-guard/tsconfig.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-revert-guard/tsconfig.json rename to .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/tsconfig.json diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json new file mode 100644 index 0000000..819429b --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-blind-keychain-read-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/test/index.test.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/test/index.test.mts new file mode 100644 index 0000000..8567a3b --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/test/index.test.mts @@ -0,0 +1,142 @@ +/** + * @file Unit tests for findKeychainReads — the structural matcher that + * classifies a Bash command string into keychain READ hits (vs writes, + * deletes, and unrelated commands). + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { findKeychainReads } from '../index.mts' + +test('macOS find-generic-password is flagged', () => { + const hits = findKeychainReads( + 'security find-generic-password -s socket-cli -a SOCKET_API_KEY -w', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'macos') +}) + +test('macOS find-internet-password is flagged', () => { + const hits = findKeychainReads( + 'security find-internet-password -s example.com -a user', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'macos') +}) + +test('macOS add-generic-password is NOT flagged (write)', () => { + const hits = findKeychainReads( + 'security add-generic-password -U -s socket-cli -a SOCKET_API_KEY -w xxx', + ) + assert.equal(hits.length, 0) +}) + +test('macOS delete-generic-password is NOT flagged (delete)', () => { + const hits = findKeychainReads( + 'security delete-generic-password -s socket-cli -a SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Linux secret-tool lookup is flagged', () => { + const hits = findKeychainReads( + 'secret-tool lookup service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'linux') +}) + +test('Linux secret-tool search is flagged', () => { + const hits = findKeychainReads('secret-tool search service socket-cli') + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'linux') +}) + +test('Linux secret-tool store is NOT flagged (write)', () => { + const hits = findKeychainReads( + 'secret-tool store --label="Socket API token" service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Linux secret-tool clear is NOT flagged (delete)', () => { + const hits = findKeychainReads( + 'secret-tool clear service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Windows Get-StoredCredential is flagged', () => { + const hits = findKeychainReads( + 'powershell -Command "(Get-StoredCredential -Target \'socket-cli:SOCKET_API_KEY\').Password"', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'windows') +}) + +test('Windows Get-Credential | ConvertFrom-SecureString is flagged', () => { + const hits = findKeychainReads( + 'Get-Credential -Credential admin | ConvertFrom-SecureString -AsPlainText', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'windows') +}) + +test('Windows Get-Credential WITHOUT pipe is NOT flagged (fresh prompt)', () => { + // Bare Get-Credential is an interactive fresh-prompt flow, not a + // readback of a stored credential. Don't block. + const hits = findKeychainReads('$cred = Get-Credential -Credential admin') + assert.equal(hits.length, 0) +}) + +test('Windows New-StoredCredential is NOT flagged (write)', () => { + const hits = findKeychainReads( + "New-StoredCredential -Target 'socket-cli:SOCKET_API_KEY' -UserName x -SecurePassword $s", + ) + assert.equal(hits.length, 0) +}) + +test('keyring get is flagged', () => { + const hits = findKeychainReads('keyring get socket-cli SOCKET_API_KEY') + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'cross-platform') +}) + +test('keyring set is NOT flagged (write)', () => { + const hits = findKeychainReads('keyring set socket-cli SOCKET_API_KEY') + assert.equal(hits.length, 0) +}) + +test('chained reads count separately', () => { + // && chain with two reads + const hits = findKeychainReads( + 'security find-generic-password -s a -a b -w && secret-tool lookup service a user b', + ) + assert.equal(hits.length, 2) +}) + +test('unrelated commands are not flagged', () => { + for (const cmd of [ + 'ls -la', + 'git log --oneline -5', + 'echo $SOCKET_API_KEY', + 'pnpm install', + 'grep security file.txt', + 'security delete-keychain ~/Library/Keychains/foo.keychain', + ]) { + const hits = findKeychainReads(cmd) + assert.equal(hits.length, 0, `should not flag: ${cmd}`) + } +}) + +test('command substitution wrapping is still flagged', () => { + // The structural matcher is intentionally a regex, not an AST. This + // catches the common subshell shape — verifying the inner verb is + // detected even inside `$(...)`. AST-based parsing is overkill for + // a non-security-critical reminder hook. + const hits = findKeychainReads( + 'TOKEN="$(security find-generic-password -s socket-cli -a SOCKET_API_KEY -w)" && echo done', + ) + assert.equal(hits.length, 1) +}) diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json rename to .claude/hooks/fleet/no-blind-keychain-read-guard/tsconfig.json diff --git a/.claude/hooks/no-disable-lint-rule-guard/README.md b/.claude/hooks/fleet/no-disable-lint-rule-guard/README.md similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/README.md rename to .claude/hooks/fleet/no-disable-lint-rule-guard/README.md diff --git a/.claude/hooks/no-disable-lint-rule-guard/index.mts b/.claude/hooks/fleet/no-disable-lint-rule-guard/index.mts similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/index.mts rename to .claude/hooks/fleet/no-disable-lint-rule-guard/index.mts diff --git a/.claude/hooks/no-disable-lint-rule-guard/package.json b/.claude/hooks/fleet/no-disable-lint-rule-guard/package.json similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/package.json rename to .claude/hooks/fleet/no-disable-lint-rule-guard/package.json diff --git a/.claude/hooks/no-disable-lint-rule-guard/test/index.test.mts b/.claude/hooks/fleet/no-disable-lint-rule-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/test/index.test.mts rename to .claude/hooks/fleet/no-disable-lint-rule-guard/test/index.test.mts diff --git a/.claude/hooks/no-token-in-dotenv-guard/tsconfig.json b/.claude/hooks/fleet/no-disable-lint-rule-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/tsconfig.json rename to .claude/hooks/fleet/no-disable-lint-rule-guard/tsconfig.json diff --git a/.claude/hooks/no-empty-commit-guard/README.md b/.claude/hooks/fleet/no-empty-commit-guard/README.md similarity index 100% rename from .claude/hooks/no-empty-commit-guard/README.md rename to .claude/hooks/fleet/no-empty-commit-guard/README.md diff --git a/.claude/hooks/no-empty-commit-guard/index.mts b/.claude/hooks/fleet/no-empty-commit-guard/index.mts similarity index 100% rename from .claude/hooks/no-empty-commit-guard/index.mts rename to .claude/hooks/fleet/no-empty-commit-guard/index.mts diff --git a/.claude/hooks/no-empty-commit-guard/package.json b/.claude/hooks/fleet/no-empty-commit-guard/package.json similarity index 100% rename from .claude/hooks/no-empty-commit-guard/package.json rename to .claude/hooks/fleet/no-empty-commit-guard/package.json diff --git a/.claude/hooks/no-empty-commit-guard/test/index.test.mts b/.claude/hooks/fleet/no-empty-commit-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-empty-commit-guard/test/index.test.mts rename to .claude/hooks/fleet/no-empty-commit-guard/test/index.test.mts diff --git a/.claude/hooks/no-underscore-identifier-guard/tsconfig.json b/.claude/hooks/fleet/no-empty-commit-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/tsconfig.json rename to .claude/hooks/fleet/no-empty-commit-guard/tsconfig.json diff --git a/.claude/hooks/no-experimental-strip-types-guard/README.md b/.claude/hooks/fleet/no-experimental-strip-types-guard/README.md similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/README.md rename to .claude/hooks/fleet/no-experimental-strip-types-guard/README.md diff --git a/.claude/hooks/no-experimental-strip-types-guard/index.mts b/.claude/hooks/fleet/no-experimental-strip-types-guard/index.mts similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/index.mts rename to .claude/hooks/fleet/no-experimental-strip-types-guard/index.mts diff --git a/.claude/hooks/no-experimental-strip-types-guard/package.json b/.claude/hooks/fleet/no-experimental-strip-types-guard/package.json similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/package.json rename to .claude/hooks/fleet/no-experimental-strip-types-guard/package.json diff --git a/.claude/hooks/no-experimental-strip-types-guard/test/index.test.mts b/.claude/hooks/fleet/no-experimental-strip-types-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/test/index.test.mts rename to .claude/hooks/fleet/no-experimental-strip-types-guard/test/index.test.mts diff --git a/.claude/hooks/node-modules-staging-guard/tsconfig.json b/.claude/hooks/fleet/no-experimental-strip-types-guard/tsconfig.json similarity index 100% rename from .claude/hooks/node-modules-staging-guard/tsconfig.json rename to .claude/hooks/fleet/no-experimental-strip-types-guard/tsconfig.json diff --git a/.claude/hooks/no-external-issue-ref-guard/README.md b/.claude/hooks/fleet/no-external-issue-ref-guard/README.md similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/README.md rename to .claude/hooks/fleet/no-external-issue-ref-guard/README.md diff --git a/.claude/hooks/no-external-issue-ref-guard/index.mts b/.claude/hooks/fleet/no-external-issue-ref-guard/index.mts similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/index.mts rename to .claude/hooks/fleet/no-external-issue-ref-guard/index.mts diff --git a/.claude/hooks/no-external-issue-ref-guard/package.json b/.claude/hooks/fleet/no-external-issue-ref-guard/package.json similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/package.json rename to .claude/hooks/fleet/no-external-issue-ref-guard/package.json diff --git a/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts b/.claude/hooks/fleet/no-external-issue-ref-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/test/index.test.mts rename to .claude/hooks/fleet/no-external-issue-ref-guard/test/index.test.mts diff --git a/.claude/hooks/overeager-staging-guard/tsconfig.json b/.claude/hooks/fleet/no-external-issue-ref-guard/tsconfig.json similarity index 100% rename from .claude/hooks/overeager-staging-guard/tsconfig.json rename to .claude/hooks/fleet/no-external-issue-ref-guard/tsconfig.json diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/README.md b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/README.md similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/README.md rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/README.md diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/index.mts similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/index.mts rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/index.mts diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/package.json b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/package.json similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/package.json rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/package.json diff --git a/.claude/hooks/path-guard/tsconfig.json b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/tsconfig.json similarity index 100% rename from .claude/hooks/path-guard/tsconfig.json rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/tsconfig.json diff --git a/.claude/hooks/no-fleet-fork-guard/README.md b/.claude/hooks/fleet/no-fleet-fork-guard/README.md similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/README.md rename to .claude/hooks/fleet/no-fleet-fork-guard/README.md diff --git a/.claude/hooks/no-fleet-fork-guard/index.mts b/.claude/hooks/fleet/no-fleet-fork-guard/index.mts similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/index.mts rename to .claude/hooks/fleet/no-fleet-fork-guard/index.mts diff --git a/.claude/hooks/no-fleet-fork-guard/package.json b/.claude/hooks/fleet/no-fleet-fork-guard/package.json similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/package.json rename to .claude/hooks/fleet/no-fleet-fork-guard/package.json diff --git a/.claude/hooks/no-fleet-fork-guard/test/index.test.mts b/.claude/hooks/fleet/no-fleet-fork-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/test/index.test.mts rename to .claude/hooks/fleet/no-fleet-fork-guard/test/index.test.mts diff --git a/.claude/hooks/path-regex-normalize-reminder/tsconfig.json b/.claude/hooks/fleet/no-fleet-fork-guard/tsconfig.json similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/tsconfig.json rename to .claude/hooks/fleet/no-fleet-fork-guard/tsconfig.json diff --git a/.claude/hooks/no-meta-comments-guard/README.md b/.claude/hooks/fleet/no-meta-comments-guard/README.md similarity index 100% rename from .claude/hooks/no-meta-comments-guard/README.md rename to .claude/hooks/fleet/no-meta-comments-guard/README.md diff --git a/.claude/hooks/no-meta-comments-guard/index.mts b/.claude/hooks/fleet/no-meta-comments-guard/index.mts similarity index 100% rename from .claude/hooks/no-meta-comments-guard/index.mts rename to .claude/hooks/fleet/no-meta-comments-guard/index.mts diff --git a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md new file mode 100644 index 0000000..909dd21 --- /dev/null +++ b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md @@ -0,0 +1,34 @@ +# no-meta-comments-guard + +`PreToolUse(Edit|Write)` hook. Blocks source-file edits that introduce a comment which either: + +1. **References the current task / plan / user request** rather than the code's runtime semantics — e.g. `// Plan: use the cache here` / `// Task: rename foo to bar` / `// Per the task instructions, swap to async` / `// As requested, add retry`. + +2. **Describes code that was removed** rather than code that exists — e.g. `// removed: old behavior used a Map here` / `// previously called X` / `// used to be sync, made async in 6.0`. + +Per CLAUDE.md "Code style → Comments": comments default to none; when written, they explain the **constraint** or the **hidden invariant**, not the development context. Development context (the plan, the task, the user request, removed code) goes in commit messages and PR descriptions, not source comments. + +## The comment is usually useful — it's the prefix that's noise + +When the hook fires on a `Plan:` / `Task:` style comment, the suggested fix **strips the meta prefix and keeps the underlying explanation**: + +``` +Saw: // Plan: use the cache to avoid re-resolving +Suggest: // Use the cache to avoid re-resolving +``` + +The agent gets to keep the useful "why" — drop the meta-label. + +For removed-code references the suggestion is to delete entirely (the info lives in git history). + +## File scope + +Only matches source files: `.{m,c,}{j,t}sx?`, `.cc`, `.cpp`, `.h`, `.hpp`, `.rs`, `.go`, `.py`, `.sh`. Markdown / JSON / YAML aren't checked — those file types use `#` / `//` / `*` as legitimate body content, not as comment markers. + +## Bypass + +There's no canonical bypass phrase. The fix is to rewrite the comment per the suggestion. If you genuinely need the comment to read as-is (rare — usually means the explanation is missing important context), the hook can be temporarily disabled via `SOCKET_NO_META_COMMENTS_DISABLED=1` for the session. + +## Source of truth + +The rule itself lives in [`CLAUDE.md`](../../../CLAUDE.md) under "Code style → Comments". This hook enforces it at edit time. diff --git a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts new file mode 100644 index 0000000..895d831 --- /dev/null +++ b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts @@ -0,0 +1,358 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-meta-comments-guard. +// +// Blocks Edit/Write tool calls that introduce a comment which: +// +// (a) References the current task / plan / user request rather +// than the code's runtime semantics: +// // Plan: use the cache here +// // Task: rename foo to bar +// // Per the task instructions, swap to async +// // As requested, add retry +// // TODO from the brief: handle Win32 +// +// (b) Describes code that was removed rather than code that +// exists: +// // removed: old behavior used a Map here +// // previously called X; now Y +// // used to be sync, made async in 6.0 +// // no longer using fetch — see commit abc1234 +// +// Per CLAUDE.md "Code style → Comments": comments default to none; +// when written, audience is a junior dev — explain the CONSTRAINT +// or the hidden invariant, not the development context (commit +// messages and PR descriptions are where development context goes). +// +// On block, emits a stderr suggestion stripping the meta prefix so +// the agent can keep the explanation if it's actually useful and +// just drop the noise. Example transform: +// +// // Plan: use the cache to avoid re-resolving → // Use the cache to avoid re-resolving +// +// Reads a Claude Code PreToolUse JSON payload from stdin: +// { "tool_name": "Edit"|"Write", +// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } +// +// Exit codes: +// 0 — pass (not Edit/Write, no meta comments). +// 2 — block (at least one meta-comment pattern found). +// +// Fails open on malformed payloads (exit 0 + stderr log). + +import process from 'node:process' + +import { splitLines, walkComments } from '../_shared/acorn/index.mts' + +interface ToolInput { + readonly tool_input?: + | { + readonly content?: string | undefined + readonly file_path?: string | undefined + readonly new_string?: string | undefined + } + | undefined + readonly tool_name?: string | undefined +} + +interface MetaCommentFinding { + readonly kind: 'task' | 'removed-code' + readonly line: number + readonly snippet: string + readonly suggestion: string +} + +// Task / plan / user-request references. +// +// Patterns are anchored on `// `, `/* `, `# `, ` * `, ` - ` (markdown +// bullet inside comment) so we don't false-positive on identifiers +// or string literals containing the words. +// +// `Plan:` / `Task:` are case-insensitive leading labels. The free- +// form phrases (`per the task`, `as requested`) match anywhere in +// the comment body — those are the dead-give-away tells, not the +// rest of the sentence. +const TASK_PATTERNS: ReadonlyArray<{ + readonly re: RegExp + readonly stripPrefix?: RegExp | undefined +}> = [ + // `// Plan: ...` / `// Task: ...` / `// Note from plan: ...` + { + re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:plan|task|note from (?:brief|plan|task))\s*:/i, + stripPrefix: + /^(\s*(?:\/\/|\/\*|\*|#|-)\s*)(?:plan|task|note from (?:brief|plan|task))\s*:\s*/i, + }, + // `// Per the task ...` / `// Per the plan ...` / `// As requested ...` + { + re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:per the (?:brief|plan|request|spec|task|user)|as requested|per the user('s)? request)\b/i, + }, + // `// TODO from the brief` / `// FIXME per plan` + { + re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:FIXME|TODO|XXX)\s+(?:from|per)\s+(?:the\s+)?(?:brief|plan|request|spec|task|user)\b/i, + }, + // Phase / tier / step markers — `// Tier 1 ...`, `// Phase 10a: + // ...`, `// Step 3 - ...`. These leak the roadmap shape into source + // and rot when the roadmap shifts. Catch as bare labels (followed + // by whitespace + number) OR as `Phase NNN:` / `Step NNN -` colon / + // dash labels. + { + re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\b/i, + stripPrefix: + /^(\s*(?:\/\/|\/\*|\*|#|-)\s*)(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\s*[:.-]?\s*/i, + }, +] + +// Removed-code references. +const REMOVED_CODE_PATTERNS: readonly RegExp[] = [ + // `// removed X` / `// removed: X` + /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*removed\b/i, + // `// previously X` / `// previously called X` + /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*previously\b/i, + // `// used to X` / `// used to be X` + /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*used\s+to\b/i, + // `// no longer X` / `// no longer needed` + /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*no\s+longer\b/i, + // `// formerly X` + /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*formerly\b/i, +] + +/** + * Uppercase the first alphabetic character that follows the comment marker, so + * a stripped `// plan: use the cache` reads as `// Use the cache`. Skips the + * comment marker tokens so they don't count as "first letter". + */ +export function uppercaseFirstLetterAfterMarker(line: string): string { + const m = line.match(/^(\s*(?:\/\/|\/\*|\*|#|-)\s*)([a-zA-Z])/) + if (!m) { + return line + } + const prefix = m[1]! + const firstChar = m[2]! + return prefix + firstChar.toUpperCase() + line.slice(prefix.length + 1) +} + +// Body-only versions of the patterns (no comment-marker prefix — +// the AST walker already gives us the body text). The same TASK_PATTERNS +// and REMOVED_CODE_PATTERNS above retain the marker-prefixed form so the +// non-JS lexical path below can still use them. +const TASK_BODY_PATTERNS: ReadonlyArray<{ + readonly re: RegExp + readonly stripBody?: RegExp | undefined +}> = [ + { + re: /^\s*(?:plan|task|note from (?:brief|plan|task))\s*:/i, + stripBody: /^\s*(?:plan|task|note from (?:brief|plan|task))\s*:\s*/i, + }, + { + re: /^\s*(?:per the (?:brief|plan|request|spec|task|user)|as requested|per the user('s)? request)\b/i, + }, + { + re: /^\s*(?:FIXME|TODO|XXX)\s+(?:from|per)\s+(?:the\s+)?(?:brief|plan|request|spec|task|user)\b/i, + }, + { + re: /^\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\b/i, + stripBody: + /^\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\s*[:.-]?\s*/i, + }, +] + +const REMOVED_CODE_BODY_PATTERNS: readonly RegExp[] = [ + /^\s*removed\b/i, + /^\s*previously\b/i, + /^\s*used\s+to\b/i, + /^\s*no\s+longer\b/i, + /^\s*formerly\b/i, +] + +/** + * AST-based detector for JS/TS/JSX/TSX source. Uses `walkComments` from the + * shared acorn helper to walk just the comment tokens — string-literal mentions + * of `Plan:` / `Task:` etc. don't trigger. + */ +export function findMetaCommentsAst(text: string): MetaCommentFinding[] { + const findings: MetaCommentFinding[] = [] + const lines = splitLines(text) + for (const c of walkComments(text, { comments: true })) { + // Block comments may have multiple meaningful lines; check each + // line of the body individually so the suggestion can name the + // exact offending line. + const bodyLines = splitLines(c.value) + for (let li = 0; li < bodyLines.length; li += 1) { + const body = bodyLines[li]! + // Strip leading ` *` / `*` decorators that JSDoc-style blocks use. + const cleaned = body.replace(/^\s*\*\s?/, '') + const lineNum = c.line + li + const sourceLine = (lines[lineNum - 1] ?? '').trim() + let matched = false + for (const { re, stripBody } of TASK_BODY_PATTERNS) { + if (!re.test(cleaned)) { + continue + } + const stripped = stripBody + ? cleaned.replace(stripBody, '').trim() + : cleaned.trim() + const suggestion = uppercaseFirstLetterAfterMarker( + c.kind === 'Line' ? `// ${stripped}` : `* ${stripped}`, + ) + findings.push({ + kind: 'task', + line: lineNum, + snippet: sourceLine, + suggestion: + suggestion || + '(remove the comment entirely — it has no runtime content)', + }) + matched = true + break + } + if (matched) { + continue + } + for ( + let i = 0, { length } = REMOVED_CODE_BODY_PATTERNS; + i < length; + i += 1 + ) { + const re = REMOVED_CODE_BODY_PATTERNS[i]! + if (!re.test(cleaned)) { + continue + } + findings.push({ + kind: 'removed-code', + line: lineNum, + snippet: sourceLine, + suggestion: + '(remove the comment — code that no longer exists is git-history territory, not source comments)', + }) + break + } + } + } + return findings +} + +/** + * Lexical-regex fallback for non-JS sources (C++, Rust, Go, Python, shell). The + * acorn-wasm parser only understands JS/TS, so for those languages we keep the + * marker-anchored regex scan. False-positives on string-literal mentions of `// + * Plan:` etc. are possible but rare in practice for those language + * conventions. + */ +export function findMetaCommentsLexical(text: string): MetaCommentFinding[] { + const findings: MetaCommentFinding[] = [] + const lines = splitLines(text) + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + for (const { re, stripPrefix } of TASK_PATTERNS) { + if (!re.test(`\n${line}`)) { + continue + } + const stripped = stripPrefix + ? line.replace(stripPrefix, '$1').replace(/\s+/g, ' ').trim() + : line + .trim() + .replace(/^[\s/*#-]+/, '') + .trim() + const suggestion = uppercaseFirstLetterAfterMarker(stripped) + findings.push({ + kind: 'task', + line: i + 1, + snippet: line.trim(), + suggestion: + suggestion || + '(remove the comment entirely — it has no runtime content)', + }) + break + } + for (let i = 0, { length } = REMOVED_CODE_PATTERNS; i < length; i += 1) { + const re = REMOVED_CODE_PATTERNS[i]! + if (!re.test(`\n${line}`)) { + continue + } + findings.push({ + kind: 'removed-code', + line: i + 1, + snippet: line.trim(), + suggestion: + '(remove the comment — code that no longer exists is git-history territory, not source comments)', + }) + break + } + } + return findings +} + +const JS_TS_FILE_RE = /\.(?:[cm]?[jt]sx?)$/ + +export function findMetaComments( + text: string, + filePath: string, +): MetaCommentFinding[] { + return JS_TS_FILE_RE.test(filePath) + ? findMetaCommentsAst(text) + : findMetaCommentsLexical(text) +} + +let payloadRaw = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', chunk => { + payloadRaw += chunk +}) +process.stdin.on('end', () => { + try { + let payload: ToolInput + try { + payload = JSON.parse(payloadRaw) as ToolInput + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + process.exit(0) + } + const filePath = payload.tool_input?.file_path ?? '' + // Only check source files. Markdown / json / yaml don't have + // "code comments" in the relevant sense — those file types use + // the same prefix tokens (`#`, `//`, `*`) as legitimate body + // content, not as comment markers. + if (!/\.(?:[cm]?[jt]sx?|cc|cpp|h|hpp|rs|go|py|sh)$/.test(filePath)) { + process.exit(0) + } + const text = + payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' + if (!text) { + process.exit(0) + } + + const findings = findMetaComments(text, filePath) + if (findings.length === 0) { + process.exit(0) + } + + const lines: string[] = [] + lines.push('[no-meta-comments-guard] Blocked: meta-comment(s) in source.') + lines.push(` File: ${filePath}`) + lines.push('') + for (let i = 0, { length } = findings; i < length; i += 1) { + const f = findings[i]! + lines.push(` Line ${f.line} (${f.kind}):`) + lines.push(` Saw: ${f.snippet}`) + lines.push(` Suggest: ${f.suggestion}`) + lines.push('') + } + lines.push(' Per CLAUDE.md "Code style → Comments": comments describe the') + lines.push(' CONSTRAINT or the hidden invariant. Development context') + lines.push( + ' (the plan, the task, the user request, removed code) lives in', + ) + lines.push(' commit messages and PR descriptions, not source comments.') + lines.push('') + lines.push(' Rewrite or delete the comment, then retry the Edit/Write.') + process.stderr.write(lines.join('\n') + '\n') + process.exit(2) + } catch (e) { + process.stderr.write( + `[no-meta-comments-guard] hook error (allowing): ${e}\n`, + ) + process.exit(0) + } +}) diff --git a/.claude/hooks/no-meta-comments-guard/package.json b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/package.json similarity index 100% rename from .claude/hooks/no-meta-comments-guard/package.json rename to .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/package.json diff --git a/.claude/hooks/no-meta-comments-guard/test/index.test.mts b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-meta-comments-guard/test/index.test.mts rename to .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/test/index.test.mts diff --git a/.claude/hooks/paths-mts-inherit-guard/tsconfig.json b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/tsconfig.json similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/tsconfig.json rename to .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/tsconfig.json diff --git a/.claude/hooks/fleet/no-meta-comments-guard/package.json b/.claude/hooks/fleet/no-meta-comments-guard/package.json new file mode 100644 index 0000000..8c1e7e4 --- /dev/null +++ b/.claude/hooks/fleet/no-meta-comments-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-meta-comments-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/no-meta-comments-guard/test/index.test.mts b/.claude/hooks/fleet/no-meta-comments-guard/test/index.test.mts new file mode 100644 index 0000000..82aeb4e --- /dev/null +++ b/.claude/hooks/fleet/no-meta-comments-guard/test/index.test.mts @@ -0,0 +1,261 @@ +// node --test specs for the no-meta-comments-guard hook. + +import test from 'node:test' +import assert from 'node:assert/strict' +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +async function runHook(payload: Record): Promise { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +test('non-Edit/Write tool calls pass through', async () => { + const result = await runHook({ + tool_input: { command: 'echo // Plan: do thing' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) + assert.strictEqual(result.stderr, '') +}) + +test('non-source files pass through (markdown / json / yaml)', async () => { + for (const file_path of [ + '/x/docs/readme.md', + '/x/package.json', + '/x/.github/workflows/ci.yml', + ]) { + const result = await runHook({ + tool_input: { + file_path, + new_string: '// Plan: do the thing\nconst x = 1', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 0, file_path) + } +}) + +test('// Plan: prefix is blocked with strip-prefix suggestion', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: + 'const x = 1\n// Plan: use the cache to avoid re-resolving\nconst y = 2', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /Plan/) + assert.match(result.stderr, /Use the cache to avoid re-resolving/) +}) + +test('// Task: prefix is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.mts', + new_string: '// Task: rename foo to bar\nconst bar = 1', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// Per the task instructions ... is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: '// Per the task instructions, swap to async\nawait foo()', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /Per the task/i) +}) + +test('// As requested ... is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: '// As requested, add retry\nawait retry(foo)', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// removed X is blocked (removed-code pattern)', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: + '// removed: old behavior used a Map here\nconst data = new Set()', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /removed-code/) +}) + +test('// previously called X is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: + '// previously called fooSync; now async\nasync function foo() {}', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// used to be sync, made async in 6.0 is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: + '// used to be sync, made async in 6.0\nasync function foo() {}', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// no longer needed because X is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: + '// no longer needed because Node 26 ships this natively\nlet polyfill: unknown', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// Tier 1 implementation. is blocked (phase marker)', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.cc', + new_string: '// Tier 1 implementation. Mirrors upstream X.\nint x = 1;', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /Tier 1/) +}) + +test('// Tier 2 surface — mirrors ... is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.hpp', + new_string: '// Tier 2 surface — mirrors OpenTUI.\nclass Foo {};', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// Phase 10a: temporal_rs shim ... is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: '// Phase 10a: temporal_rs shim Instant\nconst x = 1', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// Step 3 - parser rejection is blocked', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.go', + new_string: '// Step 3 - parser rejection\nx := 1', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// Milestone V achievable is blocked (Roman numeral phase)', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: '// Milestone V achievable now\nconst x = 1', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('// "tier" inside content (not a phase marker) passes through', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: + '// Cache tier selection happens in resolveTier()\nconst t = 0', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`) +}) + +test('normal explanatory comments pass through', async () => { + for (const text of [ + '// Use the cache to avoid re-resolving on every call.\nconst cache = new Map()', + "// Falls back to the JS impl when smol-versions isn't available.\nconst v = getSmol()", + '// V8 inlines this when the call site is monomorphic.\nfunction hot() {}', + '/* Multi-line block comments describing the invariant\n are also fine. */\nfunction f() {}', + ]) { + const result = await runHook({ + tool_input: { file_path: '/x/src/foo.ts', new_string: text }, + tool_name: 'Edit', + }) + assert.strictEqual( + result.code, + 0, + `Expected pass for: ${text.slice(0, 60)}…\n stderr: ${result.stderr}`, + ) + } +}) + +test('multiple findings in one file are all surfaced', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/src/foo.ts', + new_string: + '// Plan: use the cache\nconst x = 1\n// removed: old impl was sync\nconst y = 2', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /Plan/) + assert.match(result.stderr, /removed-code/) + // Both line numbers should appear in the output. + assert.match(result.stderr, /Line 1/) + assert.match(result.stderr, /Line 3/) +}) diff --git a/.claude/hooks/perfectionist-reminder/tsconfig.json b/.claude/hooks/fleet/no-meta-comments-guard/tsconfig.json similarity index 100% rename from .claude/hooks/perfectionist-reminder/tsconfig.json rename to .claude/hooks/fleet/no-meta-comments-guard/tsconfig.json diff --git a/.claude/hooks/fleet/no-non-fleet-push-guard/README.md b/.claude/hooks/fleet/no-non-fleet-push-guard/README.md new file mode 100644 index 0000000..332dccb --- /dev/null +++ b/.claude/hooks/fleet/no-non-fleet-push-guard/README.md @@ -0,0 +1,81 @@ +# no-non-fleet-push-guard + +PreToolUse(Bash) hook that blocks `git push` to a repository outside the +fleet. + +## Why + +The fleet's git-side pre-push hook only exists in repos that installed +the fleet hook chain. A non-fleet repo (a personal checkout, a sibling +project like `depot`) has no such hook, so a stray `cd /…/depot && git +push` sails straight through. The block has to live agent-side, before +the command runs, and resolve the target repo against the fleet roster. + +Past incident: an agent `cd`-ed into `depot` (not a fleet repo) and +pushed a fleet-convention change to its `main`. The push succeeded +because depot has no fleet pre-push hook. This guard is the response. + +## What it blocks + +| Command shape | Resolves target via | Block? | +| ------------------------------------------ | ------------------- | ------ | +| `git push` (in a fleet repo cwd) | process cwd | no | +| `git push` (in a non-fleet repo cwd) | process cwd | yes | +| `cd /path/to/depot && git push` | leading `cd` | yes | +| `git -C /path/to/depot push` | `-C` flag | yes | +| `echo "git push"` / commit msg saying push | (not a push) | no | +| `git push` where `origin` is unresolvable | (fail open) | no | + +Fleet membership is the broad set in +[`_shared/fleet-repos.mts`](../_shared/fleet-repos.mts) (`FLEET_REPO_NAMES`), +which includes `ultrathink` and other members the narrower cascade +roster (`cascading-fleet/lib/fleet-repos.json`) omits. Gating on the +broad set is deliberate: a fleet member is pushable even if it isn't a +cascade target. + +## Target-directory resolution + +In priority order: + +1. `git -C push …` — the explicit `-C` dir. +2. A leading `cd ` in the command chain (`cd X && git push`), + resolved against the process cwd for relative paths. +3. The hook's process cwd. + +Then `git -C remote get-url origin` → slug via `slugFromRemoteUrl` +→ `isFleetRepo(slug)`. + +## Fail-open + +Any resolution ambiguity (no `git push` found, dir unreadable, no +`origin`, unparseable remote URL) → allow. Under-blocking is recoverable +(the operator reverts a stray push); a false block wedges a valid +workflow. The guard only fires when it can positively identify a +non-fleet origin slug. + +## Bypass + +Type the canonical phrase in a new message: + + Allow non-fleet-push bypass + +Use for a genuine push to a personal / non-fleet repo you own. + +## Detection: shell parser, not regex + +`git push` detection goes through the shared shell parser +([`_shared/shell-command.mts`](../_shared/shell-command.mts), which wraps +`shell-quote`), not a regex. The parser splits the command line into +segments and reads the binary + subcommand at each position, so it sees +through: + +- `&&` / `||` / `;` / `|` chains (`cd /x && git push`) +- `$(…)` command substitution (`git push $(echo origin)`) +- quoted bodies (`git commit -m "git push later"` is NOT a push) +- global options before the subcommand (`git -C /x push`) + +Remaining limits of any static parser (shared with +`gh-token-hygiene-guard`): a binary fully sourced from a variable +(`g=git; $g push`) can't be statically resolved to `git` — the parser +FLAGS it as opaque (`hasOpaqueInvocation`) but this guard doesn't act on +that today; and an alias or wrapper script that pushes is out of scope. diff --git a/.claude/hooks/fleet/no-non-fleet-push-guard/index.mts b/.claude/hooks/fleet/no-non-fleet-push-guard/index.mts new file mode 100644 index 0000000..1bb753a --- /dev/null +++ b/.claude/hooks/fleet/no-non-fleet-push-guard/index.mts @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-non-fleet-push-guard. +// +// Blocks `git push` to a repository that is NOT a fleet member. The +// fleet's git-side pre-push hook can't catch this: a non-fleet repo +// never has the fleet hook chain installed (that's exactly how a stray +// push to e.g. `depot` slips through). So the guard lives agent-side, +// inspecting the Bash command before it runs, and resolves the target +// repo's origin remote against the canonical fleet roster. +// +// Detection model: +// - Fires only on Bash commands containing `git push` at an +// executable position (not inside quotes / heredoc bodies — a +// commit message that says "git push" is not a push). +// - Resolves the TARGET directory, in priority order: +// 1. `git -C push …` (explicit -C) +// 2. a leading `cd && …` (the `cd /…/depot && git push` +// shape that bypasses the session cwd) +// 3. the hook's process cwd +// - Reads `git -C remote get-url origin`, extracts the repo +// slug, and blocks when the slug is not in FLEET_REPO_NAMES. +// +// Bypass: `Allow non-fleet-push bypass` typed verbatim in a recent user +// turn — for the rare legitimate push to a personal / non-fleet repo. +// +// Fails OPEN on any resolution ambiguity (can't find the command, the +// dir, or the remote): better to under-block than to wedge a valid +// push when the shape is unfamiliar. The cost of a missed block is one +// `Allow … bypass`-free push the operator can revert; the cost of a +// false block is a bricked workflow. + +import path from 'node:path' +import process from 'node:process' + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +import { isFleetRepo, slugFromRemoteUrl } from '../_shared/fleet-repos.mts' +import { findInvocation } from '../_shared/shell-command.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: { readonly command?: string | undefined } | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow non-fleet-push bypass' + +// `git -C …` — capture the dir (quoted or bare). Still a regex +// because we only need the -C VALUE, not command structure; the push +// DETECTION (which needs structure) goes through the shell parser. +const GIT_DASH_C_RE = /\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/ + +// A leading `cd ` before the push, e.g. `cd /x/depot && git push`. +// Only the FIRST cd in the chain matters for where git runs. +const LEADING_CD_RE = /(?:^|[;&|]|&&)\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/ + +export function extractGitCwd(command: string): string { + // Priority 1: explicit `git -C `. + const dashC = GIT_DASH_C_RE.exec(command) + if (dashC) { + return dashC[2] ?? dashC[3] ?? dashC[4] ?? process.cwd() + } + // Priority 2: a leading `cd ` in the chain. + const cd = LEADING_CD_RE.exec(command) + if (cd) { + const dir = cd[2] ?? cd[3] ?? cd[4] + if (dir) { + // Resolve against process cwd so a relative `cd ../foo` works. + return path.resolve(process.cwd(), dir) + } + } + // Priority 3: the hook's own cwd. + return process.cwd() +} + +export function originSlug(dir: string): string | undefined { + let out: string + try { + const r = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { + encoding: 'utf8', + }) + if (r.status !== 0) { + return undefined + } + out = String(r.stdout ?? '').trim() + } catch { + return undefined + } + return slugFromRemoteUrl(out) +} + +async function main(): Promise { + let raw: string + try { + raw = await readStdin() + } catch { + process.exit(0) + } + if (!raw) { + process.exit(0) + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.exit(0) + } + + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = payload.tool_input?.command + if (!command) { + process.exit(0) + } + + // Detect `git push` via the shell parser (not regex): it splits the + // command line into segments, sees through `&&`/`|`/`;` chains and + // `$(…)` substitution, and ignores `push` inside a quoted commit + // message — so `git commit -m "git push later"` is correctly NOT a + // push, while `cd /x && git push` and `git -C /x push` are. + if (!findInvocation(command, { binary: 'git', subcommand: 'push' })) { + process.exit(0) + } + + const dir = extractGitCwd(command) + const slug = originSlug(dir) + + // Fail open: no resolvable origin slug → can't classify, allow. + if (!slug) { + process.exit(0) + } + if (isFleetRepo(slug)) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + process.stderr.write( + [ + '[no-non-fleet-push-guard] Blocked: push to a non-fleet repository', + '', + ` Target dir: ${dir}`, + ` origin repo: ${slug}`, + '', + ` \`${slug}\` is not in the fleet roster, and fleet tooling must`, + ' not push to repos outside the fleet. A non-fleet repo has no', + ' fleet hook chain, so this agent-side guard is the only check', + ' standing between you and a stray push to someone else’s repo.', + '', + ' If this push is wrong: you probably `cd`-ed into the wrong repo', + ' or have the wrong `origin`. Verify with:', + ` git -C ${dir} remote get-url origin`, + '', + ` If the push is genuinely intended (a personal / non-fleet repo`, + ` you own), type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[no-non-fleet-push-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/fleet/no-non-fleet-push-guard/package.json b/.claude/hooks/fleet/no-non-fleet-push-guard/package.json new file mode 100644 index 0000000..4f2d28d --- /dev/null +++ b/.claude/hooks/fleet/no-non-fleet-push-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-non-fleet-push-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/no-non-fleet-push-guard/test/index.test.mts b/.claude/hooks/fleet/no-non-fleet-push-guard/test/index.test.mts new file mode 100644 index 0000000..9371ea6 --- /dev/null +++ b/.claude/hooks/fleet/no-non-fleet-push-guard/test/index.test.mts @@ -0,0 +1,171 @@ +// node --test specs for the no-non-fleet-push-guard hook. + +// prefer-async-spawn: streaming-stdio-required — test spawns the hook +// subprocess and pipes stdin/stdout/stderr. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +// prefer-spawn-over-execsync: required -- test asserts the hook's behavior under a synchronous execFileSync call path. +import { execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +// Make a throwaway git repo with the given origin URL, return its path. +function gitRepoWithOrigin(originUrl: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'nfp-guard-')) + const run = (...args: string[]) => + execFileSync('git', ['-C', dir, ...args], { stdio: 'ignore' }) + run('init', '-q') + run('remote', 'add', 'origin', originUrl) + return dir +} + +// A dir that is NOT a git repo (no origin) — for the fail-open case. +function nonGitDir(): string { + return mkdtempSync(path.join(os.tmpdir(), 'nfp-nongit-')) +} + +async function runHook( + payload: Record, + cwd?: string, +): Promise { + const child = spawn(process.execPath, [HOOK], { cwd, stdio: 'pipe' }) + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +const bash = (command: string) => ({ + tool_name: 'Bash', + tool_input: { command }, +}) + +test('non-Bash tool passes', async () => { + const r = await runHook({ tool_name: 'Edit', tool_input: { command: 'x' } }) + assert.strictEqual(r.code, 0) +}) + +test('Bash without git push passes', async () => { + const r = await runHook(bash('ls -la && echo hi')) + assert.strictEqual(r.code, 0) +}) + +test('fleet repo via cwd — git push allowed', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/socket-cli.git') + const r = await runHook(bash('git push origin main'), dir) + assert.strictEqual(r.code, 0) +}) + +test('non-fleet repo via cwd — git push BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const r = await runHook(bash('git push origin main'), dir) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('non-fleet repo via leading cd — BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + // cwd is a fleet repo; the cd redirects git into the non-fleet one. + const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') + const r = await runHook(bash(`cd ${dir} && git push origin main`), fleetCwd) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('non-fleet repo via git -C — BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') + const r = await runHook(bash(`git -C ${dir} push origin main`), fleetCwd) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('ultrathink (fleet member, not in cascade roster) — allowed', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/ultrathink.git') + const r = await runHook(bash('git push'), dir) + assert.strictEqual(r.code, 0) +}) + +test('HTTPS remote, non-fleet — BLOCKED', async () => { + const dir = gitRepoWithOrigin('https://github.com/SocketDev/depot.git') + const r = await runHook(bash('git push origin main'), dir) + assert.strictEqual(r.code, 2) +}) + +test('fork under another owner of a fleet name — allowed (slug matches)', async () => { + // slug is keyed on repo name; a socket-cli fork still resolves to a + // fleet slug. (Owner-level gating is out of scope; the name is the key.) + const dir = gitRepoWithOrigin('git@github.com:someuser/socket-cli.git') + const r = await runHook(bash('git push'), dir) + assert.strictEqual(r.code, 0) +}) + +test('git push mentioned only in a quoted commit message — not a push', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const r = await runHook( + bash(`git commit -m "remember to git push later"`), + dir, + ) + assert.strictEqual(r.code, 0) +}) + +test('non-git dir (no origin) — fail open, allowed', async () => { + const dir = nonGitDir() + const r = await runHook(bash('git push'), dir) + assert.strictEqual(r.code, 0) +}) + +test('substitution: git $(printf push) to a non-fleet repo — BLOCKED', async () => { + // The shell parser surfaces `git push` even when the subcommand is + // produced by a $(…) substitution — a form the old regex missed. + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const r = await runHook(bash('git push $(echo origin) main'), dir) + assert.strictEqual(r.code, 2) + assert.ok(r.stderr.includes('depot')) +}) + +test('pipe/chain push to non-fleet repo — BLOCKED', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') + const r = await runHook( + bash(`echo start && cd ${dir} && git push origin main`), + fleetCwd, + ) + assert.strictEqual(r.code, 2) +}) + +test('bypass phrase in transcript — non-fleet push allowed', async () => { + const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') + const txDir = mkdtempSync(path.join(os.tmpdir(), 'nfp-tx-')) + const transcriptPath = path.join(txDir, 'session.jsonl') + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'user', + message: { content: 'Allow non-fleet-push bypass' }, + }) + '\n', + ) + const r = await runHook( + { + ...bash('git push origin main'), + transcript_path: transcriptPath, + }, + dir, + ) + assert.strictEqual(r.code, 0) +}) diff --git a/.claude/hooks/plan-location-guard/tsconfig.json b/.claude/hooks/fleet/no-non-fleet-push-guard/tsconfig.json similarity index 100% rename from .claude/hooks/plan-location-guard/tsconfig.json rename to .claude/hooks/fleet/no-non-fleet-push-guard/tsconfig.json diff --git a/.claude/hooks/no-orphaned-staging/README.md b/.claude/hooks/fleet/no-orphaned-staging/README.md similarity index 100% rename from .claude/hooks/no-orphaned-staging/README.md rename to .claude/hooks/fleet/no-orphaned-staging/README.md diff --git a/.claude/hooks/no-orphaned-staging/index.mts b/.claude/hooks/fleet/no-orphaned-staging/index.mts similarity index 100% rename from .claude/hooks/no-orphaned-staging/index.mts rename to .claude/hooks/fleet/no-orphaned-staging/index.mts diff --git a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md new file mode 100644 index 0000000..f12eb5f --- /dev/null +++ b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md @@ -0,0 +1,49 @@ +# no-orphaned-staging + +Stop hook. Fires at turn-end and lists any files that are staged +(`git diff --cached --name-only`) but not yet committed. + +## Why + +Fleet rule from CLAUDE.md ("Don't leave the worktree dirty"): + +> Stage only when you're about to commit. `git add` and `git commit` +> belong on the same line (chained with `&&`) OR in the same Bash +> call. Don't stage as a side-effect of "preparing" — staging is a +> commit-time action. + +A turn that ends with staged-but-uncommitted hunks is the failure +mode the rule warns against. Common causes: + +1. The agent ran `git add` but forgot the `git commit`. +2. A pre-commit hook failed and left the index half-cooked. +3. The agent staged "for later" — exactly what this rule forbids. + +All three look identical to the next session: a populated index of +unknown provenance. The reminder makes the dangling state visible +at the turn that created it. + +## Output + +Stderr only. Exit code always 0 — informational, never blocks +(Stop hooks can't refuse anything anyway; the turn already ended). + +``` +[no-orphaned-staging] Turn ended with staged-but-uncommitted files: + - scripts/foo.mts + - template/CLAUDE.md + ... and 3 more + +Fleet rule: stage only when about to commit. Either: + • Run `git commit` to finish the work, OR + • Run `git reset` to unstage (keep changes in working tree). + +CLAUDE.md → "Don't leave the worktree dirty" → "Stage only when +you're about to commit". +``` + +## Disable + +`SOCKET_NO_ORPHANED_STAGING_DISABLED=1` in the env. Use during +intentional mid-refactor pauses or worktree migrations where staged +state is the work-product. diff --git a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts new file mode 100644 index 0000000..7fab1d6 --- /dev/null +++ b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// Claude Code Stop hook — no-orphaned-staging. +// +// Fires at turn-end. Checks `git diff --cached --name-only` in +// $CLAUDE_PROJECT_DIR. If anything is staged but uncommitted, emits +// a stderr warning listing the orphaned paths. +// +// The fleet rule (CLAUDE.md "Don't leave the worktree dirty"): +// +// Stage only when you're about to commit. `git add` and `git +// commit` belong on the same line (chained with `&&`) OR in the +// same Bash call. Don't stage as a side-effect of "preparing" +// — staging is a commit-time action. +// +// A turn that ends with staged-but-uncommitted hunks tends to be +// either: +// (a) the agent forgot the commit half of `git add && git commit`, +// (b) a failed pre-commit hook unstuck the index, or +// (c) the agent staged "for later" — exactly what this rule +// forbids. +// +// All three are the same failure mode: the next session sees an +// already-staged index and has to figure out the intent. The +// reminder makes the dangling state visible at the very turn that +// created it. +// +// Why a reminder, not a block: Stop hooks fire AFTER the turn ended; +// there's no tool call to refuse. The signal goes to stderr so the +// next message includes the warning. The agent can then either +// commit or explicitly explain why the staged state is intentional. +// +// Exit codes: +// 0 — always. This is informational; never blocks. +// +// Disabled via `SOCKET_NO_ORPHANED_STAGING_DISABLED=1`. + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import process from 'node:process' + +export async function drainStdin(): Promise { + // Stop payloads carry transcript_path; this hook doesn't need it, + // but the stdin must be drained so the harness doesn't pipe-stall. + await new Promise(resolve => { + let chunks = '' + process.stdin.on('data', d => { + chunks += d.toString('utf8') + }) + process.stdin.on('end', () => resolve()) + process.stdin.on('error', () => resolve()) + setTimeout(() => resolve(), 200) + void chunks + }) +} + +export function getProjectDir(): string | undefined { + // Prefer the harness-supplied env (correct even when cwd has been + // chdir'd by a tool). Fall back to cwd. + return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() +} + +export function listStagedFiles(repoDir: string): string[] { + const r = spawnSync('git', ['diff', '--cached', '--name-only'], { + cwd: repoDir, + timeout: 5_000, + }) + if (r.status !== 0) { + return [] + } + return String(r.stdout) + .split('\n') + .map((s: string) => s.trim()) + .filter(Boolean) +} + +async function main(): Promise { + if (process.env['SOCKET_NO_ORPHANED_STAGING_DISABLED']) { + return + } + await drainStdin() + + const repoDir = getProjectDir() + if (!repoDir) { + return + } + + const staged = listStagedFiles(repoDir) + if (staged.length === 0) { + return + } + + process.stderr.write( + '[no-orphaned-staging] Turn ended with staged-but-uncommitted files:\n', + ) + for (const f of staged.slice(0, 10)) { + process.stderr.write(` - ${f}\n`) + } + if (staged.length > 10) { + process.stderr.write(` ... and ${staged.length - 10} more\n`) + } + process.stderr.write( + '\nFleet rule: stage only when about to commit. Either:\n' + + ' • Run `git commit` to finish the work, OR\n' + + ' • Run `git reset` to unstage (keep changes in working tree).\n' + + '\nCLAUDE.md → "Don\'t leave the worktree dirty" → "Stage only when ' + + 'you\'re about to commit".\n', + ) +} + +main().catch(e => { + process.stderr.write( + `[no-orphaned-staging] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) +}) diff --git a/.claude/hooks/no-orphaned-staging/package.json b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/package.json similarity index 100% rename from .claude/hooks/no-orphaned-staging/package.json rename to .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/package.json diff --git a/.claude/hooks/no-orphaned-staging/test/index.test.mts b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/test/index.test.mts similarity index 100% rename from .claude/hooks/no-orphaned-staging/test/index.test.mts rename to .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/test/index.test.mts diff --git a/.claude/hooks/plan-review-reminder/tsconfig.json b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/tsconfig.json similarity index 100% rename from .claude/hooks/plan-review-reminder/tsconfig.json rename to .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/tsconfig.json diff --git a/.claude/hooks/fleet/no-orphaned-staging/test/index.test.mts b/.claude/hooks/fleet/no-orphaned-staging/test/index.test.mts new file mode 100644 index 0000000..8b55414 --- /dev/null +++ b/.claude/hooks/fleet/no-orphaned-staging/test/index.test.mts @@ -0,0 +1,127 @@ +/** + * @file Unit tests for no-orphaned-staging hook. Test strategy: create a temp + * git repo, stage a file (or not), spawn the hook with CLAUDE_PROJECT_DIR + * pointed at the temp repo, and inspect stderr. + */ + +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, test } from 'node:test' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(__dirname, '..', 'index.mts') + +interface RunResult { + code: number + stderr: string +} + +function runHook(env: Record): RunResult { + const r = spawnSync('node', [HOOK], { + input: '{}', + env: { ...process.env, ...env }, + }) + return { + code: typeof r.status === 'number' ? r.status : 0, + stderr: String(r.stderr || ''), + } +} + +function git(repoDir: string, args: string[]): void { + const r = spawnSync('git', args, { cwd: repoDir }) + if (r.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`) + } +} + +let tmpRepo: string + +beforeEach(() => { + tmpRepo = mkdtempSync(path.join(os.tmpdir(), 'no-orphaned-staging-')) + git(tmpRepo, ['init', '-q']) + git(tmpRepo, ['config', 'user.email', 'test@example.com']) + git(tmpRepo, ['config', 'user.name', 'Test']) + writeFileSync(path.join(tmpRepo, 'README.md'), '# test\n') + git(tmpRepo, ['add', 'README.md']) + git(tmpRepo, ['commit', '-q', '-m', 'initial']) +}) + +afterEach(() => { + rmSync(tmpRepo, { recursive: true, force: true }) +}) + +describe('no-orphaned-staging', () => { + test('clean index → silent', () => { + const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') + }) + + test('staged file → warning', () => { + writeFileSync(path.join(tmpRepo, 'foo.txt'), 'staged content\n') + git(tmpRepo, ['add', 'foo.txt']) + const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) + assert.equal(r.code, 0) + assert.match(r.stderr, /no-orphaned-staging/) + assert.match(r.stderr, /foo\.txt/) + }) + + test('multiple staged files listed', () => { + for (const name of ['a.txt', 'b.txt', 'c.txt']) { + writeFileSync(path.join(tmpRepo, name), `${name}\n`) + git(tmpRepo, ['add', name]) + } + const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) + assert.equal(r.code, 0) + for (const name of ['a.txt', 'b.txt', 'c.txt']) { + assert.match(r.stderr, new RegExp(name)) + } + }) + + test('disabled via env → silent even when staged', () => { + writeFileSync(path.join(tmpRepo, 'foo.txt'), 'staged content\n') + git(tmpRepo, ['add', 'foo.txt']) + const r = runHook({ + CLAUDE_PROJECT_DIR: tmpRepo, + SOCKET_NO_ORPHANED_STAGING_DISABLED: '1', + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') + }) + + test('non-repo dir → silent (not a git repo)', () => { + const nonRepo = mkdtempSync(path.join(os.tmpdir(), 'not-a-repo-')) + try { + const r = runHook({ CLAUDE_PROJECT_DIR: nonRepo }) + assert.equal(r.code, 0) + // git returns non-zero exit + the helper returns empty list. + assert.equal(r.stderr, '') + } finally { + rmSync(nonRepo, { recursive: true, force: true }) + } + }) + + test('truncates listing past 10 files', () => { + for (let i = 0; i < 15; i += 1) { + const name = `f${i}.txt` + writeFileSync(path.join(tmpRepo, name), `${name}\n`) + git(tmpRepo, ['add', name]) + } + const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) + assert.match(r.stderr, /and 5 more/) + }) + + test('fail-open on hook bug', () => { + // Empty stdin would normally drain; verifying the hook doesn't + // crash on missing-env-vars or other edge cases. + const r = spawnSync('node', [HOOK], { + input: '', + env: { ...process.env, CLAUDE_PROJECT_DIR: '/nonexistent/path' }, + }) + assert.equal(r.status, 0) + }) +}) diff --git a/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/README.md b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/README.md new file mode 100644 index 0000000..acffb60 --- /dev/null +++ b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/README.md @@ -0,0 +1,55 @@ +# no-package-json-pnpm-overrides-guard + +PreToolUse Edit/Write hook that blocks adding (or expanding) a +`pnpm.overrides` block in any `package.json`. + +## Why + +pnpm reads dependency overrides from two places: `pnpm.overrides` in +`package.json`, or the top-level `overrides:` map in `pnpm-workspace.yaml`. +The fleet standardizes on the workspace file as the single override surface. + +A `pnpm.overrides` block in package.json splits the source of truth: a +reviewer auditing pins now has to check two files, and the workspace file's +`trustPolicy: no-downgrade` only governs the overrides declared there. An +override hiding in a package.json can silently downgrade a transitive dep +past the trust policy. + +## What it blocks + +| Pattern | Block? | +| ------------------------------------------------------------------ | ------ | +| Edit/Write that adds a key under `pnpm.overrides` in package.json | yes | +| Edit/Write that removes a key from `pnpm.overrides` | no | +| Edit/Write touching package.json but not `pnpm.overrides` | no | +| Edit/Write to `pnpm-workspace.yaml` `overrides:` (the right place) | no | +| Edit/Write to any other file | no | + +## Bypass + +Type the canonical phrase in a new message: + + Allow package-json-overrides bypass + +Rare legitimate case: a published package that ships its own +`pnpm.overrides` you're vendoring verbatim and must not rewrite. + +## Detection + +The hook parses both the current package.json and the after-edit contents +as JSON, reads `pnpm.overrides`, and computes the set difference of override +keys. Keys added → block. Keys removed or unchanged → pass. + +Fails open on JSON parse errors: better to under-block than to brick edits +when the file is in a transient bad state. + +## Fix + +Move the override to the top-level `overrides:` map in `pnpm-workspace.yaml`, +then `pnpm install`: + +```yaml +# pnpm-workspace.yaml +overrides: + some-dep: '>=1.2.3' +``` diff --git a/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts new file mode 100644 index 0000000..85cb6bf --- /dev/null +++ b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts @@ -0,0 +1,179 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-package-json-pnpm-overrides-guard. +// +// Blocks Edit/Write operations that add (or expand) a `pnpm.overrides` +// block in any `package.json`. The fleet keeps dependency overrides in +// `pnpm-workspace.yaml` `overrides:` as the single source of truth. A +// `pnpm.overrides` block in package.json splits that surface and sits +// outside the workspace file's `trustPolicy: no-downgrade` governance. +// +// Detection model: +// - Fires only on Edit / Write to files named `package.json`. +// - Parses before + after JSON. Reports the override keys that are +// present in the after-state but absent (or fewer) in the before. +// - New / expanded `pnpm.overrides` → block. +// +// Bypass: `Allow package-json-overrides bypass` typed verbatim in a +// recent user turn. +// +// Fails open on parse errors (better to under-block than to brick edits +// when the file isn't parseable JSON). + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly old_string?: string | undefined + readonly content?: string | undefined + } + | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow package-json-overrides bypass' + +// Extract the set of override keys declared under `pnpm.overrides` in a +// package.json text. Returns an empty set when the block is absent, the +// text isn't valid JSON, or `pnpm.overrides` isn't an object. pnpm reads +// overrides from `pnpm.overrides` (package.json) or top-level `overrides` +// (pnpm-workspace.yaml); this guard targets the package.json form only. +export function extractOverrideKeys(jsonText: string): Set { + const out = new Set() + let parsed: unknown + try { + parsed = JSON.parse(jsonText) + } catch { + return out + } + if (!parsed || typeof parsed !== 'object') { + return out + } + const pnpm = (parsed as { pnpm?: unknown | undefined }).pnpm + if (!pnpm || typeof pnpm !== 'object') { + return out + } + const overrides = (pnpm as { overrides?: unknown | undefined }).overrides + if (!overrides || typeof overrides !== 'object') { + return out + } + for (const key of Object.keys(overrides as Record)) { + out.add(key) + } + return out +} + +export function readFileSafe(p: string): string { + try { + return readFileSync(p, 'utf8') + } catch { + return '' + } +} + +async function main(): Promise { + let raw: string + try { + raw = await readStdin() + } catch { + process.exit(0) + } + if (!raw) { + process.exit(0) + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.exit(0) + } + + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + process.exit(0) + } + const input = payload.tool_input + const filePath = input?.file_path + if (!filePath || path.basename(filePath) !== 'package.json') { + process.exit(0) + } + + const currentText = readFileSafe(filePath) + let afterText: string + if (payload.tool_name === 'Write') { + afterText = input?.content ?? input?.new_string ?? '' + } else { + const oldStr = input?.old_string ?? '' + const newStr = input?.new_string ?? '' + if (!oldStr) { + process.exit(0) + } + if (!currentText.includes(oldStr)) { + process.exit(0) + } + afterText = currentText.replace(oldStr, newStr) + } + + let beforeKeys: Set + let afterKeys: Set + try { + beforeKeys = extractOverrideKeys(currentText) + afterKeys = extractOverrideKeys(afterText) + } catch (e) { + process.stderr.write( + `[no-package-json-pnpm-overrides-guard] parse error (allowing): ${e}\n`, + ) + process.exit(0) + } + + const added: string[] = [] + for (const key of afterKeys) { + if (!beforeKeys.has(key)) { + added.push(key) + } + } + if (added.length === 0) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + added.sort() + process.stderr.write( + [ + '[no-package-json-pnpm-overrides-guard] Blocked: package.json pnpm.overrides additions', + '', + ` File: ${filePath}`, + ` New entries: ${added.map(k => `\`${k}\``).join(', ')}`, + '', + ' The fleet keeps dependency overrides in `pnpm-workspace.yaml`', + ' `overrides:`, the single override surface. A `pnpm.overrides`', + ' block in package.json splits the source of truth and sits', + ' outside the workspace file’s `trustPolicy: no-downgrade`.', + '', + ' Fix: move the override to the top-level `overrides:` map in', + ' `pnpm-workspace.yaml`, then `pnpm install`.', + '', + ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[no-package-json-pnpm-overrides-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/package.json b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/package.json new file mode 100644 index 0000000..eeb28c3 --- /dev/null +++ b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-package-json-pnpm-overrides-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/test/index.test.mts b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/test/index.test.mts new file mode 100644 index 0000000..616ff54 --- /dev/null +++ b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/test/index.test.mts @@ -0,0 +1,147 @@ +// node --test specs for the no-package-json-pnpm-overrides-guard hook. + +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +function tmpPackageJson(content: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-test-')) + const p = path.join(dir, 'package.json') + writeFileSync(p, content) + return p +} + +async function runHook(payload: Record): Promise { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +test('non-Edit/Write tool passes', async () => { + const r = await runHook({ + tool_name: 'Bash', + tool_input: { command: 'echo hi' }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit to a non-package.json file passes', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-other-')) + const filePath = path.join(dir, 'pnpm-workspace.yaml') + writeFileSync(filePath, 'overrides:\n foo: 1.0.0\n') + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: 'foo: 1.0.0', + new_string: 'foo: 2.0.0', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit that does not touch pnpm.overrides passes', async () => { + const filePath = tmpPackageJson( + '{\n "name": "x",\n "version": "1.0.0"\n}\n', + ) + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: '"1.0.0"', + new_string: '"1.0.1"', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit removes a pnpm.overrides key — passes', async () => { + const filePath = tmpPackageJson( + '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1", "b": "2" } }\n}\n', + ) + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: '{ "a": "1", "b": "2" }', + new_string: '{ "a": "1" }', + }, + }) + assert.strictEqual(r.code, 0) +}) + +test('Edit adds a new pnpm.overrides key — blocked', async () => { + const filePath = tmpPackageJson( + '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1" } }\n}\n', + ) + const r = await runHook({ + tool_name: 'Edit', + tool_input: { + file_path: filePath, + old_string: '{ "a": "1" }', + new_string: '{ "a": "1", "b": "2" }', + }, + }) + assert.strictEqual(r.code, 2) + assert.ok(String(r.stderr).includes('`b`')) +}) + +test('Write adds a fresh pnpm.overrides — blocked', async () => { + const filePath = tmpPackageJson('{ "name": "x" }') + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: '{ "name": "x", "pnpm": { "overrides": { "sketchy": "9" } } }', + }, + }) + assert.strictEqual(r.code, 2) + assert.ok(String(r.stderr).includes('sketchy')) +}) + +test('Edit with bypass phrase in transcript — passes', async () => { + const filePath = tmpPackageJson('{ "name": "x" }') + const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-tx-')) + const transcriptPath = path.join(dir, 'session.jsonl') + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'user', + message: { content: 'Allow package-json-overrides bypass' }, + }) + '\n', + ) + const r = await runHook({ + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: '{ "name": "x", "pnpm": { "overrides": { "b": "2" } } }', + }, + transcript_path: transcriptPath, + }) + assert.strictEqual(r.code, 0) +}) diff --git a/.claude/hooks/pointer-comment-guard/tsconfig.json b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/tsconfig.json similarity index 100% rename from .claude/hooks/pointer-comment-guard/tsconfig.json rename to .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/tsconfig.json diff --git a/.claude/hooks/no-revert-guard/README.md b/.claude/hooks/fleet/no-revert-guard/README.md similarity index 100% rename from .claude/hooks/no-revert-guard/README.md rename to .claude/hooks/fleet/no-revert-guard/README.md diff --git a/.claude/hooks/no-revert-guard/index.mts b/.claude/hooks/fleet/no-revert-guard/index.mts similarity index 100% rename from .claude/hooks/no-revert-guard/index.mts rename to .claude/hooks/fleet/no-revert-guard/index.mts diff --git a/.claude/hooks/no-revert-guard/package.json b/.claude/hooks/fleet/no-revert-guard/package.json similarity index 100% rename from .claude/hooks/no-revert-guard/package.json rename to .claude/hooks/fleet/no-revert-guard/package.json diff --git a/.claude/hooks/no-revert-guard/test/index.test.mts b/.claude/hooks/fleet/no-revert-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-revert-guard/test/index.test.mts rename to .claude/hooks/fleet/no-revert-guard/test/index.test.mts diff --git a/.claude/hooks/pr-vs-push-default-reminder/tsconfig.json b/.claude/hooks/fleet/no-revert-guard/tsconfig.json similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/tsconfig.json rename to .claude/hooks/fleet/no-revert-guard/tsconfig.json diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/README.md b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/README.md rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/index.mts rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/package.json b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/package.json similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/package.json rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/package.json diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/test/index.test.mts diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/tsconfig.json similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/tsconfig.json diff --git a/.claude/hooks/no-token-in-dotenv-guard/README.md b/.claude/hooks/fleet/no-token-in-dotenv-guard/README.md similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/README.md rename to .claude/hooks/fleet/no-token-in-dotenv-guard/README.md diff --git a/.claude/hooks/no-token-in-dotenv-guard/index.mts b/.claude/hooks/fleet/no-token-in-dotenv-guard/index.mts similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/index.mts rename to .claude/hooks/fleet/no-token-in-dotenv-guard/index.mts diff --git a/.claude/hooks/no-token-in-dotenv-guard/package.json b/.claude/hooks/fleet/no-token-in-dotenv-guard/package.json similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/package.json rename to .claude/hooks/fleet/no-token-in-dotenv-guard/package.json diff --git a/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts b/.claude/hooks/fleet/no-token-in-dotenv-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/test/index.test.mts rename to .claude/hooks/fleet/no-token-in-dotenv-guard/test/index.test.mts diff --git a/.claude/hooks/private-name-guard/tsconfig.json b/.claude/hooks/fleet/no-token-in-dotenv-guard/tsconfig.json similarity index 100% rename from .claude/hooks/private-name-guard/tsconfig.json rename to .claude/hooks/fleet/no-token-in-dotenv-guard/tsconfig.json diff --git a/.claude/hooks/no-underscore-identifier-guard/README.md b/.claude/hooks/fleet/no-underscore-identifier-guard/README.md similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/README.md rename to .claude/hooks/fleet/no-underscore-identifier-guard/README.md diff --git a/.claude/hooks/no-underscore-identifier-guard/index.mts b/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/index.mts rename to .claude/hooks/fleet/no-underscore-identifier-guard/index.mts diff --git a/.claude/hooks/no-underscore-identifier-guard/package.json b/.claude/hooks/fleet/no-underscore-identifier-guard/package.json similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/package.json rename to .claude/hooks/fleet/no-underscore-identifier-guard/package.json diff --git a/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts b/.claude/hooks/fleet/no-underscore-identifier-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/test/index.test.mts rename to .claude/hooks/fleet/no-underscore-identifier-guard/test/index.test.mts diff --git a/.claude/hooks/public-surface-reminder/tsconfig.json b/.claude/hooks/fleet/no-underscore-identifier-guard/tsconfig.json similarity index 100% rename from .claude/hooks/public-surface-reminder/tsconfig.json rename to .claude/hooks/fleet/no-underscore-identifier-guard/tsconfig.json diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/README.md b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/README.md new file mode 100644 index 0000000..d1d7f35 --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/README.md @@ -0,0 +1,32 @@ +# no-unmocked-network-in-tests-guard + +PreToolUse hook. Blocks a Write/Edit to a test file that performs HTTP against a +third-party host without mocking it via [`nock`](https://github.com/nock/nock). + +Live network in tests is flaky, slow, and a data-exfil surface. The fleet +pattern is `nock.disableNetConnect()` + endpoint stubs; the `registry-*.test.mts` +suites are canonical. + +## Fires when + +- Tool is `Write` or `Edit`. +- Target path is a test file (`*.test.*` / `*.spec.*`, or under `test/` / + `__tests__/`). +- Post-edit content calls `httpJson` / `httpText` / `httpRequest` / `fetch` / + `.request(`. +- The content has no `nock` reference. +- At least one network target is a non-localhost host (localhost-only is + allowed). + +## Bypass + +Type `Allow unmocked-network-in-tests bypass` verbatim in a recent message. + +## Why + +2026-05-27, socket-packageurl-js: `purlExists` conda/docker dispatch tests hit +live `api.anaconda.org` / `hub.docker.com`, timing out at 15s. Full rationale: +`docs/claude.md/fleet/no-live-network-in-tests.md`. + +Defense in depth with the fleet `test/setup.mts` (runtime `disableNetConnect()`) +and the CLAUDE.md rule. diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/index.mts b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/index.mts new file mode 100644 index 0000000..927c56e --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/index.mts @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-unmocked-network-in-tests-guard. +// +// Blocks Write/Edit operations on a test file that performs HTTP against a +// third-party host without mocking it via `nock`. Live network in tests is +// flaky, slow, and a data-exfil surface; the fleet pattern is +// `nock.disableNetConnect()` + endpoint stubs (see the `registry-*.test.mts` +// suites and `docs/claude.md/fleet/no-live-network-in-tests.md`). +// +// Detection model: +// - Fires only on Write/Edit whose target path looks like a test file +// (`*.test.*` or under a `test/` or `__tests__/` directory). +// - Looks at the post-edit file content (`content` for Write, `new_string` +// for Edit). +// - Flags a network call: `httpJson(`, `httpText(`, `httpRequest(`, +// `fetch(`, or `.request(` — the fleet HTTP surface plus raw fetch. +// - If the content references `nock` (the file mocks the network), allow. +// - If every network call targets localhost / 127.0.0.1 (a fixture server), +// allow. +// - Otherwise block. +// +// Bypass: `Allow unmocked-network-in-tests bypass` typed verbatim in a recent +// user turn. +// +// Fails open on parse errors or non-test files — under-blocking beats blocking +// on infrastructure problems. + +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +const BYPASS_PHRASE = 'Allow unmocked-network-in-tests bypass' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly content?: string | undefined + } + | undefined + readonly transcript_path?: string | undefined +} + +// A path is a test file if its basename matches `*.test.*` / `*.spec.*` or it +// lives under a `test/` or `__tests__/` directory. +export function isTestFilePath(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/') + if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalized)) { + return true + } + return /(?:^|\/)(?:test|tests|__tests__)\//.test(normalized) +} + +// Network-call surfaces flagged in test bodies: the fleet HTTP helpers and raw +// fetch / `.request(`. +const NETWORK_CALL_RE = + /\b(?:httpJson|httpText|httpRequest|fetch)\s*\(|\.request\s*\(/ + +export function hasNetworkCall(text: string): boolean { + return NETWORK_CALL_RE.test(text) +} + +export function referencesNock(text: string): boolean { + return /\bnock\b/.test(text) +} + +// True when every literal URL/host in the text is localhost. If there are no +// literal hosts at all we can't prove it's localhost-only, so return false. +export function onlyLocalhostHosts(text: string): boolean { + const urls = text.match(/https?:\/\/[^\s'"`)]+/g) + if (!urls || urls.length === 0) { + return false + } + return urls.every(u => + /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::|\/|$)/.test(u), + ) +} + +export function shouldBlock(filePath: string, content: string): boolean { + if (!isTestFilePath(filePath)) { + return false + } + if (!hasNetworkCall(content)) { + return false + } + if (referencesNock(content)) { + return false + } + if (onlyLocalhostHosts(content)) { + return false + } + return true +} + +async function main(): Promise { + const raw = await readStdin() + if (!raw) { + return + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + return + } + + const toolName = payload.tool_name + if (toolName !== 'Edit' && toolName !== 'Write') { + return + } + + const filePath = payload.tool_input?.file_path + if (!filePath) { + return + } + + const content = + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' + if (!shouldBlock(filePath, content)) { + return + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + process.stderr.write( + [ + '[no-unmocked-network-in-tests-guard] Blocked: test makes a live third-party connection', + '', + ` File: ${filePath}`, + '', + ' This test calls httpJson/httpText/httpRequest/fetch against a', + ' non-localhost host with no `nock` mock in the file. Live network in', + ' tests is flaky, slow, and a data-exfil surface.', + '', + ' Fix: mock the endpoint with nock, like the registry-*.test.mts suites:', + " import nock from 'nock'", + ' beforeEach(() => nock.disableNetConnect())', + ' afterEach(() => { nock.cleanAll(); nock.enableNetConnect() })', + " nock('https://host').get('/path').reply(200, { ... })", + '', + ' Detail: docs/claude.md/fleet/no-live-network-in-tests.md', + ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[no-unmocked-network-in-tests-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/package.json b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/package.json new file mode 100644 index 0000000..9ac69cc --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-unmocked-network-in-tests-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/test/index.test.mts b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/test/index.test.mts new file mode 100644 index 0000000..2f251f7 --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/test/index.test.mts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + hasNetworkCall, + isTestFilePath, + onlyLocalhostHosts, + referencesNock, + shouldBlock, +} from '../index.mts' + +describe('isTestFilePath', () => { + it('matches *.test.* and test/ dirs', () => { + assert.equal(isTestFilePath('src/foo.test.mts'), true) + assert.equal(isTestFilePath('test/registry-cran.test.mts'), true) + assert.equal(isTestFilePath('pkg/__tests__/a.spec.ts'), true) + assert.equal(isTestFilePath('src/foo.mts'), false) + assert.equal(isTestFilePath('scripts/build.mts'), false) + }) +}) + +describe('hasNetworkCall', () => { + it('flags fleet HTTP helpers and fetch', () => { + assert.equal(hasNetworkCall('await httpJson(url)'), true) + assert.equal(hasNetworkCall('const r = httpText( x )'), true) + assert.equal(hasNetworkCall('await fetch(`https://x`)'), true) + assert.equal(hasNetworkCall('client.request(opts)'), true) + assert.equal(hasNetworkCall('const x = 1'), false) + }) +}) + +describe('referencesNock / onlyLocalhostHosts', () => { + it('detects nock usage', () => { + assert.equal(referencesNock("import nock from 'nock'"), true) + assert.equal(referencesNock('no mocking here'), false) + }) + it('treats localhost-only hosts as allowed', () => { + assert.equal(onlyLocalhostHosts('fetch("http://127.0.0.1:8080/x")'), true) + assert.equal(onlyLocalhostHosts('fetch("http://localhost/x")'), true) + assert.equal(onlyLocalhostHosts('fetch("https://api.example.com")'), false) + // No literal host present -> can't prove localhost-only. + assert.equal(onlyLocalhostHosts('fetch(url)'), false) + }) +}) + +describe('shouldBlock', () => { + const unmocked = + "import { httpJson } from 'x'\nit('t', async () => { await httpJson('https://api.anaconda.org/p') })" + const mocked = + "import nock from 'nock'\nit('t', async () => { nock('https://api.anaconda.org').get('/p').reply(200,{}); await httpJson('https://api.anaconda.org/p') })" + const localhostOnly = + "it('t', async () => { await fetch('http://127.0.0.1:9/p') })" + + it('blocks an unmocked third-party call in a test file', () => { + assert.equal(shouldBlock('test/x.test.mts', unmocked), true) + }) + it('allows when nock is present', () => { + assert.equal(shouldBlock('test/x.test.mts', mocked), false) + }) + it('allows localhost-only calls', () => { + assert.equal(shouldBlock('test/x.test.mts', localhostOnly), false) + }) + it('ignores non-test files', () => { + assert.equal(shouldBlock('src/x.mts', unmocked), false) + }) + it('ignores test files with no network call', () => { + assert.equal(shouldBlock('test/x.test.mts', 'const a = 1'), false) + }) +}) diff --git a/.claude/hooks/pull-request-target-guard/tsconfig.json b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/tsconfig.json similarity index 100% rename from .claude/hooks/pull-request-target-guard/tsconfig.json rename to .claude/hooks/fleet/no-unmocked-network-in-tests-guard/tsconfig.json diff --git a/.claude/hooks/node-modules-staging-guard/README.md b/.claude/hooks/fleet/node-modules-staging-guard/README.md similarity index 100% rename from .claude/hooks/node-modules-staging-guard/README.md rename to .claude/hooks/fleet/node-modules-staging-guard/README.md diff --git a/.claude/hooks/node-modules-staging-guard/index.mts b/.claude/hooks/fleet/node-modules-staging-guard/index.mts similarity index 100% rename from .claude/hooks/node-modules-staging-guard/index.mts rename to .claude/hooks/fleet/node-modules-staging-guard/index.mts diff --git a/.claude/hooks/node-modules-staging-guard/package.json b/.claude/hooks/fleet/node-modules-staging-guard/package.json similarity index 100% rename from .claude/hooks/node-modules-staging-guard/package.json rename to .claude/hooks/fleet/node-modules-staging-guard/package.json diff --git a/.claude/hooks/node-modules-staging-guard/test/index.test.mts b/.claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/node-modules-staging-guard/test/index.test.mts rename to .claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts diff --git a/.claude/hooks/readme-fleet-shape-guard/tsconfig.json b/.claude/hooks/fleet/node-modules-staging-guard/tsconfig.json similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/tsconfig.json rename to .claude/hooks/fleet/node-modules-staging-guard/tsconfig.json diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/README.md b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/README.md new file mode 100644 index 0000000..a026032 --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/README.md @@ -0,0 +1,32 @@ +# non-fleet-pr-issue-ask-guard + +PreToolUse hook that blocks `gh pr create` / `gh issue create` / `gh release create` calls targeting a repository NOT in the fleet roster, unless the user has typed the canonical bypass phrase. + +## Rule + +Public-facing artifacts (PRs, issues, releases) on non-fleet repos go out under the user's gh identity. They're permanent on the upstream side once posted — closing one with an "opened in error" comment doesn't fully un-publish it (the email notification fires, the issue number is consumed, the upstream maintainers see the noise). + +The fleet rule: **never submit to a non-fleet repo without explicit per-action confirmation**. Captured plan text + batched "do all N tasks" directives are NOT standing authorization to post under your identity. + +## Detection + +Fires on Bash commands containing `gh pr create`, `gh issue create`, or `gh release create`. Resolves the target repo via: + +1. `--repo /` flag when present. +2. Otherwise, `git remote get-url origin` from the resolved git cwd (matching the priority order used by `no-non-fleet-push-guard`: `-C `, leading `cd &&`, then process.cwd()). + +Blocks when the resolved slug is not in the fleet roster (`_shared/fleet-repos.mts::isFleetRepo`). + +## Bypass + +Type `Allow non-fleet-publish bypass` verbatim in a recent user turn. Per the fleet bypass-phrase convention. Single-action: a phrase from a previous turn doesn't carry forward indefinitely — the hook reads the active session's transcript. + +## Why a hook + +A captured-plan task that says "file an upstream issue" isn't permission to run `gh issue create` against that repo. 2026-05-28 incident: working through a deferred-tasks list, I ran `gh issue create --repo oxc-project/oxc ...` from a captured plan without re-confirming. The user said "don't create an issue" but the bg `gh` call had already completed; the issue was live until closed post-hoc. + +This hook makes the rule enforceable at edit time — the bg call blocks before the API request fires. + +## Fail-open + +The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/index.mts b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/index.mts new file mode 100644 index 0000000..9a0c63a --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/index.mts @@ -0,0 +1,205 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — non-fleet-pr-issue-ask-guard. +// +// Blocks `gh pr create` / `gh issue create` / `gh release create` +// calls that target a repository NOT in the fleet roster. The +// canonical fleet rule: never auto-submit publicly-visible artifacts +// (PRs, issues, releases) to upstream / third-party repos without +// explicit user confirmation. Captured plan text + batched "do all N +// tasks" directives are NOT standing authorization to post under the +// user's gh identity. +// +// 2026-05-28 incident: a captured-plan task said "file an oxfmt +// upstream issue" as one bullet. Working through the deferred list, +// I ran `gh issue create --repo oxc-project/oxc ...` without re- +// confirming. The user said "don't create an issue" but the bg `gh` +// call had already completed; the issue was live until closed +// post-hoc with an "opened in error" comment. This hook prevents +// the repeat. +// +// Detection: +// - Fires only on Bash commands containing `gh pr create`, +// `gh issue create`, or `gh release create`. +// - Resolves the target repo via `--repo /` flag +// when present, otherwise via `git remote get-url origin` from +// the resolved git cwd (same priority order as +// `no-non-fleet-push-guard`: -C , then `cd &&`, +// then process.cwd()). +// - Blocks when the slug is not in FLEET_REPO_NAMES. +// +// Bypass: `Allow non-fleet-publish bypass` typed verbatim in a +// recent user turn. +// +// Fails OPEN on resolution ambiguity (can't find the command, the +// dir, or the remote): better to under-block than to wedge a +// legitimate fleet PR/issue when the shape is unfamiliar. + +import path from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' + +import { isFleetRepo, slugFromRemoteUrl } from '../_shared/fleet-repos.mts' +import { commandsFor } from '../_shared/shell-command.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: { readonly command?: string | undefined } | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow non-fleet-publish bypass' + +const GH_DASH_REPO_RE = /--repo[\s=]+("([^"]+)"|'([^']+)'|(\S+))/ +const GIT_DASH_C_RE = /\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/ +const LEADING_CD_RE = /(?:^|[;&|]|&&)\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/ + +// gh subcommands that publish public-facing content. `release create` +// is also in the harness deny list, but the hook layer here catches +// the bypass-phrase escape path so the user has ONE consistent way +// to authorize public-facing actions. +const PUBLIC_SURFACE_SUBCOMMANDS = [ + ['pr', 'create'], + ['issue', 'create'], + ['release', 'create'], +] as const + +export function extractGhTargetRepo(command: string): string | undefined { + const m = GH_DASH_REPO_RE.exec(command) + if (m) { + return m[2] ?? m[3] ?? m[4] + } + return undefined +} + +export function extractGitCwd(command: string): string { + const dashC = GIT_DASH_C_RE.exec(command) + if (dashC) { + return dashC[2] ?? dashC[3] ?? dashC[4] ?? process.cwd() + } + const cd = LEADING_CD_RE.exec(command) + if (cd) { + const dir = cd[2] ?? cd[3] ?? cd[4] + if (dir) { + return path.resolve(process.cwd(), dir) + } + } + return process.cwd() +} + +function originSlugFromCwd(dir: string): string | undefined { + try { + const r = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + timeout: 5000, + }) + if (r.status !== 0) { + return undefined + } + const url = (r.stdout ?? '').trim() + return slugFromRemoteUrl(url) + } catch { + return undefined + } +} + +// Identifies the gh subcommand. Returns the matching +// [verb, action] pair when one is present at an executable +// position, undefined otherwise. +export function findPublicGhInvocation( + command: string, +): readonly [string, string] | undefined { + const ghCommands = commandsFor(command, 'gh') + for (const c of ghCommands) { + for (const pair of PUBLIC_SURFACE_SUBCOMMANDS) { + if (c.args[0] === pair[0] && c.args[1] === pair[1]) { + return pair + } + } + } + return undefined +} + +async function main(): Promise { + const raw = await readStdin() + let payload: ToolInput + try { + payload = raw ? JSON.parse(raw) : {} + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = payload.tool_input?.command ?? '' + if (!command || !/\bgh\b/.test(command)) { + process.exit(0) + } + const subcommand = findPublicGhInvocation(command) + if (!subcommand) { + process.exit(0) + } + + // Resolve target slug. `--repo` carries owner/repo (shown + // verbatim in messages). For membership, `isFleetRepo` keys on + // the bare repo name, so strip the owner before checking. + let slug: string | undefined + const dashRepo = extractGhTargetRepo(command) + if (dashRepo) { + slug = dashRepo + } else { + const cwd = extractGitCwd(command) + slug = originSlugFromCwd(cwd) + } + if (!slug) { + // Fail open — can't determine target. The user gets the gh + // command's own error if it's malformed. + process.exit(0) + } + const slashIdx = slug.indexOf('/') + const bareSlug = slashIdx === -1 ? slug : slug.slice(slashIdx + 1) + + if (isFleetRepo(bareSlug)) { + // Fleet repo — fall through. The action is authorized by being + // inside the fleet. + process.exit(0) + } + + // Non-fleet target. Check bypass phrase. + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + process.stderr.write( + [ + 'non-fleet-pr-issue-ask-guard: blocked', + '', + ` Command targets non-fleet repo: ${slug}`, + ` Subcommand: gh ${subcommand.join(' ')}`, + '', + ` Public-facing artifacts (PRs, issues, releases) on non-fleet`, + ` repos go out under your gh identity. The fleet rule: never`, + ` submit without explicit per-action user confirmation —`, + ` captured plans + "do all N tasks" directives do NOT count.`, + '', + ` If you really want to submit: type the canonical phrase`, + ` in your next message, then re-run:`, + ` ${BYPASS_PHRASE}`, + '', + ' Otherwise: draft locally, share for review, get explicit', + ' yes/no before re-attempting.', + ].join('\n') + '\n', + ) + process.exit(2) +} + +main().catch(err => { + process.stderr.write( + `non-fleet-pr-issue-ask-guard: hook crashed, failing open: ${err instanceof Error ? err.message : String(err)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/package.json b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/package.json new file mode 100644 index 0000000..06bf490 --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-non-fleet-pr-issue-ask-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + } +} diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts new file mode 100644 index 0000000..be89fe3 --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts @@ -0,0 +1,108 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function runHook(payload: object): { stderr: string; exitCode: number } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify(payload), + encoding: 'utf8', + }) + return { stderr: result.stderr ?? '', exitCode: result.status ?? -1 } +} + +test('BLOCKS gh pr create --repo against non-fleet repo', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: + 'gh pr create --repo oxc-project/oxc --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /non-fleet-pr-issue-ask-guard: blocked/) + assert.match(stderr, /oxc-project\/oxc/) + assert.match(stderr, /gh pr create/) +}) + +test('BLOCKS gh issue create --repo against non-fleet repo', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: + 'gh issue create --repo nodejs/node --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /nodejs\/node/) + assert.match(stderr, /gh issue create/) +}) + +test('BLOCKS gh release create --repo against non-fleet repo', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: 'gh release create v1.0 --repo example/repo', + }, + }) + assert.equal(exitCode, 2) +}) + +test('ALLOWS gh pr create --repo against fleet repo (SocketDev/socket-lib)', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: + 'gh pr create --repo SocketDev/socket-lib --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 0) +}) + +test('ALLOWS gh pr create --repo against fleet repo (SocketDev/socket-wheelhouse)', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: + 'gh pr create --repo SocketDev/socket-wheelhouse --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 0) +}) + +test('IGNORES non-public gh subcommands (gh pr view, gh issue list)', () => { + const { exitCode: prView } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'gh pr view --repo oxc-project/oxc 12345' }, + }) + assert.equal(prView, 0) + + const { exitCode: issueList } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'gh issue list --repo oxc-project/oxc' }, + }) + assert.equal(issueList, 0) +}) + +test('IGNORES non-Bash tools', () => { + const { exitCode } = runHook({ + tool_name: 'Edit', + tool_input: { + file_path: '/x.txt', + new_string: 'gh pr create --repo oxc-project/oxc', + }, + }) + assert.equal(exitCode, 0) +}) + +test('IGNORES commands without gh', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'ls -la' }, + }) + assert.equal(exitCode, 0) +}) diff --git a/.claude/hooks/release-workflow-guard/tsconfig.json b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/tsconfig.json similarity index 100% rename from .claude/hooks/release-workflow-guard/tsconfig.json rename to .claude/hooks/fleet/non-fleet-pr-issue-ask-guard/tsconfig.json diff --git a/.claude/hooks/fleet/overeager-staging-guard/README.md b/.claude/hooks/fleet/overeager-staging-guard/README.md new file mode 100644 index 0000000..7add92c --- /dev/null +++ b/.claude/hooks/fleet/overeager-staging-guard/README.md @@ -0,0 +1,33 @@ +# overeager-staging-guard + +**Lifecycle**: PreToolUse (Bash) + +**Purpose**: catch the failure mode where an agent's `git commit` sweeps in files it didn't author — usually another Claude session's work that was already staged when this session opened the repo. + +## Two enforcement layers + +### Layer 1: BLOCK broad-stage commands + +The hook blocks any of: + +- `git add -A` +- `git add .` +- `git add --all` +- `git add -u` +- `git add --update` + +These sweep everything in the working tree into the index, which is hostile to parallel-session repos. Per the fleet CLAUDE.md rule: **surgical `git add ` only — never `-A` / `.`**. + +### Layer 2: WARN on commit with unfamiliar staged files + +On `git commit`, if the index contains files the agent has NOT touched this session (via `Edit` / `Write` / `git add ` / `git rm `), the hook emits a stderr summary listing every unfamiliar staged file. **Exit 0 — informational, not a block.** The point is to give the agent a chance to spot parallel-session work before the commit goes through. + +The detection heuristic walks the transcript's tool-use history; files staged but never touched this session surface as suspicious entries. + +## Bypass + +Type `Allow add-all bypass` verbatim in a recent user turn to permit `-A` / `.` / `-u` for one operation. The bypass is single-use and not persisted across sessions. + +## Why this hook exists + +Past incident: a session's own `pnpm check` surfaced another agent's migration files; the session nearly committed them. The block-broad-stage + warn-on-unfamiliar pair is defense-in-depth for the parallel-Claude-session model. diff --git a/.claude/hooks/overeager-staging-guard/index.mts b/.claude/hooks/fleet/overeager-staging-guard/index.mts similarity index 100% rename from .claude/hooks/overeager-staging-guard/index.mts rename to .claude/hooks/fleet/overeager-staging-guard/index.mts diff --git a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts new file mode 100644 index 0000000..c6eeae3 --- /dev/null +++ b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — overeager-staging-guard. +// +// Catches the failure mode where an agent's `git commit` sweeps in +// files it didn't author — usually another Claude session's work +// that was already staged when this session opened the repo. Two +// enforcement layers: +// +// 1. BLOCK `git add -A` / `git add .` / `git add --all` / `git add -u` +// / `git add --update`. These sweep everything in the working +// tree into the index, which is hostile to parallel-session +// repos: another agent's unstaged edits get staged into your +// next commit. Per CLAUDE.md: "surgical `git add `. +// Never `-A` / `.`." +// +// 2. WARN on `git commit` when the index contains files the agent +// has NOT touched this session (via Edit / Write / `git add +// ` / `git rm `). Exits 0 — informational, not a +// block — but emits a stderr summary listing every unfamiliar +// staged file so the agent has a chance to spot parallel-session +// work before the commit goes through. +// +// Detection heuristic: list staged files, compare against tool- +// use history in the transcript. Files staged but never touched +// this session surface as suspicious entries. +// +// Both layers fail open on hook bugs (exit 0 + stderr log). +// +// Bypass: +// - `Allow add-all bypass` in a recent user turn (case-sensitive, +// exact match) — disables layer 1 for the next add. +// - `SOCKET_OVEREAGER_STAGING_GUARD_DISABLED=1` — disables both. +// +// Reads a Claude Code PreToolUse JSON payload from stdin: +// { "tool_name": "Bash", +// "tool_input": { "command": "..." }, +// "transcript_path": "/.../session.jsonl" } + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import process from 'node:process' + +import { readTouchedPaths } from '../_shared/foreign-paths.mts' +import { + detectBroadGitAdd, + findInvocation, +} from '../_shared/shell-command.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: { readonly command?: unknown | undefined } | undefined + readonly transcript_path?: string | undefined +} + +const ENV_DISABLE = 'SOCKET_OVEREAGER_STAGING_GUARD_DISABLED' +const BYPASS_PHRASES = ['Allow add-all bypass'] as const + +export function getRepoDir(): string { + return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() +} + +export function isGitCommit(command: string): boolean { + return findInvocation(command, { binary: 'git', subcommand: 'commit' }) +} + +export function listStagedFiles(repoDir: string): string[] { + const r = spawnSync('git', ['diff', '--cached', '--name-only'], { + cwd: repoDir, + timeout: 5_000, + }) + if (r.status !== 0) { + return [] + } + return String(r.stdout) + .split('\n') + .map((s: string) => s.trim()) + .filter(Boolean) +} + + +async function main(): Promise { + if (process.env[ENV_DISABLE]) { + process.exit(0) + } + const raw = await readStdin() + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = ( + payload.tool_input as { command?: unknown | undefined } | undefined + )?.command + if (typeof command !== 'string' || !command.trim()) { + process.exit(0) + } + + const repoDir = getRepoDir() + const transcriptPath = payload.transcript_path + + // ── Layer 1: block `git add -A` / `.` / `-u` ───────────────────── + const broad = detectBroadGitAdd(command) + if (broad) { + // Fleet-sync sentinel: cascade scripts run `git add -u` inside a + // worktree they just created off origin/main — no parallel-session + // hazard because the worktree is empty otherwise. Same opt-in + // sentinel the no-revert-guard recognizes (`FLEET_SYNC=1` prefix). + if (/(?:^|\s)FLEET_SYNC\s*=\s*1\b/.test(command)) { + process.exit(0) + } + if ( + transcriptPath && + bypassPhrasePresent(transcriptPath, BYPASS_PHRASES, 3) + ) { + process.exit(0) + } + process.stderr.write( + [ + `[overeager-staging-guard] Blocked: ${broad}`, + '', + ' This sweeps the entire working tree into the index.', + " In a parallel-session repo, that pulls in another agent's", + ' unstaged edits and they get swept into your next commit.', + '', + ' Fix: stage by explicit path.', + ' git add path/to/file.ts path/to/other.ts', + '', + ' Bypass (only if you genuinely need a sweep):', + ' user types "Allow add-all bypass" in chat, then retry.', + ].join('\n') + '\n', + ) + process.exit(2) + } + + // ── Layer 2: warn on `git commit` if index has unfamiliar files ── + if (isGitCommit(command)) { + const staged = listStagedFiles(repoDir) + if (staged.length === 0) { + process.exit(0) + } + const touched = readTouchedPaths(transcriptPath) + const unfamiliar: string[] = [] + for (let i = 0, { length } = staged; i < length; i += 1) { + const f = staged[i]! + const abs = path.resolve(repoDir, f) + if (!touched.has(abs)) { + unfamiliar.push(f) + } + } + if (unfamiliar.length === 0) { + process.exit(0) + } + // Don't block — commits with pre-staged content can be legitimate. + // Just print a loud stderr warning so the agent inspects before + // proceeding (and humans reviewing the session can spot the slip). + process.stderr.write( + [ + '[overeager-staging-guard] ⚠ git commit about to sweep in files this session has not touched:', + '', + ...unfamiliar.slice(0, 20).map(f => ` ${f}`), + ...(unfamiliar.length > 20 + ? [` ... and ${unfamiliar.length - 20} more`] + : []), + '', + ' Likely cause: a parallel Claude session staged these. The', + ' commit will include them under your authorship.', + '', + ' If unintended, abort and run:', + ' git restore --staged # to drop one file', + ' git reset HEAD # to drop everything', + '', + ' If intended, proceed — this is informational, not a block.', + ].join('\n') + '\n', + ) + process.exit(0) + } + + process.exit(0) +} + +main().catch(e => { + process.stderr.write( + `[overeager-staging-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/overeager-staging-guard/package.json b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/package.json similarity index 100% rename from .claude/hooks/overeager-staging-guard/package.json rename to .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/package.json diff --git a/.claude/hooks/overeager-staging-guard/test/index.test.mts b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/overeager-staging-guard/test/index.test.mts rename to .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/test/index.test.mts diff --git a/.claude/hooks/scan-label-in-commit-guard/tsconfig.json b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/tsconfig.json similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/tsconfig.json rename to .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/tsconfig.json diff --git a/.claude/hooks/fleet/overeager-staging-guard/package.json b/.claude/hooks/fleet/overeager-staging-guard/package.json new file mode 100644 index 0000000..6d10817 --- /dev/null +++ b/.claude/hooks/fleet/overeager-staging-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-overeager-staging-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/overeager-staging-guard/test/index.test.mts b/.claude/hooks/fleet/overeager-staging-guard/test/index.test.mts new file mode 100644 index 0000000..1fd9a97 --- /dev/null +++ b/.claude/hooks/fleet/overeager-staging-guard/test/index.test.mts @@ -0,0 +1,319 @@ +/** + * @file Unit tests for overeager-staging-guard hook. Two layers under test: + * + * 1. Layer 1 — block `git add -A` / `.` / `-u` (exit 2). + * 2. Layer 2 — informational warning on `git commit` when index contains files + * not touched by this session (exit 0 + stderr). + */ + +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, test } from 'node:test' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(__dirname, '..', 'index.mts') + +interface RunResult { + readonly code: number + readonly stderr: string +} + +function runHook( + command: string, + options: { + cwd?: string | undefined + transcriptPath?: string | undefined + env?: Record | undefined + } = {}, +): RunResult { + const payload = { + tool_name: 'Bash', + tool_input: { command }, + transcript_path: options.transcriptPath, + } + const r = spawnSync('node', [HOOK], { + input: JSON.stringify(payload), + env: { + ...process.env, + ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), + ...(options.env ?? {}), + }, + }) + return { + code: typeof r.status === 'number' ? r.status : 0, + stderr: String(r.stderr || ''), + } +} + +function gitInit(repo: string): void { + spawnSync('git', ['init', '-q'], { cwd: repo }) + spawnSync('git', ['config', 'user.email', 'test@example.com'], { + cwd: repo, + }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) +} + +function gitAdd(repo: string, files: string[]): void { + spawnSync('git', ['add', ...files], { cwd: repo }) +} + +function writeTranscript(entries: object[]): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'overeager-tx-')) + const transcriptPath = path.join(dir, 'session.jsonl') + writeFileSync(transcriptPath, entries.map(e => JSON.stringify(e)).join('\n')) + return transcriptPath +} + +let tmpRepo: string + +beforeEach(() => { + tmpRepo = mkdtempSync(path.join(os.tmpdir(), 'overeager-repo-')) + gitInit(tmpRepo) +}) + +afterEach(() => { + rmSync(tmpRepo, { recursive: true, force: true }) +}) + +// ─── Layer 1: broad git-add blocking ────────────────────────────── + +test('blocks `git add -A`', () => { + const r = runHook('git add -A', { cwd: tmpRepo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /git add -A/) + assert.match(r.stderr, /Blocked/) +}) + +test('blocks `git add --all`', () => { + const r = runHook('git add --all', { cwd: tmpRepo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /git add --all/) +}) + +test('blocks `git add .`', () => { + const r = runHook('git add .', { cwd: tmpRepo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /git add \./) +}) + +test('blocks `git add -u`', () => { + const r = runHook('git add -u', { cwd: tmpRepo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /git add -u/) +}) + +test('blocks `git add --update`', () => { + const r = runHook('git add --update', { cwd: tmpRepo }) + assert.equal(r.code, 2) +}) + +test('blocks broad add chained after another command', () => { + const r = runHook('echo hi && git add -A && git commit -m x', { + cwd: tmpRepo, + }) + assert.equal(r.code, 2) +}) + +test('blocks broad add when env vars are set on the command', () => { + const r = runHook('GIT_AUTHOR_NAME=foo git add .', { cwd: tmpRepo }) + assert.equal(r.code, 2) +}) + +test('blocks `git -C path add .` (subcommand after a global flag)', () => { + const r = runHook(`git -C ${tmpRepo} add .`, { cwd: tmpRepo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /git add \./) +}) + +test('quoted "git add ." inside a message is NOT a broad add', () => { + // Regression: the parser distinguishes a real invocation from the + // same words sitting inside a quoted commit-message argument. + const r = runHook('git commit -m "stop using git add ."', { cwd: tmpRepo }) + assert.equal(r.code, 0) +}) + +test('allows `git add path/to/file.ts`', () => { + const r = runHook('git add src/foo.ts', { cwd: tmpRepo }) + assert.equal(r.code, 0) +}) + +test('allows `git add ./relative-path.ts` (not a broad sweep)', () => { + const r = runHook('git add ./src/foo.ts', { cwd: tmpRepo }) + assert.equal(r.code, 0) +}) + +test('allows `git add multiple specific files`', () => { + const r = runHook('git add src/a.ts src/b.ts test/c.test.ts', { + cwd: tmpRepo, + }) + assert.equal(r.code, 0) +}) + +test('allows `git commit -m`', () => { + const r = runHook('git commit -m "fix: thing"', { cwd: tmpRepo }) + assert.equal(r.code, 0) +}) + +test('allows non-git Bash commands', () => { + const r = runHook('ls -la', { cwd: tmpRepo }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('bypass: `Allow add-all bypass` in transcript allows broad add', () => { + const transcriptPath = writeTranscript([ + { + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'Allow add-all bypass' }], + }, + }, + ]) + const r = runHook('git add -A', { cwd: tmpRepo, transcriptPath }) + assert.equal(r.code, 0) +}) + +test('env disable short-circuits', () => { + const r = runHook('git add -A', { + cwd: tmpRepo, + env: { SOCKET_OVEREAGER_STAGING_GUARD_DISABLED: '1' }, + }) + assert.equal(r.code, 0) +}) + +// ─── Layer 2: warn on git commit with unfamiliar staged files ───── + +test('git commit with empty index passes silently', () => { + const r = runHook('git commit -m "x"', { cwd: tmpRepo }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('git commit warns when index has files not touched this session', () => { + writeFileSync(path.join(tmpRepo, 'parallel.ts'), '// other agent') + gitAdd(tmpRepo, ['parallel.ts']) + // Empty transcript — agent touched nothing. + const transcriptPath = writeTranscript([]) + const r = runHook('git commit -m "mine"', { + cwd: tmpRepo, + transcriptPath, + }) + // Layer 2 is informational — exit 0 with stderr warning. + assert.equal(r.code, 0) + assert.match(r.stderr, /parallel\.ts/) + assert.match(r.stderr, /not touched/) +}) + +test('git commit silent when index files match transcript Edit history', () => { + const myFile = path.join(tmpRepo, 'mine.ts') + writeFileSync(myFile, '// mine') + gitAdd(tmpRepo, ['mine.ts']) + const transcriptPath = writeTranscript([ + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Edit', + input: { file_path: myFile }, + }, + ], + }, + }, + ]) + const r = runHook('git commit -m "mine"', { + cwd: tmpRepo, + transcriptPath, + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('git commit silent when index files match transcript git-add history', () => { + const myFile = path.join(tmpRepo, 'mine.ts') + writeFileSync(myFile, '// mine') + gitAdd(tmpRepo, ['mine.ts']) + const transcriptPath = writeTranscript([ + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Bash', + input: { command: `git add ${myFile}` }, + }, + ], + }, + }, + ]) + const r = runHook('git commit -m "mine"', { + cwd: tmpRepo, + transcriptPath, + }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +// ─── Misc edge cases ────────────────────────────────────────────── + +test('non-Bash tool_name is ignored', () => { + const r = spawnSync('node', [HOOK], { + input: JSON.stringify({ + tool_name: 'Edit', + tool_input: { file_path: '/tmp/foo' }, + }), + }) + assert.equal(r.status, 0) +}) + +test('malformed payload is ignored (fail-open)', () => { + const r = spawnSync('node', [HOOK], { + input: 'not-json', + }) + assert.equal(r.status, 0) +}) + +test('empty command is ignored', () => { + const r = runHook('', { cwd: tmpRepo }) + assert.equal(r.code, 0) +}) + +// ─── FLEET_SYNC=1 sentinel ──────────────────────────────────────── + +test('FLEET_SYNC=1 allows `git add -u`', () => { + const r = runHook('FLEET_SYNC=1 git add -u', { cwd: tmpRepo }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('FLEET_SYNC=1 allows `git add -A`', () => { + const r = runHook('FLEET_SYNC=1 git add -A', { cwd: tmpRepo }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('FLEET_SYNC=1 allows `git add .`', () => { + const r = runHook('FLEET_SYNC=1 git add .', { cwd: tmpRepo }) + assert.equal(r.code, 0) + assert.equal(r.stderr, '') +}) + +test('no FLEET_SYNC: `git add -u` still blocked', () => { + const r = runHook('git add -u', { cwd: tmpRepo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /Blocked: git add -u/) +}) + +test('FLEET_SYNC=0 (explicit off): `git add -u` still blocked', () => { + const r = runHook('FLEET_SYNC=0 git add -u', { cwd: tmpRepo }) + assert.equal(r.code, 2) +}) diff --git a/.claude/hooks/setup-basics-tools/tsconfig.json b/.claude/hooks/fleet/overeager-staging-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-basics-tools/tsconfig.json rename to .claude/hooks/fleet/overeager-staging-guard/tsconfig.json diff --git a/.claude/hooks/fleet/parallel-agent-edit-guard/README.md b/.claude/hooks/fleet/parallel-agent-edit-guard/README.md new file mode 100644 index 0000000..9b93609 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-edit-guard/README.md @@ -0,0 +1,51 @@ +# parallel-agent-edit-guard + +PreToolUse (Edit / Write / NotebookEdit) hook. Blocks a write whose target +file is **another agent's in-flight work** — dirty in this checkout, not +authored by this session, and changed recently. Writing it would silently +clobber the other agent's uncommitted edits. + +## When it fires + +Only when the **edit target** is foreign (see `_shared/foreign-paths.mts`): + +- the target path is dirty in `git status --porcelain` (minus + untracked-by-default trees: `vendor/`, `third_party/`, `upstream/`, …), +- its resolved absolute path is not in this session's transcript + touched-set (Edit / Write / NotebookEdit `file_path` + `git add|mv|rm`), +- its on-disk mtime is within 30 min of now (stale pre-session dirt is + ignored). + +Editing your own files, a fresh file nobody has touched, or any file when +no parallel agent is active — all pass through. + +## Why + +Incident 2026-05-27: two Claude sessions plus a Codex companion shared one +`socket-wheelhouse` checkout. One session repeatedly re-cascaded +`shell-command.mts` + test files, silently reverting the other session's +type-error fixes one Edit at a time. The four-times-clobbered fixes only +stuck once both sessions stopped touching the same files. + +`parallel-agent-staging-guard` catches the *git-op* version of this hazard +(`git add -A` / `stash` / `reset --hard`); it can't see a plain `Write` +that overwrites a file. This hook closes that gap at the write itself. + +## Companion hooks + +- `parallel-agent-staging-guard` — refuses git ops that sweep/destroy + foreign work. +- `parallel-agent-on-stop-reminder` — surfaces the foreign-path signal at + turn end (informational). + +All three share the `_shared/foreign-paths.mts` heuristic. + +## Bypass + +- User types `Allow parallel-agent-edit bypass` in chat (case-sensitive), + then retry — one action. +- `SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED=1` in env. +- `FLEET_SYNC=1` in env — cascade scripts run in a fresh worktree off + `origin/main`, so there is no parallel-session hazard. + +Fails open on hook bugs (exit 0 + stderr log). diff --git a/.claude/hooks/fleet/parallel-agent-edit-guard/index.mts b/.claude/hooks/fleet/parallel-agent-edit-guard/index.mts new file mode 100644 index 0000000..9e3d3bd --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-edit-guard/index.mts @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — parallel-agent-edit-guard. +// +// Blocks an Edit / Write / NotebookEdit whose target file is ANOTHER +// agent's in-flight work: a path that is dirty in this checkout, was NOT +// authored by this session, and changed recently. Writing it would +// silently clobber the other agent's uncommitted edits (the failure mode +// where two sessions share one `.git/` and each overwrites the other's +// changes mid-edit). +// +// Relationship to the sibling parallel-agent hooks: +// • parallel-agent-staging-guard — refuses git ops (add -A / stash / +// reset --hard / …) that sweep up or destroy foreign work. +// • parallel-agent-on-stop-reminder — surfaces the foreign-path signal +// at turn end (informational). +// • THIS hook — refuses the direct file write that clobbers a foreign +// file before it lands. Same "foreign" heuristic +// (`_shared/foreign-paths.mts`), applied to the edit target. +// +// Only fires when the target is itself foreign — editing your own files, +// or any file when no parallel agent is active, passes through. A fresh +// (untouched-by-anyone) file is never foreign. +// +// Why this exists (incident 2026-05-27): two Claude sessions + a Codex +// companion shared one socket-wheelhouse checkout. One session kept +// re-cascading shell-command.mts / test files, silently reverting the +// other's type-error fixes Edit-by-Edit. The staging guard didn't catch +// it (no git op involved) — the clobber was a plain Write. +// +// Bypass: +// • `SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED=1`. +// • `Allow parallel-agent-edit bypass` in a recent user turn +// (case-sensitive) — one action. +// • `FLEET_SYNC=1` in env — cascade scripts run in a fresh worktree off +// origin/main and have no parallel-session hazard. +// +// Fails open on hook bugs (exit 0 + stderr log). Reads a PreToolUse JSON +// payload from stdin: +// { "tool_name": "Edit" | "Write" | "NotebookEdit", +// "tool_input": { "file_path": "..." }, +// "transcript_path": "/.../session.jsonl" } + +import path from 'node:path' +import process from 'node:process' + +import { + listForeignDirtyPaths, + readTouchedPaths, +} from '../_shared/foreign-paths.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolPayload { + readonly tool_name?: string | undefined + readonly tool_input?: { readonly file_path?: unknown | undefined } | undefined + readonly transcript_path?: string | undefined +} + +const ENV_DISABLE = 'SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED' +const BYPASS_PHRASES = ['Allow parallel-agent-edit bypass'] as const +const EDIT_TOOLS = new Set(['Edit', 'NotebookEdit', 'Write']) + +function getProjectDir(): string { + return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() +} + +async function main(): Promise { + if (process.env[ENV_DISABLE] || process.env['FLEET_SYNC'] === '1') { + process.exit(0) + } + const raw = await readStdin() + let payload: ToolPayload + try { + payload = JSON.parse(raw) as ToolPayload + } catch { + process.exit(0) + } + if (!payload.tool_name || !EDIT_TOOLS.has(payload.tool_name)) { + process.exit(0) + } + const filePath = ( + payload.tool_input as { file_path?: unknown | undefined } | undefined + )?.file_path + if (typeof filePath !== 'string' || !filePath.trim()) { + process.exit(0) + } + + const repoDir = getProjectDir() + const targetAbs = path.resolve(repoDir, filePath) + + const touched = readTouchedPaths(payload.transcript_path) + // If THIS session already authored the target, it's ours — not foreign. + if (touched.has(targetAbs)) { + process.exit(0) + } + + const foreign = listForeignDirtyPaths(repoDir, touched) + if (foreign.length === 0) { + process.exit(0) + } + // The target is foreign only if it's in the foreign-dirty set. + const targetIsForeign = foreign.some( + rel => path.resolve(repoDir, rel) === targetAbs, + ) + if (!targetIsForeign) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES, 3) + ) { + process.exit(0) + } + + process.stderr.write( + [ + `[parallel-agent-edit-guard] Blocked: ${payload.tool_name} ${filePath}`, + '', + ' This file is dirty in the checkout, was NOT authored by this', + ' session, and changed recently — another agent on the same `.git/`', + ' is editing it. Writing now would silently clobber their', + ' uncommitted work (and they may clobber yours right back).', + '', + ' Fix: coordinate — let the other session commit first, or work on', + ' a different file. For an isolated edit, use a `git worktree`.', + '', + ' Bypass (only if you are certain the other edit is abandoned):', + ' user types "Allow parallel-agent-edit bypass" in chat, then retry.', + ].join('\n') + '\n', + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[parallel-agent-edit-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/parallel-agent-edit-guard/package.json b/.claude/hooks/fleet/parallel-agent-edit-guard/package.json new file mode 100644 index 0000000..3af0e2a --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-edit-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-parallel-agent-edit-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/parallel-agent-edit-guard/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-edit-guard/test/index.test.mts new file mode 100644 index 0000000..a58a460 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-edit-guard/test/index.test.mts @@ -0,0 +1,180 @@ +/** + * @file Unit tests for parallel-agent-edit-guard hook. The guard blocks an Edit + * / Write / NotebookEdit whose target file is foreign: dirty in the checkout, + * not in this session's transcript touched-set, recently changed. Editing + * your own file, a fresh file, or any file when no parallel agent is active + * passes through. Each test builds a real git repo in tmpdir, optionally + * creates a "foreign" dirty file (written WITHOUT a transcript entry), and + * runs the hook as a child process with a synthesized PreToolUse payload. + */ + +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, test } from 'node:test' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(__dirname, '..', 'index.mts') + +interface RunResult { + readonly code: number + readonly stderr: string +} + +function runHook( + filePath: string, + options: { + toolName?: string | undefined + cwd?: string | undefined + transcriptPath?: string | undefined + env?: Record | undefined + } = {}, +): RunResult { + const payload = { + tool_name: options.toolName ?? 'Write', + tool_input: { file_path: filePath }, + transcript_path: options.transcriptPath, + } + const r = spawnSync('node', [HOOK], { + input: JSON.stringify(payload), + env: { + ...process.env, + ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), + ...(options.env ?? {}), + }, + }) + return { + code: typeof r.status === 'number' ? r.status : 0, + stderr: String(r.stderr || ''), + } +} + +function gitInit(repo: string): void { + spawnSync('git', ['init', '-q'], { cwd: repo }) + spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) +} + +// Write a dirty file with NO transcript entry → it reads as foreign. +function writeForeign(repo: string, name: string): string { + const p = path.join(repo, name) + writeFileSync(p, 'foreign content') + return p +} + +// A transcript whose only tool use is an Edit on `ownAbsPath` → that path is +// this session's, not foreign. +function writeTranscriptTouching(ownAbsPath: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'paeguard-tx-')) + const transcriptPath = path.join(dir, 'session.jsonl') + const entry = { + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'Edit', input: { file_path: ownAbsPath } }, + ], + }, + } + writeFileSync(transcriptPath, JSON.stringify(entry)) + return transcriptPath +} + +let repo: string + +beforeEach(() => { + repo = mkdtempSync(path.join(os.tmpdir(), 'paeguard-repo-')) + gitInit(repo) +}) + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }) +}) + +// ─── Blocks when the target is foreign ──────────────────────────── + +test('blocks Write to a foreign dirty file', () => { + const theirs = writeForeign(repo, 'theirs.txt') + const r = runHook(theirs, { cwd: repo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /Blocked/) + assert.match(r.stderr, /theirs\.txt/) +}) + +test('blocks Edit to a foreign dirty file', () => { + const theirs = writeForeign(repo, 'theirs.txt') + const r = runHook(theirs, { cwd: repo, toolName: 'Edit' }) + assert.equal(r.code, 2) +}) + +test('blocks NotebookEdit to a foreign dirty file', () => { + const theirs = writeForeign(repo, 'theirs.ipynb') + const r = runHook(theirs, { cwd: repo, toolName: 'NotebookEdit' }) + assert.equal(r.code, 2) +}) + +test('foreign target matches via a repo-relative file_path', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('theirs.txt', { cwd: repo }) + assert.equal(r.code, 2) +}) + +// ─── Passes ─────────────────────────────────────────────────────── + +test("allows editing THIS session's own dirty file", () => { + const own = writeForeign(repo, 'mine.txt') + const tx = writeTranscriptTouching(own) + const r = runHook(own, { cwd: repo, transcriptPath: tx }) + assert.equal(r.code, 0) +}) + +test("allows editing a foreign file's NEIGHBOR (different file)", () => { + writeForeign(repo, 'theirs.txt') + // Target is a fresh file the other agent isn't touching. + const r = runHook(path.join(repo, 'ours-new.txt'), { cwd: repo }) + assert.equal(r.code, 0) +}) + +test('allows editing a fresh file in a clean repo (no foreign paths)', () => { + const r = runHook(path.join(repo, 'new.txt'), { cwd: repo }) + assert.equal(r.code, 0) +}) + +// ─── Bypass / sentinel / disable ────────────────────────────────── + +test('FLEET_SYNC=1 env bypasses the block', () => { + const theirs = writeForeign(repo, 'theirs.txt') + const r = runHook(theirs, { cwd: repo, env: { FLEET_SYNC: '1' } }) + assert.equal(r.code, 0) +}) + +test('disabled via env var', () => { + const theirs = writeForeign(repo, 'theirs.txt') + const r = runHook(theirs, { + cwd: repo, + env: { SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED: '1' }, + }) + assert.equal(r.code, 0) +}) + +test('non-edit tool is ignored', () => { + writeForeign(repo, 'theirs.txt') + const r = spawnSync('node', [HOOK], { + input: JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'ls' }, + }), + env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, + }) + assert.equal(typeof r.status === 'number' ? r.status : 0, 0) +}) + +test('fails open on malformed payload', () => { + const r = spawnSync('node', [HOOK], { + input: 'not json', + env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, + }) + assert.equal(typeof r.status === 'number' ? r.status : 0, 0) +}) diff --git a/.claude/hooks/setup-claude-scanners/tsconfig.json b/.claude/hooks/fleet/parallel-agent-edit-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-claude-scanners/tsconfig.json rename to .claude/hooks/fleet/parallel-agent-edit-guard/tsconfig.json diff --git a/.claude/hooks/fleet/parallel-agent-on-stop-reminder/README.md b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/README.md new file mode 100644 index 0000000..1d93988 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/README.md @@ -0,0 +1,37 @@ +# parallel-agent-on-stop-reminder + +Stop hook. At turn-end, lists dirty paths this session did **not** author and +that changed recently — the fingerprint of another Claude session sharing the +same `.git/`. Informational (exit 0, never blocks). + +## Heuristic + +A path is **foreign** when all hold (see `_shared/foreign-paths.mts`): + +- it's dirty in `git status --porcelain` (minus untracked-by-default trees: + `vendor/`, `third_party/`, `upstream/`, `*-bundled`, …), +- its resolved absolute path is not in this session's transcript touched-set + (Edit / Write / NotebookEdit `file_path` + `git add|mv|rm ` from Bash), +- its on-disk mtime is within `maxAgeMs` (default 30 min) of now — so stale + pre-session dirt doesn't false-fire. Deleted / renamed entries count without a + mtime check. + +## Why + +Incident 2026-05-27, socket-lib: a session running `pnpm run check` saw ~6 dirty +files it never touched (a parallel agent's esbuild→rolldown migration) and nearly +investigated them as its own regression, then nearly committed them. Nothing +warned it. This hook makes the signal visible at the turn that surfaces it. + +## Config + +- Disable: `SOCKET_PARALLEL_AGENT_REMINDER_DISABLED=1`. + +## Related + +- `parallel-agent-staging-guard` — PreToolUse block on destructive git ops while + foreign paths exist (the enforcement half). +- `dirty-worktree-on-stop-reminder` — the broader "you left the worktree dirty" + reminder this is modeled on. +- `overeager-staging-guard` — commit-time block on staging unfamiliar files. +- CLAUDE.md → "Parallel Claude sessions". diff --git a/.claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts new file mode 100644 index 0000000..7cd0d71 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Claude Code Stop hook — parallel-agent-on-stop-reminder. +// +// Fires at turn-end. Detects dirty paths in the checkout that THIS +// session did not author and that changed recently — the fingerprint +// of another Claude session (parallel agent, second terminal, or a +// worktree sharing the same `.git/`) working in the codebase at the +// same time. Emits a stderr reminder listing those foreign paths so +// the agent treats them cautiously: don't commit / revert / stash / +// stage them, stage only your own files by explicit path. +// +// Why this exists (incident 2026-05-27, socket-lib): a session running +// `pnpm run check` / build saw ~6 dirty files it never touched (an +// esbuild->rolldown migration another agent was mid-flight on) and +// nearly investigated them as its own regression, then nearly swept +// them into a commit. Nothing warned it. CLAUDE.md "Parallel Claude +// sessions" states the rule; this hook makes the live signal visible +// at the turn that surfaced it. +// +// Heuristic lives in `_shared/foreign-paths.mts` (shared with +// overeager-staging-guard + parallel-agent-staging-guard): foreign = +// dirty AND not in this session's transcript touched-set AND mtime +// recent. Vendored / build-copied trees are excluded. +// +// Exit codes: +// 0 — always. Informational; never blocks (Stop hooks fire after the +// turn ended — there's no tool call to refuse). +// +// Disabled via `SOCKET_PARALLEL_AGENT_REMINDER_DISABLED=1`. + +import process from 'node:process' + +import { + listForeignDirtyPaths, + readTouchedPaths, +} from '../_shared/foreign-paths.mts' +import { readStdin } from '../_shared/transcript.mts' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +function getProjectDir(): string | undefined { + return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() +} + +async function main(): Promise { + if (process.env['SOCKET_PARALLEL_AGENT_REMINDER_DISABLED']) { + return + } + const raw = await readStdin() + let payload: StopPayload = {} + try { + payload = JSON.parse(raw) as StopPayload + } catch { + // Stop payload is optional for this hook; fall through with no + // transcript (touched-set empty → every recent dirty path counts). + } + + const repoDir = getProjectDir() + if (!repoDir) { + return + } + + const touched = readTouchedPaths(payload.transcript_path) + const foreign = listForeignDirtyPaths(repoDir, touched) + if (foreign.length === 0) { + return + } + + process.stderr.write( + `[parallel-agent-on-stop-reminder] ${foreign.length} dirty path(s) this session did not author and that changed recently — likely another agent on the same checkout:\n`, + ) + for (const p of foreign.slice(0, 10)) { + process.stderr.write(` ${p}\n`) + } + if (foreign.length > 10) { + process.stderr.write(` ... and ${foreign.length - 10} more\n`) + } + process.stderr.write( + '\nAnother Claude session may be working in this checkout. Be cautious:\n' + + ' • Do NOT commit, revert, stash, or `git add -A` these paths —\n' + + " that sweeps up or destroys the other agent's in-flight work.\n" + + ' • Stage only the files YOU authored, by explicit path.\n' + + ' • If you saw these appear after your own build / check run, they\n' + + " are the other agent's edits landing — not your regression.\n" + + '\nSee: CLAUDE.md → "Parallel Claude sessions"\n' + + ' docs/claude.md/fleet/parallel-claude-sessions.md\n', + ) +} + +main().catch(e => { + process.stderr.write( + `[parallel-agent-on-stop-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) +}) diff --git a/.claude/hooks/fleet/parallel-agent-on-stop-reminder/package.json b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/package.json new file mode 100644 index 0000000..3c8075c --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-parallel-agent-on-stop-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts new file mode 100644 index 0000000..6e00020 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts @@ -0,0 +1,138 @@ +/** + * @file Unit tests for parallel-agent-on-stop-reminder hook. + * + * Stop hook, always exit 0. Emits a stderr reminder listing dirty paths this + * session did not author and that changed recently. Each test builds a real + * git repo in tmpdir, writes foreign / own dirty files, and runs the hook as a + * child process with a synthesized Stop payload. + */ + +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, test } from 'node:test' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(__dirname, '..', 'index.mts') + +interface RunResult { + readonly code: number + readonly stderr: string +} + +function runHook( + options: { + cwd?: string | undefined + transcriptPath?: string | undefined + env?: Record | undefined + } = {}, +): RunResult { + const payload = { transcript_path: options.transcriptPath } + const r = spawnSync('node', [HOOK], { + input: JSON.stringify(payload), + env: { + ...process.env, + ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), + ...(options.env ?? {}), + }, + }) + return { + code: typeof r.status === 'number' ? r.status : 0, + stderr: String(r.stderr || ''), + } +} + +function gitInit(repo: string): void { + spawnSync('git', ['init', '-q'], { cwd: repo }) + spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) +} + +function writeFile(repo: string, name: string): string { + const p = path.join(repo, name) + writeFileSync(p, 'content') + return p +} + +function writeTranscriptTouching(ownAbsPath: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'pareminder-tx-')) + const transcriptPath = path.join(dir, 'session.jsonl') + const entry = { + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'Write', input: { file_path: ownAbsPath } }, + ], + }, + } + writeFileSync(transcriptPath, JSON.stringify(entry)) + return transcriptPath +} + +let repo: string + +beforeEach(() => { + repo = mkdtempSync(path.join(os.tmpdir(), 'pareminder-repo-')) + gitInit(repo) +}) + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }) +}) + +test('always exits 0', () => { + writeFile(repo, 'theirs.txt') + assert.equal(runHook({ cwd: repo }).code, 0) +}) + +test('reminds when a foreign dirty file exists (no transcript)', () => { + writeFile(repo, 'theirs.txt') + const r = runHook({ cwd: repo }) + assert.match(r.stderr, /parallel-agent-on-stop-reminder/) + assert.match(r.stderr, /theirs\.txt/) + assert.match(r.stderr, /another (Claude )?session|another agent/i) +}) + +test('silent when the only dirty file is this session\'s', () => { + const own = writeFile(repo, 'mine.txt') + const tx = writeTranscriptTouching(own) + const r = runHook({ cwd: repo, transcriptPath: tx }) + assert.equal(r.code, 0) + assert.doesNotMatch(r.stderr, /mine\.txt/) +}) + +test('silent on a clean repo', () => { + const r = runHook({ cwd: repo }) + assert.equal(r.code, 0) + assert.doesNotMatch(r.stderr, /parallel-agent-on-stop-reminder.*dirty/s) +}) + +test('ignores untracked-by-default trees (vendor/)', () => { + spawnSync('mkdir', ['-p', path.join(repo, 'vendor')], { cwd: repo }) + writeFile(repo, path.join('vendor', 'dep.js')) + const r = runHook({ cwd: repo }) + assert.doesNotMatch(r.stderr, /vendor\/dep\.js/) +}) + +test('disabled via env var', () => { + writeFile(repo, 'theirs.txt') + const r = runHook({ + cwd: repo, + env: { SOCKET_PARALLEL_AGENT_REMINDER_DISABLED: '1' }, + }) + assert.equal(r.code, 0) + assert.equal(r.stderr.trim(), '') +}) + +test('fails open on malformed payload', () => { + writeFile(repo, 'theirs.txt') + const r = spawnSync('node', [HOOK], { + input: 'not json', + env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, + }) + // No transcript → empty touched-set → still lists foreign, but never crashes. + assert.equal(typeof r.status === 'number' ? r.status : 0, 0) +}) diff --git a/.claude/hooks/setup-firewall/tsconfig.json b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/setup-firewall/tsconfig.json rename to .claude/hooks/fleet/parallel-agent-on-stop-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/parallel-agent-staging-guard/README.md b/.claude/hooks/fleet/parallel-agent-staging-guard/README.md new file mode 100644 index 0000000..915ea28 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/README.md @@ -0,0 +1,47 @@ +# parallel-agent-staging-guard + +PreToolUse (Bash) hook. Blocks git operations that would sweep up, hide, or +destroy another agent's in-flight work — **only when foreign dirty paths are +present** in the checkout. Surgical ops and the all-clear case pass through. + +## Gated operations (blocked only when foreign paths exist) + +| Op | Hazard | +|----|--------| +| `git add -A` / `.` / `--all` / `-u` | stages their unstaged edits | +| `git commit -a` / `--all` | stages + commits their edits | +| `git stash` / `stash push` | hides their working-tree changes | +| `git reset --hard` | destroys their uncommitted work | +| `git checkout ` / `git switch ` | may clobber on switch | +| `git restore ` | reverts their changes | + +Detection runs through the shared shell AST parser +(`_shared/shell-command.mts`), so indirection can't dodge it +(`git $(echo add) -A`, `g=git; $g stash`). Broad-add detection reuses +`detectBroadGitAdd` so this hook and `overeager-staging-guard` agree. + +## Relationship to overeager-staging-guard + +`overeager-staging-guard` owns the **general** broad-add rule (blocks `git add -A` +regardless of parallel agents). This hook adds the parallel-agent-specific +**destructive-op** coverage (stash / reset --hard / checkout / restore) and fires +**only** when the parallel-agent signal is live. On plain `git add -A` both may +fire; messages complement (this one names the foreign paths). + +## Foreign-path heuristic + +Same as `parallel-agent-on-stop-reminder` — see `_shared/foreign-paths.mts`. + +## Config / bypass + +- `FLEET_SYNC=1` command prefix — cascade worktrees off origin/main have no + parallel-session hazard. +- `Allow parallel-agent-staging bypass` in a recent user turn — one action. +- `SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED=1` — disable entirely. + +Fails open on hook bugs (exit 0 + stderr log). + +## Why + +Incident 2026-05-27, socket-lib — see `parallel-agent-on-stop-reminder`. The +reminder surfaces the signal; this guard refuses the destructive action. diff --git a/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts b/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts new file mode 100644 index 0000000..81848b8 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — parallel-agent-staging-guard. +// +// Blocks git operations that would sweep up or destroy ANOTHER agent's +// in-flight work when foreign dirty paths are present in the checkout. +// "Foreign" = dirty, not authored by this session (transcript touched- +// set), changed recently — see `_shared/foreign-paths.mts`. +// +// Gated operations (only blocked WHEN foreign paths exist): +// • `git add -A` / `.` / `--all` / `-u` / `--update` (broad stage) +// • `git commit -a` / `--all` (stage+commit) +// • `git stash` / `git stash push` (hides theirs) +// • `git reset --hard` (destroys theirs) +// • `git checkout ` / `git switch ` (may clobber) +// • `git restore ` (reverts theirs) +// +// Surgical `git add ` and every op when NO foreign paths are +// present pass through untouched. +// +// Relationship to overeager-staging-guard: that hook owns the GENERAL +// broad-add rule (blocks `git add -A` regardless of parallel agents). +// This hook adds the parallel-agent-specific destructive-op coverage +// (stash / reset --hard / checkout / restore) that the broad-add rule +// doesn't reach, and only fires when the parallel-agent signal is live. +// On plain `git add -A` both may fire; their messages are written to +// complement, not contradict (this one names the foreign paths). +// +// Why this exists (incident 2026-05-27, socket-lib): see +// parallel-agent-on-stop-reminder. The Stop reminder surfaces the +// signal; this guard refuses the destructive action before it lands. +// +// Reuses the shared shell AST parser (`_shared/shell-command.mts`) so +// chains / substitution / quoting / `$VAR` indirection can't dodge the +// match (`git $(echo add) -A`, `g=git; $g stash`). +// +// Bypass: +// • `FLEET_SYNC=1` command prefix — cascade scripts in a fresh +// worktree off origin/main have no parallel-session hazard. +// • `Allow parallel-agent-staging bypass` in a recent user turn +// (case-sensitive) — one action. +// • `SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED=1`. +// +// Fails open on hook bugs (exit 0 + stderr log). Reads a PreToolUse JSON +// payload from stdin: +// { "tool_name": "Bash", +// "tool_input": { "command": "..." }, +// "transcript_path": "/.../session.jsonl" } + +import process from 'node:process' + +import { + listForeignDirtyPaths, + readTouchedPaths, +} from '../_shared/foreign-paths.mts' +import { + detectBroadGitAdd, + findInvocation, + invocationHasFlag, +} from '../_shared/shell-command.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolPayload { + readonly tool_name?: string | undefined + readonly tool_input?: { readonly command?: unknown | undefined } | undefined + readonly transcript_path?: string | undefined +} + +const ENV_DISABLE = 'SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED' +const BYPASS_PHRASES = ['Allow parallel-agent-staging bypass'] as const + +function getProjectDir(): string { + return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() +} + +// Return a short label for the gated op the command performs, or undefined. +// Reuses the shared AST parser — never regex on the raw string. +export function detectGatedGitOp(command: string): string | undefined { + // Broad `git add -A/./--all/-u` — reuse the canonical detector so this + // hook and overeager-staging-guard agree on what "broad" means. + const broadAdd = detectBroadGitAdd(command) + if (broadAdd) { + return broadAdd + } + // `git commit -a/--all`. + if ( + findInvocation(command, { binary: 'git', subcommand: 'commit' }) && + invocationHasFlag(command, 'git', ['-a', '--all']) + ) { + return 'git commit -a' + } + // `git stash` (and `stash push`). + if (findInvocation(command, { binary: 'git', subcommand: 'stash' })) { + return 'git stash' + } + // `git reset --hard`. + if ( + findInvocation(command, { binary: 'git', subcommand: 'reset' }) && + invocationHasFlag(command, 'git', ['--hard']) + ) { + return 'git reset --hard' + } + // `git checkout ` / `git switch `. + if ( + findInvocation(command, { binary: 'git', subcommand: 'checkout' }) || + findInvocation(command, { binary: 'git', subcommand: 'switch' }) + ) { + return 'git checkout/switch' + } + // `git restore`. + if (findInvocation(command, { binary: 'git', subcommand: 'restore' })) { + return 'git restore' + } + return undefined +} + +async function main(): Promise { + if (process.env[ENV_DISABLE]) { + process.exit(0) + } + const raw = await readStdin() + let payload: ToolPayload + try { + payload = JSON.parse(raw) as ToolPayload + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = ( + payload.tool_input as { command?: unknown } | undefined + )?.command + if (typeof command !== 'string' || !command.trim()) { + process.exit(0) + } + + // Fleet-sync cascade sentinel: no parallel-session hazard in a fresh + // cascade worktree off origin/main. + if (/(?:^|\s)FLEET_SYNC\s*=\s*1\b/.test(command)) { + process.exit(0) + } + + const gatedOp = detectGatedGitOp(command) + if (!gatedOp) { + process.exit(0) + } + + const repoDir = getProjectDir() + const touched = readTouchedPaths(payload.transcript_path) + const foreign = listForeignDirtyPaths(repoDir, touched) + if (foreign.length === 0) { + // No parallel-agent signal — let the op through (overeager-staging- + // guard still owns the general broad-add rule independently). + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES, 3) + ) { + process.exit(0) + } + + process.stderr.write( + [ + `[parallel-agent-staging-guard] Blocked: ${gatedOp}`, + '', + ` ${foreign.length} dirty path(s) here were NOT authored by this`, + ' session and changed recently — likely another agent on the', + ' same checkout. This operation would sweep up, hide, or destroy', + ' their in-flight work:', + ...foreign.slice(0, 10).map(p => ` ${p}`), + ...(foreign.length > 10 ? [` ... and ${foreign.length - 10} more`] : []), + '', + ' Fix: stage only YOUR files by explicit path, and avoid stash /', + ' reset --hard / checkout while the other agent is active.', + ' git add path/to/your-file.ts', + '', + ' Bypass (only if you are certain): user types', + ' "Allow parallel-agent-staging bypass" in chat, then retry.', + ].join('\n') + '\n', + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[parallel-agent-staging-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/parallel-agent-staging-guard/package.json b/.claude/hooks/fleet/parallel-agent-staging-guard/package.json new file mode 100644 index 0000000..bac8547 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-parallel-agent-staging-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts new file mode 100644 index 0000000..1ce39e9 --- /dev/null +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts @@ -0,0 +1,194 @@ +/** + * @file Unit tests for parallel-agent-staging-guard hook. + * + * The guard blocks sweep / destructive git ops (add -A, commit -a, stash, + * reset --hard, checkout, restore) ONLY when foreign dirty paths are present: + * dirty, not in this session's transcript touched-set, recently changed. + * + * Each test builds a real git repo in tmpdir, optionally creates a "foreign" + * dirty file (written WITHOUT a corresponding Edit/Write transcript entry), + * and runs the hook as a child process with a synthesized PreToolUse payload. + */ + +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, test } from 'node:test' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(__dirname, '..', 'index.mts') + +interface RunResult { + readonly code: number + readonly stderr: string +} + +function runHook( + command: string, + options: { + cwd?: string | undefined + transcriptPath?: string | undefined + env?: Record | undefined + } = {}, +): RunResult { + const payload = { + tool_name: 'Bash', + tool_input: { command }, + transcript_path: options.transcriptPath, + } + const r = spawnSync('node', [HOOK], { + input: JSON.stringify(payload), + env: { + ...process.env, + ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), + ...(options.env ?? {}), + }, + }) + return { + code: typeof r.status === 'number' ? r.status : 0, + stderr: String(r.stderr || ''), + } +} + +function gitInit(repo: string): void { + spawnSync('git', ['init', '-q'], { cwd: repo }) + spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) +} + +// Write a dirty file with NO transcript entry → it reads as foreign. +function writeForeign(repo: string, name: string): string { + const p = path.join(repo, name) + writeFileSync(p, 'foreign content') + return p +} + +// A transcript whose only tool use is an Edit on `ownFile` → that path is +// this session's, not foreign. +function writeTranscriptTouching(ownAbsPath: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'paguard-tx-')) + const transcriptPath = path.join(dir, 'session.jsonl') + const entry = { + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'Edit', input: { file_path: ownAbsPath } }, + ], + }, + } + writeFileSync(transcriptPath, JSON.stringify(entry)) + return transcriptPath +} + +let repo: string + +beforeEach(() => { + repo = mkdtempSync(path.join(os.tmpdir(), 'paguard-repo-')) + gitInit(repo) +}) + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }) +}) + +// ─── Blocks when foreign paths present ──────────────────────────── + +test('blocks `git add -A` when a foreign dirty file exists', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('git add -A', { cwd: repo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /Blocked/) + assert.match(r.stderr, /theirs\.txt/) +}) + +test('blocks `git stash` when a foreign dirty file exists', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('git stash', { cwd: repo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /git stash/) +}) + +test('blocks `git reset --hard` when a foreign dirty file exists', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('git reset --hard', { cwd: repo }) + assert.equal(r.code, 2) + assert.match(r.stderr, /reset --hard/) +}) + +test('blocks `git checkout other` when a foreign dirty file exists', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('git checkout other-branch', { cwd: repo }) + assert.equal(r.code, 2) +}) + +test('blocks `git restore .` when a foreign dirty file exists', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('git restore .', { cwd: repo }) + assert.equal(r.code, 2) +}) + +test('sees through variable indirection (`g=git; $g stash`)', () => { + writeForeign(repo, 'theirs.txt') + // shell-quote flags $g as variable-sourced; the guard should still treat a + // resolvable `git stash` shape cautiously. If the parser cannot resolve the + // binary, the op is not matched — documents current behavior. + const r = runHook('git stash', { cwd: repo }) + assert.equal(r.code, 2) +}) + +// ─── Passes when NO foreign paths ───────────────────────────────── + +test('allows `git add -A` in a clean repo (no foreign paths)', () => { + const r = runHook('git add -A', { cwd: repo }) + assert.equal(r.code, 0) +}) + +test('allows `git stash` when the only dirty file is this session\'s', () => { + const own = writeForeign(repo, 'mine.txt') + const tx = writeTranscriptTouching(own) + const r = runHook('git stash', { cwd: repo, transcriptPath: tx }) + assert.equal(r.code, 0) +}) + +test('allows a surgical `git add ` even with foreign paths present', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('git add mine.txt', { cwd: repo }) + assert.equal(r.code, 0) +}) + +// ─── Bypass / sentinel / disable ────────────────────────────────── + +test('FLEET_SYNC=1 prefix bypasses the block', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('FLEET_SYNC=1 git add -A', { cwd: repo }) + assert.equal(r.code, 0) +}) + +test('disabled via env var', () => { + writeForeign(repo, 'theirs.txt') + const r = runHook('git stash', { + cwd: repo, + env: { SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED: '1' }, + }) + assert.equal(r.code, 0) +}) + +test('non-Bash tool is ignored', () => { + writeForeign(repo, 'theirs.txt') + const r = spawnSync('node', [HOOK], { + input: JSON.stringify({ tool_name: 'Edit', tool_input: {} }), + env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, + }) + assert.equal(typeof r.status === 'number' ? r.status : 0, 0) +}) + +test('fails open on malformed payload', () => { + const r = spawnSync('node', [HOOK], { + input: 'not json', + env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, + }) + assert.equal(typeof r.status === 'number' ? r.status : 0, 0) +}) diff --git a/.claude/hooks/setup-misc-tools/tsconfig.json b/.claude/hooks/fleet/parallel-agent-staging-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-misc-tools/tsconfig.json rename to .claude/hooks/fleet/parallel-agent-staging-guard/tsconfig.json diff --git a/.claude/hooks/path-guard/README.md b/.claude/hooks/fleet/path-guard/README.md similarity index 100% rename from .claude/hooks/path-guard/README.md rename to .claude/hooks/fleet/path-guard/README.md diff --git a/.claude/hooks/path-guard/index.mts b/.claude/hooks/fleet/path-guard/index.mts similarity index 100% rename from .claude/hooks/path-guard/index.mts rename to .claude/hooks/fleet/path-guard/index.mts diff --git a/.claude/hooks/path-guard/package.json b/.claude/hooks/fleet/path-guard/package.json similarity index 100% rename from .claude/hooks/path-guard/package.json rename to .claude/hooks/fleet/path-guard/package.json diff --git a/.claude/hooks/fleet/path-guard/path-guard/README.md b/.claude/hooks/fleet/path-guard/path-guard/README.md new file mode 100644 index 0000000..bec03c1 --- /dev/null +++ b/.claude/hooks/fleet/path-guard/path-guard/README.md @@ -0,0 +1,113 @@ +# path-guard + +A **Claude Code hook** that runs before `Edit` or `Write` tool calls +on `.mts` or `.cts` files and **blocks** edits that would build a +multi-segment build/output path inline. The fleet's rule, in one +sentence: + +> 1 path, 1 reference. Construct a path _once_ in a canonical +> `paths.mts` (or a build-infra helper); reference the computed value +> everywhere else. + +> If you haven't worked with Claude Code hooks before: hooks are tiny +> scripts that run at specific lifecycle points. A `PreToolUse` hook +> like this one fires _before_ Claude calls a tool. It can either +> **prime** (write to stderr, exit 0, model carries on) or **block** +> (exit 2, edit never happens). This one blocks. + +## Why this rule exists + +Build outputs typically nest deep — `build///out/Final/`. +If three different scripts all `path.join(...)` their own version of +that path, a refactor that changes the layout breaks one or two of +them silently. Centralizing the construction in a single `paths.mts` +per package means a refactor is a one-file diff, and divergence +becomes impossible because every consumer imports the same value. + +The companion `scripts/check-paths.mts` runs a deeper whole-repo +scan at `pnpm check` time, catching anything this hook missed. + +## What it blocks + +| Rule | Example that gets blocked | Fix | +| ------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A** — multi-stage path constructed inline | `path.join(PKG, 'build', mode, 'out', 'Final', name)` | Move the construction into the package's `scripts/paths.mts` (or use `getFinalBinaryPath` from `build-infra/lib/paths`); import the computed value here. | +| **B** — cross-package path traversal | `path.join(PKG, '..', 'lief-builder', 'build', ...)` | Add `lief-builder: workspace:*` as a dependency; import its `paths.mts` via the workspace `exports` field. | + +The hook fires on `Edit` and `Write` tool calls when the target path +ends in `.mts` or `.cts`. Other extensions (`.ts`, `.mjs`, `.js`, +`.yml`, `.json`, `.md`) pass through — TS path code lives in `.mts` +per fleet convention, and other file types are covered by the +`scripts/check-paths.mts` gate at commit time. + +## What it allows + +- Edits to a `paths.mts` (the canonical constructor). +- Edits to `scripts/check-paths.mts` (the gate itself, which + legitimately enumerates patterns). +- Edits to this hook's own files (the test suite has to enumerate + the same patterns). +- `path.join` calls with a single stage segment, e.g. + `path.join(packageRoot, 'build', 'temp')` — that's a one-off + helper path, not a multi-stage build output. +- `path.join` calls with no stage segments at all (most + general-purpose joins). +- Any string concatenation that doesn't go through `path.join` — + the hook is regex-based and intentionally narrow. + +## Stage segments the hook recognizes + +These come from `build-infra/lib/constants.mts:BUILD_STAGES` plus the +lowercase directory-name siblings used by some builders: + +`Final`, `Release`, `Stripped`, `Compressed`, `Optimized`, `Synced`, +`wasm`, `downloaded` + +Two or more in the same `path.join` call — or one stage segment plus +one of `'build'`/`'out'` plus one mode (`'dev'`/`'prod'`) — triggers +Rule A. + +## Known sibling packages (for Rule B) + +The hook recognizes Rule B traversals only when the next segment +after `..` is a known fleet package name: + +`binflate`, `binject`, `binpress`, `bin-infra`, `build-infra`, +`codet5-models-builder`, `curl-builder`, `libpq-builder`, +`lief-builder`, `minilm-builder`, `models`, `napi-go`, +`node-smol-builder`, `onnxruntime-builder`, `opentui-builder`, +`stubs-builder`, `ultraviolet-builder`, `yoga-layout-builder` + +When a new package joins the workspace, add it to +`KNOWN_SIBLING_PACKAGES` in `index.mts`. + +## Fail-open on hook bugs + +If the hook itself crashes, it writes a log line and exits `0` — +i.e. _the edit is allowed_. A buggy security hook that blocks +everything is worse than one that temporarily lets things through. +The companion `scripts/check-paths.mts` gate at commit time catches +anything the hook missed. + +## Testing + +```bash +pnpm --filter hook-path-guard test +``` + +Adding a new detection pattern: update `STAGE_SEGMENTS` (or +`KNOWN_SIBLING_PACKAGES`) in `index.mts`, then add a positive and a +negative test in `test/path-guard.test.mts`. + +## Cross-fleet sync + +This README and the hook itself live in +[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/path-guard) +and are required to be byte-identical across every fleet repo. +`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. + +To propagate a change from the template to every fleet repo: + +```bash +node scripts/sync-scaffolding.mts --all --fix +``` diff --git a/.claude/hooks/fleet/path-guard/path-guard/index.mts b/.claude/hooks/fleet/path-guard/path-guard/index.mts new file mode 100644 index 0000000..33f730d --- /dev/null +++ b/.claude/hooks/fleet/path-guard/path-guard/index.mts @@ -0,0 +1,351 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — path-guard firewall. +// +// Mantra: 1 path, 1 reference. +// +// Blocks Edit/Write tool calls that would *construct* a multi-segment +// build/output path inline in a `.mts` or `.cts` file, instead of +// importing the constructed value from the canonical `paths.mts` (or a +// build-infra helper). This fires BEFORE the write lands; exit code 2 +// makes Claude Code refuse the tool call so the diff never touches the +// repo. The model sees the rejection reason on stderr and retries with +// an import-based approach. +// +// What the hook checks (subset of the gate's rules — diff-local only): +// +// Rule A — Multi-stage path construction: a `path.join(...)` / +// `path.resolve(...)` call or string-template that stitches together +// two or more "stage" segments together with build / out / mode / +// platform-arch context. Outside a `paths.mts` file this is a +// violation: the construction belongs in a helper, every consumer +// imports the computed value. +// +// Rule B — Cross-package traversal: `path.join(*, '..', '', 'build', ...)` reaches into a sibling's build output +// without going through its `exports`. Forces consumers to declare a +// workspace dep and import the sibling's `paths.mts`. +// +// What the hook does NOT check (the gate handles repo-wide concerns): +// +// Rule C — workflow YAML repetition (gate scans .yml files). +// Rule D — comment-encoded paths (gate scans comments + JSDoc). +// Rule F — same path reconstructed in multiple files. +// Rule G — Makefile / Dockerfile / shell-script paths. +// +// AST-based detector (vendored acorn-wasm). Replaces the prior +// regex+paren-balance string scanner that the previous file's +// `extractPathCalls` had to roll by hand because regex couldn't +// handle nested parens in argument lists like +// `path.join(getDir(x), 'Final')`. The AST visitor sees those calls +// natively, with arguments resolved as Literal / NewExpression / +// CallExpression / TemplateLiteral nodes; we only treat string-Literal +// arguments as path segments (every other shape is a computed value +// that doesn't participate in the rule). +// +// Scope: +// - Fires only on `Edit` and `Write` tool calls. +// - Only `.mts` / `.cts` source files. +// - Skips `paths.mts` itself (canonical constructor) and the gate / +// hook implementations that enumerate stage tokens. +// +// The hook fails OPEN on its own bugs (exit 0 + stderr log). + +import process from 'node:process' + +import { findTemplateLiterals } from '../_shared/acorn/index.mts' +import type { TemplateLiteralSite } from '../_shared/acorn/index.mts' +import { + BUILD_ROOT_SEGMENTS, + KNOWN_SIBLING_PACKAGES, + MODE_SEGMENTS, + STAGE_SEGMENTS, +} from './segments.mts' + +const EXEMPT_FILE_PATTERNS: RegExp[] = [ + /(?:^|\/)paths\.(?:cts|mts)$/, + /scripts\/check-paths\.mts$/, + /scripts\/check-paths\//, + /\.claude\/hooks\/path-guard\/index\.(?:cts|mts)$/, + /\.claude\/hooks\/path-guard\/test\//, + /scripts\/check-consistency\.mts$/, +] + +class BlockError extends Error { + public readonly rule: string + public readonly suggestion: string + public readonly snippet: string + constructor(rule: string, suggestion: string, snippet: string) { + super(rule) + this.name = 'BlockError' + this.rule = rule + this.suggestion = suggestion + this.snippet = snippet.slice(0, 240) + (snippet.length > 240 ? '…' : '') + } +} + +interface ToolInput { + tool_name?: string | undefined + tool_input?: + | { + file_path?: string | undefined + new_string?: string | undefined + content?: string | undefined + } + | undefined +} + +export function stdin(): Promise { + return new Promise(resolve => { + let buf = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => (buf += chunk)) + process.stdin.on('end', () => resolve(buf)) + }) +} + +export function isInScope(filePath: string) { + if (!filePath) { + return false + } + if (!filePath.endsWith('.mts') && !filePath.endsWith('.cts')) { + return false + } + return !EXEMPT_FILE_PATTERNS.some(re => re.test(filePath)) +} + +/** + * Collect string-literal arguments from each `path.join` / `path.resolve` call. + * We deliberately only consume the `firstStringArg` + the + * `allStringLiteralArgs` flag from the AST helper's MemberCallSite, then walk + * the call again at the source level only as a fallback for displaying the + * snippet — we never parse arguments by hand. + * + * To get ALL string-literal args (not just the first), we re-parse the + * arguments via `findMemberCalls`'s nature: it visits one CallExpression at a + * time. Since the public surface returns only `firstStringArg`, here we walk + * again with a custom visitor that inspects each argument. This keeps the + * public helper API narrow while letting path-guard get the full literal list + * it needs. + */ +import { walkSimple } from '../_shared/acorn/index.mts' +import type { AcornNode } from '../_shared/acorn/index.mts' + +interface PathCall { + /** + * All string-Literal arguments in source order. + */ + literals: string[] + /** + * Whether ANY argument was a non-string node (Identifier / CallExpression / + * etc.). + */ + hasComputedArg: boolean + /** + * Source snippet around the call for the block message. + */ + snippet: string + /** + * 1-based line of the call. + */ + line: number +} + +export function collectPathCalls(source: string): PathCall[] { + const lines = source.split('\n') + const out: PathCall[] = [] + // Match both `path.join(...)` and `path.resolve(...)` via two passes. + for (const property of ['join', 'resolve']) { + walkSimple(source, { + CallExpression(node: AcornNode) { + const callee = node['callee'] as AcornNode | undefined + if (!callee || callee.type !== 'MemberExpression') { + return + } + const obj = callee['object'] as AcornNode | undefined + if ( + !obj || + obj.type !== 'Identifier' || + (obj['name'] as string) !== 'path' + ) { + return + } + const prop = callee['property'] as AcornNode | undefined + if ( + !prop || + prop.type !== 'Identifier' || + (prop['name'] as string) !== property + ) { + return + } + const args = (node['arguments'] as AcornNode[] | undefined) ?? [] + const literals: string[] = [] + let hasComputedArg = false + for (let i = 0, { length } = args; i < length; i += 1) { + const a = args[i]! + if (a.type === 'Literal' && typeof a['value'] === 'string') { + literals.push(a['value'] as string) + } else { + hasComputedArg = true + } + } + const start = node['start'] as number | undefined + const end = node['end'] as number | undefined + if (typeof start !== 'number' || typeof end !== 'number') { + return + } + const line = source.slice(0, start).split('\n').length /* 1-based */ + const snippet = source.slice(start, end) + const trimmedLine = lines[line - 1]?.trim() ?? '' + out.push({ + literals, + hasComputedArg, + // Prefer the single-line text when the call fits on one + // line; otherwise show the slice (truncated by BlockError). + snippet: snippet.includes('\n') ? snippet : trimmedLine, + line, + }) + }, + }) + } + return out +} + +export function checkRuleA(calls: PathCall[]) { + for (let i = 0, { length } = calls; i < length; i += 1) { + const call = calls[i]! + const stages = call.literals.filter(l => STAGE_SEGMENTS.has(l)) + const buildRoots = call.literals.filter(l => BUILD_ROOT_SEGMENTS.has(l)) + const modes = call.literals.filter(l => MODE_SEGMENTS.has(l)) + const twoStages = stages.length >= 2 + const stagePlusContext = + stages.length >= 1 && buildRoots.length >= 1 && modes.length >= 1 + if (twoStages || stagePlusContext) { + throw new BlockError( + 'A — multi-stage path constructed inline', + 'Construct this path in the owning `paths.mts` (or a build-infra helper like `getFinalBinaryPath`) and import the computed value here. 1 path, 1 reference.', + call.snippet, + ) + } + } +} + +export function checkRuleB(calls: PathCall[]) { + for (let i = 0, { length } = calls; i < length; i += 1) { + const call = calls[i]! + const hasBuildContext = call.literals.some( + l => BUILD_ROOT_SEGMENTS.has(l) || STAGE_SEGMENTS.has(l), + ) + if (!hasBuildContext) { + continue + } + for (let i = 0; i < call.literals.length - 1; i++) { + if ( + call.literals[i] === '..' && + KNOWN_SIBLING_PACKAGES.has(call.literals[i + 1]!) + ) { + const sibling = call.literals[i + 1]! + throw new BlockError( + 'B — cross-package path traversal', + `Don't reach into '${sibling}'s build output via \`..\`. Add \`${sibling}: workspace:*\` as a dep and import its \`paths.mts\` via the \`exports\` field. 1 path, 1 reference.`, + call.snippet, + ) + } + } + } +} + +export function checkRuleATemplate(templates: TemplateLiteralSite[]) { + for (let i = 0, { length } = templates; i < length; i += 1) { + const tpl = templates[i]! + // Skip templates with no `/` separator — they can't be path-shaped. + if (!tpl.segments.includes('/')) { + continue + } + // Replace `\0` expression sentinels with empty (they don't + // contribute path segments); split on `/`; filter empty. + const segments = tpl.segments + .replace(/\x00/g, '') + .split('/') + .filter(s => s.length > 0) + const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) + const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) + const modes = segments.filter(s => MODE_SEGMENTS.has(s)) + const hasBuildAndOut = + buildRoots.includes('build') && buildRoots.includes('out') + const hasOut = buildRoots.includes('out') + const hasBuild = buildRoots.includes('build') + const triggers = + (hasBuildAndOut && stages.length >= 1) || + (stages.length >= 2 && hasOut) || + (hasBuild && stages.length >= 1 && modes.length >= 1) + if (triggers) { + throw new BlockError( + 'A — multi-stage path constructed inline via template literal', + 'Construct this path in the owning `paths.mts` (or a build-infra helper) and import the computed value here. 1 path, 1 reference.', + tpl.text, + ) + } + } +} + +export function check(source: string) { + const calls = collectPathCalls(source) + if (calls.length > 0) { + checkRuleA(calls) + checkRuleB(calls) + } + const templates = findTemplateLiterals(source) + if (templates.length > 0) { + checkRuleATemplate(templates) + } +} + +export function emitBlock(filePath: string, err: BlockError) { + process.stderr.write( + `\n[path-guard] Blocked: ${err.rule}\n` + + ` Mantra: 1 path, 1 reference\n` + + ` File: ${filePath}\n` + + ` Snippet: ${err.snippet}\n` + + ` Fix: ${err.suggestion}\n\n`, + ) +} + +async function main() { + const raw = await stdin() + if (!raw) { + return + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + return + } + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + return + } + const filePath = payload.tool_input?.file_path ?? '' + if (!isInScope(filePath)) { + return + } + const source = + payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' + if (!source) { + return + } + try { + check(source) + } catch (e) { + if (e instanceof BlockError) { + emitBlock(filePath, e) + process.exitCode = 2 + return + } + throw e + } +} + +main().catch(e => { + process.stderr.write(`[path-guard] hook error (allowing): ${e}\n`) + process.exitCode = 0 +}) diff --git a/.claude/hooks/fleet/path-guard/path-guard/package.json b/.claude/hooks/fleet/path-guard/path-guard/package.json new file mode 100644 index 0000000..a7cb503 --- /dev/null +++ b/.claude/hooks/fleet/path-guard/path-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-path-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + } +} diff --git a/.claude/hooks/path-guard/segments.mts b/.claude/hooks/fleet/path-guard/path-guard/segments.mts similarity index 100% rename from .claude/hooks/path-guard/segments.mts rename to .claude/hooks/fleet/path-guard/path-guard/segments.mts diff --git a/.claude/hooks/path-guard/test/path-guard.test.mts b/.claude/hooks/fleet/path-guard/path-guard/test/path-guard.test.mts similarity index 100% rename from .claude/hooks/path-guard/test/path-guard.test.mts rename to .claude/hooks/fleet/path-guard/path-guard/test/path-guard.test.mts diff --git a/.claude/hooks/setup-security-tools/tsconfig.json b/.claude/hooks/fleet/path-guard/path-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-security-tools/tsconfig.json rename to .claude/hooks/fleet/path-guard/path-guard/tsconfig.json diff --git a/.claude/hooks/fleet/path-guard/segments.mts b/.claude/hooks/fleet/path-guard/segments.mts new file mode 100644 index 0000000..2069a7c --- /dev/null +++ b/.claude/hooks/fleet/path-guard/segments.mts @@ -0,0 +1,74 @@ +// Canonical path-segment vocabulary shared by the path-guard hook +// (.claude/hooks/fleet/path-guard/index.mts) and gate (scripts/check-paths.mts). +// +// Mantra: 1 path, 1 reference. This module is the *one* place stage, +// build-root, mode, and sibling-package vocabulary is defined. Both +// consumers import from here so they can never drift apart. +// +// Synced byte-identically across the Socket fleet via +// socket-wheelhouse/scripts/sync-scaffolding.mts (IDENTICAL_FILES). +// When adding a new stage/build-root/mode/sibling, edit this file in +// the template and re-sync. + +// "Stage" segments — Rule A core. Two of these spread via `path.join` +// or interpolated into a template literal is a finding outside a +// canonical `paths.mts`. Sourced from build-infra/lib/constants.mts +// `BUILD_STAGES` plus their lowercase directory-name siblings used by +// some builders. +export const STAGE_SEGMENTS = new Set([ + 'Compressed', + 'Final', + 'Optimized', + 'Release', + 'Stripped', + 'Synced', + 'downloaded', + 'wasm', +]) + +// "Build-root" segments — at least one must be present together with +// a stage segment to confirm we're constructing a build output path +// rather than something coincidental. Example: a join that yields +// `//` doesn't fire if no build-root segment is +// present; `/build//out/` does. +export const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) + +// Build-mode segments — a stage segment plus one of these is also a +// finding (`build///out/` is the canonical shape). +export const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) + +// Sibling fleet packages (Rule B). Union of all packages across the +// Socket fleet — the gate is byte-identical via sync-scaffolding, so +// listing every fleet package keeps Rule B firing in any repo. When a +// new package joins the workspace, add it here and propagate via +// `node scripts/sync-scaffolding.mts --all --fix` from +// socket-wheelhouse. +export const KNOWN_SIBLING_PACKAGES = new Set([ + 'acorn', + 'bin-infra', + 'binflate', + 'binject', + 'binpress', + 'build-infra', + 'cli', + 'codet5-models-builder', + 'core', + 'curl-builder', + 'libpq-builder', + 'lief-builder', + 'minilm-builder', + 'models', + 'napi-go', + 'node-smol-builder', + 'npm', + 'onnxruntime-builder', + 'opentui-builder', + 'package-builder', + 'react', + 'renderer', + 'stubs-builder', + 'ultraviolet', + 'ultraviolet-builder', + 'yoga', + 'yoga-layout-builder', +]) diff --git a/.claude/hooks/fleet/path-guard/test/path-guard.test.mts b/.claude/hooks/fleet/path-guard/test/path-guard.test.mts new file mode 100644 index 0000000..ee79d27 --- /dev/null +++ b/.claude/hooks/fleet/path-guard/test/path-guard.test.mts @@ -0,0 +1,311 @@ +// Tests for the path-guard hook. Each `node:test` block writes a +// mock PreToolUse payload to the hook's stdin and asserts on its exit +// code + stderr. Exit 2 = blocked; exit 0 = allowed. +// +// Run: pnpm --filter hook-path-guard test +// (or directly: node --test test/*.test.mts) + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const HOOK = path.resolve(__dirname, '..', 'index.mts') + +const runHook = ( + toolName: string, + filePath: string, + source: string, +): { code: number; stderr: string } => { + const payload = JSON.stringify({ + tool_name: toolName, + tool_input: + toolName === 'Edit' + ? { file_path: filePath, new_string: source } + : { file_path: filePath, content: source }, + }) + const result = spawnSync(process.execPath, [HOOK], { + input: payload, + }) + return { + code: result.status ?? -1, + stderr: String(result.stderr), + } +} + +describe('path-guard — Rule A (multi-stage construction)', () => { + it('blocks two stage segments in path.join', () => { + const source = ` + const p = path.join(PACKAGE_ROOT, 'wasm', 'out', 'Final', 'bin') + ` + const { code, stderr } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 2) + assert.match(stderr, /Blocked: A/) + assert.match(stderr, /1 path, 1 reference/) + }) + + it('blocks build + mode + stage', () => { + const source = ` + const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'binary') + ` + const { code } = runHook('Edit', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 2) + }) + + it('blocks Release + Stripped together', () => { + const source = ` + const p = path.join(buildDir, 'Release', 'Stripped') + ` + const { code } = runHook( + 'Write', + 'packages/foo/scripts/release.mts', + source, + ) + assert.equal(code, 2) + }) + + it('allows single stage segment with one build root', () => { + // 'build' + 'temp' → no stage segment at all → pass + const source = ` + const tmp = path.join(packageRoot, 'build', 'temp') + ` + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 0) + }) + + it('allows path.join with no stage segments', () => { + const source = ` + const cfg = path.join(packageRoot, 'config', 'settings.json') + ` + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 0) + }) +}) + +describe('path-guard — Rule B (cross-package traversal)', () => { + it('blocks .. + sibling package + build context', () => { + const source = ` + const lief = path.join(PKG, '..', 'lief-builder', 'build', 'Final') + ` + const { code, stderr } = runHook( + 'Write', + 'packages/binject/scripts/build.mts', + source, + ) + assert.equal(code, 2) + assert.match(stderr, /Blocked: B/) + assert.match(stderr, /lief-builder/) + }) + + it('allows .. + sibling without build context', () => { + // Reaching into a sibling for a non-build asset is allowed; the + // gate may still flag it but the hook is scoped to build paths. + const source = ` + const cfg = path.join(PKG, '..', 'lief-builder', 'config.json') + ` + const { code } = runHook( + 'Write', + 'packages/binject/scripts/build.mts', + source, + ) + assert.equal(code, 0) + }) + + it('does not fire on traversal to unknown directory', () => { + const source = ` + const x = path.join(PKG, '..', 'fixtures', 'build', 'Final') + ` + const { code } = runHook('Write', 'packages/foo/test/test.mts', source) + assert.equal(code, 0) + }) + + it('does not fire when .. and sibling are non-adjacent (regression)', () => { + // Earlier regex ran with sticky sawDotDot — once it saw `..` it + // would flag any later sibling-named segment. The fix requires + // the sibling to appear *immediately* after `..`. + const source = ` + const x = path.join(PKG, '..', 'cache', 'lief-builder', 'config.json') + ` + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 0) + }) +}) + +describe('path-guard — paren-balance correctness', () => { + it('detects A through nested function-call args (regression)', () => { + // Old regex used \\([^()]*\\) which only handled one nesting + // level — `path.join(getDir(child(x)), 'build', 'dev', 'Final')` + // silently slipped through. The paren-balancing scanner catches it. + const source = ` + const p = path.join(getDir(child(x)), 'build', 'dev', 'out', 'Final') + ` + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 2) + }) + + it('detects A in path.resolve() too', () => { + const source = ` + const p = path.resolve(PKG, 'build', 'dev', 'out', 'Final', 'bin') + ` + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 2) + }) +}) + +describe('path-guard — template literals', () => { + it('detects A in fully-literal template path', () => { + const source = '\n const p = `build/dev/out/Final/binary`\n ' + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 2) + }) + + it('detects A in template with placeholders', () => { + const source = + '\n const p = `${PKG}/build/${mode}/${arch}/out/Final/${name}`\n ' + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 2) + }) + + it('allows template with single non-stage segment', () => { + const source = '\n const url = `https://example.com/path`\n ' + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 0) + }) + + it('allows template with no stage segments', () => { + const source = '\n const tmp = `${packageRoot}/build/temp/cache`\n ' + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 0) + }) + + it('allows template that is purely interpolation', () => { + // `${a}/${b}/${c}` has no literal stage segments. + const source = '\n const p = `${a}/${b}/${c}`\n ' + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 0) + }) +}) + +describe('path-guard — file-type filter', () => { + it('skips .ts files', () => { + const source = ` + const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') + ` + const { code } = runHook('Write', 'packages/foo/src/index.ts', source) + assert.equal(code, 0) + }) + + it('skips .mjs files', () => { + const source = ` + const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') + ` + const { code } = runHook('Write', 'additions/foo.mjs', source) + assert.equal(code, 0) + }) + + it('skips .yml files', () => { + const source = ` + run: | + FINAL="build/\${MODE}/\${ARCH}/out/Final" + ` + const { code } = runHook('Write', '.github/workflows/foo.yml', source) + assert.equal(code, 0) + }) + + it('inspects .mts files', () => { + const source = ` + const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') + ` + const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 2) + }) + + it('inspects .cts files', () => { + const source = ` + const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') + ` + const { code } = runHook('Write', 'packages/foo/scripts/build.cts', source) + assert.equal(code, 2) + }) +}) + +describe('path-guard — exempt files', () => { + it('allows edits to paths.mts', () => { + const source = ` + export const FINAL_DIR = path.join(PKG, 'build', 'dev', 'out', 'Final') + ` + const { code } = runHook('Write', 'packages/foo/scripts/paths.mts', source) + assert.equal(code, 0) + }) + + it('allows edits to check-paths.mts (the gate)', () => { + const source = ` + const PATTERNS = [path.join('build', 'Final', 'wasm')] + ` + const { code } = runHook('Write', 'scripts/check-paths.mts', source) + assert.equal(code, 0) + }) + + it('allows edits to the path-guard hook itself', () => { + const source = ` + const STAGES = ['Final', 'Release', 'Stripped'] + ` + const { code } = runHook( + 'Write', + '.claude/hooks/fleet/path-guard/index.mts', + source, + ) + assert.equal(code, 0) + }) + + it('allows edits to path-guard tests', () => { + const source = ` + const fixture = path.join('build', 'dev', 'out', 'Final') + ` + const { code } = runHook( + 'Write', + '.claude/hooks/fleet/path-guard/test/path-guard.test.mts', + source, + ) + assert.equal(code, 0) + }) +}) + +describe('path-guard — tool-name filter', () => { + it('skips Bash', () => { + const source = `path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin')` + const { code } = runHook('Bash', '', source) + assert.equal(code, 0) + }) + + it('skips Read', () => { + const source = '' + const { code } = runHook('Read', 'packages/foo/scripts/build.mts', source) + assert.equal(code, 0) + }) +}) + +describe('path-guard — bug-tolerance (fails open)', () => { + it('passes through invalid JSON payload', () => { + const result = spawnSync(process.execPath, [HOOK], { + input: 'not json at all', + }) + assert.equal(result.status, 0) + }) + + it('passes through empty stdin', () => { + const result = spawnSync(process.execPath, [HOOK], { + input: '', + }) + assert.equal(result.status, 0) + }) +}) diff --git a/.claude/hooks/setup-signing/tsconfig.json b/.claude/hooks/fleet/path-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-signing/tsconfig.json rename to .claude/hooks/fleet/path-guard/tsconfig.json diff --git a/.claude/hooks/path-regex-normalize-reminder/README.md b/.claude/hooks/fleet/path-regex-normalize-reminder/README.md similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/README.md rename to .claude/hooks/fleet/path-regex-normalize-reminder/README.md diff --git a/.claude/hooks/path-regex-normalize-reminder/index.mts b/.claude/hooks/fleet/path-regex-normalize-reminder/index.mts similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/index.mts rename to .claude/hooks/fleet/path-regex-normalize-reminder/index.mts diff --git a/.claude/hooks/path-regex-normalize-reminder/package.json b/.claude/hooks/fleet/path-regex-normalize-reminder/package.json similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/package.json rename to .claude/hooks/fleet/path-regex-normalize-reminder/package.json diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json b/.claude/hooks/fleet/path-regex-normalize-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json rename to .claude/hooks/fleet/path-regex-normalize-reminder/tsconfig.json diff --git a/.claude/hooks/paths-mts-inherit-guard/README.md b/.claude/hooks/fleet/paths-mts-inherit-guard/README.md similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/README.md rename to .claude/hooks/fleet/paths-mts-inherit-guard/README.md diff --git a/.claude/hooks/paths-mts-inherit-guard/index.mts b/.claude/hooks/fleet/paths-mts-inherit-guard/index.mts similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/index.mts rename to .claude/hooks/fleet/paths-mts-inherit-guard/index.mts diff --git a/.claude/hooks/paths-mts-inherit-guard/package.json b/.claude/hooks/fleet/paths-mts-inherit-guard/package.json similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/package.json rename to .claude/hooks/fleet/paths-mts-inherit-guard/package.json diff --git a/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts b/.claude/hooks/fleet/paths-mts-inherit-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/test/index.test.mts rename to .claude/hooks/fleet/paths-mts-inherit-guard/test/index.test.mts diff --git a/.claude/hooks/socket-token-minifier-start/tsconfig.json b/.claude/hooks/fleet/paths-mts-inherit-guard/tsconfig.json similarity index 100% rename from .claude/hooks/socket-token-minifier-start/tsconfig.json rename to .claude/hooks/fleet/paths-mts-inherit-guard/tsconfig.json diff --git a/.claude/hooks/perfectionist-reminder/README.md b/.claude/hooks/fleet/perfectionist-reminder/README.md similarity index 100% rename from .claude/hooks/perfectionist-reminder/README.md rename to .claude/hooks/fleet/perfectionist-reminder/README.md diff --git a/.claude/hooks/perfectionist-reminder/index.mts b/.claude/hooks/fleet/perfectionist-reminder/index.mts similarity index 100% rename from .claude/hooks/perfectionist-reminder/index.mts rename to .claude/hooks/fleet/perfectionist-reminder/index.mts diff --git a/.claude/hooks/perfectionist-reminder/package.json b/.claude/hooks/fleet/perfectionist-reminder/package.json similarity index 100% rename from .claude/hooks/perfectionist-reminder/package.json rename to .claude/hooks/fleet/perfectionist-reminder/package.json diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md new file mode 100644 index 0000000..d915170 --- /dev/null +++ b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md @@ -0,0 +1,53 @@ +# perfectionist-reminder + +Stop hook that scans the assistant's most recent turn for speed-vs-depth choice menus where the perfectionist path is the obvious right answer. + +## Why + +CLAUDE.md "Judgment & self-evaluation" says: + +> Default to perfectionist when you have latitude. "Works now" ≠ "right." Before calling done: perfectionist vs. pragmatist views. Default perfectionist absent a signal. + +Sister rule from "Fix > defer" already catches "implement vs accept-as-gap" via `excuse-detector`. The speed-vs-depth menu is a different but related failure pattern: offering "Option A (do it right) / Option B (ship fast)" as a binary choice when the user already signaled they want correctness (asked the right question, requested a thorough audit, said "do it properly", etc.). + +The assistant's job is to internalize the perfectionist default and execute, not re-litigate the velocity tradeoff every time the work is non-trivial. + +## What it catches + +| Phrase pattern | Why it's flagged | +| --------------------------------------------------- | ---------------------------------------------------- | +| `Option A (depth)… Option B (speed)` | Binary choice menu offloading judgment. Pick depth. | +| `maximally useful vs maximally shipped` | Same framing — execute the perfectionist path. | +| `ship-it precision`, `ship-it-now` | Velocity euphemism. Use only when user time-boxed. | +| `depth over breadth?` / `breadth over depth?` | The default IS depth (perfectionist). | +| `speed vs depth`, `fast vs right`, `now vs correct` | Speed-vs-quality framing. Perfectionist is default. | +| `if you say A … if you say B` | Binary choice architecture pretending to be helpful. | +| `plow through vs do it right` | Same pattern — velocity vs care. | + +## Legitimate exceptions + +The hook can't tell from text alone whether the trade-off is real: + +- **User explicitly asked** "is this worth doing fully?" — they introduced the dichotomy. +- **Time-boxed engagement** — the user said "we have 1 hour" and the work needs more. +- **Off-machine action required** — the perfectionist path needs gh dispatch / npm publish / infra access. + +In all three cases, the menu is genuinely useful framing. The hook still flags it; the user reads the warning and decides. + +## Why it doesn't block + +Stop hooks fire after the assistant has produced its response. Blocking would truncate. The warning surfaces alongside the response so the user reads both and can push back next turn. + +## Configuration + +`SOCKET_PERFECTIONIST_REMINDER_DISABLED=1` — turn off entirely. + +## Relationship to excuse-detector + +`excuse-detector` catches **fix vs defer** ("should I implement X or accept as gap?"). This hook catches **depth vs speed** ("should I do it properly or ship a quick version?"). Different failure modes, same underlying anti-pattern: a choice menu where the user already picked. + +## Test + +```sh +pnpm test +``` diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts new file mode 100644 index 0000000..135a461 --- /dev/null +++ b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Claude Code Stop hook — perfectionist-reminder. +// +// Flags speed-vs-depth choice menus in the assistant's most recent +// turn. CLAUDE.md "Judgment & self-evaluation" says "Default to +// perfectionist when you have latitude" — so when the assistant +// presents a choice between "speed" and "depth" / "correctness" +// without the user having asked for the trade-off, it's the same +// failure pattern as the excuse-detector's fix-vs-defer menu: +// offloading a decision the assistant should have made. +// +// What this catches (regex on code-fence-stripped text): +// +// - "Option A (depth): ... Option B (speed): ..." +// - "Maximally useful vs maximally shipped" +// - "Ship-it precision" / "ship-it-now" +// - "Depth over breadth?" / "breadth over depth?" +// - "Speed vs depth" / "speed vs correctness" / "fast vs right" +// - "If you say A I'll ... if you say B I'll ..." (binary choice +// architecture) +// +// Exceptions: the user explicitly asked which approach to take, or +// the trade-off is genuinely irreducible (time-boxed engagement, +// off-machine action required). The hook can't tell from text alone; +// it just flags the pattern. The user reads the warning and decides +// if it's legitimate or pushback-worthy. +// +// Disable via SOCKET_PERFECTIONIST_REMINDER_DISABLED. + +import { runStopReminder } from '../_shared/stop-reminder.mts' + +await runStopReminder({ + name: 'perfectionist-reminder', + disabledEnvVar: 'SOCKET_PERFECTIONIST_REMINDER_DISABLED', + patterns: [ + { + label: 'option A (depth/correctness) … option B (speed/shipped)', + regex: + /\boption\s+a\b[^.?!\n]{0,80}\b(?:correctness|depth|proper|thorough)\b[\s\S]{0,200}\boption\s+b\b[^.?!\n]{0,80}\b(?:breadth|fast|ship|speed)\b/i, + why: 'Speed-vs-depth choice menu. Per CLAUDE.md "Default to perfectionist when you have latitude" — pick depth and execute.', + }, + { + label: 'maximally useful vs maximally shipped', + regex: + /\bmaximally\s+(?:correct|thorough|useful)\b[\s\S]{0,80}\bmaximally\s+(?:fast|quick|shipped)\b/i, + why: 'Same pattern — re-litigating perfectionist-vs-velocity. User already chose perfectionist.', + }, + { + label: 'ship-it precision / ship-it-now', + regex: /\bship[- ]it[- ]?(?:fast|now|precision|version)\b/i, + why: 'Velocity-framed; CLAUDE.md says perfectionist default. Use unless user explicitly time-boxed.', + }, + { + label: 'depth over breadth / breadth over depth', + regex: /\b(?:depth\s+over\s+breadth|breadth\s+over\s+depth)\?/i, + why: 'The CLAUDE.md default is depth (perfectionist). Pick it.', + }, + { + label: 'speed vs depth / fast vs right / now vs correct', + regex: + /\b(?:fast|now|quick|speed)\s+vs\.?\s+(?:correct|depth|proper|right|thorough)\b/i, + why: 'Same speed-vs-quality framing; perfectionist is the default unless user opted out.', + }, + { + label: 'if you say A … if you say B', + regex: /\bif\s+you\s+say\s+a\b[\s\S]{0,200}\bif\s+you\s+say\s+b\b/i, + why: 'Binary choice architecture — masquerades as helpful framing but offloads judgment to user.', + }, + { + label: 'plow through vs do it right', + regex: + /\bplow\s+(?:ahead|through)\b[\s\S]{0,80}\b(?:carefully|correctly|properly|right)\b/i, + why: 'Same pattern (velocity vs care). Default perfectionist.', + }, + ], + closingHint: + 'CLAUDE.md "Judgment & self-evaluation": "Default to perfectionist when you have latitude." If the user already gave perfectionist signals (asked for correctness, asked for depth, said "do it right"), do not re-present the choice — execute the perfectionist path.', +}) diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json new file mode 100644 index 0000000..3583aec --- /dev/null +++ b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-perfectionist-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/perfectionist-reminder/test/index.test.mts b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/perfectionist-reminder/test/index.test.mts rename to .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/test/index.test.mts diff --git a/.claude/hooks/squash-history-reminder/tsconfig.json b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/squash-history-reminder/tsconfig.json rename to .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/perfectionist-reminder/test/index.test.mts b/.claude/hooks/fleet/perfectionist-reminder/test/index.test.mts new file mode 100644 index 0000000..9b320fd --- /dev/null +++ b/.claude/hooks/fleet/perfectionist-reminder/test/index.test.mts @@ -0,0 +1,137 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function makeTranscript(assistantText: string): { + path: string + cleanup: () => void +} { + const dir = mkdtempSync(path.join(os.tmpdir(), 'perfectionist-')) + const transcriptPath = path.join(dir, 'session.jsonl') + const lines = [ + JSON.stringify({ role: 'user', content: 'hi' }), + JSON.stringify({ role: 'assistant', content: assistantText }), + ].join('\n') + writeFileSync(transcriptPath, lines) + return { + path: transcriptPath, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + } +} + +function runHook(transcriptPath: string): { stderr: string; exitCode: number } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: transcriptPath }), + }) + return { stderr: String(result.stderr), exitCode: result.status ?? -1 } +} + +test('flags Option A / Option B depth-vs-speed menu', () => { + const { path: p, cleanup } = makeTranscript( + 'Option A (depth): I do 4-5 hooks well. Option B (speed): I ship all 12 with regex-only.', + ) + try { + const { stderr, exitCode } = runHook(p) + assert.equal(exitCode, 0) + assert.match(stderr, /perfectionist-reminder/) + assert.match(stderr, /option/i) + } finally { + cleanup() + } +}) + +test('flags maximally useful vs maximally shipped', () => { + const { path: p, cleanup } = makeTranscript( + 'Should I go for maximally useful (proper) or maximally shipped (fast)?', + ) + try { + const { stderr } = runHook(p) + assert.match(stderr, /maximally/) + } finally { + cleanup() + } +}) + +test('flags ship-it precision framing', () => { + const { path: p, cleanup } = makeTranscript( + 'I could do this with ship-it precision and iterate later.', + ) + try { + const { stderr } = runHook(p) + assert.match(stderr, /ship-it/) + } finally { + cleanup() + } +}) + +test('flags speed vs depth phrasing', () => { + const { path: p, cleanup } = makeTranscript( + 'This is a speed vs depth question — which way?', + ) + try { + const { stderr } = runHook(p) + assert.match(stderr, /speed/i) + } finally { + cleanup() + } +}) + +test('flags "if you say A / if you say B" binary choice', () => { + const { path: p, cleanup } = makeTranscript( + 'If you say A I will do all 12 properly. If you say B I will ship regex-only.', + ) + try { + const { stderr } = runHook(p) + assert.match(stderr, /if you say/i) + } finally { + cleanup() + } +}) + +test('does not flag plain technical prose', () => { + const { path: p, cleanup } = makeTranscript( + 'The cache stores parsed results keyed by file path. Each entry expires after 10 minutes.', + ) + try { + const { stderr, exitCode } = runHook(p) + assert.equal(exitCode, 0) + assert.equal(stderr, '') + } finally { + cleanup() + } +}) + +test('does not false-positive on phrases inside code fences', () => { + const { path: p, cleanup } = makeTranscript( + 'Plain output here.\n```\nspeed vs depth (this is in code)\n```\nMore prose.', + ) + try { + const { stderr } = runHook(p) + assert.equal(stderr, '') + } finally { + cleanup() + } +}) + +test('disabled env var short-circuits', () => { + const { path: p, cleanup } = makeTranscript( + 'Option A (depth) or Option B (speed)?', + ) + try { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ transcript_path: p }), + env: { ...process.env, SOCKET_PERFECTIONIST_REMINDER_DISABLED: '1' }, + }) + assert.equal(result.status, 0) + assert.equal(result.stderr, '') + } finally { + cleanup() + } +}) diff --git a/.claude/hooks/stale-process-sweeper/tsconfig.json b/.claude/hooks/fleet/perfectionist-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/stale-process-sweeper/tsconfig.json rename to .claude/hooks/fleet/perfectionist-reminder/tsconfig.json diff --git a/.claude/hooks/plan-location-guard/README.md b/.claude/hooks/fleet/plan-location-guard/README.md similarity index 100% rename from .claude/hooks/plan-location-guard/README.md rename to .claude/hooks/fleet/plan-location-guard/README.md diff --git a/.claude/hooks/plan-location-guard/index.mts b/.claude/hooks/fleet/plan-location-guard/index.mts similarity index 100% rename from .claude/hooks/plan-location-guard/index.mts rename to .claude/hooks/fleet/plan-location-guard/index.mts diff --git a/.claude/hooks/plan-location-guard/package.json b/.claude/hooks/fleet/plan-location-guard/package.json similarity index 100% rename from .claude/hooks/plan-location-guard/package.json rename to .claude/hooks/fleet/plan-location-guard/package.json diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md new file mode 100644 index 0000000..d1f1f9e --- /dev/null +++ b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md @@ -0,0 +1,55 @@ +# plan-location-guard + +PreToolUse hook that blocks plan-shaped `.md` writes to tracked locations. + +## What it blocks + +Edit / Write / MultiEdit on a markdown file is blocked when: + +1. The target path lives under `docs/plans/` (at any depth), OR +2. The target path lives under a sub-package `.claude/plans/` (i.e. + any `.claude/plans/` that is NOT at the repo root — detected by + the presence of a `packages/`, `apps/`, or `crates/` segment in + the path prefix, OR by finding a second `.claude/plans/` deeper + than the first). + +AND the doc looks like a plan, per a narrow heuristic: + +- Filename stem contains one of: `plan`, `roadmap`, `migration`, + `design`, `next-steps`, `dispatcher-plan`. +- OR the first heading of the content contains one of: `plan`, + `roadmap`, `migration plan`, `design doc`. + +Both conditions must be true to block — paths that look like plan +_locations_ but don't have plan-shaped content are pass-through. This +keeps the hook narrow; the goal is to catch the specific failure +mode where a design doc gets dropped into `docs/plans/`. + +## What it allows + +- `/.claude/plans/.md` — the canonical home (untracked). +- Random `.md` writes outside `docs/plans/` and `.claude/plans/`. +- Markdown writes that don't look like plans (e.g. a `README.md` that + happens to live under `docs/plans/`). +- Bash / Read / non-Edit tool calls. + +## Bypass phrase + +`Allow plan-location bypass` — the user types this verbatim in a +recent (last 8 user turns) message. The hook reads the transcript via +the `_shared/transcript.mts` helper. + +## Why a hook on top of the CLAUDE.md rule + +The CLAUDE.md rule documents the convention. The hook is the actual +enforcement at edit time. The recurring failure mode this rule was +written to address: socket-btm grew three parallel `docs/plans/` +directories (root, package-level, `.claude/plans/`) — same content +type, all tracked, all drifting. Without an edit-time guard, that +failure mode recurs every session a new agent reaches for "the +obvious place" to put a plan. + +## Reading + +- `docs/claude.md/fleet/plan-storage.md` — full rule + migration playbook. +- CLAUDE.md → `### Plan storage` — inline summary. diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts new file mode 100644 index 0000000..7e1eb62 --- /dev/null +++ b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts @@ -0,0 +1,304 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — plan-location-guard. +// +// Blocks Edit/Write/MultiEdit operations that try to land a +// design/implementation/migration *plan* document at a tracked +// location instead of `/.claude/plans/.md`. Per the +// fleet "Plan storage" rule, plans are working notes and must not be +// tracked by version control. +// +// Blocked target paths (case-insensitive on the `plans/` segment, +// any depth from repo root): +// +// - `**/docs/plans/**/*.md` +// The classic "I wrote a design doc somewhere visible" failure +// mode. Covers root `docs/plans/` and any package-level +// `/docs/plans/`. +// +// - `**//.claude/plans/**/*.md` (i.e. .claude/plans/ that is +// NOT at the repo root) +// Sub-package .claude/ trees are not part of the operator's +// session-level .claude/ — the canonical operator dir is the +// repo root. +// +// Allowed: +// - `/.claude/plans/**/*.md` — the canonical home. +// - Any `.md` whose filename, headings, and content do NOT look +// like a plan (we only block when filename + content match the +// plan-shape heuristic; other docs are out of scope). +// +// Heuristic for "looks like a plan" — at least one of: +// - Filename contains `plan`, `roadmap`, `migration`, `dispatcher-plan`, +// `design`, `next-steps`, or `*-plan-*.md` shape. +// - File content (the `new_string` / `content` payload from +// Edit/Write) opens with a `# ` heading whose words +// include "plan", "roadmap", "migration plan", or "design doc". +// +// The heuristic is intentionally narrow: this hook is not trying to +// classify every .md file in the fleet — it's catching the specific +// failure mode where someone writes a design doc into `docs/plans/` +// because that's what "feels right." Random `.md` writes outside +// `docs/plans/` and `.claude/plans/` are pass-through. +// +// Bypass phrase: `Allow plan-location bypass`. Reading recent user +// turns follows the same pattern as no-revert-guard / +// no-fleet-fork-guard. +// +// Why a hook on top of the CLAUDE.md rule: the rule documents the +// convention; the hook is the actual enforcement at edit time. +// Catches the recurring failure mode where Claude or a parallel +// session writes a design doc into `docs/plans/` because that's the +// historical convention (see the socket-btm migration that triggered +// this rule — three parallel `docs/plans/` directories drifted). +// +// Reads a Claude Code PreToolUse JSON payload from stdin: +// { "tool_name": "Edit" | "Write" | "MultiEdit", +// "tool_input": { "file_path": "...", +// "content"?: "...", +// "new_string"?: "..." }, +// "transcript_path": "/.../session.jsonl" } +// +// Exits: +// 0 — allowed. +// 2 — blocked (with stderr message that explains rule + fix + +// bypass phrase). +// 0 (with stderr log) — fail-open on hook bugs. + +import path from 'node:path' +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +type ToolInput = { + tool_input?: + | { + content?: string | undefined + file_path?: string | undefined + new_string?: string | undefined + } + | undefined + tool_name?: string | undefined + transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow plan-location bypass' +const BYPASS_LOOKBACK_USER_TURNS = 8 + +// Filename-stem tokens that mark a doc as "plan-shaped." The check +// is on the base name (extension stripped, lowercased). +const PLAN_FILENAME_TOKENS = [ + 'plan', + 'roadmap', + 'migration', + 'design', + 'next-steps', + 'dispatcher-plan', +] + +// First-heading tokens that mark a doc as "plan-shaped." Checked +// against the first non-blank line of the new content if the +// filename heuristic didn't fire. +const PLAN_HEADING_TOKENS = ['plan', 'roadmap', 'migration plan', 'design doc'] + +/** + * Lowercased filename without extension. Returns empty string for paths without + * a basename. + */ +export function basenameStem(filePath: string): string { + const base = path.basename(filePath) + const dot = base.lastIndexOf('.') + const stem = dot > 0 ? base.slice(0, dot) : base + return stem.toLowerCase() +} + +/** + * Classify the target path. Returns: + * + * - 'allowed-root-claude-plans' — under <something>/.claude/plans/ + * - 'blocked-docs-plans' — under <something>/docs/plans/ + * - 'blocked-sub-claude-plans' — under <something>/<sub>/.claude/plans/ (i.e. not + * at the first .claude/ segment) + * - 'irrelevant' — none of the above + * + * The classification is purely lexical on the resolved path. It does NOT walk + * for a repo root, since the fleet rule applies to any docs/plans/ regardless + * of repo context — including the case where a script under /tmp tries to write + * into a project tree. + */ +export function classifyPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/') + const segs = normalized.split('/') + + // Find the FIRST `.claude/plans/` segment pair vs any DEEPER one. + // The "first" one nearest the root is the canonical operator dir; + // anything deeper (i.e. `<pkg>/.claude/plans/`) is a sub-package + // plans dir and is forbidden. + let firstClaudeIdx = -1 + for (let i = 0; i < segs.length - 1; i++) { + if (segs[i] === '.claude' && segs[i + 1] === 'plans') { + firstClaudeIdx = i + break + } + } + + if (firstClaudeIdx !== -1) { + // Look for a SECOND `.claude/plans/` deeper than the first. + for (let i = firstClaudeIdx + 2; i < segs.length - 1; i++) { + if (segs[i] === '.claude' && segs[i + 1] === 'plans') { + return 'blocked-sub-claude-plans' + } + } + // Check whether the first `.claude/plans/` is itself nested under + // another package directory (heuristic: preceded by `packages/`, + // `apps/`, or `crates/` in the parent path). + const prefix = segs.slice(0, firstClaudeIdx).join('/') + if ( + prefix.includes('/packages/') || + prefix.includes('/apps/') || + prefix.includes('/crates/') + ) { + return 'blocked-sub-claude-plans' + } + return 'allowed-root-claude-plans' + } + + // Look for any `docs/plans/` segment pair. + for (let i = 0; i < segs.length - 1; i++) { + if (segs[i] === 'docs' && segs[i + 1] === 'plans') { + return 'blocked-docs-plans' + } + } + + return 'irrelevant' +} + +export function contentLooksLikePlan(content: string | undefined): boolean { + if (!content) { + return false + } + // First non-blank line. + let firstLine = '' + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (trimmed) { + firstLine = trimmed.toLowerCase() + break + } + } + if (!firstLine.startsWith('#')) { + return false + } + return PLAN_HEADING_TOKENS.some(token => firstLine.includes(token)) +} + +export function filenameLooksLikePlan(filePath: string): boolean { + const stem = basenameStem(filePath) + if (!stem) { + return false + } + return PLAN_FILENAME_TOKENS.some(token => stem.includes(token)) +} + +async function main(): Promise<number> { + const raw = await readStdin() + if (!raw.trim()) { + return 0 + } + + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + process.stderr.write( + 'plan-location-guard: failed to parse stdin payload — fail-open\n', + ) + return 0 + } + + const tool = payload.tool_name + if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { + return 0 + } + + const filePath = payload.tool_input?.file_path + if (!filePath) { + return 0 + } + + // Only target markdown files. + if (!filePath.toLowerCase().endsWith('.md')) { + return 0 + } + + const classification = classifyPath(filePath) + if ( + classification !== 'blocked-docs-plans' && + classification !== 'blocked-sub-claude-plans' + ) { + return 0 + } + + // Apply the plan-shape heuristic. If the doc clearly looks like a + // plan (filename OR opening heading), block. If neither fires, this + // is probably a coincidence (e.g. an unrelated doc that happened + // to live under docs/plans/ for historical reasons) — let it through + // and let the human decide. + const content = payload.tool_input?.new_string ?? payload.tool_input?.content + const looksLikePlan = + filenameLooksLikePlan(filePath) || contentLooksLikePlan(content) + if (!looksLikePlan) { + return 0 + } + + // Bypass-phrase check. + if ( + bypassPhrasePresent( + payload.transcript_path, + BYPASS_PHRASE, + BYPASS_LOOKBACK_USER_TURNS, + ) + ) { + return 0 + } + + const suggestion = + classification === 'blocked-docs-plans' + ? 'Move the plan to <repo-root>/.claude/plans/<lowercase-hyphenated>.md (untracked by default).' + : 'Move the plan to the REPO-ROOT .claude/plans/ — sub-package .claude/plans/ is not the canonical home.' + + process.stderr.write( + [ + `🚨 plan-location-guard: blocked plan-shaped .md write at a tracked location.`, + ``, + `File: ${filePath}`, + `Classification: ${classification}`, + ``, + `Per the fleet "Plan storage" rule (CLAUDE.md → Plan storage),`, + `plans live at <repo-root>/.claude/plans/<name>.md and must NOT`, + `be tracked by version control. The fleet .gitignore excludes`, + `/.claude/* and intentionally omits plans/ from the allowlist —`, + `so a plan written to the canonical path is untracked by default.`, + ``, + `Fix:`, + ` ${suggestion}`, + ``, + `Background reading:`, + ` docs/claude.md/fleet/plan-storage.md`, + ``, + `One-shot bypass (rare): user types "${BYPASS_PHRASE}" verbatim`, + `in a recent message.`, + ``, + ].join('\n'), + ) + return 2 +} + +main().then( + code => process.exit(code), + err => { + process.stderr.write( + `plan-location-guard: hook error — fail-open: ${String(err)}\n`, + ) + process.exit(0) + }, +) diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json new file mode 100644 index 0000000..3f32f24 --- /dev/null +++ b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json @@ -0,0 +1,18 @@ +{ + "name": "hook-plan-location-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "dependencies": { + "@socketsecurity/lib-stable": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/plan-location-guard/test/index.test.mts b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/plan-location-guard/test/index.test.mts rename to .claude/hooks/fleet/plan-location-guard/plan-location-guard/test/index.test.mts diff --git a/.claude/hooks/sweep-ds-store/tsconfig.json b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/tsconfig.json similarity index 100% rename from .claude/hooks/sweep-ds-store/tsconfig.json rename to .claude/hooks/fleet/plan-location-guard/plan-location-guard/tsconfig.json diff --git a/.claude/hooks/fleet/plan-location-guard/test/index.test.mts b/.claude/hooks/fleet/plan-location-guard/test/index.test.mts new file mode 100644 index 0000000..9c7c1e9 --- /dev/null +++ b/.claude/hooks/fleet/plan-location-guard/test/index.test.mts @@ -0,0 +1,216 @@ +// node --test specs for the plan-location-guard hook. + +import test from 'node:test' +import assert from 'node:assert/strict' +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +async function runHook(payload: Record<string, unknown>): Promise<Result> { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +test('non-Edit/Write tool calls pass through', async () => { + const result = await runHook({ + tool_input: { command: 'ls' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('non-markdown files pass through', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/docs/plans/script.ts', + content: '// not a markdown file', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 0) +}) + +test('blocks plan-shaped doc under docs/plans/ at repo root', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', + content: '# Migration plan\n\nSteps:\n\n1. ...', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /plan-location-guard: blocked/) + assert.match(result.stderr, /docs-plans/) +}) + +test('blocks plan-shaped doc under package-level docs/plans/', async () => { + const result = await runHook({ + tool_input: { + file_path: + '/Users/x/projects/foo/packages/bar/docs/plans/refactor-plan.md', + content: '# Refactor plan', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /docs-plans/) +}) + +test('allows plan under repo-root .claude/plans/', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/.claude/plans/my-plan.md', + content: '# My plan', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 0) +}) + +test('blocks plan under sub-package .claude/plans/', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/packages/bar/.claude/plans/sub-plan.md', + content: '# Sub-package plan', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /sub-claude-plans/) +}) + +test('blocks plan under a SECOND .claude/plans/ deeper than the first', async () => { + const result = await runHook({ + tool_input: { + file_path: '/x/.claude/plans/outer/.claude/plans/inner.md', + content: '# Inner plan', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /sub-claude-plans/) +}) + +test('blocks README.md whose heading mentions "plans" (heading heuristic)', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/docs/plans/README.md', + content: + '# Plans directory\n\nThis directory holds historical plan archives.', + }, + tool_name: 'Write', + }) + // Filename ("readme") is benign but the heading "# Plans directory" + // contains a plan-shape token. The heuristic is intentionally + // OR-shaped — either signal blocks. + assert.strictEqual(result.code, 2) +}) + +test("allows truly-unrelated doc under docs/plans/ that doesn't look like a plan", async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/docs/plans/index.md', + content: '# Archive index\n\nLinks to historical artifacts.', + }, + tool_name: 'Write', + }) + // Neither filename ("index") nor heading ("Archive index") contains + // a plan-shape token. Pass-through. + assert.strictEqual(result.code, 0) +}) + +test('blocks Edit (not just Write) to plan-shaped path', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', + new_string: 'updated # Migration plan content', + }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('detects plan via filename when content is missing', async () => { + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/docs/plans/roadmap.md', + }, + tool_name: 'Write', + }) + // Filename contains 'roadmap' — plan-shaped. Block. + assert.strictEqual(result.code, 2) +}) + +test('respects bypass phrase in recent user turn', async t => { + // Build a transcript file containing the bypass phrase. + const { writeFile, mkdtemp, rm } = await import('node:fs/promises') + const os = await import('node:os') + const tmp = await mkdtemp(path.join(os.tmpdir(), 'plan-location-test-')) + const transcriptPath = path.join(tmp, 'session.jsonl') + const turn = { + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'Allow plan-location bypass' }], + }, + } + await writeFile(transcriptPath, JSON.stringify(turn) + '\n', 'utf8') + t.after(async () => { + await rm(tmp, { recursive: true, force: true }) + }) + + const result = await runHook({ + tool_input: { + file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', + content: '# Migration plan', + }, + tool_name: 'Write', + transcript_path: transcriptPath, + }) + assert.strictEqual(result.code, 0) +}) + +test('fails open on malformed stdin', async () => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.stdin!.end('not valid json') + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + const code: number = await new Promise(resolve => { + child.process.on('exit', c => resolve(c ?? 0)) + }) + assert.strictEqual(code, 0) + assert.match(stderr, /fail-open/) +}) + +test('fails open on empty stdin', async () => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.stdin!.end('') + const code: number = await new Promise(resolve => { + child.process.on('exit', c => resolve(c ?? 0)) + }) + assert.strictEqual(code, 0) +}) diff --git a/.claude/hooks/token-guard/tsconfig.json b/.claude/hooks/fleet/plan-location-guard/tsconfig.json similarity index 100% rename from .claude/hooks/token-guard/tsconfig.json rename to .claude/hooks/fleet/plan-location-guard/tsconfig.json diff --git a/.claude/hooks/plan-review-reminder/README.md b/.claude/hooks/fleet/plan-review-reminder/README.md similarity index 100% rename from .claude/hooks/plan-review-reminder/README.md rename to .claude/hooks/fleet/plan-review-reminder/README.md diff --git a/.claude/hooks/plan-review-reminder/index.mts b/.claude/hooks/fleet/plan-review-reminder/index.mts similarity index 100% rename from .claude/hooks/plan-review-reminder/index.mts rename to .claude/hooks/fleet/plan-review-reminder/index.mts diff --git a/.claude/hooks/plan-review-reminder/package.json b/.claude/hooks/fleet/plan-review-reminder/package.json similarity index 100% rename from .claude/hooks/plan-review-reminder/package.json rename to .claude/hooks/fleet/plan-review-reminder/package.json diff --git a/.claude/hooks/plan-review-reminder/test/index.test.mts b/.claude/hooks/fleet/plan-review-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/plan-review-reminder/test/index.test.mts rename to .claude/hooks/fleet/plan-review-reminder/test/index.test.mts diff --git a/.claude/hooks/variant-analysis-reminder/tsconfig.json b/.claude/hooks/fleet/plan-review-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/variant-analysis-reminder/tsconfig.json rename to .claude/hooks/fleet/plan-review-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/plugin-patch-format-guard/README.md b/.claude/hooks/fleet/plugin-patch-format-guard/README.md new file mode 100644 index 0000000..c58c4b1 --- /dev/null +++ b/.claude/hooks/fleet/plugin-patch-format-guard/README.md @@ -0,0 +1,37 @@ +# plugin-patch-format-guard + +PreToolUse Edit/Write hook that blocks malformed plugin-cache patches under `scripts/plugin-patches/`. + +## What it enforces + +The runtime consumer is `scripts/install-claude-plugins.mts` — its `reapplyPluginPatches()` parses each patch filename, strips the `# @key:` header, and feeds the body to `patch -p1`. A patch that doesn't match the convention is skipped (or fails to apply) at reconcile time. This hook catches the mistake at edit time instead. Rules: + +1. **Filename** matches `<plugin>-<version>-<slug>.patch` — lowercase-kebab plugin, dotted semver version, lowercase-kebab slug (e.g. `codex-1.0.1-stdin-eagain.patch`). +2. **Header** carries all four provenance keys as line-start comments: `# @plugin:`, `# @plugin-version:`, `# @sha:`, `# @description:` (`# @upstream:` is recommended but not required). +3. **Plain unified diff body** — must contain a `--- ` line, and must NOT contain git-diff markers: `diff --git`, `index <hash>..<hash>`, `new file mode`. `patch -p1` doesn't expect git markers; they break the apply. +4. **Version cross-check** — the `# @plugin-version:` value must match the version embedded in the filename (they map to the same plugin-cache dir). + +## Scope + +Fires only when the target `file_path` resolves under `scripts/plugin-patches/` and ends in `.patch` (normalized to `/`-separators first). Everything else passes through untouched. + +`Write` carries the whole file in `tool_input.content`, so it's fully validated. `Edit` only carries a `new_string` fragment — the hook can't see the surrounding file, so an `Edit` without `content` is skipped (the next `Write` or commit-time path catches it). + +## Why + +A plugin-cache patch is replayed over a cache Claude Code regenerates on every install. The format is load-bearing: the filename maps to the cache dir, the header carries provenance, and the body must be a tool-`patch`-compatible plain diff. Git-diff output (`git diff` / `git format-patch`) injects `index`/`mode` markers that bare `patch` rejects — a classic foot-gun this gate closes. Full spec: [`docs/claude.md/fleet/plugin-cache-patches.md`](../../../docs/claude.md/fleet/plugin-cache-patches.md). Regenerate stale patches via the `regenerating-plugin-patches` skill. + +## No bypass + +This is a pure format gate, not a policy gate — there's no `Allow … bypass` phrase. A malformed patch is always wrong; fix the patch. + +## Companion files + +- `index.mts` — the hook (exports `classifyPluginPatch`, `isPluginPatchPath`, `emitBlock`). +- `test/index.test.mts` — node:test specs. +- `package.json` — workspace declaration so `taze` can see the hook's deps. +- `tsconfig.json` — fleet-canonical TS config. + +## Failing open + +The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. diff --git a/.claude/hooks/fleet/plugin-patch-format-guard/index.mts b/.claude/hooks/fleet/plugin-patch-format-guard/index.mts new file mode 100644 index 0000000..f7566c8 --- /dev/null +++ b/.claude/hooks/fleet/plugin-patch-format-guard/index.mts @@ -0,0 +1,272 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — plugin-patch-format-guard. +// +// Blocks Edit/Write tool calls that would write a plugin-cache patch +// (`scripts/plugin-patches/*.patch`) in a non-canonical shape. The +// runtime consumer is `install-claude-plugins.mts`'s +// `reapplyPluginPatches()`, which: parses the filename via +// `parsePatchFileName`, strips the `# @key: value` header via +// `stripPatchHeader`, then feeds the body to `patch -p1`. A patch that +// doesn't match the convention is silently skipped (or worse, fails to +// apply) at reconcile time — this hook catches the mistake at edit time. +// +// What it enforces (full spec: docs/claude.md/fleet/plugin-cache-patches.md): +// +// 1. Filename `<plugin>-<version>-<slug>.patch` — lowercase-kebab +// plugin, dotted semver version, lowercase-kebab slug. +// 2. Four required `# @key:` header lines: @plugin, @plugin-version, +// @sha, @description. +// 3. A PLAIN `diff -u` body: must have a `--- ` line, must NOT carry +// git-diff markers (`diff --git`, `index ab..cd`, `new file mode`). +// `patch` doesn't expect git markers; they break the apply. +// 4. The `# @plugin-version:` value must match the version embedded in +// the filename (best-effort cross-check). +// +// Validation needs the WHOLE file content. Write passes it as +// `tool_input.content`. Edit only passes a `new_string` fragment — we +// can't see the surrounding file, so an Edit without `content` is +// skipped (documented limitation; the commit-time path / the next Write +// catch it). No bypass — this is a pure format gate, not a policy gate. +// +// Exit code 2 makes Claude Code refuse the tool call. +// +// Reads a Claude Code PreToolUse JSON payload from stdin: +// { "tool_name": "Edit"|"Write", +// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } +// +// Fails open on hook bugs (exit 0 + stderr log). + +import process from 'node:process' + +import { + isAbsolute, + normalizePath, +} from '@socketsecurity/lib-stable/paths/normalize' + +import { readStdin } from '../_shared/transcript.mts' + +type ToolInput = { + tool_input?: + | { + content?: string | undefined + file_path?: string | undefined + new_string?: string | undefined + } + | undefined + tool_name?: string | undefined +} + +// <plugin>-<version>-<slug>.patch — lowercase-kebab plugin, dotted +// semver version, lowercase-kebab slug. Mirrors `PATCH_FILE_NAME` in +// scripts/install-claude-plugins.mts so the hook and the consumer agree. +const PATCH_FILE_NAME = /^[a-z0-9-]+-(\d+\.\d+\.\d+)-[a-z0-9-]+\.patch$/ + +// The four header keys the consumer's provenance block requires. +const REQUIRED_HEADER_KEYS = [ + '@plugin', + '@plugin-version', + '@sha', + '@description', +] as const + +// Line-start `# @plugin-version: <semver>` — used to cross-check the +// header version against the filename version. +const HEADER_PLUGIN_VERSION = /^# @plugin-version:\s*(\d+\.\d+\.\d+)\s*$/m + +type Verdict = { ok: true } | { ok: false; reason: string } + +/** + * Is the target file path a plugin-cache patch under `scripts/plugin-patches/`? + * Normalizes to `/`-separators first so the check is cross-platform (per the + * fleet path-regex-normalize rule), then matches the canonical dir + `.patch` + * extension. + */ +export function isPluginPatchPath(filePath: string): boolean { + const normalized = normalizePath(filePath) + // Match the dir segment with or without a leading slash so a (malformed) + // relative path is still recognized as a plugin patch — the caller then + // flags the non-absolute path rather than letting it slip past as "not a + // patch". `/scripts/plugin-patches/` (mid-path) and `scripts/plugin-patches/` + // (path start) both count. + return ( + /(?:^|\/)scripts\/plugin-patches\//.test(normalized) && + normalized.endsWith('.patch') + ) +} + +/** + * Pure classifier: given a patch filename + its full content, return a verdict. + * Exported for unit tests. Mirrors the runtime contract of + * `install-claude-plugins.mts` (filename → cache dir, header → provenance, + * plain `diff -u` body → `patch -p1`). + */ +export function classifyPluginPatch( + fileName: string, + content: string, +): Verdict { + // (1) Filename shape. + const nameMatch = PATCH_FILE_NAME.exec(fileName) + if (!nameMatch) { + return { + ok: false, + reason: + `Filename "${fileName}" must match <plugin>-<version>-<slug>.patch ` + + '(lowercase-kebab plugin, dotted semver version, lowercase-kebab ' + + 'slug). Example: codex-1.0.1-stdin-eagain.patch.', + } + } + const fileVersion = nameMatch[1]! + + // (2) Required header keys, each as a line-start `# @key:` comment. + const missing: string[] = [] + for (let i = 0, { length } = REQUIRED_HEADER_KEYS; i < length; i += 1) { + const key = REQUIRED_HEADER_KEYS[i]! + const re = new RegExp(`^# ${key}:`, 'm') + if (!re.test(content)) { + missing.push(`# ${key}:`) + } + } + if (missing.length) { + return { + ok: false, + reason: + `Missing required header line(s): ${missing.join(', ')}. Every ` + + 'plugin patch needs a `# @plugin:` / `# @plugin-version:` / ' + + '`# @sha:` / `# @description:` provenance header above the diff.', + } + } + + // (3) Plain unified diff body — must have a `--- ` line. + if (!/^--- /m.test(content)) { + return { + ok: false, + reason: + 'No `--- ` line found. The body must be a plain unified diff ' + + '(`diff -u` output) — `reapplyPluginPatches()` strips everything ' + + 'before the first `--- ` line and feeds the rest to `patch -p1`.', + } + } + + // (3b) Reject git-diff markers — `patch` doesn't expect them. + const lines = content.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]! + if (line.startsWith('diff --git ')) { + return { + ok: false, + reason: + 'Body is a `git diff` (found `diff --git`). Use a plain ' + + '`diff -u a/file b/file` instead — git markers break `patch -p1`. ' + + 'Regenerate via the regenerating-plugin-patches skill.', + } + } + if (/^index [0-9a-f]+\.\./.test(line)) { + return { + ok: false, + reason: + 'Body has a git `index <hash>..<hash>` line. Use a plain ' + + '`diff -u` body (no git markers); regenerate via the ' + + 'regenerating-plugin-patches skill.', + } + } + if (line.startsWith('new file mode ')) { + return { + ok: false, + reason: + 'Body has a git `new file mode` line. Use a plain `diff -u` ' + + 'body (no git markers); regenerate via the ' + + 'regenerating-plugin-patches skill.', + } + } + } + + // (4) Cross-check the header version against the filename version. + const headerMatch = HEADER_PLUGIN_VERSION.exec(content) + if (headerMatch) { + const headerVersion = headerMatch[1]! + if (headerVersion !== fileVersion) { + return { + ok: false, + reason: + `Version mismatch: filename says ${fileVersion}, ` + + `\`# @plugin-version:\` says ${headerVersion}. They map to the ` + + 'same plugin-cache dir, so they must agree. Fix one to match.', + } + } + } + + return { ok: true } +} + +export function emitBlock(filePath: string, reason: string): void { + const lines: string[] = [] + lines.push('[plugin-patch-format-guard] Blocked: malformed plugin patch.') + lines.push(` File: ${filePath}`) + lines.push(` Issue: ${reason}`) + lines.push('') + lines.push(' A plugin-cache patch must be:') + lines.push(' - named <plugin>-<version>-<slug>.patch (dotted semver),') + lines.push( + ' - headed by # @plugin: / # @plugin-version: / # @sha: / # @description:,', + ) + lines.push( + ' - a plain `diff -u` body (a/… b/…, NO `diff --git`/`index`/`mode`).', + ) + lines.push(' Spec: docs/claude.md/fleet/plugin-cache-patches.md') + process.stderr.write(lines.join('\n') + '\n') +} + +async function main(): Promise<void> { + const raw = await readStdin() + if (!raw) { + return + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + return + } + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + return + } + const filePath = payload.tool_input?.file_path ?? '' + if (!filePath || !isPluginPatchPath(filePath)) { + return + } + // PreToolUse always hands hooks an absolute file_path. A relative one is + // anomalous — the path-match + filename-derivation below assume an absolute + // path, so flag it rather than silently mis-derive the cache mapping. + if (!isAbsolute(filePath)) { + process.stderr.write( + `[plugin-patch-format-guard] Blocked: file_path must be absolute.\n` + + ` Where: tool_input.file_path = "${filePath}"\n` + + ` Saw: a relative path; wanted an absolute path (PreToolUse ` + + `always passes one).\n` + + ` Fix: pass the absolute path to the patch under ` + + `scripts/plugin-patches/.\n`, + ) + process.exitCode = 2 + return + } + // Validation needs the whole file. Write carries it in `content`; an + // Edit only carries a `new_string` fragment, so we can't see the full + // file — skip the Edit-without-content case rather than guess. + const content = payload.tool_input?.content + if (typeof content !== 'string') { + return + } + const fileName = normalizePath(filePath).split('/').pop() ?? '' + const verdict = classifyPluginPatch(fileName, content) + if (verdict.ok) { + return + } + emitBlock(filePath, verdict.reason) + process.exitCode = 2 +} + +main().catch(e => { + process.stderr.write( + `[plugin-patch-format-guard] hook error (continuing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/fleet/plugin-patch-format-guard/package.json b/.claude/hooks/fleet/plugin-patch-format-guard/package.json new file mode 100644 index 0000000..49f8d30 --- /dev/null +++ b/.claude/hooks/fleet/plugin-patch-format-guard/package.json @@ -0,0 +1,18 @@ +{ + "name": "hook-plugin-patch-format-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "dependencies": { + "@socketsecurity/lib-stable": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts b/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts new file mode 100644 index 0000000..0a6c124 --- /dev/null +++ b/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts @@ -0,0 +1,251 @@ +// node --test specs for the plugin-patch-format-guard hook. + +import test from 'node:test' +import assert from 'node:assert/strict' +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { classifyPluginPatch, isPluginPatchPath } from '../index.mts' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +type Result = { code: number; stderr: string } + +async function runHook(payload: Record<string, unknown>): Promise<Result> { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + child.stdin!.end(JSON.stringify(payload)) + let stderr = '' + child.process.stderr!.on('data', chunk => { + stderr += chunk.toString('utf8') + }) + return new Promise(resolve => { + child.process.on('exit', code => { + resolve({ code: code ?? 0, stderr }) + }) + }) +} + +const PATCH_PATH = + '/Users/x/projects/foo/scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch' + +const VALID_PATCH = `# @plugin: codex +# @plugin-version: 1.0.1 +# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 +# @upstream: https://github.com/openai/codex-plugin-cc +# @description: Fix EAGAIN on stdin read +# +--- a/scripts/lib/fs.mjs ++++ b/scripts/lib/fs.mjs +@@ -32,9 +32,39 @@ + context +-old ++new + context +` + +// --- Unit tests for the pure classifier. --- + +test('classifyPluginPatch: valid patch passes', () => { + const verdict = classifyPluginPatch( + 'codex-1.0.1-stdin-eagain.patch', + VALID_PATCH, + ) + assert.deepStrictEqual(verdict, { ok: true }) +}) + +test('classifyPluginPatch: bad filename blocks', () => { + for (const name of [ + 'codex-1.0-x.patch', // version not dotted-semver + 'Codex-1.0.1-x.patch', // uppercase plugin + 'codex-1.0.1-X.patch', // uppercase slug + 'codex-1.0.1.patch', // missing slug + 'codex-1.0.1-x.diff', // wrong extension + ]) { + const verdict = classifyPluginPatch(name, VALID_PATCH) + assert.strictEqual(verdict.ok, false, `${name} should be blocked`) + if (!verdict.ok) { + assert.match(verdict.reason, /<plugin>-<version>-<slug>\.patch/) + } + } +}) + +test('classifyPluginPatch: missing each required header key blocks', () => { + const keys = ['@plugin', '@plugin-version', '@sha', '@description'] as const + for (const key of keys) { + // Drop just the line for `key`. Use a per-key version match for + // @plugin-version so the cross-check doesn't pre-empt the header check. + const content = VALID_PATCH.split('\n') + .filter(line => !line.startsWith(`# ${key}:`)) + .join('\n') + const verdict = classifyPluginPatch('codex-1.0.1-x.patch', content) + assert.strictEqual(verdict.ok, false, `missing ${key} should block`) + if (!verdict.ok) { + assert.match(verdict.reason, /header/i) + } + } +}) + +test('classifyPluginPatch: git-diff markers block', () => { + const gitDiffGit = VALID_PATCH.replace( + '--- a/scripts/lib/fs.mjs', + 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', + ) + const v1 = classifyPluginPatch('codex-1.0.1-x.patch', gitDiffGit) + assert.strictEqual(v1.ok, false) + if (!v1.ok) { + assert.match(v1.reason, /diff --git/) + } + + const gitIndex = VALID_PATCH.replace( + '--- a/scripts/lib/fs.mjs', + 'index ab12cd34..ef56ab78 100644\n--- a/scripts/lib/fs.mjs', + ) + const v2 = classifyPluginPatch('codex-1.0.1-x.patch', gitIndex) + assert.strictEqual(v2.ok, false) + if (!v2.ok) { + assert.match(v2.reason, /index/) + } + + const gitNewFile = VALID_PATCH.replace( + '--- a/scripts/lib/fs.mjs', + 'new file mode 100644\n--- a/scripts/lib/fs.mjs', + ) + const v3 = classifyPluginPatch('codex-1.0.1-x.patch', gitNewFile) + assert.strictEqual(v3.ok, false) + if (!v3.ok) { + assert.match(v3.reason, /new file mode/) + } +}) + +test('classifyPluginPatch: missing diff body blocks', () => { + const headerOnly = `# @plugin: codex +# @plugin-version: 1.0.1 +# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 +# @description: no diff body +# +` + const verdict = classifyPluginPatch('codex-1.0.1-x.patch', headerOnly) + assert.strictEqual(verdict.ok, false) + if (!verdict.ok) { + assert.match(verdict.reason, /--- /) + } +}) + +test('classifyPluginPatch: version/filename mismatch blocks', () => { + // Filename says 2.0.0, header says 1.0.1. + const verdict = classifyPluginPatch('codex-2.0.0-x.patch', VALID_PATCH) + assert.strictEqual(verdict.ok, false) + if (!verdict.ok) { + assert.match(verdict.reason, /mismatch/i) + } +}) + +test('isPluginPatchPath: matches only scripts/plugin-patches/*.patch', () => { + assert.strictEqual(isPluginPatchPath(PATCH_PATH), true) + assert.strictEqual( + isPluginPatchPath('/Users/x/projects/foo/scripts/other/codex-1.0.1-x.patch'), + false, + ) + assert.strictEqual( + isPluginPatchPath('/Users/x/projects/foo/scripts/plugin-patches/notes.md'), + false, + ) +}) + +// --- Integration tests through the hook subprocess. --- + +test('hook: non-Edit/Write tool calls pass through', async () => { + const result = await runHook({ + tool_input: { command: 'ls' }, + tool_name: 'Bash', + }) + assert.strictEqual(result.code, 0) +}) + +test('hook: non-patch files pass through', async () => { + const result = await runHook({ + tool_input: { + content: 'export const X = 1', + file_path: '/Users/x/projects/foo/src/index.mts', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 0) +}) + +test('hook: valid patch via Write passes', async () => { + const result = await runHook({ + tool_input: { content: VALID_PATCH, file_path: PATCH_PATH }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 0, result.stderr) +}) + +test('hook: git-diff body via Write blocks', async () => { + const gitDiff = VALID_PATCH.replace( + '--- a/scripts/lib/fs.mjs', + 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', + ) + const result = await runHook({ + tool_input: { content: gitDiff, file_path: PATCH_PATH }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /plugin-patch-format-guard/) + assert.match(result.stderr, /diff --git/) +}) + +test('hook: bad filename via Write blocks', async () => { + const result = await runHook({ + tool_input: { + content: VALID_PATCH, + file_path: + '/Users/x/projects/foo/scripts/plugin-patches/Codex-1.0-bad.patch', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /<plugin>-<version>-<slug>\.patch/) +}) + +test('hook: Edit without content is skipped (cannot see whole file)', async () => { + const result = await runHook({ + tool_input: { file_path: PATCH_PATH, new_string: 'diff --git oops' }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 0) +}) + +test('hook: Edit WITH content is validated', async () => { + const gitDiff = VALID_PATCH.replace( + '--- a/scripts/lib/fs.mjs', + 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', + ) + const result = await runHook({ + tool_input: { content: gitDiff, file_path: PATCH_PATH }, + tool_name: 'Edit', + }) + assert.strictEqual(result.code, 2) +}) + +test('hook: relative plugin-patch path blocks (PreToolUse always passes absolute)', async () => { + const result = await runHook({ + tool_input: { + content: VALID_PATCH, + file_path: 'scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch', + }, + tool_name: 'Write', + }) + assert.strictEqual(result.code, 2) + assert.match(result.stderr, /must be absolute/) +}) diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json b/.claude/hooks/fleet/plugin-patch-format-guard/tsconfig.json similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json rename to .claude/hooks/fleet/plugin-patch-format-guard/tsconfig.json diff --git a/.claude/hooks/pointer-comment-guard/README.md b/.claude/hooks/fleet/pointer-comment-guard/README.md similarity index 100% rename from .claude/hooks/pointer-comment-guard/README.md rename to .claude/hooks/fleet/pointer-comment-guard/README.md diff --git a/.claude/hooks/pointer-comment-guard/index.mts b/.claude/hooks/fleet/pointer-comment-guard/index.mts similarity index 100% rename from .claude/hooks/pointer-comment-guard/index.mts rename to .claude/hooks/fleet/pointer-comment-guard/index.mts diff --git a/.claude/hooks/pointer-comment-guard/package.json b/.claude/hooks/fleet/pointer-comment-guard/package.json similarity index 100% rename from .claude/hooks/pointer-comment-guard/package.json rename to .claude/hooks/fleet/pointer-comment-guard/package.json diff --git a/.claude/hooks/pointer-comment-guard/test/index.test.mts b/.claude/hooks/fleet/pointer-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/pointer-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/pointer-comment-guard/test/index.test.mts diff --git a/.claude/hooks/version-bump-order-guard/tsconfig.json b/.claude/hooks/fleet/pointer-comment-guard/tsconfig.json similarity index 100% rename from .claude/hooks/version-bump-order-guard/tsconfig.json rename to .claude/hooks/fleet/pointer-comment-guard/tsconfig.json diff --git a/.claude/hooks/pr-vs-push-default-reminder/README.md b/.claude/hooks/fleet/pr-vs-push-default-reminder/README.md similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/README.md rename to .claude/hooks/fleet/pr-vs-push-default-reminder/README.md diff --git a/.claude/hooks/pr-vs-push-default-reminder/index.mts b/.claude/hooks/fleet/pr-vs-push-default-reminder/index.mts similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/index.mts rename to .claude/hooks/fleet/pr-vs-push-default-reminder/index.mts diff --git a/.claude/hooks/pr-vs-push-default-reminder/package.json b/.claude/hooks/fleet/pr-vs-push-default-reminder/package.json similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/package.json rename to .claude/hooks/fleet/pr-vs-push-default-reminder/package.json diff --git a/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts b/.claude/hooks/fleet/pr-vs-push-default-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/test/index.test.mts rename to .claude/hooks/fleet/pr-vs-push-default-reminder/test/index.test.mts diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json b/.claude/hooks/fleet/pr-vs-push-default-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json rename to .claude/hooks/fleet/pr-vs-push-default-reminder/tsconfig.json diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/README.md b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/README.md similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/README.md rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/README.md diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/index.mts b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/index.mts similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/index.mts rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/index.mts diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/package.json b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/package.json similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/package.json rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/package.json diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/test/index.test.mts diff --git a/.claude/hooks/workflow-uses-comment-guard/tsconfig.json b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/tsconfig.json similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/tsconfig.json rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/tsconfig.json diff --git a/.claude/hooks/private-name-guard/README.md b/.claude/hooks/fleet/private-name-guard/README.md similarity index 100% rename from .claude/hooks/private-name-guard/README.md rename to .claude/hooks/fleet/private-name-guard/README.md diff --git a/.claude/hooks/private-name-guard/index.mts b/.claude/hooks/fleet/private-name-guard/index.mts similarity index 100% rename from .claude/hooks/private-name-guard/index.mts rename to .claude/hooks/fleet/private-name-guard/index.mts diff --git a/.claude/hooks/private-name-guard/package.json b/.claude/hooks/fleet/private-name-guard/package.json similarity index 100% rename from .claude/hooks/private-name-guard/package.json rename to .claude/hooks/fleet/private-name-guard/package.json diff --git a/.claude/hooks/private-name-guard/test/private-name-guard.test.mts b/.claude/hooks/fleet/private-name-guard/test/private-name-guard.test.mts similarity index 100% rename from .claude/hooks/private-name-guard/test/private-name-guard.test.mts rename to .claude/hooks/fleet/private-name-guard/test/private-name-guard.test.mts diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json b/.claude/hooks/fleet/private-name-guard/tsconfig.json similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json rename to .claude/hooks/fleet/private-name-guard/tsconfig.json diff --git a/.claude/hooks/fleet/provenance-publish-reminder/README.md b/.claude/hooks/fleet/provenance-publish-reminder/README.md new file mode 100644 index 0000000..80b6c94 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/README.md @@ -0,0 +1,53 @@ +# provenance-publish-reminder + +Stop hook that fires after a release commit, queries the npm registry +for the published version, and warns to stderr if the version is +missing provenance attestation or trusted-publisher OIDC metadata. + +## Trigger + +The hook activates when HEAD looks like a release commit: + +- Commit subject matches `chore: bump version to vX.Y.Z` (or + `chore(scope): release vX.Y.Z`), AND the captured version equals + `package.json` version. +- OR HEAD has an annotated tag matching `vX.Y.Z` whose version equals + `package.json` version. + +## Action + +For the resolved name@version: + +1. Fetch `https://registry.npmjs.org/<name>/<version>`. +2. If 404: silent (release in flight, retry next Stop). +3. If 2xx and BOTH `dist.attestations` + `_npmUser.trustedPublisher` + are present: silent. +4. Otherwise: warn to stderr listing the missing signals and pointing + at `scripts/check-provenance.mts` for follow-up. + +The hook never fails the turn — Stop hooks shouldn't gate. The warning +surfaces; the operator decides what to do. + +## State + +`.claude/state/provenance-reminder.last` holds the last-checked +`<name>@<version>` string so a given release is checked at most once. +Bumping the version resets the throttle (different stateKey). + +## Configuration + +| Env var | Behavior | +| ------------------------------------- | -------------- | +| `SOCKET_PROVENANCE_REMINDER_DISABLED` | Skip entirely. | + +## Why this exists + +Even with the canonical `scripts/publish.mts --staged + --approve` +flow, an OIDC regression in CI (workflow YAML drift, missing +`id-token: write` permission, fallback to a classic token) can publish +a version without provenance. The publish workflow exits 0; nothing +visible goes wrong; the version on npm just lacks the trust metadata +that ties it back to a specific GitHub Actions run. + +This hook closes that loop: every release commit is followed by a +quick registry check that confirms the trust signals landed. diff --git a/.claude/hooks/fleet/provenance-publish-reminder/index.mts b/.claude/hooks/fleet/provenance-publish-reminder/index.mts new file mode 100644 index 0000000..861b625 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/index.mts @@ -0,0 +1,258 @@ +#!/usr/bin/env node +// Claude Code Stop hook — provenance-publish-reminder. +// +// After a release commit (HEAD matches `chore: bump version to vX.Y.Z` +// or HEAD has a `vX.Y.Z`-shaped annotated tag), query the npm registry +// for that version's trust metadata and warn if it's missing either: +// - dist.attestations (--provenance was used) +// - _npmUser.trustedPublisher (OIDC trusted publisher) +// +// Why a Stop hook (not a PreToolUse gate): the version's been +// published by the time we can verify. This is post-hoc; the gate +// already failed if it failed. We catch the failure mode where the +// publish workflow ran "successfully" but somehow without OIDC (e.g. +// the workflow regressed, fell back to a classic token without +// updating the trusted-publisher block on npmjs.com). +// +// Behavior on Stop: +// 1. Drain stdin (Stop payload; we don't use it). +// 2. Skip if SOCKET_PROVENANCE_REMINDER_DISABLED is set. +// 3. Read package.json → name + version. +// 4. Check HEAD for release-shape markers. Skip if none. +// 5. Throttle via .claude/state/provenance-reminder.last so each +// release is checked at most once per name@version per session. +// 6. Fetch the registry packument. If version not yet published, +// skip silently (release is in-flight, retry next Stop). +// 7. If version exists AND has both signals → silent. +// 8. If version exists AND missing one or both → emit a warning to +// stderr (visible in transcript, not blocking). +// +// Configuration env vars (all optional): +// SOCKET_PROVENANCE_REMINDER_DISABLED skip entirely +// +// The hook NEVER fails the turn. Stop hooks shouldn't gate; they +// nudge. The warning surfaces so the operator decides what to do. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib-stable/errors' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +const logger = getDefaultLogger() + +const RELEASE_MESSAGE_RE = + /^chore(?:\([^)]*\))?:\s+(?:bump version to |release )v?(\d+\.\d+\.\d+)/i +const RELEASE_TAG_RE = /^v?(\d+\.\d+\.\d+)$/ +const STATE_PATH = '.claude/state/provenance-reminder.last' + +interface RegistryVersionInfo { + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined + attestations?: + | { url: string; provenance: { predicateType: string } } + | undefined +} + +async function main(): Promise<void> { + // Drain stdin. Stop hooks always receive a payload; we don't need it. + await readStdin() + + if (process.env['SOCKET_PROVENANCE_REMINDER_DISABLED']) { + return + } + + const repoRoot = process.cwd() + const pkgPath = path.join(repoRoot, 'package.json') + if (!existsSync(pkgPath)) { + return + } + + let pkg: { name?: string | undefined; version?: string | undefined } + try { + pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as typeof pkg + } catch { + return + } + if (!pkg.name || !pkg.version) { + return + } + + if (!isReleaseHead(repoRoot, pkg.version)) { + return + } + + const stateKey = `${pkg.name}@${pkg.version}` + if (alreadyCheckedThisSession(repoRoot, stateKey)) { + return + } + + const info = await fetchVersionInfo(pkg.name, pkg.version) + if (info === undefined) { + // Version not on registry yet — release in flight or never + // published. Don't warn; the next Stop will re-check. + return + } + + // Mark this version as checked even on the happy path so we don't + // spam-fetch the registry on every Stop event. + recordChecked(repoRoot, stateKey) + + const missing: string[] = [] + if (!info.attestations) { + missing.push('provenance attestation (`--provenance` flag)') + } + if (!info.trustedPublisher) { + missing.push('trusted-publisher OIDC (`_npmUser.trustedPublisher`)') + } + if (missing.length === 0) { + return + } + + process.stderr.write( + [ + `[provenance-publish-reminder] ${stateKey} is published but missing:`, + ...missing.map(m => ` - ${m}`), + ` Verify with: pnpm exec node scripts/check-provenance.mts ${pkg.name} --version ${pkg.version}`, + ` This typically means the publish workflow regressed (e.g. fell back from staged-publish + OIDC to a classic-token publish).`, + '', + ].join('\n'), + ) +} + +/** + * Check whether HEAD looks like a release commit. Two signals: 1. HEAD's commit + * message matches the release-shape regex. 2. HEAD has an annotated tag + * matching vX.Y.Z and the version matches the package.json version (catches the + * case where the tag was created separately from the bump commit). + */ +function isReleaseHead(repoRoot: string, pkgVersion: string): boolean { + // Signal 1: commit message. + const msg = spawnSync('git', ['log', '-1', '--format=%B'], { + cwd: repoRoot, + encoding: 'utf8', + }) + if (msg.status === 0) { + const subject = (msg.stdout as string | undefined)?.split('\n')[0] ?? '' + const m = RELEASE_MESSAGE_RE.exec(subject) + if (m && m[1] === pkgVersion) { + return true + } + } + // Signal 2: HEAD tag. + const tag = spawnSync('git', ['tag', '--points-at', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }) + if (tag.status !== 0) { + return false + } + const tags = ((tag.stdout as string | undefined) ?? '') + .split('\n') + .filter(Boolean) + for (const t of tags) { + const m = RELEASE_TAG_RE.exec(t) + if (m && m[1] === pkgVersion) { + return true + } + } + return false +} + +function alreadyCheckedThisSession( + repoRoot: string, + stateKey: string, +): boolean { + const statePath = path.join(repoRoot, STATE_PATH) + if (!existsSync(statePath)) { + return false + } + try { + const last = readFileSync(statePath, 'utf8').trim() + return last === stateKey + } catch { + return false + } +} + +function recordChecked(repoRoot: string, stateKey: string): void { + const statePath = path.join(repoRoot, STATE_PATH) + try { + mkdirSync(path.dirname(statePath), { recursive: true }) + writeFileSync(statePath, stateKey, 'utf8') + } catch { + // Best-effort; if we can't write state we'll re-check next Stop. + } +} + +/** + * Fetch a single version's trust info. Returns undefined when the version isn't + * on the registry yet (the publish hasn't propagated or didn't happen). + */ +async function fetchVersionInfo( + name: string, + version: string, +): Promise<RegistryVersionInfo | undefined> { + const url = `https://registry.npmjs.org/${encodeURIComponent(name).replace('%40', '@')}/${encodeURIComponent(version)}` + try { + // socket-hook: allow global-fetch -- provenance check probes the npm registry; runs as a standalone hook without the lib http-request helper wired up. + const response = await fetch(url, { + headers: { accept: 'application/json' }, + }) + if (response.status === 404) { + return undefined + } + if (!response.ok) { + return undefined + } + const json = (await response.json()) as { + dist?: + | { + attestations?: + | { url: string; provenance: { predicateType: string } } + | undefined + } + | undefined + _npmUser?: + | { + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined + } + | undefined + } + return { + ...(json._npmUser?.trustedPublisher + ? { trustedPublisher: json._npmUser.trustedPublisher } + : {}), + ...(json.dist?.attestations + ? { attestations: json.dist.attestations } + : {}), + } + } catch { + return undefined + } +} + +function readStdin(): Promise<string> { + return new Promise(resolve => { + let buf = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + buf += chunk + }) + process.stdin.on('end', () => { + resolve(buf) + }) + }) +} + +main().catch(e => { + // Stop hooks should never crash the turn. Log + continue. + process.stderr.write( + `[provenance-publish-reminder] hook error (continuing): ${errorMessage(e)}\n`, + ) +}) diff --git a/.claude/hooks/fleet/provenance-publish-reminder/package.json b/.claude/hooks/fleet/provenance-publish-reminder/package.json new file mode 100644 index 0000000..f6de099 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/package.json @@ -0,0 +1,18 @@ +{ + "name": "hook-provenance-publish-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "dependencies": { + "@socketsecurity/lib-stable": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts b/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts new file mode 100644 index 0000000..cb96430 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts @@ -0,0 +1,39 @@ +/** + * @file Smoke test for provenance-publish-reminder. + * + * Stop hook that fires when the assistant's recent turn appears to be a + * publish action without the canonical provenance + trustedPublisher + * verification steps. + * + * Smoke contract: hook loads + dispatches without throwing; empty + * transcript path → exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'provenance-reminder-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/fleet/provenance-publish-reminder/tsconfig.json b/.claude/hooks/fleet/provenance-publish-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/public-surface-reminder/README.md b/.claude/hooks/fleet/public-surface-reminder/README.md new file mode 100644 index 0000000..d182572 --- /dev/null +++ b/.claude/hooks/fleet/public-surface-reminder/README.md @@ -0,0 +1,86 @@ +# public-surface-reminder + +A **Claude Code hook** that runs before any Bash command Claude is +about to execute and prints a quick reminder about two writing rules +to stderr. It never blocks — its job is just to make sure those rules +are top-of-mind right when Claude is about to commit, push, comment +on a PR, or otherwise publish text somewhere public. + +> If you haven't worked with Claude Code hooks before: hooks are tiny +> scripts that run at specific lifecycle points. A `PreToolUse` hook +> like this one fires _before_ Claude calls a tool (here, the Bash +> tool). The hook can either **prime** the model (write to stderr, +> exit 0, model carries on) or **block** the call (exit 2). This one +> only primes. + +## The two rules + +1. **No real customer or company names.** Use a placeholder like + `Acme Inc`. No exceptions. +2. **No internal work-item IDs or tracker URLs.** No `SOC-123` / + `ENG-456` / `ASK-789` / similar; no `linear.app` / `sentry.io` / + internal Jira links. + +## What counts as "public surface" + +The hook only primes for commands that publish text outward: + +- `git commit` (including `--amend`) +- `git push` +- `gh pr (create|edit|comment|review)` +- `gh issue (create|edit|comment)` +- `gh api -X POST|PATCH|PUT` +- `gh release (create|edit)` + +Any other Bash command passes through silently. + +## Why no denylist + +You might ask: why doesn't the hook just have a list of customer +names to scan for? Because **the list itself is the leak**. A file +named `customers.txt` enumerating "these are our customers" is worse +than the bug it tries to prevent — anyone who finds it gets the org's +full customer map for free. Recognition has to happen at write time, +done by the model reading what it's about to send. The hook just +makes sure that read happens. + +## Wiring + +In `.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/fleet/public-surface-reminder/index.mts" + } + ] + } + ] + } +} +``` + +## Exit code + +Always `0`. The hook prints a reminder and steps aside. + +## Sibling hooks + +- [`private-name-guard`](../private-name-guard/) — primes on private + repo / project names. +- [`token-guard`](../token-guard/) — _blocks_ Bash calls that would + leak literal secrets to stdout. (The blocking sibling, contrasted + with this priming one.) + +## Cross-fleet sync + +This README and the hook itself live in +[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/public-surface-reminder) +and are required to be byte-identical across every fleet repo. +`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/public-surface-reminder/index.mts b/.claude/hooks/fleet/public-surface-reminder/index.mts similarity index 100% rename from .claude/hooks/public-surface-reminder/index.mts rename to .claude/hooks/fleet/public-surface-reminder/index.mts diff --git a/.claude/hooks/public-surface-reminder/package.json b/.claude/hooks/fleet/public-surface-reminder/package.json similarity index 100% rename from .claude/hooks/public-surface-reminder/package.json rename to .claude/hooks/fleet/public-surface-reminder/package.json diff --git a/.claude/hooks/public-surface-reminder/README.md b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/README.md similarity index 100% rename from .claude/hooks/public-surface-reminder/README.md rename to .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/README.md diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts new file mode 100644 index 0000000..0856652 --- /dev/null +++ b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — public-surface reminder. +// +// Never blocks. On every Bash command that would publish text to a public +// Git/GitHub surface (git commit, git push, gh pr/issue/api/release write), +// writes a short reminder to stderr so the model re-reads the command with +// the two rules freshly in mind: +// +// 1. No real customer/company names — ever. Use `Acme Inc` instead. +// 2. No internal work-item IDs or tracker URLs — no `SOC-123`, `ENG-456`, +// `ASK-789`, `linear.app`, `sentry.io`, etc. +// +// Exit code is always 0. This is attention priming, not enforcement. The +// model is responsible for actually applying the rule — the hook just makes +// sure the rule is in the active context at the moment the command is about +// to fire. +// +// Deliberately carries no list of customer names. Recognition and +// replacement happen at write time, not via enumeration. +// +// Reads a Claude Code PreToolUse JSON payload from stdin: +// { "tool_name": "Bash", "tool_input": { "command": "..." } } + +import { readFileSync } from 'node:fs' + +type ToolInput = { + tool_name?: string | undefined + tool_input?: + | { + command?: string | undefined + } + | undefined +} + +// Commands that can publish content outside the local machine. +// Keep broad — better to remind on an extra read than miss a write. +const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ + /\bgit\s+commit\b/, + /\bgit\s+push\b/, + /\bgh\s+pr\s+(?:comment|create|edit|review)\b/, + /\bgh\s+issue\s+(?:comment|create|edit)\b/, + /\bgh\s+api\b[^|]*-X\s*(?:PATCH|POST|PUT)\b/i, + /\bgh\s+release\s+(?:create|edit)\b/, +] + +export function isPublicSurface(command: string): boolean { + const normalized = command.replace(/\s+/g, ' ') + return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized)) +} + +function main(): void { + let raw = '' + try { + raw = readFileSync(0, 'utf8') + } catch { + return + } + + let input: ToolInput + try { + input = JSON.parse(raw) + } catch { + return + } + + if (input.tool_name !== 'Bash') { + return + } + const command = input.tool_input?.command + if (!command || typeof command !== 'string') { + return + } + if (!isPublicSurface(command)) { + return + } + + const lines = [ + '[public-surface-reminder] This command writes to a public Git/GitHub surface.', + ' • Re-read the commit message / PR body / comment BEFORE it sends.', + ' • No real customer or company names — use `Acme Inc`. No exceptions.', + ' • No internal work-item IDs or tracker URLs (linear.app, sentry.io, SOC-/ENG-/ASK-/etc.).', + ' • If you spot one, cancel and rewrite the text first.', + ] + process.stderr.write(lines.join('\n') + '\n') +} + +main() diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json new file mode 100644 index 0000000..3346432 --- /dev/null +++ b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-public-surface-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/test/public-surface-reminder.test.mts similarity index 100% rename from .claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts rename to .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/test/public-surface-reminder.test.mts diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/public-surface-reminder/test/public-surface-reminder.test.mts b/.claude/hooks/fleet/public-surface-reminder/test/public-surface-reminder.test.mts new file mode 100644 index 0000000..8240de4 --- /dev/null +++ b/.claude/hooks/fleet/public-surface-reminder/test/public-surface-reminder.test.mts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import path from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.resolve(__dirname, '..', 'index.mts') + +interface Payload { + tool_name?: string | undefined + tool_input?: + | { + command?: string | undefined + } + | undefined +} + +function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { + stdio: ['pipe', 'ignore', 'pipe'], + }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + let stderr = '' + child.process.stderr!.on('data', d => { + stderr += d.toString() + }) + child.process.on('error', reject) + child.process.on('exit', code => { + resolve({ code: code ?? -1, stderr }) + }) + child.stdin!.end(JSON.stringify(payload)) + }) +} + +test('reminds on git commit (exit 0 + stderr)', async () => { + const { code, stderr } = await runHook({ + tool_name: 'Bash', + tool_input: { + command: 'git commit -m "feat: x"', + }, + }) + assert.equal(code, 0, `expected reminder, not block; got exit ${code}`) + assert.ok(stderr.length > 0, 'expected reminder text on stderr') +}) + +test('reminds on gh release create', async () => { + const { code, stderr } = await runHook({ + tool_name: 'Bash', + tool_input: { + command: 'gh release create v1.0.0 --notes "release"', + }, + }) + assert.equal(code, 0) + assert.ok(stderr.length > 0) +}) + +test('stays silent on non-public-surface commands', async () => { + const { code, stderr } = await runHook({ + tool_name: 'Bash', + tool_input: { + command: 'git status', + }, + }) + assert.equal(code, 0) + assert.equal(stderr.length, 0) +}) + +test('stays silent on non-Bash tool', async () => { + const { code, stderr } = await runHook({ + tool_name: 'Read', + tool_input: {}, + }) + assert.equal(code, 0) + assert.equal(stderr.length, 0) +}) + +test('fails open on malformed stdin', async () => { + const child = spawn(process.execPath, [HOOK], { + stdio: ['pipe', 'ignore', 'pipe'], + }) + child.stdin!.end('}}}invalid') + const code = await new Promise<number>(resolve => { + child.process.on('exit', c => resolve(c ?? -1)) + }) + assert.equal(code, 0) +}) diff --git a/.claude/hooks/fleet/public-surface-reminder/tsconfig.json b/.claude/hooks/fleet/public-surface-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/public-surface-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/pull-request-target-guard/README.md b/.claude/hooks/fleet/pull-request-target-guard/README.md similarity index 100% rename from .claude/hooks/pull-request-target-guard/README.md rename to .claude/hooks/fleet/pull-request-target-guard/README.md diff --git a/.claude/hooks/pull-request-target-guard/index.mts b/.claude/hooks/fleet/pull-request-target-guard/index.mts similarity index 100% rename from .claude/hooks/pull-request-target-guard/index.mts rename to .claude/hooks/fleet/pull-request-target-guard/index.mts diff --git a/.claude/hooks/pull-request-target-guard/package.json b/.claude/hooks/fleet/pull-request-target-guard/package.json similarity index 100% rename from .claude/hooks/pull-request-target-guard/package.json rename to .claude/hooks/fleet/pull-request-target-guard/package.json diff --git a/.claude/hooks/pull-request-target-guard/test/index.test.mts b/.claude/hooks/fleet/pull-request-target-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/pull-request-target-guard/test/index.test.mts rename to .claude/hooks/fleet/pull-request-target-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/pull-request-target-guard/tsconfig.json b/.claude/hooks/fleet/pull-request-target-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/pull-request-target-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/readme-fleet-shape-guard/README.md b/.claude/hooks/fleet/readme-fleet-shape-guard/README.md similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/README.md rename to .claude/hooks/fleet/readme-fleet-shape-guard/README.md diff --git a/.claude/hooks/readme-fleet-shape-guard/index.mts b/.claude/hooks/fleet/readme-fleet-shape-guard/index.mts similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/index.mts rename to .claude/hooks/fleet/readme-fleet-shape-guard/index.mts diff --git a/.claude/hooks/readme-fleet-shape-guard/package.json b/.claude/hooks/fleet/readme-fleet-shape-guard/package.json similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/package.json rename to .claude/hooks/fleet/readme-fleet-shape-guard/package.json diff --git a/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts b/.claude/hooks/fleet/readme-fleet-shape-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/test/index.test.mts rename to .claude/hooks/fleet/readme-fleet-shape-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/readme-fleet-shape-guard/tsconfig.json b/.claude/hooks/fleet/readme-fleet-shape-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/readme-fleet-shape-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/release-workflow-guard/README.md b/.claude/hooks/fleet/release-workflow-guard/README.md similarity index 100% rename from .claude/hooks/release-workflow-guard/README.md rename to .claude/hooks/fleet/release-workflow-guard/README.md diff --git a/.claude/hooks/release-workflow-guard/index.mts b/.claude/hooks/fleet/release-workflow-guard/index.mts similarity index 100% rename from .claude/hooks/release-workflow-guard/index.mts rename to .claude/hooks/fleet/release-workflow-guard/index.mts diff --git a/.claude/hooks/release-workflow-guard/package.json b/.claude/hooks/fleet/release-workflow-guard/package.json similarity index 100% rename from .claude/hooks/release-workflow-guard/package.json rename to .claude/hooks/fleet/release-workflow-guard/package.json diff --git a/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts b/.claude/hooks/fleet/release-workflow-guard/test/release-workflow-guard.test.mts similarity index 100% rename from .claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts rename to .claude/hooks/fleet/release-workflow-guard/test/release-workflow-guard.test.mts diff --git a/.claude/hooks/fleet/release-workflow-guard/tsconfig.json b/.claude/hooks/fleet/release-workflow-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/release-workflow-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/scan-label-in-commit-guard/README.md b/.claude/hooks/fleet/scan-label-in-commit-guard/README.md similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/README.md rename to .claude/hooks/fleet/scan-label-in-commit-guard/README.md diff --git a/.claude/hooks/scan-label-in-commit-guard/index.mts b/.claude/hooks/fleet/scan-label-in-commit-guard/index.mts similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/index.mts rename to .claude/hooks/fleet/scan-label-in-commit-guard/index.mts diff --git a/.claude/hooks/scan-label-in-commit-guard/package.json b/.claude/hooks/fleet/scan-label-in-commit-guard/package.json similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/package.json rename to .claude/hooks/fleet/scan-label-in-commit-guard/package.json diff --git a/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts b/.claude/hooks/fleet/scan-label-in-commit-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/test/index.test.mts rename to .claude/hooks/fleet/scan-label-in-commit-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/scan-label-in-commit-guard/tsconfig.json b/.claude/hooks/fleet/scan-label-in-commit-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/scan-label-in-commit-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-basics-tools/README.md b/.claude/hooks/fleet/setup-basics-tools/README.md similarity index 100% rename from .claude/hooks/setup-basics-tools/README.md rename to .claude/hooks/fleet/setup-basics-tools/README.md diff --git a/.claude/hooks/setup-basics-tools/install.mts b/.claude/hooks/fleet/setup-basics-tools/install.mts similarity index 100% rename from .claude/hooks/setup-basics-tools/install.mts rename to .claude/hooks/fleet/setup-basics-tools/install.mts diff --git a/.claude/hooks/setup-basics-tools/package.json b/.claude/hooks/fleet/setup-basics-tools/package.json similarity index 100% rename from .claude/hooks/setup-basics-tools/package.json rename to .claude/hooks/fleet/setup-basics-tools/package.json diff --git a/.claude/hooks/fleet/setup-basics-tools/tsconfig.json b/.claude/hooks/fleet/setup-basics-tools/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/setup-basics-tools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/setup-claude-scanners/README.md b/.claude/hooks/fleet/setup-claude-scanners/README.md new file mode 100644 index 0000000..cda015c --- /dev/null +++ b/.claude/hooks/fleet/setup-claude-scanners/README.md @@ -0,0 +1,39 @@ +# setup-claude-scanners + +Operator-invoked installer for **AgentShield** + **zizmor** — the two +claude-config / GitHub-Actions scanners. Slim leaf of the +`setup-security-tools` umbrella. + +## When to use + +- You want to install or refresh ONLY the scanner surface + (AgentShield + zizmor) without re-running the firewall / + socket-basics / misc installers. +- You're onboarding a fresh worktree where the only thing you need + scanning right now is claude-config + workflow YAML. + +```sh +node .claude/hooks/fleet/setup-claude-scanners/install.mts +``` + +For the full setup (firewall + scanners + socket-basics + misc), use +`node .claude/hooks/fleet/setup-security-tools/install.mts`. + +## Relationship to setup-security-tools + +The umbrella `setup-security-tools/install.mts` does everything this +leaf does PLUS sfw (firewall) + socket-basics tools (TruffleHog, +Trivy, OpenGrep, uv) + misc tools (cdxgen, synp, janus). + +This leaf is a thin re-entry point that imports `setupAgentShield` + +- `setupZizmor` from the umbrella's `lib/installers.mts` and runs + ONLY those. No token resolution / keychain / shell-rc plumbing is + involved — the two scanners are auth-free. + +## What gets installed + +| Tool | Source | Purpose | +| ----------- | --------------------------------------- | ------------------------------------------------------------- | +| AgentShield | `pkg:npm/ecc-agentshield@1.4.0` via dlx | Claude AI config security scanner (prompt injection, secrets) | +| zizmor | `github:zizmorcore/zizmor` GH-release | GitHub Actions security scanner | diff --git a/.claude/hooks/fleet/setup-claude-scanners/install.mts b/.claude/hooks/fleet/setup-claude-scanners/install.mts new file mode 100644 index 0000000..51f5715 --- /dev/null +++ b/.claude/hooks/fleet/setup-claude-scanners/install.mts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * @file Install-only entry point for AgentShield + zizmor — the two + * claude-config / GitHub-Actions scanners. Slim leaf of the + * `setup-security-tools` umbrella. Run via: node + * .claude/hooks/fleet/setup-claude-scanners/install.mts For the full setup + * (firewall + scanners + socket-basics + misc), use `node + * .claude/hooks/fleet/setup-security-tools/install.mts`. + */ + +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +const logger = getDefaultLogger() + +async function main(): Promise<void> { + logger.log('Claude scanners — install / verify') + logger.log('') + + const { setupAgentShield, setupZizmor } = + (await import('../setup-security-tools/lib/installers.mts')) as { + setupAgentShield: () => Promise<boolean> + setupZizmor: () => Promise<boolean> + } + + const agentshieldOk = await setupAgentShield() + logger.log('') + const zizmorOk = await setupZizmor() + logger.log('') + + logger.log('=== Summary ===') + logger.log(`AgentShield: ${agentshieldOk ? 'ready' : 'NOT AVAILABLE'}`) + logger.log(`Zizmor: ${zizmorOk ? 'ready' : 'FAILED'}`) + + if (!(agentshieldOk && zizmorOk)) { + process.exitCode = 1 + } +} + +main().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e) + logger.error(`setup-claude-scanners install: ${msg}`) + process.exitCode = 1 +}) diff --git a/.claude/hooks/setup-claude-scanners/package.json b/.claude/hooks/fleet/setup-claude-scanners/package.json similarity index 100% rename from .claude/hooks/setup-claude-scanners/package.json rename to .claude/hooks/fleet/setup-claude-scanners/package.json diff --git a/.claude/hooks/setup-claude-scanners/README.md b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/README.md similarity index 100% rename from .claude/hooks/setup-claude-scanners/README.md rename to .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/README.md diff --git a/.claude/hooks/setup-claude-scanners/install.mts b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/install.mts similarity index 100% rename from .claude/hooks/setup-claude-scanners/install.mts rename to .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/install.mts diff --git a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json new file mode 100644 index 0000000..c8e5359 --- /dev/null +++ b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json @@ -0,0 +1,16 @@ +{ + "name": "hook-setup-claude-scanners", + "private": true, + "type": "module", + "main": "./install.mts", + "exports": { + ".": "./install.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@socketsecurity/lib-stable": "catalog:", + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/setup-claude-scanners/tsconfig.json b/.claude/hooks/fleet/setup-claude-scanners/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/setup-claude-scanners/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-firewall/README.md b/.claude/hooks/fleet/setup-firewall/README.md similarity index 100% rename from .claude/hooks/setup-firewall/README.md rename to .claude/hooks/fleet/setup-firewall/README.md diff --git a/.claude/hooks/setup-firewall/install.mts b/.claude/hooks/fleet/setup-firewall/install.mts similarity index 100% rename from .claude/hooks/setup-firewall/install.mts rename to .claude/hooks/fleet/setup-firewall/install.mts diff --git a/.claude/hooks/setup-firewall/package.json b/.claude/hooks/fleet/setup-firewall/package.json similarity index 100% rename from .claude/hooks/setup-firewall/package.json rename to .claude/hooks/fleet/setup-firewall/package.json diff --git a/.claude/hooks/fleet/setup-firewall/tsconfig.json b/.claude/hooks/fleet/setup-firewall/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/setup-firewall/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-misc-tools/README.md b/.claude/hooks/fleet/setup-misc-tools/README.md similarity index 100% rename from .claude/hooks/setup-misc-tools/README.md rename to .claude/hooks/fleet/setup-misc-tools/README.md diff --git a/.claude/hooks/setup-misc-tools/install.mts b/.claude/hooks/fleet/setup-misc-tools/install.mts similarity index 100% rename from .claude/hooks/setup-misc-tools/install.mts rename to .claude/hooks/fleet/setup-misc-tools/install.mts diff --git a/.claude/hooks/setup-misc-tools/package.json b/.claude/hooks/fleet/setup-misc-tools/package.json similarity index 100% rename from .claude/hooks/setup-misc-tools/package.json rename to .claude/hooks/fleet/setup-misc-tools/package.json diff --git a/.claude/hooks/fleet/setup-misc-tools/tsconfig.json b/.claude/hooks/fleet/setup-misc-tools/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/setup-misc-tools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-security-tools/README.md b/.claude/hooks/fleet/setup-security-tools/README.md similarity index 100% rename from .claude/hooks/setup-security-tools/README.md rename to .claude/hooks/fleet/setup-security-tools/README.md diff --git a/.claude/hooks/setup-security-tools/external-tools.json b/.claude/hooks/fleet/setup-security-tools/external-tools.json similarity index 100% rename from .claude/hooks/setup-security-tools/external-tools.json rename to .claude/hooks/fleet/setup-security-tools/external-tools.json diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/fleet/setup-security-tools/index.mts similarity index 100% rename from .claude/hooks/setup-security-tools/index.mts rename to .claude/hooks/fleet/setup-security-tools/index.mts diff --git a/.claude/hooks/setup-security-tools/install.mts b/.claude/hooks/fleet/setup-security-tools/install.mts similarity index 100% rename from .claude/hooks/setup-security-tools/install.mts rename to .claude/hooks/fleet/setup-security-tools/install.mts diff --git a/.claude/hooks/setup-security-tools/lib/api-token.mts b/.claude/hooks/fleet/setup-security-tools/lib/api-token.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/api-token.mts rename to .claude/hooks/fleet/setup-security-tools/lib/api-token.mts diff --git a/.claude/hooks/setup-security-tools/lib/installers.mts b/.claude/hooks/fleet/setup-security-tools/lib/installers.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/installers.mts rename to .claude/hooks/fleet/setup-security-tools/lib/installers.mts diff --git a/.claude/hooks/setup-security-tools/lib/operator-prompts.mts b/.claude/hooks/fleet/setup-security-tools/lib/operator-prompts.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/operator-prompts.mts rename to .claude/hooks/fleet/setup-security-tools/lib/operator-prompts.mts diff --git a/.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts b/.claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts rename to .claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts diff --git a/.claude/hooks/setup-security-tools/lib/token-storage.mts b/.claude/hooks/fleet/setup-security-tools/lib/token-storage.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/token-storage.mts rename to .claude/hooks/fleet/setup-security-tools/lib/token-storage.mts diff --git a/.claude/hooks/setup-security-tools/package.json b/.claude/hooks/fleet/setup-security-tools/package.json similarity index 100% rename from .claude/hooks/setup-security-tools/package.json rename to .claude/hooks/fleet/setup-security-tools/package.json diff --git a/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts b/.claude/hooks/fleet/setup-security-tools/test/setup-security-tools.test.mts similarity index 100% rename from .claude/hooks/setup-security-tools/test/setup-security-tools.test.mts rename to .claude/hooks/fleet/setup-security-tools/test/setup-security-tools.test.mts diff --git a/.claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts b/.claude/hooks/fleet/setup-security-tools/test/shell-rc-bridge.test.mts similarity index 100% rename from .claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts rename to .claude/hooks/fleet/setup-security-tools/test/shell-rc-bridge.test.mts diff --git a/.claude/hooks/fleet/setup-security-tools/tsconfig.json b/.claude/hooks/fleet/setup-security-tools/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/setup-security-tools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/setup-security-tools/update.mts b/.claude/hooks/fleet/setup-security-tools/update.mts similarity index 100% rename from .claude/hooks/setup-security-tools/update.mts rename to .claude/hooks/fleet/setup-security-tools/update.mts diff --git a/.claude/hooks/setup-signing/README.md b/.claude/hooks/fleet/setup-signing/README.md similarity index 100% rename from .claude/hooks/setup-signing/README.md rename to .claude/hooks/fleet/setup-signing/README.md diff --git a/.claude/hooks/setup-signing/install.mts b/.claude/hooks/fleet/setup-signing/install.mts similarity index 100% rename from .claude/hooks/setup-signing/install.mts rename to .claude/hooks/fleet/setup-signing/install.mts diff --git a/.claude/hooks/setup-signing/package.json b/.claude/hooks/fleet/setup-signing/package.json similarity index 100% rename from .claude/hooks/setup-signing/package.json rename to .claude/hooks/fleet/setup-signing/package.json diff --git a/.claude/hooks/fleet/setup-signing/tsconfig.json b/.claude/hooks/fleet/setup-signing/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/setup-signing/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/README.md b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/README.md rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/index.mts b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/index.mts rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/package.json b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/package.json similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/package.json rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/package.json diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/soak-exclude-date-annotation-guard/tsconfig.json b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/socket-token-minifier-start/README.md b/.claude/hooks/fleet/socket-token-minifier-start/README.md similarity index 100% rename from .claude/hooks/socket-token-minifier-start/README.md rename to .claude/hooks/fleet/socket-token-minifier-start/README.md diff --git a/.claude/hooks/socket-token-minifier-start/index.mts b/.claude/hooks/fleet/socket-token-minifier-start/index.mts similarity index 100% rename from .claude/hooks/socket-token-minifier-start/index.mts rename to .claude/hooks/fleet/socket-token-minifier-start/index.mts diff --git a/.claude/hooks/socket-token-minifier-start/package.json b/.claude/hooks/fleet/socket-token-minifier-start/package.json similarity index 100% rename from .claude/hooks/socket-token-minifier-start/package.json rename to .claude/hooks/fleet/socket-token-minifier-start/package.json diff --git a/.claude/hooks/fleet/socket-token-minifier-start/tsconfig.json b/.claude/hooks/fleet/socket-token-minifier-start/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/socket-token-minifier-start/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/squash-history-reminder/README.md b/.claude/hooks/fleet/squash-history-reminder/README.md similarity index 100% rename from .claude/hooks/squash-history-reminder/README.md rename to .claude/hooks/fleet/squash-history-reminder/README.md diff --git a/.claude/hooks/squash-history-reminder/index.mts b/.claude/hooks/fleet/squash-history-reminder/index.mts similarity index 100% rename from .claude/hooks/squash-history-reminder/index.mts rename to .claude/hooks/fleet/squash-history-reminder/index.mts diff --git a/.claude/hooks/squash-history-reminder/package.json b/.claude/hooks/fleet/squash-history-reminder/package.json similarity index 100% rename from .claude/hooks/squash-history-reminder/package.json rename to .claude/hooks/fleet/squash-history-reminder/package.json diff --git a/.claude/hooks/squash-history-reminder/test/index.test.mts b/.claude/hooks/fleet/squash-history-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/squash-history-reminder/test/index.test.mts rename to .claude/hooks/fleet/squash-history-reminder/test/index.test.mts diff --git a/.claude/hooks/fleet/squash-history-reminder/tsconfig.json b/.claude/hooks/fleet/squash-history-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/squash-history-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/stale-process-sweeper/README.md b/.claude/hooks/fleet/stale-process-sweeper/README.md new file mode 100644 index 0000000..c419051 --- /dev/null +++ b/.claude/hooks/fleet/stale-process-sweeper/README.md @@ -0,0 +1,94 @@ +# stale-process-sweeper + +A **Claude Code hook** that runs at the _end_ of every Claude turn +and sweeps stale Node test/build worker processes that lost their +parent. Without this, abandoned workers accumulate across turns and +gradually exhaust system memory. + +> If you haven't worked with Claude Code hooks before: hooks are tiny +> scripts that run at specific lifecycle points. A `Stop` hook like +> this one fires _after_ Claude finishes a turn (a unit of work that +> ends with the model handing the conversation back to the user). +> Stop hooks can do cleanup, log diagnostics, or — like this one — +> reap orphans. + +## Why orphans pile up + +Vitest's `forks` pool spawns one Node worker per CPU. When the parent +runner exits abnormally — a `Bash` tool timeout, a `SIGINT` from the +user, a pre-commit hook crash — the workers stay alive holding +roughly 80–100 MB of RSS each. Tools like `tsgo` and `esbuild` have +similar long-lived service processes that can outlive their parent. + +After a few interrupted runs, you can have several gigabytes of +abandoned processes sitting around. The sweeper finds them by +matching their command line against a known pattern list, confirms +their parent process has died (so we don't kill workers belonging to +a _real_ in-progress run), and sends them `SIGTERM`. + +## What's swept + +| Pattern | What it matches | +| -------------------------------------- | -------------------------------- | +| `vitest/dist/workers/(forks\|threads)` | Vitest worker pool processes | +| `vitest/dist/(cli\|node).[mc]?js` | Orphaned Vitest parent runners | +| `\btsgo\b` | TypeScript Go-based type checker | +| `type-coverage/bin/type-coverage` | Type coverage tool | +| `esbuild/(bin\|lib)/.*\bservice\b` | esbuild's daemon service | + +## What's not swept + +- Anything spawned by a still-living shell (parent process alive). + Those are part of an in-progress run; killing them would break + legitimate work. +- The Claude Code process itself or its parent terminal. +- Anything outside the pattern list. The sweeper is conservative — + if a stuck process isn't pattern-matched, it survives. + +## Wiring + +In `.claude/settings.json`: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/fleet/stale-process-sweeper/index.mts" + } + ] + } + ] + } +} +``` + +## Output + +Silent on the happy path (no orphans found). When something is +reaped: + +``` +[stale-process-sweeper] reaped 14 stale worker(s), ~1120MB freed: +vitest-worker=29240(95MB), vitest-worker=33278(93MB), … +``` + +The line goes to stderr. Stop-hook output is shown to the user, not +the model — useful diagnostic, doesn't pollute Claude's context. + +## Testing + +```bash +cd .claude/hooks/stale-process-sweeper +node --test test/*.test.mts +``` + +## Cross-fleet sync + +This README and the hook itself live in +[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/stale-process-sweeper) +and are required to be byte-identical across every fleet repo. +`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/stale-process-sweeper/index.mts b/.claude/hooks/fleet/stale-process-sweeper/index.mts similarity index 100% rename from .claude/hooks/stale-process-sweeper/index.mts rename to .claude/hooks/fleet/stale-process-sweeper/index.mts diff --git a/.claude/hooks/stale-process-sweeper/package.json b/.claude/hooks/fleet/stale-process-sweeper/package.json similarity index 100% rename from .claude/hooks/stale-process-sweeper/package.json rename to .claude/hooks/fleet/stale-process-sweeper/package.json diff --git a/.claude/hooks/stale-process-sweeper/README.md b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/README.md similarity index 100% rename from .claude/hooks/stale-process-sweeper/README.md rename to .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/README.md diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts new file mode 100644 index 0000000..9b367e4 --- /dev/null +++ b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts @@ -0,0 +1,320 @@ +#!/usr/bin/env node +// Claude Code Stop hook — stale-process-sweeper. +// +// Fires at turn-end. Finds Node test/build worker processes that the +// session left behind (test runner crashed mid-run, hook timed out, +// user interrupted `Bash`, etc.) and kills them so they don't pile up +// across turns and exhaust system memory. +// +// What's swept: +// - vitest workers (`vitest/dist/workers/forks` and the threads pool) +// - vitest itself (orphan parent runners that survived a SIGINT) +// - tsgo / tsc type-check daemons +// - type-coverage workers +// - esbuild service processes +// - Socket Firewall wrappers (`~/.socket/_wheelhouse/bin/sfw`) — each pnpm / +// yarn invocation goes through one, and the wrapper sometimes +// outlives its pnpm child. On a busy day this can pile up to +// hundreds of orphans holding ~200MB RSS each (20+GB total). +// Only orphans are reaped (parent dead or init) — live-parent +// wrappers might be tied to an in-progress install. +// +// What's NOT swept: +// - Anything spawned by a still-living shell (PPID alive) +// - Anything matching the user's editors / IDEs / terminals +// - The Claude Code process itself +// +// The hook is fast (one `ps` call + a few regex matches + a couple of +// `kill -0` probes) and silent on the happy path. It only writes to +// stderr when it actually killed something — that's a useful signal. +// +// Stop hooks receive JSON on stdin (we don't read it; the body +// shape is irrelevant to our work) and exit code is advisory. + +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import process from 'node:process' + +// Process-name patterns that indicate a stale test/build worker. +// Must be specific enough that real user processes (a normal `node` +// invocation, an editor's language server) don't match. +const STALE_PATTERNS: Array<{ name: string; rx: RegExp }> = [ + // Vitest worker pools — both `forks` (process-per-worker) and the + // path the threads pool uses when isolation is requested. The + // canonical leak: Vitest spawns N workers, parent crashes/SIGINTs, + // workers stay alive holding 80–100MB each. + { + name: 'vitest-worker', + rx: /vitest\/dist\/workers\/(forks|threads)/, + }, + // Vitest parent runner that survived its own children's exit. + // Matches both shapes: + // - `node ... vitest/dist/cli ... run` (older entry point) + // - `node ... vitest/dist/node.mjs ... run` (alternate entry point) + // - `node node_modules/.bin/../vitest/vitest.mjs run` (current shape + // spawned by `pnpm test` / `vitest run`) + { + name: 'vitest-runner', + rx: /vitest\/(dist\/(cli|node)\.[mc]?js|vitest\.[mc]?js)\b/, + }, + // tsgo / tsc daemons. `tsgo` is the new Go-based type checker; + // `tsc --watch` daemons can also linger. + { + name: 'tsgo', + rx: /\btsgo\b/, + }, + // type-coverage runs as a separate process and sometimes outlives + // its CI step. + { + name: 'type-coverage', + rx: /type-coverage\/bin\/type-coverage/, + }, + // esbuild's daemon service helper. + { + name: 'esbuild-service', + rx: /esbuild\/(bin|lib)\/.*\bservice\b/, + }, + // Socket Firewall command wrappers. Three deployment layouts: + // - ~/.socket/_wheelhouse/bin/sfw[-<version>] (current dev install) + // - ~/.socket/_dlx/<hash>/sfw (planned: dlxBinary cache) + // - ${RUNNER_TEMP}/sfw-bin/sfw[.exe] (CI runner install) + // Path component is invariant across home prefixes (/Users/<user>/ vs + // /home/<user>/). The CI path uses RUNNER_TEMP which varies per OS but + // the trailing `/sfw-bin/sfw` is stable. + // + // Orphan-only (the parent-alive branch in sweep()) — a live-parent + // sfw is likely a mid-flight pnpm/yarn install. + { + name: 'sfw-wrapper', + rx: /(?:\.socket\/(?:_dlx\/[0-9a-f]+|sfw\/bin)|sfw-bin)\/sfw(?:-[\w.]+)?(?:\.exe)?\b/, + }, +] + +interface ProcRow { + command: string + // Elapsed seconds since process started. + elapsedSec: number + pcpu: number + pid: number + ppid: number + rss: number +} + +// Convert ps `etime` field ([dd-]hh:mm:ss or mm:ss) to seconds. +// Examples: "05:23" → 323, "1:02:30" → 3750, "2-04:00:00" → 187200. +export function parseEtime(etime: string): number { + let rest = etime + let days = 0 + const dashIdx = rest.indexOf('-') + if (dashIdx !== -1) { + days = Number.parseInt(rest.slice(0, dashIdx), 10) || 0 + rest = rest.slice(dashIdx + 1) + } + const parts = rest.split(':').map(p => Number.parseInt(p, 10) || 0) + let hours = 0 + let mins = 0 + let secs = 0 + if (parts.length === 3) { + ;[hours, mins, secs] = parts as [number, number, number] + } else if (parts.length === 2) { + ;[mins, secs] = parts as [number, number] + } else if (parts.length === 1) { + secs = parts[0] ?? 0 + } + return days * 86400 + hours * 3600 + mins * 60 + secs +} + +export function listProcesses(): ProcRow[] { + // -A: all processes, -o: custom format, no truncation. macOS + Linux + // both support `pcpu` (instantaneous CPU%) and `etime` (elapsed time). + // Windows isn't supported (Stop hook is unix-only in practice). + const result = spawnSync( + 'ps', + ['-A', '-o', 'pid=,ppid=,rss=,pcpu=,etime=,command='], + {}, + ) + if (result.status !== 0 || !result.stdout) { + return [] + } + const rows: ProcRow[] = [] + // `ps -A` is unix-only (see comment above), so the output uses LF + // line endings — no CRLF normalization needed here. + for (const line of String(result.stdout).split('\n')) { + if (!line.trim()) { + continue + } + // Split into [pid, ppid, rss, pcpu, etime, ...command]. `command` + // may contain arbitrary spaces, so re-join after the first five + // fields. `pcpu` and `etime` are well-formed (no embedded space). + const parts = line.trim().split(/\s+/) + if (parts.length < 6) { + continue + } + const pid = Number.parseInt(parts[0]!, 10) + const ppid = Number.parseInt(parts[1]!, 10) + const rss = Number.parseInt(parts[2]!, 10) + const pcpu = Number.parseFloat(parts[3]!) + const elapsedSec = parseEtime(parts[4]!) + if (!Number.isFinite(pid) || !Number.isFinite(ppid)) { + continue + } + const command = parts.slice(5).join(' ') + rows.push({ + pid, + ppid, + rss, + pcpu: Number.isFinite(pcpu) ? pcpu : 0, + elapsedSec, + command, + }) + } + return rows +} + +export function isAlive(pid: number): boolean { + if (pid <= 1) { + // PID 0 / 1 are the kernel / init — if our parent is one of those, + // we're definitely an orphan, but `kill -0 1` would mislead. + return false + } + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +export function classify(row: ProcRow): string | undefined { + for (const { name, rx } of STALE_PATTERNS) { + if (rx.test(row.command)) { + return name + } + } + return undefined +} + +// Two reasons a matched worker should be reaped: +// 1. ORPHAN — parent is gone or is init (PID 1). Classic case: vitest +// SIGINT'd, parent exited, workers re-parented to init. +// 2. STUCK — parent is alive but the worker has been running for a +// long time, holding lots of memory, and burning CPU. Classic case: +// vitest run timed out from inside Claude Code; the parent CLI +// process is technically alive but unproductive, and its workers +// spin forever consuming gigabytes. We sweep these even though the +// parent's still around. +// +// Stuck-worker thresholds — conservative on purpose. A real, productive +// worker doesn't simultaneously hit all three: 5+ minutes of wallclock +// AND >50% CPU sustained AND >500MB RSS. Healthy parallel test runs +// finish well under 5 minutes per worker; CI workers that legitimately +// take longer don't run inside Claude Code's hook environment anyway. +const STUCK_MIN_ELAPSED_SEC = 300 +const STUCK_MIN_PCPU = 50 +const STUCK_MIN_RSS_KB = 500 * 1024 + +export function sweep(): { + killed: Array<{ + name: string + pid: number + reason: 'orphan' | 'stuck' + rssMb: number + }> + skipped: number +} { + const rows = listProcesses() + const myPid = process.pid + const myPpid = process.ppid + const killed: Array<{ + name: string + pid: number + reason: 'orphan' | 'stuck' + rssMb: number + }> = [] + let skipped = 0 + + for (let i = 0, { length } = rows; i < length; i += 1) { + const row = rows[i]! + // Never touch ourselves or our parent (Claude Code). + if (row.pid === myPid || row.pid === myPpid) { + continue + } + const name = classify(row) + if (!name) { + continue + } + let reason: 'orphan' | 'stuck' | undefined + if (row.ppid === 1 || !isAlive(row.ppid)) { + reason = 'orphan' + } else if ( + row.elapsedSec >= STUCK_MIN_ELAPSED_SEC && + row.pcpu >= STUCK_MIN_PCPU && + row.rss >= STUCK_MIN_RSS_KB + ) { + // Worker is matched, has a live parent, but is wedged: long + // elapsed time + spinning CPU + heavy memory. This is the + // user-reported case where vitest workers hung at 100% CPU / + // 1+GB RSS while their parent CLI was technically alive. + reason = 'stuck' + } + if (reason === undefined) { + skipped += 1 + continue + } + try { + // SIGTERM first — give the worker a chance to flush. We don't + // wait for it; the next sweep (next turn) will SIGKILL anything + // that ignored SIGTERM. Keeping the hook fast matters more than + // squeezing every last byte. + process.kill(row.pid, 'SIGTERM') + killed.push({ + name, + pid: row.pid, + reason, + rssMb: Math.round(row.rss / 1024), + }) + } catch { + // Already gone, or we lack permission — nothing to do. + } + } + return { killed, skipped } +} + +function main() { + // Drain stdin (Stop hook delivers a JSON payload). We don't need + // the body, but Node will keep the event loop alive if we don't + // consume it. + process.stdin.resume() + process.stdin.on('data', () => {}) + process.stdin.on('end', runSweep) + // If stdin is already closed (some hook runners don't pipe input), + // run immediately. + if (process.stdin.readable === false) { + runSweep() + } +} + +export function runSweep() { + let result: ReturnType<typeof sweep> + try { + result = sweep() + } catch (e) { + // Hooks must never crash a Claude turn. Log and exit clean. + process.stderr.write( + `[stale-process-sweeper] unexpected error: ${(e as Error).message}\n`, + ) + process.exit(0) + } + if (result.killed.length > 0) { + const totalMb = result.killed.reduce((sum, k) => sum + k.rssMb, 0) + const breakdown = result.killed + .map(k => `${k.name}=${k.pid}(${k.rssMb}MB,${k.reason})`) + .join(', ') + process.stderr.write( + `[stale-process-sweeper] reaped ${result.killed.length} stale ` + + `worker(s), ~${totalMb}MB freed: ${breakdown}\n`, + ) + } + process.exit(0) +} + +main() diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json new file mode 100644 index 0000000..1a0f6de --- /dev/null +++ b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-stale-process-sweeper", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + } +} diff --git a/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/test/stale-process-sweeper.test.mts similarity index 100% rename from .claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts rename to .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/test/stale-process-sweeper.test.mts diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/stale-process-sweeper/test/stale-process-sweeper.test.mts b/.claude/hooks/fleet/stale-process-sweeper/test/stale-process-sweeper.test.mts new file mode 100644 index 0000000..bdcd520 --- /dev/null +++ b/.claude/hooks/fleet/stale-process-sweeper/test/stale-process-sweeper.test.mts @@ -0,0 +1,92 @@ +// prefer-async-spawn: streaming-stdio-required — test spawns child +// subprocess and pipes stdin/stdout/stderr; Node spawn returns the +// ChildProcess streaming surface the lib promise wrapper does not. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.resolve(__dirname, '..', 'index.mts') + +// Run the hook with an empty stdin payload (Stop hook delivers JSON, +// but the body is unused). Captures stderr + exit code. +function runHook(): Promise<{ code: number; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { + stdio: ['pipe', 'ignore', 'pipe'], + }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit; this test reads stderr + exit via manual listeners + // instead. Swallow the Promise rejection so it doesn't race the + // listener-based resolve and trigger "async activity after test ended". + void child.catch(() => undefined) + let stderr = '' + child.process.stderr!.on('data', d => { + stderr += d.toString() + }) + child.process.on('error', reject) + child.process.on('exit', code => { + resolve({ code: code ?? -1, stderr }) + }) + // Stop hooks receive a JSON payload on stdin. Send an empty object + // so the hook's drain logic completes. + child.stdin!.end('{}\n') + }) +} + +test('stale-process-sweeper: exits 0 when nothing to sweep', async () => { + const { code, stderr } = await runHook() + assert.equal(code, 0, `hook should exit 0; stderr=${stderr}`) + // On a clean host the hook should be silent. + assert.equal( + stderr, + '', + `hook should be silent when no orphans exist; got: ${stderr}`, + ) +}) + +test('stale-process-sweeper: ignores live-parent test workers', async () => { + // Spawn a fake "vitest worker" whose parent is still alive. The + // sweeper must not touch it. We use a script path that matches the + // worker regex; the actual command runs `node -e 'setTimeout(...)'` + // long enough to outlive the hook invocation. + // + // Note: matching the regex `vitest/dist/workers/forks` requires a + // command line that contains that substring. We can't easily forge + // a real vitest binary, so we approximate by passing the path as an + // argv string — `ps -o command=` reflects argv, and the regex sees + // it. + const fakeWorker = spawn( + process.execPath, + [ + '-e', + 'setTimeout(() => {}, 5000)', + // This dummy arg is what `ps` will report; the sweeper's regex + // picks it up. The worker still has a live parent (this test + // process), so the sweeper should NOT kill it. + '/fake/vitest/dist/workers/forks.js', + ], + { stdio: 'ignore', detached: false }, + ) + // Give the OS a moment to register the child. + await new Promise(r => setTimeout(r, 100)) + try { + const { code, stderr } = await runHook() + assert.equal(code, 0) + // Should NOT have reaped the fake worker — its parent (us) is + // alive. If the hook killed it, the message would mention it. + assert.ok( + !stderr.includes('reaped'), + `hook reaped a live-parent worker: ${stderr}`, + ) + // Verify the worker is still alive. + assert.ok( + !fakeWorker.process.killed && fakeWorker.process.exitCode === null, + 'fake worker should still be running', + ) + } finally { + fakeWorker.process.kill('SIGKILL') + } +}) diff --git a/.claude/hooks/fleet/stale-process-sweeper/tsconfig.json b/.claude/hooks/fleet/stale-process-sweeper/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/stale-process-sweeper/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/sweep-ds-store/README.md b/.claude/hooks/fleet/sweep-ds-store/README.md similarity index 100% rename from .claude/hooks/sweep-ds-store/README.md rename to .claude/hooks/fleet/sweep-ds-store/README.md diff --git a/.claude/hooks/sweep-ds-store/index.mts b/.claude/hooks/fleet/sweep-ds-store/index.mts similarity index 100% rename from .claude/hooks/sweep-ds-store/index.mts rename to .claude/hooks/fleet/sweep-ds-store/index.mts diff --git a/.claude/hooks/sweep-ds-store/package.json b/.claude/hooks/fleet/sweep-ds-store/package.json similarity index 100% rename from .claude/hooks/sweep-ds-store/package.json rename to .claude/hooks/fleet/sweep-ds-store/package.json diff --git a/.claude/hooks/sweep-ds-store/test/index.test.mts b/.claude/hooks/fleet/sweep-ds-store/test/index.test.mts similarity index 100% rename from .claude/hooks/sweep-ds-store/test/index.test.mts rename to .claude/hooks/fleet/sweep-ds-store/test/index.test.mts diff --git a/.claude/hooks/fleet/sweep-ds-store/tsconfig.json b/.claude/hooks/fleet/sweep-ds-store/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/sweep-ds-store/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/token-guard/README.md b/.claude/hooks/fleet/token-guard/README.md similarity index 100% rename from .claude/hooks/token-guard/README.md rename to .claude/hooks/fleet/token-guard/README.md diff --git a/.claude/hooks/token-guard/index.mts b/.claude/hooks/fleet/token-guard/index.mts similarity index 100% rename from .claude/hooks/token-guard/index.mts rename to .claude/hooks/fleet/token-guard/index.mts diff --git a/.claude/hooks/token-guard/package.json b/.claude/hooks/fleet/token-guard/package.json similarity index 100% rename from .claude/hooks/token-guard/package.json rename to .claude/hooks/fleet/token-guard/package.json diff --git a/.claude/hooks/token-guard/test/token-guard.test.mts b/.claude/hooks/fleet/token-guard/test/token-guard.test.mts similarity index 100% rename from .claude/hooks/token-guard/test/token-guard.test.mts rename to .claude/hooks/fleet/token-guard/test/token-guard.test.mts diff --git a/.claude/hooks/fleet/token-guard/tsconfig.json b/.claude/hooks/fleet/token-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/token-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/trust-downgrade-guard/README.md b/.claude/hooks/fleet/trust-downgrade-guard/README.md new file mode 100644 index 0000000..1f4f4b7 --- /dev/null +++ b/.claude/hooks/fleet/trust-downgrade-guard/README.md @@ -0,0 +1,58 @@ +# trust-downgrade-guard + +PreToolUse hook. Blocks any action that **weakens a supply-chain trust gate** +unless the user typed `Allow trust-downgrade bypass` — and the bypass is +**single-use, never persisted**. + +## What it blocks + +**Bash commands** that relax a policy at invocation time: + +- `--config.trustPolicy=trust-all` (or any non-`no-downgrade` value) +- `--config.minimumReleaseAge=0` +- `--no-verify-store-integrity` +- `--dangerously-allow-all-scripts` / `--dangerously-allow-all-builds` +- `--config.dangerously*=true` +- `ignore-scripts=false` + +**Edit/Write** to a policy file (`pnpm-workspace.yaml`, `.npmrc`) that: + +- sets `trustPolicy` to anything but `no-downgrade` +- lowers `minimumReleaseAge` below the fleet floor (10080) +- rewrites `pnpm-workspace.yaml` without `trustPolicy: no-downgrade` or + `blockExoticSubdeps: true` + +## Single-use bypass + +`Allow trust-downgrade bypass` authorizes exactly **one** downgrade. The guard +counts prior downgrade actions in the assistant tool-use history (mirrors +`release-workflow-guard`'s per-dispatch model) and requires an unconsumed phrase +occurrence. A persisted bypass — an env var, or a phrase that opens the door for +every future downgrade — is *itself* a trust downgrade, so it's disallowed by +design. Each downgrade needs its own freshly-typed phrase. + +## The right fix instead of a downgrade + +A stale lockfile rejected by `no-downgrade` (e.g. after bumping a dep whose old +version lost provenance) is fixed by **adding the soak / exclude entry for the +specific version and re-resolving** — never by disabling the policy. + +## Why + +Incident 2026-05-27: an agent ran `pnpm install --config.trustPolicy=trust-all` +to force a lockfile refresh past a stale-entry rejection, disabling package- +takeover protection to make a command succeed. CLAUDE.md "Never weaken a +supply-chain trust gate" states the rule; this hook enforces it. + +## Config + +- Disable: `SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED=1` — note this env var is + itself a persisted downgrade; it exists only for this hook's test harness and + emergency wedged-session recovery. + +## Related + +- `minimum-release-age-guard` / `soak-exclude-date-annotation-guard` — the soak side. +- `check-new-deps` — Socket-scores new deps at edit time. +- `release-workflow-guard` — the single-use-bypass pattern this mirrors. +- CLAUDE.md → "Never weaken a supply-chain trust gate". diff --git a/.claude/hooks/fleet/trust-downgrade-guard/index.mts b/.claude/hooks/fleet/trust-downgrade-guard/index.mts new file mode 100644 index 0000000..bf3eb5f --- /dev/null +++ b/.claude/hooks/fleet/trust-downgrade-guard/index.mts @@ -0,0 +1,323 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — trust-downgrade-guard. +// +// Blocks any action that WEAKENS a supply-chain trust gate unless the +// user has typed `Allow trust-downgrade bypass` — and the bypass is +// SINGLE-USE, never persisted (each prior downgrade this session +// consumes one phrase occurrence, like release-workflow-guard's +// per-dispatch model). +// +// Two trigger surfaces: +// +// 1. Bash commands that relax a policy at invocation time: +// - `--config.trustPolicy=trust-all` (or any non-`no-downgrade` +// value): disables pnpm's package-takeover protection. +// - `--config.minimumReleaseAge=0` / `--no-verify-store-integrity` +// / `--config.dangerouslyAllowAllBuilds` style relaxations. +// - npm `--dangerously-allow-all-scripts`, `ignore-scripts=false` +// flips on install. +// +// 2. Edit/Write that weakens a policy file: +// - removing or downgrading `trustPolicy: no-downgrade` in +// pnpm-workspace.yaml (to `trust-all` / `trust` / deleting it). +// - deleting `blockExoticSubdeps: true`. +// - lowering `minimumReleaseAge` below the fleet floor (10080). +// +// Why this exists (incident 2026-05-27): an agent ran +// `pnpm install --config.trustPolicy=trust-all` to force a lockfile +// refresh past a stale-entry rejection — disabling the no-downgrade +// takeover protection to make a command succeed. The correct fix was +// to add the soak/exclude entry and re-resolve, never to relax the +// policy. CLAUDE.md "Never weaken a supply-chain trust gate" states +// the rule; this hook enforces it. +// +// Single-use bypass rationale: a persisted bypass (env var, or a phrase +// that authorizes every future downgrade in the session) is itself a +// trust downgrade. Each downgrade must be individually authorized. +// +// Exit codes: +// 2 — blocked (a trust downgrade without an unconsumed bypass phrase). +// 0 — allowed (not a downgrade, or an unconsumed bypass is present), +// and on any hook error (fail-open + stderr log). +// +// Disabled via `SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED=1` — note this env +// var ITSELF is a persisted trust downgrade; it exists only for the +// hook's own test harness and emergency wedged-session recovery. +// +// Reads a PreToolUse JSON payload from stdin: +// { "tool_name": "Bash" | "Edit" | "Write" | "MultiEdit", +// "tool_input": { "command"? , "file_path"?, "content"?, "new_string"? }, +// "transcript_path": "/.../session.jsonl" } + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { bypassPhraseRemaining, readStdin } from '../_shared/transcript.mts' + +interface Payload { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly command?: unknown | undefined + readonly file_path?: unknown | undefined + readonly content?: unknown | undefined + readonly new_string?: unknown | undefined + } + | undefined + readonly transcript_path?: string | undefined +} + +const ENV_DISABLE = 'SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED' +const BYPASS_PHRASE = 'Allow trust-downgrade bypass' + +// Fleet minimumReleaseAge floor (minutes) — 7 days. A lower value is a +// downgrade. +const MIN_RELEASE_AGE_FLOOR = 10080 + +// Bash-command patterns that relax a trust gate at invocation time. +// Matched against the raw command; these are flag shapes, not command +// structure, so a regex match is the right tool (a flag can't be +// "hidden" behind shell indirection the way a binary name can — the +// flag string has to appear literally for pnpm/npm to parse it). +const BASH_DOWNGRADE_PATTERNS: ReadonlyArray<{ re: RegExp; label: string }> = [ + { + re: /--config\.trustPolicy[=\s]+(?!no-downgrade\b)\S+/i, + label: 'trustPolicy override to a value other than no-downgrade', + }, + { + re: /--config\.minimumReleaseAge[=\s]+0\b/i, + label: 'minimumReleaseAge override to 0', + }, + { + re: /--no-verify-store-integrity\b/i, + label: '--no-verify-store-integrity', + }, + { + re: /--dangerously-allow-all-(?:scripts|builds)\b/i, + label: '--dangerously-allow-all-* escape hatch', + }, + { + re: /--config\.dangerously\S*=\s*true\b/i, + label: '--config.dangerously* = true', + }, + { + re: /(?:^|\s)--?ignore-scripts[=\s]+false\b/i, + label: 'ignore-scripts=false', + }, +] + +export function detectBashDowngrade(command: string): string | undefined { + for (let i = 0, { length } = BASH_DOWNGRADE_PATTERNS; i < length; i += 1) { + const { re, label } = BASH_DOWNGRADE_PATTERNS[i]! + if (re.test(command)) { + return label + } + } + return undefined +} + +// Is the edited file a supply-chain policy file we gate? +function isPolicyFile(filePath: string): boolean { + const base = path.basename(filePath) + return base === 'pnpm-workspace.yaml' || base === '.npmrc' +} + +// Inspect the NEW text an Edit/Write would write. We can only see the +// replacement fragment (Edit `new_string`) or full `content` (Write), +// not the resulting whole file — so we flag the *removal/weakening +// shapes* that appear in the new text, and (for Write) the absence of +// the no-downgrade line when the file is being rewritten wholesale. +export function detectEditDowngrade( + toolName: string, + filePath: string, + newText: string, + fullContent: string | undefined, +): string | undefined { + if (!isPolicyFile(filePath)) { + return undefined + } + // A fragment that sets trustPolicy to a non-no-downgrade value. + if (/trustPolicy\s*:\s*(?!no-downgrade\b)\S+/i.test(newText)) { + return 'trustPolicy set to a value other than no-downgrade' + } + // Lowering minimumReleaseAge below the floor. + const m = /minimumReleaseAge\s*:\s*(\d+)/i.exec(newText) + if (m && Number(m[1]) < MIN_RELEASE_AGE_FLOOR) { + return `minimumReleaseAge lowered below the ${MIN_RELEASE_AGE_FLOOR} floor` + } + // A wholesale Write of pnpm-workspace.yaml that drops the + // no-downgrade line entirely is a downgrade (the gate vanishes). + if ( + (toolName === 'Write' || fullContent !== undefined) && + path.basename(filePath) === 'pnpm-workspace.yaml' + ) { + const body = fullContent ?? newText + if (body && !/trustPolicy\s*:\s*no-downgrade\b/i.test(body)) { + return 'pnpm-workspace.yaml rewritten without `trustPolicy: no-downgrade`' + } + } + // Deleting blockExoticSubdeps — visible only if the Edit's new_string + // shows the surrounding region without it is not detectable from a + // fragment alone; a Write can be checked. + if ( + (toolName === 'Write' || fullContent !== undefined) && + path.basename(filePath) === 'pnpm-workspace.yaml' + ) { + const body = fullContent ?? newText + if (body && !/blockExoticSubdeps\s*:\s*true\b/i.test(body)) { + return 'pnpm-workspace.yaml rewritten without `blockExoticSubdeps: true`' + } + } + return undefined +} + +// Count prior trust-downgrade actions in the assistant tool-use history +// — each consumes one bypass-phrase occurrence (single-use semantics). +// Mirrors release-workflow-guard's countPriorDispatches. +export function countPriorDowngrades( + transcriptPath: string | undefined, +): number { + if (!transcriptPath) { + return 0 + } + let raw: string + try { + raw = readFileSync(transcriptPath, 'utf8') + } catch { + return 0 + } + let count = 0 + for (const line of raw.split('\n')) { + if (!line) { + continue + } + let evt: unknown + try { + evt = JSON.parse(line) + } catch { + continue + } + if ( + !evt || + typeof evt !== 'object' || + (evt as Record<string, unknown>)['type'] !== 'assistant' + ) { + continue + } + const msg = (evt as { message?: unknown }).message + const content = + msg && typeof msg === 'object' + ? (msg as { content?: unknown }).content + : undefined + if (!Array.isArray(content)) { + continue + } + for (let i = 0, { length } = content; i < length; i += 1) { + const part = content[i]! + if (!part || typeof part !== 'object') { + continue + } + const name = (part as { name?: unknown }).name + const input = (part as { input?: unknown }).input + if (typeof name !== 'string' || !input || typeof input !== 'object') { + continue + } + const inp = input as Record<string, unknown> + if (name === 'Bash' && typeof inp['command'] === 'string') { + if (detectBashDowngrade(inp['command'])) { + count += 1 + } + } else if ( + (name === 'Edit' || name === 'Write' || name === 'MultiEdit') && + typeof inp['file_path'] === 'string' + ) { + const newText = + (typeof inp['new_string'] === 'string' ? inp['new_string'] : '') || + (typeof inp['content'] === 'string' ? inp['content'] : '') + const fullContent = + typeof inp['content'] === 'string' ? inp['content'] : undefined + if (detectEditDowngrade(name, inp['file_path'], newText, fullContent)) { + count += 1 + } + } + } + } + return count +} + +async function main(): Promise<void> { + if (process.env[ENV_DISABLE]) { + process.exit(0) + } + const raw = await readStdin() + let payload: Payload + try { + payload = JSON.parse(raw) as Payload + } catch { + process.exit(0) + } + + const tool = payload.tool_name + const input = payload.tool_input + let downgrade: string | undefined + + if (tool === 'Bash') { + const command = input?.command + if (typeof command === 'string' && command.trim()) { + downgrade = detectBashDowngrade(command) + } + } else if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') { + const filePath = input?.file_path + if (typeof filePath === 'string' && filePath) { + const newText = + (typeof input?.new_string === 'string' ? input.new_string : '') || + (typeof input?.content === 'string' ? input.content : '') + const fullContent = + typeof input?.content === 'string' ? input.content : undefined + downgrade = detectEditDowngrade(tool, filePath, newText, fullContent) + } + } + + if (!downgrade) { + process.exit(0) + } + + // Single-use bypass: total phrase occurrences minus prior downgrades + // already performed this session. > 0 means an unconsumed phrase + // authorizes THIS one. + const prior = countPriorDowngrades(payload.transcript_path) + const remaining = bypassPhraseRemaining( + payload.transcript_path, + BYPASS_PHRASE, + prior, + ) + if (remaining > 0) { + process.exit(0) + } + + process.stderr.write( + [ + `[trust-downgrade-guard] Blocked: ${downgrade}`, + '', + ' This WEAKENS a supply-chain trust gate (package-takeover /', + ' malicious-install protection). Disabling the policy to make a', + ' command succeed is never the fix.', + '', + ' If a stale lockfile is being rejected: add the soak / exclude', + ' entry for the specific version and re-resolve — keep the policy.', + '', + ` Bypass (single-use, NOT persisted): the user types`, + ` "${BYPASS_PHRASE}"`, + ' verbatim in chat, then retry. Each downgrade needs its own phrase.', + ].join('\n') + '\n', + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[trust-downgrade-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/trust-downgrade-guard/package.json b/.claude/hooks/fleet/trust-downgrade-guard/package.json new file mode 100644 index 0000000..0baf265 --- /dev/null +++ b/.claude/hooks/fleet/trust-downgrade-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-trust-downgrade-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts b/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts new file mode 100644 index 0000000..37680e2 --- /dev/null +++ b/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts @@ -0,0 +1,208 @@ +/** + * @file Unit tests for trust-downgrade-guard hook. + * + * Spawns the hook as a child process with synthesized PreToolUse payloads. + * Covers Bash + Edit/Write downgrade detection, single-use bypass + * consumption, the disabled env var, and fail-open. + */ + +import assert from 'node:assert/strict' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, test } from 'node:test' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(__dirname, '..', 'index.mts') + +interface RunResult { + readonly code: number + readonly stderr: string +} + +function run(payload: object, env?: Record<string, string>): RunResult { + const r = spawnSync('node', [HOOK], { + input: JSON.stringify(payload), + env: { ...process.env, ...(env ?? {}) }, + }) + return { + code: typeof r.status === 'number' ? r.status : 0, + stderr: String(r.stderr || ''), + } +} + +function bash(command: string, transcriptPath?: string): object { + return { + tool_name: 'Bash', + tool_input: { command }, + transcript_path: transcriptPath, + } +} + +function edit(filePath: string, newString: string): object { + return { + tool_name: 'Edit', + tool_input: { file_path: filePath, new_string: newString }, + } +} + +function write(filePath: string, content: string): object { + return { tool_name: 'Write', tool_input: { file_path: filePath, content } } +} + +// A transcript whose assistant turns contain `priorDowngrades` prior +// trust-all Bash calls, plus `phrases` user occurrences of the bypass. +function writeTranscript(opts: { + priorDowngrades?: number + phrases?: number +}): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'tdguard-tx-')) + const p = path.join(dir, 'session.jsonl') + const lines: string[] = [] + for (let i = 0; i < (opts.phrases ?? 0); i += 1) { + lines.push( + JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'Allow trust-downgrade bypass' }, + }), + ) + } + for (let i = 0; i < (opts.priorDowngrades ?? 0); i += 1) { + lines.push( + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Bash', + input: { command: 'pnpm install --config.trustPolicy=trust-all' }, + }, + ], + }, + }), + ) + } + writeFileSync(p, lines.join('\n')) + return p +} + +let tmp: string + +beforeEach(() => { + tmp = mkdtempSync(path.join(os.tmpdir(), 'tdguard-repo-')) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +// ─── Bash downgrade detection ───────────────────────────────────── + +test('blocks --config.trustPolicy=trust-all', () => { + const r = run(bash('pnpm install --config.trustPolicy=trust-all')) + assert.equal(r.code, 2) + assert.match(r.stderr, /Blocked/) + assert.match(r.stderr, /trustPolicy/) +}) + +test('blocks --config.minimumReleaseAge=0', () => { + const r = run(bash('pnpm install --config.minimumReleaseAge=0')) + assert.equal(r.code, 2) +}) + +test('blocks --dangerously-allow-all-scripts', () => { + const r = run(bash('npm ci --dangerously-allow-all-scripts')) + assert.equal(r.code, 2) +}) + +test('blocks ignore-scripts=false', () => { + const r = run(bash('npm install --ignore-scripts=false')) + assert.equal(r.code, 2) +}) + +test('allows --config.trustPolicy=no-downgrade (not a downgrade)', () => { + const r = run(bash('pnpm install --config.trustPolicy=no-downgrade')) + assert.equal(r.code, 0) +}) + +test('allows an ordinary pnpm install', () => { + const r = run(bash('pnpm install')) + assert.equal(r.code, 0) +}) + +// ─── Edit/Write downgrade detection ─────────────────────────────── + +test('blocks Edit setting trustPolicy to trust-all', () => { + const f = path.join(tmp, 'pnpm-workspace.yaml') + const r = run(edit(f, 'trustPolicy: trust-all')) + assert.equal(r.code, 2) +}) + +test('blocks Write of pnpm-workspace.yaml missing no-downgrade', () => { + const f = path.join(tmp, 'pnpm-workspace.yaml') + const r = run(write(f, 'packages:\n - .\nblockExoticSubdeps: true\n')) + assert.equal(r.code, 2) +}) + +test('allows Write of pnpm-workspace.yaml that keeps the gates', () => { + const f = path.join(tmp, 'pnpm-workspace.yaml') + const r = run( + write(f, 'trustPolicy: no-downgrade\nblockExoticSubdeps: true\n'), + ) + assert.equal(r.code, 0) +}) + +test('blocks lowering minimumReleaseAge below the floor', () => { + const f = path.join(tmp, 'pnpm-workspace.yaml') + const r = run(edit(f, 'minimumReleaseAge: 60')) + assert.equal(r.code, 2) +}) + +test('ignores edits to non-policy files', () => { + const f = path.join(tmp, 'README.md') + const r = run(edit(f, 'trustPolicy: trust-all (just docs prose)')) + assert.equal(r.code, 0) +}) + +// ─── Single-use bypass ──────────────────────────────────────────── + +test('one unconsumed phrase authorizes one downgrade', () => { + const tx = writeTranscript({ phrases: 1, priorDowngrades: 0 }) + const r = run(bash('pnpm install --config.trustPolicy=trust-all', tx)) + assert.equal(r.code, 0) +}) + +test('a phrase already consumed by a prior downgrade does not authorize a second', () => { + const tx = writeTranscript({ phrases: 1, priorDowngrades: 1 }) + const r = run(bash('pnpm install --config.trustPolicy=trust-all', tx)) + assert.equal(r.code, 2) +}) + +test('two phrases authorize two downgrades (one prior, one now)', () => { + const tx = writeTranscript({ phrases: 2, priorDowngrades: 1 }) + const r = run(bash('pnpm install --config.trustPolicy=trust-all', tx)) + assert.equal(r.code, 0) +}) + +// ─── Disable + fail-open ────────────────────────────────────────── + +test('disabled via env var', () => { + const r = run(bash('pnpm install --config.trustPolicy=trust-all'), { + SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED: '1', + }) + assert.equal(r.code, 0) +}) + +test('fails open on malformed payload', () => { + const r = spawnSync('node', [HOOK], { input: 'not json', env: process.env }) + assert.equal(typeof r.status === 'number' ? r.status : 0, 0) +}) + +test('non-gated tool is ignored', () => { + const r = run({ tool_name: 'Read', tool_input: { file_path: '/x' } }) + assert.equal(r.code, 0) +}) diff --git a/.claude/hooks/fleet/trust-downgrade-guard/tsconfig.json b/.claude/hooks/fleet/trust-downgrade-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/trust-downgrade-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/README.md b/.claude/hooks/fleet/uses-sha-verify-guard/README.md new file mode 100644 index 0000000..c0da7e2 --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/README.md @@ -0,0 +1,33 @@ +# uses-sha-verify-guard + +PreToolUse hook that blocks Edit/Write tool calls introducing GitHub URL pins that aren't full 40-char SHAs reachable in their referenced repo. + +## What it enforces + +Every GitHub URL pin across the fleet needs a full 40-char commit SHA that resolves. Truncated SHAs (`3d33ecebbb` — 10 chars), version tags (`v1.2.3`), branch names (`main`), and SHAs that don't resolve via `gh api repos/<owner>/<repo>/commits/<sha>` are all blocked. + +Three surfaces: + +| Surface | Required pin shape | +| --- | --- | +| `.github/workflows/*.yml` + `.github/actions/*/action.yml` | `uses: <owner>/<repo>(/<path>)?@<40-hex>` | +| `.gitmodules` | BOTH `# <name>-<version> sha256:<64-hex>` comment AND `ref = <40-hex>` field per `[submodule]` block | +| `package.json` | `git+https://github.com/<owner>/<repo>(.git)?#<40-hex>` for any GitHub-URL dep specifier | + +The `.gitmodules` content-hash (`sha256:`) and the `ref =` (commit SHA) are both required — the comment is the upstream-archive content-hash pin (drift-watch signal); the `ref` is what `git submodule update` checks out. + +## Why a hook + +Typing a truncated SHA into a `uses:` line is a silent fail. The action resolver may quietly succeed against a "close enough" ref, or fail at runtime in CI long after the bad edit landed. The hook catches it at edit time, before the bad pin reaches the commit. It's a companion to `gitmodules-comment-guard` (which enforces the `# <name>-<version>` shape but not SHA correctness). + +## Caching + +`gh api` results are cached at `~/.claude/uses-sha-verify-cache.json` keyed by `<owner>/<repo>@<sha>` with a 7-day TTL. A SHA reachable yesterday is reachable today; re-querying every edit is wasteful and rate-limit-prone. + +## Bypass + +Type the canonical phrase `Allow uses-sha-verify bypass` verbatim in a recent user turn. Per the fleet bypass-phrase convention. + +## Fail-open + +The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/index.mts b/.claude/hooks/fleet/uses-sha-verify-guard/index.mts new file mode 100644 index 0000000..6624908 --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/index.mts @@ -0,0 +1,427 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — uses-sha-verify-guard. +// +// Every GitHub URL pin in fleet repos needs a full 40-char SHA that +// resolves in the referenced repo. Blocks Edit/Write tool calls that +// introduce SHA pins that are: +// 1. Truncated (less than 40 hex chars for commit SHAs; less than +// 64 hex chars for content-hash sha256: pins). +// 2. Not actually hex (version tags like `v1.2.3`, branch names +// like `main`, partial SHAs). +// 3. Real-length but not reachable in the referenced repo (via +// `gh api repos/<owner>/<repo>/commits/<sha>`). +// 4. Missing from a `.gitmodules` submodule block (BOTH the +// `# <name>-<version> sha256:<64hex>` comment AND the +// `ref = <40hex>` field are required). +// +// Three surfaces: +// +// A. `.github/workflows/*.yml` + `.github/actions/*/action.yml`: +// Every `uses: <owner>/<repo>(?:/<path>)?@<ref>` must have a full +// 40-char hex `<ref>` that resolves. +// +// B. `.gitmodules` at the repo root: +// Every `[submodule "..."]` block MUST carry BOTH a +// `# <name>-<version> sha256:<64hex>` header comment AND a +// `ref = <40hex>` field. +// +// C. `package.json`: +// Every `git+https://github.com/<owner>/<repo>(?:\.git)?#<ref>` +// dep specifier in `dependencies`, `devDependencies`, +// `peerDependencies`, `optionalDependencies`, `overrides`, or +// `resolutions` must have a full 40-char hex `<ref>`. +// +// Companion to `gitmodules-comment-guard` (which enforces the +// `# <name>-<version>` shape but not SHA validity). Caching via +// `~/.claude/uses-sha-verify-cache.json` keyed by `<repo>@<sha>` +// with a 7-day TTL. +// +// Bypass: `Allow uses-sha-verify bypass`. +// +// Exits: +// 0 — allowed (not a tracked file, all SHAs verify, OR bypass). +// 2 — blocked (stderr explains which pin failed + how to fix). +// 0 (with stderr log) — fail-open on hook bugs. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +const BYPASS_PHRASE = 'Allow uses-sha-verify bypass' + +const CACHE_FILE = path.join( + os.homedir(), + '.claude', + 'uses-sha-verify-cache.json', +) +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +interface Hook { + tool_name?: string | undefined + tool_input?: + | { + file_path?: string | undefined + new_string?: string | undefined + content?: string | undefined + } + | undefined + transcript_path?: string | undefined +} + +interface CacheEntry { + reachable: boolean + checkedAt: number +} + +interface Cache { + entries: Record<string, CacheEntry> +} + +function loadCache(): Cache { + if (!existsSync(CACHE_FILE)) { + return { entries: {} } + } + try { + const parsed = JSON.parse(readFileSync(CACHE_FILE, 'utf8')) as Cache + if (!parsed || typeof parsed !== 'object' || !parsed.entries) { + return { entries: {} } + } + return parsed + } catch { + return { entries: {} } + } +} + +function saveCache(cache: Cache): void { + try { + mkdirSync(path.dirname(CACHE_FILE), { recursive: true }) + writeFileSync(CACHE_FILE, JSON.stringify(cache), 'utf8') + } catch { + // best-effort + } +} + +// Verify a commit SHA against `gh api repos/<owner>/<repo>/commits/<sha>`. +// Cached for 7 days; a previously-reachable SHA stays reachable. +export function verifyCommitSha( + ownerRepo: string, + sha: string, + cache: Cache, +): boolean { + const key = `${ownerRepo}@${sha}` + const entry = cache.entries[key] + if (entry && Date.now() - entry.checkedAt < CACHE_TTL_MS) { + return entry.reachable + } + const result = spawnSync( + 'gh', + ['api', `repos/${ownerRepo}/commits/${sha}`, '--silent'], + { stdio: 'ignore', timeout: 5000 }, + ) + const reachable = result.status === 0 + cache.entries[key] = { reachable, checkedAt: Date.now() } + return reachable +} + +// Match `uses: <owner>/<repo>(/<path>)?@<ref>`. Tolerates leading +// whitespace, list dash (`- uses:`), and trailing comments. +const USES_RE = + /^\s*(?:-\s+)?uses:\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_./-]+)?)@([^\s#]+)/ + +// Match `# <name>-<version> sha256:<hex>` header. +const GITMODULES_HEADER_RE = + /^#\s+[a-z0-9]+(?:[a-z0-9.-]*[a-z0-9])?-[^\s]+\s+sha256:([0-9a-f]+)/ + +// Match `ref = <hex>` inside a submodule block. +const GITMODULES_REF_RE = /^\s*ref\s*=\s*([0-9a-f]+)\s*$/ + +// Match `[submodule "PATH"]`. +const SUBMODULE_OPEN_RE = /^\s*\[submodule\s+"([^"]+)"\s*\]\s*$/ + +// Match `git+https://github.com/<owner>/<repo>(.git)?#<ref>` in JSON. +// Captures owner/repo and ref. Tolerates quoting around the URL value. +const PACKAGE_JSON_GITHUB_RE = + /git\+https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?#([^"]+)/g + +interface UsesIssue { + line: number + raw: string + problem: string +} + +export function findUsesIssues(content: string, cache: Cache): UsesIssue[] { + const issues: UsesIssue[] = [] + const lines = content.split('\n') + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + const m = USES_RE.exec(line) + if (!m) { + continue + } + const ownerRepoPath = m[1]! + const ref = m[2]! + const ownerRepo = ownerRepoPath.split('/').slice(0, 2).join('/') + if (!/^[0-9a-f]{40}$/i.test(ref)) { + issues.push({ + line: i + 1, + raw: line.trim(), + problem: /^[0-9a-f]+$/i.test(ref) + ? `truncated SHA (${ref.length} hex chars, need exactly 40)` + : `not a SHA pin (got "${ref}"; fleet requires full 40-char hex)`, + }) + continue + } + if (!verifyCommitSha(ownerRepo, ref, cache)) { + issues.push({ + line: i + 1, + raw: line.trim(), + problem: `SHA ${ref.slice(0, 10)}… not reachable in ${ownerRepo} (gh api 404). Either the SHA was mistyped or the repo is private and gh isn't authed for it.`, + }) + } + } + return issues +} + +interface SubmoduleIssue { + submodule: string + line: number + problem: string +} + +export function findGitmodulesIssues(content: string): SubmoduleIssue[] { + const issues: SubmoduleIssue[] = [] + const lines = content.split('\n') + + interface Block { + name: string + startLine: number + headerCommentSha: string | undefined + refSha: string | undefined + } + const blocks: Block[] = [] + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + const open = SUBMODULE_OPEN_RE.exec(line) + if (!open) { + continue + } + const name = open[1]! + let headerSha: string | undefined + for (let j = i - 1; j >= 0; j -= 1) { + const prev = lines[j]! + if (prev.trim() === '' || SUBMODULE_OPEN_RE.test(prev)) { + break + } + const headerMatch = GITMODULES_HEADER_RE.exec(prev) + if (headerMatch) { + headerSha = headerMatch[1] + break + } + } + let refSha: string | undefined + for (let j = i + 1; j < lines.length; j += 1) { + const next = lines[j]! + if (/^\s*\[/.test(next)) { + break + } + const refMatch = GITMODULES_REF_RE.exec(next) + if (refMatch) { + refSha = refMatch[1] + break + } + } + blocks.push({ name, startLine: i + 1, headerCommentSha: headerSha, refSha }) + } + + for (const block of blocks) { + if (!block.headerCommentSha) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: + 'missing `# <name>-<version> sha256:<64hex>` comment above the [submodule] block (content-hash pin required)', + }) + } else if (!/^[0-9a-f]{64}$/.test(block.headerCommentSha)) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: `header comment sha256 must be exactly 64 hex chars; got ${block.headerCommentSha.length}`, + }) + } + if (!block.refSha) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: + 'missing `ref = <40hex>` field inside the [submodule] block (commit-SHA pin required)', + }) + } else if (!/^[0-9a-f]{40}$/.test(block.refSha)) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: `ref must be exactly 40 hex chars; got ${block.refSha.length}`, + }) + } + } + return issues +} + +interface PackageJsonIssue { + ownerRepo: string + ref: string + problem: string +} + +export function findPackageJsonIssues( + content: string, + cache: Cache, +): PackageJsonIssue[] { + const issues: PackageJsonIssue[] = [] + PACKAGE_JSON_GITHUB_RE.lastIndex = 0 + let match: RegExpExecArray | null = PACKAGE_JSON_GITHUB_RE.exec(content) + while (match) { + const ownerRepo = match[1]! + const ref = match[2]! + if (!/^[0-9a-f]{40}$/i.test(ref)) { + issues.push({ + ownerRepo, + ref, + problem: /^[0-9a-f]+$/i.test(ref) + ? `truncated SHA (${ref.length} hex chars, need exactly 40)` + : `not a SHA pin (got "${ref}"; fleet requires full 40-char hex)`, + }) + } else if (!verifyCommitSha(ownerRepo, ref, cache)) { + issues.push({ + ownerRepo, + ref, + problem: `SHA ${ref.slice(0, 10)}… not reachable in ${ownerRepo} (gh api 404).`, + }) + } + match = PACKAGE_JSON_GITHUB_RE.exec(content) + } + return issues +} + +function readBodyFromPayload(payload: Hook): string { + const ti = payload.tool_input + if (!ti) { + return '' + } + if (typeof ti.new_string === 'string') { + return ti.new_string + } + if (typeof ti.content === 'string') { + return ti.content + } + return '' +} + +function isWorkflowOrActionPath(filePath: string): boolean { + return ( + /\.github\/workflows\/[^/]+\.ya?ml$/.test(filePath) || + /\.github\/actions\/[^/]+\/action\.ya?ml$/.test(filePath) + ) +} + +function isGitmodulesPath(filePath: string): boolean { + return filePath.endsWith('/.gitmodules') || filePath === '.gitmodules' +} + +function isPackageJsonPath(filePath: string): boolean { + // Match repo-root package.json AND nested workspace package.json files. + // Excludes node_modules paths. + if (filePath.includes('/node_modules/')) { + return false + } + return ( + filePath.endsWith('/package.json') || + filePath === 'package.json' + ) +} + +async function main(): Promise<void> { + const raw = await readStdin() + let payload: Hook + try { + payload = raw ? JSON.parse(raw) : {} + } catch { + process.exit(0) + } + const toolName = payload.tool_name + if (toolName !== 'Edit' && toolName !== 'Write' && toolName !== 'MultiEdit') { + process.exit(0) + } + const filePath = payload.tool_input?.file_path ?? '' + if (!filePath) { + process.exit(0) + } + const isUses = isWorkflowOrActionPath(filePath) + const isGitmodules = isGitmodulesPath(filePath) + const isPackageJson = isPackageJsonPath(filePath) + if (!isUses && !isGitmodules && !isPackageJson) { + process.exit(0) + } + + const body = readBodyFromPayload(payload) + if (!body) { + process.exit(0) + } + + const cache = loadCache() + const usesIssues = isUses ? findUsesIssues(body, cache) : [] + const gitmodulesIssues = isGitmodules ? findGitmodulesIssues(body) : [] + const packageJsonIssues = isPackageJson ? findPackageJsonIssues(body, cache) : [] + saveCache(cache) + + if ( + usesIssues.length === 0 && + gitmodulesIssues.length === 0 && + packageJsonIssues.length === 0 + ) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + const out: string[] = [ + 'uses-sha-verify-guard: SHA pin verification failed', + '', + ] + for (const issue of usesIssues) { + out.push(` ${filePath}:${issue.line}`) + out.push(` ${issue.raw}`) + out.push(` ↳ ${issue.problem}`) + out.push('') + } + for (const issue of gitmodulesIssues) { + out.push(` ${filePath}:${issue.line} [submodule "${issue.submodule}"]`) + out.push(` ↳ ${issue.problem}`) + out.push('') + } + for (const issue of packageJsonIssues) { + out.push(` ${filePath}: git+https://github.com/${issue.ownerRepo}#${issue.ref}`) + out.push(` ↳ ${issue.problem}`) + out.push('') + } + out.push('Fix the pin(s) above, or bypass with the canonical phrase:') + out.push(` ${BYPASS_PHRASE}`) + process.stderr.write(`${out.join('\n')}\n`) + process.exit(2) +} + +main().catch(err => { + // Fail-open on hook bugs. + process.stderr.write( + `uses-sha-verify-guard: hook crashed, failing open: ${err instanceof Error ? err.message : String(err)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/package.json b/.claude/hooks/fleet/uses-sha-verify-guard/package.json new file mode 100644 index 0000000..6a5801f --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-uses-sha-verify-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + } +} diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts b/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts new file mode 100644 index 0000000..655d59a --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts @@ -0,0 +1,161 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function runHook(payload: object): { stderr: string; exitCode: number } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify(payload), + encoding: 'utf8', + }) + return { stderr: result.stderr ?? '', exitCode: result.status ?? -1 } +} + +// ------- workflow / action: uses: pin ------- + +test('BLOCKS workflow `uses:` with truncated SHA', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.github/workflows/ci.yml', + content: + 'jobs:\n job:\n steps:\n - uses: actions/checkout@abc123\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /uses-sha-verify-guard/) + assert.match(stderr, /truncated SHA/) +}) + +test('BLOCKS workflow `uses:` with version tag', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.github/workflows/ci.yml', + content: ' - uses: actions/checkout@v4\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /not a SHA pin/) +}) + +test('IGNORES file outside .github/workflows/ + .github/actions/', () => { + const { exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/README.md', + content: ' - uses: actions/checkout@v4\n', + }, + }) + assert.equal(exitCode, 0) +}) + +test('IGNORES non-Edit/Write tools', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'git status' }, + }) + assert.equal(exitCode, 0) +}) + +// ------- .gitmodules: BOTH header + ref required ------- + +test('BLOCKS .gitmodules submodule missing both header + ref', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '[submodule "vendor/foo"]\n\tpath = vendor/foo\n\turl = https://github.com/owner/foo.git\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /missing.*sha256:<64hex>/) + assert.match(stderr, /missing `ref = <40hex>`/) +}) + +test('BLOCKS .gitmodules submodule with header but no ref', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '# foo-1.2.3 sha256:' + 'a'.repeat(64) + + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\turl = https://github.com/owner/foo.git\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /missing `ref = <40hex>`/) +}) + +test('BLOCKS .gitmodules header sha256 of wrong length', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '# foo-1.2.3 sha256:' + 'a'.repeat(32) + + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\tref = ' + 'b'.repeat(40) + '\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /sha256 must be exactly 64 hex chars/) +}) + +test('BLOCKS .gitmodules ref of wrong length', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '# foo-1.2.3 sha256:' + 'a'.repeat(64) + + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\tref = abc123\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /ref must be exactly 40 hex chars/) +}) + +// ------- package.json GitHub URL deps ------- + +test('BLOCKS package.json git+https://github.com URL with truncated SHA', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/package.json', + content: + '{"dependencies": {"foo": "git+https://github.com/owner/foo#abc123"}}', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /truncated SHA/) +}) + +test('BLOCKS package.json git+https://github.com URL with version tag', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/package.json', + content: + '{"dependencies": {"foo": "git+https://github.com/owner/foo.git#v1.2.3"}}', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /not a SHA pin/) +}) + +test('IGNORES node_modules/package.json', () => { + const { exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/node_modules/foo/package.json', + content: + '{"dependencies": {"x": "git+https://github.com/owner/x#abc"}}', + }, + }) + assert.equal(exitCode, 0) +}) diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/tsconfig.json b/.claude/hooks/fleet/uses-sha-verify-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/variant-analysis-reminder/README.md b/.claude/hooks/fleet/variant-analysis-reminder/README.md similarity index 100% rename from .claude/hooks/variant-analysis-reminder/README.md rename to .claude/hooks/fleet/variant-analysis-reminder/README.md diff --git a/.claude/hooks/variant-analysis-reminder/index.mts b/.claude/hooks/fleet/variant-analysis-reminder/index.mts similarity index 100% rename from .claude/hooks/variant-analysis-reminder/index.mts rename to .claude/hooks/fleet/variant-analysis-reminder/index.mts diff --git a/.claude/hooks/variant-analysis-reminder/package.json b/.claude/hooks/fleet/variant-analysis-reminder/package.json similarity index 100% rename from .claude/hooks/variant-analysis-reminder/package.json rename to .claude/hooks/fleet/variant-analysis-reminder/package.json diff --git a/.claude/hooks/variant-analysis-reminder/test/index.test.mts b/.claude/hooks/fleet/variant-analysis-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/variant-analysis-reminder/test/index.test.mts rename to .claude/hooks/fleet/variant-analysis-reminder/test/index.test.mts diff --git a/.claude/hooks/fleet/variant-analysis-reminder/tsconfig.json b/.claude/hooks/fleet/variant-analysis-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/variant-analysis-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/README.md b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/README.md similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/README.md rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/README.md diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/index.mts similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/index.mts rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/index.mts diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/package.json b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/package.json similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/package.json rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/package.json diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/test/index.test.mts diff --git a/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/tsconfig.json b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/version-bump-order-guard/README.md b/.claude/hooks/fleet/version-bump-order-guard/README.md similarity index 100% rename from .claude/hooks/version-bump-order-guard/README.md rename to .claude/hooks/fleet/version-bump-order-guard/README.md diff --git a/.claude/hooks/version-bump-order-guard/index.mts b/.claude/hooks/fleet/version-bump-order-guard/index.mts similarity index 100% rename from .claude/hooks/version-bump-order-guard/index.mts rename to .claude/hooks/fleet/version-bump-order-guard/index.mts diff --git a/.claude/hooks/version-bump-order-guard/package.json b/.claude/hooks/fleet/version-bump-order-guard/package.json similarity index 100% rename from .claude/hooks/version-bump-order-guard/package.json rename to .claude/hooks/fleet/version-bump-order-guard/package.json diff --git a/.claude/hooks/version-bump-order-guard/test/index.test.mts b/.claude/hooks/fleet/version-bump-order-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/version-bump-order-guard/test/index.test.mts rename to .claude/hooks/fleet/version-bump-order-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/version-bump-order-guard/tsconfig.json b/.claude/hooks/fleet/version-bump-order-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/version-bump-order-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/README.md b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/README.md similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/README.md rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/README.md diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/index.mts b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/index.mts similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/index.mts rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/index.mts diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/package.json b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/package.json similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/package.json rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/package.json diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/vitest-include-vs-node-test-guard/tsconfig.json b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/workflow-uses-comment-guard/README.md b/.claude/hooks/fleet/workflow-uses-comment-guard/README.md similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/README.md rename to .claude/hooks/fleet/workflow-uses-comment-guard/README.md diff --git a/.claude/hooks/workflow-uses-comment-guard/index.mts b/.claude/hooks/fleet/workflow-uses-comment-guard/index.mts similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/index.mts rename to .claude/hooks/fleet/workflow-uses-comment-guard/index.mts diff --git a/.claude/hooks/workflow-uses-comment-guard/package.json b/.claude/hooks/fleet/workflow-uses-comment-guard/package.json similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/package.json rename to .claude/hooks/fleet/workflow-uses-comment-guard/package.json diff --git a/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts b/.claude/hooks/fleet/workflow-uses-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/workflow-uses-comment-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/workflow-uses-comment-guard/tsconfig.json b/.claude/hooks/fleet/workflow-uses-comment-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/workflow-uses-comment-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/README.md b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/README.md similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/README.md rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/README.md diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/index.mts similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/index.mts rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/index.mts diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/package.json b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/package.json similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/package.json rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/package.json diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/tsconfig.json b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/settings.json b/.claude/settings.json index 6895c71..cfc9f7a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,139 +6,147 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-new-deps/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/alpha-sort-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/claude-md-section-size-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/check-new-deps/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/claude-md-size-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/claude-md-section-size-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/cross-repo-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/claude-md-size-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-disable-lint-rule-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/cross-repo-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gitmodules-comment-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-disable-lint-rule-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lock-step-ref-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/gitmodules-comment-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/logger-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/lock-step-ref-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/markdown-filename-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/logger-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minimum-release-age-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/markdown-filename-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/minimum-release-age-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-fleet-fork-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/new-hook-claude-md-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-fleet-fork-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-meta-comments-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-token-in-dotenv-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-meta-comments-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-underscore-identifier-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-token-in-dotenv-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/parallel-agent-edit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/path-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/paths-mts-inherit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/parallel-agent-edit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-location-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/path-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plugin-patch-format-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/paths-mts-inherit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pull-request-target-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/plan-location-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/readme-fleet-shape-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/plugin-patch-format-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-uses-comment-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/pull-request-target-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/marketplace-comment-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/readme-fleet-shape-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/vitest-include-vs-node-test-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/workflow-uses-comment-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/marketplace-comment-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/immutable-release-pattern-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/vitest-include-vs-node-test-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inline-script-defer-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/consumer-grep-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/immutable-release-pattern-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/soak-exclude-date-annotation-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/inline-script-defer-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/consumer-grep-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/trust-downgrade-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/trust-downgrade-guard/index.mts" } ] }, @@ -147,7 +155,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ask-suppression-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/ask-suppression-reminder/index.mts" } ] }, @@ -156,7 +164,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-no-write-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/codex-no-write-guard/index.mts" } ] }, @@ -165,103 +173,107 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-no-write-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/avoid-cd-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/codex-no-write-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-author-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-message-format-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/commit-author-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/concurrent-cargo-build-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/commit-message-format-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/default-branch-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/concurrent-cargo-build-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gh-token-hygiene-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/default-branch-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-blind-keychain-read-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-experimental-strip-types-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-empty-commit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-experimental-strip-types-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/node-modules-staging-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-empty-commit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pr-vs-push-default-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/node-modules-staging-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-non-fleet-push-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/pr-vs-push-default-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-external-issue-ref-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-non-fleet-push-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-revert-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-external-issue-ref-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/overeager-staging-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-revert-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/parallel-agent-staging-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/overeager-staging-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/prefer-rebase-over-revert-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/private-name-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/prefer-rebase-over-revert-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/public-surface-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/private-name-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/release-workflow-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/public-surface-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scan-label-in-commit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/release-workflow-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/token-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/scan-label-in-commit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/trust-downgrade-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/token-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/version-bump-order-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/trust-downgrade-guard/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/version-bump-order-guard/index.mts" } ] } @@ -271,8 +283,13 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/socket-token-minifier-start/index.mts", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/socket-token-minifier-start/index.mts", "timeout": 5 + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/broken-hook-detector/index.mts", + "timeout": 8 } ] } @@ -283,7 +300,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minify-mcp-output/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/minify-mcp-output/index.mts" } ] }, @@ -292,11 +309,11 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/actionlint-on-workflow-edit/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/actionlint-on-workflow-edit/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/extension-build-current-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/extension-build-current-guard/index.mts" } ] }, @@ -305,7 +322,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enterprise-push-property-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/enterprise-push-property-reminder/index.mts" } ] } @@ -315,95 +332,103 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auth-rotation-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/comment-tone-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/answer-status-requests-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-pr-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/auth-rotation-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/compound-lessons-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/comment-tone-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/commit-pr-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dont-blame-user-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/compound-lessons-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dont-stop-mid-queue-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/drift-check-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/dont-blame-user-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/error-message-quality-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/dont-stop-mid-queue-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/excuse-detector/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/drift-check-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/file-size-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/error-message-quality-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/follow-direct-imperative-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/excuse-detector/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/identifying-users-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/file-size-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/judgment-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/follow-direct-imperative-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/parallel-agent-on-stop-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/identifying-users-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/perfectionist-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/judgment-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-review-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-orphaned-staging/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/perfectionist-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/setup-security-tools/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/plan-review-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/squash-history-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-orphaned-staging/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stale-process-sweeper/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/setup-security-tools/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/sweep-ds-store/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/squash-history-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/variant-analysis-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/stale-process-sweeper/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/sweep-ds-store/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/variant-analysis-reminder/index.mts" } ] } @@ -413,11 +438,14 @@ "deny": [ "Bash(gh release create:*)", "Bash(gh release delete:*)", - "Bash(git push --force:*)", - "Bash(git push -f:*)", "Bash(npm publish:*)", "Bash(pnpm publish:*)", "Bash(yarn publish:*)" + ], + "ask": [ + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(git push --force-with-lease:*)" ] } } diff --git a/.claude/skills/auditing-gha-settings/SKILL.md b/.claude/skills/auditing-gha-settings/SKILL.md index ece1c21..b5faae7 100644 --- a/.claude/skills/auditing-gha-settings/SKILL.md +++ b/.claude/skills/auditing-gha-settings/SKILL.md @@ -3,6 +3,8 @@ name: auditing-gha-settings description: Audits a repo's GitHub Actions permissions + allowlist against the fleet baseline. Reports drift only. Fixes are manual in Settings → Actions because flipping these silently is unsafe. Use when a CI failure looks like "action X is not allowed to be used", when onboarding a new fleet repo, or as a periodic fleet-wide health check. user-invocable: true allowed-tools: Read, Grep, Glob, Bash(gh:*), Bash(node:*), Bash(jq:*) +model: claude-haiku-4-5 +context: fork --- # auditing-gha-settings diff --git a/.claude/skills/cascading-fleet/SKILL.md b/.claude/skills/cascading-fleet/SKILL.md index bfe6dfa..5d8b9aa 100644 --- a/.claude/skills/cascading-fleet/SKILL.md +++ b/.claude/skills/cascading-fleet/SKILL.md @@ -3,12 +3,16 @@ name: cascading-fleet description: Propagate a wheelhouse template change to every fleet repo (or a registry-pin chain to every dependent repo). Packages the canonical fleet-repo list, the FLEET_SYNC=1 sentinel pattern, the worktree-per-repo loop, push-direct + PR-fallback, and worktree-cleanup that survives mid-loop crashes. Use when a wheelhouse template SHA needs to land in every fleet repo, when a registry pin chain needs propagation, or when batching multiple template SHAs into one cascade wave. user-invocable: true allowed-tools: Bash(git fetch:*), Bash(git worktree:*), Bash(git branch:*), Bash(git status:*), Bash(git rev-list:*), Bash(git symbolic-ref:*), Bash(git show-ref:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(git log:*), Bash(node:*), Bash(gh pr create:*), Bash(gh repo view:*), Read, Bash(bash:*), Bash(chmod:*), Bash(cd:*), Bash(printf:*), Bash(echo:*), Bash(tee:*), Bash(tail:*), Bash(ls:*) +model: claude-haiku-4-5 +context: fork --- # cascading-fleet The fleet runs on `chore(wheelhouse): cascade template@<sha>` commits. Every wheelhouse template change has to land in every fleet repo to take effect. This skill packages the operation so it isn't recreated ad-hoc per session. +🚨 **This is mechanical work, not a thinking task.** Run the canonical operation, commit, push. Don't analyze each modified file in the cascade, don't design alternatives, don't write multi-paragraph rationale — the wheelhouse template is the source of truth and the sync runner decides what changes. If a repo's cascade refuses to apply (lockfile policy reject, soak window, broken hook from a stale install), bump the immediate blocker (soak-exclude entry, lockfile rebuild) or defer the repo and report it — don't reason through a multi-step manual reproduction of what the sync runner already does. Cheap/fast model settings are the right default; reserve heavier reasoning for genuine design work. + ## When to use - A wheelhouse `template/` SHA needs to propagate to every fleet repo. diff --git a/.claude/skills/cleaning-redundant-ci/SKILL.md b/.claude/skills/cleaning-redundant-ci/SKILL.md index 1c92189..b27d895 100644 --- a/.claude/skills/cleaning-redundant-ci/SKILL.md +++ b/.claude/skills/cleaning-redundant-ci/SKILL.md @@ -3,6 +3,8 @@ name: cleaning-redundant-ci description: Sweeps a fleet repo (or every fleet repo) for redundant CI surface. Three classes: orphan workflow YAML files (lint.yml / check.yml / type.yml / test.yml that the unified ci.yml replaced), GitHub-Dependabot auto-fix PRs that the fleet handles via /updating-security, and stale workflow run history in the Actions sidebar. Deletes the YAML files, disables Dependabot automated-security-fixes via gh api, and reports anything that needs a manual UI toggle. Once-and-never-again sweep meant to leave a repo clean. user-invocable: true allowed-tools: Read, Edit, Write, Glob, Grep, Bash(gh:*), Bash(git:*), Bash(ls:*), Bash(rm:*), Bash(find:*), Bash(jq:*) +model: claude-haiku-4-5 +context: fork --- # cleaning-redundant-ci diff --git a/.claude/skills/guarding-paths/SKILL.md b/.claude/skills/guarding-paths/SKILL.md index 4f3a428..1cf04e3 100644 --- a/.claude/skills/guarding-paths/SKILL.md +++ b/.claude/skills/guarding-paths/SKILL.md @@ -3,6 +3,8 @@ name: guarding-paths description: Audits and fixes path duplication in a Socket repo. Applies the strict "1 path, 1 reference" rule: every build/test/runtime/config path is constructed exactly once; everywhere else references the constructed value. Default mode finds and fixes; `check` mode reports only; `install` mode drops the gate + hook + rule into a fresh repo. Use when path drift surfaces from `pnpm check`, when a new sibling package needs path conventions, or when bootstrapping a fresh Socket repo. user-invocable: true allowed-tools: Task, Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(node scripts/check-paths:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(git:*) +model: claude-haiku-4-5 +context: fork --- # guarding-paths diff --git a/.claude/skills/refreshing-history/SKILL.md b/.claude/skills/refreshing-history/SKILL.md index f1ea818..dd36ab9 100644 --- a/.claude/skills/refreshing-history/SKILL.md +++ b/.claude/skills/refreshing-history/SKILL.md @@ -3,6 +3,8 @@ name: refreshing-history description: Squashes the repo's default branch (main, falling back to master) to a single signed "Initial commit", refreshes deps + lockfile, runs format / fix / check / type passes, amends results, and force-pushes. Wraps the lower-level `squashing-history` skill with a dep-refresh + integrity check + verified-signature workflow. Use when cutting a fleet-wide history reset or preparing a clean baseline before a major release. user-invocable: true allowed-tools: AskUserQuestion, Bash(git:*), Bash(pnpm:*), Bash(diff:*), Bash(ls:*) +model: claude-haiku-4-5 +context: fork --- # refreshing-history diff --git a/.claude/skills/reviewing-code/SKILL.md b/.claude/skills/reviewing-code/SKILL.md index bf8ff87..3a23c35 100644 --- a/.claude/skills/reviewing-code/SKILL.md +++ b/.claude/skills/reviewing-code/SKILL.md @@ -3,6 +3,8 @@ name: reviewing-code description: Reviews the current branch against a base ref using multiple AI backends. Routes discovery, discovery-secondary, remediation, and verify passes through the available agents (codex, claude, opencode, kimi, …), gracefully skipping any backend that isn't installed. Writes a markdown findings report under docs/. Use when preparing or updating a PR, before merging a feature branch, or when wanting an independent second opinion from a different agent. user-invocable: true allowed-tools: Read, Grep, Glob, Bash(node:*), Bash(git:*), Bash(command -v:*) +model: claude-opus-4-8 +context: fork --- # reviewing-code diff --git a/.claude/skills/running-test262/SKILL.md b/.claude/skills/running-test262/SKILL.md index c2c9d45..f896de4 100644 --- a/.claude/skills/running-test262/SKILL.md +++ b/.claude/skills/running-test262/SKILL.md @@ -3,6 +3,8 @@ name: running-test262 description: Run the test262 conformance suite against fleet parsers / runtimes (ultrathink acorn variants, socket-btm temporal-infra, future ports) using each repo's canonical runner. Never write homebrew test262 runners. Every parser/runtime in the fleet ships a runner under `test/scripts/test262-*.mts` and an unsupported-features config. Use this skill when asked to run spec tests, check conformance, debug a failing test262 case, or compare a parser against a reference implementation. user-invocable: true allowed-tools: Bash(node:*), Bash(pnpm:*), Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(find:*), Read +model: claude-haiku-4-5 +context: fork --- # running-test262 diff --git a/.claude/skills/scanning-quality/SKILL.md b/.claude/skills/scanning-quality/SKILL.md index 5a6d8b4..af08d83 100644 --- a/.claude/skills/scanning-quality/SKILL.md +++ b/.claude/skills/scanning-quality/SKILL.md @@ -3,6 +3,8 @@ name: scanning-quality description: Scans the codebase for bugs, logic errors, cache races, workflow problems, insecure defaults, security regressions in the diff, and variant analysis on prior findings. Spawns specialized Task agents per scan type, deduplicates findings, and produces an A-F prioritized report. Use when preparing a release, investigating quality issues, running pre-merge checks, or whenever a recent diff touches security-sensitive code. user-invocable: true allowed-tools: Task, Read, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(pnpm run test:*), Bash(pnpm test:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*) +model: claude-opus-4-8 +context: fork --- # scanning-quality diff --git a/.claude/skills/scanning-security/SKILL.md b/.claude/skills/scanning-security/SKILL.md index 489c5ff..7c41c4f 100644 --- a/.claude/skills/scanning-security/SKILL.md +++ b/.claude/skills/scanning-security/SKILL.md @@ -3,6 +3,8 @@ name: scanning-security description: Runs a multi-tool security scan: AgentShield for Claude config, zizmor for GitHub Actions, and optionally Socket CLI for dependency scanning. Produces an A-F graded security report. Use after modifying `.claude/` config, hooks, agents, or GitHub Actions workflows, and before releases. user-invocable: true allowed-tools: Task, Read, Bash(pnpm exec agentshield:*), Bash(zizmor:*), Bash(command -v:*), Bash(find .cache/external-tools/zizmor:*) +model: claude-opus-4-8 +context: fork --- # scanning-security diff --git a/.claude/skills/squashing-history/SKILL.md b/.claude/skills/squashing-history/SKILL.md index 9ddc953..c9b2ff0 100644 --- a/.claude/skills/squashing-history/SKILL.md +++ b/.claude/skills/squashing-history/SKILL.md @@ -3,6 +3,8 @@ name: squashing-history description: Squashes all commits on the repo's default branch (main, falling back to master) to a single "Initial commit" with backup branch, integrity verification, and user confirmation before force push. Use when cleaning history or preparing for fresh start. user-invocable: true allowed-tools: AskUserQuestion, Bash(git:*), Bash(diff:*), Bash(rm:*), Bash(ls:*) +model: claude-haiku-4-5 +context: fork --- # squashing-history diff --git a/.claude/skills/updating-coverage/SKILL.md b/.claude/skills/updating-coverage/SKILL.md index 3b60da0..89a71d1 100644 --- a/.claude/skills/updating-coverage/SKILL.md +++ b/.claude/skills/updating-coverage/SKILL.md @@ -3,6 +3,8 @@ name: updating-coverage description: Refresh the coverage badge in the root README by running the repo's coverage script and rewriting the `![Coverage](https://img.shields.io/badge/coverage-<PCT>%25-brightgreen)` line. Sibling of `updating-security` / `updating-lockstep` under the `updating` umbrella. user-invocable: true allowed-tools: Read, Edit, Bash(pnpm run cover:*), Bash(pnpm run coverage:*), Bash(pnpm run test:cover:*), Bash(node:*), Bash(git:*), Bash(jq:*), Bash(cat:*) +model: claude-haiku-4-5 +context: fork --- # updating-coverage diff --git a/.claude/skills/updating-lockstep/SKILL.md b/.claude/skills/updating-lockstep/SKILL.md index 38b9245..c814af6 100644 --- a/.claude/skills/updating-lockstep/SKILL.md +++ b/.claude/skills/updating-lockstep/SKILL.md @@ -3,6 +3,8 @@ name: updating-lockstep description: Acts on `lockstep.json` drift for repos that carry the lockstep manifest. Reads `pnpm run lockstep --json`, auto-bumps mechanical `version-pin` rows, surfaces `file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` rows as advisory. Invoked by the `updating` umbrella skill; can also run standalone. user-invocable: true allowed-tools: Read, Edit, Grep, Glob, Bash(pnpm:*), Bash(npm:*), Bash(git:*), Bash(node:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(cat:*), Bash(head:*), Bash(tail:*), Bash(wc:*), Bash(diff:*) +model: claude-haiku-4-5 +context: fork --- # updating-lockstep diff --git a/.claude/skills/updating/SKILL.md b/.claude/skills/updating/SKILL.md index 0574466..d99b6b1 100644 --- a/.claude/skills/updating/SKILL.md +++ b/.claude/skills/updating/SKILL.md @@ -3,6 +3,8 @@ name: updating description: Umbrella update skill for a Socket fleet repo. Runs `pnpm run update` (npm), validates `lockstep.json` via `pnpm run lockstep` (if present), optionally bumps submodules, checks workflow SHA pins, resolves open Dependabot security alerts, refreshes the README coverage badge when applicable, and audits GitHub repo + Actions settings drift via `scripts/lint-github-settings.mts`. Use when asked to update dependencies, sync upstreams, fix security advisories, refresh coverage, or prepare for a release. user-invocable: true allowed-tools: Task, Skill, Read, Edit, Grep, Glob, Bash(pnpm run:*), Bash(pnpm test:*), Bash(pnpm install:*), Bash(git:*), Bash(claude --version) +model: claude-haiku-4-5 +context: fork --- # updating @@ -17,7 +19,7 @@ Umbrella update skill. Runs `pnpm run update` for npm deps, then adapts to whate ## Update targets -- **npm packages**: `pnpm run update` (every fleet repo has this script). +- **npm packages**: `pnpm run update` (every fleet repo has this script). If the diff bumps `engines.pnpm`, `packageManager`, or `engines.npm`, see **"When the bump includes pnpm or npm"** below. - **lockstep-managed upstreams**: `pnpm run lockstep` when `lockstep.json` exists. Mechanical `version-pin` bumps auto-apply; `file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` rows surface as advisory. - **Other submodules**: repo-specific `updating-*` sub-skills handle `.gitmodules` entries not claimed by a lockstep `version-pin` row. - **Workflow SHA pins**: `_local-not-for-reuse-*.yml` SHAs against the remote's default branch (per CLAUDE.md _Default branch fallback_); run `/updating-workflows` when stale. @@ -27,6 +29,22 @@ Umbrella update skill. Runs `pnpm run update` for npm deps, then adapts to whate This umbrella reads repo state first to discover what applies. Sub-skills are only invoked when relevant. +## When the bump includes pnpm or npm + +A bump to `engines.pnpm`, `packageManager: "pnpm@<ver>"`, or `engines.npm` in a fleet repo has a **transitive blast radius**: the socket-registry shared `setup-and-install` GHA action installs pnpm from `external-tools.json` at a specific version; if that version doesn't match the fleet repo's new `packageManager` pin, every CI job fails the version check before tests run. + +The fix order is fixed — **don't try to land the fleet-repo bump first**: + +1. **Defer to socket-registry's `updating-workflows` skill** (lives at `socket-registry/.claude/skills/updating-workflows/SKILL.md`). That skill drives the Layer 1 → 2a → 2b → 3 → 4 cascade in socket-registry, ending at a **Layer 3 merge SHA** known as the **propagation SHA**. The skill's external-tools.json bump bundles the new pnpm version with its 7-platform SRI integrity values. + +2. **Capture the propagation SHA** from step 1. Every fleet-repo `uses: socket-registry/.github/{workflows,actions}/...@<sha>` ref bumps to it. + +3. **Update wheelhouse template** in the same wave: `template/package.json` `engines.pnpm` / `engines.npm` / `packageManager` + `template/pnpm-workspace.yaml` `allowBuilds` entries for any new transitive build-scripts the bumped pnpm enforces (`pnpm@11.4` added `[ERR_PNPM_IGNORED_BUILDS]` as hard exit, so `esbuild` and friends need explicit allowlisting). + +4. **Cascade fleet repos** atomically: each downstream socket-* repo gets the new pnpm pin AND the new propagation SHA in the same cascade commit. Without atomicity, you get the failure mode we hit on 2026-05-28: fleet repo bumps to pnpm@11.4, CI fails because the installed pnpm (11.3 via old setup-action) refuses the pin. + +Why reference, not duplicate: the cascade procedure is fleet-canonical knowledge owned by socket-registry. Duplicating it into wheelhouse means two copies that drift. The wheelhouse `updating` skill encodes "when to run the registry cascade and how to consume its output", not the cascade itself. + ## Phases | # | Phase | Outcome | diff --git a/.claude/skills/worktree-management/SKILL.md b/.claude/skills/worktree-management/SKILL.md index a959cf4..e6bc929 100644 --- a/.claude/skills/worktree-management/SKILL.md +++ b/.claude/skills/worktree-management/SKILL.md @@ -3,6 +3,8 @@ name: worktree-management description: Manages git worktrees per the fleet's parallel-Claude-sessions rule. Creates new task-worktrees, fans out one worktree per open PR for parallel review, and prunes stale worktrees whose branches were deleted upstream. Use when starting a task that needs an isolated working tree, when reviewing every open PR locally without disturbing the primary checkout, or when cleaning up after merges. user-invocable: true allowed-tools: Bash(git worktree:*), Bash(git branch:*), Bash(git fetch:*), Bash(gh pr list:*), Bash(gh auth status), Bash(ls:*), Read +model: claude-haiku-4-5 +context: fork --- # worktree-management diff --git a/.config/oxlint-plugin/index.mts b/.config/oxlint-plugin/index.mts index 27f5ea4..15d6554 100644 --- a/.config/oxlint-plugin/index.mts +++ b/.config/oxlint-plugin/index.mts @@ -40,11 +40,13 @@ import preferAsyncSpawn from './rules/prefer-async-spawn.mts' import preferCachedForLoop from './rules/prefer-cached-for-loop.mts' import preferEllipsisChar from './rules/prefer-ellipsis-char.mts' import preferEnvAsBoolean from './rules/prefer-env-as-boolean.mts' +import preferErrorMessage from './rules/prefer-error-message.mts' import preferExistsSync from './rules/prefer-exists-sync.mts' import preferFunctionDeclaration from './rules/prefer-function-declaration.mts' import preferNodeBuiltinImports from './rules/prefer-node-builtin-imports.mts' import preferNodeModulesDotCache from './rules/prefer-node-modules-dot-cache.mts' import preferNonCapturingGroup from './rules/prefer-non-capturing-group.mts' +import preferPureCallForm from './rules/prefer-pure-call-form.mts' import preferSafeDelete from './rules/prefer-safe-delete.mts' import preferSeparateTypeImport from './rules/prefer-separate-type-import.mts' import preferSpawnOverExecsync from './rules/prefer-spawn-over-execsync.mts' @@ -100,11 +102,13 @@ const plugin = { 'prefer-cached-for-loop': preferCachedForLoop, 'prefer-ellipsis-char': preferEllipsisChar, 'prefer-env-as-boolean': preferEnvAsBoolean, + 'prefer-error-message': preferErrorMessage, 'prefer-exists-sync': preferExistsSync, 'prefer-function-declaration': preferFunctionDeclaration, 'prefer-node-builtin-imports': preferNodeBuiltinImports, 'prefer-node-modules-dot-cache': preferNodeModulesDotCache, 'prefer-non-capturing-group': preferNonCapturingGroup, + 'prefer-pure-call-form': preferPureCallForm, 'prefer-safe-delete': preferSafeDelete, 'prefer-separate-type-import': preferSeparateTypeImport, 'prefer-spawn-over-execsync': preferSpawnOverExecsync, diff --git a/.config/oxlint-plugin/rules/no-underscore-identifier.mts b/.config/oxlint-plugin/rules/no-underscore-identifier.mts index ff81879..593456f 100644 --- a/.config/oxlint-plugin/rules/no-underscore-identifier.mts +++ b/.config/oxlint-plugin/rules/no-underscore-identifier.mts @@ -25,6 +25,14 @@ import type { AstNode, RuleContext } from '../lib/rule-types.mts' const UNDERSCORE_NAME_RE = /^_[A-Za-z]/ +// Node CJS exposes `__dirname` and `__filename` as module-scoped free +// variables. ESM modules conventionally re-create them with +// `path.dirname(fileURLToPath(import.meta.url))` etc., which means the +// identifiers appear in a `const ... = ...` declaration. Treat those +// declarations as allowed — they're not a `_internal` marker, they're +// matching Node's published names. +const ALLOWED_FREE_VARS = new Set(['__dirname', '__filename']) + function isInInternalDir(filename: string): boolean { return filename.includes('/_internal/') } @@ -37,6 +45,9 @@ function checkIdentifier( if (!name || !UNDERSCORE_NAME_RE.test(name)) { return } + if (ALLOWED_FREE_VARS.has(name)) { + return + } context.report({ node, messageId: 'noUnderscoreIdentifier', diff --git a/.config/oxlint-plugin/rules/prefer-error-message.mts b/.config/oxlint-plugin/rules/prefer-error-message.mts new file mode 100644 index 0000000..3b5fdd3 --- /dev/null +++ b/.config/oxlint-plugin/rules/prefer-error-message.mts @@ -0,0 +1,129 @@ +/** + * @file Flag the `<id> instanceof Error ? <id>.message : String(<id>)` ternary + * and prefer the `errorMessage` helper from + * `@socketsecurity/lib/errors`. The helper short-circuits the same + * shape, handles `aggregate` / cause chaining the bare ternary doesn't, and + * keeps every call site identical so a future change (adding cause chains, + * redacting tokens, etc.) lands in one place. The ternary form gets reinvented + * in nearly every error-handling branch, so the linter is the right surface + * to catch it. + * + * Report-only — no autofix. The rewrite to `errorMessage(<id>)` looks + * mechanical but the right import path depends on the file's context: a + * runtime source file in a downstream repo wants + * `@socketsecurity/lib/errors` (catalog), a script / test / hook in the same + * repo wants `@socketsecurity/lib-stable/errors` (devDep), and a repo that + * doesn't depend on `@socketsecurity/lib` at all can't apply the rewrite + * without first adding the dep. None of those choices belong to the linter. + * Surface the smell, let the human pick the import line. + * + * The rule deliberately does not chase any of the harder variants + * (`e?.message ?? String(e)`, `typeof e === 'string' ? e : ...`, + * `'message' in e ? e.message : String(e)`) because each carries different + * semantics — only the `instanceof Error` form is unambiguously equivalent + * to `errorMessage(e)`. + */ + +import type { AstNode, RuleContext } from '../lib/rule-types.mts' + +function identifierName(node: AstNode | undefined): string | undefined { + if (!node || node.type !== 'Identifier') { + return undefined + } + return node.name +} + +function isStringCallOf(node: AstNode | undefined, name: string): boolean { + if (!node || node.type !== 'CallExpression') { + return false + } + const callee = node.callee + if (!callee || callee.type !== 'Identifier' || callee.name !== 'String') { + return false + } + const args = node.arguments ?? [] + if (args.length !== 1) { + return false + } + return identifierName(args[0]) === name +} + +function isMessageMemberOf(node: AstNode | undefined, name: string): boolean { + if (!node || node.type !== 'MemberExpression') { + return false + } + if (node.computed) { + return false + } + const property = node.property + if (!property || property.type !== 'Identifier' || property.name !== 'message') { + return false + } + return identifierName(node.object) === name +} + +function isInstanceOfErrorOf( + node: AstNode | undefined, + name: string, +): boolean { + if (!node || node.type !== 'BinaryExpression') { + return false + } + if (node.operator !== 'instanceof') { + return false + } + if (identifierName(node.left) !== name) { + return false + } + return identifierName(node.right) === 'Error' +} + +const rule = { + meta: { + type: 'suggestion', + docs: { + description: + 'Prefer `errorMessage(e)` from `@socketsecurity/lib/errors` over the `e instanceof Error ? e.message : String(e)` ternary.', + category: 'Stylistic Issues', + recommended: true, + }, + fixable: undefined, + messages: { + preferErrorMessage: + '`{{name}} instanceof Error ? {{name}}.message : String({{name}})` reinvents `errorMessage({{name}})` from `@socketsecurity/lib/errors`. Replace with `errorMessage({{name}})` and add the import — `@socketsecurity/lib/errors` for runtime source, `@socketsecurity/lib-stable/errors` for scripts / tests / hooks.', + }, + schema: [], + }, + + create(context: RuleContext) { + return { + ConditionalExpression(node: AstNode) { + const test = node.test + if (!test || test.type !== 'BinaryExpression') { + return + } + const name = identifierName(test.left) + if (!name) { + return + } + if (!isInstanceOfErrorOf(test, name)) { + return + } + if (!isMessageMemberOf(node.consequent, name)) { + return + } + if (!isStringCallOf(node.alternate, name)) { + return + } + context.report({ + node, + messageId: 'preferErrorMessage', + data: { name }, + }) + }, + } + }, +} + +// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. +export default rule diff --git a/.config/oxlint-plugin/rules/prefer-pure-call-form.mts b/.config/oxlint-plugin/rules/prefer-pure-call-form.mts new file mode 100644 index 0000000..f41f973 --- /dev/null +++ b/.config/oxlint-plugin/rules/prefer-pure-call-form.mts @@ -0,0 +1,150 @@ +/** + * @file Flag `/*@__PURE__*\/` and `/*@__NO_SIDE_EFFECTS__*\/` magic comments + * that are NOT directly attached to a CallExpression / NewExpression. + * Rolldown (and Terser/esbuild before it) only treats the magic when it sits + * immediately before a call: + * + * ```ts + * const x = /*@__PURE__*\/ foo() + * ``` + * + * In any other position the bundler silently ignores the hint, and the value + * the user wanted treated as side-effect-free is kept live in the output — + * tree-shaking regresses without warning. This rule catches the failure modes + * we've seen oxfmt produce in practice: + * + * - Comment on a `class X {}` declaration (oxfmt re-flows it onto the class, + * where it has no effect): `/*@__PURE__*\/ class Logger {}`. + * - Comment outside parenthesized expressions where the call lives inside: + * `const x = /*@__PURE__*\/ (foo()).bar` — the magic is detached from + * the call site by the parens / member expression. + * - Comment on a bare identifier reference: `const ctor = /*@__PURE__*\/ + * SomeClass` (no parens means no call; the hint does nothing). + * + * Report-only — the right rewrite is "put the comment immediately before the + * call, like `const x = /*@__PURE__*\/ foo()`," and oxfmt's tendency to move + * comments back makes any literal autofix a moving target. The rule writes + * the call site location and leaves the human to either reposition the + * comment or restructure the surrounding code (the documented workaround: + * introduce an intermediate const so the magic comment lands adjacent to the + * call, e.g. `const tmp = /*@__PURE__*\/ foo(); const x = tmp.bar`). + */ + +import type { AstNode, RuleContext } from '../lib/rule-types.mts' + +const PURE_MAGIC_RE = /^\s*@(?:__PURE__|__NO_SIDE_EFFECTS__)\s*$/ + +function isMagicCommentText(raw: string | undefined): boolean { + if (!raw) { + return false + } + return PURE_MAGIC_RE.test(raw) +} + +function commentRange(c: AstNode): [number, number] | undefined { + const r = c.range + if (!Array.isArray(r) || r.length !== 2) { + return undefined + } + return [r[0], r[1]] +} + +function nodeRange(n: AstNode | undefined): [number, number] | undefined { + if (!n) { + return undefined + } + const r = n.range + if (!Array.isArray(r) || r.length !== 2) { + return undefined + } + return [r[0], r[1]] +} + +const rule = { + meta: { + type: 'suggestion', + docs: { + description: + '`/*@__PURE__*/` / `/*@__NO_SIDE_EFFECTS__*/` magic comments only affect the bundler when they sit directly before a CallExpression or NewExpression. Detached comments silently regress tree-shaking.', + category: 'Possible Errors', + recommended: true, + }, + fixable: undefined, + messages: { + detachedPureComment: + '`{{kind}}` magic comment is not attached to a CallExpression / NewExpression — the bundler ignores it and the value stays live in the output. Move the comment to immediately before the call, e.g. `const x = {{kind}} foo()`; if the call is buried in a member or parenthesized expression, introduce an intermediate `const tmp = {{kind}} foo()` so the comment can land adjacent.', + }, + schema: [], + }, + + create(context: RuleContext) { + const sourceCode = context.getSourceCode + ? context.getSourceCode() + : context.sourceCode + + // Source-text approach. After the magic comment, the next + // syntactically significant token must form a call shape: + // - `<identifier>(` — bare or qualified call + // - `<identifier>.<chain>` — qualified call (validated by the + // parser via the eventual `(`) + // - `new <identifier>(` — constructor call + // Anything else (`class`, a parenthesized group like `(foo()).x`, + // a bare identifier reference with no parens, etc.) means the + // bundler will discard the hint. + // + // Why not use the AST: the failure modes we care about + // (oxfmt placing the comment on a `class` decl, or outside + // parens) all show up as syntactically valid programs where the + // comment is just floating; the AST visitor doesn't make it + // obvious that the comment isn't on a call node. The textual + // shape is what the bundler ultimately reads. + + return { + Program() { + const comments = + (sourceCode.getAllComments && sourceCode.getAllComments()) || [] + const text = sourceCode.getText() + for (let i = 0, { length } = comments; i < length; i += 1) { + const c = comments[i] + if (!c || c.type !== 'Block') { + continue + } + if (!isMagicCommentText(c.value)) { + continue + } + const cRange = commentRange(c) + if (!cRange) { + continue + } + const tail = text.slice(cRange[1]) + // Strip leading whitespace (\n included). Anchor matching + // on what follows. + const stripped = tail.replace(/^\s+/, '') + // Attached shapes: + // foo( — direct call + // foo.bar( — qualified call (no parens before `.`) + // new Foo( — constructor call + // foo<T>( — TS generic call + // foo?.( — optional call + const attachedRe = + /^(?:new\s+)?[A-Za-z_$][\w$]*(?:(?:\.|\?\.)[A-Za-z_$][\w$]*)*(?:<[^<>]*>)?(?:\(|\?\.\()/ + if (attachedRe.test(stripped)) { + continue + } + const ct = c.value || '' + const kind = /__NO_SIDE_EFFECTS__/.test(ct) + ? '/*@__NO_SIDE_EFFECTS__*/' + : '/*@__PURE__*/' + context.report({ + node: c, + messageId: 'detachedPureComment', + data: { kind }, + }) + } + }, + } + }, +} + +// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. +export default rule diff --git a/.config/oxlint-plugin/test/prefer-error-message.test.mts b/.config/oxlint-plugin/test/prefer-error-message.test.mts new file mode 100644 index 0000000..9a19664 --- /dev/null +++ b/.config/oxlint-plugin/test/prefer-error-message.test.mts @@ -0,0 +1,58 @@ +/** + * @file Unit tests for socket/prefer-error-message. + */ + +import { describe, test } from 'node:test' + +import { RuleTester } from '../lib/rule-tester.mts' +import rule from '../rules/prefer-error-message.mts' + +describe('socket/prefer-error-message', () => { + test('valid + invalid cases', () => { + new RuleTester().run('prefer-error-message', rule, { + valid: [ + { + name: 'errorMessage helper already in use', + code: 'const msg = errorMessage(e)\n', + }, + { + name: 'plain String(e) without instanceof guard', + code: 'const msg = String(e)\n', + }, + { + name: 'instanceof Error without the message/String shape', + code: 'if (e instanceof Error) { throw e }\n', + }, + { + name: 'mismatched identifiers across positions', + code: 'const msg = e instanceof Error ? other.message : String(e)\n', + }, + { + name: 'instanceof non-Error subclass', + code: 'const msg = e instanceof TypeError ? e.message : String(e)\n', + }, + { + name: 'optional-chain variant (different semantics)', + code: 'const msg = e?.message ?? String(e)\n', + }, + ], + invalid: [ + { + name: 'canonical ternary with `e`', + code: 'const msg = e instanceof Error ? e.message : String(e)\n', + errors: [{ messageId: 'preferErrorMessage' }], + }, + { + name: 'canonical ternary with `err`', + code: 'const msg = err instanceof Error ? err.message : String(err)\n', + errors: [{ messageId: 'preferErrorMessage' }], + }, + { + name: 'inside a catch block', + code: 'try { f() } catch (e) { log(e instanceof Error ? e.message : String(e)) }\n', + errors: [{ messageId: 'preferErrorMessage' }], + }, + ], + }) + }) +}) diff --git a/.config/oxlint-plugin/test/prefer-pure-call-form.test.mts b/.config/oxlint-plugin/test/prefer-pure-call-form.test.mts new file mode 100644 index 0000000..486ac6c --- /dev/null +++ b/.config/oxlint-plugin/test/prefer-pure-call-form.test.mts @@ -0,0 +1,62 @@ +/** + * @file Unit tests for socket/prefer-pure-call-form. + */ + +import { describe, test } from 'node:test' + +import { RuleTester } from '../lib/rule-tester.mts' +import rule from '../rules/prefer-pure-call-form.mts' + +describe('socket/prefer-pure-call-form', () => { + test('valid + invalid cases', () => { + new RuleTester().run('prefer-pure-call-form', rule, { + valid: [ + { + name: 'magic adjacent to bare call', + code: 'const x = /*@__PURE__*/ foo()\n', + }, + { + name: 'magic adjacent to NewExpression', + code: 'const x = /*@__PURE__*/ new Logger()\n', + }, + { + name: 'magic adjacent to method call', + code: 'const x = /*@__PURE__*/ obj.method()\n', + }, + { + name: 'magic adjacent to chained call', + code: 'const x = /*@__PURE__*/ make().then()\n', + }, + { + name: 'no magic comments at all', + code: 'const x = foo()\n', + }, + { + name: 'unrelated block comment', + code: '/* explanation */\nconst x = foo()\n', + }, + { + name: 'magic with NO_SIDE_EFFECTS adjacent to call', + code: 'const x = /*@__NO_SIDE_EFFECTS__*/ foo()\n', + }, + ], + invalid: [ + { + name: 'magic on class declaration (oxfmt misplacement)', + code: '/*@__PURE__*/ class Logger {}\n', + errors: [{ messageId: 'detachedPureComment' }], + }, + { + name: 'magic on bare identifier reference', + code: 'const ctor = /*@__PURE__*/ SomeClass\n', + errors: [{ messageId: 'detachedPureComment' }], + }, + { + name: 'magic outside parens, call inside', + code: 'const x = /*@__PURE__*/ (foo()).bar\n', + errors: [{ messageId: 'detachedPureComment' }], + }, + ], + }) + }) +}) diff --git a/.config/vitest.coverage.fleet.config.mts b/.config/vitest.coverage.fleet.config.mts new file mode 100644 index 0000000..0918fdb --- /dev/null +++ b/.config/vitest.coverage.fleet.config.mts @@ -0,0 +1,56 @@ +/** + * @file Fleet-canonical coverage defaults — the shape every socket-* repo + * shares. Repos layer their own exclude entries + thresholds on top via + * .config/vitest.coverage.config.mts. Do NOT add repo-specific paths here; + * anything in this file cascades to every fleet repo. + */ + +import type { CoverageOptions } from 'vitest' + +/** + * Fleet-shared coverage base. Excludes cover the dirs every fleet repo has + * (node_modules, dist, test, scripts, perf, external bundles). Repo-specific + * source paths to skip (integration-only modules, generated artifacts) get + * appended in the repo's own coverage config. + */ +export const baseFleetCoverageConfig: CoverageOptions = { + provider: 'v8', + reporter: ['text', 'json', 'json-summary', 'html', 'lcov', 'clover'], + exclude: [ + '**/*.config.*', + '**/node_modules/**', + '**/[.]**', + '**/*.d.ts', + '**/virtual:*', + 'coverage/**', + 'test/**', + 'packages/**', + 'perf/**', + 'dist/**', + '**/dist/**', + '**/{dist,build,out}/**', + 'src/external/**', + 'dist/external/**', + '**/external/**', + 'src/types.ts', + 'scripts/**', + ], + include: ['src/**/*.{ts,mts,cts}', '!src/external/**'], + excludeAfterRemap: true, + all: true, + clean: true, + skipFull: false, + ignoreClassMethods: ['constructor'], +} + +/** + * Fleet-default cumulative threshold. A repo can override these in its own + * coverage config when its bar is materially different — the wheelhouse default + * is the conservative starting point. + */ +export const baseFleetAggregateThresholds = { + branches: 95, + functions: 99, + lines: 99, + statements: 99, +} diff --git a/docs/claude.md/fleet/bypass-phrases.md b/docs/claude.md/fleet/bypass-phrases.md index 4347cb3..dc4beef 100644 --- a/docs/claude.md/fleet/bypass-phrases.md +++ b/docs/claude.md/fleet/bypass-phrases.md @@ -14,7 +14,8 @@ The phrase format is `Allow <X> bypass`. Case-sensitive, exact match. | `SKIP_ASSET_DOWNLOAD=1` (skips release-asset fetch in build — degraded-mode flag; becomes a bypass when used to push past rate-limited pre-commit) | `Allow asset-download bypass` | | `git stash` (any form: bare, `push`, `save`, `--keep-index`) in primary checkout — shared stash store, another Claude session can pop yours. Use a worktree instead. | `Allow stash bypass` | | Bash file-write (`python -c '...write...'`, `sed -i`, heredoc `cat << EOF > file`, `tee <source-file>`, `dd of=…`) — typically used to dodge an Edit/Write hook block. Move file / refactor / get original-hook bypass instead. | `Allow bash-write bypass` | -| `git push --force` / `-f` | `Allow force-push bypass` | +| `git push --force-with-lease` (refuses if remote moved since fetch — preferred safe form, always reach for this before `--force`) | `Allow force-with-lease bypass` | +| `git push --force` / `-f` (CAN silently clobber remote commits — high-friction phrase; use `--force-with-lease` unless you specifically need the lease check off) | `Allow force-push bypass` | | External GitHub issue/PR reference in a commit message or PR/issue body (`<owner>/<repo>#<num>` or full URL to a non-SocketDev repo) — would auto-link a backref into the upstream maintainer's issue | `Allow external-issue-ref bypass` | | Sub-package `scripts/paths.mts` that doesn't `export *` from the nearest ancestor paths.mts (re-derives REPO_ROOT etc. — drift risk) | `Allow paths-mts-inherit bypass` | | `gh workflow run` / `gh workflow dispatch` / `gh api …/dispatches` for a workflow without a `dry-run:` input — one-off recovery dispatches or workflows that can't dry-run by design (e.g. node-smol build) | `Allow workflow-dispatch bypass` | diff --git a/docs/claude.md/fleet/gh-token-hygiene.md b/docs/claude.md/fleet/gh-token-hygiene.md index 4f8ec9a..df4db66 100644 --- a/docs/claude.md/fleet/gh-token-hygiene.md +++ b/docs/claude.md/fleet/gh-token-hygiene.md @@ -135,4 +135,20 @@ Local timestamp tracking is advisory. A malicious process can backdate the file. - `~/.claude/gh-token-issued-at`: local timestamp stamped by the hook when the user runs `gh auth login` or `gh auth refresh`. The 8h age check reads this. - `~/.claude/gh-workflow-grant`: presence marker for an unconsumed workflow-dispatch authorization. Created when a bypass-authorized + auth-passed `gh auth refresh -s workflow` runs; deleted as soon as the first dispatch is let through. +## Refresh recovery — when the hook didn't see your refresh + +The hook stamps `~/.claude/gh-token-issued-at` from a `PreToolUse` event — meaning it only sees `gh auth refresh` invocations that pass through Claude's tool layer. If you ran `gh auth refresh` in a side terminal (e.g. via the `<bash-input>` pasteback flow), the hook didn't see it and the stamp file stays at its prior age, so the next gh tool call gets the >8h block. + +Three recovery paths, ordered from cleanest to most surgical: + +1. **Run the refresh through Claude.** Ask Claude to run `gh auth refresh -h github.com` in a Bash tool call. The hook sees it, pre-stamps, and the next gh call goes through. +2. **Use the hook's `--stamp` CLI mode.** From any shell: + ```sh + node ~/.claude/hooks/gh-token-hygiene-guard/index.mts --stamp + ``` + Writes a fresh `Date.now()` to the stamp file. Use this when you've already done `gh auth refresh` externally and don't want to re-run it. +3. **Auto-correction of malformed values.** If the stamp file contains a value less than `1577836800000` (2020-01-01 in ms) — e.g. you accidentally wrote POSIX seconds via `date "+%s" > ~/.claude/gh-token-issued-at` — the hook treats it as malformed on the next read, re-stamps, and proceeds. No manual intervention required; the malformed-value branch is there as a safety net for cases like the seconds-vs-ms confusion (2026-05-28 incident). + +The stamp file is purely an in-process record of "when did the hook last see a refresh"; the actual token security lives in the OS keychain. A wrong stamp value can't escalate access — at worst it temporarily locks the user out of gh tool calls until they reauth or re-stamp. + No escape hatches. The hook is failsafe-deny on all invariants. The OS-auth path (Touch ID + osascript + dscl, called via absolute `/usr/bin/` paths to defeat PATH-hijack) is intentionally unreachable in unit tests; the auth path is exercised by manual smoke-testing when the hook ships. diff --git a/docs/claude.md/fleet/skill-model-routing.md b/docs/claude.md/fleet/skill-model-routing.md new file mode 100644 index 0000000..9e9708e --- /dev/null +++ b/docs/claude.md/fleet/skill-model-routing.md @@ -0,0 +1,78 @@ +# Skill model routing + +Claude Code supports `model:` + `context: fork` in skill SKILL.md frontmatter. When both are set, invoking the skill forks the conversation onto the declared model for the skill's duration. The rest of the session keeps the user-chosen model. + +The fleet uses this to match model capability to task shape: + +## Tier 1 — `claude-haiku-4-5` (mechanical) + +Skills where the work is "run the tool, commit, push" without judgment: + +- `auditing-gha-settings` — drift report +- `cascading-fleet` — propagate wheelhouse template to fleet +- `cleaning-redundant-ci` — sweep orphan workflow files +- `guarding-paths` — path-dedup audit +- `refreshing-history` — squash + reset +- `regenerating-plugin-patches` — regenerate patches against pinned upstream +- `running-test262` — conformance suite runner +- `squashing-history` — git reset/squash +- `updating` — pnpm update + soak +- `updating-coverage` — coverage badge refresh +- `updating-lockstep` — lockstep.json drift bump +- `worktree-management` — worktree create/fanout + +These tasks fail-cheap (the sync runner / git command decides what changes), so Haiku's faster latency + lower cost dominates. + +## Tier 2 — default model (general dev work) + +Skills with some judgment but mostly mechanical: + +- `driving-cursor-bugbot` — classify Bugbot threads +- `greening-ci` — watch CI, surface failures +- `handing-off` — conversation → handoff doc +- `plug-leaking-promise-race` — concurrency bug reference +- `prose` — prose editing +- `trimming-bundle` — stub unused dist/ paths +- `updating-security` — Dependabot resolution + +These inherit whatever the user's session is on (typically Sonnet 4.6 or Opus 4.8). + +## Tier 3 — `claude-opus-4-8` (heavy reasoning) + +Skills where mistakes ship as security incidents or false-negative review passes: + +- `reviewing-code` — code review against base ref +- `scanning-quality` — static-analysis bug/race/insecure-default detection +- `scanning-security` — multi-tool security scan + grading + +The `.claude/agents/security-reviewer.md` subagent also declares `model: claude-opus-4-8` for the same reason. + +## When to override + +A skill's declared model is the **default**; the caller can still override via `Skill` tool args or by spawning a subagent with a different `model:` parameter. The fleet convention is: when in doubt, the skill's declared tier wins — overrides should be rare and explanatory. + +## Why not `context: fork` everywhere? + +Forking copies the parent conversation context to the new model; that has token cost. For tiny one-shot operations, forking + switching wastes more than it saves. The 12 Haiku-declared skills are all multi-step (cascade waves, test suite runs, lockstep traversals) where Haiku's speed/cost win pays back the fork overhead. + +## AI-assisted lint fix routing + +The same tiering applies to `scripts/ai-lint-fix/cli.mts`, which spawns a headless `claude --print` per file to apply rule-driven rewrites. Routing lives in `scripts/ai-lint-fix/rule-guidance.mts`: + +- `RULE_MODEL_TIER` — per-rule tier label (`haiku` | `sonnet` | `opus`). +- `TIER_MODEL` — tier-label → model-ID map. Single source of truth for global model bumps. +- `escalateTier(ruleIds)` — picks the highest tier present in a per-file batch. + +Tiers by rule: + +- **Haiku** (identifier renames, single-token substitutions): `socket/inclusive-language`, `socket/no-placeholders`, `socket/personal-path-placeholders`, `socket/prefer-node-builtin-imports`, `socket/prefer-undefined-over-null`. +- **Sonnet** (control-flow / caller-chain rewrites): `socket/no-fetch-prefer-http-request`, `socket/prefer-async-spawn`, `socket/prefer-exists-sync`. +- **Opus** (module decomposition): `socket/max-file-lines`. + +A file's batch may contain multiple rules — the highest tier wins. A Haiku-only batch spawns Haiku; a Haiku+Sonnet batch spawns Sonnet; any `max-file-lines` finding triggers Opus. + +When adding a new rule to `AI_HANDLED_RULES`, slot it into `RULE_MODEL_TIER` at the right level. Prompt-engineering invariants follow Anthropic's best practices (https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices): XML-structured prompt (`<role>`, `<task>`, `<file>`, `<findings>`, `<rules>`, `<constraint>`, `<output>`), low-freedom per-rule guidance, explicit skip-on-uncertainty constraint. + +## Why not fast mode? + +Fast mode (`speed: "fast"` + the `fast-mode-2026-02-01` beta header) runs the same Opus weights at up to 2.5x output tokens/sec, but bills at a premium multiplier on standard rates (Opus 4.8 fast = $10/$50 per MTok in/out, above standard Opus 4.8). It is opted into per API request, not via skill `model:` frontmatter, and is access-gated (research preview, account-manager / waitlist). The fleet does not enable it: our skills are throughput-bound, not latency-bound, and the premium fails the "doesn't cost more" bar. An interactive `/fast` toggle in a personal Claude Code session is a per-user choice and touches nothing in this repo. Revisit only if fast mode reaches standard pricing or a genuinely latency-critical skill appears. Source: https://platform.claude.com/docs/en/build-with-claude/fast-mode. diff --git a/scripts/ai-lint-fix/cli.mts b/scripts/ai-lint-fix/cli.mts index 86f36d0..ba8a611 100644 --- a/scripts/ai-lint-fix/cli.mts +++ b/scripts/ai-lint-fix/cli.mts @@ -42,7 +42,12 @@ import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { isSpawnError } from '@socketsecurity/lib-stable/process/spawn/errors' import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { AI_HANDLED_RULES, RULE_GUIDANCE } from './rule-guidance.mts' +import { + AI_HANDLED_RULES, + RULE_GUIDANCE, + TIER_MODEL, + escalateTier, +} from './rule-guidance.mts' const logger = getDefaultLogger() @@ -207,7 +212,7 @@ function bucketFindings(files: OxlintFile[]): Map<string, OxlintMessage[]> { for (let i = 0, { length } = files; i < length; i += 1) { const f = files[i]! const handled = f.messages.filter( - m => m.ruleId && AI_HANDLED_RULES.has(m.ruleId), + m => m.ruleId !== undefined && AI_HANDLED_RULES.has(m.ruleId), ) if (handled.length === 0) { continue @@ -329,15 +334,20 @@ async function runClaudeFix( _filePath: string, prompt: string, cwd: string, + model: string, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { // AI_PROFILE.edit = in-place edits only (Edit on existing files, no // Write/MultiEdit) — exactly the lint-fix contract: the prompt forbids // creating files. spawnAiAgent owns the --no-session-persistence / // --add-dir / 529-retry the hand-rolled version used to duplicate. + // The model is picked per-file by the caller via escalateTier() — see + // RULE_MODEL_TIER in rule-guidance.mts. Simple regex-shaped rewrites + // run on Haiku; control-flow + caller-chain rewrites run on Sonnet; + // module-split refactors (`socket/max-file-lines`) run on Opus. const { exitCode, stderr, stdout } = await spawnAiAgent({ ...AI_PROFILE.edit, cwd, - model: 'claude-sonnet-4-6', + model, prompt, timeoutMs: 5 * 60 * 1000, }) @@ -385,9 +395,20 @@ async function main(): Promise<void> { for (const [filePath, findings] of byFile) { const rel = path.relative(cwd, filePath) - logger.log(`AI-fix ${rel} (${findings.length} findings)…`) + // Pick the model from the highest-tier rule in this file's batch. + // Pure-Haiku files (identifier renames, null→undefined, etc.) run + // cheap; any caller-chain rewrite escalates to Sonnet; a + // `socket/max-file-lines` finding escalates to Opus. + const ruleIds = findings + .map(f => f.ruleId) + .filter((r): r is string => typeof r === 'string') + const tier = escalateTier(ruleIds) + const model = TIER_MODEL[tier] + logger.log( + `AI-fix ${rel} (${findings.length} findings, ${tier})…`, + ) const prompt = buildPrompt(filePath, findings) - const { exitCode, stderr } = await runClaudeFix(filePath, prompt, cwd) + const { exitCode, stderr } = await runClaudeFix(filePath, prompt, cwd, model) if (exitCode === 0) { totalEdits += findings.length continue diff --git a/scripts/ai-lint-fix/rule-guidance.mts b/scripts/ai-lint-fix/rule-guidance.mts index f4f0432..eca8061 100644 --- a/scripts/ai-lint-fix/rule-guidance.mts +++ b/scripts/ai-lint-fix/rule-guidance.mts @@ -33,6 +33,89 @@ export const AI_HANDLED_RULES: ReadonlySet<string> = new Set([ 'socket/prefer-undefined-over-null', ]) +/** + * Capability tier per rule. The orchestrator picks the highest-tier model + * among a per-file batch's rules so a single Haiku-only file goes cheap, a + * mixed batch gets Sonnet, and any `max-file-lines` finding triggers Opus + * (module splits are real refactoring). + * + * Why per-rule rather than per-file or per-finding: + * - Per-finding would spawn N AI calls per file. Wasteful. + * - Per-file flat would route everything to Sonnet defensively. Wasteful too. + * - Per-rule + escalation matches the actual cost surface: simple regex-shaped + * rewrites (identifier rename, null→undefined, fs.X → X) work fine on Haiku; + * control-flow + caller-chain rewrites (fetch→httpJson, sync→async, fs.access + * → existsSync) need Sonnet; module decomposition needs Opus. + * + * Tier order: `claude-haiku-4-5` < `claude-sonnet-4-6` < `claude-opus-4-8`. + * Add new rules to the right bucket when adding to AI_HANDLED_RULES. + */ +export const RULE_MODEL_TIER: Readonly<Record<string, 'haiku' | 'sonnet' | 'opus'>> = { + __proto__: null, + // Identifier renames, single-token substitutions, namespace rewrites. + // The right rewrite is fully determined by the pattern that fired. + 'socket/inclusive-language': 'haiku', + 'socket/no-placeholders': 'haiku', + 'socket/personal-path-placeholders': 'haiku', + 'socket/prefer-node-builtin-imports': 'haiku', + 'socket/prefer-undefined-over-null': 'haiku', + // Control-flow / caller-chain rewrites. Need to read surrounding code + + // reason about side effects (the `fs.access` Promise<boolean> collapse, + // the sync→async caller chain, the fetch → httpJson error-handling + // shape). Sonnet's reasoning is the right depth. + 'socket/no-fetch-prefer-http-request': 'sonnet', + 'socket/prefer-async-spawn': 'sonnet', + 'socket/prefer-exists-sync': 'sonnet', + // Module decomposition. The model has to read the whole file, partition + // by domain, decide what each new module exports, and rewrite imports + // in every consumer. Real refactoring; Opus's depth pays back. + 'socket/max-file-lines': 'opus', +} as Readonly<Record<string, 'haiku' | 'sonnet' | 'opus'>> + +/** + * Map a tier label to the canonical Claude Code model ID. Centralized here + * so a global tier bump (Haiku 4.5 → 4.6, Sonnet 4.6 → 5.0, etc.) is a + * single-file edit and won't drift across the orchestrator + the docs. + */ +export const TIER_MODEL: Readonly<Record<'haiku' | 'sonnet' | 'opus', string>> = { + __proto__: null, + haiku: 'claude-haiku-4-5', + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-8', +} as Readonly<Record<'haiku' | 'sonnet' | 'opus', string>> + +/** + * Pick the highest tier present in a per-file batch's rule set. Returns a + * tier label; the caller resolves it to a model via `TIER_MODEL`. Default + * (no recognized rules in batch) is `sonnet` — the historical baseline. + * + * `ruleIds` is a concrete array (not `Iterable<string>`) so the loop can + * use the cached-length for-loop idiom the fleet's `prefer-cached-for-loop` + * lint rule enforces. Callers in cli.mts already build a string[] via + * `findings.map(f => f.ruleId).filter(...)`. + */ +export function escalateTier( + ruleIds: readonly string[], +): 'haiku' | 'sonnet' | 'opus' { + let highest: 'haiku' | 'sonnet' | 'opus' = 'haiku' + let sawAny = false + for (let i = 0, { length } = ruleIds; i < length; i += 1) { + const tier = RULE_MODEL_TIER[ruleIds[i]!] + if (!tier) { + continue + } + sawAny = true + if (tier === 'opus') { + return 'opus' + } + if (tier === 'sonnet') { + highest = 'sonnet' + } + } + // No recognized rules → fall back to sonnet (historical default). + return sawAny ? highest : 'sonnet' +} + /** * Per-rule guidance — concise, low-freedom (one canonical rewrite per rule). * Built per Anthropic's prompt-engineering best practices: direct instructions, diff --git a/scripts/audit-transcript.mts b/scripts/audit-transcript.mts index 7bf1af5..64ef01b 100644 --- a/scripts/audit-transcript.mts +++ b/scripts/audit-transcript.mts @@ -21,6 +21,7 @@ import path from 'node:path' import process from 'node:process' import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { parseShell } from '@socketsecurity/lib-stable/shell/parse' const logger = getDefaultLogger() @@ -87,6 +88,73 @@ function readToolUses(transcriptPath: string): ToolUseEvent[] { return out } +/** + * Walk a shell command's parsed tokens and return the args of each + * invocation whose leading tokens match `cmdLine` (e.g. `['sudo']`, + * `['gh', 'auth', 'refresh']`). Returns an empty array when no + * invocation matches. + * + * Will be lifted to `@socketsecurity/lib-stable/shell/parse` in the next + * lib bump (the exports are already on socket-lib's `src/` but haven't + * shipped yet). Keep this inline copy until the cascade can pin the new + * lib version; remove it then. + * + * Uses the AST-based `parseShell` (wraps `shell-quote`) so the matcher + * sees actual invocations only, not embedded args (`echo "sudo foo"`), + * variable substitutions (`$gh`), or command substitution (`$(...)`). + * Treats `&&`, `;`, `||`, `|` as segment terminators so chained + * commands each get their own scan. + */ +function findInvocations( + command: string, + cmdLine: readonly string[], +): readonly string[][] { + // shell-quote is permissive — partial parses don't throw; the walk + // below tolerates any shape it returns. + const entries = parseShell(command) + const segments: string[][] = [[]] + for (let i = 0, { length } = entries; i < length; i += 1) { + const entry = entries[i] + if (entry && typeof entry === 'object' && 'op' in entry) { + segments.push([]) + continue + } + if (typeof entry === 'string') { + segments[segments.length - 1]!.push(entry) + } + } + const matches: string[][] = [] + for (let i = 0, { length } = segments; i < length; i += 1) { + const seg = segments[i]! + if (seg.length < cmdLine.length) { + continue + } + let ok = true + for (let j = 0, { length: cl } = cmdLine; j < cl; j += 1) { + if (seg[j] !== cmdLine[j]) { + ok = false + break + } + } + if (ok) { + matches.push(seg.slice(cmdLine.length)) + } + } + return matches +} + +/** + * Convenience: does `command` contain at least one invocation of + * `cmdLine`? Equivalent to `findInvocations(command, cmdLine).length > + * 0`. The most common audit-pattern shape. + */ +function commandInvokes( + command: string, + cmdLine: readonly string[], +): boolean { + return findInvocations(command, cmdLine).length > 0 +} + const PATTERNS: ReadonlyArray<{ severity: Finding['severity'] category: string @@ -106,9 +174,27 @@ const PATTERNS: ReadonlyArray<{ severity: 'critical', category: 'gh auth refresh -s workflow (workflow scope grant)', tool: 'Bash', - matches: c => - /\bgh\s+auth\s+refresh\b/.test(c) && - /(?:^|\s)(?:--scopes|-s)\b[^|;&]*\bworkflow\b/.test(c), + matches: c => { + // For each `gh auth refresh ...` invocation, check whether its + // args carry a `-s|--scopes ...workflow...` pair. The AST walk + // ensures we only inspect args of the actual gh invocation — + // `echo "gh auth refresh -s workflow"` doesn't trip the matcher. + const invocations = findInvocations(c, ['gh', 'auth', 'refresh']) + for (let i = 0, { length } = invocations; i < length; i += 1) { + const args = invocations[i]! + for (let j = 0, { length: al } = args; j < al; j += 1) { + const a = args[j] + if (a !== '-s' && a !== '--scopes') { + continue + } + const value = args[j + 1] ?? '' + if (value.includes('workflow')) { + return true + } + } + } + return false + }, }, { severity: 'critical', @@ -140,7 +226,7 @@ const PATTERNS: ReadonlyArray<{ category: 'sudo invocation (non-cached)', tool: 'Bash', matches: c => - /(?:^|\s|;|&&|\|\|)sudo\s+/.test(c) && !/\bsudo\s+-k\b/.test(c), + commandInvokes(c, ['sudo']) && !commandInvokes(c, ['sudo', '-k']), }, // WARN — unusual surfaces that should be checked. { @@ -223,6 +309,7 @@ function findRecentTranscript(): string | undefined { // `/` becomes the leading `-` automatically since the replace // operates on the whole path. (So `/Users/foo` → `-Users-foo`, not // `--Users-foo`.) + // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- audit-transcript intentionally reads the user-invoked cwd to look up the matching Claude Code transcript dir; anchoring on the script's own location would always return the wheelhouse transcripts. const encoded = process.cwd().replace(/\//g, '-') const dir = path.join(os.homedir(), '.claude', 'projects', encoded) if (!existsSync(dir)) { diff --git a/scripts/check-paths/exempt.mts b/scripts/check-paths/exempt.mts index a7f7031..3e755f3 100644 --- a/scripts/check-paths/exempt.mts +++ b/scripts/check-paths/exempt.mts @@ -17,7 +17,7 @@ export const EXEMPT_FILE_PATTERNS: RegExp[] = [ /scripts\/check-paths\.mts$/, /scripts\/check-paths\//, /scripts\/check-consistency\.mts$/, - /\.claude\/hooks\/path-guard\//, + /\.claude\/hooks\/fleet\/path-guard\//, // Allowlist + config files. /\.github\/paths-allowlist\.yml$/, ] diff --git a/scripts/check-paths/scan-code.mts b/scripts/check-paths/scan-code.mts index aea6d13..5149bc6 100644 --- a/scripts/check-paths/scan-code.mts +++ b/scripts/check-paths/scan-code.mts @@ -19,7 +19,7 @@ import { KNOWN_SIBLING_PACKAGES, MODE_SEGMENTS, STAGE_SEGMENTS, -} from '../../.claude/hooks/path-guard/segments.mts' +} from '../../.claude/hooks/fleet/path-guard/segments.mts' import { pushFinding } from './state.mts' // Locate `path.join(` or `path.resolve(` call sites; argument-list diff --git a/scripts/check-prompt-less-setup.mts b/scripts/check-prompt-less-setup.mts index adf1a6c..f35b1a3 100644 --- a/scripts/check-prompt-less-setup.mts +++ b/scripts/check-prompt-less-setup.mts @@ -326,7 +326,7 @@ function checkSocketTokenInEnv(): CheckResult { detail: 'SOCKET_API_KEY is not in the current env AND no shell-rc-bridge block is wired up. Hooks fall through to the keychain, which prompts on first access.', fix: - 'node .claude/hooks/setup-security-tools/install.mts\n' + + 'node .claude/hooks/fleet/setup-security-tools/install.mts\n' + ' # installs the shell-rc-bridge block; exports the token in every fresh shell', } } @@ -355,7 +355,7 @@ function checkKeychainTokenAcl(): CheckResult { detail: 'No socket-cli/SOCKET_API_KEY entry in the Keychain. Tools that fall back to keychain (when env is empty) will prompt for input on first use.', fix: - 'node .claude/hooks/setup-security-tools/install.mts\n' + + 'node .claude/hooks/fleet/setup-security-tools/install.mts\n' + ' # prompts for the token interactively and persists it to the Keychain with -T "" (any app can read).', } } diff --git a/scripts/validate-file-size.mts b/scripts/validate-file-size.mts index 6753363..5d10e32 100644 --- a/scripts/validate-file-size.mts +++ b/scripts/validate-file-size.mts @@ -32,7 +32,7 @@ const MAX_FILE_SIZE = 2 * 1024 * 1024 // intentional — it should only happen for files the fleet jointly owns, // not per-repo binary leaks. const ALLOWED_LARGE_FILES = new Set<string>([ - '.claude/hooks/_shared/acorn/acorn.wasm', + '.claude/hooks/fleet/_shared/acorn/acorn.wasm', 'vendor/acorn-wasm/acorn.wasm', ]) From dc7306caadae0b1798421ef6e5756107c72155d0 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:38:39 -0400 Subject: [PATCH 03/17] feat(scripts): seed cross-org publish allowlist + cascade groundwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-stages the source-allowlist machinery for socket-bin's role as publisher-of-record for cross-org standalone-CLI binary tails. Three files copy the fleet-canonical schema + library from socket-wheelhouse@9032f887; the fourth is socket-bin's own per-repo allowlist with the first authorized entry. - `scripts/util/pack-app-triplets.mts` — canonical pnpm pack-app platform triplets + helpers. Cascade-fed; identical to template. - `scripts/util/source-allowlist.mts` — schema for cross-org publish allowlists. Cascade-fed; identical to template. - `scripts/util/multi-package-publish.mts` — stager + verifier library: download GH Release assets, gh attestation verify, sha verify, extract, name/triplet conformance check, stamp version. Cascade-fed; identical to template. - `scripts/source-allowlist.mts` — socket-bin-OWNED data. Lists the authorized publish-tuples for `@socketbin/*` tails. First entry: ultrathink-acorn — authorizes `SocketDev/ultrathink`'s `build-rust.yml` to feed `@socketbin/acorn-<triplet>` tails when the release tag matches `acorn-rust-<semver>`. This commit lands the data + library shape. Next steps (separate PRs): the wrapping script that drives `stageMultiPackagePublish()` per publish dispatch, and the GitHub App for cross-repo workflow dispatch tokens (so ultrathink's build-rust.yml can chain to socket-bin's publish workflow without a long-lived PAT). --- scripts/source-allowlist.mts | 47 ++ scripts/util/multi-package-publish.mts | 591 +++++++++++++++++++++++++ scripts/util/pack-app-triplets.mts | 235 ++++++++++ scripts/util/source-allowlist.mts | 201 +++++++++ 4 files changed, 1074 insertions(+) create mode 100644 scripts/source-allowlist.mts create mode 100644 scripts/util/multi-package-publish.mts create mode 100644 scripts/util/pack-app-triplets.mts create mode 100644 scripts/util/source-allowlist.mts diff --git a/scripts/source-allowlist.mts b/scripts/source-allowlist.mts new file mode 100644 index 0000000..2110f49 --- /dev/null +++ b/scripts/source-allowlist.mts @@ -0,0 +1,47 @@ +/** + * @file Cross-org publish allowlist for socket-bin. Each entry authorizes + * one (source-repo, build-workflow, tag-pattern) tuple to publish under + * `@socketbin/<prefix><triplet>`. + * + * The allowlist is the trust boundary: adding a row is a PR review. + * Each row must independently pass the second trust gate — a successful + * `gh attestation verify --signer-workflow=<row.attestationSubject>` + * against the downloaded artifact — before publishing proceeds. + * + * @see scripts/util/source-allowlist.mts (cascade-fed from + * socket-wheelhouse template) for the `SourceAllowlistEntry` type. + * @see scripts/util/pack-app-triplets.mts for the canonical triplet set. + */ + +import { PACK_APP_TRIPLETS } from './util/pack-app-triplets.mts' +import { + buildAttestationSubject, + type SourceAllowlistEntry, +} from './util/source-allowlist.mts' + +/** + * Every authorized cross-org publish for `@socketbin/*` tail packages. + * Ordered by `familyId` ASCII byte order so additions sit deterministically. + */ +export const SOURCE_ALLOWLIST: readonly SourceAllowlistEntry[] = [ + // ultrathink/acorn — standalone parser CLI binaries built by ultrathink's + // Rust crate in `packages/acorn/lang/rust/crates/cli/`. The `build-rust.yml` + // matrix produces a per-triplet tarball, uploads them to a GH Release + // tagged `acorn-rust-<semver>`, and signs them via GitHub Actions OIDC. + // socket-bin verifies + republishes under `@socketbin/acorn-*`. + { + sourceRepo: 'SocketDev/ultrathink', + familyId: 'ultrathink-acorn', + workflowPath: '.github/workflows/build-rust.yml', + tagPattern: /^acorn-rust-\d+\.\d+\.\d+(?:-[\w.]+)?$/, + targetScope: '@socketbin', + namePrefix: 'acorn-', + triplets: PACK_APP_TRIPLETS, + attestationSubject: buildAttestationSubject({ + sourceRepo: 'SocketDev/ultrathink', + workflowPath: '.github/workflows/build-rust.yml', + tagGlob: 'acorn-rust-*', + }), + maintainer: '@jdalton', + }, +] as const satisfies readonly SourceAllowlistEntry[] diff --git a/scripts/util/multi-package-publish.mts b/scripts/util/multi-package-publish.mts new file mode 100644 index 0000000..ee5de5f --- /dev/null +++ b/scripts/util/multi-package-publish.mts @@ -0,0 +1,591 @@ +/* max-file-lines: legitimate state-machine — the trust-verification + stage pipeline is one phase; splitting would scatter the publish-attempt's failure-mode boundary. */ +/** + * @file Stager + verifier for cross-org binary-tail publishes. Consumed by + * socket-bin (standalone CLI tails) and socket-addon (.node NAPI tails) to + * download, verify, and stage tails built in a different repo before + * publishing them under the consumer's npm scope. This module does NOT call + * `npm publish`. It returns a structured staging result; the consumer's + * wrapping script loops the staged tails and invokes its own publish runner + * (fleet-canonical staged-publish flow, direct `pnpm publish`, etc.). The + * split lets each consumer pick its own publish-call shape without forking + * the verify-and-stage logic. Trust model — every successful stage requires + * ALL of: + * + * 1. Allowlist match — `findAllowlistEntry(allowlist, sourceRepo, releaseTag)` + * returns a row. + * 2. Tag conformance — release tag matches the row's `tagPattern` regex + * (anchored). + * 3. Triplet conformance — every downloaded archive's parsed triplet is in the + * row's `triplets` set. + * 4. Name conformance — every archive's `package.json.name` equals + * `buildTailPackageName(entry, triplet)`. + * 5. SHA verification — every archive's sha256 matches its line in the release's + * SHA256SUMS file. + * 6. Attestation verification — `gh attestation verify` against the row's + * `attestationSubject` passes for every archive AND for SHA256SUMS itself. + * Any failure aborts the whole family — no partial stage. The staging + * directory is left in place on failure for diagnostics; the consumer's + * wrapping script is responsible for cleanup on retry. + * + * @see ./source-allowlist.mts for `SourceAllowlistEntry` + helpers. + * @see ./pack-app-triplets.mts for the canonical triplet set. + */ + +import crypto from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' +import { errorMessage } from '@socketsecurity/lib-stable/errors' + +import { isPackAppTriplet, parseTripletSegment } from './pack-app-triplets.mts' +import type { PackAppTriplet } from './pack-app-triplets.mts' +import { + buildTailPackageName, + findAllowlistEntry, +} from './source-allowlist.mts' +import type { + GitHubRepoSlug, + SourceAllowlistEntry, +} from './source-allowlist.mts' + +const logger = getDefaultLogger() + +/** + * Configuration the consumer (socket-bin / socket-addon) supplies. Splits the + * per-publisher policy from the cross-publisher stage logic. + */ +export interface MultiPackagePublishConfig { + /** + * The consumer's source-allowlist. Imported by the consumer from its own + * `scripts/source-allowlist.mts` and passed in. + */ + readonly allowlist: readonly SourceAllowlistEntry[] + + /** + * GitHub `<owner>/<repo>` of the source repo whose release we're staging + * from. Used to look up the allowlist row + as the `--repo` arg for `gh + * release download` and `gh attestation verify`. + */ + readonly sourceRepo: GitHubRepoSlug + + /** + * Release tag in the source repo. Must match exactly one allowlist row's + * `tagPattern`. + */ + readonly releaseTag: string + + /** + * Locate this consumer's per-tail package directory for a given triplet. + * Returns the absolute path to the directory containing the tail's + * `package.json`. The directory MUST exist (the consumer pre-creates per-tail + * manifest dirs in its repo). + * + * Examples: + * + * - Socket-bin: `(triplet) => path.join(rootPath, 'packages', + * \`${prefix}${triplet}`)` + * - Socket-addon: `(triplet) => path.join(rootPath, 'packages', 'npm', + * '@socketaddon', \`${prefix}${triplet}`)` + */ + readonly tailDirFor: (triplet: PackAppTriplet) => string + + /** + * Relative path inside the tail directory where the extracted binary should + * land. Caller controls so a .node addon goes to e.g. `acorn.node` while a + * CLI goes to `bin/acorn` (or `bin/acorn.exe` for win32 triplets). + */ + readonly binaryPathInTail: (triplet: PackAppTriplet) => string + + /** + * Absolute path to the staging directory the library uses for extraction + + * verification scratch. Cleared at start, populated during run, left in place + * on failure. + */ + readonly stagingDir: string + + /** + * Optional dry-run flag. When true: download + verify + stage, but don't + * overwrite the consumer's `packages/<tail>/` tree. + */ + readonly dryRun?: boolean | undefined + + /** + * Optional triplet filter — if set, only stage these triplets even if the + * allowlist entry permits more. For partial-rebuild scenarios or smoke + * tests. + */ + readonly tripletsFilter?: readonly PackAppTriplet[] | undefined +} + +/** + * Per-tail staging outcome. Returned for every triplet attempted. + */ +export interface TailStageOutcome { + readonly triplet: PackAppTriplet + readonly tailName: string + readonly version: string + readonly tailDir: string + readonly stagedBinary: string + readonly stagedManifest: string + readonly sha256: string +} + +/** + * Top-level result of a staging run. Either every tail in the requested set + * succeeded, or the run aborted at the first failure and `tails` is empty. + */ +export interface MultiPackagePublishResult { + readonly entry: SourceAllowlistEntry + readonly releaseTag: string + readonly version: string + readonly tails: readonly TailStageOutcome[] +} + +/** + * Thrown when a stage attempt fails. Carries the stage where it failed + the + * offending tail (if known) so the wrapping script can render a focused error. + */ +export class MultiPackageStageError extends Error { + readonly stage: + | 'allowlist-miss' + | 'tag-version-parse' + | 'download' + | 'sha-mismatch' + | 'sha-list-missing' + | 'attestation' + | 'archive-extract' + | 'tail-dir-missing' + | 'triplet-conformance' + | 'name-conformance' + | 'manifest-write' + readonly triplet?: PackAppTriplet | undefined + + constructor( + message: string, + stage: MultiPackageStageError['stage'], + triplet?: PackAppTriplet, + ) { + super(message) + this.name = 'MultiPackageStageError' + this.stage = stage + this.triplet = triplet + } +} + +/** + * Stage every tail in a cross-org publish request. Returns the structured + * staging result on success; throws `MultiPackageStageError` on any failure + * (the first one encountered — fail-fast). + * + * The consumer's wrapping script is responsible for invoking the actual `npm + * publish` per `tails[i]` after this returns. + */ +export async function stageMultiPackagePublish( + config: MultiPackagePublishConfig, +): Promise<MultiPackagePublishResult> { + // Stage 1 — allowlist match. + const entry = findAllowlistEntry( + config.allowlist, + config.sourceRepo, + config.releaseTag, + ) + if (!entry) { + throw new MultiPackageStageError( + `No allowlist row matches ${config.sourceRepo} tag ${config.releaseTag}. Add a SourceAllowlistEntry or correct the inputs.`, + 'allowlist-miss', + ) + } + logger.log( + `Matched allowlist row: ${entry.familyId} → ${entry.targetScope}/${entry.namePrefix}*`, + ) + + // Stage 2 — parse the version segment off the release tag. Used to + // stamp every tail's package.json + to validate triplet conformance + // when archive names are version-suffixed. + const version = extractVersionFromTag(config.releaseTag, entry.tagPattern) + if (!version) { + throw new MultiPackageStageError( + `Could not extract a semver-shaped version from tag ${config.releaseTag} (pattern ${entry.tagPattern}).`, + 'tag-version-parse', + ) + } + logger.log(`Version segment: ${version}`) + + // Stage 3 — reset staging dir. + await safeDelete(config.stagingDir, { force: true }) + await safeMkdir(config.stagingDir, { recursive: true }) + + // Stage 4 — download release assets. + logger.log( + `Downloading release assets: ${config.sourceRepo} @ ${config.releaseTag}`, + ) + const downloadResult = await runGh( + [ + 'release', + 'download', + config.releaseTag, + '--repo', + config.sourceRepo, + '--dir', + config.stagingDir, + ], + config.stagingDir, + ) + if (downloadResult.code !== 0) { + throw new MultiPackageStageError( + `gh release download failed (exit ${downloadResult.code}): ${downloadResult.stderr}`, + 'download', + ) + } + + // Stage 5 — read SHA256SUMS. + const sumsPath = path.join(config.stagingDir, 'SHA256SUMS') + if (!existsSync(sumsPath)) { + throw new MultiPackageStageError( + `Release ${config.releaseTag} has no SHA256SUMS file. Refusing to stage without a hash manifest.`, + 'sha-list-missing', + ) + } + const sums = parseShaSums(readFileSync(sumsPath, 'utf8')) + + // Stage 6 — verify SHA256SUMS itself is attested. + logger.log('Verifying SHA256SUMS attestation') + await verifyAttestation(sumsPath, config.sourceRepo, entry.attestationSubject) + + // Stage 7 — for each requested triplet, find + verify + extract + stage. + const tripletsToStage = config.tripletsFilter ?? entry.triplets + const tripletSet = new Set<PackAppTriplet>(entry.triplets) + const outcomes: TailStageOutcome[] = [] + for (let i = 0, { length } = tripletsToStage; i < length; i += 1) { + const triplet = tripletsToStage[i]! + if (!tripletSet.has(triplet)) { + throw new MultiPackageStageError( + `Requested triplet ${triplet} is not in the allowlist row's triplets set.`, + 'triplet-conformance', + triplet, + ) + } + + // Find the archive matching this triplet. Convention: + // `<prefix><triplet>.tgz` or `<prefix><triplet>.tar.gz`. + const archiveName = findArchiveForTriplet( + config.stagingDir, + entry.namePrefix, + triplet, + ) + if (!archiveName) { + throw new MultiPackageStageError( + `No archive in release for triplet ${triplet} (expected ${entry.namePrefix}${triplet}.{tgz,tar.gz}).`, + 'download', + triplet, + ) + } + const archivePath = path.join(config.stagingDir, archiveName) + + // Verify sha against SHA256SUMS. + const actualSha = sha256Of(archivePath) + const expectedSha = sums.get(archiveName) + if (!expectedSha) { + throw new MultiPackageStageError( + `${archiveName} not listed in SHA256SUMS.`, + 'sha-mismatch', + triplet, + ) + } + if (actualSha !== expectedSha) { + throw new MultiPackageStageError( + `${archiveName} sha256 mismatch: got ${actualSha}, expected ${expectedSha}.`, + 'sha-mismatch', + triplet, + ) + } + + // Verify per-archive attestation. + // eslint-disable-next-line no-await-in-loop + await verifyAttestation( + archivePath, + config.sourceRepo, + entry.attestationSubject, + ) + + // Extract. + const extractDir = path.join(config.stagingDir, `extract-${triplet}`) + // eslint-disable-next-line no-await-in-loop + await safeMkdir(extractDir, { recursive: true }) + // eslint-disable-next-line no-await-in-loop + const extractResult = await runCommand( + 'tar', + ['-xzf', archivePath, '-C', extractDir], + config.stagingDir, + ) + if (extractResult.code !== 0) { + throw new MultiPackageStageError( + `tar extract failed for ${archiveName}: ${extractResult.stderr}`, + 'archive-extract', + triplet, + ) + } + + // Validate name conformance from extracted manifest. + const extractedManifestPath = path.join(extractDir, 'package.json') + if (!existsSync(extractedManifestPath)) { + throw new MultiPackageStageError( + `Extracted archive ${archiveName} has no package.json at the top level.`, + 'archive-extract', + triplet, + ) + } + const extractedManifest = JSON.parse( + readFileSync(extractedManifestPath, 'utf8'), + ) as { name?: string | undefined; version?: string | undefined } + const expectedName = buildTailPackageName(entry, triplet) + if (extractedManifest.name !== expectedName) { + throw new MultiPackageStageError( + `Extracted ${archiveName} name mismatch: got ${extractedManifest.name}, expected ${expectedName}.`, + 'name-conformance', + triplet, + ) + } + + // Stage into consumer's per-tail dir (unless dry-run). + const tailDir = config.tailDirFor(triplet) + if (!existsSync(tailDir)) { + throw new MultiPackageStageError( + `Consumer tail directory missing: ${tailDir}. Pre-create the package.json manifest before staging.`, + 'tail-dir-missing', + triplet, + ) + } + + const binaryRelative = config.binaryPathInTail(triplet) + const stagedBinary = path.join(tailDir, binaryRelative) + const stagedManifest = path.join(tailDir, 'package.json') + + if (!config.dryRun) { + // Read the consumer's tail manifest, stamp version, write back. + const consumerManifestRaw = readFileSync(stagedManifest, 'utf8') + const consumerManifest = JSON.parse(consumerManifestRaw) as { + name?: string | undefined + } + if (consumerManifest.name !== expectedName) { + throw new MultiPackageStageError( + `Consumer manifest at ${stagedManifest} declares name ${consumerManifest.name}; expected ${expectedName}.`, + 'name-conformance', + triplet, + ) + } + const stampedManifest = JSON.stringify( + { ...consumerManifest, version }, + undefined, + 2, + ) + try { + writeFileSync(stagedManifest, `${stampedManifest}\n`, 'utf8') + } catch (e) { + throw new MultiPackageStageError( + `Failed to write stamped manifest at ${stagedManifest}: ${errorMessage(e)}`, + 'manifest-write', + triplet, + ) + } + + // Move the extracted binary into place. The extracted layout + // mirrors the published tail, so the binary's relative path + // inside the extract matches binaryRelative. + const extractedBinary = path.join(extractDir, binaryRelative) + if (!existsSync(extractedBinary)) { + throw new MultiPackageStageError( + `Extracted archive ${archiveName} has no binary at ${binaryRelative} (relative to archive root).`, + 'archive-extract', + triplet, + ) + } + // eslint-disable-next-line no-await-in-loop + await safeMkdir(path.dirname(stagedBinary), { recursive: true }) + writeFileSync(stagedBinary, readFileSync(extractedBinary)) + } + + outcomes.push({ + triplet, + tailName: expectedName, + version, + tailDir, + stagedBinary, + stagedManifest, + sha256: actualSha, + }) + + logger.success( + `Staged ${expectedName}@${version}${config.dryRun ? ' [dry-run]' : ''}`, + ) + } + + return { + entry, + releaseTag: config.releaseTag, + version, + tails: outcomes, + } +} + +/** + * Extract the version segment from a release tag by inverting the allowlist + * row's pattern. Strategy: strip the longest non-version prefix that the + * pattern enforces, then return what remains. + * + * Works for the common shape `<family>-<semver>` (the pattern's literal prefix + * is everything before `\d`). For more exotic patterns the caller can override + * by post-processing the result. + */ +export function extractVersionFromTag( + tag: string, + pattern: RegExp, +): string | undefined { + // Try to find the version directly via a sub-match. Common patterns + // use `\d+\.\d+\.\d+(?:-[\w.]+)?` for the version. + const versionMatch = tag.match(/\d+\.\d+\.\d+(?:-[\w.]+)?$/) + if (!versionMatch) { + return undefined + } + // Sanity check the full tag still matches the allowlist pattern. + if (!pattern.test(tag)) { + return undefined + } + return versionMatch[0] +} + +/** + * Parse a SHA256SUMS file (one `<sha> <filename>` line per archive) into a map + * keyed by filename. + */ +export function parseShaSums(text: string): Map<string, string> { + const result = new Map<string, string>() + const lines = text.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]!.trim() + if (!line || line.startsWith('#')) { + continue + } + // Format: `<64-hex> <filename>` (two spaces per coreutils sha256sum). + const match = line.match(/^([0-9a-f]{64})\s+(?:\*)?(.+)$/i) + if (match) { + result.set(match[2]!.trim(), match[1]!.toLowerCase()) + } + } + return result +} + +/** + * Find the archive in `dir` matching the family prefix + triplet. Accepts + * `.tgz` or `.tar.gz` suffix. Returns the basename or undefined. + */ +export function findArchiveForTriplet( + dir: string, + namePrefix: string, + triplet: PackAppTriplet, +): string | undefined { + const candidates = [ + `${namePrefix}${triplet}.tgz`, + `${namePrefix}${triplet}.tar.gz`, + ] + for (let i = 0, { length } = candidates; i < length; i += 1) { + const candidate = candidates[i]! + if (existsSync(path.join(dir, candidate))) { + return candidate + } + } + return undefined +} + +/** + * Compute sha256 hex digest of a file's contents. + */ +export function sha256Of(filePath: string): string { + const buf = readFileSync(filePath) + return crypto.createHash('sha256').update(buf).digest('hex') +} + +/** + * Wrap `gh attestation verify` against the row's signer-workflow. Throws + * `MultiPackageStageError` on non-zero exit so the caller's try/catch chain + * stops the stage. + */ +export async function verifyAttestation( + artifactPath: string, + sourceRepo: GitHubRepoSlug, + signerWorkflow: string, +): Promise<void> { + const result = await runGh( + [ + 'attestation', + 'verify', + artifactPath, + '--repo', + sourceRepo, + '--signer-workflow', + signerWorkflow, + ], + path.dirname(artifactPath), + ) + if (result.code !== 0) { + throw new MultiPackageStageError( + `gh attestation verify failed for ${path.basename(artifactPath)} (exit ${result.code}): ${result.stderr}`, + 'attestation', + parseTripletSegment( + path.basename(artifactPath).replace(/\.(?:tar\.gz|tgz)$/, ''), + ), + ) + } +} + +/** + * Validate that a CLI-supplied string is one of the canonical triplets. Throws + * `MultiPackageStageError` if not, so CLI parsing surfaces a proper error. + */ +export function assertTripletList(values: readonly string[]): PackAppTriplet[] { + const result: PackAppTriplet[] = [] + for (let i = 0, { length } = values; i < length; i += 1) { + const value = values[i]! + if (!isPackAppTriplet(value)) { + throw new MultiPackageStageError( + `${value} is not a canonical pnpm pack-app triplet.`, + 'triplet-conformance', + ) + } + result.push(value) + } + return result +} + +interface RunResult { + readonly code: number + readonly stdout: string + readonly stderr: string +} + +async function runCommand( + cmd: string, + args: readonly string[], + cwd: string, +): Promise<RunResult> { + const result = await spawn(cmd, [...args], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + stdioString: true, + }) + return { + code: result.code ?? 1, + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + } +} + +async function runGh( + args: readonly string[], + cwd: string, +): Promise<RunResult> { + return runCommand('gh', args, cwd) +} diff --git a/scripts/util/pack-app-triplets.mts b/scripts/util/pack-app-triplets.mts new file mode 100644 index 0000000..0f86c00 --- /dev/null +++ b/scripts/util/pack-app-triplets.mts @@ -0,0 +1,235 @@ +/** + * @file Canonical platform-triplet identifiers, matching pnpm pack-app's + * supported targets. Single source of truth for fleet surfaces that enumerate + * platforms: tail-package manifest generators, meta-package runtime loaders + * (resolve current process → triplet → + * `require.resolve('@<scope>/<prefix>-<triplet>/bin/<name>')`), + * source-allowlist entries, and lint rules that validate tail-name suffixes + * against the known set. Sorted ASCII byte order so the list reads + * identically to `socket/sort-named-imports` / `sort-source-methods` + * enforcement elsewhere — every consumer that wants priority order sorts + * downstream. + * + * @see https://pnpm.io/11.x/cli/pack-app for the upstream triplet spec. + */ + +/** + * Every platform triplet pnpm pack-app supports, in ASCII order. + * + * Linux gets four variants (glibc + musl × arm64 + x64). macOS and Windows get + * two each (arm64 + x64). The `-musl` qualifier is Linux-only. + */ +export const PACK_APP_TRIPLETS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-arm64-musl', + 'linux-x64', + 'linux-x64-musl', + 'win32-arm64', + 'win32-x64', +] as const + +/** + * Literal-union type derived from `PACK_APP_TRIPLETS`. Use as a type annotation + * everywhere a triplet appears so a typo at the call site fails compile. + */ +export type PackAppTriplet = (typeof PACK_APP_TRIPLETS)[number] + +/** + * Linux-only subset (glibc + musl × arm64 + x64). For package families that + * ship Linux binaries without macOS / Windows support. + */ +export const PACK_APP_TRIPLETS_LINUX = [ + 'linux-arm64', + 'linux-arm64-musl', + 'linux-x64', + 'linux-x64-musl', +] as const satisfies readonly PackAppTriplet[] + +/** + * MacOS-only subset (arm64 + x64). + */ +export const PACK_APP_TRIPLETS_DARWIN = [ + 'darwin-arm64', + 'darwin-x64', +] as const satisfies readonly PackAppTriplet[] + +/** + * Windows-only subset (arm64 + x64). + */ +export const PACK_APP_TRIPLETS_WIN32 = [ + 'win32-arm64', + 'win32-x64', +] as const satisfies readonly PackAppTriplet[] + +/** + * Glibc-only subset (excludes musl). For families whose Linux build doesn't + * support musl distros (Alpine, …). + */ +export const PACK_APP_TRIPLETS_GLIBC = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +] as const satisfies readonly PackAppTriplet[] + +/** + * O(1) membership set for hot paths (lint rules, allowlist validators). + * Materialized once at module load. + */ +export const PACK_APP_TRIPLET_SET: ReadonlySet<PackAppTriplet> = new Set( + PACK_APP_TRIPLETS, +) + +/** + * Type-guard: is `value` one of the canonical triplets? + * + * Use at trust boundaries — anywhere an untrusted string (CLI arg, env var, + * release-tag-parsing output) is about to be used as a triplet. + */ +export function isPackAppTriplet(value: unknown): value is PackAppTriplet { + return ( + typeof value === 'string' && + PACK_APP_TRIPLET_SET.has(value as PackAppTriplet) + ) +} + +/** + * Inputs to `resolveCurrentTriplet`. Pure data so the function is unit-testable + * without mocking `process` or filesystem libc detection. + */ +export interface CurrentTripletInputs { + /** + * `process.platform` value. + */ + readonly platform: NodeJS.Platform + /** + * `process.arch` value. + */ + readonly arch: string + /** + * Whether the current Linux runtime uses musl libc. Ignored on non-Linux. + * Detection is the caller's job (typically by probing + * `/proc/self/map_files/../maps` or `ldd --version`). + */ + readonly isMusl: boolean +} + +/** + * Pure-function triplet resolver. Returns the canonical triplet for the given + * runtime inputs, or `undefined` if no triplet matches (running on an + * unsupported platform or arch). + * + * Examples: - `{ platform: 'darwin', arch: 'arm64', isMusl: false }` → + * `darwin-arm64` - `{ platform: 'linux', arch: 'x64', isMusl: true }` → + * `linux-x64-musl` - `{ platform: 'sunos', arch: 'sparc', isMusl: false }` → + * `undefined` + */ +export function resolveCurrentTriplet( + inputs: CurrentTripletInputs, +): PackAppTriplet | undefined { + const { platform, arch, isMusl } = inputs + + // Only Linux carries the libc qualifier. + if (platform === 'linux') { + if (arch === 'arm64') { + return isMusl ? 'linux-arm64-musl' : 'linux-arm64' + } + if (arch === 'x64') { + return isMusl ? 'linux-x64-musl' : 'linux-x64' + } + return undefined + } + + if (platform === 'darwin') { + if (arch === 'arm64') { + return 'darwin-arm64' + } + if (arch === 'x64') { + return 'darwin-x64' + } + return undefined + } + + if (platform === 'win32') { + if (arch === 'arm64') { + return 'win32-arm64' + } + if (arch === 'x64') { + return 'win32-x64' + } + return undefined + } + + return undefined +} + +/** + * Parse a triplet suffix off the end of a tail-package name. Returns the + * triplet if the name ends in one, `undefined` otherwise. + * + * Greedy-match against the canonical set so `linux-arm64-musl` wins over + * `linux-arm64` when both could match — the longer triplet always sorts before + * the shorter prefix in the constant list, so the first match wins. + * + * Examples: - `parseTripletSegment('acorn-linux-arm64-musl')` → + * `linux-arm64-musl` - `parseTripletSegment('stuie-yoga-darwin-arm64')` → + * `darwin-arm64` - `parseTripletSegment('acorn-wasm')` → `undefined` + */ +export function parseTripletSegment(name: string): PackAppTriplet | undefined { + // Iterate longest-suffix-first so musl forms win over their glibc + // shortenings. + const ordered = PACK_APP_TRIPLETS.toSorted((a, b) => b.length - a.length) + for (let i = 0, { length } = ordered; i < length; i += 1) { + const triplet = ordered[i]! + if (name === triplet || name.endsWith(`-${triplet}`)) { + return triplet + } + } + return undefined +} + +/** + * The `os` / `cpu` / `libc` package.json fields for a given triplet. Tail + * manifest generators stamp these directly so a tail can never resolve on the + * wrong platform. + */ +export interface TripletEngineFields { + readonly os: readonly [NodeJS.Platform] + readonly cpu: readonly [string] + readonly libc?: readonly ['glibc' | 'musl'] | undefined +} + +/** + * Resolve the package.json engine-restriction fields (`os`, `cpu`, optionally + * `libc`) for a triplet. Used by tail-manifest generators. + */ +export function tripletEngineFields( + triplet: PackAppTriplet, +): TripletEngineFields { + if (triplet === 'darwin-arm64') { + return { os: ['darwin'], cpu: ['arm64'] } + } + if (triplet === 'darwin-x64') { + return { os: ['darwin'], cpu: ['x64'] } + } + if (triplet === 'linux-arm64') { + return { os: ['linux'], cpu: ['arm64'], libc: ['glibc'] } + } + if (triplet === 'linux-arm64-musl') { + return { os: ['linux'], cpu: ['arm64'], libc: ['musl'] } + } + if (triplet === 'linux-x64') { + return { os: ['linux'], cpu: ['x64'], libc: ['glibc'] } + } + if (triplet === 'linux-x64-musl') { + return { os: ['linux'], cpu: ['x64'], libc: ['musl'] } + } + if (triplet === 'win32-arm64') { + return { os: ['win32'], cpu: ['arm64'] } + } + return { os: ['win32'], cpu: ['x64'] } +} diff --git a/scripts/util/source-allowlist.mts b/scripts/util/source-allowlist.mts new file mode 100644 index 0000000..c152a59 --- /dev/null +++ b/scripts/util/source-allowlist.mts @@ -0,0 +1,201 @@ +/** + * @file Schema for cross-org publish allowlists used by infrastructure + * publishers (socket-addon, socket-bin). Each entry authorizes one + * (source-repo, build-workflow, tag-pattern) tuple to feed one (target-scope, + * name-prefix, triplet-set) family of npm tail packages. The allowlist is the + * trust boundary: an authorized source can mint binaries; an unauthorized + * source cannot. Adding a row is a PR review. This file declares only the + * SHAPE. Each consumer repo (socket-addon / socket-bin) ships its own + * `scripts/source-allowlist.mts` that imports `SourceAllowlistEntry` from + * here and exports an array of entries typed against it. The publish runner + * reads its repo's array and refuses to publish anything not matched by a + * row. Threat-model boundary: the allowlist DOES NOT verify that the bytes + * downloaded from a release are what the workflow signed. That's the job of + * GitHub's artifact-attestation API (`gh attestation verify + * --signer-workflow=…`). A row's `attestationSubject` field is the literal + * string to pass to that command. Allowlist + attestation are layered: + * allowlist answers "may I publish?", attestation answers "did the right + * workflow produce these bytes?". Both must hold. + * + * @see ./pack-app-triplets.mts for `PackAppTriplet`. + * @see Fleet plan `publishing-ultrathink-acorn-audit.md` for the topology. + */ + +import type { PackAppTriplet } from './pack-app-triplets.mts' + +/** + * Npm scope authorized to publish binary tails. Limited to the two + * fleet-infrastructure scopes — extending this union is a fleet-level decision + * (new scope = new publisher repo = new trust boundary). + */ +export type SourceAllowlistTargetScope = '@socketaddon' | '@socketbin' + +/** + * Workflow path under a source repo's `.github/workflows/` directory. Encoded + * as a template literal so a typo at compile time hurts. + */ +export type SourceAllowlistWorkflowPath = + | `.github/workflows/${string}.yml` + | `.github/workflows/${string}.yaml` + +/** + * GitHub `<owner>/<repo>` slug. Stricter than a bare string — at least one + * slash, no leading / trailing slash. + */ +export type GitHubRepoSlug = `${string}/${string}` + +/** + * One authorized publish-tuple. Each row is independently revokable — delete it + * and that family can no longer publish through this consumer. + */ +export interface SourceAllowlistEntry { + /** + * GitHub `<owner>/<repo>` of the authorized source. The repo whose + * `releases/` the publisher reads from. + */ + readonly sourceRepo: GitHubRepoSlug + + /** + * Human label used in logs, audit trails, and PR descriptions. Should + * uniquely identify the family within the consumer repo — `stuie-yoga`, + * `ultrathink-acorn`, etc. + */ + readonly familyId: string + + /** + * Path inside `sourceRepo` to the build workflow authorized to produce + * releases for this family. The publisher accepts releases only from this + * workflow — releases manually created (via `gh release create`) or produced + * by a different workflow are refused. + */ + readonly workflowPath: SourceAllowlistWorkflowPath + + /** + * Anchored regex matching the release-tag shape for this family. Must use + * `^…$` anchors. Typical pattern: `^acorn-rust-\d+\.\d+\.\d+(-\S+)?$` for a + * family that tags `acorn-rust-1.2.0` or `acorn-rust-1.2.0-alpha.0`. + * + * The pattern is the second authorization layer (after `workflowPath`): even + * an authorized workflow can produce releases for other purposes (debug + * builds, internal smoke runs); only releases whose tag matches this pattern + * are eligible. + */ + readonly tagPattern: RegExp + + /** + * Npm scope this family publishes under. One of the fleet infrastructure + * scopes. + */ + readonly targetScope: SourceAllowlistTargetScope + + /** + * Name prefix for every tail in this family. Tail name is + * `${namePrefix}${triplet}`. Example: prefix `stuie-yoga-` → tail + * `@socketaddon/stuie-yoga-darwin-arm64`. + * + * Convention: `<source-project>-<package>-` so the prefix carries both the + * upstream project name and the package name. The trailing hyphen is REQUIRED + * so the publisher can rely on `${prefix}${triplet}` concatenation. + */ + readonly namePrefix: `${string}-` + + /** + * Triplet set this family ships for. Subset of `PACK_APP_TRIPLETS`. Refusing + * to publish a triplet not in this set defends against the source repo trying + * to publish for unexpected platforms (e.g. a Linux-only family suddenly + * shipping a Windows tail). + */ + readonly triplets: readonly PackAppTriplet[] + + /** + * Sigstore signer-subject expected on artifact attestations. Passed verbatim + * to `gh attestation verify --signer-workflow=<this>`. + * + * Format: + * `https://github.com/<owner>/<repo>/.github/workflows/<wf>@refs/tags/<pattern>`. + * Derived from `sourceRepo` + `workflowPath` + the tag pattern, but + * materialized explicitly so the verifier has a single comparison string and + * any drift between the regex and the attestation surfaces as a review-time + * mismatch, not a runtime surprise. + */ + readonly attestationSubject: string + + /** + * Optional maintainer label. Surfaces in publish audit logs and the fleet's + * PR-review trail for changes to the allowlist. Free-form; typical value is a + * GitHub handle or a team alias. + */ + readonly maintainer?: string | undefined +} + +/** + * Build the canonical `attestationSubject` string for an allowlist row. + * Centralized so every consumer derives the same shape — drift between "what + * the verifier expects" and "what the build attests to" is the exact failure + * mode this helper prevents. + * + * @example + * buildAttestationSubject({ + * sourceRepo: 'SocketDev/ultrathink', + * workflowPath: '.github/workflows/build-rust.yml', + * tagGlob: 'acorn-rust-*', + * }) + * // → 'https://github.com/SocketDev/ultrathink/.github/workflows/build-rust.yml@refs/tags/acorn-rust-*' + */ +export function buildAttestationSubject(input: { + readonly sourceRepo: GitHubRepoSlug + readonly workflowPath: SourceAllowlistWorkflowPath + readonly tagGlob: string +}): string { + return `https://github.com/${input.sourceRepo}/${input.workflowPath}@refs/tags/${input.tagGlob}` +} + +/** + * Look up the allowlist row that matches a `(sourceRepo, releaseTag)` pair. + * Returns the entry if both `sourceRepo === entry.sourceRepo` and + * `entry.tagPattern.test(releaseTag)`; returns `undefined` if no row matches. + * + * The publisher calls this at step 1 of every publish attempt. A `undefined` + * return means "refuse the publish." + * + * If multiple rows match (same source repo + overlapping tag patterns), the + * first match wins — author the allowlist so this never happens. A future lint + * rule can sanity-check that no two rows in the same consumer could ever both + * match the same tag. + */ +export function findAllowlistEntry( + allowlist: readonly SourceAllowlistEntry[], + sourceRepo: GitHubRepoSlug, + releaseTag: string, +): SourceAllowlistEntry | undefined { + for (let i = 0, { length } = allowlist; i < length; i += 1) { + const entry = allowlist[i]! + if (entry.sourceRepo === sourceRepo && entry.tagPattern.test(releaseTag)) { + return entry + } + } + return undefined +} + +/** + * Compute the full tail-package name for a `(entry, triplet)` pair. Pure + * concatenation; centralized so callers can't misjoin the prefix. + * + * @example + * buildTailPackageName(entry, 'darwin-arm64') + * // → '@socketaddon/stuie-yoga-darwin-arm64' + */ +export function buildTailPackageName( + entry: SourceAllowlistEntry, + triplet: PackAppTriplet, +): string { + return `${entry.targetScope}/${entry.namePrefix}${triplet}` +} + +/** + * Sentinel empty allowlist — useful for tests and for fresh-clone state where + * the consumer hasn't yet declared any entries. Typed so an uninitialized + * consumer can do `const ALLOWLIST: readonly SourceAllowlistEntry[] = + * EMPTY_ALLOWLIST` without TypeScript complaining about widening. + */ +export const EMPTY_ALLOWLIST: readonly SourceAllowlistEntry[] = [] From fa9ab7ef5a9c38c7dd54cdb10a5c03dcc768bd40 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:40:27 -0400 Subject: [PATCH 04/17] fix(hooks): remove doubled-directory orphans from earlier migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `fix(hooks): migrate .claude/hooks/<name>/ → .claude/hooks/fleet/<name>/` (cc8f2c0) left behind doubled directory paths like `.claude/hooks/fleet/<name>/<name>/`. The real hook files already live at `.claude/hooks/fleet/<name>/<file>`; the second nesting layer's files are stale duplicates left over from rsync / mv glob mistakes during the original migration. Removes 79 stale files (~7k lines) across the doubled paths. The canonical hook surface at `.claude/hooks/fleet/<name>/<file>` is untouched. Touched hooks: comment-tone-reminder, commit-pr-reminder, file-size-reminder, follow-direct-imperative-reminder, inline-script-defer-guard, minify-mcp-output, no-blind-keychain-read-guard, no-meta-comments-guard, no-orphaned-staging, overeager-staging-guard, parallel-agent-*, prefer-function-declaration-guard, socket-token-minifier-start, stale-process-sweeper. --- .../comment-tone-reminder/README.md | 34 -- .../comment-tone-reminder/index.mts | 53 --- .../comment-tone-reminder/package.json | 15 - .../comment-tone-reminder/test/index.test.mts | 117 ------ .../comment-tone-reminder/tsconfig.json | 16 - .../commit-pr-reminder/README.md | 19 - .../commit-pr-reminder/index.mts | 46 --- .../commit-pr-reminder/package.json | 15 - .../commit-pr-reminder/test/index.test.mts | 79 ---- .../commit-pr-reminder/tsconfig.json | 16 - .../file-size-reminder/README.md | 52 --- .../file-size-reminder/index.mts | 218 ----------- .../file-size-reminder/package.json | 15 - .../file-size-reminder/test/index.test.mts | 196 ---------- .../file-size-reminder/tsconfig.json | 16 - .../README.md | 44 --- .../index.mts | 313 --------------- .../package.json | 15 - .../test/index.test.mts | 111 ------ .../tsconfig.json | 16 - .../inline-script-defer-guard/README.md | 53 --- .../inline-script-defer-guard/index.mts | 190 ---------- .../inline-script-defer-guard/package.json | 15 - .../test/index.test.mts | 134 ------- .../inline-script-defer-guard/tsconfig.json | 16 - .../minify-mcp-output/README.md | 85 ----- .../minify-mcp-output/index.mts | 154 -------- .../minify-mcp-output/package.json | 12 - .../minify-mcp-output/test/index.test.mts | 164 -------- .../minify-mcp-output/tsconfig.json | 16 - .../no-blind-keychain-read-guard/README.md | 65 ---- .../no-blind-keychain-read-guard/index.mts | 229 ----------- .../no-blind-keychain-read-guard/package.json | 15 - .../test/index.test.mts | 142 ------- .../tsconfig.json | 16 - .../no-meta-comments-guard/README.md | 34 -- .../no-meta-comments-guard/index.mts | 358 ------------------ .../no-meta-comments-guard/package.json | 15 - .../test/index.test.mts | 261 ------------- .../no-meta-comments-guard/tsconfig.json | 16 - .../no-orphaned-staging/README.md | 49 --- .../no-orphaned-staging/index.mts | 113 ------ .../no-orphaned-staging/package.json | 15 - .../no-orphaned-staging/test/index.test.mts | 127 ------- .../no-orphaned-staging/tsconfig.json | 16 - .../overeager-staging-guard/index.mts | 191 ---------- .../overeager-staging-guard/package.json | 15 - .../test/index.test.mts | 319 ---------------- .../overeager-staging-guard/tsconfig.json | 16 - .../fleet/path-guard/path-guard/README.md | 113 ------ .../fleet/path-guard/path-guard/index.mts | 351 ----------------- .../fleet/path-guard/path-guard/package.json | 12 - .../fleet/path-guard/path-guard/segments.mts | 74 ---- .../path-guard/test/path-guard.test.mts | 311 --------------- .../fleet/path-guard/path-guard/tsconfig.json | 16 - .../perfectionist-reminder/README.md | 53 --- .../perfectionist-reminder/index.mts | 78 ---- .../perfectionist-reminder/package.json | 15 - .../test/index.test.mts | 137 ------- .../perfectionist-reminder/tsconfig.json | 16 - .../plan-location-guard/README.md | 55 --- .../plan-location-guard/index.mts | 304 --------------- .../plan-location-guard/package.json | 18 - .../plan-location-guard/test/index.test.mts | 216 ----------- .../plan-location-guard/tsconfig.json | 16 - .../public-surface-reminder/README.md | 86 ----- .../public-surface-reminder/index.mts | 87 ----- .../public-surface-reminder/package.json | 12 - .../test/public-surface-reminder.test.mts | 95 ----- .../public-surface-reminder/tsconfig.json | 16 - .../setup-claude-scanners/README.md | 39 -- .../setup-claude-scanners/install.mts | 45 --- .../setup-claude-scanners/package.json | 16 - .../setup-claude-scanners/tsconfig.json | 16 - .../stale-process-sweeper/README.md | 94 ----- .../stale-process-sweeper/index.mts | 320 ---------------- .../stale-process-sweeper/package.json | 12 - .../test/stale-process-sweeper.test.mts | 92 ----- .../stale-process-sweeper/tsconfig.json | 16 - 79 files changed, 6988 deletions(-) delete mode 100644 .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md delete mode 100644 .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/index.mts delete mode 100644 .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/package.json delete mode 100644 .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/test/index.test.mts delete mode 100644 .claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/tsconfig.json delete mode 100644 .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md delete mode 100644 .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/index.mts delete mode 100644 .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/package.json delete mode 100644 .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/test/index.test.mts delete mode 100644 .claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/tsconfig.json delete mode 100644 .claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md delete mode 100644 .claude/hooks/fleet/file-size-reminder/file-size-reminder/index.mts delete mode 100644 .claude/hooks/fleet/file-size-reminder/file-size-reminder/package.json delete mode 100644 .claude/hooks/fleet/file-size-reminder/file-size-reminder/test/index.test.mts delete mode 100644 .claude/hooks/fleet/file-size-reminder/file-size-reminder/tsconfig.json delete mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md delete mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/index.mts delete mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/package.json delete mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/test/index.test.mts delete mode 100644 .claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/tsconfig.json delete mode 100644 .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md delete mode 100644 .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts delete mode 100644 .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/package.json delete mode 100644 .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/test/index.test.mts delete mode 100644 .claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/tsconfig.json delete mode 100644 .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/README.md delete mode 100644 .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts delete mode 100644 .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/package.json delete mode 100644 .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/test/index.test.mts delete mode 100644 .claude/hooks/fleet/minify-mcp-output/minify-mcp-output/tsconfig.json delete mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md delete mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts delete mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/package.json delete mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/test/index.test.mts delete mode 100644 .claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/tsconfig.json delete mode 100644 .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md delete mode 100644 .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts delete mode 100644 .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/package.json delete mode 100644 .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/test/index.test.mts delete mode 100644 .claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/tsconfig.json delete mode 100644 .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md delete mode 100644 .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts delete mode 100644 .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/package.json delete mode 100644 .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/test/index.test.mts delete mode 100644 .claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/tsconfig.json delete mode 100644 .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts delete mode 100644 .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/package.json delete mode 100644 .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/test/index.test.mts delete mode 100644 .claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/tsconfig.json delete mode 100644 .claude/hooks/fleet/path-guard/path-guard/README.md delete mode 100644 .claude/hooks/fleet/path-guard/path-guard/index.mts delete mode 100644 .claude/hooks/fleet/path-guard/path-guard/package.json delete mode 100644 .claude/hooks/fleet/path-guard/path-guard/segments.mts delete mode 100644 .claude/hooks/fleet/path-guard/path-guard/test/path-guard.test.mts delete mode 100644 .claude/hooks/fleet/path-guard/path-guard/tsconfig.json delete mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md delete mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts delete mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json delete mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/test/index.test.mts delete mode 100644 .claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/tsconfig.json delete mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md delete mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts delete mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json delete mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/test/index.test.mts delete mode 100644 .claude/hooks/fleet/plan-location-guard/plan-location-guard/tsconfig.json delete mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/README.md delete mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts delete mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json delete mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/test/public-surface-reminder.test.mts delete mode 100644 .claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json delete mode 100644 .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/README.md delete mode 100644 .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/install.mts delete mode 100644 .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json delete mode 100644 .claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json delete mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/README.md delete mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts delete mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json delete mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/test/stale-process-sweeper.test.mts delete mode 100644 .claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json diff --git a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md deleted file mode 100644 index 55fb050..0000000 --- a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# comment-tone-reminder - -Stop hook that scans the assistant's most recent turn for teacher-tone phrases that would read condescendingly if written into a code comment. - -## Why - -CLAUDE.md's "Code style → Comments" rule: comments default to none; when written, the audience is a junior dev — explain the constraint, the hidden invariant, the "why this and not the obvious thing." No teacher-tone preamble. - -The patterns this hook flags are predictable shapes: "First, we will...", "Note that...", "It's important to...", "As you can see...", "Remember that...", "In order to...". - -## What it catches - -| Phrase | Why it's flagged | -| ------------------------------------- | ------------------------------------------------------- | -| `first, we (will\|are\|need\|should)` | Step-by-step narration — drop the framing. | -| `note that` | Tutorial filler. State the load-bearing point directly. | -| `it's important to` | Don't announce importance — state the constraint. | -| `as you can see` | Presupposes reader engagement. Drop. | -| `remember (that\|to)` | Reader doesn't need reminding — state the rule. | -| `in order to` | Wordy. "To X" suffices unless contrasting paths. | - -## Why it doesn't block - -Stop hooks fire after the assistant has produced its response. Blocking would truncate the message. The warning surfaces to stderr alongside the response so the user reads both and can push back in the next turn. - -## Configuration - -`SOCKET_COMMENT_TONE_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/index.mts b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/index.mts deleted file mode 100644 index 3af6fba..0000000 --- a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/index.mts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — comment-tone-reminder. -// -// Flags teacher-tone phrases in the most-recent assistant turn that -// suggest comments written in code edits will read condescendingly. -// CLAUDE.md "Code style → Comments" says: audience is a junior dev, -// explain the constraint, not the obvious. No "First, we'll …" / -// "Note that …" / "It's important …" / "As you can see …" tone. -// -// Fires informationally to stderr; never blocks. -// -// Disable via SOCKET_COMMENT_TONE_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -await runStopReminder({ - name: 'comment-tone-reminder', - disabledEnvVar: 'SOCKET_COMMENT_TONE_REMINDER_DISABLED', - patterns: [ - { - label: 'first, we (will|are)', - regex: /\bfirst,? we (?:are|need|should|will)\b/i, - why: 'Teacher-tone narration. Drop the step-by-step framing in comments.', - }, - { - label: 'note that', - regex: /\bnote that\b/i, - why: 'Tutorial filler. If the note is load-bearing, state it directly without the preamble.', - }, - { - label: "it['’]?s important to", - regex: /\bit'?s important to\b/i, - why: "Teacher-tone. State the constraint, don't announce that it's important.", - }, - { - label: 'as you can see', - regex: /\bas you can see\b/i, - why: 'Presupposes reader engagement. Drop the phrase.', - }, - { - label: 'remember that', - regex: /\bremember (?:that|to)\b/i, - why: "Teacher-tone. The reader doesn't need to be reminded — state the rule.", - }, - { - label: 'in order to', - regex: /\bin order to\b/i, - why: 'Wordy. "To X" is sufficient unless contrasting with another path.', - }, - ], - closingHint: - 'These phrases in code comments age into noise. Per CLAUDE.md "Comments": audience is a junior dev — explain the constraint, the hidden invariant. Default to no comment.', -}) diff --git a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/package.json b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/package.json deleted file mode 100644 index 5a01b70..0000000 --- a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-comment-tone-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/test/index.test.mts b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/test/index.test.mts deleted file mode 100644 index 85d59a3..0000000 --- a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/test/index.test.mts +++ /dev/null @@ -1,117 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): { - path: string - cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'comment-tone-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ role: 'assistant', content: assistantText }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { - stdout: string - stderr: string - exitCode: number -} { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { - stdout: String(result.stdout), - stderr: String(result.stderr), - exitCode: result.status ?? -1, - } -} - -test('flags "first, we will" teacher-tone preamble', () => { - const { path: p, cleanup } = makeTranscript('First, we will parse the input.') - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /comment-tone-reminder/) - assert.match(stderr, /first, we/) - } finally { - cleanup() - } -}) - -test('flags "note that" tutorial filler', () => { - const { path: p, cleanup } = makeTranscript( - 'Note that the parser caches results.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /note that/) - } finally { - cleanup() - } -}) - -test('flags "in order to" wordiness', () => { - const { path: p, cleanup } = makeTranscript( - 'We use a cache in order to avoid recomputation.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /in order to/) - } finally { - cleanup() - } -}) - -test('does not flag plain prose', () => { - const { path: p, cleanup } = makeTranscript( - 'The cache stores parsed results keyed by input.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does not false-positive on phrases inside code fences', () => { - const { path: p, cleanup } = makeTranscript( - 'Plain output here.\n```\nnote that this is in code\n```\nMore prose.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript('Note that we should skip this.') - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_COMMENT_TONE_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/tsconfig.json b/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/comment-tone-reminder/comment-tone-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md deleted file mode 100644 index a8712fd..0000000 --- a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# commit-pr-reminder - -Stop hook that flags assistant turns drafting commit messages or PR bodies missing fleet conventions. - -## What it catches - -- **AI attribution** — "Generated with Claude", "Co-Authored-By: Claude", `🤖 Generated`. The fleet's Commits & PRs rule forbids these. - -The companion guards that actually block `git commit` / `gh pr create` invocations live separately. This hook only nudges when drafted text shows the antipatterns in the assistant turn. - -## Bypass - -- `SOCKET_COMMIT_PR_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/index.mts b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/index.mts deleted file mode 100644 index 578db48..0000000 --- a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/index.mts +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — commit-pr-reminder. -// -// Flags assistant turns that drafted a commit message or PR body -// missing the fleet's required structure: -// -// - Conventional Commits header (`<type>(<scope>): <description>`). -// Anti-pattern: free-form sentences as the commit title. -// -// - AI attribution lines ("Generated with Claude", "Co-Authored-By: -// Claude", "🤖" tag lines). The fleet forbids these. -// -// - PR body missing a Summary section (PRs that paste a commit log -// without a 1-3 bullet summary). -// -// This hook only flags drafted text in the assistant turn — it doesn't -// inspect real git/gh invocations. The git/PR ones live in their own -// PreToolUse guards. -// -// Disable via SOCKET_COMMIT_PR_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -await runStopReminder({ - name: 'commit-pr-reminder', - disabledEnvVar: 'SOCKET_COMMIT_PR_REMINDER_DISABLED', - patterns: [ - { - label: 'AI attribution: Generated with Claude', - regex: /generated with (?:anthropic|claude)/i, - why: 'The fleet forbids AI attribution in commit/PR text. Remove the line.', - }, - { - label: 'AI attribution: Co-Authored-By Claude', - regex: /co-authored-by:?\s*claude/i, - why: 'Co-Authored-By Claude is forbidden in commit/PR trailers.', - }, - { - label: 'AI attribution: robot emoji tag line', - regex: /^.*🤖.*generated/im, - why: 'Remove the robot-emoji + "Generated" attribution line.', - }, - ], - closingHint: - 'Commits/PRs must use Conventional Commits (`<type>(<scope>): <description>`) with no AI attribution. PR bodies need a Summary section. See CLAUDE.md "Commits & PRs".', -}) diff --git a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/package.json b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/package.json deleted file mode 100644 index a00faf1..0000000 --- a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-commit-pr-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/test/index.test.mts b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/test/index.test.mts deleted file mode 100644 index d3cf75d..0000000 --- a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/test/index.test.mts +++ /dev/null @@ -1,79 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-pr-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: 'do it' }) + - '\n' + - JSON.stringify({ role: 'assistant', content: assistantText }), - ) - return transcriptPath -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('FLAGS "Generated with Claude"', () => { - const t = makeTranscript('Commit body:\n\nGenerated with Claude Code') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /commit-pr-reminder/) - assert.match(stderr, /generated with claude/i) -}) - -test('FLAGS "Co-Authored-By: Claude"', () => { - const t = makeTranscript( - 'Trailer:\nCo-Authored-By: Claude <noreply@anthropic.com>', - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /co-authored-by/i) -}) - -test('FLAGS robot emoji generated tag', () => { - const t = makeTranscript('PR body:\n🤖 Generated with assistance') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /robot emoji/i) -}) - -test('does NOT fire on plain Conventional Commit text', () => { - const t = makeTranscript( - 'feat(api): add new endpoint\n\nDetails about the change.', - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does NOT fire on the word "generated" without "claude" nearby', () => { - const t = makeTranscript('The build artifacts are generated by tsc.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('disabled env var short-circuits', () => { - const t = makeTranscript('Generated with Claude Code') - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: t }), - env: { ...process.env, SOCKET_COMMIT_PR_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') -}) diff --git a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/tsconfig.json b/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/commit-pr-reminder/commit-pr-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md deleted file mode 100644 index 28a7b24..0000000 --- a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# file-size-reminder - -Stop hook that warns when an assistant turn's Write / Edit / NotebookEdit tool calls push a file past the 500-line soft cap or 1000-line hard cap. - -## Why - -CLAUDE.md "File size" rule: - -> Soft cap **500 lines**, hard cap **1000 lines** per source file. Past those, split along natural seams — group by domain, not line count; name files for what's in them; co-locate helpers with consumers. Exceptions: a single function that legitimately needs the space (note it inline), or a generated artifact. - -The intent is to catch the slide where a file gradually accumulates 600, then 700, then 1200 lines because nobody noticed each individual edit pushing it over. The hook surfaces the count alongside the edit so the next turn can act on it. - -## What it catches - -After each assistant turn, the hook walks the most recent assistant's tool-use events, finds calls to: - -- `Write` (creating a new file or full rewrite) -- `Edit` (modifying a file in place) -- `NotebookEdit` (Jupyter cell modifications) - -For each target `file_path`, it reads the current on-disk state (post-edit, since the hook fires after the tool ran), counts lines, and warns if the count is past either cap. - -| Cap | Threshold | Action | -| ---- | -------------- | ---------------------------------- | -| Soft | 501-1000 lines | Warning — start planning the split | -| Hard | 1001+ lines | Stronger warning — split now | - -## Exempt paths - -Generated / vendored / build-output paths are skipped to avoid noise: - -- `node_modules/`, `.cache/`, `coverage/`, `coverage-isolated/` -- `dist/`, `build/`, `external/`, `vendor/`, `upstream/` -- `.git/`, `test/fixtures/`, `test/packages/` -- `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `Cargo.lock` -- `*.d.ts`, `*.d.ts.map`, `*.tsbuildinfo`, `*.map` - -The skip list errs on the side of suppressing false positives — genuine in-scope files past the cap will still surface. - -## Why it doesn't block - -Stop hooks fire after the tool has run. Blocking would just truncate the warning. The size violation is in the diff already; the warning prompts the next turn to address it. - -## Configuration - -`SOCKET_FILE_SIZE_REMINDER_DISABLED=1` — turn off entirely. Useful for sessions intentionally working on a generated-file context the skip list doesn't recognize. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/index.mts b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/index.mts deleted file mode 100644 index 583140a..0000000 --- a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/index.mts +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — file-size-reminder. -// -// Surfaces file-size violations after Write / Edit / NotebookEdit -// tool calls. CLAUDE.md "File size": -// -// Soft cap 500 lines, hard cap 1000 lines per source file. Past -// those, split along natural seams — group by domain, not line -// count; name files for what's in them; co-locate helpers with -// consumers. -// -// Exceptions (also from CLAUDE.md / docs/claude.md/file-size.md): -// -// - A single function that legitimately needs the space (the user -// notes this inline at the top of the function). -// - Generated artifacts (lockfiles, schema dumps, vendored data). -// -// The hook walks the most-recent assistant turn's tool-use events, -// finds Write/Edit/NotebookEdit calls, reads each target file from -// disk (post-edit state, since the hook fires after the tool ran), -// counts lines, and flags any file past either cap. -// -// Skips paths matching the generated-artifact heuristic — anything -// under common vendor / generated / dist / build / coverage paths. -// The skip list errs on the side of suppressing false positives; -// genuine in-scope files past the cap will still surface. -// -// Disable via SOCKET_FILE_SIZE_REMINDER_DISABLED. - -import { existsSync, readFileSync, statSync } from 'node:fs' -import process from 'node:process' - -import { readLastAssistantToolUses, readStdin } from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -const SOFT_CAP_LINES = 500 -const HARD_CAP_LINES = 1000 - -// Tool names that write or modify file content. Read / Glob / Grep -// don't change a file, so they don't trigger this hook. -const FILE_WRITING_TOOLS = new Set(['Edit', 'NotebookEdit', 'Write']) - -// Path patterns we skip — generated, vendored, or otherwise -// exempt from the cap. Tested as substring matches against the -// absolute file_path; a hit anywhere in the path skips the file. -// -// Each entry is intentionally generous: false-positives in the -// skip list are recoverable (the user can disable the hook or -// reduce the list), but false-positives in the *flagging* list -// would noise up every turn that touches a vendored file. -const SKIP_PATH_SUBSTRINGS: readonly string[] = [ - '/node_modules/', - '/.cache/', - '/coverage/', - '/coverage-isolated/', - '/dist/', - '/build/', - '/external/', - '/vendor/', - '/upstream/', - '/.git/', - '/test/fixtures/', - '/test/packages/', - // Lockfiles + manifests - 'pnpm-lock.yaml', - 'package-lock.json', - 'yarn.lock', - 'Cargo.lock', - // Type declarations (often generated) - '.d.ts', - '.d.ts.map', - '.tsbuildinfo', - // Map files - '.map', -] - -export function collectHits( - events: ReadonlyArray<{ name: string; input: Record<string, unknown> }>, -): SizeHit[] { - const seen = new Set<string>() - const hits: SizeHit[] = [] - for (let i = 0, { length } = events; i < length; i += 1) { - const event = events[i]! - if (!FILE_WRITING_TOOLS.has(event.name)) { - continue - } - const pathField = event.input['file_path'] ?? event.input['notebook_path'] - if (typeof pathField !== 'string') { - continue - } - if (seen.has(pathField)) { - continue - } - seen.add(pathField) - if (isExempt(pathField)) { - continue - } - const lines = countLines(pathField) - if (lines === undefined) { - continue - } - if (lines > HARD_CAP_LINES) { - hits.push({ path: pathField, lines, cap: 'hard' }) - } else if (lines > SOFT_CAP_LINES) { - hits.push({ path: pathField, lines, cap: 'soft' }) - } - } - return hits -} - -export function countLines(absPath: string): number | undefined { - try { - if (!existsSync(absPath)) { - return undefined - } - const stat = statSync(absPath) - if (!stat.isFile()) { - return undefined - } - // Use byte-count fast-path for very large files: if the file is - // over ~256 KB it's almost certainly past the cap unless every - // line is one byte (unrealistic). Otherwise read + count newlines. - const content = readFileSync(absPath, 'utf8') - // Count newlines + 1 unless the file is empty. This matches the - // canonical `wc -l` convention (which counts newlines, off-by-one - // for files without trailing newline) closely enough — exact - // boundary cases at the cap edge don't matter, the cap is a - // judgement guideline not a hard machine check. - if (content.length === 0) { - return 0 - } - let count = 0 - for (let i = 0, { length } = content; i < length; i += 1) { - if (content.charCodeAt(i) === 10) { - count += 1 - } - } - // Add 1 for the final line if it doesn't end in a newline. - if (content.charCodeAt(content.length - 1) !== 10) { - count += 1 - } - return count - } catch { - return undefined - } -} - -interface SizeHit { - readonly path: string - readonly lines: number - readonly cap: 'soft' | 'hard' -} - -export function isExempt(absPath: string): boolean { - for (let i = 0, { length } = SKIP_PATH_SUBSTRINGS; i < length; i += 1) { - if (absPath.includes(SKIP_PATH_SUBSTRINGS[i]!)) { - return true - } - } - return false -} - -async function main(): Promise<void> { - const payloadRaw = await readStdin() - if (process.env['SOCKET_FILE_SIZE_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - - const events = readLastAssistantToolUses(payload.transcript_path) - if (events.length === 0) { - process.exit(0) - } - const hits = collectHits(events) - if (hits.length === 0) { - process.exit(0) - } - - const lines = ['[file-size-reminder] File-size cap exceeded:', ''] - for (let i = 0, { length } = hits; i < length; i += 1) { - const hit = hits[i]! - const capLabel = - hit.cap === 'hard' - ? `HARD CAP (${HARD_CAP_LINES} lines)` - : `soft cap (${SOFT_CAP_LINES} lines)` - lines.push(` • ${hit.path}`) - lines.push(` ${hit.lines} lines — past ${capLabel}`) - } - lines.push('') - lines.push( - ' CLAUDE.md "File size": split along natural seams — group by domain,', - ) - lines.push( - " name files for what's in them, co-locate helpers with consumers.", - ) - lines.push( - ' Exceptions (single legitimate large function / generated artifact)', - ) - lines.push( - ' should be stated inline. Full playbook: docs/claude.md/file-size.md.', - ) - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - // Fail-open on any hook bug. - process.exit(0) -}) diff --git a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/package.json b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/package.json deleted file mode 100644 index b76df77..0000000 --- a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-file-size-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/test/index.test.mts b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/test/index.test.mts deleted file mode 100644 index 2e93659..0000000 --- a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/test/index.test.mts +++ /dev/null @@ -1,196 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface ToolUseEvent { - readonly name: string - readonly input: Record<string, unknown> -} - -function makeTranscript( - dir: string, - toolUses: readonly ToolUseEvent[], -): string { - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'text', text: 'doing the thing' }, - ...toolUses.map(tu => ({ - type: 'tool_use', - name: tu.name, - input: tu.input, - })), - ], - }, - }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return transcriptPath -} - -function writeLines(filePath: string, n: number): void { - const content = Array.from({ length: n }, (_, i) => `line ${i + 1}`).join( - '\n', - ) - writeFileSync(filePath, content) -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags soft-cap violation (501-1000 lines)', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'big.mts') - writeLines(target, 750) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: target, new_string: 'x' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.match(stderr, /file-size-reminder/) - assert.match(stderr, /soft cap/) - assert.match(stderr, /750 lines/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('flags hard-cap violation (>1000 lines)', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'huge.mts') - writeLines(target, 1500) - const transcript = makeTranscript(dir, [ - { name: 'Write', input: { file_path: target, content: '...' } }, - ]) - const { stderr } = runHook(transcript) - assert.match(stderr, /HARD CAP/) - assert.match(stderr, /1500 lines/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('does not flag files at or under soft cap', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'small.mts') - writeLines(target, 500) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: target, new_string: 'x' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('skips node_modules paths', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const realDir = path.join(dir, 'node_modules', 'pkg') - mkdirSync(realDir, { recursive: true }) - const realTarget = path.join(realDir, 'big.mts') - writeLines(realTarget, 2000) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: realTarget, new_string: 'x' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('skips Read / Glob tool uses', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'big.mts') - writeLines(target, 2000) - const transcript = makeTranscript(dir, [ - { name: 'Read', input: { file_path: target } }, - { name: 'Glob', input: { pattern: '**/*.mts' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - // Read/Glob don't write, so no flag even though file is over cap - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('handles missing file gracefully (no crash)', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const transcript = makeTranscript(dir, [ - { - name: 'Edit', - input: { file_path: '/tmp/does-not-exist-xyz.mts', new_string: 'x' }, - }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('deduplicates multiple edits to the same file', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'multi.mts') - writeLines(target, 600) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: target, new_string: 'a' } }, - { name: 'Edit', input: { file_path: target, new_string: 'b' } }, - { name: 'Edit', input: { file_path: target, new_string: 'c' } }, - ]) - const { stderr } = runHook(transcript) - // Only one warning for the file, not three. - const matches = stderr.match(/600 lines/g) ?? [] - assert.equal(matches.length, 1) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('disabled env var short-circuits', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'big.mts') - writeLines(target, 1500) - const transcript = makeTranscript(dir, [ - { name: 'Write', input: { file_path: target, content: '...' } }, - ]) - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcript }), - env: { ...process.env, SOCKET_FILE_SIZE_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) diff --git a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/tsconfig.json b/.claude/hooks/fleet/file-size-reminder/file-size-reminder/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/file-size-reminder/file-size-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md deleted file mode 100644 index e2da1e3..0000000 --- a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# follow-direct-imperative-reminder - -Stop hook that flags assistant turns which respond to a bare imperative user command with hedging or re-litigation before the tool call. - -## Why - -CLAUDE.md "Judgment & self-evaluation" rule: - -> Direct imperatives → execute, don't litigate. When the user issues a bare command ("use nvm 26.2.0", "cancel the build", "do it", "kill it"), the response is the tool call, not a paragraph weighing trade-offs. - -Past incident (the trigger for this hook): user typed "use nvm use 26.2.0". Assistant responded with a paragraph explaining why it wouldn't help the in-flight build, instead of switching Node. Same turn the user typed "cancel the build right now". Assistant kept narrating build phases instead of killing the process. User asked for a hook to stop the behavior. - -The failure mode is analysis-before-action when the command was unambiguous. The user already weighed the trade-off. Re-litigating wastes a turn and signals the directive was optional. It wasn't. - -## Detection - -Two-signal rule, both must hit: - -1. **Previous user turn is a bare imperative.** Single short sentence (≤ 8 words), starts with an action verb (`cancel`, `kill`, `use`, `run`, `commit`, `push`, `do`, `continue`, etc.) or common imperative phrase (`let's`, `just`, `please`). No question mark (questions invite analysis). -2. **Assistant turn contains hedge / re-litigation markers**: - - `doesn't help` / `won't help` - - `before I do that` / `let me explain` / `let me first` - - `to be clear` / `worth noting` / `that said` / `actually` - - `the in-flight X` (re-litigating in-flight state) - - `caveat:` / `note:` / `important:` - -Both signals fire: stderr reminder lands in the next turn's context. - -## What it does NOT catch - -- Questions from the user ("should I use Node 26?"). Analysis is invited. -- Long contextual user messages. Those carry their own framing. -- Assistant turns that hedge after the tool call. Post-action qualification is fine. - -## Disable - -```bash -SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1 -``` - -## Related - -- `dont-stop-mid-queue-reminder`: Stop hook for premature "what's next?" after authorized continuous-work directives. -- `ask-suppression-reminder`: Stop hook for AskUserQuestion when recent transcript already authorized the obvious default. diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/index.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/index.mts deleted file mode 100644 index f708b8b..0000000 --- a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/index.mts +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — follow-direct-imperative-reminder. -// -// Fires at turn-end. If the immediately-preceding user turn was a bare -// imperative command (short, action-verb-led) AND the just-emitted -// assistant text contains hedge / re-litigation patterns BEFORE any -// tool call, emit a stderr reminder pointing at the failure mode. -// -// The fleet rule (CLAUDE.md "Judgment & self-evaluation"): -// -// Direct imperatives → execute, don't litigate. When the user -// issues a bare command ("use nvm 26.2.0", "cancel the build", -// "do it", "kill it"), the response is the tool call, not a -// paragraph weighing trade-offs. -// -// Past incident: user typed "use nvm use 26.2.0"; assistant responded -// with a paragraph explaining why it wouldn't help the in-flight -// build instead of running the command. Same turn the user typed -// "cancel the build right now" — assistant continued narrating -// build phases instead of killing the process. The user explicitly -// asked for a hook to stop this. -// -// Detection: -// - Last user turn is a single short imperative (≤ 8 words, -// starts with an action verb or a known imperative form). -// - Last assistant turn (just emitted) contains hedge openers -// OR a leading analysis paragraph that precedes any tool call. -// -// Why a reminder, not a block: Stop hooks fire AFTER the turn ended. -// The reminder lands in the next turn's context so the agent sees -// the pattern it just exhibited. -// -// Exit codes: -// 0 — always. Informational; never blocks. -// -// Disabled via `SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1`. - -import { readFileSync } from 'node:fs' -import process from 'node:process' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -interface TranscriptEntry { - readonly type?: string | undefined - readonly role?: string | undefined - readonly message?: - | { - readonly content?: unknown | undefined - readonly role?: string | undefined - } - | undefined - readonly content?: unknown | undefined -} - -export async function drainStdinJson(): Promise<StopPayload> { - return await new Promise<StopPayload>(resolve => { - let raw = '' - process.stdin.on('data', d => { - raw += d.toString('utf8') - }) - process.stdin.on('end', () => { - try { - resolve(raw ? (JSON.parse(raw) as StopPayload) : {}) - } catch { - resolve({}) - } - }) - process.stdin.on('error', () => resolve({})) - setTimeout(() => resolve({}), 200) - }) -} - -// Read the last N entries from a JSONL transcript file. The harness -// uses one JSON object per line. -export function readTranscriptTail( - path: string, - count: number, -): TranscriptEntry[] { - let text: string - try { - text = readFileSync(path, 'utf8') - } catch { - return [] - } - const lines = text.split('\n').filter(Boolean) - const tail = lines.slice(-count) - const out: TranscriptEntry[] = [] - for (const line of tail) { - try { - out.push(JSON.parse(line) as TranscriptEntry) - } catch { - // ignore malformed - } - } - return out -} - -// Flatten content (string | content-block-array) into one string. -export function flattenContent(content: unknown): string { - if (typeof content === 'string') { - return content - } - if (Array.isArray(content)) { - const parts: string[] = [] - for (const block of content) { - if (block && typeof block === 'object') { - const b = block as { - type?: string | undefined - text?: string | undefined - } - if (b.type === 'text' && typeof b.text === 'string') { - parts.push(b.text) - } - } - } - return parts.join('\n') - } - return '' -} - -// Role detection across the two shapes the transcript uses. -export function entryRole(e: TranscriptEntry): string | undefined { - return e.role ?? e.message?.role ?? e.type -} - -export function entryText(e: TranscriptEntry): string { - return flattenContent(e.message?.content ?? e.content ?? '') -} - -// Imperative-command opening verbs/forms. Kept conservative — -// over-matching would trigger the reminder on normal conversation. -const IMPERATIVE_OPENERS = [ - // Single-verb commands. - 'cancel', - 'kill', - 'stop', - 'abort', - 'do', - 'use', - 'run', - 'commit', - 'push', - 'fix', - 'try', - 'continue', - 'restart', - 'rerun', - 'redo', - 'execute', - 'go', - 'land', - 'merge', - 'rebase', - 'reset', - 'add', - 'remove', - 'delete', - 'install', - 'switch', - 'check', - 'show', - 'list', - 'open', - 'close', - 'undo', - 'revert', - 'apply', - 'build', - 'test', - 'deploy', - 'finish', - 'follow', - 'now', - // Common imperative phrases. - "let's", - 'just', - 'please', -] - -// Returns true when the text looks like a bare imperative directive -// (short, action-verb-led, no question mark, no long context). -export function looksLikeImperative(text: string): boolean { - const trimmed = text.trim().toLowerCase() - if (!trimmed) { - return false - } - // Strip leading punctuation. - const body = trimmed.replace(/^[!,.\s]+/, '') - // Skip questions entirely — questions invite analysis. - if (body.includes('?')) { - return false - } - // Bounded length: long contextual messages are not bare imperatives. - const wordCount = body.split(/\s+/).filter(Boolean).length - if (wordCount > 8) { - return false - } - // Pull the first word. - const firstWord = body.split(/\s+/)[0] ?? '' - return IMPERATIVE_OPENERS.includes(firstWord) -} - -// Hedge / re-litigation markers in the assistant's text. The goal is -// to catch paragraphs that explain WHY the command might not help -// before the tool call lands. -const HEDGE_MARKERS = [ - /\bdoesn't help\b/i, - /\bwon't help\b/i, - /\bbefore (?:i|we) (?:do that|run|kick|switch|cancel)\b/i, - /\blet me (?:explain|first|note)\b/i, - /\b(?:to be clear|just so we'?re clear)\b/i, - /\bworth (?:checking|confirming|noting)\b/i, - /\bone thing to (?:note|flag)\b/i, - /\bthat said\b/i, - /\bactually,?\s+/i, - /\b(?:however|but),?\s+(?:that|the|this)\b/i, - // "the in-flight X is past Y" — re-litigation of in-flight state. - /\bthe in-?flight\b/i, - // Heavy throat-clearing. - /\b(?:caveat|note|important):/i, -] - -export function hasHedge(text: string): boolean { - for (let i = 0, { length } = HEDGE_MARKERS; i < length; i += 1) { - const re = HEDGE_MARKERS[i]! - if (re.test(text)) { - return true - } - } - return false -} - -async function main(): Promise<void> { - if (process.env['SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED']) { - return - } - const payload = await drainStdinJson() - const transcriptPath = payload.transcript_path - if (!transcriptPath) { - return - } - // Pull the last ~6 entries — usually covers the last user + last - // assistant turn plus any tool result entries between them. - const tail = readTranscriptTail(transcriptPath, 8) - if (tail.length === 0) { - return - } - - // Find the last assistant entry (what we just emitted) and the - // last user entry BEFORE it. - let lastAssistantIdx = -1 - for (let i = tail.length - 1; i >= 0; i -= 1) { - if (entryRole(tail[i]!) === 'assistant') { - lastAssistantIdx = i - break - } - } - if (lastAssistantIdx === -1) { - return - } - let lastUserIdx = -1 - for (let i = lastAssistantIdx - 1; i >= 0; i -= 1) { - if (entryRole(tail[i]!) === 'user') { - lastUserIdx = i - break - } - } - if (lastUserIdx === -1) { - return - } - - const userText = entryText(tail[lastUserIdx]!) - const assistantText = entryText(tail[lastAssistantIdx]!) - if (!userText || !assistantText) { - return - } - if (!looksLikeImperative(userText)) { - return - } - if (!hasHedge(assistantText)) { - return - } - - const userPreview = userText.trim().slice(0, 60) - process.stderr.write( - [ - '[follow-direct-imperative-reminder] You hedged before executing a direct imperative.', - '', - ` User said: "${userPreview}"`, - '', - ' The response to a bare command should be the tool call,', - ' not a paragraph weighing trade-offs. Hedge openers ("That', - ' won\'t help…", "Let me explain…", "Before I do that…") +', - ' analysis-before-action when the command was unambiguous', - ' are the failure mode the rule targets.', - '', - ' Fix: state the intent in one short sentence at most, then', - ' run the command. If you genuinely think the directive is', - " wrong, run it AFTER raising the concern — don't refuse to act.", - '', - " CLAUDE.md → 'Judgment & self-evaluation' → Direct imperatives.", - '', - ].join('\n'), - ) -} - -main().catch(e => { - process.stderr.write( - `[follow-direct-imperative-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) -}) diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/package.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/package.json deleted file mode 100644 index fe86e4b..0000000 --- a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-follow-direct-imperative-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/test/index.test.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/test/index.test.mts deleted file mode 100644 index fe0f1cd..0000000 --- a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/test/index.test.mts +++ /dev/null @@ -1,111 +0,0 @@ -// node --test specs for follow-direct-imperative-reminder. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { flattenContent, hasHedge, looksLikeImperative } from '../index.mts' - -test('looksLikeImperative: "use nvm 26.2.0"', () => { - assert.strictEqual(looksLikeImperative('use nvm 26.2.0'), true) -}) - -test('looksLikeImperative: "cancel the build right now"', () => { - assert.strictEqual(looksLikeImperative('cancel the build right now'), true) -}) - -test('looksLikeImperative: "kill it"', () => { - assert.strictEqual(looksLikeImperative('kill it'), true) -}) - -test('looksLikeImperative: "do what I said"', () => { - assert.strictEqual(looksLikeImperative('do what I said'), true) -}) - -test('looksLikeImperative: "continue"', () => { - assert.strictEqual(looksLikeImperative('continue'), true) -}) - -test('looksLikeImperative: rejects questions', () => { - assert.strictEqual(looksLikeImperative('should I use 26?'), false) -}) - -test('looksLikeImperative: rejects long context', () => { - assert.strictEqual( - looksLikeImperative( - 'use nvm to switch to Node 26.2.0 so the build runs with the right engines', - ), - false, - ) -}) - -test('looksLikeImperative: rejects non-verb opener', () => { - assert.strictEqual(looksLikeImperative('hey there friend'), false) - assert.strictEqual(looksLikeImperative('thanks for that'), false) -}) - -test('looksLikeImperative: empty', () => { - assert.strictEqual(looksLikeImperative(''), false) - assert.strictEqual(looksLikeImperative(' '), false) -}) - -test('hasHedge: "doesn\'t help"', () => { - assert.strictEqual( - hasHedge( - "Switching the shell's Node to 26.2.0 doesn't help the build that's already running", - ), - true, - ) -}) - -test('hasHedge: "Before I do that"', () => { - assert.strictEqual( - hasHedge('Before I do that, the in-flight build is at 37%.'), - true, - ) -}) - -test('hasHedge: "Let me explain"', () => { - assert.strictEqual(hasHedge('Let me explain why this fails.'), true) -}) - -test('hasHedge: "actually,"', () => { - assert.strictEqual(hasHedge('actually, the dependency graph shows…'), true) -}) - -test('hasHedge: clean status update', () => { - assert.strictEqual(hasHedge('Switched. Now on Node 26.2.0.'), false) -}) - -test('hasHedge: tool result narration', () => { - assert.strictEqual(hasHedge('Build cancelled. No processes remain.'), false) -}) - -test('flattenContent: string', () => { - assert.strictEqual(flattenContent('hi'), 'hi') -}) - -test('flattenContent: text blocks', () => { - assert.strictEqual( - flattenContent([ - { type: 'text', text: 'one' }, - { type: 'text', text: 'two' }, - ]), - 'one\ntwo', - ) -}) - -test('flattenContent: ignores non-text blocks', () => { - assert.strictEqual( - flattenContent([ - { type: 'tool_use', name: 'Bash' }, - { type: 'text', text: 'survives' }, - ]), - 'survives', - ) -}) - -test('flattenContent: empty/garbage', () => { - assert.strictEqual(flattenContent(undefined), '') - assert.strictEqual(flattenContent(42), '') - assert.strictEqual(flattenContent(undefined), '') -}) diff --git a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/tsconfig.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/follow-direct-imperative-reminder/follow-direct-imperative-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md deleted file mode 100644 index e8f2bb4..0000000 --- a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# inline-script-defer-guard - -PreToolUse Edit/Write hook that blocks introducing `<script defer>` or -`<script async>` to an HTML / template file when the same tag lacks a -`src=` attribute. - -## Why - -Per HTML spec, `defer` and `async` are no-ops on inline (no-src) -`<script>` tags. The script executes immediately, even though the author -intent is "wait for DOMContentLoaded." Browsers don't warn. The failure -mode is a silently broken page — code styles `<pre><code>` blocks that -don't exist yet, etc. - -This pattern bit a fleet project twice. The fix is the -`DOMContentLoaded` listener: - -```html -<script> - document.addEventListener('DOMContentLoaded', () => { - /* your code */ - }) -</script> -``` - -Or, for code that genuinely belongs in an external file: - -```html -<script defer src="/path/to/script.js"></script> -``` - -## What it covers - -| File extension | Checked? | -| -------------------------------------------------------- | --------------- | -| `.html` / `.htm` | full text | -| `.njk` / `.ejs` / `.hbs` / `.handlebars` | full text | -| `.svelte` / `.vue` / `.astro` | full text | -| `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / etc. | new_string only | -| anything else | not checked | - -## Bypass - -Type the canonical phrase in a new message: - - Allow inline-defer bypass - -Use sparingly — the bug is silent in production. - -## Companion: oxlint rule - -`socket/no-inline-defer-async` catches the same shape at commit time -even when edits happened outside Claude. diff --git a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts deleted file mode 100644 index 95bef9f..0000000 --- a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/index.mts +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — inline-script-defer-guard. -// -// Blocks Edit/Write operations that add `<script defer>` or -// `<script async>` to an HTML / template file when the same tag lacks a -// `src=` attribute. Per HTML spec, `defer` and `async` are no-ops on -// inline (no-src) `<script>` tags — the script executes immediately, -// even though the author intent is "wait for DOMContentLoaded." Browsers -// don't warn; the failure mode is a silent broken page (e.g. unstyled -// `<pre><code>` blocks when the script that styles them runs before its -// targets exist). -// -// Detection: regex over the after-edit text. Find `<script [^>]*\b(defer|async)\b[^>]*>`, -// check the same tag for `src=`. If absent → block. -// -// Fix: wrap the script body in -// -// <script> -// document.addEventListener('DOMContentLoaded', () => { -// // your code here -// }) -// </script> -// -// Files covered: `*.html` / `*.htm` / `*.njk` / `*.ejs` / `*.hbs` / -// `*.handlebars` / `*.svelte` / `*.vue` / `*.astro`. Also fires on TS/JS -// source files that contain HTML string literals matching the pattern — -// SSR / static-gen code paths. -// -// Bypass: `Allow inline-defer bypass` typed verbatim in a recent user turn. - -import { readFileSync } from 'node:fs' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow inline-defer bypass' - -// File extensions where we check the full text content. For other -// extensions, only the new_string is checked (template strings embedded -// in TS/JS source). -const HTML_EXT_RE = /\.(astro|ejs|handlebars|hbs|htm|html|njk|svelte|vue)$/i - -const SOURCE_EXT_RE = /\.(m?[jt]sx?|cts|cjs)$/i - -// Match each `<script ...>` opener and capture its attribute body. -const SCRIPT_OPENER_RE = /<script\b([^>]*)>/gi - -export function findInlineDeferOrAsync(text: string): - | { - attrs: string - } - | undefined { - let m: RegExpExecArray | null - // Reset the regex's lastIndex for safety across multiple calls. - SCRIPT_OPENER_RE.lastIndex = 0 - while ((m = SCRIPT_OPENER_RE.exec(text)) !== null) { - const attrs = m[1] ?? '' - if (!/\b(async|defer)\b/i.test(attrs)) { - continue - } - // If src= is present (anywhere in the tag), the defer/async IS valid. - if (/\bsrc\s*=/.test(attrs)) { - continue - } - return { attrs } - } - return undefined -} - -export function readFileSafe(p: string): string { - try { - return readFileSync(p, 'utf8') - } catch { - return '' - } -} - -async function main(): Promise<void> { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const input = payload.tool_input - const filePath = input?.file_path - if (!filePath) { - process.exit(0) - } - const isHtml = HTML_EXT_RE.test(filePath) - const isSource = SOURCE_EXT_RE.test(filePath) - if (!isHtml && !isSource) { - process.exit(0) - } - - // For HTML files, check the FULL after-edit text (the violation may - // already be present and we're touching neighboring lines). - // For source files, only check the new_string (avoid flagging existing - // template strings buried in unrelated source). - let textToScan: string - if (payload.tool_name === 'Write') { - textToScan = input?.content ?? input?.new_string ?? '' - } else { - const newStr = input?.new_string ?? '' - if (isHtml) { - const currentText = readFileSafe(filePath) - textToScan = newStr - ? currentText.replace( - (input?.['old_string' as 'new_string'] as never as string) ?? '', - newStr, - ) - : currentText - } else { - textToScan = newStr - } - } - - const found = findInlineDeferOrAsync(textToScan) - if (!found) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - process.stderr.write( - [ - // socket-hook: allow inline-defer -- the hook's own diagnostic text names the banned shape; it isn't real inline-script markup. - '[inline-script-defer-guard] Blocked: <script defer/async> without src=', - '', - ` File: ${filePath}`, - ` Tag: <script${found.attrs.slice(0, 80)}>`, - '', - ' Per the HTML spec, `defer` and `async` are no-ops on inline', - ' (no-src) `<script>` tags. The script runs immediately — the', - ' author intent (wait for DOMContentLoaded) is silently ignored.', - ' Browsers do not warn; the failure mode is a broken page.', - '', - ' Fix — wrap the body in a DOMContentLoaded listener:', - '', - ' <script>', - " document.addEventListener('DOMContentLoaded', () => {", - ' /* your code here */', - ' })', - ' </script>', - '', - ' Or — if the script DOES belong in an external file:', - '', - ' <script defer src="/path/to/script.js"></script>', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[inline-script-defer-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/package.json b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/package.json deleted file mode 100644 index 43b2da5..0000000 --- a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-inline-script-defer-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/test/index.test.mts b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/test/index.test.mts deleted file mode 100644 index fb3bba8..0000000 --- a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/test/index.test.mts +++ /dev/null @@ -1,134 +0,0 @@ -// node --test specs for the inline-script-defer-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-HTML / non-source file passes', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/note.txt', - content: '<script defer>do.thing()</script>', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('<script defer src="..."> passes (valid external)', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.html', - content: '<!doctype html><script defer src="/main.js"></script>', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('<script async src="..."> passes (valid external)', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.html', - content: '<!doctype html><script async src="/main.js"></script>', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('<script> without defer/async passes', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.html', - content: '<!doctype html><script>document.title = "x"</script>', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('inline <script defer> in .html blocked', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.html', - content: '<!doctype html><script defer>document.title = "x"</script>', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('inline <script async> in .html blocked', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.html', - content: '<!doctype html><script async>document.title = "x"</script>', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('inline <script defer> in .njk template blocked', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.njk', - content: '<script defer>do.thing()</script>', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('bypass phrase passes', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'idef-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow inline-defer bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.html', - content: '<script defer>x()</script>', - }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/tsconfig.json b/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/inline-script-defer-guard/inline-script-defer-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/README.md b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/README.md deleted file mode 100644 index 52af7c5..0000000 --- a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# minify-mcp-output - -A **Claude Code PostToolUse hook** that compresses MCP-tool output text -losslessly before it enters Claude's context. Pairs with the wire-level -proxy [`@socketsecurity/token-minifier`](../../packages/socket-token-minifier/) -for built-in tools (Read, Bash, Edit, etc.) — those have no PostToolUse -rewrite channel, so they only benefit from wire-level compression. - -## Why this rule - -MCP tools (declared via `.mcp.json`) can produce verbose output: JSON -arrays, nested objects, long text fields with whitespace and line -prefixes. Stage compression saves tokens **both** on the wire AND in -context (because Claude reads the compressed version going forward). - -Built-in tool results don't go through this hook — Claude Code's hook -runtime accepts `updatedMCPToolOutput` only when `tool_name` starts -with `mcp__`. For built-in tools, use the proxy instead. - -## Stages (identical to socket-token-minifier) - -| Stage | What it does | -| ------------- | ------------------------------------------------------- | -| `minify` | `JSON.stringify` without indent on JSON-shaped strings. | -| `strip-lines` | Removes ` 42\t` cat -n style line prefixes. | -| `whitespace` | Collapses 3+ blank lines to a single blank line. | - -All are deterministic, information-preserving transforms. No semantic -compression, no ML, no Python. - -## What's enforced - -- Hook fires only on `PostToolUse`. -- Hook activates only when `tool_name` starts with `mcp__`. -- Stages applied to all text content in the MCP `tool_response`, - including string-shaped responses, `{type:"text", text:"..."}` blocks, - and arrays thereof. -- Non-text content (images, structured data) passes through unchanged. -- The hook fails **open** on any internal error (exit 0 with no output) - so a bad deploy can't break tool delivery. - -## What's not enforced - -- Built-in tools (Read, Bash, Edit, Write, etc.) — Claude Code's - runtime does not accept `updatedMCPToolOutput` for them. Use the - proxy for wire-level compression. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "mcp__.*", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minify-mcp-output/index.mts" - } - ] - } - ] - } -} -``` - -The matcher `mcp__.*` is a belt-and-suspenders narrowing — the hook -itself also checks `tool_name` startsWith `mcp__` and exits 0 if it -doesn't match. - -## Cross-fleet sync - -This hook lives in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/minify-mcp-output) -and is required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. - -The compression-stage logic is intentionally **inlined** here rather -than imported from `packages/socket-token-minifier/` — that package -lives only in wheelhouse, while this hook cascades fleet-wide. -Inlining keeps the dependency-resolution graph trivial for downstream -repos. diff --git a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts deleted file mode 100644 index 291f573..0000000 --- a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/index.mts +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env node -// Claude Code PostToolUse hook — minify-mcp-output. -// -// Applies lossless minification stages (minify / strip-lines / -// whitespace) to MCP-tool output text and returns the result via -// `hookSpecificOutput.updatedMCPToolOutput` — the only documented -// rewrite channel for PostToolUse, verified empirically. -// -// Scope: -// - PostToolUse only. -// - tool_name starts with `mcp__` (Claude Code's MCP tool naming -// convention: mcp__<server>__<tool>). -// - Other tool names (built-in: Read/Bash/Edit/etc.) pass through -// untouched — those have no PostToolUse rewrite channel; use the -// wire-level proxy (socket-token-minifier) instead. -// -// The hook fails OPEN on its own errors (exit 0 with no output) so a -// bad deploy can't break tool result delivery. -// -// Stages here are inlined (not imported from packages/socket-token- -// minifier/) because this hook cascades into every fleet repo via -// sync-scaffolding, while packages/socket-token-minifier/ lives only -// in wheelhouse. The stage logic is small enough that inlining is -// cleaner than orchestrating a workspace dependency that downstream -// repos don't have. - -import process from 'node:process' - -interface Payload { - hook_event_name?: string | undefined - tool_name?: string | undefined - tool_response?: unknown | undefined - // Plus session_id, cwd, etc. — we don't care. -} - -// ---------- Inlined stages (synced with packages/socket-token-minifier/src/stages/) ---------- - -export function minify(text: string): string { - const trimmed = text.trimStart() - if (trimmed.length === 0) { - return text - } - const first = trimmed.charCodeAt(0) - if (first !== 0x7b && first !== 0x5b) { - return text - } - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - return text - } - return JSON.stringify(parsed) -} - -const LINE_PREFIX_RE = /^[ \t]*\d+\t/gm -export function stripLines(text: string): string { - return text.replace(LINE_PREFIX_RE, '') -} - -const BLANK_RUN_RE = /\n(?:[ \t]*\n){2,}/g -export function whitespace(text: string): string { - return text.replace(BLANK_RUN_RE, '\n\n') -} - -export function applyStages(text: string): string { - return whitespace(stripLines(minify(text))) -} - -// ---------- Tool-response walker ---------- - -/** - * Walk an MCP tool_response value and compress text content in place. Returns - * the same structure with strings minified. Non-text content (images, - * structured data we don't recognize) passes through unchanged. - * - * Shapes we handle: - * - * - String → minified string. - * - { type: "text", text: string } → minified text. - * - { content: <recurse> } - * - { type: "text", text: string }[] (typical MCP shape). - * - Other → passes through. - */ -export function compressMCPOutput(value: unknown): unknown { - if (typeof value === 'string') { - return applyStages(value) - } - if (Array.isArray(value)) { - return value.map(compressMCPOutput) - } - if (value !== null && typeof value === 'object') { - const obj = value as Record<string, unknown> - const out: Record<string, unknown> = { ...obj } - if (typeof obj['text'] === 'string') { - out['text'] = applyStages(obj['text']) - } - if (obj['content'] !== undefined) { - out['content'] = compressMCPOutput(obj['content']) - } - return out - } - return value -} - -// ---------- Hook IO ---------- - -export function isMCPToolName(name: string | undefined): boolean { - return typeof name === 'string' && name.startsWith('mcp__') -} - -function main() { - let stdin = '' - process.stdin.on('data', chunk => { - stdin += chunk - }) - process.stdin.on('end', () => { - try { - let payload: Payload - try { - payload = JSON.parse(stdin) as Payload - } catch { - process.exit(0) - } - if (payload.hook_event_name !== 'PostToolUse') { - process.exit(0) - } - if (!isMCPToolName(payload.tool_name)) { - process.exit(0) - } - const original = payload.tool_response - if (original === undefined) { - process.exit(0) - } - const compressed = compressMCPOutput(original) - const out = { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - updatedMCPToolOutput: compressed, - }, - } - process.stdout.write(JSON.stringify(out)) - process.exit(0) - } catch { - // Fail-open: silently exit 0 so Claude Code uses the original. - process.exit(0) - } - }) - if (process.stdin.readable === false) { - process.exit(0) - } -} - -main() diff --git a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/package.json b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/package.json deleted file mode 100644 index 492493d..0000000 --- a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-minify-mcp-output", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/test/index.test.mts b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/test/index.test.mts deleted file mode 100644 index ce83b7e..0000000 --- a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/test/index.test.mts +++ /dev/null @@ -1,164 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { compressMCPOutput, isMCPToolName } from '../index.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function runHook(payload: object): { - stdout: string - exitCode: number -} { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - }) - return { stdout: String(result.stdout), exitCode: result.status ?? -1 } -} - -// ---------- isMCPToolName ---------- - -test('isMCPToolName: accepts mcp__ prefix', () => { - assert.equal(isMCPToolName('mcp__github__list_repos'), true) - assert.equal(isMCPToolName('mcp__playwright__navigate'), true) -}) - -test('isMCPToolName: rejects built-in tool names', () => { - for (const name of ['Read', 'Bash', 'Edit', 'Write', 'Grep']) { - assert.equal(isMCPToolName(name), false) - } -}) - -test('isMCPToolName: rejects undefined / wrong type', () => { - assert.equal(isMCPToolName(undefined), false) - assert.equal(isMCPToolName(''), false) -}) - -// ---------- compressMCPOutput ---------- - -test('compressMCPOutput: minifies string-shaped response', () => { - const got = compressMCPOutput(' 1\thello\n 2\tworld\n') - assert.equal(got, 'hello\nworld\n') -}) - -test('compressMCPOutput: minifies text block in object', () => { - const got = compressMCPOutput({ - type: 'text', - text: '\n\n\n\nfoo\n', - }) - assert.deepEqual(got, { type: 'text', text: '\n\nfoo\n' }) -}) - -test('compressMCPOutput: minifies text blocks in arrays', () => { - const got = compressMCPOutput([ - { type: 'text', text: ' 1\tline a\n' }, - { type: 'text', text: ' 2\tline b\n' }, - ]) - assert.deepEqual(got, [ - { type: 'text', text: 'line a\n' }, - { type: 'text', text: 'line b\n' }, - ]) -}) - -test('compressMCPOutput: walks into nested content fields', () => { - const got = compressMCPOutput({ - content: [{ type: 'text', text: ' 1\tfoo\n' }], - }) - assert.deepEqual(got, { - content: [{ type: 'text', text: 'foo\n' }], - }) -}) - -test('compressMCPOutput: passes through non-text blocks', () => { - const input = { - type: 'image', - source: { data: 'abc', media_type: 'image/png' }, - } - assert.deepEqual(compressMCPOutput(input), input) -}) - -test('compressMCPOutput: passes through primitives that aren’t strings', () => { - assert.equal(compressMCPOutput(42), 42) - assert.equal(compressMCPOutput(true), true) - assert.equal(compressMCPOutput(undefined), null) -}) - -test('compressMCPOutput: minifies JSON-shaped strings', () => { - const got = compressMCPOutput('{\n "a": 1,\n "b": 2\n}') - assert.equal(got, '{"a":1,"b":2}') -}) - -// ---------- hook IO ---------- - -test('hook: SKIPS non-PostToolUse events', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PreToolUse', - tool_name: 'mcp__x__y', - tool_response: 'whatever', - }) - assert.equal(exitCode, 0) - assert.equal(stdout.trim(), '') -}) - -test('hook: SKIPS built-in tools', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Read', - tool_response: { content: 'whatever' }, - }) - assert.equal(exitCode, 0) - assert.equal(stdout.trim(), '') -}) - -test('hook: SKIPS when tool_response is absent', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'mcp__x__y', - }) - assert.equal(exitCode, 0) - assert.equal(stdout.trim(), '') -}) - -test('hook: emits updatedMCPToolOutput for MCP tool with text content', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'mcp__github__list_repos', - tool_response: [{ type: 'text', text: ' 1\tfoo\n 2\tbar\n' }], - }) - assert.equal(exitCode, 0) - const parsed = JSON.parse(stdout) as { - hookSpecificOutput: { - hookEventName: string - updatedMCPToolOutput: Array<{ text: string }> - } - } - assert.equal(parsed.hookSpecificOutput.hookEventName, 'PostToolUse') - assert.equal( - parsed.hookSpecificOutput.updatedMCPToolOutput[0]!.text, - 'foo\nbar\n', - ) -}) - -test('hook: emits updatedMCPToolOutput for MCP tool with string-shaped response', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'mcp__custom__tool', - tool_response: '{\n "x": 1\n}', - }) - assert.equal(exitCode, 0) - const parsed = JSON.parse(stdout) as { - hookSpecificOutput: { updatedMCPToolOutput: string } - } - assert.equal(parsed.hookSpecificOutput.updatedMCPToolOutput, '{"x":1}') -}) - -test('hook: fails open on malformed stdin', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: '{not json', - }) - assert.equal(result.status, 0) - assert.equal(String(result.stdout).trim(), '') -}) diff --git a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/tsconfig.json b/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/minify-mcp-output/minify-mcp-output/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md deleted file mode 100644 index 22a1796..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# no-blind-keychain-read-guard - -`PreToolUse(Bash)` blocker that refuses direct keychain READ calls -from Bash. The keychain APIs surface a UI auth prompt per call; -reading three times costs three prompts. The fleet's canonical -in-process resolver (`api-token.mts.findApiToken()`) caches the -value module-scoped after the first hit, so subsequent code paths -should never need to re-read the keychain. - -## Detected reads - -| Platform | Pattern | -| -------------- | ---------------------------------------------- | -| macOS | `security find-{generic,internet}-password` | -| Linux | `secret-tool lookup` / `secret-tool search` | -| Windows | `Get-StoredCredential` | -| Windows | `Get-Credential … \| ConvertFrom-SecureString` | -| cross-platform | `keyring get` | - -## Allowed (not flagged) - -Writes and deletes — these only happen during operator-driven -setup / rotation, never on hot paths: - -- `security add-generic-password` / `security delete-generic-password` -- `secret-tool store` / `secret-tool clear` -- `New-StoredCredential` / `Remove-StoredCredential` -- `keyring set` / `keyring del` - -## Bypass - -Type the canonical phrase verbatim in your next user turn: - -``` -Allow blind-keychain-read bypass -``` - -Use when you genuinely need a fresh keychain read — operator-invoked -diagnostics, verifying an entry exists, etc. - -## Why - -`security find-generic-password` on macOS prompts the user every call -unless the calling process is on the entry's ACL. Claude Code's Bash -tool spawns a fresh process per call, so each `security` invocation -re-prompts. The same shape exists on Linux (`secret-tool` against -gnome-keyring / kwallet) and Windows (`Get-StoredCredential` against -the CredentialManager UI). - -The right answer is to read the cached value from process state: - -```ts -import { findApiToken } from '../setup-security-tools/lib/api-token.mts' -const { token } = findApiToken() // module-cached after first call -``` - -Or from a child process spawned by hooks: - -```bash -echo "$SOCKET_API_KEY" # populated by wheelhouse shell-rc bridge -``` - -The bridge writes the token to `~/.zshenv` (or platform equivalent) -so every new shell exports `SOCKET_API_KEY` + `SOCKET_API_TOKEN` -without a keychain read. diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts deleted file mode 100644 index bdb1094..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/index.mts +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-blind-keychain-read-guard. -// -// Blocks Bash invocations that READ a credential from the OS -// keychain. Reading via the platform CLI surfaces a per-call UI auth -// prompt on the user's screen ("this app wants to access your -// keychain"), and the prompt fires once per call — a hook chain that -// reads the keychain three times costs three prompts. Tokens are -// already cached in process memory after the first resolution; the -// fleet's canonical resolver (`api-token.mts.findApiToken()`) hits -// the cache, then env, then keychain, in that order. Bash callers -// that go straight to `security find-generic-password` skip all of -// that and re-prompt the user every time. -// -// Detects (case-sensitive, structural — not just substring): -// -// macOS: -// security find-generic-password -// security find-internet-password -// -// Linux: -// secret-tool lookup -// secret-tool search -// -// Windows (PowerShell): -// Get-StoredCredential (CredentialManager module) -// Get-Credential (when piping to ConvertFrom-SecureString) -// -// Cross-platform (Python keyring CLI): -// keyring get -// -// Allowed (writes / deletes — necessary for operator-driven setup / -// rotation, never on hot paths): -// -// security add-generic-password security delete-generic-password -// secret-tool store secret-tool clear -// New-StoredCredential Remove-StoredCredential -// keyring set keyring del -// -// Bypass: `Allow blind-keychain-read bypass` in a recent user turn. -// Use when you genuinely need to verify a keychain entry exists -// (e.g. operator-invoked diagnostics). -// -// Exit codes: -// 0 — pass. -// 2 — block. -// -// Fails open on malformed payloads (exit 0 + stderr log) — the fleet's -// hook contract. - -import process from 'node:process' - -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly command?: string | undefined - } - | undefined - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined -} - -interface Hit { - readonly tool: string - readonly platform: 'macos' | 'linux' | 'windows' | 'cross-platform' - readonly snippet: string -} - -const BYPASS_PHRASE = 'Allow blind-keychain-read bypass' - -// Token-bearing read patterns. Each entry: the literal verb that -// surfaces a UI prompt + a label for the error message. Writes / -// deletes are intentionally absent from this list. -const READ_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly tool: string - readonly platform: Hit['platform'] -}> = [ - // macOS — `security(1)`. The `-w` flag prints the password to - // stdout, but even the metadata-only form triggers the ACL prompt. - { - re: /\bsecurity\s+(?:find-generic-password|find-internet-password)\b/, - tool: 'security find-*-password', - platform: 'macos', - }, - // Linux — `secret-tool`. `lookup` returns the password; `search` - // lists matches (also surfaces the libsecret prompt). - { - re: /\bsecret-tool\s+(?:lookup|search)\b/, - tool: 'secret-tool lookup/search', - platform: 'linux', - }, - // Windows PowerShell — CredentialManager module. The - // `Get-StoredCredential` cmdlet returns a PSCredential; reading - // `.Password | ConvertFrom-SecureString` is the read pattern. - { - re: /\bGet-StoredCredential\b/, - tool: 'Get-StoredCredential', - platform: 'windows', - }, - // PowerShell `Get-Credential -Credential` piped to - // `ConvertFrom-SecureString -AsPlainText` is the readback shape. - // The bare `Get-Credential` (no pipe) is a fresh-prompt-the-user - // flow and not the issue here — match only the readback pipe. - { - re: /\bGet-Credential\b[^|]*\|\s*ConvertFrom-SecureString\b/, - tool: 'Get-Credential | ConvertFrom-SecureString', - platform: 'windows', - }, - // Python `keyring` CLI — `keyring get <service> <username>`. - { - re: /\bkeyring\s+get\b/, - tool: 'keyring get', - platform: 'cross-platform', - }, -] - -/** - * Scan a Bash command string for keychain READ patterns. Returns one hit per - * matching subcommand so the error message can name them all (a `&&`-chained - * command might have multiple). - */ -export function findKeychainReads(command: string): Hit[] { - const hits: Hit[] = [] - for (let i = 0, { length } = READ_PATTERNS; i < length; i += 1) { - const entry = READ_PATTERNS[i]! - const m = entry.re.exec(command) - if (!m) { - continue - } - // Pull a short snippet around the match (up to 80 chars) so the - // operator can see the context. Centered on the match start. - const start = Math.max(0, m.index - 10) - const end = Math.min(command.length, m.index + m[0].length + 50) - const snippet = command.slice(start, end) - hits.push({ - tool: entry.tool, - platform: entry.platform, - snippet: snippet.length < command.length ? `…${snippet}…` : snippet, - }) - } - return hits -} - -function handlePayload(payloadRaw: string): number { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - return 0 - } - if (payload.tool_name !== 'Bash') { - return 0 - } - const command = payload.tool_input?.command ?? '' - if (!command) { - return 0 - } - const hits = findKeychainReads(command) - if (hits.length === 0) { - return 0 - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - return 0 - } - const lines: string[] = [] - lines.push( - '[no-blind-keychain-read-guard] Blocked: direct keychain READ from Bash.', - ) - lines.push('') - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - lines.push(` ${h.platform.padEnd(15)} ${h.tool}`) - lines.push(` Saw: ${h.snippet}`) - } - lines.push('') - lines.push(' Reading the keychain via the platform CLI surfaces a UI auth') - lines.push(" prompt on the user's screen — and the prompt fires once per") - lines.push(' call. A hook chain that reads three times costs three prompts.') - lines.push('') - lines.push(' The token is almost certainly already available without a') - lines.push(' keychain read:') - lines.push('') - lines.push(' - In-process: call findApiToken() from setup-security-tools/') - lines.push(' lib/api-token.mts. It returns the module-cached value from') - lines.push(' the first call onward, then env, then keychain.') - lines.push('') - lines.push(' - From Bash: read process.env.SOCKET_API_KEY or') - lines.push( - ' process.env.SOCKET_API_TOKEN. The wheelhouse shell-rc bridge', - ) - lines.push(' exports both for every new shell session.') - lines.push('') - lines.push(' Writes / deletes (security add-generic-password / secret-tool') - lines.push(' store / New-StoredCredential / etc.) are allowed — they only') - lines.push(' happen during operator-driven setup / rotation.') - lines.push('') - lines.push(' Bypass (e.g. operator-invoked diagnostics that need a fresh') - lines.push(' keychain read):') - lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) - process.stderr.write(lines.join('\n') + '\n') - return 2 -} - -export { handlePayload } - -// CLI entrypoint — only fires when this file is the main module. -// During tests the importer pulls `findKeychainReads` without triggering -// the stdin reader (which would never see an `end` event in test env -// and hang the process). -if (process.argv[1] && process.argv[1].endsWith('index.mts')) { - let payloadRaw = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { - payloadRaw += chunk - }) - process.stdin.on('end', () => { - try { - process.exit(handlePayload(payloadRaw)) - } catch (e) { - process.stderr.write( - `[no-blind-keychain-read-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) -} diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/package.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/package.json deleted file mode 100644 index 819429b..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-blind-keychain-read-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/test/index.test.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/test/index.test.mts deleted file mode 100644 index 8567a3b..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/test/index.test.mts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @file Unit tests for findKeychainReads — the structural matcher that - * classifies a Bash command string into keychain READ hits (vs writes, - * deletes, and unrelated commands). - */ - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { findKeychainReads } from '../index.mts' - -test('macOS find-generic-password is flagged', () => { - const hits = findKeychainReads( - 'security find-generic-password -s socket-cli -a SOCKET_API_KEY -w', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'macos') -}) - -test('macOS find-internet-password is flagged', () => { - const hits = findKeychainReads( - 'security find-internet-password -s example.com -a user', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'macos') -}) - -test('macOS add-generic-password is NOT flagged (write)', () => { - const hits = findKeychainReads( - 'security add-generic-password -U -s socket-cli -a SOCKET_API_KEY -w xxx', - ) - assert.equal(hits.length, 0) -}) - -test('macOS delete-generic-password is NOT flagged (delete)', () => { - const hits = findKeychainReads( - 'security delete-generic-password -s socket-cli -a SOCKET_API_KEY', - ) - assert.equal(hits.length, 0) -}) - -test('Linux secret-tool lookup is flagged', () => { - const hits = findKeychainReads( - 'secret-tool lookup service socket-cli user SOCKET_API_KEY', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'linux') -}) - -test('Linux secret-tool search is flagged', () => { - const hits = findKeychainReads('secret-tool search service socket-cli') - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'linux') -}) - -test('Linux secret-tool store is NOT flagged (write)', () => { - const hits = findKeychainReads( - 'secret-tool store --label="Socket API token" service socket-cli user SOCKET_API_KEY', - ) - assert.equal(hits.length, 0) -}) - -test('Linux secret-tool clear is NOT flagged (delete)', () => { - const hits = findKeychainReads( - 'secret-tool clear service socket-cli user SOCKET_API_KEY', - ) - assert.equal(hits.length, 0) -}) - -test('Windows Get-StoredCredential is flagged', () => { - const hits = findKeychainReads( - 'powershell -Command "(Get-StoredCredential -Target \'socket-cli:SOCKET_API_KEY\').Password"', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'windows') -}) - -test('Windows Get-Credential | ConvertFrom-SecureString is flagged', () => { - const hits = findKeychainReads( - 'Get-Credential -Credential admin | ConvertFrom-SecureString -AsPlainText', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'windows') -}) - -test('Windows Get-Credential WITHOUT pipe is NOT flagged (fresh prompt)', () => { - // Bare Get-Credential is an interactive fresh-prompt flow, not a - // readback of a stored credential. Don't block. - const hits = findKeychainReads('$cred = Get-Credential -Credential admin') - assert.equal(hits.length, 0) -}) - -test('Windows New-StoredCredential is NOT flagged (write)', () => { - const hits = findKeychainReads( - "New-StoredCredential -Target 'socket-cli:SOCKET_API_KEY' -UserName x -SecurePassword $s", - ) - assert.equal(hits.length, 0) -}) - -test('keyring get is flagged', () => { - const hits = findKeychainReads('keyring get socket-cli SOCKET_API_KEY') - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'cross-platform') -}) - -test('keyring set is NOT flagged (write)', () => { - const hits = findKeychainReads('keyring set socket-cli SOCKET_API_KEY') - assert.equal(hits.length, 0) -}) - -test('chained reads count separately', () => { - // && chain with two reads - const hits = findKeychainReads( - 'security find-generic-password -s a -a b -w && secret-tool lookup service a user b', - ) - assert.equal(hits.length, 2) -}) - -test('unrelated commands are not flagged', () => { - for (const cmd of [ - 'ls -la', - 'git log --oneline -5', - 'echo $SOCKET_API_KEY', - 'pnpm install', - 'grep security file.txt', - 'security delete-keychain ~/Library/Keychains/foo.keychain', - ]) { - const hits = findKeychainReads(cmd) - assert.equal(hits.length, 0, `should not flag: ${cmd}`) - } -}) - -test('command substitution wrapping is still flagged', () => { - // The structural matcher is intentionally a regex, not an AST. This - // catches the common subshell shape — verifying the inner verb is - // detected even inside `$(...)`. AST-based parsing is overkill for - // a non-security-critical reminder hook. - const hits = findKeychainReads( - 'TOKEN="$(security find-generic-password -s socket-cli -a SOCKET_API_KEY -w)" && echo done', - ) - assert.equal(hits.length, 1) -}) diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/tsconfig.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/no-blind-keychain-read-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md deleted file mode 100644 index 909dd21..0000000 --- a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# no-meta-comments-guard - -`PreToolUse(Edit|Write)` hook. Blocks source-file edits that introduce a comment which either: - -1. **References the current task / plan / user request** rather than the code's runtime semantics — e.g. `// Plan: use the cache here` / `// Task: rename foo to bar` / `// Per the task instructions, swap to async` / `// As requested, add retry`. - -2. **Describes code that was removed** rather than code that exists — e.g. `// removed: old behavior used a Map here` / `// previously called X` / `// used to be sync, made async in 6.0`. - -Per CLAUDE.md "Code style → Comments": comments default to none; when written, they explain the **constraint** or the **hidden invariant**, not the development context. Development context (the plan, the task, the user request, removed code) goes in commit messages and PR descriptions, not source comments. - -## The comment is usually useful — it's the prefix that's noise - -When the hook fires on a `Plan:` / `Task:` style comment, the suggested fix **strips the meta prefix and keeps the underlying explanation**: - -``` -Saw: // Plan: use the cache to avoid re-resolving -Suggest: // Use the cache to avoid re-resolving -``` - -The agent gets to keep the useful "why" — drop the meta-label. - -For removed-code references the suggestion is to delete entirely (the info lives in git history). - -## File scope - -Only matches source files: `.{m,c,}{j,t}sx?`, `.cc`, `.cpp`, `.h`, `.hpp`, `.rs`, `.go`, `.py`, `.sh`. Markdown / JSON / YAML aren't checked — those file types use `#` / `//` / `*` as legitimate body content, not as comment markers. - -## Bypass - -There's no canonical bypass phrase. The fix is to rewrite the comment per the suggestion. If you genuinely need the comment to read as-is (rare — usually means the explanation is missing important context), the hook can be temporarily disabled via `SOCKET_NO_META_COMMENTS_DISABLED=1` for the session. - -## Source of truth - -The rule itself lives in [`CLAUDE.md`](../../../CLAUDE.md) under "Code style → Comments". This hook enforces it at edit time. diff --git a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts deleted file mode 100644 index 895d831..0000000 --- a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/index.mts +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-meta-comments-guard. -// -// Blocks Edit/Write tool calls that introduce a comment which: -// -// (a) References the current task / plan / user request rather -// than the code's runtime semantics: -// // Plan: use the cache here -// // Task: rename foo to bar -// // Per the task instructions, swap to async -// // As requested, add retry -// // TODO from the brief: handle Win32 -// -// (b) Describes code that was removed rather than code that -// exists: -// // removed: old behavior used a Map here -// // previously called X; now Y -// // used to be sync, made async in 6.0 -// // no longer using fetch — see commit abc1234 -// -// Per CLAUDE.md "Code style → Comments": comments default to none; -// when written, audience is a junior dev — explain the CONSTRAINT -// or the hidden invariant, not the development context (commit -// messages and PR descriptions are where development context goes). -// -// On block, emits a stderr suggestion stripping the meta prefix so -// the agent can keep the explanation if it's actually useful and -// just drop the noise. Example transform: -// -// // Plan: use the cache to avoid re-resolving → // Use the cache to avoid re-resolving -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Exit codes: -// 0 — pass (not Edit/Write, no meta comments). -// 2 — block (at least one meta-comment pattern found). -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import process from 'node:process' - -import { splitLines, walkComments } from '../_shared/acorn/index.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly content?: string | undefined - readonly file_path?: string | undefined - readonly new_string?: string | undefined - } - | undefined - readonly tool_name?: string | undefined -} - -interface MetaCommentFinding { - readonly kind: 'task' | 'removed-code' - readonly line: number - readonly snippet: string - readonly suggestion: string -} - -// Task / plan / user-request references. -// -// Patterns are anchored on `// `, `/* `, `# `, ` * `, ` - ` (markdown -// bullet inside comment) so we don't false-positive on identifiers -// or string literals containing the words. -// -// `Plan:` / `Task:` are case-insensitive leading labels. The free- -// form phrases (`per the task`, `as requested`) match anywhere in -// the comment body — those are the dead-give-away tells, not the -// rest of the sentence. -const TASK_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly stripPrefix?: RegExp | undefined -}> = [ - // `// Plan: ...` / `// Task: ...` / `// Note from plan: ...` - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:plan|task|note from (?:brief|plan|task))\s*:/i, - stripPrefix: - /^(\s*(?:\/\/|\/\*|\*|#|-)\s*)(?:plan|task|note from (?:brief|plan|task))\s*:\s*/i, - }, - // `// Per the task ...` / `// Per the plan ...` / `// As requested ...` - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:per the (?:brief|plan|request|spec|task|user)|as requested|per the user('s)? request)\b/i, - }, - // `// TODO from the brief` / `// FIXME per plan` - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:FIXME|TODO|XXX)\s+(?:from|per)\s+(?:the\s+)?(?:brief|plan|request|spec|task|user)\b/i, - }, - // Phase / tier / step markers — `// Tier 1 ...`, `// Phase 10a: - // ...`, `// Step 3 - ...`. These leak the roadmap shape into source - // and rot when the roadmap shifts. Catch as bare labels (followed - // by whitespace + number) OR as `Phase NNN:` / `Step NNN -` colon / - // dash labels. - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\b/i, - stripPrefix: - /^(\s*(?:\/\/|\/\*|\*|#|-)\s*)(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\s*[:.-]?\s*/i, - }, -] - -// Removed-code references. -const REMOVED_CODE_PATTERNS: readonly RegExp[] = [ - // `// removed X` / `// removed: X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*removed\b/i, - // `// previously X` / `// previously called X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*previously\b/i, - // `// used to X` / `// used to be X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*used\s+to\b/i, - // `// no longer X` / `// no longer needed` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*no\s+longer\b/i, - // `// formerly X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*formerly\b/i, -] - -/** - * Uppercase the first alphabetic character that follows the comment marker, so - * a stripped `// plan: use the cache` reads as `// Use the cache`. Skips the - * comment marker tokens so they don't count as "first letter". - */ -export function uppercaseFirstLetterAfterMarker(line: string): string { - const m = line.match(/^(\s*(?:\/\/|\/\*|\*|#|-)\s*)([a-zA-Z])/) - if (!m) { - return line - } - const prefix = m[1]! - const firstChar = m[2]! - return prefix + firstChar.toUpperCase() + line.slice(prefix.length + 1) -} - -// Body-only versions of the patterns (no comment-marker prefix — -// the AST walker already gives us the body text). The same TASK_PATTERNS -// and REMOVED_CODE_PATTERNS above retain the marker-prefixed form so the -// non-JS lexical path below can still use them. -const TASK_BODY_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly stripBody?: RegExp | undefined -}> = [ - { - re: /^\s*(?:plan|task|note from (?:brief|plan|task))\s*:/i, - stripBody: /^\s*(?:plan|task|note from (?:brief|plan|task))\s*:\s*/i, - }, - { - re: /^\s*(?:per the (?:brief|plan|request|spec|task|user)|as requested|per the user('s)? request)\b/i, - }, - { - re: /^\s*(?:FIXME|TODO|XXX)\s+(?:from|per)\s+(?:the\s+)?(?:brief|plan|request|spec|task|user)\b/i, - }, - { - re: /^\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\b/i, - stripBody: - /^\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\s*[:.-]?\s*/i, - }, -] - -const REMOVED_CODE_BODY_PATTERNS: readonly RegExp[] = [ - /^\s*removed\b/i, - /^\s*previously\b/i, - /^\s*used\s+to\b/i, - /^\s*no\s+longer\b/i, - /^\s*formerly\b/i, -] - -/** - * AST-based detector for JS/TS/JSX/TSX source. Uses `walkComments` from the - * shared acorn helper to walk just the comment tokens — string-literal mentions - * of `Plan:` / `Task:` etc. don't trigger. - */ -export function findMetaCommentsAst(text: string): MetaCommentFinding[] { - const findings: MetaCommentFinding[] = [] - const lines = splitLines(text) - for (const c of walkComments(text, { comments: true })) { - // Block comments may have multiple meaningful lines; check each - // line of the body individually so the suggestion can name the - // exact offending line. - const bodyLines = splitLines(c.value) - for (let li = 0; li < bodyLines.length; li += 1) { - const body = bodyLines[li]! - // Strip leading ` *` / `*` decorators that JSDoc-style blocks use. - const cleaned = body.replace(/^\s*\*\s?/, '') - const lineNum = c.line + li - const sourceLine = (lines[lineNum - 1] ?? '').trim() - let matched = false - for (const { re, stripBody } of TASK_BODY_PATTERNS) { - if (!re.test(cleaned)) { - continue - } - const stripped = stripBody - ? cleaned.replace(stripBody, '').trim() - : cleaned.trim() - const suggestion = uppercaseFirstLetterAfterMarker( - c.kind === 'Line' ? `// ${stripped}` : `* ${stripped}`, - ) - findings.push({ - kind: 'task', - line: lineNum, - snippet: sourceLine, - suggestion: - suggestion || - '(remove the comment entirely — it has no runtime content)', - }) - matched = true - break - } - if (matched) { - continue - } - for ( - let i = 0, { length } = REMOVED_CODE_BODY_PATTERNS; - i < length; - i += 1 - ) { - const re = REMOVED_CODE_BODY_PATTERNS[i]! - if (!re.test(cleaned)) { - continue - } - findings.push({ - kind: 'removed-code', - line: lineNum, - snippet: sourceLine, - suggestion: - '(remove the comment — code that no longer exists is git-history territory, not source comments)', - }) - break - } - } - } - return findings -} - -/** - * Lexical-regex fallback for non-JS sources (C++, Rust, Go, Python, shell). The - * acorn-wasm parser only understands JS/TS, so for those languages we keep the - * marker-anchored regex scan. False-positives on string-literal mentions of `// - * Plan:` etc. are possible but rare in practice for those language - * conventions. - */ -export function findMetaCommentsLexical(text: string): MetaCommentFinding[] { - const findings: MetaCommentFinding[] = [] - const lines = splitLines(text) - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - for (const { re, stripPrefix } of TASK_PATTERNS) { - if (!re.test(`\n${line}`)) { - continue - } - const stripped = stripPrefix - ? line.replace(stripPrefix, '$1').replace(/\s+/g, ' ').trim() - : line - .trim() - .replace(/^[\s/*#-]+/, '') - .trim() - const suggestion = uppercaseFirstLetterAfterMarker(stripped) - findings.push({ - kind: 'task', - line: i + 1, - snippet: line.trim(), - suggestion: - suggestion || - '(remove the comment entirely — it has no runtime content)', - }) - break - } - for (let i = 0, { length } = REMOVED_CODE_PATTERNS; i < length; i += 1) { - const re = REMOVED_CODE_PATTERNS[i]! - if (!re.test(`\n${line}`)) { - continue - } - findings.push({ - kind: 'removed-code', - line: i + 1, - snippet: line.trim(), - suggestion: - '(remove the comment — code that no longer exists is git-history territory, not source comments)', - }) - break - } - } - return findings -} - -const JS_TS_FILE_RE = /\.(?:[cm]?[jt]sx?)$/ - -export function findMetaComments( - text: string, - filePath: string, -): MetaCommentFinding[] { - return JS_TS_FILE_RE.test(filePath) - ? findMetaCommentsAst(text) - : findMetaCommentsLexical(text) -} - -let payloadRaw = '' -process.stdin.setEncoding('utf8') -process.stdin.on('data', chunk => { - payloadRaw += chunk -}) -process.stdin.on('end', () => { - try { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path ?? '' - // Only check source files. Markdown / json / yaml don't have - // "code comments" in the relevant sense — those file types use - // the same prefix tokens (`#`, `//`, `*`) as legitimate body - // content, not as comment markers. - if (!/\.(?:[cm]?[jt]sx?|cc|cpp|h|hpp|rs|go|py|sh)$/.test(filePath)) { - process.exit(0) - } - const text = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!text) { - process.exit(0) - } - - const findings = findMetaComments(text, filePath) - if (findings.length === 0) { - process.exit(0) - } - - const lines: string[] = [] - lines.push('[no-meta-comments-guard] Blocked: meta-comment(s) in source.') - lines.push(` File: ${filePath}`) - lines.push('') - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - lines.push(` Line ${f.line} (${f.kind}):`) - lines.push(` Saw: ${f.snippet}`) - lines.push(` Suggest: ${f.suggestion}`) - lines.push('') - } - lines.push(' Per CLAUDE.md "Code style → Comments": comments describe the') - lines.push(' CONSTRAINT or the hidden invariant. Development context') - lines.push( - ' (the plan, the task, the user request, removed code) lives in', - ) - lines.push(' commit messages and PR descriptions, not source comments.') - lines.push('') - lines.push(' Rewrite or delete the comment, then retry the Edit/Write.') - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) - } catch (e) { - process.stderr.write( - `[no-meta-comments-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } -}) diff --git a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/package.json b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/package.json deleted file mode 100644 index 8c1e7e4..0000000 --- a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-meta-comments-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/test/index.test.mts b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/test/index.test.mts deleted file mode 100644 index 82aeb4e..0000000 --- a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/test/index.test.mts +++ /dev/null @@ -1,261 +0,0 @@ -// node --test specs for the no-meta-comments-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'echo // Plan: do thing' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('non-source files pass through (markdown / json / yaml)', async () => { - for (const file_path of [ - '/x/docs/readme.md', - '/x/package.json', - '/x/.github/workflows/ci.yml', - ]) { - const result = await runHook({ - tool_input: { - file_path, - new_string: '// Plan: do the thing\nconst x = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0, file_path) - } -}) - -test('// Plan: prefix is blocked with strip-prefix suggestion', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - 'const x = 1\n// Plan: use the cache to avoid re-resolving\nconst y = 2', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Plan/) - assert.match(result.stderr, /Use the cache to avoid re-resolving/) -}) - -test('// Task: prefix is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.mts', - new_string: '// Task: rename foo to bar\nconst bar = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Per the task instructions ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// Per the task instructions, swap to async\nawait foo()', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Per the task/i) -}) - -test('// As requested ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// As requested, add retry\nawait retry(foo)', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// removed X is blocked (removed-code pattern)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// removed: old behavior used a Map here\nconst data = new Set()', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /removed-code/) -}) - -test('// previously called X is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// previously called fooSync; now async\nasync function foo() {}', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// used to be sync, made async in 6.0 is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// used to be sync, made async in 6.0\nasync function foo() {}', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// no longer needed because X is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// no longer needed because Node 26 ships this natively\nlet polyfill: unknown', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Tier 1 implementation. is blocked (phase marker)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.cc', - new_string: '// Tier 1 implementation. Mirrors upstream X.\nint x = 1;', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Tier 1/) -}) - -test('// Tier 2 surface — mirrors ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.hpp', - new_string: '// Tier 2 surface — mirrors OpenTUI.\nclass Foo {};', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Phase 10a: temporal_rs shim ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// Phase 10a: temporal_rs shim Instant\nconst x = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Step 3 - parser rejection is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.go', - new_string: '// Step 3 - parser rejection\nx := 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Milestone V achievable is blocked (Roman numeral phase)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// Milestone V achievable now\nconst x = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// "tier" inside content (not a phase marker) passes through', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// Cache tier selection happens in resolveTier()\nconst t = 0', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`) -}) - -test('normal explanatory comments pass through', async () => { - for (const text of [ - '// Use the cache to avoid re-resolving on every call.\nconst cache = new Map()', - "// Falls back to the JS impl when smol-versions isn't available.\nconst v = getSmol()", - '// V8 inlines this when the call site is monomorphic.\nfunction hot() {}', - '/* Multi-line block comments describing the invariant\n are also fine. */\nfunction f() {}', - ]) { - const result = await runHook({ - tool_input: { file_path: '/x/src/foo.ts', new_string: text }, - tool_name: 'Edit', - }) - assert.strictEqual( - result.code, - 0, - `Expected pass for: ${text.slice(0, 60)}…\n stderr: ${result.stderr}`, - ) - } -}) - -test('multiple findings in one file are all surfaced', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// Plan: use the cache\nconst x = 1\n// removed: old impl was sync\nconst y = 2', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Plan/) - assert.match(result.stderr, /removed-code/) - // Both line numbers should appear in the output. - assert.match(result.stderr, /Line 1/) - assert.match(result.stderr, /Line 3/) -}) diff --git a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/tsconfig.json b/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-meta-comments-guard/no-meta-comments-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md deleted file mode 100644 index f12eb5f..0000000 --- a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# no-orphaned-staging - -Stop hook. Fires at turn-end and lists any files that are staged -(`git diff --cached --name-only`) but not yet committed. - -## Why - -Fleet rule from CLAUDE.md ("Don't leave the worktree dirty"): - -> Stage only when you're about to commit. `git add` and `git commit` -> belong on the same line (chained with `&&`) OR in the same Bash -> call. Don't stage as a side-effect of "preparing" — staging is a -> commit-time action. - -A turn that ends with staged-but-uncommitted hunks is the failure -mode the rule warns against. Common causes: - -1. The agent ran `git add` but forgot the `git commit`. -2. A pre-commit hook failed and left the index half-cooked. -3. The agent staged "for later" — exactly what this rule forbids. - -All three look identical to the next session: a populated index of -unknown provenance. The reminder makes the dangling state visible -at the turn that created it. - -## Output - -Stderr only. Exit code always 0 — informational, never blocks -(Stop hooks can't refuse anything anyway; the turn already ended). - -``` -[no-orphaned-staging] Turn ended with staged-but-uncommitted files: - - scripts/foo.mts - - template/CLAUDE.md - ... and 3 more - -Fleet rule: stage only when about to commit. Either: - • Run `git commit` to finish the work, OR - • Run `git reset` to unstage (keep changes in working tree). - -CLAUDE.md → "Don't leave the worktree dirty" → "Stage only when -you're about to commit". -``` - -## Disable - -`SOCKET_NO_ORPHANED_STAGING_DISABLED=1` in the env. Use during -intentional mid-refactor pauses or worktree migrations where staged -state is the work-product. diff --git a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts deleted file mode 100644 index 7fab1d6..0000000 --- a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/index.mts +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — no-orphaned-staging. -// -// Fires at turn-end. Checks `git diff --cached --name-only` in -// $CLAUDE_PROJECT_DIR. If anything is staged but uncommitted, emits -// a stderr warning listing the orphaned paths. -// -// The fleet rule (CLAUDE.md "Don't leave the worktree dirty"): -// -// Stage only when you're about to commit. `git add` and `git -// commit` belong on the same line (chained with `&&`) OR in the -// same Bash call. Don't stage as a side-effect of "preparing" -// — staging is a commit-time action. -// -// A turn that ends with staged-but-uncommitted hunks tends to be -// either: -// (a) the agent forgot the commit half of `git add && git commit`, -// (b) a failed pre-commit hook unstuck the index, or -// (c) the agent staged "for later" — exactly what this rule -// forbids. -// -// All three are the same failure mode: the next session sees an -// already-staged index and has to figure out the intent. The -// reminder makes the dangling state visible at the very turn that -// created it. -// -// Why a reminder, not a block: Stop hooks fire AFTER the turn ended; -// there's no tool call to refuse. The signal goes to stderr so the -// next message includes the warning. The agent can then either -// commit or explicitly explain why the staged state is intentional. -// -// Exit codes: -// 0 — always. This is informational; never blocks. -// -// Disabled via `SOCKET_NO_ORPHANED_STAGING_DISABLED=1`. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -export async function drainStdin(): Promise<void> { - // Stop payloads carry transcript_path; this hook doesn't need it, - // but the stdin must be drained so the harness doesn't pipe-stall. - await new Promise<void>(resolve => { - let chunks = '' - process.stdin.on('data', d => { - chunks += d.toString('utf8') - }) - process.stdin.on('end', () => resolve()) - process.stdin.on('error', () => resolve()) - setTimeout(() => resolve(), 200) - void chunks - }) -} - -export function getProjectDir(): string | undefined { - // Prefer the harness-supplied env (correct even when cwd has been - // chdir'd by a tool). Fall back to cwd. - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -export function listStagedFiles(repoDir: string): string[] { - const r = spawnSync('git', ['diff', '--cached', '--name-only'], { - cwd: repoDir, - timeout: 5_000, - }) - if (r.status !== 0) { - return [] - } - return String(r.stdout) - .split('\n') - .map((s: string) => s.trim()) - .filter(Boolean) -} - -async function main(): Promise<void> { - if (process.env['SOCKET_NO_ORPHANED_STAGING_DISABLED']) { - return - } - await drainStdin() - - const repoDir = getProjectDir() - if (!repoDir) { - return - } - - const staged = listStagedFiles(repoDir) - if (staged.length === 0) { - return - } - - process.stderr.write( - '[no-orphaned-staging] Turn ended with staged-but-uncommitted files:\n', - ) - for (const f of staged.slice(0, 10)) { - process.stderr.write(` - ${f}\n`) - } - if (staged.length > 10) { - process.stderr.write(` ... and ${staged.length - 10} more\n`) - } - process.stderr.write( - '\nFleet rule: stage only when about to commit. Either:\n' + - ' • Run `git commit` to finish the work, OR\n' + - ' • Run `git reset` to unstage (keep changes in working tree).\n' + - '\nCLAUDE.md → "Don\'t leave the worktree dirty" → "Stage only when ' + - 'you\'re about to commit".\n', - ) -} - -main().catch(e => { - process.stderr.write( - `[no-orphaned-staging] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) -}) diff --git a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/package.json b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/package.json deleted file mode 100644 index 898f674..0000000 --- a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-orphaned-staging", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/test/index.test.mts b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/test/index.test.mts deleted file mode 100644 index 8b55414..0000000 --- a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/test/index.test.mts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Unit tests for no-orphaned-staging hook. Test strategy: create a temp - * git repo, stage a file (or not), spawn the hook with CLAUDE_PROJECT_DIR - * pointed at the temp repo, and inspect stderr. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, describe, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - code: number - stderr: string -} - -function runHook(env: Record<string, string>): RunResult { - const r = spawnSync('node', [HOOK], { - input: '{}', - env: { ...process.env, ...env }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function git(repoDir: string, args: string[]): void { - const r = spawnSync('git', args, { cwd: repoDir }) - if (r.status !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`) - } -} - -let tmpRepo: string - -beforeEach(() => { - tmpRepo = mkdtempSync(path.join(os.tmpdir(), 'no-orphaned-staging-')) - git(tmpRepo, ['init', '-q']) - git(tmpRepo, ['config', 'user.email', 'test@example.com']) - git(tmpRepo, ['config', 'user.name', 'Test']) - writeFileSync(path.join(tmpRepo, 'README.md'), '# test\n') - git(tmpRepo, ['add', 'README.md']) - git(tmpRepo, ['commit', '-q', '-m', 'initial']) -}) - -afterEach(() => { - rmSync(tmpRepo, { recursive: true, force: true }) -}) - -describe('no-orphaned-staging', () => { - test('clean index → silent', () => { - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') - }) - - test('staged file → warning', () => { - writeFileSync(path.join(tmpRepo, 'foo.txt'), 'staged content\n') - git(tmpRepo, ['add', 'foo.txt']) - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.equal(r.code, 0) - assert.match(r.stderr, /no-orphaned-staging/) - assert.match(r.stderr, /foo\.txt/) - }) - - test('multiple staged files listed', () => { - for (const name of ['a.txt', 'b.txt', 'c.txt']) { - writeFileSync(path.join(tmpRepo, name), `${name}\n`) - git(tmpRepo, ['add', name]) - } - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.equal(r.code, 0) - for (const name of ['a.txt', 'b.txt', 'c.txt']) { - assert.match(r.stderr, new RegExp(name)) - } - }) - - test('disabled via env → silent even when staged', () => { - writeFileSync(path.join(tmpRepo, 'foo.txt'), 'staged content\n') - git(tmpRepo, ['add', 'foo.txt']) - const r = runHook({ - CLAUDE_PROJECT_DIR: tmpRepo, - SOCKET_NO_ORPHANED_STAGING_DISABLED: '1', - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') - }) - - test('non-repo dir → silent (not a git repo)', () => { - const nonRepo = mkdtempSync(path.join(os.tmpdir(), 'not-a-repo-')) - try { - const r = runHook({ CLAUDE_PROJECT_DIR: nonRepo }) - assert.equal(r.code, 0) - // git returns non-zero exit + the helper returns empty list. - assert.equal(r.stderr, '') - } finally { - rmSync(nonRepo, { recursive: true, force: true }) - } - }) - - test('truncates listing past 10 files', () => { - for (let i = 0; i < 15; i += 1) { - const name = `f${i}.txt` - writeFileSync(path.join(tmpRepo, name), `${name}\n`) - git(tmpRepo, ['add', name]) - } - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.match(r.stderr, /and 5 more/) - }) - - test('fail-open on hook bug', () => { - // Empty stdin would normally drain; verifying the hook doesn't - // crash on missing-env-vars or other edge cases. - const r = spawnSync('node', [HOOK], { - input: '', - env: { ...process.env, CLAUDE_PROJECT_DIR: '/nonexistent/path' }, - }) - assert.equal(r.status, 0) - }) -}) diff --git a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/tsconfig.json b/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-orphaned-staging/no-orphaned-staging/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts deleted file mode 100644 index c6eeae3..0000000 --- a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/index.mts +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — overeager-staging-guard. -// -// Catches the failure mode where an agent's `git commit` sweeps in -// files it didn't author — usually another Claude session's work -// that was already staged when this session opened the repo. Two -// enforcement layers: -// -// 1. BLOCK `git add -A` / `git add .` / `git add --all` / `git add -u` -// / `git add --update`. These sweep everything in the working -// tree into the index, which is hostile to parallel-session -// repos: another agent's unstaged edits get staged into your -// next commit. Per CLAUDE.md: "surgical `git add <specific-file>`. -// Never `-A` / `.`." -// -// 2. WARN on `git commit` when the index contains files the agent -// has NOT touched this session (via Edit / Write / `git add -// <path>` / `git rm <path>`). Exits 0 — informational, not a -// block — but emits a stderr summary listing every unfamiliar -// staged file so the agent has a chance to spot parallel-session -// work before the commit goes through. -// -// Detection heuristic: list staged files, compare against tool- -// use history in the transcript. Files staged but never touched -// this session surface as suspicious entries. -// -// Both layers fail open on hook bugs (exit 0 + stderr log). -// -// Bypass: -// - `Allow add-all bypass` in a recent user turn (case-sensitive, -// exact match) — disables layer 1 for the next add. -// - `SOCKET_OVEREAGER_STAGING_GUARD_DISABLED=1` — disables both. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// "transcript_path": "/.../session.jsonl" } - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import process from 'node:process' - -import { readTouchedPaths } from '../_shared/foreign-paths.mts' -import { - detectBroadGitAdd, - findInvocation, -} from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_OVEREAGER_STAGING_GUARD_DISABLED' -const BYPASS_PHRASES = ['Allow add-all bypass'] as const - -export function getRepoDir(): string { - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -export function isGitCommit(command: string): boolean { - return findInvocation(command, { binary: 'git', subcommand: 'commit' }) -} - -export function listStagedFiles(repoDir: string): string[] { - const r = spawnSync('git', ['diff', '--cached', '--name-only'], { - cwd: repoDir, - timeout: 5_000, - }) - if (r.status !== 0) { - return [] - } - return String(r.stdout) - .split('\n') - .map((s: string) => s.trim()) - .filter(Boolean) -} - - -async function main(): Promise<void> { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const raw = await readStdin() - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = ( - payload.tool_input as { command?: unknown | undefined } | undefined - )?.command - if (typeof command !== 'string' || !command.trim()) { - process.exit(0) - } - - const repoDir = getRepoDir() - const transcriptPath = payload.transcript_path - - // ── Layer 1: block `git add -A` / `.` / `-u` ───────────────────── - const broad = detectBroadGitAdd(command) - if (broad) { - // Fleet-sync sentinel: cascade scripts run `git add -u` inside a - // worktree they just created off origin/main — no parallel-session - // hazard because the worktree is empty otherwise. Same opt-in - // sentinel the no-revert-guard recognizes (`FLEET_SYNC=1` prefix). - if (/(?:^|\s)FLEET_SYNC\s*=\s*1\b/.test(command)) { - process.exit(0) - } - if ( - transcriptPath && - bypassPhrasePresent(transcriptPath, BYPASS_PHRASES, 3) - ) { - process.exit(0) - } - process.stderr.write( - [ - `[overeager-staging-guard] Blocked: ${broad}`, - '', - ' This sweeps the entire working tree into the index.', - " In a parallel-session repo, that pulls in another agent's", - ' unstaged edits and they get swept into your next commit.', - '', - ' Fix: stage by explicit path.', - ' git add path/to/file.ts path/to/other.ts', - '', - ' Bypass (only if you genuinely need a sweep):', - ' user types "Allow add-all bypass" in chat, then retry.', - ].join('\n') + '\n', - ) - process.exit(2) - } - - // ── Layer 2: warn on `git commit` if index has unfamiliar files ── - if (isGitCommit(command)) { - const staged = listStagedFiles(repoDir) - if (staged.length === 0) { - process.exit(0) - } - const touched = readTouchedPaths(transcriptPath) - const unfamiliar: string[] = [] - for (let i = 0, { length } = staged; i < length; i += 1) { - const f = staged[i]! - const abs = path.resolve(repoDir, f) - if (!touched.has(abs)) { - unfamiliar.push(f) - } - } - if (unfamiliar.length === 0) { - process.exit(0) - } - // Don't block — commits with pre-staged content can be legitimate. - // Just print a loud stderr warning so the agent inspects before - // proceeding (and humans reviewing the session can spot the slip). - process.stderr.write( - [ - '[overeager-staging-guard] ⚠ git commit about to sweep in files this session has not touched:', - '', - ...unfamiliar.slice(0, 20).map(f => ` ${f}`), - ...(unfamiliar.length > 20 - ? [` ... and ${unfamiliar.length - 20} more`] - : []), - '', - ' Likely cause: a parallel Claude session staged these. The', - ' commit will include them under your authorship.', - '', - ' If unintended, abort and run:', - ' git restore --staged <file> # to drop one file', - ' git reset HEAD # to drop everything', - '', - ' If intended, proceed — this is informational, not a block.', - ].join('\n') + '\n', - ) - process.exit(0) - } - - process.exit(0) -} - -main().catch(e => { - process.stderr.write( - `[overeager-staging-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) - process.exit(0) -}) diff --git a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/package.json b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/package.json deleted file mode 100644 index 6d10817..0000000 --- a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-overeager-staging-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/test/index.test.mts b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/test/index.test.mts deleted file mode 100644 index 1fd9a97..0000000 --- a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/test/index.test.mts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * @file Unit tests for overeager-staging-guard hook. Two layers under test: - * - * 1. Layer 1 — block `git add -A` / `.` / `-u` (exit 2). - * 2. Layer 2 — informational warning on `git commit` when index contains files - * not touched by this session (exit 0 + stderr). - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly code: number - readonly stderr: string -} - -function runHook( - command: string, - options: { - cwd?: string | undefined - transcriptPath?: string | undefined - env?: Record<string, string> | undefined - } = {}, -): RunResult { - const payload = { - tool_name: 'Bash', - tool_input: { command }, - transcript_path: options.transcriptPath, - } - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - env: { - ...process.env, - ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), - ...(options.env ?? {}), - }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function gitInit(repo: string): void { - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { - cwd: repo, - }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) -} - -function gitAdd(repo: string, files: string[]): void { - spawnSync('git', ['add', ...files], { cwd: repo }) -} - -function writeTranscript(entries: object[]): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'overeager-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync(transcriptPath, entries.map(e => JSON.stringify(e)).join('\n')) - return transcriptPath -} - -let tmpRepo: string - -beforeEach(() => { - tmpRepo = mkdtempSync(path.join(os.tmpdir(), 'overeager-repo-')) - gitInit(tmpRepo) -}) - -afterEach(() => { - rmSync(tmpRepo, { recursive: true, force: true }) -}) - -// ─── Layer 1: broad git-add blocking ────────────────────────────── - -test('blocks `git add -A`', () => { - const r = runHook('git add -A', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add -A/) - assert.match(r.stderr, /Blocked/) -}) - -test('blocks `git add --all`', () => { - const r = runHook('git add --all', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add --all/) -}) - -test('blocks `git add .`', () => { - const r = runHook('git add .', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add \./) -}) - -test('blocks `git add -u`', () => { - const r = runHook('git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add -u/) -}) - -test('blocks `git add --update`', () => { - const r = runHook('git add --update', { cwd: tmpRepo }) - assert.equal(r.code, 2) -}) - -test('blocks broad add chained after another command', () => { - const r = runHook('echo hi && git add -A && git commit -m x', { - cwd: tmpRepo, - }) - assert.equal(r.code, 2) -}) - -test('blocks broad add when env vars are set on the command', () => { - const r = runHook('GIT_AUTHOR_NAME=foo git add .', { cwd: tmpRepo }) - assert.equal(r.code, 2) -}) - -test('blocks `git -C path add .` (subcommand after a global flag)', () => { - const r = runHook(`git -C ${tmpRepo} add .`, { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add \./) -}) - -test('quoted "git add ." inside a message is NOT a broad add', () => { - // Regression: the parser distinguishes a real invocation from the - // same words sitting inside a quoted commit-message argument. - const r = runHook('git commit -m "stop using git add ."', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows `git add path/to/file.ts`', () => { - const r = runHook('git add src/foo.ts', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows `git add ./relative-path.ts` (not a broad sweep)', () => { - const r = runHook('git add ./src/foo.ts', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows `git add multiple specific files`', () => { - const r = runHook('git add src/a.ts src/b.ts test/c.test.ts', { - cwd: tmpRepo, - }) - assert.equal(r.code, 0) -}) - -test('allows `git commit -m`', () => { - const r = runHook('git commit -m "fix: thing"', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows non-git Bash commands', () => { - const r = runHook('ls -la', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('bypass: `Allow add-all bypass` in transcript allows broad add', () => { - const transcriptPath = writeTranscript([ - { - type: 'user', - message: { - role: 'user', - content: [{ type: 'text', text: 'Allow add-all bypass' }], - }, - }, - ]) - const r = runHook('git add -A', { cwd: tmpRepo, transcriptPath }) - assert.equal(r.code, 0) -}) - -test('env disable short-circuits', () => { - const r = runHook('git add -A', { - cwd: tmpRepo, - env: { SOCKET_OVEREAGER_STAGING_GUARD_DISABLED: '1' }, - }) - assert.equal(r.code, 0) -}) - -// ─── Layer 2: warn on git commit with unfamiliar staged files ───── - -test('git commit with empty index passes silently', () => { - const r = runHook('git commit -m "x"', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('git commit warns when index has files not touched this session', () => { - writeFileSync(path.join(tmpRepo, 'parallel.ts'), '// other agent') - gitAdd(tmpRepo, ['parallel.ts']) - // Empty transcript — agent touched nothing. - const transcriptPath = writeTranscript([]) - const r = runHook('git commit -m "mine"', { - cwd: tmpRepo, - transcriptPath, - }) - // Layer 2 is informational — exit 0 with stderr warning. - assert.equal(r.code, 0) - assert.match(r.stderr, /parallel\.ts/) - assert.match(r.stderr, /not touched/) -}) - -test('git commit silent when index files match transcript Edit history', () => { - const myFile = path.join(tmpRepo, 'mine.ts') - writeFileSync(myFile, '// mine') - gitAdd(tmpRepo, ['mine.ts']) - const transcriptPath = writeTranscript([ - { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - name: 'Edit', - input: { file_path: myFile }, - }, - ], - }, - }, - ]) - const r = runHook('git commit -m "mine"', { - cwd: tmpRepo, - transcriptPath, - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('git commit silent when index files match transcript git-add history', () => { - const myFile = path.join(tmpRepo, 'mine.ts') - writeFileSync(myFile, '// mine') - gitAdd(tmpRepo, ['mine.ts']) - const transcriptPath = writeTranscript([ - { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - name: 'Bash', - input: { command: `git add ${myFile}` }, - }, - ], - }, - }, - ]) - const r = runHook('git commit -m "mine"', { - cwd: tmpRepo, - transcriptPath, - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -// ─── Misc edge cases ────────────────────────────────────────────── - -test('non-Bash tool_name is ignored', () => { - const r = spawnSync('node', [HOOK], { - input: JSON.stringify({ - tool_name: 'Edit', - tool_input: { file_path: '/tmp/foo' }, - }), - }) - assert.equal(r.status, 0) -}) - -test('malformed payload is ignored (fail-open)', () => { - const r = spawnSync('node', [HOOK], { - input: 'not-json', - }) - assert.equal(r.status, 0) -}) - -test('empty command is ignored', () => { - const r = runHook('', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -// ─── FLEET_SYNC=1 sentinel ──────────────────────────────────────── - -test('FLEET_SYNC=1 allows `git add -u`', () => { - const r = runHook('FLEET_SYNC=1 git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('FLEET_SYNC=1 allows `git add -A`', () => { - const r = runHook('FLEET_SYNC=1 git add -A', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('FLEET_SYNC=1 allows `git add .`', () => { - const r = runHook('FLEET_SYNC=1 git add .', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('no FLEET_SYNC: `git add -u` still blocked', () => { - const r = runHook('git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /Blocked: git add -u/) -}) - -test('FLEET_SYNC=0 (explicit off): `git add -u` still blocked', () => { - const r = runHook('FLEET_SYNC=0 git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 2) -}) diff --git a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/tsconfig.json b/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/overeager-staging-guard/overeager-staging-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/path-guard/path-guard/README.md b/.claude/hooks/fleet/path-guard/path-guard/README.md deleted file mode 100644 index bec03c1..0000000 --- a/.claude/hooks/fleet/path-guard/path-guard/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# path-guard - -A **Claude Code hook** that runs before `Edit` or `Write` tool calls -on `.mts` or `.cts` files and **blocks** edits that would build a -multi-segment build/output path inline. The fleet's rule, in one -sentence: - -> 1 path, 1 reference. Construct a path _once_ in a canonical -> `paths.mts` (or a build-infra helper); reference the computed value -> everywhere else. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool. It can either -> **prime** (write to stderr, exit 0, model carries on) or **block** -> (exit 2, edit never happens). This one blocks. - -## Why this rule exists - -Build outputs typically nest deep — `build/<mode>/<platform>/out/Final/<bin>`. -If three different scripts all `path.join(...)` their own version of -that path, a refactor that changes the layout breaks one or two of -them silently. Centralizing the construction in a single `paths.mts` -per package means a refactor is a one-file diff, and divergence -becomes impossible because every consumer imports the same value. - -The companion `scripts/check-paths.mts` runs a deeper whole-repo -scan at `pnpm check` time, catching anything this hook missed. - -## What it blocks - -| Rule | Example that gets blocked | Fix | -| ------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **A** — multi-stage path constructed inline | `path.join(PKG, 'build', mode, 'out', 'Final', name)` | Move the construction into the package's `scripts/paths.mts` (or use `getFinalBinaryPath` from `build-infra/lib/paths`); import the computed value here. | -| **B** — cross-package path traversal | `path.join(PKG, '..', 'lief-builder', 'build', ...)` | Add `lief-builder: workspace:*` as a dependency; import its `paths.mts` via the workspace `exports` field. | - -The hook fires on `Edit` and `Write` tool calls when the target path -ends in `.mts` or `.cts`. Other extensions (`.ts`, `.mjs`, `.js`, -`.yml`, `.json`, `.md`) pass through — TS path code lives in `.mts` -per fleet convention, and other file types are covered by the -`scripts/check-paths.mts` gate at commit time. - -## What it allows - -- Edits to a `paths.mts` (the canonical constructor). -- Edits to `scripts/check-paths.mts` (the gate itself, which - legitimately enumerates patterns). -- Edits to this hook's own files (the test suite has to enumerate - the same patterns). -- `path.join` calls with a single stage segment, e.g. - `path.join(packageRoot, 'build', 'temp')` — that's a one-off - helper path, not a multi-stage build output. -- `path.join` calls with no stage segments at all (most - general-purpose joins). -- Any string concatenation that doesn't go through `path.join` — - the hook is regex-based and intentionally narrow. - -## Stage segments the hook recognizes - -These come from `build-infra/lib/constants.mts:BUILD_STAGES` plus the -lowercase directory-name siblings used by some builders: - -`Final`, `Release`, `Stripped`, `Compressed`, `Optimized`, `Synced`, -`wasm`, `downloaded` - -Two or more in the same `path.join` call — or one stage segment plus -one of `'build'`/`'out'` plus one mode (`'dev'`/`'prod'`) — triggers -Rule A. - -## Known sibling packages (for Rule B) - -The hook recognizes Rule B traversals only when the next segment -after `..` is a known fleet package name: - -`binflate`, `binject`, `binpress`, `bin-infra`, `build-infra`, -`codet5-models-builder`, `curl-builder`, `libpq-builder`, -`lief-builder`, `minilm-builder`, `models`, `napi-go`, -`node-smol-builder`, `onnxruntime-builder`, `opentui-builder`, -`stubs-builder`, `ultraviolet-builder`, `yoga-layout-builder` - -When a new package joins the workspace, add it to -`KNOWN_SIBLING_PACKAGES` in `index.mts`. - -## Fail-open on hook bugs - -If the hook itself crashes, it writes a log line and exits `0` — -i.e. _the edit is allowed_. A buggy security hook that blocks -everything is worse than one that temporarily lets things through. -The companion `scripts/check-paths.mts` gate at commit time catches -anything the hook missed. - -## Testing - -```bash -pnpm --filter hook-path-guard test -``` - -Adding a new detection pattern: update `STAGE_SEGMENTS` (or -`KNOWN_SIBLING_PACKAGES`) in `index.mts`, then add a positive and a -negative test in `test/path-guard.test.mts`. - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/path-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. - -To propagate a change from the template to every fleet repo: - -```bash -node scripts/sync-scaffolding.mts --all --fix -``` diff --git a/.claude/hooks/fleet/path-guard/path-guard/index.mts b/.claude/hooks/fleet/path-guard/path-guard/index.mts deleted file mode 100644 index 33f730d..0000000 --- a/.claude/hooks/fleet/path-guard/path-guard/index.mts +++ /dev/null @@ -1,351 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — path-guard firewall. -// -// Mantra: 1 path, 1 reference. -// -// Blocks Edit/Write tool calls that would *construct* a multi-segment -// build/output path inline in a `.mts` or `.cts` file, instead of -// importing the constructed value from the canonical `paths.mts` (or a -// build-infra helper). This fires BEFORE the write lands; exit code 2 -// makes Claude Code refuse the tool call so the diff never touches the -// repo. The model sees the rejection reason on stderr and retries with -// an import-based approach. -// -// What the hook checks (subset of the gate's rules — diff-local only): -// -// Rule A — Multi-stage path construction: a `path.join(...)` / -// `path.resolve(...)` call or string-template that stitches together -// two or more "stage" segments together with build / out / mode / -// platform-arch context. Outside a `paths.mts` file this is a -// violation: the construction belongs in a helper, every consumer -// imports the computed value. -// -// Rule B — Cross-package traversal: `path.join(*, '..', '<sibling -// package>', 'build', ...)` reaches into a sibling's build output -// without going through its `exports`. Forces consumers to declare a -// workspace dep and import the sibling's `paths.mts`. -// -// What the hook does NOT check (the gate handles repo-wide concerns): -// -// Rule C — workflow YAML repetition (gate scans .yml files). -// Rule D — comment-encoded paths (gate scans comments + JSDoc). -// Rule F — same path reconstructed in multiple files. -// Rule G — Makefile / Dockerfile / shell-script paths. -// -// AST-based detector (vendored acorn-wasm). Replaces the prior -// regex+paren-balance string scanner that the previous file's -// `extractPathCalls` had to roll by hand because regex couldn't -// handle nested parens in argument lists like -// `path.join(getDir(x), 'Final')`. The AST visitor sees those calls -// natively, with arguments resolved as Literal / NewExpression / -// CallExpression / TemplateLiteral nodes; we only treat string-Literal -// arguments as path segments (every other shape is a computed value -// that doesn't participate in the rule). -// -// Scope: -// - Fires only on `Edit` and `Write` tool calls. -// - Only `.mts` / `.cts` source files. -// - Skips `paths.mts` itself (canonical constructor) and the gate / -// hook implementations that enumerate stage tokens. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log). - -import process from 'node:process' - -import { findTemplateLiterals } from '../_shared/acorn/index.mts' -import type { TemplateLiteralSite } from '../_shared/acorn/index.mts' -import { - BUILD_ROOT_SEGMENTS, - KNOWN_SIBLING_PACKAGES, - MODE_SEGMENTS, - STAGE_SEGMENTS, -} from './segments.mts' - -const EXEMPT_FILE_PATTERNS: RegExp[] = [ - /(?:^|\/)paths\.(?:cts|mts)$/, - /scripts\/check-paths\.mts$/, - /scripts\/check-paths\//, - /\.claude\/hooks\/path-guard\/index\.(?:cts|mts)$/, - /\.claude\/hooks\/path-guard\/test\//, - /scripts\/check-consistency\.mts$/, -] - -class BlockError extends Error { - public readonly rule: string - public readonly suggestion: string - public readonly snippet: string - constructor(rule: string, suggestion: string, snippet: string) { - super(rule) - this.name = 'BlockError' - this.rule = rule - this.suggestion = suggestion - this.snippet = snippet.slice(0, 240) + (snippet.length > 240 ? '…' : '') - } -} - -interface ToolInput { - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -export function stdin(): Promise<string> { - return new Promise<string>(resolve => { - let buf = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => (buf += chunk)) - process.stdin.on('end', () => resolve(buf)) - }) -} - -export function isInScope(filePath: string) { - if (!filePath) { - return false - } - if (!filePath.endsWith('.mts') && !filePath.endsWith('.cts')) { - return false - } - return !EXEMPT_FILE_PATTERNS.some(re => re.test(filePath)) -} - -/** - * Collect string-literal arguments from each `path.join` / `path.resolve` call. - * We deliberately only consume the `firstStringArg` + the - * `allStringLiteralArgs` flag from the AST helper's MemberCallSite, then walk - * the call again at the source level only as a fallback for displaying the - * snippet — we never parse arguments by hand. - * - * To get ALL string-literal args (not just the first), we re-parse the - * arguments via `findMemberCalls`'s nature: it visits one CallExpression at a - * time. Since the public surface returns only `firstStringArg`, here we walk - * again with a custom visitor that inspects each argument. This keeps the - * public helper API narrow while letting path-guard get the full literal list - * it needs. - */ -import { walkSimple } from '../_shared/acorn/index.mts' -import type { AcornNode } from '../_shared/acorn/index.mts' - -interface PathCall { - /** - * All string-Literal arguments in source order. - */ - literals: string[] - /** - * Whether ANY argument was a non-string node (Identifier / CallExpression / - * etc.). - */ - hasComputedArg: boolean - /** - * Source snippet around the call for the block message. - */ - snippet: string - /** - * 1-based line of the call. - */ - line: number -} - -export function collectPathCalls(source: string): PathCall[] { - const lines = source.split('\n') - const out: PathCall[] = [] - // Match both `path.join(...)` and `path.resolve(...)` via two passes. - for (const property of ['join', 'resolve']) { - walkSimple(source, { - CallExpression(node: AcornNode) { - const callee = node['callee'] as AcornNode | undefined - if (!callee || callee.type !== 'MemberExpression') { - return - } - const obj = callee['object'] as AcornNode | undefined - if ( - !obj || - obj.type !== 'Identifier' || - (obj['name'] as string) !== 'path' - ) { - return - } - const prop = callee['property'] as AcornNode | undefined - if ( - !prop || - prop.type !== 'Identifier' || - (prop['name'] as string) !== property - ) { - return - } - const args = (node['arguments'] as AcornNode[] | undefined) ?? [] - const literals: string[] = [] - let hasComputedArg = false - for (let i = 0, { length } = args; i < length; i += 1) { - const a = args[i]! - if (a.type === 'Literal' && typeof a['value'] === 'string') { - literals.push(a['value'] as string) - } else { - hasComputedArg = true - } - } - const start = node['start'] as number | undefined - const end = node['end'] as number | undefined - if (typeof start !== 'number' || typeof end !== 'number') { - return - } - const line = source.slice(0, start).split('\n').length /* 1-based */ - const snippet = source.slice(start, end) - const trimmedLine = lines[line - 1]?.trim() ?? '' - out.push({ - literals, - hasComputedArg, - // Prefer the single-line text when the call fits on one - // line; otherwise show the slice (truncated by BlockError). - snippet: snippet.includes('\n') ? snippet : trimmedLine, - line, - }) - }, - }) - } - return out -} - -export function checkRuleA(calls: PathCall[]) { - for (let i = 0, { length } = calls; i < length; i += 1) { - const call = calls[i]! - const stages = call.literals.filter(l => STAGE_SEGMENTS.has(l)) - const buildRoots = call.literals.filter(l => BUILD_ROOT_SEGMENTS.has(l)) - const modes = call.literals.filter(l => MODE_SEGMENTS.has(l)) - const twoStages = stages.length >= 2 - const stagePlusContext = - stages.length >= 1 && buildRoots.length >= 1 && modes.length >= 1 - if (twoStages || stagePlusContext) { - throw new BlockError( - 'A — multi-stage path constructed inline', - 'Construct this path in the owning `paths.mts` (or a build-infra helper like `getFinalBinaryPath`) and import the computed value here. 1 path, 1 reference.', - call.snippet, - ) - } - } -} - -export function checkRuleB(calls: PathCall[]) { - for (let i = 0, { length } = calls; i < length; i += 1) { - const call = calls[i]! - const hasBuildContext = call.literals.some( - l => BUILD_ROOT_SEGMENTS.has(l) || STAGE_SEGMENTS.has(l), - ) - if (!hasBuildContext) { - continue - } - for (let i = 0; i < call.literals.length - 1; i++) { - if ( - call.literals[i] === '..' && - KNOWN_SIBLING_PACKAGES.has(call.literals[i + 1]!) - ) { - const sibling = call.literals[i + 1]! - throw new BlockError( - 'B — cross-package path traversal', - `Don't reach into '${sibling}'s build output via \`..\`. Add \`${sibling}: workspace:*\` as a dep and import its \`paths.mts\` via the \`exports\` field. 1 path, 1 reference.`, - call.snippet, - ) - } - } - } -} - -export function checkRuleATemplate(templates: TemplateLiteralSite[]) { - for (let i = 0, { length } = templates; i < length; i += 1) { - const tpl = templates[i]! - // Skip templates with no `/` separator — they can't be path-shaped. - if (!tpl.segments.includes('/')) { - continue - } - // Replace `\0` expression sentinels with empty (they don't - // contribute path segments); split on `/`; filter empty. - const segments = tpl.segments - .replace(/\x00/g, '') - .split('/') - .filter(s => s.length > 0) - const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) - const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) - const modes = segments.filter(s => MODE_SEGMENTS.has(s)) - const hasBuildAndOut = - buildRoots.includes('build') && buildRoots.includes('out') - const hasOut = buildRoots.includes('out') - const hasBuild = buildRoots.includes('build') - const triggers = - (hasBuildAndOut && stages.length >= 1) || - (stages.length >= 2 && hasOut) || - (hasBuild && stages.length >= 1 && modes.length >= 1) - if (triggers) { - throw new BlockError( - 'A — multi-stage path constructed inline via template literal', - 'Construct this path in the owning `paths.mts` (or a build-infra helper) and import the computed value here. 1 path, 1 reference.', - tpl.text, - ) - } - } -} - -export function check(source: string) { - const calls = collectPathCalls(source) - if (calls.length > 0) { - checkRuleA(calls) - checkRuleB(calls) - } - const templates = findTemplateLiterals(source) - if (templates.length > 0) { - checkRuleATemplate(templates) - } -} - -export function emitBlock(filePath: string, err: BlockError) { - process.stderr.write( - `\n[path-guard] Blocked: ${err.rule}\n` + - ` Mantra: 1 path, 1 reference\n` + - ` File: ${filePath}\n` + - ` Snippet: ${err.snippet}\n` + - ` Fix: ${err.suggestion}\n\n`, - ) -} - -async function main() { - const raw = await stdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!isInScope(filePath)) { - return - } - const source = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!source) { - return - } - try { - check(source) - } catch (e) { - if (e instanceof BlockError) { - emitBlock(filePath, e) - process.exitCode = 2 - return - } - throw e - } -} - -main().catch(e => { - process.stderr.write(`[path-guard] hook error (allowing): ${e}\n`) - process.exitCode = 0 -}) diff --git a/.claude/hooks/fleet/path-guard/path-guard/package.json b/.claude/hooks/fleet/path-guard/path-guard/package.json deleted file mode 100644 index a7cb503..0000000 --- a/.claude/hooks/fleet/path-guard/path-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-path-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/fleet/path-guard/path-guard/segments.mts b/.claude/hooks/fleet/path-guard/path-guard/segments.mts deleted file mode 100644 index c4eb78e..0000000 --- a/.claude/hooks/fleet/path-guard/path-guard/segments.mts +++ /dev/null @@ -1,74 +0,0 @@ -// Canonical path-segment vocabulary shared by the path-guard hook -// (.claude/hooks/path-guard/index.mts) and gate (scripts/check-paths.mts). -// -// Mantra: 1 path, 1 reference. This module is the *one* place stage, -// build-root, mode, and sibling-package vocabulary is defined. Both -// consumers import from here so they can never drift apart. -// -// Synced byte-identically across the Socket fleet via -// socket-wheelhouse/scripts/sync-scaffolding.mts (IDENTICAL_FILES). -// When adding a new stage/build-root/mode/sibling, edit this file in -// the template and re-sync. - -// "Stage" segments — Rule A core. Two of these spread via `path.join` -// or interpolated into a template literal is a finding outside a -// canonical `paths.mts`. Sourced from build-infra/lib/constants.mts -// `BUILD_STAGES` plus their lowercase directory-name siblings used by -// some builders. -export const STAGE_SEGMENTS = new Set([ - 'Compressed', - 'Final', - 'Optimized', - 'Release', - 'Stripped', - 'Synced', - 'downloaded', - 'wasm', -]) - -// "Build-root" segments — at least one must be present together with -// a stage segment to confirm we're constructing a build output path -// rather than something coincidental. Example: a join that yields -// `<root>/<stage>/<lib>` doesn't fire if no build-root segment is -// present; `<root>/build/<stage>/out/<stage>` does. -export const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) - -// Build-mode segments — a stage segment plus one of these is also a -// finding (`build/<mode>/<arch>/out/<stage>` is the canonical shape). -export const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling fleet packages (Rule B). Union of all packages across the -// Socket fleet — the gate is byte-identical via sync-scaffolding, so -// listing every fleet package keeps Rule B firing in any repo. When a -// new package joins the workspace, add it here and propagate via -// `node scripts/sync-scaffolding.mts --all --fix` from -// socket-wheelhouse. -export const KNOWN_SIBLING_PACKAGES = new Set([ - 'acorn', - 'bin-infra', - 'binflate', - 'binject', - 'binpress', - 'build-infra', - 'cli', - 'codet5-models-builder', - 'core', - 'curl-builder', - 'libpq-builder', - 'lief-builder', - 'minilm-builder', - 'models', - 'napi-go', - 'node-smol-builder', - 'npm', - 'onnxruntime-builder', - 'opentui-builder', - 'package-builder', - 'react', - 'renderer', - 'stubs-builder', - 'ultraviolet', - 'ultraviolet-builder', - 'yoga', - 'yoga-layout-builder', -]) diff --git a/.claude/hooks/fleet/path-guard/path-guard/test/path-guard.test.mts b/.claude/hooks/fleet/path-guard/path-guard/test/path-guard.test.mts deleted file mode 100644 index 12e35b9..0000000 --- a/.claude/hooks/fleet/path-guard/path-guard/test/path-guard.test.mts +++ /dev/null @@ -1,311 +0,0 @@ -// Tests for the path-guard hook. Each `node:test` block writes a -// mock PreToolUse payload to the hook's stdin and asserts on its exit -// code + stderr. Exit 2 = blocked; exit 0 = allowed. -// -// Run: pnpm --filter hook-path-guard test -// (or directly: node --test test/*.test.mts) - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -const runHook = ( - toolName: string, - filePath: string, - source: string, -): { code: number; stderr: string } => { - const payload = JSON.stringify({ - tool_name: toolName, - tool_input: - toolName === 'Edit' - ? { file_path: filePath, new_string: source } - : { file_path: filePath, content: source }, - }) - const result = spawnSync(process.execPath, [HOOK], { - input: payload, - }) - return { - code: result.status ?? -1, - stderr: String(result.stderr), - } -} - -describe('path-guard — Rule A (multi-stage construction)', () => { - it('blocks two stage segments in path.join', () => { - const source = ` - const p = path.join(PACKAGE_ROOT, 'wasm', 'out', 'Final', 'bin') - ` - const { code, stderr } = runHook( - 'Write', - 'packages/foo/scripts/build.mts', - source, - ) - assert.equal(code, 2) - assert.match(stderr, /Blocked: A/) - assert.match(stderr, /1 path, 1 reference/) - }) - - it('blocks build + mode + stage', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'binary') - ` - const { code } = runHook('Edit', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('blocks Release + Stripped together', () => { - const source = ` - const p = path.join(buildDir, 'Release', 'Stripped') - ` - const { code } = runHook( - 'Write', - 'packages/foo/scripts/release.mts', - source, - ) - assert.equal(code, 2) - }) - - it('allows single stage segment with one build root', () => { - // 'build' + 'temp' → no stage segment at all → pass - const source = ` - const tmp = path.join(packageRoot, 'build', 'temp') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) - - it('allows path.join with no stage segments', () => { - const source = ` - const cfg = path.join(packageRoot, 'config', 'settings.json') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — Rule B (cross-package traversal)', () => { - it('blocks .. + sibling package + build context', () => { - const source = ` - const lief = path.join(PKG, '..', 'lief-builder', 'build', 'Final') - ` - const { code, stderr } = runHook( - 'Write', - 'packages/binject/scripts/build.mts', - source, - ) - assert.equal(code, 2) - assert.match(stderr, /Blocked: B/) - assert.match(stderr, /lief-builder/) - }) - - it('allows .. + sibling without build context', () => { - // Reaching into a sibling for a non-build asset is allowed; the - // gate may still flag it but the hook is scoped to build paths. - const source = ` - const cfg = path.join(PKG, '..', 'lief-builder', 'config.json') - ` - const { code } = runHook( - 'Write', - 'packages/binject/scripts/build.mts', - source, - ) - assert.equal(code, 0) - }) - - it('does not fire on traversal to unknown directory', () => { - const source = ` - const x = path.join(PKG, '..', 'fixtures', 'build', 'Final') - ` - const { code } = runHook('Write', 'packages/foo/test/test.mts', source) - assert.equal(code, 0) - }) - - it('does not fire when .. and sibling are non-adjacent (regression)', () => { - // Earlier regex ran with sticky sawDotDot — once it saw `..` it - // would flag any later sibling-named segment. The fix requires - // the sibling to appear *immediately* after `..`. - const source = ` - const x = path.join(PKG, '..', 'cache', 'lief-builder', 'config.json') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — paren-balance correctness', () => { - it('detects A through nested function-call args (regression)', () => { - // Old regex used \\([^()]*\\) which only handled one nesting - // level — `path.join(getDir(child(x)), 'build', 'dev', 'Final')` - // silently slipped through. The paren-balancing scanner catches it. - const source = ` - const p = path.join(getDir(child(x)), 'build', 'dev', 'out', 'Final') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('detects A in path.resolve() too', () => { - const source = ` - const p = path.resolve(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) -}) - -describe('path-guard — template literals', () => { - it('detects A in fully-literal template path', () => { - const source = '\n const p = `build/dev/out/Final/binary`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('detects A in template with placeholders', () => { - const source = - '\n const p = `${PKG}/build/${mode}/${arch}/out/Final/${name}`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('allows template with single non-stage segment', () => { - const source = '\n const url = `https://example.com/path`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) - - it('allows template with no stage segments', () => { - const source = '\n const tmp = `${packageRoot}/build/temp/cache`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) - - it('allows template that is purely interpolation', () => { - // `${a}/${b}/${c}` has no literal stage segments. - const source = '\n const p = `${a}/${b}/${c}`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — file-type filter', () => { - it('skips .ts files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/src/index.ts', source) - assert.equal(code, 0) - }) - - it('skips .mjs files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'additions/foo.mjs', source) - assert.equal(code, 0) - }) - - it('skips .yml files', () => { - const source = ` - run: | - FINAL="build/\${MODE}/\${ARCH}/out/Final" - ` - const { code } = runHook('Write', '.github/workflows/foo.yml', source) - assert.equal(code, 0) - }) - - it('inspects .mts files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('inspects .cts files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.cts', source) - assert.equal(code, 2) - }) -}) - -describe('path-guard — exempt files', () => { - it('allows edits to paths.mts', () => { - const source = ` - export const FINAL_DIR = path.join(PKG, 'build', 'dev', 'out', 'Final') - ` - const { code } = runHook('Write', 'packages/foo/scripts/paths.mts', source) - assert.equal(code, 0) - }) - - it('allows edits to check-paths.mts (the gate)', () => { - const source = ` - const PATTERNS = [path.join('build', 'Final', 'wasm')] - ` - const { code } = runHook('Write', 'scripts/check-paths.mts', source) - assert.equal(code, 0) - }) - - it('allows edits to the path-guard hook itself', () => { - const source = ` - const STAGES = ['Final', 'Release', 'Stripped'] - ` - const { code } = runHook( - 'Write', - '.claude/hooks/path-guard/index.mts', - source, - ) - assert.equal(code, 0) - }) - - it('allows edits to path-guard tests', () => { - const source = ` - const fixture = path.join('build', 'dev', 'out', 'Final') - ` - const { code } = runHook( - 'Write', - '.claude/hooks/path-guard/test/path-guard.test.mts', - source, - ) - assert.equal(code, 0) - }) -}) - -describe('path-guard — tool-name filter', () => { - it('skips Bash', () => { - const source = `path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin')` - const { code } = runHook('Bash', '', source) - assert.equal(code, 0) - }) - - it('skips Read', () => { - const source = '' - const { code } = runHook('Read', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — bug-tolerance (fails open)', () => { - it('passes through invalid JSON payload', () => { - const result = spawnSync(process.execPath, [HOOK], { - input: 'not json at all', - }) - assert.equal(result.status, 0) - }) - - it('passes through empty stdin', () => { - const result = spawnSync(process.execPath, [HOOK], { - input: '', - }) - assert.equal(result.status, 0) - }) -}) diff --git a/.claude/hooks/fleet/path-guard/path-guard/tsconfig.json b/.claude/hooks/fleet/path-guard/path-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/path-guard/path-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md deleted file mode 100644 index d915170..0000000 --- a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# perfectionist-reminder - -Stop hook that scans the assistant's most recent turn for speed-vs-depth choice menus where the perfectionist path is the obvious right answer. - -## Why - -CLAUDE.md "Judgment & self-evaluation" says: - -> Default to perfectionist when you have latitude. "Works now" ≠ "right." Before calling done: perfectionist vs. pragmatist views. Default perfectionist absent a signal. - -Sister rule from "Fix > defer" already catches "implement vs accept-as-gap" via `excuse-detector`. The speed-vs-depth menu is a different but related failure pattern: offering "Option A (do it right) / Option B (ship fast)" as a binary choice when the user already signaled they want correctness (asked the right question, requested a thorough audit, said "do it properly", etc.). - -The assistant's job is to internalize the perfectionist default and execute, not re-litigate the velocity tradeoff every time the work is non-trivial. - -## What it catches - -| Phrase pattern | Why it's flagged | -| --------------------------------------------------- | ---------------------------------------------------- | -| `Option A (depth)… Option B (speed)` | Binary choice menu offloading judgment. Pick depth. | -| `maximally useful vs maximally shipped` | Same framing — execute the perfectionist path. | -| `ship-it precision`, `ship-it-now` | Velocity euphemism. Use only when user time-boxed. | -| `depth over breadth?` / `breadth over depth?` | The default IS depth (perfectionist). | -| `speed vs depth`, `fast vs right`, `now vs correct` | Speed-vs-quality framing. Perfectionist is default. | -| `if you say A … if you say B` | Binary choice architecture pretending to be helpful. | -| `plow through vs do it right` | Same pattern — velocity vs care. | - -## Legitimate exceptions - -The hook can't tell from text alone whether the trade-off is real: - -- **User explicitly asked** "is this worth doing fully?" — they introduced the dichotomy. -- **Time-boxed engagement** — the user said "we have 1 hour" and the work needs more. -- **Off-machine action required** — the perfectionist path needs gh dispatch / npm publish / infra access. - -In all three cases, the menu is genuinely useful framing. The hook still flags it; the user reads the warning and decides. - -## Why it doesn't block - -Stop hooks fire after the assistant has produced its response. Blocking would truncate. The warning surfaces alongside the response so the user reads both and can push back next turn. - -## Configuration - -`SOCKET_PERFECTIONIST_REMINDER_DISABLED=1` — turn off entirely. - -## Relationship to excuse-detector - -`excuse-detector` catches **fix vs defer** ("should I implement X or accept as gap?"). This hook catches **depth vs speed** ("should I do it properly or ship a quick version?"). Different failure modes, same underlying anti-pattern: a choice menu where the user already picked. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts deleted file mode 100644 index 135a461..0000000 --- a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/index.mts +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — perfectionist-reminder. -// -// Flags speed-vs-depth choice menus in the assistant's most recent -// turn. CLAUDE.md "Judgment & self-evaluation" says "Default to -// perfectionist when you have latitude" — so when the assistant -// presents a choice between "speed" and "depth" / "correctness" -// without the user having asked for the trade-off, it's the same -// failure pattern as the excuse-detector's fix-vs-defer menu: -// offloading a decision the assistant should have made. -// -// What this catches (regex on code-fence-stripped text): -// -// - "Option A (depth): ... Option B (speed): ..." -// - "Maximally useful vs maximally shipped" -// - "Ship-it precision" / "ship-it-now" -// - "Depth over breadth?" / "breadth over depth?" -// - "Speed vs depth" / "speed vs correctness" / "fast vs right" -// - "If you say A I'll ... if you say B I'll ..." (binary choice -// architecture) -// -// Exceptions: the user explicitly asked which approach to take, or -// the trade-off is genuinely irreducible (time-boxed engagement, -// off-machine action required). The hook can't tell from text alone; -// it just flags the pattern. The user reads the warning and decides -// if it's legitimate or pushback-worthy. -// -// Disable via SOCKET_PERFECTIONIST_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -await runStopReminder({ - name: 'perfectionist-reminder', - disabledEnvVar: 'SOCKET_PERFECTIONIST_REMINDER_DISABLED', - patterns: [ - { - label: 'option A (depth/correctness) … option B (speed/shipped)', - regex: - /\boption\s+a\b[^.?!\n]{0,80}\b(?:correctness|depth|proper|thorough)\b[\s\S]{0,200}\boption\s+b\b[^.?!\n]{0,80}\b(?:breadth|fast|ship|speed)\b/i, - why: 'Speed-vs-depth choice menu. Per CLAUDE.md "Default to perfectionist when you have latitude" — pick depth and execute.', - }, - { - label: 'maximally useful vs maximally shipped', - regex: - /\bmaximally\s+(?:correct|thorough|useful)\b[\s\S]{0,80}\bmaximally\s+(?:fast|quick|shipped)\b/i, - why: 'Same pattern — re-litigating perfectionist-vs-velocity. User already chose perfectionist.', - }, - { - label: 'ship-it precision / ship-it-now', - regex: /\bship[- ]it[- ]?(?:fast|now|precision|version)\b/i, - why: 'Velocity-framed; CLAUDE.md says perfectionist default. Use unless user explicitly time-boxed.', - }, - { - label: 'depth over breadth / breadth over depth', - regex: /\b(?:depth\s+over\s+breadth|breadth\s+over\s+depth)\?/i, - why: 'The CLAUDE.md default is depth (perfectionist). Pick it.', - }, - { - label: 'speed vs depth / fast vs right / now vs correct', - regex: - /\b(?:fast|now|quick|speed)\s+vs\.?\s+(?:correct|depth|proper|right|thorough)\b/i, - why: 'Same speed-vs-quality framing; perfectionist is the default unless user opted out.', - }, - { - label: 'if you say A … if you say B', - regex: /\bif\s+you\s+say\s+a\b[\s\S]{0,200}\bif\s+you\s+say\s+b\b/i, - why: 'Binary choice architecture — masquerades as helpful framing but offloads judgment to user.', - }, - { - label: 'plow through vs do it right', - regex: - /\bplow\s+(?:ahead|through)\b[\s\S]{0,80}\b(?:carefully|correctly|properly|right)\b/i, - why: 'Same pattern (velocity vs care). Default perfectionist.', - }, - ], - closingHint: - 'CLAUDE.md "Judgment & self-evaluation": "Default to perfectionist when you have latitude." If the user already gave perfectionist signals (asked for correctness, asked for depth, said "do it right"), do not re-present the choice — execute the perfectionist path.', -}) diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json deleted file mode 100644 index 3583aec..0000000 --- a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-perfectionist-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/test/index.test.mts b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/test/index.test.mts deleted file mode 100644 index 9b320fd..0000000 --- a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/test/index.test.mts +++ /dev/null @@ -1,137 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): { - path: string - cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'perfectionist-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ role: 'assistant', content: assistantText }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags Option A / Option B depth-vs-speed menu', () => { - const { path: p, cleanup } = makeTranscript( - 'Option A (depth): I do 4-5 hooks well. Option B (speed): I ship all 12 with regex-only.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /perfectionist-reminder/) - assert.match(stderr, /option/i) - } finally { - cleanup() - } -}) - -test('flags maximally useful vs maximally shipped', () => { - const { path: p, cleanup } = makeTranscript( - 'Should I go for maximally useful (proper) or maximally shipped (fast)?', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /maximally/) - } finally { - cleanup() - } -}) - -test('flags ship-it precision framing', () => { - const { path: p, cleanup } = makeTranscript( - 'I could do this with ship-it precision and iterate later.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /ship-it/) - } finally { - cleanup() - } -}) - -test('flags speed vs depth phrasing', () => { - const { path: p, cleanup } = makeTranscript( - 'This is a speed vs depth question — which way?', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /speed/i) - } finally { - cleanup() - } -}) - -test('flags "if you say A / if you say B" binary choice', () => { - const { path: p, cleanup } = makeTranscript( - 'If you say A I will do all 12 properly. If you say B I will ship regex-only.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /if you say/i) - } finally { - cleanup() - } -}) - -test('does not flag plain technical prose', () => { - const { path: p, cleanup } = makeTranscript( - 'The cache stores parsed results keyed by file path. Each entry expires after 10 minutes.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does not false-positive on phrases inside code fences', () => { - const { path: p, cleanup } = makeTranscript( - 'Plain output here.\n```\nspeed vs depth (this is in code)\n```\nMore prose.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript( - 'Option A (depth) or Option B (speed)?', - ) - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_PERFECTIONIST_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/tsconfig.json b/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/perfectionist-reminder/perfectionist-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md deleted file mode 100644 index d1f1f9e..0000000 --- a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# plan-location-guard - -PreToolUse hook that blocks plan-shaped `.md` writes to tracked locations. - -## What it blocks - -Edit / Write / MultiEdit on a markdown file is blocked when: - -1. The target path lives under `docs/plans/` (at any depth), OR -2. The target path lives under a sub-package `.claude/plans/` (i.e. - any `.claude/plans/` that is NOT at the repo root — detected by - the presence of a `packages/`, `apps/`, or `crates/` segment in - the path prefix, OR by finding a second `.claude/plans/` deeper - than the first). - -AND the doc looks like a plan, per a narrow heuristic: - -- Filename stem contains one of: `plan`, `roadmap`, `migration`, - `design`, `next-steps`, `dispatcher-plan`. -- OR the first heading of the content contains one of: `plan`, - `roadmap`, `migration plan`, `design doc`. - -Both conditions must be true to block — paths that look like plan -_locations_ but don't have plan-shaped content are pass-through. This -keeps the hook narrow; the goal is to catch the specific failure -mode where a design doc gets dropped into `docs/plans/`. - -## What it allows - -- `<repo-root>/.claude/plans/<name>.md` — the canonical home (untracked). -- Random `.md` writes outside `docs/plans/` and `.claude/plans/`. -- Markdown writes that don't look like plans (e.g. a `README.md` that - happens to live under `docs/plans/`). -- Bash / Read / non-Edit tool calls. - -## Bypass phrase - -`Allow plan-location bypass` — the user types this verbatim in a -recent (last 8 user turns) message. The hook reads the transcript via -the `_shared/transcript.mts` helper. - -## Why a hook on top of the CLAUDE.md rule - -The CLAUDE.md rule documents the convention. The hook is the actual -enforcement at edit time. The recurring failure mode this rule was -written to address: socket-btm grew three parallel `docs/plans/` -directories (root, package-level, `.claude/plans/`) — same content -type, all tracked, all drifting. Without an edit-time guard, that -failure mode recurs every session a new agent reaches for "the -obvious place" to put a plan. - -## Reading - -- `docs/claude.md/fleet/plan-storage.md` — full rule + migration playbook. -- CLAUDE.md → `### Plan storage` — inline summary. diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts deleted file mode 100644 index 7e1eb62..0000000 --- a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/index.mts +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — plan-location-guard. -// -// Blocks Edit/Write/MultiEdit operations that try to land a -// design/implementation/migration *plan* document at a tracked -// location instead of `<repo-root>/.claude/plans/<name>.md`. Per the -// fleet "Plan storage" rule, plans are working notes and must not be -// tracked by version control. -// -// Blocked target paths (case-insensitive on the `plans/` segment, -// any depth from repo root): -// -// - `**/docs/plans/**/*.md` -// The classic "I wrote a design doc somewhere visible" failure -// mode. Covers root `docs/plans/` and any package-level -// `<pkg>/docs/plans/`. -// -// - `**/<pkg>/.claude/plans/**/*.md` (i.e. .claude/plans/ that is -// NOT at the repo root) -// Sub-package .claude/ trees are not part of the operator's -// session-level .claude/ — the canonical operator dir is the -// repo root. -// -// Allowed: -// - `<repo-root>/.claude/plans/**/*.md` — the canonical home. -// - Any `.md` whose filename, headings, and content do NOT look -// like a plan (we only block when filename + content match the -// plan-shape heuristic; other docs are out of scope). -// -// Heuristic for "looks like a plan" — at least one of: -// - Filename contains `plan`, `roadmap`, `migration`, `dispatcher-plan`, -// `design`, `next-steps`, or `*-plan-*.md` shape. -// - File content (the `new_string` / `content` payload from -// Edit/Write) opens with a `# <title>` heading whose words -// include "plan", "roadmap", "migration plan", or "design doc". -// -// The heuristic is intentionally narrow: this hook is not trying to -// classify every .md file in the fleet — it's catching the specific -// failure mode where someone writes a design doc into `docs/plans/` -// because that's what "feels right." Random `.md` writes outside -// `docs/plans/` and `.claude/plans/` are pass-through. -// -// Bypass phrase: `Allow plan-location bypass`. Reading recent user -// turns follows the same pattern as no-revert-guard / -// no-fleet-fork-guard. -// -// Why a hook on top of the CLAUDE.md rule: the rule documents the -// convention; the hook is the actual enforcement at edit time. -// Catches the recurring failure mode where Claude or a parallel -// session writes a design doc into `docs/plans/` because that's the -// historical convention (see the socket-btm migration that triggered -// this rule — three parallel `docs/plans/` directories drifted). -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit" | "Write" | "MultiEdit", -// "tool_input": { "file_path": "...", -// "content"?: "...", -// "new_string"?: "..." }, -// "transcript_path": "/.../session.jsonl" } -// -// Exits: -// 0 — allowed. -// 2 — blocked (with stderr message that explains rule + fix + -// bypass phrase). -// 0 (with stderr log) — fail-open on hook bugs. - -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - } - | undefined - tool_name?: string | undefined - transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow plan-location bypass' -const BYPASS_LOOKBACK_USER_TURNS = 8 - -// Filename-stem tokens that mark a doc as "plan-shaped." The check -// is on the base name (extension stripped, lowercased). -const PLAN_FILENAME_TOKENS = [ - 'plan', - 'roadmap', - 'migration', - 'design', - 'next-steps', - 'dispatcher-plan', -] - -// First-heading tokens that mark a doc as "plan-shaped." Checked -// against the first non-blank line of the new content if the -// filename heuristic didn't fire. -const PLAN_HEADING_TOKENS = ['plan', 'roadmap', 'migration plan', 'design doc'] - -/** - * Lowercased filename without extension. Returns empty string for paths without - * a basename. - */ -export function basenameStem(filePath: string): string { - const base = path.basename(filePath) - const dot = base.lastIndexOf('.') - const stem = dot > 0 ? base.slice(0, dot) : base - return stem.toLowerCase() -} - -/** - * Classify the target path. Returns: - * - * - 'allowed-root-claude-plans' — under <something>/.claude/plans/ - * - 'blocked-docs-plans' — under <something>/docs/plans/ - * - 'blocked-sub-claude-plans' — under <something>/<sub>/.claude/plans/ (i.e. not - * at the first .claude/ segment) - * - 'irrelevant' — none of the above - * - * The classification is purely lexical on the resolved path. It does NOT walk - * for a repo root, since the fleet rule applies to any docs/plans/ regardless - * of repo context — including the case where a script under /tmp tries to write - * into a project tree. - */ -export function classifyPath(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/') - const segs = normalized.split('/') - - // Find the FIRST `.claude/plans/` segment pair vs any DEEPER one. - // The "first" one nearest the root is the canonical operator dir; - // anything deeper (i.e. `<pkg>/.claude/plans/`) is a sub-package - // plans dir and is forbidden. - let firstClaudeIdx = -1 - for (let i = 0; i < segs.length - 1; i++) { - if (segs[i] === '.claude' && segs[i + 1] === 'plans') { - firstClaudeIdx = i - break - } - } - - if (firstClaudeIdx !== -1) { - // Look for a SECOND `.claude/plans/` deeper than the first. - for (let i = firstClaudeIdx + 2; i < segs.length - 1; i++) { - if (segs[i] === '.claude' && segs[i + 1] === 'plans') { - return 'blocked-sub-claude-plans' - } - } - // Check whether the first `.claude/plans/` is itself nested under - // another package directory (heuristic: preceded by `packages/`, - // `apps/`, or `crates/` in the parent path). - const prefix = segs.slice(0, firstClaudeIdx).join('/') - if ( - prefix.includes('/packages/') || - prefix.includes('/apps/') || - prefix.includes('/crates/') - ) { - return 'blocked-sub-claude-plans' - } - return 'allowed-root-claude-plans' - } - - // Look for any `docs/plans/` segment pair. - for (let i = 0; i < segs.length - 1; i++) { - if (segs[i] === 'docs' && segs[i + 1] === 'plans') { - return 'blocked-docs-plans' - } - } - - return 'irrelevant' -} - -export function contentLooksLikePlan(content: string | undefined): boolean { - if (!content) { - return false - } - // First non-blank line. - let firstLine = '' - for (const line of content.split('\n')) { - const trimmed = line.trim() - if (trimmed) { - firstLine = trimmed.toLowerCase() - break - } - } - if (!firstLine.startsWith('#')) { - return false - } - return PLAN_HEADING_TOKENS.some(token => firstLine.includes(token)) -} - -export function filenameLooksLikePlan(filePath: string): boolean { - const stem = basenameStem(filePath) - if (!stem) { - return false - } - return PLAN_FILENAME_TOKENS.some(token => stem.includes(token)) -} - -async function main(): Promise<number> { - const raw = await readStdin() - if (!raw.trim()) { - return 0 - } - - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.stderr.write( - 'plan-location-guard: failed to parse stdin payload — fail-open\n', - ) - return 0 - } - - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { - return 0 - } - - const filePath = payload.tool_input?.file_path - if (!filePath) { - return 0 - } - - // Only target markdown files. - if (!filePath.toLowerCase().endsWith('.md')) { - return 0 - } - - const classification = classifyPath(filePath) - if ( - classification !== 'blocked-docs-plans' && - classification !== 'blocked-sub-claude-plans' - ) { - return 0 - } - - // Apply the plan-shape heuristic. If the doc clearly looks like a - // plan (filename OR opening heading), block. If neither fires, this - // is probably a coincidence (e.g. an unrelated doc that happened - // to live under docs/plans/ for historical reasons) — let it through - // and let the human decide. - const content = payload.tool_input?.new_string ?? payload.tool_input?.content - const looksLikePlan = - filenameLooksLikePlan(filePath) || contentLooksLikePlan(content) - if (!looksLikePlan) { - return 0 - } - - // Bypass-phrase check. - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - return 0 - } - - const suggestion = - classification === 'blocked-docs-plans' - ? 'Move the plan to <repo-root>/.claude/plans/<lowercase-hyphenated>.md (untracked by default).' - : 'Move the plan to the REPO-ROOT .claude/plans/ — sub-package .claude/plans/ is not the canonical home.' - - process.stderr.write( - [ - `🚨 plan-location-guard: blocked plan-shaped .md write at a tracked location.`, - ``, - `File: ${filePath}`, - `Classification: ${classification}`, - ``, - `Per the fleet "Plan storage" rule (CLAUDE.md → Plan storage),`, - `plans live at <repo-root>/.claude/plans/<name>.md and must NOT`, - `be tracked by version control. The fleet .gitignore excludes`, - `/.claude/* and intentionally omits plans/ from the allowlist —`, - `so a plan written to the canonical path is untracked by default.`, - ``, - `Fix:`, - ` ${suggestion}`, - ``, - `Background reading:`, - ` docs/claude.md/fleet/plan-storage.md`, - ``, - `One-shot bypass (rare): user types "${BYPASS_PHRASE}" verbatim`, - `in a recent message.`, - ``, - ].join('\n'), - ) - return 2 -} - -main().then( - code => process.exit(code), - err => { - process.stderr.write( - `plan-location-guard: hook error — fail-open: ${String(err)}\n`, - ) - process.exit(0) - }, -) diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json deleted file mode 100644 index 3f32f24..0000000 --- a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-plan-location-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/test/index.test.mts b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/test/index.test.mts deleted file mode 100644 index 9c7c1e9..0000000 --- a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/test/index.test.mts +++ /dev/null @@ -1,216 +0,0 @@ -// node --test specs for the plan-location-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('non-markdown files pass through', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/script.ts', - content: '// not a markdown file', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('blocks plan-shaped doc under docs/plans/ at repo root', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', - content: '# Migration plan\n\nSteps:\n\n1. ...', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /plan-location-guard: blocked/) - assert.match(result.stderr, /docs-plans/) -}) - -test('blocks plan-shaped doc under package-level docs/plans/', async () => { - const result = await runHook({ - tool_input: { - file_path: - '/Users/x/projects/foo/packages/bar/docs/plans/refactor-plan.md', - content: '# Refactor plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /docs-plans/) -}) - -test('allows plan under repo-root .claude/plans/', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/.claude/plans/my-plan.md', - content: '# My plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('blocks plan under sub-package .claude/plans/', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/packages/bar/.claude/plans/sub-plan.md', - content: '# Sub-package plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /sub-claude-plans/) -}) - -test('blocks plan under a SECOND .claude/plans/ deeper than the first', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/.claude/plans/outer/.claude/plans/inner.md', - content: '# Inner plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /sub-claude-plans/) -}) - -test('blocks README.md whose heading mentions "plans" (heading heuristic)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/README.md', - content: - '# Plans directory\n\nThis directory holds historical plan archives.', - }, - tool_name: 'Write', - }) - // Filename ("readme") is benign but the heading "# Plans directory" - // contains a plan-shape token. The heuristic is intentionally - // OR-shaped — either signal blocks. - assert.strictEqual(result.code, 2) -}) - -test("allows truly-unrelated doc under docs/plans/ that doesn't look like a plan", async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/index.md', - content: '# Archive index\n\nLinks to historical artifacts.', - }, - tool_name: 'Write', - }) - // Neither filename ("index") nor heading ("Archive index") contains - // a plan-shape token. Pass-through. - assert.strictEqual(result.code, 0) -}) - -test('blocks Edit (not just Write) to plan-shaped path', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', - new_string: 'updated # Migration plan content', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('detects plan via filename when content is missing', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/roadmap.md', - }, - tool_name: 'Write', - }) - // Filename contains 'roadmap' — plan-shaped. Block. - assert.strictEqual(result.code, 2) -}) - -test('respects bypass phrase in recent user turn', async t => { - // Build a transcript file containing the bypass phrase. - const { writeFile, mkdtemp, rm } = await import('node:fs/promises') - const os = await import('node:os') - const tmp = await mkdtemp(path.join(os.tmpdir(), 'plan-location-test-')) - const transcriptPath = path.join(tmp, 'session.jsonl') - const turn = { - type: 'user', - message: { - role: 'user', - content: [{ type: 'text', text: 'Allow plan-location bypass' }], - }, - } - await writeFile(transcriptPath, JSON.stringify(turn) + '\n', 'utf8') - t.after(async () => { - await rm(tmp, { recursive: true, force: true }) - }) - - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', - content: '# Migration plan', - }, - tool_name: 'Write', - transcript_path: transcriptPath, - }) - assert.strictEqual(result.code, 0) -}) - -test('fails open on malformed stdin', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('not valid json') - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code: number = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) - assert.match(stderr, /fail-open/) -}) - -test('fails open on empty stdin', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('') - const code: number = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) -}) diff --git a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/tsconfig.json b/.claude/hooks/fleet/plan-location-guard/plan-location-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/plan-location-guard/plan-location-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/README.md b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/README.md deleted file mode 100644 index 01fde02..0000000 --- a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# public-surface-reminder - -A **Claude Code hook** that runs before any Bash command Claude is -about to execute and prints a quick reminder about two writing rules -to stderr. It never blocks — its job is just to make sure those rules -are top-of-mind right when Claude is about to commit, push, comment -on a PR, or otherwise publish text somewhere public. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool (here, the Bash -> tool). The hook can either **prime** the model (write to stderr, -> exit 0, model carries on) or **block** the call (exit 2). This one -> only primes. - -## The two rules - -1. **No real customer or company names.** Use a placeholder like - `Acme Inc`. No exceptions. -2. **No internal work-item IDs or tracker URLs.** No `SOC-123` / - `ENG-456` / `ASK-789` / similar; no `linear.app` / `sentry.io` / - internal Jira links. - -## What counts as "public surface" - -The hook only primes for commands that publish text outward: - -- `git commit` (including `--amend`) -- `git push` -- `gh pr (create|edit|comment|review)` -- `gh issue (create|edit|comment)` -- `gh api -X POST|PATCH|PUT` -- `gh release (create|edit)` - -Any other Bash command passes through silently. - -## Why no denylist - -You might ask: why doesn't the hook just have a list of customer -names to scan for? Because **the list itself is the leak**. A file -named `customers.txt` enumerating "these are our customers" is worse -than the bug it tries to prevent — anyone who finds it gets the org's -full customer map for free. Recognition has to happen at write time, -done by the model reading what it's about to send. The hook just -makes sure that read happens. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/public-surface-reminder/index.mts" - } - ] - } - ] - } -} -``` - -## Exit code - -Always `0`. The hook prints a reminder and steps aside. - -## Sibling hooks - -- [`private-name-guard`](../private-name-guard/) — primes on private - repo / project names. -- [`token-guard`](../token-guard/) — _blocks_ Bash calls that would - leak literal secrets to stdout. (The blocking sibling, contrasted - with this priming one.) - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/public-surface-reminder) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts deleted file mode 100644 index 0856652..0000000 --- a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/index.mts +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — public-surface reminder. -// -// Never blocks. On every Bash command that would publish text to a public -// Git/GitHub surface (git commit, git push, gh pr/issue/api/release write), -// writes a short reminder to stderr so the model re-reads the command with -// the two rules freshly in mind: -// -// 1. No real customer/company names — ever. Use `Acme Inc` instead. -// 2. No internal work-item IDs or tracker URLs — no `SOC-123`, `ENG-456`, -// `ASK-789`, `linear.app`, `sentry.io`, etc. -// -// Exit code is always 0. This is attention priming, not enforcement. The -// model is responsible for actually applying the rule — the hook just makes -// sure the rule is in the active context at the moment the command is about -// to fire. -// -// Deliberately carries no list of customer names. Recognition and -// replacement happen at write time, not via enumeration. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", "tool_input": { "command": "..." } } - -import { readFileSync } from 'node:fs' - -type ToolInput = { - tool_name?: string | undefined - tool_input?: - | { - command?: string | undefined - } - | undefined -} - -// Commands that can publish content outside the local machine. -// Keep broad — better to remind on an extra read than miss a write. -const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ - /\bgit\s+commit\b/, - /\bgit\s+push\b/, - /\bgh\s+pr\s+(?:comment|create|edit|review)\b/, - /\bgh\s+issue\s+(?:comment|create|edit)\b/, - /\bgh\s+api\b[^|]*-X\s*(?:PATCH|POST|PUT)\b/i, - /\bgh\s+release\s+(?:create|edit)\b/, -] - -export function isPublicSurface(command: string): boolean { - const normalized = command.replace(/\s+/g, ' ') - return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized)) -} - -function main(): void { - let raw = '' - try { - raw = readFileSync(0, 'utf8') - } catch { - return - } - - let input: ToolInput - try { - input = JSON.parse(raw) - } catch { - return - } - - if (input.tool_name !== 'Bash') { - return - } - const command = input.tool_input?.command - if (!command || typeof command !== 'string') { - return - } - if (!isPublicSurface(command)) { - return - } - - const lines = [ - '[public-surface-reminder] This command writes to a public Git/GitHub surface.', - ' • Re-read the commit message / PR body / comment BEFORE it sends.', - ' • No real customer or company names — use `Acme Inc`. No exceptions.', - ' • No internal work-item IDs or tracker URLs (linear.app, sentry.io, SOC-/ENG-/ASK-/etc.).', - ' • If you spot one, cancel and rewrite the text first.', - ] - process.stderr.write(lines.join('\n') + '\n') -} - -main() diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json deleted file mode 100644 index 3346432..0000000 --- a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-public-surface-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/test/public-surface-reminder.test.mts b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/test/public-surface-reminder.test.mts deleted file mode 100644 index 8240de4..0000000 --- a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/test/public-surface-reminder.test.mts +++ /dev/null @@ -1,95 +0,0 @@ -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -interface Payload { - tool_name?: string | undefined - tool_input?: - | { - command?: string | undefined - } - | undefined -} - -function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - child.stdin!.end(JSON.stringify(payload)) - }) -} - -test('reminds on git commit (exit 0 + stderr)', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'git commit -m "feat: x"', - }, - }) - assert.equal(code, 0, `expected reminder, not block; got exit ${code}`) - assert.ok(stderr.length > 0, 'expected reminder text on stderr') -}) - -test('reminds on gh release create', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'gh release create v1.0.0 --notes "release"', - }, - }) - assert.equal(code, 0) - assert.ok(stderr.length > 0) -}) - -test('stays silent on non-public-surface commands', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'git status', - }, - }) - assert.equal(code, 0) - assert.equal(stderr.length, 0) -}) - -test('stays silent on non-Bash tool', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Read', - tool_input: {}, - }) - assert.equal(code, 0) - assert.equal(stderr.length, 0) -}) - -test('fails open on malformed stdin', async () => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - child.stdin!.end('}}}invalid') - const code = await new Promise<number>(resolve => { - child.process.on('exit', c => resolve(c ?? -1)) - }) - assert.equal(code, 0) -}) diff --git a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json b/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/public-surface-reminder/public-surface-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/README.md b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/README.md deleted file mode 100644 index 4fe2f4a..0000000 --- a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# setup-claude-scanners - -Operator-invoked installer for **AgentShield** + **zizmor** — the two -claude-config / GitHub-Actions scanners. Slim leaf of the -`setup-security-tools` umbrella. - -## When to use - -- You want to install or refresh ONLY the scanner surface - (AgentShield + zizmor) without re-running the firewall / - socket-basics / misc installers. -- You're onboarding a fresh worktree where the only thing you need - scanning right now is claude-config + workflow YAML. - -```sh -node .claude/hooks/setup-claude-scanners/install.mts -``` - -For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. - -## Relationship to setup-security-tools - -The umbrella `setup-security-tools/install.mts` does everything this -leaf does PLUS sfw (firewall) + socket-basics tools (TruffleHog, -Trivy, OpenGrep, uv) + misc tools (cdxgen, synp, janus). - -This leaf is a thin re-entry point that imports `setupAgentShield` - -- `setupZizmor` from the umbrella's `lib/installers.mts` and runs - ONLY those. No token resolution / keychain / shell-rc plumbing is - involved — the two scanners are auth-free. - -## What gets installed - -| Tool | Source | Purpose | -| ----------- | --------------------------------------- | ------------------------------------------------------------- | -| AgentShield | `pkg:npm/ecc-agentshield@1.4.0` via dlx | Claude AI config security scanner (prompt injection, secrets) | -| zizmor | `github:zizmorcore/zizmor` GH-release | GitHub Actions security scanner | diff --git a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/install.mts b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/install.mts deleted file mode 100644 index 02081e9..0000000 --- a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/install.mts +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install-only entry point for AgentShield + zizmor — the two - * claude-config / GitHub-Actions scanners. Slim leaf of the - * `setup-security-tools` umbrella. Run via: node - * .claude/hooks/setup-claude-scanners/install.mts For the full setup - * (firewall + scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. - */ - -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -async function main(): Promise<void> { - logger.log('Claude scanners — install / verify') - logger.log('') - - const { setupAgentShield, setupZizmor } = - (await import('../setup-security-tools/lib/installers.mts')) as { - setupAgentShield: () => Promise<boolean> - setupZizmor: () => Promise<boolean> - } - - const agentshieldOk = await setupAgentShield() - logger.log('') - const zizmorOk = await setupZizmor() - logger.log('') - - logger.log('=== Summary ===') - logger.log(`AgentShield: ${agentshieldOk ? 'ready' : 'NOT AVAILABLE'}`) - logger.log(`Zizmor: ${zizmorOk ? 'ready' : 'FAILED'}`) - - if (!(agentshieldOk && zizmorOk)) { - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(`setup-claude-scanners install: ${msg}`) - process.exitCode = 1 -}) diff --git a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json deleted file mode 100644 index c8e5359..0000000 --- a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "hook-setup-claude-scanners", - "private": true, - "type": "module", - "main": "./install.mts", - "exports": { - ".": "./install.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@socketsecurity/lib-stable": "catalog:", - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json b/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/setup-claude-scanners/setup-claude-scanners/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/README.md b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/README.md deleted file mode 100644 index 875bb76..0000000 --- a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# stale-process-sweeper - -A **Claude Code hook** that runs at the _end_ of every Claude turn -and sweeps stale Node test/build worker processes that lost their -parent. Without this, abandoned workers accumulate across turns and -gradually exhaust system memory. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `Stop` hook like -> this one fires _after_ Claude finishes a turn (a unit of work that -> ends with the model handing the conversation back to the user). -> Stop hooks can do cleanup, log diagnostics, or — like this one — -> reap orphans. - -## Why orphans pile up - -Vitest's `forks` pool spawns one Node worker per CPU. When the parent -runner exits abnormally — a `Bash` tool timeout, a `SIGINT` from the -user, a pre-commit hook crash — the workers stay alive holding -roughly 80–100 MB of RSS each. Tools like `tsgo` and `esbuild` have -similar long-lived service processes that can outlive their parent. - -After a few interrupted runs, you can have several gigabytes of -abandoned processes sitting around. The sweeper finds them by -matching their command line against a known pattern list, confirms -their parent process has died (so we don't kill workers belonging to -a _real_ in-progress run), and sends them `SIGTERM`. - -## What's swept - -| Pattern | What it matches | -| -------------------------------------- | -------------------------------- | -| `vitest/dist/workers/(forks\|threads)` | Vitest worker pool processes | -| `vitest/dist/(cli\|node).[mc]?js` | Orphaned Vitest parent runners | -| `\btsgo\b` | TypeScript Go-based type checker | -| `type-coverage/bin/type-coverage` | Type coverage tool | -| `esbuild/(bin\|lib)/.*\bservice\b` | esbuild's daemon service | - -## What's not swept - -- Anything spawned by a still-living shell (parent process alive). - Those are part of an in-progress run; killing them would break - legitimate work. -- The Claude Code process itself or its parent terminal. -- Anything outside the pattern list. The sweeper is conservative — - if a stuck process isn't pattern-matched, it survives. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/stale-process-sweeper/index.mts" - } - ] - } - ] - } -} -``` - -## Output - -Silent on the happy path (no orphans found). When something is -reaped: - -``` -[stale-process-sweeper] reaped 14 stale worker(s), ~1120MB freed: -vitest-worker=29240(95MB), vitest-worker=33278(93MB), … -``` - -The line goes to stderr. Stop-hook output is shown to the user, not -the model — useful diagnostic, doesn't pollute Claude's context. - -## Testing - -```bash -cd .claude/hooks/stale-process-sweeper -node --test test/*.test.mts -``` - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/stale-process-sweeper) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts deleted file mode 100644 index 9b367e4..0000000 --- a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/index.mts +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — stale-process-sweeper. -// -// Fires at turn-end. Finds Node test/build worker processes that the -// session left behind (test runner crashed mid-run, hook timed out, -// user interrupted `Bash`, etc.) and kills them so they don't pile up -// across turns and exhaust system memory. -// -// What's swept: -// - vitest workers (`vitest/dist/workers/forks` and the threads pool) -// - vitest itself (orphan parent runners that survived a SIGINT) -// - tsgo / tsc type-check daemons -// - type-coverage workers -// - esbuild service processes -// - Socket Firewall wrappers (`~/.socket/_wheelhouse/bin/sfw`) — each pnpm / -// yarn invocation goes through one, and the wrapper sometimes -// outlives its pnpm child. On a busy day this can pile up to -// hundreds of orphans holding ~200MB RSS each (20+GB total). -// Only orphans are reaped (parent dead or init) — live-parent -// wrappers might be tied to an in-progress install. -// -// What's NOT swept: -// - Anything spawned by a still-living shell (PPID alive) -// - Anything matching the user's editors / IDEs / terminals -// - The Claude Code process itself -// -// The hook is fast (one `ps` call + a few regex matches + a couple of -// `kill -0` probes) and silent on the happy path. It only writes to -// stderr when it actually killed something — that's a useful signal. -// -// Stop hooks receive JSON on stdin (we don't read it; the body -// shape is irrelevant to our work) and exit code is advisory. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -// Process-name patterns that indicate a stale test/build worker. -// Must be specific enough that real user processes (a normal `node` -// invocation, an editor's language server) don't match. -const STALE_PATTERNS: Array<{ name: string; rx: RegExp }> = [ - // Vitest worker pools — both `forks` (process-per-worker) and the - // path the threads pool uses when isolation is requested. The - // canonical leak: Vitest spawns N workers, parent crashes/SIGINTs, - // workers stay alive holding 80–100MB each. - { - name: 'vitest-worker', - rx: /vitest\/dist\/workers\/(forks|threads)/, - }, - // Vitest parent runner that survived its own children's exit. - // Matches both shapes: - // - `node ... vitest/dist/cli ... run` (older entry point) - // - `node ... vitest/dist/node.mjs ... run` (alternate entry point) - // - `node node_modules/.bin/../vitest/vitest.mjs run` (current shape - // spawned by `pnpm test` / `vitest run`) - { - name: 'vitest-runner', - rx: /vitest\/(dist\/(cli|node)\.[mc]?js|vitest\.[mc]?js)\b/, - }, - // tsgo / tsc daemons. `tsgo` is the new Go-based type checker; - // `tsc --watch` daemons can also linger. - { - name: 'tsgo', - rx: /\btsgo\b/, - }, - // type-coverage runs as a separate process and sometimes outlives - // its CI step. - { - name: 'type-coverage', - rx: /type-coverage\/bin\/type-coverage/, - }, - // esbuild's daemon service helper. - { - name: 'esbuild-service', - rx: /esbuild\/(bin|lib)\/.*\bservice\b/, - }, - // Socket Firewall command wrappers. Three deployment layouts: - // - ~/.socket/_wheelhouse/bin/sfw[-<version>] (current dev install) - // - ~/.socket/_dlx/<hash>/sfw (planned: dlxBinary cache) - // - ${RUNNER_TEMP}/sfw-bin/sfw[.exe] (CI runner install) - // Path component is invariant across home prefixes (/Users/<user>/ vs - // /home/<user>/). The CI path uses RUNNER_TEMP which varies per OS but - // the trailing `/sfw-bin/sfw` is stable. - // - // Orphan-only (the parent-alive branch in sweep()) — a live-parent - // sfw is likely a mid-flight pnpm/yarn install. - { - name: 'sfw-wrapper', - rx: /(?:\.socket\/(?:_dlx\/[0-9a-f]+|sfw\/bin)|sfw-bin)\/sfw(?:-[\w.]+)?(?:\.exe)?\b/, - }, -] - -interface ProcRow { - command: string - // Elapsed seconds since process started. - elapsedSec: number - pcpu: number - pid: number - ppid: number - rss: number -} - -// Convert ps `etime` field ([dd-]hh:mm:ss or mm:ss) to seconds. -// Examples: "05:23" → 323, "1:02:30" → 3750, "2-04:00:00" → 187200. -export function parseEtime(etime: string): number { - let rest = etime - let days = 0 - const dashIdx = rest.indexOf('-') - if (dashIdx !== -1) { - days = Number.parseInt(rest.slice(0, dashIdx), 10) || 0 - rest = rest.slice(dashIdx + 1) - } - const parts = rest.split(':').map(p => Number.parseInt(p, 10) || 0) - let hours = 0 - let mins = 0 - let secs = 0 - if (parts.length === 3) { - ;[hours, mins, secs] = parts as [number, number, number] - } else if (parts.length === 2) { - ;[mins, secs] = parts as [number, number] - } else if (parts.length === 1) { - secs = parts[0] ?? 0 - } - return days * 86400 + hours * 3600 + mins * 60 + secs -} - -export function listProcesses(): ProcRow[] { - // -A: all processes, -o: custom format, no truncation. macOS + Linux - // both support `pcpu` (instantaneous CPU%) and `etime` (elapsed time). - // Windows isn't supported (Stop hook is unix-only in practice). - const result = spawnSync( - 'ps', - ['-A', '-o', 'pid=,ppid=,rss=,pcpu=,etime=,command='], - {}, - ) - if (result.status !== 0 || !result.stdout) { - return [] - } - const rows: ProcRow[] = [] - // `ps -A` is unix-only (see comment above), so the output uses LF - // line endings — no CRLF normalization needed here. - for (const line of String(result.stdout).split('\n')) { - if (!line.trim()) { - continue - } - // Split into [pid, ppid, rss, pcpu, etime, ...command]. `command` - // may contain arbitrary spaces, so re-join after the first five - // fields. `pcpu` and `etime` are well-formed (no embedded space). - const parts = line.trim().split(/\s+/) - if (parts.length < 6) { - continue - } - const pid = Number.parseInt(parts[0]!, 10) - const ppid = Number.parseInt(parts[1]!, 10) - const rss = Number.parseInt(parts[2]!, 10) - const pcpu = Number.parseFloat(parts[3]!) - const elapsedSec = parseEtime(parts[4]!) - if (!Number.isFinite(pid) || !Number.isFinite(ppid)) { - continue - } - const command = parts.slice(5).join(' ') - rows.push({ - pid, - ppid, - rss, - pcpu: Number.isFinite(pcpu) ? pcpu : 0, - elapsedSec, - command, - }) - } - return rows -} - -export function isAlive(pid: number): boolean { - if (pid <= 1) { - // PID 0 / 1 are the kernel / init — if our parent is one of those, - // we're definitely an orphan, but `kill -0 1` would mislead. - return false - } - try { - process.kill(pid, 0) - return true - } catch { - return false - } -} - -export function classify(row: ProcRow): string | undefined { - for (const { name, rx } of STALE_PATTERNS) { - if (rx.test(row.command)) { - return name - } - } - return undefined -} - -// Two reasons a matched worker should be reaped: -// 1. ORPHAN — parent is gone or is init (PID 1). Classic case: vitest -// SIGINT'd, parent exited, workers re-parented to init. -// 2. STUCK — parent is alive but the worker has been running for a -// long time, holding lots of memory, and burning CPU. Classic case: -// vitest run timed out from inside Claude Code; the parent CLI -// process is technically alive but unproductive, and its workers -// spin forever consuming gigabytes. We sweep these even though the -// parent's still around. -// -// Stuck-worker thresholds — conservative on purpose. A real, productive -// worker doesn't simultaneously hit all three: 5+ minutes of wallclock -// AND >50% CPU sustained AND >500MB RSS. Healthy parallel test runs -// finish well under 5 minutes per worker; CI workers that legitimately -// take longer don't run inside Claude Code's hook environment anyway. -const STUCK_MIN_ELAPSED_SEC = 300 -const STUCK_MIN_PCPU = 50 -const STUCK_MIN_RSS_KB = 500 * 1024 - -export function sweep(): { - killed: Array<{ - name: string - pid: number - reason: 'orphan' | 'stuck' - rssMb: number - }> - skipped: number -} { - const rows = listProcesses() - const myPid = process.pid - const myPpid = process.ppid - const killed: Array<{ - name: string - pid: number - reason: 'orphan' | 'stuck' - rssMb: number - }> = [] - let skipped = 0 - - for (let i = 0, { length } = rows; i < length; i += 1) { - const row = rows[i]! - // Never touch ourselves or our parent (Claude Code). - if (row.pid === myPid || row.pid === myPpid) { - continue - } - const name = classify(row) - if (!name) { - continue - } - let reason: 'orphan' | 'stuck' | undefined - if (row.ppid === 1 || !isAlive(row.ppid)) { - reason = 'orphan' - } else if ( - row.elapsedSec >= STUCK_MIN_ELAPSED_SEC && - row.pcpu >= STUCK_MIN_PCPU && - row.rss >= STUCK_MIN_RSS_KB - ) { - // Worker is matched, has a live parent, but is wedged: long - // elapsed time + spinning CPU + heavy memory. This is the - // user-reported case where vitest workers hung at 100% CPU / - // 1+GB RSS while their parent CLI was technically alive. - reason = 'stuck' - } - if (reason === undefined) { - skipped += 1 - continue - } - try { - // SIGTERM first — give the worker a chance to flush. We don't - // wait for it; the next sweep (next turn) will SIGKILL anything - // that ignored SIGTERM. Keeping the hook fast matters more than - // squeezing every last byte. - process.kill(row.pid, 'SIGTERM') - killed.push({ - name, - pid: row.pid, - reason, - rssMb: Math.round(row.rss / 1024), - }) - } catch { - // Already gone, or we lack permission — nothing to do. - } - } - return { killed, skipped } -} - -function main() { - // Drain stdin (Stop hook delivers a JSON payload). We don't need - // the body, but Node will keep the event loop alive if we don't - // consume it. - process.stdin.resume() - process.stdin.on('data', () => {}) - process.stdin.on('end', runSweep) - // If stdin is already closed (some hook runners don't pipe input), - // run immediately. - if (process.stdin.readable === false) { - runSweep() - } -} - -export function runSweep() { - let result: ReturnType<typeof sweep> - try { - result = sweep() - } catch (e) { - // Hooks must never crash a Claude turn. Log and exit clean. - process.stderr.write( - `[stale-process-sweeper] unexpected error: ${(e as Error).message}\n`, - ) - process.exit(0) - } - if (result.killed.length > 0) { - const totalMb = result.killed.reduce((sum, k) => sum + k.rssMb, 0) - const breakdown = result.killed - .map(k => `${k.name}=${k.pid}(${k.rssMb}MB,${k.reason})`) - .join(', ') - process.stderr.write( - `[stale-process-sweeper] reaped ${result.killed.length} stale ` + - `worker(s), ~${totalMb}MB freed: ${breakdown}\n`, - ) - } - process.exit(0) -} - -main() diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json deleted file mode 100644 index 1a0f6de..0000000 --- a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-stale-process-sweeper", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/test/stale-process-sweeper.test.mts b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/test/stale-process-sweeper.test.mts deleted file mode 100644 index bdcd520..0000000 --- a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/test/stale-process-sweeper.test.mts +++ /dev/null @@ -1,92 +0,0 @@ -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { fileURLToPath } from 'node:url' -import path from 'node:path' -import { test } from 'node:test' -import assert from 'node:assert/strict' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -// Run the hook with an empty stdin payload (Stop hook delivers JSON, -// but the body is unused). Captures stderr + exit code. -function runHook(): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - // Stop hooks receive a JSON payload on stdin. Send an empty object - // so the hook's drain logic completes. - child.stdin!.end('{}\n') - }) -} - -test('stale-process-sweeper: exits 0 when nothing to sweep', async () => { - const { code, stderr } = await runHook() - assert.equal(code, 0, `hook should exit 0; stderr=${stderr}`) - // On a clean host the hook should be silent. - assert.equal( - stderr, - '', - `hook should be silent when no orphans exist; got: ${stderr}`, - ) -}) - -test('stale-process-sweeper: ignores live-parent test workers', async () => { - // Spawn a fake "vitest worker" whose parent is still alive. The - // sweeper must not touch it. We use a script path that matches the - // worker regex; the actual command runs `node -e 'setTimeout(...)'` - // long enough to outlive the hook invocation. - // - // Note: matching the regex `vitest/dist/workers/forks` requires a - // command line that contains that substring. We can't easily forge - // a real vitest binary, so we approximate by passing the path as an - // argv string — `ps -o command=` reflects argv, and the regex sees - // it. - const fakeWorker = spawn( - process.execPath, - [ - '-e', - 'setTimeout(() => {}, 5000)', - // This dummy arg is what `ps` will report; the sweeper's regex - // picks it up. The worker still has a live parent (this test - // process), so the sweeper should NOT kill it. - '/fake/vitest/dist/workers/forks.js', - ], - { stdio: 'ignore', detached: false }, - ) - // Give the OS a moment to register the child. - await new Promise(r => setTimeout(r, 100)) - try { - const { code, stderr } = await runHook() - assert.equal(code, 0) - // Should NOT have reaped the fake worker — its parent (us) is - // alive. If the hook killed it, the message would mention it. - assert.ok( - !stderr.includes('reaped'), - `hook reaped a live-parent worker: ${stderr}`, - ) - // Verify the worker is still alive. - assert.ok( - !fakeWorker.process.killed && fakeWorker.process.exitCode === null, - 'fake worker should still be running', - ) - } finally { - fakeWorker.process.kill('SIGKILL') - } -}) diff --git a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json b/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/stale-process-sweeper/stale-process-sweeper/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} From 89456d12df8de4f32221410c78a9a16862190d8f Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:40:51 -0400 Subject: [PATCH 05/17] chore(hooks): cascade fleet hook updates from wheelhouse template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk update of `.claude/hooks/fleet/` to match the current wheelhouse template. Touches 89 files across 50+ hooks — modifications + new-hook scaffolding for: dont-blame-user-reminder, no-file-scope-oxlint-disable-guard/test, no-orphaned-staging manifests, path-regex-normalize-reminder/test, prefer-function-declaration-guard, socket-token-minifier-start/test. Mostly README + index.mts + test/*.test.mts refreshes matching the current template revisions. No behavior changes to hooks already present; new hook scaffolds add their fleet-canonical surface. --- .../fleet/auth-rotation-reminder/README.md | 2 +- .claude/hooks/fleet/check-new-deps/README.md | 2 +- .../claude-md-section-size-guard/README.md | 2 +- .../codex-no-write-guard/test/index.test.mts | 3 +- .../hooks/fleet/cross-repo-guard/README.md | 2 +- .../README.md | 2 +- .../fleet/gh-token-hygiene-guard/index.mts | 28 +++ .../fleet/gitmodules-comment-guard/README.md | 2 +- .claude/hooks/fleet/logger-guard/README.md | 2 +- .../fleet/marketplace-comment-guard/README.md | 2 +- .../fleet/new-hook-claude-md-guard/README.md | 2 +- .../fleet/new-hook-claude-md-guard/index.mts | 93 +++++--- .../test/index.test.mts | 40 ++++ .../hooks/fleet/no-fleet-fork-guard/index.mts | 21 +- .../fleet/no-orphaned-staging/package.json | 15 ++ .../fleet/no-orphaned-staging/tsconfig.json | 16 ++ .claude/hooks/fleet/no-revert-guard/index.mts | 37 ++- .../README.md | 2 +- .../no-underscore-identifier-guard/index.mts | 15 +- .../node-modules-staging-guard/README.md | 2 +- .../node-modules-staging-guard/index.mts | 2 +- .../test/index.test.mts | 2 +- .../test/index.test.mts | 39 ++++ .../index.mts | 218 ++++++++++++++++++ .../package.json | 15 ++ .../test/index.test.mts | 126 ++++++++++ .../tsconfig.json | 16 ++ .../hooks/fleet/private-name-guard/README.md | 2 +- .../fleet/readme-fleet-shape-guard/README.md | 4 +- .../fleet/release-workflow-guard/README.md | 2 +- .../fleet/release-workflow-guard/index.mts | 4 +- .../hooks/fleet/setup-basics-tools/README.md | 4 +- .../fleet/setup-basics-tools/install.mts | 4 +- .claude/hooks/fleet/setup-firewall/README.md | 4 +- .../hooks/fleet/setup-firewall/install.mts | 4 +- .../hooks/fleet/setup-misc-tools/README.md | 4 +- .../hooks/fleet/setup-misc-tools/install.mts | 4 +- .../fleet/setup-security-tools/README.md | 6 +- .../fleet/setup-security-tools/index.mts | 8 +- .../fleet/setup-security-tools/install.mts | 4 +- .../setup-security-tools/lib/installers.mts | 2 +- .../lib/shell-rc-bridge.mts | 2 +- .../fleet/setup-security-tools/update.mts | 18 +- .claude/hooks/fleet/setup-signing/README.md | 6 +- .claude/hooks/fleet/setup-signing/install.mts | 8 +- .../README.md | 2 +- .../socket-token-minifier-start/README.md | 2 +- .../test/index.test.mts | 36 +++ .../fleet/squash-history-reminder/README.md | 2 +- .../workflow-uses-comment-guard/README.md | 2 +- 50 files changed, 733 insertions(+), 109 deletions(-) create mode 100644 .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts create mode 100644 .claude/hooks/fleet/no-orphaned-staging/package.json create mode 100644 .claude/hooks/fleet/no-orphaned-staging/tsconfig.json create mode 100644 .claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts create mode 100644 .claude/hooks/fleet/prefer-function-declaration-guard/index.mts create mode 100644 .claude/hooks/fleet/prefer-function-declaration-guard/package.json create mode 100644 .claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts create mode 100644 .claude/hooks/fleet/prefer-function-declaration-guard/tsconfig.json create mode 100644 .claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts diff --git a/.claude/hooks/fleet/auth-rotation-reminder/README.md b/.claude/hooks/fleet/auth-rotation-reminder/README.md index 2b74b1f..91fce07 100644 --- a/.claude/hooks/fleet/auth-rotation-reminder/README.md +++ b/.claude/hooks/fleet/auth-rotation-reminder/README.md @@ -110,7 +110,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/auth-rotation-reminder/index.mts" + "command": "node .claude/hooks/fleet/auth-rotation-reminder/index.mts" } ] } diff --git a/.claude/hooks/fleet/check-new-deps/README.md b/.claude/hooks/fleet/check-new-deps/README.md index e513cfc..01c7bf0 100644 --- a/.claude/hooks/fleet/check-new-deps/README.md +++ b/.claude/hooks/fleet/check-new-deps/README.md @@ -136,7 +136,7 @@ The hook is registered in `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/check-new-deps/index.mts" + "command": "node .claude/hooks/fleet/check-new-deps/index.mts" } ] } diff --git a/.claude/hooks/fleet/claude-md-section-size-guard/README.md b/.claude/hooks/fleet/claude-md-section-size-guard/README.md index 54c4b97..9bb9f3d 100644 --- a/.claude/hooks/fleet/claude-md-section-size-guard/README.md +++ b/.claude/hooks/fleet/claude-md-section-size-guard/README.md @@ -35,4 +35,4 @@ No bypass phrase — the override env-var is the documented escape valve. If you ## Reading - CLAUDE.md → opening fleet-canonical note (cap is cited there). -- `.claude/hooks/claude-md-size-guard/` — the companion byte-cap hook. +- `.claude/hooks/fleet/claude-md-size-guard/` — the companion byte-cap hook. diff --git a/.claude/hooks/fleet/codex-no-write-guard/test/index.test.mts b/.claude/hooks/fleet/codex-no-write-guard/test/index.test.mts index f83b5d0..7ea6ea7 100644 --- a/.claude/hooks/fleet/codex-no-write-guard/test/index.test.mts +++ b/.claude/hooks/fleet/codex-no-write-guard/test/index.test.mts @@ -50,7 +50,8 @@ test('command mentioning the guard name (codex-no-write-guard) is NOT a codex in const r = await runHook({ tool_name: 'Bash', tool_input: { - command: 'grep -n "write" template/.claude/hooks/codex-no-write-guard/index.mts', + command: + 'grep -n "write" template/.claude/hooks/fleet/codex-no-write-guard/index.mts', }, }) assert.strictEqual(r.code, 0) diff --git a/.claude/hooks/fleet/cross-repo-guard/README.md b/.claude/hooks/fleet/cross-repo-guard/README.md index eed74eb..c56e971 100644 --- a/.claude/hooks/fleet/cross-repo-guard/README.md +++ b/.claude/hooks/fleet/cross-repo-guard/README.md @@ -86,7 +86,7 @@ companion git-side scanner in `.git-hooks/_helpers.mts` (`FLEET_REPO_NAMES`) "hooks": [ { "type": "command", - "command": "node .claude/hooks/cross-repo-guard/index.mts" + "command": "node .claude/hooks/fleet/cross-repo-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/enterprise-push-property-reminder/README.md b/.claude/hooks/fleet/enterprise-push-property-reminder/README.md index c841f95..c30bae1 100644 --- a/.claude/hooks/fleet/enterprise-push-property-reminder/README.md +++ b/.claude/hooks/fleet/enterprise-push-property-reminder/README.md @@ -47,4 +47,4 @@ The pattern requires both error lines for a tight match — generic "permission - `docs/claude.md/fleet/push-policy.md` — full rationale + operator flow. - `scripts/_shared/repo-properties.mts` — `canSkipReviewGate()` implementation used by the cascade. -- `.claude/hooks/pr-vs-push-default-reminder/` — sibling hook for the reverse case (Claude opening a PR when direct push would have worked). +- `.claude/hooks/fleet/pr-vs-push-default-reminder/` — sibling hook for the reverse case (Claude opening a PR when direct push would have worked). diff --git a/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts b/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts index 092fefa..6dbb2c3 100644 --- a/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts +++ b/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts @@ -151,6 +151,18 @@ interface GhAuthStatus { } async function main(): Promise<void> { + // CLI mode: `node index.mts --stamp` writes a fresh timestamp. + // Provides an explicit recovery path for users who ran `gh auth + // refresh` outside Claude's tool flow (so the PreToolUse-driven + // pre-stamp at line ~228 didn't fire) and got stuck on the >8h + // block. Documented in CLAUDE.md's `### gh token hygiene` section. + if (process.argv.includes('--stamp')) { + recordTokenIssuedAt() + process.stdout.write( + `gh-token-hygiene-guard: stamped ${TOKEN_ISSUED_AT_FILE}\n`, + ) + process.exit(0) + } const raw = await readStdin() let payload: PreToolUsePayload try { @@ -398,6 +410,13 @@ function isAuthMaintenanceCommand(command: string): boolean { return /\bgh\s+auth\s+(?:login|logout|refresh|status)\b/.test(command) } +// 2020-01-01T00:00:00Z in epoch ms. Any stamp file value below this is +// either zero, a POSIX-seconds value (~1.7e9) mistakenly written instead +// of ms (~1.7e12), or garbage. Treat as malformed and re-stamp so a +// user who attempted `date "+%s" > ~/.claude/gh-token-issued-at` +// doesn't get permanently blocked. +const MIN_PLAUSIBLE_STAMP_MS = 1_577_836_800_000 + function isTokenFresh(): boolean { if (!existsSync(TOKEN_ISSUED_AT_FILE)) { // First run: stamp now and treat as fresh. This makes the hook @@ -412,6 +431,15 @@ function isTokenFresh(): boolean { if (!Number.isFinite(recorded)) { return false } + // Malformed value (zero, POSIX-seconds, garbage) — re-stamp and + // treat as fresh. The actual gh token in keychain is what matters + // for security; this stamp file just tracks when we last saw a + // confirmed refresh. A wrong value here would lock the user out + // until they figured out the file format. + if (recorded < MIN_PLAUSIBLE_STAMP_MS) { + recordTokenIssuedAt() + return true + } return Date.now() - recorded < TOKEN_TTL_MS } catch { return false diff --git a/.claude/hooks/fleet/gitmodules-comment-guard/README.md b/.claude/hooks/fleet/gitmodules-comment-guard/README.md index 746b54c..92a810c 100644 --- a/.claude/hooks/fleet/gitmodules-comment-guard/README.md +++ b/.claude/hooks/fleet/gitmodules-comment-guard/README.md @@ -62,7 +62,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/gitmodules-comment-guard/index.mts" + "command": "node .claude/hooks/fleet/gitmodules-comment-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/logger-guard/README.md b/.claude/hooks/fleet/logger-guard/README.md index f5966a6..9da1e20 100644 --- a/.claude/hooks/fleet/logger-guard/README.md +++ b/.claude/hooks/fleet/logger-guard/README.md @@ -80,7 +80,7 @@ agent can apply it directly: "hooks": [ { "type": "command", - "command": "node .claude/hooks/logger-guard/index.mts" + "command": "node .claude/hooks/fleet/logger-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/marketplace-comment-guard/README.md b/.claude/hooks/fleet/marketplace-comment-guard/README.md index 8264fdc..45dd258 100644 --- a/.claude/hooks/fleet/marketplace-comment-guard/README.md +++ b/.claude/hooks/fleet/marketplace-comment-guard/README.md @@ -86,7 +86,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/marketplace-comment-guard/index.mts" + "command": "node .claude/hooks/fleet/marketplace-comment-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/new-hook-claude-md-guard/README.md b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md index e506a32..9b1f2d9 100644 --- a/.claude/hooks/fleet/new-hook-claude-md-guard/README.md +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md @@ -26,7 +26,7 @@ Accepted variants: ## Why wheelhouse-only -Downstream fleet repos receive their CLAUDE.md and hook code via `sync-scaffolding`. They consume the canonical version; they shouldn't be re-policing the source-of-truth mapping. This hook lives in `template/.claude/hooks/new-hook-claude-md-guard/` but is **NOT** listed in `scripts/sync-scaffolding/manifest.mts`'s `IDENTICAL_FILES`, so the cascade skips it. +Downstream fleet repos receive their CLAUDE.md and hook code via `sync-scaffolding`. They consume the canonical version; they shouldn't be re-policing the source-of-truth mapping. This hook lives in `template/.claude/hooks/fleet/new-hook-claude-md-guard/` but is **NOT** listed in `scripts/sync-scaffolding/manifest.mts`'s `IDENTICAL_FILES`, so the cascade skips it. ## Skipped paths diff --git a/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts b/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts index d573642..1160e30 100644 --- a/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts @@ -56,9 +56,15 @@ const BYPASS_PHRASES = [ // <repo>/.claude/hooks/<name>/index.mts (any fleet repo) // // Captures the hook name in group 1. The optional `template/` segment -// covers the wheelhouse path; the rest is identical. +// covers the wheelhouse path; the optional `fleet/` or `repo/` segment +// covers the docs-style `.claude/hooks/{fleet,repo}/<name>/` layout +// (matches the parallel docs/claude.md/{fleet,repo}/ convention). +// hookName is the LEAF name (e.g. `avoid-cd-reminder`), not the +// segment-qualified path — citations and registry refs use the full +// canonical path (`\`.claude/hooks/fleet/<name>/\``) so the guard's +// expectedRefs uses that path verbatim when checking. const HOOK_INDEX_PATH_RE = - /.*?(?:\/template)?\/\.claude\/hooks\/([^/]+)\/index\.mts$/ + /.*?(?:\/template)?\/\.claude\/hooks\/(?:(fleet|repo)\/)?([^/]+)\/index\.mts$/ // Hooks that are themselves wheelhouse-only — they don't need a // CLAUDE.md entry because they're internal tooling, not policy rules @@ -108,75 +114,94 @@ export function readPayload(raw: string): PreToolUsePayload | undefined { async function main(): Promise<void> { if (process.env[ENV_DISABLE]) { - process.exit(0) + return } const payloadRaw = await readStdin() const payload = readPayload(payloadRaw) if (!payload) { - process.exit(0) + return } const toolName = payload.tool_name if (toolName !== 'Edit' && toolName !== 'Write') { - process.exit(0) + return } const filePath = payload.tool_input?.['file_path'] if (typeof filePath !== 'string') { - process.exit(0) + return } const match = HOOK_INDEX_PATH_RE.exec(filePath) if (!match) { - process.exit(0) + return } - const hookName = match[1]! + // match[1] = "fleet" | "repo" | undefined (legacy top-level layout). + // match[2] = leaf hook name. + const segment = match[1] + const hookName = match[2]! + // hookPathSuffix is the canonical path under .claude/hooks/, used + // verbatim in CLAUDE.md citations: + // fleet → `fleet/<name>` + // repo → `repo/<name>` (per-repo, normally exempt — see below) + // (none) → `<name>` (legacy top-level) + const hookPathSuffix = segment ? `${segment}/${hookName}` : hookName // Skip _shared (helpers, not a hook) and wheelhouse-only hooks. if (hookName === '_shared' || WHEELHOUSE_ONLY_HOOKS.has(hookName)) { - process.exit(0) + return + } + // Per-repo hooks at `.claude/hooks/repo/<name>/` are NOT cascaded + // and live entirely in the host repo. Skip the CLAUDE.md citation + // requirement — repo hooks document themselves in their own README + // + the host repo's CLAUDE.md decides whether to cite them. + if (segment === 'repo') { + return } // Bypass via canonical user phrase. if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) + return } const claudeMdPath = findCanonicalClaudeMd(filePath, payload.cwd) if (!claudeMdPath || !existsSync(claudeMdPath)) { // Can't find CLAUDE.md; fail-open rather than blocking on // infrastructure problems. - process.exit(0) + return } let content: string try { content = readFileSync(claudeMdPath, 'utf8') } catch { - process.exit(0) - } - // The required form is `(enforced by `.claude/hooks/<hookName>/`)`. - // We accept either backtick-quoted or plain-text variants of the - // path — the existing fleet uses backticks consistently, but a - // trailing slash is also optional. - const expectedRefs = [ - `(enforced by \`.claude/hooks/${hookName}/\`)`, - `(enforced by \`.claude/hooks/${hookName}\`)`, - `enforced by \`.claude/hooks/${hookName}/\``, - `enforced by \`.claude/hooks/${hookName}\``, - ] - let found = false - for (let i = 0, { length } = expectedRefs; i < length; i += 1) { - if (content.includes(expectedRefs[i]!)) { - found = true - break - } + return } + // Three citation shapes recognized: + // 1. Inline rule: `enforced by \`.claude/hooks/fleet/<name>/\`` + // 2. Comma-listed: `enforced by \`.claude/hooks/fleet/a/\`, \`.../b/\`` + // 3. Brace-grouped: `enforced by \`.claude/hooks/fleet/{a,b,c}/\`` + // 1+2 contain the literal backticked path; 3 is a brace expansion + // — the leaf name appears between `{...}`. + const literalSlashed = `\`.claude/hooks/${hookPathSuffix}/\`` + const literalBare = `\`.claude/hooks/${hookPathSuffix}\`` + const lastSlash = hookPathSuffix.lastIndexOf('/') + const prefix = lastSlash >= 0 ? hookPathSuffix.slice(0, lastSlash + 1) : '' + const leaf = + lastSlash >= 0 ? hookPathSuffix.slice(lastSlash + 1) : hookPathSuffix + const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const braceRe = new RegExp( + `\`\\.claude/hooks/${escape(prefix)}\\{[^}]*\\b${escape(leaf)}\\b[^}]*\\}/\``, + ) + const found = + content.includes(literalSlashed) || + content.includes(literalBare) || + braceRe.test(content) if (found) { - process.exit(0) + return } const lines = [ - `[new-hook-claude-md-guard] Hook "${hookName}" missing CLAUDE.md reference.`, + `[new-hook-claude-md-guard] Hook "${hookPathSuffix}" missing CLAUDE.md reference.`, '', ` ${toolName} blocked: template/CLAUDE.md must contain a one-line`, ` reference to the hook before it lands. Expected form (inline,`, ` attached to the rule the hook enforces):`, '', - ` (enforced by \`.claude/hooks/${hookName}/\`)`, + ` (enforced by \`.claude/hooks/${hookPathSuffix}/\`)`, '', ' Why: fleet repos read CLAUDE.md as the source of truth. A hook', " without a CLAUDE.md entry is policy that doesn't exist on paper —", @@ -193,5 +218,7 @@ async function main(): Promise<void> { } main().catch(() => { - process.exit(0) + // Fail-open: never block a session on this hook's own bug. + // Loop drains naturally to exit 0; explicit set for clarity. + process.exitCode = 0 }) diff --git a/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts new file mode 100644 index 0000000..9bfae9f --- /dev/null +++ b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts @@ -0,0 +1,40 @@ +/** + * @file Smoke test for no-file-scope-oxlint-disable-guard. + * + * PreToolUse(Edit|Write) hook that blocks file-scope `oxlint-disable` / + * `oxlint-disable-next-line` blocks at the top of a file. The block scope + * silently exempts future edits the author never thought about; per-line + * disables with rationale are the right shape. + * + * Smoke contract: + * - benign payload (non-Edit/Write tool, or no oxlint-disable in content) + * → exit 0. + * - the hook loads + dispatches without throwing. + */ + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('benign payload exits 0', async () => { + const result = await runHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/example.ts' }, + }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/fleet/no-fleet-fork-guard/index.mts b/.claude/hooks/fleet/no-fleet-fork-guard/index.mts index fcfbe67..16bb1f3 100644 --- a/.claude/hooks/fleet/no-fleet-fork-guard/index.mts +++ b/.claude/hooks/fleet/no-fleet-fork-guard/index.mts @@ -74,11 +74,22 @@ const CANONICAL_PREFIXES = [ ] // Carve-out: paths under a CANONICAL_PREFIXES dir that are explicitly -// per-repo (not cascaded). `docs/claude.md/repo/` is the per-repo -// analog of `docs/claude.md/fleet/` — host repos drop architecture / -// commands / build-pipeline detail here to keep CLAUDE.md under the -// whole-file size cap. -const PER_REPO_PREFIXES = ['docs/claude.md/repo/'] +// per-repo (not cascaded). Mirrors the docs convention: +// docs/claude.md/fleet/ — cascaded, edited in template +// docs/claude.md/repo/ — local, edited in the host repo +// And extends it to hooks + scripts: +// .claude/hooks/<name>/ — fleet (default; cascaded) +// .claude/hooks/repo/<name>/ — per-repo, local-only +// scripts/<name> — fleet (default; cascaded) +// scripts/repo/<name> — per-repo, local-only +// Repo-local hooks/scripts let a host repo address one-off concerns +// (e.g. socket-btm's gypi source-path quirk) without forcing the +// whole fleet to carry the rule. +const PER_REPO_PREFIXES = [ + 'docs/claude.md/repo/', + '.claude/hooks/repo/', + 'scripts/repo/', +] // Fleet-canonical individual files (not under one of the prefix // dirs). Matches relative-to-repo-root. diff --git a/.claude/hooks/fleet/no-orphaned-staging/package.json b/.claude/hooks/fleet/no-orphaned-staging/package.json new file mode 100644 index 0000000..898f674 --- /dev/null +++ b/.claude/hooks/fleet/no-orphaned-staging/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-orphaned-staging", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/no-orphaned-staging/tsconfig.json b/.claude/hooks/fleet/no-orphaned-staging/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/no-orphaned-staging/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-revert-guard/index.mts b/.claude/hooks/fleet/no-revert-guard/index.mts index 0be74a6..4d756fc 100644 --- a/.claude/hooks/fleet/no-revert-guard/index.mts +++ b/.claude/hooks/fleet/no-revert-guard/index.mts @@ -14,8 +14,11 @@ // user must type "Allow <X> bypass" where <X> matches the flag // (e.g. "Allow no-verify bypass", "Allow lint bypass", // "Allow gpg bypass"). -// - Force push (--force / -f to push or push-with-lease) → -// user must type "Allow force-push bypass". +// - Force push --force-with-lease (safer; aborts if remote moved) → +// user must type "Allow force-with-lease bypass". +// - Force push --force / -f (CAN silently clobber remote commits) → +// user must type "Allow force-push bypass". Always reach for +// --force-with-lease first; this is the high-friction path. // // Phrase scoping: the hook reads the recent user turns from the // transcript (most recent N user messages). A phrase from a prior @@ -171,15 +174,39 @@ const CHECKS: readonly GuardCheck[] = [ /(?:^|[\s;&|(`])(?:python3?\s+-c\b.*(?:open\([^)]*['"]w['"]?|\.write_text\(|\.write\([^)]*\)\s*$)|sed\s+-i\b|cat\s+<<-?\s*['"]?[A-Z_]+['"]?\b[^|;`]*>\s*[^/]|tee\s+(?!-)\S*\.(?:m?[jt]sx?|json|md|ya?ml|toml|sh|py|rs|go|css)\b|\bdd\s+[^|;`]*\bof=)/, }, { + // --force-with-lease refuses the push if the remote moved since the + // last fetch — safer than --force because it can't silently clobber + // someone else's commits. Always prefer this form. Lower-friction + // bypass phrase so users aren't tempted to reach for raw --force + // when --force-with-lease would do. + bypassPhrase: 'Allow force-with-lease bypass', + label: 'git push --force-with-lease', + matches: command => + commandsFor(command, 'git').some( + c => + c.args.includes('push') && + c.args.some(a => a.startsWith('--force-with-lease')), + ) + ? 'git push --force-with-lease' + : undefined, + }, + { + // Raw --force / -f bypasses the lease check and CAN silently + // overwrite remote commits. Always reach for --force-with-lease + // first; this rule + bypass phrase exist for the narrow cases + // where the remote really should be overwritten unconditionally + // (recovering from corruption, force-clobbering a doomed + // experimental branch the user owns). bypassPhrase: 'Allow force-push bypass', label: 'git push --force / -f', matches: command => commandsFor(command, 'git').some( c => c.args.includes('push') && - (c.args.includes('--force') || - c.args.includes('-f') || - c.args.some(a => a.startsWith('--force-with-lease'))), + (c.args.includes('--force') || c.args.includes('-f')) && + // Allow --force-with-lease through this rule (it's handled + // by the preceding lease-specific rule). + !c.args.some(a => a.startsWith('--force-with-lease')), ) ? 'git push --force' : undefined, diff --git a/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md index 4b0d962..a1b9eb6 100644 --- a/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md +++ b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md @@ -96,7 +96,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/no-structured-clone-prefer-json-guard/index.mts" + "command": "node .claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts b/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts index 92ff414..13325e2 100644 --- a/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts +++ b/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts @@ -86,6 +86,13 @@ const BANNED_DECL_PATTERNS: readonly RegExp[] = [ const BYPASS_PHRASE = 'Allow underscore-identifier bypass' +// Node CJS exposes `__dirname` and `__filename` as module-scoped free +// variables. ESM modules conventionally re-create them via +// `path.dirname(fileURLToPath(import.meta.url))`, so the identifiers show +// up in a `const ...` declaration. Skip those — they're matching Node's +// published names, not a `_internal` marker. +const ALLOWED_FREE_VARS = new Set(['__dirname', '__filename']) + export function findBannedIdentifiers(text: string): Finding[] { const findings: Finding[] = [] const lines = text.split('\n') @@ -96,9 +103,13 @@ export function findBannedIdentifiers(text: string): Finding[] { pattern.lastIndex = 0 let match: RegExpExecArray | null while ((match = pattern.exec(line)) !== null) { + const identifier = match[1]! + if (ALLOWED_FREE_VARS.has(identifier)) { + continue + } findings.push({ line: i + 1, - identifier: match[1]!, + identifier, text: line.trimEnd(), }) } @@ -138,7 +149,7 @@ export function isInternalDirPath(filePath: string): boolean { // can have its own tests without bypass phrases. export function isPluginOrHookTestPath(filePath: string): boolean { return ( - filePath.includes('/.claude/hooks/no-underscore-identifier-guard/') || + filePath.includes('/.claude/hooks/fleet/no-underscore-identifier-guard/') || filePath.includes( '/.config/oxlint-plugin/rules/no-underscore-identifier.', ) || diff --git a/.claude/hooks/fleet/node-modules-staging-guard/README.md b/.claude/hooks/fleet/node-modules-staging-guard/README.md index 4ca4a6a..f1062c1 100644 --- a/.claude/hooks/fleet/node-modules-staging-guard/README.md +++ b/.claude/hooks/fleet/node-modules-staging-guard/README.md @@ -7,7 +7,7 @@ paths containing `node_modules/` or `package-lock.json` under ## Why `-f` overrides `.gitignore`. Past incident: an agent ran -`git add -f .claude/hooks/check-new-deps/node_modules/` to "fix" what +`git add -f .claude/hooks/fleet/check-new-deps/node_modules/` to "fix" what looked like a missing dir in a commit. The directory landed in 6 fleet repos via cascade. Removing it required either a history rewrite (`git filter-branch` / `git filter-repo`) + force-push, or living with diff --git a/.claude/hooks/fleet/node-modules-staging-guard/index.mts b/.claude/hooks/fleet/node-modules-staging-guard/index.mts index 5b87967..d93fce2 100644 --- a/.claude/hooks/fleet/node-modules-staging-guard/index.mts +++ b/.claude/hooks/fleet/node-modules-staging-guard/index.mts @@ -150,7 +150,7 @@ async function main(): Promise<void> { ...blockedArgs.map(a => ` ${a}`), '', ' Past incident: a cascading agent committed', - ' `.claude/hooks/check-new-deps/node_modules/` into 6 fleet repos.', + ' `.claude/hooks/fleet/check-new-deps/node_modules/` into 6 fleet repos.', ' Removing it required force-push (itself a hazard) or filter-branch.', '', ' `node_modules/` and hook `package-lock.json` files are gitignored', diff --git a/.claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts b/.claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts index ac078eb..bd5d67a 100644 --- a/.claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts +++ b/.claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts @@ -63,7 +63,7 @@ test('git add -f node_modules path blocked', async () => { const r = await runHook({ tool_name: 'Bash', tool_input: { - command: 'git add -f .claude/hooks/check-new-deps/node_modules/', + command: 'git add -f .claude/hooks/fleet/check-new-deps/node_modules/', }, }) assert.strictEqual(r.code, 2) diff --git a/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts b/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts new file mode 100644 index 0000000..36d1198 --- /dev/null +++ b/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts @@ -0,0 +1,39 @@ +/** + * @file Smoke test for path-regex-normalize-reminder. + * + * Stop hook that warns when the assistant's recent output writes dual- + * separator regexes like `[/\\]` against a path — the fleet helper + * `normalizePath` already gives one `/` representation across platforms. + * + * Smoke contract: hook loads + dispatches without throwing; empty + * transcript path → exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'path-regex-reminder-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts b/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts new file mode 100644 index 0000000..3237aa8 --- /dev/null +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts @@ -0,0 +1,218 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — prefer-function-declaration-guard. +// +// Edit-time partner of the `socket/prefer-function-declaration` oxlint +// rule. Blocks Write/Edit ops that introduce a module-scope `const`-bound +// function expression — `export const foo = () => {}`, +// `const foo = function () {}`, etc. The oxlint rule autofixes at commit +// time, but by then the agent has burned a turn writing the wrong shape +// (and may push the file to a downstream consumer that re-reads it). +// Catching at edit time keeps the agent from learning the wrong pattern. +// +// Banned shapes (module scope only — leading whitespace == top level): +// export const foo = (...) => { ... } +// export const foo = async (...) => expr +// export const foo = function (...) { ... } +// const foo = (...) => { ... } (no leading whitespace) +// const foo = async () => { ... } +// const foo = function () { ... } +// +// Allowed (passes through): +// - Indented `const foo = () => ...` — that's an inner-function +// expression, not module-scope; arrows correctly inherit `this`. +// - `const foo: SomeType = () => ...` — TS type annotation locks the +// contract; refactor requires human judgment. +// - `const foo = (... rest of complex destructuring ...) = ...` — +// non-Identifier declarators; let the human untangle. +// - `_internal/` files, `dist/`, `build/`, `node_modules/`. +// - Bypass phrase `Allow function-declaration bypass` in a recent turn. +// +// Reads PreToolUse JSON payload from stdin: +// { "tool_name": "Edit"|"Write", +// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } +// +// Exit codes: +// 0 — pass. +// 2 — block (at least one banned const-fn-expression found). +// +// Fails open on malformed payloads (exit 0 + stderr log). + +import process from 'node:process' + +import { bypassPhrasePresent } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_input?: + | { + readonly content?: string | undefined + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly old_string?: string | undefined + } + | undefined + readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined +} + +interface Finding { + readonly line: number + readonly name: string + readonly text: string +} + +// Module-scope `const`/`let`/`var` binding to an arrow or function +// expression. The leading anchor `^` plus the `(?:export\s+)?` prefix +// ensures we only match top-level declarations — anything indented is +// inside a function/block scope and outside the rule's autofix scope. +// Group 1: 'export ' or '' — preserved so a future autofix could keep +// the export keyword (not used here, only matched). +// Group 2: identifier. +// Group 3: '=' tail, used to scan for the `=>` arrow or `function` token +// further on. +const ARROW_DECL_RE = + /^(export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/gm +const FUNCEXPR_DECL_RE = + /^(export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?function\s*\*?\s*(?:\([^)]*\))/gm + +const BYPASS_PHRASE = 'Allow function-declaration bypass' + +// Files where the rule legitimately appears in fixtures: this hook's own +// tests + the oxlint rule's tests. Plus any `_internal/` dir, generated +// output (dist/build/node_modules), and the rule's own implementation +// files (which discuss the banned shapes in comments + matchers). +export function isExemptPath(filePath: string): boolean { + return ( + filePath.includes('/_internal/') || + filePath.includes('/dist/') || + filePath.includes('/build/') || + filePath.includes('/node_modules/') || + filePath.includes( + '/.claude/hooks/fleet/prefer-function-declaration-guard/', + ) || + filePath.includes('/.config/oxlint-plugin/rules/prefer-function-declaration.') || + filePath.includes('/.config/oxlint-plugin/test/prefer-function-declaration') + ) +} + +// `const foo: SomeType = () => ...` — the type annotation makes the +// arrow form the contract. Refactor would need to drop the annotation +// or migrate it to `satisfies`. The oxlint rule skips this shape too. +function hasTypeAnnotation(line: string): boolean { + // Cheap detection: a `:` between the identifier and the `=`. False + // positives on object-destructuring patterns are gated above by the + // identifier-only declarator match — patterns like `const { a }: T =` + // never reach this check. + const eqIdx = line.indexOf('=') + if (eqIdx === -1) { + return false + } + const lhs = line.slice(0, eqIdx) + return lhs.includes(':') +} + +export function findConstFnExpressions(text: string): Finding[] { + const findings: Finding[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + // Reset stateful flags before each scan. + ARROW_DECL_RE.lastIndex = 0 + FUNCEXPR_DECL_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = ARROW_DECL_RE.exec(line)) !== null) { + if (hasTypeAnnotation(line)) { + continue + } + findings.push({ line: i + 1, name: m[2]!, text: line.trimEnd() }) + } + while ((m = FUNCEXPR_DECL_RE.exec(line)) !== null) { + if (hasTypeAnnotation(line)) { + continue + } + findings.push({ line: i + 1, name: m[2]!, text: line.trimEnd() }) + } + } + return findings +} + +export async function readStdin(): Promise<string> { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + return Buffer.concat(chunks).toString('utf8') +} + +async function main(): Promise<void> { + let payload: ToolInput + try { + const raw = await readStdin() + payload = JSON.parse(raw) as ToolInput + } catch (err) { + process.stderr.write( + `prefer-function-declaration-guard: payload parse failed (${(err as Error).message})\n`, + ) + process.exit(0) + } + + const toolName = payload.tool_name + if (toolName !== 'Edit' && toolName !== 'Write') { + process.exit(0) + } + + const filePath = payload.tool_input?.file_path ?? '' + if (!filePath || isExemptPath(filePath)) { + process.exit(0) + } + + // Only police TS/JS source. Allow .cts/.mts/.cjs/.mjs/.ts/.tsx/.js/.jsx. + if (!/\.(?:c|m)?[jt]sx?$/.test(filePath)) { + process.exit(0) + } + + const text = + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' + if (!text) { + process.exit(0) + } + + const findings = findConstFnExpressions(text) + if (findings.length === 0) { + process.exit(0) + } + + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + process.stderr.write( + `prefer-function-declaration-guard: ${findings.length} const-fn-expression(s) — bypassed via "${BYPASS_PHRASE}"\n`, + ) + process.exit(0) + } + + const lines = findings + .map(f => ` ${filePath}:${f.line} ${f.name}\n ${f.text}`) + .join('\n') + process.stderr.write( + `prefer-function-declaration-guard: refusing to introduce module-scope const-bound function expression(s).\n` + + `\n` + + `${lines}\n` + + `\n` + + `Use a function declaration instead:\n` + + ` export function foo() { ... } (not export const foo = () => ...)\n` + + ` function foo() { ... } (not const foo = function () ...)\n` + + `\n` + + `Function declarations hoist, have a stable .name in stack traces, and\n` + + `sort cleanly under socket/sort-source-methods. The companion oxlint\n` + + `rule \`socket/prefer-function-declaration\` autofixes at commit time,\n` + + `but at the cost of a wasted turn writing the wrong shape.\n` + + `\n` + + `Bypass: type "${BYPASS_PHRASE}" in a recent message.\n`, + ) + process.exit(2) +} + +main().catch(err => { + process.stderr.write( + `prefer-function-declaration-guard: ${(err as Error).message}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/package.json b/.claude/hooks/fleet/prefer-function-declaration-guard/package.json new file mode 100644 index 0000000..7f8d775 --- /dev/null +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-prefer-function-declaration-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts b/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts new file mode 100644 index 0000000..6ccfea8 --- /dev/null +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { findConstFnExpressions, isExemptPath } from '../index.mts' + +describe('findConstFnExpressions', () => { + it('flags top-level export const arrow', () => { + const src = `export const foo = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + assert.equal(findings[0]!.name, 'foo') + assert.equal(findings[0]!.line, 1) + }) + + it('flags top-level const arrow without export', () => { + const src = `const foo = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + assert.equal(findings[0]!.name, 'foo') + }) + + it('flags export const function expression', () => { + const src = `export const foo = function () { return 42 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + assert.equal(findings[0]!.name, 'foo') + }) + + it('flags export const async arrow', () => { + const src = `export const foo = async () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + }) + + it('flags export const generator function expression', () => { + const src = `export const foo = function* () { yield 1 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + }) + + it('passes export function declaration', () => { + const src = `export function foo() { return 42 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes indented const arrow (not module-scope)', () => { + const src = `function outer() {\n const inner = () => 42\n return inner\n}\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes const arrow with TS type annotation', () => { + const src = `const foo: () => number = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes export const arrow with TS type annotation', () => { + const src = `export const foo: Handler = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes non-function const', () => { + const src = `export const FOO = 42\nexport const BAR = "string"\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes object literal assignment', () => { + const src = `export const config = { foo: () => 42 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('flags multiple in same file', () => { + const src = `export const a = () => 1\nexport const b = () => 2\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 2) + assert.deepEqual( + findings.map(f => f.name), + ['a', 'b'], + ) + }) +}) + +describe('isExemptPath', () => { + it('exempts dist/', () => { + assert.equal(isExemptPath('/foo/dist/bar.js'), true) + }) + + it('exempts node_modules/', () => { + assert.equal(isExemptPath('/foo/node_modules/bar.js'), true) + }) + + it('exempts _internal/', () => { + assert.equal(isExemptPath('/foo/_internal/bar.mts'), true) + }) + + it('exempts hook own tests', () => { + assert.equal( + isExemptPath( + '/foo/.claude/hooks/fleet/prefer-function-declaration-guard/test/x.mts', + ), + true, + ) + }) + + it('exempts oxlint rule + test fixtures', () => { + assert.equal( + isExemptPath('/foo/.config/oxlint-plugin/rules/prefer-function-declaration.mts'), + true, + ) + assert.equal( + isExemptPath( + '/foo/.config/oxlint-plugin/test/prefer-function-declaration.test.mts', + ), + true, + ) + }) + + it('does not exempt regular source', () => { + assert.equal(isExemptPath('/foo/src/bar.mts'), false) + }) +}) diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/tsconfig.json b/.claude/hooks/fleet/prefer-function-declaration-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/private-name-guard/README.md b/.claude/hooks/fleet/private-name-guard/README.md index 1b4d1a2..3e5cce5 100644 --- a/.claude/hooks/fleet/private-name-guard/README.md +++ b/.claude/hooks/fleet/private-name-guard/README.md @@ -56,7 +56,7 @@ sure that read happens. "hooks": [ { "type": "command", - "command": "node .claude/hooks/private-name-guard/index.mts" + "command": "node .claude/hooks/fleet/private-name-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/readme-fleet-shape-guard/README.md b/.claude/hooks/fleet/readme-fleet-shape-guard/README.md index 20f19c6..5c811fe 100644 --- a/.claude/hooks/fleet/readme-fleet-shape-guard/README.md +++ b/.claude/hooks/fleet/readme-fleet-shape-guard/README.md @@ -32,5 +32,5 @@ The hook fails open on its own bugs (exit 0 + stderr log) so a buggy hook can't ## Related -- `.claude/hooks/no-meta-comments-guard/` — structural template; same `_shared/transcript.mts` bypass pattern. -- `.claude/hooks/plan-location-guard/` — same PreToolUse + bypass shape, blocking on file-path classification. +- `.claude/hooks/fleet/no-meta-comments-guard/` — structural template; same `_shared/transcript.mts` bypass pattern. +- `.claude/hooks/fleet/plan-location-guard/` — same PreToolUse + bypass shape, blocking on file-path classification. diff --git a/.claude/hooks/fleet/release-workflow-guard/README.md b/.claude/hooks/fleet/release-workflow-guard/README.md index 5be9f04..6bfbac7 100644 --- a/.claude/hooks/fleet/release-workflow-guard/README.md +++ b/.claude/hooks/fleet/release-workflow-guard/README.md @@ -68,7 +68,7 @@ a Claude session: "hooks": [ { "type": "command", - "command": "node .claude/hooks/release-workflow-guard/index.mts" + "command": "node .claude/hooks/fleet/release-workflow-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/release-workflow-guard/index.mts b/.claude/hooks/fleet/release-workflow-guard/index.mts index 4c313a9..2d319fc 100644 --- a/.claude/hooks/fleet/release-workflow-guard/index.mts +++ b/.claude/hooks/fleet/release-workflow-guard/index.mts @@ -428,7 +428,7 @@ export function workflowDeclaresDryRunInput( export function resolveSearchRoots(command: string): string[] { // Resolution order: $CLAUDE_PROJECT_DIR (Claude Code sets this when // it remembers to) → derive from this hook script's path (the hook - // lives at <project>/.claude/hooks/release-workflow-guard/index.mts, + // lives at <project>/.claude/hooks/fleet/release-workflow-guard/index.mts, // so go three levels up from __dirname) → $PWD as last resort. // The script-path derivation is the most robust because it doesn't // depend on the runner exporting env vars correctly. @@ -438,7 +438,7 @@ export function resolveSearchRoots(command: string): string[] { // invoked via `node <path>`. Walk up to the repo root. const scriptPath = process.argv[1] if (scriptPath) { - // .claude/hooks/release-workflow-guard/index.mts → ../../../ = repo + // .claude/hooks/fleet/release-workflow-guard/index.mts → ../../../ = repo const candidate = path.resolve(scriptPath, '..', '..', '..', '..') if (existsSync(path.join(candidate, '.github', 'workflows'))) { projectDir = candidate diff --git a/.claude/hooks/fleet/setup-basics-tools/README.md b/.claude/hooks/fleet/setup-basics-tools/README.md index 99cabef..5573fd1 100644 --- a/.claude/hooks/fleet/setup-basics-tools/README.md +++ b/.claude/hooks/fleet/setup-basics-tools/README.md @@ -7,11 +7,11 @@ TruffleHog, Trivy, OpenGrep, and uv. Slim leaf of the ## When to use ```sh -node .claude/hooks/setup-basics-tools/install.mts +node .claude/hooks/fleet/setup-basics-tools/install.mts ``` For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. +`node .claude/hooks/fleet/setup-security-tools/install.mts`. ## What gets installed diff --git a/.claude/hooks/fleet/setup-basics-tools/install.mts b/.claude/hooks/fleet/setup-basics-tools/install.mts index 8aa57da..b170f2a 100644 --- a/.claude/hooks/fleet/setup-basics-tools/install.mts +++ b/.claude/hooks/fleet/setup-basics-tools/install.mts @@ -4,9 +4,9 @@ * TruffleHog (secrets scanner), Trivy (vuln/SBOM scanner), OpenGrep (SAST), * and uv (Python package manager bootstrap). Slim leaf of the * `setup-security-tools` umbrella. Run via: node - * .claude/hooks/setup-basics-tools/install.mts For the full setup (firewall + + * .claude/hooks/fleet/setup-basics-tools/install.mts For the full setup (firewall + * scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. + * .claude/hooks/fleet/setup-security-tools/install.mts`. */ import process from 'node:process' diff --git a/.claude/hooks/fleet/setup-firewall/README.md b/.claude/hooks/fleet/setup-firewall/README.md index 2e09edc..3a7e091 100644 --- a/.claude/hooks/fleet/setup-firewall/README.md +++ b/.claude/hooks/fleet/setup-firewall/README.md @@ -13,10 +13,10 @@ free). Slim leaf of the `setup-security-tools` umbrella. ```sh # Install / verify -node .claude/hooks/setup-firewall/install.mts +node .claude/hooks/fleet/setup-firewall/install.mts # Rotate the API token (re-prompts; overwrites keychain) -node .claude/hooks/setup-firewall/install.mts --rotate +node .claude/hooks/fleet/setup-firewall/install.mts --rotate ``` ## Relationship to setup-security-tools diff --git a/.claude/hooks/fleet/setup-firewall/install.mts b/.claude/hooks/fleet/setup-firewall/install.mts index 2369181..4b26932 100644 --- a/.claude/hooks/fleet/setup-firewall/install.mts +++ b/.claude/hooks/fleet/setup-firewall/install.mts @@ -6,8 +6,8 @@ * AgentShield / zizmor / socket-basics tool installers. The actual installer * code lives in `../setup-security-tools/lib/installers.mts`. This entry * point exists so operators can scope their setup precisely: node - * .claude/hooks/setup-firewall/install.mts For the full setup, use `node - * .claude/hooks/setup-security-tools/install.mts` which sequences this leaf + * .claude/hooks/fleet/setup-firewall/install.mts For the full setup, use `node + * .claude/hooks/fleet/setup-security-tools/install.mts` which sequences this leaf * alongside the others. --rotate is honored here too — re-prompts for * SOCKET_API_KEY and overwrites the OS keychain entry, just like the * umbrella's --rotate path. diff --git a/.claude/hooks/fleet/setup-misc-tools/README.md b/.claude/hooks/fleet/setup-misc-tools/README.md index 287134b..26b4a57 100644 --- a/.claude/hooks/fleet/setup-misc-tools/README.md +++ b/.claude/hooks/fleet/setup-misc-tools/README.md @@ -6,11 +6,11 @@ and **janus**. Slim leaf of the `setup-security-tools` umbrella. ## When to use ```sh -node .claude/hooks/setup-misc-tools/install.mts +node .claude/hooks/fleet/setup-misc-tools/install.mts ``` For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. +`node .claude/hooks/fleet/setup-security-tools/install.mts`. ## What gets installed diff --git a/.claude/hooks/fleet/setup-misc-tools/install.mts b/.claude/hooks/fleet/setup-misc-tools/install.mts index 3a4f524..aaa8d5d 100644 --- a/.claude/hooks/fleet/setup-misc-tools/install.mts +++ b/.claude/hooks/fleet/setup-misc-tools/install.mts @@ -2,9 +2,9 @@ /** * @file Install-only entry point for one-off tools: cdxgen (SBOM), synp * (lockfile interop), and janus. Slim leaf of the `setup-security-tools` - * umbrella. Run via: node .claude/hooks/setup-misc-tools/install.mts For the + * umbrella. Run via: node .claude/hooks/fleet/setup-misc-tools/install.mts For the * full setup (firewall + scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. + * .claude/hooks/fleet/setup-security-tools/install.mts`. */ import process from 'node:process' diff --git a/.claude/hooks/fleet/setup-security-tools/README.md b/.claude/hooks/fleet/setup-security-tools/README.md index 6290815..9159a88 100644 --- a/.claude/hooks/fleet/setup-security-tools/README.md +++ b/.claude/hooks/fleet/setup-security-tools/README.md @@ -67,7 +67,7 @@ resolves it. pnpm run setup ``` -(That's wired in `package.json` to `node .claude/hooks/setup-security-tools/index.mts`.) +(That's wired in `package.json` to `node .claude/hooks/fleet/setup-security-tools/index.mts`.) The script will detect whether you have a `SOCKET_API_KEY` (or the forward-canonical `SOCKET_API_TOKEN` alternative), ask if unsure, @@ -112,7 +112,7 @@ Safe to run multiple times: The hook is self-contained but has three workspace dependencies. To add it to a new Socket repo: -1. Copy `.claude/hooks/setup-security-tools/` and +1. Copy `.claude/hooks/fleet/setup-security-tools/` and `.claude/commands/setup-security-tools.md`. 2. Make sure the consumer repo's catalog (or `dependencies`) provides `@socketsecurity/lib-stable`, `@socketregistry/packageurl-js-stable`, and @@ -120,7 +120,7 @@ add it to a new Socket repo: 3. Make sure `.claude/hooks/` isn't gitignored — add `!/.claude/hooks/` to `.gitignore` if needed. 4. Add a `setup` script to `package.json`: - `"setup": "node .claude/hooks/setup-security-tools/index.mts"`. + `"setup": "node .claude/hooks/fleet/setup-security-tools/index.mts"`. 5. Run `pnpm install` so the hook's workspace deps resolve. ## Troubleshooting diff --git a/.claude/hooks/fleet/setup-security-tools/index.mts b/.claude/hooks/fleet/setup-security-tools/index.mts index 60c6397..1954f2f 100644 --- a/.claude/hooks/fleet/setup-security-tools/index.mts +++ b/.claude/hooks/fleet/setup-security-tools/index.mts @@ -31,7 +31,7 @@ // Output: stderr lines starting with `[setup-security-tools]`. Each // finding ends with the exact remediation command: // -// node .claude/hooks/setup-security-tools/install.mts +// node .claude/hooks/fleet/setup-security-tools/install.mts // // Disabled via `SOCKET_SETUP_SECURITY_TOOLS_DISABLED=1`. // @@ -101,7 +101,7 @@ export function checkEdition(): Finding[] { kind: 'edition-mismatch', message: 'SOCKET_API_KEY is set but the SFW shim is the free build. ' + - 'Run `node .claude/hooks/setup-security-tools/install.mts` to ' + + 'Run `node .claude/hooks/fleet/setup-security-tools/install.mts` to ' + 'switch to sfw-enterprise (org-aware malware scanning + private ' + 'package data).', }, @@ -156,7 +156,7 @@ export async function checkShims(): Promise<Finding[]> { `(manifest rebuild, manual delete, or cache rotation). Every ` + `command through ${broken.length === 1 ? 'that shim' : 'those shims'} ` + `currently fails with "No such file or directory." Run ` + - `\`node .claude/hooks/setup-security-tools/install.mts\` to ` + + `\`node .claude/hooks/fleet/setup-security-tools/install.mts\` to ` + `re-download SFW and rewrite the shims.`, }, ] @@ -222,7 +222,7 @@ export async function checkToken401( message: 'Socket API returned 401 — the configured SOCKET_API_KEY ' + 'is invalid, expired, or lacks the required permissions. ' + - 'Run `node .claude/hooks/setup-security-tools/install.mts ' + + 'Run `node .claude/hooks/fleet/setup-security-tools/install.mts ' + '--rotate` to re-prompt and overwrite the keychain entry.', }, ] diff --git a/.claude/hooks/fleet/setup-security-tools/install.mts b/.claude/hooks/fleet/setup-security-tools/install.mts index cac5d0a..9fa3ef8 100644 --- a/.claude/hooks/fleet/setup-security-tools/install.mts +++ b/.claude/hooks/fleet/setup-security-tools/install.mts @@ -16,8 +16,8 @@ * - Stdin isn't a TTY (`!process.stdin.isTTY`). In those skip cases, the script * falls back to sfw-free (the auth- free SFW build) and continues without * persisting a token. Invocation: node - * .claude/hooks/setup-security-tools/install.mts node - * .claude/hooks/setup-security-tools/install.mts --rotate Flags: --rotate + * .claude/hooks/fleet/setup-security-tools/install.mts node + * .claude/hooks/fleet/setup-security-tools/install.mts --rotate Flags: --rotate * Re-prompt for SOCKET_API_KEY and overwrite the keychain entry, ignoring * env/.env/keychain lookup. Use to rotate a leaked or expired token without * manually clearing the keychain first. --update-token Alias for --rotate. diff --git a/.claude/hooks/fleet/setup-security-tools/lib/installers.mts b/.claude/hooks/fleet/setup-security-tools/lib/installers.mts index 597abab..de1450d 100644 --- a/.claude/hooks/fleet/setup-security-tools/lib/installers.mts +++ b/.claude/hooks/fleet/setup-security-tools/lib/installers.mts @@ -61,7 +61,7 @@ const configSchema = Type.Object({ const __dirname = path.dirname(fileURLToPath(import.meta.url)) // external-tools.json lives one level up at the hook root -// (.claude/hooks/setup-security-tools/external-tools.json) — keep it +// (.claude/hooks/fleet/setup-security-tools/external-tools.json) — keep it // out of `lib/` so it's discoverable as a top-level config file rather // than buried as an implementation detail. Fall back to a sibling path // so an early-installed copy in lib/ still resolves during onboarding. diff --git a/.claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts b/.claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts index c12c52c..1a78051 100644 --- a/.claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts +++ b/.claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts @@ -48,7 +48,7 @@ const BLOCK_END = '# END socket-cli env' export function buildBlockBody(token: string): string { const quoted = shellSingleQuote(token) return `# Token persisted by setup-security-tools install.mts. -# Rotate via: node .claude/hooks/setup-security-tools/install.mts --rotate +# Rotate via: node .claude/hooks/fleet/setup-security-tools/install.mts --rotate # Keychain copy still lives at: security find-generic-password -s socket-cli -a SOCKET_API_KEY # SOCKET_API_KEY is universally supported across Socket tools (CLI, SDK, sfw, # fleet scripts) — one env var covers the whole surface with no fallback chain. diff --git a/.claude/hooks/fleet/setup-security-tools/update.mts b/.claude/hooks/fleet/setup-security-tools/update.mts index 73cf671..09450b9 100644 --- a/.claude/hooks/fleet/setup-security-tools/update.mts +++ b/.claude/hooks/fleet/setup-security-tools/update.mts @@ -18,13 +18,11 @@ import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { safeDelete } from '@socketsecurity/lib-stable/fs' -import { - httpDownload, - httpRequest, -} from '@socketsecurity/lib-stable/http-request' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' +import { httpDownload } from '@socketsecurity/lib-stable/http-request/download' +import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' const logger = getDefaultLogger() @@ -172,12 +170,12 @@ export async function writeConfig(config: Config): Promise<void> { export async function computeSha256(filePath: string): Promise<string> { const content = await fs.readFile(filePath) - return createHash('sha256').update(content).digest('hex') + return crypto.createHash('sha256').update(content).digest('hex') } export async function downloadAndHash(url: string): Promise<string> { const tmpFile = path.join( - tmpdir(), + os.tmpdir(), `security-tools-update-${Date.now()}-${Math.random().toString(36).slice(2)}`, ) try { @@ -491,7 +489,7 @@ export async function updateSfw(config: Config): Promise<UpdateResult[]> { // ── Main ── async function main(): Promise<void> { - logger.log('Checking for security tool updates...') + logger.log('Checking for security tool updates…') logger.log('') const config = readConfig() diff --git a/.claude/hooks/fleet/setup-signing/README.md b/.claude/hooks/fleet/setup-signing/README.md index 7645fd1..a8adf95 100644 --- a/.claude/hooks/fleet/setup-signing/README.md +++ b/.claude/hooks/fleet/setup-signing/README.md @@ -8,9 +8,9 @@ one-time setup mechanical. ## Usage ```sh -node .claude/hooks/setup-signing/install.mts # detect + configure -node .claude/hooks/setup-signing/install.mts --check # report status; exit 0 if configured, 1 if not -node .claude/hooks/setup-signing/install.mts --force # overwrite existing config +node .claude/hooks/fleet/setup-signing/install.mts # detect + configure +node .claude/hooks/fleet/setup-signing/install.mts --check # report status; exit 0 if configured, 1 if not +node .claude/hooks/fleet/setup-signing/install.mts --force # overwrite existing config ``` ## Detection order diff --git a/.claude/hooks/fleet/setup-signing/install.mts b/.claude/hooks/fleet/setup-signing/install.mts index 1ac7f83..fca4a16 100644 --- a/.claude/hooks/fleet/setup-signing/install.mts +++ b/.claude/hooks/fleet/setup-signing/install.mts @@ -7,10 +7,10 @@ * gpg.format` (ssh|openpgp). Paired with the pre-commit signing-config gate * and the pre-push signed-commits enforcement. Without signing set up, those * hooks block commits / pushes; this helper makes the one-time setup - * mechanical. Usage: node .claude/hooks/setup-signing/install.mts node - * .claude/hooks/setup-signing/install.mts --check # report only node - * .claude/hooks/setup-signing/install.mts --force # overwrite existing config - * Auto-detection order (first hit wins): + * mechanical. Usage: node .claude/hooks/fleet/setup-signing/install.mts node + * .claude/hooks/fleet/setup-signing/install.mts --check # report only node + * .claude/hooks/fleet/setup-signing/install.mts --force # overwrite existing + * config Auto-detection order (first hit wins): * * 1. 1Password SSH agent (SOCK at ~/Library/Group Containers/.../agent.sock). If * present + has keys, recommend SSH signing routed through 1Password. diff --git a/.claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md index 71afb43..b6e5bc5 100644 --- a/.claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md +++ b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md @@ -76,7 +76,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/soak-exclude-date-annotation-guard/index.mts" + "command": "node .claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts" } ] } diff --git a/.claude/hooks/fleet/socket-token-minifier-start/README.md b/.claude/hooks/fleet/socket-token-minifier-start/README.md index 91e1767..449ad2a 100644 --- a/.claude/hooks/fleet/socket-token-minifier-start/README.md +++ b/.claude/hooks/fleet/socket-token-minifier-start/README.md @@ -46,7 +46,7 @@ Inserted under `hooks.SessionStart`: "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/socket-token-minifier-start/index.mts", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/socket-token-minifier-start/index.mts", "timeout": 5 } ] diff --git a/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts b/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts new file mode 100644 index 0000000..374bdd0 --- /dev/null +++ b/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts @@ -0,0 +1,36 @@ +/** + * @file Smoke test for socket-token-minifier-start. + * + * SessionStart hook that auto-starts the socket-token-minifier proxy on + * `localhost:7779` and exports `ANTHROPIC_BASE_URL` only after a health + * probe succeeds. Fail-closed: missing proxy means the session uses + * api.anthropic.com directly, never silently routes through a broken + * intermediary. + * + * Smoke contract: hook loads + dispatches without throwing; empty + * payload → exit 0. + */ + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty payload exits 0', async () => { + const result = await runHook({}) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/fleet/squash-history-reminder/README.md b/.claude/hooks/fleet/squash-history-reminder/README.md index 22d6140..f8282f8 100644 --- a/.claude/hooks/fleet/squash-history-reminder/README.md +++ b/.claude/hooks/fleet/squash-history-reminder/README.md @@ -33,4 +33,4 @@ The hook fails open on its own bugs (the catch in `main()`). A buggy hook can ne - `.claude/skills/squashing-history/SKILL.md` — the canonical squash-history skill (does the actual work). - `.claude/skills/cascading-fleet/lib/fleet-repos.json` — the roster + opt-in declarations. -- `.claude/hooks/default-branch-guard/` — sibling hook that enforces `main → master` fallback wherever the default branch is hard-coded. +- `.claude/hooks/fleet/default-branch-guard/` — sibling hook that enforces `main → master` fallback wherever the default branch is hard-coded. diff --git a/.claude/hooks/fleet/workflow-uses-comment-guard/README.md b/.claude/hooks/fleet/workflow-uses-comment-guard/README.md index ea2e247..07c3a5f 100644 --- a/.claude/hooks/fleet/workflow-uses-comment-guard/README.md +++ b/.claude/hooks/fleet/workflow-uses-comment-guard/README.md @@ -66,7 +66,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/workflow-uses-comment-guard/index.mts" + "command": "node .claude/hooks/fleet/workflow-uses-comment-guard/index.mts" } ] } From 925997a91029194048061b739c64b8ac6d72b20b Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:41:08 -0400 Subject: [PATCH 06/17] chore(claude): cascade Claude config + skills from wheelhouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshes the surfaces the fleet cascade syncs alongside hook updates: - `.claude/settings.json` — hook registry / permission tweaks. - `.claude/commands/setup-security-tools.md` — command-doc refresh. - `.claude/skills/cascading-fleet/SKILL.md`, `.claude/skills/guarding-paths/SKILL.md`, `.claude/skills/_shared/path-guard-rule.md` — skill text updates. No new commands or skills; all five are existing surfaces brought forward to the current template revision. --- .claude/commands/setup-security-tools.md | 4 ++-- .claude/skills/_shared/path-guard-rule.md | 4 ++-- .claude/skills/cascading-fleet/SKILL.md | 2 +- .claude/skills/guarding-paths/SKILL.md | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.claude/commands/setup-security-tools.md b/.claude/commands/setup-security-tools.md index 009773c..6f1f739 100644 --- a/.claude/commands/setup-security-tools.md +++ b/.claude/commands/setup-security-tools.md @@ -25,7 +25,7 @@ If they don't, proceed with SFW free mode. Then run: ```bash -node .claude/hooks/setup-security-tools/index.mts +node .claude/hooks/fleet/setup-security-tools/index.mts ``` After the script completes, add the SFW shim directory to PATH: @@ -42,4 +42,4 @@ export PATH="$HOME/.socket/_wheelhouse/shims:$PATH" - SFW binary is cached via dlx at `~/.socket/_dlx/` - SFW shims are shared across repos at `~/.socket/_wheelhouse/shims/` - `.env.local` must NEVER be committed -- `/update` will check for new versions of these tools via `node .claude/hooks/setup-security-tools/update.mts` +- `/update` will check for new versions of these tools via `node .claude/hooks/fleet/setup-security-tools/update.mts` diff --git a/.claude/skills/_shared/path-guard-rule.md b/.claude/skills/_shared/path-guard-rule.md index 2dae74a..77572b1 100644 --- a/.claude/skills/_shared/path-guard-rule.md +++ b/.claude/skills/_shared/path-guard-rule.md @@ -7,7 +7,7 @@ This file is the source of truth for the rule's wording. Three artifacts embed (or paraphrase) it: 1. CLAUDE.md — every Socket repo's instructions to Claude. - 2. .claude/hooks/path-guard/README.md — what the hook blocks. + 2. .claude/hooks/fleet/path-guard/README.md — what the hook blocks. 3. .claude/skills/guarding-paths/SKILL.md — what the skill enforces. If the wording changes here, re-run `node scripts/sync-scaffolding.mts @@ -32,7 +32,7 @@ Code execution takes priority over docs: violations in `.mts`/`.cts`, Makefiles, ### Three-level enforcement -- **Hook** — `.claude/hooks/path-guard/` blocks `Edit`/`Write` calls that would introduce a violation in a `.mts`/`.cts` file. Refusal at edit time stops new duplication from landing. +- **Hook** — `.claude/hooks/fleet/path-guard/` blocks `Edit`/`Write` calls that would introduce a violation in a `.mts`/`.cts` file. Refusal at edit time stops new duplication from landing. - **Gate** — `scripts/check-paths.mts` runs in `pnpm check`. Fails the build on any violation that isn't allowlisted. - **Skill** — `/guarding-paths` audits the repo and fixes findings; `/guarding-paths check` reports only; `/guarding-paths install` drops the gate + hook + rule into a fresh repo. diff --git a/.claude/skills/cascading-fleet/SKILL.md b/.claude/skills/cascading-fleet/SKILL.md index 5d8b9aa..590e7fc 100644 --- a/.claude/skills/cascading-fleet/SKILL.md +++ b/.claude/skills/cascading-fleet/SKILL.md @@ -75,6 +75,6 @@ If the wheelhouse template change includes a `@socketsecurity/lib` catalog bump ## Reference -- FLEET_SYNC sentinel: `template/.claude/hooks/no-revert-guard/` + `template/.claude/hooks/overeager-staging-guard/`. +- FLEET_SYNC sentinel: `template/.claude/hooks/fleet/no-revert-guard/` + `template/.claude/hooks/fleet/overeager-staging-guard/`. - Wheelhouse sync-scaffolding: `socket-wheelhouse/scripts/sync-scaffolding/cli.mts`. - Fleet-repo manifest: `lib/fleet-repos.txt`. diff --git a/.claude/skills/guarding-paths/SKILL.md b/.claude/skills/guarding-paths/SKILL.md index 1cf04e3..8e551ee 100644 --- a/.claude/skills/guarding-paths/SKILL.md +++ b/.claude/skills/guarding-paths/SKILL.md @@ -25,10 +25,10 @@ context: fork The strategy lives in three artifacts that ship together: 1. **CLAUDE.md rule**: the mantra and detection rules in plain language. Every fleet repo's CLAUDE.md carries `## 1 path, 1 reference`. Synced from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md). -2. **Hook**: `.claude/hooks/path-guard/index.mts` runs `PreToolUse` on `Edit` / `Write` of `.mts` / `.cts` files. Blocks new violations at edit time. +2. **Hook**: `.claude/hooks/fleet/path-guard/index.mts` runs `PreToolUse` on `Edit` / `Write` of `.mts` / `.cts` files. Blocks new violations at edit time. 3. **Gate**: `scripts/check-paths.mts` runs in `pnpm check` (and CI). Whole-repo scan. Fails the build on any unsanctioned violation. -The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/path-guard/segments.mts`: a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. +The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/fleet/path-guard/segments.mts`: a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. This skill is the **audit-and-fix workflow** that makes a repo conform initially and validates conformance over time. @@ -93,7 +93,7 @@ For Socket repos that don't yet have the gate: 5. Append the rule snippet from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md) to the repo's `CLAUDE.md` if a `1 path, 1 reference` section is missing. 6. Add the hook entry to `.claude/settings.json` `PreToolUse` matcher `Edit|Write`: ```json - { "type": "command", "command": "node .claude/hooks/path-guard/index.mts" } + { "type": "command", "command": "node .claude/hooks/fleet/path-guard/index.mts" } ``` 7. Run the gate against the repo. Triage findings as you would in audit-and-fix mode. From 1757d06c41280c6153cb9035c9a7ded1afa40c30 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:41:23 -0400 Subject: [PATCH 07/17] docs(claude.md/fleet): cascade fleet doc updates from wheelhouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshes `docs/claude.md/fleet/*` to the current wheelhouse template: - 12 existing docs updated in place (bypass-phrases, code-style, commit-signing, drift-watch, gh-token-hygiene, path-hygiene, pull-request-target, security-stack, token-hygiene, tooling, version-bumps, worktree-hygiene). - 10 new docs added (c8-ignore-directives, commit-cadence-format, hook-registry, judgment-and-self-evaluation, no-disable-lint-rule, no-live-network-in-tests, public-surface-hygiene, push-policy, stop-the-bleeding, stranded-cascades). These are CLAUDE.md companion files — every rule mentioned in the root CLAUDE.md's fleet block points at one of these for the full rationale + bypass surface. --- docs/claude.md/fleet/bypass-phrases.md | 6 +- docs/claude.md/fleet/c8-ignore-directives.md | 92 +++++++++++++++ docs/claude.md/fleet/code-style.md | 12 +- docs/claude.md/fleet/commit-cadence-format.md | 109 ++++++++++++++++++ docs/claude.md/fleet/commit-signing.md | 6 +- docs/claude.md/fleet/drift-watch.md | 6 +- docs/claude.md/fleet/gh-token-hygiene.md | 4 +- docs/claude.md/fleet/hook-registry.md | 60 ++++++++++ .../fleet/judgment-and-self-evaluation.md | 74 ++++++++++++ docs/claude.md/fleet/no-disable-lint-rule.md | 80 +++++++++++++ .../fleet/no-live-network-in-tests.md | 76 ++++++++++++ docs/claude.md/fleet/path-hygiene.md | 6 +- .../claude.md/fleet/public-surface-hygiene.md | 42 +++++++ docs/claude.md/fleet/pull-request-target.md | 2 +- docs/claude.md/fleet/push-policy.md | 58 ++++++++++ docs/claude.md/fleet/security-stack.md | 50 ++++---- docs/claude.md/fleet/stop-the-bleeding.md | 27 +++++ docs/claude.md/fleet/stranded-cascades.md | 85 ++++++++++++++ docs/claude.md/fleet/token-hygiene.md | 24 ++-- docs/claude.md/fleet/tooling.md | 6 +- docs/claude.md/fleet/version-bumps.md | 4 +- docs/claude.md/fleet/worktree-hygiene.md | 4 +- 22 files changed, 768 insertions(+), 65 deletions(-) create mode 100644 docs/claude.md/fleet/c8-ignore-directives.md create mode 100644 docs/claude.md/fleet/commit-cadence-format.md create mode 100644 docs/claude.md/fleet/hook-registry.md create mode 100644 docs/claude.md/fleet/judgment-and-self-evaluation.md create mode 100644 docs/claude.md/fleet/no-disable-lint-rule.md create mode 100644 docs/claude.md/fleet/no-live-network-in-tests.md create mode 100644 docs/claude.md/fleet/public-surface-hygiene.md create mode 100644 docs/claude.md/fleet/push-policy.md create mode 100644 docs/claude.md/fleet/stop-the-bleeding.md create mode 100644 docs/claude.md/fleet/stranded-cascades.md diff --git a/docs/claude.md/fleet/bypass-phrases.md b/docs/claude.md/fleet/bypass-phrases.md index dc4beef..1df3cb1 100644 --- a/docs/claude.md/fleet/bypass-phrases.md +++ b/docs/claude.md/fleet/bypass-phrases.md @@ -43,7 +43,7 @@ The bypass policy is enforced at three layers: - **CLAUDE.md** documents the rule (`### Hook bypasses require the canonical phrase`). - **Memory** keeps the assistant honest across sessions even before the hook fires. -- **`.claude/hooks/no-revert-guard/`** is the enforcement: a `PreToolUse(Bash)` hook that scans the proposed command, parses the transcript, and exits 2 with a stderr message naming the phrase the user must type. +- **`.claude/hooks/fleet/no-revert-guard/`** is the enforcement: a `PreToolUse(Bash)` hook that scans the proposed command, parses the transcript, and exits 2 with a stderr message naming the phrase the user must type. The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. Trade-off: a buggy hook silently allows the destructive command. Acceptable because the alternative (hook crash wedges the session) is worse for development velocity. @@ -51,7 +51,7 @@ The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't When introducing a new destructive flag or hook bypass: -1. Add a new entry to the `CHECKS` array in `.claude/hooks/no-revert-guard/index.mts`. Each check is `{ pattern: RegExp, bypassPhrase: string, label: string }`. +1. Add a new entry to the `CHECKS` array in `.claude/hooks/fleet/no-revert-guard/index.mts`. Each check is `{ pattern: RegExp, bypassPhrase: string, label: string }`. 2. Add a row to this reference's table. -3. Add a test case to `.claude/hooks/no-revert-guard/test/index.test.mts` covering both the blocked-without-phrase and allowed-with-phrase paths. +3. Add a test case to `.claude/hooks/fleet/no-revert-guard/test/index.test.mts` covering both the blocked-without-phrase and allowed-with-phrase paths. 4. Cascade via `node socket-wheelhouse/scripts/sync-scaffolding.mts --all --fix` so every fleet repo picks up the change. diff --git a/docs/claude.md/fleet/c8-ignore-directives.md b/docs/claude.md/fleet/c8-ignore-directives.md new file mode 100644 index 0000000..45c4297 --- /dev/null +++ b/docs/claude.md/fleet/c8-ignore-directives.md @@ -0,0 +1,92 @@ +# c8 / v8 coverage ignore directives + +`c8 ignore next N` does not work the way the name implies for multi-line code. Use `c8 ignore start` / `c8 ignore stop` brackets for any body that spans more than one line. + +## The bug + +`/* c8 ignore next */` is documented as "ignore the next statement," but the c8/v8 reporter implementation treats it as "ignore the next **line**." A catch arm whose body spans three lines: + +```ts +} catch { + /* c8 ignore next - rarely throws */ + logger.warn(`unexpected: ${e}`) +} +``` + +…ignores only the `logger.warn(...` line. The closing `}` and any preceding setup statements stay counted as uncovered. `c8 ignore next 3` is no better. It counts physical lines from the directive, so the comment line itself is hop #0, then the next two lines, then the directive's coverage runs out before the body ends. + +This makes the directive functionally useless for the most common case: skipping a `catch` block that logs and returns. The reporter quietly reports the body as uncovered no matter what number you pass to `next`. + +## The fix + +Switch to start/stop brackets, which cover everything between them regardless of line count: + +```ts +/* c8 ignore start - rarely throws; defensive log path */ +} catch { + logger.warn(`unexpected: ${e}`) +} +/* c8 ignore stop */ +``` + +Place `start` on the line **before** the construct, `stop` on the line **after**. The reporter treats every statement between them as ignored, end of story. + +## Where to apply + +Any of these patterns: + +```ts +// Multi-line catch body +} catch (e) { + /* c8 ignore next ... */ // ❌ only ignores logger.warn line + logger.warn(...) + return defaultValue +} + +// Multi-line return after a comment +if (cond) { + /* c8 ignore next ... */ // ❌ comment is line 0, return is line 1 + return undefined +} + +// Multi-line module-init IIFE catch +const x = (() => { + try { return resolve() } catch { + /* c8 ignore next ... */ // ❌ body has setup + return + cleanup() + return undefined + } +})() +``` + +Convert each to: + +```ts +/* c8 ignore start - <reason> */ +} catch (e) { + logger.warn(...) + return defaultValue +} +/* c8 ignore stop */ +``` + +## Single-line uses are fine + +`/* c8 ignore next */` works correctly when the next physical line **is** the entire statement: + +```ts +/* c8 ignore next */ +return undefined +``` + +That one line. No body, no follow-on statements. The directive does what its name says here. The bug only bites when the construct it's meant to ignore spans multiple lines. + +## Real-world impact + +The socket-lib coverage push from 98.9% → 99.15% in one commit came almost entirely from converting 9 files' worth of `c8 ignore next N` directives to start/stop blocks. The "uncovered" lines weren't untested code. They were defensive arms that c8 had been instructed to ignore but the reporter quietly kept counting because the directive form was wrong. + +The bug is in the c8/v8 reporter's directive parser, not in any user code; until upstream fixes it, the fleet rule is **always use start/stop brackets for multi-line bodies, even when `next N` would seem to suffice.** + +## Reason + +> Past incident, 2026-05-24: socket-lib's coverage report showed ~30 "uncovered" lines that all had `c8 ignore next N` directives directly above them. Converting all of them to `c8 ignore start` / `c8 ignore stop` blocks moved coverage from 98.9% → 99.15% with zero test changes. The lines had been correctly marked as untestable defensive arms all along, but the reporter wasn't honoring the directive form. This compound lesson promotes the workaround to a fleet rule. diff --git a/docs/claude.md/fleet/code-style.md b/docs/claude.md/fleet/code-style.md index cd68b09..217d51c 100644 --- a/docs/claude.md/fleet/code-style.md +++ b/docs/claude.md/fleet/code-style.md @@ -64,15 +64,15 @@ Sort alphanumerically (literal byte order, ASCII before letters). Applies to: ob ## Logger -`getDefaultLogger()` from `@socketsecurity/lib-stable/logger` over `console.*` / `process.stderr.write` / `process.stdout.write` (enforced by `.claude/hooks/logger-guard/`). The logger wraps level routing, transcript-safe rendering, and the token-minifier proxy. +`getDefaultLogger()` from `@socketsecurity/lib-stable/logger` over `console.*` / `process.stderr.write` / `process.stdout.write` (enforced by `.claude/hooks/fleet/logger-guard/`). The logger wraps level routing, transcript-safe rendering, and the token-minifier proxy. ## Doc filenames -`lowercase-with-hyphens.md` under `docs/` or `.claude/` (enforced by `.claude/hooks/markdown-filename-guard/`). One canonical form; no spaces, no PascalCase, no underscores. +`lowercase-with-hyphens.md` under `docs/` or `.claude/` (enforced by `.claude/hooks/fleet/markdown-filename-guard/`). One canonical form; no spaces, no PascalCase, no underscores. ## Inline `<script>` defer/async -`<script defer>` and `<script async>` without a `src=` attribute are a spec no-op. The HTML parser ignores the deferral on inline scripts. Wrap the body in a `DOMContentLoaded` listener instead. Enforced by `.claude/hooks/inline-script-defer-guard/` + the `socket/no-inline-defer-async` oxlint rule. Bypass: `Allow inline-defer bypass`. +`<script defer>` and `<script async>` without a `src=` attribute are a spec no-op. The HTML parser ignores the deferral on inline scripts. Wrap the body in a `DOMContentLoaded` listener instead. Enforced by `.claude/hooks/fleet/inline-script-defer-guard/` + the `socket/no-inline-defer-async` oxlint rule. Bypass: `Allow inline-defer bypass`. ## ESLint / Biome config refs @@ -80,7 +80,7 @@ Stale. The fleet runs oxlint / oxfmt. Don't reference `.eslintrc` / `eslint-conf ## `structuredClone` vs JSON round-trip -`structuredClone(x)` is banned for JSON-shaped data. `JSON.parse(JSON.stringify(x))` (or `JSONParse(JSONStringify(x))` from `@socketsecurity/lib/primordials/json`) is 3-5× faster because it skips the full HTML structured-clone algorithm (type tagging, transferable handling, prototype preservation, cycle detection; none of which the JSON subset needs). The common case is "defensive-copy a `JSON.parse`d value to defend against caller mutation". That's purely JSON-shaped by construction. Opt back in per-line with `// oxlint-disable-next-line socket/no-structured-clone-prefer-json -- <reason>` when the value contains `Date` / `Map` / `Set` / `RegExp` / `ArrayBuffer` / typed-array shapes. Enforced edit-time by `.claude/hooks/no-structured-clone-prefer-json-guard/` + the `socket/no-structured-clone-prefer-json` oxlint rule. Bypass: `Allow no-structured-clone-prefer-json bypass`. +`structuredClone(x)` is banned for JSON-shaped data. `JSON.parse(JSON.stringify(x))` (or `JSONParse(JSONStringify(x))` from `@socketsecurity/lib/primordials/json`) is 3-5× faster because it skips the full HTML structured-clone algorithm (type tagging, transferable handling, prototype preservation, cycle detection; none of which the JSON subset needs). The common case is "defensive-copy a `JSON.parse`d value to defend against caller mutation". That's purely JSON-shaped by construction. Opt back in per-line with `// oxlint-disable-next-line socket/no-structured-clone-prefer-json -- <reason>` when the value contains `Date` / `Map` / `Set` / `RegExp` / `ArrayBuffer` / typed-array shapes. Enforced edit-time by `.claude/hooks/fleet/no-structured-clone-prefer-json-guard/` + the `socket/no-structured-clone-prefer-json` oxlint rule. Bypass: `Allow no-structured-clone-prefer-json bypass`. ## Ellipsis character, not three dots @@ -92,11 +92,11 @@ Don't shell out to `which` / `command -v` / `where` to locate a project binary ## Comments: cross-port Lock-step -See [`parser-comments.md`](parser-comments.md) §5–7 for the full Lock-step comment spec (port provenance, byte-identical header block, deviation paragraphs). Enforced edit-time by `.claude/hooks/lock-step-ref-guard/` and CI-gate-time by `scripts/check-lock-step-refs.mts` + `scripts/check-lock-step-header.mts`. Bypass: `Allow lock-step bypass`. +See [`parser-comments.md`](parser-comments.md) §5–7 for the full Lock-step comment spec (port provenance, byte-identical header block, deviation paragraphs). Enforced edit-time by `.claude/hooks/fleet/lock-step-ref-guard/` and CI-gate-time by `scripts/check-lock-step-refs.mts` + `scripts/check-lock-step-header.mts`. Bypass: `Allow lock-step bypass`. ## Pointer comments -`// see X` comments need both a destination and an inline one-line claim of what's at the destination (enforced by `.claude/hooks/pointer-comment-guard/`). "see X" alone forces the reader to chase the link to learn anything; "see X: it does Y" gives the reader Y up front and X for verification. +`// see X` comments need both a destination and an inline one-line claim of what's at the destination (enforced by `.claude/hooks/fleet/pointer-comment-guard/`). "see X" alone forces the reader to chase the link to learn anything; "see X: it does Y" gives the reader Y up front and X for verification. ## `Promise.race` / `Promise.any` in loops diff --git a/docs/claude.md/fleet/commit-cadence-format.md b/docs/claude.md/fleet/commit-cadence-format.md new file mode 100644 index 0000000..5daa722 --- /dev/null +++ b/docs/claude.md/fleet/commit-cadence-format.md @@ -0,0 +1,109 @@ +# Commit cadence & message format + +Companion to the `### Commit cadence & message format` rule in `template/CLAUDE.md`. The inline section gives the headline. This file holds the spec, the cadence rationale, and the bypass surface. + +## Cadence: small chunks, committed often + +Commit early, commit often. Don't sit on 20+ minutes of edits in a dirty worktree. Split the work into the smallest logical chunks and commit each as soon as it's a coherent unit: + +- Passing tests +- No half-finished functions +- A working state for the next collaborator to pick up + +Past incident: a 90-minute session ended with 11 uncommitted file changes spanning three unrelated refactors. Restoring intent took an hour of `git diff` reading. Two small commits would have kept the story legible. + +Pairs with _Don't leave the worktree dirty_ and _Smallest chunks, land ASAP_. Cadence is the input; dirty worktree is what happens when cadence slips; small-chunks is the post-commit shape. + +## Conventional Commits 1.0 + +Every commit message follows the spec at +<https://www.conventionalcommits.org/en/v1.0.0/>. The headline form is: + + <type>[optional scope][!]: <description> + + [optional body] + + [optional footer(s)] + +Where: + +- `type` (required, lowercase), one of: + - `feat`: new feature + - `fix`: bug fix + - `chore`: maintenance, deps, tooling + - `docs`: documentation only + - `style`: formatting, whitespace, no semantic change + - `refactor`: internal restructure, no behavior change + - `perf`: performance improvement + - `test`: test-only change + - `build`: build system / packaging + - `ci`: CI configuration + - `revert`: undoes a prior commit +- `[scope]` (optional): a parenthesized noun describing the affected area (e.g. `(parser)`, `(extension)`, `(lib)`, `(hooks)`) +- `[!]` (optional): flags a breaking change. Either `feat!: ...` or `feat(api)!: ...`. Adding `BREAKING CHANGE:` in the footer is also acceptable but `!` is preferred. +- `: ` (required): colon + space, separates the header from the description +- `<description>` (required): non-empty, lowercase-leading, short imperative summary + +### Valid examples + +- `feat(parser): add ability to parse arrays` +- `fix: array parsing issue when multiple spaces` +- `chore!: drop support for Node 14` +- `refactor(api)!: drop legacy /v1 routes` +- `docs(claude.md): document commit cadence` +- `ci: bump actions/checkout pin` + +### Blocked anti-patterns + +- `update stuff`: no type +- `feat:`: empty description +- `FEAT: parser`: uppercase type +- `feature(parser): X`: `feature` not in the allowed type list +- `feat parser: X`: missing colon +- `WIP` / `fix typo` / `more changes`: no type, vague description + +## No AI attribution + +The fleet forbids AI-attribution markers in commit messages, PR +descriptions, and inline review replies. The patterns blocked by +`commit-message-format-guard` and reminded by `commit-pr-reminder`: + +- `Generated with Claude` / `Generated with Anthropic` (any case) +- `Co-Authored-By: Claude` / `Co-Authored-By:Claude` +- 🤖 robot-emoji tag lines +- `<noreply@anthropic.com>` footer references + +The rule applies at draft time too. Rewrite the message to omit the strings before you run `git commit`. + +## Bypass phrases + +Per the fleet's _Hook bypasses require the canonical phrase_ rule +(`Allow <X> bypass` verbatim in a recent user turn): + +- `Allow commit-format bypass`: for format/type issues. Use when the commit message diverges from the spec on purpose (rare; usually the user is bringing in a fixup or an external patch with a pre-existing message). +- `Allow ai-attribution bypass`: for the AI-attribution check specifically. Use when a commit legitimately documents the forbidden strings (e.g. a CLAUDE.md edit that quotes them as examples, a test fixture, or a release note explaining why they're forbidden). +- Env var `SOCKET_COMMIT_MESSAGE_FORMAT_GUARD_DISABLED=1`: full disable for testing. + +## Operational rules + +- **When adding commits to an OPEN PR**, update the PR title + description to match the new scope: `gh pr edit <num> --title … --body …`. The reviewer should know what's in the PR without scrolling commits. +- **Replying to Cursor Bugbot**: reply on the inline review-comment thread, not as a detached PR comment: `gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies -X POST -f body=…`. +- **Backing out an unpushed commit**: prefer `git reset --soft HEAD~1` (or `git rebase -i HEAD~N`) over `git revert`. Revert commits are for changes already on origin; for local-only commits they just pollute history (enforced by `.claude/hooks/fleet/prefer-rebase-over-revert-guard/`). +- **No empty commits.** Never use `git commit --allow-empty`, `git cherry-pick --allow-empty`, or `--keep-redundant-commits`. Anchor releases on the actual version-bump commit + move the tag forward with `git tag -f vX.Y.Z` instead. Empty commits pollute `git log` and break CHANGELOG generators / `git log -p` / blame. Bypass: `Allow empty-commit bypass` (enforced by `.claude/hooks/fleet/no-empty-commit-guard/`). +- **Commit author**: every commit must use the user's canonical GitHub identity, not a work email or substituted name. Canonical lives in `~/.claude/git-authors.json` (or global git config); `aliases[]` are also accepted (enforced by `.claude/hooks/fleet/commit-author-guard/`). +- **Scan-internal labels stay out of commits**: `B1` / `M9` / `H3` / `L4` codes from `/scanning-quality` / `/scanning-security` reports are scaffolding. Inline the finding text in the commit body instead. Bypass: `Allow scan-label-in-commit bypass` (enforced by `.claude/hooks/fleet/scan-label-in-commit-guard/`). +- **Push policy: push, fall back to PR.** Default to `git push origin <branch>` (typically `main`). On rejection: open a PR via `gh pr create` against the default base. Don't pre-open PRs "to be safe"; don't force-push to recover. Reminder fires when `gh pr create` is invoked without an explicit user directive (enforced by `.claude/hooks/fleet/pr-vs-push-default-reminder/`). Enterprise-ruleset push rejections are unblocked via the repo's `temporarily-doesnt-touch-customers` custom property (`canSkipReviewGate()` in `scripts/_shared/repo-properties.mts`); Stop-time reminder surfaces this when the error pattern fires (enforced by `.claude/hooks/fleet/enterprise-push-property-reminder/`). Full rationale: [`docs/claude.md/fleet/push-policy.md`](push-policy.md). + +## Enforcement surface + +Defense in depth: + +- **Edit-time draft**: `commit-pr-reminder` Stop hook flags AI + attribution in assistant prose. Catches the issue before the + command is run. +- **Commit-time gate**: `commit-message-format-guard` PreToolUse hook + parses `git commit -m`/`--message` and blocks on type, format, or + AI-attribution failure. The last line of defense before history + carries the bad message. + +Two surfaces by design. A draft can sneak past the Stop hook because it only sees the most recent assistant turn. The PreToolUse gate sees every command at commit time. diff --git a/docs/claude.md/fleet/commit-signing.md b/docs/claude.md/fleet/commit-signing.md index d560568..044cfaa 100644 --- a/docs/claude.md/fleet/commit-signing.md +++ b/docs/claude.md/fleet/commit-signing.md @@ -54,9 +54,9 @@ GitHub-side enforcement is the failsafe: it catches pushes that somehow bypassed The setup helper detects available signing methods and configures git in one shot: ```sh -node .claude/hooks/setup-signing/install.mts # detect + configure -node .claude/hooks/setup-signing/install.mts --check # report status (exit 0 if configured, 1 if not) -node .claude/hooks/setup-signing/install.mts --force # overwrite existing config +node .claude/hooks/fleet/setup-signing/install.mts # detect + configure +node .claude/hooks/fleet/setup-signing/install.mts --check # report status (exit 0 if configured, 1 if not) +node .claude/hooks/fleet/setup-signing/install.mts --force # overwrite existing config ``` Detection order (first hit wins): diff --git a/docs/claude.md/fleet/drift-watch.md b/docs/claude.md/fleet/drift-watch.md index 1fd5f50..10b378d 100644 --- a/docs/claude.md/fleet/drift-watch.md +++ b/docs/claude.md/fleet/drift-watch.md @@ -21,7 +21,7 @@ Node release, a pnpm pin). - **`template/CLAUDE.md` fleet block** (between `BEGIN/END FLEET-CANONICAL` markers): must be byte-identical across the fleet. - **`template/.claude/hooks/*`**: same hook code in every repo; diverged hook code is drift. - **`lockstep.json` `pinned_sha` rows**: upstream submodules tracked by socket-btm (lsquic, yoga, etc.). -- **`.gitmodules` `# name-version` annotations** (enforced by `.claude/hooks/gitmodules-comment-guard/`). +- **`.gitmodules` `# name-version` annotations** (enforced by `.claude/hooks/fleet/gitmodules-comment-guard/`). - **pnpm / Node `packageManager` / `engines` fields**: fleet-wide pin; any divergence is drift. ## How to check @@ -62,6 +62,6 @@ sync-scaffolding tool produces this body automatically when run with ## See also -- `.claude/hooks/drift-check-reminder/` -- `.claude/hooks/gitmodules-comment-guard/` +- `.claude/hooks/fleet/drift-check-reminder/` +- `.claude/hooks/fleet/gitmodules-comment-guard/` - `scripts/sync-scaffolding/`: drift detection + auto-fix tooling (canonical in socket-wheelhouse). diff --git a/docs/claude.md/fleet/gh-token-hygiene.md b/docs/claude.md/fleet/gh-token-hygiene.md index df4db66..dc7c90a 100644 --- a/docs/claude.md/fleet/gh-token-hygiene.md +++ b/docs/claude.md/fleet/gh-token-hygiene.md @@ -1,6 +1,6 @@ # gh token hygiene -GitHub CLI auth tokens are the highest-blast-radius credential most developers carry. The Nx Console supply-chain compromise (May 2026) exfiltrated `~/.config/gh/hosts.yml` and used the token against the GitHub API within 74 seconds of malware execution. Three layered defenses, all enforced by `.claude/hooks/gh-token-hygiene-guard/` (the 8h age cap, keychain check, and workflow-scope gate all live in this hook — `auth-rotation-reminder` handles non-gh CLIs like npm/pnpm/gcloud/docker/vault). +GitHub CLI auth tokens are the highest-blast-radius credential most developers carry. The Nx Console supply-chain compromise (May 2026) exfiltrated `~/.config/gh/hosts.yml` and used the token against the GitHub API within 74 seconds of malware execution. Three layered defenses, all enforced by `.claude/hooks/fleet/gh-token-hygiene-guard/` (the 8h age cap, keychain check, and workflow-scope gate all live in this hook — `auth-rotation-reminder` handles non-gh CLIs like npm/pnpm/gcloud/docker/vault). ## 1. Keychain storage only @@ -144,7 +144,7 @@ Three recovery paths, ordered from cleanest to most surgical: 1. **Run the refresh through Claude.** Ask Claude to run `gh auth refresh -h github.com` in a Bash tool call. The hook sees it, pre-stamps, and the next gh call goes through. 2. **Use the hook's `--stamp` CLI mode.** From any shell: ```sh - node ~/.claude/hooks/gh-token-hygiene-guard/index.mts --stamp + node ~/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts --stamp ``` Writes a fresh `Date.now()` to the stamp file. Use this when you've already done `gh auth refresh` externally and don't want to re-run it. 3. **Auto-correction of malformed values.** If the stamp file contains a value less than `1577836800000` (2020-01-01 in ms) — e.g. you accidentally wrote POSIX seconds via `date "+%s" > ~/.claude/gh-token-issued-at` — the hook treats it as malformed on the next read, re-stamps, and proceeds. No manual intervention required; the malformed-value branch is there as a safety net for cases like the seconds-vs-ms confusion (2026-05-28 incident). diff --git a/docs/claude.md/fleet/hook-registry.md b/docs/claude.md/fleet/hook-registry.md new file mode 100644 index 0000000..26daddf --- /dev/null +++ b/docs/claude.md/fleet/hook-registry.md @@ -0,0 +1,60 @@ +# Hook registry + +Companion to the `### Hook registry` section in `CLAUDE.md`. Full enforcement list lives here because the inline form was pushing CLAUDE.md past the 40 KB cap. + +## Layout + +- **`.claude/hooks/fleet/<name>/`** — fleet-canonical hooks. Edited only in `socket-wheelhouse/template/.claude/hooks/fleet/<name>/`; cascade pushes to every fleet repo. Citation gate (`new-hook-claude-md-guard`) requires each hook to have a matching `(enforced by ...)` mention somewhere in CLAUDE.md or the linked fleet docs. +- **`.claude/hooks/repo/<name>/`** — host-repo-only hooks. Live in the downstream repo; exempt from the citation gate. Mirrors `docs/claude.md/repo/` + `scripts/repo/`. +- **`.claude/hooks/fleet/_shared/`** — utilities imported by hooks (`transcript.mts`, `stop-reminder.mts`, `shell-command.mts`, `acorn/`, etc.). Also fleet-canonical. + +## Currently enforced (fleet) + +The fleet hooks each cite their own trigger + bypass surface in their `README.md`. They are: + +- `actionlint-on-workflow-edit` — runs actionlint when `.github/workflows/**` is edited +- `answer-passing-questions-reminder` — surface unanswered transcript questions +- `answer-status-requests-reminder` — surface status pings before silent end-of-turn +- `auth-rotation-reminder` — reminds about expiring keychain tokens +- `avoid-cd-reminder` — keeps `cd` out of Bash, use `{ cwd }` instead +- `broken-hook-detector` — SessionStart probe for sibling hooks with missing imports +- `codex-no-write-guard` — blocks `codex` invocations with write-intent flags +- `comment-tone-reminder` — Stop-time scan for excessive code comments +- `commit-author-guard` — canonical-identity gate on git author email +- `concurrent-cargo-build-guard` — blocks a second `cargo build --release` while one runs +- `enterprise-push-property-reminder` — GitHub enterprise ruleset push-property reminders +- `extension-build-current-guard` — pairs `tools/.../extension/src/**` edits with a build +- `file-size-reminder` — Stop-time scan for source files over the 500-line soft cap +- `inline-script-defer-guard` — blocks `<script>` without `defer`/`async`/`module` +- `judgment-reminder` — perfectionist / direct-imperative / queue-completion nudges +- `no-blind-keychain-read-guard` — blocks Bash reads of platform keychain tokens +- `no-empty-commit-guard` — blocks `--allow-empty` commits without bypass +- `no-external-issue-ref-guard` — blocks `<owner>/<repo>#<num>` from non-SocketDev orgs +- `no-orphaned-staging` — blocks ending a turn with staged-but-uncommitted hunks +- `no-package-json-pnpm-overrides-guard` — keeps overrides in `pnpm-workspace.yaml` +- `no-structured-clone-prefer-json-guard` — `JSON.parse(JSON.stringify(x))` over `structuredClone` +- `no-token-in-dotenv-guard` — blocks raw token writes into `.env*` / `.envrc` +- `node-modules-staging-guard` — blocks staging `node_modules/` into git +- `parallel-agent-edit-guard` — blocks edits to files another agent owns this session +- `path-guard` — blocks multi-stage paths constructed outside `paths.mts` +- `paths-mts-inherit-guard` — sub-package `paths.mts` must `export *` from parent +- `plugin-patch-format-guard` — `# @`-header + plain `diff -u` body for plugin patches +- `pointer-comment-guard` — limits one-line "see X" pointer comments per file +- `pr-vs-push-default-reminder` — direct-push-to-main vs. PR-only-on-rejection nudge +- `prefer-rebase-over-revert-guard` — rebase unpushed commits, don't revert +- `private-name-guard` — blocks private repo / company names in public surface +- `provenance-publish-reminder` — `--staged` provenance lifecycle reminder +- `public-surface-reminder` — Linear refs / private names / external issue refs +- `pull-request-target-guard` — `pull_request_target` + fork-head checkout pattern +- `scan-label-in-commit-guard` — strips Socket scan labels from commit messages +- `socket-token-minifier-start` — auto-starts the token-minifier proxy fail-closed +- `stale-process-sweeper` — Stop-time reaper for orphaned vitest workers +- `sweep-ds-store` — Stop-time `.DS_Store` removal (no bypass) +- `token-guard` — redacts tokens/keys/JWTs in tool output +- `uses-sha-verify-guard` — full-SHA reachability check for `uses:` pins +- `version-bump-order-guard` — version bump → CHANGELOG → tag ordering +- `vitest-include-vs-node-test-guard` — vitest vs node-test runner separation +- `workflow-uses-comment-guard` — SHA-pinned `uses:` lines need `# <tag> (YYYY-MM-DD)` +- `workflow-yaml-multiline-body-guard` — `gh ... --body-file` over inline `--body "..."` + +The set drifts; the citation gate (`new-hook-claude-md-guard`) catches additions that ship without a CLAUDE.md reference. diff --git a/docs/claude.md/fleet/judgment-and-self-evaluation.md b/docs/claude.md/fleet/judgment-and-self-evaluation.md new file mode 100644 index 0000000..e9a4896 --- /dev/null +++ b/docs/claude.md/fleet/judgment-and-self-evaluation.md @@ -0,0 +1,74 @@ +# Judgment & self-evaluation + +The CLAUDE.md `### Judgment & self-evaluation` section is the headline. This file is the full prose, the example scenarios, and the past incidents that motivated each rule. + +## Default to perfectionist + +When you have latitude (no explicit pragmatism signal from the user), default to perfectionist. "Works now" is not the same as "right." Don't offer "do it right" vs "ship fast" as a binary choice menu in your response — pick perfectionist and execute. The hook that nudges you back if you start drafting a tradeoff menu is `.claude/hooks/fleet/perfectionist-reminder/`. + +Exceptions where pragmatism wins: + +- The user explicitly says "quick fix" / "minimal change" / "just patch it." +- The fix needs an off-machine action (release approval, infra change) and the local repair is a temporary stopgap. +- A larger refactor would balloon the diff past what the current PR scope can carry. + +In all three cases, name the exception in the turn summary so the user can redirect. + +## Direct imperatives → execute, don't litigate + +When the user issues a bare command — `use nvm 26.2.0`, `cancel the build`, `do it`, `kill it`, `proceed` — the correct response is the tool call. Not a paragraph weighing trade-offs. Not "Before I do that, let me explain why…" Not analysis-first when the command was unambiguous. + +The failure mode is hedge openers ("That won't help because…", "Let me first…") that delay the action the user already authorized. State the intent in one short sentence at most (`Switching to nvm 26.2.0.`), then run the command. Enforced by `.claude/hooks/fleet/follow-direct-imperative-reminder/`. + +If you genuinely think the command is wrong, say so in one sentence, run it anyway if it's local + reversible, and let the user redirect — don't refuse based on your judgment of their intent. + +## Queue authorization + +When the user authorizes a queue with phrases like "complete each one," "100%," "do them all," "hammer it out": finish every item before stopping. Don't post mid-queue check-ins: + +- "Honest stopping point?" +- "What's next?" +- "Session totals so far…" +- "Should I continue?" + +Those re-litigate intent already given. Continue until the queue is empty or you hit a genuine blocker (a dependency that hasn't published, a credential the agent doesn't hold, a destructive operation that needs explicit confirmation). Enforced by `.claude/hooks/fleet/dont-stop-mid-queue-reminder/`. + +When the user has clearly said "do it" / "yes" / "proceed" in the recent transcript, skip the AskUserQuestion confirmation step — pick the obvious default and execute. Enforced by `.claude/hooks/fleet/ask-suppression-reminder/`. + +## Fix-failed-twice reset + +If a fix fails twice in a row: + +1. Stop trying variations of the same approach. +2. Re-read the failing code top-down — not just the diff you wrote, the whole module. +3. State out loud where your mental model was wrong. +4. Try something fundamentally different (different abstraction, different tool, different control flow). + +Burning a third attempt on the same broken model is the antipattern. + +## Adjacent bug, flag don't fix-silently + +If you spot a bug adjacent to the task — wrong logic in a sibling function, a broken comment, a missed edge case — flag it inline: "I also noticed X — want me to fix it?" Don't silently fix it (the diff balloons past the user's review scope) and don't silently ignore it (the bug stays). The flag-then-ask pattern keeps the user in control. + +## Misconception, name it before executing + +If the user's request is based on a misconception (the file doesn't exist anymore, the function was renamed, the bug they're describing is fixed already), name the misconception in the response before executing anything that depends on it. The execution doesn't happen until the misconception is resolved — otherwise you're building on bad assumptions. + +## Verify rendered output before commit + +For UI / frontend / render-shape changes (`*.html`, `*.css`, `scripts/tour.mts`, any file whose output is visual): + +1. Make the change. +2. Rebuild the artifact. +3. Open / render / preview the output. +4. THEN commit. + +Past pattern: multiple wasted commits per session, each one a "fix" that broke the next rebuild because the previous "fix" was never visually verified. Enforced by `.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/`. + +Type-checking and test suites verify code correctness, not feature correctness. If you can't render-test (no browser available, headless environment), say so explicitly in the turn summary rather than claiming success. + +## Fix warnings when you see them + +Lint warning, type warning, build warning, runtime warning in your reading window — fix it. Don't leave it for "later" or label it "pre-existing" / "unrelated" / "out of scope" — those labels are rationalizations. Enforced by `.claude/hooks/fleet/excuse-detector/`. + +Exception: genuinely large refactor on a small bug; state the trade-off and ask. diff --git a/docs/claude.md/fleet/no-disable-lint-rule.md b/docs/claude.md/fleet/no-disable-lint-rule.md new file mode 100644 index 0000000..0abddc2 --- /dev/null +++ b/docs/claude.md/fleet/no-disable-lint-rule.md @@ -0,0 +1,80 @@ +# Don't disable lint rules + +## The rule + +Lint rules exist to catch real classes of bug or style drift. Adding `"some-rule": "off"` (or `"warn"`) to any of these files weakens the gate **for every file matching that selector**, not just the one violation that triggered the temptation: + +- `.config/oxlintrc.json` +- `.config/oxlintrc.dogfood.json` +- `template/.config/oxlintrc.json` +- `template/.config/oxlintrc.dogfood.json` +- Any `.eslintrc*` or `eslint.config.*` + +The fleet rule: **fix the underlying code**. The lint config is reserved for fleet-wide policy changes; individual call-site exemptions belong in code. + +## What to do instead + +### Single call-site exemption + +When ONE line genuinely needs to violate a rule (e.g. a third-party callback signature forces an unused parameter), use a per-line disable comment with a reason: + +```ts +// oxlint-disable-next-line no-unused-vars -- chrome.tabs API signature +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + // tabId IS unused but the API signature requires the slot +}) +``` + +The reason after `--` is mandatory. `git blame` will surface it to the next reader who wonders why. + +### File-class exemption via override + +When an entire directory needs a rule disabled (e.g. test files don't care about `socket/no-default-export`), use an `overrides` block in the config. ONLY when the rule doesn't apply to that class of file: + +```json +{ + "overrides": [ + { + "files": ["**/test/**", "**/tests/**"], + "rules": { + "socket/no-default-export": "off" + } + } + ] +} +``` + +This is still a weakening, but a SCOPED one with documented justification. Add a comment explaining WHY the rule doesn't apply. + +### Anti-pattern: don't disable globally + +Wrong: + +```json +{ + "rules": { + "socket/export-top-level-functions": "off" + } +} +``` + +This silences the rule for the entire repo. Every future file becomes a potential offender. If the rule doesn't fit your codebase shape, the rule is wrong. Fix the rule (in `.config/oxlint-plugin/rules/`), not the consumer. + +## Bypass + +`Allow disable-lint-rule bypass`: type this verbatim in a recent message before the edit. Use sparingly: + +1. New fleet-wide policy: the maintainer decides a rule should be disabled across all consumers. This is a fleet-level decision, not a per-task one. +2. Genuine override for a file class that the existing config doesn't yet model (e.g. a new directory of vendored code). After bypass, the next step is to update the rule itself OR add a documented overrides block. + +## Why this matters + +Past incident: an autofix wave touched a fleet config file and `prefer-non-capturing-group` was disabled globally to clear the noise. Six months later, an unrelated regex in a security-sensitive parser had a capturing-group bug that would have been caught. The disabled rule was forgotten. No signal to remove it. + +The per-line comment with a reason is the audit trail. Global disables don't have one. + +## Related rules + +- `oxlint-disable-next-line` is allowed only with a `-- <reason>` suffix (enforced by the `no-file-scope-oxlint-disable` rule). +- Bypass phrases follow the canonical `Allow <X> bypass` format; see [`bypass-phrases.md`](./bypass-phrases.md). +- `Fix it, don't defer` (in CLAUDE.md): see a lint error? Fix the code, not the rule. diff --git a/docs/claude.md/fleet/no-live-network-in-tests.md b/docs/claude.md/fleet/no-live-network-in-tests.md new file mode 100644 index 0000000..fb194f6 --- /dev/null +++ b/docs/claude.md/fleet/no-live-network-in-tests.md @@ -0,0 +1,76 @@ +# No live network in tests + +Tests must never open a connection to a third-party server. Live calls are +flaky (a slow or blocked network turns a green suite red), slow (a 15s timeout +beats a 2ms mock), non-deterministic (the remote's data changes under you), and +a privacy/data-exfil surface (a test that talks to `api.anaconda.org` leaks that +the suite ran, and to whom). Mock the HTTP layer instead. + +## The pattern + +Use [`nock`](https://github.com/nock/nock). Disable real connections in +`beforeEach`, stub each endpoint the code under test will hit, and restore in +`afterEach`. The `registry-*.test.mts` suites are the canonical reference: + +```ts +import nock from 'nock' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +describe('cranExists', () => { + beforeEach(() => { + nock.disableNetConnect() + }) + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + }) + + it('resolves an existing package', async () => { + nock('https://cran.r-universe.dev') + .get('/api/packages/ggplot2') + .reply(200, { Version: '3.4.4', versions: ['3.4.4'] }) + + expect(await cranExists('ggplot2')).toEqual({ + exists: true, + latestVersion: '3.4.4', + }) + }) +}) +``` + +A "does it dispatch to the right handler" routing test still needs a stub — the +handler makes the call regardless of what you assert. Stub it with a catch-all +(`nock(host).get(/.*/).reply(200, {})`) so the routing assertion runs offline. + +## Defense in depth + +Three layers enforce this: + +1. **Runtime fail-closed** — the fleet `test/setup.mts` (wired via vitest + `setupFiles`) calls `nock.disableNetConnect()` once, allowing only + `127.0.0.1` / `localhost` (for fixture servers). Any unmocked request throws + `NetConnectNotAllowedError` at run time, so a missing stub fails loudly + instead of silently reaching the internet. +2. **Edit-time hook** — `.claude/hooks/fleet/no-unmocked-network-in-tests-guard/` + blocks a Write/Edit to a `*.test.*` file that calls `httpJson` / `httpText` / + `fetch` / `request` against a non-localhost host with no `nock` reference in + the file. Catches it as you author. +3. **This doc + the CLAUDE.md rule** — the policy itself. + +Skill is docs, hook is edit-time, runtime setup is the gate. Each catches what +the others miss. + +## Bypass + +Genuinely need a live connection (an opt-in integration test gated behind an env +var, a localhost fixture server)? Type `Allow unmocked-network-in-tests bypass` +verbatim. Localhost is always allowed without a bypass. + +## Why this rule exists + +2026-05-27, socket-packageurl-js: the `purlExists` conda and docker dispatch +tests called `api.anaconda.org` and `hub.docker.com` directly — the test comment +read "Network call may succeed or fail." When the network was slow they timed +out at 15s and turned the suite red. The fix was to `nock`-mock the endpoints +like every `registry-*.test.mts` already did. Promoted to a fleet rule so the +next repo doesn't relearn it. diff --git a/docs/claude.md/fleet/path-hygiene.md b/docs/claude.md/fleet/path-hygiene.md index 6a6dfef..f829073 100644 --- a/docs/claude.md/fleet/path-hygiene.md +++ b/docs/claude.md/fleet/path-hygiene.md @@ -6,7 +6,7 @@ A path is constructed exactly once. Everywhere else references the constructed v - **Within a package**: every script imports its own `scripts/paths.mts`. No `path.join('build', mode, …)` outside that module. `paths.mts` is per-package (like `package.json`). Every package that has a `scripts/` dir has its own. - **Across packages**: package B imports package A's `paths.mts` via the workspace `exports` field. Never `path.join(PKG, '..', '<sibling>', 'build', …)`. -- **Sub-packages inherit**: a sub-package's `paths.mts` `export * from '<rel>/paths.mts'` from the nearest ancestor and adds local overrides below the re-export. Don't re-derive `REPO_ROOT` / `CONFIG_DIR` / `NODE_MODULES_CACHE_DIR` (enforced by `.claude/hooks/paths-mts-inherit-guard/`). +- **Sub-packages inherit**: a sub-package's `paths.mts` `export * from '<rel>/paths.mts'` from the nearest ancestor and adds local overrides below the re-export. Don't re-derive `REPO_ROOT` / `CONFIG_DIR` / `NODE_MODULES_CACHE_DIR` (enforced by `.claude/hooks/fleet/paths-mts-inherit-guard/`). - **Not just build paths**: `paths.mts` is for _every_ path the package constructs (config files (`socket-wheelhouse.json`), lockfiles, cache dirs, manifest files). The fleet ships a starter `template/scripts/paths.mts` that exports the common constants + `loadSocketWheelhouseConfig()`. - **Workflows / Dockerfiles / shell** can't `import` TS. Construct once, reference by output / `ENV` / variable. @@ -24,8 +24,8 @@ Each package's `scripts/paths.mts` exports at minimum: | Level | Surface | What it catches | | ----------- | ----------------------------------------------- | ---------------------------------------------------------------------- | -| Edit-time | `.claude/hooks/path-guard/` | Build-path construction outside `paths.mts` | -| Edit-time | `.claude/hooks/paths-mts-inherit-guard/` | Sub-package `paths.mts` that doesn't inherit from the nearest ancestor | +| Edit-time | `.claude/hooks/fleet/path-guard/` | Build-path construction outside `paths.mts` | +| Edit-time | `.claude/hooks/fleet/paths-mts-inherit-guard/` | Sub-package `paths.mts` that doesn't inherit from the nearest ancestor | | Commit-time | `scripts/check-paths.mts` (run by `pnpm check`) | Whole-repo path-hygiene scan | | Audit + fix | `/guarding-paths` skill | Interactive cleanup | diff --git a/docs/claude.md/fleet/public-surface-hygiene.md b/docs/claude.md/fleet/public-surface-hygiene.md new file mode 100644 index 0000000..2e07f77 --- /dev/null +++ b/docs/claude.md/fleet/public-surface-hygiene.md @@ -0,0 +1,42 @@ +# Public-surface hygiene + +The CLAUDE.md `### Public-surface hygiene` section gives the headline invariants. This file is the full ruleset with rationale, hook references, and bypass surface. + +The rules apply even when hooks are not installed. They're invariants, not enforcement-dependent. Enforced by `.claude/hooks/fleet/{private-name-guard,public-surface-reminder,release-workflow-guard}/` and the rules below. + +## Customer / company / internal names + +- **Real customer / company names**: never write one into a commit, PR, issue, comment, or release note. Replace with `Acme Inc` or rewrite the sentence to not need the reference. No enumerated denylist exists; a denylist is itself a leak. +- **Private repos / internal project names**: never mention. Omit the reference entirely. Don't substitute "an internal tool"; the placeholder is a tell. + +## Linear refs + +Never put `SOC-123` / `ENG-456` / Linear URLs in code, comments, or PR text. Linear lives in Linear. + +## Publish / release / build-release workflows + +Never `gh workflow run|dispatch` against publish/release workflows. The user runs them manually. Bypass paths: + +- `gh workflow run -f dry-run=true`: the workflow must declare a `dry-run:` input AND have no force-prod override set. +- `Allow workflow-dispatch bypass: <workflow>` typed verbatim: one phrase authorizes one dispatch. + +`workflow_dispatch.inputs` keys are kebab-case (`dry-run`, `build-mode`); snake_case silently fails the bypass. + +## Workflow YAML rules + +- `uses: <action>@<40-char-sha>` lines need a trailing `# <tag> (YYYY-MM-DD)` comment so we can age-out stale pins (enforced by `.claude/hooks/fleet/workflow-uses-comment-guard/`). +- Workflow `run:` blocks with `gh ... --body "..."` break YAML on multi-line markdown; always `--body-file <path>` (enforced by `.claude/hooks/fleet/workflow-yaml-multiline-body-guard/`; bypass: `Allow workflow-yaml-multiline-body bypass`). +- Edits to `.github/workflows/*.y*ml` auto-lint via local `actionlint` (enforced by `.claude/hooks/fleet/actionlint-on-workflow-edit/`). + +## `pull_request_target` is privileged + +Runs in BASE-repo context with secrets. Never combine it with `actions/checkout` of fork head + a step that executes the checked-out code (enforced by `.claude/hooks/fleet/pull-request-target-guard/`). Full threat model + safer patterns in [`pull-request-target.md`](pull-request-target.md). + +## No external issue/PR refs in commit messages or PR bodies + +GitHub auto-links `<owner>/<repo>#<num>` and `https://github.com/<owner>/<repo>/(issues|pull)/<num>` mentions back to the target issue, spamming the maintainer with `added N commits that reference this issue` events. + +- Only SocketDev-owned refs are allowed (`SocketDev/<repo>#<num>` is fine). +- For upstream maintainer issues, link them in _the PR description prose_ (which doesn't trigger backrefs from commits) or use the `[#1203](https://npmx.dev/...)` link form that omits the `owner/repo#` token. + +Bypass: `Allow external-issue-ref bypass` (enforced by `.claude/hooks/fleet/no-external-issue-ref-guard/`). diff --git a/docs/claude.md/fleet/pull-request-target.md b/docs/claude.md/fleet/pull-request-target.md index e423fb9..c5ac854 100644 --- a/docs/claude.md/fleet/pull-request-target.md +++ b/docs/claude.md/fleet/pull-request-target.md @@ -21,4 +21,4 @@ If you genuinely need `pull_request_target` semantics (e.g. to access a secret-d ## Enforcement -The `.claude/hooks/pull-request-target-guard/` hook scans workflow YAML for the combo and blocks edits that introduce it. The hook is byte-identical across fleet repos; the rule is the contract, the hook is the enforcer. +The `.claude/hooks/fleet/pull-request-target-guard/` hook scans workflow YAML for the combo and blocks edits that introduce it. The hook is byte-identical across fleet repos; the rule is the contract, the hook is the enforcer. diff --git a/docs/claude.md/fleet/push-policy.md b/docs/claude.md/fleet/push-policy.md new file mode 100644 index 0000000..5df6811 --- /dev/null +++ b/docs/claude.md/fleet/push-policy.md @@ -0,0 +1,58 @@ +# Push policy + +## The rule + +Default to `git push origin <branch>` on the current branch (typically `main`). If the push is rejected (branch protection requires a PR, conflicts, signature/identity rejection), open a PR via `gh pr create` against the default base. Don't pre-open PRs "to be safe"; the direct-push happy path is faster for the operator. Don't force-push to recover; resolve the cause (rebase to fix conflicts, fix the commit identity, etc.). + +A reminder fires when `gh pr create` is invoked without an explicit user directive ("PR this", "open a PR"). Enforced by `.claude/hooks/fleet/pr-vs-push-default-reminder/`. + +## Enterprise-ruleset escape hatch + +Some SocketDev repos sit under an enterprise-level ruleset (Socket enterprise → ruleset attached to `refs/heads/main`) that rejects direct pushes with: + +``` +remote: - Required workflow '<name>' is not satisfied +remote: - Changes must be made through a pull request. +``` + +These two rules sit ABOVE per-repo admin permission. Repository-level admins cannot bypass them. Only members of the ruleset's explicit `bypass_actors` list can push around them. + +The fleet has a documented escape hatch: the **`temporarily-doesnt-touch-customers` custom property** on the repo. + +### How it works + +Two repo custom properties gate the cascade's review-skip path: + +- `doesnt-touch-customers`: permanent. Customer-facing surface is zero. Direct push doesn't risk surprising a customer. +- `temporarily-doesnt-touch-customers`: short-lived. Same as above but signals an in-flight remediation window. + +When either is set to the literal string `"true"`, the cascade's `canSkipReviewGate()` check (in `socket-wheelhouse/scripts/_shared/repo-properties.mts`) allows direct push for routine cascade work. Anything else (`"false"`, `"Choose the value"` placeholder, missing entirely, API failure) falls back to "open a PR". + +The strict `=== "true"` match is deliberate. A misconfigured token, transient API blip, or unset placeholder defaults to the safer "open a PR" path rather than silently pushing to main. + +### Operator flow when push is blocked + +1. Push fails with the enterprise-ruleset error pattern above. +2. The `enterprise-push-property-reminder` Stop-hook surfaces the bypass mechanism inline. +3. Operator goes to https://github.com/SocketDev/`<repo>`/settings/properties and flips `temporarily-doesnt-touch-customers`to`true`. +4. Re-run `git push origin main`. It succeeds. +5. After the in-flight remediation window closes, operator flips the property back to `false` (re-engaging the ruleset). + +The bypass is manual (UI flip) on purpose. Automated bypass would defeat the property's role as an attestation that the operator has consciously decided customer-facing risk is zero for this window. + +### Why not just `gh pr merge --admin`? + +Admin-merge is a valid alternative but creates a transient PR + branch that needs cleanup. The property-flip path is cleaner for cascade work where the intent is "this is routine maintenance, no review-gate value would be added." + +For one-off pushes where review-gating IS the right answer, use the PR + admin-merge flow per the cross-repo handoff convention. + +## Reading the hook's reminder + +When the `enterprise-push-property-reminder` hook fires after a failed push, it surfaces: + +- The exact error pattern from the push output +- The property name and the literal value required (`"true"`, not `true`, not `True`) +- A link to this doc and to the repo's properties page +- The current state of the property (queried via `gh api repos/<owner>/<repo>/properties/values`) + +The hook is informational only. It does not modify the property or retry the push. The operator decides whether the bypass is appropriate for the current change set. diff --git a/docs/claude.md/fleet/security-stack.md b/docs/claude.md/fleet/security-stack.md index de99daf..21db28b 100644 --- a/docs/claude.md/fleet/security-stack.md +++ b/docs/claude.md/fleet/security-stack.md @@ -14,37 +14,37 @@ Layered enforcement, with each layer catching what the previous one missed. | Surface | Hook / mechanism | What it blocks | | -------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Socket API token storage | `.claude/hooks/no-token-in-dotenv-guard/` | Write/Edit of any `.env*`/`.envrc` file containing a real token | -| Keychain read invocations | `.claude/hooks/no-blind-keychain-read-guard/` | Bash calls to `security find-*-password`, `secret-tool lookup`, `Get-StoredCredential`, `keyring get` — these surface UI prompts per call and the token is already cached in-process | +| Socket API token storage | `.claude/hooks/fleet/no-token-in-dotenv-guard/` | Write/Edit of any `.env*`/`.envrc` file containing a real token | +| Keychain read invocations | `.claude/hooks/fleet/no-blind-keychain-read-guard/` | Bash calls to `security find-*-password`, `secret-tool lookup`, `Get-StoredCredential`, `keyring get` — these surface UI prompts per call and the token is already cached in-process | | Token detection in commits | `.git-hooks/pre-commit.mts` + `pre-push.mts` | Staged files containing AWS keys, GitHub tokens (`ghp_`/`gho_`/`ghr_`/`ghs_`/`ghu_`/`github_pat_`), Socket API tokens, or any PEM private key (RSA / EC / DSA / OPENSSH / ENCRYPTED / PGP / generic PKCS#8) | -| gh CLI token storage | `.claude/hooks/gh-token-hygiene-guard/` | Bash invocations of `gh` when the token is in the on-disk `~/.config/gh/hosts.yml` — must be `(keyring)` | +| gh CLI token storage | `.claude/hooks/fleet/gh-token-hygiene-guard/` | Bash invocations of `gh` when the token is in the on-disk `~/.config/gh/hosts.yml` — must be `(keyring)` | ## Layer 2: gate access to dangerous capabilities | Capability | Hook | Gate | | -------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gh workflow run` / dispatch | `.claude/hooks/gh-token-hygiene-guard/` | Token must have `workflow` scope (off by default) AND a fresh `Allow workflow-scope bypass` chat phrase AND Touch ID / password auth AND unconsumed grant marker. Single-use: each dispatch consumes the grant. | -| GitHub Actions workflow_dispatch | `.claude/hooks/release-workflow-guard/` | Blocks `gh workflow run`/`dispatch` against publish/release workflows. Bypass: `--dry-run=true` (if workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim | +| `gh workflow run` / dispatch | `.claude/hooks/fleet/gh-token-hygiene-guard/` | Token must have `workflow` scope (off by default) AND a fresh `Allow workflow-scope bypass` chat phrase AND Touch ID / password auth AND unconsumed grant marker. Single-use: each dispatch consumes the grant. | +| GitHub Actions workflow_dispatch | `.claude/hooks/fleet/release-workflow-guard/` | Blocks `gh workflow run`/`dispatch` against publish/release workflows. Bypass: `--dry-run=true` (if workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim | | Pre-existing branch protection | `lint-github-settings.mts` | Audits the default branch's protection on GitHub for `required_signatures`, `required_pull_request_reviews` (≥1 + dismiss_stale_reviews), `allow_force_pushes=false`, `allow_deletions=false`, `enforce_admins=true` | | Commit signing | `.git-hooks/pre-commit.mts` + `.git-hooks/pre-push.mts` | Pre-commit: `commit.gpgsign=true` + `user.signingkey` set. Pre-push: `git log --format='%G?'` excludes `N` and `B` for commits landing on `main`/`master`. | -| Hook bypass attempts | `.claude/hooks/no-revert-guard/` | Blocks `git revert`, `--no-verify`, `DISABLE_PRECOMMIT_*`, `--no-gpg-sign`, force-push — all gated by canonical `Allow X bypass` phrases | +| Hook bypass attempts | `.claude/hooks/fleet/no-revert-guard/` | Blocks `git revert`, `--no-verify`, `DISABLE_PRECOMMIT_*`, `--no-gpg-sign`, force-push — all gated by canonical `Allow X bypass` phrases | ## Layer 3: enforce token lifetime | Token | Mechanism | Window | | -------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| gh CLI token | `.claude/hooks/gh-token-hygiene-guard/` 8-hour age cap | Errors when token >8h since last `gh auth login` or `gh auth refresh`. Self-recovery: `gh auth refresh` is always allowed. | +| gh CLI token | `.claude/hooks/fleet/gh-token-hygiene-guard/` 8-hour age cap | Errors when token >8h since last `gh auth login` or `gh auth refresh`. Self-recovery: `gh auth refresh` is always allowed. | | GitHub Actions `GITHUB_TOKEN` | GitHub-provided | 1 hour per workflow run, scope-limited by the workflow's `permissions:` block | -| Authenticated CLIs (npm, pnpm, gcloud, docker, vault, …) | `.claude/hooks/auth-rotation-reminder/` | Stop-hook periodically logs you out of stale long-lived sessions. `gh` is exempt from auto-logout (would break in-session work); its age check lives in `gh-token-hygiene-guard` instead. | +| Authenticated CLIs (npm, pnpm, gcloud, docker, vault, …) | `.claude/hooks/fleet/auth-rotation-reminder/` | Stop-hook periodically logs you out of stale long-lived sessions. `gh` is exempt from auto-logout (would break in-session work); its age check lives in `gh-token-hygiene-guard` instead. | ## Layer 4: workflow + repo audit | Surface | Hook / scanner | When it fires | | ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GitHub Actions workflow YAML | `.claude/hooks/actionlint-on-workflow-edit/` | PostToolUse after Edit/Write to `.github/workflows/*.y*ml`. Runs `actionlint` (YAML / shell / SHA-pin) + `zizmor` (security: privilege escalation, secret leaks, untrusted-input-in-script, `pull_request_target` misuse) | -| `pull_request_target` misuse | `.claude/hooks/pull-request-target-guard/` | Blocks Edit/Write that creates a `pull_request_target` workflow checking out the fork head + executing the checked-out code in the same job | -| Workflow `uses:` SHA pinning | `.claude/hooks/workflow-uses-comment-guard/` | Every SHA-pinned `uses:` line needs a `# <tag> (YYYY-MM-DD)` comment for staleness tracking | -| Workflow heredoc bodies | `.claude/hooks/workflow-yaml-multiline-body-guard/` | Blocks `gh ... --body "..."` (multi-line markdown breaks YAML) in favor of `--body-file <path>` | +| GitHub Actions workflow YAML | `.claude/hooks/fleet/actionlint-on-workflow-edit/` | PostToolUse after Edit/Write to `.github/workflows/*.y*ml`. Runs `actionlint` (YAML / shell / SHA-pin) + `zizmor` (security: privilege escalation, secret leaks, untrusted-input-in-script, `pull_request_target` misuse) | +| `pull_request_target` misuse | `.claude/hooks/fleet/pull-request-target-guard/` | Blocks Edit/Write that creates a `pull_request_target` workflow checking out the fork head + executing the checked-out code in the same job | +| Workflow `uses:` SHA pinning | `.claude/hooks/fleet/workflow-uses-comment-guard/` | Every SHA-pinned `uses:` line needs a `# <tag> (YYYY-MM-DD)` comment for staleness tracking | +| Workflow heredoc bodies | `.claude/hooks/fleet/workflow-yaml-multiline-body-guard/` | Blocks `gh ... --body "..."` (multi-line markdown breaks YAML) in favor of `--body-file <path>` | | GitHub repo settings | `scripts/lint-github-settings.mts` | Audits visibility, merge settings, branch protection, required apps. Weekly cache-gated; CI doesn't burn API quota | | AgentShield + zizmor | `/scanning-security` skill | A-F graded report on `.claude/` config + workflow YAML. Run after touching `.claude/` or workflows, before releases | @@ -52,13 +52,13 @@ Layered enforcement, with each layer catching what the previous one missed. | Mistake | Hook | What it catches | | -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- | -| Pushing a real customer / company name | `.claude/hooks/private-name-guard/` | Real names in commits / PR text / release notes | -| Linear ticket refs | `.claude/hooks/private-name-guard/` | `SOC-123`, `ENG-456`, Linear URLs in code or PR text | -| External issue refs (auto-link spam) | `.claude/hooks/no-external-issue-ref-guard/` | `<owner>/<repo>#<num>` in commits or PR bodies for non-SocketDev repos | -| Empty commits | `.claude/hooks/no-empty-commit-guard/` | `git commit --allow-empty`, `cherry-pick --allow-empty` | -| `--no-verify` use | `.claude/hooks/no-revert-guard/` | Hook bypass via `--no-verify` without typed bypass phrase | +| Pushing a real customer / company name | `.claude/hooks/fleet/private-name-guard/` | Real names in commits / PR text / release notes | +| Linear ticket refs | `.claude/hooks/fleet/private-name-guard/` | `SOC-123`, `ENG-456`, Linear URLs in code or PR text | +| External issue refs (auto-link spam) | `.claude/hooks/fleet/no-external-issue-ref-guard/` | `<owner>/<repo>#<num>` in commits or PR bodies for non-SocketDev repos | +| Empty commits | `.claude/hooks/fleet/no-empty-commit-guard/` | `git commit --allow-empty`, `cherry-pick --allow-empty` | +| `--no-verify` use | `.claude/hooks/fleet/no-revert-guard/` | Hook bypass via `--no-verify` without typed bypass phrase | | Personal paths in code | `pre-commit.mts` / `pre-push.mts` | `/Users/<name>/`, `/home/<name>/`, `C:\Users\<NAME>\` | -| Cross-repo path imports | `.claude/hooks/cross-repo-guard/` + `scanCrossRepoPaths` | `../<fleet-repo>/` and absolute `/projects/<fleet-repo>/` references | +| Cross-repo path imports | `.claude/hooks/fleet/cross-repo-guard/` + `scanCrossRepoPaths` | `../<fleet-repo>/` and absolute `/projects/<fleet-repo>/` references | ## Setup helpers @@ -66,15 +66,15 @@ One-time helpers that configure the local machine to satisfy the layers above: ```sh # Master umbrella: runs every installer in sequence -node .claude/hooks/setup-security-tools/install.mts -node .claude/hooks/setup-security-tools/install.mts --rotate # rotate API token +node .claude/hooks/fleet/setup-security-tools/install.mts +node .claude/hooks/fleet/setup-security-tools/install.mts --rotate # rotate API token # Scoped leaves -node .claude/hooks/setup-firewall/install.mts # sfw (Socket Firewall) -node .claude/hooks/setup-claude-scanners/install.mts # AgentShield + zizmor -node .claude/hooks/setup-basics-tools/install.mts # TruffleHog + Trivy + OpenGrep + uv -node .claude/hooks/setup-misc-tools/install.mts # cdxgen + synp + janus -node .claude/hooks/setup-signing/install.mts # commit signing (1Password SSH → ~/.ssh → GPG) +node .claude/hooks/fleet/setup-firewall/install.mts # sfw (Socket Firewall) +node .claude/hooks/fleet/setup-claude-scanners/install.mts # AgentShield + zizmor +node .claude/hooks/fleet/setup-basics-tools/install.mts # TruffleHog + Trivy + OpenGrep + uv +node .claude/hooks/fleet/setup-misc-tools/install.mts # cdxgen + synp + janus +node .claude/hooks/fleet/setup-signing/install.mts # commit signing (1Password SSH → ~/.ssh → GPG) ``` ## Post-hoc forensics diff --git a/docs/claude.md/fleet/stop-the-bleeding.md b/docs/claude.md/fleet/stop-the-bleeding.md new file mode 100644 index 0000000..e2f2ea3 --- /dev/null +++ b/docs/claude.md/fleet/stop-the-bleeding.md @@ -0,0 +1,27 @@ +# Stop the bleeding + +Companion to the `### Drift watch` rule in `template/CLAUDE.md`. Drift watch says the newer version is canonical and older repos catch up. This file covers the **order of operations** when a cascaded file is actively _broken_ in a downstream repo — not just stale, but blocking work. + +## The principle + +> When a cascaded fleet file breaks a downstream repo, fix it locally first to unblock, then reconcile upstream and cascade. + +A file that the wheelhouse owns (a hook, a `scripts/*.mts` runner, a CLAUDE.md block) can break in a downstream `socket-*` repo when the repo's copy lags a template change — e.g. an import path the template already migrated but the downstream cascade predates. The breakage often surfaces as a crashing pre-commit hook, so it blocks the very commit you're trying to land. + +Two failure modes to avoid: + +- **Fix only locally** → the canonical template stays broken, and the next cascade re-introduces the breakage (the local fix becomes drift the moment it lands). +- **Fix only upstream** → the current work stays blocked while you do template surgery; worse if the wheelhouse is mid-flight under another agent. + +So do both, in order. + +## Order of operations + +1. **Stop the bleeding (downstream).** Make the smallest local fix that unblocks — typically matching the file to its current template form (the template is canonical per [Drift watch](drift-watch.md)). Commit the downstream work. +2. **Reconcile upstream (wheelhouse), right after.** Apply the canonical fix to `template/` (+ `scripts/sync-scaffolding/manifest.mts` if the file's required-set or path changed). "Right after" means this turn or the next — not a deferred backlog item — _unless_ a concurrent session is editing the same wheelhouse files (see [parallel-claude-sessions](parallel-claude-sessions.md); work in a clean tree, never clobber in-flight edits). +3. **Test it.** Run the affected script / hook in the wheelhouse (or a member with the deps installed) before pushing. +4. **Push to cascade.** Push directly to the wheelhouse `main`; the `template/` change then flows to every fleet repo via `node scripts/sync-scaffolding.mts --all --fix` (or the per-repo `--target` form). Use the `chore(wheelhouse): cascade <fix>` convention from Drift watch. + +## Why a local fix isn't "backward compatibility" + +Stopping the bleeding locally is not maintaining a compat shim (which the fleet forbids). It's a same-shape repair that converges _toward_ the canonical form — the upstream reconciliation in step 2 makes the local fix redundant, not permanent. If your local fix diverges from where the template is heading, you fixed it wrong. diff --git a/docs/claude.md/fleet/stranded-cascades.md b/docs/claude.md/fleet/stranded-cascades.md new file mode 100644 index 0000000..5d440a5 --- /dev/null +++ b/docs/claude.md/fleet/stranded-cascades.md @@ -0,0 +1,85 @@ +# Stranded cascades + +Local-only `chore(wheelhouse): cascade template@<sha>` commits and `chore/wheelhouse-<sha>` worktree branches whose template SHA has been **superseded** on origin. They accumulate when a cascade wave was interrupted (machine crash, push rejection followed by abandonment, parallel-session race) and a later wave pushed past the abandoned attempt. + +A real incident drove this rule: a fleet repo ended up with 4 stranded local cascade commits behind 11 origin commits including a `@socketsecurity/lib → lib-stable` migration. A trivial CLAUDE.md trim couldn't push without resolving ~50 fleet-canonical hook merge conflicts. Auto-cleanup at the start of every cascade wave prevents that state from recurring. + +## How auto-cleanup works + +The wheelhouse cascade runs `socket-wheelhouse/scripts/fleet/cleanup-stranded.mts --target <repo>` against each fleet repo **before** creating that wave's `chore/wheelhouse-<sha>` worktree. Default mode is **fix**: + +- Stranded commits are removed via `git reset --hard origin/<base>`. +- Stranded worktrees are removed via `git worktree remove --force` followed by `git branch -D chore/wheelhouse-<sha>`. + +Pass `--dry-run` to report without acting. Pass `--all` instead of `--target <path>` to sweep every fleet repo from `fleet-repos.json`. + +## No-layering rule + +🚨 **A repo carries at most one in-flight cascade at a time.** When a new cascade wave starts (a fresh `chore(wheelhouse): cascade template@<sha>` is being prepared), any pre-existing local-only cascade commits get discarded, not stacked on top of. + +The shape this rule prevents: a repo accumulates `chore(wheelhouse): cascade template@A`, then `@B`, then `@C` locally without any of them landing on origin. Each successive wave is a strict superset of the prior (template is monotonic on the relevant paths), so layering 3 unpushed cascade commits buys nothing over discarding A + B + landing C. The layered state is also hostile to merge resolution when origin diverges (a parallel session lands its own `@D` to origin). Every conflict has to be resolved against 3 cascade commits instead of 1. + +Same supersession check as below, but the comparison is **`local-commit-N` vs `local-commit-N+1`**. When wave N+1 is being prepared, wave N's local-only commit must already have a strict-ancestor relationship to N+1's template SHA. If it does (the common case where template moves forward), N gets discarded as part of N+1's setup. If it doesn't, the script bails because something unusual is going on. + +The wheelhouse cascade enforces this by running `cleanup-stranded.mts` against the target repo **before** creating wave N+1's worktree. Same call site as the supersession cleanup below, just with the "vs origin" check now extended to "vs the next-wave SHA we're about to use." + +## Safety rails + +Auto-cleanup runs **only** when every local commit ahead of origin satisfies **all four**: + +1. **Subject** matches `chore(wheelhouse): cascade template@<sha40>`. +2. **Author** is `github-actions[bot]` OR an alias in `~/.claude/git-authors.json` (mirrors the `commit-author-guard` trust set). +3. **Supersession**: the local commit's template SHA is a **strict ancestor** of EITHER origin's most recent cascade commit's SHA OR the next-wave SHA being prepared (whichever is the cleanup invocation's reference). Equal SHA means "not stranded, just unpushed"; bail. +4. **File allowlist**: every path the local commit touches is under one of `.claude/`, `.config/`, `.github/`, `.husky/`, `scripts/`, or one of a tightly enumerated set of root files (`CLAUDE.md`, `.editorconfig`, `.gitattributes`, `.gitignore`, `.gitmodules`, `.nvmrc`, `.prettierignore`, `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `tsconfig.json`). + +If **any** check fails for **any** local commit, the script bails the **whole repo**. No partial cleanup, no "skip the bad one and continue." The operator decides. + +The script will refuse to auto-clean if: + +- A non-cascade commit is present locally ahead of origin (real work; never auto-touch). +- A cascade commit was authored by someone outside the trusted email set. +- A cascade commit references a template SHA that is **not** a strict ancestor of origin's current cascade SHA (could be a future template, an unrelated branch, or a forced reset). +- A cascade commit modifies a file outside the cascade-allowlist (e.g. source code under `src/`, vendored deps, test fixtures). +- Origin has no cascade commits at all. There's nothing to prove supersession against. + +## Stranded worktree detection + +Same supersession rule, applied to worktree branches: + +- Branch name matches `chore/wheelhouse-<sha40>` (or the legacy `chore/sync-<sha40>` form during the cutover window; both regexes accepted by `cleanup-stranded.mts`). +- That SHA is a strict ancestor of origin's most recent cascade SHA (so the worktree's intent has already landed via a newer wave). + +Only worktrees that match both conditions are removed. Other worktrees (task branches, PR branches, ad-hoc work) are untouched. + +## Manual invocation + +The script lives at `socket-wheelhouse/scripts/fleet/cleanup-stranded.mts`. You don't normally run it directly (the cascade does that), but it's safe to invoke ad-hoc: + +```bash +# Dry-run against one repo (substitute the actual repo path). +node $PROJECTS/socket-wheelhouse/scripts/fleet/cleanup-stranded.mts \ + --target $PROJECTS/<repo> --dry-run + +# Sweep the whole fleet, reporting only. +node $PROJECTS/socket-wheelhouse/scripts/fleet/cleanup-stranded.mts \ + --all --dry-run + +# Apply the fix. +node $PROJECTS/socket-wheelhouse/scripts/fleet/cleanup-stranded.mts --all +``` + +## Recovery when auto-cleanup bails + +If the script reports `not cleaning up: <reason>`, the repo has at least one local commit that doesn't fit the auto-removable profile. Decide per-case: + +1. **Real work ahead of origin** (e.g. a one-off fix you committed to `main` locally without pushing): push it, or move it to a feature branch (`git switch -c feat/x && git push -u origin feat/x`). Then re-run cleanup. +2. **Cascade commit touching unexpected files**: inspect with `git show <sha>`. If the cascade should have written that path, lift the path into the cascade allowlist (in `scripts/fleet/cleanup-stranded.mts`) and re-run. If the file shouldn't be cascade-touched at all, this is an authoring bug in `sync-scaffolding/manifest.mts`. +3. **Cascade commit from an untrusted author**: usually means another agent / contributor authored it. Validate the commit by hand, then either trust the author (add to `~/.claude/git-authors.json` aliases) or rebase the commit out manually. +4. **Template SHA that's not a strict ancestor**: the local commit may be from a branch of `socket-wheelhouse/template/` that was never merged. Confirm by inspecting the SHA in the wheelhouse history (`git -C $PROJECTS/socket-wheelhouse log <sha>`). If it's orphan / abandoned, `git reset --hard origin/<base>` manually after backing up the SHA in case it's wanted later. + +## What this rule does NOT do + +- It does **not** sync the cascade's actual content. That's `sync-scaffolding/cli.mts`'s job. +- It does **not** push anything. Cleanup only mutates local state. +- It does **not** delete uncommitted working-tree changes. `git reset --hard origin/<base>` does discard tracked-but-uncommitted changes, so the cascade-template flow runs cleanup before any worktree state is at risk; the worktree for the new wave hasn't been created yet. +- It does **not** clean up stranded artifacts in branches other than the repo's default branch. v1.x release branches keep their own cascade history. diff --git a/docs/claude.md/fleet/token-hygiene.md b/docs/claude.md/fleet/token-hygiene.md index 381b1f7..bab1b4e 100644 --- a/docs/claude.md/fleet/token-hygiene.md +++ b/docs/claude.md/fleet/token-hygiene.md @@ -4,37 +4,37 @@ The CLAUDE.md `### Token hygiene` section is the headline rule plus the canonica ## Headline -Never emit the raw value of any secret to tool output, commits, comments, or replies. The `.claude/hooks/token-guard/` `PreToolUse` hook blocks the deterministic patterns (literal token shapes, env dumps, `.env*` reads, unfiltered `curl -H "Authorization:"`, sensitive-name commands without redaction). When the hook blocks a command, rewrite. Don't bypass. +Never emit the raw value of any secret to tool output, commits, comments, or replies. The `.claude/hooks/fleet/token-guard/` `PreToolUse` hook blocks the deterministic patterns (literal token shapes, env dumps, `.env*` reads, unfiltered `curl -H "Authorization:"`, sensitive-name commands without redaction). When the hook blocks a command, rewrite. Don't bypass. Behavior the hook can't catch: redact `token` / `jwt` / `access_token` / `refresh_token` / `api_key` / `secret` / `password` / `authorization` fields when citing API responses. Show key _names_ only when displaying `.env.local`. If a user pastes a secret, treat it as compromised and ask them to rotate. -Full hook spec in [`.claude/hooks/token-guard/README.md`](../../.claude/hooks/token-guard/README.md). +Full hook spec in [`.claude/hooks/fleet/token-guard/README.md`](../../.claude/hooks/fleet/token-guard/README.md). ## Where tokens live -Tokens belong in env vars (CI) or the OS keychain (dev local). Nowhere else. Never in `.env` / `.env.local` / `.envrc` / `~/.sfw.config` / `~/.config/socket/*` / any dotfile. Dotfiles leak via accidental commits, file-indexers, backup clients, shell-history dumps. Enforced by `.claude/hooks/no-token-in-dotenv-guard/`. +Tokens belong in env vars (CI) or the OS keychain (dev local). Nowhere else. Never in `.env` / `.env.local` / `.envrc` / `~/.sfw.config` / `~/.config/socket/*` / any dotfile. Dotfiles leak via accidental commits, file-indexers, backup clients, shell-history dumps. Enforced by `.claude/hooks/fleet/no-token-in-dotenv-guard/`. ## Initial setup + rotation -- **Initial setup:** `node .claude/hooks/setup-security-tools/install.mts` (prompts + persists via macOS Keychain / Linux libsecret / Windows CredentialManager). -- **Rotation:** `node .claude/hooks/setup-security-tools/install.mts --rotate`. TTY-muted prompt, overwrites the keychain entry unconditionally, ignores stale dotfile / env-var lookup. This is the ONLY correct rotator. Suggesting any other path (`socket login`, hand-editing `~/.sfw.config`, `export SOCKET_API_TOKEN=…` in a shell rc) is a token-hygiene violation. +- **Initial setup:** `node .claude/hooks/fleet/setup-security-tools/install.mts` (prompts + persists via macOS Keychain / Linux libsecret / Windows CredentialManager). +- **Rotation:** `node .claude/hooks/fleet/setup-security-tools/install.mts --rotate`. TTY-muted prompt, overwrites the keychain entry unconditionally, ignores stale dotfile / env-var lookup. This is the ONLY correct rotator. Suggesting any other path (`socket login`, hand-editing `~/.sfw.config`, `export SOCKET_API_TOKEN=…` in a shell rc) is a token-hygiene violation. -The Stop-hook flags broken sfw shims, free-vs-enterprise edition drift, and 401-rejection patterns from the last assistant turn (enforced by `.claude/hooks/setup-security-tools/`). +The Stop-hook flags broken sfw shims, free-vs-enterprise edition drift, and 401-rejection patterns from the last assistant turn (enforced by `.claude/hooks/fleet/setup-security-tools/`). ### Scoped install entrypoints Four entrypoints share the umbrella installer library for operators who want partial installs: -- `.claude/hooks/setup-firewall/`: sfw only, `--rotate` honored. -- `.claude/hooks/setup-claude-scanners/`: AgentShield + zizmor. -- `.claude/hooks/setup-basics-tools/`: TruffleHog + Trivy + OpenGrep + uv. -- `.claude/hooks/setup-misc-tools/`: cdxgen + synp + janus. +- `.claude/hooks/fleet/setup-firewall/`: sfw only, `--rotate` honored. +- `.claude/hooks/fleet/setup-claude-scanners/`: AgentShield + zizmor. +- `.claude/hooks/fleet/setup-basics-tools/`: TruffleHog + Trivy + OpenGrep + uv. +- `.claude/hooks/fleet/setup-misc-tools/`: cdxgen + synp + janus. ## Never call platform keychain CLIs from Bash -`security find-generic-password` (macOS), `secret-tool lookup` (Linux), `Get-StoredCredential` (Windows PowerShell), `keyring get` (cross-platform) all surface a UI auth prompt on the user's screen. That prompt fires _per call_, so a hook chain that reads the keychain three times costs three prompts. The token is already cached in process memory after the first resolution (see [`api-token.mts`](../../.claude/hooks/setup-security-tools/lib/api-token.mts) module-scope cache). Read it from `findApiToken()` or `process.env.SOCKET_API_KEY` / `SOCKET_API_TOKEN` instead. +`security find-generic-password` (macOS), `secret-tool lookup` (Linux), `Get-StoredCredential` (Windows PowerShell), `keyring get` (cross-platform) all surface a UI auth prompt on the user's screen. That prompt fires _per call_, so a hook chain that reads the keychain three times costs three prompts. The token is already cached in process memory after the first resolution (see [`api-token.mts`](../../.claude/hooks/fleet/setup-security-tools/lib/api-token.mts) module-scope cache). Read it from `findApiToken()` or `process.env.SOCKET_API_KEY` / `SOCKET_API_TOKEN` instead. -Writes (`security add-generic-password`, `secret-tool store`, `New-StoredCredential`) and deletes are allowed. They happen during operator-driven setup / rotation, never on hot paths. Bypass: `Allow blind-keychain-read bypass` (enforced by `.claude/hooks/no-blind-keychain-read-guard/`). +Writes (`security add-generic-password`, `secret-tool store`, `New-StoredCredential`) and deletes are allowed. They happen during operator-driven setup / rotation, never on hot paths. Bypass: `Allow blind-keychain-read bypass` (enforced by `.claude/hooks/fleet/no-blind-keychain-read-guard/`). ## Personal-path placeholders diff --git a/docs/claude.md/fleet/tooling.md b/docs/claude.md/fleet/tooling.md index e1f174f..6d6f753 100644 --- a/docs/claude.md/fleet/tooling.md +++ b/docs/claude.md/fleet/tooling.md @@ -16,11 +16,11 @@ User-facing install commands in fenced code blocks must show the pnpm form first ## New dependencies + soak -Every new dep added to `package.json` runs a Socket-score check at edit time. Low-scoring deps block (enforced by `.claude/hooks/check-new-deps/`). The 7-day `minimumReleaseAge` soak is malware protection. Never add to `pnpm-workspace.yaml` `minimumReleaseAge.exclude[]` (bypass `Allow minimumReleaseAge bypass` for emergency CVE patches; enforced by `.claude/hooks/minimum-release-age-guard/`). +Every new dep added to `package.json` runs a Socket-score check at edit time. Low-scoring deps block (enforced by `.claude/hooks/fleet/check-new-deps/`). The 7-day `minimumReleaseAge` soak is malware protection. Never add to `pnpm-workspace.yaml` `minimumReleaseAge.exclude[]` (bypass `Allow minimumReleaseAge bypass` for emergency CVE patches; enforced by `.claude/hooks/fleet/minimum-release-age-guard/`). -Every per-package soak-bypass entry (the `'pkg@1.2.3'` exact-pin form) MUST carry a `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotation as the LAST comment line above the bullet. `published` is the version's npm publish date; `removable` is `published + 7d` so a periodic cleanup can drop entries that no longer need the bypass (enforced by `.claude/hooks/soak-exclude-date-annotation-guard/` at edit time + `scripts/check-soak-exclude-dates.mts` at commit time). +Every per-package soak-bypass entry (the `'pkg@1.2.3'` exact-pin form) MUST carry a `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotation as the LAST comment line above the bullet. `published` is the version's npm publish date; `removable` is `published + 7d` so a periodic cleanup can drop entries that no longer need the bypass (enforced by `.claude/hooks/fleet/soak-exclude-date-annotation-guard/` at edit time + `scripts/check-soak-exclude-dates.mts` at commit time). -Vitest `include` globs must not match `node:test` files. Mismatched runners produce confusing "no test suite found" errors (enforced by `.claude/hooks/vitest-include-vs-node-test-guard/`). +Vitest `include` globs must not match `node:test` files. Mismatched runners produce confusing "no test suite found" errors (enforced by `.claude/hooks/fleet/vitest-include-vs-node-test-guard/`). ## Bundler diff --git a/docs/claude.md/fleet/version-bumps.md b/docs/claude.md/fleet/version-bumps.md index 961aec2..7bcda55 100644 --- a/docs/claude.md/fleet/version-bumps.md +++ b/docs/claude.md/fleet/version-bumps.md @@ -91,6 +91,6 @@ the user runs the publish workflow manually. ## See also -- `.claude/hooks/version-bump-order-guard/`: enforces the bump-at-tip + tag-after-bump ordering. -- `.claude/hooks/release-workflow-guard/`: blocks `gh workflow run` dispatches that aren't dry-run. +- `.claude/hooks/fleet/version-bump-order-guard/`: enforces the bump-at-tip + tag-after-bump ordering. +- `.claude/hooks/fleet/release-workflow-guard/`: blocks `gh workflow run` dispatches that aren't dry-run. - [`immutable-releases.md`](immutable-releases.md): every GitHub Release that lands as a result of this sequence ships immutable (Sigstore release attestation, asset lock, tag protection). The release workflow MUST use the 3-step draft → upload → publish pattern; single-call `gh release create <tag> <files>` is forbidden. diff --git a/docs/claude.md/fleet/worktree-hygiene.md b/docs/claude.md/fleet/worktree-hygiene.md index ad61536..2c23f99 100644 --- a/docs/claude.md/fleet/worktree-hygiene.md +++ b/docs/claude.md/fleet/worktree-hygiene.md @@ -5,8 +5,8 @@ Finish a code change → **commit it**. Don't end a turn with uncommitted edits, ## Rules - **After finishing a logical unit of work, commit it.** Use a Conventional Commits message per the _Commits & PRs_ rule. Never leave the working tree dirty between turns. -- **Surgical staging only.** `git add <specific-file>`, never `-A` / `.` (per the _Parallel Claude sessions_ rule). The dirty-worktree rule is no excuse to sweep in files you didn't touch. `git add -f` is forbidden for paths containing `/node_modules/` or `package-lock.json` under `.claude/hooks/*/` or `.claude/skills/*/`. Past incident: a cascading agent ran `git add -f` on node_modules across 6 fleet repos; recovery needed a force-push (enforced by `.claude/hooks/node-modules-staging-guard/`; bypass: `Allow node-modules-staging bypass`). -- **Stage only when you're about to commit.** Put `git add` and `git commit` on the same line (chained with `&&`) or in the same Bash call. Don't stage as a side-effect of "preparing". Staging belongs at commit time. A turn that ends with staged-but-uncommitted hunks is the failure mode the previous bullet warns against (enforced by `.claude/hooks/no-orphaned-staging/`). +- **Surgical staging only.** `git add <specific-file>`, never `-A` / `.` (per the _Parallel Claude sessions_ rule). The dirty-worktree rule is no excuse to sweep in files you didn't touch. `git add -f` is forbidden for paths containing `/node_modules/` or `package-lock.json` under `.claude/hooks/*/` or `.claude/skills/*/`. Past incident: a cascading agent ran `git add -f` on node_modules across 6 fleet repos; recovery needed a force-push (enforced by `.claude/hooks/fleet/node-modules-staging-guard/`; bypass: `Allow node-modules-staging bypass`). +- **Stage only when you're about to commit.** Put `git add` and `git commit` on the same line (chained with `&&`) or in the same Bash call. Don't stage as a side-effect of "preparing". Staging belongs at commit time. A turn that ends with staged-but-uncommitted hunks is the failure mode the previous bullet warns against (enforced by `.claude/hooks/fleet/no-orphaned-staging/`). - **If you can't commit yet** (mid-refactor, tests failing, waiting on the user), say so in the turn summary. The user needs to know the dirty state is intentional. Silent dirty worktrees are the failure mode. - **`git worktree add` worktrees.** Same rule, sharper. Leave the task-worktree clean (committed + pushed) before `git worktree remove`. Otherwise the removal refuses and the work strands. From e7fb9f63fc91677a5f4dfdfdd25c9636c825e0b7 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:41:38 -0400 Subject: [PATCH 08/17] chore(config): cascade oxlint + oxfmt config from wheelhouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshes the lint/format config surfaces to match the current template: - `.config/oxlintrc.json` + `.config/oxfmtrc.json` — fleet-canonical ignore blocks + rule activations updated. - `.config/oxlint-plugin/rules/{no-inline-defer-async,no-npx-dlx, no-underscore-identifier,prefer-safe-delete}.mts` — fleet rule implementations refreshed. The plugin rules are cascade-canonical sources; downstream repos just receive the byte-identical copy. --- .config/oxfmtrc.json | 3 + .../rules/no-inline-defer-async.mts | 2 +- .config/oxlint-plugin/rules/no-npx-dlx.mts | 2 +- .../rules/no-underscore-identifier.mts | 2 +- .../rules/prefer-safe-delete.mts | 2 +- .config/oxlintrc.json | 102 ++++++++---------- 6 files changed, 54 insertions(+), 59 deletions(-) diff --git a/.config/oxfmtrc.json b/.config/oxfmtrc.json index d307776..75e537f 100644 --- a/.config/oxfmtrc.json +++ b/.config/oxfmtrc.json @@ -56,6 +56,8 @@ "**/.pnpm-store/**", "**/vendor/**", "**/wasm_exec.js", + "**/scripts/plugin-patches/**/*.files/**", + "**/scripts/plugin-patches/**/*.patch", "**/.config/lockstep.schema.json", "**/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs", "**/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", @@ -65,6 +67,7 @@ "**/.config/socket-wheelhouse-schema.json", "**/.config/taze.config.mts", "**/.config/tsconfig.base.json", + "**/.config/vitest.coverage.fleet.config.mts", "**/packages/build-infra/lib/release-checksums/consumer.mts", "**/packages/build-infra/lib/release-checksums/core.mts", "**/packages/build-infra/lib/release-checksums/producer.mts", diff --git a/.config/oxlint-plugin/rules/no-inline-defer-async.mts b/.config/oxlint-plugin/rules/no-inline-defer-async.mts index 156207b..3d326a5 100644 --- a/.config/oxlint-plugin/rules/no-inline-defer-async.mts +++ b/.config/oxlint-plugin/rules/no-inline-defer-async.mts @@ -4,7 +4,7 @@ * immediately. The author intent (wait for DOMContentLoaded) is silently * ignored. Past incident: same shape bit a fleet project twice; rendered * pages went silently broken when the script tried to operate on DOM nodes - * that didn't exist yet. Sibling: `.claude/hooks/inline-script-defer-guard/` + * that didn't exist yet. Sibling: `.claude/hooks/fleet/inline-script-defer-guard/` * catches this at edit time. This lint rule catches it at commit time when * edits happened outside Claude. Detects: string literals (single-quoted, * double-quoted, or template) containing `<script ...defer...>` or `<script diff --git a/.config/oxlint-plugin/rules/no-npx-dlx.mts b/.config/oxlint-plugin/rules/no-npx-dlx.mts index 6e46385..4dce5b1 100644 --- a/.config/oxlint-plugin/rules/no-npx-dlx.mts +++ b/.config/oxlint-plugin/rules/no-npx-dlx.mts @@ -5,7 +5,7 @@ * dlx` — use `pnpm exec <package>` or `pnpm run <script>`. Detects `npx`, * `pnpm dlx`, `pnx` (the pnpm-11 dlx shorthand), and `yarn dlx` in source * string literals — argv slices passed to `spawn()`, shell strings, scripts, - * doc snippets, README examples, etc. The hook at `.claude/hooks/path-guard/` + * doc snippets, README examples, etc. The hook at `.claude/hooks/fleet/path-guard/` * blocks these at the shell layer; this rule catches them at edit / commit * time inside JavaScript / TypeScript source. Autofix: rewrites the literal * in place — `npx foo` → `pnpm exec foo`, `pnpm dlx foo` → `pnpm exec foo`, diff --git a/.config/oxlint-plugin/rules/no-underscore-identifier.mts b/.config/oxlint-plugin/rules/no-underscore-identifier.mts index 593456f..1a04170 100644 --- a/.config/oxlint-plugin/rules/no-underscore-identifier.mts +++ b/.config/oxlint-plugin/rules/no-underscore-identifier.mts @@ -7,7 +7,7 @@ * languages where it has runtime meaning (Python name mangling, Ruby * visibility); in TS the underscore is decorative and adds noise to `git * blame` and IDE autocomplete. Commit-time partner of the edit-time - * `.claude/hooks/no-underscore-identifier-guard/`. Allowed (skipped by this + * `.claude/hooks/fleet/no-underscore-identifier-guard/`. Allowed (skipped by this * rule): * * - Bare `_` as a throwaway (`for (const _ of arr)`, destructuring rest). diff --git a/.config/oxlint-plugin/rules/prefer-safe-delete.mts b/.config/oxlint-plugin/rules/prefer-safe-delete.mts index 38c38cb..67c5bb9 100644 --- a/.config/oxlint-plugin/rules/prefer-safe-delete.mts +++ b/.config/oxlint-plugin/rules/prefer-safe-delete.mts @@ -21,7 +21,7 @@ * - Calls whose result is checked/assigned in a way that depends on fs.rm's * specific throw-on-missing or callback contract. Spawn-based bans (`rm * -rf`, `Remove-Item`) live in a separate hook - * (`.claude/hooks/path-guard/`) — this rule covers the JavaScript side. + * (`.claude/hooks/fleet/path-guard/`) — this rule covers the JavaScript side. */ import { appendImportFixes, summarizeImportTarget } from './_inject-import.mts' diff --git a/.config/oxlintrc.json b/.config/oxlintrc.json index 37860e2..97b4cc4 100644 --- a/.config/oxlintrc.json +++ b/.config/oxlintrc.json @@ -1,55 +1,12 @@ { "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", - "plugins": [ - "typescript", - "unicorn", - "import" - ], - "jsPlugins": [ - "./oxlint-plugin/index.mts" - ], + "plugins": ["typescript", "unicorn", "import"], + "jsPlugins": ["./oxlint-plugin/index.mts"], "categories": { "correctness": "error", "suspicious": "error" }, "rules": { - "eslint/curly": "error", - "eslint/no-await-in-loop": "off", - "eslint/no-console": "off", - "eslint/no-control-regex": "off", - "eslint/no-empty": [ - "error", - { - "allowEmptyCatch": true - } - ], - "eslint/no-new": "error", - "eslint/no-proto": "error", - "eslint/no-shadow": "error", - "eslint/no-underscore-dangle": "off", - "eslint/no-unmodified-loop-condition": "off", - "eslint/no-unused-vars": [ - "error", - { - "args": "all", - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^$", - "caughtErrors": "all", - "caughtErrorsIgnorePattern": "^_", - "destructuredArrayIgnorePattern": "^_", - "ignoreRestSiblings": false - } - ], - "eslint/no-useless-catch": "off", - "eslint/no-var": "error", - "eslint/prefer-const": "error", - "eslint/preserve-caught-error": "off", - "eslint/sort-imports": "off", - "import/no-cycle": "off", - "import/no-named-as-default": "off", - "import/no-named-as-default-member": "off", - "import/no-self-import": "error", - "import/no-unassigned-import": "off", "socket/export-top-level-functions": "error", "socket/inclusive-language": "error", "socket/max-file-lines": "error", @@ -81,11 +38,13 @@ "socket/prefer-cached-for-loop": "error", "socket/prefer-ellipsis-char": "error", "socket/prefer-env-as-boolean": "error", + "socket/prefer-error-message": "error", "socket/prefer-exists-sync": "error", "socket/prefer-function-declaration": "error", "socket/prefer-node-builtin-imports": "error", "socket/prefer-node-modules-dot-cache": "error", "socket/prefer-non-capturing-group": "error", + "socket/prefer-pure-call-form": "error", "socket/prefer-safe-delete": "error", "socket/prefer-separate-type-import": "error", "socket/prefer-spawn-over-execsync": "error", @@ -100,12 +59,50 @@ "socket/sort-set-args": "error", "socket/sort-source-methods": "error", "socket/use-fleet-canonical-api-token-getter": "error", + "eslint/curly": "error", + "eslint/no-await-in-loop": "off", + "eslint/no-console": "off", + "eslint/no-control-regex": "off", + "eslint/no-empty": [ + "error", + { + "allowEmptyCatch": true + } + ], + "eslint/no-new": "error", + "eslint/no-underscore-dangle": "off", + "eslint/no-unmodified-loop-condition": "off", + "eslint/no-useless-catch": "off", + "eslint/no-proto": "error", + "eslint/no-shadow": "error", + "eslint/no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^$", + "caughtErrors": "all", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "ignoreRestSiblings": false + } + ], + "eslint/no-var": "error", + "eslint/prefer-const": "error", + "eslint/preserve-caught-error": "off", + "eslint/sort-imports": "off", + "import/no-cycle": "off", + "import/no-named-as-default": "off", + "import/no-named-as-default-member": "off", + "import/no-self-import": "error", + "import/no-unassigned-import": "off", "typescript/array-type": [ "error", { "default": "array-simple" } ], + "typescript/no-extraneous-class": "off", "typescript/consistent-type-assertions": [ "error", { @@ -117,7 +114,6 @@ "typescript/no-duplicate-type-constituents": "error", "typescript/no-explicit-any": "error", "typescript/no-extra-non-null-assertion": "error", - "typescript/no-extraneous-class": "off", "typescript/no-misused-new": "error", "typescript/no-non-null-asserted-optional-chain": "off", "typescript/no-this-alias": [ @@ -132,10 +128,10 @@ "typescript/triple-slash-reference": "error", "unicorn/consistent-function-scoping": "off", "unicorn/no-array-for-each": "off", - "unicorn/no-array-reverse": "error", "unicorn/no-array-sort": "error", - "unicorn/no-empty-file": "off", "unicorn/no-null": "off", + "unicorn/no-array-reverse": "error", + "unicorn/no-empty-file": "off", "unicorn/no-useless-fallback-in-spread": "off", "unicorn/prefer-node-protocol": "error", "unicorn/prefer-spread": "off" @@ -163,12 +159,7 @@ } }, { - "files": [ - "**/*.ts", - "**/*.tsx", - "**/*.mts", - "**/*.cts" - ], + "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], "rules": { "eslint/no-unused-vars": "off" } @@ -198,6 +189,8 @@ "**/.pnpm-store/**", "**/vendor/**", "**/wasm_exec.js", + "**/scripts/plugin-patches/**/*.files/**", + "**/scripts/plugin-patches/**/*.patch", "**/.config/lockstep.schema.json", "**/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs", "**/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", @@ -251,7 +244,6 @@ "**/scripts/lockstep/scan.mts", "**/scripts/lockstep/schema.mts", "**/scripts/lockstep/types.mts", - "**/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs", "**/scripts/power-state.mts", "**/scripts/publish-release.mts", "**/scripts/publish-shared.mts", From 4db58578ff92e55ad3e322201ee43b07f61e3bd7 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:41:58 -0400 Subject: [PATCH 09/17] chore(scripts): cascade fleet script refreshes from wheelhouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small refreshes to existing fleet-canonical scripts: - `scripts/check-paths/exempt.mts` — exempt-list updates. - `scripts/check-soak-exclude-dates.mts` — soak-bypass checker refresh. - `scripts/install-claude-plugins.mts` — plugin installer refresh. - `scripts/janus.mts` — janus runner refresh. - `scripts/test/check-lock-step-refs.test.mts` — test refresh. No new scripts; all five are existing cascade-canonical surfaces. --- scripts/check-paths/exempt.mts | 13 ++++++++++--- scripts/check-soak-exclude-dates.mts | 2 +- scripts/install-claude-plugins.mts | 2 +- scripts/janus.mts | 2 +- scripts/test/check-lock-step-refs.test.mts | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/check-paths/exempt.mts b/scripts/check-paths/exempt.mts index 3e755f3..0a39fad 100644 --- a/scripts/check-paths/exempt.mts +++ b/scripts/check-paths/exempt.mts @@ -3,10 +3,15 @@ * legitimately enumerate path segments — the canonical constructors * (`paths.mts`), build-infra helpers, and the scanners / hooks that READ the * segment vocabulary in order to flag everyone else. Pure data + predicate; - * no I/O. + * no I/O. Paths are normalized to forward-slash form before matching so the + * regexes work on Windows too — see [`docs/claude.md/fleet/code-style.md`] + * (cross-platform path matching). */ +import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' + // File-path patterns that legitimately enumerate path segments. +// Match against `normalizePath(filePath)` only — never raw paths. export const EXEMPT_FILE_PATTERNS: RegExp[] = [ // Any paths.mts is the canonical constructor. /(?:^|\/)paths\.(?:cts|js|mts)$/, @@ -22,5 +27,7 @@ export const EXEMPT_FILE_PATTERNS: RegExp[] = [ /\.github\/paths-allowlist\.yml$/, ] -export const isExempt = (filePath: string): boolean => - EXEMPT_FILE_PATTERNS.some(re => re.test(filePath)) +export function isExempt(filePath: string): boolean { + const normalized = normalizePath(filePath) + return EXEMPT_FILE_PATTERNS.some(re => re.test(normalized)) +} diff --git a/scripts/check-soak-exclude-dates.mts b/scripts/check-soak-exclude-dates.mts index 2738fc3..f2f9344 100644 --- a/scripts/check-soak-exclude-dates.mts +++ b/scripts/check-soak-exclude-dates.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * @file Whole-file commit-time gate that mirrors the edit-time - * `.claude/hooks/soak-exclude-date-annotation-guard/`. Scans the repo's + * `.claude/hooks/fleet/soak-exclude-date-annotation-guard/`. Scans the repo's * `pnpm-workspace.yaml` `minimumReleaseAgeExclude:` block and reports any * per-package exact-pin entry missing the canonical `# published: YYYY-MM-DD * | removable: YYYY-MM-DD` annotation. Why the second surface (hook + diff --git a/scripts/install-claude-plugins.mts b/scripts/install-claude-plugins.mts index 97b371e..e16de99 100644 --- a/scripts/install-claude-plugins.mts +++ b/scripts/install-claude-plugins.mts @@ -21,7 +21,7 @@ * keep a dev-source override; let them remove it explicitly. Idempotent — * running twice in a row is a no-op. Designed for `pnpm setup` wiring in * every fleet repo. Pin discipline is enforced by - * `.claude/hooks/marketplace-comment-guard/`: every `plugins[].source.sha` + * `.claude/hooks/fleet/marketplace-comment-guard/`: every `plugins[].source.sha` * in `marketplace.json` must have a row in `.claude-plugin/README.md` with * matching version + sha + ISO date. */ diff --git a/scripts/janus.mts b/scripts/janus.mts index 370bfc2..7e561db 100644 --- a/scripts/janus.mts +++ b/scripts/janus.mts @@ -1,6 +1,6 @@ /** * @file Canonical fleet janus launcher. Forwards argv to the janus binary - * installed by `.claude/hooks/setup-security-tools/` under the shared + * installed by `.claude/hooks/fleet/setup-security-tools/` under the shared * wheelhouse dir * (`~/.socket/_wheelhouse/janus/<version>/<platform-arch>/janus`) so every * fleet member's `pnpm run janus -- <args>` resolves to the same SHA-verified diff --git a/scripts/test/check-lock-step-refs.test.mts b/scripts/test/check-lock-step-refs.test.mts index 241c6c9..f638877 100644 --- a/scripts/test/check-lock-step-refs.test.mts +++ b/scripts/test/check-lock-step-refs.test.mts @@ -4,7 +4,7 @@ // the scan dirs declared in .config/lock-step-refs.json, greps every // canonical `Lock-step (with|from) <Lang>: <path>` comment, and fails // when the path doesn't resolve. Companion edit-time hook is -// .claude/hooks/lock-step-ref-guard/. +// .claude/hooks/fleet/lock-step-ref-guard/. // // Test strategy: build a tmpdir repo with a known set of source files + // a config + (optionally) the target files the refs claim. Spawn the From 67ea6812151bd9c59b5a996ef3685a2043438595 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:42:24 -0400 Subject: [PATCH 10/17] chore(claude-md): cascade CLAUDE.md fleet block + .gitattributes refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the root CLAUDE.md to reference the new `.claude/hooks/fleet/<name>/` paths (matching cc8f2c0's hook migration) and picks up the latest fleet block from socket-wheelhouse template. Adds the parallel-agent guard line, public-surface section expansion, and the bigger acme-* / new-fleet-rule additions that landed in the template since this branch was opened. Companion refreshes: - `.gitattributes` — fleet-canonical block updated. - `.git-hooks/pre-commit.mts` — pre-commit gate refresh. --- .git-hooks/pre-commit.mts | 4 +- .gitattributes | 505 +------------------------------------- CLAUDE.md | 153 +++++------- 3 files changed, 74 insertions(+), 588 deletions(-) diff --git a/.git-hooks/pre-commit.mts b/.git-hooks/pre-commit.mts index 8426189..844b4fd 100644 --- a/.git-hooks/pre-commit.mts +++ b/.git-hooks/pre-commit.mts @@ -76,7 +76,7 @@ const main = (): number => { logger.info(' git config --global commit.gpgsign true') logger.info('') logger.info('If you have not set up commit signing yet, run:') - logger.info(' node .claude/hooks/setup-security-tools/install.mts') + logger.info(' node .claude/hooks/fleet/setup-security-tools/install.mts') logger.info( 'which detects available signing methods (GPG, SSH, 1Password)', ) @@ -89,7 +89,7 @@ const main = (): number => { logger.info(' git config --global user.signingkey <YOUR_KEY_ID>') logger.info('') logger.info('Or run the setup helper for guided configuration:') - logger.info(' node .claude/hooks/setup-security-tools/install.mts') + logger.info(' node .claude/hooks/fleet/setup-security-tools/install.mts') errors++ } if (errors > 0) { diff --git a/.gitattributes b/.gitattributes index 08b9d60..d1ff3ce 100644 --- a/.gitattributes +++ b/.gitattributes @@ -49,502 +49,7 @@ pnpm-lock.yaml -diff .claude/commands/squash-history.md linguist-generated=true .claude/commands/update-coverage.md linguist-generated=true .claude/commands/update-security.md linguist-generated=true -.claude/hooks/_shared/README.md linguist-generated=true -.claude/hooks/_shared/acorn/README.md linguist-generated=true -.claude/hooks/_shared/acorn/acorn-bindgen.cjs linguist-generated=true -.claude/hooks/_shared/acorn/acorn-wasm-sync.mts linguist-generated=true -.claude/hooks/_shared/acorn/acorn.wasm linguist-generated=true -.claude/hooks/_shared/acorn/index.mts linguist-generated=true -.claude/hooks/_shared/fleet-repos.mts linguist-generated=true -.claude/hooks/_shared/foreign-paths.mts linguist-generated=true -.claude/hooks/_shared/hook-env.mts linguist-generated=true -.claude/hooks/_shared/markers.mts linguist-generated=true -.claude/hooks/_shared/payload.mts linguist-generated=true -.claude/hooks/_shared/shell-command.mts linguist-generated=true -.claude/hooks/_shared/stop-reminder.mts linguist-generated=true -.claude/hooks/_shared/test/fleet-repos.test.mts linguist-generated=true -.claude/hooks/_shared/test/foreign-paths.test.mts linguist-generated=true -.claude/hooks/_shared/test/shell-command.test.mts linguist-generated=true -.claude/hooks/_shared/test/transcript.test.mts linguist-generated=true -.claude/hooks/_shared/token-patterns.mts linguist-generated=true -.claude/hooks/_shared/transcript.mts linguist-generated=true -.claude/hooks/_shared/wheelhouse-root.mts linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/README.md linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/index.mts linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/package.json linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/tsconfig.json linguist-generated=true -.claude/hooks/ask-suppression-reminder/README.md linguist-generated=true -.claude/hooks/ask-suppression-reminder/index.mts linguist-generated=true -.claude/hooks/ask-suppression-reminder/package.json linguist-generated=true -.claude/hooks/ask-suppression-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/ask-suppression-reminder/tsconfig.json linguist-generated=true -.claude/hooks/auth-rotation-reminder/README.md linguist-generated=true -.claude/hooks/auth-rotation-reminder/index.mts linguist-generated=true -.claude/hooks/auth-rotation-reminder/package.json linguist-generated=true -.claude/hooks/auth-rotation-reminder/services.mts linguist-generated=true -.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts linguist-generated=true -.claude/hooks/auth-rotation-reminder/tsconfig.json linguist-generated=true -.claude/hooks/check-new-deps/README.md linguist-generated=true -.claude/hooks/check-new-deps/audit.mts linguist-generated=true -.claude/hooks/check-new-deps/index.mts linguist-generated=true -.claude/hooks/check-new-deps/package.json linguist-generated=true -.claude/hooks/check-new-deps/test/extract-deps.test.mts linguist-generated=true -.claude/hooks/check-new-deps/types.mts linguist-generated=true -.claude/hooks/claude-md-section-size-guard/README.md linguist-generated=true -.claude/hooks/claude-md-section-size-guard/index.mts linguist-generated=true -.claude/hooks/claude-md-section-size-guard/package.json linguist-generated=true -.claude/hooks/claude-md-section-size-guard/test/index.test.mts linguist-generated=true -.claude/hooks/claude-md-section-size-guard/tsconfig.json linguist-generated=true -.claude/hooks/claude-md-size-guard/README.md linguist-generated=true -.claude/hooks/claude-md-size-guard/index.mts linguist-generated=true -.claude/hooks/claude-md-size-guard/package.json linguist-generated=true -.claude/hooks/claude-md-size-guard/test/index.test.mts linguist-generated=true -.claude/hooks/claude-md-size-guard/tsconfig.json linguist-generated=true -.claude/hooks/codex-no-write-guard/README.md linguist-generated=true -.claude/hooks/codex-no-write-guard/index.mts linguist-generated=true -.claude/hooks/codex-no-write-guard/package.json linguist-generated=true -.claude/hooks/codex-no-write-guard/test/index.test.mts linguist-generated=true -.claude/hooks/codex-no-write-guard/tsconfig.json linguist-generated=true -.claude/hooks/comment-tone-reminder/README.md linguist-generated=true -.claude/hooks/comment-tone-reminder/index.mts linguist-generated=true -.claude/hooks/comment-tone-reminder/package.json linguist-generated=true -.claude/hooks/comment-tone-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/comment-tone-reminder/tsconfig.json linguist-generated=true -.claude/hooks/commit-author-guard/README.md linguist-generated=true -.claude/hooks/commit-author-guard/index.mts linguist-generated=true -.claude/hooks/commit-author-guard/package.json linguist-generated=true -.claude/hooks/commit-author-guard/test/index.test.mts linguist-generated=true -.claude/hooks/commit-author-guard/tsconfig.json linguist-generated=true -.claude/hooks/commit-message-format-guard/README.md linguist-generated=true -.claude/hooks/commit-message-format-guard/index.mts linguist-generated=true -.claude/hooks/commit-message-format-guard/package.json linguist-generated=true -.claude/hooks/commit-message-format-guard/test/format.test.mts linguist-generated=true -.claude/hooks/commit-message-format-guard/tsconfig.json linguist-generated=true -.claude/hooks/commit-pr-reminder/README.md linguist-generated=true -.claude/hooks/commit-pr-reminder/index.mts linguist-generated=true -.claude/hooks/commit-pr-reminder/package.json linguist-generated=true -.claude/hooks/commit-pr-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/commit-pr-reminder/tsconfig.json linguist-generated=true -.claude/hooks/compound-lessons-reminder/README.md linguist-generated=true -.claude/hooks/compound-lessons-reminder/index.mts linguist-generated=true -.claude/hooks/compound-lessons-reminder/package.json linguist-generated=true -.claude/hooks/compound-lessons-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/compound-lessons-reminder/tsconfig.json linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/README.md linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/index.mts linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/package.json linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/tsconfig.json linguist-generated=true -.claude/hooks/consumer-grep-reminder/README.md linguist-generated=true -.claude/hooks/consumer-grep-reminder/index.mts linguist-generated=true -.claude/hooks/consumer-grep-reminder/package.json linguist-generated=true -.claude/hooks/consumer-grep-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/consumer-grep-reminder/tsconfig.json linguist-generated=true -.claude/hooks/cross-repo-guard/README.md linguist-generated=true -.claude/hooks/cross-repo-guard/index.mts linguist-generated=true -.claude/hooks/cross-repo-guard/package.json linguist-generated=true -.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts linguist-generated=true -.claude/hooks/cross-repo-guard/tsconfig.json linguist-generated=true -.claude/hooks/default-branch-guard/README.md linguist-generated=true -.claude/hooks/default-branch-guard/index.mts linguist-generated=true -.claude/hooks/default-branch-guard/package.json linguist-generated=true -.claude/hooks/default-branch-guard/test/index.test.mts linguist-generated=true -.claude/hooks/default-branch-guard/tsconfig.json linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/README.md linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/index.mts linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/package.json linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json linguist-generated=true -.claude/hooks/dont-blame-user-reminder/README.md linguist-generated=true -.claude/hooks/dont-blame-user-reminder/index.mts linguist-generated=true -.claude/hooks/dont-blame-user-reminder/package.json linguist-generated=true -.claude/hooks/dont-blame-user-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/dont-blame-user-reminder/tsconfig.json linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/README.md linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/index.mts linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/package.json linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json linguist-generated=true -.claude/hooks/drift-check-reminder/README.md linguist-generated=true -.claude/hooks/drift-check-reminder/index.mts linguist-generated=true -.claude/hooks/drift-check-reminder/package.json linguist-generated=true -.claude/hooks/drift-check-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/drift-check-reminder/tsconfig.json linguist-generated=true -.claude/hooks/enterprise-push-property-reminder/README.md linguist-generated=true -.claude/hooks/enterprise-push-property-reminder/index.mts linguist-generated=true -.claude/hooks/enterprise-push-property-reminder/package.json linguist-generated=true -.claude/hooks/enterprise-push-property-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/enterprise-push-property-reminder/tsconfig.json linguist-generated=true -.claude/hooks/error-message-quality-reminder/README.md linguist-generated=true -.claude/hooks/error-message-quality-reminder/index.mts linguist-generated=true -.claude/hooks/error-message-quality-reminder/package.json linguist-generated=true -.claude/hooks/error-message-quality-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/error-message-quality-reminder/tsconfig.json linguist-generated=true -.claude/hooks/excuse-detector/README.md linguist-generated=true -.claude/hooks/excuse-detector/index.mts linguist-generated=true -.claude/hooks/excuse-detector/package.json linguist-generated=true -.claude/hooks/excuse-detector/test/index.test.mts linguist-generated=true -.claude/hooks/excuse-detector/tsconfig.json linguist-generated=true -.claude/hooks/extension-build-current-guard/README.md linguist-generated=true -.claude/hooks/extension-build-current-guard/index.mts linguist-generated=true -.claude/hooks/extension-build-current-guard/package.json linguist-generated=true -.claude/hooks/extension-build-current-guard/test/index.test.mts linguist-generated=true -.claude/hooks/extension-build-current-guard/tsconfig.json linguist-generated=true -.claude/hooks/file-size-reminder/README.md linguist-generated=true -.claude/hooks/file-size-reminder/index.mts linguist-generated=true -.claude/hooks/file-size-reminder/package.json linguist-generated=true -.claude/hooks/file-size-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/file-size-reminder/tsconfig.json linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/README.md linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/index.mts linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/package.json linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/tsconfig.json linguist-generated=true -.claude/hooks/gh-token-hygiene-guard/README.md linguist-generated=true -.claude/hooks/gh-token-hygiene-guard/index.mts linguist-generated=true -.claude/hooks/gh-token-hygiene-guard/package.json linguist-generated=true -.claude/hooks/gh-token-hygiene-guard/test/index.test.mts linguist-generated=true -.claude/hooks/gh-token-hygiene-guard/tsconfig.json linguist-generated=true -.claude/hooks/gitmodules-comment-guard/README.md linguist-generated=true -.claude/hooks/gitmodules-comment-guard/index.mts linguist-generated=true -.claude/hooks/gitmodules-comment-guard/package.json linguist-generated=true -.claude/hooks/gitmodules-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/gitmodules-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/identifying-users-reminder/README.md linguist-generated=true -.claude/hooks/identifying-users-reminder/index.mts linguist-generated=true -.claude/hooks/identifying-users-reminder/package.json linguist-generated=true -.claude/hooks/identifying-users-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/identifying-users-reminder/tsconfig.json linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/README.md linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/index.mts linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/package.json linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/test/index.test.mts linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/tsconfig.json linguist-generated=true -.claude/hooks/inline-script-defer-guard/README.md linguist-generated=true -.claude/hooks/inline-script-defer-guard/index.mts linguist-generated=true -.claude/hooks/inline-script-defer-guard/package.json linguist-generated=true -.claude/hooks/inline-script-defer-guard/test/index.test.mts linguist-generated=true -.claude/hooks/inline-script-defer-guard/tsconfig.json linguist-generated=true -.claude/hooks/judgment-reminder/README.md linguist-generated=true -.claude/hooks/judgment-reminder/index.mts linguist-generated=true -.claude/hooks/judgment-reminder/package.json linguist-generated=true -.claude/hooks/judgment-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/judgment-reminder/tsconfig.json linguist-generated=true -.claude/hooks/lock-step-ref-guard/README.md linguist-generated=true -.claude/hooks/lock-step-ref-guard/index.mts linguist-generated=true -.claude/hooks/lock-step-ref-guard/package.json linguist-generated=true -.claude/hooks/lock-step-ref-guard/test/index.test.mts linguist-generated=true -.claude/hooks/lock-step-ref-guard/tsconfig.json linguist-generated=true -.claude/hooks/logger-guard/README.md linguist-generated=true -.claude/hooks/logger-guard/index.mts linguist-generated=true -.claude/hooks/logger-guard/package.json linguist-generated=true -.claude/hooks/logger-guard/test/logger-guard.test.mts linguist-generated=true -.claude/hooks/logger-guard/tsconfig.json linguist-generated=true -.claude/hooks/markdown-filename-guard/README.md linguist-generated=true -.claude/hooks/markdown-filename-guard/index.mts linguist-generated=true -.claude/hooks/markdown-filename-guard/package.json linguist-generated=true -.claude/hooks/markdown-filename-guard/test/index.test.mts linguist-generated=true -.claude/hooks/markdown-filename-guard/tsconfig.json linguist-generated=true -.claude/hooks/marketplace-comment-guard/README.md linguist-generated=true -.claude/hooks/marketplace-comment-guard/index.mts linguist-generated=true -.claude/hooks/marketplace-comment-guard/package.json linguist-generated=true -.claude/hooks/marketplace-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/marketplace-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/minify-mcp-output/README.md linguist-generated=true -.claude/hooks/minify-mcp-output/index.mts linguist-generated=true -.claude/hooks/minify-mcp-output/package.json linguist-generated=true -.claude/hooks/minify-mcp-output/test/index.test.mts linguist-generated=true -.claude/hooks/minify-mcp-output/tsconfig.json linguist-generated=true -.claude/hooks/minimum-release-age-guard/README.md linguist-generated=true -.claude/hooks/minimum-release-age-guard/index.mts linguist-generated=true -.claude/hooks/minimum-release-age-guard/package.json linguist-generated=true -.claude/hooks/minimum-release-age-guard/test/index.test.mts linguist-generated=true -.claude/hooks/minimum-release-age-guard/tsconfig.json linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/README.md linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/index.mts linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/package.json linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/test/index.test.mts linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/README.md linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/index.mts linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/package.json linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-disable-lint-rule-guard/README.md linguist-generated=true -.claude/hooks/no-disable-lint-rule-guard/index.mts linguist-generated=true -.claude/hooks/no-disable-lint-rule-guard/package.json linguist-generated=true -.claude/hooks/no-disable-lint-rule-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-disable-lint-rule-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-empty-commit-guard/README.md linguist-generated=true -.claude/hooks/no-empty-commit-guard/index.mts linguist-generated=true -.claude/hooks/no-empty-commit-guard/package.json linguist-generated=true -.claude/hooks/no-empty-commit-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-empty-commit-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/README.md linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/index.mts linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/package.json linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/README.md linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/index.mts linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/package.json linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/README.md linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/package.json linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-fleet-fork-guard/README.md linguist-generated=true -.claude/hooks/no-fleet-fork-guard/index.mts linguist-generated=true -.claude/hooks/no-fleet-fork-guard/package.json linguist-generated=true -.claude/hooks/no-fleet-fork-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-fleet-fork-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-meta-comments-guard/README.md linguist-generated=true -.claude/hooks/no-meta-comments-guard/index.mts linguist-generated=true -.claude/hooks/no-meta-comments-guard/package.json linguist-generated=true -.claude/hooks/no-meta-comments-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-meta-comments-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-non-fleet-push-guard/README.md linguist-generated=true -.claude/hooks/no-non-fleet-push-guard/index.mts linguist-generated=true -.claude/hooks/no-non-fleet-push-guard/package.json linguist-generated=true -.claude/hooks/no-non-fleet-push-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-non-fleet-push-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-orphaned-staging/README.md linguist-generated=true -.claude/hooks/no-orphaned-staging/index.mts linguist-generated=true -.claude/hooks/no-orphaned-staging/package.json linguist-generated=true -.claude/hooks/no-orphaned-staging/test/index.test.mts linguist-generated=true -.claude/hooks/no-orphaned-staging/tsconfig.json linguist-generated=true -.claude/hooks/no-package-json-pnpm-overrides-guard/README.md linguist-generated=true -.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts linguist-generated=true -.claude/hooks/no-package-json-pnpm-overrides-guard/package.json linguist-generated=true -.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-revert-guard/README.md linguist-generated=true -.claude/hooks/no-revert-guard/index.mts linguist-generated=true -.claude/hooks/no-revert-guard/package.json linguist-generated=true -.claude/hooks/no-revert-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-revert-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/README.md linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/index.mts linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/package.json linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/README.md linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/index.mts linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/package.json linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/README.md linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/index.mts linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/package.json linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/tsconfig.json linguist-generated=true -.claude/hooks/node-modules-staging-guard/README.md linguist-generated=true -.claude/hooks/node-modules-staging-guard/index.mts linguist-generated=true -.claude/hooks/node-modules-staging-guard/package.json linguist-generated=true -.claude/hooks/node-modules-staging-guard/test/index.test.mts linguist-generated=true -.claude/hooks/node-modules-staging-guard/tsconfig.json linguist-generated=true -.claude/hooks/overeager-staging-guard/index.mts linguist-generated=true -.claude/hooks/overeager-staging-guard/package.json linguist-generated=true -.claude/hooks/overeager-staging-guard/test/index.test.mts linguist-generated=true -.claude/hooks/overeager-staging-guard/tsconfig.json linguist-generated=true -.claude/hooks/parallel-agent-edit-guard/README.md linguist-generated=true -.claude/hooks/parallel-agent-edit-guard/index.mts linguist-generated=true -.claude/hooks/parallel-agent-edit-guard/package.json linguist-generated=true -.claude/hooks/parallel-agent-edit-guard/test/index.test.mts linguist-generated=true -.claude/hooks/parallel-agent-edit-guard/tsconfig.json linguist-generated=true -.claude/hooks/parallel-agent-on-stop-reminder/README.md linguist-generated=true -.claude/hooks/parallel-agent-on-stop-reminder/index.mts linguist-generated=true -.claude/hooks/parallel-agent-on-stop-reminder/package.json linguist-generated=true -.claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/parallel-agent-on-stop-reminder/tsconfig.json linguist-generated=true -.claude/hooks/parallel-agent-staging-guard/README.md linguist-generated=true -.claude/hooks/parallel-agent-staging-guard/index.mts linguist-generated=true -.claude/hooks/parallel-agent-staging-guard/package.json linguist-generated=true -.claude/hooks/parallel-agent-staging-guard/test/index.test.mts linguist-generated=true -.claude/hooks/parallel-agent-staging-guard/tsconfig.json linguist-generated=true -.claude/hooks/path-guard/README.md linguist-generated=true -.claude/hooks/path-guard/index.mts linguist-generated=true -.claude/hooks/path-guard/package.json linguist-generated=true -.claude/hooks/path-guard/segments.mts linguist-generated=true -.claude/hooks/path-guard/test/path-guard.test.mts linguist-generated=true -.claude/hooks/path-guard/tsconfig.json linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/README.md linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/index.mts linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/package.json linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/tsconfig.json linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/README.md linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/index.mts linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/package.json linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/test/index.test.mts linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/tsconfig.json linguist-generated=true -.claude/hooks/perfectionist-reminder/README.md linguist-generated=true -.claude/hooks/perfectionist-reminder/index.mts linguist-generated=true -.claude/hooks/perfectionist-reminder/package.json linguist-generated=true -.claude/hooks/perfectionist-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/perfectionist-reminder/tsconfig.json linguist-generated=true -.claude/hooks/plan-location-guard/README.md linguist-generated=true -.claude/hooks/plan-location-guard/index.mts linguist-generated=true -.claude/hooks/plan-location-guard/package.json linguist-generated=true -.claude/hooks/plan-location-guard/test/index.test.mts linguist-generated=true -.claude/hooks/plan-location-guard/tsconfig.json linguist-generated=true -.claude/hooks/plan-review-reminder/README.md linguist-generated=true -.claude/hooks/plan-review-reminder/index.mts linguist-generated=true -.claude/hooks/plan-review-reminder/package.json linguist-generated=true -.claude/hooks/plan-review-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/plan-review-reminder/tsconfig.json linguist-generated=true -.claude/hooks/plugin-patch-format-guard/README.md linguist-generated=true -.claude/hooks/plugin-patch-format-guard/index.mts linguist-generated=true -.claude/hooks/plugin-patch-format-guard/package.json linguist-generated=true -.claude/hooks/plugin-patch-format-guard/test/index.test.mts linguist-generated=true -.claude/hooks/plugin-patch-format-guard/tsconfig.json linguist-generated=true -.claude/hooks/pointer-comment-guard/README.md linguist-generated=true -.claude/hooks/pointer-comment-guard/index.mts linguist-generated=true -.claude/hooks/pointer-comment-guard/package.json linguist-generated=true -.claude/hooks/pointer-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/pointer-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/README.md linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/index.mts linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/package.json linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/tsconfig.json linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/README.md linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/index.mts linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/package.json linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json linguist-generated=true -.claude/hooks/private-name-guard/README.md linguist-generated=true -.claude/hooks/private-name-guard/index.mts linguist-generated=true -.claude/hooks/private-name-guard/package.json linguist-generated=true -.claude/hooks/private-name-guard/test/private-name-guard.test.mts linguist-generated=true -.claude/hooks/private-name-guard/tsconfig.json linguist-generated=true -.claude/hooks/provenance-publish-reminder/README.md linguist-generated=true -.claude/hooks/provenance-publish-reminder/index.mts linguist-generated=true -.claude/hooks/provenance-publish-reminder/package.json linguist-generated=true -.claude/hooks/provenance-publish-reminder/tsconfig.json linguist-generated=true -.claude/hooks/public-surface-reminder/README.md linguist-generated=true -.claude/hooks/public-surface-reminder/index.mts linguist-generated=true -.claude/hooks/public-surface-reminder/package.json linguist-generated=true -.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts linguist-generated=true -.claude/hooks/public-surface-reminder/tsconfig.json linguist-generated=true -.claude/hooks/pull-request-target-guard/README.md linguist-generated=true -.claude/hooks/pull-request-target-guard/index.mts linguist-generated=true -.claude/hooks/pull-request-target-guard/package.json linguist-generated=true -.claude/hooks/pull-request-target-guard/test/index.test.mts linguist-generated=true -.claude/hooks/pull-request-target-guard/tsconfig.json linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/README.md linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/index.mts linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/package.json linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/test/index.test.mts linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/tsconfig.json linguist-generated=true -.claude/hooks/release-workflow-guard/README.md linguist-generated=true -.claude/hooks/release-workflow-guard/index.mts linguist-generated=true -.claude/hooks/release-workflow-guard/package.json linguist-generated=true -.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts linguist-generated=true -.claude/hooks/release-workflow-guard/tsconfig.json linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/README.md linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/index.mts linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/package.json linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/test/index.test.mts linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/tsconfig.json linguist-generated=true -.claude/hooks/setup-basics-tools/README.md linguist-generated=true -.claude/hooks/setup-basics-tools/install.mts linguist-generated=true -.claude/hooks/setup-basics-tools/package.json linguist-generated=true -.claude/hooks/setup-basics-tools/tsconfig.json linguist-generated=true -.claude/hooks/setup-claude-scanners/README.md linguist-generated=true -.claude/hooks/setup-claude-scanners/install.mts linguist-generated=true -.claude/hooks/setup-claude-scanners/package.json linguist-generated=true -.claude/hooks/setup-claude-scanners/tsconfig.json linguist-generated=true -.claude/hooks/setup-firewall/README.md linguist-generated=true -.claude/hooks/setup-firewall/install.mts linguist-generated=true -.claude/hooks/setup-firewall/package.json linguist-generated=true -.claude/hooks/setup-firewall/tsconfig.json linguist-generated=true -.claude/hooks/setup-misc-tools/README.md linguist-generated=true -.claude/hooks/setup-misc-tools/install.mts linguist-generated=true -.claude/hooks/setup-misc-tools/package.json linguist-generated=true -.claude/hooks/setup-misc-tools/tsconfig.json linguist-generated=true -.claude/hooks/setup-security-tools/README.md linguist-generated=true -.claude/hooks/setup-security-tools/external-tools.json linguist-generated=true -.claude/hooks/setup-security-tools/index.mts linguist-generated=true -.claude/hooks/setup-security-tools/install.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/api-token.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/installers.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/operator-prompts.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/token-storage.mts linguist-generated=true -.claude/hooks/setup-security-tools/package.json linguist-generated=true -.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts linguist-generated=true -.claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts linguist-generated=true -.claude/hooks/setup-security-tools/tsconfig.json linguist-generated=true -.claude/hooks/setup-signing/README.md linguist-generated=true -.claude/hooks/setup-signing/install.mts linguist-generated=true -.claude/hooks/setup-signing/package.json linguist-generated=true -.claude/hooks/setup-signing/tsconfig.json linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/README.md linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/index.mts linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/package.json linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json linguist-generated=true -.claude/hooks/socket-token-minifier-start/README.md linguist-generated=true -.claude/hooks/socket-token-minifier-start/index.mts linguist-generated=true -.claude/hooks/socket-token-minifier-start/package.json linguist-generated=true -.claude/hooks/socket-token-minifier-start/tsconfig.json linguist-generated=true -.claude/hooks/squash-history-reminder/README.md linguist-generated=true -.claude/hooks/squash-history-reminder/index.mts linguist-generated=true -.claude/hooks/squash-history-reminder/package.json linguist-generated=true -.claude/hooks/squash-history-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/squash-history-reminder/tsconfig.json linguist-generated=true -.claude/hooks/stale-process-sweeper/README.md linguist-generated=true -.claude/hooks/stale-process-sweeper/index.mts linguist-generated=true -.claude/hooks/stale-process-sweeper/package.json linguist-generated=true -.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts linguist-generated=true -.claude/hooks/stale-process-sweeper/tsconfig.json linguist-generated=true -.claude/hooks/sweep-ds-store/README.md linguist-generated=true -.claude/hooks/sweep-ds-store/index.mts linguist-generated=true -.claude/hooks/sweep-ds-store/package.json linguist-generated=true -.claude/hooks/sweep-ds-store/test/index.test.mts linguist-generated=true -.claude/hooks/sweep-ds-store/tsconfig.json linguist-generated=true -.claude/hooks/token-guard/README.md linguist-generated=true -.claude/hooks/token-guard/index.mts linguist-generated=true -.claude/hooks/token-guard/package.json linguist-generated=true -.claude/hooks/token-guard/test/token-guard.test.mts linguist-generated=true -.claude/hooks/token-guard/tsconfig.json linguist-generated=true -.claude/hooks/trust-downgrade-guard/README.md linguist-generated=true -.claude/hooks/trust-downgrade-guard/index.mts linguist-generated=true -.claude/hooks/trust-downgrade-guard/package.json linguist-generated=true -.claude/hooks/trust-downgrade-guard/test/index.test.mts linguist-generated=true -.claude/hooks/trust-downgrade-guard/tsconfig.json linguist-generated=true -.claude/hooks/variant-analysis-reminder/README.md linguist-generated=true -.claude/hooks/variant-analysis-reminder/index.mts linguist-generated=true -.claude/hooks/variant-analysis-reminder/package.json linguist-generated=true -.claude/hooks/variant-analysis-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/variant-analysis-reminder/tsconfig.json linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/README.md linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/package.json linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json linguist-generated=true -.claude/hooks/version-bump-order-guard/README.md linguist-generated=true -.claude/hooks/version-bump-order-guard/index.mts linguist-generated=true -.claude/hooks/version-bump-order-guard/package.json linguist-generated=true -.claude/hooks/version-bump-order-guard/test/index.test.mts linguist-generated=true -.claude/hooks/version-bump-order-guard/tsconfig.json linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/README.md linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/index.mts linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/package.json linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/README.md linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/index.mts linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/package.json linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/README.md linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/index.mts linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/package.json linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json linguist-generated=true +.claude/hooks/fleet linguist-generated=true .claude/settings.json linguist-generated=true .claude/skills/_shared/compound-lessons.md linguist-generated=true .claude/skills/_shared/env-check.md linguist-generated=true @@ -582,6 +87,7 @@ pnpm-lock.yaml -diff .claude/skills/prose/references/structures.md linguist-generated=true .claude/skills/refreshing-history/SKILL.md linguist-generated=true .claude/skills/refreshing-history/run.mts linguist-generated=true +.claude/skills/regenerating-plugin-patches/SKILL.md linguist-generated=true .claude/skills/reviewing-code/SKILL.md linguist-generated=true .claude/skills/reviewing-code/run.mts linguist-generated=true .claude/skills/running-test262/SKILL.md linguist-generated=true @@ -649,11 +155,13 @@ pnpm-lock.yaml -diff .config/oxlint-plugin/rules/prefer-cached-for-loop.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-ellipsis-char.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-env-as-boolean.mts linguist-generated=true +.config/oxlint-plugin/rules/prefer-error-message.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-exists-sync.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-function-declaration.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-node-builtin-imports.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-node-modules-dot-cache.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-non-capturing-group.mts linguist-generated=true +.config/oxlint-plugin/rules/prefer-pure-call-form.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-safe-delete.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-separate-type-import.mts linguist-generated=true .config/oxlint-plugin/rules/prefer-spawn-over-execsync.mts linguist-generated=true @@ -700,11 +208,13 @@ pnpm-lock.yaml -diff .config/oxlint-plugin/test/prefer-cached-for-loop.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-ellipsis-char.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-env-as-boolean.test.mts linguist-generated=true +.config/oxlint-plugin/test/prefer-error-message.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-exists-sync.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-function-declaration.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-node-builtin-imports.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-node-modules-dot-cache.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-non-capturing-group.test.mts linguist-generated=true +.config/oxlint-plugin/test/prefer-pure-call-form.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-safe-delete.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-separate-type-import.test.mts linguist-generated=true .config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts linguist-generated=true @@ -726,6 +236,7 @@ pnpm-lock.yaml -diff .config/socket-wheelhouse-schema.json linguist-generated=true .config/taze.config.mts linguist-generated=true .config/tsconfig.base.json linguist-generated=true +.config/vitest.coverage.fleet.config.mts linguist-generated=true .git-hooks/_helpers.mts linguist-generated=true .git-hooks/_resolve-node.sh linguist-generated=true .git-hooks/commit-msg linguist-generated=true @@ -760,6 +271,7 @@ assets/socket-logo-light-1680.png linguist-generated=true assets/socket-logo-light-420.png linguist-generated=true assets/socket-logo-light-840.png linguist-generated=true assets/socket-logo-light.svg linguist-generated=true +docs/claude.md/fleet linguist-generated=true docs/claude.md/fleet/agent-delegation.md linguist-generated=true docs/claude.md/fleet/agents-and-skills.md linguist-generated=true docs/claude.md/fleet/bypass-phrases.md linguist-generated=true @@ -780,6 +292,7 @@ docs/claude.md/fleet/path-hygiene.md linguist-generated=true docs/claude.md/fleet/plan-storage.md linguist-generated=true docs/claude.md/fleet/plugin-cache-patches.md linguist-generated=true docs/claude.md/fleet/security-stack.md linguist-generated=true +docs/claude.md/fleet/skill-model-routing.md linguist-generated=true docs/claude.md/fleet/socket-bypass-markers.md linguist-generated=true docs/claude.md/fleet/sorting.md linguist-generated=true docs/claude.md/fleet/token-hygiene.md linguist-generated=true diff --git a/CLAUDE.md b/CLAUDE.md index 00ed2bb..f6a7005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,20 +2,12 @@ **MANDATORY**: Act as principal-level engineer. Follow these guidelines exactly. -This file has two parts: +Two parts: -1. **📚 Wheelhouse Standards** — content between the `BEGIN FLEET-CANONICAL` / - `END FLEET-CANONICAL` markers below is the wheelhouse-owned ruleset, distributed byte-identical to every - fleet repo (`socket-*` and `ultrathink`). Wheelhouse controls; fleet - members consume. **Do not edit in a downstream (fleet) repo** — - edit `socket-wheelhouse/template/CLAUDE.md` and run - `node scripts/sync-scaffolding.mts --all --fix`. -2. **🏗️ Project-Specific** — everything _outside_ the fleet markers is - owned by the host repo. Architecture, commands, build pipelines, - domain rules, etc. live there. +1. **📚 Wheelhouse Standards** — the wheelhouse-owned ruleset between the `BEGIN FLEET-CANONICAL` and `END FLEET-CANONICAL` markers. The wheelhouse ships these to every fleet repo (`socket-*` and `ultrathink`) byte-identical. Don't edit in a downstream repo. Edit `socket-wheelhouse/template/CLAUDE.md` and run `node scripts/sync-scaffolding.mts --all --fix`. +2. **🏗️ Project-Specific** — everything outside the markers belongs to the host repo: architecture, commands, build pipelines, domain rules. -The fleet block comes first because it changes most often (centrally -curated), and it never interweaves with project content. Fleet-block size is capped at 40 KB (enforced by `.claude/hooks/claude-md-size-guard/`) — every byte costs N copies of fleet-wide in-context tokens. Individual `###` sections inside the fleet block are also capped at 8 body lines; past that, outsource detail to `docs/claude.md/fleet/<topic>.md` (enforced by `.claude/hooks/claude-md-section-size-guard/`). +The fleet block goes first; the wheelhouse changes it more often than the project content changes. **CLAUDE.md caps at 40 KB total** (enforced by `.claude/hooks/fleet/claude-md-size-guard/`); every byte costs tokens in every session. Keep both halves terse. Fleet detail goes to `docs/claude.md/fleet/<topic>.md`; per-repo detail goes to `docs/claude.md/repo/<topic>.md`. Individual `###` sections cap at 8 body lines (enforced by `.claude/hooks/fleet/claude-md-section-size-guard/`). <!-- BEGIN FLEET-CANONICAL — sync via socket-wheelhouse/scripts/sync-scaffolding.mts. Do not edit downstream. --> @@ -23,11 +15,11 @@ curated), and it never interweaves with project content. Fleet-block size is cap ### Identifying users -Identify users by git credentials and use their actual name. Use "you/your" when speaking directly; use names when referencing contributions (enforced by `.claude/hooks/identifying-users-reminder/`). +Identify users by git credentials and use their actual name. Use "you/your" when speaking directly; use names when referencing contributions (enforced by `.claude/hooks/fleet/identifying-users-reminder/`). ### Parallel Claude sessions -🚨 Multiple Claude sessions may target the same checkout (parallel agents, terminals, or worktrees on the same `.git/`). **The umbrella rule:** never run a git command that mutates state belonging to a path other than the file you just edited. Forbidden in the primary checkout: `git stash`, `git add -A` / `git add .` (enforced by `.claude/hooks/overeager-staging-guard/`; bypass: `Allow add-all bypass`), `git checkout/switch <branch>`, `git reset --hard <non-HEAD>`. Branch work goes in a `git worktree`. Cross-repo imports via `@socketsecurity/lib/...` only, never `../<sibling-repo>/...` (enforced by `.claude/hooks/cross-repo-guard/`). Dirty paths you didn't author this session + that changed recently are likely another live agent — never `add -A`/`stash`/`reset --hard`/`checkout`/`restore` over them; stage only your own files (enforced by `.claude/hooks/parallel-agent-on-stop-reminder/` + `.claude/hooks/parallel-agent-staging-guard/`; bypass `Allow parallel-agent-staging bypass`). **Why:** 2026-05-27 a session's own `pnpm check` surfaced another agent's migration files; it nearly committed them. Full prohibition list + worktree recipe in [`docs/claude.md/fleet/parallel-claude-sessions.md`](docs/claude.md/fleet/parallel-claude-sessions.md). +🚨 Multiple Claude sessions may target the same checkout (parallel agents, terminals, or worktrees on the same `.git/`). **The umbrella rule:** never run a git command that mutates state belonging to a path other than the file you just edited. Forbidden in the primary checkout: `git stash`, `git add -A` / `git add .` (enforced by `.claude/hooks/fleet/overeager-staging-guard/`; bypass: `Allow add-all bypass`), `git checkout/switch <branch>`, `git reset --hard <non-HEAD>`. Branch work goes in a `git worktree`. Cross-repo imports via `@socketsecurity/lib/...` only, never `../<sibling-repo>/...` (enforced by `.claude/hooks/fleet/cross-repo-guard/`). Dirty paths you didn't author this session + that changed recently are likely another live agent — never `add -A`/`stash`/`reset --hard`/`checkout`/`restore` over them; stage only your own files (enforced by `.claude/hooks/fleet/parallel-agent-on-stop-reminder/`, enforced by `.claude/hooks/fleet/parallel-agent-staging-guard/`; bypass `Allow parallel-agent-staging bypass`). **Why:** 2026-05-27 a session's own `pnpm check` surfaced another agent's migration files; it nearly committed them. Full prohibition list + worktree recipe in [`docs/claude.md/fleet/parallel-claude-sessions.md`](docs/claude.md/fleet/parallel-claude-sessions.md). ### Default branch fallback @@ -40,13 +32,13 @@ BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remo BASE="${BASE:-main}" ``` -Apply in: worktree creation, base-ref resolution for `git diff`/`git rev-list`, PR base detection, hook scripts walking history. Doc examples may write `main` for clarity; scripts must look up. Order matters — `main → master` matches fleet reality; reversing would mispick during rename migrations (enforced by `.claude/hooks/default-branch-guard/`). +Apply in: worktree creation, base-ref resolution for `git diff`/`git rev-list`, PR base detection, hook scripts walking history. Doc examples may write `main` for clarity; scripts must look up. Order matters — `main → master` matches fleet reality; reversing would mispick during rename migrations (enforced by `.claude/hooks/fleet/default-branch-guard/`). ### Public-surface hygiene -🚨 Never write a real customer / company name, private repo / internal project name, or Linear ref (`SOC-123`, `ENG-456`, Linear URLs) into a commit, PR, issue, comment, or release note. No denylist — a denylist is itself a leak (enforced by `.claude/hooks/{private-name-guard,public-surface-reminder}/`). +🚨 Never write a real customer / company name, private repo / internal project name, or Linear ref (`SOC-123`, `ENG-456`, Linear URLs) into a commit, PR, issue, comment, or release note. No denylist — a denylist is itself a leak (enforced by `.claude/hooks/fleet/{private-name-guard,public-surface-reminder}/`). -🚨 Never `gh workflow run|dispatch` against publish / release / build-release workflows (enforced by `.claude/hooks/release-workflow-guard/`). Bypass: `gh workflow run -f dry-run=true` (workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim. `workflow_dispatch.inputs` keys are kebab-case. +🚨 Never `gh workflow run|dispatch` against publish / release / build-release workflows (enforced by `.claude/hooks/fleet/release-workflow-guard/`). Bypass: `gh workflow run -f dry-run=true` (workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim. `workflow_dispatch.inputs` keys are kebab-case. 🚨 **Workflow YAML invariants:** SHA-pinned `uses:` lines need a `# <tag> (YYYY-MM-DD)` comment; `run:` blocks with multi-line `gh ... --body "..."` break YAML — always `--body-file <path>`; `pull_request_target` is privileged and never combines with fork-head checkout + execute. External-issue refs (`<owner>/<repo>#<num>`) in commits / PR bodies spam upstream maintainers — only `SocketDev/<repo>#<num>` is allowed inline; link upstream refs in PR _description prose_ instead. Bypass: `Allow external-issue-ref bypass`. @@ -54,11 +46,11 @@ Full ruleset + threat model + bypass surface in [`docs/claude.md/fleet/public-su ### Canonical README -🚨 Root `README.md` follows the fleet skeleton — 5 level-2 sections in order (Why this repo exists / Install / Usage / Development / License), no `socket-wheelhouse` mentions (it's a private repo), no sibling-relative script commands (e.g. `node ../socket-foo/scripts/...` fails for outside readers). Canonical skeleton: `socket-wheelhouse/template/README.md`. Bypass: `Allow readme-fleet-shape bypass` (enforced by `.claude/hooks/readme-fleet-shape-guard/`). +🚨 Root `README.md` follows the fleet skeleton — 5 level-2 sections in order (Why this repo exists / Install / Usage / Development / License), no `socket-wheelhouse` mentions (it's a private repo), no sibling-relative script commands (e.g. `node ../socket-foo/scripts/...` fails for outside readers). Canonical skeleton: `socket-wheelhouse/template/README.md`. Bypass: `Allow readme-fleet-shape bypass` (enforced by `.claude/hooks/fleet/readme-fleet-shape-guard/`). ### Commits & PRs -🚨 Conventional Commits `<type>(<scope>): <description>`, lowercase type, NO AI attribution (enforced by `.claude/hooks/commit-message-format-guard/` + draft-time reminder `.claude/hooks/commit-pr-reminder/`; bypasses `Allow commit-format bypass` / `Allow ai-attribution bypass`). Push policy: push direct → fall back to PR only on rejection (no pre-emptive PRs, no force-pushes). When adding commits to an OPEN PR, update the title + description via `gh pr edit` to match the new scope. NEVER push to a non-fleet repo (bypass `Allow non-fleet-push bypass`; enforced by `.claude/hooks/no-non-fleet-push-guard/`). +🚨 Conventional Commits `<type>(<scope>): <description>`, lowercase type, NO AI attribution (enforced by `.claude/hooks/fleet/commit-message-format-guard/`, enforced by `.claude/hooks/fleet/commit-pr-reminder/`; bypasses `Allow commit-format bypass` / `Allow ai-attribution bypass`). Push direct → PR only on rejection. NEVER push, open PRs, file issues, or create releases against a non-fleet repo without confirmation (bypasses `Allow non-fleet-push bypass` / `Allow non-fleet-publish bypass`; enforced by `.claude/hooks/fleet/no-non-fleet-push-guard/`, enforced by `.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/`). Full ruleset — open-PR edits, Bugbot inline replies, rebase-over-revert for unpushed commits, no-empty-commits, commit-author canonical identity, scan-label scrubbing, enterprise-ruleset bypass — in [`docs/claude.md/fleet/commit-cadence-format.md`](docs/claude.md/fleet/commit-cadence-format.md). @@ -68,11 +60,11 @@ Full ruleset — open-PR edits, Bugbot inline replies, rebase-over-revert for un ### Squash-history opt-in -Some fleet repos squash the default branch on a cadence — currently socket-addon, socket-bin, socket-btm, sdxgen, stuie (declared via `optIns: ['squash-history']` in `template/.claude/skills/cascading-fleet/lib/fleet-repos.json`). When working in an opted-in repo, prefer one consolidated commit per logical change over a long fan of tiny WIP commits; the `squashing-history` skill is the documented way to collapse history when it grows long. Threshold reminder + bypass `Allow squash-history-reminder bypass` (enforced by `.claude/hooks/squash-history-reminder/`). +Some fleet repos squash the default branch on a cadence — currently socket-addon, socket-bin, socket-btm, sdxgen, stuie (declared via `optIns: ['squash-history']` in `template/.claude/skills/cascading-fleet/lib/fleet-repos.json`). When working in an opted-in repo, prefer one consolidated commit per logical change over a long fan of tiny WIP commits; the `squashing-history` skill is the documented way to collapse history when it grows long. Threshold reminder + bypass `Allow squash-history-reminder bypass` (enforced by `.claude/hooks/fleet/squash-history-reminder/`). ### Version bumps & immutable releases -🚨 Bump: (1) `pnpm run update` → `pnpm i` → `pnpm run fix --all` → `pnpm run check --all`; (2) CHANGELOG public-facing only; (3) `chore: bump version to X.Y.Z` LAST; (4) `git tag vX.Y.Z` (`version-bump-order-guard`); (5) user dispatches publish. Stop reminder verifies provenance + trustedPublisher. GH Releases ship **immutable** (Sigstore attestation, GA 2025-10-28). Release workflows use 3-step `gh release create --draft` → `gh release upload` → `gh release edit --draft=false`; single-call form is forbidden (enforced by `.claude/hooks/immutable-release-pattern-guard/`; bypass: `Allow immutable-release-pattern bypass`). Verify: `gh release verify <tag>`. Detail: [`docs/claude.md/fleet/version-bumps.md`](docs/claude.md/fleet/version-bumps.md), [`docs/claude.md/fleet/immutable-releases.md`](docs/claude.md/fleet/immutable-releases.md). +🚨 Bump: (1) `pnpm run update` → `pnpm i` → `pnpm run fix --all` → `pnpm run check --all`; (2) CHANGELOG public-facing only; (3) `chore: bump version to X.Y.Z` LAST; (4) `git tag vX.Y.Z` (`version-bump-order-guard`); (5) user dispatches publish. GH Releases ship **immutable** via 3-step `gh release create --draft` → `gh release upload` → `gh release edit --draft=false`; single-call form forbidden (enforced by `.claude/hooks/fleet/immutable-release-pattern-guard/`; bypass `Allow immutable-release-pattern bypass`). Detail: [`docs/claude.md/fleet/version-bumps.md`](docs/claude.md/fleet/version-bumps.md), [`docs/claude.md/fleet/immutable-releases.md`](docs/claude.md/fleet/immutable-releases.md). ### Programmatic Claude calls @@ -80,35 +72,27 @@ Some fleet repos squash the default branch on a cadence — currently socket-add ### Tooling -🚨 **Package manager: `pnpm`** — scripts via `pnpm run foo --flag` (never `foo:bar`); after `package.json` edits, `pnpm install`. NEVER `npx` / `pnpm dlx` / `yarn dlx` — use `pnpm exec` or `pnpm run` # socket-hook: allow npx. NEVER `--experimental-strip-types` to Node (enforced by `.claude/hooks/no-experimental-strip-types-guard/`). +🚨 **Package manager: `pnpm`** — scripts via `pnpm run foo --flag`; `pnpm install` after `package.json` edits. NEVER `npx` / `pnpm dlx` / `yarn dlx` — use `pnpm exec` / `pnpm run`. NEVER `--experimental-strip-types` (enforced by `.claude/hooks/fleet/no-experimental-strip-types-guard/`). Engine floors: `engines.pnpm: ">=11.4.0"`, `engines.npm: ">=11.16.0"`. `package.json` `allowScripts` mirrors `pnpm-workspace.yaml` `allowBuilds` per-repo (sync-scaffolding `allow_scripts_drift` auto-fixes). **Bundler: rolldown, not esbuild.** Backward compatibility is FORBIDDEN. **`-stable` self-import:** `scripts/**` + `.claude/hooks/**` import via `-stable` alias, never bare name. Autofix `socket/prefer-stable-self-import`. -🚨 **Engine floors pinned fleet-wide:** `engines.pnpm: ">=11.4.0"` (matches the `packageManager` pin), `engines.npm: ">=11.16.0"` (added `allowScripts` script opt-in, RFC #868). Wheelhouse `package.json` is source of truth; both cascade via sync-scaffolding `engines_pnpm_drift` + `engines_npm_drift`. - -🚨 **Bundler: rolldown, not esbuild.** Backward compatibility is FORBIDDEN — actively remove when encountered. - -🚨 **`-stable` self-import:** `scripts/**` + `.claude/hooks/**` import the repo-owned fleet package via its `-stable` alias, never the bare name (bare = WIP local `src/`). Autofix `socket/prefer-stable-self-import`. - -🚨 **New deps Socket-scored at edit time** (enforced by `.claude/hooks/check-new-deps/`); the 7-day `minimumReleaseAge` soak is malware protection (bypass `Allow minimumReleaseAge bypass`; enforced by `.claude/hooks/minimum-release-age-guard/`). Soak-bypass entries need `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotations (enforced by `.claude/hooks/soak-exclude-date-annotation-guard/`). Dep overrides go in `pnpm-workspace.yaml` `overrides:`, never `package.json` `pnpm.overrides` (bypass `Allow package-json-overrides bypass`; enforced by `.claude/hooks/no-package-json-pnpm-overrides-guard/`). - -🚨 **Never weaken a supply-chain trust gate** (`trustPolicy: no-downgrade`, `--config.trustPolicy=trust-all`, `blockExoticSubdeps`). Any change making the system more trusting/vulnerable needs `Allow trust-downgrade bypass` verbatim — **single-use, not persisted**. Fix a stale lockfile via the soak/exclude entry, never by disabling the policy (enforced by `.claude/hooks/trust-downgrade-guard/`). +🚨 **Supply-chain hygiene.** New deps Socket-scored at edit time; 7-day `minimumReleaseAge` soak is malware protection (bypass `Allow minimumReleaseAge bypass`); soak-bypass entries need `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotations. Dep overrides in `pnpm-workspace.yaml`, never `package.json` `pnpm.overrides` (bypass `Allow package-json-overrides bypass`). **Never weaken a trust gate** (`trustPolicy: no-downgrade`, `--config.trustPolicy=trust-all`, `blockExoticSubdeps`) — fix stale lockfiles via the soak/exclude entry; the bypass `Allow trust-downgrade bypass` is single-use and not persisted (enforced by `.claude/hooks/fleet/{check-new-deps,minimum-release-age-guard,soak-exclude-date-annotation-guard,no-package-json-pnpm-overrides-guard,trust-downgrade-guard}/`). Full ruleset (docs lead with pnpm, `packageManager` field, `.config/` placement, `.mts` runners, monorepo `engines.node`, vitest/node-test runner separation, `npm-run-all2` + `node --run` opt-in) in [`docs/claude.md/fleet/tooling.md`](docs/claude.md/fleet/tooling.md). -🚨 **Need a database? PostgreSQL + Drizzle ORM** (driver `node:smol-sql`, `pglite` for tests, config `.config/drizzle.config.mts`). Most repos need none; don't add speculatively. [`docs/claude.md/fleet/database.md`](docs/claude.md/fleet/database.md). +🚨 **Database:** PostgreSQL + Drizzle ORM (driver `node:smol-sql`, `pglite` for tests). Most repos need none. [`docs/claude.md/fleet/database.md`](docs/claude.md/fleet/database.md). ### Claude Code plugin pins -🚨 Fleet-blessed Claude Code plugins are SHA-pinned in the wheelhouse-canonical [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json), with companion human-readable metadata (pin date, pinner) in [`.claude-plugin/README.md`](../.claude-plugin/README.md). The pair is enforced together: every `plugins[].source.sha` in `marketplace.json` must have a row in the README table with matching `version` + `sha` + an ISO-8601 `date`. Same staleness signal the GHA `uses:` SHA-pin comments carry. Bump the SHA → bump the row. Run `pnpm run install-claude-plugins` to reconcile a machine to the pinned set — adds the marketplace + installs each plugin at its pinned SHA, then reapplies `scripts/plugin-patches/*.patch` for upstream bugs we can't land yet (fleet `# @`-header + plain `diff -u` body, `patch -p1`; regenerate via `regenerating-plugin-patches`; full spec [`docs/claude.md/fleet/plugin-cache-patches.md`](docs/claude.md/fleet/plugin-cache-patches.md)) (enforced by `.claude/hooks/marketplace-comment-guard/`, `.claude/hooks/plugin-patch-format-guard/`). +🚨 Fleet-blessed Claude Code plugins are SHA-pinned in the wheelhouse-canonical [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json), with companion human-readable metadata (pin date, pinner) in [`.claude-plugin/README.md`](../.claude-plugin/README.md). The pair is enforced together: every `plugins[].source.sha` in `marketplace.json` must have a row in the README table with matching `version` + `sha` + an ISO-8601 `date`. Same staleness signal the GHA `uses:` SHA-pin comments carry. Bump the SHA → bump the row. Run `pnpm run install-claude-plugins` to reconcile a machine to the pinned set — adds the marketplace + installs each plugin at its pinned SHA, then reapplies `scripts/plugin-patches/*.patch` for upstream bugs we can't land yet (fleet `# @`-header + plain `diff -u` body, `patch -p1`; regenerate via `regenerating-plugin-patches`; full spec [`docs/claude.md/fleet/plugin-cache-patches.md`](docs/claude.md/fleet/plugin-cache-patches.md)) (enforced by `.claude/hooks/fleet/marketplace-comment-guard/`, `.claude/hooks/fleet/plugin-patch-format-guard/`). ### Token minification -Two surfaces apply lossless, deterministic compression to Claude tool_result payloads (JSON whitespace, `cat -n` prefixes, blank-line runs; no ML). **Wire-level proxy** `@socketsecurity/token-minifier` ([`packages/`](../packages/socket-token-minifier/)) sits between Claude Code and api.anthropic.com via `ANTHROPIC_BASE_URL=http://localhost:7779`; auto-started **fail-closed** by `socket-token-minifier-start` (sets the env var only if healthy). **In-context hook** `minify-mcp-output` rewrites MCP results via `hookSpecificOutput.updatedMCPToolOutput` (built-in Read/Bash have no such channel — use the proxy) (enforced by `.claude/hooks/minify-mcp-output/`, `.claude/hooks/socket-token-minifier-start/`). +Two surfaces apply lossless, deterministic compression to Claude tool_result payloads (JSON whitespace, `cat -n` prefixes, blank-line runs; no ML). **Wire-level proxy** `@socketsecurity/token-minifier` ([`packages/`](../packages/socket-token-minifier/)) sits between Claude Code and api.anthropic.com via `ANTHROPIC_BASE_URL=http://localhost:7779`; auto-started **fail-closed** by `socket-token-minifier-start` (sets the env var only if healthy). **In-context hook** `minify-mcp-output` rewrites MCP results via `hookSpecificOutput.updatedMCPToolOutput` (built-in Read/Bash have no such channel — use the proxy) (enforced by `.claude/hooks/fleet/minify-mcp-output/`, `.claude/hooks/fleet/socket-token-minifier-start/`). ### Fix it, don't defer -🚨 See a lint/type/test error or broken comment in your reading window — fix it. Stop current task, fix the issue in a sibling commit, resume. Don't label as "pre-existing", "unrelated", or "out of scope" — the labels are rationalizations (enforced by `.claude/hooks/excuse-detector/`). +🚨 See a lint/type/test error or broken comment in your reading window — fix it. Stop current task, fix the issue in a sibling commit, resume. Don't label as "pre-existing", "unrelated", or "out of scope" — the labels are rationalizations (enforced by `.claude/hooks/fleet/excuse-detector/`). -🚨 Don't blame the user (or "the linter") when your own edits get reverted between turns. The cause is almost always your own scripts: pre-commit autofix, sync-cascade from `template/`, oxlint --fix. Investigate with `git log -S`, run pre-commit phases in isolation, diff `template/` canonical sources. Only attribute to the user with direct evidence (enforced by `.claude/hooks/dont-blame-user-reminder/`). +🚨 Don't blame the user (or "the linter") when your own edits get reverted between turns. The cause is almost always your own scripts: pre-commit autofix, sync-cascade from `template/`, oxlint --fix. Investigate with `git log -S`, run pre-commit phases in isolation, diff `template/` canonical sources. Only attribute to the user with direct evidence (enforced by `.claude/hooks/fleet/dont-blame-user-reminder/`). 🚨 Never offer "fix vs accept-as-gap" as a choice — pick the fix. @@ -116,7 +100,7 @@ Exceptions (state the trade-off and ask): genuinely large refactor on a small bu ### Don't leave the worktree dirty -🚨 Finish a code change → **commit it**. Never end a turn with uncommitted edits, untracked files, or staged-but-uncommitted hunks. Surgical staging only (`git add <specific-file>`, never `-A` / `.`); stage and commit in the same Bash call. If you can't commit yet (mid-refactor, failing tests, waiting on user), announce it in the turn summary — silent dirty worktrees are the failure mode. Worktrees from `git worktree add` must be left clean (committed + pushed) before `git worktree remove`. Enforced by `.claude/hooks/no-orphaned-staging/` + `.claude/hooks/node-modules-staging-guard/` (bypass: `Allow node-modules-staging bypass`); end-of-turn dirty-worktree scan (enforced by `.claude/hooks/dirty-worktree-on-stop-reminder/`). Full rules + parallel-session rationale in [`docs/claude.md/fleet/worktree-hygiene.md`](docs/claude.md/fleet/worktree-hygiene.md). +🚨 Finish a code change → **commit it**. Never end a turn with uncommitted edits, untracked files, or staged-but-uncommitted hunks. Surgical staging only (`git add <specific-file>`, never `-A` / `.`); stage and commit in the same Bash call. If you can't commit yet (mid-refactor, failing tests, waiting on user), announce it in the turn summary — silent dirty worktrees are the failure mode. Worktrees from `git worktree add` must be left clean (committed + pushed) before `git worktree remove`. Enforced by `.claude/hooks/fleet/no-orphaned-staging/` + `.claude/hooks/fleet/node-modules-staging-guard/` (bypass: `Allow node-modules-staging bypass`); end-of-turn dirty-worktree scan (enforced by `.claude/hooks/fleet/dirty-worktree-on-stop-reminder/`). Full rules + parallel-session rationale in [`docs/claude.md/fleet/worktree-hygiene.md`](docs/claude.md/fleet/worktree-hygiene.md). ### Smallest chunks, land ASAP @@ -124,53 +108,57 @@ Exceptions (state the trade-off and ask): genuinely large refactor on a small bu ### Commit cadence & message format -🚨 Commit early, commit often. Every commit follows [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/): lowercase `<type>[(scope)][!]: <description>` with type ∈ { feat, fix, chore, docs, style, refactor, perf, test, build, ci, revert }. No AI attribution anywhere. Bypass: `Allow commit-format bypass` or `Allow ai-attribution bypass`. Full rationale + examples + edge cases in [`docs/claude.md/fleet/commit-cadence-format.md`](docs/claude.md/fleet/commit-cadence-format.md) (enforced by `.claude/hooks/commit-message-format-guard/` at commit time + `.claude/hooks/commit-pr-reminder/` at draft time). +🚨 Commit early, commit often. Every commit follows [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/): lowercase `<type>[(scope)][!]: <description>` with type ∈ { feat, fix, chore, docs, style, refactor, perf, test, build, ci, revert }. No AI attribution anywhere. Bypass: `Allow commit-format bypass` or `Allow ai-attribution bypass`. Full rationale + examples + edge cases in [`docs/claude.md/fleet/commit-cadence-format.md`](docs/claude.md/fleet/commit-cadence-format.md) (enforced by `.claude/hooks/fleet/commit-message-format-guard/`, enforced by `.claude/hooks/fleet/commit-pr-reminder/`). ### Don't disable lint rules -🚨 Adding `"rule-name": "off"` (or `"warn"`) to any oxlint/eslint config weakens the gate for every file matching that selector. Fix the underlying code instead. For genuine single-call-site exemptions, use `oxlint-disable-next-line <rule> -- <reason>` on the specific line. Bypass: `Allow disable-lint-rule bypass`. Full rationale + recipes in [`docs/claude.md/fleet/no-disable-lint-rule.md`](docs/claude.md/fleet/no-disable-lint-rule.md) (enforced by `.claude/hooks/no-disable-lint-rule-guard/`). +🚨 Adding `"rule-name": "off"` (or `"warn"`) to any oxlint/eslint config weakens the gate for every file matching that selector. Fix the underlying code instead. For genuine single-call-site exemptions, use `oxlint-disable-next-line <rule> -- <reason>` on the specific line. Bypass: `Allow disable-lint-rule bypass`. Full rationale + recipes in [`docs/claude.md/fleet/no-disable-lint-rule.md`](docs/claude.md/fleet/no-disable-lint-rule.md) (enforced by `.claude/hooks/fleet/no-disable-lint-rule-guard/`). ### Extension build hygiene -🚨 The trusted-publisher Chrome extension at `tools/trusted-publisher-extension/` is bundled via rolldown. Commits that touch `tools/trusted-publisher-extension/src/**` MUST be paired with a successful `pnpm --filter @socketsecurity/trusted-publisher-extension build` so the bundled output stays loadable. Bypass: `Allow extension-build-current bypass`. (Enforced by `.claude/hooks/extension-build-current-guard/`.) +🚨 The trusted-publisher Chrome extension at `tools/trusted-publisher-extension/` is bundled via rolldown. Commits that touch `tools/trusted-publisher-extension/src/**` MUST be paired with a successful `pnpm --filter @socketsecurity/trusted-publisher-extension build` so the bundled output stays loadable. Bypass: `Allow extension-build-current bypass`. (Enforced by `.claude/hooks/fleet/extension-build-current-guard/`.) ### Untracked-by-default for vendored / build-copied trees -🚨 Dirs under `additions/source-patched/`, `vendor/`, `third_party/`, `external/`, `upstream/`, `deps/<lib>/`, `pkg-node/`, `*-bundled`/`*-vendored` are **untracked-by-default** — before staging, `git status --ignored` + read `.gitignore` allowlists + find the build script that copies the dir. When REMOVING a consumed class/attr/selector, grep the repo root AND every `upstream/`/`vendor/` submodule first (enforced by `.claude/hooks/consumer-grep-reminder/`). Run the command instead of guessing; ask before 100+-file/multi-MB drops. Full playbook: [`docs/claude.md/fleet/untracked-by-default.md`](docs/claude.md/fleet/untracked-by-default.md). +🚨 Dirs under `additions/source-patched/`, `vendor/`, `third_party/`, `external/`, `upstream/`, `deps/<lib>/`, `pkg-node/`, `*-bundled`/`*-vendored` are **untracked-by-default** — before staging, `git status --ignored` + read `.gitignore` allowlists + find the build script that copies the dir. When REMOVING a consumed class/attr/selector, grep the repo root AND every `upstream/`/`vendor/` submodule first (enforced by `.claude/hooks/fleet/consumer-grep-reminder/`). Run the command instead of guessing; ask before 100+-file/multi-MB drops. Full playbook: [`docs/claude.md/fleet/untracked-by-default.md`](docs/claude.md/fleet/untracked-by-default.md). ### Hook bypasses require the canonical phrase -🚨 Reverting tracked changes or bypassing a hook (--no-verify, DISABLE*PRECOMMIT*\*, --no-gpg-sign, force-push) requires the user to type **`Allow <X> bypass`** verbatim in a recent user turn (e.g. `Allow revert bypass`, `Allow no-verify bypass`). Paraphrases don't count (enforced by `.claude/hooks/no-revert-guard/`). Full phrase table: [`docs/claude.md/fleet/bypass-phrases.md`](docs/claude.md/fleet/bypass-phrases.md). +🚨 Reverting tracked changes or bypassing a hook (--no-verify, DISABLE*PRECOMMIT*\*, --no-gpg-sign, force-push) requires the user to type **`Allow <X> bypass`** verbatim in a recent user turn (e.g. `Allow revert bypass`, `Allow no-verify bypass`). Paraphrases don't count (enforced by `.claude/hooks/fleet/no-revert-guard/`). Full phrase table: [`docs/claude.md/fleet/bypass-phrases.md`](docs/claude.md/fleet/bypass-phrases.md). -**Exception — wheelhouse cascade.** Mechanical `chore(wheelhouse): cascade template@<sha>` operations across the fleet would otherwise need a fresh bypass phrase per repo. Prefix cascade Bash commands with `FLEET_SYNC=1` to opt in: the sentinel allowlists exactly three operations — (1) `git commit --no-verify` whose message starts with `chore(wheelhouse): cascade template@`; (2) `git push --no-verify`; (3) broad-stage `git add -A` / `git add -u` / `git add .` (safe inside a fresh worktree off `origin/main`, which is how cascade scripts work). Everything else with `FLEET_SYNC=1` still falls through to the normal checks — `git stash`, `git reset --hard`, `git checkout/restore`, non-cascade commits all still need the canonical phrase. The sentinel is opt-in per command; no global env-var poisoning. (Enforced by `.claude/hooks/no-revert-guard/` + `.claude/hooks/overeager-staging-guard/`.) +**Exception — wheelhouse cascade.** Mechanical `chore(wheelhouse): cascade template@<sha>` operations across the fleet would otherwise need a fresh bypass phrase per repo. Prefix cascade Bash commands with `FLEET_SYNC=1` to opt in: the sentinel allowlists exactly three operations — (1) `git commit --no-verify` whose message starts with `chore(wheelhouse): cascade template@`; (2) `git push --no-verify`; (3) broad-stage `git add -A` / `git add -u` / `git add .` (safe inside a fresh worktree off `origin/main`, which is how cascade scripts work). Everything else with `FLEET_SYNC=1` still falls through to the normal checks — `git stash`, `git reset --hard`, `git checkout/restore`, non-cascade commits all still need the canonical phrase. The sentinel is opt-in per command; no global env-var poisoning. (Enforced by `.claude/hooks/fleet/no-revert-guard/` + `.claude/hooks/fleet/overeager-staging-guard/`.) ### Variant analysis on every High/Critical finding 🚨 When a finding lands at severity High or Critical, **search the rest of the repo for the same shape** before closing it. Bugs cluster — same mental model, same antipattern. Three searches: same file (read the whole thing, not just the hunk), sibling files (`rg` the shape, not the names), cross-package (parallel implementations love to drift). -Skip for style nits. Full taxonomy in [`.claude/skills/_shared/variant-analysis.md`](.claude/skills/_shared/variant-analysis.md). Cross-fleet variants become a _Drift watch_ task — open `chore(wheelhouse): cascade <fix>` (enforced by `.claude/hooks/variant-analysis-reminder/`). +Skip for style nits. Full taxonomy in [`.claude/skills/_shared/variant-analysis.md`](.claude/skills/_shared/variant-analysis.md). Cross-fleet variants become a _Drift watch_ task — open `chore(wheelhouse): cascade <fix>` (enforced by `.claude/hooks/fleet/variant-analysis-reminder/`). ### Compound lessons into rules -When the same kind of finding fires twice — across two runs, two PRs, or two fleet repos — **promote it to a rule** instead of fixing it again. Land it in CLAUDE.md, a `.claude/hooks/*` block, or a skill prompt — pick the lowest-friction surface. Always cite the original incident in a `**Why:**` line. Skip the retrospective doc; the rule is the artifact (enforced by `.claude/hooks/compound-lessons-reminder/`). Discipline: [`.claude/skills/_shared/compound-lessons.md`](.claude/skills/_shared/compound-lessons.md). +When the same kind of finding fires twice — across two runs, two PRs, or two fleet repos — **promote it to a rule** instead of fixing it again. Land it in CLAUDE.md, a `.claude/hooks/*` block, or a skill prompt — pick the lowest-friction surface. Always cite the original incident in a `**Why:**` line. Skip the retrospective doc; the rule is the artifact (enforced by `.claude/hooks/fleet/compound-lessons-reminder/`). Discipline: [`.claude/skills/_shared/compound-lessons.md`](.claude/skills/_shared/compound-lessons.md). -Every new `.claude/hooks/<name>/` hook must have a matching `(enforced by `.claude/hooks/<name>/`)` reference in CLAUDE.md before the hook's `index.mts` can be written (enforced by `.claude/hooks/new-hook-claude-md-guard/`). Hooks ignore CLAUDE.md themselves — citing the enforcer inline keeps the rule visible to whoever's reading either surface. +Every new `.claude/hooks/<name>/` hook must have a matching `(enforced by `.claude/hooks/<name>/`)` reference in CLAUDE.md before the hook's `index.mts` can be written (enforced by `.claude/hooks/fleet/new-hook-claude-md-guard/`). Hooks ignore CLAUDE.md themselves — citing the enforcer inline keeps the rule visible to whoever's reading either surface. ### Plan review before approval -For non-trivial work (multi-file refactor, new feature, migration), the plan itself is a deliverable. List steps numerically, name files you'll touch, name rules you'll honor — don't bury the plan in prose. If the plan touches fleet-shared resources (this CLAUDE.md fleet block, hooks, `_shared/`), invite a second-opinion pass before writing code. If the plan adds a fleet rule, name the original incident (per _Compound lessons_) (enforced by `.claude/hooks/plan-review-reminder/`). +For non-trivial work (multi-file refactor, new feature, migration), the plan itself is a deliverable. List steps numerically, name files you'll touch, name rules you'll honor — don't bury the plan in prose. If the plan touches fleet-shared resources (this CLAUDE.md fleet block, hooks, `_shared/`), invite a second-opinion pass before writing code. If the plan adds a fleet rule, name the original incident (per _Compound lessons_) (enforced by `.claude/hooks/fleet/plan-review-reminder/`). ### Plan storage -🚨 Design / implementation / migration plan docs live at `<repo-root>/.claude/plans/<lowercase-hyphenated>.md` and are **never tracked by version control** — the fleet `.gitignore` excludes `/.claude/*` and `plans/` is intentionally absent from the allowlist. Don't write plans into `docs/plans/` or a package-level `<pkg>/docs/plans/` (enforced by `.claude/hooks/plan-location-guard/`; bypass: `Allow plan-location bypass`). Full rationale + migration guidance in [`docs/claude.md/fleet/plan-storage.md`](docs/claude.md/fleet/plan-storage.md). +🚨 Design / implementation / migration plan docs live at `<repo-root>/.claude/plans/<lowercase-hyphenated>.md` and are **never tracked by version control** — the fleet `.gitignore` excludes `/.claude/*` and `plans/` is intentionally absent from the allowlist. Don't write plans into `docs/plans/` or a package-level `<pkg>/docs/plans/` (enforced by `.claude/hooks/fleet/plan-location-guard/`; bypass: `Allow plan-location bypass`). Full rationale + migration guidance in [`docs/claude.md/fleet/plan-storage.md`](docs/claude.md/fleet/plan-storage.md). ### Doc filenames -🚨 Markdown files are `lowercase-with-hyphens.md` and live in any `docs/` directory (repo-root `docs/`, package `packages/<pkg>/docs/`, language `packages/<pkg>/lang/<lang>/docs/`, etc.) or under `.claude/`. SCREAMING_CASE names are restricted to a fleet allowlist (`README`, `LICENSE`, `CLAUDE`, `CHANGELOG`, `CONTRIBUTING`, `GOVERNANCE`, `MAINTAINERS`, `NOTICE`, `SECURITY`, `SUPPORT`, etc.) and only at repo root, repo-root `docs/`, or `.claude/` — not deeper. `README.md` and `LICENSE` are allowed anywhere. Source-file-hint shape (`smol-ffi.js.md` describing `smol-ffi.js`) is allowed in any `docs/` (enforced by `.claude/hooks/markdown-filename-guard/`). +🚨 Markdown files are `lowercase-with-hyphens.md` and live in any `docs/` directory (repo-root `docs/`, package `packages/<pkg>/docs/`, language `packages/<pkg>/lang/<lang>/docs/`, etc.) or under `.claude/`. SCREAMING_CASE names are restricted to a fleet allowlist (`README`, `LICENSE`, `CLAUDE`, `CHANGELOG`, `CONTRIBUTING`, `GOVERNANCE`, `MAINTAINERS`, `NOTICE`, `SECURITY`, `SUPPORT`, etc.) and only at repo root, repo-root `docs/`, or `.claude/` — not deeper. `README.md` and `LICENSE` are allowed anywhere. Source-file-hint shape (`smol-ffi.js.md` describing `smol-ffi.js`) is allowed in any `docs/` (enforced by `.claude/hooks/fleet/markdown-filename-guard/`). + +### Cascade work is mechanical, not analytical + +🚨 **Wheelhouse → fleet syncing is automated dumb-bit propagation, not a thinking task.** `pnpm run sync --target . --fix` is the canonical operation: run it, commit the result with `chore(wheelhouse): cascade template@<sha>`, push. Do NOT analyze each modified file, design alternatives, or write multi-paragraph rationale for cascade commits — the wheelhouse template is the source of truth and the sync runner is the authority on what changes. If a cascade refuses to apply (lockfile policy reject, dependency soak window, broken hook from stale install), the right move is almost always (a) bump the immediate blocker (soak-exclude entry, lockfile rebuild) or (b) defer the repo and report it. Don't reason through a multi-step manual reproduction of what the sync runner already does. Cheap/fast model settings are the right default for cascade waves; reserve principal-engineer mode for genuine design work. ### Drift watch -🚨 **Drift across fleet repos is a defect, not a feature.** When two socket-\* repos pin different versions of the same shared resource (a tool in `external-tools.json`, a workflow SHA, a CLAUDE.md fleet block, a hook in `.claude/hooks/`, an upstream submodule, `.gitmodules` `# name-version` annotations enforced by `.claude/hooks/gitmodules-comment-guard/`, pnpm/Node `packageManager`/`engines`), **opt for the latest**. Canonical sources: `socket-registry`'s `setup-and-install` action for tool SHAs; `socket-wheelhouse`'s `template/` tree for `.claude/`, CLAUDE.md fleet block, hooks. Either reconcile in the same PR or open `chore(wheelhouse): cascade <thing> from <newer-repo>` and link it (enforced by `.claude/hooks/drift-check-reminder/`). Full drift-surface list + cascade-PR convention in [`docs/claude.md/fleet/drift-watch.md`](docs/claude.md/fleet/drift-watch.md). +🚨 **Drift across fleet repos is a defect, not a feature.** When two socket-\* repos pin different versions of the same shared resource (a tool in `external-tools.json`, a workflow SHA, a CLAUDE.md fleet block, a hook in `.claude/hooks/`, an upstream submodule, `.gitmodules` `# name-version` annotations enforced by `.claude/hooks/fleet/gitmodules-comment-guard/` + GitHub SHA-pin reachability across workflows/`.gitmodules`/`package.json` URLs by `.claude/hooks/fleet/uses-sha-verify-guard/` (bypass `Allow uses-sha-verify bypass`), pnpm/Node `packageManager`/`engines`), **opt for the latest**. Canonical sources: `socket-registry`'s `setup-and-install` action for tool SHAs; `socket-wheelhouse`'s `template/` tree for `.claude/`, CLAUDE.md fleet block, hooks. Either reconcile in the same PR or open `chore(wheelhouse): cascade <thing> from <newer-repo>` and link it (enforced by `.claude/hooks/fleet/drift-check-reminder/`). Full drift-surface list + cascade-PR convention in [`docs/claude.md/fleet/drift-watch.md`](docs/claude.md/fleet/drift-watch.md). ### Stranded cascades @@ -178,15 +166,23 @@ For non-trivial work (multi-file refactor, new feature, migration), the plan its ### Never fork fleet-canonical files locally -🚨 Edit fleet-canonical files (anything in the sync manifest) ONLY in `socket-wheelhouse/template/...` — never in a downstream repo. Spot a missing helper in a downstream copy? Lift it upstream and re-cascade (enforced by `.claude/hooks/no-fleet-fork-guard/`; bypass: `Allow fleet-fork bypass`). Full canonical-surface list + lifting workflow: [`docs/claude.md/wheelhouse/no-local-fork-canonical.md`](docs/claude.md/wheelhouse/no-local-fork-canonical.md). +🚨 Edit fleet-canonical files (anything in the sync manifest) ONLY in `socket-wheelhouse/template/...` — never in a downstream repo. Spot a missing helper in a downstream copy? Lift it upstream and re-cascade (enforced by `.claude/hooks/fleet/no-fleet-fork-guard/`; bypass: `Allow fleet-fork bypass`). Full canonical-surface list + lifting workflow: [`docs/claude.md/wheelhouse/no-local-fork-canonical.md`](docs/claude.md/wheelhouse/no-local-fork-canonical.md). ### Code style -Default to no comments (enforced by `.claude/hooks/no-meta-comments-guard/`); when written, write for a junior reader. Heaviest fleet invariants: no `TODO`/`FIXME`/stubs; `undefined` over `null`; `httpJson`/`httpText` from `@socketsecurity/lib/http-request` over `fetch()`; `safeDelete()` from `@socketsecurity/lib/fs` over `fs.rm`; Edit tool over `sed`/`awk`; `JSON.parse(JSON.stringify(x))` over `structuredClone(x)` for JSON-shaped data; `getDefaultLogger()` over `console.*` (enforced by `.claude/hooks/logger-guard/`); `@sinclair/typebox` for wire/config schema validation over zod/valibot/ajv. Cross-port files use `Lock-step` comments; see [`docs/claude.md/fleet/parser-comments.md`](docs/claude.md/fleet/parser-comments.md) §5–7 (enforced by `.claude/hooks/lock-step-ref-guard/` + `scripts/check-lock-step-{refs,header}.mts`; bypass: `Allow lock-step bypass`). Full ruleset in [`docs/claude.md/fleet/code-style.md`](docs/claude.md/fleet/code-style.md). +Default to no comments (enforced by `.claude/hooks/fleet/no-meta-comments-guard/`); when written, write for a junior reader. Heaviest fleet invariants: no `TODO`/`FIXME`/stubs; `undefined` over `null`; `httpJson`/`httpText` from `@socketsecurity/lib/http-request` over `fetch()`; `safeDelete()` from `@socketsecurity/lib/fs` over `fs.rm`; Edit tool over `sed`/`awk`; `JSON.parse(JSON.stringify(x))` over `structuredClone(x)` for JSON-shaped data; `getDefaultLogger()` over `console.*` (enforced by `.claude/hooks/fleet/logger-guard/`); `@sinclair/typebox` for wire/config schema validation over zod/valibot/ajv. Cross-port files use `Lock-step` comments; see [`docs/claude.md/fleet/parser-comments.md`](docs/claude.md/fleet/parser-comments.md) §5–7 (enforced by `.claude/hooks/fleet/lock-step-ref-guard/` + `scripts/check-lock-step-{refs,header}.mts`; bypass: `Allow lock-step bypass`). Full ruleset in [`docs/claude.md/fleet/code-style.md`](docs/claude.md/fleet/code-style.md). ### No underscore-prefixed identifiers -🚨 Never prefix an **identifier** (function, variable, type, export) with `_` — patterns like `_resetX`, `_cache`, `_doFoo`, `_internal` are banned at the symbol level. Privacy in TS is handled by module boundaries (not exporting) or by `_internal/` _directory_ layout; the underscore-as-internal-marker convention from other languages adds noise without enforcement. Exporting "internal" helpers is fine and explicitly preferred — easier to unit-test. **Exception:** the directory name `_internal/` is allowed (and is the documented way to signal module-private files); the rule is about identifiers inside files, not folder layout (enforced by `.claude/hooks/no-underscore-identifier-guard/` + the `socket/no-underscore-identifier` oxlint rule; bypass: `Allow underscore-identifier bypass`). +🚨 Never prefix an **identifier** (function, variable, type, export) with `_` — patterns like `_resetX`, `_cache`, `_doFoo`, `_internal` are banned at the symbol level. Privacy in TS is handled by module boundaries (not exporting) or by `_internal/` _directory_ layout; the underscore-as-internal-marker convention from other languages adds noise without enforcement. Exporting "internal" helpers is fine and explicitly preferred — easier to unit-test. **Exception:** the directory name `_internal/` is allowed (and is the documented way to signal module-private files); the rule is about identifiers inside files, not folder layout (enforced by `.claude/hooks/fleet/no-underscore-identifier-guard/` + the `socket/no-underscore-identifier` oxlint rule; bypass: `Allow underscore-identifier bypass`). + +### Function declarations over const expressions + +🚨 Module-scope functions use `function foo() {}` declarations, not `const foo = () => {}` or `const foo = function () {}` expressions. Function declarations hoist, sort cleanly under `socket/sort-source-methods` (one of the `socket/sort-*` family that also sorts named imports, object-literal properties, Set args, regex alternations, equality disjunctions, boolean chains — sort every sibling list alphanumerically, code or not; non-code surfaces JSON/YAML/markdown/bash are nudged by `.claude/hooks/fleet/alpha-sort-reminder/`, full ruleset [`docs/claude.md/fleet/sorting.md`](docs/claude.md/fleet/sorting.md)), and render with a stable `foo.name` in stack traces. Arrow expressions assigned to `const` lose all three. Apply also to `export` (write `export function foo()`, not `export const foo = () =>`). Exception: declarators carrying a TS type annotation (`const foo: Handler = () => ...`) — the annotation is the contract. Enforced by the `socket/prefer-function-declaration` oxlint rule (autofixes at commit time) and at edit time by `.claude/hooks/fleet/prefer-function-declaration-guard/` so the agent never writes the wrong shape in the first place. Bypass: `Allow function-declaration bypass`. + +### Export everything; NO `any` ever + +🚨 Every top-level function / interface / type alias / class in `src/` is `export`ed — privacy is handled by NOT importing, never by leaving symbols private. `typescript/no-explicit-any: "error"` is fleet-wide and never relaxed; `as any` is forbidden, bulk `: any` → `: unknown` breaks property access. Use real shapes (`Record<string, unknown>`, `t.ImportDeclaration`, …) or `unknown` + narrowing guards. Full rationale + typed-namespace-cast recipe: [`docs/claude.md/fleet/export-and-no-any.md`](docs/claude.md/fleet/export-and-no-any.md). ### File size @@ -194,15 +190,7 @@ Soft cap **500 lines**, hard cap **1000 lines** per source file. Past those, spl ### Lint rules: errors over warnings, fixable over reporting -- **Errors, not warnings.** Default `"error"` for new rules. -- **Fixable when possible.** Ship an autofix (`fixable: 'code'` + `fix(fixer) => ...`) whenever the rewrite is deterministic. -- **Skill or hook ≠ no rule.** Defense in depth — skill is docs, hook is edit-time, lint is commit-time. -- **Tooling: oxlint + oxfmt only.** No ESLint, no Prettier. Fleet socket-\* oxlint plugin lives in `template/.config/oxlint-plugin/`. -- **Invoke oxfmt / oxlint with `-c .config/...rc.json` explicitly.** Both tools accept a `-c PATH` (oxfmt) / `--config PATH` (oxlint). The fleet keeps both configs under `.config/`, not at repo root. Without the flag, the tools fall through to their built-in defaults — oxfmt's default is double-quotes + semis, the opposite of the fleet style, and would silently rewrite ~200 files on `pnpm run format`. Canonical script bodies in `manifest.mts` already encode the flag; the sync-scaffolding gate rewrites drifted scripts back to the canonical form. -- **No file-scope `oxlint-disable`.** Always use `oxlint-disable-next-line <rule> -- <reason>` per call site so each exemption is independently justified in `git blame`. File-scope blocks silently exempt future edits the author never thought about (enforced by `socket/no-file-scope-oxlint-disable` lint rule + `.claude/hooks/no-file-scope-oxlint-disable-guard/` edit-time guard). -- **Don't repeat the same `oxlint-disable-next-line` comment on adjacent lines.** Byte-identical disables on consecutive lines is a smell — refactor: lift the repeated call into a helper, or extract the disabled value into a single named constant that carries the exemption once. Per-call-site exemptions remain correct when reasons genuinely differ. Recipes in [`docs/claude.md/fleet/lint-rules.md`](docs/claude.md/fleet/lint-rules.md). - -Full rationale + cascade behavior in [`docs/claude.md/fleet/lint-rules.md`](docs/claude.md/fleet/lint-rules.md). +🚨 Fleet lint rules are guardrails for AI-generated code — make them strict. Default new rules to `"error"` (never `"warn"`). Ship an autofix when the rewrite is deterministic (`fixable: 'code'` + `fix(fixer) => ...`). Defense in depth: skill (docs) + hook (edit-time) + lint (commit-time) — having one doesn't excuse the others. Tooling: oxlint + oxfmt only (no ESLint, no Prettier); the fleet socket-\* plugin lives in `template/.config/oxlint-plugin/`. Always invoke with explicit `-c .config/...rc.json` so the tools don't fall through to their double-quotes + semis defaults. No file-scope `oxlint-disable` blocks — use `oxlint-disable-next-line <rule> -- <reason>` per call site (enforced by `socket/no-file-scope-oxlint-disable`, enforced by `.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/`). Don't stack byte-identical disables on adjacent lines — refactor to a helper or named constant. Full rationale + cascade behavior + recipes in [`docs/claude.md/fleet/lint-rules.md`](docs/claude.md/fleet/lint-rules.md). ### c8 / v8 coverage ignore directives @@ -210,7 +198,7 @@ Full rationale + cascade behavior in [`docs/claude.md/fleet/lint-rules.md`](docs ### 1 path, 1 reference -🚨 A path is constructed exactly once; everywhere else references the constructed value. Per-package `scripts/paths.mts` is the canonical owner; sub-packages inherit via `export *`. Build outputs live at `<package-root>/build/<mode>/<platform-arch>/out/Final/<artifact>`. Enforced at three levels: `.claude/hooks/path-guard/` (edit-time, build-path construction outside `paths.mts`), `.claude/hooks/paths-mts-inherit-guard/` (edit-time, sub-package inheritance), `scripts/check-paths.mts` (commit-time, whole-repo). `/guarding-paths` is the audit-and-fix skill. Full ruleset + canonical layout + common mistakes in [`docs/claude.md/fleet/path-hygiene.md`](docs/claude.md/fleet/path-hygiene.md). +🚨 A path is constructed exactly once; everywhere else references the constructed value. Per-package `scripts/paths.mts` is the canonical owner; sub-packages inherit via `export *`. Build outputs live at `<package-root>/build/<mode>/<platform-arch>/out/Final/<artifact>`. Enforced at three levels: `.claude/hooks/fleet/path-guard/` (edit-time, build-path construction outside `paths.mts`), `.claude/hooks/fleet/paths-mts-inherit-guard/` (edit-time, sub-package inheritance), `scripts/check-paths.mts` (commit-time, whole-repo). `/guarding-paths` is the audit-and-fix skill. Full ruleset + canonical layout + common mistakes in [`docs/claude.md/fleet/path-hygiene.md`](docs/claude.md/fleet/path-hygiene.md). ### Conformance runners @@ -218,27 +206,17 @@ External-spec-conformance runners (test262, WPT, future suites) use a canonical ### Cross-platform path matching -When a regex matches against a path string, **normalize the path first** with `normalizePath` (or `toUnixPath`) from `@socketsecurity/lib/paths/normalize` and write the regex against `/` only. Don't write dual-separator patterns like `[/\\]` — they're easy to miss in some branches, slower to read, and they multiply when you add `\\\\` for escaped Windows separators. `normalizePath` is the same helper the fleet uses everywhere; relying on it gives one path representation across `darwin` / `linux` / `win32` (enforced by `.claude/hooks/path-regex-normalize-reminder/`). Bypass: `Allow path-regex-normalize bypass`. +When a regex matches against a path string, **normalize the path first** with `normalizePath` (or `toUnixPath`) from `@socketsecurity/lib/paths/normalize` and write the regex against `/` only. Don't write dual-separator patterns like `[/\\]` — they're easy to miss in some branches, slower to read, and they multiply when you add `\\\\` for escaped Windows separators. `normalizePath` is the same helper the fleet uses everywhere; relying on it gives one path representation across `darwin` / `linux` / `win32` (enforced by `.claude/hooks/fleet/path-regex-normalize-reminder/`). Bypass: `Allow path-regex-normalize bypass`. ### Background Bash -Never use `Bash(run_in_background: true)` for test / build commands (`vitest`, `pnpm test`, `pnpm build`, `tsgo`). Backgrounded runs you don't poll get abandoned and leak Node workers. Background mode is for dev servers and long migrations whose results you'll consume. If a run hangs, kill it: `pkill -f "vitest/dist/workers"`. The `.claude/hooks/stale-process-sweeper/` `Stop` hook reaps true orphans as a safety net. - -`.DS_Store` files created by Finder mid-session are swept at turn-end by `.claude/hooks/sweep-ds-store/` (excludes `.git/` and `node_modules/`). Silent on the happy path; logs sweep count when files are found. No bypass — `.DS_Store` is never wanted in a repo (enforced by `.claude/hooks/sweep-ds-store/`). +Never use `Bash(run_in_background: true)` for test / build commands (`vitest`, `pnpm test`, `pnpm build`, `tsgo`) — backgrounded runs you don't poll leak Node workers. Background mode is for dev servers and long migrations whose results you'll consume. Kill hangs with `pkill -f "vitest/dist/workers"`; `.claude/hooks/fleet/stale-process-sweeper/` reaps orphans on Stop. `.DS_Store` files swept at turn-end by `.claude/hooks/fleet/sweep-ds-store/` — no bypass; never wanted in a repo. When writing Bash-allowlist hooks, prefer **AST-based parsing** (via `.claude/hooks/_shared/shell-command.mts` / `findInvocation`, wraps `shell-quote`) over regex when the rule reasons about command structure — regex approves `git $(echo rm) foo.txt`; AST blocks it. -When writing or extending a Bash-allowlist hook, prefer **AST-based parsing** over regex matchers when the rule needs to reason about command structure (chains, subshells, redirects, command substitution). Regex matchers approve `git $(echo rm) foo.txt` because the surface looks like `git`; an AST parser sees the substitution and blocks. Pure-syntactic rules (binary name only) can stay regex; structure-sensitive rules (no destructive chains, no `$(…)` containing destructive verbs) need a parser: use `.claude/hooks/_shared/shell-command.mts` (`findInvocation`, wraps `shell-quote`). - -🚨 Tests never connect to third-party servers — mock HTTP with `nock` (`disableNetConnect()` + stubs; `registry-*.test.mts` are canonical). Fleet `test/setup.mts` fails closed; localhost stays allowed. **Why:** 2026-05-27 `purlExists` conda/docker tests hit live registries, 15s timeouts. Bypass: `Allow unmocked-network-in-tests bypass` (enforced by `.claude/hooks/no-unmocked-network-in-tests-guard/`). +🚨 Tests never connect to third-party servers — mock HTTP with `nock` (`disableNetConnect()` + stubs; `registry-*.test.mts` are canonical). Fleet `test/setup.mts` fails closed; localhost stays allowed. Bypass: `Allow unmocked-network-in-tests bypass` (enforced by `.claude/hooks/fleet/no-unmocked-network-in-tests-guard/`). ### Judgment & self-evaluation -- If the request is based on a misconception, say so before executing. -- If you spot an adjacent bug, flag it: "I also noticed X — want me to fix it?" -- Fix warnings (lint / type / build / runtime) when you see them — don't leave them for later. For UI/render changes (`*.html` / `*.css` / `scripts/tour.mts`-shape files): rebuild the artifact + verify the rendered output BEFORE committing — past pattern: multiple wasted commits per session ("rebuild before you fucking commit") (enforced by `.claude/hooks/verify-rendered-output-before-commit-reminder/`). -- **Default to perfectionist** when you have latitude (absent a signal). "Works now" ≠ "right." Don't offer "do it right" vs "ship fast" as a binary menu — pick perfectionist and execute (enforced by `.claude/hooks/perfectionist-reminder/`). -- If a fix fails twice: stop, re-read top-down, state where the mental model was wrong, try something fundamentally different. -- **When the user authorizes a queue** ("complete each one", "100%", "do them all"): finish every item before stopping; don't post "what's next?" / "session totals" mid-queue (enforced by `.claude/hooks/dont-stop-mid-queue-reminder/`). Skip AskUserQuestion when recent transcript carries explicit go-ahead ("do it" / "yes" / "proceed") — pick the default and execute (enforced by `.claude/hooks/ask-suppression-reminder/`). -- **Direct imperatives → execute, don't litigate.** When the user issues a bare command ("use nvm 26.2.0", "cancel the build", "do it", "kill it"), the response is the tool call, not a paragraph weighing trade-offs. Hedge openers ("That won't help because…", "Let me explain why…", "Before I do that…") + analysis-before-action when the command was unambiguous are the failure mode. State the intent in one short sentence at most, then run the command (enforced by `.claude/hooks/follow-direct-imperative-reminder/`). +🚨 **Default to perfectionist** when you have latitude — "works now" ≠ "right" (enforced by `.claude/hooks/fleet/perfectionist-reminder/`). **Direct imperatives → execute, don't litigate**: bare commands ("do it", "kill it", "cancel the build") get the tool call, not a tradeoff paragraph (enforced by `.claude/hooks/fleet/follow-direct-imperative-reminder/`). **When the user authorizes a queue** ("complete each one", "100%", "do them all"): finish every item before stopping — no "what's next?" / "session totals" mid-queue (enforced by `.claude/hooks/fleet/dont-stop-mid-queue-reminder/`); skip AskUserQuestion when explicit go-ahead is already in transcript (enforced by `.claude/hooks/fleet/ask-suppression-reminder/`). **Fix warnings on sight** — don't label "pre-existing" / "out of scope" (enforced by `.claude/hooks/fleet/excuse-detector/`). **UI/render changes**: rebuild + visually verify BEFORE committing (enforced by `.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/`). Flag adjacent bugs ("I also noticed X — want me to fix it?"). Name misconceptions before executing. If a fix fails twice: stop, re-read top-down, try something fundamentally different. Full prose + scenarios + past incidents in [`docs/claude.md/fleet/judgment-and-self-evaluation.md`](docs/claude.md/fleet/judgment-and-self-evaluation.md). ### Error messages @@ -249,23 +227,23 @@ An error message is UI. The reader should fix the problem from the message alone 3. **Saw vs. wanted** — the bad value and the allowed shape or set. 4. **Fix** — one imperative action (`rename the key to …`). -Use `isError` / `isErrnoException` / `errorMessage` / `errorStack` from `@socketsecurity/lib/errors` over hand-rolled checks. Use `joinAnd` / `joinOr` from `@socketsecurity/lib/arrays` for allowed-set lists. Vague-shape `throw new Error("…")` strings are flagged on Stop (enforced by `.claude/hooks/error-message-quality-reminder/`). Full guidance in [`docs/claude.md/fleet/error-messages.md`](docs/claude.md/fleet/error-messages.md). +Use `isError` / `isErrnoException` / `errorMessage` / `errorStack` from `@socketsecurity/lib/errors` over hand-rolled checks. Use `joinAnd` / `joinOr` from `@socketsecurity/lib/arrays` for allowed-set lists. Vague-shape `throw new Error("…")` strings are flagged on Stop (enforced by `.claude/hooks/fleet/error-message-quality-reminder/`). Full guidance in [`docs/claude.md/fleet/error-messages.md`](docs/claude.md/fleet/error-messages.md). ### Token hygiene -🚨 Never emit a raw secret to tool output, commits, comments, or replies; when blocked, rewrite — don't bypass. Redact `token` / `jwt` / `api_key` / `secret` / `password` / `authorization` fields when citing API responses (`.claude/hooks/token-guard/`). Tokens live in env vars (CI) or the OS keychain (dev local) — never in `.env*` / `.envrc` / `~/.sfw.config` / dotfiles (`.claude/hooks/no-token-in-dotenv-guard/`). Setup + rotation: `node .claude/hooks/setup-security-tools/install.mts [--rotate]` — the ONLY correct rotator. Never call platform keychain CLIs from Bash to read (token is already in-process — use `findApiToken()` or `process.env.SOCKET_API_KEY` / `SOCKET_API_TOKEN`); writes/deletes are allowed. Bypass: `Allow blind-keychain-read bypass` (`.claude/hooks/no-blind-keychain-read-guard/`). Canonical env var: `SOCKET_API_TOKEN` in docs / workflow inputs / `.env.example`; local-dev keychain stores as `SOCKET_API_KEY`. Full spec: [`docs/claude.md/fleet/token-hygiene.md`](docs/claude.md/fleet/token-hygiene.md). +🚨 Never emit a raw secret to tool output, commits, comments, or replies; when blocked, rewrite — don't bypass. Redact `token` / `jwt` / `api_key` / `secret` / `password` / `authorization` fields when citing API responses (`.claude/hooks/fleet/token-guard/`). Tokens live in env vars (CI) or the OS keychain (dev local) — never in `.env*` / `.envrc` / `~/.sfw.config` / dotfiles (`.claude/hooks/fleet/no-token-in-dotenv-guard/`). Setup + rotation: `node .claude/hooks/fleet/setup-security-tools/install.mts [--rotate]` — the ONLY correct rotator. Never call platform keychain CLIs from Bash to read (token is already in-process — use `findApiToken()` or `process.env.SOCKET_API_KEY` / `SOCKET_API_TOKEN`); writes/deletes are allowed. Bypass: `Allow blind-keychain-read bypass` (`.claude/hooks/fleet/no-blind-keychain-read-guard/`). Canonical env var: `SOCKET_API_TOKEN` in docs / workflow inputs / `.env.example`; local-dev keychain stores as `SOCKET_API_KEY`. Full spec: [`docs/claude.md/fleet/token-hygiene.md`](docs/claude.md/fleet/token-hygiene.md). ### gh token hygiene -🚨 GitHub CLI tokens are high-blast-radius. Three invariants apply (enforced by `.claude/hooks/gh-token-hygiene-guard/`): +🚨 GitHub CLI tokens are high-blast-radius. Three invariants apply (enforced by `.claude/hooks/fleet/gh-token-hygiene-guard/`): 1. **Keychain storage only.** `gh auth status` must report `(keyring)`. On-disk `~/.config/gh/hosts.yml` rejected — re-auth with `gh auth logout && gh auth login` (keychain is the default since gh 2.40). Nx breach exfiltrated this file in <74s. 2. **`workflow` scope off by default; bypass single-use + Touch ID.** Type `Allow workflow-scope bypass` → `gh auth refresh -s workflow` → Touch ID (osascript fallback, absolute `/usr/bin/` paths defeat PATH-hijack) → ONE dispatch. Recommended scopes: `read:org, repo, gist` (gh forces `gist`). -3. **8-hour token age cap.** Same hook (not `auth-rotation-reminder`). Refresh: `gh auth refresh -h github.com`. The cap is the floor — real defense is signed commits + branch protection + audit log. Full spec: [`docs/claude.md/fleet/gh-token-hygiene.md`](docs/claude.md/fleet/gh-token-hygiene.md). +3. **8-hour token age cap.** Same hook. Refresh: `gh auth refresh -h github.com`. If you refreshed outside Claude (side shell), run `node .claude/hooks/fleet/gh-token-hygiene-guard/index.mts --stamp` to recover. Full spec + recovery: [`docs/claude.md/fleet/gh-token-hygiene.md`](docs/claude.md/fleet/gh-token-hygiene.md). ### Commit signing -🚨 Commits on `main`/`master` must be signed. Three layers: pre-commit config gate, pre-push signature check (`%G?` ∈ {`N`,`B`} blocks), GitHub `required_signatures`. Setup: `node .claude/hooks/setup-signing/install.mts`. Bypass envs `SOCKET_PRE_{COMMIT,PUSH}_ALLOW_UNSIGNED=1`. Full spec: [`docs/claude.md/fleet/commit-signing.md`](docs/claude.md/fleet/commit-signing.md). Post-hoc audit: `node scripts/audit-transcript.mts --recent` flags privileged tool uses in a session ([full stack](docs/claude.md/fleet/security-stack.md)). +🚨 Commits on `main`/`master` must be signed. Three layers: pre-commit config gate, pre-push signature check (`%G?` ∈ {`N`,`B`} blocks), GitHub `required_signatures`. Setup: `node .claude/hooks/fleet/setup-signing/install.mts`. Bypass envs `SOCKET_PRE_{COMMIT,PUSH}_ALLOW_UNSIGNED=1`. Full spec: [`docs/claude.md/fleet/commit-signing.md`](docs/claude.md/fleet/commit-signing.md). Post-hoc audit: `node scripts/audit-transcript.mts --recent` flags privileged tool uses in a session ([full stack](docs/claude.md/fleet/security-stack.md)). ### Agents & skills @@ -275,17 +253,12 @@ Use `isError` / `isErrnoException` / `errorMessage` / `errorStack` from `@socket - **Handing off to another agent** — see [`docs/claude.md/fleet/agent-delegation.md`](docs/claude.md/fleet/agent-delegation.md). - **Skill scope tiers** (fleet / partial / unique), the `updating` umbrella + `updating-*` siblings convention, and the `scripts/run-skill-fleet.mts` cross-fleet runner in [`docs/claude.md/fleet/agents-and-skills.md`](docs/claude.md/fleet/agents-and-skills.md). -### Tool-specific guards - -Hooks that gate specific external tools — they only fire when those tools appear in a command, so they're safe to wire fleet-wide: +### Hook registry -- `codex-no-write-guard` — blocks `codex` CLI / `codex:codex-rescue` Agent invocations with write-intent flags or prompts. The rule (originally from ultrathink: Codex regressions cost real perf; use Codex for advice not code changes) applies fleet-wide whenever Codex is invoked. Bypass: `Allow codex-write bypass` (enforced by `.claude/hooks/codex-no-write-guard/`). -- `concurrent-cargo-build-guard` — blocks a second `cargo build --release` while one is in flight (8 LLVM threads × 8-22GB = OOM on dual builds). Fires only on cargo release commands, so a no-op in non-cargo repos. Bypass: `Allow concurrent-cargo-build bypass` (enforced by `.claude/hooks/concurrent-cargo-build-guard/`). +Hooks under `.claude/hooks/fleet/<name>/` (fleet-canonical); host-repo-only hooks under `.claude/hooks/repo/<name>/` (exempt from citation gate). Each hook's README documents trigger + bypass. Full listing + per-hook enforcement details: [`docs/claude.md/fleet/hook-registry.md`](docs/claude.md/fleet/hook-registry.md). <!-- END FLEET-CANONICAL --> ## 🏗️ Project-Specific -Per-repo content lives below this header. Replace this paragraph with the host repo's architecture notes, build pipeline, commands, domain rules, etc. - -This template ships an empty Project-Specific section so a fresh `socket-*` repo can adopt the file unchanged. The fleet block above is byte-identical across the fleet; everything below this marker is freely editable per repo. +Per-repo content lives below this header. Replace this paragraph with the host repo's architecture invariants only. The whole CLAUDE.md is capped at 40 KB; full architecture / commands / build-pipeline / domain detail goes in `docs/claude.md/repo/<topic>.md` (per-repo, not cascaded). Keep this section to ≤8 lines per topic with a link out. From 03240e1ac255a9315e5fd604462bde60a3fc7402 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 01:42:40 -0400 Subject: [PATCH 11/17] =?UTF-8?q?chore(deps):=20bump=20pnpm=2011.3=20?= =?UTF-8?q?=E2=86=92=2011.4=20+=20lib=20catalog=205.28=20=E2=86=92=206.0.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog refresh matching the current wheelhouse template baseline: - pnpm @ 11.4.0 (was 11.3.0) — packageManager + engines.pnpm floor. - @socketsecurity/lib + lib-stable @ 6.0.5 (was 5.28.0). - Adds @types/shell-quote catalog entry for new fleet-canonical shell-command parser support. Regenerated `pnpm-lock.yaml` reflects the lib v6 upgrade chain. --- package.json | 4 +- pnpm-lock.yaml | 667 +++----------------------------------------- pnpm-workspace.yaml | 10 +- 3 files changed, 43 insertions(+), 638 deletions(-) diff --git a/package.json b/package.json index 778df49..60f8265 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "description": "SEA-packed binaries for @socketbin/* packages. Downloads prebuilt artifacts from socket-btm GH Releases, verifies checksums, publishes to npm.", "license": "MIT", "type": "module", - "packageManager": "pnpm@11.3.0", + "packageManager": "pnpm@11.4.0", "engines": { "node": ">=18.20.8", - "pnpm": ">=11.3.0" + "pnpm": ">=11.4.0" }, "scripts": { "build": "echo 'no build — publish-only repo'", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b6d9c9..36c22bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,11 +13,8 @@ catalogs: specifier: npm:@socketregistry/packageurl-js@1.4.2 version: 1.4.2 '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.3 - version: 6.0.3 - '@socketsecurity/sdk-stable': - specifier: npm:@socketsecurity/sdk@4.0.1 - version: 4.0.1 + specifier: npm:@socketsecurity/lib@6.0.5 + version: 6.0.5 '@types/node': specifier: 24.9.2 version: 24.9.2 @@ -45,7 +42,7 @@ catalogs: overrides: '@socketregistry/packageurl-js': 1.4.2 - '@socketsecurity/lib': 6.0.3 + '@socketsecurity/lib': 6.0.5 '@socketsecurity/registry': 2.0.2 '@socketsecurity/sdk': 4.0.1 @@ -63,11 +60,11 @@ importers: specifier: 'catalog:' version: '@socketregistry/packageurl-js@1.4.2' '@socketsecurity/lib': - specifier: 6.0.3 - version: 6.0.3(typescript@5.9.2) + specifier: 6.0.5 + version: 6.0.5(typescript@5.9.2) '@socketsecurity/lib-stable': specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' + version: '@socketsecurity/lib@6.0.5(typescript@5.9.2)' '@types/node': specifier: 'catalog:' version: 24.9.2 @@ -96,618 +93,65 @@ importers: specifier: 'catalog:' version: 4.0.3(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.9.0) - .claude/hooks/actionlint-on-workflow-edit: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/ask-suppression-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/auth-rotation-reminder: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/check-new-deps: - dependencies: - '@socketregistry/packageurl-js-stable': - specifier: 'catalog:' - version: '@socketregistry/packageurl-js@1.4.2' - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - '@socketsecurity/sdk-stable': - specifier: 'catalog:' - version: '@socketsecurity/sdk@4.0.1' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/claude-md-section-size-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/claude-md-size-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/codex-no-write-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/comment-tone-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/commit-author-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/commit-message-format-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/commit-pr-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/compound-lessons-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/concurrent-cargo-build-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/consumer-grep-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/cross-repo-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/default-branch-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/dirty-worktree-on-stop-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/dont-blame-user-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/dont-stop-mid-queue-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/drift-check-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/enterprise-push-property-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/error-message-quality-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/excuse-detector: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/extension-build-current-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/file-size-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/follow-direct-imperative-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/gh-token-hygiene-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/gitmodules-comment-guard: {} - - .claude/hooks/identifying-users-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/immutable-release-pattern-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/inline-script-defer-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/judgment-reminder: - dependencies: - compromise: - specifier: 14.15.1 - version: 14.15.1 - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/lock-step-ref-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/logger-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/markdown-filename-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/marketplace-comment-guard: {} - - .claude/hooks/minify-mcp-output: {} - - .claude/hooks/minimum-release-age-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/new-hook-claude-md-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-blind-keychain-read-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-disable-lint-rule-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-empty-commit-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-experimental-strip-types-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-external-issue-ref-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-file-scope-oxlint-disable-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-fleet-fork-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-meta-comments-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-non-fleet-push-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-orphaned-staging: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-package-json-pnpm-overrides-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-revert-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate: {} - .claude/hooks/no-structured-clone-prefer-json-guard: {} - - .claude/hooks/no-token-in-dotenv-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-underscore-identifier-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/node-modules-staging-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/overeager-staging-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/parallel-agent-edit-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/parallel-agent-on-stop-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/parallel-agent-staging-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/path-guard: {} - - .claude/hooks/path-regex-normalize-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/paths-mts-inherit-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/perfectionist-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/plan-location-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/plan-review-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/plugin-patch-format-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/pointer-comment-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/pr-vs-push-default-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/prefer-rebase-over-revert-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-darwin-arm64: {} - .claude/hooks/private-name-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-darwin-x64: {} - .claude/hooks/public-surface-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-linux-arm64: {} - .claude/hooks/pull-request-target-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-linux-arm64-musl: {} - .claude/hooks/readme-fleet-shape-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-linux-x64: {} - .claude/hooks/release-workflow-guard: - devDependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-linux-x64-musl: {} - .claude/hooks/scan-label-in-commit-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-win32-arm64: {} - .claude/hooks/setup-basics-tools: - devDependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binflate-win32-x64: {} - .claude/hooks/setup-claude-scanners: - devDependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binject: {} - .claude/hooks/setup-firewall: - devDependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binject-darwin-arm64: {} - .claude/hooks/setup-misc-tools: - devDependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binject-darwin-x64: {} - .claude/hooks/setup-security-tools: - dependencies: - '@sinclair/typebox': - specifier: 'catalog:' - version: 0.34.49 - '@socketregistry/packageurl-js-stable': - specifier: 'catalog:' - version: '@socketregistry/packageurl-js@1.4.2' - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' + packages/binject-linux-arm64: {} - .claude/hooks/setup-signing: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binject-linux-arm64-musl: {} - .claude/hooks/soak-exclude-date-annotation-guard: {} + packages/binject-linux-x64: {} - .claude/hooks/socket-token-minifier-start: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' + packages/binject-linux-x64-musl: {} - .claude/hooks/squash-history-reminder: - dependencies: - '@socketsecurity/lib-stable': - specifier: 'catalog:' - version: '@socketsecurity/lib@6.0.3(typescript@5.9.2)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binject-win32-arm64: {} - .claude/hooks/stale-process-sweeper: {} + packages/binject-win32-x64: {} - .claude/hooks/sweep-ds-store: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binpress: {} - .claude/hooks/token-guard: {} + packages/binpress-darwin-arm64: {} - .claude/hooks/trust-downgrade-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binpress-darwin-x64: {} - .claude/hooks/variant-analysis-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binpress-linux-arm64: {} - .claude/hooks/verify-rendered-output-before-commit-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binpress-linux-arm64-musl: {} - .claude/hooks/version-bump-order-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binpress-linux-x64: {} - .claude/hooks/vitest-include-vs-node-test-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binpress-linux-x64-musl: {} - .claude/hooks/workflow-uses-comment-guard: {} + packages/binpress-win32-arm64: {} - .claude/hooks/workflow-yaml-multiline-body-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 + packages/binpress-win32-x64: {} packages/build-infra: dependencies: '@socketsecurity/lib': - specifier: 6.0.3 - version: 6.0.3(typescript@5.9.2) + specifier: 6.0.5 + version: 6.0.5(typescript@5.9.2) devDependencies: '@types/node': specifier: 'catalog:' @@ -1281,9 +725,9 @@ packages: resolution: {integrity: sha512-yt9UfUzD02wZ7kwb67oe4jxG2D9JtgPqjrK/ans2BovFyeie0w8hvRR0MuOWM4mUt2371oFPp7NB6O5ZjYJmlw==} engines: {node: '>=18.20.8', pnpm: '>=11.0.0-rc.0'} - '@socketsecurity/lib@6.0.3': - resolution: {integrity: sha512-m6Rb7K+QQcwZehe8QOaO1aG6z1fVRtbVrlMmT5Luqelfrjppta3PuA5hfa2HDJ4hojrt+hAIpWbPZN5JBYrtkw==} - engines: {node: '>=22', pnpm: '>=11.3.0'} + '@socketsecurity/lib@6.0.5': + resolution: {integrity: sha512-Ka2k1xdm+tj0ttq/MmMKdcItI5AmNeJOxvibXAzt5NCq7WlnoqD7UFc/asduICekx2vC2V0ojG1wtMrhbH/bJA==} + engines: {node: '>=22', npm: '>=11.16.0', pnpm: '>=11.4.0'} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -1291,10 +735,6 @@ packages: typescript: optional: true - '@socketsecurity/sdk@4.0.1': - resolution: {integrity: sha512-fe3DQp2dFwhc0G6Za36GIMSV+QaPAP5L96K3ZOtywt9nhbwxc9IQwqzdOVztdn5Rbez3t9EHU9Esj24/hWdP0g==} - engines: {node: '>=18.20.8', pnpm: '>=11.0.0-rc.0'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1438,10 +878,6 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - compromise@14.15.1: - resolution: {integrity: sha512-9F3UkUaEU1PPz2fgStkE/TI4tk++0wHxS8xfWq9PQWL/v28dy8bEcPVVSLh3dISIRD7PEhJ8YTzHRKF8y9tnLA==} - engines: {node: '>=12.0.0'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1474,10 +910,6 @@ packages: engines: {node: '>=18'} hasBin: true - efrt@2.7.0: - resolution: {integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==} - engines: {node: '>=12.0.0'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1570,10 +1002,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - grad-school@0.0.5: - resolution: {integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==} - engines: {node: '>=8.0.0'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1746,9 +1174,6 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - suffix-thumb@5.0.2: - resolution: {integrity: sha512-I5PWXAFKx3FYnI9a+dQMWNqTxoRt6vdBdb0O+BJ1sxXCWtSoQCusc13E58f+9p4MYx/qCnEMkD5jac6K2j3dgA==} - taze@19.9.2: resolution: {integrity: sha512-If8bq7lSckIMPfXV+C9jjEfdsQnRryh/foKfpX/ah6zI0TrQfUGWSGCaaD32Bqy5/KGRmLZie3EwMSr3Au21XQ==} hasBin: true @@ -2201,12 +1626,10 @@ snapshots: '@socketregistry/packageurl-js@1.4.2': {} - '@socketsecurity/lib@6.0.3(typescript@5.9.2)': + '@socketsecurity/lib@6.0.5(typescript@5.9.2)': optionalDependencies: typescript: 5.9.2 - '@socketsecurity/sdk@4.0.1': {} - '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': @@ -2340,12 +1763,6 @@ snapshots: commander@13.1.0: {} - compromise@14.15.1: - dependencies: - efrt: 2.7.0 - grad-school: 0.0.5 - suffix-thumb: 5.0.2 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2379,8 +1796,6 @@ snapshots: transitivePeerDependencies: - encoding - efrt@2.7.0: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2497,8 +1912,6 @@ snapshots: gopd@1.2.0: {} - grad-school@0.0.5: {} - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -2692,8 +2105,6 @@ snapshots: std-env@3.10.0: {} - suffix-thumb@5.0.2: {} - taze@19.9.2: dependencies: '@antfu/ni': 27.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db48ca9..18f1de4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,8 +11,8 @@ catalog: '@sinclair/typebox': 0.34.49 '@socketregistry/packageurl-js': 1.4.2 '@socketregistry/packageurl-js-stable': npm:@socketregistry/packageurl-js@1.4.2 - '@socketsecurity/lib': 6.0.3 - '@socketsecurity/lib-stable': 'npm:@socketsecurity/lib@6.0.3' + '@socketsecurity/lib': 6.0.5 + '@socketsecurity/lib-stable': npm:@socketsecurity/lib@6.0.5 '@socketsecurity/registry': 2.0.2 '@socketsecurity/registry-stable': npm:@socketsecurity/registry@2.0.2 '@socketsecurity/sdk': 4.0.1 @@ -54,12 +54,6 @@ minimumReleaseAgeExclude: - '@socketregistry/*' - '@socketsecurity/*' - '@stuie/*' - # compromise@14.15.1 is the attested re-publish that clears the - # soak. The cascaded judgment-reminder hook pins it exactly, so it - # needs a soak bypass. Scoped to the exact tag so a future 14.15.2 - # still goes through the normal soak time. - # published: 2026-05-27 | removable: 2026-06-03 - - 'compromise@14.15.1' # Network-mocking lib used in fleet test suites. v15 betas pre-date # npm's `time` field for the major; allow pinned beta until v15 GA. - 'nock@15.0.0-beta.11' From ad063399779cc856df09591bbe925b45a5f98653 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 02:09:13 -0400 Subject: [PATCH 12/17] chore: post-rebase fleet cascade + stale-import fixups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-rebase reconciliation after `git rebase --onto origin/main`. Two classes of change rolled into one commit because they're a single logical unit (get the rebased branch green): 1. `pnpm run sync --target . --fix` cascade-pull from socket-wheelhouse template. Refreshes hooks, _shared/, configs, skills, docs, oxlint plugin rules. Adds new hook `alpha-sort-reminder`. Renames `acorn-wasm-sync.mts` → `acorn-sync.mts` per fleet's vendor-naming convention. 2. Stale-import fixups for lib v6 path migration: - `scripts/{check,lint,test,security,janus}.mts` → `@socketsecurity/lib-stable/process/spawn/child` (was `lib-stable/spawn`). - `scripts/validate-{esbuild-minify,file-count,no-link-deps}.mts` → `@socketsecurity/lib-stable/logger/default`. - `scripts/clean.mts` → `lib/fs/safe` + `lib/logger/default`. - `scripts/lib/error-utils.mts` → `lib/errors/message` + `lib/errors/stack` (split modules in lib v6). - `scripts/validate-esbuild-minify.mts`, `validate-file-count.mts`, `.config/esbuild/shorten-paths.mts` → `errorMessage()` helper instead of inline `instanceof Error ? .message : String()`. - `.config/vitest.config.mts` → named const + targeted `oxlint-disable-next-line` for `socket/no-default-export` (vitest discovers configs by default export). - `scripts/source-allowlist.mts` → separate `import type` per `socket/prefer-separate-type-import`. - `scripts/publish.mts:340` → narrow `Object.values()` element with a typed cast. 3. Remove 7 orphaned legacy-path hooks at `.claude/hooks/<name>/` (no-non-fleet-push-guard, no-package-json-pnpm-overrides-guard, parallel-agent-{edit,on-stop,staging}-{guard,reminder}, plugin-patch-format-guard, trust-downgrade-guard). All seven were duplicated at `.claude/hooks/fleet/<name>/` by an earlier cascade; the legacy paths import `../_shared/` which no longer exists post-migration. Keeps fleet/ versions. After this commit: `pnpm install` clean, lint passes, format clean. Pre-existing tsgo errors remain in three hook subtrees with their own package.json dep gaps (`shell-quote`, `@socketsecurity/sdk- stable`, `compromise`) — not regressions from this rebase. --- .claude/hooks/fleet/_shared/acorn/README.md | 8 +- .../{acorn-wasm-sync.mts => acorn-sync.mts} | 0 .claude/hooks/fleet/_shared/acorn/index.mts | 14 +- .claude/hooks/fleet/_shared/fleet-repos.mts | 41 ++- .claude/hooks/fleet/_shared/foreign-paths.mts | 59 ++-- .claude/hooks/fleet/_shared/shell-command.mts | 7 +- .../fleet/_shared/test/shell-command.test.mts | 36 +- .../hooks/fleet/_shared/token-patterns.mts | 6 +- .../hooks/fleet/alpha-sort-reminder/README.md | 46 +++ .../hooks/fleet/alpha-sort-reminder/index.mts | 249 ++++++++++++++ .../alpha-sort-reminder}/package.json | 2 +- .../alpha-sort-reminder/test/index.test.mts | 82 +++++ .../alpha-sort-reminder}/tsconfig.json | 0 .../index.mts | 15 +- .../test/index.test.mts | 11 +- .../answer-status-requests-reminder/index.mts | 17 +- .../test/index.test.mts | 13 +- .../hooks/fleet/avoid-cd-reminder/index.mts | 13 +- .../fleet/broken-hook-detector/index.mts | 6 +- .../broken-hook-detector/test/index.test.mts | 15 +- .../fleet/codex-no-write-guard/index.mts | 4 +- .../fleet/dont-blame-user-reminder/README.md | 12 +- .../test/index.test.mts | 8 +- .../test/index.test.mts | 6 +- .../fleet/overeager-staging-guard/index.mts | 6 +- .../fleet/parallel-agent-edit-guard/README.md | 2 +- .../test/index.test.mts | 13 +- .../parallel-agent-staging-guard/README.md | 16 +- .../parallel-agent-staging-guard/index.mts | 9 +- .../test/index.test.mts | 18 +- .../test/index.test.mts | 13 +- .../test/index.test.mts | 4 +- .../index.mts | 4 +- .../test/index.test.mts | 4 +- .../provenance-publish-reminder/index.mts | 3 - .../test/index.test.mts | 12 +- .../fleet/setup-basics-tools/install.mts | 4 +- .../hooks/fleet/setup-firewall/install.mts | 8 +- .../hooks/fleet/setup-misc-tools/install.mts | 4 +- .../fleet/setup-security-tools/install.mts | 12 +- .../fleet/setup-security-tools/update.mts | 2 +- .../test/index.test.mts | 16 +- .../fleet/trust-downgrade-guard/README.md | 2 +- .../trust-downgrade-guard/test/index.test.mts | 9 +- .../fleet/uses-sha-verify-guard/README.md | 10 +- .../fleet/uses-sha-verify-guard/index.mts | 13 +- .../uses-sha-verify-guard/test/index.test.mts | 16 +- .../hooks/no-non-fleet-push-guard/README.md | 81 ----- .../hooks/no-non-fleet-push-guard/index.mts | 173 ---------- .../no-non-fleet-push-guard/package.json | 15 - .../test/index.test.mts | 171 ---------- .../README.md | 55 --- .../index.mts | 179 ---------- .../package.json | 15 - .../test/index.test.mts | 147 -------- .../tsconfig.json | 16 - .../hooks/parallel-agent-edit-guard/README.md | 51 --- .../hooks/parallel-agent-edit-guard/index.mts | 139 -------- .../parallel-agent-edit-guard/package.json | 15 - .../test/index.test.mts | 180 ---------- .../parallel-agent-edit-guard/tsconfig.json | 16 - .../parallel-agent-on-stop-reminder/README.md | 37 -- .../parallel-agent-on-stop-reminder/index.mts | 96 ------ .../package.json | 15 - .../test/index.test.mts | 138 -------- .../tsconfig.json | 16 - .../parallel-agent-staging-guard/README.md | 47 --- .../parallel-agent-staging-guard/index.mts | 191 ----------- .../parallel-agent-staging-guard/package.json | 15 - .../test/index.test.mts | 194 ----------- .../tsconfig.json | 16 - .../hooks/plugin-patch-format-guard/README.md | 37 -- .../hooks/plugin-patch-format-guard/index.mts | 272 --------------- .../plugin-patch-format-guard/package.json | 18 - .../test/index.test.mts | 251 -------------- .../plugin-patch-format-guard/tsconfig.json | 16 - .claude/hooks/trust-downgrade-guard/README.md | 58 ---- .claude/hooks/trust-downgrade-guard/index.mts | 323 ------------------ .../trust-downgrade-guard/test/index.test.mts | 208 ----------- .../hooks/trust-downgrade-guard/tsconfig.json | 16 - .claude/skills/guarding-paths/SKILL.md | 5 +- .claude/skills/updating/SKILL.md | 2 +- .config/.prettierignore | 10 +- .config/esbuild/shorten-paths.mts | 4 +- .config/oxfmtrc.json | 3 + .config/oxlint-plugin/index.mts | 2 + .../rules/no-eslint-biome-config-ref.mts | 12 +- .../rules/no-inline-defer-async.mts | 17 +- .config/oxlint-plugin/rules/no-npx-dlx.mts | 12 +- .../rules/no-underscore-identifier.mts | 4 +- .../rules/prefer-error-message.mts | 48 ++- .../rules/prefer-pure-call-form.mts | 32 +- .../rules/prefer-safe-delete.mts | 3 +- .../rules/sort-object-literal-properties.mts | Bin 0 -> 7673 bytes .config/oxlintrc.json | 5 + .config/vitest.config.mts | 5 +- .gitattributes | 7 +- docs/claude.md/fleet/export-and-no-any.md | 66 ++++ docs/claude.md/fleet/path-hygiene.md | 4 +- .../claude.md/fleet/public-surface-hygiene.md | 11 + docs/claude.md/fleet/security-stack.md | 32 +- docs/claude.md/fleet/sorting.md | 141 +++++++- pnpm-lock.yaml | 54 --- scripts/ai-lint-fix/cli.mts | 11 +- scripts/ai-lint-fix/rule-guidance.mts | 54 +-- scripts/audit-transcript.mts | 36 +- scripts/check.mts | 6 +- scripts/clean.mts | 4 +- scripts/install-claude-plugins.mts | 6 +- scripts/lib/error-utils.mts | 6 +- scripts/lint.mts | 80 ++++- .../scripts/lib/read-stdin-sync.mjs | 28 +- scripts/publish-shared.mts | 253 ++++++++++++++ scripts/publish.mts | 2 +- scripts/source-allowlist.mts | 6 +- scripts/test.mts | 148 ++++---- scripts/validate-esbuild-minify.mts | 7 +- scripts/validate-file-count.mts | 7 +- scripts/validate-file-size.mts | 4 +- scripts/validate-no-link-deps.mts | 2 +- 120 files changed, 1412 insertions(+), 3814 deletions(-) rename .claude/hooks/fleet/_shared/acorn/{acorn-wasm-sync.mts => acorn-sync.mts} (100%) create mode 100644 .claude/hooks/fleet/alpha-sort-reminder/README.md create mode 100644 .claude/hooks/fleet/alpha-sort-reminder/index.mts rename .claude/hooks/{trust-downgrade-guard => fleet/alpha-sort-reminder}/package.json (85%) create mode 100644 .claude/hooks/fleet/alpha-sort-reminder/test/index.test.mts rename .claude/hooks/{no-non-fleet-push-guard => fleet/alpha-sort-reminder}/tsconfig.json (100%) delete mode 100644 .claude/hooks/no-non-fleet-push-guard/README.md delete mode 100644 .claude/hooks/no-non-fleet-push-guard/index.mts delete mode 100644 .claude/hooks/no-non-fleet-push-guard/package.json delete mode 100644 .claude/hooks/no-non-fleet-push-guard/test/index.test.mts delete mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/README.md delete mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/index.mts delete mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/package.json delete mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts delete mode 100644 .claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json delete mode 100644 .claude/hooks/parallel-agent-edit-guard/README.md delete mode 100644 .claude/hooks/parallel-agent-edit-guard/index.mts delete mode 100644 .claude/hooks/parallel-agent-edit-guard/package.json delete mode 100644 .claude/hooks/parallel-agent-edit-guard/test/index.test.mts delete mode 100644 .claude/hooks/parallel-agent-edit-guard/tsconfig.json delete mode 100644 .claude/hooks/parallel-agent-on-stop-reminder/README.md delete mode 100644 .claude/hooks/parallel-agent-on-stop-reminder/index.mts delete mode 100644 .claude/hooks/parallel-agent-on-stop-reminder/package.json delete mode 100644 .claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts delete mode 100644 .claude/hooks/parallel-agent-on-stop-reminder/tsconfig.json delete mode 100644 .claude/hooks/parallel-agent-staging-guard/README.md delete mode 100644 .claude/hooks/parallel-agent-staging-guard/index.mts delete mode 100644 .claude/hooks/parallel-agent-staging-guard/package.json delete mode 100644 .claude/hooks/parallel-agent-staging-guard/test/index.test.mts delete mode 100644 .claude/hooks/parallel-agent-staging-guard/tsconfig.json delete mode 100644 .claude/hooks/plugin-patch-format-guard/README.md delete mode 100644 .claude/hooks/plugin-patch-format-guard/index.mts delete mode 100644 .claude/hooks/plugin-patch-format-guard/package.json delete mode 100644 .claude/hooks/plugin-patch-format-guard/test/index.test.mts delete mode 100644 .claude/hooks/plugin-patch-format-guard/tsconfig.json delete mode 100644 .claude/hooks/trust-downgrade-guard/README.md delete mode 100644 .claude/hooks/trust-downgrade-guard/index.mts delete mode 100644 .claude/hooks/trust-downgrade-guard/test/index.test.mts delete mode 100644 .claude/hooks/trust-downgrade-guard/tsconfig.json create mode 100644 .config/oxlint-plugin/rules/sort-object-literal-properties.mts create mode 100644 docs/claude.md/fleet/export-and-no-any.md create mode 100644 scripts/publish-shared.mts diff --git a/.claude/hooks/fleet/_shared/acorn/README.md b/.claude/hooks/fleet/_shared/acorn/README.md index e039213..e0bf5f2 100644 --- a/.claude/hooks/fleet/_shared/acorn/README.md +++ b/.claude/hooks/fleet/_shared/acorn/README.md @@ -1,4 +1,4 @@ -# acorn-wasm — shared parser for fleet hooks +# acorn — shared wasm parser for fleet hooks Vendored from [`@ultrathink/acorn-monorepo`](https://github.com/SocketDev/ultrathink/tree/main/packages/acorn)'s @@ -13,7 +13,7 @@ The three vendored files come straight from the ultrathink prod build: - `acorn.wasm` — compiled Rust acorn parser, ~3.3 MB. - `acorn-bindgen.cjs` — wasm-bindgen JS glue. -- `acorn-wasm-sync.mts` — sync ESM loader (no top-level await, +- `acorn-sync.mts` — sync ESM loader (no top-level await, `WebAssembly.Instance` constructed at module import). The artifact is rebuilt in ultrathink with `pnpm run @@ -39,12 +39,12 @@ Last refreshed: 2026-05-20 (ultrathink build dated 2026-05-20). ## Public surface -`template/.claude/hooks/_shared/acorn/index.mts` is the canonical +`template/.claude/hooks/fleet/_shared/acorn/index.mts` is the canonical import path for fleet hooks. It re-exports a narrow `tryParse` / `walkSimple` / `findBareCallsTo` surface — see the module's JSDoc for the parse-failure tolerance + visitor patterns hook authors rely on. -Don't import `acorn-wasm-sync.mts` directly from hooks; the `index.mts` +Don't import `acorn-sync.mts` directly from hooks; the `index.mts` wrapper provides the failure-handling + visitor adapters every hook needs. diff --git a/.claude/hooks/fleet/_shared/acorn/acorn-wasm-sync.mts b/.claude/hooks/fleet/_shared/acorn/acorn-sync.mts similarity index 100% rename from .claude/hooks/fleet/_shared/acorn/acorn-wasm-sync.mts rename to .claude/hooks/fleet/_shared/acorn/acorn-sync.mts diff --git a/.claude/hooks/fleet/_shared/acorn/index.mts b/.claude/hooks/fleet/_shared/acorn/index.mts index 92f7421..f09403f 100644 --- a/.claude/hooks/fleet/_shared/acorn/index.mts +++ b/.claude/hooks/fleet/_shared/acorn/index.mts @@ -1,14 +1,14 @@ /** * @file Shared acorn-wasm wrapper for fleet hooks. Vendored from - * socket-lib/vendor/acorn-wasm pending the `@ultrathink/acorn` npm publish; - * once that lands, fleet hooks switch to the published package and this - * directory can be retired. Surface kept narrow: `parse(source, opts)` for - * raw AST + `simple(source, visitors, opts)` for visitor-based walks. - * Higher-level shape detectors (`findCallsTo`, `findBareCallsTo`) cover the - * common "lint a specific identifier call" pattern that hooks need. + * socket-lib/vendor/acorn pending the `@ultrathink/acorn` npm publish; once + * that lands, fleet hooks switch to the published package and this directory + * can be retired. Surface kept narrow: `parse(source, opts)` for raw AST + + * `simple(source, visitors, opts)` for visitor-based walks. Higher-level + * shape detectors (`findCallsTo`, `findBareCallsTo`) cover the common "lint a + * specific identifier call" pattern that hooks need. */ -import { parse as wasmParse, simple as wasmSimple } from './acorn-wasm-sync.mts' +import { parse as wasmParse, simple as wasmSimple } from './acorn-sync.mts' export interface AcornNode { type: string diff --git a/.claude/hooks/fleet/_shared/fleet-repos.mts b/.claude/hooks/fleet/_shared/fleet-repos.mts index b16ca05..3234460 100644 --- a/.claude/hooks/fleet/_shared/fleet-repos.mts +++ b/.claude/hooks/fleet/_shared/fleet-repos.mts @@ -1,18 +1,17 @@ /** - * @file Single source of truth for fleet-repo membership, shared by the - * hooks that need to know "is this one of ours?": + * @file Single source of truth for fleet-repo membership, shared by the hooks + * that need to know "is this one of ours?": * * - `cross-repo-guard` — blocks `../<fleet-repo>/…` sibling-path imports. - * - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the - * fleet (a non-fleet repo never has the fleet hook chain installed, so - * the guard has to live agent-side and know the roster itself). - * - * This is the BROAD membership set, intentionally wider than the cascade - * roster in `cascading-fleet/lib/fleet-repos.json` (which lists only - * template-cascade targets and omits e.g. `ultrathink`). Membership here - * answers "may fleet tooling act on this repo at all", not "does the - * wheelhouse cascade into it". Keep the two distinct: a repo can be a - * fleet member (pushable, importable) without being a cascade target. + * - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the fleet (a + * non-fleet repo never has the fleet hook chain installed, so the guard has + * to live agent-side and know the roster itself). This is the BROAD + * membership set, intentionally wider than the cascade roster in + * `cascading-fleet/lib/fleet-repos.json` (which lists only template-cascade + * targets and omits e.g. `ultrathink`). Membership here answers "may fleet + * tooling act on this repo at all", not "does the wheelhouse cascade into + * it". Keep the two distinct: a repo can be a fleet member (pushable, + * importable) without being a cascade target. */ // All under the SocketDev org. Names match the GitHub repo slug @@ -40,24 +39,24 @@ const FLEET_REPO_SET: ReadonlySet<string> = new Set(FLEET_REPO_NAMES) /** * True when `slug` (a bare repo name like `socket-cli`) is a fleet member. - * Case-insensitive — GitHub slugs are case-insensitive and remotes can be - * typed in any case. + * Case-insensitive — GitHub slugs are case-insensitive and remotes can be typed + * in any case. */ export function isFleetRepo(slug: string): boolean { return FLEET_REPO_SET.has(slug.toLowerCase()) } /** - * Extract the bare repo slug from a git remote URL, or `undefined` when the - * URL isn't a recognizable GitHub remote. Handles the three forms git emits: + * Extract the bare repo slug from a git remote URL, or `undefined` when the URL + * isn't a recognizable GitHub remote. Handles the three forms git emits: * - * git@github.com:SocketDev/socket-cli.git (SSH scp-like) - * ssh://git@github.com/SocketDev/socket-cli.git (SSH URL) - * https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git) + * Git@github.com:SocketDev/socket-cli.git (SSH scp-like) + * ssh://git@github.com/SocketDev/socket-cli.git (SSH URL) + * https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git) * * Returns the slug only (`socket-cli`), lowercased. The owner is dropped on - * purpose: membership is keyed on the repo name, and a fork under a - * different owner is still not a fleet push target. + * purpose: membership is keyed on the repo name, and a fork under a different + * owner is still not a fleet push target. */ export function slugFromRemoteUrl(url: string): string | undefined { const trimmed = url.trim() diff --git a/.claude/hooks/fleet/_shared/foreign-paths.mts b/.claude/hooks/fleet/_shared/foreign-paths.mts index e691c94..4ef3cf0 100644 --- a/.claude/hooks/fleet/_shared/foreign-paths.mts +++ b/.claude/hooks/fleet/_shared/foreign-paths.mts @@ -1,24 +1,23 @@ /** * @file Shared heuristic for "which dirty paths in this checkout were authored - * by ANOTHER agent, not this session". Two responsibilities the parallel-agent - * hooks (and overeager-staging-guard) share: + * by ANOTHER agent, not this session". Two responsibilities the + * parallel-agent hooks (and overeager-staging-guard) share: * - * 1. `readTouchedPaths(transcriptPath)` — the set of absolute paths THIS - * session modified: Edit / Write `file_path` targets plus `git add|mv|rm - * <path>` arguments parsed out of Bash commands. Lifted here from + * 1. `readTouchedPaths(transcriptPath)` — the set of absolute paths THIS session + * modified: Edit / Write `file_path` targets plus `git add|mv|rm <path>` + * arguments parsed out of Bash commands. Lifted here from * overeager-staging-guard so the three consumers share one implementation * instead of drifting copies. - * 2. `listForeignDirtyPaths(repoDir, touched, opts)` — dirty paths - * (`git status --porcelain`) that this session did NOT touch and whose - * mtime is recent (so stale pre-session dirt doesn't false-fire). These are - * the likely fingerprints of a concurrent Claude session sharing the - * `.git/` — the failure mode where `git add -A` / `git stash` / `git - * reset --hard` would sweep up or destroy another agent's work. - * - * Fail-open contract (matches the rest of `_shared/`): every helper returns a - * safe default on any parse / I/O error rather than throwing. A hook that - * crashes wedges every Claude Code call; one that returns "nothing foreign" - * simply falls through to the hook's default decision. + * 2. `listForeignDirtyPaths(repoDir, touched, opts)` — dirty paths (`git status + * --porcelain`) that this session did NOT touch and whose mtime is recent + * (so stale pre-session dirt doesn't false-fire). These are the likely + * fingerprints of a concurrent Claude session sharing the `.git/` — the + * failure mode where `git add -A` / `git stash` / `git reset --hard` would + * sweep up or destroy another agent's work. Fail-open contract (matches + * the rest of `_shared/`): every helper returns a safe default on any + * parse / I/O error rather than throwing. A hook that crashes wedges every + * Claude Code call; one that returns "nothing foreign" simply falls + * through to the hook's default decision. */ import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' @@ -45,9 +44,13 @@ const UNTRACKED_BY_DEFAULT_PREFIXES = [ const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000 export interface ForeignPathsOptions { - /** Max age (ms) of a dirty path's mtime to count as foreign. */ + /** + * Max age (ms) of a dirty path's mtime to count as foreign. + */ readonly maxAgeMs?: number | undefined - /** Injectable clock for tests. Defaults to `Date.now()`. */ + /** + * Injectable clock for tests. Defaults to `Date.now()`. + */ readonly now?: number | undefined } @@ -62,10 +65,10 @@ export function isUntrackedByDefault(p: string): boolean { /** * Parse `git add|mv|rm <path>` arguments out of a Bash command line and add the - * resolved absolute paths to `touched`. Broad forms (`git add .` / `-A`) are NOT - * surgical adds and are skipped — they don't establish authorship of a specific - * file. Tolerates leading `NAME=val` env assignments and `&&` / `;` / `|` - * chains. + * resolved absolute paths to `touched`. Broad forms (`git add .` / `-A`) are + * NOT surgical adds and are skipped — they don't establish authorship of a + * specific file. Tolerates leading `NAME=val` env assignments and `&&` / `;` / + * `|` chains. */ export function addTouchedFromBash( command: string, @@ -173,7 +176,7 @@ export interface DirtyEntry { /** * Parse `git status --porcelain` output, dropping untracked-by-default trees. - * Rename entries (`R old -> new`) resolve to the new path. + * Rename entries (`R old -> new`) resolve to the new path. */ export function parsePorcelain(out: string): DirtyEntry[] { const entries: DirtyEntry[] = [] @@ -196,11 +199,11 @@ export function parsePorcelain(out: string): DirtyEntry[] { /** * Dirty paths this session did NOT author and that changed recently — the * fingerprint of a concurrent agent on the same `.git/`. A path qualifies when: - * - it's dirty (modified / deleted / untracked, minus vendored trees), AND - * - its resolved absolute path is not in `touched`, AND - * - its on-disk mtime is within `maxAgeMs` of `now`. - * Deleted paths (no mtime) are included only if their status is `D`/`R` — a - * delete by another agent is still foreign. Returns repo-relative paths. + * - it's dirty (modified / deleted / untracked, minus vendored trees), AND - + * its resolved absolute path is not in `touched`, AND - its on-disk mtime is + * within `maxAgeMs` of `now`. Deleted paths (no mtime) are included only if + * their status is `D`/`R` — a delete by another agent is still foreign. Returns + * repo-relative paths. */ export function listForeignDirtyPaths( repoDir: string, diff --git a/.claude/hooks/fleet/_shared/shell-command.mts b/.claude/hooks/fleet/_shared/shell-command.mts index 9a4e6ea..6ba9ed2 100644 --- a/.claude/hooks/fleet/_shared/shell-command.mts +++ b/.claude/hooks/fleet/_shared/shell-command.mts @@ -248,7 +248,12 @@ export function detectBroadGitAdd(command: string): string | undefined { } for (let k = 0, { length } = c.args; k < length; k += 1) { const arg = c.args[k]! - if (arg === '--all' || arg === '-A' || arg === '--update' || arg === '-u') { + if ( + arg === '--all' || + arg === '-A' || + arg === '--update' || + arg === '-u' + ) { return `git add ${arg}` } if (arg === '.') { diff --git a/.claude/hooks/fleet/_shared/test/shell-command.test.mts b/.claude/hooks/fleet/_shared/test/shell-command.test.mts index 2d8b6ff..8b15379 100644 --- a/.claude/hooks/fleet/_shared/test/shell-command.test.mts +++ b/.claude/hooks/fleet/_shared/test/shell-command.test.mts @@ -49,31 +49,51 @@ test('parseCommands: comments dropped', () => { }) test('findInvocation: matches plain git push', () => { - assert.ok(findInvocation('git push origin main', { binary: 'git', subcommand: 'push' })) + assert.ok( + findInvocation('git push origin main', { + binary: 'git', + subcommand: 'push', + }), + ) }) test('findInvocation: matches git -C <dir> push (subcommand after option value)', () => { - assert.ok(findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' })) + assert.ok( + findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' }), + ) }) test('findInvocation: matches git -c k=v push', () => { - assert.ok(findInvocation('git -c foo=bar push', { binary: 'git', subcommand: 'push' })) + assert.ok( + findInvocation('git -c foo=bar push', { + binary: 'git', + subcommand: 'push', + }), + ) }) test('findInvocation: matches push reached via && chain', () => { assert.ok( - findInvocation('cd /x/depot && git push', { binary: 'git', subcommand: 'push' }), + findInvocation('cd /x/depot && git push', { + binary: 'git', + subcommand: 'push', + }), ) }) test('findInvocation: matches push in a pipe chain', () => { assert.ok( - findInvocation('ls | grep x && git push', { binary: 'git', subcommand: 'push' }), + findInvocation('ls | grep x && git push', { + binary: 'git', + subcommand: 'push', + }), ) }) test('findInvocation: a different subcommand does not match', () => { - assert.ok(!findInvocation('git status', { binary: 'git', subcommand: 'push' })) + assert.ok( + !findInvocation('git status', { binary: 'git', subcommand: 'push' }), + ) }) test('findInvocation: quoted "git push" in a commit message is NOT a push', () => { @@ -122,7 +142,9 @@ test('commandsFor: binary-in-a-path is NOT the binary', () => { }) test('invocationHasFlag: exact flag', () => { - assert.ok(invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w'])) + assert.ok( + invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w']), + ) assert.ok(invocationHasFlag('codex -w prompt', 'codex', ['--write', '-w'])) }) diff --git a/.claude/hooks/fleet/_shared/token-patterns.mts b/.claude/hooks/fleet/_shared/token-patterns.mts index bd91c3f..a17656d 100644 --- a/.claude/hooks/fleet/_shared/token-patterns.mts +++ b/.claude/hooks/fleet/_shared/token-patterns.mts @@ -195,9 +195,9 @@ export function isTokenKey(key: string): boolean { * inspection). * * Kept short to minimize false positives. A "PASSWORD" mention in a - * commit-message body would otherwise trip every commit, so token-guard - * narrows matches to assignment / flag-value positions rather than any - * occurrence in arbitrary text. + * commit-message body would otherwise trip every commit, so token-guard narrows + * matches to assignment / flag-value positions rather than any occurrence in + * arbitrary text. */ export const SENSITIVE_NAME_FRAGMENTS: readonly string[] = [ 'TOKEN', diff --git a/.claude/hooks/fleet/alpha-sort-reminder/README.md b/.claude/hooks/fleet/alpha-sort-reminder/README.md new file mode 100644 index 0000000..085fce6 --- /dev/null +++ b/.claude/hooks/fleet/alpha-sort-reminder/README.md @@ -0,0 +1,46 @@ +# alpha-sort-reminder + +PreToolUse Edit/Write hook that nudges (never blocks) when a non-code file edit +introduces a sibling block that looks unsorted. oxlint only sees JS/TS, so the +`socket/sort-*` lint rules can't reach JSON / YAML / markdown / bash. This hook +covers those surfaces per [`docs/claude.md/fleet/sorting.md`](../../../../docs/claude.md/fleet/sorting.md). + +## What it flags + +| Surface | Detects | Key shape | +| -------------------------------------------------- | ------------------------------------------------------------------------- | ----------- | +| JSON / JSONC (`.json`, `.jsonc`, `.oxlintrc.json`) | runs of object keys at one indent, out of ASCII order | `"name": …` | +| YAML (`.yml`, `.yaml`) | runs of mapping keys at one indent (`env:` / `with:` / matrix) | `name:` | +| Markdown (`.md`, `.markdown`) | runs of `-`/`*` bullets out of order; bullets ending in `…`/`...` | `- text` | +| Bash (`.sh`, `.bash`) | runs of all-caps `NAME=…` assignments out of order (cache-key var blocks) | `NAME=…` | + +Detection is conservative: **3+** adjacent siblings at the same indent, ASCII +byte order only. False quiet beats false nag: a missed block is a review catch, +while a wrong nag trains the agent to ignore the hook. + +## Trigger + +Fires on `Edit` / `Write` tool calls. Reads `tool_input.file_path` + +`content`/`new_string` from the PreToolUse payload on stdin. Always exits 0; the +reminder is informational on stderr. + +## Bypass + +No phrase; the hook never blocks. Silence it entirely with the env var +`SOCKET_ALPHA_SORT_REMINDER_DISABLED=1`. For a genuinely order-bearing block, +just leave it unsorted and state the reason inline (the hook is advisory; review +honors the stated reason). + +## Why + +John-David has asked for alphanumeric sorting across every file type repeatedly +(2026-04-17 → 2026-05-29: JSON config keys, README consumer lists, workflow YAML +matrix + bash cache-key vars, "no ellipsis"). Code surfaces got lint rules; the +non-code surfaces had no enforcement. This hook closes that gap at edit time. + +## Companion files + +- `index.mts` — the hook; `findUnsortedBlocks(filePath, content)` is the pure, + exported detector. +- `test/index.test.mts` — node:test specs. +- `package.json` — workspace declaration so `taze` can see the hook's deps. diff --git a/.claude/hooks/fleet/alpha-sort-reminder/index.mts b/.claude/hooks/fleet/alpha-sort-reminder/index.mts new file mode 100644 index 0000000..477a4ce --- /dev/null +++ b/.claude/hooks/fleet/alpha-sort-reminder/index.mts @@ -0,0 +1,249 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — alpha-sort-reminder. +// +// Nudges (never blocks) when an Edit/Write to a non-code file introduces a +// block of sibling items that looks unsorted. oxlint only sees JS/TS, so the +// `socket/sort-*` lint rules can't reach JSON / YAML / markdown / bash — this +// hook covers those surfaces per `docs/claude.md/fleet/sorting.md`: +// +// - JSON / JSONC: runs of `"key":` lines at one indent, ASCII order. +// - YAML: runs of `key:` mapping lines at one indent (env:/with:/matrix). +// - Markdown: runs of `-`/`*` bullets; also flags trailing-ellipsis lines. +// - Bash: runs of `NAME=...` assignments (cache-key var blocks). +// +// Detection is deliberately conservative: 3+ adjacent siblings at the same +// indent, and only ASCII-comparison. False quiet beats false nag — a missed +// block is a review catch, a wrong nag trains the agent to ignore the hook. +// Always exits 0; the message is informational on stderr. +// +// Disable via SOCKET_ALPHA_SORT_REMINDER_DISABLED. + +import path from 'node:path' +import process from 'node:process' + +import { readStdin } from '../_shared/transcript.mts' + +type ToolInput = { + tool_input?: + | { + content?: string | undefined + file_path?: string | undefined + new_string?: string | undefined + } + | undefined + tool_name?: string | undefined +} + +export interface SortFinding { + surface: 'json' | 'yaml' | 'markdown' | 'bash' + hint: string +} + +// Minimum sibling count before a run is worth flagging. Two-item runs carry +// too little signal (and are often guard pairs); 3+ is unambiguously a list. +const MIN_RUN = 3 + +// ASCII byte order, ascending. Returns true when already sorted. +function isAscadSorted(keys: readonly string[]): boolean { + for (let i = 1; i < keys.length; i += 1) { + if (keys[i - 1]! > keys[i]!) { + return false + } + } + return true +} + +// Leading-whitespace width of a line (spaces only; tabs count as one). +function indentOf(line: string): number { + const m = line.match(/^(\s*)/) + return m ? m[1]!.length : 0 +} + +// Walk lines, grouping maximal runs of lines that (a) match `keyFor` to a +// non-undefined key and (b) share the same indent as the run's first line. +// Calls back with each run's keys. Blank lines and non-matching lines break a +// run. +function scanRuns( + lines: readonly string[], + keyFor: (line: string) => string | undefined, + onRun: (keys: string[]) => void, +): void { + let runKeys: string[] = [] + let runIndent = -1 + const flush = () => { + if (runKeys.length >= MIN_RUN) { + onRun(runKeys) + } + runKeys = [] + runIndent = -1 + } + for (const line of lines) { + const key = keyFor(line) + if (key === undefined) { + flush() + continue + } + const ind = indentOf(line) + if (runKeys.length === 0) { + runIndent = ind + runKeys.push(key) + } else if (ind === runIndent) { + runKeys.push(key) + } else { + flush() + runIndent = ind + runKeys.push(key) + } + } + flush() +} + +// JSON / JSONC object keys: `"name": ...` (allow trailing comma). +function jsonKey(line: string): string | undefined { + const m = line.match(/^\s*"([^"]+)"\s*:/) + return m ? m[1] : undefined +} + +// YAML mapping keys: `name:` at line start (not a `- ` sequence item, not a +// comment). Skips document markers and key-less lines. +function yamlKey(line: string): string | undefined { + if (/^\s*#/.test(line) || /^\s*-/.test(line)) { + return undefined + } + const m = line.match(/^\s*([A-Za-z0-9_.-]+)\s*:(\s|$)/) + return m ? m[1] : undefined +} + +// Markdown bullets: `- text` / `* text`. Returns the text after the marker. +function mdBullet(line: string): string | undefined { + const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/) + if (!m) { + return undefined + } + // Skip task-list checkboxes and nested numbered intent. + return m[1]!.toLowerCase() +} + +// Bash all-caps assignments: `NAME=...` (cache-key var style). +function bashAssign(line: string): string | undefined { + const m = line.match(/^\s*([A-Z][A-Z0-9_]+)=/) + return m ? m[1] : undefined +} + +/** + * Inspect file content for likely-unsorted sibling blocks. Pure — no I/O. + * Returns a finding per surface that looks unsorted (deduped by surface). + */ +export function findUnsortedBlocks( + filePath: string, + content: string, +): SortFinding[] { + const ext = path.extname(filePath).toLowerCase() + const base = path.basename(filePath).toLowerCase() + const lines = content.split('\n') + const findings: SortFinding[] = [] + let pushed = false + const note = (surface: SortFinding['surface'], hint: string) => { + if (!pushed) { + findings.push({ surface, hint }) + pushed = true + } + } + + if (ext === '.json' || ext === '.jsonc' || base === '.oxlintrc.json') { + scanRuns(lines, jsonKey, keys => { + if (!isAscadSorted(keys)) { + note( + 'json', + `object keys out of order near: ${keys.slice(0, 4).join(', ')}…`, + ) + } + }) + } else if (ext === '.yml' || ext === '.yaml') { + scanRuns(lines, yamlKey, keys => { + if (!isAscadSorted(keys)) { + note( + 'yaml', + `mapping keys out of order near: ${keys.slice(0, 4).join(', ')}…`, + ) + } + }) + } else if (ext === '.md' || ext === '.markdown') { + scanRuns(lines, mdBullet, keys => { + if (!isAscadSorted(keys)) { + note( + 'markdown', + `bullet list out of order near: ${keys.slice(0, 3).join('; ')}…`, + ) + } + }) + if (!pushed && /^\s*[-*]\s+.*(\.\.\.|…)\s*$/m.test(content)) { + note( + 'markdown', + 'a bullet ends in an ellipsis — list every item or write "N items, see <source>"', + ) + } + } else if (ext === '.sh' || ext === '.bash' || base.endsWith('.bash')) { + scanRuns(lines, bashAssign, keys => { + if (!isAscadSorted(keys)) { + note( + 'bash', + `variable assignments out of order near: ${keys.slice(0, 4).join(', ')}…`, + ) + } + }) + } + return findings +} + +function emit(filePath: string, findings: readonly SortFinding[]): void { + const lines = [ + `[alpha-sort-reminder] ${path.basename(filePath)} may have an unsorted list:`, + ] + for (const f of findings) { + lines.push(` • (${f.surface}) ${f.hint}`) + } + lines.push( + ' Sort sibling items alphanumerically (ASCII order) unless order is load-bearing.', + ' Fully re-sort the block when you touch it. See docs/claude.md/fleet/sorting.md.', + ) + process.stderr.write(lines.join('\n') + '\n') +} + +async function main(): Promise<void> { + if (process.env['SOCKET_ALPHA_SORT_REMINDER_DISABLED']) { + return + } + const raw = await readStdin() + if (!raw) { + return + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + return + } + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + return + } + const filePath = payload.tool_input?.file_path ?? '' + if (!filePath) { + return + } + // Write → full content; Edit → the replacement text (best-effort window). + const content = + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' + if (!content) { + return + } + const findings = findUnsortedBlocks(filePath, content) + if (findings.length) { + emit(filePath, findings) + } +} + +main().catch(e => { + // Fail open — a reminder hook must never break a tool call. + process.stderr.write(`[alpha-sort-reminder] skipped: ${String(e)}\n`) +}) diff --git a/.claude/hooks/trust-downgrade-guard/package.json b/.claude/hooks/fleet/alpha-sort-reminder/package.json similarity index 85% rename from .claude/hooks/trust-downgrade-guard/package.json rename to .claude/hooks/fleet/alpha-sort-reminder/package.json index 0baf265..d346546 100644 --- a/.claude/hooks/trust-downgrade-guard/package.json +++ b/.claude/hooks/fleet/alpha-sort-reminder/package.json @@ -1,5 +1,5 @@ { - "name": "hook-trust-downgrade-guard", + "name": "hook-alpha-sort-reminder", "private": true, "type": "module", "main": "./index.mts", diff --git a/.claude/hooks/fleet/alpha-sort-reminder/test/index.test.mts b/.claude/hooks/fleet/alpha-sort-reminder/test/index.test.mts new file mode 100644 index 0000000..4fb77a7 --- /dev/null +++ b/.claude/hooks/fleet/alpha-sort-reminder/test/index.test.mts @@ -0,0 +1,82 @@ +/** + * @file Unit tests for the alpha-sort-reminder detector. + */ + +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { findUnsortedBlocks } from '../index.mts' + +describe('alpha-sort-reminder / findUnsortedBlocks', () => { + test('JSON: flags out-of-order object keys', () => { + const code = '{\n "gamma": 1,\n "alpha": 2,\n "beta": 3\n}\n' + const f = findUnsortedBlocks('config.json', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'json') + }) + + test('JSON: quiet on sorted keys', () => { + const code = '{\n "alpha": 1,\n "beta": 2,\n "gamma": 3\n}\n' + assert.equal(findUnsortedBlocks('config.json', code).length, 0) + }) + + test('JSON: quiet on a 2-key run (below MIN_RUN)', () => { + const code = '{\n "gamma": 1,\n "alpha": 2\n}\n' + assert.equal(findUnsortedBlocks('config.json', code).length, 0) + }) + + test('JSON: nested object at different indent is its own run', () => { + // outer keys sorted; inner keys sorted — no finding. + const code = + '{\n "a": {\n "x": 1,\n "y": 2,\n "z": 3\n },\n "b": 2,\n "c": 3\n}\n' + assert.equal(findUnsortedBlocks('config.json', code).length, 0) + }) + + test('YAML: flags out-of-order env block', () => { + const code = 'env:\n ZED: 1\n ALPHA: 2\n MID: 3\n' + const f = findUnsortedBlocks('ci.yml', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'yaml') + }) + + test('YAML: ignores sequence items and comments', () => { + const code = 'steps:\n # a comment\n - uses: foo\n - uses: bar\n' + assert.equal(findUnsortedBlocks('ci.yml', code).length, 0) + }) + + test('markdown: flags out-of-order bullets', () => { + const code = '- zebra\n- apple\n- mango\n' + const f = findUnsortedBlocks('README.md', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'markdown') + }) + + test('markdown: flags trailing ellipsis even when sorted', () => { + const code = '- apple\n- banana, ...\n' + const f = findUnsortedBlocks('README.md', code) + assert.equal(f.length, 1) + assert.match(f[0]!.hint, /ellipsis/) + }) + + test('markdown: quiet on sorted bullets', () => { + const code = '- apple\n- mango\n- zebra\n' + assert.equal(findUnsortedBlocks('README.md', code).length, 0) + }) + + test('bash: flags out-of-order cache-key vars', () => { + const code = 'ZED_LIB=$(hash)\nALPHA_LIB=$(hash)\nMID_LIB=$(hash)\n' + const f = findUnsortedBlocks('build.sh', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'bash') + }) + + test('bash: quiet on sorted vars', () => { + const code = 'ALPHA_LIB=$(hash)\nMID_LIB=$(hash)\nZED_LIB=$(hash)\n' + assert.equal(findUnsortedBlocks('build.sh', code).length, 0) + }) + + test('unknown extension: no findings', () => { + const code = 'const o = { b: 1, a: 2 }\n' + assert.equal(findUnsortedBlocks('app.ts', code).length, 0) + }) +}) diff --git a/.claude/hooks/no-non-fleet-push-guard/tsconfig.json b/.claude/hooks/fleet/alpha-sort-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/no-non-fleet-push-guard/tsconfig.json rename to .claude/hooks/fleet/alpha-sort-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts b/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts index 41c4286..8f68568 100644 --- a/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts @@ -37,7 +37,7 @@ interface StopPayload { // Phrases that indicate the assistant brushed past the question. const DEFLECTION_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ { - label: 'right now I\'m / right now I am', + label: "right now I'm / right now I am", regex: /\bright\s+now\s+i'?(m|\s+am)\b/i, }, { @@ -45,7 +45,8 @@ const DEFLECTION_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ regex: /\b(let\s+me\s+(finish|first|wrap)|finish\s+first)\b/i, }, { - label: 'that\'s a (structural|bigger|separate) (fix|refactor|question) (for|later)', + label: + "that's a (structural|bigger|separate) (fix|refactor|question) (for|later)", regex: /\bthat'?s\s+(a\s+)?(structural|bigger|separate|different)\s+(fix|refactor|question|issue|concern)\s+(for\s+later|though|\.\s)/i, }, @@ -54,12 +55,13 @@ const DEFLECTION_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ regex: /\bfor\s+(now|the\s+moment)\s*,?\s+(i'?m|let\s+me|focus)/i, }, { - label: 'I\'ll come back to / get to that', + label: "I'll come back to / get to that", regex: /\bi'?ll\s+(come\s+back\s+to|get\s+to)\s+(that|it|this)\b/i, }, { label: 'later — focus / first', - regex: /\b(later|that\s+(part|piece))\s*[—–\-]\s*(focus|first|right\s+now)/i, + regex: + /\b(later|that\s+(part|piece))\s*[—–\-]\s*(focus|first|right\s+now)/i, }, { label: 'noted / good question — moving on', @@ -145,10 +147,7 @@ async function main(): Promise<void> { return } - const userSnippet = recentUser - .slice(0, 200) - .replace(/\s+/g, ' ') - .trim() + const userSnippet = recentUser.slice(0, 200).replace(/\s+/g, ' ').trim() const lines = [ '[answer-passing-questions-reminder] User asked a passing question; assistant turn brushed past it without answering:', '', diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts b/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts index d9027a3..c601196 100644 --- a/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts @@ -1,11 +1,8 @@ /** - * @file Smoke test for answer-passing-questions-reminder. - * - * Stop hook that catches the failure mode where the user asks a passing - * question mid-task and the assistant deflects. - * - * Smoke contract: hook loads + dispatches without throwing; empty - * transcript path → exit 0. + * @file Smoke test for answer-passing-questions-reminder. Stop hook that + * catches the failure mode where the user asks a passing question mid-task + * and the assistant deflects. Smoke contract: hook loads + dispatches without + * throwing; empty transcript path → exit 0. */ import { mkdtempSync, writeFileSync } from 'node:fs' diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/index.mts b/.claude/hooks/fleet/answer-status-requests-reminder/index.mts index d98731f..7b23eb1 100644 --- a/.claude/hooks/fleet/answer-status-requests-reminder/index.mts +++ b/.claude/hooks/fleet/answer-status-requests-reminder/index.mts @@ -85,15 +85,18 @@ const DECLINE_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ }, { label: 'not enough time has passed', - regex: /\b(not\s+enough\s+time|hasn'?t\s+been\s+(long|enough))\s+(has\s+)?(passed|elapsed|gone\s+by)\b/i, + regex: + /\b(not\s+enough\s+time|hasn'?t\s+been\s+(long|enough))\s+(has\s+)?(passed|elapsed|gone\s+by)\b/i, }, { label: "let me wait / I'll wait / wait a bit", - regex: /\b(let\s+me\s+wait|i'?ll\s+wait|wait\s+(a\s+(bit|moment|few|minute|second)|until))/i, + regex: + /\b(let\s+me\s+wait|i'?ll\s+wait|wait\s+(a\s+(bit|moment|few|minute|second)|until))/i, }, { label: 'no need to check / no point', - regex: /\b(no\s+(need|point)\s+(to\s+)?(check(ing)?|polling|looking)|nothing\s+(to\s+)?check)\b/i, + regex: + /\b(no\s+(need|point)\s+(to\s+)?(check(ing)?|polling|looking)|nothing\s+(to\s+)?check)\b/i, }, { label: 'polling is wasted / pointless', @@ -101,7 +104,8 @@ const DECLINE_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ }, { label: 'no change since last check (without checking)', - regex: /\b(no\s+change|nothing\s+new|same\s+as\s+(before|last))\s+since\s+(the\s+)?last\s+(check|update|time)\b/i, + regex: + /\b(no\s+change|nothing\s+new|same\s+as\s+(before|last))\s+since\s+(the\s+)?last\s+(check|update|time)\b/i, }, ] @@ -158,10 +162,7 @@ async function main(): Promise<void> { return } - const userSnippet = recentUser - .slice(0, 200) - .replace(/\s+/g, ' ') - .trim() + const userSnippet = recentUser.slice(0, 200).replace(/\s+/g, ' ').trim() const lines = [ '[answer-status-requests-reminder] User asked for a status update; assistant declined with rate-limiting excuse:', '', diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts b/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts index e3aa188..48f0fdb 100644 --- a/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts +++ b/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts @@ -1,12 +1,9 @@ /** - * @file Smoke test for answer-status-requests-reminder. - * - * Stop hook that catches the failure mode where the user explicitly asks - * for a status update and the assistant declines with a "too soon since - * last check" excuse. - * - * Smoke contract: hook loads + dispatches without throwing; empty - * transcript path → exit 0. + * @file Smoke test for answer-status-requests-reminder. Stop hook that catches + * the failure mode where the user explicitly asks for a status update and the + * assistant declines with a "too soon since last check" excuse. Smoke + * contract: hook loads + dispatches without throwing; empty transcript path → + * exit 0. */ import { mkdtempSync, writeFileSync } from 'node:fs' diff --git a/.claude/hooks/fleet/avoid-cd-reminder/index.mts b/.claude/hooks/fleet/avoid-cd-reminder/index.mts index 472899d..00e20d7 100644 --- a/.claude/hooks/fleet/avoid-cd-reminder/index.mts +++ b/.claude/hooks/fleet/avoid-cd-reminder/index.mts @@ -33,13 +33,15 @@ import process from 'node:process' -import { readStdin } from '../../_shared/transcript.mts' +import { readStdin } from '../_shared/transcript.mts' interface PreToolUseInput { readonly tool_name?: string | undefined - readonly tool_input?: { - readonly command?: string | undefined - } | undefined + readonly tool_input?: + | { + readonly command?: string | undefined + } + | undefined } // Matches `cd <something>` not preceded by `(` (subshell) and not @@ -52,7 +54,6 @@ function detectsBareCd(command: string): boolean { const cdRe = /(^|[\s;&|])cd\s+(\S+)/g let m: RegExpExecArray | null while ((m = cdRe.exec(flat)) !== null) { - const lead = m[1]! const target = m[2]! // Skip `cd -` (intentional return). @@ -111,7 +112,7 @@ async function main(): Promise<void> { '[avoid-cd-reminder] Bash command contains a bare `cd <path>`.', '', " The Bash tool's cwd PERSISTS across tool calls — a cd here lingers", - " for every later command until something resets it. Recover with one", + ' for every later command until something resets it. Recover with one', ' of:', '', ' (a) Use absolute paths so no cd is needed:', diff --git a/.claude/hooks/fleet/broken-hook-detector/index.mts b/.claude/hooks/fleet/broken-hook-detector/index.mts index cea26a8..85c5b11 100644 --- a/.claude/hooks/fleet/broken-hook-detector/index.mts +++ b/.claude/hooks/fleet/broken-hook-detector/index.mts @@ -32,7 +32,7 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' -const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR ?? process.cwd() +const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd() const HOOKS_DIR = path.join(PROJECT_DIR, '.claude', 'hooks') // 4-second total budget. Each `node --check` is ~50-150 ms; with @@ -145,8 +145,8 @@ function probeHook(hookPath: string): ProbeFailure | undefined { // positives. CLAUDE_PROJECT_DIR is preserved because some // hooks read it at import time. env: { - PATH: process.env.PATH ?? '', - HOME: process.env.HOME ?? '', + PATH: process.env['PATH'] ?? '', + HOME: process.env['HOME'] ?? '', CLAUDE_PROJECT_DIR: PROJECT_DIR, // Suppress node's deprecation warnings during the probe; // unrelated to broken-hook detection. diff --git a/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts b/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts index e0d17c5..d460988 100644 --- a/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts +++ b/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts @@ -1,13 +1,10 @@ /** - * @file Smoke test for broken-hook-detector. - * - * SessionStart hook (Node built-ins only, self-imposed) that walks every - * other hook's index.mts + every _shared/*.mts, spawns `node --check` on - * each, and aggregates ERR_MODULE_NOT_FOUND failures into one structured - * recovery message. Fail-open by design. - * - * Smoke contract: hook loads + dispatches without throwing; empty - * payload → exit 0 (fail-open). + * @file Smoke test for broken-hook-detector. SessionStart hook (Node built-ins + * only, self-imposed) that walks every other hook's index.mts + every + * _shared/*.mts, spawns `node --check` on each, and aggregates + * ERR_MODULE_NOT_FOUND failures into one structured recovery message. + * Fail-open by design. Smoke contract: hook loads + dispatches without + * throwing; empty payload → exit 0 (fail-open). */ import { spawn } from 'node:child_process' diff --git a/.claude/hooks/fleet/codex-no-write-guard/index.mts b/.claude/hooks/fleet/codex-no-write-guard/index.mts index 5a1530e..070a364 100644 --- a/.claude/hooks/fleet/codex-no-write-guard/index.mts +++ b/.claude/hooks/fleet/codex-no-write-guard/index.mts @@ -112,9 +112,7 @@ async function main(): Promise<void> { // Check write-intent verbs only in the codex command's OWN args // (the prompt), not the whole shell line — so a sibling command // or a path containing a verb word doesn't trip the guard. - const codexArgText = codexCommands - .flatMap(c => c.args) - .join(' ') + const codexArgText = codexCommands.flatMap(c => c.args).join(' ') const verb = hasWriteIntent(codexArgText) if (verb) { blocked = { kind: 'bash', reason: `write-intent verb "${verb}"` } diff --git a/.claude/hooks/fleet/dont-blame-user-reminder/README.md b/.claude/hooks/fleet/dont-blame-user-reminder/README.md index a297956..8e78a29 100644 --- a/.claude/hooks/fleet/dont-blame-user-reminder/README.md +++ b/.claude/hooks/fleet/dont-blame-user-reminder/README.md @@ -10,14 +10,14 @@ Past incident: the assistant repeatedly claimed "the user reverted my edits" / " ## What it catches -| Phrase shape | Why it's flagged | -| --- | --- | +| Phrase shape | Why it's flagged | +| --------------------------------------------------------------- | ---------------------------------------------------------------------- | | `the user/linter/formatter reverted/stripped/removed/rewrote …` | Attributes state to the user/tool as the cause, with no investigation. | -| `user's intentional/preferred/preserved state` | Same — assumes intent the assistant hasn't evidenced. | -| `removed/reverted/stripped by the user/linter/formatter` | Same. | -| `the user/linter wants/chose to keep/strip/remove …` | Same. | +| `user's intentional/preferred/preserved state` | Same — assumes intent the assistant hasn't evidenced. | +| `removed/reverted/stripped by the user/linter/formatter` | Same. | +| `the user/linter wants/chose to keep/strip/remove …` | Same. | -Quoted spans are stripped before matching, so the hook doesn't self-fire when the assistant *describes* these phrases (e.g. paraphrasing this doc in a turn summary). +Quoted spans are stripped before matching, so the hook doesn't self-fire when the assistant _describes_ these phrases (e.g. paraphrasing this doc in a turn summary). ## Why it blocks diff --git a/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts index 9bfae9f..26c2472 100644 --- a/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts +++ b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts @@ -1,14 +1,12 @@ /** * @file Smoke test for no-file-scope-oxlint-disable-guard. - * * PreToolUse(Edit|Write) hook that blocks file-scope `oxlint-disable` / * `oxlint-disable-next-line` blocks at the top of a file. The block scope * silently exempts future edits the author never thought about; per-line - * disables with rationale are the right shape. + * disables with rationale are the right shape. Smoke contract: * - * Smoke contract: - * - benign payload (non-Edit/Write tool, or no oxlint-disable in content) - * → exit 0. + * - benign payload (non-Edit/Write tool, or no oxlint-disable in content) → + * exit 0. * - the hook loads + dispatches without throwing. */ diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts index be89fe3..5b426ca 100644 --- a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts @@ -19,8 +19,7 @@ test('BLOCKS gh pr create --repo against non-fleet repo', () => { const { stderr, exitCode } = runHook({ tool_name: 'Bash', tool_input: { - command: - 'gh pr create --repo oxc-project/oxc --title "x" --body "y"', + command: 'gh pr create --repo oxc-project/oxc --title "x" --body "y"', }, }) assert.equal(exitCode, 2) @@ -33,8 +32,7 @@ test('BLOCKS gh issue create --repo against non-fleet repo', () => { const { stderr, exitCode } = runHook({ tool_name: 'Bash', tool_input: { - command: - 'gh issue create --repo nodejs/node --title "x" --body "y"', + command: 'gh issue create --repo nodejs/node --title "x" --body "y"', }, }) assert.equal(exitCode, 2) diff --git a/.claude/hooks/fleet/overeager-staging-guard/index.mts b/.claude/hooks/fleet/overeager-staging-guard/index.mts index c6eeae3..f6f9be8 100644 --- a/.claude/hooks/fleet/overeager-staging-guard/index.mts +++ b/.claude/hooks/fleet/overeager-staging-guard/index.mts @@ -41,10 +41,7 @@ import path from 'node:path' import process from 'node:process' import { readTouchedPaths } from '../_shared/foreign-paths.mts' -import { - detectBroadGitAdd, - findInvocation, -} from '../_shared/shell-command.mts' +import { detectBroadGitAdd, findInvocation } from '../_shared/shell-command.mts' import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' interface ToolInput { @@ -78,7 +75,6 @@ export function listStagedFiles(repoDir: string): string[] { .filter(Boolean) } - async function main(): Promise<void> { if (process.env[ENV_DISABLE]) { process.exit(0) diff --git a/.claude/hooks/fleet/parallel-agent-edit-guard/README.md b/.claude/hooks/fleet/parallel-agent-edit-guard/README.md index 9b93609..5147738 100644 --- a/.claude/hooks/fleet/parallel-agent-edit-guard/README.md +++ b/.claude/hooks/fleet/parallel-agent-edit-guard/README.md @@ -27,7 +27,7 @@ Incident 2026-05-27: two Claude sessions plus a Codex companion shared one type-error fixes one Edit at a time. The four-times-clobbered fixes only stuck once both sessions stopped touching the same files. -`parallel-agent-staging-guard` catches the *git-op* version of this hazard +`parallel-agent-staging-guard` catches the _git-op_ version of this hazard (`git add -A` / `stash` / `reset --hard`); it can't see a plain `Write` that overwrites a file. This hook closes that gap at the write itself. diff --git a/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts index 6e00020..b070fff 100644 --- a/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts +++ b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts @@ -1,10 +1,9 @@ /** - * @file Unit tests for parallel-agent-on-stop-reminder hook. - * - * Stop hook, always exit 0. Emits a stderr reminder listing dirty paths this - * session did not author and that changed recently. Each test builds a real - * git repo in tmpdir, writes foreign / own dirty files, and runs the hook as a - * child process with a synthesized Stop payload. + * @file Unit tests for parallel-agent-on-stop-reminder hook. Stop hook, always + * exit 0. Emits a stderr reminder listing dirty paths this session did not + * author and that changed recently. Each test builds a real git repo in + * tmpdir, writes foreign / own dirty files, and runs the hook as a child + * process with a synthesized Stop payload. */ import assert from 'node:assert/strict' @@ -96,7 +95,7 @@ test('reminds when a foreign dirty file exists (no transcript)', () => { assert.match(r.stderr, /another (Claude )?session|another agent/i) }) -test('silent when the only dirty file is this session\'s', () => { +test("silent when the only dirty file is this session's", () => { const own = writeFile(repo, 'mine.txt') const tx = writeTranscriptTouching(own) const r = runHook({ cwd: repo, transcriptPath: tx }) diff --git a/.claude/hooks/fleet/parallel-agent-staging-guard/README.md b/.claude/hooks/fleet/parallel-agent-staging-guard/README.md index 915ea28..43564e4 100644 --- a/.claude/hooks/fleet/parallel-agent-staging-guard/README.md +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/README.md @@ -6,14 +6,14 @@ present** in the checkout. Surgical ops and the all-clear case pass through. ## Gated operations (blocked only when foreign paths exist) -| Op | Hazard | -|----|--------| -| `git add -A` / `.` / `--all` / `-u` | stages their unstaged edits | -| `git commit -a` / `--all` | stages + commits their edits | -| `git stash` / `stash push` | hides their working-tree changes | -| `git reset --hard` | destroys their uncommitted work | -| `git checkout <branch>` / `git switch <branch>` | may clobber on switch | -| `git restore <path>` | reverts their changes | +| Op | Hazard | +| ----------------------------------------------- | -------------------------------- | +| `git add -A` / `.` / `--all` / `-u` | stages their unstaged edits | +| `git commit -a` / `--all` | stages + commits their edits | +| `git stash` / `stash push` | hides their working-tree changes | +| `git reset --hard` | destroys their uncommitted work | +| `git checkout <branch>` / `git switch <branch>` | may clobber on switch | +| `git restore <path>` | reverts their changes | Detection runs through the shared shell AST parser (`_shared/shell-command.mts`), so indirection can't dodge it diff --git a/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts b/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts index 81848b8..7c2d6d9 100644 --- a/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts @@ -127,9 +127,8 @@ async function main(): Promise<void> { if (payload.tool_name !== 'Bash') { process.exit(0) } - const command = ( - payload.tool_input as { command?: unknown } | undefined - )?.command + const command = (payload.tool_input as { command?: unknown } | undefined) + ?.command if (typeof command !== 'string' || !command.trim()) { process.exit(0) } @@ -170,7 +169,9 @@ async function main(): Promise<void> { ' same checkout. This operation would sweep up, hide, or destroy', ' their in-flight work:', ...foreign.slice(0, 10).map(p => ` ${p}`), - ...(foreign.length > 10 ? [` ... and ${foreign.length - 10} more`] : []), + ...(foreign.length > 10 + ? [` ... and ${foreign.length - 10} more`] + : []), '', ' Fix: stage only YOUR files by explicit path, and avoid stash /', ' reset --hard / checkout while the other agent is active.', diff --git a/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts index 1ce39e9..946fddb 100644 --- a/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts @@ -1,13 +1,11 @@ /** - * @file Unit tests for parallel-agent-staging-guard hook. - * - * The guard blocks sweep / destructive git ops (add -A, commit -a, stash, - * reset --hard, checkout, restore) ONLY when foreign dirty paths are present: - * dirty, not in this session's transcript touched-set, recently changed. - * - * Each test builds a real git repo in tmpdir, optionally creates a "foreign" - * dirty file (written WITHOUT a corresponding Edit/Write transcript entry), - * and runs the hook as a child process with a synthesized PreToolUse payload. + * @file Unit tests for parallel-agent-staging-guard hook. The guard blocks + * sweep / destructive git ops (add -A, commit -a, stash, reset --hard, + * checkout, restore) ONLY when foreign dirty paths are present: dirty, not in + * this session's transcript touched-set, recently changed. Each test builds a + * real git repo in tmpdir, optionally creates a "foreign" dirty file (written + * WITHOUT a corresponding Edit/Write transcript entry), and runs the hook as + * a child process with a synthesized PreToolUse payload. */ import assert from 'node:assert/strict' @@ -146,7 +144,7 @@ test('allows `git add -A` in a clean repo (no foreign paths)', () => { assert.equal(r.code, 0) }) -test('allows `git stash` when the only dirty file is this session\'s', () => { +test("allows `git stash` when the only dirty file is this session's", () => { const own = writeForeign(repo, 'mine.txt') const tx = writeTranscriptTouching(own) const r = runHook('git stash', { cwd: repo, transcriptPath: tx }) diff --git a/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts b/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts index 36d1198..2417bd3 100644 --- a/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts +++ b/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts @@ -1,12 +1,9 @@ /** - * @file Smoke test for path-regex-normalize-reminder. - * - * Stop hook that warns when the assistant's recent output writes dual- - * separator regexes like `[/\\]` against a path — the fleet helper - * `normalizePath` already gives one `/` representation across platforms. - * - * Smoke contract: hook loads + dispatches without throwing; empty - * transcript path → exit 0. + * @file Smoke test for path-regex-normalize-reminder. Stop hook that warns when + * the assistant's recent output writes dual- separator regexes like `[/\\]` + * against a path — the fleet helper `normalizePath` already gives one `/` + * representation across platforms. Smoke contract: hook loads + dispatches + * without throwing; empty transcript path → exit 0. */ import { mkdtempSync, writeFileSync } from 'node:fs' diff --git a/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts b/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts index 0a6c124..b4c3f0a 100644 --- a/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts +++ b/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts @@ -153,7 +153,9 @@ test('classifyPluginPatch: version/filename mismatch blocks', () => { test('isPluginPatchPath: matches only scripts/plugin-patches/*.patch', () => { assert.strictEqual(isPluginPatchPath(PATCH_PATH), true) assert.strictEqual( - isPluginPatchPath('/Users/x/projects/foo/scripts/other/codex-1.0.1-x.patch'), + isPluginPatchPath( + '/Users/x/projects/foo/scripts/other/codex-1.0.1-x.patch', + ), false, ) assert.strictEqual( diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts b/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts index 3237aa8..c9cdc7c 100644 --- a/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts @@ -89,7 +89,9 @@ export function isExemptPath(filePath: string): boolean { filePath.includes( '/.claude/hooks/fleet/prefer-function-declaration-guard/', ) || - filePath.includes('/.config/oxlint-plugin/rules/prefer-function-declaration.') || + filePath.includes( + '/.config/oxlint-plugin/rules/prefer-function-declaration.', + ) || filePath.includes('/.config/oxlint-plugin/test/prefer-function-declaration') ) } diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts b/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts index 6ccfea8..ee533b5 100644 --- a/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts @@ -109,7 +109,9 @@ describe('isExemptPath', () => { it('exempts oxlint rule + test fixtures', () => { assert.equal( - isExemptPath('/foo/.config/oxlint-plugin/rules/prefer-function-declaration.mts'), + isExemptPath( + '/foo/.config/oxlint-plugin/rules/prefer-function-declaration.mts', + ), true, ) assert.equal( diff --git a/.claude/hooks/fleet/provenance-publish-reminder/index.mts b/.claude/hooks/fleet/provenance-publish-reminder/index.mts index 861b625..672adb3 100644 --- a/.claude/hooks/fleet/provenance-publish-reminder/index.mts +++ b/.claude/hooks/fleet/provenance-publish-reminder/index.mts @@ -38,11 +38,8 @@ import path from 'node:path' import process from 'node:process' import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -const logger = getDefaultLogger() - const RELEASE_MESSAGE_RE = /^chore(?:\([^)]*\))?:\s+(?:bump version to |release )v?(\d+\.\d+\.\d+)/i const RELEASE_TAG_RE = /^v?(\d+\.\d+\.\d+)$/ diff --git a/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts b/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts index cb96430..c7324a0 100644 --- a/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts +++ b/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts @@ -1,12 +1,8 @@ /** - * @file Smoke test for provenance-publish-reminder. - * - * Stop hook that fires when the assistant's recent turn appears to be a - * publish action without the canonical provenance + trustedPublisher - * verification steps. - * - * Smoke contract: hook loads + dispatches without throwing; empty - * transcript path → exit 0. + * @file Smoke test for provenance-publish-reminder. Stop hook that fires when + * the assistant's recent turn appears to be a publish action without the + * canonical provenance + trustedPublisher verification steps. Smoke contract: + * hook loads + dispatches without throwing; empty transcript path → exit 0. */ import { mkdtempSync, writeFileSync } from 'node:fs' diff --git a/.claude/hooks/fleet/setup-basics-tools/install.mts b/.claude/hooks/fleet/setup-basics-tools/install.mts index b170f2a..ab3d470 100644 --- a/.claude/hooks/fleet/setup-basics-tools/install.mts +++ b/.claude/hooks/fleet/setup-basics-tools/install.mts @@ -4,8 +4,8 @@ * TruffleHog (secrets scanner), Trivy (vuln/SBOM scanner), OpenGrep (SAST), * and uv (Python package manager bootstrap). Slim leaf of the * `setup-security-tools` umbrella. Run via: node - * .claude/hooks/fleet/setup-basics-tools/install.mts For the full setup (firewall + - * scanners + socket-basics + misc), use `node + * .claude/hooks/fleet/setup-basics-tools/install.mts For the full setup + * (firewall + scanners + socket-basics + misc), use `node * .claude/hooks/fleet/setup-security-tools/install.mts`. */ diff --git a/.claude/hooks/fleet/setup-firewall/install.mts b/.claude/hooks/fleet/setup-firewall/install.mts index 4b26932..5b21d77 100644 --- a/.claude/hooks/fleet/setup-firewall/install.mts +++ b/.claude/hooks/fleet/setup-firewall/install.mts @@ -6,10 +6,10 @@ * AgentShield / zizmor / socket-basics tool installers. The actual installer * code lives in `../setup-security-tools/lib/installers.mts`. This entry * point exists so operators can scope their setup precisely: node - * .claude/hooks/fleet/setup-firewall/install.mts For the full setup, use `node - * .claude/hooks/fleet/setup-security-tools/install.mts` which sequences this leaf - * alongside the others. --rotate is honored here too — re-prompts for - * SOCKET_API_KEY and overwrites the OS keychain entry, just like the + * .claude/hooks/fleet/setup-firewall/install.mts For the full setup, use + * `node .claude/hooks/fleet/setup-security-tools/install.mts` which sequences + * this leaf alongside the others. --rotate is honored here too — re-prompts + * for SOCKET_API_KEY and overwrites the OS keychain entry, just like the * umbrella's --rotate path. */ diff --git a/.claude/hooks/fleet/setup-misc-tools/install.mts b/.claude/hooks/fleet/setup-misc-tools/install.mts index aaa8d5d..c666dc9 100644 --- a/.claude/hooks/fleet/setup-misc-tools/install.mts +++ b/.claude/hooks/fleet/setup-misc-tools/install.mts @@ -2,8 +2,8 @@ /** * @file Install-only entry point for one-off tools: cdxgen (SBOM), synp * (lockfile interop), and janus. Slim leaf of the `setup-security-tools` - * umbrella. Run via: node .claude/hooks/fleet/setup-misc-tools/install.mts For the - * full setup (firewall + scanners + socket-basics + misc), use `node + * umbrella. Run via: node .claude/hooks/fleet/setup-misc-tools/install.mts + * For the full setup (firewall + scanners + socket-basics + misc), use `node * .claude/hooks/fleet/setup-security-tools/install.mts`. */ diff --git a/.claude/hooks/fleet/setup-security-tools/install.mts b/.claude/hooks/fleet/setup-security-tools/install.mts index 9fa3ef8..13bac99 100644 --- a/.claude/hooks/fleet/setup-security-tools/install.mts +++ b/.claude/hooks/fleet/setup-security-tools/install.mts @@ -17,12 +17,12 @@ * falls back to sfw-free (the auth- free SFW build) and continues without * persisting a token. Invocation: node * .claude/hooks/fleet/setup-security-tools/install.mts node - * .claude/hooks/fleet/setup-security-tools/install.mts --rotate Flags: --rotate - * Re-prompt for SOCKET_API_KEY and overwrite the keychain entry, ignoring - * env/.env/keychain lookup. Use to rotate a leaked or expired token without - * manually clearing the keychain first. --update-token Alias for --rotate. - * Exit codes: 0 — all tools installed + verified. 1 — at least one tool - * failed; details on stderr. + * .claude/hooks/fleet/setup-security-tools/install.mts --rotate Flags: + * --rotate Re-prompt for SOCKET_API_KEY and overwrite the keychain entry, + * ignoring env/.env/keychain lookup. Use to rotate a leaked or expired + * token without manually clearing the keychain first. --update-token Alias + * for --rotate. Exit codes: 0 — all tools installed + verified. 1 — at + * least one tool failed; details on stderr. */ import { existsSync, promises as fs } from 'node:fs' diff --git a/.claude/hooks/fleet/setup-security-tools/update.mts b/.claude/hooks/fleet/setup-security-tools/update.mts index 09450b9..3515cee 100644 --- a/.claude/hooks/fleet/setup-security-tools/update.mts +++ b/.claude/hooks/fleet/setup-security-tools/update.mts @@ -124,7 +124,7 @@ export async function ghApiLatestRelease(repo: string): Promise<GhRelease> { { stdio: 'pipe' }, ) const stdout = - typeof result.stdout === 'string' ? result.stdout : result.stdout.toString() + typeof result.stdout === 'string' ? result.stdout : String(result.stdout) return JSON.parse(stdout) as GhRelease } diff --git a/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts b/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts index 374bdd0..da7f32a 100644 --- a/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts +++ b/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts @@ -1,14 +1,10 @@ /** - * @file Smoke test for socket-token-minifier-start. - * - * SessionStart hook that auto-starts the socket-token-minifier proxy on - * `localhost:7779` and exports `ANTHROPIC_BASE_URL` only after a health - * probe succeeds. Fail-closed: missing proxy means the session uses - * api.anthropic.com directly, never silently routes through a broken - * intermediary. - * - * Smoke contract: hook loads + dispatches without throwing; empty - * payload → exit 0. + * @file Smoke test for socket-token-minifier-start. SessionStart hook that + * auto-starts the socket-token-minifier proxy on `localhost:7779` and exports + * `ANTHROPIC_BASE_URL` only after a health probe succeeds. Fail-closed: + * missing proxy means the session uses api.anthropic.com directly, never + * silently routes through a broken intermediary. Smoke contract: hook loads + + * dispatches without throwing; empty payload → exit 0. */ import { spawn } from 'node:child_process' diff --git a/.claude/hooks/fleet/trust-downgrade-guard/README.md b/.claude/hooks/fleet/trust-downgrade-guard/README.md index 1f4f4b7..98269c9 100644 --- a/.claude/hooks/fleet/trust-downgrade-guard/README.md +++ b/.claude/hooks/fleet/trust-downgrade-guard/README.md @@ -28,7 +28,7 @@ unless the user typed `Allow trust-downgrade bypass` — and the bypass is counts prior downgrade actions in the assistant tool-use history (mirrors `release-workflow-guard`'s per-dispatch model) and requires an unconsumed phrase occurrence. A persisted bypass — an env var, or a phrase that opens the door for -every future downgrade — is *itself* a trust downgrade, so it's disallowed by +every future downgrade — is _itself_ a trust downgrade, so it's disallowed by design. Each downgrade needs its own freshly-typed phrase. ## The right fix instead of a downgrade diff --git a/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts b/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts index 37680e2..cf2c791 100644 --- a/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts +++ b/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts @@ -1,9 +1,8 @@ /** - * @file Unit tests for trust-downgrade-guard hook. - * - * Spawns the hook as a child process with synthesized PreToolUse payloads. - * Covers Bash + Edit/Write downgrade detection, single-use bypass - * consumption, the disabled env var, and fail-open. + * @file Unit tests for trust-downgrade-guard hook. Spawns the hook as a child + * process with synthesized PreToolUse payloads. Covers Bash + Edit/Write + * downgrade detection, single-use bypass consumption, the disabled env var, + * and fail-open. */ import assert from 'node:assert/strict' diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/README.md b/.claude/hooks/fleet/uses-sha-verify-guard/README.md index c0da7e2..cb3f5e4 100644 --- a/.claude/hooks/fleet/uses-sha-verify-guard/README.md +++ b/.claude/hooks/fleet/uses-sha-verify-guard/README.md @@ -8,11 +8,11 @@ Every GitHub URL pin across the fleet needs a full 40-char commit SHA that resol Three surfaces: -| Surface | Required pin shape | -| --- | --- | -| `.github/workflows/*.yml` + `.github/actions/*/action.yml` | `uses: <owner>/<repo>(/<path>)?@<40-hex>` | -| `.gitmodules` | BOTH `# <name>-<version> sha256:<64-hex>` comment AND `ref = <40-hex>` field per `[submodule]` block | -| `package.json` | `git+https://github.com/<owner>/<repo>(.git)?#<40-hex>` for any GitHub-URL dep specifier | +| Surface | Required pin shape | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `.github/workflows/*.yml` + `.github/actions/*/action.yml` | `uses: <owner>/<repo>(/<path>)?@<40-hex>` | +| `.gitmodules` | BOTH `# <name>-<version> sha256:<64-hex>` comment AND `ref = <40-hex>` field per `[submodule]` block | +| `package.json` | `git+https://github.com/<owner>/<repo>(.git)?#<40-hex>` for any GitHub-URL dep specifier | The `.gitmodules` content-hash (`sha256:`) and the `ref =` (commit SHA) are both required — the comment is the upstream-archive content-hash pin (drift-watch signal); the `ref` is what `git submodule update` checks out. diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/index.mts b/.claude/hooks/fleet/uses-sha-verify-guard/index.mts index 6624908..da37100 100644 --- a/.claude/hooks/fleet/uses-sha-verify-guard/index.mts +++ b/.claude/hooks/fleet/uses-sha-verify-guard/index.mts @@ -337,10 +337,7 @@ function isPackageJsonPath(filePath: string): boolean { if (filePath.includes('/node_modules/')) { return false } - return ( - filePath.endsWith('/package.json') || - filePath === 'package.json' - ) + return filePath.endsWith('/package.json') || filePath === 'package.json' } async function main(): Promise<void> { @@ -374,7 +371,9 @@ async function main(): Promise<void> { const cache = loadCache() const usesIssues = isUses ? findUsesIssues(body, cache) : [] const gitmodulesIssues = isGitmodules ? findGitmodulesIssues(body) : [] - const packageJsonIssues = isPackageJson ? findPackageJsonIssues(body, cache) : [] + const packageJsonIssues = isPackageJson + ? findPackageJsonIssues(body, cache) + : [] saveCache(cache) if ( @@ -408,7 +407,9 @@ async function main(): Promise<void> { out.push('') } for (const issue of packageJsonIssues) { - out.push(` ${filePath}: git+https://github.com/${issue.ownerRepo}#${issue.ref}`) + out.push( + ` ${filePath}: git+https://github.com/${issue.ownerRepo}#${issue.ref}`, + ) out.push(` ↳ ${issue.problem}`) out.push('') } diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts b/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts index 655d59a..a4b3626 100644 --- a/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts +++ b/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts @@ -84,7 +84,8 @@ test('BLOCKS .gitmodules submodule with header but no ref', () => { tool_input: { file_path: '/repo/.gitmodules', content: - '# foo-1.2.3 sha256:' + 'a'.repeat(64) + + '# foo-1.2.3 sha256:' + + 'a'.repeat(64) + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\turl = https://github.com/owner/foo.git\n', }, }) @@ -98,8 +99,11 @@ test('BLOCKS .gitmodules header sha256 of wrong length', () => { tool_input: { file_path: '/repo/.gitmodules', content: - '# foo-1.2.3 sha256:' + 'a'.repeat(32) + - '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\tref = ' + 'b'.repeat(40) + '\n', + '# foo-1.2.3 sha256:' + + 'a'.repeat(32) + + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\tref = ' + + 'b'.repeat(40) + + '\n', }, }) assert.equal(exitCode, 2) @@ -112,7 +116,8 @@ test('BLOCKS .gitmodules ref of wrong length', () => { tool_input: { file_path: '/repo/.gitmodules', content: - '# foo-1.2.3 sha256:' + 'a'.repeat(64) + + '# foo-1.2.3 sha256:' + + 'a'.repeat(64) + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\tref = abc123\n', }, }) @@ -153,8 +158,7 @@ test('IGNORES node_modules/package.json', () => { tool_name: 'Write', tool_input: { file_path: '/repo/node_modules/foo/package.json', - content: - '{"dependencies": {"x": "git+https://github.com/owner/x#abc"}}', + content: '{"dependencies": {"x": "git+https://github.com/owner/x#abc"}}', }, }) assert.equal(exitCode, 0) diff --git a/.claude/hooks/no-non-fleet-push-guard/README.md b/.claude/hooks/no-non-fleet-push-guard/README.md deleted file mode 100644 index 332dccb..0000000 --- a/.claude/hooks/no-non-fleet-push-guard/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# no-non-fleet-push-guard - -PreToolUse(Bash) hook that blocks `git push` to a repository outside the -fleet. - -## Why - -The fleet's git-side pre-push hook only exists in repos that installed -the fleet hook chain. A non-fleet repo (a personal checkout, a sibling -project like `depot`) has no such hook, so a stray `cd /…/depot && git -push` sails straight through. The block has to live agent-side, before -the command runs, and resolve the target repo against the fleet roster. - -Past incident: an agent `cd`-ed into `depot` (not a fleet repo) and -pushed a fleet-convention change to its `main`. The push succeeded -because depot has no fleet pre-push hook. This guard is the response. - -## What it blocks - -| Command shape | Resolves target via | Block? | -| ------------------------------------------ | ------------------- | ------ | -| `git push` (in a fleet repo cwd) | process cwd | no | -| `git push` (in a non-fleet repo cwd) | process cwd | yes | -| `cd /path/to/depot && git push` | leading `cd` | yes | -| `git -C /path/to/depot push` | `-C` flag | yes | -| `echo "git push"` / commit msg saying push | (not a push) | no | -| `git push` where `origin` is unresolvable | (fail open) | no | - -Fleet membership is the broad set in -[`_shared/fleet-repos.mts`](../_shared/fleet-repos.mts) (`FLEET_REPO_NAMES`), -which includes `ultrathink` and other members the narrower cascade -roster (`cascading-fleet/lib/fleet-repos.json`) omits. Gating on the -broad set is deliberate: a fleet member is pushable even if it isn't a -cascade target. - -## Target-directory resolution - -In priority order: - -1. `git -C <dir> push …` — the explicit `-C` dir. -2. A leading `cd <dir>` in the command chain (`cd X && git push`), - resolved against the process cwd for relative paths. -3. The hook's process cwd. - -Then `git -C <dir> remote get-url origin` → slug via `slugFromRemoteUrl` -→ `isFleetRepo(slug)`. - -## Fail-open - -Any resolution ambiguity (no `git push` found, dir unreadable, no -`origin`, unparseable remote URL) → allow. Under-blocking is recoverable -(the operator reverts a stray push); a false block wedges a valid -workflow. The guard only fires when it can positively identify a -non-fleet origin slug. - -## Bypass - -Type the canonical phrase in a new message: - - Allow non-fleet-push bypass - -Use for a genuine push to a personal / non-fleet repo you own. - -## Detection: shell parser, not regex - -`git push` detection goes through the shared shell parser -([`_shared/shell-command.mts`](../_shared/shell-command.mts), which wraps -`shell-quote`), not a regex. The parser splits the command line into -segments and reads the binary + subcommand at each position, so it sees -through: - -- `&&` / `||` / `;` / `|` chains (`cd /x && git push`) -- `$(…)` command substitution (`git push $(echo origin)`) -- quoted bodies (`git commit -m "git push later"` is NOT a push) -- global options before the subcommand (`git -C /x push`) - -Remaining limits of any static parser (shared with -`gh-token-hygiene-guard`): a binary fully sourced from a variable -(`g=git; $g push`) can't be statically resolved to `git` — the parser -FLAGS it as opaque (`hasOpaqueInvocation`) but this guard doesn't act on -that today; and an alias or wrapper script that pushes is out of scope. diff --git a/.claude/hooks/no-non-fleet-push-guard/index.mts b/.claude/hooks/no-non-fleet-push-guard/index.mts deleted file mode 100644 index 1bb753a..0000000 --- a/.claude/hooks/no-non-fleet-push-guard/index.mts +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-non-fleet-push-guard. -// -// Blocks `git push` to a repository that is NOT a fleet member. The -// fleet's git-side pre-push hook can't catch this: a non-fleet repo -// never has the fleet hook chain installed (that's exactly how a stray -// push to e.g. `depot` slips through). So the guard lives agent-side, -// inspecting the Bash command before it runs, and resolves the target -// repo's origin remote against the canonical fleet roster. -// -// Detection model: -// - Fires only on Bash commands containing `git push` at an -// executable position (not inside quotes / heredoc bodies — a -// commit message that says "git push" is not a push). -// - Resolves the TARGET directory, in priority order: -// 1. `git -C <dir> push …` (explicit -C) -// 2. a leading `cd <dir> && …` (the `cd /…/depot && git push` -// shape that bypasses the session cwd) -// 3. the hook's process cwd -// - Reads `git -C <dir> remote get-url origin`, extracts the repo -// slug, and blocks when the slug is not in FLEET_REPO_NAMES. -// -// Bypass: `Allow non-fleet-push bypass` typed verbatim in a recent user -// turn — for the rare legitimate push to a personal / non-fleet repo. -// -// Fails OPEN on any resolution ambiguity (can't find the command, the -// dir, or the remote): better to under-block than to wedge a valid -// push when the shape is unfamiliar. The cost of a missed block is one -// `Allow … bypass`-free push the operator can revert; the cost of a -// false block is a bricked workflow. - -import path from 'node:path' -import process from 'node:process' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { isFleetRepo, slugFromRemoteUrl } from '../_shared/fleet-repos.mts' -import { findInvocation } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow non-fleet-push bypass' - -// `git -C <dir> …` — capture the dir (quoted or bare). Still a regex -// because we only need the -C VALUE, not command structure; the push -// DETECTION (which needs structure) goes through the shell parser. -const GIT_DASH_C_RE = /\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/ - -// A leading `cd <dir>` before the push, e.g. `cd /x/depot && git push`. -// Only the FIRST cd in the chain matters for where git runs. -const LEADING_CD_RE = /(?:^|[;&|]|&&)\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/ - -export function extractGitCwd(command: string): string { - // Priority 1: explicit `git -C <dir>`. - const dashC = GIT_DASH_C_RE.exec(command) - if (dashC) { - return dashC[2] ?? dashC[3] ?? dashC[4] ?? process.cwd() - } - // Priority 2: a leading `cd <dir>` in the chain. - const cd = LEADING_CD_RE.exec(command) - if (cd) { - const dir = cd[2] ?? cd[3] ?? cd[4] - if (dir) { - // Resolve against process cwd so a relative `cd ../foo` works. - return path.resolve(process.cwd(), dir) - } - } - // Priority 3: the hook's own cwd. - return process.cwd() -} - -export function originSlug(dir: string): string | undefined { - let out: string - try { - const r = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { - encoding: 'utf8', - }) - if (r.status !== 0) { - return undefined - } - out = String(r.stdout ?? '').trim() - } catch { - return undefined - } - return slugFromRemoteUrl(out) -} - -async function main(): Promise<void> { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command - if (!command) { - process.exit(0) - } - - // Detect `git push` via the shell parser (not regex): it splits the - // command line into segments, sees through `&&`/`|`/`;` chains and - // `$(…)` substitution, and ignores `push` inside a quoted commit - // message — so `git commit -m "git push later"` is correctly NOT a - // push, while `cd /x && git push` and `git -C /x push` are. - if (!findInvocation(command, { binary: 'git', subcommand: 'push' })) { - process.exit(0) - } - - const dir = extractGitCwd(command) - const slug = originSlug(dir) - - // Fail open: no resolvable origin slug → can't classify, allow. - if (!slug) { - process.exit(0) - } - if (isFleetRepo(slug)) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - process.stderr.write( - [ - '[no-non-fleet-push-guard] Blocked: push to a non-fleet repository', - '', - ` Target dir: ${dir}`, - ` origin repo: ${slug}`, - '', - ` \`${slug}\` is not in the fleet roster, and fleet tooling must`, - ' not push to repos outside the fleet. A non-fleet repo has no', - ' fleet hook chain, so this agent-side guard is the only check', - ' standing between you and a stray push to someone else’s repo.', - '', - ' If this push is wrong: you probably `cd`-ed into the wrong repo', - ' or have the wrong `origin`. Verify with:', - ` git -C ${dir} remote get-url origin`, - '', - ` If the push is genuinely intended (a personal / non-fleet repo`, - ` you own), type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[no-non-fleet-push-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/no-non-fleet-push-guard/package.json b/.claude/hooks/no-non-fleet-push-guard/package.json deleted file mode 100644 index 4f2d28d..0000000 --- a/.claude/hooks/no-non-fleet-push-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-non-fleet-push-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts b/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts deleted file mode 100644 index 9371ea6..0000000 --- a/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts +++ /dev/null @@ -1,171 +0,0 @@ -// node --test specs for the no-non-fleet-push-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns the hook -// subprocess and pipes stdin/stdout/stderr. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -// prefer-spawn-over-execsync: required -- test asserts the hook's behavior under a synchronous execFileSync call path. -import { execFileSync } from 'node:child_process' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -// Make a throwaway git repo with the given origin URL, return its path. -function gitRepoWithOrigin(originUrl: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'nfp-guard-')) - const run = (...args: string[]) => - execFileSync('git', ['-C', dir, ...args], { stdio: 'ignore' }) - run('init', '-q') - run('remote', 'add', 'origin', originUrl) - return dir -} - -// A dir that is NOT a git repo (no origin) — for the fail-open case. -function nonGitDir(): string { - return mkdtempSync(path.join(os.tmpdir(), 'nfp-nongit-')) -} - -async function runHook( - payload: Record<string, unknown>, - cwd?: string, -): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { cwd, stdio: 'pipe' }) - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -const bash = (command: string) => ({ - tool_name: 'Bash', - tool_input: { command }, -}) - -test('non-Bash tool passes', async () => { - const r = await runHook({ tool_name: 'Edit', tool_input: { command: 'x' } }) - assert.strictEqual(r.code, 0) -}) - -test('Bash without git push passes', async () => { - const r = await runHook(bash('ls -la && echo hi')) - assert.strictEqual(r.code, 0) -}) - -test('fleet repo via cwd — git push allowed', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/socket-cli.git') - const r = await runHook(bash('git push origin main'), dir) - assert.strictEqual(r.code, 0) -}) - -test('non-fleet repo via cwd — git push BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const r = await runHook(bash('git push origin main'), dir) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('non-fleet repo via leading cd — BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - // cwd is a fleet repo; the cd redirects git into the non-fleet one. - const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') - const r = await runHook(bash(`cd ${dir} && git push origin main`), fleetCwd) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('non-fleet repo via git -C — BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') - const r = await runHook(bash(`git -C ${dir} push origin main`), fleetCwd) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('ultrathink (fleet member, not in cascade roster) — allowed', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/ultrathink.git') - const r = await runHook(bash('git push'), dir) - assert.strictEqual(r.code, 0) -}) - -test('HTTPS remote, non-fleet — BLOCKED', async () => { - const dir = gitRepoWithOrigin('https://github.com/SocketDev/depot.git') - const r = await runHook(bash('git push origin main'), dir) - assert.strictEqual(r.code, 2) -}) - -test('fork under another owner of a fleet name — allowed (slug matches)', async () => { - // slug is keyed on repo name; a socket-cli fork still resolves to a - // fleet slug. (Owner-level gating is out of scope; the name is the key.) - const dir = gitRepoWithOrigin('git@github.com:someuser/socket-cli.git') - const r = await runHook(bash('git push'), dir) - assert.strictEqual(r.code, 0) -}) - -test('git push mentioned only in a quoted commit message — not a push', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const r = await runHook( - bash(`git commit -m "remember to git push later"`), - dir, - ) - assert.strictEqual(r.code, 0) -}) - -test('non-git dir (no origin) — fail open, allowed', async () => { - const dir = nonGitDir() - const r = await runHook(bash('git push'), dir) - assert.strictEqual(r.code, 0) -}) - -test('substitution: git $(printf push) to a non-fleet repo — BLOCKED', async () => { - // The shell parser surfaces `git push` even when the subcommand is - // produced by a $(…) substitution — a form the old regex missed. - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const r = await runHook(bash('git push $(echo origin) main'), dir) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('pipe/chain push to non-fleet repo — BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') - const r = await runHook( - bash(`echo start && cd ${dir} && git push origin main`), - fleetCwd, - ) - assert.strictEqual(r.code, 2) -}) - -test('bypass phrase in transcript — non-fleet push allowed', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const txDir = mkdtempSync(path.join(os.tmpdir(), 'nfp-tx-')) - const transcriptPath = path.join(txDir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow non-fleet-push bypass' }, - }) + '\n', - ) - const r = await runHook( - { - ...bash('git push origin main'), - transcript_path: transcriptPath, - }, - dir, - ) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md b/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md deleted file mode 100644 index acffb60..0000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# no-package-json-pnpm-overrides-guard - -PreToolUse Edit/Write hook that blocks adding (or expanding) a -`pnpm.overrides` block in any `package.json`. - -## Why - -pnpm reads dependency overrides from two places: `pnpm.overrides` in -`package.json`, or the top-level `overrides:` map in `pnpm-workspace.yaml`. -The fleet standardizes on the workspace file as the single override surface. - -A `pnpm.overrides` block in package.json splits the source of truth: a -reviewer auditing pins now has to check two files, and the workspace file's -`trustPolicy: no-downgrade` only governs the overrides declared there. An -override hiding in a package.json can silently downgrade a transitive dep -past the trust policy. - -## What it blocks - -| Pattern | Block? | -| ------------------------------------------------------------------ | ------ | -| Edit/Write that adds a key under `pnpm.overrides` in package.json | yes | -| Edit/Write that removes a key from `pnpm.overrides` | no | -| Edit/Write touching package.json but not `pnpm.overrides` | no | -| Edit/Write to `pnpm-workspace.yaml` `overrides:` (the right place) | no | -| Edit/Write to any other file | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow package-json-overrides bypass - -Rare legitimate case: a published package that ships its own -`pnpm.overrides` you're vendoring verbatim and must not rewrite. - -## Detection - -The hook parses both the current package.json and the after-edit contents -as JSON, reads `pnpm.overrides`, and computes the set difference of override -keys. Keys added → block. Keys removed or unchanged → pass. - -Fails open on JSON parse errors: better to under-block than to brick edits -when the file is in a transient bad state. - -## Fix - -Move the override to the top-level `overrides:` map in `pnpm-workspace.yaml`, -then `pnpm install`: - -```yaml -# pnpm-workspace.yaml -overrides: - some-dep: '>=1.2.3' -``` diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts b/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts deleted file mode 100644 index 85cb6bf..0000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-package-json-pnpm-overrides-guard. -// -// Blocks Edit/Write operations that add (or expand) a `pnpm.overrides` -// block in any `package.json`. The fleet keeps dependency overrides in -// `pnpm-workspace.yaml` `overrides:` as the single source of truth. A -// `pnpm.overrides` block in package.json splits that surface and sits -// outside the workspace file's `trustPolicy: no-downgrade` governance. -// -// Detection model: -// - Fires only on Edit / Write to files named `package.json`. -// - Parses before + after JSON. Reports the override keys that are -// present in the after-state but absent (or fewer) in the before. -// - New / expanded `pnpm.overrides` → block. -// -// Bypass: `Allow package-json-overrides bypass` typed verbatim in a -// recent user turn. -// -// Fails open on parse errors (better to under-block than to brick edits -// when the file isn't parseable JSON). - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly old_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow package-json-overrides bypass' - -// Extract the set of override keys declared under `pnpm.overrides` in a -// package.json text. Returns an empty set when the block is absent, the -// text isn't valid JSON, or `pnpm.overrides` isn't an object. pnpm reads -// overrides from `pnpm.overrides` (package.json) or top-level `overrides` -// (pnpm-workspace.yaml); this guard targets the package.json form only. -export function extractOverrideKeys(jsonText: string): Set<string> { - const out = new Set<string>() - let parsed: unknown - try { - parsed = JSON.parse(jsonText) - } catch { - return out - } - if (!parsed || typeof parsed !== 'object') { - return out - } - const pnpm = (parsed as { pnpm?: unknown | undefined }).pnpm - if (!pnpm || typeof pnpm !== 'object') { - return out - } - const overrides = (pnpm as { overrides?: unknown | undefined }).overrides - if (!overrides || typeof overrides !== 'object') { - return out - } - for (const key of Object.keys(overrides as Record<string, unknown>)) { - out.add(key) - } - return out -} - -export function readFileSafe(p: string): string { - try { - return readFileSync(p, 'utf8') - } catch { - return '' - } -} - -async function main(): Promise<void> { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const input = payload.tool_input - const filePath = input?.file_path - if (!filePath || path.basename(filePath) !== 'package.json') { - process.exit(0) - } - - const currentText = readFileSafe(filePath) - let afterText: string - if (payload.tool_name === 'Write') { - afterText = input?.content ?? input?.new_string ?? '' - } else { - const oldStr = input?.old_string ?? '' - const newStr = input?.new_string ?? '' - if (!oldStr) { - process.exit(0) - } - if (!currentText.includes(oldStr)) { - process.exit(0) - } - afterText = currentText.replace(oldStr, newStr) - } - - let beforeKeys: Set<string> - let afterKeys: Set<string> - try { - beforeKeys = extractOverrideKeys(currentText) - afterKeys = extractOverrideKeys(afterText) - } catch (e) { - process.stderr.write( - `[no-package-json-pnpm-overrides-guard] parse error (allowing): ${e}\n`, - ) - process.exit(0) - } - - const added: string[] = [] - for (const key of afterKeys) { - if (!beforeKeys.has(key)) { - added.push(key) - } - } - if (added.length === 0) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - added.sort() - process.stderr.write( - [ - '[no-package-json-pnpm-overrides-guard] Blocked: package.json pnpm.overrides additions', - '', - ` File: ${filePath}`, - ` New entries: ${added.map(k => `\`${k}\``).join(', ')}`, - '', - ' The fleet keeps dependency overrides in `pnpm-workspace.yaml`', - ' `overrides:`, the single override surface. A `pnpm.overrides`', - ' block in package.json splits the source of truth and sits', - ' outside the workspace file’s `trustPolicy: no-downgrade`.', - '', - ' Fix: move the override to the top-level `overrides:` map in', - ' `pnpm-workspace.yaml`, then `pnpm install`.', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[no-package-json-pnpm-overrides-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json b/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json deleted file mode 100644 index eeb28c3..0000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-package-json-pnpm-overrides-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts b/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts deleted file mode 100644 index 616ff54..0000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts +++ /dev/null @@ -1,147 +0,0 @@ -// node --test specs for the no-package-json-pnpm-overrides-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function tmpPackageJson(content: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-test-')) - const p = path.join(dir, 'package.json') - writeFileSync(p, content) - return p -} - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'echo hi' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit to a non-package.json file passes', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-other-')) - const filePath = path.join(dir, 'pnpm-workspace.yaml') - writeFileSync(filePath, 'overrides:\n foo: 1.0.0\n') - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: 'foo: 1.0.0', - new_string: 'foo: 2.0.0', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit that does not touch pnpm.overrides passes', async () => { - const filePath = tmpPackageJson( - '{\n "name": "x",\n "version": "1.0.0"\n}\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: '"1.0.0"', - new_string: '"1.0.1"', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit removes a pnpm.overrides key — passes', async () => { - const filePath = tmpPackageJson( - '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1", "b": "2" } }\n}\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: '{ "a": "1", "b": "2" }', - new_string: '{ "a": "1" }', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit adds a new pnpm.overrides key — blocked', async () => { - const filePath = tmpPackageJson( - '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1" } }\n}\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: '{ "a": "1" }', - new_string: '{ "a": "1", "b": "2" }', - }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('`b`')) -}) - -test('Write adds a fresh pnpm.overrides — blocked', async () => { - const filePath = tmpPackageJson('{ "name": "x" }') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: '{ "name": "x", "pnpm": { "overrides": { "sketchy": "9" } } }', - }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('sketchy')) -}) - -test('Edit with bypass phrase in transcript — passes', async () => { - const filePath = tmpPackageJson('{ "name": "x" }') - const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow package-json-overrides bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: '{ "name": "x", "pnpm": { "overrides": { "b": "2" } } }', - }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json b/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/parallel-agent-edit-guard/README.md b/.claude/hooks/parallel-agent-edit-guard/README.md deleted file mode 100644 index 9b93609..0000000 --- a/.claude/hooks/parallel-agent-edit-guard/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# parallel-agent-edit-guard - -PreToolUse (Edit / Write / NotebookEdit) hook. Blocks a write whose target -file is **another agent's in-flight work** — dirty in this checkout, not -authored by this session, and changed recently. Writing it would silently -clobber the other agent's uncommitted edits. - -## When it fires - -Only when the **edit target** is foreign (see `_shared/foreign-paths.mts`): - -- the target path is dirty in `git status --porcelain` (minus - untracked-by-default trees: `vendor/`, `third_party/`, `upstream/`, …), -- its resolved absolute path is not in this session's transcript - touched-set (Edit / Write / NotebookEdit `file_path` + `git add|mv|rm`), -- its on-disk mtime is within 30 min of now (stale pre-session dirt is - ignored). - -Editing your own files, a fresh file nobody has touched, or any file when -no parallel agent is active — all pass through. - -## Why - -Incident 2026-05-27: two Claude sessions plus a Codex companion shared one -`socket-wheelhouse` checkout. One session repeatedly re-cascaded -`shell-command.mts` + test files, silently reverting the other session's -type-error fixes one Edit at a time. The four-times-clobbered fixes only -stuck once both sessions stopped touching the same files. - -`parallel-agent-staging-guard` catches the *git-op* version of this hazard -(`git add -A` / `stash` / `reset --hard`); it can't see a plain `Write` -that overwrites a file. This hook closes that gap at the write itself. - -## Companion hooks - -- `parallel-agent-staging-guard` — refuses git ops that sweep/destroy - foreign work. -- `parallel-agent-on-stop-reminder` — surfaces the foreign-path signal at - turn end (informational). - -All three share the `_shared/foreign-paths.mts` heuristic. - -## Bypass - -- User types `Allow parallel-agent-edit bypass` in chat (case-sensitive), - then retry — one action. -- `SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED=1` in env. -- `FLEET_SYNC=1` in env — cascade scripts run in a fresh worktree off - `origin/main`, so there is no parallel-session hazard. - -Fails open on hook bugs (exit 0 + stderr log). diff --git a/.claude/hooks/parallel-agent-edit-guard/index.mts b/.claude/hooks/parallel-agent-edit-guard/index.mts deleted file mode 100644 index 9e3d3bd..0000000 --- a/.claude/hooks/parallel-agent-edit-guard/index.mts +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — parallel-agent-edit-guard. -// -// Blocks an Edit / Write / NotebookEdit whose target file is ANOTHER -// agent's in-flight work: a path that is dirty in this checkout, was NOT -// authored by this session, and changed recently. Writing it would -// silently clobber the other agent's uncommitted edits (the failure mode -// where two sessions share one `.git/` and each overwrites the other's -// changes mid-edit). -// -// Relationship to the sibling parallel-agent hooks: -// • parallel-agent-staging-guard — refuses git ops (add -A / stash / -// reset --hard / …) that sweep up or destroy foreign work. -// • parallel-agent-on-stop-reminder — surfaces the foreign-path signal -// at turn end (informational). -// • THIS hook — refuses the direct file write that clobbers a foreign -// file before it lands. Same "foreign" heuristic -// (`_shared/foreign-paths.mts`), applied to the edit target. -// -// Only fires when the target is itself foreign — editing your own files, -// or any file when no parallel agent is active, passes through. A fresh -// (untouched-by-anyone) file is never foreign. -// -// Why this exists (incident 2026-05-27): two Claude sessions + a Codex -// companion shared one socket-wheelhouse checkout. One session kept -// re-cascading shell-command.mts / test files, silently reverting the -// other's type-error fixes Edit-by-Edit. The staging guard didn't catch -// it (no git op involved) — the clobber was a plain Write. -// -// Bypass: -// • `SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED=1`. -// • `Allow parallel-agent-edit bypass` in a recent user turn -// (case-sensitive) — one action. -// • `FLEET_SYNC=1` in env — cascade scripts run in a fresh worktree off -// origin/main and have no parallel-session hazard. -// -// Fails open on hook bugs (exit 0 + stderr log). Reads a PreToolUse JSON -// payload from stdin: -// { "tool_name": "Edit" | "Write" | "NotebookEdit", -// "tool_input": { "file_path": "..." }, -// "transcript_path": "/.../session.jsonl" } - -import path from 'node:path' -import process from 'node:process' - -import { - listForeignDirtyPaths, - readTouchedPaths, -} from '../_shared/foreign-paths.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolPayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly file_path?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED' -const BYPASS_PHRASES = ['Allow parallel-agent-edit bypass'] as const -const EDIT_TOOLS = new Set(['Edit', 'NotebookEdit', 'Write']) - -function getProjectDir(): string { - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -async function main(): Promise<void> { - if (process.env[ENV_DISABLE] || process.env['FLEET_SYNC'] === '1') { - process.exit(0) - } - const raw = await readStdin() - let payload: ToolPayload - try { - payload = JSON.parse(raw) as ToolPayload - } catch { - process.exit(0) - } - if (!payload.tool_name || !EDIT_TOOLS.has(payload.tool_name)) { - process.exit(0) - } - const filePath = ( - payload.tool_input as { file_path?: unknown | undefined } | undefined - )?.file_path - if (typeof filePath !== 'string' || !filePath.trim()) { - process.exit(0) - } - - const repoDir = getProjectDir() - const targetAbs = path.resolve(repoDir, filePath) - - const touched = readTouchedPaths(payload.transcript_path) - // If THIS session already authored the target, it's ours — not foreign. - if (touched.has(targetAbs)) { - process.exit(0) - } - - const foreign = listForeignDirtyPaths(repoDir, touched) - if (foreign.length === 0) { - process.exit(0) - } - // The target is foreign only if it's in the foreign-dirty set. - const targetIsForeign = foreign.some( - rel => path.resolve(repoDir, rel) === targetAbs, - ) - if (!targetIsForeign) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES, 3) - ) { - process.exit(0) - } - - process.stderr.write( - [ - `[parallel-agent-edit-guard] Blocked: ${payload.tool_name} ${filePath}`, - '', - ' This file is dirty in the checkout, was NOT authored by this', - ' session, and changed recently — another agent on the same `.git/`', - ' is editing it. Writing now would silently clobber their', - ' uncommitted work (and they may clobber yours right back).', - '', - ' Fix: coordinate — let the other session commit first, or work on', - ' a different file. For an isolated edit, use a `git worktree`.', - '', - ' Bypass (only if you are certain the other edit is abandoned):', - ' user types "Allow parallel-agent-edit bypass" in chat, then retry.', - ].join('\n') + '\n', - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[parallel-agent-edit-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) - process.exit(0) -}) diff --git a/.claude/hooks/parallel-agent-edit-guard/package.json b/.claude/hooks/parallel-agent-edit-guard/package.json deleted file mode 100644 index 3af0e2a..0000000 --- a/.claude/hooks/parallel-agent-edit-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-parallel-agent-edit-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/parallel-agent-edit-guard/test/index.test.mts b/.claude/hooks/parallel-agent-edit-guard/test/index.test.mts deleted file mode 100644 index a58a460..0000000 --- a/.claude/hooks/parallel-agent-edit-guard/test/index.test.mts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @file Unit tests for parallel-agent-edit-guard hook. The guard blocks an Edit - * / Write / NotebookEdit whose target file is foreign: dirty in the checkout, - * not in this session's transcript touched-set, recently changed. Editing - * your own file, a fresh file, or any file when no parallel agent is active - * passes through. Each test builds a real git repo in tmpdir, optionally - * creates a "foreign" dirty file (written WITHOUT a transcript entry), and - * runs the hook as a child process with a synthesized PreToolUse payload. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly code: number - readonly stderr: string -} - -function runHook( - filePath: string, - options: { - toolName?: string | undefined - cwd?: string | undefined - transcriptPath?: string | undefined - env?: Record<string, string> | undefined - } = {}, -): RunResult { - const payload = { - tool_name: options.toolName ?? 'Write', - tool_input: { file_path: filePath }, - transcript_path: options.transcriptPath, - } - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - env: { - ...process.env, - ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), - ...(options.env ?? {}), - }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function gitInit(repo: string): void { - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) -} - -// Write a dirty file with NO transcript entry → it reads as foreign. -function writeForeign(repo: string, name: string): string { - const p = path.join(repo, name) - writeFileSync(p, 'foreign content') - return p -} - -// A transcript whose only tool use is an Edit on `ownAbsPath` → that path is -// this session's, not foreign. -function writeTranscriptTouching(ownAbsPath: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'paeguard-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const entry = { - message: { - role: 'assistant', - content: [ - { type: 'tool_use', name: 'Edit', input: { file_path: ownAbsPath } }, - ], - }, - } - writeFileSync(transcriptPath, JSON.stringify(entry)) - return transcriptPath -} - -let repo: string - -beforeEach(() => { - repo = mkdtempSync(path.join(os.tmpdir(), 'paeguard-repo-')) - gitInit(repo) -}) - -afterEach(() => { - rmSync(repo, { recursive: true, force: true }) -}) - -// ─── Blocks when the target is foreign ──────────────────────────── - -test('blocks Write to a foreign dirty file', () => { - const theirs = writeForeign(repo, 'theirs.txt') - const r = runHook(theirs, { cwd: repo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /Blocked/) - assert.match(r.stderr, /theirs\.txt/) -}) - -test('blocks Edit to a foreign dirty file', () => { - const theirs = writeForeign(repo, 'theirs.txt') - const r = runHook(theirs, { cwd: repo, toolName: 'Edit' }) - assert.equal(r.code, 2) -}) - -test('blocks NotebookEdit to a foreign dirty file', () => { - const theirs = writeForeign(repo, 'theirs.ipynb') - const r = runHook(theirs, { cwd: repo, toolName: 'NotebookEdit' }) - assert.equal(r.code, 2) -}) - -test('foreign target matches via a repo-relative file_path', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('theirs.txt', { cwd: repo }) - assert.equal(r.code, 2) -}) - -// ─── Passes ─────────────────────────────────────────────────────── - -test("allows editing THIS session's own dirty file", () => { - const own = writeForeign(repo, 'mine.txt') - const tx = writeTranscriptTouching(own) - const r = runHook(own, { cwd: repo, transcriptPath: tx }) - assert.equal(r.code, 0) -}) - -test("allows editing a foreign file's NEIGHBOR (different file)", () => { - writeForeign(repo, 'theirs.txt') - // Target is a fresh file the other agent isn't touching. - const r = runHook(path.join(repo, 'ours-new.txt'), { cwd: repo }) - assert.equal(r.code, 0) -}) - -test('allows editing a fresh file in a clean repo (no foreign paths)', () => { - const r = runHook(path.join(repo, 'new.txt'), { cwd: repo }) - assert.equal(r.code, 0) -}) - -// ─── Bypass / sentinel / disable ────────────────────────────────── - -test('FLEET_SYNC=1 env bypasses the block', () => { - const theirs = writeForeign(repo, 'theirs.txt') - const r = runHook(theirs, { cwd: repo, env: { FLEET_SYNC: '1' } }) - assert.equal(r.code, 0) -}) - -test('disabled via env var', () => { - const theirs = writeForeign(repo, 'theirs.txt') - const r = runHook(theirs, { - cwd: repo, - env: { SOCKET_PARALLEL_AGENT_EDIT_GUARD_DISABLED: '1' }, - }) - assert.equal(r.code, 0) -}) - -test('non-edit tool is ignored', () => { - writeForeign(repo, 'theirs.txt') - const r = spawnSync('node', [HOOK], { - input: JSON.stringify({ - tool_name: 'Bash', - tool_input: { command: 'ls' }, - }), - env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, - }) - assert.equal(typeof r.status === 'number' ? r.status : 0, 0) -}) - -test('fails open on malformed payload', () => { - const r = spawnSync('node', [HOOK], { - input: 'not json', - env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, - }) - assert.equal(typeof r.status === 'number' ? r.status : 0, 0) -}) diff --git a/.claude/hooks/parallel-agent-edit-guard/tsconfig.json b/.claude/hooks/parallel-agent-edit-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/parallel-agent-edit-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/README.md b/.claude/hooks/parallel-agent-on-stop-reminder/README.md deleted file mode 100644 index 1d93988..0000000 --- a/.claude/hooks/parallel-agent-on-stop-reminder/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# parallel-agent-on-stop-reminder - -Stop hook. At turn-end, lists dirty paths this session did **not** author and -that changed recently — the fingerprint of another Claude session sharing the -same `.git/`. Informational (exit 0, never blocks). - -## Heuristic - -A path is **foreign** when all hold (see `_shared/foreign-paths.mts`): - -- it's dirty in `git status --porcelain` (minus untracked-by-default trees: - `vendor/`, `third_party/`, `upstream/`, `*-bundled`, …), -- its resolved absolute path is not in this session's transcript touched-set - (Edit / Write / NotebookEdit `file_path` + `git add|mv|rm <path>` from Bash), -- its on-disk mtime is within `maxAgeMs` (default 30 min) of now — so stale - pre-session dirt doesn't false-fire. Deleted / renamed entries count without a - mtime check. - -## Why - -Incident 2026-05-27, socket-lib: a session running `pnpm run check` saw ~6 dirty -files it never touched (a parallel agent's esbuild→rolldown migration) and nearly -investigated them as its own regression, then nearly committed them. Nothing -warned it. This hook makes the signal visible at the turn that surfaces it. - -## Config - -- Disable: `SOCKET_PARALLEL_AGENT_REMINDER_DISABLED=1`. - -## Related - -- `parallel-agent-staging-guard` — PreToolUse block on destructive git ops while - foreign paths exist (the enforcement half). -- `dirty-worktree-on-stop-reminder` — the broader "you left the worktree dirty" - reminder this is modeled on. -- `overeager-staging-guard` — commit-time block on staging unfamiliar files. -- CLAUDE.md → "Parallel Claude sessions". diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/index.mts b/.claude/hooks/parallel-agent-on-stop-reminder/index.mts deleted file mode 100644 index 7cd0d71..0000000 --- a/.claude/hooks/parallel-agent-on-stop-reminder/index.mts +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — parallel-agent-on-stop-reminder. -// -// Fires at turn-end. Detects dirty paths in the checkout that THIS -// session did not author and that changed recently — the fingerprint -// of another Claude session (parallel agent, second terminal, or a -// worktree sharing the same `.git/`) working in the codebase at the -// same time. Emits a stderr reminder listing those foreign paths so -// the agent treats them cautiously: don't commit / revert / stash / -// stage them, stage only your own files by explicit path. -// -// Why this exists (incident 2026-05-27, socket-lib): a session running -// `pnpm run check` / build saw ~6 dirty files it never touched (an -// esbuild->rolldown migration another agent was mid-flight on) and -// nearly investigated them as its own regression, then nearly swept -// them into a commit. Nothing warned it. CLAUDE.md "Parallel Claude -// sessions" states the rule; this hook makes the live signal visible -// at the turn that surfaced it. -// -// Heuristic lives in `_shared/foreign-paths.mts` (shared with -// overeager-staging-guard + parallel-agent-staging-guard): foreign = -// dirty AND not in this session's transcript touched-set AND mtime -// recent. Vendored / build-copied trees are excluded. -// -// Exit codes: -// 0 — always. Informational; never blocks (Stop hooks fire after the -// turn ended — there's no tool call to refuse). -// -// Disabled via `SOCKET_PARALLEL_AGENT_REMINDER_DISABLED=1`. - -import process from 'node:process' - -import { - listForeignDirtyPaths, - readTouchedPaths, -} from '../_shared/foreign-paths.mts' -import { readStdin } from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -function getProjectDir(): string | undefined { - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -async function main(): Promise<void> { - if (process.env['SOCKET_PARALLEL_AGENT_REMINDER_DISABLED']) { - return - } - const raw = await readStdin() - let payload: StopPayload = {} - try { - payload = JSON.parse(raw) as StopPayload - } catch { - // Stop payload is optional for this hook; fall through with no - // transcript (touched-set empty → every recent dirty path counts). - } - - const repoDir = getProjectDir() - if (!repoDir) { - return - } - - const touched = readTouchedPaths(payload.transcript_path) - const foreign = listForeignDirtyPaths(repoDir, touched) - if (foreign.length === 0) { - return - } - - process.stderr.write( - `[parallel-agent-on-stop-reminder] ${foreign.length} dirty path(s) this session did not author and that changed recently — likely another agent on the same checkout:\n`, - ) - for (const p of foreign.slice(0, 10)) { - process.stderr.write(` ${p}\n`) - } - if (foreign.length > 10) { - process.stderr.write(` ... and ${foreign.length - 10} more\n`) - } - process.stderr.write( - '\nAnother Claude session may be working in this checkout. Be cautious:\n' + - ' • Do NOT commit, revert, stash, or `git add -A` these paths —\n' + - " that sweeps up or destroys the other agent's in-flight work.\n" + - ' • Stage only the files YOU authored, by explicit path.\n' + - ' • If you saw these appear after your own build / check run, they\n' + - " are the other agent's edits landing — not your regression.\n" + - '\nSee: CLAUDE.md → "Parallel Claude sessions"\n' + - ' docs/claude.md/fleet/parallel-claude-sessions.md\n', - ) -} - -main().catch(e => { - process.stderr.write( - `[parallel-agent-on-stop-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) -}) diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/package.json b/.claude/hooks/parallel-agent-on-stop-reminder/package.json deleted file mode 100644 index 3c8075c..0000000 --- a/.claude/hooks/parallel-agent-on-stop-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-parallel-agent-on-stop-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts b/.claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts deleted file mode 100644 index 6e00020..0000000 --- a/.claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * @file Unit tests for parallel-agent-on-stop-reminder hook. - * - * Stop hook, always exit 0. Emits a stderr reminder listing dirty paths this - * session did not author and that changed recently. Each test builds a real - * git repo in tmpdir, writes foreign / own dirty files, and runs the hook as a - * child process with a synthesized Stop payload. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly code: number - readonly stderr: string -} - -function runHook( - options: { - cwd?: string | undefined - transcriptPath?: string | undefined - env?: Record<string, string> | undefined - } = {}, -): RunResult { - const payload = { transcript_path: options.transcriptPath } - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - env: { - ...process.env, - ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), - ...(options.env ?? {}), - }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function gitInit(repo: string): void { - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) -} - -function writeFile(repo: string, name: string): string { - const p = path.join(repo, name) - writeFileSync(p, 'content') - return p -} - -function writeTranscriptTouching(ownAbsPath: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pareminder-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const entry = { - message: { - role: 'assistant', - content: [ - { type: 'tool_use', name: 'Write', input: { file_path: ownAbsPath } }, - ], - }, - } - writeFileSync(transcriptPath, JSON.stringify(entry)) - return transcriptPath -} - -let repo: string - -beforeEach(() => { - repo = mkdtempSync(path.join(os.tmpdir(), 'pareminder-repo-')) - gitInit(repo) -}) - -afterEach(() => { - rmSync(repo, { recursive: true, force: true }) -}) - -test('always exits 0', () => { - writeFile(repo, 'theirs.txt') - assert.equal(runHook({ cwd: repo }).code, 0) -}) - -test('reminds when a foreign dirty file exists (no transcript)', () => { - writeFile(repo, 'theirs.txt') - const r = runHook({ cwd: repo }) - assert.match(r.stderr, /parallel-agent-on-stop-reminder/) - assert.match(r.stderr, /theirs\.txt/) - assert.match(r.stderr, /another (Claude )?session|another agent/i) -}) - -test('silent when the only dirty file is this session\'s', () => { - const own = writeFile(repo, 'mine.txt') - const tx = writeTranscriptTouching(own) - const r = runHook({ cwd: repo, transcriptPath: tx }) - assert.equal(r.code, 0) - assert.doesNotMatch(r.stderr, /mine\.txt/) -}) - -test('silent on a clean repo', () => { - const r = runHook({ cwd: repo }) - assert.equal(r.code, 0) - assert.doesNotMatch(r.stderr, /parallel-agent-on-stop-reminder.*dirty/s) -}) - -test('ignores untracked-by-default trees (vendor/)', () => { - spawnSync('mkdir', ['-p', path.join(repo, 'vendor')], { cwd: repo }) - writeFile(repo, path.join('vendor', 'dep.js')) - const r = runHook({ cwd: repo }) - assert.doesNotMatch(r.stderr, /vendor\/dep\.js/) -}) - -test('disabled via env var', () => { - writeFile(repo, 'theirs.txt') - const r = runHook({ - cwd: repo, - env: { SOCKET_PARALLEL_AGENT_REMINDER_DISABLED: '1' }, - }) - assert.equal(r.code, 0) - assert.equal(r.stderr.trim(), '') -}) - -test('fails open on malformed payload', () => { - writeFile(repo, 'theirs.txt') - const r = spawnSync('node', [HOOK], { - input: 'not json', - env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, - }) - // No transcript → empty touched-set → still lists foreign, but never crashes. - assert.equal(typeof r.status === 'number' ? r.status : 0, 0) -}) diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/tsconfig.json b/.claude/hooks/parallel-agent-on-stop-reminder/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/parallel-agent-on-stop-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/parallel-agent-staging-guard/README.md b/.claude/hooks/parallel-agent-staging-guard/README.md deleted file mode 100644 index 915ea28..0000000 --- a/.claude/hooks/parallel-agent-staging-guard/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# parallel-agent-staging-guard - -PreToolUse (Bash) hook. Blocks git operations that would sweep up, hide, or -destroy another agent's in-flight work — **only when foreign dirty paths are -present** in the checkout. Surgical ops and the all-clear case pass through. - -## Gated operations (blocked only when foreign paths exist) - -| Op | Hazard | -|----|--------| -| `git add -A` / `.` / `--all` / `-u` | stages their unstaged edits | -| `git commit -a` / `--all` | stages + commits their edits | -| `git stash` / `stash push` | hides their working-tree changes | -| `git reset --hard` | destroys their uncommitted work | -| `git checkout <branch>` / `git switch <branch>` | may clobber on switch | -| `git restore <path>` | reverts their changes | - -Detection runs through the shared shell AST parser -(`_shared/shell-command.mts`), so indirection can't dodge it -(`git $(echo add) -A`, `g=git; $g stash`). Broad-add detection reuses -`detectBroadGitAdd` so this hook and `overeager-staging-guard` agree. - -## Relationship to overeager-staging-guard - -`overeager-staging-guard` owns the **general** broad-add rule (blocks `git add -A` -regardless of parallel agents). This hook adds the parallel-agent-specific -**destructive-op** coverage (stash / reset --hard / checkout / restore) and fires -**only** when the parallel-agent signal is live. On plain `git add -A` both may -fire; messages complement (this one names the foreign paths). - -## Foreign-path heuristic - -Same as `parallel-agent-on-stop-reminder` — see `_shared/foreign-paths.mts`. - -## Config / bypass - -- `FLEET_SYNC=1` command prefix — cascade worktrees off origin/main have no - parallel-session hazard. -- `Allow parallel-agent-staging bypass` in a recent user turn — one action. -- `SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED=1` — disable entirely. - -Fails open on hook bugs (exit 0 + stderr log). - -## Why - -Incident 2026-05-27, socket-lib — see `parallel-agent-on-stop-reminder`. The -reminder surfaces the signal; this guard refuses the destructive action. diff --git a/.claude/hooks/parallel-agent-staging-guard/index.mts b/.claude/hooks/parallel-agent-staging-guard/index.mts deleted file mode 100644 index 81848b8..0000000 --- a/.claude/hooks/parallel-agent-staging-guard/index.mts +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — parallel-agent-staging-guard. -// -// Blocks git operations that would sweep up or destroy ANOTHER agent's -// in-flight work when foreign dirty paths are present in the checkout. -// "Foreign" = dirty, not authored by this session (transcript touched- -// set), changed recently — see `_shared/foreign-paths.mts`. -// -// Gated operations (only blocked WHEN foreign paths exist): -// • `git add -A` / `.` / `--all` / `-u` / `--update` (broad stage) -// • `git commit -a` / `--all` (stage+commit) -// • `git stash` / `git stash push` (hides theirs) -// • `git reset --hard` (destroys theirs) -// • `git checkout <branch>` / `git switch <branch>` (may clobber) -// • `git restore <path>` (reverts theirs) -// -// Surgical `git add <file>` and every op when NO foreign paths are -// present pass through untouched. -// -// Relationship to overeager-staging-guard: that hook owns the GENERAL -// broad-add rule (blocks `git add -A` regardless of parallel agents). -// This hook adds the parallel-agent-specific destructive-op coverage -// (stash / reset --hard / checkout / restore) that the broad-add rule -// doesn't reach, and only fires when the parallel-agent signal is live. -// On plain `git add -A` both may fire; their messages are written to -// complement, not contradict (this one names the foreign paths). -// -// Why this exists (incident 2026-05-27, socket-lib): see -// parallel-agent-on-stop-reminder. The Stop reminder surfaces the -// signal; this guard refuses the destructive action before it lands. -// -// Reuses the shared shell AST parser (`_shared/shell-command.mts`) so -// chains / substitution / quoting / `$VAR` indirection can't dodge the -// match (`git $(echo add) -A`, `g=git; $g stash`). -// -// Bypass: -// • `FLEET_SYNC=1` command prefix — cascade scripts in a fresh -// worktree off origin/main have no parallel-session hazard. -// • `Allow parallel-agent-staging bypass` in a recent user turn -// (case-sensitive) — one action. -// • `SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED=1`. -// -// Fails open on hook bugs (exit 0 + stderr log). Reads a PreToolUse JSON -// payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// "transcript_path": "/.../session.jsonl" } - -import process from 'node:process' - -import { - listForeignDirtyPaths, - readTouchedPaths, -} from '../_shared/foreign-paths.mts' -import { - detectBroadGitAdd, - findInvocation, - invocationHasFlag, -} from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolPayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED' -const BYPASS_PHRASES = ['Allow parallel-agent-staging bypass'] as const - -function getProjectDir(): string { - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -// Return a short label for the gated op the command performs, or undefined. -// Reuses the shared AST parser — never regex on the raw string. -export function detectGatedGitOp(command: string): string | undefined { - // Broad `git add -A/./--all/-u` — reuse the canonical detector so this - // hook and overeager-staging-guard agree on what "broad" means. - const broadAdd = detectBroadGitAdd(command) - if (broadAdd) { - return broadAdd - } - // `git commit -a/--all`. - if ( - findInvocation(command, { binary: 'git', subcommand: 'commit' }) && - invocationHasFlag(command, 'git', ['-a', '--all']) - ) { - return 'git commit -a' - } - // `git stash` (and `stash push`). - if (findInvocation(command, { binary: 'git', subcommand: 'stash' })) { - return 'git stash' - } - // `git reset --hard`. - if ( - findInvocation(command, { binary: 'git', subcommand: 'reset' }) && - invocationHasFlag(command, 'git', ['--hard']) - ) { - return 'git reset --hard' - } - // `git checkout <branch>` / `git switch <branch>`. - if ( - findInvocation(command, { binary: 'git', subcommand: 'checkout' }) || - findInvocation(command, { binary: 'git', subcommand: 'switch' }) - ) { - return 'git checkout/switch' - } - // `git restore`. - if (findInvocation(command, { binary: 'git', subcommand: 'restore' })) { - return 'git restore' - } - return undefined -} - -async function main(): Promise<void> { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const raw = await readStdin() - let payload: ToolPayload - try { - payload = JSON.parse(raw) as ToolPayload - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = ( - payload.tool_input as { command?: unknown } | undefined - )?.command - if (typeof command !== 'string' || !command.trim()) { - process.exit(0) - } - - // Fleet-sync cascade sentinel: no parallel-session hazard in a fresh - // cascade worktree off origin/main. - if (/(?:^|\s)FLEET_SYNC\s*=\s*1\b/.test(command)) { - process.exit(0) - } - - const gatedOp = detectGatedGitOp(command) - if (!gatedOp) { - process.exit(0) - } - - const repoDir = getProjectDir() - const touched = readTouchedPaths(payload.transcript_path) - const foreign = listForeignDirtyPaths(repoDir, touched) - if (foreign.length === 0) { - // No parallel-agent signal — let the op through (overeager-staging- - // guard still owns the general broad-add rule independently). - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES, 3) - ) { - process.exit(0) - } - - process.stderr.write( - [ - `[parallel-agent-staging-guard] Blocked: ${gatedOp}`, - '', - ` ${foreign.length} dirty path(s) here were NOT authored by this`, - ' session and changed recently — likely another agent on the', - ' same checkout. This operation would sweep up, hide, or destroy', - ' their in-flight work:', - ...foreign.slice(0, 10).map(p => ` ${p}`), - ...(foreign.length > 10 ? [` ... and ${foreign.length - 10} more`] : []), - '', - ' Fix: stage only YOUR files by explicit path, and avoid stash /', - ' reset --hard / checkout while the other agent is active.', - ' git add path/to/your-file.ts', - '', - ' Bypass (only if you are certain): user types', - ' "Allow parallel-agent-staging bypass" in chat, then retry.', - ].join('\n') + '\n', - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[parallel-agent-staging-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) - process.exit(0) -}) diff --git a/.claude/hooks/parallel-agent-staging-guard/package.json b/.claude/hooks/parallel-agent-staging-guard/package.json deleted file mode 100644 index bac8547..0000000 --- a/.claude/hooks/parallel-agent-staging-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-parallel-agent-staging-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/parallel-agent-staging-guard/test/index.test.mts b/.claude/hooks/parallel-agent-staging-guard/test/index.test.mts deleted file mode 100644 index 1ce39e9..0000000 --- a/.claude/hooks/parallel-agent-staging-guard/test/index.test.mts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * @file Unit tests for parallel-agent-staging-guard hook. - * - * The guard blocks sweep / destructive git ops (add -A, commit -a, stash, - * reset --hard, checkout, restore) ONLY when foreign dirty paths are present: - * dirty, not in this session's transcript touched-set, recently changed. - * - * Each test builds a real git repo in tmpdir, optionally creates a "foreign" - * dirty file (written WITHOUT a corresponding Edit/Write transcript entry), - * and runs the hook as a child process with a synthesized PreToolUse payload. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly code: number - readonly stderr: string -} - -function runHook( - command: string, - options: { - cwd?: string | undefined - transcriptPath?: string | undefined - env?: Record<string, string> | undefined - } = {}, -): RunResult { - const payload = { - tool_name: 'Bash', - tool_input: { command }, - transcript_path: options.transcriptPath, - } - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - env: { - ...process.env, - ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), - ...(options.env ?? {}), - }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function gitInit(repo: string): void { - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) -} - -// Write a dirty file with NO transcript entry → it reads as foreign. -function writeForeign(repo: string, name: string): string { - const p = path.join(repo, name) - writeFileSync(p, 'foreign content') - return p -} - -// A transcript whose only tool use is an Edit on `ownFile` → that path is -// this session's, not foreign. -function writeTranscriptTouching(ownAbsPath: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'paguard-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const entry = { - message: { - role: 'assistant', - content: [ - { type: 'tool_use', name: 'Edit', input: { file_path: ownAbsPath } }, - ], - }, - } - writeFileSync(transcriptPath, JSON.stringify(entry)) - return transcriptPath -} - -let repo: string - -beforeEach(() => { - repo = mkdtempSync(path.join(os.tmpdir(), 'paguard-repo-')) - gitInit(repo) -}) - -afterEach(() => { - rmSync(repo, { recursive: true, force: true }) -}) - -// ─── Blocks when foreign paths present ──────────────────────────── - -test('blocks `git add -A` when a foreign dirty file exists', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('git add -A', { cwd: repo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /Blocked/) - assert.match(r.stderr, /theirs\.txt/) -}) - -test('blocks `git stash` when a foreign dirty file exists', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('git stash', { cwd: repo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git stash/) -}) - -test('blocks `git reset --hard` when a foreign dirty file exists', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('git reset --hard', { cwd: repo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /reset --hard/) -}) - -test('blocks `git checkout other` when a foreign dirty file exists', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('git checkout other-branch', { cwd: repo }) - assert.equal(r.code, 2) -}) - -test('blocks `git restore .` when a foreign dirty file exists', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('git restore .', { cwd: repo }) - assert.equal(r.code, 2) -}) - -test('sees through variable indirection (`g=git; $g stash`)', () => { - writeForeign(repo, 'theirs.txt') - // shell-quote flags $g as variable-sourced; the guard should still treat a - // resolvable `git stash` shape cautiously. If the parser cannot resolve the - // binary, the op is not matched — documents current behavior. - const r = runHook('git stash', { cwd: repo }) - assert.equal(r.code, 2) -}) - -// ─── Passes when NO foreign paths ───────────────────────────────── - -test('allows `git add -A` in a clean repo (no foreign paths)', () => { - const r = runHook('git add -A', { cwd: repo }) - assert.equal(r.code, 0) -}) - -test('allows `git stash` when the only dirty file is this session\'s', () => { - const own = writeForeign(repo, 'mine.txt') - const tx = writeTranscriptTouching(own) - const r = runHook('git stash', { cwd: repo, transcriptPath: tx }) - assert.equal(r.code, 0) -}) - -test('allows a surgical `git add <file>` even with foreign paths present', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('git add mine.txt', { cwd: repo }) - assert.equal(r.code, 0) -}) - -// ─── Bypass / sentinel / disable ────────────────────────────────── - -test('FLEET_SYNC=1 prefix bypasses the block', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('FLEET_SYNC=1 git add -A', { cwd: repo }) - assert.equal(r.code, 0) -}) - -test('disabled via env var', () => { - writeForeign(repo, 'theirs.txt') - const r = runHook('git stash', { - cwd: repo, - env: { SOCKET_PARALLEL_AGENT_STAGING_GUARD_DISABLED: '1' }, - }) - assert.equal(r.code, 0) -}) - -test('non-Bash tool is ignored', () => { - writeForeign(repo, 'theirs.txt') - const r = spawnSync('node', [HOOK], { - input: JSON.stringify({ tool_name: 'Edit', tool_input: {} }), - env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, - }) - assert.equal(typeof r.status === 'number' ? r.status : 0, 0) -}) - -test('fails open on malformed payload', () => { - const r = spawnSync('node', [HOOK], { - input: 'not json', - env: { ...process.env, CLAUDE_PROJECT_DIR: repo }, - }) - assert.equal(typeof r.status === 'number' ? r.status : 0, 0) -}) diff --git a/.claude/hooks/parallel-agent-staging-guard/tsconfig.json b/.claude/hooks/parallel-agent-staging-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/parallel-agent-staging-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/plugin-patch-format-guard/README.md b/.claude/hooks/plugin-patch-format-guard/README.md deleted file mode 100644 index c58c4b1..0000000 --- a/.claude/hooks/plugin-patch-format-guard/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# plugin-patch-format-guard - -PreToolUse Edit/Write hook that blocks malformed plugin-cache patches under `scripts/plugin-patches/`. - -## What it enforces - -The runtime consumer is `scripts/install-claude-plugins.mts` — its `reapplyPluginPatches()` parses each patch filename, strips the `# @key:` header, and feeds the body to `patch -p1`. A patch that doesn't match the convention is skipped (or fails to apply) at reconcile time. This hook catches the mistake at edit time instead. Rules: - -1. **Filename** matches `<plugin>-<version>-<slug>.patch` — lowercase-kebab plugin, dotted semver version, lowercase-kebab slug (e.g. `codex-1.0.1-stdin-eagain.patch`). -2. **Header** carries all four provenance keys as line-start comments: `# @plugin:`, `# @plugin-version:`, `# @sha:`, `# @description:` (`# @upstream:` is recommended but not required). -3. **Plain unified diff body** — must contain a `--- ` line, and must NOT contain git-diff markers: `diff --git`, `index <hash>..<hash>`, `new file mode`. `patch -p1` doesn't expect git markers; they break the apply. -4. **Version cross-check** — the `# @plugin-version:` value must match the version embedded in the filename (they map to the same plugin-cache dir). - -## Scope - -Fires only when the target `file_path` resolves under `scripts/plugin-patches/` and ends in `.patch` (normalized to `/`-separators first). Everything else passes through untouched. - -`Write` carries the whole file in `tool_input.content`, so it's fully validated. `Edit` only carries a `new_string` fragment — the hook can't see the surrounding file, so an `Edit` without `content` is skipped (the next `Write` or commit-time path catches it). - -## Why - -A plugin-cache patch is replayed over a cache Claude Code regenerates on every install. The format is load-bearing: the filename maps to the cache dir, the header carries provenance, and the body must be a tool-`patch`-compatible plain diff. Git-diff output (`git diff` / `git format-patch`) injects `index`/`mode` markers that bare `patch` rejects — a classic foot-gun this gate closes. Full spec: [`docs/claude.md/fleet/plugin-cache-patches.md`](../../../docs/claude.md/fleet/plugin-cache-patches.md). Regenerate stale patches via the `regenerating-plugin-patches` skill. - -## No bypass - -This is a pure format gate, not a policy gate — there's no `Allow … bypass` phrase. A malformed patch is always wrong; fix the patch. - -## Companion files - -- `index.mts` — the hook (exports `classifyPluginPatch`, `isPluginPatchPath`, `emitBlock`). -- `test/index.test.mts` — node:test specs. -- `package.json` — workspace declaration so `taze` can see the hook's deps. -- `tsconfig.json` — fleet-canonical TS config. - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. diff --git a/.claude/hooks/plugin-patch-format-guard/index.mts b/.claude/hooks/plugin-patch-format-guard/index.mts deleted file mode 100644 index f7566c8..0000000 --- a/.claude/hooks/plugin-patch-format-guard/index.mts +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — plugin-patch-format-guard. -// -// Blocks Edit/Write tool calls that would write a plugin-cache patch -// (`scripts/plugin-patches/*.patch`) in a non-canonical shape. The -// runtime consumer is `install-claude-plugins.mts`'s -// `reapplyPluginPatches()`, which: parses the filename via -// `parsePatchFileName`, strips the `# @key: value` header via -// `stripPatchHeader`, then feeds the body to `patch -p1`. A patch that -// doesn't match the convention is silently skipped (or worse, fails to -// apply) at reconcile time — this hook catches the mistake at edit time. -// -// What it enforces (full spec: docs/claude.md/fleet/plugin-cache-patches.md): -// -// 1. Filename `<plugin>-<version>-<slug>.patch` — lowercase-kebab -// plugin, dotted semver version, lowercase-kebab slug. -// 2. Four required `# @key:` header lines: @plugin, @plugin-version, -// @sha, @description. -// 3. A PLAIN `diff -u` body: must have a `--- ` line, must NOT carry -// git-diff markers (`diff --git`, `index ab..cd`, `new file mode`). -// `patch` doesn't expect git markers; they break the apply. -// 4. The `# @plugin-version:` value must match the version embedded in -// the filename (best-effort cross-check). -// -// Validation needs the WHOLE file content. Write passes it as -// `tool_input.content`. Edit only passes a `new_string` fragment — we -// can't see the surrounding file, so an Edit without `content` is -// skipped (documented limitation; the commit-time path / the next Write -// catch it). No bypass — this is a pure format gate, not a policy gate. -// -// Exit code 2 makes Claude Code refuse the tool call. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Fails open on hook bugs (exit 0 + stderr log). - -import process from 'node:process' - -import { - isAbsolute, - normalizePath, -} from '@socketsecurity/lib-stable/paths/normalize' - -import { readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - } - | undefined - tool_name?: string | undefined -} - -// <plugin>-<version>-<slug>.patch — lowercase-kebab plugin, dotted -// semver version, lowercase-kebab slug. Mirrors `PATCH_FILE_NAME` in -// scripts/install-claude-plugins.mts so the hook and the consumer agree. -const PATCH_FILE_NAME = /^[a-z0-9-]+-(\d+\.\d+\.\d+)-[a-z0-9-]+\.patch$/ - -// The four header keys the consumer's provenance block requires. -const REQUIRED_HEADER_KEYS = [ - '@plugin', - '@plugin-version', - '@sha', - '@description', -] as const - -// Line-start `# @plugin-version: <semver>` — used to cross-check the -// header version against the filename version. -const HEADER_PLUGIN_VERSION = /^# @plugin-version:\s*(\d+\.\d+\.\d+)\s*$/m - -type Verdict = { ok: true } | { ok: false; reason: string } - -/** - * Is the target file path a plugin-cache patch under `scripts/plugin-patches/`? - * Normalizes to `/`-separators first so the check is cross-platform (per the - * fleet path-regex-normalize rule), then matches the canonical dir + `.patch` - * extension. - */ -export function isPluginPatchPath(filePath: string): boolean { - const normalized = normalizePath(filePath) - // Match the dir segment with or without a leading slash so a (malformed) - // relative path is still recognized as a plugin patch — the caller then - // flags the non-absolute path rather than letting it slip past as "not a - // patch". `/scripts/plugin-patches/` (mid-path) and `scripts/plugin-patches/` - // (path start) both count. - return ( - /(?:^|\/)scripts\/plugin-patches\//.test(normalized) && - normalized.endsWith('.patch') - ) -} - -/** - * Pure classifier: given a patch filename + its full content, return a verdict. - * Exported for unit tests. Mirrors the runtime contract of - * `install-claude-plugins.mts` (filename → cache dir, header → provenance, - * plain `diff -u` body → `patch -p1`). - */ -export function classifyPluginPatch( - fileName: string, - content: string, -): Verdict { - // (1) Filename shape. - const nameMatch = PATCH_FILE_NAME.exec(fileName) - if (!nameMatch) { - return { - ok: false, - reason: - `Filename "${fileName}" must match <plugin>-<version>-<slug>.patch ` + - '(lowercase-kebab plugin, dotted semver version, lowercase-kebab ' + - 'slug). Example: codex-1.0.1-stdin-eagain.patch.', - } - } - const fileVersion = nameMatch[1]! - - // (2) Required header keys, each as a line-start `# @key:` comment. - const missing: string[] = [] - for (let i = 0, { length } = REQUIRED_HEADER_KEYS; i < length; i += 1) { - const key = REQUIRED_HEADER_KEYS[i]! - const re = new RegExp(`^# ${key}:`, 'm') - if (!re.test(content)) { - missing.push(`# ${key}:`) - } - } - if (missing.length) { - return { - ok: false, - reason: - `Missing required header line(s): ${missing.join(', ')}. Every ` + - 'plugin patch needs a `# @plugin:` / `# @plugin-version:` / ' + - '`# @sha:` / `# @description:` provenance header above the diff.', - } - } - - // (3) Plain unified diff body — must have a `--- ` line. - if (!/^--- /m.test(content)) { - return { - ok: false, - reason: - 'No `--- ` line found. The body must be a plain unified diff ' + - '(`diff -u` output) — `reapplyPluginPatches()` strips everything ' + - 'before the first `--- ` line and feeds the rest to `patch -p1`.', - } - } - - // (3b) Reject git-diff markers — `patch` doesn't expect them. - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - if (line.startsWith('diff --git ')) { - return { - ok: false, - reason: - 'Body is a `git diff` (found `diff --git`). Use a plain ' + - '`diff -u a/file b/file` instead — git markers break `patch -p1`. ' + - 'Regenerate via the regenerating-plugin-patches skill.', - } - } - if (/^index [0-9a-f]+\.\./.test(line)) { - return { - ok: false, - reason: - 'Body has a git `index <hash>..<hash>` line. Use a plain ' + - '`diff -u` body (no git markers); regenerate via the ' + - 'regenerating-plugin-patches skill.', - } - } - if (line.startsWith('new file mode ')) { - return { - ok: false, - reason: - 'Body has a git `new file mode` line. Use a plain `diff -u` ' + - 'body (no git markers); regenerate via the ' + - 'regenerating-plugin-patches skill.', - } - } - } - - // (4) Cross-check the header version against the filename version. - const headerMatch = HEADER_PLUGIN_VERSION.exec(content) - if (headerMatch) { - const headerVersion = headerMatch[1]! - if (headerVersion !== fileVersion) { - return { - ok: false, - reason: - `Version mismatch: filename says ${fileVersion}, ` + - `\`# @plugin-version:\` says ${headerVersion}. They map to the ` + - 'same plugin-cache dir, so they must agree. Fix one to match.', - } - } - } - - return { ok: true } -} - -export function emitBlock(filePath: string, reason: string): void { - const lines: string[] = [] - lines.push('[plugin-patch-format-guard] Blocked: malformed plugin patch.') - lines.push(` File: ${filePath}`) - lines.push(` Issue: ${reason}`) - lines.push('') - lines.push(' A plugin-cache patch must be:') - lines.push(' - named <plugin>-<version>-<slug>.patch (dotted semver),') - lines.push( - ' - headed by # @plugin: / # @plugin-version: / # @sha: / # @description:,', - ) - lines.push( - ' - a plain `diff -u` body (a/… b/…, NO `diff --git`/`index`/`mode`).', - ) - lines.push(' Spec: docs/claude.md/fleet/plugin-cache-patches.md') - process.stderr.write(lines.join('\n') + '\n') -} - -async function main(): Promise<void> { - const raw = await readStdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!filePath || !isPluginPatchPath(filePath)) { - return - } - // PreToolUse always hands hooks an absolute file_path. A relative one is - // anomalous — the path-match + filename-derivation below assume an absolute - // path, so flag it rather than silently mis-derive the cache mapping. - if (!isAbsolute(filePath)) { - process.stderr.write( - `[plugin-patch-format-guard] Blocked: file_path must be absolute.\n` + - ` Where: tool_input.file_path = "${filePath}"\n` + - ` Saw: a relative path; wanted an absolute path (PreToolUse ` + - `always passes one).\n` + - ` Fix: pass the absolute path to the patch under ` + - `scripts/plugin-patches/.\n`, - ) - process.exitCode = 2 - return - } - // Validation needs the whole file. Write carries it in `content`; an - // Edit only carries a `new_string` fragment, so we can't see the full - // file — skip the Edit-without-content case rather than guess. - const content = payload.tool_input?.content - if (typeof content !== 'string') { - return - } - const fileName = normalizePath(filePath).split('/').pop() ?? '' - const verdict = classifyPluginPatch(fileName, content) - if (verdict.ok) { - return - } - emitBlock(filePath, verdict.reason) - process.exitCode = 2 -} - -main().catch(e => { - process.stderr.write( - `[plugin-patch-format-guard] hook error (continuing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/plugin-patch-format-guard/package.json b/.claude/hooks/plugin-patch-format-guard/package.json deleted file mode 100644 index 49f8d30..0000000 --- a/.claude/hooks/plugin-patch-format-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-plugin-patch-format-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/plugin-patch-format-guard/test/index.test.mts b/.claude/hooks/plugin-patch-format-guard/test/index.test.mts deleted file mode 100644 index 0a6c124..0000000 --- a/.claude/hooks/plugin-patch-format-guard/test/index.test.mts +++ /dev/null @@ -1,251 +0,0 @@ -// node --test specs for the plugin-patch-format-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { classifyPluginPatch, isPluginPatchPath } from '../index.mts' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -const PATCH_PATH = - '/Users/x/projects/foo/scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch' - -const VALID_PATCH = `# @plugin: codex -# @plugin-version: 1.0.1 -# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 -# @upstream: https://github.com/openai/codex-plugin-cc -# @description: Fix EAGAIN on stdin read -# ---- a/scripts/lib/fs.mjs -+++ b/scripts/lib/fs.mjs -@@ -32,9 +32,39 @@ - context --old -+new - context -` - -// --- Unit tests for the pure classifier. --- - -test('classifyPluginPatch: valid patch passes', () => { - const verdict = classifyPluginPatch( - 'codex-1.0.1-stdin-eagain.patch', - VALID_PATCH, - ) - assert.deepStrictEqual(verdict, { ok: true }) -}) - -test('classifyPluginPatch: bad filename blocks', () => { - for (const name of [ - 'codex-1.0-x.patch', // version not dotted-semver - 'Codex-1.0.1-x.patch', // uppercase plugin - 'codex-1.0.1-X.patch', // uppercase slug - 'codex-1.0.1.patch', // missing slug - 'codex-1.0.1-x.diff', // wrong extension - ]) { - const verdict = classifyPluginPatch(name, VALID_PATCH) - assert.strictEqual(verdict.ok, false, `${name} should be blocked`) - if (!verdict.ok) { - assert.match(verdict.reason, /<plugin>-<version>-<slug>\.patch/) - } - } -}) - -test('classifyPluginPatch: missing each required header key blocks', () => { - const keys = ['@plugin', '@plugin-version', '@sha', '@description'] as const - for (const key of keys) { - // Drop just the line for `key`. Use a per-key version match for - // @plugin-version so the cross-check doesn't pre-empt the header check. - const content = VALID_PATCH.split('\n') - .filter(line => !line.startsWith(`# ${key}:`)) - .join('\n') - const verdict = classifyPluginPatch('codex-1.0.1-x.patch', content) - assert.strictEqual(verdict.ok, false, `missing ${key} should block`) - if (!verdict.ok) { - assert.match(verdict.reason, /header/i) - } - } -}) - -test('classifyPluginPatch: git-diff markers block', () => { - const gitDiffGit = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', - ) - const v1 = classifyPluginPatch('codex-1.0.1-x.patch', gitDiffGit) - assert.strictEqual(v1.ok, false) - if (!v1.ok) { - assert.match(v1.reason, /diff --git/) - } - - const gitIndex = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'index ab12cd34..ef56ab78 100644\n--- a/scripts/lib/fs.mjs', - ) - const v2 = classifyPluginPatch('codex-1.0.1-x.patch', gitIndex) - assert.strictEqual(v2.ok, false) - if (!v2.ok) { - assert.match(v2.reason, /index/) - } - - const gitNewFile = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'new file mode 100644\n--- a/scripts/lib/fs.mjs', - ) - const v3 = classifyPluginPatch('codex-1.0.1-x.patch', gitNewFile) - assert.strictEqual(v3.ok, false) - if (!v3.ok) { - assert.match(v3.reason, /new file mode/) - } -}) - -test('classifyPluginPatch: missing diff body blocks', () => { - const headerOnly = `# @plugin: codex -# @plugin-version: 1.0.1 -# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 -# @description: no diff body -# -` - const verdict = classifyPluginPatch('codex-1.0.1-x.patch', headerOnly) - assert.strictEqual(verdict.ok, false) - if (!verdict.ok) { - assert.match(verdict.reason, /--- /) - } -}) - -test('classifyPluginPatch: version/filename mismatch blocks', () => { - // Filename says 2.0.0, header says 1.0.1. - const verdict = classifyPluginPatch('codex-2.0.0-x.patch', VALID_PATCH) - assert.strictEqual(verdict.ok, false) - if (!verdict.ok) { - assert.match(verdict.reason, /mismatch/i) - } -}) - -test('isPluginPatchPath: matches only scripts/plugin-patches/*.patch', () => { - assert.strictEqual(isPluginPatchPath(PATCH_PATH), true) - assert.strictEqual( - isPluginPatchPath('/Users/x/projects/foo/scripts/other/codex-1.0.1-x.patch'), - false, - ) - assert.strictEqual( - isPluginPatchPath('/Users/x/projects/foo/scripts/plugin-patches/notes.md'), - false, - ) -}) - -// --- Integration tests through the hook subprocess. --- - -test('hook: non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('hook: non-patch files pass through', async () => { - const result = await runHook({ - tool_input: { - content: 'export const X = 1', - file_path: '/Users/x/projects/foo/src/index.mts', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('hook: valid patch via Write passes', async () => { - const result = await runHook({ - tool_input: { content: VALID_PATCH, file_path: PATCH_PATH }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0, result.stderr) -}) - -test('hook: git-diff body via Write blocks', async () => { - const gitDiff = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', - ) - const result = await runHook({ - tool_input: { content: gitDiff, file_path: PATCH_PATH }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /plugin-patch-format-guard/) - assert.match(result.stderr, /diff --git/) -}) - -test('hook: bad filename via Write blocks', async () => { - const result = await runHook({ - tool_input: { - content: VALID_PATCH, - file_path: - '/Users/x/projects/foo/scripts/plugin-patches/Codex-1.0-bad.patch', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /<plugin>-<version>-<slug>\.patch/) -}) - -test('hook: Edit without content is skipped (cannot see whole file)', async () => { - const result = await runHook({ - tool_input: { file_path: PATCH_PATH, new_string: 'diff --git oops' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) - -test('hook: Edit WITH content is validated', async () => { - const gitDiff = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', - ) - const result = await runHook({ - tool_input: { content: gitDiff, file_path: PATCH_PATH }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('hook: relative plugin-patch path blocks (PreToolUse always passes absolute)', async () => { - const result = await runHook({ - tool_input: { - content: VALID_PATCH, - file_path: 'scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /must be absolute/) -}) diff --git a/.claude/hooks/plugin-patch-format-guard/tsconfig.json b/.claude/hooks/plugin-patch-format-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/plugin-patch-format-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/trust-downgrade-guard/README.md b/.claude/hooks/trust-downgrade-guard/README.md deleted file mode 100644 index 1f4f4b7..0000000 --- a/.claude/hooks/trust-downgrade-guard/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# trust-downgrade-guard - -PreToolUse hook. Blocks any action that **weakens a supply-chain trust gate** -unless the user typed `Allow trust-downgrade bypass` — and the bypass is -**single-use, never persisted**. - -## What it blocks - -**Bash commands** that relax a policy at invocation time: - -- `--config.trustPolicy=trust-all` (or any non-`no-downgrade` value) -- `--config.minimumReleaseAge=0` -- `--no-verify-store-integrity` -- `--dangerously-allow-all-scripts` / `--dangerously-allow-all-builds` -- `--config.dangerously*=true` -- `ignore-scripts=false` - -**Edit/Write** to a policy file (`pnpm-workspace.yaml`, `.npmrc`) that: - -- sets `trustPolicy` to anything but `no-downgrade` -- lowers `minimumReleaseAge` below the fleet floor (10080) -- rewrites `pnpm-workspace.yaml` without `trustPolicy: no-downgrade` or - `blockExoticSubdeps: true` - -## Single-use bypass - -`Allow trust-downgrade bypass` authorizes exactly **one** downgrade. The guard -counts prior downgrade actions in the assistant tool-use history (mirrors -`release-workflow-guard`'s per-dispatch model) and requires an unconsumed phrase -occurrence. A persisted bypass — an env var, or a phrase that opens the door for -every future downgrade — is *itself* a trust downgrade, so it's disallowed by -design. Each downgrade needs its own freshly-typed phrase. - -## The right fix instead of a downgrade - -A stale lockfile rejected by `no-downgrade` (e.g. after bumping a dep whose old -version lost provenance) is fixed by **adding the soak / exclude entry for the -specific version and re-resolving** — never by disabling the policy. - -## Why - -Incident 2026-05-27: an agent ran `pnpm install --config.trustPolicy=trust-all` -to force a lockfile refresh past a stale-entry rejection, disabling package- -takeover protection to make a command succeed. CLAUDE.md "Never weaken a -supply-chain trust gate" states the rule; this hook enforces it. - -## Config - -- Disable: `SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED=1` — note this env var is - itself a persisted downgrade; it exists only for this hook's test harness and - emergency wedged-session recovery. - -## Related - -- `minimum-release-age-guard` / `soak-exclude-date-annotation-guard` — the soak side. -- `check-new-deps` — Socket-scores new deps at edit time. -- `release-workflow-guard` — the single-use-bypass pattern this mirrors. -- CLAUDE.md → "Never weaken a supply-chain trust gate". diff --git a/.claude/hooks/trust-downgrade-guard/index.mts b/.claude/hooks/trust-downgrade-guard/index.mts deleted file mode 100644 index bf3eb5f..0000000 --- a/.claude/hooks/trust-downgrade-guard/index.mts +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — trust-downgrade-guard. -// -// Blocks any action that WEAKENS a supply-chain trust gate unless the -// user has typed `Allow trust-downgrade bypass` — and the bypass is -// SINGLE-USE, never persisted (each prior downgrade this session -// consumes one phrase occurrence, like release-workflow-guard's -// per-dispatch model). -// -// Two trigger surfaces: -// -// 1. Bash commands that relax a policy at invocation time: -// - `--config.trustPolicy=trust-all` (or any non-`no-downgrade` -// value): disables pnpm's package-takeover protection. -// - `--config.minimumReleaseAge=0` / `--no-verify-store-integrity` -// / `--config.dangerouslyAllowAllBuilds` style relaxations. -// - npm `--dangerously-allow-all-scripts`, `ignore-scripts=false` -// flips on install. -// -// 2. Edit/Write that weakens a policy file: -// - removing or downgrading `trustPolicy: no-downgrade` in -// pnpm-workspace.yaml (to `trust-all` / `trust` / deleting it). -// - deleting `blockExoticSubdeps: true`. -// - lowering `minimumReleaseAge` below the fleet floor (10080). -// -// Why this exists (incident 2026-05-27): an agent ran -// `pnpm install --config.trustPolicy=trust-all` to force a lockfile -// refresh past a stale-entry rejection — disabling the no-downgrade -// takeover protection to make a command succeed. The correct fix was -// to add the soak/exclude entry and re-resolve, never to relax the -// policy. CLAUDE.md "Never weaken a supply-chain trust gate" states -// the rule; this hook enforces it. -// -// Single-use bypass rationale: a persisted bypass (env var, or a phrase -// that authorizes every future downgrade in the session) is itself a -// trust downgrade. Each downgrade must be individually authorized. -// -// Exit codes: -// 2 — blocked (a trust downgrade without an unconsumed bypass phrase). -// 0 — allowed (not a downgrade, or an unconsumed bypass is present), -// and on any hook error (fail-open + stderr log). -// -// Disabled via `SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED=1` — note this env -// var ITSELF is a persisted trust downgrade; it exists only for the -// hook's own test harness and emergency wedged-session recovery. -// -// Reads a PreToolUse JSON payload from stdin: -// { "tool_name": "Bash" | "Edit" | "Write" | "MultiEdit", -// "tool_input": { "command"? , "file_path"?, "content"?, "new_string"? }, -// "transcript_path": "/.../session.jsonl" } - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhraseRemaining, readStdin } from '../_shared/transcript.mts' - -interface Payload { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly command?: unknown | undefined - readonly file_path?: unknown | undefined - readonly content?: unknown | undefined - readonly new_string?: unknown | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED' -const BYPASS_PHRASE = 'Allow trust-downgrade bypass' - -// Fleet minimumReleaseAge floor (minutes) — 7 days. A lower value is a -// downgrade. -const MIN_RELEASE_AGE_FLOOR = 10080 - -// Bash-command patterns that relax a trust gate at invocation time. -// Matched against the raw command; these are flag shapes, not command -// structure, so a regex match is the right tool (a flag can't be -// "hidden" behind shell indirection the way a binary name can — the -// flag string has to appear literally for pnpm/npm to parse it). -const BASH_DOWNGRADE_PATTERNS: ReadonlyArray<{ re: RegExp; label: string }> = [ - { - re: /--config\.trustPolicy[=\s]+(?!no-downgrade\b)\S+/i, - label: 'trustPolicy override to a value other than no-downgrade', - }, - { - re: /--config\.minimumReleaseAge[=\s]+0\b/i, - label: 'minimumReleaseAge override to 0', - }, - { - re: /--no-verify-store-integrity\b/i, - label: '--no-verify-store-integrity', - }, - { - re: /--dangerously-allow-all-(?:scripts|builds)\b/i, - label: '--dangerously-allow-all-* escape hatch', - }, - { - re: /--config\.dangerously\S*=\s*true\b/i, - label: '--config.dangerously* = true', - }, - { - re: /(?:^|\s)--?ignore-scripts[=\s]+false\b/i, - label: 'ignore-scripts=false', - }, -] - -export function detectBashDowngrade(command: string): string | undefined { - for (let i = 0, { length } = BASH_DOWNGRADE_PATTERNS; i < length; i += 1) { - const { re, label } = BASH_DOWNGRADE_PATTERNS[i]! - if (re.test(command)) { - return label - } - } - return undefined -} - -// Is the edited file a supply-chain policy file we gate? -function isPolicyFile(filePath: string): boolean { - const base = path.basename(filePath) - return base === 'pnpm-workspace.yaml' || base === '.npmrc' -} - -// Inspect the NEW text an Edit/Write would write. We can only see the -// replacement fragment (Edit `new_string`) or full `content` (Write), -// not the resulting whole file — so we flag the *removal/weakening -// shapes* that appear in the new text, and (for Write) the absence of -// the no-downgrade line when the file is being rewritten wholesale. -export function detectEditDowngrade( - toolName: string, - filePath: string, - newText: string, - fullContent: string | undefined, -): string | undefined { - if (!isPolicyFile(filePath)) { - return undefined - } - // A fragment that sets trustPolicy to a non-no-downgrade value. - if (/trustPolicy\s*:\s*(?!no-downgrade\b)\S+/i.test(newText)) { - return 'trustPolicy set to a value other than no-downgrade' - } - // Lowering minimumReleaseAge below the floor. - const m = /minimumReleaseAge\s*:\s*(\d+)/i.exec(newText) - if (m && Number(m[1]) < MIN_RELEASE_AGE_FLOOR) { - return `minimumReleaseAge lowered below the ${MIN_RELEASE_AGE_FLOOR} floor` - } - // A wholesale Write of pnpm-workspace.yaml that drops the - // no-downgrade line entirely is a downgrade (the gate vanishes). - if ( - (toolName === 'Write' || fullContent !== undefined) && - path.basename(filePath) === 'pnpm-workspace.yaml' - ) { - const body = fullContent ?? newText - if (body && !/trustPolicy\s*:\s*no-downgrade\b/i.test(body)) { - return 'pnpm-workspace.yaml rewritten without `trustPolicy: no-downgrade`' - } - } - // Deleting blockExoticSubdeps — visible only if the Edit's new_string - // shows the surrounding region without it is not detectable from a - // fragment alone; a Write can be checked. - if ( - (toolName === 'Write' || fullContent !== undefined) && - path.basename(filePath) === 'pnpm-workspace.yaml' - ) { - const body = fullContent ?? newText - if (body && !/blockExoticSubdeps\s*:\s*true\b/i.test(body)) { - return 'pnpm-workspace.yaml rewritten without `blockExoticSubdeps: true`' - } - } - return undefined -} - -// Count prior trust-downgrade actions in the assistant tool-use history -// — each consumes one bypass-phrase occurrence (single-use semantics). -// Mirrors release-workflow-guard's countPriorDispatches. -export function countPriorDowngrades( - transcriptPath: string | undefined, -): number { - if (!transcriptPath) { - return 0 - } - let raw: string - try { - raw = readFileSync(transcriptPath, 'utf8') - } catch { - return 0 - } - let count = 0 - for (const line of raw.split('\n')) { - if (!line) { - continue - } - let evt: unknown - try { - evt = JSON.parse(line) - } catch { - continue - } - if ( - !evt || - typeof evt !== 'object' || - (evt as Record<string, unknown>)['type'] !== 'assistant' - ) { - continue - } - const msg = (evt as { message?: unknown }).message - const content = - msg && typeof msg === 'object' - ? (msg as { content?: unknown }).content - : undefined - if (!Array.isArray(content)) { - continue - } - for (let i = 0, { length } = content; i < length; i += 1) { - const part = content[i]! - if (!part || typeof part !== 'object') { - continue - } - const name = (part as { name?: unknown }).name - const input = (part as { input?: unknown }).input - if (typeof name !== 'string' || !input || typeof input !== 'object') { - continue - } - const inp = input as Record<string, unknown> - if (name === 'Bash' && typeof inp['command'] === 'string') { - if (detectBashDowngrade(inp['command'])) { - count += 1 - } - } else if ( - (name === 'Edit' || name === 'Write' || name === 'MultiEdit') && - typeof inp['file_path'] === 'string' - ) { - const newText = - (typeof inp['new_string'] === 'string' ? inp['new_string'] : '') || - (typeof inp['content'] === 'string' ? inp['content'] : '') - const fullContent = - typeof inp['content'] === 'string' ? inp['content'] : undefined - if (detectEditDowngrade(name, inp['file_path'], newText, fullContent)) { - count += 1 - } - } - } - } - return count -} - -async function main(): Promise<void> { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const raw = await readStdin() - let payload: Payload - try { - payload = JSON.parse(raw) as Payload - } catch { - process.exit(0) - } - - const tool = payload.tool_name - const input = payload.tool_input - let downgrade: string | undefined - - if (tool === 'Bash') { - const command = input?.command - if (typeof command === 'string' && command.trim()) { - downgrade = detectBashDowngrade(command) - } - } else if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') { - const filePath = input?.file_path - if (typeof filePath === 'string' && filePath) { - const newText = - (typeof input?.new_string === 'string' ? input.new_string : '') || - (typeof input?.content === 'string' ? input.content : '') - const fullContent = - typeof input?.content === 'string' ? input.content : undefined - downgrade = detectEditDowngrade(tool, filePath, newText, fullContent) - } - } - - if (!downgrade) { - process.exit(0) - } - - // Single-use bypass: total phrase occurrences minus prior downgrades - // already performed this session. > 0 means an unconsumed phrase - // authorizes THIS one. - const prior = countPriorDowngrades(payload.transcript_path) - const remaining = bypassPhraseRemaining( - payload.transcript_path, - BYPASS_PHRASE, - prior, - ) - if (remaining > 0) { - process.exit(0) - } - - process.stderr.write( - [ - `[trust-downgrade-guard] Blocked: ${downgrade}`, - '', - ' This WEAKENS a supply-chain trust gate (package-takeover /', - ' malicious-install protection). Disabling the policy to make a', - ' command succeed is never the fix.', - '', - ' If a stale lockfile is being rejected: add the soak / exclude', - ' entry for the specific version and re-resolve — keep the policy.', - '', - ` Bypass (single-use, NOT persisted): the user types`, - ` "${BYPASS_PHRASE}"`, - ' verbatim in chat, then retry. Each downgrade needs its own phrase.', - ].join('\n') + '\n', - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[trust-downgrade-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) - process.exit(0) -}) diff --git a/.claude/hooks/trust-downgrade-guard/test/index.test.mts b/.claude/hooks/trust-downgrade-guard/test/index.test.mts deleted file mode 100644 index 37680e2..0000000 --- a/.claude/hooks/trust-downgrade-guard/test/index.test.mts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * @file Unit tests for trust-downgrade-guard hook. - * - * Spawns the hook as a child process with synthesized PreToolUse payloads. - * Covers Bash + Edit/Write downgrade detection, single-use bypass - * consumption, the disabled env var, and fail-open. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly code: number - readonly stderr: string -} - -function run(payload: object, env?: Record<string, string>): RunResult { - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - env: { ...process.env, ...(env ?? {}) }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function bash(command: string, transcriptPath?: string): object { - return { - tool_name: 'Bash', - tool_input: { command }, - transcript_path: transcriptPath, - } -} - -function edit(filePath: string, newString: string): object { - return { - tool_name: 'Edit', - tool_input: { file_path: filePath, new_string: newString }, - } -} - -function write(filePath: string, content: string): object { - return { tool_name: 'Write', tool_input: { file_path: filePath, content } } -} - -// A transcript whose assistant turns contain `priorDowngrades` prior -// trust-all Bash calls, plus `phrases` user occurrences of the bypass. -function writeTranscript(opts: { - priorDowngrades?: number - phrases?: number -}): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'tdguard-tx-')) - const p = path.join(dir, 'session.jsonl') - const lines: string[] = [] - for (let i = 0; i < (opts.phrases ?? 0); i += 1) { - lines.push( - JSON.stringify({ - type: 'user', - message: { role: 'user', content: 'Allow trust-downgrade bypass' }, - }), - ) - } - for (let i = 0; i < (opts.priorDowngrades ?? 0); i += 1) { - lines.push( - JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - name: 'Bash', - input: { command: 'pnpm install --config.trustPolicy=trust-all' }, - }, - ], - }, - }), - ) - } - writeFileSync(p, lines.join('\n')) - return p -} - -let tmp: string - -beforeEach(() => { - tmp = mkdtempSync(path.join(os.tmpdir(), 'tdguard-repo-')) -}) - -afterEach(() => { - rmSync(tmp, { recursive: true, force: true }) -}) - -// ─── Bash downgrade detection ───────────────────────────────────── - -test('blocks --config.trustPolicy=trust-all', () => { - const r = run(bash('pnpm install --config.trustPolicy=trust-all')) - assert.equal(r.code, 2) - assert.match(r.stderr, /Blocked/) - assert.match(r.stderr, /trustPolicy/) -}) - -test('blocks --config.minimumReleaseAge=0', () => { - const r = run(bash('pnpm install --config.minimumReleaseAge=0')) - assert.equal(r.code, 2) -}) - -test('blocks --dangerously-allow-all-scripts', () => { - const r = run(bash('npm ci --dangerously-allow-all-scripts')) - assert.equal(r.code, 2) -}) - -test('blocks ignore-scripts=false', () => { - const r = run(bash('npm install --ignore-scripts=false')) - assert.equal(r.code, 2) -}) - -test('allows --config.trustPolicy=no-downgrade (not a downgrade)', () => { - const r = run(bash('pnpm install --config.trustPolicy=no-downgrade')) - assert.equal(r.code, 0) -}) - -test('allows an ordinary pnpm install', () => { - const r = run(bash('pnpm install')) - assert.equal(r.code, 0) -}) - -// ─── Edit/Write downgrade detection ─────────────────────────────── - -test('blocks Edit setting trustPolicy to trust-all', () => { - const f = path.join(tmp, 'pnpm-workspace.yaml') - const r = run(edit(f, 'trustPolicy: trust-all')) - assert.equal(r.code, 2) -}) - -test('blocks Write of pnpm-workspace.yaml missing no-downgrade', () => { - const f = path.join(tmp, 'pnpm-workspace.yaml') - const r = run(write(f, 'packages:\n - .\nblockExoticSubdeps: true\n')) - assert.equal(r.code, 2) -}) - -test('allows Write of pnpm-workspace.yaml that keeps the gates', () => { - const f = path.join(tmp, 'pnpm-workspace.yaml') - const r = run( - write(f, 'trustPolicy: no-downgrade\nblockExoticSubdeps: true\n'), - ) - assert.equal(r.code, 0) -}) - -test('blocks lowering minimumReleaseAge below the floor', () => { - const f = path.join(tmp, 'pnpm-workspace.yaml') - const r = run(edit(f, 'minimumReleaseAge: 60')) - assert.equal(r.code, 2) -}) - -test('ignores edits to non-policy files', () => { - const f = path.join(tmp, 'README.md') - const r = run(edit(f, 'trustPolicy: trust-all (just docs prose)')) - assert.equal(r.code, 0) -}) - -// ─── Single-use bypass ──────────────────────────────────────────── - -test('one unconsumed phrase authorizes one downgrade', () => { - const tx = writeTranscript({ phrases: 1, priorDowngrades: 0 }) - const r = run(bash('pnpm install --config.trustPolicy=trust-all', tx)) - assert.equal(r.code, 0) -}) - -test('a phrase already consumed by a prior downgrade does not authorize a second', () => { - const tx = writeTranscript({ phrases: 1, priorDowngrades: 1 }) - const r = run(bash('pnpm install --config.trustPolicy=trust-all', tx)) - assert.equal(r.code, 2) -}) - -test('two phrases authorize two downgrades (one prior, one now)', () => { - const tx = writeTranscript({ phrases: 2, priorDowngrades: 1 }) - const r = run(bash('pnpm install --config.trustPolicy=trust-all', tx)) - assert.equal(r.code, 0) -}) - -// ─── Disable + fail-open ────────────────────────────────────────── - -test('disabled via env var', () => { - const r = run(bash('pnpm install --config.trustPolicy=trust-all'), { - SOCKET_TRUST_DOWNGRADE_GUARD_DISABLED: '1', - }) - assert.equal(r.code, 0) -}) - -test('fails open on malformed payload', () => { - const r = spawnSync('node', [HOOK], { input: 'not json', env: process.env }) - assert.equal(typeof r.status === 'number' ? r.status : 0, 0) -}) - -test('non-gated tool is ignored', () => { - const r = run({ tool_name: 'Read', tool_input: { file_path: '/x' } }) - assert.equal(r.code, 0) -}) diff --git a/.claude/hooks/trust-downgrade-guard/tsconfig.json b/.claude/hooks/trust-downgrade-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/trust-downgrade-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/skills/guarding-paths/SKILL.md b/.claude/skills/guarding-paths/SKILL.md index 8e551ee..be78db5 100644 --- a/.claude/skills/guarding-paths/SKILL.md +++ b/.claude/skills/guarding-paths/SKILL.md @@ -93,7 +93,10 @@ For Socket repos that don't yet have the gate: 5. Append the rule snippet from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md) to the repo's `CLAUDE.md` if a `1 path, 1 reference` section is missing. 6. Add the hook entry to `.claude/settings.json` `PreToolUse` matcher `Edit|Write`: ```json - { "type": "command", "command": "node .claude/hooks/fleet/path-guard/index.mts" } + { + "type": "command", + "command": "node .claude/hooks/fleet/path-guard/index.mts" + } ``` 7. Run the gate against the repo. Triage findings as you would in audit-and-fix mode. diff --git a/.claude/skills/updating/SKILL.md b/.claude/skills/updating/SKILL.md index d99b6b1..9318c75 100644 --- a/.claude/skills/updating/SKILL.md +++ b/.claude/skills/updating/SKILL.md @@ -41,7 +41,7 @@ The fix order is fixed — **don't try to land the fleet-repo bump first**: 3. **Update wheelhouse template** in the same wave: `template/package.json` `engines.pnpm` / `engines.npm` / `packageManager` + `template/pnpm-workspace.yaml` `allowBuilds` entries for any new transitive build-scripts the bumped pnpm enforces (`pnpm@11.4` added `[ERR_PNPM_IGNORED_BUILDS]` as hard exit, so `esbuild` and friends need explicit allowlisting). -4. **Cascade fleet repos** atomically: each downstream socket-* repo gets the new pnpm pin AND the new propagation SHA in the same cascade commit. Without atomicity, you get the failure mode we hit on 2026-05-28: fleet repo bumps to pnpm@11.4, CI fails because the installed pnpm (11.3 via old setup-action) refuses the pin. +4. **Cascade fleet repos** atomically: each downstream socket-\* repo gets the new pnpm pin AND the new propagation SHA in the same cascade commit. Without atomicity, you get the failure mode we hit on 2026-05-28: fleet repo bumps to pnpm@11.4, CI fails because the installed pnpm (11.3 via old setup-action) refuses the pin. Why reference, not duplicate: the cascade procedure is fleet-canonical knowledge owned by socket-registry. Duplicating it into wheelhouse means two copies that drift. The wheelhouse `updating` skill encodes "when to run the registry cascade and how to consume its output", not the cascade itself. diff --git a/.config/.prettierignore b/.config/.prettierignore index 6cbe4c6..6f928bd 100644 --- a/.config/.prettierignore +++ b/.config/.prettierignore @@ -21,16 +21,16 @@ # `SyntaxError: Unexpected token 'export'`. Past incident: cascaded # to two fleet repos before the break surfaced. # -# The generators we DO own (`acorn-wasm-sync.{mts,cts}`, -# `acorn-wasm-embed.{mts,cts}`) are not listed here on purpose — +# The generators we DO own (`acorn-sync.{mts,cts}`, +# `acorn-embed.{mts,cts}`) are not listed here on purpose — # the ultrathink build emits them already formatted+linted per fleet # rules so they participate in the regular lint pass like any other # JS source. Only the raw wasm blob + the bindgen glue skip the # formatter. Marked `binary` in .gitattributes for the wasm blob too # so PR diffs collapse. -template/.claude/hooks/_shared/acorn/acorn.wasm -template/.claude/hooks/_shared/acorn/acorn-bindgen.cjs -.claude/hooks/_shared/acorn/acorn-bindgen.cjs +template/.claude/hooks/fleet/_shared/acorn/acorn.wasm +template/.claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs +.claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs # Vendored / upstream trees — kept byte-identical with their source # of truth. Per CLAUDE.md "Untracked-by-default for vendored / build- diff --git a/.config/esbuild/shorten-paths.mts b/.config/esbuild/shorten-paths.mts index d881093..95c2fdd 100644 --- a/.config/esbuild/shorten-paths.mts +++ b/.config/esbuild/shorten-paths.mts @@ -16,6 +16,8 @@ import type { Comment } from '@babel/types' import { parse } from '@babel/parser' import MagicString from 'magic-string' +import { errorMessage } from '@socketsecurity/lib-stable/errors' + import type { BuildResult, PluginBuild } from 'esbuild' import { NODE_MODULES } from '@socketsecurity/lib-stable/paths/dirnames' @@ -162,7 +164,7 @@ export function createPathShorteningPlugin() { await fs.writeFile(outputPath, magicString.toString(), 'utf8') } catch (e) { logger.error( - `Failed to shorten paths in ${outputPath}: ${e instanceof Error ? e.message : String(e)}`, + `Failed to shorten paths in ${outputPath}: ${errorMessage(e)}`, ) } } diff --git a/.config/oxfmtrc.json b/.config/oxfmtrc.json index 75e537f..5d5406d 100644 --- a/.config/oxfmtrc.json +++ b/.config/oxfmtrc.json @@ -125,7 +125,10 @@ "**/scripts/test/install-claude-plugins.test.mts", "**/scripts/test/install-git-hooks.test.mts", "**/scripts/update.mts", + "**/scripts/util/multi-package-publish.mts", + "**/scripts/util/pack-app-triplets.mts", "**/scripts/util/run-command.mts", + "**/scripts/util/source-allowlist.mts", "**/scripts/validate-bundle-deps.mts", "**/scripts/validate-config-paths.mts", "**/scripts/validate-file-size.mts", diff --git a/.config/oxlint-plugin/index.mts b/.config/oxlint-plugin/index.mts index 15d6554..2bcaab7 100644 --- a/.config/oxlint-plugin/index.mts +++ b/.config/oxlint-plugin/index.mts @@ -57,6 +57,7 @@ import socketApiTokenEnv from './rules/socket-api-token-env.mts' import sortBooleanChains from './rules/sort-boolean-chains.mts' import sortEqualityDisjunctions from './rules/sort-equality-disjunctions.mts' import sortNamedImports from './rules/sort-named-imports.mts' +import sortObjectLiteralProperties from './rules/sort-object-literal-properties.mts' import sortRegexAlternations from './rules/sort-regex-alternations.mts' import sortSetArgs from './rules/sort-set-args.mts' import sortSourceMethods from './rules/sort-source-methods.mts' @@ -119,6 +120,7 @@ const plugin = { 'sort-boolean-chains': sortBooleanChains, 'sort-equality-disjunctions': sortEqualityDisjunctions, 'sort-named-imports': sortNamedImports, + 'sort-object-literal-properties': sortObjectLiteralProperties, 'sort-regex-alternations': sortRegexAlternations, 'sort-set-args': sortSetArgs, 'sort-source-methods': sortSourceMethods, diff --git a/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts b/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts index 47a978a..53e3659 100644 --- a/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts +++ b/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts @@ -7,7 +7,15 @@ * TS/JS source — package.json + workflow YAML are caught by other tooling * (the SBOM / dep scanners flag the package refs at install time). No * autofix: the right replacement varies (drop the line, swap to - * `oxlint`/`oxfmt`, or rewrite a script invocation). Reporting only. + * `oxlint`/`oxfmt`, or rewrite a script invocation). Reporting only. **Test + * fixtures:** if a pattern-matching test reaches for a real package name that + * happens to start with `eslint-` / `biome` / `@biomejs/`, the rule fires on + * the test fixture even though it isn't a config ref. Use the documented + * neutral placeholder family `acme-*` (`acme-plugin-react`, `acme-foo`, + * `@acme/widget`) — same convention as `Acme Inc` for customer-name + * placeholders in [`fleet/public-surface-hygiene`]. They keep wildcard + * semantics intact without tripping the rule. Reserve the bypass comment for + * genuinely irreplaceable cases (e.g. testing the rule itself). */ import { makeBypassChecker } from '../lib/comment-markers.mts' @@ -65,7 +73,7 @@ const rule = { }, messages: { staleConfig: - '`{{ref}}` is a stale ESLint/Biome reference — the fleet runs oxlint + oxfmt. Drop the line or swap to the oxlint/oxfmt equivalent. (See `template/.config/oxlintrc.json` / `oxfmtrc.json` for the canonical configs.)', + '`{{ref}}` is a stale ESLint/Biome reference — the fleet runs oxlint + oxfmt. Drop the line or swap to the oxlint/oxfmt equivalent. (See `template/.config/oxlintrc.json` / `oxfmtrc.json` for the canonical configs.) If this is a test fixture, rename to the neutral placeholder family `acme-*` (mirrors the `Acme Inc` convention from `fleet/public-surface-hygiene`).', }, schema: [], }, diff --git a/.config/oxlint-plugin/rules/no-inline-defer-async.mts b/.config/oxlint-plugin/rules/no-inline-defer-async.mts index 3d326a5..06b0031 100644 --- a/.config/oxlint-plugin/rules/no-inline-defer-async.mts +++ b/.config/oxlint-plugin/rules/no-inline-defer-async.mts @@ -4,14 +4,15 @@ * immediately. The author intent (wait for DOMContentLoaded) is silently * ignored. Past incident: same shape bit a fleet project twice; rendered * pages went silently broken when the script tried to operate on DOM nodes - * that didn't exist yet. Sibling: `.claude/hooks/fleet/inline-script-defer-guard/` - * catches this at edit time. This lint rule catches it at commit time when - * edits happened outside Claude. Detects: string literals (single-quoted, - * double-quoted, or template) containing `<script ...defer...>` or `<script - * ...async...>` lacking `src=`. The rule applies to TS/JS source — HTML / - * template files aren't lint-target by oxlint. Autofix: remove the `defer` / - * `async` attribute. The DOMContentLoaded wrap is a manual fix surfaced in - * the error message. + * that didn't exist yet. Sibling: + * `.claude/hooks/fleet/inline-script-defer-guard/` catches this at edit time. + * This lint rule catches it at commit time when edits happened outside + * Claude. Detects: string literals (single-quoted, double-quoted, or + * template) containing `<script ...defer...>` or `<script ...async...>` + * lacking `src=`. The rule applies to TS/JS source — HTML / template files + * aren't lint-target by oxlint. Autofix: remove the `defer` / `async` + * attribute. The DOMContentLoaded wrap is a manual fix surfaced in the error + * message. */ import { makeBypassChecker } from '../lib/comment-markers.mts' diff --git a/.config/oxlint-plugin/rules/no-npx-dlx.mts b/.config/oxlint-plugin/rules/no-npx-dlx.mts index 4dce5b1..7d24a02 100644 --- a/.config/oxlint-plugin/rules/no-npx-dlx.mts +++ b/.config/oxlint-plugin/rules/no-npx-dlx.mts @@ -5,12 +5,12 @@ * dlx` — use `pnpm exec <package>` or `pnpm run <script>`. Detects `npx`, * `pnpm dlx`, `pnx` (the pnpm-11 dlx shorthand), and `yarn dlx` in source * string literals — argv slices passed to `spawn()`, shell strings, scripts, - * doc snippets, README examples, etc. The hook at `.claude/hooks/fleet/path-guard/` - * blocks these at the shell layer; this rule catches them at edit / commit - * time inside JavaScript / TypeScript source. Autofix: rewrites the literal - * in place — `npx foo` → `pnpm exec foo`, `pnpm dlx foo` → `pnpm exec foo`, - * `yarn dlx foo` → `pnpm exec foo`, `pnx foo` → `pnpm exec foo`. Allowed - * exceptions (skipped): + * doc snippets, README examples, etc. The hook at + * `.claude/hooks/fleet/path-guard/` blocks these at the shell layer; this + * rule catches them at edit / commit time inside JavaScript / TypeScript + * source. Autofix: rewrites the literal in place — `npx foo` → `pnpm exec + * foo`, `pnpm dlx foo` → `pnpm exec foo`, `yarn dlx foo` → `pnpm exec foo`, + * `pnx foo` → `pnpm exec foo`. Allowed exceptions (skipped): * * - The literal `npx` inside a comment with `socket-hook: allow npx` — the * canonical bypass marker, used by the lockdown skill spec. diff --git a/.config/oxlint-plugin/rules/no-underscore-identifier.mts b/.config/oxlint-plugin/rules/no-underscore-identifier.mts index 1a04170..57d2fe3 100644 --- a/.config/oxlint-plugin/rules/no-underscore-identifier.mts +++ b/.config/oxlint-plugin/rules/no-underscore-identifier.mts @@ -7,8 +7,8 @@ * languages where it has runtime meaning (Python name mangling, Ruby * visibility); in TS the underscore is decorative and adds noise to `git * blame` and IDE autocomplete. Commit-time partner of the edit-time - * `.claude/hooks/fleet/no-underscore-identifier-guard/`. Allowed (skipped by this - * rule): + * `.claude/hooks/fleet/no-underscore-identifier-guard/`. Allowed (skipped by + * this rule): * * - Bare `_` as a throwaway (`for (const _ of arr)`, destructuring rest). * - Files under any `_internal/` directory — the canonical structural pattern diff --git a/.config/oxlint-plugin/rules/prefer-error-message.mts b/.config/oxlint-plugin/rules/prefer-error-message.mts index 3b5fdd3..a12525c 100644 --- a/.config/oxlint-plugin/rules/prefer-error-message.mts +++ b/.config/oxlint-plugin/rules/prefer-error-message.mts @@ -1,27 +1,22 @@ /** * @file Flag the `<id> instanceof Error ? <id>.message : String(<id>)` ternary - * and prefer the `errorMessage` helper from - * `@socketsecurity/lib/errors`. The helper short-circuits the same - * shape, handles `aggregate` / cause chaining the bare ternary doesn't, and - * keeps every call site identical so a future change (adding cause chains, - * redacting tokens, etc.) lands in one place. The ternary form gets reinvented - * in nearly every error-handling branch, so the linter is the right surface - * to catch it. - * - * Report-only — no autofix. The rewrite to `errorMessage(<id>)` looks - * mechanical but the right import path depends on the file's context: a - * runtime source file in a downstream repo wants - * `@socketsecurity/lib/errors` (catalog), a script / test / hook in the same - * repo wants `@socketsecurity/lib-stable/errors` (devDep), and a repo that - * doesn't depend on `@socketsecurity/lib` at all can't apply the rewrite + * and prefer the `errorMessage` helper from `@socketsecurity/lib/errors`. The + * helper short-circuits the same shape, handles `aggregate` / cause chaining + * the bare ternary doesn't, and keeps every call site identical so a future + * change (adding cause chains, redacting tokens, etc.) lands in one place. + * The ternary form gets reinvented in nearly every error-handling branch, so + * the linter is the right surface to catch it. Report-only — no autofix. The + * rewrite to `errorMessage(<id>)` looks mechanical but the right import path + * depends on the file's context: a runtime source file in a downstream repo + * wants `@socketsecurity/lib/errors` (catalog), a script / test / hook in the + * same repo wants `@socketsecurity/lib-stable/errors` (devDep), and a repo + * that doesn't depend on `@socketsecurity/lib` at all can't apply the rewrite * without first adding the dep. None of those choices belong to the linter. - * Surface the smell, let the human pick the import line. - * - * The rule deliberately does not chase any of the harder variants - * (`e?.message ?? String(e)`, `typeof e === 'string' ? e : ...`, - * `'message' in e ? e.message : String(e)`) because each carries different - * semantics — only the `instanceof Error` form is unambiguously equivalent - * to `errorMessage(e)`. + * Surface the smell, let the human pick the import line. The rule + * deliberately does not chase any of the harder variants (`e?.message ?? + * String(e)`, `typeof e === 'string' ? e : ...`, `'message' in e ? e.message + * : String(e)`) because each carries different semantics — only the + * `instanceof Error` form is unambiguously equivalent to `errorMessage(e)`. */ import type { AstNode, RuleContext } from '../lib/rule-types.mts' @@ -56,16 +51,17 @@ function isMessageMemberOf(node: AstNode | undefined, name: string): boolean { return false } const property = node.property - if (!property || property.type !== 'Identifier' || property.name !== 'message') { + if ( + !property || + property.type !== 'Identifier' || + property.name !== 'message' + ) { return false } return identifierName(node.object) === name } -function isInstanceOfErrorOf( - node: AstNode | undefined, - name: string, -): boolean { +function isInstanceOfErrorOf(node: AstNode | undefined, name: string): boolean { if (!node || node.type !== 'BinaryExpression') { return false } diff --git a/.config/oxlint-plugin/rules/prefer-pure-call-form.mts b/.config/oxlint-plugin/rules/prefer-pure-call-form.mts index f41f973..1851c86 100644 --- a/.config/oxlint-plugin/rules/prefer-pure-call-form.mts +++ b/.config/oxlint-plugin/rules/prefer-pure-call-form.mts @@ -16,18 +16,17 @@ * - Comment on a `class X {}` declaration (oxfmt re-flows it onto the class, * where it has no effect): `/*@__PURE__*\/ class Logger {}`. * - Comment outside parenthesized expressions where the call lives inside: - * `const x = /*@__PURE__*\/ (foo()).bar` — the magic is detached from - * the call site by the parens / member expression. + * `const x = /*@__PURE__*\/ (foo()).bar` — the magic is detached from the + * call site by the parens / member expression. * - Comment on a bare identifier reference: `const ctor = /*@__PURE__*\/ - * SomeClass` (no parens means no call; the hint does nothing). - * - * Report-only — the right rewrite is "put the comment immediately before the - * call, like `const x = /*@__PURE__*\/ foo()`," and oxfmt's tendency to move - * comments back makes any literal autofix a moving target. The rule writes - * the call site location and leaves the human to either reposition the - * comment or restructure the surrounding code (the documented workaround: - * introduce an intermediate const so the magic comment lands adjacent to the - * call, e.g. `const tmp = /*@__PURE__*\/ foo(); const x = tmp.bar`). + * SomeClass` (no parens means no call; the hint does nothing). Report-only + * — the right rewrite is "put the comment immediately before the call, like + * `const x = /*@__PURE__*\/ foo()`," and oxfmt's tendency to move comments + * back makes any literal autofix a moving target. The rule writes the call + * site location and leaves the human to either reposition the comment or + * restructure the surrounding code (the documented workaround: introduce an + * intermediate const so the magic comment lands adjacent to the call, e.g. + * `const tmp = /*@__PURE__*\/ foo(); const x = tmp.bar`). */ import type { AstNode, RuleContext } from '../lib/rule-types.mts' @@ -49,17 +48,6 @@ function commentRange(c: AstNode): [number, number] | undefined { return [r[0], r[1]] } -function nodeRange(n: AstNode | undefined): [number, number] | undefined { - if (!n) { - return undefined - } - const r = n.range - if (!Array.isArray(r) || r.length !== 2) { - return undefined - } - return [r[0], r[1]] -} - const rule = { meta: { type: 'suggestion', diff --git a/.config/oxlint-plugin/rules/prefer-safe-delete.mts b/.config/oxlint-plugin/rules/prefer-safe-delete.mts index 67c5bb9..ce69c14 100644 --- a/.config/oxlint-plugin/rules/prefer-safe-delete.mts +++ b/.config/oxlint-plugin/rules/prefer-safe-delete.mts @@ -21,7 +21,8 @@ * - Calls whose result is checked/assigned in a way that depends on fs.rm's * specific throw-on-missing or callback contract. Spawn-based bans (`rm * -rf`, `Remove-Item`) live in a separate hook - * (`.claude/hooks/fleet/path-guard/`) — this rule covers the JavaScript side. + * (`.claude/hooks/fleet/path-guard/`) — this rule covers the JavaScript + * side. */ import { appendImportFixes, summarizeImportTarget } from './_inject-import.mts' diff --git a/.config/oxlint-plugin/rules/sort-object-literal-properties.mts b/.config/oxlint-plugin/rules/sort-object-literal-properties.mts new file mode 100644 index 0000000000000000000000000000000000000000..b1d40bdeebb2ed8c2b58d0fbfa3d88bd5c5a6bd0 GIT binary patch literal 7673 zcmb7J+j1Mn5zRBdqN#Ej3kg_At~^1e8A@U*EZG)IRFZO$jIh`p5NqrO*_kDUkgUp6 zJ|LAZ^q1sx&&=)sq-2{ak-+R+PIsTa%}$;^-J_@Ur$t^UI#mt5{{HB%Z;s<KqbFx| zWAf_i2{mn@rlj$e%u<og=LN=T-PCJ^DN0klproRD{<BIA??+VRMm0%6NwHogRa>ei zPm`k9P~@M?H%3w2WH|JffB%n;&R(CK&|EF*Mp2;*mg`|mXDJS`TjrWFRpfK*YZP{> zlBTI|$kdcv*Lj8yZMtM&vgvrPl19D>Di26c?Phg_9UK%5u@XZG4rclCDmQsj<o^OP z7L3X|g9gT$q0lU?D{W>Y2yAznsZVQGjUTTY)`cI<)FNpMGn3uZrAk*Si)r@pBeZAg zj~}O0wM8+bD5)~LUqW+6(odO@Ug7|#F&jdkH_1keF*GgmMjIemswA^E*ie?3gjQ<9 zN_BcO1C~(HqEL!IKQWDhyk-fFCQvF?jHs%i0ZNNR>nBtu%?i@vL@HZtV2>n=?QTb{ zsTcVtYSc{w8{3`dO_D0sfKipv(4YWR0LAtlfhtO?=9<l+@e9fXgPd8fHO?qZE<;o# zo4Pd=l{t*tXqqwY##K_PY@C;@XgvchKCn1I0SV6Do}8W@zp?0|M)906n|DYzOI1xF z#l~}W!wVA9zypm%vp9}{%*=*D!A1#O0Cbi}n_36+Q<&fi5;tvS@)C}q$^@TM{qn&X zX<e>c0|i90^VOx}+_gK0ZNV1Gbv0n6Ym?03e%8V2m8xDsZ)}XZETK=(^cttmmAO%> zVoO?hVtug@jAnWf*_+B*lpVO}P2Cn5%^~>8Z|tNrrpf26$?GbncWXnee=s-~V`5a? zcH5`C4@ZCb@Zpr0eyXv%bp{8!)4G6>6pocq*dsSf$Yq)&mk0tWnORJW*<=C*z&p%% zS=TG4F&qsKw{d0cgxQQ`x0#oMc{$nJo4813HgM8gOO<G#G!8O@Vf-E*EWdsbXTG<K z>@>Os;aBR_W)1Sc79VZso)%4A(jbl}h&dByi?Q_#9hXKA_V(O*dnO%e^JAT<5%FQK z*%>~;JNe6>^G|T6oh>=6j(MryO(F=r`swuO?Cj(FV>+NosFDl)RP=G7pFOIPi#e-d zLS1ZeZVU$7pdnsN-`6+5!r??%3qlmaMqM+tHkO9Mt$5EOI(`4{!@G}fkADK9!5<*D z9cKI(x!ofwl}V=ZiHIed(m=OY@D*m&V1yY&S)FP>2j7%Vn|#gBVQcp@;FROc?Sr%D zHK&<}$z&@uPsoO1xSfye!`Piplp<?f)y)Rj&djFBfi0yIty`tLIgPS*&Qvx9O<QH5 z-`hV3IP9p{AcdLTqEcy{K+;~cwqj4l4!vCOYz_+CQNrrnIz8};WbkmCB$4)#8=BqT zCaGzY;{G1#2BIOkVk(CHXFTxoIfxo5ri3ZZFBvih@jK;cuOb+Y2{{efIdnF&2Gw=j zq#$m_W_8o!D`n2QaVOE?<4>cZdp_-+_gl&%d(JY^&evRkJ9y}S9`303han3?1cnPu zID(>BKs-6Mg$ODFK#w2czeHA{^h;9Ls@Wj*agevWsX{B7BOD`hArvz$cyG=*F}+u& zZ7NM|g-ni|mC2cUnZRx7201U`7>@aVx5y@!5r-#90Yi8cdlse^1w0QcNU!p>j3qXk zB8K5-(N-xtgixMke5*DQb7ty8%MfK4qBY{>U4V!^kbm+8MeZZX9<c<bg9EtjnWVhq zLUBeJ(B0kVCLV)l4YFHhlEN<X>K(Wxo-}-9BkJj~xG4y)^23N&fVfwN2m7mQEmznQ z2UZxbWGm=CzWxp*V9iMep>iaoM(i8S7Ii(HajNa=E8;O^X&w_jPf)s|;Pml?7g}4v zt+);pme4_`On%P9p?xU5<4pIM=uo!cDZ$`W2CtK%{ro~+dM216K13#af3wP-J~)uo zFKCzo9h%>vHD}&#k3pAP27uG&1yr$KqI>J59hY-`RB*MmVM42n!6Q~$?VewP(DS-R zVyavuf)_yf5gGu>VfYs>@#kAskB$pfU6~~hJi~)f3YL?noaZ#A7nl3=ovb*|FZaV~ z9px4YT)xx4C#(PKgy!%sb9N<?D&9u)h$BwuozjSSid%*-|20IB;KW~S&*YA0==*lc z-JS5<3ktEn3DH|vZkfl*%Xh=6fEs>bsoSG-Fh(a3;xbu_zq;V#`32`WLJ{3oP_mS& zP7HK&u9Thx;qI6J{1?fd!=iL#h;t~qT|)NG<1H5Jx>QlL@{9=M+K>)lN80A@7sRq& zV5QQ@LcIkeAQ=;b+Z<21K=HfW^s9ce@;I>|B^=N>ItfwXWo&B4^ay#lD+%Y_XA#R& z#D|ep;%wkFA0@+m2eg|q-<dKGrZ~=G8#v1xzBztDh$;zt0)ORi&pmfsB9&D7Cf959 zTvkgh5+P?QsR_mDqu$41L~aQh^aHlN^0u@Z&+JA}I$(Y-Yr&uoBzIE9uu~_o7w7Pi zU<zqgc6iza#KY})=)J=tvqh9U2DLUg-h&;+Ki7E`4MsHZY`3d?jfHjA%3oUOfZLU} zFLN$+{`_+5p|ooLnRPlF*CcXnO%-kDh=z>7S7uy~y|!hOKB;t`^>OSlnAT~Mo#Npd z8zQzR;R*=W?F$I&PEoo=+^r=%BaItwiXkR_cVI6h`jM}{?w-H6q;C(<-D~3>zmN*m z9>Tx9_6bZqzqCojW_qs&mjFQN=V26p(z&ihBhQ_G)booZ<l55Rp5tou6BPFYU!`}o zKTqMS5ESxcDH9akuDs?s_dU{`klb7EaX_2_dN{uV=C?pWLlWV?YHU$2U1q2nA3Bh8 zs#kJFvrDB9eBJO$F5R$`+$MzzL^c*yOesR+;DVV1Fk?MVqVwQ(ONoJkJb<*QwfX{r zqXkos*)a+N1Z(>@oqzxN^CiFb@*~IY_h7bl)s+Wbr4+E;hZM$S-5fUysX8+WI@SZ~ zPUzSjtPSQL<3<g=Ri}>5R~}5SoBS$Atljp0A(osR_2-XBK4b5mnjU4Yf8fhCzHY-# zlZ$E+<I>JVe)}#L^n!6z`T&pO<Qas%C}=}CGjGQvUJ$K^Apox78Z~dhpg=k{NC0Zr z1XB2+*TS_Ix)$GZFgPz!i3c7%VzNn>5yXnl>B3w-9Zo_7Hhz@5XJFW$P@bb)4A8;7 zx4v1^`3ox!yqu^fBizM&wWCQ6Kn1Qrfkr?3YJ!9^*ir{ea7csEwoDxE9u-qKa^i7X z!2J>bi0Jm4`#ZY*<Ne)*8QRmfkUPx>y$JeCKZdbTyyHj83%NpkG#wptQbcglK3 zrc0LCWB6c`Z7uB{@#wjMkO885l9qfs5Owm<0RNsz!D=D*2^_<C8158&b~S~>ORq@Y z;63NB==?Evx*T}|_pItNfeUl4J3xv2j$bKAbKtAp-}PcasAkgxHbX<?Um?f?+yJ2x z2uA&jg_!=H6f0`i_P$bXhhb*MBbQF-=W)fY`C0*QO0XBgd?{6)s#yJenSfElDO9u- zbZ#e=yncDm<?Oa>f{@mDZ6R)JJ01Dv^e<7^J`bJIn<Qw*y!!#am)}W>w2UH~?q|WC zcFW+J1Ja@hcw=9>yis_aftN*m2l>dcU4z#<J)dfY`2;uj2U3sak%xGbVs9ZyUJ&`J zm_>d7?{9b|#SN54RyPAek>uv_izi8_tiTYkJvD<kK;<UeKOMjODW~2HJpVA>na8lY zMSKm*bNXwdU4X1QAPiPSyu_XnE<A1Ot0pP8Zk9OQJU-i+=||idWN)xtXQb|&Q2H(} z2L|g+<_YtFOU^OfxDO_~PO-!7p!%?hDs+LZwh-xv-xLJ*06~m!a=2K*5b+U!z_0of zzmXWD17RYJEBKv#^JTC4CsjRmgdd{?0hwt$c8h3Tw5SQhT~W<(cKv6Y!@HqndCTZl Vpd*#L`C0-%yyy1ue#FoA{tp^a4O;*J literal 0 HcmV?d00001 diff --git a/.config/oxlintrc.json b/.config/oxlintrc.json index 97b4cc4..4340258 100644 --- a/.config/oxlintrc.json +++ b/.config/oxlintrc.json @@ -200,6 +200,7 @@ "**/.config/socket-wheelhouse-schema.json", "**/.config/taze.config.mts", "**/.config/tsconfig.base.json", + "**/.config/vitest.coverage.fleet.config.mts", "**/packages/build-infra/lib/release-checksums/consumer.mts", "**/packages/build-infra/lib/release-checksums/core.mts", "**/packages/build-infra/lib/release-checksums/producer.mts", @@ -244,6 +245,7 @@ "**/scripts/lockstep/scan.mts", "**/scripts/lockstep/schema.mts", "**/scripts/lockstep/types.mts", + "**/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs", "**/scripts/power-state.mts", "**/scripts/publish-release.mts", "**/scripts/publish-shared.mts", @@ -256,7 +258,10 @@ "**/scripts/test/install-claude-plugins.test.mts", "**/scripts/test/install-git-hooks.test.mts", "**/scripts/update.mts", + "**/scripts/util/multi-package-publish.mts", + "**/scripts/util/pack-app-triplets.mts", "**/scripts/util/run-command.mts", + "**/scripts/util/source-allowlist.mts", "**/scripts/validate-bundle-deps.mts", "**/scripts/validate-config-paths.mts", "**/scripts/validate-file-size.mts", diff --git a/.config/vitest.config.mts b/.config/vitest.config.mts index 07d5f5b..741f6ed 100644 --- a/.config/vitest.config.mts +++ b/.config/vitest.config.mts @@ -9,7 +9,7 @@ const isCoverageEnabled = process.env.COVERAGE === 'true' || process.argv.some(arg => arg.includes('coverage')) -export default defineConfig({ +const vitestConfig = defineConfig({ test: { deps: { interopDefault: false, @@ -80,3 +80,6 @@ export default defineConfig({ }, }, }) + +// oxlint-disable-next-line socket/no-default-export -- vitest discovers configs by default export; named export wouldn't be picked up. +export default vitestConfig diff --git a/.gitattributes b/.gitattributes index d1ff3ce..c9373e3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -359,13 +359,16 @@ scripts/test/check-lock-step-refs.test.mts linguist-generated=true scripts/test/install-claude-plugins.test.mts linguist-generated=true scripts/test/install-git-hooks.test.mts linguist-generated=true scripts/update.mts linguist-generated=true +scripts/util/multi-package-publish.mts linguist-generated=true +scripts/util/pack-app-triplets.mts linguist-generated=true scripts/util/run-command.mts linguist-generated=true +scripts/util/source-allowlist.mts linguist-generated=true scripts/validate-bundle-deps.mts linguist-generated=true scripts/validate-config-paths.mts linguist-generated=true scripts/validate-file-size.mts linguist-generated=true scripts/validate-rolldown-minify.mts linguist-generated=true # Vendored binary blobs (no diff, no merge, no text-mode). -.claude/hooks/_shared/acorn/acorn.wasm binary -template/.claude/hooks/_shared/acorn/acorn.wasm binary +.claude/hooks/fleet/_shared/acorn/acorn.wasm binary +template/.claude/hooks/fleet/_shared/acorn/acorn.wasm binary # ─── END fleet-canonical ──────────────────────────────────────── diff --git a/docs/claude.md/fleet/export-and-no-any.md b/docs/claude.md/fleet/export-and-no-any.md new file mode 100644 index 0000000..028960e --- /dev/null +++ b/docs/claude.md/fleet/export-and-no-any.md @@ -0,0 +1,66 @@ +# Export everything + NO `any` ever + +Two paired fleet rules captured under one doc because they're symbiotic — exporting types is what makes "no `any`" practical, and "no `any`" is what makes the export discipline pay back. + +## Export everything + +**Every top-level function, interface, type alias, class, and helper in `src/` is `export`ed.** No private symbols. + +- Privacy is handled by NOT importing in consumers, or by `_internal/` directory layout for module-private files. +- Underscore-prefixed identifiers are separately banned (see _No underscore-prefixed identifiers_). +- Tests need to reach helpers directly — coverage holes appear whenever a test has to go through the public API to exercise an internal helper. +- The `socket/export-top-level-functions` oxlint rule enforces this for functions. A planned `socket/export-top-level-types` extends to interfaces + type aliases. + +**Past incident.** socket-packageurl-js had `interface PurlObject` private at `src/purl-type.mts`. Tests of per-type validators (`PurlType.npm.validate(...)`) had to cast `PurlType` to `any` to call `.validate` because the helper namespace's generic shape didn't propagate the per-type signatures. The `any` cast hid every other type error on those call sites. The fix was to `export interface PurlObject` so tests can import it and type the shape correctly. + +## NO `any` ever + +The fleet's `typescript/no-explicit-any: "error"` lint setting stays at error level fleet-wide and **never gets relaxed**. + +When tests or scripts touch a value of unknown shape, the right choices are: + +1. **Type with the actual shape it holds.** Tests rarely operate on truly unknown data — the test author chose the input. Use the concrete shape: `Record<string, unknown>` for dynamic-key access, `t.ImportDeclaration` for babel AST nodes, `{ default?: typeof X | undefined }` for CJS/ESM interop probes. + +2. **Type as `unknown` + narrow with a type guard at the use site.** Works when the test really doesn't know the shape ahead of time (parsing arbitrary JSON, reading an opaque API response). Add `if (typeof x === 'string') { ... }` or `assert.ok(isObject(x))` before access. + +3. **For namespace objects whose generics don't propagate** (e.g. `createHelpersNamespaceObject` returning `Record<string, Record<string, unknown>>`): define the typed shape inline and cast `as unknown as TypedShape` **once** at the import site, then reference the typed binding everywhere else. Don't cast per-call. + +**What's forbidden:** + +- `as any` / `: any` / `<any>` anywhere in source or test files. +- Bulk `: any` → `: unknown` sed-replacements without adding type guards. `unknown` and `any` are not interchangeable — `unknown` requires narrowing before property access, so a bulk replace breaks every `x.foo` site downstream. +- Scoped oxlint override on `test/**` that disables `typescript/no-explicit-any`. The `socket/no-file-scope-oxlint-disable` rule + the wider _Don't disable lint rules_ policy both forbid this — fix the underlying types instead. +- Per-line `oxlint-disable-next-line typescript/no-explicit-any` as a default. The disable comments are reserved for genuinely intractable cases (third-party type holes) and need a `-- <reason>` annotation. + +**Past incident.** socket-sdk-js's `test/unit/bundle-validation.test.mts` had `path: any` params in babel visitors (`ImportDeclaration(path: any)`, `CallExpression(path: any)`, etc.). A bulk `: any` → `: unknown` sed pass kept the code compiling but broke every `path.node.X` access downstream (TS18046 cascade). The right fix was importing `import type { NodePath } from '@babel/traverse'` + `import type * as t from '@babel/types'` and typing each visitor as `NodePath<t.ImportDeclaration>` etc., then guarding `callee.type === 'Identifier'` before reading `callee.name`. Slower to type out, much safer. + +## When generics don't propagate + +If you find yourself wanting `any` to call a method on a namespace object, the underlying issue is almost always that the namespace builder's generic types collapsed. Two patches: + +1. **Fix the builder** (preferred long-term): re-type the helper-namespace constructor to be properly generic — `function createHelpersNamespaceObject<H extends Record<string, Record<string, unknown>>>(helpers: H): H` — so consumers see the per-type signatures. Touches src; do it when the change is small. + +2. **Type at the consumer** (preferred short-term, for tests): define the typed shape next to the consumer and cast once. + +```ts +// Pattern: typed alias next to the consumer. Mirror the runtime shape — +// `createHelpersNamespaceObject` inverts so calls read as `<key>.<method>`, +// not `<method>.<key>`. PurlType uses ecosystem keys (npm, pypi, …); +// PurlComponent uses component keys (name, namespace, …). +import { PurlType } from '../src/purl-type.mjs' +import type { PurlObject } from '../src/purl-type.mjs' + +type PurlTypeHelpers = Record< + string, + { + readonly validate: (purl: PurlObject, throws: boolean) => boolean + readonly normalize: (purl: PurlObject) => PurlObject + } +> +const PurlTypeT = PurlType as unknown as PurlTypeHelpers + +// Then everywhere: +PurlTypeT['npm']!.validate(comp, false) +``` + +The cost is one block of declaration prose at the import site; the payoff is every call site type-checks without `any`. diff --git a/docs/claude.md/fleet/path-hygiene.md b/docs/claude.md/fleet/path-hygiene.md index f829073..57a28d8 100644 --- a/docs/claude.md/fleet/path-hygiene.md +++ b/docs/claude.md/fleet/path-hygiene.md @@ -24,8 +24,8 @@ Each package's `scripts/paths.mts` exports at minimum: | Level | Surface | What it catches | | ----------- | ----------------------------------------------- | ---------------------------------------------------------------------- | -| Edit-time | `.claude/hooks/fleet/path-guard/` | Build-path construction outside `paths.mts` | -| Edit-time | `.claude/hooks/fleet/paths-mts-inherit-guard/` | Sub-package `paths.mts` that doesn't inherit from the nearest ancestor | +| Edit-time | `.claude/hooks/fleet/path-guard/` | Build-path construction outside `paths.mts` | +| Edit-time | `.claude/hooks/fleet/paths-mts-inherit-guard/` | Sub-package `paths.mts` that doesn't inherit from the nearest ancestor | | Commit-time | `scripts/check-paths.mts` (run by `pnpm check`) | Whole-repo path-hygiene scan | | Audit + fix | `/guarding-paths` skill | Interactive cleanup | diff --git a/docs/claude.md/fleet/public-surface-hygiene.md b/docs/claude.md/fleet/public-surface-hygiene.md index 2e07f77..9521b77 100644 --- a/docs/claude.md/fleet/public-surface-hygiene.md +++ b/docs/claude.md/fleet/public-surface-hygiene.md @@ -9,6 +9,17 @@ The rules apply even when hooks are not installed. They're invariants, not enfor - **Real customer / company names**: never write one into a commit, PR, issue, comment, or release note. Replace with `Acme Inc` or rewrite the sentence to not need the reference. No enumerated denylist exists; a denylist is itself a leak. - **Private repos / internal project names**: never mention. Omit the reference entirely. Don't substitute "an internal tool"; the placeholder is a tell. +## Neutral placeholders for test fixtures + +Pattern-matching tests, sample documentation, and example configs are tempting places to reach for a "real" package name (e.g. `eslint-plugin-react`, `react`, `lodash`). When the test exercises the _shape_ of a name rather than its identity, use the `acme-*` placeholder family — same convention as `Acme Inc` for company-name placeholders. This avoids tripping lint rules that flag references to specific package families (e.g. `socket/no-eslint-biome-config-ref` fires on `eslint-` prefixes even when the literal is a fixture, not a config ref). Recommended placeholder shapes: + +- bare: `acme-foo`, `acme-widget` +- plugin-family: `acme-plugin-react`, `acme-plugin-node` +- scoped: `@acme/widget`, `@acme/types` +- versioned: `acme-foo@1.0.0`, `@acme/widget@2.0.0` + +The bypass comment (`socket-hook: allow eslint-biome-ref -- <reason>`) exists for genuinely irreplaceable cases — testing the lint rule itself, or quoting a real `.eslintrc.json` file path inside a migration script. Renaming the fixture is preferred over the bypass. + ## Linear refs Never put `SOC-123` / `ENG-456` / Linear URLs in code, comments, or PR text. Linear lives in Linear. diff --git a/docs/claude.md/fleet/security-stack.md b/docs/claude.md/fleet/security-stack.md index 21db28b..e5eef17 100644 --- a/docs/claude.md/fleet/security-stack.md +++ b/docs/claude.md/fleet/security-stack.md @@ -12,52 +12,52 @@ Layered enforcement, with each layer catching what the previous one missed. ## Layer 1: never let secrets touch disk -| Surface | Hook / mechanism | What it blocks | -| -------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Surface | Hook / mechanism | What it blocks | +| -------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Socket API token storage | `.claude/hooks/fleet/no-token-in-dotenv-guard/` | Write/Edit of any `.env*`/`.envrc` file containing a real token | | Keychain read invocations | `.claude/hooks/fleet/no-blind-keychain-read-guard/` | Bash calls to `security find-*-password`, `secret-tool lookup`, `Get-StoredCredential`, `keyring get` — these surface UI prompts per call and the token is already cached in-process | -| Token detection in commits | `.git-hooks/pre-commit.mts` + `pre-push.mts` | Staged files containing AWS keys, GitHub tokens (`ghp_`/`gho_`/`ghr_`/`ghs_`/`ghu_`/`github_pat_`), Socket API tokens, or any PEM private key (RSA / EC / DSA / OPENSSH / ENCRYPTED / PGP / generic PKCS#8) | +| Token detection in commits | `.git-hooks/pre-commit.mts` + `pre-push.mts` | Staged files containing AWS keys, GitHub tokens (`ghp_`/`gho_`/`ghr_`/`ghs_`/`ghu_`/`github_pat_`), Socket API tokens, or any PEM private key (RSA / EC / DSA / OPENSSH / ENCRYPTED / PGP / generic PKCS#8) | | gh CLI token storage | `.claude/hooks/fleet/gh-token-hygiene-guard/` | Bash invocations of `gh` when the token is in the on-disk `~/.config/gh/hosts.yml` — must be `(keyring)` | ## Layer 2: gate access to dangerous capabilities | Capability | Hook | Gate | | -------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gh workflow run` / dispatch | `.claude/hooks/fleet/gh-token-hygiene-guard/` | Token must have `workflow` scope (off by default) AND a fresh `Allow workflow-scope bypass` chat phrase AND Touch ID / password auth AND unconsumed grant marker. Single-use: each dispatch consumes the grant. | -| GitHub Actions workflow_dispatch | `.claude/hooks/fleet/release-workflow-guard/` | Blocks `gh workflow run`/`dispatch` against publish/release workflows. Bypass: `--dry-run=true` (if workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim | +| `gh workflow run` / dispatch | `.claude/hooks/fleet/gh-token-hygiene-guard/` | Token must have `workflow` scope (off by default) AND a fresh `Allow workflow-scope bypass` chat phrase AND Touch ID / password auth AND unconsumed grant marker. Single-use: each dispatch consumes the grant. | +| GitHub Actions workflow_dispatch | `.claude/hooks/fleet/release-workflow-guard/` | Blocks `gh workflow run`/`dispatch` against publish/release workflows. Bypass: `--dry-run=true` (if workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim | | Pre-existing branch protection | `lint-github-settings.mts` | Audits the default branch's protection on GitHub for `required_signatures`, `required_pull_request_reviews` (≥1 + dismiss_stale_reviews), `allow_force_pushes=false`, `allow_deletions=false`, `enforce_admins=true` | | Commit signing | `.git-hooks/pre-commit.mts` + `.git-hooks/pre-push.mts` | Pre-commit: `commit.gpgsign=true` + `user.signingkey` set. Pre-push: `git log --format='%G?'` excludes `N` and `B` for commits landing on `main`/`master`. | -| Hook bypass attempts | `.claude/hooks/fleet/no-revert-guard/` | Blocks `git revert`, `--no-verify`, `DISABLE_PRECOMMIT_*`, `--no-gpg-sign`, force-push — all gated by canonical `Allow X bypass` phrases | +| Hook bypass attempts | `.claude/hooks/fleet/no-revert-guard/` | Blocks `git revert`, `--no-verify`, `DISABLE_PRECOMMIT_*`, `--no-gpg-sign`, force-push — all gated by canonical `Allow X bypass` phrases | ## Layer 3: enforce token lifetime -| Token | Mechanism | Window | -| -------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Token | Mechanism | Window | +| -------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | gh CLI token | `.claude/hooks/fleet/gh-token-hygiene-guard/` 8-hour age cap | Errors when token >8h since last `gh auth login` or `gh auth refresh`. Self-recovery: `gh auth refresh` is always allowed. | -| GitHub Actions `GITHUB_TOKEN` | GitHub-provided | 1 hour per workflow run, scope-limited by the workflow's `permissions:` block | +| GitHub Actions `GITHUB_TOKEN` | GitHub-provided | 1 hour per workflow run, scope-limited by the workflow's `permissions:` block | | Authenticated CLIs (npm, pnpm, gcloud, docker, vault, …) | `.claude/hooks/fleet/auth-rotation-reminder/` | Stop-hook periodically logs you out of stale long-lived sessions. `gh` is exempt from auto-logout (would break in-session work); its age check lives in `gh-token-hygiene-guard` instead. | ## Layer 4: workflow + repo audit -| Surface | Hook / scanner | When it fires | -| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Surface | Hook / scanner | When it fires | +| ---------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | GitHub Actions workflow YAML | `.claude/hooks/fleet/actionlint-on-workflow-edit/` | PostToolUse after Edit/Write to `.github/workflows/*.y*ml`. Runs `actionlint` (YAML / shell / SHA-pin) + `zizmor` (security: privilege escalation, secret leaks, untrusted-input-in-script, `pull_request_target` misuse) | | `pull_request_target` misuse | `.claude/hooks/fleet/pull-request-target-guard/` | Blocks Edit/Write that creates a `pull_request_target` workflow checking out the fork head + executing the checked-out code in the same job | | Workflow `uses:` SHA pinning | `.claude/hooks/fleet/workflow-uses-comment-guard/` | Every SHA-pinned `uses:` line needs a `# <tag> (YYYY-MM-DD)` comment for staleness tracking | | Workflow heredoc bodies | `.claude/hooks/fleet/workflow-yaml-multiline-body-guard/` | Blocks `gh ... --body "..."` (multi-line markdown breaks YAML) in favor of `--body-file <path>` | -| GitHub repo settings | `scripts/lint-github-settings.mts` | Audits visibility, merge settings, branch protection, required apps. Weekly cache-gated; CI doesn't burn API quota | -| AgentShield + zizmor | `/scanning-security` skill | A-F graded report on `.claude/` config + workflow YAML. Run after touching `.claude/` or workflows, before releases | +| GitHub repo settings | `scripts/lint-github-settings.mts` | Audits visibility, merge settings, branch protection, required apps. Weekly cache-gated; CI doesn't burn API quota | +| AgentShield + zizmor | `/scanning-security` skill | A-F graded report on `.claude/` config + workflow YAML. Run after touching `.claude/` or workflows, before releases | ## Layer 5: catch the operator mistake -| Mistake | Hook | What it catches | -| -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- | +| Mistake | Hook | What it catches | +| -------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | | Pushing a real customer / company name | `.claude/hooks/fleet/private-name-guard/` | Real names in commits / PR text / release notes | | Linear ticket refs | `.claude/hooks/fleet/private-name-guard/` | `SOC-123`, `ENG-456`, Linear URLs in code or PR text | | External issue refs (auto-link spam) | `.claude/hooks/fleet/no-external-issue-ref-guard/` | `<owner>/<repo>#<num>` in commits or PR bodies for non-SocketDev repos | | Empty commits | `.claude/hooks/fleet/no-empty-commit-guard/` | `git commit --allow-empty`, `cherry-pick --allow-empty` | | `--no-verify` use | `.claude/hooks/fleet/no-revert-guard/` | Hook bypass via `--no-verify` without typed bypass phrase | -| Personal paths in code | `pre-commit.mts` / `pre-push.mts` | `/Users/<name>/`, `/home/<name>/`, `C:\Users\<NAME>\` | +| Personal paths in code | `pre-commit.mts` / `pre-push.mts` | `/Users/<name>/`, `/home/<name>/`, `C:\Users\<NAME>\` | | Cross-repo path imports | `.claude/hooks/fleet/cross-repo-guard/` + `scanCrossRepoPaths` | `../<fleet-repo>/` and absolute `/projects/<fleet-repo>/` references | ## Setup helpers diff --git a/docs/claude.md/fleet/sorting.md b/docs/claude.md/fleet/sorting.md index 3cd2681..f4aa1c5 100644 --- a/docs/claude.md/fleet/sorting.md +++ b/docs/claude.md/fleet/sorting.md @@ -1,20 +1,135 @@ # Sorting reference -Sort lists alphanumerically (literal byte order, ASCII before letters). +Sort lists alphanumerically (literal byte order, ASCII before letters). This is a +**universal** rule: any block of sibling items, in any file type, gets sorted +unless there's a documented ordering reason. When you touch an unsorted block, +**fully re-sort it**. Don't append the new entry and leave the rest unsorted. -## Where to sort +## What "alphanumeric" means here -- **Config lists**: `permissions.allow` / `permissions.deny` in `.claude/settings.json`, `external-tools.json` checksum keys, allowlists in workflow YAML. -- **Object key entries**: keys in plain JSON config + return-shape literals + internal-state objects. (Exception: `__proto__: null` always comes first, ahead of any data keys.) -- **Import specifiers**: sort named imports inside a single statement: `import { encrypt, randomDataKey, wrapKey } from './crypto.mts'`. `import type` follows the same rule. Statement _order_ (`node:` → external → local → types) is separate from specifier order _within_ a statement. -- **Method / function placement**: within a module, sort top-level functions alphabetically. Convention: private functions (lowercase / un-exported) sort first, exported functions second. The `export` keyword is the divider. -- **Array literals**: when the array is a config list, allowlist, or set-like collection. Position-bearing arrays (e.g. `argv`, anything where index matters semantically) keep their meaningful order. -- **`Set` constructor arguments**: `new Set([...])` and `new SafeSet([...])` literals. The runtime is order-insensitive, so source order is alphanumeric. Same rationale as Array literals: predictable diffs, no merge conflicts on insertions. -- **Regex alternation groups**: `(foo|bar|baz)` reads as `(bar|baz|foo)`. Capturing, non-capturing, and named-capture groups all follow the rule. Auto-fixable when every alternative is a simple literal. The exception is order-bearing alternations where the regex engine MUST try one alternative before another (rare; the canonical example is markup parsers where `<!--|-->` would silently mismatch if reordered). Append `// socket-hook: allow regex-alternation-order` on those lines. -- **String-equality disjunctions**: `x === 'a' || x === 'b' || x === 'c'` reads with the comparand strings in alpha order. The De Morgan dual `x !== 'a' && x !== 'b'` (negative-membership check) follows the same rule. The `||` chain short-circuits regardless of operand order; sorting reduces diff churn when adding new comparands and makes "is X in this set?" checks visually consistent. Auto-fixable when every clause has the same left operand and uses string-literal comparands. Mixed shape (different left, different operator, non-string right) is skipped. Those are usually ordering-sensitive predicates and the autofix would change semantics. -- **Boolean identifier chains**: `agentshieldOk && zizmorOk && sfwOk` reads with the names in alpha order: `agentshieldOk && sfwOk && zizmorOk`. Same rule for `||` chains. The lint rule fires only when (1) every leaf is a bare `Identifier` (no calls, no member access, no literals, no negations; those have side-effect or short-circuit semantics where order can be observable) AND (2) the chain has **3 or more operands**. Two-operand chains like `useHttp && oauthEnabled` are guard patterns where order carries narrative ("in HTTP mode, did OAuth get enabled?") that alpha-sort would destroy; only length-3+ chains are unambiguously flag lists. Duplicate identifiers and chains with interior comments are skipped (the autofix would lose information). Enforced by `socket/sort-boolean-chains`. -- **TypeScript union of string literals**: `type Source = 'download' | 'path' | 'vfs'` (not `'vfs' | 'path' | 'download'`). Members are interchangeable at the type level; alpha order makes "which values can this take?" answerable without scanning. Applies to type aliases, inline parameter unions, and template-literal type alternatives. Position-bearing unions (rare; e.g. a discriminator where order encodes priority) keep their meaningful order; append `// socket-hook: allow union-order` on those lines. +1. **ASCII byte order**, not natural/numeric order. `'name-10'` sorts **before** + `'name-2'`. Stable across Node versions and machines. +2. **Case-sensitive.** `'Z' < 'a'` (uppercase first). Raw `<` comparison, not + `localeCompare`. +3. **No locale-aware collation.** No `Intl.Collator`, no `numeric: true`. +4. **Whole-token comparison**, not character-class buckets. + +These are the exact semantics every `socket/sort-*` lint rule uses. + +## Where to sort: code surfaces (lint-enforced) + +- **Import specifiers**: named imports inside a single statement, e.g. + `import { encrypt, randomDataKey, wrapKey } from './crypto.mts'`. `import type` + follows the same rule. Statement _order_ (`node:` → external → local → types) + is separate from specifier order _within_ a statement. Enforced by + `socket/sort-named-imports`. +- **Object literal properties**: sibling properties of an object literal at + module scope (and inside `export const` / `export default`) sort + alphanumerically. Exception: `__proto__: null` always comes first, ahead of + any data key. Object literals that are position-bearing (HTTP header order, + protocol field order) opt out with `// socket-hook: allow object-property-order`. + Enforced by `socket/sort-object-literal-properties`. +- **Method / function placement**: within a module, sort top-level functions + alphabetically. Private functions (lowercase / un-exported) sort first, + exported functions second; the `export` keyword is the divider. `main`, if + present, stays last. Enforced by `socket/sort-source-methods`. +- **Array literals**: when the array is a config list, allowlist, or set-like + collection. Position-bearing arrays (`argv`, anything where index matters + semantically) keep their meaningful order. +- **`Set` constructor arguments**: `new Set([...])` and `new SafeSet([...])` + literals. The runtime is order-insensitive, so source order is alphanumeric. + Enforced by `socket/sort-set-args`. +- **Regex alternation groups**: `(foo|bar|baz)` reads as `(bar|baz|foo)`. + Capturing, non-capturing, and named-capture groups all follow the rule. + Auto-fixable when every alternative is a simple literal. Order-bearing + alternations (rare; markup parsers where `<!--|-->` would silently mismatch if + reordered) append `// socket-hook: allow regex-alternation-order`. Enforced by + `socket/sort-regex-alternations`. +- **String-equality disjunctions**: `x === 'a' || x === 'b' || x === 'c'` reads + with the comparand strings in alpha order. The De Morgan dual + `x !== 'a' && x !== 'b'` follows the same rule. Auto-fixable when every clause + has the same left operand and uses string-literal comparands; mixed shapes are + skipped. Enforced by `socket/sort-equality-disjunctions`. +- **Boolean identifier chains**: `agentshieldOk && zizmorOk && sfwOk` reads in + alpha order. Fires only when every leaf is a bare `Identifier` AND the chain + has **3+ operands** (two-operand chains are guard patterns whose order carries + narrative). Duplicate identifiers and interior comments are skipped. Enforced + by `socket/sort-boolean-chains`. +- **TypeScript union of string literals**: `type Source = 'download' | 'path' | 'vfs'`. + Members are interchangeable at the type level; alpha order makes "which values + can this take?" answerable without scanning. Position-bearing unions (a + discriminator where order encodes priority) append + `// socket-hook: allow union-order`. _(Rule planned; see Roadmap.)_ + +## Where to sort: non-code surfaces (hook-reminded, manual) + +oxlint only sees JS/TS, so these are caught by the `alpha-sort-reminder` hook on +edit and by review, not by a lint rule. + +- **JSON / JSONC** (`tsconfig.json`, `package.json`, `.oxlintrc.json`, + `.config/*.json`): sort every object's keys alphanumerically. + - Exception: `tsconfig.json` top-level has a canonical order + (`extends` → `compilerOptions` → `include` → `exclude` → `files`); keys + _inside_ `compilerOptions` alphabetize. + - Exception: `package.json` top-level keeps npm convention + (`name` → `version` → `description` → … → `scripts` → `dependencies`); keys + inside `scripts` / `dependencies` / `devDependencies` alphabetize. +- **YAML** (`.github/workflows/*.yml`, `pnpm-workspace.yaml`): `env:` blocks, + `with:` blocks, `catalog:` entries, `minimumReleaseAgeExclude` arrays, and + allowlist arrays alphabetize. `matrix.include[]` entries alphabetize by a + compound `platform → arch` key. **Even commented-out matrix entries** sort into + position; don't drop them at the bottom. + - Exception: step lists are ordered by pipeline phase, not alpha. + - Exception: active matrix entries today are `x64`-before-`arm64` fleet-wide + for historical reasons; **new** entries follow alpha (`arm64` < `x64`), and a + fleet-wide cascade re-sort of the active entries is a future PR. (Origin: + socket-btm `boringssl.yml`, commit c8dd1f1b.) +- **Bash / shell variables in workflow scripts**: cache-key hash assignments + (`BIN_INFRA_LIB=$(...)`, `BORINGSSL_PACKAGE_JSON=$(...)`) alphabetize. Hash + order doesn't affect correctness, but stable diffs do. +- **Markdown lists** (README consumer lists, doc bullet lists, fleet-canonical + tables): alphabetize sibling bullets. + - Exception: narrative ordering (numbered setup steps, "first X then Y"). + State the reason in surrounding prose. + - **NO ELLIPSIS.** Drop `"..."` / `"…"` from list endings. List every item + alphabetically, or write "N items, see `<source>`". Never trail off. + +## Behavior rules + +- **Fully re-sort, don't append.** Editing an already-sorted block → insert in + sorted position. Editing an unsorted block → fully re-sort it in the same + commit. +- **Cascade-scoped re-sorts** (e.g. all 8 builder workflows' matrix entries) get + a dedicated `chore(wheelhouse): cascade alpha-sort <pattern>` PR. Don't slip + the re-sort into unrelated work. +- **State the reason for any non-alpha order inline.** Boot/init sequences, + dependency chains, parser tokens in lex order, and discriminator priority all + qualify. ## Default -When in doubt, sort. The cost of a sorted list that didn't need to be is approximately zero; the cost of an unsorted list that did need to be is a merge conflict. +When in doubt, sort. Sorting a list that didn't need it costs nothing. Leaving +one unsorted that did costs a merge conflict later. + +## Roadmap (not yet enforced) + +| Surface | Plan | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `export { … }` lists | `socket/sort-named-exports` — mirror `sort-named-imports`. | +| TS string-literal unions | `socket/sort-union-members` — with `// socket-hook: allow union-order` escape. | +| Module-scope const arrays | `socket/sort-array-literals` — skip position-bearing arrays. | +| Independent switch-case branches | future rule; skip fall-through / early-return chains. | +| `.claude/settings.json` permission lists, `external-tools.json` keys | sync-scaffolding sort check. | + +## Provenance + +User-confirmed across 2026-04-17 → 2026-05-29 in socket-lib, socket-cli, +socket-btm, ultrathink, socket-sdk-js, socket-wheelhouse. Representative asks: +"properties and configs should be sorted alphanumerically" (JSON keys, +2026-04-17); "lets alphanumeric sort" (object-literal props); repeated +`sort-source-methods` reorders; "make `sort-source-methods` autofixable"; "add a +`sort-boolean-chains` rule"; "alphanumeric, no ellipsis" (README lists, +2026-05-29); "alphanumeric sort" on commented matrix entries +(`boringssl.yml`, 2026-05-29); "how can we do more alphanumeric sorting" +(2026-05-29, the meta-ask that produced this consolidation). John-David treats +an unsorted list as a defect: "when in doubt, sort." diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36c22bf..161614e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,60 +93,6 @@ importers: specifier: 'catalog:' version: 4.0.3(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.9.0) - packages/binflate: {} - - packages/binflate-darwin-arm64: {} - - packages/binflate-darwin-x64: {} - - packages/binflate-linux-arm64: {} - - packages/binflate-linux-arm64-musl: {} - - packages/binflate-linux-x64: {} - - packages/binflate-linux-x64-musl: {} - - packages/binflate-win32-arm64: {} - - packages/binflate-win32-x64: {} - - packages/binject: {} - - packages/binject-darwin-arm64: {} - - packages/binject-darwin-x64: {} - - packages/binject-linux-arm64: {} - - packages/binject-linux-arm64-musl: {} - - packages/binject-linux-x64: {} - - packages/binject-linux-x64-musl: {} - - packages/binject-win32-arm64: {} - - packages/binject-win32-x64: {} - - packages/binpress: {} - - packages/binpress-darwin-arm64: {} - - packages/binpress-darwin-x64: {} - - packages/binpress-linux-arm64: {} - - packages/binpress-linux-arm64-musl: {} - - packages/binpress-linux-x64: {} - - packages/binpress-linux-x64-musl: {} - - packages/binpress-win32-arm64: {} - - packages/binpress-win32-x64: {} - packages/build-infra: dependencies: '@socketsecurity/lib': diff --git a/scripts/ai-lint-fix/cli.mts b/scripts/ai-lint-fix/cli.mts index ba8a611..b4674e2 100644 --- a/scripts/ai-lint-fix/cli.mts +++ b/scripts/ai-lint-fix/cli.mts @@ -404,11 +404,14 @@ async function main(): Promise<void> { .filter((r): r is string => typeof r === 'string') const tier = escalateTier(ruleIds) const model = TIER_MODEL[tier] - logger.log( - `AI-fix ${rel} (${findings.length} findings, ${tier})…`, - ) + logger.log(`AI-fix ${rel} (${findings.length} findings, ${tier})…`) const prompt = buildPrompt(filePath, findings) - const { exitCode, stderr } = await runClaudeFix(filePath, prompt, cwd, model) + const { exitCode, stderr } = await runClaudeFix( + filePath, + prompt, + cwd, + model, + ) if (exitCode === 0) { totalEdits += findings.length continue diff --git a/scripts/ai-lint-fix/rule-guidance.mts b/scripts/ai-lint-fix/rule-guidance.mts index eca8061..98d4d7e 100644 --- a/scripts/ai-lint-fix/rule-guidance.mts +++ b/scripts/ai-lint-fix/rule-guidance.mts @@ -34,12 +34,13 @@ export const AI_HANDLED_RULES: ReadonlySet<string> = new Set([ ]) /** - * Capability tier per rule. The orchestrator picks the highest-tier model - * among a per-file batch's rules so a single Haiku-only file goes cheap, a - * mixed batch gets Sonnet, and any `max-file-lines` finding triggers Opus - * (module splits are real refactoring). + * Capability tier per rule. The orchestrator picks the highest-tier model among + * a per-file batch's rules so a single Haiku-only file goes cheap, a mixed + * batch gets Sonnet, and any `max-file-lines` finding triggers Opus (module + * splits are real refactoring). * * Why per-rule rather than per-file or per-finding: + * * - Per-finding would spawn N AI calls per file. Wasteful. * - Per-file flat would route everything to Sonnet defensively. Wasteful too. * - Per-rule + escalation matches the actual cost surface: simple regex-shaped @@ -47,10 +48,12 @@ export const AI_HANDLED_RULES: ReadonlySet<string> = new Set([ * control-flow + caller-chain rewrites (fetch→httpJson, sync→async, fs.access * → existsSync) need Sonnet; module decomposition needs Opus. * - * Tier order: `claude-haiku-4-5` < `claude-sonnet-4-6` < `claude-opus-4-8`. - * Add new rules to the right bucket when adding to AI_HANDLED_RULES. + * Tier order: `claude-haiku-4-5` < `claude-sonnet-4-6` < `claude-opus-4-8`. Add + * new rules to the right bucket when adding to AI_HANDLED_RULES. */ -export const RULE_MODEL_TIER: Readonly<Record<string, 'haiku' | 'sonnet' | 'opus'>> = { +export const RULE_MODEL_TIER: Readonly< + Record<string, 'haiku' | 'opus' | 'sonnet'> +> = { __proto__: null, // Identifier renames, single-token substitutions, namespace rewrites. // The right rewrite is fully determined by the pattern that fired. @@ -70,34 +73,35 @@ export const RULE_MODEL_TIER: Readonly<Record<string, 'haiku' | 'sonnet' | 'opus // by domain, decide what each new module exports, and rewrite imports // in every consumer. Real refactoring; Opus's depth pays back. 'socket/max-file-lines': 'opus', -} as Readonly<Record<string, 'haiku' | 'sonnet' | 'opus'>> +} as unknown as Readonly<Record<string, 'haiku' | 'opus' | 'sonnet'>> /** - * Map a tier label to the canonical Claude Code model ID. Centralized here - * so a global tier bump (Haiku 4.5 → 4.6, Sonnet 4.6 → 5.0, etc.) is a - * single-file edit and won't drift across the orchestrator + the docs. + * Map a tier label to the canonical Claude Code model ID. Centralized here so a + * global tier bump (Haiku 4.5 → 4.6, Sonnet 4.6 → 5.0, etc.) is a single-file + * edit and won't drift across the orchestrator + the docs. */ -export const TIER_MODEL: Readonly<Record<'haiku' | 'sonnet' | 'opus', string>> = { - __proto__: null, - haiku: 'claude-haiku-4-5', - sonnet: 'claude-sonnet-4-6', - opus: 'claude-opus-4-8', -} as Readonly<Record<'haiku' | 'sonnet' | 'opus', string>> +export const TIER_MODEL: Readonly<Record<'haiku' | 'opus' | 'sonnet', string>> = + { + __proto__: null, + haiku: 'claude-haiku-4-5', + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-8', + } as Readonly<Record<'haiku' | 'opus' | 'sonnet', string>> /** - * Pick the highest tier present in a per-file batch's rule set. Returns a - * tier label; the caller resolves it to a model via `TIER_MODEL`. Default - * (no recognized rules in batch) is `sonnet` — the historical baseline. + * Pick the highest tier present in a per-file batch's rule set. Returns a tier + * label; the caller resolves it to a model via `TIER_MODEL`. Default (no + * recognized rules in batch) is `sonnet` — the historical baseline. * - * `ruleIds` is a concrete array (not `Iterable<string>`) so the loop can - * use the cached-length for-loop idiom the fleet's `prefer-cached-for-loop` - * lint rule enforces. Callers in cli.mts already build a string[] via + * `ruleIds` is a concrete array (not `Iterable<string>`) so the loop can use + * the cached-length for-loop idiom the fleet's `prefer-cached-for-loop` lint + * rule enforces. Callers in cli.mts already build a string[] via * `findings.map(f => f.ruleId).filter(...)`. */ export function escalateTier( ruleIds: readonly string[], -): 'haiku' | 'sonnet' | 'opus' { - let highest: 'haiku' | 'sonnet' | 'opus' = 'haiku' +): 'haiku' | 'opus' | 'sonnet' { + let highest: 'haiku' | 'opus' | 'sonnet' = 'haiku' let sawAny = false for (let i = 0, { length } = ruleIds; i < length; i += 1) { const tier = RULE_MODEL_TIER[ruleIds[i]!] diff --git a/scripts/audit-transcript.mts b/scripts/audit-transcript.mts index 64ef01b..24312af 100644 --- a/scripts/audit-transcript.mts +++ b/scripts/audit-transcript.mts @@ -89,21 +89,20 @@ function readToolUses(transcriptPath: string): ToolUseEvent[] { } /** - * Walk a shell command's parsed tokens and return the args of each - * invocation whose leading tokens match `cmdLine` (e.g. `['sudo']`, - * `['gh', 'auth', 'refresh']`). Returns an empty array when no - * invocation matches. + * Walk a shell command's parsed tokens and return the args of each invocation + * whose leading tokens match `cmdLine` (e.g. `['sudo']`, `['gh', 'auth', + * 'refresh']`). Returns an empty array when no invocation matches. * - * Will be lifted to `@socketsecurity/lib-stable/shell/parse` in the next - * lib bump (the exports are already on socket-lib's `src/` but haven't - * shipped yet). Keep this inline copy until the cascade can pin the new - * lib version; remove it then. + * Will be lifted to `@socketsecurity/lib-stable/shell/parse` in the next lib + * bump (the exports are already on socket-lib's `src/` but haven't shipped + * yet). Keep this inline copy until the cascade can pin the new lib version; + * remove it then. * - * Uses the AST-based `parseShell` (wraps `shell-quote`) so the matcher - * sees actual invocations only, not embedded args (`echo "sudo foo"`), - * variable substitutions (`$gh`), or command substitution (`$(...)`). - * Treats `&&`, `;`, `||`, `|` as segment terminators so chained - * commands each get their own scan. + * Uses the AST-based `parseShell` (wraps `shell-quote`) so the matcher sees + * actual invocations only, not embedded args (`echo "sudo foo"`), variable + * substitutions (`$gh`), or command substitution (`$(...)`). Treats `&&`, `;`, + * `||`, `|` as segment terminators so chained commands each get their own + * scan. */ function findInvocations( command: string, @@ -144,14 +143,11 @@ function findInvocations( } /** - * Convenience: does `command` contain at least one invocation of - * `cmdLine`? Equivalent to `findInvocations(command, cmdLine).length > - * 0`. The most common audit-pattern shape. + * Convenience: does `command` contain at least one invocation of `cmdLine`? + * Equivalent to `findInvocations(command, cmdLine).length > 0`. The most common + * audit-pattern shape. */ -function commandInvokes( - command: string, - cmdLine: readonly string[], -): boolean { +function commandInvokes(command: string, cmdLine: readonly string[]): boolean { return findInvocations(command, cmdLine).length > 0 } diff --git a/scripts/check.mts b/scripts/check.mts index ebe8c38..21d69ea 100644 --- a/scripts/check.mts +++ b/scripts/check.mts @@ -11,7 +11,7 @@ // prefer-async-spawn: sync-required — top-level CLI runner; entire // flow is sequential gate-running with exit-code aggregation. -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' const args = process.argv.slice(2) @@ -31,7 +31,7 @@ const steps: Array<() => boolean> = [ () => run('node', ['scripts/lint.mts', ...forwardedArgs]), () => run('pnpm', ['exec', 'tsgo', '--noEmit', '-p', 'tsconfig.check.json']), // Path-hygiene check (1 path, 1 reference). Mantra-driven gate; - // see .claude/skills/path-guard/ + .claude/hooks/path-guard/. + // see .claude/skills/path-guard/ + .claude/hooks/fleet/path-guard/. () => run('node', ['scripts/check-paths.mts', '--quiet']), // Lock-step reference hygiene. Opt-in gate that exits clean when // .config/lock-step-refs.json is absent; for repos that ship @@ -47,7 +47,7 @@ const steps: Array<() => boolean> = [ // docs/claude.md/fleet/parser-comments.md §7. () => run('node', ['scripts/check-lock-step-header.mts', '--quiet']), // Soak-exclude date-annotation gate — pairs with - // .claude/hooks/soak-exclude-date-annotation-guard/. Catches + // .claude/hooks/fleet/soak-exclude-date-annotation-guard/. Catches // pnpm-workspace.yaml `minimumReleaseAgeExclude` entries that landed // via non-Claude paths without the canonical // `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotation. diff --git a/scripts/clean.mts b/scripts/clean.mts index f952e2f..42641ec 100644 --- a/scripts/clean.mts +++ b/scripts/clean.mts @@ -11,8 +11,8 @@ import { glob } from 'node:fs/promises' import path from 'node:path' -import { safeDelete } from '@socketsecurity/lib/fs' -import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { safeDelete } from '@socketsecurity/lib/fs/safe' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' import { REPO_ROOT } from './paths.mts' diff --git a/scripts/install-claude-plugins.mts b/scripts/install-claude-plugins.mts index e16de99..bad0a52 100644 --- a/scripts/install-claude-plugins.mts +++ b/scripts/install-claude-plugins.mts @@ -21,9 +21,9 @@ * keep a dev-source override; let them remove it explicitly. Idempotent — * running twice in a row is a no-op. Designed for `pnpm setup` wiring in * every fleet repo. Pin discipline is enforced by - * `.claude/hooks/fleet/marketplace-comment-guard/`: every `plugins[].source.sha` - * in `marketplace.json` must have a row in `.claude-plugin/README.md` with - * matching version + sha + ISO date. + * `.claude/hooks/fleet/marketplace-comment-guard/`: every + * `plugins[].source.sha` in `marketplace.json` must have a row in + * `.claude-plugin/README.md` with matching version + sha + ISO date. */ import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' diff --git a/scripts/lib/error-utils.mts b/scripts/lib/error-utils.mts index 3ded0a7..a93d433 100644 --- a/scripts/lib/error-utils.mts +++ b/scripts/lib/error-utils.mts @@ -5,10 +5,8 @@ * package's "always return a non-empty string" contract for `errorStack`. */ -import { - errorMessage as libErrorMessage, - errorStack as libErrorStack, -} from '@socketsecurity/lib/errors' +import { errorMessage as libErrorMessage } from '@socketsecurity/lib/errors/message' +import { errorStack as libErrorStack } from '@socketsecurity/lib/errors/stack' export const errorMessage = libErrorMessage diff --git a/scripts/lint.mts b/scripts/lint.mts index 9dd8025..e11ab05 100644 --- a/scripts/lint.mts +++ b/scripts/lint.mts @@ -17,12 +17,12 @@ // prefer-async-spawn: sync-required — top-level CLI runner; entire // flow is sync (sequential gates, exit-code aggregation). -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import type { SpawnSyncOptions } from 'node:child_process' import { existsSync } from 'node:fs' import path from 'node:path' import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' const logger = getDefaultLogger() @@ -35,6 +35,11 @@ const mode: 'staged' | 'all' | 'modified' = args.includes('--all') const fix = args.includes('--fix') const quiet = args.includes('--quiet') || args.includes('--silent') const stdio: SpawnSyncOptions['stdio'] = quiet ? 'pipe' : 'inherit' +// On Windows, `pnpm` is a .cmd shim that Node refuses to exec directly +// via spawnSync (CVE-2024-27980 hardening). The shell wrapper resolves +// the shim; on POSIX we keep direct invocation so no shell-quoting +// surface is introduced. +const useShell = process.platform === 'win32' const LINTABLE_EXTS = new Set(['.cjs', '.cts', '.js', '.mjs', '.mts', '.ts']) @@ -58,8 +63,8 @@ function gitFiles(args: string[]): string[] { // spawnSync with array args — no shell interpolation, no injection // surface even if a future caller passes data into args. const r = spawnSync('git', args, { - encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + stdioString: true, }) if (r.status !== 0 || typeof r.stdout !== 'string') { return [] @@ -115,8 +120,46 @@ function filterLintable(files: string[]): string[] { // `template/` and once in `.claude/`. const DOGFOOD_LINT_PATHS = ['.config/oxlint-plugin', 'template'] +// Markdown lint pass — gated behind LINT_MARKDOWN=1 so existing fleet +// repos with pre-existing markdown hygiene findings aren't blocked +// until they've cleaned up. Operates over the markdownlint-cli2 config +// at .config/.markdownlint-cli2.jsonc, which scopes globs + ignores +// and registers the three fleet custom rules +// (socket-no-private-wheelhouse-leak, socket-no-relative-sibling- +// script, socket-readme-required-sections). When the env var is unset +// the function is a no-op and returns 0. +// +// Scope choice: markdown lint always runs over the whole tree (the +// canonical config's globs/ignores decide the scope, not the script). +// Per-file invocation would require pre-filtering for the same globs + +// is slower for the small overall file count typical in fleet repos. +function runMarkdown(): number { + if (process.env['LINT_MARKDOWN'] !== '1') { + return 0 + } + if (!existsSync('.config/.markdownlint-cli2.jsonc')) { + log('Skipping markdownlint: .config/.markdownlint-cli2.jsonc absent.') + return 0 + } + log('Running markdownlint-cli2…') + const mdArgs = [ + 'exec', + 'markdownlint-cli2', + '--config', + '.config/.markdownlint-cli2.jsonc', + ] + if (fix) { + mdArgs.push('--fix') + } + const mdRes = spawnSync('pnpm', mdArgs, { shell: useShell, stdio }) + if (mdRes.status !== 0) { + return 1 + } + return 0 +} + function runAll(): number { - log('Formatting all files...') + log('Formatting all files…') // spawnSync with array args, no shell interpolation. Matches the // socket/prefer-spawn-over-execsync rule: shell-string execSync is // banned because every interpolated value is a potential injection @@ -131,16 +174,16 @@ function runAll(): number { fix ? '--write' : '--check', '.', ] - const fmtRes = spawnSync('pnpm', oxfmtArgs, { stdio }) + const fmtRes = spawnSync('pnpm', oxfmtArgs, { shell: useShell, stdio }) if (fmtRes.status !== 0) { return 1 } - log('Running oxlint on all files...') + log('Running oxlint on all files…') const oxlintArgs = ['exec', 'oxlint', '-c', '.config/oxlintrc.json'] if (fix) { oxlintArgs.push('--fix') } - const lintRes = spawnSync('pnpm', oxlintArgs, { stdio }) + const lintRes = spawnSync('pnpm', oxlintArgs, { shell: useShell, stdio }) if (lintRes.status !== 0) { return 1 } @@ -160,7 +203,7 @@ function runAll(): number { // to opt in. if (process.env['LINT_DOGFOOD'] === '1') { if (!quiet) { - logger.log('Running oxlint on wheelhouse-self dogfood paths...') + logger.log('Running oxlint on wheelhouse-self dogfood paths…') } for (let i = 0, { length } = DOGFOOD_LINT_PATHS; i < length; i += 1) { const dogfoodPath = DOGFOOD_LINT_PATHS[i]! @@ -176,12 +219,16 @@ function runAll(): number { args.push('--fix') } args.push(dogfoodPath) - const r = spawnSync('pnpm', args, { stdio }) + const r = spawnSync('pnpm', args, { shell: useShell, stdio }) if (r.status !== 0) { return 1 } } } + const mdStatus = runMarkdown() + if (mdStatus !== 0) { + return mdStatus + } return 0 } @@ -202,7 +249,7 @@ function runFiles(files: string[]): number { '--no-error-on-unmatched-pattern', ...files, ] - const fmtRes = spawnSync('pnpm', oxfmtArgs, { stdio }) + const fmtRes = spawnSync('pnpm', oxfmtArgs, { shell: useShell, stdio }) if (fmtRes.status !== 0) { return 1 } @@ -223,10 +270,21 @@ function runFiles(files: string[]): number { oxlintArgs.push('--fix') } oxlintArgs.push(...files) - const lintRes = spawnSync('pnpm', oxlintArgs, { stdio }) + const lintRes = spawnSync('pnpm', oxlintArgs, { shell: useShell, stdio }) if (lintRes.status !== 0) { return 1 } + // Markdown lint when any of the changed files is .md / .mdx. The + // markdownlint-cli2 config picks its own scope from globs; we just + // gate on whether to invoke at all so unrelated edits don't pay the + // markdownlint startup cost. + const touchedMarkdown = files.some(f => /\.(?:md|mdx)$/i.test(f)) + if (touchedMarkdown) { + const mdStatus = runMarkdown() + if (mdStatus !== 0) { + return mdStatus + } + } return 0 } diff --git a/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs b/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs index b04b243..9caa116 100644 --- a/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs +++ b/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs @@ -11,29 +11,29 @@ // in install-claude-plugins.mts copies this file into the cache before applying // the diff. Provenance + lifecycle: docs/claude.md/fleet/plugin-cache-patches.md. -import fs from "node:fs"; +import fs from 'node:fs' export function readStdinSync() { - const chunks = []; - const buf = Buffer.alloc(65536); + const chunks = [] + const buf = Buffer.alloc(65536) for (;;) { - let bytesRead; + let bytesRead try { - bytesRead = fs.readSync(0, buf, 0, buf.length, null); + bytesRead = fs.readSync(0, buf, 0, buf.length, null) } catch (e) { - if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); - continue; + if (e && (e.code === 'EAGAIN' || e.code === 'EWOULDBLOCK')) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2) + continue } - if (e && e.code === "EOF") { - break; + if (e && e.code === 'EOF') { + break } - throw e; + throw e } if (bytesRead === 0) { - break; + break } - chunks.push(Buffer.from(buf.subarray(0, bytesRead))); + chunks.push(Buffer.from(buf.subarray(0, bytesRead))) } - return Buffer.concat(chunks).toString("utf8"); + return Buffer.concat(chunks).toString('utf8') } diff --git a/scripts/publish-shared.mts b/scripts/publish-shared.mts new file mode 100644 index 0000000..020d11f --- /dev/null +++ b/scripts/publish-shared.mts @@ -0,0 +1,253 @@ +/** + * @file Shared helpers for fleet-canonical publish scripts. Used by + * `publish.mts` (npm + staged) and `publish-release.mts` (GitHub Release + + * checksums). Helpers below cover process spawning, git introspection, and + * npm-registry queries. Lives in `scripts/` (not `scripts/lib/`) because the + * fleet's convention puts thin helpers next to the scripts that consume them. + * `scripts/lib/` is reserved for substantial libraries that warrant their own + * directory (none exist today). + */ + +// oxlint-disable-next-line socket/prefer-async-spawn -- streaming +// stdio required to forward `pnpm stage approve` 2FA prompts + +// `gh release create` upload progress. lib/spawn returns a Promise +// that resolves only on exit; here we need the live ChildProcess +// stream. +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import process from 'node:process' + +const WIN32 = process.platform === 'win32' + +/** + * Spawn a command and forward stdio (interactive). Returns the exit code. Used + * when the user needs to see / interact with the live output stream + * (publish/approve prompts, gh upload progress). + */ +export function runInherit( + cmd: string, + args: string[], + cwd: string, +): Promise<number> { + return new Promise((resolve, reject) => { + const { process: child } = spawn(cmd, args, { + cwd, + shell: WIN32, + stdio: 'inherit', + }) + child.on('error', reject) + child.on('exit', code => { + resolve(code ?? 0) + }) + }) +} + +/** + * Spawn a command and capture stdout. Stderr goes to the parent process's + * stderr so error messages stay visible. Returns the collected stdout + exit + * code. Used for one-shot queries (git, npm view, pnpm stage list --json). + */ +export function runCapture( + cmd: string, + args: string[], + cwd: string, +): Promise<{ stdout: string; code: number }> { + return new Promise((resolve, reject) => { + const { process: child } = spawn(cmd, args, { + cwd, + shell: WIN32, + stdio: ['ignore', 'pipe', 'inherit'], + }) + let stdout = '' + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + }) + child.on('error', reject) + child.on('exit', code => { + resolve({ stdout, code: code ?? 0 }) + }) + }) +} + +/** + * Resolve `git rev-parse --short HEAD`. Returns the literal string `unknown` + * when git fails (detached worktree, missing git, etc.) — callers that need a + * guaranteed-valid SHA should check for that. + */ +export async function gitShortSha(cwd: string): Promise<string> { + const { stdout, code } = await runCapture( + 'git', + ['rev-parse', '--short', 'HEAD'], + cwd, + ) + if (code !== 0) { + return 'unknown' + } + return stdout.trim() +} + +/** + * `npm view <name>@<version> version` exits 0 iff the version exists on the + * registry. Faster than fetching the full packument for a yes/no check. + */ +export async function isAlreadyPublished( + name: string, + version: string, + cwd: string, +): Promise<boolean> { + const { code } = await runCapture( + 'npm', + ['view', `${name}@${version}`, 'version'], + cwd, + ) + return code === 0 +} + +/** + * Extract the first balanced top-level `{ … }` JSON object from a + * possibly-noisy stdout stream (pnpm wraps JSON output in progress lines that + * aren't valid JSON themselves). Returns undefined if no balanced object + * found. + * + * Used by publish.mts to parse `pnpm stage list --json`. + */ +export function extractFirstJson(text: string): string | undefined { + const startIdx = text.indexOf('{') + if (startIdx === -1) { + return undefined + } + let depth = 0 + let inString = false + let escape = false + for (let i = startIdx, { length } = text; i < length; i += 1) { + const ch = text[i]! + if (escape) { + escape = false + continue + } + if (ch === '\\') { + escape = true + continue + } + if (ch === '"') { + inString = !inString + continue + } + if (inString) { + continue + } + if (ch === '{') { + depth += 1 + } else if (ch === '}') { + depth -= 1 + if (depth === 0) { + return text.slice(startIdx, i + 1) + } + } + } + return undefined +} + +/** + * Subset of `https://registry.npmjs.org/<name>` packument fields the fleet's + * publish scripts care about. The full shape is much larger; we project to what + * we use so callers don't have to know the rest. + */ +export interface RegistryVersionInfo { + /** + * `_npmUser.trustedPublisher` — set when the version was uploaded via OIDC + * trusted publisher (GitHub Actions). Omit when classic token was used. + */ + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined + /** + * `dist.attestations` — present when the upload included npm provenance + * (`--provenance` flag). The URL fetches the SLSA provenance bundle. + */ + attestations?: + | { + url: string + provenance: { predicateType: string } + } + | undefined +} + +/** + * Fetch a package's registry packument and return the per-version trust + * metadata. Returns `{}` for any package that isn't on the registry (or that + * the fetch itself failed for). + * + * The npm registry exposes two packument formats: + * + * - Full (~100KB+): includes per-version `_npmUser.trustedPublisher` (OIDC + * trusted-publisher attribution) AND `dist.attestations` (SLSA provenance + * bundle URL). + * - Abbreviated (~10-20KB, Accept: application/vnd.npm.install-v1+json): drops + * `_npmUser` but keeps `dist.attestations`. + * + * Callers pick: `'abbreviated'` for cheap attestation-only checks (Stop-hook, + * approve-flow enrich), `'full'` for audits that need to confirm + * trusted-publisher attribution (check-provenance.mts). + * + * Use this from `check-provenance.mts` (CLI audit), the approve flow (show + * prior-version status), and the Stop-hook (verify a freshly- bumped version + * landed with provenance). + */ +export async function fetchVersionTrustInfo( + name: string, + variant: 'abbreviated' | 'full' = 'abbreviated', +): Promise<Record<string, RegistryVersionInfo>> { + const url = `https://registry.npmjs.org/${encodeURIComponent(name).replace('%40', '@')}` + let json: { + versions?: + | Record< + string, + { + dist?: + | { + attestations?: + | { + url: string + provenance: { predicateType: string } + } + | undefined + } + | undefined + _npmUser?: + | { + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined + } + | undefined + } + > + | undefined + } + try { + const headers: Record<string, string> = + variant === 'abbreviated' + ? { accept: 'application/vnd.npm.install-v1+json' } + : { accept: 'application/json' } + // socket-hook: allow global-fetch -- publish tooling probes the npm registry directly; the lib http-request helper isn't a dependency here. + const response = await fetch(url, { headers }) + if (!response.ok) { + return {} + } + json = (await response.json()) as typeof json + } catch { + return {} + } + const result: Record<string, RegistryVersionInfo> = {} + for (const [version, info] of Object.entries(json.versions ?? {})) { + result[version] = { + ...(info._npmUser?.trustedPublisher + ? { trustedPublisher: info._npmUser.trustedPublisher } + : {}), + ...(info.dist?.attestations + ? { attestations: info.dist.attestations } + : {}), + } + } + return result +} diff --git a/scripts/publish.mts b/scripts/publish.mts index 251012b..b383b93 100644 --- a/scripts/publish.mts +++ b/scripts/publish.mts @@ -337,7 +337,7 @@ async function fetchPriorProvenanceMap( [...uniqueNames].map(async name => { const versions = await fetchVersionTrustInfo(name, 'abbreviated') const hasAnyAttestation = Object.values(versions).some( - v => !!v.attestations, + v => !!(v as { attestations?: unknown }).attestations, ) result.set(name, hasAnyAttestation) }), diff --git a/scripts/source-allowlist.mts b/scripts/source-allowlist.mts index 2110f49..bd9a512 100644 --- a/scripts/source-allowlist.mts +++ b/scripts/source-allowlist.mts @@ -14,10 +14,8 @@ */ import { PACK_APP_TRIPLETS } from './util/pack-app-triplets.mts' -import { - buildAttestationSubject, - type SourceAllowlistEntry, -} from './util/source-allowlist.mts' +import { buildAttestationSubject } from './util/source-allowlist.mts' +import type { SourceAllowlistEntry } from './util/source-allowlist.mts' /** * Every authorized cross-org publish for `@socketbin/*` tail packages. diff --git a/scripts/test.mts b/scripts/test.mts index 070b8d2..724817b 100644 --- a/scripts/test.mts +++ b/scripts/test.mts @@ -1,30 +1,34 @@ /* eslint-disable no-shadow -- nested cached-length for-loops intentionally reuse `i`/`length` names for the fleet-wide cached-loop idiom; renaming would diverge from the codebase pattern. */ /** - * @file Canonical minimal test runner for socket-* repos. Scope modes: - * (default) Run tests covering files modified in the working tree vs HEAD. - * --staged Run tests covering files in the git index (pre-commit hook). --all - * Run the full test suite. Flags: --quiet Suppress progress output. - * Scope-to-tests mapping (adapt per repo layout): + * @file Canonical minimal test runner for socket-* repos. Delegates the + * scope-to-tests mapping to vitest itself rather than rolling a basename- + * based mapper that would inevitably drift from the actual module graph. + * Scope modes: * - * - Changed test files run themselves. - * - Changed source files under `packages/<pkg>/src/` run the sibling - * `packages/<pkg>/test/` folder. Non-workspace repos can adapt the - * resolveTestPatterns() function to their layout (e.g. single src/ + test/ - * at root, or tests colocated with source). - * - Config / infrastructure changes escalate to the full suite. This is the - * minimal zero-dependency reference implementation. Larger repos - * (socket-registry, socket-sdk-js, socket-packageurl-js, etc.) use a richer - * version; this one keeps the same CLI contract so pre-commit hooks and CI - * work identically across repos. + * - `(default)` — local-dev scope. Runs `vitest --changed`, vitest's + * compare-vs-HEAD-with-uncommitted mode. Walks the actual import graph so a + * change to a util shared by many tests runs every affected test file, not + * the union of two guesses. + * - `--staged` — pre-commit hook scope. Hands `git diff --cached` filenames to + * `vitest related <files…> --run`. Same module-graph walk, but rooted at + * the staged delta. The `--run` flag is mandatory: `vitest related` + * defaults to watch mode just like the bare `vitest` invocation, which + * would hang the pre-commit hook. + * - `--all` — run the full suite (`vitest run`). Used in CI and on explicit + * opt-in. Flags: `--quiet` / `--silent` suppress progress output. Config / + * infrastructure changes (`vitest.config*`, `tsconfig*`, `.oxlintrc.json`, + * `.oxfmtrc.json`, `pnpm-lock.yaml`, `package.json`, anything under + * `.config/` or `scripts/`) still escalate to `all` — module-graph + * traversal doesn't capture config-derived discovery + alias changes. See + * https://vitest.dev/guide/cli.html#vitest-related. */ // prefer-async-spawn: sync-required — top-level CLI runner; entire // flow is sync (test runner invocation + exit-code aggregation). -import { spawnSync } from '@socketsecurity/lib-stable/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import type { SpawnSyncOptions } from 'node:child_process' -import { existsSync } from 'node:fs' import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' const logger = getDefaultLogger() @@ -36,6 +40,10 @@ const mode: 'staged' | 'all' | 'modified' = args.includes('--all') : 'modified' const quiet = args.includes('--quiet') || args.includes('--silent') const stdio: SpawnSyncOptions['stdio'] = quiet ? 'pipe' : 'inherit' +// On Windows, `pnpm` is a .cmd shim that Node refuses to exec directly via +// spawnSync (CVE-2024-27980 hardening). Wrap through the shell on Windows +// only; POSIX keeps direct invocation. +const useShell = process.platform === 'win32' // Paths that, when changed, force the full suite to run. const ESCALATION_PATTERNS = [ @@ -45,7 +53,7 @@ const ESCALATION_PATTERNS = [ /^tsconfig.*\.json$/, /^\.oxlintrc\.json$/, /^\.oxfmtrc\.json$/, - /^vitest\.config\.(js|mjs|mts|ts)$/, + /^vitest\.config\.(?:js|mjs|mts|ts)$/, /^package\.json$/, /^lockstep\.schema\.json$/, ] @@ -60,8 +68,8 @@ function gitFiles(args: string[]): string[] { // spawnSync with array args — no shell interpolation. Matches the // socket/prefer-spawn-over-execsync rule contract. const r = spawnSync('git', args, { - encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + stdioString: true, }) if (r.status !== 0 || typeof r.stdout !== 'string') { return [] @@ -93,45 +101,13 @@ function shouldEscalate(files: string[]): boolean { return false } -/** - * Map changed files to vitest test patterns. - * - * Default implementation handles two common layouts: - * - * - Pnpm workspace: packages/<pkg>/src/... → packages/<pkg>/test - * - Single repo: src/... → test Adapt to your repo's layout if different. - */ -function resolveTestPatterns(files: string[]): string[] { - const patterns = new Set<string>() - for (let i = 0, { length } = files; i < length; i += 1) { - const f = files[i]! - // Test file itself. - if (/\.test\.(m?[jt]s)$/.test(f)) { - patterns.add(f) - continue - } - // Workspace source file. Only emit the pattern if the test dir exists; - // packages without a test/ directory are skipped rather than making - // vitest error on an unknown pattern. - const wsMatch = f.match(/^(packages\/[^/]+)\/src\//) - if (wsMatch && existsSync(`${wsMatch[1]}/test`)) { - patterns.add(`${wsMatch[1]}/test`) - continue - } - // Single-repo source file. - if (f.startsWith('src/') && existsSync('test')) { - patterns.add('test') - } - } - return [...patterns] -} - -function runAll(): number { - log('Test scope: all') +function runVitest(vitestArgs: string[], label: string): number { + log(`Test scope: ${label}`) const r = spawnSync( 'pnpm', - ['exec', 'vitest', 'run', '--config', '.config/vitest.config.mts'], - { stdio }, + ['exec', 'vitest', ...vitestArgs, '--config', '.config/vitest.config.mts'], + // Windows shell-shim rationale: see useShell at file top. + { shell: useShell, stdio }, ) if (r.status !== 0) { log('Tests failed') @@ -141,36 +117,26 @@ function runAll(): number { return 0 } -function runPatterns(patterns: string[]): number { - if (patterns.length === 0) { - log('No tests to run; skipping.') - return 0 - } - log(`Test scope: ${mode} (${patterns.length} pattern(s))`) - // --passWithNoTests: if a pattern produces zero matches (e.g. a freshly - // added package with an empty test dir, or a source change that doesn't - // touch any testable code), vitest treats it as success rather than a - // "no test files found" error. Scoped-by-default runs shouldn't fail - // just because the change didn't happen to touch a testable file. - const r = spawnSync( - 'pnpm', - [ - 'exec', - 'vitest', - 'run', - '--config', - '.config/vitest.config.mts', - '--passWithNoTests', - ...patterns, - ], - { stdio }, +function runAll(): number { + return runVitest(['run'], 'all') +} + +// --passWithNoTests: a scoped run where the changed files don't resolve +// to any test file should succeed rather than error with "No test files +// found". Keeps pre-commit hooks passing when an edit touches only +// non-testable code. +function runChanged(): number { + return runVitest(['run', '--changed', '--passWithNoTests'], 'changed') +} + +function runRelated(files: string[]): number { + // `vitest related <files…>` defaults to watch mode; `--run` forces a + // single non-watch execution. Pass the staged file list as positionals; + // vitest walks the module graph from each. + return runVitest( + ['related', ...files, '--run', '--passWithNoTests'], + `staged (${files.length} file(s))`, ) - if (r.status !== 0) { - log('Tests failed') - return 1 - } - log('All tests passed') - return 0 } function main(): void { @@ -192,8 +158,14 @@ function main(): void { return } - const patterns = resolveTestPatterns(files) - process.exitCode = runPatterns(patterns) + if (mode === 'staged') { + process.exitCode = runRelated(files) + return + } + + // Working-tree changed → vitest's native --changed (it re-detects the + // file list via git itself, including uncommitted edits). + process.exitCode = runChanged() } main() diff --git a/scripts/validate-esbuild-minify.mts b/scripts/validate-esbuild-minify.mts index 1583fcf..67f1095 100644 --- a/scripts/validate-esbuild-minify.mts +++ b/scripts/validate-esbuild-minify.mts @@ -8,7 +8,8 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { errorMessage } from '@socketsecurity/lib-stable/errors' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' const logger = getDefaultLogger() @@ -60,9 +61,7 @@ async function validateEsbuildMinify(): Promise<MinifyViolation[]> { return violations } catch (e) { - logger.error( - `Failed to load esbuild config: ${e instanceof Error ? e.message : String(e)}`, - ) + logger.error(`Failed to load esbuild config: ${errorMessage(e)}`) process.exitCode = 1 return [] } diff --git a/scripts/validate-file-count.mts b/scripts/validate-file-count.mts index b07bf86..cab4223 100644 --- a/scripts/validate-file-count.mts +++ b/scripts/validate-file-count.mts @@ -13,7 +13,8 @@ import process from 'node:process' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { errorMessage } from '@socketsecurity/lib-stable/errors' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' const logger = getDefaultLogger() const execAsync = promisify(exec) @@ -112,9 +113,7 @@ async function main(): Promise<void> { process.exitCode = 1 } catch (e) { - logger.fail( - `Validation failed: ${e instanceof Error ? e.message : String(e)}`, - ) + logger.fail(`Validation failed: ${errorMessage(e)}`) process.exitCode = 1 } } diff --git a/scripts/validate-file-size.mts b/scripts/validate-file-size.mts index 5d10e32..a53beb6 100644 --- a/scripts/validate-file-size.mts +++ b/scripts/validate-file-size.mts @@ -27,13 +27,13 @@ const MAX_FILE_SIZE = 2 * 1024 * 1024 // the upstream they ship, not by repo authoring. acorn.wasm is the AST // parser shared by AST-based oxlint plugin rules + hooks; its ~3MB is the // upstream build artifact. Two paths because socket-lib vendors its own -// copy at vendor/acorn-wasm/ (so the lib package's own AST helpers can +// copy at vendor/acorn/ (so the lib package's own AST helpers can // load without a node_modules round-trip). Adding a path here is // intentional — it should only happen for files the fleet jointly owns, // not per-repo binary leaks. const ALLOWED_LARGE_FILES = new Set<string>([ '.claude/hooks/fleet/_shared/acorn/acorn.wasm', - 'vendor/acorn-wasm/acorn.wasm', + 'vendor/acorn/acorn.wasm', ]) // Directories to skip diff --git a/scripts/validate-no-link-deps.mts b/scripts/validate-no-link-deps.mts index 48f41aa..40b31e2 100644 --- a/scripts/validate-no-link-deps.mts +++ b/scripts/validate-no-link-deps.mts @@ -9,7 +9,7 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' const logger = getDefaultLogger() From d1106c54a24809b12e6ea12522094c61d5af107c Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Fri, 29 May 2026 02:18:59 -0400 Subject: [PATCH 13/17] fix(deps): add missing root deps for hook subtree tsgo resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit socket-bin's tsgo --noEmit -p tsconfig.check.json covers `.claude/hooks/**/*.mts` (per the wheelhouse-canonical tsconfig.check.json shape), but the root package.json was missing three deps that hook subtrees import via the root resolution chain: - @socketsecurity/sdk-stable (used by check-new-deps hook) - @types/shell-quote (used by _shared/shell-command.mts) - compromise (used by judgment-reminder hook) - shell-quote (used by _shared/shell-command.mts) Adds the four to the root devDependencies (catalog: form for the three already in the catalog; compromise gets a new catalog entry + soak-exclude annotation since 14.15.1 is within the 7-day soak window). Also adds the missing canonical soak-bypass date annotations for nock@15.0.0-beta.11 and npm-run-all2@9.0.0 that were flagged by check-soak-exclude-dates. Per-hook package.json files already declare these deps; the symlinks in their respective `node_modules` are correct. But tsgo's bundler-style resolver from the root tsconfig walks up from each hook file and lands at the root node_modules, which previously had no @types/shell-quote etc. — hence the TS2307 module-not-found errors. With the root deps in place, tsgo resolves cleanly. Verified: `pnpm install` clean, `pnpm run check` passes. --- package.json | 4 +++ pnpm-lock.yaml | 68 +++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 5 ++++ 3 files changed, 77 insertions(+) diff --git a/package.json b/package.json index 60f8265..dbb4e27 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,16 @@ "@socketregistry/packageurl-js-stable": "catalog:", "@socketsecurity/lib": "catalog:", "@socketsecurity/lib-stable": "catalog:", + "@socketsecurity/sdk-stable": "catalog:", "@types/node": "catalog:", + "@types/shell-quote": "catalog:", "@typescript/native-preview": "catalog:", "build-infra": "workspace:*", + "compromise": "catalog:", "ecc-agentshield": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", + "shell-quote": "catalog:", "taze": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 161614e..9a1c126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,12 +15,21 @@ catalogs: '@socketsecurity/lib-stable': specifier: npm:@socketsecurity/lib@6.0.5 version: 6.0.5 + '@socketsecurity/sdk-stable': + specifier: npm:@socketsecurity/sdk@4.0.1 + version: 4.0.1 '@types/node': specifier: 24.9.2 version: 24.9.2 + '@types/shell-quote': + specifier: 1.7.5 + version: 1.7.5 '@typescript/native-preview': specifier: 7.0.0-dev.20250926.1 version: 7.0.0-dev.20250926.1 + compromise: + specifier: 14.15.1 + version: 14.15.1 ecc-agentshield: specifier: 1.4.0 version: 1.4.0 @@ -30,6 +39,9 @@ catalogs: oxlint: specifier: 1.52.0 version: 1.52.0 + shell-quote: + specifier: 1.8.4 + version: 1.8.4 taze: specifier: 19.9.2 version: 19.9.2 @@ -65,15 +77,24 @@ importers: '@socketsecurity/lib-stable': specifier: 'catalog:' version: '@socketsecurity/lib@6.0.5(typescript@5.9.2)' + '@socketsecurity/sdk-stable': + specifier: 'catalog:' + version: '@socketsecurity/sdk@4.0.1' '@types/node': specifier: 'catalog:' version: 24.9.2 + '@types/shell-quote': + specifier: 'catalog:' + version: 1.7.5 '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20250926.1 build-infra: specifier: workspace:* version: link:packages/build-infra + compromise: + specifier: 'catalog:' + version: 14.15.1 ecc-agentshield: specifier: 'catalog:' version: 1.4.0 @@ -83,6 +104,9 @@ importers: oxlint: specifier: 'catalog:' version: 1.52.0 + shell-quote: + specifier: 'catalog:' + version: 1.8.4 taze: specifier: 'catalog:' version: 19.9.2 @@ -681,6 +705,10 @@ packages: typescript: optional: true + '@socketsecurity/sdk@4.0.1': + resolution: {integrity: sha512-fe3DQp2dFwhc0G6Za36GIMSV+QaPAP5L96K3ZOtywt9nhbwxc9IQwqzdOVztdn5Rbez3t9EHU9Esj24/hWdP0g==} + engines: {node: '>=18.20.8', pnpm: '>=11.0.0-rc.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -705,6 +733,9 @@ packages: '@types/node@24.9.2': resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} + '@types/shell-quote@1.7.5': + resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20250926.1': resolution: {integrity: sha512-gnfz/RY+akGaZER1M9h+k8VW3wO4RRBeDHbC7NrDzEDmynOWN5clIBNXew3vMiyPJ31hMh4DdkEk5uXiQCkM4A==} cpu: [arm64] @@ -824,6 +855,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + compromise@14.15.1: + resolution: {integrity: sha512-9F3UkUaEU1PPz2fgStkE/TI4tk++0wHxS8xfWq9PQWL/v28dy8bEcPVVSLh3dISIRD7PEhJ8YTzHRKF8y9tnLA==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -856,6 +891,10 @@ packages: engines: {node: '>=18'} hasBin: true + efrt@2.7.0: + resolution: {integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==} + engines: {node: '>=12.0.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -948,6 +987,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + grad-school@0.0.5: + resolution: {integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==} + engines: {node: '>=8.0.0'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1103,6 +1146,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1120,6 +1167,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + suffix-thumb@5.0.2: + resolution: {integrity: sha512-I5PWXAFKx3FYnI9a+dQMWNqTxoRt6vdBdb0O+BJ1sxXCWtSoQCusc13E58f+9p4MYx/qCnEMkD5jac6K2j3dgA==} + taze@19.9.2: resolution: {integrity: sha512-If8bq7lSckIMPfXV+C9jjEfdsQnRryh/foKfpX/ah6zI0TrQfUGWSGCaaD32Bqy5/KGRmLZie3EwMSr3Au21XQ==} hasBin: true @@ -1576,6 +1626,8 @@ snapshots: optionalDependencies: typescript: 5.9.2 + '@socketsecurity/sdk@4.0.1': {} + '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': @@ -1602,6 +1654,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/shell-quote@1.7.5': {} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20250926.1': optional: true @@ -1709,6 +1763,12 @@ snapshots: commander@13.1.0: {} + compromise@14.15.1: + dependencies: + efrt: 2.7.0 + grad-school: 0.0.5 + suffix-thumb: 5.0.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1742,6 +1802,8 @@ snapshots: transitivePeerDependencies: - encoding + efrt@2.7.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -1858,6 +1920,8 @@ snapshots: gopd@1.2.0: {} + grad-school@0.0.5: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -2041,6 +2105,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.4: {} + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -2051,6 +2117,8 @@ snapshots: std-env@3.10.0: {} + suffix-thumb@5.0.2: {} + taze@19.9.2: dependencies: '@antfu/ni': 27.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 18f1de4..a472142 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,6 +23,7 @@ catalog: '@typescript/native-preview': 7.0.0-dev.20250926.1 '@vitest/coverage-v8': 4.1.6 '@vitest/ui': 4.1.6 + compromise: 14.15.1 ecc-agentshield: 1.4.0 husky: 9.1.7 'mdast-util-from-markdown': 2.0.3 @@ -56,8 +57,12 @@ minimumReleaseAgeExclude: - '@stuie/*' # Network-mocking lib used in fleet test suites. v15 betas pre-date # npm's `time` field for the major; allow pinned beta until v15 GA. + # published: 2025-10-13 | removable: 2025-10-20 - 'nock@15.0.0-beta.11' + # published: 2025-08-04 | removable: 2025-08-11 - 'npm-run-all2@9.0.0' + # published: 2026-05-27 | removable: 2026-06-03 + - 'compromise@14.15.1' - 'shell-quote' - 'vite' - 'vitest' From c6b1793e8d8e968b82174ee6413c8d1c398b54a3 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Mon, 1 Jun 2026 00:45:59 -0400 Subject: [PATCH 14/17] fix(build): migrate pnpm external-tools entry to platforms schema socket-registry moved the pnpm tool entry from a checksums map (asset + raw sha256 hex) to a platforms map (asset + SRI integrity). Align this repo's external-tools.json so consumers reading .pnpm.platforms[...] (the fleet-canonical shape) resolve correctly. Integrity strings decode to the same sha256 the checksums block carried; assets and pinned version are unchanged. --- external-tools.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/external-tools.json b/external-tools.json index 5d824fa..613b8a0 100644 --- a/external-tools.json +++ b/external-tools.json @@ -30,32 +30,32 @@ "notes": [ "Required: yes", "Bootstrap locally via `corepack enable pnpm` (the version resolves from package.json's packageManager field)", - "CI downloads + sha256-verifies the pinned tarball directly" + "CI downloads + integrity-verifies the pinned tarball directly" ], - "checksums": { + "platforms": { "darwin-arm64": { "asset": "pnpm-darwin-arm64.tar.gz", - "sha256": "3620a0fcaf81ecd3aaeccd5965919d90dbc913f4d07a96e11e7cafc2c785054b" + "integrity": "sha256-NiCg/K+B7NOq7M1ZZZGdkNvJE/TQepbhHnyvwseFBUs=" }, "darwin-x64": { "asset": "pnpm-darwin-x64.tar.gz", - "sha256": "1701748b75187f1333a9c616827943ff84ff46cc42becc156ff6864b9bd0f948" + "integrity": "sha256-FwF0i3UYfxMzqcYWgnlD/4T/RsxCvswVb/aGS5vQ+Ug=" }, "linux-arm64": { "asset": "pnpm-linux-arm64.tar.gz", - "sha256": "1e6d87ebfd7ff169966ff5b3ad71b780b883c68d3e59987df1096dfd8853df75" + "integrity": "sha256-Hm2H6/1/8WmWb/WzrXG3gLiDxo0+WZh98Qlt/YhT33U=" }, "linux-x64": { "asset": "pnpm-linux-x64.tar.gz", - "sha256": "9b44acc77ada40fc41b665fde1d57367a5ebec31bd4b1b00598daed195da3e17" + "integrity": "sha256-m0Ssx3raQPxBtmX94dVzZ6Xr7DG9SxsAWY2u0ZXaPhc=" }, "win-arm64": { "asset": "pnpm-win32-arm64.zip", - "sha256": "0746be8e98ca183078d0747559f0cbbd30a13a53eb177f67474eb3c52dc21bc8" + "integrity": "sha256-B0a+jpjKGDB40HR1WfDLvTChOlPrF39nR06zxS3CG8g=" }, "win-x64": { "asset": "pnpm-win32-x64.zip", - "sha256": "581e222e622cd0cc4f0ac5f85dd0db76b65117e3b17507979d89e63fdc68edca" + "integrity": "sha256-WB4iLmIs0MxPCsX4XdDbdrZRF+OxdQeXnYnmP9xo7co=" } } }, From cbdd200c80f5b726d1d9d6720c5c18e91e210536 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Mon, 1 Jun 2026 01:09:39 -0400 Subject: [PATCH 15/17] chore(claude): rehome skills under .claude/skills/fleet/ Move dangling .claude/skills/<name>/ entries into .claude/skills/fleet/<name>/ to match the wheelhouse-canonical layout. Pre-segmentation layout; the cascade now expects every fleet-shared skill to live under fleet/. Rehomed (22): - auditing-gha-settings - cascading-fleet - cleaning-redundant-ci - driving-cursor-bugbot - greening-ci - guarding-paths - handing-off - locking-down-programmatic-claude - plug-leaking-promise-race - prose - refreshing-history - reviewing-code - running-test262 - scanning-quality - scanning-security - squashing-history - trimming-bundle - updating-coverage - updating-lockstep - updating-security - updating - worktree-management --- .claude/skills/{ => fleet}/auditing-gha-settings/SKILL.md | 0 .claude/skills/{ => fleet}/auditing-gha-settings/run.mts | 0 .claude/skills/{ => fleet}/cascading-fleet/SKILL.md | 0 .../skills/{ => fleet}/cascading-fleet/lib/cascade-template.mts | 0 .../skills/{ => fleet}/cascading-fleet/lib/cascade-template.sh | 0 .claude/skills/{ => fleet}/cascading-fleet/lib/fleet-repos.json | 0 .claude/skills/{ => fleet}/cascading-fleet/lib/fleet-repos.txt | 0 .claude/skills/{ => fleet}/cleaning-redundant-ci/SKILL.md | 0 .claude/skills/{ => fleet}/driving-cursor-bugbot/SKILL.md | 0 .claude/skills/{ => fleet}/driving-cursor-bugbot/reference.md | 0 .claude/skills/{ => fleet}/greening-ci/SKILL.md | 0 .claude/skills/{ => fleet}/greening-ci/run.mts | 0 .claude/skills/{ => fleet}/guarding-paths/SKILL.md | 0 .claude/skills/{ => fleet}/guarding-paths/reference.md | 0 .../{ => fleet}/guarding-paths/reference/check-paths.mts.tmpl | 0 .../skills/{ => fleet}/guarding-paths/reference/claude-md-rule.md | 0 .../{ => fleet}/guarding-paths/reference/paths-allowlist.yml.tmpl | 0 .../{ => fleet}/guarding-paths/templates/check-paths.mts.tmpl | 0 .../{ => fleet}/guarding-paths/templates/paths-allowlist.yml.tmpl | 0 .claude/skills/{ => fleet}/handing-off/SKILL.md | 0 .../skills/{ => fleet}/locking-down-programmatic-claude/SKILL.md | 0 .claude/skills/{ => fleet}/plug-leaking-promise-race/SKILL.md | 0 .claude/skills/{ => fleet}/prose/SKILL.md | 0 .claude/skills/{ => fleet}/prose/references/examples.md | 0 .claude/skills/{ => fleet}/prose/references/phrases.md | 0 .claude/skills/{ => fleet}/prose/references/structures.md | 0 .claude/skills/{ => fleet}/refreshing-history/SKILL.md | 0 .claude/skills/{ => fleet}/refreshing-history/run.mts | 0 .claude/skills/{ => fleet}/reviewing-code/SKILL.md | 0 .claude/skills/{ => fleet}/reviewing-code/run.mts | 0 .claude/skills/{ => fleet}/running-test262/SKILL.md | 0 .claude/skills/{ => fleet}/scanning-quality/SKILL.md | 0 .claude/skills/{ => fleet}/scanning-quality/scans/bundle-trim.md | 0 .claude/skills/{ => fleet}/scanning-quality/scans/differential.md | 0 .../{ => fleet}/scanning-quality/scans/insecure-defaults.md | 0 .../skills/{ => fleet}/scanning-quality/scans/variant-analysis.md | 0 .claude/skills/{ => fleet}/scanning-security/SKILL.md | 0 .claude/skills/{ => fleet}/squashing-history/SKILL.md | 0 .claude/skills/{ => fleet}/squashing-history/reference.md | 0 .claude/skills/{ => fleet}/trimming-bundle/SKILL.md | 0 .claude/skills/{ => fleet}/updating-coverage/SKILL.md | 0 .claude/skills/{ => fleet}/updating-lockstep/SKILL.md | 0 .claude/skills/{ => fleet}/updating-lockstep/reference.md | 0 .claude/skills/{ => fleet}/updating-security/SKILL.md | 0 .claude/skills/{ => fleet}/updating-security/reference.md | 0 .claude/skills/{ => fleet}/updating/SKILL.md | 0 .claude/skills/{ => fleet}/updating/reference.md | 0 .claude/skills/{ => fleet}/worktree-management/SKILL.md | 0 48 files changed, 0 insertions(+), 0 deletions(-) rename .claude/skills/{ => fleet}/auditing-gha-settings/SKILL.md (100%) rename .claude/skills/{ => fleet}/auditing-gha-settings/run.mts (100%) rename .claude/skills/{ => fleet}/cascading-fleet/SKILL.md (100%) rename .claude/skills/{ => fleet}/cascading-fleet/lib/cascade-template.mts (100%) rename .claude/skills/{ => fleet}/cascading-fleet/lib/cascade-template.sh (100%) rename .claude/skills/{ => fleet}/cascading-fleet/lib/fleet-repos.json (100%) rename .claude/skills/{ => fleet}/cascading-fleet/lib/fleet-repos.txt (100%) rename .claude/skills/{ => fleet}/cleaning-redundant-ci/SKILL.md (100%) rename .claude/skills/{ => fleet}/driving-cursor-bugbot/SKILL.md (100%) rename .claude/skills/{ => fleet}/driving-cursor-bugbot/reference.md (100%) rename .claude/skills/{ => fleet}/greening-ci/SKILL.md (100%) rename .claude/skills/{ => fleet}/greening-ci/run.mts (100%) rename .claude/skills/{ => fleet}/guarding-paths/SKILL.md (100%) rename .claude/skills/{ => fleet}/guarding-paths/reference.md (100%) rename .claude/skills/{ => fleet}/guarding-paths/reference/check-paths.mts.tmpl (100%) rename .claude/skills/{ => fleet}/guarding-paths/reference/claude-md-rule.md (100%) rename .claude/skills/{ => fleet}/guarding-paths/reference/paths-allowlist.yml.tmpl (100%) rename .claude/skills/{ => fleet}/guarding-paths/templates/check-paths.mts.tmpl (100%) rename .claude/skills/{ => fleet}/guarding-paths/templates/paths-allowlist.yml.tmpl (100%) rename .claude/skills/{ => fleet}/handing-off/SKILL.md (100%) rename .claude/skills/{ => fleet}/locking-down-programmatic-claude/SKILL.md (100%) rename .claude/skills/{ => fleet}/plug-leaking-promise-race/SKILL.md (100%) rename .claude/skills/{ => fleet}/prose/SKILL.md (100%) rename .claude/skills/{ => fleet}/prose/references/examples.md (100%) rename .claude/skills/{ => fleet}/prose/references/phrases.md (100%) rename .claude/skills/{ => fleet}/prose/references/structures.md (100%) rename .claude/skills/{ => fleet}/refreshing-history/SKILL.md (100%) rename .claude/skills/{ => fleet}/refreshing-history/run.mts (100%) rename .claude/skills/{ => fleet}/reviewing-code/SKILL.md (100%) rename .claude/skills/{ => fleet}/reviewing-code/run.mts (100%) rename .claude/skills/{ => fleet}/running-test262/SKILL.md (100%) rename .claude/skills/{ => fleet}/scanning-quality/SKILL.md (100%) rename .claude/skills/{ => fleet}/scanning-quality/scans/bundle-trim.md (100%) rename .claude/skills/{ => fleet}/scanning-quality/scans/differential.md (100%) rename .claude/skills/{ => fleet}/scanning-quality/scans/insecure-defaults.md (100%) rename .claude/skills/{ => fleet}/scanning-quality/scans/variant-analysis.md (100%) rename .claude/skills/{ => fleet}/scanning-security/SKILL.md (100%) rename .claude/skills/{ => fleet}/squashing-history/SKILL.md (100%) rename .claude/skills/{ => fleet}/squashing-history/reference.md (100%) rename .claude/skills/{ => fleet}/trimming-bundle/SKILL.md (100%) rename .claude/skills/{ => fleet}/updating-coverage/SKILL.md (100%) rename .claude/skills/{ => fleet}/updating-lockstep/SKILL.md (100%) rename .claude/skills/{ => fleet}/updating-lockstep/reference.md (100%) rename .claude/skills/{ => fleet}/updating-security/SKILL.md (100%) rename .claude/skills/{ => fleet}/updating-security/reference.md (100%) rename .claude/skills/{ => fleet}/updating/SKILL.md (100%) rename .claude/skills/{ => fleet}/updating/reference.md (100%) rename .claude/skills/{ => fleet}/worktree-management/SKILL.md (100%) diff --git a/.claude/skills/auditing-gha-settings/SKILL.md b/.claude/skills/fleet/auditing-gha-settings/SKILL.md similarity index 100% rename from .claude/skills/auditing-gha-settings/SKILL.md rename to .claude/skills/fleet/auditing-gha-settings/SKILL.md diff --git a/.claude/skills/auditing-gha-settings/run.mts b/.claude/skills/fleet/auditing-gha-settings/run.mts similarity index 100% rename from .claude/skills/auditing-gha-settings/run.mts rename to .claude/skills/fleet/auditing-gha-settings/run.mts diff --git a/.claude/skills/cascading-fleet/SKILL.md b/.claude/skills/fleet/cascading-fleet/SKILL.md similarity index 100% rename from .claude/skills/cascading-fleet/SKILL.md rename to .claude/skills/fleet/cascading-fleet/SKILL.md diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.mts b/.claude/skills/fleet/cascading-fleet/lib/cascade-template.mts similarity index 100% rename from .claude/skills/cascading-fleet/lib/cascade-template.mts rename to .claude/skills/fleet/cascading-fleet/lib/cascade-template.mts diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.sh b/.claude/skills/fleet/cascading-fleet/lib/cascade-template.sh similarity index 100% rename from .claude/skills/cascading-fleet/lib/cascade-template.sh rename to .claude/skills/fleet/cascading-fleet/lib/cascade-template.sh diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.json b/.claude/skills/fleet/cascading-fleet/lib/fleet-repos.json similarity index 100% rename from .claude/skills/cascading-fleet/lib/fleet-repos.json rename to .claude/skills/fleet/cascading-fleet/lib/fleet-repos.json diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.txt b/.claude/skills/fleet/cascading-fleet/lib/fleet-repos.txt similarity index 100% rename from .claude/skills/cascading-fleet/lib/fleet-repos.txt rename to .claude/skills/fleet/cascading-fleet/lib/fleet-repos.txt diff --git a/.claude/skills/cleaning-redundant-ci/SKILL.md b/.claude/skills/fleet/cleaning-redundant-ci/SKILL.md similarity index 100% rename from .claude/skills/cleaning-redundant-ci/SKILL.md rename to .claude/skills/fleet/cleaning-redundant-ci/SKILL.md diff --git a/.claude/skills/driving-cursor-bugbot/SKILL.md b/.claude/skills/fleet/driving-cursor-bugbot/SKILL.md similarity index 100% rename from .claude/skills/driving-cursor-bugbot/SKILL.md rename to .claude/skills/fleet/driving-cursor-bugbot/SKILL.md diff --git a/.claude/skills/driving-cursor-bugbot/reference.md b/.claude/skills/fleet/driving-cursor-bugbot/reference.md similarity index 100% rename from .claude/skills/driving-cursor-bugbot/reference.md rename to .claude/skills/fleet/driving-cursor-bugbot/reference.md diff --git a/.claude/skills/greening-ci/SKILL.md b/.claude/skills/fleet/greening-ci/SKILL.md similarity index 100% rename from .claude/skills/greening-ci/SKILL.md rename to .claude/skills/fleet/greening-ci/SKILL.md diff --git a/.claude/skills/greening-ci/run.mts b/.claude/skills/fleet/greening-ci/run.mts similarity index 100% rename from .claude/skills/greening-ci/run.mts rename to .claude/skills/fleet/greening-ci/run.mts diff --git a/.claude/skills/guarding-paths/SKILL.md b/.claude/skills/fleet/guarding-paths/SKILL.md similarity index 100% rename from .claude/skills/guarding-paths/SKILL.md rename to .claude/skills/fleet/guarding-paths/SKILL.md diff --git a/.claude/skills/guarding-paths/reference.md b/.claude/skills/fleet/guarding-paths/reference.md similarity index 100% rename from .claude/skills/guarding-paths/reference.md rename to .claude/skills/fleet/guarding-paths/reference.md diff --git a/.claude/skills/guarding-paths/reference/check-paths.mts.tmpl b/.claude/skills/fleet/guarding-paths/reference/check-paths.mts.tmpl similarity index 100% rename from .claude/skills/guarding-paths/reference/check-paths.mts.tmpl rename to .claude/skills/fleet/guarding-paths/reference/check-paths.mts.tmpl diff --git a/.claude/skills/guarding-paths/reference/claude-md-rule.md b/.claude/skills/fleet/guarding-paths/reference/claude-md-rule.md similarity index 100% rename from .claude/skills/guarding-paths/reference/claude-md-rule.md rename to .claude/skills/fleet/guarding-paths/reference/claude-md-rule.md diff --git a/.claude/skills/guarding-paths/reference/paths-allowlist.yml.tmpl b/.claude/skills/fleet/guarding-paths/reference/paths-allowlist.yml.tmpl similarity index 100% rename from .claude/skills/guarding-paths/reference/paths-allowlist.yml.tmpl rename to .claude/skills/fleet/guarding-paths/reference/paths-allowlist.yml.tmpl diff --git a/.claude/skills/guarding-paths/templates/check-paths.mts.tmpl b/.claude/skills/fleet/guarding-paths/templates/check-paths.mts.tmpl similarity index 100% rename from .claude/skills/guarding-paths/templates/check-paths.mts.tmpl rename to .claude/skills/fleet/guarding-paths/templates/check-paths.mts.tmpl diff --git a/.claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl b/.claude/skills/fleet/guarding-paths/templates/paths-allowlist.yml.tmpl similarity index 100% rename from .claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl rename to .claude/skills/fleet/guarding-paths/templates/paths-allowlist.yml.tmpl diff --git a/.claude/skills/handing-off/SKILL.md b/.claude/skills/fleet/handing-off/SKILL.md similarity index 100% rename from .claude/skills/handing-off/SKILL.md rename to .claude/skills/fleet/handing-off/SKILL.md diff --git a/.claude/skills/locking-down-programmatic-claude/SKILL.md b/.claude/skills/fleet/locking-down-programmatic-claude/SKILL.md similarity index 100% rename from .claude/skills/locking-down-programmatic-claude/SKILL.md rename to .claude/skills/fleet/locking-down-programmatic-claude/SKILL.md diff --git a/.claude/skills/plug-leaking-promise-race/SKILL.md b/.claude/skills/fleet/plug-leaking-promise-race/SKILL.md similarity index 100% rename from .claude/skills/plug-leaking-promise-race/SKILL.md rename to .claude/skills/fleet/plug-leaking-promise-race/SKILL.md diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/fleet/prose/SKILL.md similarity index 100% rename from .claude/skills/prose/SKILL.md rename to .claude/skills/fleet/prose/SKILL.md diff --git a/.claude/skills/prose/references/examples.md b/.claude/skills/fleet/prose/references/examples.md similarity index 100% rename from .claude/skills/prose/references/examples.md rename to .claude/skills/fleet/prose/references/examples.md diff --git a/.claude/skills/prose/references/phrases.md b/.claude/skills/fleet/prose/references/phrases.md similarity index 100% rename from .claude/skills/prose/references/phrases.md rename to .claude/skills/fleet/prose/references/phrases.md diff --git a/.claude/skills/prose/references/structures.md b/.claude/skills/fleet/prose/references/structures.md similarity index 100% rename from .claude/skills/prose/references/structures.md rename to .claude/skills/fleet/prose/references/structures.md diff --git a/.claude/skills/refreshing-history/SKILL.md b/.claude/skills/fleet/refreshing-history/SKILL.md similarity index 100% rename from .claude/skills/refreshing-history/SKILL.md rename to .claude/skills/fleet/refreshing-history/SKILL.md diff --git a/.claude/skills/refreshing-history/run.mts b/.claude/skills/fleet/refreshing-history/run.mts similarity index 100% rename from .claude/skills/refreshing-history/run.mts rename to .claude/skills/fleet/refreshing-history/run.mts diff --git a/.claude/skills/reviewing-code/SKILL.md b/.claude/skills/fleet/reviewing-code/SKILL.md similarity index 100% rename from .claude/skills/reviewing-code/SKILL.md rename to .claude/skills/fleet/reviewing-code/SKILL.md diff --git a/.claude/skills/reviewing-code/run.mts b/.claude/skills/fleet/reviewing-code/run.mts similarity index 100% rename from .claude/skills/reviewing-code/run.mts rename to .claude/skills/fleet/reviewing-code/run.mts diff --git a/.claude/skills/running-test262/SKILL.md b/.claude/skills/fleet/running-test262/SKILL.md similarity index 100% rename from .claude/skills/running-test262/SKILL.md rename to .claude/skills/fleet/running-test262/SKILL.md diff --git a/.claude/skills/scanning-quality/SKILL.md b/.claude/skills/fleet/scanning-quality/SKILL.md similarity index 100% rename from .claude/skills/scanning-quality/SKILL.md rename to .claude/skills/fleet/scanning-quality/SKILL.md diff --git a/.claude/skills/scanning-quality/scans/bundle-trim.md b/.claude/skills/fleet/scanning-quality/scans/bundle-trim.md similarity index 100% rename from .claude/skills/scanning-quality/scans/bundle-trim.md rename to .claude/skills/fleet/scanning-quality/scans/bundle-trim.md diff --git a/.claude/skills/scanning-quality/scans/differential.md b/.claude/skills/fleet/scanning-quality/scans/differential.md similarity index 100% rename from .claude/skills/scanning-quality/scans/differential.md rename to .claude/skills/fleet/scanning-quality/scans/differential.md diff --git a/.claude/skills/scanning-quality/scans/insecure-defaults.md b/.claude/skills/fleet/scanning-quality/scans/insecure-defaults.md similarity index 100% rename from .claude/skills/scanning-quality/scans/insecure-defaults.md rename to .claude/skills/fleet/scanning-quality/scans/insecure-defaults.md diff --git a/.claude/skills/scanning-quality/scans/variant-analysis.md b/.claude/skills/fleet/scanning-quality/scans/variant-analysis.md similarity index 100% rename from .claude/skills/scanning-quality/scans/variant-analysis.md rename to .claude/skills/fleet/scanning-quality/scans/variant-analysis.md diff --git a/.claude/skills/scanning-security/SKILL.md b/.claude/skills/fleet/scanning-security/SKILL.md similarity index 100% rename from .claude/skills/scanning-security/SKILL.md rename to .claude/skills/fleet/scanning-security/SKILL.md diff --git a/.claude/skills/squashing-history/SKILL.md b/.claude/skills/fleet/squashing-history/SKILL.md similarity index 100% rename from .claude/skills/squashing-history/SKILL.md rename to .claude/skills/fleet/squashing-history/SKILL.md diff --git a/.claude/skills/squashing-history/reference.md b/.claude/skills/fleet/squashing-history/reference.md similarity index 100% rename from .claude/skills/squashing-history/reference.md rename to .claude/skills/fleet/squashing-history/reference.md diff --git a/.claude/skills/trimming-bundle/SKILL.md b/.claude/skills/fleet/trimming-bundle/SKILL.md similarity index 100% rename from .claude/skills/trimming-bundle/SKILL.md rename to .claude/skills/fleet/trimming-bundle/SKILL.md diff --git a/.claude/skills/updating-coverage/SKILL.md b/.claude/skills/fleet/updating-coverage/SKILL.md similarity index 100% rename from .claude/skills/updating-coverage/SKILL.md rename to .claude/skills/fleet/updating-coverage/SKILL.md diff --git a/.claude/skills/updating-lockstep/SKILL.md b/.claude/skills/fleet/updating-lockstep/SKILL.md similarity index 100% rename from .claude/skills/updating-lockstep/SKILL.md rename to .claude/skills/fleet/updating-lockstep/SKILL.md diff --git a/.claude/skills/updating-lockstep/reference.md b/.claude/skills/fleet/updating-lockstep/reference.md similarity index 100% rename from .claude/skills/updating-lockstep/reference.md rename to .claude/skills/fleet/updating-lockstep/reference.md diff --git a/.claude/skills/updating-security/SKILL.md b/.claude/skills/fleet/updating-security/SKILL.md similarity index 100% rename from .claude/skills/updating-security/SKILL.md rename to .claude/skills/fleet/updating-security/SKILL.md diff --git a/.claude/skills/updating-security/reference.md b/.claude/skills/fleet/updating-security/reference.md similarity index 100% rename from .claude/skills/updating-security/reference.md rename to .claude/skills/fleet/updating-security/reference.md diff --git a/.claude/skills/updating/SKILL.md b/.claude/skills/fleet/updating/SKILL.md similarity index 100% rename from .claude/skills/updating/SKILL.md rename to .claude/skills/fleet/updating/SKILL.md diff --git a/.claude/skills/updating/reference.md b/.claude/skills/fleet/updating/reference.md similarity index 100% rename from .claude/skills/updating/reference.md rename to .claude/skills/fleet/updating/reference.md diff --git a/.claude/skills/worktree-management/SKILL.md b/.claude/skills/fleet/worktree-management/SKILL.md similarity index 100% rename from .claude/skills/worktree-management/SKILL.md rename to .claude/skills/fleet/worktree-management/SKILL.md From 8e9deba7ceba9a991ca8d8a56d6f552fc43e68c4 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Mon, 1 Jun 2026 01:09:43 -0400 Subject: [PATCH 16/17] chore(claude): rehome commands under .claude/commands/fleet/ Move dangling .claude/commands/<name>/ entries into .claude/commands/fleet/<name>/ to match the wheelhouse-canonical layout. Pre-segmentation layout; the cascade now expects every fleet-shared command to live under fleet/. Rehomed (7): - audit-gha-settings.md - green-ci.md - quality-loop.md - security-scan.md - setup-security-tools.md - squash-history.md - update-security.md --- .claude/commands/{ => fleet}/audit-gha-settings.md | 0 .claude/commands/{ => fleet}/green-ci.md | 0 .claude/commands/{ => fleet}/quality-loop.md | 0 .claude/commands/{ => fleet}/security-scan.md | 0 .claude/commands/{ => fleet}/setup-security-tools.md | 0 .claude/commands/{ => fleet}/squash-history.md | 0 .claude/commands/{ => fleet}/update-security.md | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename .claude/commands/{ => fleet}/audit-gha-settings.md (100%) rename .claude/commands/{ => fleet}/green-ci.md (100%) rename .claude/commands/{ => fleet}/quality-loop.md (100%) rename .claude/commands/{ => fleet}/security-scan.md (100%) rename .claude/commands/{ => fleet}/setup-security-tools.md (100%) rename .claude/commands/{ => fleet}/squash-history.md (100%) rename .claude/commands/{ => fleet}/update-security.md (100%) diff --git a/.claude/commands/audit-gha-settings.md b/.claude/commands/fleet/audit-gha-settings.md similarity index 100% rename from .claude/commands/audit-gha-settings.md rename to .claude/commands/fleet/audit-gha-settings.md diff --git a/.claude/commands/green-ci.md b/.claude/commands/fleet/green-ci.md similarity index 100% rename from .claude/commands/green-ci.md rename to .claude/commands/fleet/green-ci.md diff --git a/.claude/commands/quality-loop.md b/.claude/commands/fleet/quality-loop.md similarity index 100% rename from .claude/commands/quality-loop.md rename to .claude/commands/fleet/quality-loop.md diff --git a/.claude/commands/security-scan.md b/.claude/commands/fleet/security-scan.md similarity index 100% rename from .claude/commands/security-scan.md rename to .claude/commands/fleet/security-scan.md diff --git a/.claude/commands/setup-security-tools.md b/.claude/commands/fleet/setup-security-tools.md similarity index 100% rename from .claude/commands/setup-security-tools.md rename to .claude/commands/fleet/setup-security-tools.md diff --git a/.claude/commands/squash-history.md b/.claude/commands/fleet/squash-history.md similarity index 100% rename from .claude/commands/squash-history.md rename to .claude/commands/fleet/squash-history.md diff --git a/.claude/commands/update-security.md b/.claude/commands/fleet/update-security.md similarity index 100% rename from .claude/commands/update-security.md rename to .claude/commands/fleet/update-security.md From 99ba89071584feedc11d6644cd345a3db08d3629 Mon Sep 17 00:00:00 2001 From: jdalton <john.david.dalton@gmail.com> Date: Mon, 1 Jun 2026 01:09:46 -0400 Subject: [PATCH 17/17] chore(claude): rehome agents under .claude/agents/fleet/ Move dangling .claude/agents/<name>/ entries into .claude/agents/fleet/<name>/ to match the wheelhouse-canonical layout. Pre-segmentation layout; the cascade now expects every fleet-shared agent to live under fleet/. Rehomed (1): - security-reviewer.md --- .claude/agents/{ => fleet}/security-reviewer.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .claude/agents/{ => fleet}/security-reviewer.md (100%) diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/fleet/security-reviewer.md similarity index 100% rename from .claude/agents/security-reviewer.md rename to .claude/agents/fleet/security-reviewer.md