@wrongstack/plugins 0.280.1 → 0.281.1

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/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @wrongstack/plugins
2
2
 
3
3
  First-party plugin collection for [WrongStack](https://github.com/WrongStack/WrongStack).
4
- Twenty-one focused, single-purpose plugins ship in this package and load
5
- automatically for every `wstack` session.
4
+ Thirty-six focused, single-purpose plugins ship in this package. Core safety
5
+ plugins load automatically for every `wstack` session; provider-wire plugins
6
+ are opt-in because they can change model-call semantics.
6
7
 
7
8
  ## What this is
8
9
 
@@ -23,25 +24,40 @@ under the `BUILTIN_PLUGIN_FACTORIES` array. To opt out, add
23
24
  |---|---|---|---|---|
24
25
  | 1 | [`auto-doc`](./src/auto-doc) | `auto_doc` | — | JSDoc/TSDoc generation with `dry_run` preview |
25
26
  | 2 | [`git-autocommit`](./src/git-autocommit) | `git_autocommit` | — | AI-written conventional commits; warns on simultaneous worktrees |
26
- | 3 | [`shell-check`](./src/shell-check) | `shellcheck` | — | Runs `shellcheck` on files OR directories (recursive scan) |
27
- | 4 | [`cost-tracker`](./src/cost-tracker) | `cost_summary`, `cost_reset`, `cost_export` | — | Per-model token + USD tracking; reads from `api.modelsRegistry` (models.dev) with a `pricingOverrides` config escape hatch |
28
- | 5 | [`file-watcher`](./src/file-watcher) | `watch_start`, `watch_stop`, `watch_list` | — | Filesystem event hook (chokidar); feeds the `dep-watcher` bridge in the CLI |
29
- | 6 | [`cron`](./src/cron) | `cron_schedule`, `cron_list`, `cron_cancel` | — | In-session recurring tasks; lifecycle via `beforeIteration` |
30
- | 7 | [`template-engine`](./src/template-engine) | `template_expand`, `template_render`, `template_create`, `template_list` | — | Handlebars-style `{{var}}`, `{{#if}}`, `{{#each}}` |
31
- | 8 | [`semver-bump`](./src/semver-bump) | `semver_bump`, `semver_current`, `semver_changelog` | — | Conventional-commit semver version bump; can tag |
32
- | 9 | [`secret-scanner`](./src/secret-scanner) | `secret_scanner_status`, `secret_scanner_test` | `PreToolUse` (`bash\|write\|edit`) + `PostToolUse` (`*`) | Blocks/redacts input secrets; warns on output leaks |
33
- | 10 | [`todo-tracker`](./src/todo-tracker) | `todo_tracker_list/add/complete/drop/remove/pull/status` | — | Persistent project-scoped backlog that survives across sessions; cross-session bridge via `todo_tracker_pull` |
34
- | 11 | [`token-budget`](./src/token-budget) | `token_budget_status` | `Stop` | Enforces a per-session token budget warns at `warnPercent`, stops agent loop at `stopPercent` |
35
- | 12 | [`lint-gate`](./src/lint-gate) | `lint_gate_status` | `PreToolUse` (`write\|edit`) | Runs biome/eslint on would-be file content before write or edit commits; blocks or warns on lint issues |
36
- | 13 | [`branch-guard`](./src/branch-guard) | `branch_guard_status` | `PreToolUse` (`bash\|git_autocommit`) | Blocks commits, pushes, and merges to protected branches (default: main, master) |
37
- | 14 | [`diff-summary`](./src/diff-summary) | `diff_summary_status` | `PostToolUse` (`write\|edit`) | Injects compact git diff into LLM context after every write or edit |
38
- | 15 | [`commit-validator`](./src/commit-validator) | `commit_validator_status` | `PreToolUse` (`bash\|git_autocommit`) | Validates conventional-commit format before git_autocommit or bash git commit runs |
39
- | 16 | [`format-on-save`](./src/format-on-save) | `format_on_save_status` | `PostToolUse` (`write\|edit`) | Runs `biome format --write` on the file after every write or edit |
40
- | 17 | [`test-runner-gate`](./src/test-runner-gate) | `test_gate_status` | `PostToolUse` (`write\|edit`) | Runs the relevant test file after every write or edit to a source file |
41
- | 18 | [`import-organizer`](./src/import-organizer) | `import_organizer_status` | `PostToolUse` (`write\|edit`) | Runs `biome check --write --unsafe` (or `eslint --fix`) on the file after write or edit, re-sorting imports and applying safe fixes |
42
- | 19 | [`todo-listener`](./src/todo-listener) | `todo_listener_status` | `PostToolUse` (`todo`) | Broadcasts a status update to the project mailbox whenever the `todo` tool is called, so other agents can see what this one is working on |
43
- | 20 | [`session-recap`](./src/session-recap) | `session_recap_status` | `Stop` | Posts a one-page session summary (tokens, tool calls, commits, last activity) to the project mailbox when the agent loop ends |
44
- | 21 | [`spec-linker`](./src/spec-linker) | `spec_linker_status` | `PostToolUse` (`write\|edit`) | Scans markdown files for unlinked plugin references and surfaces them to the LLM via additionalContext |
27
+ | 3 | [`shell-check`](./src/shell-check) | `shellcheck` | — | Runs `shellcheck` on files or directories |
28
+ | 4 | [`cost-tracker`](./src/cost-tracker) | `cost_summary`, `cost_reset`, `cost_export` | — | Tracks per-model token usage and estimated USD cost |
29
+ | 5 | [`file-watcher`](./src/file-watcher) | `watch_start`, `watch_stop`, `watch_list` | — | Watches project files and emits debounced change events |
30
+ | 6 | [`cron`](./src/cron) | `cron_schedule`, `cron_list`, `cron_cancel` | — | Schedules recurring in-session actions |
31
+ | 7 | [`template-engine`](./src/template-engine) | `template_expand`, `template_render`, `template_create`, `template_list` | — | Expands templates with variables, conditionals, and loops |
32
+ | 8 | [`semver-bump`](./src/semver-bump) | `semver_bump`, `semver_current`, `semver_changelog` | — | Infers version bumps and changelogs from conventional commits |
33
+ | 9 | [`secret-scanner`](./src/secret-scanner) | `secret_scanner_status`, `secret_scanner_test` | `PreToolUse` + `PostToolUse` | Blocks/redacts input secrets and warns on output leaks |
34
+ | 10 | [`todo-tracker`](./src/todo-tracker) | `todo_tracker_*` | — | Persistent project-scoped backlog that survives sessions |
35
+ | 11 | [`token-budget`](./src/token-budget) | `token_budget_status` | `Stop` + `PostToolUse` | Tracks token use and can warn/stop at configured limits |
36
+ | 12 | [`lint-gate`](./src/lint-gate) | `lint_gate_status` | `PreToolUse` | Lints would-be write/edit content before mutation |
37
+ | 13 | [`branch-guard`](./src/branch-guard) | `branch_guard_status` | `PreToolUse` | Blocks or warns on commits, pushes, and merges on protected branches |
38
+ | 14 | [`diff-summary`](./src/diff-summary) | `diff_summary_status` | `PostToolUse` | Injects compact git diff context after write/edit |
39
+ | 15 | [`commit-validator`](./src/commit-validator) | `commit_validator_status` | `PreToolUse` | Validates conventional-commit messages |
40
+ | 16 | [`format-on-save`](./src/format-on-save) | `format_on_save_status` | `PostToolUse` | Runs formatter after write/edit |
41
+ | 17 | [`test-runner-gate`](./src/test-runner-gate) | `test_gate_status` | `PostToolUse` | Runs relevant tests after source edits |
42
+ | 18 | [`import-organizer`](./src/import-organizer) | `import_organizer_status` | `PostToolUse` | Organizes imports and applies safe fixes after write/edit |
43
+ | 19 | [`todo-listener`](./src/todo-listener) | `todo_listener_status` | `PostToolUse` | Broadcasts todo status changes to the project mailbox |
44
+ | 20 | [`session-recap`](./src/session-recap) | `session_recap_status` | `Stop` | Posts a compact session recap to the project mailbox |
45
+ | 21 | [`spec-linker`](./src/spec-linker) | `spec_linker_status` | `PreToolUse` + `PostToolUse` | Detects unlinked plugin references in markdown docs |
46
+ | 22 | [`loop-breaker`](./src/loop-breaker) | `loop_breaker_status` | `PreToolUse` | Detects repeated tool-call loops and oscillations |
47
+ | 23 | [`path-guard`](./src/path-guard) | `path_guard_status` | `PreToolUse` | Guards protected paths from writes and destructive shell commands |
48
+ | 24 | [`context-pins`](./src/context-pins) | `pin_add`, `pin_remove`, `pin_list` | System prompt contributor | Persists short pinned facts across compaction/session boundaries |
49
+ | 25 | [`checkpoint`](./src/checkpoint) | `checkpoint_create`, `checkpoint_list`, `checkpoint_restore` | `PreToolUse` | Captures file snapshots before risky edits and restores them on demand |
50
+ | 26 | [`error-lens`](./src/error-lens) | `error_lens_history` | `PostToolUse` | Summarizes failed tool output and repeated failure patterns |
51
+ | 27 | [`dep-guard`](./src/dep-guard) | `dep_guard_status` | `PreToolUse` | Supervises dependency installs for deny-list and supply-chain warnings |
52
+ | 28 | [`config-validator`](./src/config-validator) | `config_validator_status` | `PostToolUse` | Validates edited JSON/JSONC/YAML/TOML config files |
53
+ | 29 | [`notify-hub`](./src/notify-hub) | `notify_send`, `notify_hub_status` | `Stop` + event subscriptions | Sends session/tool notifications to a configured webhook |
54
+ | 30 | [`changelog-writer`](./src/changelog-writer) | `changelog_add`, `changelog_preview`, `changelog_write` | — | Collects and writes Keep-a-Changelog entries |
55
+ | 31 | [`injection-shield`](./src/injection-shield) | `injection_shield_status` | `PostToolUse` | Warns when tool output contains prompt-injection patterns |
56
+ | 32 | [`llm-cache`](./src/llm-cache) | `llm_cache_status`, `llm_cache_clear` | Provider runner wrapper | Opt-in cache for deterministic provider requests |
57
+ | 33 | [`model-router`](./src/model-router) | `model_router_status` | Provider runner wrapper | Opt-in routing of provider calls by declarative rules |
58
+ | 34 | [`prompt-firewall`](./src/prompt-firewall) | `prompt_firewall_status` | Provider runner wrapper | Opt-in provider-wire prompt/secret scanning and redaction/blocking |
59
+ | 35 | [`auto-escalate`](./src/auto-escalate) | `auto_escalate_status` | `onError` extension | Opt-in retry/escalation ladder for transient provider errors |
60
+ | 36 | [`token-throttle`](./src/token-throttle) | `token_throttle_status` | Provider runner wrapper | Opt-in rolling-window token throttling for provider calls |
45
61
 
46
62
  ### Removed plugins (use built-in tools instead)
47
63
 
@@ -101,7 +117,7 @@ with a bundled `PRICING` table as the baseline (Layer 3) and a
101
117
  `pricingOverrides` config field as the top-priority escape hatch
102
118
  (Layer 1). The `pricingOverrides` field is the user-facing tool for
103
119
  correcting a specific model's price without waiting for a plugin
104
- release. See [cost-tracker source](./src/cost-tracker/index.ts) for
120
+ release. See the [`cost-tracker`](./src/cost-tracker) source for
105
121
  the full lookup chain.
106
122
 
107
123
  ```jsonc
@@ -174,7 +190,7 @@ has already run, the hook cannot block — instead it injects
174
190
  `additionalContext` so the LLM knows not to echo, store, or commit
175
191
  the leaked value.
176
192
 
177
- Three modes (`config.extensions['secret-scanner'].mode`):
193
+ Three modes (`config.extensions['secret-scanner'].mode`) for [`secret-scanner`](./src/secret-scanner):
178
194
  - **`block` (default)**: returns `HookOutcome{ decision: 'block', reason }`
179
195
  - **`redact`**: returns `HookOutcome{ decision: 'allow', modifiedInput, additionalContext }` with the offending strings replaced by `[REDACTED:type]`
180
196
  - **`allow`**: only logs; never blocks
@@ -246,7 +262,7 @@ mutates `ctx.todos`). The plugin never touches `ctx.todos` directly
246
262
 
247
263
  **Storage**: per-project JSON at the path provided by
248
264
  `paths.projectDir` (via the host's wiring) or via the explicit
249
- `config.extensions["todo-tracker"].filePath` config field.
265
+ [`todo-tracker`](./src/todo-tracker) `filePath` config field.
250
266
 
251
267
  ```jsonc
252
268
  // Explicit override (use when the host doesn't supply paths.projectDir)
@@ -451,9 +467,10 @@ the file was reformatted.
451
467
  ```
452
468
 
453
469
  Biome detection runs once at setup(). If biome is not installed, the
454
- hook is a silent no-op. Works alongside lint-gate (which lints BEFORE
455
- the write) and diff-summary (which shows the diff AFTER) — together
456
- they form a complete write pipeline: lint write format → diff.
470
+ hook is a silent no-op. Works alongside [`lint-gate`](./src/lint-gate)
471
+ (which lints BEFORE the write) and [`diff-summary`](./src/diff-summary)
472
+ (which shows the diff AFTER) together they form a complete write
473
+ pipeline: lint → write → format → diff.
457
474
 
458
475
  ### 17. `test-runner-gate` — automatic test execution
459
476
 
@@ -655,7 +672,7 @@ context. Increase `includeTranscriptTail` for more history.
655
672
  Two hooks working together:
656
673
 
657
674
  **PostToolUse** (always active when the plugin is enabled):
658
- scans markdown files for *unlinked* references to one of the 21
675
+ scans markdown files for *unlinked* references to one of the 36
659
676
  known plugins and surfaces them to the LLM via
660
677
  `additionalContext`. The plugin does NOT modify the file — it
661
678
  only injects a low-noise context block listing the unlinked
@@ -679,7 +696,7 @@ the PostToolUse context tells the LLM what to fix.
679
696
 
680
697
  **Detection rules** (both hooks):
681
698
  - Source matches `config.fileGlobs` (default: `**/*.md`, `**/*.mdx`)
682
- - The reference matches one of the 21 known plugin names
699
+ - The reference matches one of the 36 known plugin names
683
700
  (case-insensitive)
684
701
  - It is NOT already wrapped in a markdown link `[name](...)` or
685
702
  inline code `` `name` ``
@@ -713,6 +730,281 @@ LLM (or the user) decide whether to fix it, instead of
713
730
  silently rewriting the file on every save. Opt into `autoFix`
714
731
  once you're confident in the rewrite.
715
732
 
733
+ ### 22 — `loop-breaker`
734
+
735
+ **Tools**: `loop_breaker_status`
736
+ **Hooks**: `PreToolUse`
737
+
738
+ Detects repeated tool calls before they execute. It tracks exact-repeat
739
+ streaks and A-B-A-B oscillations, then either blocks the call or injects a
740
+ warning depending on `mode`.
741
+
742
+ ```jsonc
743
+ {
744
+ "extensions": {
745
+ "loop-breaker": {
746
+ "enabled": true,
747
+ "mode": "block", // "block" | "warn"
748
+ "repeatThreshold": 3,
749
+ "oscillationThreshold": 2
750
+ }
751
+ }
752
+ }
753
+ ```
754
+
755
+ ### 23 — `path-guard`
756
+
757
+ **Tools**: `path_guard_status`
758
+ **Hooks**: `PreToolUse`
759
+
760
+ Protects sensitive paths from accidental writes/edits and destructive shell
761
+ commands. Defaults cover lockfiles, `.env`, `.git`, and migration-like paths;
762
+ use `mode: "warn"` for advisory-only enforcement.
763
+
764
+ ```jsonc
765
+ {
766
+ "extensions": {
767
+ "path-guard": {
768
+ "enabled": true,
769
+ "mode": "block",
770
+ "protected": [".env*", ".git/**", "**/migrations/**"]
771
+ }
772
+ }
773
+ }
774
+ ```
775
+
776
+ ### 24 — `context-pins`
777
+
778
+ **Tools**: `pin_add`, `pin_remove`, `pin_list`
779
+ **Hooks**: system prompt contributor
780
+
781
+ Stores short durable facts and injects them into the system prompt so they
782
+ survive context compaction. When the host supplies a project directory, pins
783
+ persist to a per-project JSON file; otherwise the plugin falls back to
784
+ in-memory state.
785
+
786
+ ```jsonc
787
+ pin_add({ label: "api", text: "Use the v2 billing endpoint for invoices." })
788
+ ```
789
+
790
+ ### 25 — `checkpoint`
791
+
792
+ **Tools**: `checkpoint_create`, `checkpoint_list`, `checkpoint_restore`
793
+ **Hooks**: `PreToolUse`
794
+
795
+ Captures file snapshots before risky write/edit operations and lets the agent
796
+ restore a snapshot later. Restore never deletes files that did not exist at
797
+ capture time; it reports them instead.
798
+
799
+ ```jsonc
800
+ checkpoint_create({ paths: ["src/service.ts"], label: "before refactor" })
801
+ ```
802
+
803
+ ### 26 — `error-lens`
804
+
805
+ **Tools**: `error_lens_history`
806
+ **Hooks**: `PostToolUse`
807
+
808
+ Distills failed command/tool output into compact diagnostics: likely error
809
+ line, source frames, repeat count, and a capped history for follow-up
810
+ inspection.
811
+
812
+ ```jsonc
813
+ {
814
+ "extensions": {
815
+ "error-lens": { "enabled": true, "maxFrames": 5, "historyLimit": 10 }
816
+ }
817
+ }
818
+ ```
819
+
820
+ ### 27 — `dep-guard`
821
+
822
+ **Tools**: `dep_guard_status`
823
+ **Hooks**: `PreToolUse`
824
+
825
+ Supervises dependency installs before they run. It can block deny-listed
826
+ packages, warn on typosquat lookalikes, and flag unpinned versions or risky
827
+ install sources.
828
+
829
+ ```jsonc
830
+ {
831
+ "extensions": {
832
+ "dep-guard": {
833
+ "enabled": true,
834
+ "mode": "block",
835
+ "deny": ["event-stream"],
836
+ "warnUnpinned": true
837
+ }
838
+ }
839
+ }
840
+ ```
841
+
842
+ ### 28 — `config-validator`
843
+
844
+ **Tools**: `config_validator_status`
845
+ **Hooks**: `PostToolUse`
846
+
847
+ Validates edited config files immediately after write/edit. Supported formats
848
+ include JSON, JSONC, YAML, and TOML-like files depending on extension.
849
+
850
+ ```jsonc
851
+ {
852
+ "extensions": {
853
+ "config-validator": {
854
+ "enabled": true,
855
+ "extensions": [".json", ".jsonc", ".yaml", ".yml", ".toml"]
856
+ }
857
+ }
858
+ }
859
+ ```
860
+
861
+ ### 29 — `notify-hub`
862
+
863
+ **Tools**: `notify_send`, `notify_hub_status`
864
+ **Hooks**: `Stop` + event subscriptions
865
+
866
+ Sends compact JSON notifications to a configured webhook for session stops,
867
+ tool errors, budget threshold events, and manual `notify_send` calls. Empty
868
+ `webhookUrl` means the plugin idles.
869
+
870
+ ```jsonc
871
+ {
872
+ "extensions": {
873
+ "notify-hub": {
874
+ "webhookUrl": "https://hooks.example.test/wrongstack",
875
+ "events": ["session.stop", "tool.error"],
876
+ "timeoutMs": 5000
877
+ }
878
+ }
879
+ }
880
+ ```
881
+
882
+ ### 30 — `changelog-writer`
883
+
884
+ **Tools**: `changelog_add`, `changelog_preview`, `changelog_write`
885
+
886
+ Collects pending Keep-a-Changelog entries during a session and writes them
887
+ under `## [Unreleased]`. It can also collect conventional-commit subjects and
888
+ map them to changelog sections.
889
+
890
+ ```jsonc
891
+ changelog_add({ section: "Fixed", text: "Prevent template path traversal." })
892
+ ```
893
+
894
+ ### 31 — `injection-shield`
895
+
896
+ **Tools**: `injection_shield_status`
897
+ **Hooks**: `PostToolUse`
898
+
899
+ Scans tool output for prompt-injection patterns such as attempts to override
900
+ instructions, exfiltrate secrets, or treat retrieved content as higher-priority
901
+ commands. It warns via additional context; it does not modify tool output.
902
+
903
+ ```jsonc
904
+ {
905
+ "extensions": {
906
+ "injection-shield": { "enabled": true, "tools": "*", "maxFindings": 5 }
907
+ }
908
+ }
909
+ ```
910
+
911
+ ### 32 — `llm-cache`
912
+
913
+ **Tools**: `llm_cache_status`, `llm_cache_clear`
914
+ **Hooks**: provider runner wrapper (opt-in)
915
+
916
+ Caches identical deterministic provider requests and short-circuits the
917
+ provider call on a hit. It is disabled by default because caching changes
918
+ provider-call semantics; sampled/high-temperature calls are intentionally not
919
+ cached.
920
+
921
+ ```jsonc
922
+ {
923
+ "extensions": {
924
+ "llm-cache": { "enabled": true, "maxEntries": 256, "ttlMs": 300000 }
925
+ }
926
+ }
927
+ ```
928
+
929
+ ### 33 — `model-router`
930
+
931
+ **Tools**: `model_router_status`
932
+ **Hooks**: provider runner wrapper (opt-in)
933
+
934
+ Routes provider calls to alternate models based on declarative rules such as
935
+ input size, tool usage, or dry-run mode. Disabled by default because it changes
936
+ which model serves a turn.
937
+
938
+ ```jsonc
939
+ {
940
+ "extensions": {
941
+ "model-router": {
942
+ "enabled": true,
943
+ "dryRun": false,
944
+ "rules": []
945
+ }
946
+ }
947
+ }
948
+ ```
949
+
950
+ ### 34 — `prompt-firewall`
951
+
952
+ **Tools**: `prompt_firewall_status`
953
+ **Hooks**: provider runner wrapper (opt-in)
954
+
955
+ Scans provider requests and, optionally, responses for credential leaks and
956
+ prompt-risk patterns. Modes are `warn`, `redact`, and `block`; the plugin is
957
+ disabled by default because redact/block can alter or stop provider calls.
958
+
959
+ ```jsonc
960
+ {
961
+ "extensions": {
962
+ "prompt-firewall": { "enabled": true, "mode": "redact", "scanResponse": true }
963
+ }
964
+ }
965
+ ```
966
+
967
+ ### 35 — `auto-escalate`
968
+
969
+ **Tools**: `auto_escalate_status`
970
+ **Hooks**: `onError` extension (opt-in)
971
+
972
+ Retries transient provider failures with the next model in a configured
973
+ escalation ladder. Disabled by default because it changes error-recovery
974
+ behavior and can increase cost.
975
+
976
+ ```jsonc
977
+ {
978
+ "extensions": {
979
+ "auto-escalate": {
980
+ "enabled": true,
981
+ "escalation": ["gpt-5-mini", "gpt-5.5"]
982
+ }
983
+ }
984
+ }
985
+ ```
986
+
987
+ ### 36 — `token-throttle`
988
+
989
+ **Tools**: `token_throttle_status`
990
+ **Hooks**: provider runner wrapper (opt-in)
991
+
992
+ Applies a rolling-window token/minute budget to provider calls. When the next
993
+ request would exceed the configured rate, the wrapper delays the call instead
994
+ of failing it.
995
+
996
+ ```jsonc
997
+ {
998
+ "extensions": {
999
+ "token-throttle": {
1000
+ "enabled": true,
1001
+ "tokensPerMinute": 120000,
1002
+ "maxDelayMs": 30000
1003
+ }
1004
+ }
1005
+ }
1006
+ ```
1007
+
716
1008
  ## Configuration patterns
717
1009
 
718
1010
  There are two surfaces for plugin configuration:
@@ -747,11 +1039,10 @@ To disable a single built-in without removing its config:
747
1039
 
748
1040
  ## H1 audit pattern
749
1041
 
750
- Plugins that hold module-scope state (`cron`, `file-watcher`,
751
- `template-engine`, `git-autocommit`, `cost-tracker`, `secret-scanner`,
752
- `todo-tracker`, `auto-doc`, `shell-check`, `semver-bump`,
753
- `token-budget`, `lint-gate`, `branch-guard`, `diff-summary`, `commit-validator`, `format-on-save`, `test-runner-gate`, `import-organizer`, `todo-listener`, `session-recap`, `spec-linker`) follow a strict lifecycle to survive hot-reload
754
- without leaking resources. The pattern was formalized after a
1042
+ All built-in plugins that hold module-scope state follow a strict lifecycle
1043
+ to survive hot-reload without leaking resources. This includes file-backed
1044
+ stores, timers, watchers, hook counters, in-memory caches, provider wrappers,
1045
+ and notification state. The pattern was formalized after a
755
1046
  2026-06-03 audit (the "H1 audit") found that several plugins kept
756
1047
  their state inside the `setup()` closure, where the loader's
757
1048
  `WeakMap<Plugin, PluginAPI>` could not reach it during teardown —
package/dist/auto-doc.js CHANGED
@@ -1,5 +1,15 @@
1
+ import { resolve, isAbsolute, relative } from 'path';
2
+
1
3
  // src/auto-doc/index.ts
2
4
  var AUTO_DOC_API_VERSION = "^0.1.10";
5
+ function resolveProjectPath(rawPath, cwd = process.cwd()) {
6
+ if (typeof rawPath !== "string" || rawPath.length === 0) return null;
7
+ const root = resolve(cwd);
8
+ const resolved = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
9
+ const rel = relative(root, resolved);
10
+ if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel)) return resolved;
11
+ return null;
12
+ }
3
13
  var state = {
4
14
  invocationCount: 0,
5
15
  /** Doc comments whose prose came from the LLM this session. */
@@ -170,14 +180,19 @@ async function runAutoDoc(input, api) {
170
180
  const maxLlmEntities = typeof extConfig["maxLlmEntities"] === "number" && extConfig["maxLlmEntities"] >= 0 ? extConfig["maxLlmEntities"] : 25;
171
181
  const results = [];
172
182
  let llmBudget = maxLlmEntities;
173
- for (const file of input.files) {
183
+ for (const rawFile of input.files) {
184
+ const safeFile = resolveProjectPath(rawFile);
185
+ if (!safeFile) {
186
+ api.log.warn(`auto-doc: skipped file outside project directory: ${rawFile}`);
187
+ continue;
188
+ }
174
189
  try {
175
190
  const { readFileSync, writeFileSync } = await import('fs');
176
191
  let content;
177
192
  try {
178
- content = readFileSync(file, "utf-8");
193
+ content = readFileSync(safeFile, "utf-8");
179
194
  } catch {
180
- api.log.warn(`auto-doc: could not read file ${file}`);
195
+ api.log.warn(`auto-doc: could not read file ${safeFile}`);
181
196
  continue;
182
197
  }
183
198
  const entities = parseSource(content);
@@ -205,14 +220,14 @@ async function runAutoDoc(input, api) {
205
220
  }
206
221
  if (!doc) doc = generateDocComment(entity, includeTypes);
207
222
  modified = injectDocComment(modified, entity, doc);
208
- results.push({ file, entity: entity.name, source });
223
+ results.push({ file: safeFile, entity: entity.name, source });
209
224
  }
210
225
  if (!input.dry_run && results.length > 0) {
211
- writeFileSync(file, modified, "utf-8");
212
- api.log.info(`auto-doc: updated ${file}`);
226
+ writeFileSync(safeFile, modified, "utf-8");
227
+ api.log.info(`auto-doc: updated ${safeFile}`);
213
228
  }
214
229
  } catch (err) {
215
- api.log.error(`auto-doc: error processing ${file}: ${err}`);
230
+ api.log.error(`auto-doc: error processing ${safeFile}: ${err}`);
216
231
  }
217
232
  }
218
233
  return {
@@ -95,9 +95,21 @@ var plugin = {
95
95
  default: "block",
96
96
  description: '"block" refuses the call; "warn" injects context but lets it through.'
97
97
  },
98
- blockCommit: { type: "boolean", default: true, description: "Block commits on protected branches." },
99
- blockPush: { type: "boolean", default: true, description: "Block pushes from protected branches." },
100
- blockMerge: { type: "boolean", default: true, description: "Block merges into protected branches." }
98
+ blockCommit: {
99
+ type: "boolean",
100
+ default: true,
101
+ description: "Block commits on protected branches."
102
+ },
103
+ blockPush: {
104
+ type: "boolean",
105
+ default: true,
106
+ description: "Block pushes from protected branches."
107
+ },
108
+ blockMerge: {
109
+ type: "boolean",
110
+ default: true,
111
+ description: "Block merges into protected branches."
112
+ }
101
113
  }
102
114
  },
103
115
  setup(api) {
@@ -115,6 +127,7 @@ var plugin = {
115
127
  state.invocationCount += 1;
116
128
  let gitOp = null;
117
129
  if (toolName === "git_autocommit") {
130
+ if (inp["dry_run"] === true) return;
118
131
  gitOp = { type: "commit", snippet: "git_autocommit" };
119
132
  } else if (toolName === "bash") {
120
133
  const command = inp["command"];
@@ -129,6 +142,7 @@ var plugin = {
129
142
  const when = (/* @__PURE__ */ new Date()).toISOString();
130
143
  const opVerb = gitOp.type === "commit" ? "committing to" : gitOp.type === "push" ? "pushing from" : "merging into";
131
144
  const hasUncommitted = detectUncommittedChanges(cwd);
145
+ const retryStep = toolName === "git_autocommit" ? "retry git_autocommit" : `git ${gitOp.type} ...`;
132
146
  const suggestionParts = [];
133
147
  if (hasUncommitted) {
134
148
  suggestionParts.push("git stash");
@@ -137,7 +151,7 @@ var plugin = {
137
151
  if (hasUncommitted) {
138
152
  suggestionParts.push("git stash pop");
139
153
  }
140
- suggestionParts.push(`git ${gitOp.type} ...`);
154
+ suggestionParts.push(retryStep);
141
155
  const suggestion = suggestionParts.join(" \u2192 ");
142
156
  const reason = `branch-guard: refused to ${gitOp.type} on protected branch '${branch}'. You're on a protected branch. Use a feature branch instead.
143
157
  ` + (hasUncommitted ? `You have uncommitted changes. Safe workflow:
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, writeFileSync } from 'fs';
2
+ import { resolve, isAbsolute, relative } from 'path';
2
3
 
3
4
  // src/changelog-writer/index.ts
4
5
  var state = {
@@ -16,12 +17,19 @@ var DEFAULTS = {
16
17
  collectCommits: true,
17
18
  maxEntries: 200
18
19
  };
20
+ function resolveProjectPath(rawPath, cwd = process.cwd()) {
21
+ const root = resolve(cwd);
22
+ const resolved = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
23
+ const rel = relative(root, resolved);
24
+ if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel)) return resolved;
25
+ return null;
26
+ }
19
27
  function readConfig(raw) {
20
28
  if (!raw || typeof raw !== "object") return { ...DEFAULTS };
21
29
  const r = raw;
22
30
  return {
23
31
  enabled: r["enabled"] !== false,
24
- filePath: typeof r["filePath"] === "string" && r["filePath"].length > 0 ? r["filePath"] : DEFAULTS.filePath,
32
+ filePath: typeof r["filePath"] === "string" && r["filePath"].length > 0 ? resolveProjectPath(r["filePath"]) ?? "" : resolveProjectPath(DEFAULTS.filePath) ?? DEFAULTS.filePath,
25
33
  collectCommits: r["collectCommits"] !== false,
26
34
  maxEntries: typeof r["maxEntries"] === "number" && r["maxEntries"] >= 10 ? r["maxEntries"] : DEFAULTS.maxEntries
27
35
  };
@@ -291,6 +299,9 @@ var plugin = {
291
299
  mutating: true,
292
300
  async execute(input) {
293
301
  if (!cfg.enabled) return { ok: false, error: "changelog-writer is disabled" };
302
+ if (!cfg.filePath) {
303
+ return { ok: false, error: "filePath must stay within the current project directory" };
304
+ }
294
305
  if (state.entries.length === 0) {
295
306
  return { ok: false, error: "no pending entries \u2014 add some with changelog_add first" };
296
307
  }
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync, writeFileSync, statSync, readFileSync } from 'fs';
2
- import { dirname } from 'path';
2
+ import { dirname, resolve, isAbsolute, relative } from 'path';
3
3
 
4
4
  // src/checkpoint/index.ts
5
5
  var state = {
@@ -26,6 +26,13 @@ function readConfig(raw) {
26
26
  maxFileBytes: typeof r["maxFileBytes"] === "number" && r["maxFileBytes"] >= 1024 ? r["maxFileBytes"] : DEFAULTS.maxFileBytes
27
27
  };
28
28
  }
29
+ function resolveProjectPath(rawPath, cwd = process.cwd()) {
30
+ const root = resolve(cwd);
31
+ const resolved = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
32
+ const rel = relative(root, resolved);
33
+ if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel)) return resolved;
34
+ return null;
35
+ }
29
36
  function captureFile(path, maxBytes) {
30
37
  try {
31
38
  const st = statSync(path);
@@ -92,7 +99,9 @@ var plugin = {
92
99
  const ti = input.toolInput ?? {};
93
100
  const raw = ti["path"] ?? ti["file_path"] ?? ti["filePath"];
94
101
  if (typeof raw !== "string" || raw.length === 0) return;
95
- const captured = captureFile(raw, cfg.maxFileBytes);
102
+ const safePath = resolveProjectPath(raw);
103
+ if (!safePath) return;
104
+ const captured = captureFile(safePath, cfg.maxFileBytes);
96
105
  if (captured === "too-large") {
97
106
  state.skippedLarge += 1;
98
107
  return;
@@ -134,9 +143,15 @@ var plugin = {
134
143
  const paths = Array.isArray(input.paths) ? input.paths.filter((p) => typeof p === "string" && p.length > 0) : [];
135
144
  if (paths.length === 0) return { ok: false, error: "paths must not be empty" };
136
145
  const files = [];
146
+ const rejectedOutsideProject = [];
137
147
  let skipped = 0;
138
148
  for (const p of paths) {
139
- const captured = captureFile(p, cfg.maxFileBytes);
149
+ const safePath = resolveProjectPath(p);
150
+ if (!safePath) {
151
+ rejectedOutsideProject.push(p);
152
+ continue;
153
+ }
154
+ const captured = captureFile(safePath, cfg.maxFileBytes);
140
155
  if (captured === "too-large") {
141
156
  skipped += 1;
142
157
  state.skippedLarge += 1;
@@ -144,6 +159,13 @@ var plugin = {
144
159
  }
145
160
  files.push(captured);
146
161
  }
162
+ if (rejectedOutsideProject.length > 0) {
163
+ return {
164
+ ok: false,
165
+ error: "paths must stay within the current project directory",
166
+ rejectedOutsideProject
167
+ };
168
+ }
147
169
  if (files.length === 0) {
148
170
  return { ok: false, error: "all files were skipped (too large)" };
149
171
  }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, mkdirSync, writeFileSync } from 'fs';
2
- import { dirname } from 'path';
2
+ import { dirname, resolve, isAbsolute, relative } from 'path';
3
3
 
4
4
  // src/context-pins/index.ts
5
5
  var state = {
@@ -16,12 +16,21 @@ var DEFAULTS = {
16
16
  maxPins: 20,
17
17
  maxPinChars: 500
18
18
  };
19
+ function resolveProjectPath(rawPath, cwd = process.cwd()) {
20
+ if (typeof rawPath !== "string" || rawPath.length === 0) return "";
21
+ const root = resolve(cwd);
22
+ const resolved = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
23
+ const rel = relative(root, resolved);
24
+ if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel)) return resolved;
25
+ return null;
26
+ }
19
27
  function readConfig(raw) {
20
28
  if (!raw || typeof raw !== "object") return { ...DEFAULTS };
21
29
  const r = raw;
30
+ const rawPath = typeof r["filePath"] === "string" ? r["filePath"] : DEFAULTS.filePath;
22
31
  return {
23
32
  enabled: r["enabled"] !== false,
24
- filePath: typeof r["filePath"] === "string" ? r["filePath"] : DEFAULTS.filePath,
33
+ filePath: rawPath ? resolveProjectPath(rawPath) ?? "" : "",
25
34
  maxPins: typeof r["maxPins"] === "number" && r["maxPins"] >= 1 && r["maxPins"] <= 100 ? r["maxPins"] : DEFAULTS.maxPins,
26
35
  maxPinChars: typeof r["maxPinChars"] === "number" && r["maxPinChars"] >= 20 ? r["maxPinChars"] : DEFAULTS.maxPinChars
27
36
  };