ashlr-plugin 1.36.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +39 -0
- package/.claude-plugin/plugin.json +36 -0
- package/.codex/agents/README.md +9 -0
- package/.codex/agents/ashlr-explorer.md +36 -0
- package/.codex/agents/ashlr-worker.md +38 -0
- package/.codex-plugin/plugin.json +45 -0
- package/.mcp.json +14 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/agents/ashlr-code.md +69 -0
- package/agents/ashlr-explore.md +44 -0
- package/agents/ashlr-plan.md +54 -0
- package/bench/fixtures/notebook-large.ipynb +660 -0
- package/bench/fixtures/notebook-small.ipynb +39 -0
- package/bench/fixtures/tasklist-closed.json +62 -0
- package/bench/fixtures/tasklist-large-mixed.json +27 -0
- package/bench/fixtures/tasklist-sprint-open.json +79 -0
- package/bench/fixtures/websearch-bun-runtime.json +32 -0
- package/bench/fixtures/websearch-react-hooks-patterns.json +32 -0
- package/bench/fixtures/websearch-typescript-generics.json +38 -0
- package/bench/refs/node-sdk/.refrev +1 -0
- package/bench/refs/node-sdk/README.md +17 -0
- package/bench/refs/node-sdk/src/agent/agent.ts +163 -0
- package/bench/refs/node-sdk/src/agent/create-agent-ui-stream.ts +83 -0
- package/bench/refs/node-sdk/src/agent/tool-loop-agent.ts +238 -0
- package/bench/refs/node-sdk/src/embed/embed-many.ts +409 -0
- package/bench/refs/node-sdk/src/embed/embed-result.ts +50 -0
- package/bench/refs/node-sdk/src/embed/embed.ts +257 -0
- package/bench/refs/node-sdk/src/error/invalid-argument-error.ts +34 -0
- package/bench/refs/node-sdk/src/error/invalid-tool-input-error.ts +33 -0
- package/bench/refs/node-sdk/src/error/no-object-generated-error.ts +70 -0
- package/bench/refs/node-sdk/src/generate-image/generate-image-result.ts +42 -0
- package/bench/refs/node-sdk/src/generate-image/generate-image.ts +362 -0
- package/bench/refs/node-sdk/src/generate-object/generate-object-result.ts +67 -0
- package/bench/refs/node-sdk/src/generate-object/generate-object.ts +501 -0
- package/bench/refs/node-sdk/src/generate-object/inject-json-instruction.ts +30 -0
- package/bench/refs/node-sdk/src/generate-object/output-strategy.ts +415 -0
- package/bench/refs/node-sdk/src/generate-object/repair-text.ts +12 -0
- package/bench/refs/node-sdk/src/generate-object/stream-object.ts +960 -0
- package/bench/refs/node-sdk/src/generate-speech/generate-speech.ts +191 -0
- package/bench/refs/node-sdk/src/generate-text/generate-text.ts +1484 -0
- package/bench/refs/node-sdk/src/generate-text/stream-text.ts +2708 -0
- package/bench/refs/node-sdk/src/generate-text/tool-call.ts +59 -0
- package/bench/refs/node-sdk/src/generate-text/tool-result.ts +36 -0
- package/bench/refs/node-sdk/src/telemetry/create-telemetry-dispatcher.ts +146 -0
- package/bench/refs/node-sdk/src/telemetry/telemetry-registry.ts +15 -0
- package/bench/refs/node-sdk/src/telemetry/telemetry.ts +223 -0
- package/bench/refs/node-sdk/src/util/async-iterable-stream.ts +94 -0
- package/bench/refs/node-sdk/src/util/consume-stream.ts +31 -0
- package/bench/refs/node-sdk/src/util/create-stitchable-stream.ts +112 -0
- package/bench/refs/node-sdk/src/util/download/download.ts +74 -0
- package/bench/refs/node-sdk/src/util/error-handler.ts +1 -0
- package/bench/refs/node-sdk/src/util/fix-json.ts +401 -0
- package/bench/refs/node-sdk/src/util/merge-objects.ts +79 -0
- package/bench/refs/python-lib/.refrev +1 -0
- package/bench/refs/python-lib/README.md +18 -0
- package/bench/refs/python-lib/pandas/core/_numba/executor.py +249 -0
- package/bench/refs/python-lib/pandas/core/_numba/extensions.py +586 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/mean_.py +198 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/min_max_.py +178 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/sum_.py +255 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/var_.py +251 -0
- package/bench/refs/python-lib/pandas/core/accessor.py +520 -0
- package/bench/refs/python-lib/pandas/core/api.py +138 -0
- package/bench/refs/python-lib/pandas/core/array_algos/masked_accumulations.py +97 -0
- package/bench/refs/python-lib/pandas/core/array_algos/masked_reductions.py +252 -0
- package/bench/refs/python-lib/pandas/core/array_algos/putmask.py +150 -0
- package/bench/refs/python-lib/pandas/core/array_algos/quantile.py +232 -0
- package/bench/refs/python-lib/pandas/core/array_algos/replace.py +159 -0
- package/bench/refs/python-lib/pandas/core/array_algos/take.py +609 -0
- package/bench/refs/python-lib/pandas/core/arraylike.py +534 -0
- package/bench/refs/python-lib/pandas/core/arrays/_arrow_string_mixins.py +434 -0
- package/bench/refs/python-lib/pandas/core/arrays/_mixins.py +630 -0
- package/bench/refs/python-lib/pandas/core/arrays/_ranges.py +276 -0
- package/bench/refs/python-lib/pandas/core/arrays/_utils.py +77 -0
- package/bench/refs/python-lib/pandas/core/arrays/arrow/accessors.py +521 -0
- package/bench/refs/python-lib/pandas/core/arrays/arrow/extension_types.py +174 -0
- package/bench/refs/python-lib/pandas/core/arrays/boolean.py +454 -0
- package/bench/refs/python-lib/pandas/core/arrays/floating.py +192 -0
- package/bench/refs/python-lib/pandas/core/arrays/integer.py +296 -0
- package/bench/refs/python-lib/pandas/core/arrays/masked.py +2140 -0
- package/bench/refs/python-lib/pandas/core/arrays/numeric.py +305 -0
- package/bench/refs/python-lib/pandas/core/arrays/numpy_.py +701 -0
- package/bench/refs/python-lib/pandas/core/arrays/period.py +1586 -0
- package/bench/refs/python-lib/pandas/core/arrays/sparse/accessor.py +505 -0
- package/bench/refs/python-lib/pandas/core/arrays/sparse/scipy_sparse.py +208 -0
- package/bench/refs/rust-project/.refrev +1 -0
- package/bench/refs/rust-project/README.md +17 -0
- package/bench/refs/rust-project/tokio/src/doc/os.rs +68 -0
- package/bench/refs/rust-project/tokio/src/fs/dir_builder.rs +129 -0
- package/bench/refs/rust-project/tokio/src/fs/file.rs +1113 -0
- package/bench/refs/rust-project/tokio/src/fs/mocks.rs +176 -0
- package/bench/refs/rust-project/tokio/src/fs/mod.rs +324 -0
- package/bench/refs/rust-project/tokio/src/fs/open_options.rs +856 -0
- package/bench/refs/rust-project/tokio/src/fs/read.rs +80 -0
- package/bench/refs/rust-project/tokio/src/fs/read_dir.rs +357 -0
- package/bench/refs/rust-project/tokio/src/fs/write.rs +99 -0
- package/bench/refs/rust-project/tokio/src/future/maybe_done.rs +123 -0
- package/bench/refs/rust-project/tokio/src/future/try_join.rs +82 -0
- package/bench/refs/rust-project/tokio/src/io/async_buf_read.rs +117 -0
- package/bench/refs/rust-project/tokio/src/io/async_read.rs +133 -0
- package/bench/refs/rust-project/tokio/src/io/async_seek.rs +96 -0
- package/bench/refs/rust-project/tokio/src/io/async_write.rs +402 -0
- package/bench/refs/rust-project/tokio/src/io/blocking.rs +339 -0
- package/bench/refs/rust-project/tokio/src/io/interest.rs +345 -0
- package/bench/refs/rust-project/tokio/src/io/join.rs +130 -0
- package/bench/refs/rust-project/tokio/src/io/mod.rs +308 -0
- package/bench/refs/rust-project/tokio/src/io/poll_evented.rs +319 -0
- package/bench/refs/rust-project/tokio/src/io/read_buf.rs +344 -0
- package/bench/refs/rust-project/tokio/src/io/ready.rs +322 -0
- package/bench/refs/rust-project/tokio/src/io/split.rs +166 -0
- package/bench/refs/rust-project/tokio/src/io/stderr.rs +148 -0
- package/bench/refs/rust-project/tokio/src/io/stdin.rs +98 -0
- package/bench/refs/rust-project/tokio/src/io/stdio_common.rs +222 -0
- package/bench/refs/rust-project/tokio/src/io/stdout.rs +197 -0
- package/bench/refs/rust-project/tokio/src/io/util/buf_reader.rs +311 -0
- package/bench/refs/rust-project/tokio/src/io/util/buf_stream.rs +207 -0
- package/bench/refs/rust-project/tokio/src/io/util/buf_writer.rs +310 -0
- package/bench/refs/rust-project/tokio/src/io/util/chain.rs +144 -0
- package/commands/_spawn-patterns.json +32 -0
- package/commands/ashlr-allow.md +21 -0
- package/commands/ashlr-benchmark.md +53 -0
- package/commands/ashlr-brief.md +97 -0
- package/commands/ashlr-budget.md +76 -0
- package/commands/ashlr-compact.md +54 -0
- package/commands/ashlr-dashboard.md +37 -0
- package/commands/ashlr-doctor.md +56 -0
- package/commands/ashlr-eco-mode.md +92 -0
- package/commands/ashlr-genome-init.md +24 -0
- package/commands/ashlr-genome-keygen.md +36 -0
- package/commands/ashlr-genome-loop.md +118 -0
- package/commands/ashlr-genome-push.md +36 -0
- package/commands/ashlr-genome-rewrap.md +48 -0
- package/commands/ashlr-genome-team-init.md +67 -0
- package/commands/ashlr-handoff.md +23 -0
- package/commands/ashlr-help.md +86 -0
- package/commands/ashlr-hook-timings.md +30 -0
- package/commands/ashlr-leaderboard.md +50 -0
- package/commands/ashlr-ollama-setup.md +39 -0
- package/commands/ashlr-orchestrate-status.md +72 -0
- package/commands/ashlr-orchestrate.md +83 -0
- package/commands/ashlr-parallelize.md +63 -0
- package/commands/ashlr-report-crash.md +33 -0
- package/commands/ashlr-resume.md +72 -0
- package/commands/ashlr-savings.md +24 -0
- package/commands/ashlr-settings.md +50 -0
- package/commands/ashlr-spawn.md +61 -0
- package/commands/ashlr-start.md +60 -0
- package/commands/ashlr-status.md +64 -0
- package/commands/ashlr-team-invite.md +47 -0
- package/commands/ashlr-tier.md +122 -0
- package/commands/ashlr-tour.md +60 -0
- package/commands/ashlr-update.md +155 -0
- package/commands/ashlr-upgrade.md +96 -0
- package/commands/goal.md +58 -0
- package/commands/loop.md +40 -0
- package/docs/CNAME +1 -0
- package/docs/PRO_TIER.md +104 -0
- package/docs/README.md +21 -0
- package/docs/architecture.md +415 -0
- package/docs/ashlr-pipe.md +161 -0
- package/docs/assets/logo.svg +19 -0
- package/docs/assets/og.png +0 -0
- package/docs/assets/og.svg +41 -0
- package/docs/benchmarks-v2.json +355 -0
- package/docs/benchmarks.json +102 -0
- package/docs/benchmarks.md +211 -0
- package/docs/billing.md +185 -0
- package/docs/cloud-genome.md +155 -0
- package/docs/competitive-wozcode.md +92 -0
- package/docs/deploy.md +325 -0
- package/docs/email-templates.md +113 -0
- package/docs/embed-tuning.md +103 -0
- package/docs/genome.md +120 -0
- package/docs/github-oauth-onboarding.md +127 -0
- package/docs/hero-video-script.md +129 -0
- package/docs/index.html +2262 -0
- package/docs/install-prompt.md +78 -0
- package/docs/install-windows.md +132 -0
- package/docs/install.ps1 +171 -0
- package/docs/install.sh +146 -0
- package/docs/launch/README.md +114 -0
- package/docs/launch/hn.md +49 -0
- package/docs/launch/reddit.md +45 -0
- package/docs/launch/x.md +65 -0
- package/docs/launch-post.md +112 -0
- package/docs/legal.md +78 -0
- package/docs/llm-providers.md +99 -0
- package/docs/multi-host-mcp.md +217 -0
- package/docs/multi-turn-architecture.md +108 -0
- package/docs/operations/deployment.md +108 -0
- package/docs/operations/error-monitoring.md +70 -0
- package/docs/operations/weekly-digest.md +70 -0
- package/docs/operations.md +494 -0
- package/docs/platform-support.md +133 -0
- package/docs/policy-packs.md +188 -0
- package/docs/press-kit/fact-sheet.md +78 -0
- package/docs/press-kit/founder-bio.md +3 -0
- package/docs/press-kit/logo.svg +33 -0
- package/docs/press-kit/screenshots.md +146 -0
- package/docs/pricing.md +251 -0
- package/docs/pro-backend-architecture.md +523 -0
- package/docs/producthunt.md +52 -0
- package/docs/router-migration.md +110 -0
- package/docs/session-log-schema.md +68 -0
- package/docs/social.md +95 -0
- package/docs/team-genome.md +528 -0
- package/docs/telemetry.md +194 -0
- package/docs/test-isolation.md +100 -0
- package/docs/upgrade.md +159 -0
- package/hooks/_hook-errors.ts +87 -0
- package/hooks/_hook-perf-emit.ts +113 -0
- package/hooks/_hook-timing-ledger.ts +1133 -0
- package/hooks/_nudge-throttle.ts +127 -0
- package/hooks/_recent-blocks.ts +127 -0
- package/hooks/audit-upload.ts +86 -0
- package/hooks/codex-hooks.json +171 -0
- package/hooks/commit-attribution.ts +179 -0
- package/hooks/edit-batching-nudge.ts +154 -0
- package/hooks/genome-scribe-hook.ts +219 -0
- package/hooks/hooks.json +294 -0
- package/hooks/orient-nudge-hook.ts +242 -0
- package/hooks/policy-enforce.ts +214 -0
- package/hooks/post-tool-use-embedding.ts +93 -0
- package/hooks/post-tool-use-genome.sh +37 -0
- package/hooks/post-tool-use-genome.ts +69 -0
- package/hooks/posttooluse-correlate.ts +122 -0
- package/hooks/posttooluse-dedupe-output.ts +241 -0
- package/hooks/posttooluse-genome-refresh.ts +223 -0
- package/hooks/posttooluse-native-nudge.ts +205 -0
- package/hooks/posttooluse-stale-result.ts +277 -0
- package/hooks/pretooluse-bash.ts +63 -0
- package/hooks/pretooluse-budget-guard.ts +318 -0
- package/hooks/pretooluse-common.ts +985 -0
- package/hooks/pretooluse-eco-router.ts +196 -0
- package/hooks/pretooluse-edit.sh +93 -0
- package/hooks/pretooluse-edit.ts +197 -0
- package/hooks/pretooluse-glob.ts +70 -0
- package/hooks/pretooluse-grep.sh +85 -0
- package/hooks/pretooluse-grep.ts +96 -0
- package/hooks/pretooluse-notebookedit.ts +121 -0
- package/hooks/pretooluse-read.sh +118 -0
- package/hooks/pretooluse-read.ts +118 -0
- package/hooks/pretooluse-task.ts +130 -0
- package/hooks/pretooluse-webfetch.ts +66 -0
- package/hooks/pretooluse-websearch.ts +85 -0
- package/hooks/pretooluse-write.ts +128 -0
- package/hooks/pulse-emit.ts +197 -0
- package/hooks/session-end-consolidate.sh +35 -0
- package/hooks/session-end-consolidate.ts +188 -0
- package/hooks/session-end-stats-gc.ts +80 -0
- package/hooks/session-log-append.sh +94 -0
- package/hooks/session-log-append.ts +116 -0
- package/hooks/session-start.ts +993 -0
- package/hooks/sessionend-hook-health-nudge.ts +451 -0
- package/hooks/sessionend-savings-nudge.ts +318 -0
- package/hooks/sessionend-status-line-nudge.ts +296 -0
- package/hooks/sessionstart-brief.ts +140 -0
- package/hooks/sessionstart-cost-refactor.ts +95 -0
- package/hooks/sessionstart-efficient.ts +110 -0
- package/hooks/sessionstart-genome-author.ts +93 -0
- package/hooks/sessionstart-lean-tools.ts +98 -0
- package/hooks/sessionstart-search.ts +97 -0
- package/hooks/stop-accounting.ts +152 -0
- package/hooks/subagent-stop-rollup.ts +162 -0
- package/hooks/userpromptsubmit-brief-trigger.ts +156 -0
- package/package.json +56 -0
- package/ports/README.md +108 -0
- package/ports/cursor/mcp.json +11 -0
- package/ports/goose/recipe.yaml +26 -0
- package/scripts/ashlr-mcp.ts +40 -0
- package/scripts/auto-update.ts +177 -0
- package/scripts/baseline-scan.ts +831 -0
- package/scripts/bench-orchestrate.ts +377 -0
- package/scripts/benchmark-refs.ts +292 -0
- package/scripts/bootstrap.mjs +100 -0
- package/scripts/brief-eval.ts +234 -0
- package/scripts/bump-version.ts +82 -0
- package/scripts/bun-resolve.d.mts +11 -0
- package/scripts/bun-resolve.mjs +59 -0
- package/scripts/calibrate-grep.ts +467 -0
- package/scripts/capture-grep-workload.ts +260 -0
- package/scripts/check-tier.ts +61 -0
- package/scripts/check-version-sync.ts +97 -0
- package/scripts/cli-orchestrate-status.ts +386 -0
- package/scripts/cli-orchestrate.ts +303 -0
- package/scripts/cli.ts +478 -0
- package/scripts/cloud-smoke-test.ts +313 -0
- package/scripts/coach-report.ts +373 -0
- package/scripts/context-db-migrate.ts +137 -0
- package/scripts/context-status.ts +43 -0
- package/scripts/demo-run.ts +255 -0
- package/scripts/deploy-smoke.sh +140 -0
- package/scripts/doctor.ts +992 -0
- package/scripts/embed-file-worker.ts +104 -0
- package/scripts/embed-tune.ts +308 -0
- package/scripts/errors-report.ts +428 -0
- package/scripts/find-test-leak.ts +69 -0
- package/scripts/generate-badge.ts +357 -0
- package/scripts/genome-auto-consolidate.ts +533 -0
- package/scripts/genome-auto-propose.ts +578 -0
- package/scripts/genome-cloud-pull.ts +363 -0
- package/scripts/genome-cloud-push.ts +382 -0
- package/scripts/genome-cloud-sync.ts +587 -0
- package/scripts/genome-commit-watcher.ts +295 -0
- package/scripts/genome-init.ts +986 -0
- package/scripts/genome-key.ts +233 -0
- package/scripts/genome-keygen.ts +168 -0
- package/scripts/genome-link.ts +59 -0
- package/scripts/genome-refresh-worker.ts +364 -0
- package/scripts/genome-rewrap.ts +90 -0
- package/scripts/genome-synthesizer.ts +679 -0
- package/scripts/genome-team-init.ts +312 -0
- package/scripts/handoff-pack.ts +438 -0
- package/scripts/hook-bootstrap.mjs +75 -0
- package/scripts/hook-timing-reader.ts +371 -0
- package/scripts/hook-timings-report.ts +554 -0
- package/scripts/install-genome-hooks.ts +196 -0
- package/scripts/install-onnx-model.ts +154 -0
- package/scripts/install-permissions.ts +276 -0
- package/scripts/install-status-line.ts +150 -0
- package/scripts/mcp-entrypoint.sh +78 -0
- package/scripts/mcp-entrypoint.ts +130 -0
- package/scripts/mdx-angle-bracket-lint.ts +152 -0
- package/scripts/measure-cold-start.ts +126 -0
- package/scripts/migrate-stats-to-sqlite.ts +86 -0
- package/scripts/ollama-setup.ts +498 -0
- package/scripts/onboarding-wizard.ts +1691 -0
- package/scripts/orchestrate-emit-telemetry.ts +177 -0
- package/scripts/orchestrate-expand.ts +517 -0
- package/scripts/orchestrate-run.ts +744 -0
- package/scripts/print-legend.ts +50 -0
- package/scripts/product-metadata.ts +38 -0
- package/scripts/prune-old-memories.ts +208 -0
- package/scripts/publish.sh +88 -0
- package/scripts/read-calibration.ts +122 -0
- package/scripts/report-crash.ts +226 -0
- package/scripts/run-benchmark.ts +750 -0
- package/scripts/run-root-tests.ts +28 -0
- package/scripts/savings-dashboard.ts +1258 -0
- package/scripts/savings-report-extras.ts +447 -0
- package/scripts/savings-status-line.ts +873 -0
- package/scripts/session-event-emit.ts +266 -0
- package/scripts/session-greet.ts +686 -0
- package/scripts/session-log-report.ts +551 -0
- package/scripts/session-resume.ts +614 -0
- package/scripts/set-hook-mode.ts +83 -0
- package/scripts/smoke-cross-platform.ts +117 -0
- package/scripts/smoke-realtime.ts +257 -0
- package/scripts/smoke-tools.ts +21 -0
- package/scripts/stats-cloud-pull.ts +213 -0
- package/scripts/stats-cloud-sync.ts +261 -0
- package/scripts/telemetry-flush.ts +160 -0
- package/scripts/telemetry-status.ts +266 -0
- package/scripts/timing-budget.ts +35 -0
- package/scripts/ui-animation.ts +120 -0
- package/scripts/ui.ts +431 -0
- package/scripts/upgrade-flow.ts +775 -0
- package/scripts/weekly-digest.ts +394 -0
- package/servers/_accounting.ts +78 -0
- package/servers/_ask-router.ts +84 -0
- package/servers/_ast-chunker.ts +434 -0
- package/servers/_ast-helpers.ts +183 -0
- package/servers/_ast-languages.ts +153 -0
- package/servers/_ast-refactor/_shared.ts +221 -0
- package/servers/_ast-refactor/cross-file-rename.ts +560 -0
- package/servers/_ast-refactor/extract-function.ts +357 -0
- package/servers/_ast-refactor/file-local-rename.ts +172 -0
- package/servers/_ast-refactor.ts +41 -0
- package/servers/_ast-skeleton.ts +107 -0
- package/servers/_bash-summarizers-registry.ts +1096 -0
- package/servers/_crash-dump.ts +210 -0
- package/servers/_cwd-clamp.ts +342 -0
- package/servers/_edit-log.ts +28 -0
- package/servers/_edit-match.ts +236 -0
- package/servers/_edit-validate.ts +196 -0
- package/servers/_embed-calibration.ts +123 -0
- package/servers/_embedding-cache.ts +401 -0
- package/servers/_embedding-model.ts +329 -0
- package/servers/_events.ts +113 -0
- package/servers/_genome-cache.ts +154 -0
- package/servers/_genome-commits.ts +172 -0
- package/servers/_genome-crypto-v2.ts +242 -0
- package/servers/_genome-crypto.ts +259 -0
- package/servers/_genome-discoveries.ts +140 -0
- package/servers/_genome-embed-populator.ts +216 -0
- package/servers/_genome-freshness.ts +134 -0
- package/servers/_genome-issues.ts +171 -0
- package/servers/_genome-live.ts +263 -0
- package/servers/_genome-prs.ts +188 -0
- package/servers/_genome-search.ts +479 -0
- package/servers/_genome-sync.ts +297 -0
- package/servers/_history-tracker.ts +279 -0
- package/servers/_http-helpers.ts +151 -0
- package/servers/_identity-hash.ts +151 -0
- package/servers/_llm-providers/anthropic.ts +132 -0
- package/servers/_llm-providers/cloud.ts +195 -0
- package/servers/_llm-providers/index.ts +350 -0
- package/servers/_llm-providers/local.ts +127 -0
- package/servers/_llm-providers/onnx.ts +393 -0
- package/servers/_llm-providers/types.ts +27 -0
- package/servers/_manifest-v2.ts +447 -0
- package/servers/_mcp-host.ts +93 -0
- package/servers/_nudge-events.ts +451 -0
- package/servers/_orchestrate-executor.ts +316 -0
- package/servers/_prefetch.ts +435 -0
- package/servers/_pricing.ts +256 -0
- package/servers/_pro.ts +381 -0
- package/servers/_read-cache.ts +44 -0
- package/servers/_router-handlers.ts +46 -0
- package/servers/_router.ts +55 -0
- package/servers/_run-with-timeout.ts +173 -0
- package/servers/_savings-render.ts +214 -0
- package/servers/_stats-sqlite.ts +640 -0
- package/servers/_stats.ts +1183 -0
- package/servers/_status-line-cells.ts +469 -0
- package/servers/_streaks.ts +162 -0
- package/servers/_summarize.ts +208 -0
- package/servers/_task-graph-render.ts +217 -0
- package/servers/_task-graph.ts +545 -0
- package/servers/_telemetry.ts +719 -0
- package/servers/_test-parsers.ts +530 -0
- package/servers/_test-watch.ts +626 -0
- package/servers/_text-helpers.ts +37 -0
- package/servers/_token-measure.ts +160 -0
- package/servers/_tool-base.ts +248 -0
- package/servers/_weekly-digest.ts +391 -0
- package/servers/ask-server-handlers.ts +49 -0
- package/servers/ask-server.ts +174 -0
- package/servers/bash-server-handlers.ts +138 -0
- package/servers/bash-server.ts +1136 -0
- package/servers/bench.ts +186 -0
- package/servers/diff-semantic-server-handlers.ts +39 -0
- package/servers/diff-semantic-server.ts +566 -0
- package/servers/diff-server-handlers.ts +317 -0
- package/servers/diff-server.ts +20 -0
- package/servers/edit-server-handlers.ts +31 -0
- package/servers/edit-server.ts +209 -0
- package/servers/edit-structural-server-handlers.ts +322 -0
- package/servers/efficiency-server-handlers.ts +161 -0
- package/servers/efficiency-server.ts +1135 -0
- package/servers/flush-server-handlers.ts +21 -0
- package/servers/flush-server.ts +19 -0
- package/servers/genome-server-handlers.ts +118 -0
- package/servers/genome-server.ts +397 -0
- package/servers/github-server-handlers.ts +187 -0
- package/servers/github-server.ts +856 -0
- package/servers/glob-server-handlers.ts +233 -0
- package/servers/glob-server.ts +20 -0
- package/servers/grep-server-handlers.ts +61 -0
- package/servers/grep-server.ts +564 -0
- package/servers/http-server-handlers.ts +36 -0
- package/servers/http-server.ts +149 -0
- package/servers/logs-server-handlers.ts +42 -0
- package/servers/logs-server.ts +359 -0
- package/servers/ls-server-handlers.ts +166 -0
- package/servers/ls-server.ts +20 -0
- package/servers/multi-edit-server-handlers.ts +51 -0
- package/servers/multi-edit-server.ts +422 -0
- package/servers/notebook-edit-server-handlers.ts +34 -0
- package/servers/notebook-edit-server.ts +181 -0
- package/servers/orient-server-handlers.ts +52 -0
- package/servers/orient-server.ts +436 -0
- package/servers/pipe-server-handlers.ts +68 -0
- package/servers/pipe-server.ts +300 -0
- package/servers/read-server-handlers.ts +42 -0
- package/servers/read-server.ts +206 -0
- package/servers/rename-file-server-handlers.ts +67 -0
- package/servers/rename-file-server.ts +699 -0
- package/servers/savings-server-handlers.ts +27 -0
- package/servers/savings-server.ts +54 -0
- package/servers/search-replace-regex-server-handlers.ts +96 -0
- package/servers/search-replace-regex-server.ts +751 -0
- package/servers/sql-server-handlers.ts +58 -0
- package/servers/sql-server.ts +505 -0
- package/servers/task-server-handlers.ts +358 -0
- package/servers/task-server.ts +24 -0
- package/servers/test-server-handlers.ts +351 -0
- package/servers/test-server.ts +20 -0
- package/servers/tree-server-handlers.ts +503 -0
- package/servers/tree-server.ts +20 -0
- package/servers/webfetch-server-handlers.ts +166 -0
- package/servers/webfetch-server.ts +20 -0
- package/servers/websearch-server-handlers.ts +215 -0
- package/servers/websearch-server.ts +19 -0
- package/servers/write-server-handlers.ts +27 -0
- package/servers/write-server.ts +97 -0
- package/skills/ashlr-brief/SKILL.md +80 -0
- package/skills/ashlr-codex-doctor/SKILL.md +21 -0
- package/skills/ashlr-codex-efficient-tools/SKILL.md +17 -0
- package/skills/ashlr-codex-genome-refresh/SKILL.md +20 -0
- package/skills/ashlr-codex-parallel-explore/SKILL.md +20 -0
- package/skills/ashlr-codex-savings/SKILL.md +17 -0
- package/skills/ashlr-cost-refactor/SKILL.md +50 -0
- package/skills/ashlr-efficient/SKILL.md +64 -0
- package/skills/ashlr-genome-author/SKILL.md +43 -0
- package/skills/ashlr-lean-tools/SKILL.md +43 -0
- package/skills/ashlr-search/SKILL.md +46 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pretooluse-common.ts — Shared helpers for the pretooluse-{read,grep,edit}
|
|
3
|
+
* hooks. Each hook shells out via its own `bun run` entry in hooks.json, so we
|
|
4
|
+
* keep this module dependency-free and side-effect-free.
|
|
5
|
+
*
|
|
6
|
+
* Every helper is designed around the "fail open" contract: if anything looks
|
|
7
|
+
* unexpected we let the built-in tool call proceed by exiting 0, rather than
|
|
8
|
+
* blocking the agent with an error.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { resolve, dirname, join, sep } from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
findSummarizer,
|
|
18
|
+
isLargeDiffCommand,
|
|
19
|
+
} from "../servers/_bash-summarizers-registry";
|
|
20
|
+
import {
|
|
21
|
+
appendHookTimingBatch,
|
|
22
|
+
HOOK_TIMING_BATCH_MAX_BYTES,
|
|
23
|
+
} from "./_hook-timing-ledger";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True when enforcement is disabled — the hook should exit 0 immediately.
|
|
27
|
+
*
|
|
28
|
+
* Enforcement is OFF by default. Only enable when `ASHLR_ENFORCE=1` is set,
|
|
29
|
+
* and still honor `ASHLR_NO_ENFORCE=1` as a kill switch for back-compat.
|
|
30
|
+
*/
|
|
31
|
+
export function enforcementDisabled(): boolean {
|
|
32
|
+
return process.env.ASHLR_ENFORCE !== "1" || process.env.ASHLR_NO_ENFORCE === "1";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Read the full stdin payload as a UTF-8 string (trimmed). */
|
|
36
|
+
export function readStdin(): Promise<string> {
|
|
37
|
+
// Parameter name avoids shadowing the `resolve` imported from "path" above.
|
|
38
|
+
return new Promise((done) => {
|
|
39
|
+
const chunks: Buffer[] = [];
|
|
40
|
+
process.stdin.on("data", (c: Buffer) => chunks.push(c));
|
|
41
|
+
process.stdin.on("end", () => {
|
|
42
|
+
done(Buffer.concat(chunks).toString("utf-8").trim());
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PreToolUsePayload {
|
|
48
|
+
tool_name: string;
|
|
49
|
+
file_path: string;
|
|
50
|
+
pattern: string;
|
|
51
|
+
search_path: string;
|
|
52
|
+
command: string;
|
|
53
|
+
bypass: boolean;
|
|
54
|
+
// v1.22: Edit-specific fields surfaced so the micro-edit guard can size
|
|
55
|
+
// the proposed change without re-parsing stdin. Empty for non-Edit tools.
|
|
56
|
+
old_string: string;
|
|
57
|
+
new_string: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse a pretooluse JSON payload. Returns null if the input is empty or
|
|
62
|
+
* malformed (the caller should exit 0 in that case). Missing fields come back
|
|
63
|
+
* as empty strings / false so call-sites can stay uniform.
|
|
64
|
+
*/
|
|
65
|
+
export function parsePayload(raw: string): PreToolUsePayload | null {
|
|
66
|
+
if (!raw) return null;
|
|
67
|
+
try {
|
|
68
|
+
const p = JSON.parse(raw);
|
|
69
|
+
const input = p?.tool_input ?? {};
|
|
70
|
+
const toolName =
|
|
71
|
+
typeof p?.tool_name === "string"
|
|
72
|
+
? p.tool_name
|
|
73
|
+
: typeof p?.toolName === "string"
|
|
74
|
+
? p.toolName
|
|
75
|
+
: typeof p?.tool === "string"
|
|
76
|
+
? p.tool
|
|
77
|
+
: "";
|
|
78
|
+
const filePath =
|
|
79
|
+
typeof input.file_path === "string"
|
|
80
|
+
? input.file_path
|
|
81
|
+
: typeof input.path === "string" && (toolName === "Read" || toolName === "Edit" || toolName === "Write")
|
|
82
|
+
? input.path
|
|
83
|
+
: "";
|
|
84
|
+
return {
|
|
85
|
+
tool_name: toolName,
|
|
86
|
+
file_path: filePath,
|
|
87
|
+
pattern: typeof input.pattern === "string" ? input.pattern : "",
|
|
88
|
+
search_path: typeof input.path === "string" ? input.path : "",
|
|
89
|
+
command:
|
|
90
|
+
typeof input.command === "string"
|
|
91
|
+
? input.command
|
|
92
|
+
: typeof input.cmd === "string"
|
|
93
|
+
? input.cmd
|
|
94
|
+
: typeof p?.command === "string"
|
|
95
|
+
? p.command
|
|
96
|
+
: typeof p?.cmd === "string"
|
|
97
|
+
? p.cmd
|
|
98
|
+
: "",
|
|
99
|
+
bypass: input.bypassSummary === true,
|
|
100
|
+
old_string: typeof input.old_string === "string" ? input.old_string : "",
|
|
101
|
+
new_string: typeof input.new_string === "string" ? input.new_string : "",
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Absolute path to the plugin root, derived from the calling hook's URL. */
|
|
109
|
+
export function pluginRootFrom(moduleUrl: string): string {
|
|
110
|
+
return resolve(dirname(fileURLToPath(moduleUrl)), "..");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when `p` lies inside the plugin's own directory tree. */
|
|
114
|
+
export function isInsidePluginRoot(p: string, pluginRoot: string): boolean {
|
|
115
|
+
if (!p) return false;
|
|
116
|
+
if (p === pluginRoot) return true;
|
|
117
|
+
return p.startsWith(pluginRoot + sep);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* True when the absolute path `p` lies inside the working directory `cwd`.
|
|
122
|
+
* Used by redirect mode as a safety net — we never block tool calls against
|
|
123
|
+
* paths the user didn't explicitly bring into scope (e.g. /tmp, /etc, other
|
|
124
|
+
* projects). Falls back to nudge in that case.
|
|
125
|
+
*
|
|
126
|
+
* Canonicalizes via realpath when possible so symlinked roots (notably
|
|
127
|
+
* macOS's /tmp → /private/tmp) match correctly. Plain `resolve()` only
|
|
128
|
+
* normalizes `..`/`.` segments — it does NOT follow symlinks.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Memoized realpath — resolves each unique path only once per hook process.
|
|
133
|
+
// Calling realpathSync twice per hook (once for cwd, once for the target) on
|
|
134
|
+
// NFS/cloud-mount drives adds measurable latency when hooks fire 3-5× per MCP
|
|
135
|
+
// call. The cache is process-scoped so test isolation is automatic.
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
const _realpathCache = new Map<string, string>();
|
|
138
|
+
function cachedRealpath(p: string): string {
|
|
139
|
+
const cached = _realpathCache.get(p);
|
|
140
|
+
if (cached !== undefined) return cached;
|
|
141
|
+
let result: string;
|
|
142
|
+
try {
|
|
143
|
+
result = realpathSync(p);
|
|
144
|
+
} catch {
|
|
145
|
+
result = p;
|
|
146
|
+
}
|
|
147
|
+
_realpathCache.set(p, result);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function isInsideCwd(p: string, cwd: string = process.cwd()): boolean {
|
|
152
|
+
if (!p) return false;
|
|
153
|
+
try {
|
|
154
|
+
const absP = resolve(p);
|
|
155
|
+
const absCwd = resolve(cwd);
|
|
156
|
+
const realP = cachedRealpath(absP);
|
|
157
|
+
const realCwd = cachedRealpath(absCwd);
|
|
158
|
+
for (const base of new Set([absCwd, realCwd])) {
|
|
159
|
+
for (const target of new Set([absP, realP])) {
|
|
160
|
+
if (target === base) return true;
|
|
161
|
+
if (target.startsWith(base + sep)) return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Hook-mode resolution — v1.18 redirect vs. nudge vs. off
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
//
|
|
174
|
+
// "redirect" (default): PreToolUse hooks BLOCK the native Read/Grep/Edit call
|
|
175
|
+
// and instruct the agent to call the equivalent ashlr__* MCP tool instead.
|
|
176
|
+
// The block is emitted as structured JSON on stdout using the PreToolUse
|
|
177
|
+
// `permissionDecision: "deny"` contract so Claude Code surfaces the reason
|
|
178
|
+
// back to the model without raising a user permission prompt.
|
|
179
|
+
//
|
|
180
|
+
// "nudge" (escape hatch, ASHLR_HOOK_MODE=nudge): preserves the pre-v1.18
|
|
181
|
+
// behavior — the pretooluse hooks never block; instead they inject
|
|
182
|
+
// `additionalContext` as a soft suggestion the model may or may not follow.
|
|
183
|
+
// Previously this was the job of the now-retired hooks/tool-redirect.ts —
|
|
184
|
+
// the nudge text and thresholds have been absorbed into this module so we
|
|
185
|
+
// ship a single PreToolUse hook per tool rather than two.
|
|
186
|
+
//
|
|
187
|
+
// "off" (total pass-through): no block, no nudge. Selected by the legacy
|
|
188
|
+
// `~/.ashlr/settings.json { "toolRedirect": false }` kill switch, or by
|
|
189
|
+
// setting `ASHLR_HOOK_MODE=off` / `hookMode: "off"` in config.json.
|
|
190
|
+
//
|
|
191
|
+
// Priority order (highest first):
|
|
192
|
+
// 1. `ASHLR_HOOK_MODE` env var — "redirect" | "nudge" | "off"
|
|
193
|
+
// 2. `~/.ashlr/config.json` `hookMode` field
|
|
194
|
+
// 3. `~/.ashlr/settings.json` `toolRedirect: false` → resolves to "off"
|
|
195
|
+
// 4. Default: "redirect"
|
|
196
|
+
//
|
|
197
|
+
// Legacy back-compat: `ASHLR_ENFORCE=1` still selects the exit-code-based
|
|
198
|
+
// block path in the individual hooks. That flag is honored independently so
|
|
199
|
+
// older harness configurations (and the pretouse hook-timings tests that
|
|
200
|
+
// spawn with `ASHLR_ENFORCE=1`) keep working unchanged.
|
|
201
|
+
|
|
202
|
+
export type HookMode = "redirect" | "nudge" | "off";
|
|
203
|
+
|
|
204
|
+
function normalizeMode(v: unknown): HookMode | null {
|
|
205
|
+
if (typeof v !== "string") return null;
|
|
206
|
+
const s = v.trim().toLowerCase();
|
|
207
|
+
if (s === "redirect" || s === "nudge" || s === "off") return s;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* True when the legacy `~/.ashlr/settings.json { toolRedirect: false }` kill
|
|
213
|
+
* switch is in effect. Absent file, malformed JSON, or any other error →
|
|
214
|
+
* treated as "not disabled" (safe default).
|
|
215
|
+
*
|
|
216
|
+
* This helper was ported from the retired hooks/tool-redirect.ts so the
|
|
217
|
+
* settings-based opt-out keeps working after that hook is removed.
|
|
218
|
+
*/
|
|
219
|
+
export function isRedirectEnabled(home: string = process.env.HOME ?? homedir()): boolean {
|
|
220
|
+
try {
|
|
221
|
+
const settingsPath = join(home, ".ashlr", "settings.json");
|
|
222
|
+
if (!existsSync(settingsPath)) return true;
|
|
223
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as {
|
|
224
|
+
toolRedirect?: boolean;
|
|
225
|
+
};
|
|
226
|
+
return raw.toolRedirect !== false;
|
|
227
|
+
} catch {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read a per-hook mode override from settings.json or config.json.
|
|
234
|
+
* Returns the matching mode if found, or null if no override applies.
|
|
235
|
+
*
|
|
236
|
+
* Looks for `{ "hookModes": { "<hookKey>": "redirect" | "nudge" | "off" } }`
|
|
237
|
+
* in either file. settings.json wins over config.json (per audit feedback —
|
|
238
|
+
* settings is where users put intent, config is plugin-managed state).
|
|
239
|
+
*/
|
|
240
|
+
function getPerHookOverride(hookKey: string, home: string): HookMode | null {
|
|
241
|
+
for (const file of ["settings.json", "config.json"]) {
|
|
242
|
+
try {
|
|
243
|
+
const p = join(home, ".ashlr", file);
|
|
244
|
+
if (!existsSync(p)) continue;
|
|
245
|
+
const raw = JSON.parse(readFileSync(p, "utf-8")) as {
|
|
246
|
+
hookModes?: Record<string, unknown>;
|
|
247
|
+
};
|
|
248
|
+
const m = normalizeMode(raw?.hookModes?.[hookKey]);
|
|
249
|
+
if (m) return m;
|
|
250
|
+
} catch {
|
|
251
|
+
/* ignore — try next file */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Resolve the active hook mode. Never throws — on any read/parse error we
|
|
259
|
+
* fall through to the default ("redirect").
|
|
260
|
+
*/
|
|
261
|
+
export function getHookMode(home: string = process.env.HOME ?? homedir()): HookMode {
|
|
262
|
+
const envMode = normalizeMode(process.env.ASHLR_HOOK_MODE);
|
|
263
|
+
if (envMode) return envMode;
|
|
264
|
+
if (process.env.ASHLR_MCP_HOST === "codex-cli") {
|
|
265
|
+
const codexMode = normalizeMode(process.env.ASHLR_CODEX_HOOK_MODE);
|
|
266
|
+
return codexMode ?? "nudge";
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const cfgPath = join(home, ".ashlr", "config.json");
|
|
270
|
+
if (existsSync(cfgPath)) {
|
|
271
|
+
const raw = JSON.parse(readFileSync(cfgPath, "utf-8")) as { hookMode?: unknown };
|
|
272
|
+
const fileMode = normalizeMode(raw?.hookMode);
|
|
273
|
+
if (fileMode) return fileMode;
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* ignore — fall through to next check */
|
|
277
|
+
}
|
|
278
|
+
// Legacy kill switch: ~/.ashlr/settings.json { toolRedirect: false } → "off"
|
|
279
|
+
if (!isRedirectEnabled(home)) return "off";
|
|
280
|
+
return "redirect";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Per-hook variant of {@link getHookMode}. When called with a hook key
|
|
285
|
+
* like "task" or "read", looks for a per-hook override in
|
|
286
|
+
* `~/.ashlr/settings.json { "hookModes": { "<key>": "redirect|nudge|off" } }`
|
|
287
|
+
* (or config.json) before falling back to the global resolution.
|
|
288
|
+
*
|
|
289
|
+
* `ASHLR_HOOK_MODE` env still wins as a global panic switch.
|
|
290
|
+
*
|
|
291
|
+
* Priority order:
|
|
292
|
+
* 1. ASHLR_HOOK_MODE env (global panic switch)
|
|
293
|
+
* 2. hookModes[hookKey] in settings.json
|
|
294
|
+
* 3. hookModes[hookKey] in config.json
|
|
295
|
+
* 4. hookMode (global) in config.json
|
|
296
|
+
* 5. toolRedirect: false in settings.json (legacy → "off")
|
|
297
|
+
* 6. Default: "redirect"
|
|
298
|
+
*
|
|
299
|
+
* Hooks should call this with their own key; tests/back-compat callers
|
|
300
|
+
* should keep using {@link getHookMode} (no key) for unchanged behavior.
|
|
301
|
+
*/
|
|
302
|
+
export function getHookModeFor(
|
|
303
|
+
hookKey: string,
|
|
304
|
+
home: string = process.env.HOME ?? homedir(),
|
|
305
|
+
): HookMode {
|
|
306
|
+
const envMode = normalizeMode(process.env.ASHLR_HOOK_MODE);
|
|
307
|
+
if (envMode) return envMode;
|
|
308
|
+
if (process.env.ASHLR_MCP_HOST === "codex-cli") {
|
|
309
|
+
const codexMode = normalizeMode(process.env.ASHLR_CODEX_HOOK_MODE);
|
|
310
|
+
if (codexMode) return codexMode;
|
|
311
|
+
}
|
|
312
|
+
const perHook = getPerHookOverride(hookKey, home);
|
|
313
|
+
if (perHook) return perHook;
|
|
314
|
+
return getHookMode(home);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* File-first variant of {@link getHookMode}. Use from long-lived MCP server
|
|
319
|
+
* processes whose `process.env.ASHLR_HOOK_MODE` was inherited at spawn time
|
|
320
|
+
* and may be stale after the user updates their config. Reads the config
|
|
321
|
+
* file as the source of truth (representing the user's *intent*), falls back
|
|
322
|
+
* to env only when no file exists, then to legacy / default.
|
|
323
|
+
*
|
|
324
|
+
* Hooks themselves should keep using getHookMode() — they need env-first
|
|
325
|
+
* priority so an explicit per-invocation override (`ASHLR_HOOK_MODE=off bun
|
|
326
|
+
* run …`) takes effect immediately. The savings opportunity engine and
|
|
327
|
+
* other diagnostic surfaces should use this function so the UI reflects
|
|
328
|
+
* the user's current configuration without requiring a Claude Code restart.
|
|
329
|
+
*/
|
|
330
|
+
export function getConfiguredHookMode(home: string = process.env.HOME ?? homedir()): HookMode {
|
|
331
|
+
try {
|
|
332
|
+
const cfgPath = join(home, ".ashlr", "config.json");
|
|
333
|
+
if (existsSync(cfgPath)) {
|
|
334
|
+
const raw = JSON.parse(readFileSync(cfgPath, "utf-8")) as { hookMode?: unknown };
|
|
335
|
+
const fileMode = normalizeMode(raw?.hookMode);
|
|
336
|
+
if (fileMode) return fileMode;
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
/* ignore — fall through to env */
|
|
340
|
+
}
|
|
341
|
+
const envMode = normalizeMode(process.env.ASHLR_HOOK_MODE);
|
|
342
|
+
if (envMode) return envMode;
|
|
343
|
+
if (!isRedirectEnabled(home)) return "off";
|
|
344
|
+
return "redirect";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Per-hook variant of {@link getConfiguredHookMode}. Reads file as source
|
|
349
|
+
* of truth (intent) with per-hook override beating global. Mirrors
|
|
350
|
+
* {@link getHookModeFor} but file-first for use from long-lived MCP server
|
|
351
|
+
* processes whose env was inherited stale.
|
|
352
|
+
*/
|
|
353
|
+
export function getConfiguredHookModeFor(
|
|
354
|
+
hookKey: string,
|
|
355
|
+
home: string = process.env.HOME ?? homedir(),
|
|
356
|
+
): HookMode {
|
|
357
|
+
const perHook = getPerHookOverride(hookKey, home);
|
|
358
|
+
if (perHook) return perHook;
|
|
359
|
+
return getConfiguredHookMode(home);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Hook-output shape for a PreToolUse block that routes the agent to an
|
|
364
|
+
* ashlr__* MCP tool. Claude Code surfaces `permissionDecisionReason` back to
|
|
365
|
+
* the model so it can re-issue the call against the suggested tool without
|
|
366
|
+
* raising a user-facing permission prompt.
|
|
367
|
+
*/
|
|
368
|
+
export interface RedirectBlockOutput {
|
|
369
|
+
hookSpecificOutput: {
|
|
370
|
+
hookEventName: "PreToolUse";
|
|
371
|
+
permissionDecision: "deny";
|
|
372
|
+
permissionDecisionReason: string;
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Build a redirect-block JSON payload. Pure — no side effects. */
|
|
377
|
+
export function buildRedirectBlock(reason: string): RedirectBlockOutput {
|
|
378
|
+
return {
|
|
379
|
+
hookSpecificOutput: {
|
|
380
|
+
hookEventName: "PreToolUse",
|
|
381
|
+
permissionDecision: "deny",
|
|
382
|
+
permissionDecisionReason: reason,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Tool-specific redirect block builder — v1.22 Track E.
|
|
389
|
+
*
|
|
390
|
+
* Emits a structured redirect message that Claude can act on immediately:
|
|
391
|
+
* 1. `bypass` instruction front-loaded (first 60 chars) — v1.21 invariant.
|
|
392
|
+
* 2. Full canonical MCP tool name so Claude can copy-paste the call.
|
|
393
|
+
* 3. Pre-filled `args` JSON block ready to substitute into a tool_use.
|
|
394
|
+
* 4. One-line "why" explaining the savings rationale.
|
|
395
|
+
*
|
|
396
|
+
* Format:
|
|
397
|
+
* [ashlr bypass: ...] Token savings: ~<N>%. Call this instead:
|
|
398
|
+
*
|
|
399
|
+
* tool_use: mcp__plugin_ashlr_ashlr__ashlr__<tool>
|
|
400
|
+
* args: { ... }
|
|
401
|
+
*
|
|
402
|
+
* Why: <savings rationale>
|
|
403
|
+
*
|
|
404
|
+
* Style guide for hooks that don't exist in this worktree (Tracks B/C):
|
|
405
|
+
* - mcpToolName: full canonical name, e.g. "mcp__plugin_ashlr_ashlr__ashlr__websearch"
|
|
406
|
+
* - shortToolName: short form, e.g. "ashlr__websearch"
|
|
407
|
+
* - argsJson: single-line JSON args object, e.g. '{ "query": "<your query>" }'
|
|
408
|
+
* - why: one sentence, e.g. "native WebSearch returns raw HTML; ashlr__websearch filters to text"
|
|
409
|
+
* - savingsPct: estimated token savings (0–100), e.g. 80
|
|
410
|
+
* - bypassNote: how to disable this redirect, e.g. "set ASHLR_HOOK_MODE=nudge"
|
|
411
|
+
*/
|
|
412
|
+
export function buildToolRedirectBlock(opts: {
|
|
413
|
+
mcpToolName: string;
|
|
414
|
+
argsJson: string;
|
|
415
|
+
why: string;
|
|
416
|
+
savingsPct: number;
|
|
417
|
+
bypassNote?: string;
|
|
418
|
+
}): RedirectBlockOutput {
|
|
419
|
+
const bypass = opts.bypassNote ?? "set ASHLR_HOOK_MODE=nudge in ~/.ashlr/config.json";
|
|
420
|
+
const reason =
|
|
421
|
+
`[ashlr bypass: ${bypass}] ~${opts.savingsPct}% token savings. Call this instead:\n\n` +
|
|
422
|
+
` tool_use: ${opts.mcpToolName}\n` +
|
|
423
|
+
` args: ${opts.argsJson}\n\n` +
|
|
424
|
+
`Why: ${opts.why}`;
|
|
425
|
+
return buildRedirectBlock(reason);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Empty pass-through JSON — used in "off" mode and for out-of-scope paths
|
|
430
|
+
* where we want Claude Code to proceed with the native tool unaltered.
|
|
431
|
+
*/
|
|
432
|
+
export function buildPassThrough(): {
|
|
433
|
+
hookSpecificOutput: { hookEventName: "PreToolUse" };
|
|
434
|
+
} {
|
|
435
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" } };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Hook-output shape for a soft nudge — inject `additionalContext` so the
|
|
440
|
+
* agent sees the ashlr__* suggestion, but NEVER set `permissionDecision`
|
|
441
|
+
* (doing so would force a permission prompt even in bypassPermissions mode).
|
|
442
|
+
* The native Read/Grep/Edit call proceeds unchanged.
|
|
443
|
+
*/
|
|
444
|
+
export interface NudgeOutput {
|
|
445
|
+
hookSpecificOutput: {
|
|
446
|
+
hookEventName: "PreToolUse";
|
|
447
|
+
additionalContext: string;
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Size threshold below which Read nudges are skipped — tiny files have
|
|
453
|
+
* nothing to compact, so we pass through silently. Matches the snipCompact
|
|
454
|
+
* threshold used by the efficiency-server's ashlr__read implementation.
|
|
455
|
+
*
|
|
456
|
+
* Kept as a named constant so it can be overridden in tests and shared
|
|
457
|
+
* across the redirect + nudge paths for consistency.
|
|
458
|
+
*/
|
|
459
|
+
export const NUDGE_READ_THRESHOLD = 2048;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Pick a nudge variant index by hashing the call-site string.
|
|
463
|
+
* Produces values 0..N-1 stable across calls with the same key so
|
|
464
|
+
* repeated nudges on the same file rotate through the phrasing set
|
|
465
|
+
* rather than always showing the same message.
|
|
466
|
+
*
|
|
467
|
+
* @internal
|
|
468
|
+
*/
|
|
469
|
+
export function nudgeVariantIndex(key: string, count: number): number {
|
|
470
|
+
if (count <= 0) return 0;
|
|
471
|
+
let h = 0;
|
|
472
|
+
for (let i = 0; i < key.length; i++) {
|
|
473
|
+
h = Math.imul(31, h) + key.charCodeAt(i) | 0;
|
|
474
|
+
}
|
|
475
|
+
return Math.abs(h) % count;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function nudgeToolName(shortName: string): string {
|
|
479
|
+
if (process.env.ASHLR_MCP_HOST === "codex-cli") return shortName;
|
|
480
|
+
if (process.env.ASHLR_MCP_HOST && process.env.ASHLR_MCP_HOST !== "claude-code") return shortName;
|
|
481
|
+
return `mcp__plugin_ashlr_ashlr__${shortName}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Build the `additionalContext` nudge payload for a given tool invocation.
|
|
486
|
+
* Returns null when the tool is uninteresting (unknown name, tiny file, etc.)
|
|
487
|
+
* so the caller can fall back to {@link buildPassThrough}.
|
|
488
|
+
*
|
|
489
|
+
* Nudge copy is action-specific: each message names the exact MCP tool to call,
|
|
490
|
+
* includes the file size or pattern where relevant, and stays ≤80 chars of
|
|
491
|
+
* signal. 3-4 phrasings per tool are rotated by hashing the call-site key
|
|
492
|
+
* (filePath or pattern) so repeated nudges don't read as identical.
|
|
493
|
+
*
|
|
494
|
+
* Never throws. Any filesystem / parse error → null (silent pass-through).
|
|
495
|
+
*/
|
|
496
|
+
export function buildNudgeContext(
|
|
497
|
+
toolName: string,
|
|
498
|
+
toolInput: Record<string, unknown>,
|
|
499
|
+
): NudgeOutput | null {
|
|
500
|
+
switch (toolName) {
|
|
501
|
+
case "Read": {
|
|
502
|
+
const filePath =
|
|
503
|
+
typeof toolInput.file_path === "string" ? toolInput.file_path : null;
|
|
504
|
+
if (!filePath) return null;
|
|
505
|
+
const size = fileSize(filePath);
|
|
506
|
+
if (size === null) return null;
|
|
507
|
+
if (size <= NUDGE_READ_THRESHOLD) return null;
|
|
508
|
+
const sizeKb = (size / 1024).toFixed(1);
|
|
509
|
+
const compactKb = ((size * 0.2) / 1024).toFixed(1);
|
|
510
|
+
const tool = nudgeToolName("ashlr__read");
|
|
511
|
+
const variants = [
|
|
512
|
+
`[ashlr] ashlr__read on this ${sizeKb}KB file returns ~${compactKb}KB (80% less). ` +
|
|
513
|
+
`Call ${tool} { "path": "${filePath}" }.`,
|
|
514
|
+
`[ashlr] Native Read echoes all ${sizeKb}KB; ashlr__read returns ~${compactKb}KB (head+tail). ` +
|
|
515
|
+
`Call ${tool} { "path": "${filePath}" }.`,
|
|
516
|
+
`[ashlr] ${sizeKb}KB file — prefer ashlr__read (~${compactKb}KB output, 80% savings). ` +
|
|
517
|
+
`${tool} { "path": "${filePath}" }.`,
|
|
518
|
+
];
|
|
519
|
+
const text = variants[nudgeVariantIndex(filePath, variants.length)];
|
|
520
|
+
return {
|
|
521
|
+
hookSpecificOutput: {
|
|
522
|
+
hookEventName: "PreToolUse",
|
|
523
|
+
additionalContext: text,
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
case "Grep": {
|
|
528
|
+
const pattern =
|
|
529
|
+
typeof toolInput.pattern === "string" ? toolInput.pattern : "<pattern>";
|
|
530
|
+
const tool = nudgeToolName("ashlr__grep");
|
|
531
|
+
const variants = [
|
|
532
|
+
`[ashlr] ashlr__grep saves ~80% vs native Grep (genome-aware or truncated ripgrep). ` +
|
|
533
|
+
`Call ${tool} { "pattern": ${JSON.stringify(pattern)} }.`,
|
|
534
|
+
`[ashlr] Native Grep returns raw rg output; ashlr__grep compresses to relevant chunks. ` +
|
|
535
|
+
`${tool} { "pattern": ${JSON.stringify(pattern)} }.`,
|
|
536
|
+
`[ashlr] Prefer ashlr__grep — genome-aware retrieval or truncated ripgrep, ~80% smaller. ` +
|
|
537
|
+
`${tool} { "pattern": ${JSON.stringify(pattern)} }.`,
|
|
538
|
+
];
|
|
539
|
+
const text = variants[nudgeVariantIndex(pattern, variants.length)];
|
|
540
|
+
return {
|
|
541
|
+
hookSpecificOutput: {
|
|
542
|
+
hookEventName: "PreToolUse",
|
|
543
|
+
additionalContext: text,
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
case "Edit": {
|
|
548
|
+
const filePath =
|
|
549
|
+
typeof toolInput.file_path === "string" ? toolInput.file_path : "<path>";
|
|
550
|
+
const tool = nudgeToolName("ashlr__edit");
|
|
551
|
+
const variants = [
|
|
552
|
+
`[ashlr] ashlr__edit returns a compact diff (~60% smaller than native Edit). ` +
|
|
553
|
+
`${tool} { "path": "${filePath}", "search": ..., "replace": ..., "strict": true }.`,
|
|
554
|
+
`[ashlr] Native Edit echoes the full file; ashlr__edit returns only the changed lines. ` +
|
|
555
|
+
`${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
|
|
556
|
+
`[ashlr] Use ashlr__edit for 60% token savings — strict search/replace + diff output. ` +
|
|
557
|
+
`${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
|
|
558
|
+
];
|
|
559
|
+
const text = variants[nudgeVariantIndex(filePath, variants.length)];
|
|
560
|
+
return {
|
|
561
|
+
hookSpecificOutput: {
|
|
562
|
+
hookEventName: "PreToolUse",
|
|
563
|
+
additionalContext: text,
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
case "Write": {
|
|
568
|
+
const filePath =
|
|
569
|
+
typeof toolInput.file_path === "string" ? toolInput.file_path : "<path>";
|
|
570
|
+
// Only nudge for rewrites of existing files — Write on a new file has
|
|
571
|
+
// no ashlr equivalent (ashlr__edit requires a search string).
|
|
572
|
+
if (fileSize(filePath) === null) return null;
|
|
573
|
+
const tool = nudgeToolName("ashlr__edit");
|
|
574
|
+
const variants = [
|
|
575
|
+
`[ashlr] Rewriting existing file — ashlr__edit returns only a diff (~60% less). ` +
|
|
576
|
+
`${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
|
|
577
|
+
`[ashlr] Native Write echoes the full new file; ashlr__edit returns only changed lines. ` +
|
|
578
|
+
`${tool} { "path": "${filePath}", "search": ..., "replace": ..., "strict": true }.`,
|
|
579
|
+
`[ashlr] Prefer ashlr__edit for targeted rewrites — compact diff output, 60% savings. ` +
|
|
580
|
+
`${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
|
|
581
|
+
];
|
|
582
|
+
const text = variants[nudgeVariantIndex(filePath, variants.length)];
|
|
583
|
+
return {
|
|
584
|
+
hookSpecificOutput: {
|
|
585
|
+
hookEventName: "PreToolUse",
|
|
586
|
+
additionalContext: text,
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
case "MultiEdit": {
|
|
591
|
+
const filePath =
|
|
592
|
+
typeof toolInput.file_path === "string" ? toolInput.file_path : "<path>";
|
|
593
|
+
const tool = nudgeToolName("ashlr__multi_edit");
|
|
594
|
+
const variants = [
|
|
595
|
+
`[ashlr] ashlr__multi_edit applies all edits atomically and returns one diff summary. ` +
|
|
596
|
+
`${tool} { "edits": [{ "path": "${filePath}", "search": ..., "replace": ... }] }.`,
|
|
597
|
+
`[ashlr] Prefer ashlr__multi_edit — atomic apply + consolidated diff, ~60% token savings. ` +
|
|
598
|
+
`${tool} { "edits": [{ "path": "${filePath}", "search": ..., "replace": ... }] }.`,
|
|
599
|
+
];
|
|
600
|
+
const text = variants[nudgeVariantIndex(filePath, variants.length)];
|
|
601
|
+
return {
|
|
602
|
+
hookSpecificOutput: {
|
|
603
|
+
hookEventName: "PreToolUse",
|
|
604
|
+
additionalContext: text,
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
case "apply_patch":
|
|
609
|
+
case "functions.apply_patch": {
|
|
610
|
+
const text =
|
|
611
|
+
"[ashlr] For large or repeated file edits, consider ashlr__multi_edit after gathering the exact replacements. " +
|
|
612
|
+
"Keep apply_patch for direct patch application when it is already the shortest path.";
|
|
613
|
+
return {
|
|
614
|
+
hookSpecificOutput: {
|
|
615
|
+
hookEventName: "PreToolUse",
|
|
616
|
+
additionalContext: text,
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
case "Bash": {
|
|
621
|
+
const command =
|
|
622
|
+
typeof toolInput.command === "string" ? toolInput.command : "";
|
|
623
|
+
if (!command) return null;
|
|
624
|
+
// Only nudge for commands where ashlr__bash can actually compress output.
|
|
625
|
+
// Quiet commands (echo, pwd, mv, rm, simple greps) pass through silently
|
|
626
|
+
// so the agent isn't pestered on every shell call. The match is the
|
|
627
|
+
// same predicate ashlr__bash uses internally to decide whether to apply
|
|
628
|
+
// a structured summarizer (findSummarizer) or LLM diff compression
|
|
629
|
+
// (isLargeDiffCommand) — so a nudge here means "real savings are on
|
|
630
|
+
// the table for this exact command."
|
|
631
|
+
const matches =
|
|
632
|
+
findSummarizer(command) !== null || isLargeDiffCommand(command);
|
|
633
|
+
if (!matches) return null;
|
|
634
|
+
const tool = nudgeToolName("ashlr__bash");
|
|
635
|
+
const variants = [
|
|
636
|
+
`[ashlr] This shell command typically produces verbose output. ` +
|
|
637
|
+
`Prefer the MCP tool \`ashlr__bash\` — it auto-compresses long stdout ` +
|
|
638
|
+
`(head + tail with elided middle) and emits structured summaries for ` +
|
|
639
|
+
`common commands (git status/log, npm/bun install, test runners, ` +
|
|
640
|
+
`find, ls, ps, docker ps, tsc). Call it with { "command": ${JSON.stringify(command)} }.`,
|
|
641
|
+
`[ashlr] ashlr__bash compresses verbose stdout (structured summaries for git/npm/bun/tsc). ` +
|
|
642
|
+
`${tool} { "command": ${JSON.stringify(command)} }.`,
|
|
643
|
+
`[ashlr] Native Bash returns raw output; ashlr__bash applies head+tail compression + summaries. ` +
|
|
644
|
+
`${tool} { "command": ${JSON.stringify(command)} }.`,
|
|
645
|
+
];
|
|
646
|
+
const text = variants[nudgeVariantIndex(command, variants.length)];
|
|
647
|
+
return {
|
|
648
|
+
hookSpecificOutput: {
|
|
649
|
+
hookEventName: "PreToolUse",
|
|
650
|
+
additionalContext: text,
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
default:
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Return the file size in bytes, or null if the path isn't a regular file
|
|
661
|
+
* (doesn't exist, is a dir, permission denied, etc.). Never throws.
|
|
662
|
+
*/
|
|
663
|
+
export function fileSize(filePath: string): number | null {
|
|
664
|
+
try {
|
|
665
|
+
const st = statSync(filePath);
|
|
666
|
+
if (!st.isFile()) return null;
|
|
667
|
+
return st.size;
|
|
668
|
+
} catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Hook-timing telemetry
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
//
|
|
677
|
+
// Every PreToolUse hook is a separate subprocess spawned by Claude Code.
|
|
678
|
+
// Users who suspect hooks are slow (common on network-drive projects) have
|
|
679
|
+
// no way to see which hook is the culprit. These helpers append a single
|
|
680
|
+
// JSONL record per invocation to `~/.ashlr/hook-timings.jsonl` so
|
|
681
|
+
// `/ashlr-hook-timings` can surface real p50/p95 numbers.
|
|
682
|
+
//
|
|
683
|
+
// Design rules:
|
|
684
|
+
// - Never throw. Telemetry that breaks the hook is worse than no telemetry.
|
|
685
|
+
// - Respect `ASHLR_HOOK_TIMINGS=0` as a kill switch.
|
|
686
|
+
// - Cap record size — no arbitrary fields from the caller.
|
|
687
|
+
// - Async + batched: writes are queued in memory and flushed via
|
|
688
|
+
// setImmediate (end of current event loop turn) + a 1-second interval
|
|
689
|
+
// guard. On process exit the pending queue is drained synchronously so
|
|
690
|
+
// short-lived subprocess hooks always persist their record before dying.
|
|
691
|
+
|
|
692
|
+
/** Resolve the timings log path at call time so tests overriding HOME work. */
|
|
693
|
+
function hookTimingsPath(): string {
|
|
694
|
+
return join(process.env.HOME ?? homedir(), ".ashlr", "hook-timings.jsonl");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function timingsEnabled(): boolean {
|
|
698
|
+
return process.env.ASHLR_HOOK_TIMINGS !== "0";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// Async write batcher — collects JSONL lines and flushes them as a single
|
|
703
|
+
// appendFile call. setImmediate drains at end of the current event-loop turn;
|
|
704
|
+
// a 1-second interval is a safety net for long-running hooks.
|
|
705
|
+
// On process exit, any remaining lines are written synchronously so that
|
|
706
|
+
// short-lived subprocess hooks (which call process.exit immediately after
|
|
707
|
+
// recording) always persist their record.
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
let _pending: Array<{ path: string; line: string }> = [];
|
|
710
|
+
let _flushScheduled = false;
|
|
711
|
+
|
|
712
|
+
const HOOK_TIMING_TEXT_MAX_BYTES = 512;
|
|
713
|
+
const HOOK_TIMING_RECORD_MAX_BYTES = 4096;
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Drain the pending queue into a path→concatenated-lines map. Each entry
|
|
717
|
+
* captured its target path at RECORD time (recordHookTiming), so a deferred
|
|
718
|
+
* flush — or a concurrent test that swapped process.env.HOME — can never
|
|
719
|
+
* misroute it. In prod HOME never changes, so this is a single-entry map and
|
|
720
|
+
* behaviour is identical to the old join(); it only matters for test isolation
|
|
721
|
+
* (Bun runs test files in one process; deferred flushes used to leak across
|
|
722
|
+
* files via the shared HOME).
|
|
723
|
+
*/
|
|
724
|
+
function _takePendingByPath(): Map<string, string[]> {
|
|
725
|
+
const byPath = new Map<string, string[]>();
|
|
726
|
+
for (const { path, line } of _pending) {
|
|
727
|
+
const batches = byPath.get(path) ?? [];
|
|
728
|
+
const last = batches[batches.length - 1];
|
|
729
|
+
if (
|
|
730
|
+
last === undefined ||
|
|
731
|
+
Buffer.byteLength(last, "utf8") + Buffer.byteLength(line, "utf8") > HOOK_TIMING_BATCH_MAX_BYTES
|
|
732
|
+
) batches.push(line);
|
|
733
|
+
else batches[batches.length - 1] = last + line;
|
|
734
|
+
byPath.set(path, batches);
|
|
735
|
+
}
|
|
736
|
+
_pending = [];
|
|
737
|
+
return byPath;
|
|
738
|
+
}
|
|
739
|
+
let _flushInterval: ReturnType<typeof setInterval> | null = null;
|
|
740
|
+
let _exitHandlerInstalled = false;
|
|
741
|
+
let _activeFlush: Promise<void> | null = null;
|
|
742
|
+
|
|
743
|
+
interface TimingWorkerResponse {
|
|
744
|
+
contended: Array<{ path: string; batch: string }>;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function _requeueBatches(entries: Array<{ path: string; batch: string }>): void {
|
|
748
|
+
if (entries.length === 0) return;
|
|
749
|
+
_pending = [
|
|
750
|
+
...entries.map(({ path, batch }) => ({ path, line: batch })),
|
|
751
|
+
..._pending,
|
|
752
|
+
];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function _runTimingWorker(entries: Map<string, string[]>): Promise<TimingWorkerResponse> {
|
|
756
|
+
return new Promise((resolve) => {
|
|
757
|
+
let settled = false;
|
|
758
|
+
const all = [...entries].flatMap(([path, batches]) =>
|
|
759
|
+
batches.map((batch) => ({ path, batch })),
|
|
760
|
+
);
|
|
761
|
+
const finish = (response: TimingWorkerResponse) => {
|
|
762
|
+
if (settled) return;
|
|
763
|
+
settled = true;
|
|
764
|
+
resolve(response);
|
|
765
|
+
};
|
|
766
|
+
try {
|
|
767
|
+
const workerUrl = new URL("./_hook-timing-ledger.ts", import.meta.url);
|
|
768
|
+
workerUrl.searchParams.set("hookTimingWorker", "1");
|
|
769
|
+
const worker = new Worker(workerUrl.href);
|
|
770
|
+
worker.onmessage = (event: MessageEvent<TimingWorkerResponse>) => {
|
|
771
|
+
const response = event.data;
|
|
772
|
+
void worker.terminate();
|
|
773
|
+
finish(response);
|
|
774
|
+
};
|
|
775
|
+
worker.onerror = () => {
|
|
776
|
+
void worker.terminate();
|
|
777
|
+
finish({ contended: all });
|
|
778
|
+
};
|
|
779
|
+
worker.postMessage({
|
|
780
|
+
entries: [...entries].map(([path, batches]) => ({ path, batches })),
|
|
781
|
+
});
|
|
782
|
+
} catch {
|
|
783
|
+
finish({ contended: all });
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function _writeEntries(entries: Map<string, string[]>): Promise<void> {
|
|
789
|
+
const prior = _activeFlush;
|
|
790
|
+
const current = (async () => {
|
|
791
|
+
if (prior) await prior;
|
|
792
|
+
const response = await _runTimingWorker(entries);
|
|
793
|
+
_requeueBatches(response.contended);
|
|
794
|
+
})();
|
|
795
|
+
_activeFlush = current;
|
|
796
|
+
void current.finally(() => {
|
|
797
|
+
if (_activeFlush === current) _activeFlush = null;
|
|
798
|
+
});
|
|
799
|
+
return current;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function _drainPending(maxAttempts: number): Promise<void> {
|
|
803
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
804
|
+
if (_activeFlush) await _activeFlush;
|
|
805
|
+
if (_pending.length === 0) return;
|
|
806
|
+
await _writeEntries(_takePendingByPath());
|
|
807
|
+
if (_pending.length === 0) return;
|
|
808
|
+
await new Promise((done) => setTimeout(done, 10));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function _ensureFlushInterval(): void {
|
|
813
|
+
if (_flushInterval !== null) return;
|
|
814
|
+
_flushInterval = setInterval(_flushBatchAsync, 1000);
|
|
815
|
+
// Don't keep the process alive for the interval alone.
|
|
816
|
+
if (typeof _flushInterval.unref === "function") _flushInterval.unref();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function _ensureExitHandler(): void {
|
|
820
|
+
if (_exitHandlerInstalled) return;
|
|
821
|
+
_exitHandlerInstalled = true;
|
|
822
|
+
// process.on("exit") fires synchronously on process.exit(); it is the
|
|
823
|
+
// last chance to do synchronous I/O before the process dies. We drain
|
|
824
|
+
// any pending lines here so subprocess hooks that call process.exit(0)
|
|
825
|
+
// immediately after recordHookTiming() always write their record.
|
|
826
|
+
process.on("exit", () => {
|
|
827
|
+
if (_pending.length === 0) return;
|
|
828
|
+
for (const [path, batches] of _takePendingByPath()) {
|
|
829
|
+
for (const batch of batches) appendHookTimingBatch(path, batch, { lockWaitMs: 0 });
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function _flushBatchAsync(): void {
|
|
835
|
+
_flushScheduled = false;
|
|
836
|
+
if (_pending.length === 0) return;
|
|
837
|
+
void _drainPending(1);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
841
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
842
|
+
let result = "";
|
|
843
|
+
let bytes = 0;
|
|
844
|
+
for (const character of value) {
|
|
845
|
+
const size = Buffer.byteLength(character, "utf8");
|
|
846
|
+
if (bytes + size > maxBytes) break;
|
|
847
|
+
result += character;
|
|
848
|
+
bytes += size;
|
|
849
|
+
}
|
|
850
|
+
return result;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function serializeHookTiming(record: {
|
|
854
|
+
hook: string;
|
|
855
|
+
tool?: string;
|
|
856
|
+
durationMs: number;
|
|
857
|
+
outcome: HookOutcome;
|
|
858
|
+
}): string {
|
|
859
|
+
let hook = truncateUtf8(String(record.hook), HOOK_TIMING_TEXT_MAX_BYTES);
|
|
860
|
+
let tool = record.tool === undefined
|
|
861
|
+
? null
|
|
862
|
+
: truncateUtf8(String(record.tool), HOOK_TIMING_TEXT_MAX_BYTES);
|
|
863
|
+
const durationMs = Number.isFinite(record.durationMs)
|
|
864
|
+
? Math.max(0, Math.round(record.durationMs))
|
|
865
|
+
: 0;
|
|
866
|
+
const outcome: HookOutcome = (
|
|
867
|
+
record.outcome === "ok" ||
|
|
868
|
+
record.outcome === "bypass" ||
|
|
869
|
+
record.outcome === "block" ||
|
|
870
|
+
record.outcome === "error" ||
|
|
871
|
+
record.outcome === "timeout"
|
|
872
|
+
) ? record.outcome : "error";
|
|
873
|
+
const makeLine = () => JSON.stringify({
|
|
874
|
+
ts: new Date().toISOString(),
|
|
875
|
+
hook,
|
|
876
|
+
tool,
|
|
877
|
+
durationMs,
|
|
878
|
+
outcome,
|
|
879
|
+
}) + "\n";
|
|
880
|
+
let line = makeLine();
|
|
881
|
+
while (Buffer.byteLength(line, "utf8") > HOOK_TIMING_RECORD_MAX_BYTES) {
|
|
882
|
+
hook = truncateUtf8(hook, Math.floor(Buffer.byteLength(hook, "utf8") * 0.75));
|
|
883
|
+
if (tool !== null) {
|
|
884
|
+
tool = truncateUtf8(tool, Math.floor(Buffer.byteLength(tool, "utf8") * 0.75));
|
|
885
|
+
}
|
|
886
|
+
line = makeLine();
|
|
887
|
+
}
|
|
888
|
+
return line;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Record one hook invocation to the timings log. Non-blocking — the write is
|
|
893
|
+
* queued and flushed asynchronously (or synchronously on process exit). Never
|
|
894
|
+
* throws.
|
|
895
|
+
*/
|
|
896
|
+
export type HookOutcome = "ok" | "bypass" | "block" | "error" | "timeout";
|
|
897
|
+
|
|
898
|
+
export function recordHookTiming(record: {
|
|
899
|
+
hook: string;
|
|
900
|
+
tool?: string;
|
|
901
|
+
durationMs: number;
|
|
902
|
+
outcome: HookOutcome;
|
|
903
|
+
}): void {
|
|
904
|
+
if (!timingsEnabled()) return;
|
|
905
|
+
try {
|
|
906
|
+
const line = serializeHookTiming(record);
|
|
907
|
+
_pending.push({ path: hookTimingsPath(), line });
|
|
908
|
+
_ensureFlushInterval();
|
|
909
|
+
_ensureExitHandler();
|
|
910
|
+
if (!_flushScheduled) {
|
|
911
|
+
_flushScheduled = true;
|
|
912
|
+
setImmediate(_flushBatchAsync);
|
|
913
|
+
}
|
|
914
|
+
} catch {
|
|
915
|
+
/* swallow */
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Flush any pending timing lines. Exposed for tests that need to assert on
|
|
921
|
+
* file contents without waiting for the async batcher or process exit.
|
|
922
|
+
* Returns a Promise that resolves once the write completes (or immediately if
|
|
923
|
+
* the queue is empty).
|
|
924
|
+
*/
|
|
925
|
+
export async function flushHookTimings(): Promise<void> {
|
|
926
|
+
_flushScheduled = false;
|
|
927
|
+
await _drainPending(3);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Wrap a hook's main function in timing instrumentation. The callback returns
|
|
932
|
+
* the hook's outcome classification so the timings log distinguishes
|
|
933
|
+
* "took 2ms and did nothing" from "took 300ms and blocked the call".
|
|
934
|
+
*/
|
|
935
|
+
export async function withHookTiming<T>(
|
|
936
|
+
hookName: string,
|
|
937
|
+
fn: () => Promise<{ value: T; outcome: HookOutcome; tool?: string }>,
|
|
938
|
+
): Promise<T> {
|
|
939
|
+
const start = Date.now();
|
|
940
|
+
try {
|
|
941
|
+
const { value, outcome, tool } = await fn();
|
|
942
|
+
recordHookTiming({ hook: hookName, tool, durationMs: Date.now() - start, outcome });
|
|
943
|
+
return value;
|
|
944
|
+
} catch (err) {
|
|
945
|
+
recordHookTiming({ hook: hookName, durationMs: Date.now() - start, outcome: "error" });
|
|
946
|
+
throw err;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Install a top-level safety timeout for a hook. If the hook hasn't exited
|
|
952
|
+
* within `timeoutMs`, log a timeout event and force-exit 0 so the user's
|
|
953
|
+
* tool call isn't blocked indefinitely.
|
|
954
|
+
*
|
|
955
|
+
* Call this once at the top of each hook's entry script, before any I/O.
|
|
956
|
+
* The timer is unref'd so it never extends the hook's natural lifetime —
|
|
957
|
+
* if the hook completes on its own, the timeout silently goes away.
|
|
958
|
+
*
|
|
959
|
+
* Env override: `ASHLR_HOOK_TIMEOUT_MS` (per-process). Default 2000ms.
|
|
960
|
+
*/
|
|
961
|
+
export function installHookTimeout(hookName: string, timeoutMs?: number): void {
|
|
962
|
+
const envMs = Number(process.env.ASHLR_HOOK_TIMEOUT_MS);
|
|
963
|
+
const ms = timeoutMs ?? (Number.isFinite(envMs) && envMs > 0 ? envMs : 2000);
|
|
964
|
+
const start = Date.now();
|
|
965
|
+
const timer = setTimeout(() => {
|
|
966
|
+
try {
|
|
967
|
+
process.stderr.write(
|
|
968
|
+
`[ashlr ${hookName}] hook timeout — exceeded ${ms}ms, forcing exit 0\n`,
|
|
969
|
+
);
|
|
970
|
+
recordHookTiming({
|
|
971
|
+
hook: hookName,
|
|
972
|
+
durationMs: Date.now() - start,
|
|
973
|
+
outcome: "timeout",
|
|
974
|
+
});
|
|
975
|
+
} catch {
|
|
976
|
+
/* never throw from the safety net */
|
|
977
|
+
}
|
|
978
|
+
// flushHookTimings is async; give it a tick, then exit unconditionally.
|
|
979
|
+
void flushHookTimings().finally(() => process.exit(0));
|
|
980
|
+
// Belt-and-suspenders: if the flush hangs too, kill the process after 200ms.
|
|
981
|
+
const killer = setTimeout(() => process.exit(0), 200);
|
|
982
|
+
if (typeof killer.unref === "function") killer.unref();
|
|
983
|
+
}, ms);
|
|
984
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
985
|
+
}
|