@opengsd/gsd-core 1.7.0-rc.6 → 1.8.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 (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -6,8 +6,12 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
+ // Namespace (not destructured) so tests can inject spawn failures by
10
+ // monkeypatching childProcess.execFileSync.
11
+ const childProcess = require('child_process');
9
12
  const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
10
13
  const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
14
+ const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
11
15
 
12
16
  // --- Config + last-command readers ------------------------------------------
13
17
 
@@ -286,6 +290,201 @@ function formatGsdState(s) {
286
290
  return parts.join(' · ');
287
291
  }
288
292
 
293
+ // --- Context token count (opt-in) ---------------------------------------------
294
+
295
+ /**
296
+ * Format a token count compactly: 156342 → '156k', 1234567 → '1.2M'.
297
+ */
298
+ function formatTokens(tokens) {
299
+ // Promote to the M branch when k-rounding would reach 1000 (999,500-999,999
300
+ // must render "1.0M", never "1000k").
301
+ if (tokens >= 1000000 || Math.round(tokens / 1000) >= 1000) {
302
+ return (tokens / 1000000).toFixed(1) + 'M';
303
+ }
304
+ if (tokens >= 1000) return Math.round(tokens / 1000) + 'k';
305
+ return String(tokens);
306
+ }
307
+
308
+ /**
309
+ * Pure function: build the token-count suffix for the context meter from the
310
+ * hook input's context_window.current_usage block. Sums input, cache-creation,
311
+ * cache-read, and output tokens (the same total Claude Code's /context shows).
312
+ * Returns ' (156k)' or '' when usage is absent/empty.
313
+ */
314
+ function contextTokenSuffix(currentUsage) {
315
+ if (!currentUsage || typeof currentUsage !== 'object') return '';
316
+ const total = (Number(currentUsage.input_tokens) || 0) +
317
+ (Number(currentUsage.cache_creation_input_tokens) || 0) +
318
+ (Number(currentUsage.cache_read_input_tokens) || 0) +
319
+ (Number(currentUsage.output_tokens) || 0);
320
+ return total > 0 ? ` (${formatTokens(total)})` : '';
321
+ }
322
+
323
+ // --- Compact state format (opt-in) ---------------------------------------------
324
+
325
+ /**
326
+ * Collapse GSD's free-text status (often a multi-sentence narrative) to a
327
+ * single keyword, built on the canonical normalizer (#2162 approval
328
+ * condition): normalizeStateStatus() in state-document.cjs owns the status
329
+ * vocabulary (discussing / planning / executing / verifying / completed /
330
+ * paused) so the two can't drift. "paused" — the canonical stuck state — is
331
+ * uppercased to PAUSED, the one state worth shouting about. Statuses the
332
+ * normalizer passes through unrecognized fall back to their first word,
333
+ * capped at 16 chars so a rogue STATE.md can't blow up the line.
334
+ * Returns null for empty input.
335
+ */
336
+ const CANONICAL_STATUSES = ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused'];
337
+
338
+ function shortGsdStatus(status) {
339
+ if (!status) return null;
340
+ const norm = normalizeStateStatus(status, null);
341
+ if (CANONICAL_STATUSES.includes(norm)) {
342
+ return norm === 'paused' ? 'PAUSED' : norm;
343
+ }
344
+ // Unrecognized free text passes through normalizeStateStatus verbatim —
345
+ // fall back to the first word, capped.
346
+ const first = String(norm).trim().split(/[\s\u2014\u2013-]+/)[0] || '';
347
+ return first ? first.slice(0, 16) : null;
348
+ }
349
+
350
+ /**
351
+ * Compact alternative to formatGsdState, selected via
352
+ * `statusline.state_format: "compact"`:
353
+ *
354
+ * "v1.12 · P7/12 · executing" (phase active)
355
+ * "v2.0 · P4.5 · BLOCKED" (no total known)
356
+ * "v2.0 · complete" (milestone done)
357
+ * "v2.0 · next execute-phase 4.5" (idle with a queued action)
358
+ *
359
+ * Drops the milestone name and progress bar — the biggest width costs in the
360
+ * default format — and collapses narrative statuses via shortGsdStatus().
361
+ * The default "full" format is untouched.
362
+ */
363
+ function formatGsdStateCompact(s) {
364
+ const parts = [];
365
+
366
+ if (s.milestone) parts.push(s.milestone);
367
+
368
+ const phaseId = s.activePhase || s.phaseNum;
369
+ if (phaseId) {
370
+ parts.push(s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`);
371
+ }
372
+
373
+ // Scene exclusivity mirrors formatGsdState's if/else chain: an in-flight
374
+ // phase (Scene 1, gated on activePhase ONLY — the legacy phaseNum shape
375
+ // still completes) wins over milestone-complete (Scene 3), even if a
376
+ // non-atomic STATE.md edit leaves percent=100 alongside a lifecycle phase.
377
+ const done = !s.activePhase && (Number(s.percent) === 100 ||
378
+ (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases));
379
+
380
+ if (done) {
381
+ parts.push('complete');
382
+ } else {
383
+ const st = shortGsdStatus(s.status);
384
+ if (st) {
385
+ parts.push(st);
386
+ } else if (!phaseId && s.nextAction) {
387
+ const phasesStr = (s.nextPhases && s.nextPhases.length > 0) ? s.nextPhases.join('/') : '';
388
+ parts.push(`next ${s.nextAction}${phasesStr ? ' ' + phasesStr : ''}`);
389
+ }
390
+ }
391
+
392
+ return parts.join(' \u00b7 ');
393
+ }
394
+
395
+ // --- Model name --------------------------------------------------------------
396
+
397
+ /**
398
+ * Collapse the verbose " (… context)" model-name suffix Claude Code sends for
399
+ * long-context sessions (e.g. "Sonnet 4.5 (1M context)") to a compact badge
400
+ * (" (1M)"). The signal is preserved; the width isn't. Tolerant by design
401
+ * (issue #2160 approval condition): any trailing parenthesized token ending
402
+ * in "context" is collapsed — a future "(500K context)" becomes "(500K)"
403
+ * rather than silently no-opping. The token's own casing is preserved.
404
+ * Any other display name passes through unchanged.
405
+ */
406
+ function compactModelName(name) {
407
+ if (typeof name !== 'string') return name;
408
+ return name.replace(/\s*\(([^)]+?)\s+(?:context|ctx)\)$/i, ' ($1)');
409
+ }
410
+
411
+ // --- Git segment (opt-in) ------------------------------------------------------
412
+ //
413
+ // Opt-in via `statusline.show_git: true` in .planning/config.json. Renders the
414
+ // current branch plus compact work-state markers after the directory segment:
415
+ // " │ main+2~1?3↑1" (staged / unstaged / untracked / ahead / behind)
416
+ // " │ main✓" (clean, in sync)
417
+ // One `git status --porcelain=v2 --branch` spawn per render — no shell, args
418
+ // are a fixed array, and the workspace dir is passed via -C. Fails silently
419
+ // (segment absent) outside a repo, without git, or on timeout.
420
+
421
+ const GIT_STATUS_TIMEOUT_MS = 1500;
422
+
423
+ /**
424
+ * Run `git status --porcelain=v2 --branch` in dir.
425
+ * Returns raw stdout, or null when git is missing, dir isn't a repo, or the
426
+ * call times out. Never throws.
427
+ */
428
+ function readGitStatus(dir) {
429
+ try {
430
+ // 8 MiB maxBuffer (default 1 MiB) headroom for repos with very many changed
431
+ // or untracked files; overflow still degrades safely to segment-absent via
432
+ // the catch below.
433
+ return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
434
+ { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
435
+ } catch (e) {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Pure function: parse `git status --porcelain=v2 --branch` output.
442
+ *
443
+ * Returns { branch, ahead, behind, staged, unstaged, untracked } or null when
444
+ * the text carries no branch header (not a repo / unparseable). Detached HEAD
445
+ * reports branch "(detached)" — porcelain v2's literal spelling, shown as-is.
446
+ * Unmerged (conflict) entries count as unstaged: they're pending work either way.
447
+ */
448
+ function parseGitStatus(text) {
449
+ if (typeof text !== 'string') return null;
450
+ const info = { branch: null, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 };
451
+ for (const line of text.split('\n')) {
452
+ if (line.startsWith('# branch.head ')) {
453
+ info.branch = line.slice('# branch.head '.length).trim() || null;
454
+ } else if (line.startsWith('# branch.ab ')) {
455
+ const m = line.match(/\+(\d+) -(\d+)/);
456
+ if (m) { info.ahead = parseInt(m[1], 10); info.behind = parseInt(m[2], 10); }
457
+ } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
458
+ // Changed / renamed entries: XY pair at cols 2-3, '.' = unmodified side
459
+ const xy = line.slice(2, 4);
460
+ if (xy[0] !== '.') info.staged++;
461
+ if (xy[1] !== '.') info.unstaged++;
462
+ } else if (line.startsWith('u ')) {
463
+ info.unstaged++;
464
+ } else if (line.startsWith('? ')) {
465
+ info.untracked++;
466
+ }
467
+ }
468
+ return info.branch ? info : null;
469
+ }
470
+
471
+ /**
472
+ * Pure function: format parsed git info into the statusline segment, divider
473
+ * included (mirrors lastCmdSuffix). Branch is dimmed to match the directory
474
+ * segment; markers keep their own colors. Returns '' when info is absent.
475
+ */
476
+ function buildGitSegment(info) {
477
+ if (!info || !info.branch) return '';
478
+ const markers = [];
479
+ if (info.staged) markers.push(`\x1b[32m+${info.staged}\x1b[0m`);
480
+ if (info.unstaged) markers.push(`\x1b[33m~${info.unstaged}\x1b[0m`);
481
+ if (info.untracked) markers.push(`\x1b[31m?${info.untracked}\x1b[0m`);
482
+ if (info.ahead) markers.push(`\x1b[32m↑${info.ahead}\x1b[0m`);
483
+ if (info.behind) markers.push(`\x1b[31m↓${info.behind}\x1b[0m`);
484
+ const state = markers.length ? markers.join('') : '\x1b[32m✓\x1b[0m';
485
+ return ` │ \x1b[2m${info.branch}\x1b[0m${state}`;
486
+ }
487
+
289
488
  // --- stdin ------------------------------------------------------------------
290
489
 
291
490
  function runStatusline() {
@@ -299,11 +498,16 @@ function runStatusline() {
299
498
  clearTimeout(stdinTimeout);
300
499
  try {
301
500
  const data = JSON.parse(input);
302
- const model = data.model?.display_name || 'Claude';
501
+ const model = compactModelName(data.model?.display_name || 'Claude');
303
502
  const dir = data.workspace?.current_dir || process.cwd();
304
503
  const session = data.session_id || '';
305
504
  const remaining = data.context_window?.remaining_percentage;
306
505
 
506
+ // Read .planning config once — used by the context meter (token suffix)
507
+ // and the last-command/position block below. Fail-soft to {}.
508
+ let cfg = {};
509
+ try { cfg = readGsdConfig(dir); } catch (e) {}
510
+
307
511
  // Context window display (shows USED percentage scaled to usable context)
308
512
  // Claude Code reserves a buffer for autocompact. By default this is ~16.5%
309
513
  // of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW
@@ -349,15 +553,21 @@ function runStatusline() {
349
553
  const filled = Math.floor(used / 10);
350
554
  const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
351
555
 
556
+ // Opt-in absolute token count after the percentage (statusline.show_context_tokens)
557
+ let tokenSuffix = '';
558
+ if (getConfigValue(cfg, 'statusline.show_context_tokens') === true) {
559
+ tokenSuffix = contextTokenSuffix(data.context_window?.current_usage);
560
+ }
561
+
352
562
  // Color based on usable context thresholds
353
563
  if (used < 50) {
354
- ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`;
564
+ ctx = ` \x1b[32m${bar} ${used}%${tokenSuffix}\x1b[0m`;
355
565
  } else if (used < 65) {
356
- ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`;
566
+ ctx = ` \x1b[33m${bar} ${used}%${tokenSuffix}\x1b[0m`;
357
567
  } else if (used < 80) {
358
- ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
568
+ ctx = ` \x1b[38;5;208m${bar} ${used}%${tokenSuffix}\x1b[0m`;
359
569
  } else {
360
- ctx = ` \x1b[5;31m💀 ${bar} ${used}%\x1b[0m`;
570
+ ctx = ` \x1b[5;31m💀 ${bar} ${used}%${tokenSuffix}\x1b[0m`;
361
571
  }
362
572
  }
363
573
 
@@ -392,8 +602,9 @@ function runStatusline() {
392
602
  }
393
603
  }
394
604
 
395
- // GSD state (milestone · status · phase) — shown when no todo task
396
- const gsdStateStr = task ? '' : formatGsdState(readGsdState(dir) || {});
605
+ // GSD state (milestone · status · phase) — shown when no todo task.
606
+ // Format resolved below once config is read (statusline.state_format).
607
+ let gsdStateStr = '';
397
608
 
398
609
  // GSD update available?
399
610
  // Read only the per-package shared cache file (#607). The legacy
@@ -421,8 +632,9 @@ function runStatusline() {
421
632
  // Failure here must never break the statusline — wrap the entire lookup.
422
633
  let lastCmdSuffix = '';
423
634
  let position = 'end';
635
+ let stateFormat = 'full';
636
+ let gitSuffix = '';
424
637
  try {
425
- const cfg = readGsdConfig(dir);
426
638
  if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
427
639
  const transcriptPath = data.transcript_path;
428
640
  const lastCmd = readLastSlashCommand(transcriptPath);
@@ -432,8 +644,17 @@ function runStatusline() {
432
644
  }
433
645
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
434
646
  if (cfgPos != null) position = cfgPos;
647
+ if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
648
+ if (getConfigValue(cfg, 'statusline.show_git') === true) {
649
+ gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
650
+ }
435
651
  } catch (e) {
436
- // Never break the statusline on config/transcript errors
652
+ // Never break the statusline on config/transcript/git errors
653
+ }
654
+
655
+ if (!task) {
656
+ const state = readGsdState(dir) || {};
657
+ gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
437
658
  }
438
659
 
439
660
  // Output
@@ -444,7 +665,7 @@ function runStatusline() {
444
665
  ? `\x1b[2m${gsdStateStr}\x1b[0m`
445
666
  : null;
446
667
 
447
- process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, position }));
668
+ process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position }));
448
669
  } catch (e) {
449
670
  // Silent fail - don't break statusline on parse errors
450
671
  }
@@ -463,6 +684,7 @@ function runStatusline() {
463
684
  * @param {string|null} [opts.middle=null] - middle segment (todo task or GSD state), null = absent
464
685
  * @param {string} opts.dirname - project directory basename (dim styling applied here)
465
686
  * @param {string} [opts.lastCmdSuffix=''] - last-command suffix, e.g. ' │ last: /foo'
687
+ * @param {string} [opts.gitSuffix=''] - git branch/status segment, e.g. ' │ main✓' (after dirname)
466
688
  * @param {'end'|'front'} [opts.position='end']
467
689
  * - 'end' (default): ctx appended after dirname — preserved byte-for-byte
468
690
  * - 'front': ctx immediately after model name so the meter stays visible in narrow terminals
@@ -478,6 +700,7 @@ function composeStatusline({
478
700
  middle = null,
479
701
  dirname,
480
702
  lastCmdSuffix = '',
703
+ gitSuffix = '',
481
704
  position = 'end',
482
705
  } = {}) {
483
706
  const modelSeg = `\x1b[2m${model}\x1b[0m`;
@@ -486,12 +709,12 @@ function composeStatusline({
486
709
  const pos = position === 'front' ? 'front' : 'end';
487
710
 
488
711
  if (pos === 'front') {
489
- if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${lastCmdSuffix}`;
490
- return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${lastCmdSuffix}`;
712
+ if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
713
+ return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
491
714
  }
492
715
  // 'end' — preserved byte-for-byte relative to original inline templates
493
- if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${ctx}${lastCmdSuffix}`;
494
- return `${gsdUpdate}${modelSeg} │ ${dirSeg}${ctx}${lastCmdSuffix}`;
716
+ if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
717
+ return `${gsdUpdate}${modelSeg} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
495
718
  }
496
719
 
497
720
  function isInstalledAheadOfLatest(installed, latest) {
@@ -531,6 +754,11 @@ module.exports = {
531
754
  composeStatusline,
532
755
  isInstalledAheadOfLatest,
533
756
  evaluateUpdateCache,
757
+ formatTokens,
758
+ contextTokenSuffix,
759
+ shortGsdStatus, formatGsdStateCompact,
760
+ compactModelName,
761
+ readGitStatus, parseGitStatus, buildGitSegment,
534
762
  };
535
763
 
536
764
  /**
@@ -538,12 +766,14 @@ module.exports = {
538
766
  * testing without feeding stdin. Returns the rendered string.
539
767
  */
540
768
  function renderStatusline(data) {
541
- const model = data.model?.display_name || 'Claude';
769
+ const model = compactModelName(data.model?.display_name || 'Claude');
542
770
  const dir = data.workspace?.current_dir || process.cwd();
543
771
  const dirname = path.basename(dir);
544
772
 
545
773
  let lastCmdSuffix = '';
546
774
  let position = 'end';
775
+ let stateFormat = 'full';
776
+ let gitSuffix = '';
547
777
  try {
548
778
  const cfg = readGsdConfig(dir);
549
779
  if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
@@ -554,11 +784,16 @@ function renderStatusline(data) {
554
784
  }
555
785
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
556
786
  if (cfgPos != null) position = cfgPos;
787
+ if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
788
+ if (getConfigValue(cfg, 'statusline.show_git') === true) {
789
+ gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
790
+ }
557
791
  } catch (e) { /* swallow */ }
558
792
 
559
- const gsdStateStr = formatGsdState(readGsdState(dir) || {});
793
+ const state = readGsdState(dir) || {};
794
+ const gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
560
795
  const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
561
- return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, position });
796
+ return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
562
797
  }
563
798
 
564
799
  module.exports.renderStatusline = renderStatusline;
@@ -180,15 +180,33 @@ process.stdin.on('end', () => {
180
180
  'starting new complex work.';
181
181
  }
182
182
 
183
- const output = {
184
- hookSpecificOutput: {
185
- hookEventName: (data.hook_event_name && data.hook_event_name.trim())
186
- || (process.env.GEMINI_API_KEY ? "AfterTool" : "PostToolUse"),
187
- additionalContext: message
188
- }
189
- };
190
-
191
- process.stdout.write(JSON.stringify(output));
183
+ // #2289: the hookSpecificOutput.additionalContext envelope is only a valid
184
+ // output shape for the context-injection events (PostToolUse, and AfterTool
185
+ // for the Gemini dialect). This hook is also wired to other lifecycle events
186
+ // on some hosts — Codex registers it under Stop / SubagentStart /
187
+ // SubagentStop / PreCompact (#772) — and those reject the envelope
188
+ // ("hook returned invalid stop hook JSON output"). Use a POSITIVE allowlist:
189
+ // emit only for injection-capable events; every other event, and a
190
+ // missing/unrecognized name, exits 0 with no stdout. A Stop-only blacklist is
191
+ // not enough — a missing name would still fall through to the injection path.
192
+ // All side effects above (debounce counter, one-time critical-session
193
+ // recording) have already run regardless of whether output is emitted.
194
+ const eventName = (data.hook_event_name && data.hook_event_name.trim()) || "";
195
+ // Preserve the pre-#2289 Gemini fallback: a missing event name under a
196
+ // Gemini-dialect runtime (GEMINI_API_KEY set) still means AfterTool, so its
197
+ // advisory output is unchanged. A missing name on any other host is silent.
198
+ const geminiFallback = eventName === "" && !!process.env.GEMINI_API_KEY;
199
+ const injectionSupported = eventName === "PostToolUse" || eventName === "AfterTool" || geminiFallback;
200
+
201
+ if (injectionSupported) {
202
+ const output = {
203
+ hookSpecificOutput: {
204
+ hookEventName: eventName || "AfterTool",
205
+ additionalContext: message
206
+ }
207
+ };
208
+ process.stdout.write(JSON.stringify(output));
209
+ }
192
210
  } catch (e) {
193
211
  // Silent fail -- never block tool execution
194
212
  process.exit(0);