@opengsd/gsd-core 1.16.0 → 1.17.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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -2
  3. package/.opencode/plugins/gsd-core.js +251 -1
  4. package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
  5. package/GEMINI.md +1 -1
  6. package/agents/gsd-debug-session-manager.compact.md +6 -0
  7. package/agents/gsd-debug-session-manager.md +6 -0
  8. package/bin/gsd-mcp-server.js +3 -1
  9. package/bin/install.js +361 -94
  10. package/gsd-core/bin/gsd-tools.cjs +17 -2
  11. package/gsd-core/bin/lib/adr-parser.cjs +37 -5
  12. package/gsd-core/bin/lib/capability-registry.cjs +132 -80
  13. package/gsd-core/bin/lib/capability-validator.cjs +2 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +2 -1
  15. package/gsd-core/bin/lib/commands.cjs +28 -8
  16. package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
  17. package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
  18. package/gsd-core/bin/lib/graphify.cjs +3 -2
  19. package/gsd-core/bin/lib/init.cjs +32 -12
  20. package/gsd-core/bin/lib/install-engine.cjs +11 -0
  21. package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
  22. package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
  23. package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
  24. package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
  25. package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
  26. package/gsd-core/bin/lib/mcp-server.cjs +5 -1
  27. package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
  28. package/gsd-core/bin/lib/opencode-version.cjs +486 -0
  29. package/gsd-core/bin/lib/phase.cjs +21 -0
  30. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
  31. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +95 -4
  32. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
  33. package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
  34. package/gsd-core/bin/lib/smart-entry.cjs +92 -53
  35. package/gsd-core/bin/lib/state-transition.cjs +67 -9
  36. package/gsd-core/bin/lib/state.cjs +454 -66
  37. package/gsd-core/bin/lib/update-context.cjs +18 -1
  38. package/gsd-core/bin/lib/verification.cjs +107 -17
  39. package/gsd-core/workflows/code-review.md +16 -1
  40. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
  41. package/gsd-core/workflows/execute-phase.md +3 -1
  42. package/gsd-core/workflows/resume-project.md +14 -10
  43. package/hooks/dist/gsd-check-update.js +26 -20
  44. package/hooks/dist/gsd-context-monitor.js +14 -5
  45. package/hooks/dist/gsd-secret-read-guard.js +117 -1
  46. package/hooks/dist/gsd-statusline.js +321 -22
  47. package/hooks/gsd-check-update.js +26 -20
  48. package/hooks/gsd-context-monitor.js +14 -5
  49. package/hooks/gsd-secret-read-guard.js +117 -1
  50. package/hooks/gsd-statusline.js +321 -22
  51. package/package.json +1 -1
  52. package/scripts/gen-capability-registry.cjs +7 -1
  53. package/scripts/gen-loop-host-contract.cjs +4 -1
  54. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  55. package/scripts/lib/platform-conformance-tier.generated.cjs +10 -0
  56. package/scripts/mutation-matrix.cjs +3 -1
  57. package/scripts/release-tarball-smoke.cjs +14 -7
  58. package/vscode/package.json +1 -1
@@ -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 (shows USED percentage scaled to usable context)
841
- // Claude Code reserves a buffer for autocompact. By default this is ~16.5%
842
- // of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW
843
- // (a token count). When the env var is set, compute the buffer % dynamically so
844
- // the meter correctly reflects early-compaction configurations (#2219).
845
- const totalCtx = data.context_window?.total_tokens || 1_000_000;
846
- const acw = parseInt(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '0', 10);
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 (remaining != null) {
852
- // Normalize: subtract buffer from remaining, scale to usable range
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
- // used_pct written to the bridge must match CC's native /context reporting:
865
- // raw used = 100 - remaining_percentage (no buffer normalization applied).
866
- // The normalized `used` value is correct for the statusline progress bar but
867
- // inflates the context monitor warning messages by ~13 points (#2451).
868
- const rawUsedPct = Math.round(100 - remaining);
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: rawUsedPct,
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
  /**
@@ -67,26 +67,32 @@ const cacheFile = path.join(cacheDir, updateCacheFileName);
67
67
  const projectVersionFile = path.join(projectConfigDir, 'gsd-core', 'VERSION');
68
68
  const globalVersionFile = path.join(globalConfigDir, 'gsd-core', 'VERSION');
69
69
 
70
- // Ensure cache directory exists
71
- if (!fs.existsSync(cacheDir)) {
70
+ // This background check is advisory. Cache or worker failures must not make
71
+ // the SessionStart hook fail. Recursive mkdir is safe if another session
72
+ // creates the directory concurrently.
73
+ try {
72
74
  fs.mkdirSync(cacheDir, { recursive: true });
73
- }
74
75
 
75
- // Run check in background via a dedicated worker script.
76
- // Spawning a file (rather than node -e '<inline code>') keeps the worker logic
77
- // in plain JS with no template-literal regex-escaping concerns, and makes the
78
- // worker independently testable.
79
- const workerPath = path.join(__dirname, 'gsd-check-update-worker.js');
80
- const child = spawn(process.execPath, [workerPath], {
81
- stdio: 'ignore',
82
- windowsHide: true,
83
- detached: true, // Required on Windows for proper process detachment
84
- env: {
85
- ...process.env,
86
- GSD_CACHE_FILE: cacheFile,
87
- GSD_PROJECT_VERSION_FILE: projectVersionFile,
88
- GSD_GLOBAL_VERSION_FILE: globalVersionFile,
89
- },
90
- });
76
+ // Run check in background via a dedicated worker script.
77
+ // Spawning a file (rather than node -e '<inline code>') keeps the worker logic
78
+ // in plain JS with no template-literal regex-escaping concerns, and makes the
79
+ // worker independently testable.
80
+ const workerPath = path.join(__dirname, 'gsd-check-update-worker.js');
81
+ const child = spawn(process.execPath, [workerPath], {
82
+ stdio: 'ignore',
83
+ windowsHide: true,
84
+ detached: true, // Required on Windows for proper process detachment
85
+ env: {
86
+ ...process.env,
87
+ GSD_CACHE_FILE: cacheFile,
88
+ GSD_PROJECT_VERSION_FILE: projectVersionFile,
89
+ GSD_GLOBAL_VERSION_FILE: globalVersionFile,
90
+ },
91
+ });
91
92
 
92
- child.unref();
93
+ // A spawn failure can also arrive asynchronously on the child process.
94
+ child.on('error', () => {});
95
+ child.unref();
96
+ } catch {
97
+ // Update hints are best effort; a failed check must not interrupt startup.
98
+ }
@@ -14,6 +14,8 @@
14
14
  // Thresholds:
15
15
  // WARNING (remaining <= 35%): Agent should wrap up current task
16
16
  // CRITICAL (remaining <= 25%): Agent should stop immediately and save state
17
+ // "remaining" is of the auto-compact threshold — the statusline puts the
18
+ // bridge on that scale (#4985), so both fire before Claude Code compacts.
17
19
  // Both fire-points are overridable per project via .planning/config.json
18
20
  // (hooks.context_warning_threshold / hooks.context_critical_threshold, #4285);
19
21
  // the values above are the defaults used when the keys are absent or unusable.
@@ -500,23 +502,30 @@ const handleStdinEnd = () => {
500
502
  }
501
503
 
502
504
  // Build advisory warning message (never use imperative commands that
503
- // override user preferences — see #884)
505
+ // override user preferences — see #884). The percentages are of the
506
+ // auto-compact threshold (#4985); the token counts, when the bridge has
507
+ // them, are the ones /context shows (#2451).
508
+ const kTokens = n => `${Math.round(n / 1000)}k`;
509
+ const tokenDetail = Number.isFinite(metrics.used_tokens) && Number.isFinite(metrics.threshold_tokens)
510
+ ? ` (${kTokens(metrics.used_tokens)} of ${kTokens(metrics.threshold_tokens)} usable tokens)`
511
+ : '';
512
+ const usage = `Usage at ${usedPct}%${tokenDetail}. Remaining: ${remaining}%. `;
504
513
  let message;
505
514
  if (isCritical) {
506
515
  message = isGsdActive
507
- ? `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
516
+ ? `CONTEXT CRITICAL: ${usage}` +
508
517
  'Context is nearly exhausted. Do NOT start new complex work or write handoff files — ' +
509
518
  'GSD state is already tracked in STATE.md. Inform the user so they can run ' +
510
519
  '/gsd:pause-work at the next natural stopping point.'
511
- : `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
520
+ : `CONTEXT CRITICAL: ${usage}` +
512
521
  'Context is nearly exhausted. Inform the user that context is low and ask how they ' +
513
522
  'want to proceed. Do NOT autonomously save state or write handoff files unless the user asks.';
514
523
  } else {
515
524
  message = isGsdActive
516
- ? `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
525
+ ? `CONTEXT WARNING: ${usage}` +
517
526
  'Context is getting limited. Avoid starting new complex work. If not between ' +
518
527
  'defined plan steps, inform the user so they can prepare to pause.'
519
- : `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
528
+ : `CONTEXT WARNING: ${usage}` +
520
529
  'Be aware that context is getting limited. Avoid unnecessary exploration or ' +
521
530
  'starting new complex work.';
522
531
  }
@@ -103,9 +103,23 @@
103
103
  // edit of `.env` lands in the link target — a write redirection, the
104
104
  // out-of-scope write class, not a read.)
105
105
  //
106
+ // A second, unrelated contract is enforced on the Bash leg because this is the
107
+ // one hook that already parses EVERY Bash command (#5214): a Bash command that
108
+ // launches the debugger as a CLI process — `claude … --agent gsd-debugger` — is
109
+ // denied (`debugger-cli-spawn`). The debug session manager is required to spawn
110
+ // gsd-debugger with `Agent(run_in_background=false)` so it can read the
111
+ // debugger's structured return; on a captured 1.15.0 run it routed around that
112
+ // literal and shelled out with the Bash parameter `run_in_background: true`,
113
+ // so no return ever came back and the checkpoint never advanced. Prose in the
114
+ // agent file cannot stop an improvised tool call; a PreToolUse denial can, and
115
+ // Claude Code runs plugin hooks for a subagent's tool calls too. A person
116
+ // running `claude --agent gsd-debugger` in their own terminal never reaches
117
+ // this hook. Only the debugger is named: it is the agent whose blocking return
118
+ // the managed debug loop depends on.
119
+ //
106
120
  // Triggers on: Read, Grep, Bash tool calls (Kimi: ReadFile, Grep, Shell)
107
121
  // Action: BLOCK (decision: 'block', exit 2) — codes secret-read |
108
- // glob-too-complex | command-too-large
122
+ // glob-too-complex | command-too-large | debugger-cli-spawn
109
123
  // No-op: other tools, non-secret targets, hook errors (fail open — a parser
110
124
  // bug in a hook that runs on EVERY Bash call must never brick a
111
125
  // session; the crash policy is declared once below).
@@ -1004,6 +1018,100 @@ function findSecretRead(command, depth) {
1004
1018
  return null;
1005
1019
  }
1006
1020
 
1021
+ // ---------------------------------------------------------------------------
1022
+ // gsd-debugger CLI spawn scan (#5214)
1023
+ // ---------------------------------------------------------------------------
1024
+
1025
+ const DEBUGGER_AGENT_NAME = 'gsd-debugger';
1026
+ const CLAUDE_BINARY_RE = /^claude(\.exe)?$/;
1027
+
1028
+ // `gsd-debugger`, or a plugin-namespaced `<plugin>:gsd-debugger`.
1029
+ function isDebuggerAgent(value) {
1030
+ return value.slice(value.lastIndexOf(':') + 1) === DEBUGGER_AGENT_NAME;
1031
+ }
1032
+
1033
+ // `timeout [flags] DURATION cmd …` is not in CMD_PREFIXES because its first
1034
+ // operand is a duration, not the command. Returns the command that follows it.
1035
+ function unwrapTimeout(cmd) {
1036
+ let { base, operands } = cmd;
1037
+ while (base === 'timeout') {
1038
+ let k = 0;
1039
+ while (k < operands.length && operands[k].text.startsWith('-')) k++;
1040
+ k++; // the DURATION operand
1041
+ const rest = resolveCommand(operands.slice(k));
1042
+ if (!rest) return null;
1043
+ ({ base, operands } = rest);
1044
+ }
1045
+ return { base, operands };
1046
+ }
1047
+
1048
+ // Returns the `--agent` value text of a debugger launch, or null.
1049
+ function findDebuggerCliSpawn(command, depth) {
1050
+ const { tokens, nested } = tokenize(command);
1051
+
1052
+ for (const sub of nested) {
1053
+ if (depth < MAX_NESTING_DEPTH) {
1054
+ const hit = findDebuggerCliSpawn(sub, depth + 1);
1055
+ if (hit) return hit;
1056
+ }
1057
+ }
1058
+
1059
+ const bySeg = new Map();
1060
+ for (const t of tokens) {
1061
+ if (t.kind !== 'word' && t.kind !== 'op') continue;
1062
+ if (!bySeg.has(t.seg)) bySeg.set(t.seg, []);
1063
+ bySeg.get(t.seg).push(t);
1064
+ }
1065
+
1066
+ for (const segTokens of bySeg.values()) {
1067
+ const words = [];
1068
+ for (let k = 0; k < segTokens.length; k++) {
1069
+ const t = segTokens[k];
1070
+ if (t.kind === 'op') {
1071
+ // A redirect consumes its target word; it is never the command.
1072
+ if (!t.dup && segTokens[k + 1] && segTokens[k + 1].kind === 'word') k++;
1073
+ continue;
1074
+ }
1075
+ words.push(t);
1076
+ }
1077
+ if (!words.length) continue;
1078
+
1079
+ const first = resolveCommand(words);
1080
+ const cmd = first && unwrapTimeout(first);
1081
+ if (!cmd) continue;
1082
+ const { base, operands } = cmd;
1083
+
1084
+ if (base === 'eval') {
1085
+ if (depth < MAX_NESTING_DEPTH) {
1086
+ const hit = findDebuggerCliSpawn(operands.map((w) => w.text).join(' '), depth + 1);
1087
+ if (hit) return hit;
1088
+ }
1089
+ continue;
1090
+ }
1091
+
1092
+ if (SHELL_INTERPRETERS.has(base)) {
1093
+ const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
1094
+ if (cIdx !== -1 && operands[cIdx + 1] && depth < MAX_NESTING_DEPTH) {
1095
+ const hit = findDebuggerCliSpawn(operands[cIdx + 1].text, depth + 1);
1096
+ if (hit) return hit;
1097
+ }
1098
+ continue;
1099
+ }
1100
+
1101
+ if (!CLAUDE_BINARY_RE.test(base)) continue;
1102
+ for (let k = 0; k < operands.length; k++) {
1103
+ const text = operands[k].text;
1104
+ if (text === '--agent') {
1105
+ const value = operands[k + 1];
1106
+ if (value && isDebuggerAgent(value.text)) return value.text;
1107
+ } else if (text.startsWith('--agent=') && isDebuggerAgent(text.slice('--agent='.length))) {
1108
+ return text.slice('--agent='.length);
1109
+ }
1110
+ }
1111
+ }
1112
+ return null;
1113
+ }
1114
+
1007
1115
  // A shell interpreter's script comes from `-c`, a file operand, or stdin.
1008
1116
  function scanShellInterpreter(operands, heredocs, hereStrings, s, bySeg, sepAfter, depth) {
1009
1117
  const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
@@ -1102,6 +1210,12 @@ function reasonFor(code, tool, target) {
1102
1210
  return `Secret read guard: the Grep glob '${target}' expands to more than ${MAX_GLOB_ALTERNATIVES} ` +
1103
1211
  'alternatives and cannot be checked for secret-file matches. Use a narrower glob.';
1104
1212
  }
1213
+ if (code === 'debugger-cli-spawn') {
1214
+ return `Debugger spawn guard: this Bash command launches the debugger as a CLI process ('--agent ${target}'). ` +
1215
+ 'A shell-spawned debugger runs detached, returns no structured result, and the debug checkpoint never advances. ' +
1216
+ 'Spawn it with the Agent tool instead: Agent(subagent_type="gsd-debugger", run_in_background=false, ...) ' +
1217
+ 'and wait for its structured return.';
1218
+ }
1105
1219
  return `Secret read guard: ${tool} would read '${target}', which matches a protected secret-file ` +
1106
1220
  `pattern (${PATTERN_TEXT}). Secret values must not be read into the conversation. ` +
1107
1221
  'If you need a specific value, ask the user for it; if you need the variable NAMES, ' +
@@ -1253,6 +1367,8 @@ process.stdin.on('end', () => {
1253
1367
  if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
1254
1368
  const hit = findSecretRead(command, 0);
1255
1369
  if (hit !== null) emitBlock('secret-read', tool, hit);
1370
+ const spawn = findDebuggerCliSpawn(command, 0);
1371
+ if (spawn !== null) emitBlock('debugger-cli-spawn', tool, spawn);
1256
1372
  allow(undefined);
1257
1373
  } catch {
1258
1374
  // Fail open — never block valid tool calls due to hook errors.