@opengsd/gsd-core 1.16.0 → 1.18.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -2
- package/.opencode/plugins/gsd-core.js +251 -1
- package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
- package/GEMINI.md +1 -1
- package/agents/gsd-advisor-researcher.compact.md +1 -1
- package/agents/gsd-advisor-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.compact.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +1 -1
- package/agents/gsd-code-fixer.compact.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.compact.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-codebase-mapper.compact.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.compact.md +6 -0
- package/agents/gsd-debug-session-manager.md +6 -0
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-verifier.compact.md +4 -4
- package/agents/gsd-doc-verifier.md +4 -3
- package/agents/gsd-doc-writer.compact.md +23 -5
- package/agents/gsd-doc-writer.md +19 -6
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +7 -3
- package/agents/gsd-integration-checker.compact.md +1 -1
- package/agents/gsd-integration-checker.md +1 -1
- package/agents/gsd-nyquist-auditor.compact.md +1 -1
- package/agents/gsd-nyquist-auditor.md +1 -1
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +2 -1
- package/agents/gsd-planner.md +5 -4
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +5 -5
- package/agents/gsd-research-synthesizer.md +5 -5
- package/agents/gsd-roadmapper.compact.md +1 -1
- package/agents/gsd-roadmapper.md +1 -1
- package/agents/gsd-security-auditor.compact.md +1 -1
- package/agents/gsd-security-auditor.md +1 -1
- package/agents/gsd-ui-auditor.compact.md +1 -1
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.compact.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.compact.md +13 -1
- package/agents/gsd-ui-researcher.md +28 -1
- package/agents/gsd-verifier.md +1 -1
- package/bin/gsd-mcp-server.js +3 -1
- package/bin/install.js +398 -95
- package/commands/gsd/code-review.md +8 -0
- package/commands/gsd/pause-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +34 -12
- package/gsd-core/bin/lib/adr-parser.cjs +37 -5
- package/gsd-core/bin/lib/capability-ledger.cjs +25 -0
- package/gsd-core/bin/lib/capability-lock.cjs +29 -7
- package/gsd-core/bin/lib/capability-registry.cjs +138 -80
- package/gsd-core/bin/lib/capability-source.cjs +3 -1
- package/gsd-core/bin/lib/capability-state.cjs +6 -3
- package/gsd-core/bin/lib/capability-validator.cjs +8 -5
- package/gsd-core/bin/lib/capability-writer.cjs +2 -1
- package/gsd-core/bin/lib/commands.cjs +33 -9
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/decision-coverage-support.cjs +19 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +15 -0
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +13 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +23 -3
- package/gsd-core/bin/lib/gate-predicate.cjs +8 -12
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +60 -5
- package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
- package/gsd-core/bin/lib/graphify.cjs +3 -2
- package/gsd-core/bin/lib/init.cjs +43 -16
- package/gsd-core/bin/lib/install-effort-resolver.cjs +2 -2
- package/gsd-core/bin/lib/install-engine.cjs +11 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +4 -3
- package/gsd-core/bin/lib/install-profiles.cjs +5 -3
- package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
- package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
- package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
- package/gsd-core/bin/lib/mcp-server.cjs +5 -1
- package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
- package/gsd-core/bin/lib/opencode-version.cjs +486 -0
- package/gsd-core/bin/lib/phase.cjs +141 -11
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
- package/gsd-core/bin/lib/report-parser.cjs +57 -1
- package/gsd-core/bin/lib/review-lane-invocation.cjs +40 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +20 -1
- package/gsd-core/bin/lib/roadmap.cjs +56 -5
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +142 -6
- package/gsd-core/bin/lib/runtime-homes.cjs +18 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
- package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +2 -0
- package/gsd-core/bin/lib/smart-entry.cjs +100 -53
- package/gsd-core/bin/lib/state-document.cjs +37 -3
- package/gsd-core/bin/lib/state-transition.cjs +98 -19
- package/gsd-core/bin/lib/state.cjs +544 -126
- package/gsd-core/bin/lib/task-command-router.cjs +2 -3
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +5 -0
- package/gsd-core/bin/lib/tdd-test-files.cjs +42 -0
- package/gsd-core/bin/lib/update-context.cjs +18 -1
- package/gsd-core/bin/lib/verification.cjs +107 -17
- package/gsd-core/bin/lib/verify.cjs +36 -1
- package/gsd-core/references/agent-contracts.md +1 -1
- package/gsd-core/references/agent-skills-bootstrap.md +26 -7
- package/gsd-core/references/git-integration.md +3 -3
- package/gsd-core/references/planner-reversibility.md +18 -4
- package/gsd-core/references/planner-verify-command-grounding.md +18 -0
- package/gsd-core/references/tdd.md +4 -3
- package/gsd-core/references/verifier-phase-gates.md +8 -1
- package/gsd-core/workflows/add-backlog.md +10 -2
- package/gsd-core/workflows/add-phase.md +5 -1
- package/gsd-core/workflows/add-tests.md +5 -1
- package/gsd-core/workflows/add-todo.md +5 -1
- package/gsd-core/workflows/ai-integration-phase.md +5 -1
- package/gsd-core/workflows/audit-milestone.md +21 -9
- package/gsd-core/workflows/autonomous/templates/minimal-context.md +41 -0
- package/gsd-core/workflows/autonomous.md +52 -64
- package/gsd-core/workflows/check-todos.md +5 -1
- package/gsd-core/workflows/code-review-fix.md +5 -1
- package/gsd-core/workflows/code-review.md +21 -2
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +5 -1
- package/gsd-core/workflows/complete-milestone.md +25 -5
- package/gsd-core/workflows/debug.md +5 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +11 -3
- package/gsd-core/workflows/discuss-phase.md +5 -1
- package/gsd-core/workflows/docs-update/detail/elaboration.md +7 -6
- package/gsd-core/workflows/docs-update.md +27 -5
- package/gsd-core/workflows/edit-phase.md +5 -1
- package/gsd-core/workflows/eval-review.md +11 -3
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +5 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +12 -6
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +19 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +18 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +5 -1
- package/gsd-core/workflows/execute-phase.md +40 -7
- package/gsd-core/workflows/execute-plan.md +8 -4
- package/gsd-core/workflows/extract-learnings.md +6 -2
- package/gsd-core/workflows/forensics.md +5 -1
- package/gsd-core/workflows/import.md +5 -1
- package/gsd-core/workflows/insert-phase.md +5 -1
- package/gsd-core/workflows/manager.md +10 -2
- package/gsd-core/workflows/map-codebase.md +5 -1
- package/gsd-core/workflows/milestone-summary.md +10 -2
- package/gsd-core/workflows/new-milestone.md +13 -5
- package/gsd-core/workflows/new-project.md +9 -5
- package/gsd-core/workflows/next.md +8 -4
- package/gsd-core/workflows/pause-work.md +3 -3
- package/gsd-core/workflows/plan-phase/steps/auto-advance-check.md +70 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +5 -1
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +6 -2
- package/gsd-core/workflows/plan-phase.md +41 -73
- package/gsd-core/workflows/plan-review-convergence.md +17 -5
- package/gsd-core/workflows/plant-seed.md +8 -4
- package/gsd-core/workflows/progress.md +6 -2
- package/gsd-core/workflows/quick-batch.md +5 -1
- package/gsd-core/workflows/quick.md +22 -7
- package/gsd-core/workflows/remove-phase.md +5 -1
- package/gsd-core/workflows/resume-project.md +19 -11
- package/gsd-core/workflows/review.md +6 -2
- package/gsd-core/workflows/scan.md +5 -1
- package/gsd-core/workflows/secure-phase.md +6 -2
- package/gsd-core/workflows/settings.md +3 -3
- package/gsd-core/workflows/ship.md +13 -10
- package/gsd-core/workflows/thread.md +10 -2
- package/gsd-core/workflows/transition.md +5 -1
- package/gsd-core/workflows/ui-phase.md +5 -1
- package/gsd-core/workflows/ui-review.md +6 -2
- package/gsd-core/workflows/ultraplan-phase.md +5 -1
- package/gsd-core/workflows/update.md +5 -1
- package/gsd-core/workflows/validate-phase.md +6 -2
- package/gsd-core/workflows/verify-work/detail/elaboration.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -1
- package/gsd-core/workflows/verify-work.md +11 -3
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -1
- package/hooks/dist/gsd-check-update.js +26 -20
- package/hooks/dist/gsd-context-monitor.js +14 -5
- package/hooks/dist/gsd-cursor-subagent-start.js +2 -1
- package/hooks/dist/gsd-secret-read-guard.js +189 -15
- package/hooks/dist/gsd-statusline.js +321 -22
- package/hooks/dist/gsd-validate-commit.sh +27 -5
- package/hooks/gsd-agent-isolation-guard.js +3 -1
- package/hooks/gsd-check-update.js +26 -20
- package/hooks/gsd-context-monitor.js +14 -5
- package/hooks/gsd-cursor-subagent-start.js +2 -1
- package/hooks/gsd-secret-read-guard.js +189 -15
- package/hooks/gsd-statusline.js +321 -22
- package/hooks/gsd-validate-commit.sh +27 -5
- package/package.json +1 -1
- package/scripts/audit-workflow-script-paths.cjs +1 -1
- package/scripts/diagnose-validate-commit-in-tier.cjs +270 -0
- package/scripts/diagnose-validate-commit-phases.cjs +348 -0
- package/scripts/diagnose-windows-spawn-watcher.cjs +317 -0
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-capability-registry.cjs +7 -1
- package/scripts/gen-loop-host-contract.cjs +4 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +13 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +26 -0
- package/scripts/lint-phase-arg-assignment.cjs +2 -2
- package/scripts/lint-workflow-shellcheck-baseline.json +16 -16
- package/scripts/mutation-matrix.cjs +3 -1
- package/scripts/release-tarball-smoke.cjs +15 -7
- package/scripts/xtrace-phase-timing.cjs +205 -0
- package/skills/gsd-code-review/SKILL.md +8 -0
- package/skills/gsd-pause-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -67,6 +67,8 @@
|
|
|
67
67
|
// `git check-ignore` / `git ls-files` / `git rm --cached`, and the
|
|
68
68
|
// destination of a one-source `cp`/`mv` (#4856). The #4856 carve-outs
|
|
69
69
|
// fail closed: an option they do not list withdraws them from the segment.
|
|
70
|
+
// A git global option the walker does not know also keeps the #5045 `-c`
|
|
71
|
+
// scan running over every later word (see walkGitGlobals).
|
|
70
72
|
// A shell interpreter (bash/sh/zsh/dash/ksh/su) has its script scanned
|
|
71
73
|
// whether it arrives via `-c '…'`, a `<( )` file operand, a heredoc /
|
|
72
74
|
// here-string, or a pipe from a knowable `echo`/`printf` source
|
|
@@ -103,9 +105,23 @@
|
|
|
103
105
|
// edit of `.env` lands in the link target — a write redirection, the
|
|
104
106
|
// out-of-scope write class, not a read.)
|
|
105
107
|
//
|
|
108
|
+
// A second, unrelated contract is enforced on the Bash leg because this is the
|
|
109
|
+
// one hook that already parses EVERY Bash command (#5214): a Bash command that
|
|
110
|
+
// launches the debugger as a CLI process — `claude … --agent gsd-debugger` — is
|
|
111
|
+
// denied (`debugger-cli-spawn`). The debug session manager is required to spawn
|
|
112
|
+
// gsd-debugger with `Agent(run_in_background=false)` so it can read the
|
|
113
|
+
// debugger's structured return; on a captured 1.15.0 run it routed around that
|
|
114
|
+
// literal and shelled out with the Bash parameter `run_in_background: true`,
|
|
115
|
+
// so no return ever came back and the checkpoint never advanced. Prose in the
|
|
116
|
+
// agent file cannot stop an improvised tool call; a PreToolUse denial can, and
|
|
117
|
+
// Claude Code runs plugin hooks for a subagent's tool calls too. A person
|
|
118
|
+
// running `claude --agent gsd-debugger` in their own terminal never reaches
|
|
119
|
+
// this hook. Only the debugger is named: it is the agent whose blocking return
|
|
120
|
+
// the managed debug loop depends on.
|
|
121
|
+
//
|
|
106
122
|
// Triggers on: Read, Grep, Bash tool calls (Kimi: ReadFile, Grep, Shell)
|
|
107
123
|
// Action: BLOCK (decision: 'block', exit 2) — codes secret-read |
|
|
108
|
-
// glob-too-complex | command-too-large
|
|
124
|
+
// glob-too-complex | command-too-large | debugger-cli-spawn
|
|
109
125
|
// No-op: other tools, non-secret targets, hook errors (fail open — a parser
|
|
110
126
|
// bug in a hook that runs on EVERY Bash call must never brick a
|
|
111
127
|
// session; the crash policy is declared once below).
|
|
@@ -178,14 +194,29 @@ const GIT_PATHSPEC_SUBCOMMANDS = new Map([
|
|
|
178
194
|
}],
|
|
179
195
|
]);
|
|
180
196
|
|
|
181
|
-
// git's global options (
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
197
|
+
// git's global options (git.c handle_options, git 2.49) — the ONE vocabulary
|
|
198
|
+
// both the #4856 pathspec exemption and the #5045 -c scan walk, via
|
|
199
|
+
// walkGitGlobals. A value option consumes the next word; the long options in
|
|
200
|
+
// GIT_GLOBAL_EQUALS_FORM_OPTIONS also take it attached as `--opt=value`.
|
|
201
|
+
// `-C`, `-c` and the hidden `--shallow-file` accept only the separate word
|
|
202
|
+
// (git rejects `--shallow-file=x` and `-c<name>=<value>`), and
|
|
203
|
+
// `--exec-path` runs a subcommand only as `--exec-path=<path>` (bare, it
|
|
204
|
+
// prints the path and exits). Any other option fails closed for both
|
|
205
|
+
// consumers: its arity is unknown, so its value could be read as the
|
|
206
|
+
// subcommand (`git -C ls-files show HEAD:.env` runs `show`). The pathspec
|
|
207
|
+
// exemption is withdrawn, and the -c scan keeps collecting every later
|
|
208
|
+
// `-c <value>` pair instead of stopping.
|
|
209
|
+
const GIT_GLOBAL_VALUE_OPTIONS = new Set([
|
|
210
|
+
'-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source',
|
|
211
|
+
'--shallow-file',
|
|
212
|
+
]);
|
|
213
|
+
const GIT_GLOBAL_EQUALS_FORM_OPTIONS = new Set([
|
|
214
|
+
'--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source', '--exec-path',
|
|
215
|
+
]);
|
|
186
216
|
const GIT_GLOBAL_FLAGS = new Set([
|
|
187
217
|
'-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--no-lazy-fetch',
|
|
188
218
|
'--no-optional-locks', '--no-advice', '--bare',
|
|
219
|
+
'--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs',
|
|
189
220
|
]);
|
|
190
221
|
|
|
191
222
|
// #4856: `cp`/`mv` write their destination and never print it, so a secret
|
|
@@ -775,6 +806,29 @@ function resolveCommand(words) {
|
|
|
775
806
|
return { base: lastSegment(words[idx].text).toLowerCase(), operands: words.slice(idx + 1) };
|
|
776
807
|
}
|
|
777
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Rescan only the command-valued -c keys selected by #5045. This deliberately
|
|
811
|
+
* does not resolve config files, --config-env/GIT_CONFIG_* values, or other
|
|
812
|
+
* executing keys (diff drivers, filters, credential helpers, ssh, gpg).
|
|
813
|
+
* Those are documented gaps, not a promise of complete Git config analysis.
|
|
814
|
+
* Global option values and subcommand arguments are data, not more -c flags.
|
|
815
|
+
*/
|
|
816
|
+
function scanGitConfig(operands, depth) {
|
|
817
|
+
for (const config of walkGitGlobals(operands).configs) {
|
|
818
|
+
const eq = config.indexOf('=');
|
|
819
|
+
if (eq === -1) continue;
|
|
820
|
+
const key = config.slice(0, eq).toLowerCase();
|
|
821
|
+
const value = config.slice(eq + 1);
|
|
822
|
+
let script;
|
|
823
|
+
if (key.startsWith('alias.') && value.startsWith('!')) script = value.slice(1);
|
|
824
|
+
else if (key === 'diff.external') script = value;
|
|
825
|
+
else continue;
|
|
826
|
+
const hit = findSecretRead(script, depth + 1);
|
|
827
|
+
if (hit) return hit;
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
|
|
778
832
|
// Operand indices the segment's command consumes as a NAME, never as
|
|
779
833
|
// CONTENTS (#4639, #4856). Only these positions skip the operand check;
|
|
780
834
|
// every other operand is still checked, so a carve-out cannot launder a read
|
|
@@ -806,26 +860,38 @@ function isListedFlag(text, flags) {
|
|
|
806
860
|
return /^-[A-Za-z]+$/.test(text) && [...text.slice(1)].every((ch) => flags.has(`-${ch}`));
|
|
807
861
|
}
|
|
808
862
|
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
|
|
863
|
+
// Walks git's global options (see GIT_GLOBAL_VALUE_OPTIONS). `sub` is the
|
|
864
|
+
// subcommand's index, or -1 when an unlisted option (or the end of the words)
|
|
865
|
+
// comes first; `configs` holds every `-c` value seen on the way, in order.
|
|
866
|
+
// Past an unlisted option the subcommand cannot be located, so `configs`
|
|
867
|
+
// conservatively takes the word after every later `-c` as well: a read
|
|
868
|
+
// still needs a command-valued key, so a harmless value stays allowed.
|
|
869
|
+
function walkGitGlobals(operands) {
|
|
870
|
+
const configs = [];
|
|
812
871
|
for (let k = 0; k < operands.length; k++) {
|
|
813
872
|
const t = operands[k].text;
|
|
814
|
-
if (!t.startsWith('-')) return k;
|
|
815
|
-
if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) {
|
|
873
|
+
if (!t.startsWith('-')) return { sub: k, configs };
|
|
874
|
+
if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) {
|
|
875
|
+
if (t === '-c' && k + 1 < operands.length) configs.push(operands[k + 1].text);
|
|
876
|
+
k++;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
816
879
|
if (GIT_GLOBAL_FLAGS.has(t)) continue;
|
|
817
880
|
const eq = t.indexOf('=');
|
|
818
|
-
|
|
819
|
-
|
|
881
|
+
if (t.startsWith('--') && eq !== -1 && GIT_GLOBAL_EQUALS_FORM_OPTIONS.has(t.slice(0, eq))) continue;
|
|
882
|
+
for (let j = k + 1; j + 1 < operands.length; j++) {
|
|
883
|
+
if (operands[j].text === '-c') configs.push(operands[++j].text);
|
|
884
|
+
}
|
|
885
|
+
return { sub: -1, configs };
|
|
820
886
|
}
|
|
821
|
-
return -1;
|
|
887
|
+
return { sub: -1, configs };
|
|
822
888
|
}
|
|
823
889
|
|
|
824
890
|
// #4856: the pathspec operands of a GIT_PATHSPEC_SUBCOMMANDS invocation. git
|
|
825
891
|
// permutes options, so one may follow a pathspec; after `--` every word is a
|
|
826
892
|
// pathspec. The subcommand is matched case-sensitively, as git looks it up.
|
|
827
893
|
function gitPathspecIndices(operands) {
|
|
828
|
-
const sub =
|
|
894
|
+
const { sub } = walkGitGlobals(operands);
|
|
829
895
|
const spec = sub === -1 ? undefined : GIT_PATHSPEC_SUBCOMMANDS.get(operands[sub].text);
|
|
830
896
|
if (!spec) return new Set();
|
|
831
897
|
const pathspecs = new Set();
|
|
@@ -993,6 +1059,12 @@ function findSecretRead(command, depth) {
|
|
|
993
1059
|
// `bash .env` (file mode) is caught by the operand check below.
|
|
994
1060
|
}
|
|
995
1061
|
|
|
1062
|
+
// Git -c can carry a shell script instead of a file-name operand (#5045).
|
|
1063
|
+
if ((base === 'git' || base === 'git.exe') && depth < MAX_NESTING_DEPTH) {
|
|
1064
|
+
const hit = scanGitConfig(operands, depth);
|
|
1065
|
+
if (hit) return hit;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
996
1068
|
if (NON_READING_COMMANDS.has(base)) continue;
|
|
997
1069
|
|
|
998
1070
|
const nameOnly = nameOnlyOperandIndices(base, operands);
|
|
@@ -1004,6 +1076,100 @@ function findSecretRead(command, depth) {
|
|
|
1004
1076
|
return null;
|
|
1005
1077
|
}
|
|
1006
1078
|
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
// gsd-debugger CLI spawn scan (#5214)
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
|
|
1083
|
+
const DEBUGGER_AGENT_NAME = 'gsd-debugger';
|
|
1084
|
+
const CLAUDE_BINARY_RE = /^claude(\.exe)?$/;
|
|
1085
|
+
|
|
1086
|
+
// `gsd-debugger`, or a plugin-namespaced `<plugin>:gsd-debugger`.
|
|
1087
|
+
function isDebuggerAgent(value) {
|
|
1088
|
+
return value.slice(value.lastIndexOf(':') + 1) === DEBUGGER_AGENT_NAME;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// `timeout [flags] DURATION cmd …` is not in CMD_PREFIXES because its first
|
|
1092
|
+
// operand is a duration, not the command. Returns the command that follows it.
|
|
1093
|
+
function unwrapTimeout(cmd) {
|
|
1094
|
+
let { base, operands } = cmd;
|
|
1095
|
+
while (base === 'timeout') {
|
|
1096
|
+
let k = 0;
|
|
1097
|
+
while (k < operands.length && operands[k].text.startsWith('-')) k++;
|
|
1098
|
+
k++; // the DURATION operand
|
|
1099
|
+
const rest = resolveCommand(operands.slice(k));
|
|
1100
|
+
if (!rest) return null;
|
|
1101
|
+
({ base, operands } = rest);
|
|
1102
|
+
}
|
|
1103
|
+
return { base, operands };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Returns the `--agent` value text of a debugger launch, or null.
|
|
1107
|
+
function findDebuggerCliSpawn(command, depth) {
|
|
1108
|
+
const { tokens, nested } = tokenize(command);
|
|
1109
|
+
|
|
1110
|
+
for (const sub of nested) {
|
|
1111
|
+
if (depth < MAX_NESTING_DEPTH) {
|
|
1112
|
+
const hit = findDebuggerCliSpawn(sub, depth + 1);
|
|
1113
|
+
if (hit) return hit;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const bySeg = new Map();
|
|
1118
|
+
for (const t of tokens) {
|
|
1119
|
+
if (t.kind !== 'word' && t.kind !== 'op') continue;
|
|
1120
|
+
if (!bySeg.has(t.seg)) bySeg.set(t.seg, []);
|
|
1121
|
+
bySeg.get(t.seg).push(t);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
for (const segTokens of bySeg.values()) {
|
|
1125
|
+
const words = [];
|
|
1126
|
+
for (let k = 0; k < segTokens.length; k++) {
|
|
1127
|
+
const t = segTokens[k];
|
|
1128
|
+
if (t.kind === 'op') {
|
|
1129
|
+
// A redirect consumes its target word; it is never the command.
|
|
1130
|
+
if (!t.dup && segTokens[k + 1] && segTokens[k + 1].kind === 'word') k++;
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
words.push(t);
|
|
1134
|
+
}
|
|
1135
|
+
if (!words.length) continue;
|
|
1136
|
+
|
|
1137
|
+
const first = resolveCommand(words);
|
|
1138
|
+
const cmd = first && unwrapTimeout(first);
|
|
1139
|
+
if (!cmd) continue;
|
|
1140
|
+
const { base, operands } = cmd;
|
|
1141
|
+
|
|
1142
|
+
if (base === 'eval') {
|
|
1143
|
+
if (depth < MAX_NESTING_DEPTH) {
|
|
1144
|
+
const hit = findDebuggerCliSpawn(operands.map((w) => w.text).join(' '), depth + 1);
|
|
1145
|
+
if (hit) return hit;
|
|
1146
|
+
}
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (SHELL_INTERPRETERS.has(base)) {
|
|
1151
|
+
const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
|
|
1152
|
+
if (cIdx !== -1 && operands[cIdx + 1] && depth < MAX_NESTING_DEPTH) {
|
|
1153
|
+
const hit = findDebuggerCliSpawn(operands[cIdx + 1].text, depth + 1);
|
|
1154
|
+
if (hit) return hit;
|
|
1155
|
+
}
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (!CLAUDE_BINARY_RE.test(base)) continue;
|
|
1160
|
+
for (let k = 0; k < operands.length; k++) {
|
|
1161
|
+
const text = operands[k].text;
|
|
1162
|
+
if (text === '--agent') {
|
|
1163
|
+
const value = operands[k + 1];
|
|
1164
|
+
if (value && isDebuggerAgent(value.text)) return value.text;
|
|
1165
|
+
} else if (text.startsWith('--agent=') && isDebuggerAgent(text.slice('--agent='.length))) {
|
|
1166
|
+
return text.slice('--agent='.length);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1007
1173
|
// A shell interpreter's script comes from `-c`, a file operand, or stdin.
|
|
1008
1174
|
function scanShellInterpreter(operands, heredocs, hereStrings, s, bySeg, sepAfter, depth) {
|
|
1009
1175
|
const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
|
|
@@ -1102,6 +1268,12 @@ function reasonFor(code, tool, target) {
|
|
|
1102
1268
|
return `Secret read guard: the Grep glob '${target}' expands to more than ${MAX_GLOB_ALTERNATIVES} ` +
|
|
1103
1269
|
'alternatives and cannot be checked for secret-file matches. Use a narrower glob.';
|
|
1104
1270
|
}
|
|
1271
|
+
if (code === 'debugger-cli-spawn') {
|
|
1272
|
+
return `Debugger spawn guard: this Bash command launches the debugger as a CLI process ('--agent ${target}'). ` +
|
|
1273
|
+
'A shell-spawned debugger runs detached, returns no structured result, and the debug checkpoint never advances. ' +
|
|
1274
|
+
'Spawn it with the Agent tool instead: Agent(subagent_type="gsd-debugger", run_in_background=false, ...) ' +
|
|
1275
|
+
'and wait for its structured return.';
|
|
1276
|
+
}
|
|
1105
1277
|
return `Secret read guard: ${tool} would read '${target}', which matches a protected secret-file ` +
|
|
1106
1278
|
`pattern (${PATTERN_TEXT}). Secret values must not be read into the conversation. ` +
|
|
1107
1279
|
'If you need a specific value, ask the user for it; if you need the variable NAMES, ' +
|
|
@@ -1253,6 +1425,8 @@ process.stdin.on('end', () => {
|
|
|
1253
1425
|
if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
|
|
1254
1426
|
const hit = findSecretRead(command, 0);
|
|
1255
1427
|
if (hit !== null) emitBlock('secret-read', tool, hit);
|
|
1428
|
+
const spawn = findDebuggerCliSpawn(command, 0);
|
|
1429
|
+
if (spawn !== null) emitBlock('debugger-cli-spawn', tool, spawn);
|
|
1256
1430
|
allow(undefined);
|
|
1257
1431
|
} catch {
|
|
1258
1432
|
// Fail open — never block valid tool calls due to hook errors.
|
|
@@ -170,6 +170,303 @@ function readStateFileOrNull(statePath) {
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The env vars Claude Code itself consults to switch auto-compaction off, and
|
|
175
|
+
* the values it accepts as true. Both are taken from the shipped binary's own
|
|
176
|
+
* resolution — `Boolean(truthy(DISABLE_COMPACT) || DISABLE_AUTO_COMPACT)`, and
|
|
177
|
+
* only when that is false does `autoCompactEnabled` decide — rather than
|
|
178
|
+
* guessed: an invented name reads as "not disabled" forever, which is exactly
|
|
179
|
+
* the defect this gate exists to close.
|
|
180
|
+
*
|
|
181
|
+
* The truthy set applies to both keys: in the binary (claude 2.1.292) the
|
|
182
|
+
* second operand is the env registry's `D.bool()` field, which runs the same
|
|
183
|
+
* `["1","true","yes","on"]` helper as `DISABLE_COMPACT`.
|
|
184
|
+
*/
|
|
185
|
+
const AUTO_COMPACT_DISABLE_ENV_KEYS = ['DISABLE_AUTO_COMPACT', 'DISABLE_COMPACT'];
|
|
186
|
+
const AUTO_COMPACT_ENV_TRUTHY = ['1', 'true', 'yes', 'on'];
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Is Claude Code's auto-compact switched off for this session? When it is,
|
|
190
|
+
* the auto-compact buffer is ordinary usable context, so the meter's 100% is
|
|
191
|
+
* the model window — otherwise the bar pins at 100% early and the last of
|
|
192
|
+
* the window is invisible.
|
|
193
|
+
*
|
|
194
|
+
* The sources and their precedence mirror Claude Code's own resolution —
|
|
195
|
+
* either env var wins over the setting, and only then does the setting decide:
|
|
196
|
+
* 1. env: DISABLE_AUTO_COMPACT, or DISABLE_COMPACT (which switches off
|
|
197
|
+
* manual `/compact` as well). Truthy is `1` / `true` / `yes` / `on`,
|
|
198
|
+
* trimmed and case-insensitive — Claude Code's own accepted set.
|
|
199
|
+
* 2. <dir>/.claude/settings.local.json, <dir>/.claude/settings.json
|
|
200
|
+
* 3. (CLAUDE_CONFIG_DIR || ~/.claude)/settings.local.json, settings.json
|
|
201
|
+
* reading the boolean `autoCompactEnabled`. Fail-soft: anything unreadable
|
|
202
|
+
* or absent means "not disabled" (current behaviour).
|
|
203
|
+
*/
|
|
204
|
+
function isAutoCompactDisabled(dir, env = process.env) {
|
|
205
|
+
for (const key of AUTO_COMPACT_DISABLE_ENV_KEYS) {
|
|
206
|
+
const v = env[key];
|
|
207
|
+
if (typeof v === 'string' && AUTO_COMPACT_ENV_TRUTHY.includes(v.trim().toLowerCase())) return true;
|
|
208
|
+
}
|
|
209
|
+
return readClaudeSetting(dir, env, 'autoCompactEnabled', v => typeof v === 'boolean') === false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The first value of `key` that `accept` takes, reading the Claude Code
|
|
214
|
+
* settings files highest precedence first:
|
|
215
|
+
* <dir>/.claude/settings.local.json, <dir>/.claude/settings.json,
|
|
216
|
+
* (CLAUDE_CONFIG_DIR || ~/.claude)/settings.local.json, settings.json.
|
|
217
|
+
* Fail-soft: an absent or unparseable file is skipped; nothing found is
|
|
218
|
+
* `undefined`.
|
|
219
|
+
*/
|
|
220
|
+
function readClaudeSetting(dir, env, key, accept) {
|
|
221
|
+
// Lazy: stop at the first file that answers, so a lower file is never read.
|
|
222
|
+
for (const file of claudeSettingsPaths(dir, env)) {
|
|
223
|
+
const settings = readSettingsFile(file);
|
|
224
|
+
if (settings && accept(settings[key])) return settings[key];
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The parsed Claude Code settings files, highest precedence first. An absent,
|
|
231
|
+
* unparseable or non-object file is left out.
|
|
232
|
+
*/
|
|
233
|
+
function readClaudeSettingsFiles(dir, env) {
|
|
234
|
+
return claudeSettingsPaths(dir, env).map(readSettingsFile).filter(Boolean);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A settings file larger than this is treated as absent. Claude Code's own
|
|
239
|
+
* settings files are a few kilobytes; the cap keeps a hostile project's
|
|
240
|
+
* `.claude/settings.json` from bloating a read that runs on every render.
|
|
241
|
+
*/
|
|
242
|
+
const SETTINGS_FILE_MAX_BYTES = 1024 * 1024;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The parsed settings object in `file`, or null. Only a regular file of at
|
|
246
|
+
* most SETTINGS_FILE_MAX_BYTES is read: the project's `.claude/` is
|
|
247
|
+
* repository content, and a FIFO there would block the statusline forever.
|
|
248
|
+
* The file is opened non-blocking and checked through its own descriptor,
|
|
249
|
+
* so the check and the read see the same file, and at most the checked size
|
|
250
|
+
* is read: a file that grew or shrank after the check is absent. A symlink
|
|
251
|
+
* is followed — a settings file linked in from a dotfiles repo is ordinary.
|
|
252
|
+
*/
|
|
253
|
+
function readSettingsFile(file) {
|
|
254
|
+
let fd;
|
|
255
|
+
try {
|
|
256
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
257
|
+
const st = fs.fstatSync(fd);
|
|
258
|
+
if (!st.isFile() || st.size > SETTINGS_FILE_MAX_BYTES) return null;
|
|
259
|
+
// One byte past the checked size, so growth after the check shows as a longer read.
|
|
260
|
+
const buf = Buffer.alloc(st.size + 1);
|
|
261
|
+
let n = 0;
|
|
262
|
+
for (let r; n < buf.length && (r = fs.readSync(fd, buf, n, buf.length - n, n)) > 0;) n += r;
|
|
263
|
+
if (n !== st.size) return null;
|
|
264
|
+
const settings = JSON.parse(buf.toString('utf8', 0, n));
|
|
265
|
+
return settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : null;
|
|
266
|
+
} catch (e) {
|
|
267
|
+
return null; // absent, unreadable or unparseable — keep looking
|
|
268
|
+
} finally {
|
|
269
|
+
if (fd !== undefined) {
|
|
270
|
+
try { fs.closeSync(fd); } catch (e) { /* already closed */ }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The Claude Code settings file paths, highest precedence first:
|
|
277
|
+
* <dir>/.claude/settings.local.json, <dir>/.claude/settings.json,
|
|
278
|
+
* (CLAUDE_CONFIG_DIR || ~/.claude)/settings.local.json, settings.json.
|
|
279
|
+
*/
|
|
280
|
+
function claudeSettingsPaths(dir, env) {
|
|
281
|
+
const candidates = [];
|
|
282
|
+
if (dir) {
|
|
283
|
+
candidates.push(path.join(dir, '.claude', 'settings.local.json'));
|
|
284
|
+
candidates.push(path.join(dir, '.claude', 'settings.json'));
|
|
285
|
+
}
|
|
286
|
+
let claudeDir;
|
|
287
|
+
try {
|
|
288
|
+
claudeDir = env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
289
|
+
} catch (e) {
|
|
290
|
+
claudeDir = null;
|
|
291
|
+
}
|
|
292
|
+
if (claudeDir) {
|
|
293
|
+
candidates.push(path.join(claudeDir, 'settings.local.json'));
|
|
294
|
+
candidates.push(path.join(claudeDir, 'settings.json'));
|
|
295
|
+
}
|
|
296
|
+
return candidates;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Claude Code's settings key for a model: its canonical name, which also
|
|
301
|
+
* matches the dated, `[1m]`, Bedrock and Vertex spellings of it
|
|
302
|
+
* (`claude-opus-5-5[1m]`, `us.anthropic.claude-opus-5-5-v1:0` and
|
|
303
|
+
* `claude-opus-5-5@20260101` all key as `claude-opus-5-5`). A Bedrock
|
|
304
|
+
* revision suffix is stripped only after a version or date segment, so a
|
|
305
|
+
* model whose own name ends in `-v<n>` (`anthropic.claude-v2`) keeps it.
|
|
306
|
+
* The rules repeat until nothing changes, so a key is its own key: one pass
|
|
307
|
+
* left `x[1m][1m]` as `x[1m]`. Lowercasing runs once, before the loop (it
|
|
308
|
+
* can lengthen a string: `İ` becomes two characters); inside the loop every
|
|
309
|
+
* rule only removes characters, so it ends. A key longer than
|
|
310
|
+
* MODEL_KEY_MAX_LENGTH is no model's — '' — which also bounds the loop's
|
|
311
|
+
* work on a hostile settings file. '' for a non-string.
|
|
312
|
+
*/
|
|
313
|
+
const MODEL_KEY_MAX_LENGTH = 256;
|
|
314
|
+
|
|
315
|
+
function canonicalModelKey(id) {
|
|
316
|
+
if (typeof id !== 'string') return '';
|
|
317
|
+
let key = id.toLowerCase();
|
|
318
|
+
if (key.length > MODEL_KEY_MAX_LENGTH) return '';
|
|
319
|
+
for (let prev; prev !== key;) {
|
|
320
|
+
prev = key;
|
|
321
|
+
key = key.trim()
|
|
322
|
+
.replace(/(?:\[1m\])+$/, '')
|
|
323
|
+
.replace(/^(?:[a-z]+\.)?anthropic\./, '')
|
|
324
|
+
.replace(/(\d)-v\d+(?::\d+)?$/, '$1')
|
|
325
|
+
.replace(/@\d{8}$/, '')
|
|
326
|
+
.replace(/-\d{8}$/, '');
|
|
327
|
+
}
|
|
328
|
+
return key;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isAutoCompactWindowValue(v) {
|
|
332
|
+
return v === 'auto' ||
|
|
333
|
+
(Number.isInteger(v) && v >= AUTO_COMPACT_WINDOW_MIN && v <= AUTO_COMPACT_WINDOW_MAX);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The `autoCompactWindow` setting for this model, merged across the settings
|
|
338
|
+
* files the way Claude Code merges it (claude 2.1.292): lowest precedence
|
|
339
|
+
* first, each file's `modelSettings.<model>.autoCompactWindow` entries
|
|
340
|
+
* layer over the lower files', and a file that sets a top-level
|
|
341
|
+
* `autoCompactWindow` replaces the default and drops the lower files'
|
|
342
|
+
* per-model entries. The model's own entry wins over the default.
|
|
343
|
+
* `/autocompact` saves the per-model entry; older versions saved the
|
|
344
|
+
* top-level one. `"auto"` (Claude Code's tuned window) and an absent setting
|
|
345
|
+
* are null, so the caller falls back to the model window. An invalid value
|
|
346
|
+
* is treated as absent.
|
|
347
|
+
*/
|
|
348
|
+
function readAutoCompactWindowSetting(dir, env, modelId) {
|
|
349
|
+
let fallback;
|
|
350
|
+
let byModel = {};
|
|
351
|
+
for (const settings of readClaudeSettingsFiles(dir, env).reverse()) {
|
|
352
|
+
const own = {};
|
|
353
|
+
const perModel = settings.modelSettings;
|
|
354
|
+
if (perModel && typeof perModel === 'object' && !Array.isArray(perModel)) {
|
|
355
|
+
for (const [name, entry] of Object.entries(perModel)) {
|
|
356
|
+
const v = entry && typeof entry === 'object' ? entry.autoCompactWindow : undefined;
|
|
357
|
+
if (!isAutoCompactWindowValue(v)) continue;
|
|
358
|
+
const key = canonicalModelKey(name);
|
|
359
|
+
// A canonically spelled key wins over another spelling of it.
|
|
360
|
+
if (name === key || !Object.hasOwn(own, key)) own[key] = v;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (isAutoCompactWindowValue(settings.autoCompactWindow)) {
|
|
364
|
+
fallback = settings.autoCompactWindow;
|
|
365
|
+
byModel = own;
|
|
366
|
+
} else {
|
|
367
|
+
byModel = { ...byModel, ...own };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const key = canonicalModelKey(modelId);
|
|
371
|
+
const value = key && Object.hasOwn(byModel, key) ? byModel[key] : fallback;
|
|
372
|
+
return Number.isInteger(value) ? value : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Where Claude Code auto-compacts (#4985). The meter's 100% is this token
|
|
377
|
+
* count, so it has to be resolved the way Claude Code resolves it:
|
|
378
|
+
*
|
|
379
|
+
* window CLAUDE_CODE_AUTO_COMPACT_WINDOW (an unparseable or non-positive
|
|
380
|
+
* value is ignored; capped at 1M, raised to 100k), else the
|
|
381
|
+
* `autoCompactWindow` setting `/autocompact` saves, per model or
|
|
382
|
+
* top-level (an integer in 100k–1M; see
|
|
383
|
+
* readAutoCompactWindowSetting), else the model window. Always
|
|
384
|
+
* capped at the model window.
|
|
385
|
+
* threshold window − min(model max output, 20,000) − 13,000, lowered to
|
|
386
|
+
* floor((window − 20,000) × pct / 100) when
|
|
387
|
+
* CLAUDE_AUTOCOMPACT_PCT_OVERRIDE (0 < pct ≤ 100) is lower.
|
|
388
|
+
*
|
|
389
|
+
* `/context` shows the 33,000 as "Autocompact buffer". The hardcoded 16.5%
|
|
390
|
+
* this replaces is 33,000 / 200,000, so it was right only on a 200K window
|
|
391
|
+
* with nothing configured. Every current model's max output is at least
|
|
392
|
+
* 20,000, so the reserve is taken as 20,000.
|
|
393
|
+
*
|
|
394
|
+
* Not visible to a statusline, so not modelled: the `--autocompact` launch
|
|
395
|
+
* flag, managed-policy settings, and Claude Code's server-side and per-model
|
|
396
|
+
* window defaults (what `"auto"` resolves to). Those sessions fall back to
|
|
397
|
+
* the model window.
|
|
398
|
+
*/
|
|
399
|
+
const AUTO_COMPACT_WINDOW_MIN = 100_000;
|
|
400
|
+
const AUTO_COMPACT_WINDOW_MAX = 1_000_000;
|
|
401
|
+
const AUTO_COMPACT_OUTPUT_RESERVE = 20_000;
|
|
402
|
+
const AUTO_COMPACT_SUMMARY_BUFFER = 13_000;
|
|
403
|
+
|
|
404
|
+
function parseAutoCompactWindowEnv(raw) {
|
|
405
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
406
|
+
const s = raw.trim();
|
|
407
|
+
const n = /^\d[\d_,]*$/.test(s) ? parseInt(s.replace(/[_,]/g, ''), 10) : parseInt(s, 10);
|
|
408
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
409
|
+
return Math.max(AUTO_COMPACT_WINDOW_MIN, Math.min(AUTO_COMPACT_WINDOW_MAX, n));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The token count at which Claude Code auto-compacts this session, or null
|
|
414
|
+
* when auto-compaction is off (see isAutoCompactDisabled).
|
|
415
|
+
*/
|
|
416
|
+
function resolveAutoCompactThreshold(modelWindow, dir, env = process.env, modelId) {
|
|
417
|
+
if (!(modelWindow > 0) || isAutoCompactDisabled(dir, env)) return null;
|
|
418
|
+
let window = parseAutoCompactWindowEnv(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW);
|
|
419
|
+
if (window === null) {
|
|
420
|
+
window = readAutoCompactWindowSetting(dir, env, modelId) ?? modelWindow;
|
|
421
|
+
}
|
|
422
|
+
const effective = Math.min(window, modelWindow) - AUTO_COMPACT_OUTPUT_RESERVE;
|
|
423
|
+
let threshold = effective - AUTO_COMPACT_SUMMARY_BUFFER;
|
|
424
|
+
const pct = parseFloat(env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE);
|
|
425
|
+
if (Number.isFinite(pct) && pct > 0 && pct <= 100) {
|
|
426
|
+
threshold = Math.min(threshold, Math.floor(effective * (pct / 100)));
|
|
427
|
+
}
|
|
428
|
+
return threshold > 0 ? threshold : null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The context meter, in one scale: `used` + `remaining` = 100, where 100% is
|
|
433
|
+
* the auto-compact threshold, or the model window when compaction is off.
|
|
434
|
+
* `usedTokens` is Claude Code's own count (input + cache creation + cache
|
|
435
|
+
* read of the last turn, the sum /context reports) and `limitTokens` the
|
|
436
|
+
* token count 100% stands for.
|
|
437
|
+
*
|
|
438
|
+
* Taken from token counts, not by re-scaling `remaining_percentage`: that
|
|
439
|
+
* value is already rounded against the model window, which loses up to 5,000
|
|
440
|
+
* tokens on a 1M model. Without `current_usage` the tokens are recovered
|
|
441
|
+
* from the percentage; without `context_window_size` the window is unknown,
|
|
442
|
+
* so the meter shows Claude Code's own percentage unscaled. Returns null
|
|
443
|
+
* when there is no usage yet.
|
|
444
|
+
*/
|
|
445
|
+
function contextMeter(contextWindow, dir, env = process.env, modelId) {
|
|
446
|
+
if (!contextWindow || typeof contextWindow !== 'object') return null;
|
|
447
|
+
const size = Number(contextWindow.context_window_size);
|
|
448
|
+
const usage = contextWindow.current_usage;
|
|
449
|
+
const remainingPct = contextWindow.remaining_percentage;
|
|
450
|
+
if (size > 0) {
|
|
451
|
+
let usedTokens = null;
|
|
452
|
+
if (usage && typeof usage === 'object') {
|
|
453
|
+
usedTokens = (Number(usage.input_tokens) || 0) +
|
|
454
|
+
(Number(usage.cache_creation_input_tokens) || 0) +
|
|
455
|
+
(Number(usage.cache_read_input_tokens) || 0);
|
|
456
|
+
} else if (remainingPct != null) {
|
|
457
|
+
usedTokens = Math.round(((100 - remainingPct) * size) / 100);
|
|
458
|
+
}
|
|
459
|
+
if (usedTokens !== null) {
|
|
460
|
+
const limitTokens = resolveAutoCompactThreshold(size, dir, env, modelId) ?? size;
|
|
461
|
+
const used = Math.max(0, Math.min(100, Math.round((usedTokens / limitTokens) * 100)));
|
|
462
|
+
return { used, remaining: 100 - used, usedTokens, limitTokens };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (remainingPct == null) return null;
|
|
466
|
+
const used = Math.max(0, Math.min(100, Math.round(100 - remainingPct)));
|
|
467
|
+
return { used, remaining: 100 - used, usedTokens: null, limitTokens: null };
|
|
468
|
+
}
|
|
469
|
+
|
|
173
470
|
/**
|
|
174
471
|
* Walk up from dir looking for .planning/STATE.md (flat mode). If an ancestor
|
|
175
472
|
* has no flat STATE.md but IS in workstream mode (.planning/workstreams/
|
|
@@ -830,28 +1127,22 @@ function runStatusline() {
|
|
|
830
1127
|
const model = compactModelName(data.model?.display_name || 'Claude');
|
|
831
1128
|
const dir = data.workspace?.current_dir || process.cwd();
|
|
832
1129
|
const session = data.session_id || '';
|
|
833
|
-
const remaining = data.context_window?.remaining_percentage;
|
|
834
1130
|
|
|
835
1131
|
// Read .planning config once — used by the context meter (token suffix)
|
|
836
1132
|
// and the last-command/position block below. Fail-soft to {}.
|
|
837
1133
|
let cfg = {};
|
|
838
1134
|
try { cfg = readGsdConfig(dir); } catch (e) {}
|
|
839
1135
|
|
|
840
|
-
// Context window display
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
// (
|
|
844
|
-
// the
|
|
845
|
-
|
|
846
|
-
const
|
|
847
|
-
const AUTO_COMPACT_BUFFER_PCT = acw > 0
|
|
848
|
-
? Math.min(100, Math.max(0, (1 - acw / totalCtx) * 100))
|
|
849
|
-
: 16.5;
|
|
1136
|
+
// Context window display: USED percentage of the auto-compact threshold,
|
|
1137
|
+
// so 100% is where Claude Code compacts — resolved from
|
|
1138
|
+
// CLAUDE_CODE_AUTO_COMPACT_WINDOW, the `autoCompactWindow` setting, or the
|
|
1139
|
+
// model window (#2219, #4985). With auto-compact disabled (#4844) 100% is
|
|
1140
|
+
// the model window. Project settings are read from workspace.project_dir,
|
|
1141
|
+
// Claude Code's launch directory, not the current directory.
|
|
1142
|
+
const meter = contextMeter(data.context_window, data.workspace?.project_dir || dir, process.env, data.model?.id);
|
|
850
1143
|
let ctx = '';
|
|
851
|
-
if (
|
|
852
|
-
|
|
853
|
-
const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
|
|
854
|
-
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
|
|
1144
|
+
if (meter) {
|
|
1145
|
+
const { used } = meter;
|
|
855
1146
|
|
|
856
1147
|
// Write context metrics to bridge file for the context-monitor PostToolUse hook.
|
|
857
1148
|
// The monitor reads this file to inject agent-facing warnings when context is low.
|
|
@@ -861,15 +1152,20 @@ function runStatusline() {
|
|
|
861
1152
|
if (sessionSafe) {
|
|
862
1153
|
try {
|
|
863
1154
|
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${session}.json`);
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
|
|
1155
|
+
// The bridge is on the bar's scale: remaining_percentage counts down
|
|
1156
|
+
// to the auto-compact threshold, so the monitor's WARNING/CRITICAL
|
|
1157
|
+
// fire before compaction rather than after it (#4985). The token
|
|
1158
|
+
// counts let its message match /context, which is what #2451 kept
|
|
1159
|
+
// the bridge raw for; they are absent when the payload has no
|
|
1160
|
+
// context_window_size.
|
|
869
1161
|
const bridgeData = JSON.stringify({
|
|
870
1162
|
session_id: session,
|
|
871
|
-
remaining_percentage: remaining,
|
|
872
|
-
used_pct:
|
|
1163
|
+
remaining_percentage: meter.remaining,
|
|
1164
|
+
used_pct: used,
|
|
1165
|
+
...(meter.usedTokens !== null && {
|
|
1166
|
+
used_tokens: meter.usedTokens,
|
|
1167
|
+
threshold_tokens: meter.limitTokens,
|
|
1168
|
+
}),
|
|
873
1169
|
timestamp: Math.floor(Date.now() / 1000)
|
|
874
1170
|
});
|
|
875
1171
|
fs.writeFileSync(bridgePath, bridgeData);
|
|
@@ -1097,6 +1393,9 @@ module.exports = {
|
|
|
1097
1393
|
readStateHeadCommits, parseRevListCounts, deriveStateFreshness,
|
|
1098
1394
|
formatStateFreshness, resolveStatuslineOptions,
|
|
1099
1395
|
renderBracketPhaseDisplay, renderBracketMilestoneDisplay,
|
|
1396
|
+
isAutoCompactDisabled, AUTO_COMPACT_DISABLE_ENV_KEYS, AUTO_COMPACT_ENV_TRUTHY,
|
|
1397
|
+
resolveAutoCompactThreshold, contextMeter, SETTINGS_FILE_MAX_BYTES,
|
|
1398
|
+
parseAutoCompactWindowEnv, canonicalModelKey, isAutoCompactWindowValue,
|
|
1100
1399
|
};
|
|
1101
1400
|
|
|
1102
1401
|
/**
|