@opengsd/gsd-core 1.7.0-rc.6 → 1.7.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/bin/install.js +5 -4
  6. package/commands/gsd/mempalace-capture.md +27 -1
  7. package/commands/gsd/surface.md +6 -6
  8. package/gsd-core/bin/gsd-tools.cjs +63 -2
  9. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  10. package/gsd-core/bin/lib/audit.cjs +7 -6
  11. package/gsd-core/bin/lib/capability-registry.cjs +57 -57
  12. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  13. package/gsd-core/bin/lib/clock.cjs +19 -0
  14. package/gsd-core/bin/lib/commands.cjs +48 -9
  15. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  16. package/gsd-core/bin/lib/config.cjs +12 -0
  17. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  18. package/gsd-core/bin/lib/drift.cjs +4 -4
  19. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  20. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  21. package/gsd-core/bin/lib/init.cjs +27 -13
  22. package/gsd-core/bin/lib/install-engine.cjs +3 -2
  23. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  24. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  25. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  26. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  27. package/gsd-core/bin/lib/milestone.cjs +217 -31
  28. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  29. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  30. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  31. package/gsd-core/bin/lib/phase.cjs +436 -61
  32. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  33. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  34. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  35. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -6
  36. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  37. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +2 -1
  38. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +25 -17
  39. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  40. package/gsd-core/bin/lib/security.cjs +1 -1
  41. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  42. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  43. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  44. package/gsd-core/bin/lib/state.cjs +391 -126
  45. package/gsd-core/bin/lib/surface.cjs +2 -2
  46. package/gsd-core/bin/lib/template.cjs +2 -1
  47. package/gsd-core/bin/lib/uat.cjs +54 -8
  48. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  49. package/gsd-core/bin/lib/verify.cjs +4 -3
  50. package/gsd-core/bin/lib/workstream.cjs +3 -2
  51. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  52. package/gsd-core/bin/lib/write-set.cjs +38 -0
  53. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  54. package/gsd-core/references/checkpoints.md +12 -0
  55. package/gsd-core/workflows/add-tests.md +1 -1
  56. package/gsd-core/workflows/debug.md +2 -0
  57. package/gsd-core/workflows/execute-phase.md +5 -3
  58. package/gsd-core/workflows/fast.md +8 -22
  59. package/gsd-core/workflows/progress.md +2 -2
  60. package/gsd-core/workflows/quick.md +2 -0
  61. package/gsd-core/workflows/review.md +42 -3
  62. package/gsd-core/workflows/secure-phase.md +1 -1
  63. package/gsd-core/workflows/ship.md +8 -2
  64. package/gsd-core/workflows/spec-phase.md +1 -1
  65. package/gsd-core/workflows/transition.md +1 -1
  66. package/hooks/dist/gsd-statusline.js +164 -14
  67. package/hooks/gsd-statusline.js +164 -14
  68. package/package.json +4 -2
  69. package/scripts/ci-test-scope.cjs +39 -1
  70. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  71. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  72. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  73. package/scripts/lint-table-schema-drift.cjs +157 -0
  74. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  75. package/skills/gsd-surface/SKILL.md +6 -6
  76. package/vscode/package.json +1 -1
@@ -6,6 +6,9 @@
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');
11
14
 
@@ -286,6 +289,129 @@ function formatGsdState(s) {
286
289
  return parts.join(' · ');
287
290
  }
288
291
 
292
+ // --- Context token count (opt-in) ---------------------------------------------
293
+
294
+ /**
295
+ * Format a token count compactly: 156342 → '156k', 1234567 → '1.2M'.
296
+ */
297
+ function formatTokens(tokens) {
298
+ // Promote to the M branch when k-rounding would reach 1000 (999,500-999,999
299
+ // must render "1.0M", never "1000k").
300
+ if (tokens >= 1000000 || Math.round(tokens / 1000) >= 1000) {
301
+ return (tokens / 1000000).toFixed(1) + 'M';
302
+ }
303
+ if (tokens >= 1000) return Math.round(tokens / 1000) + 'k';
304
+ return String(tokens);
305
+ }
306
+
307
+ /**
308
+ * Pure function: build the token-count suffix for the context meter from the
309
+ * hook input's context_window.current_usage block. Sums input, cache-creation,
310
+ * cache-read, and output tokens (the same total Claude Code's /context shows).
311
+ * Returns ' (156k)' or '' when usage is absent/empty.
312
+ */
313
+ function contextTokenSuffix(currentUsage) {
314
+ if (!currentUsage || typeof currentUsage !== 'object') return '';
315
+ const total = (Number(currentUsage.input_tokens) || 0) +
316
+ (Number(currentUsage.cache_creation_input_tokens) || 0) +
317
+ (Number(currentUsage.cache_read_input_tokens) || 0) +
318
+ (Number(currentUsage.output_tokens) || 0);
319
+ return total > 0 ? ` (${formatTokens(total)})` : '';
320
+ }
321
+
322
+ // --- Model name --------------------------------------------------------------
323
+
324
+ /**
325
+ * Collapse the verbose " (… context)" model-name suffix Claude Code sends for
326
+ * long-context sessions (e.g. "Sonnet 4.5 (1M context)") to a compact badge
327
+ * (" (1M)"). The signal is preserved; the width isn't. Tolerant by design
328
+ * (issue #2160 approval condition): any trailing parenthesized token ending
329
+ * in "context" is collapsed — a future "(500K context)" becomes "(500K)"
330
+ * rather than silently no-opping. The token's own casing is preserved.
331
+ * Any other display name passes through unchanged.
332
+ */
333
+ function compactModelName(name) {
334
+ if (typeof name !== 'string') return name;
335
+ return name.replace(/\s*\(([^)]+?)\s+(?:context|ctx)\)$/i, ' ($1)');
336
+ }
337
+
338
+ // --- Git segment (opt-in) ------------------------------------------------------
339
+ //
340
+ // Opt-in via `statusline.show_git: true` in .planning/config.json. Renders the
341
+ // current branch plus compact work-state markers after the directory segment:
342
+ // " │ main+2~1?3↑1" (staged / unstaged / untracked / ahead / behind)
343
+ // " │ main✓" (clean, in sync)
344
+ // One `git status --porcelain=v2 --branch` spawn per render — no shell, args
345
+ // are a fixed array, and the workspace dir is passed via -C. Fails silently
346
+ // (segment absent) outside a repo, without git, or on timeout.
347
+
348
+ const GIT_STATUS_TIMEOUT_MS = 1500;
349
+
350
+ /**
351
+ * Run `git status --porcelain=v2 --branch` in dir.
352
+ * Returns raw stdout, or null when git is missing, dir isn't a repo, or the
353
+ * call times out. Never throws.
354
+ */
355
+ function readGitStatus(dir) {
356
+ try {
357
+ // 8 MiB maxBuffer (default 1 MiB) headroom for repos with very many changed
358
+ // or untracked files; overflow still degrades safely to segment-absent via
359
+ // the catch below.
360
+ return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
361
+ { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
362
+ } catch (e) {
363
+ return null;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Pure function: parse `git status --porcelain=v2 --branch` output.
369
+ *
370
+ * Returns { branch, ahead, behind, staged, unstaged, untracked } or null when
371
+ * the text carries no branch header (not a repo / unparseable). Detached HEAD
372
+ * reports branch "(detached)" — porcelain v2's literal spelling, shown as-is.
373
+ * Unmerged (conflict) entries count as unstaged: they're pending work either way.
374
+ */
375
+ function parseGitStatus(text) {
376
+ if (typeof text !== 'string') return null;
377
+ const info = { branch: null, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 };
378
+ for (const line of text.split('\n')) {
379
+ if (line.startsWith('# branch.head ')) {
380
+ info.branch = line.slice('# branch.head '.length).trim() || null;
381
+ } else if (line.startsWith('# branch.ab ')) {
382
+ const m = line.match(/\+(\d+) -(\d+)/);
383
+ if (m) { info.ahead = parseInt(m[1], 10); info.behind = parseInt(m[2], 10); }
384
+ } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
385
+ // Changed / renamed entries: XY pair at cols 2-3, '.' = unmodified side
386
+ const xy = line.slice(2, 4);
387
+ if (xy[0] !== '.') info.staged++;
388
+ if (xy[1] !== '.') info.unstaged++;
389
+ } else if (line.startsWith('u ')) {
390
+ info.unstaged++;
391
+ } else if (line.startsWith('? ')) {
392
+ info.untracked++;
393
+ }
394
+ }
395
+ return info.branch ? info : null;
396
+ }
397
+
398
+ /**
399
+ * Pure function: format parsed git info into the statusline segment, divider
400
+ * included (mirrors lastCmdSuffix). Branch is dimmed to match the directory
401
+ * segment; markers keep their own colors. Returns '' when info is absent.
402
+ */
403
+ function buildGitSegment(info) {
404
+ if (!info || !info.branch) return '';
405
+ const markers = [];
406
+ if (info.staged) markers.push(`\x1b[32m+${info.staged}\x1b[0m`);
407
+ if (info.unstaged) markers.push(`\x1b[33m~${info.unstaged}\x1b[0m`);
408
+ if (info.untracked) markers.push(`\x1b[31m?${info.untracked}\x1b[0m`);
409
+ if (info.ahead) markers.push(`\x1b[32m↑${info.ahead}\x1b[0m`);
410
+ if (info.behind) markers.push(`\x1b[31m↓${info.behind}\x1b[0m`);
411
+ const state = markers.length ? markers.join('') : '\x1b[32m✓\x1b[0m';
412
+ return ` │ \x1b[2m${info.branch}\x1b[0m${state}`;
413
+ }
414
+
289
415
  // --- stdin ------------------------------------------------------------------
290
416
 
291
417
  function runStatusline() {
@@ -299,11 +425,16 @@ function runStatusline() {
299
425
  clearTimeout(stdinTimeout);
300
426
  try {
301
427
  const data = JSON.parse(input);
302
- const model = data.model?.display_name || 'Claude';
428
+ const model = compactModelName(data.model?.display_name || 'Claude');
303
429
  const dir = data.workspace?.current_dir || process.cwd();
304
430
  const session = data.session_id || '';
305
431
  const remaining = data.context_window?.remaining_percentage;
306
432
 
433
+ // Read .planning config once — used by the context meter (token suffix)
434
+ // and the last-command/position block below. Fail-soft to {}.
435
+ let cfg = {};
436
+ try { cfg = readGsdConfig(dir); } catch (e) {}
437
+
307
438
  // Context window display (shows USED percentage scaled to usable context)
308
439
  // Claude Code reserves a buffer for autocompact. By default this is ~16.5%
309
440
  // of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW
@@ -349,15 +480,21 @@ function runStatusline() {
349
480
  const filled = Math.floor(used / 10);
350
481
  const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
351
482
 
483
+ // Opt-in absolute token count after the percentage (statusline.show_context_tokens)
484
+ let tokenSuffix = '';
485
+ if (getConfigValue(cfg, 'statusline.show_context_tokens') === true) {
486
+ tokenSuffix = contextTokenSuffix(data.context_window?.current_usage);
487
+ }
488
+
352
489
  // Color based on usable context thresholds
353
490
  if (used < 50) {
354
- ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`;
491
+ ctx = ` \x1b[32m${bar} ${used}%${tokenSuffix}\x1b[0m`;
355
492
  } else if (used < 65) {
356
- ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`;
493
+ ctx = ` \x1b[33m${bar} ${used}%${tokenSuffix}\x1b[0m`;
357
494
  } else if (used < 80) {
358
- ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
495
+ ctx = ` \x1b[38;5;208m${bar} ${used}%${tokenSuffix}\x1b[0m`;
359
496
  } else {
360
- ctx = ` \x1b[5;31m💀 ${bar} ${used}%\x1b[0m`;
497
+ ctx = ` \x1b[5;31m💀 ${bar} ${used}%${tokenSuffix}\x1b[0m`;
361
498
  }
362
499
  }
363
500
 
@@ -421,8 +558,8 @@ function runStatusline() {
421
558
  // Failure here must never break the statusline — wrap the entire lookup.
422
559
  let lastCmdSuffix = '';
423
560
  let position = 'end';
561
+ let gitSuffix = '';
424
562
  try {
425
- const cfg = readGsdConfig(dir);
426
563
  if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
427
564
  const transcriptPath = data.transcript_path;
428
565
  const lastCmd = readLastSlashCommand(transcriptPath);
@@ -432,8 +569,11 @@ function runStatusline() {
432
569
  }
433
570
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
434
571
  if (cfgPos != null) position = cfgPos;
572
+ if (getConfigValue(cfg, 'statusline.show_git') === true) {
573
+ gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
574
+ }
435
575
  } catch (e) {
436
- // Never break the statusline on config/transcript errors
576
+ // Never break the statusline on config/transcript/git errors
437
577
  }
438
578
 
439
579
  // Output
@@ -444,7 +584,7 @@ function runStatusline() {
444
584
  ? `\x1b[2m${gsdStateStr}\x1b[0m`
445
585
  : null;
446
586
 
447
- process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, position }));
587
+ process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position }));
448
588
  } catch (e) {
449
589
  // Silent fail - don't break statusline on parse errors
450
590
  }
@@ -463,6 +603,7 @@ function runStatusline() {
463
603
  * @param {string|null} [opts.middle=null] - middle segment (todo task or GSD state), null = absent
464
604
  * @param {string} opts.dirname - project directory basename (dim styling applied here)
465
605
  * @param {string} [opts.lastCmdSuffix=''] - last-command suffix, e.g. ' │ last: /foo'
606
+ * @param {string} [opts.gitSuffix=''] - git branch/status segment, e.g. ' │ main✓' (after dirname)
466
607
  * @param {'end'|'front'} [opts.position='end']
467
608
  * - 'end' (default): ctx appended after dirname — preserved byte-for-byte
468
609
  * - 'front': ctx immediately after model name so the meter stays visible in narrow terminals
@@ -478,6 +619,7 @@ function composeStatusline({
478
619
  middle = null,
479
620
  dirname,
480
621
  lastCmdSuffix = '',
622
+ gitSuffix = '',
481
623
  position = 'end',
482
624
  } = {}) {
483
625
  const modelSeg = `\x1b[2m${model}\x1b[0m`;
@@ -486,12 +628,12 @@ function composeStatusline({
486
628
  const pos = position === 'front' ? 'front' : 'end';
487
629
 
488
630
  if (pos === 'front') {
489
- if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${lastCmdSuffix}`;
490
- return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${lastCmdSuffix}`;
631
+ if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
632
+ return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
491
633
  }
492
634
  // '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}`;
635
+ if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
636
+ return `${gsdUpdate}${modelSeg} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
495
637
  }
496
638
 
497
639
  function isInstalledAheadOfLatest(installed, latest) {
@@ -531,6 +673,10 @@ module.exports = {
531
673
  composeStatusline,
532
674
  isInstalledAheadOfLatest,
533
675
  evaluateUpdateCache,
676
+ formatTokens,
677
+ contextTokenSuffix,
678
+ compactModelName,
679
+ readGitStatus, parseGitStatus, buildGitSegment,
534
680
  };
535
681
 
536
682
  /**
@@ -538,12 +684,13 @@ module.exports = {
538
684
  * testing without feeding stdin. Returns the rendered string.
539
685
  */
540
686
  function renderStatusline(data) {
541
- const model = data.model?.display_name || 'Claude';
687
+ const model = compactModelName(data.model?.display_name || 'Claude');
542
688
  const dir = data.workspace?.current_dir || process.cwd();
543
689
  const dirname = path.basename(dir);
544
690
 
545
691
  let lastCmdSuffix = '';
546
692
  let position = 'end';
693
+ let gitSuffix = '';
547
694
  try {
548
695
  const cfg = readGsdConfig(dir);
549
696
  if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
@@ -554,11 +701,14 @@ function renderStatusline(data) {
554
701
  }
555
702
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
556
703
  if (cfgPos != null) position = cfgPos;
704
+ if (getConfigValue(cfg, 'statusline.show_git') === true) {
705
+ gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
706
+ }
557
707
  } catch (e) { /* swallow */ }
558
708
 
559
709
  const gsdStateStr = formatGsdState(readGsdState(dir) || {});
560
710
  const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
561
- return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, position });
711
+ return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
562
712
  }
563
713
 
564
714
  module.exports.renderStatusline = renderStatusline;
@@ -6,6 +6,9 @@
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');
11
14
 
@@ -286,6 +289,129 @@ function formatGsdState(s) {
286
289
  return parts.join(' · ');
287
290
  }
288
291
 
292
+ // --- Context token count (opt-in) ---------------------------------------------
293
+
294
+ /**
295
+ * Format a token count compactly: 156342 → '156k', 1234567 → '1.2M'.
296
+ */
297
+ function formatTokens(tokens) {
298
+ // Promote to the M branch when k-rounding would reach 1000 (999,500-999,999
299
+ // must render "1.0M", never "1000k").
300
+ if (tokens >= 1000000 || Math.round(tokens / 1000) >= 1000) {
301
+ return (tokens / 1000000).toFixed(1) + 'M';
302
+ }
303
+ if (tokens >= 1000) return Math.round(tokens / 1000) + 'k';
304
+ return String(tokens);
305
+ }
306
+
307
+ /**
308
+ * Pure function: build the token-count suffix for the context meter from the
309
+ * hook input's context_window.current_usage block. Sums input, cache-creation,
310
+ * cache-read, and output tokens (the same total Claude Code's /context shows).
311
+ * Returns ' (156k)' or '' when usage is absent/empty.
312
+ */
313
+ function contextTokenSuffix(currentUsage) {
314
+ if (!currentUsage || typeof currentUsage !== 'object') return '';
315
+ const total = (Number(currentUsage.input_tokens) || 0) +
316
+ (Number(currentUsage.cache_creation_input_tokens) || 0) +
317
+ (Number(currentUsage.cache_read_input_tokens) || 0) +
318
+ (Number(currentUsage.output_tokens) || 0);
319
+ return total > 0 ? ` (${formatTokens(total)})` : '';
320
+ }
321
+
322
+ // --- Model name --------------------------------------------------------------
323
+
324
+ /**
325
+ * Collapse the verbose " (… context)" model-name suffix Claude Code sends for
326
+ * long-context sessions (e.g. "Sonnet 4.5 (1M context)") to a compact badge
327
+ * (" (1M)"). The signal is preserved; the width isn't. Tolerant by design
328
+ * (issue #2160 approval condition): any trailing parenthesized token ending
329
+ * in "context" is collapsed — a future "(500K context)" becomes "(500K)"
330
+ * rather than silently no-opping. The token's own casing is preserved.
331
+ * Any other display name passes through unchanged.
332
+ */
333
+ function compactModelName(name) {
334
+ if (typeof name !== 'string') return name;
335
+ return name.replace(/\s*\(([^)]+?)\s+(?:context|ctx)\)$/i, ' ($1)');
336
+ }
337
+
338
+ // --- Git segment (opt-in) ------------------------------------------------------
339
+ //
340
+ // Opt-in via `statusline.show_git: true` in .planning/config.json. Renders the
341
+ // current branch plus compact work-state markers after the directory segment:
342
+ // " │ main+2~1?3↑1" (staged / unstaged / untracked / ahead / behind)
343
+ // " │ main✓" (clean, in sync)
344
+ // One `git status --porcelain=v2 --branch` spawn per render — no shell, args
345
+ // are a fixed array, and the workspace dir is passed via -C. Fails silently
346
+ // (segment absent) outside a repo, without git, or on timeout.
347
+
348
+ const GIT_STATUS_TIMEOUT_MS = 1500;
349
+
350
+ /**
351
+ * Run `git status --porcelain=v2 --branch` in dir.
352
+ * Returns raw stdout, or null when git is missing, dir isn't a repo, or the
353
+ * call times out. Never throws.
354
+ */
355
+ function readGitStatus(dir) {
356
+ try {
357
+ // 8 MiB maxBuffer (default 1 MiB) headroom for repos with very many changed
358
+ // or untracked files; overflow still degrades safely to segment-absent via
359
+ // the catch below.
360
+ return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
361
+ { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
362
+ } catch (e) {
363
+ return null;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Pure function: parse `git status --porcelain=v2 --branch` output.
369
+ *
370
+ * Returns { branch, ahead, behind, staged, unstaged, untracked } or null when
371
+ * the text carries no branch header (not a repo / unparseable). Detached HEAD
372
+ * reports branch "(detached)" — porcelain v2's literal spelling, shown as-is.
373
+ * Unmerged (conflict) entries count as unstaged: they're pending work either way.
374
+ */
375
+ function parseGitStatus(text) {
376
+ if (typeof text !== 'string') return null;
377
+ const info = { branch: null, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 };
378
+ for (const line of text.split('\n')) {
379
+ if (line.startsWith('# branch.head ')) {
380
+ info.branch = line.slice('# branch.head '.length).trim() || null;
381
+ } else if (line.startsWith('# branch.ab ')) {
382
+ const m = line.match(/\+(\d+) -(\d+)/);
383
+ if (m) { info.ahead = parseInt(m[1], 10); info.behind = parseInt(m[2], 10); }
384
+ } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
385
+ // Changed / renamed entries: XY pair at cols 2-3, '.' = unmodified side
386
+ const xy = line.slice(2, 4);
387
+ if (xy[0] !== '.') info.staged++;
388
+ if (xy[1] !== '.') info.unstaged++;
389
+ } else if (line.startsWith('u ')) {
390
+ info.unstaged++;
391
+ } else if (line.startsWith('? ')) {
392
+ info.untracked++;
393
+ }
394
+ }
395
+ return info.branch ? info : null;
396
+ }
397
+
398
+ /**
399
+ * Pure function: format parsed git info into the statusline segment, divider
400
+ * included (mirrors lastCmdSuffix). Branch is dimmed to match the directory
401
+ * segment; markers keep their own colors. Returns '' when info is absent.
402
+ */
403
+ function buildGitSegment(info) {
404
+ if (!info || !info.branch) return '';
405
+ const markers = [];
406
+ if (info.staged) markers.push(`\x1b[32m+${info.staged}\x1b[0m`);
407
+ if (info.unstaged) markers.push(`\x1b[33m~${info.unstaged}\x1b[0m`);
408
+ if (info.untracked) markers.push(`\x1b[31m?${info.untracked}\x1b[0m`);
409
+ if (info.ahead) markers.push(`\x1b[32m↑${info.ahead}\x1b[0m`);
410
+ if (info.behind) markers.push(`\x1b[31m↓${info.behind}\x1b[0m`);
411
+ const state = markers.length ? markers.join('') : '\x1b[32m✓\x1b[0m';
412
+ return ` │ \x1b[2m${info.branch}\x1b[0m${state}`;
413
+ }
414
+
289
415
  // --- stdin ------------------------------------------------------------------
290
416
 
291
417
  function runStatusline() {
@@ -299,11 +425,16 @@ function runStatusline() {
299
425
  clearTimeout(stdinTimeout);
300
426
  try {
301
427
  const data = JSON.parse(input);
302
- const model = data.model?.display_name || 'Claude';
428
+ const model = compactModelName(data.model?.display_name || 'Claude');
303
429
  const dir = data.workspace?.current_dir || process.cwd();
304
430
  const session = data.session_id || '';
305
431
  const remaining = data.context_window?.remaining_percentage;
306
432
 
433
+ // Read .planning config once — used by the context meter (token suffix)
434
+ // and the last-command/position block below. Fail-soft to {}.
435
+ let cfg = {};
436
+ try { cfg = readGsdConfig(dir); } catch (e) {}
437
+
307
438
  // Context window display (shows USED percentage scaled to usable context)
308
439
  // Claude Code reserves a buffer for autocompact. By default this is ~16.5%
309
440
  // of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW
@@ -349,15 +480,21 @@ function runStatusline() {
349
480
  const filled = Math.floor(used / 10);
350
481
  const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
351
482
 
483
+ // Opt-in absolute token count after the percentage (statusline.show_context_tokens)
484
+ let tokenSuffix = '';
485
+ if (getConfigValue(cfg, 'statusline.show_context_tokens') === true) {
486
+ tokenSuffix = contextTokenSuffix(data.context_window?.current_usage);
487
+ }
488
+
352
489
  // Color based on usable context thresholds
353
490
  if (used < 50) {
354
- ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`;
491
+ ctx = ` \x1b[32m${bar} ${used}%${tokenSuffix}\x1b[0m`;
355
492
  } else if (used < 65) {
356
- ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`;
493
+ ctx = ` \x1b[33m${bar} ${used}%${tokenSuffix}\x1b[0m`;
357
494
  } else if (used < 80) {
358
- ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
495
+ ctx = ` \x1b[38;5;208m${bar} ${used}%${tokenSuffix}\x1b[0m`;
359
496
  } else {
360
- ctx = ` \x1b[5;31m💀 ${bar} ${used}%\x1b[0m`;
497
+ ctx = ` \x1b[5;31m💀 ${bar} ${used}%${tokenSuffix}\x1b[0m`;
361
498
  }
362
499
  }
363
500
 
@@ -421,8 +558,8 @@ function runStatusline() {
421
558
  // Failure here must never break the statusline — wrap the entire lookup.
422
559
  let lastCmdSuffix = '';
423
560
  let position = 'end';
561
+ let gitSuffix = '';
424
562
  try {
425
- const cfg = readGsdConfig(dir);
426
563
  if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
427
564
  const transcriptPath = data.transcript_path;
428
565
  const lastCmd = readLastSlashCommand(transcriptPath);
@@ -432,8 +569,11 @@ function runStatusline() {
432
569
  }
433
570
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
434
571
  if (cfgPos != null) position = cfgPos;
572
+ if (getConfigValue(cfg, 'statusline.show_git') === true) {
573
+ gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
574
+ }
435
575
  } catch (e) {
436
- // Never break the statusline on config/transcript errors
576
+ // Never break the statusline on config/transcript/git errors
437
577
  }
438
578
 
439
579
  // Output
@@ -444,7 +584,7 @@ function runStatusline() {
444
584
  ? `\x1b[2m${gsdStateStr}\x1b[0m`
445
585
  : null;
446
586
 
447
- process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, position }));
587
+ process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position }));
448
588
  } catch (e) {
449
589
  // Silent fail - don't break statusline on parse errors
450
590
  }
@@ -463,6 +603,7 @@ function runStatusline() {
463
603
  * @param {string|null} [opts.middle=null] - middle segment (todo task or GSD state), null = absent
464
604
  * @param {string} opts.dirname - project directory basename (dim styling applied here)
465
605
  * @param {string} [opts.lastCmdSuffix=''] - last-command suffix, e.g. ' │ last: /foo'
606
+ * @param {string} [opts.gitSuffix=''] - git branch/status segment, e.g. ' │ main✓' (after dirname)
466
607
  * @param {'end'|'front'} [opts.position='end']
467
608
  * - 'end' (default): ctx appended after dirname — preserved byte-for-byte
468
609
  * - 'front': ctx immediately after model name so the meter stays visible in narrow terminals
@@ -478,6 +619,7 @@ function composeStatusline({
478
619
  middle = null,
479
620
  dirname,
480
621
  lastCmdSuffix = '',
622
+ gitSuffix = '',
481
623
  position = 'end',
482
624
  } = {}) {
483
625
  const modelSeg = `\x1b[2m${model}\x1b[0m`;
@@ -486,12 +628,12 @@ function composeStatusline({
486
628
  const pos = position === 'front' ? 'front' : 'end';
487
629
 
488
630
  if (pos === 'front') {
489
- if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${lastCmdSuffix}`;
490
- return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${lastCmdSuffix}`;
631
+ if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
632
+ return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
491
633
  }
492
634
  // '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}`;
635
+ if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
636
+ return `${gsdUpdate}${modelSeg} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
495
637
  }
496
638
 
497
639
  function isInstalledAheadOfLatest(installed, latest) {
@@ -531,6 +673,10 @@ module.exports = {
531
673
  composeStatusline,
532
674
  isInstalledAheadOfLatest,
533
675
  evaluateUpdateCache,
676
+ formatTokens,
677
+ contextTokenSuffix,
678
+ compactModelName,
679
+ readGitStatus, parseGitStatus, buildGitSegment,
534
680
  };
535
681
 
536
682
  /**
@@ -538,12 +684,13 @@ module.exports = {
538
684
  * testing without feeding stdin. Returns the rendered string.
539
685
  */
540
686
  function renderStatusline(data) {
541
- const model = data.model?.display_name || 'Claude';
687
+ const model = compactModelName(data.model?.display_name || 'Claude');
542
688
  const dir = data.workspace?.current_dir || process.cwd();
543
689
  const dirname = path.basename(dir);
544
690
 
545
691
  let lastCmdSuffix = '';
546
692
  let position = 'end';
693
+ let gitSuffix = '';
547
694
  try {
548
695
  const cfg = readGsdConfig(dir);
549
696
  if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
@@ -554,11 +701,14 @@ function renderStatusline(data) {
554
701
  }
555
702
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
556
703
  if (cfgPos != null) position = cfgPos;
704
+ if (getConfigValue(cfg, 'statusline.show_git') === true) {
705
+ gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
706
+ }
557
707
  } catch (e) { /* swallow */ }
558
708
 
559
709
  const gsdStateStr = formatGsdState(readGsdState(dir) || {});
560
710
  const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
561
- return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, position });
711
+ return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
562
712
  }
563
713
 
564
714
  module.exports.renderStatusline = renderStatusline;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.7.0-rc.6",
3
+ "version": "1.7.0",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -90,6 +90,7 @@
90
90
  "gen:plugin-skills": "node scripts/gen-plugin-skills.cjs --write",
91
91
  "gen:capability-registry": "node scripts/gen-capability-registry.cjs --write",
92
92
  "gen:registry": "node scripts/gen-registry.cjs --write",
93
+ "gen:golden": "node scripts/gen-golden-install-parity-zcode.cjs && node scripts/gen-install-tree-fixtures.cjs",
93
94
  "validate:registry": "node scripts/validate-registry.cjs",
94
95
  "prepack": "npm run build:lib",
95
96
  "prepare": "npm run build:lib",
@@ -99,7 +100,8 @@
99
100
  "pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
100
101
  "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
101
102
  "lint:fix": "eslint . --fix",
102
- "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/validate-registry.cjs",
103
+ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
104
+ "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs",
103
105
  "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
104
106
  "lint:regression-names": "node scripts/lint-regression-test-names.cjs",
105
107
  "lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -145,6 +145,37 @@ const RULES = [
145
145
  'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
146
146
  ],
147
147
  },
148
+ {
149
+ name: 'shipped install content (golden-parity drift guard, #2267)',
150
+ // Every source file the installer EMITS into a runtime layout is captured by
151
+ // golden-install-parity + the install-tree snapshot. A source edit here that
152
+ // changes emitted output MUST re-verify the fixtures — otherwise stale golden
153
+ // fixtures merge silently (#2266: a hooks/gsd-statusline.js edit changed
154
+ // installed output but no rule selected golden-parity, so stale fixtures
155
+ // shipped to next undetected). Union semantics: this ADDS the parity guard on
156
+ // top of each path's existing content-specific tests. Targeted lane only (the
157
+ // golden test skips win32 by design), no fullMatrix.
158
+ // NOTE: intentionally NOT a blanket 'gsd-core/' prefix, for two reasons:
159
+ // (1) gsd-core/bin/** is tsc-compiled runtime output — EXCLUDED_PREFIXES-
160
+ // excluded from both manifests, and already covered by the 'installer and
161
+ // package layout' rule (path.startsWith('gsd-core/bin/')) — so matching it
162
+ // here would be pure noise; and
163
+ // (2) enumerating only the installer-shipped content subtrees preserves the
164
+ // bug-408 unit-fallback contract: a gsd-core/ path that is NOT shipped
165
+ // verbatim (the bug-408 test uses gsd-core/src/some-util.js) must still
166
+ // fall back to ['unit'] when no rule matches.
167
+ // Listed: the four gsd-core content subtrees the installer ships verbatim
168
+ // (contexts, references, templates, workflows) + bin/shared/*.json data files.
169
+ // Verify against Object.keys(golden fixture) grouped by gsd-core/<subdir>.
170
+ match: path =>
171
+ ['hooks/', 'commands/', 'agents/', 'skills/', 'gsd-core/workflows/', 'gsd-core/templates/', 'gsd-core/references/', 'gsd-core/contexts/', 'scripts/changeset/', 'scripts/lib/'].some(p => path.startsWith(p)) ||
172
+ (path.startsWith('gsd-core/bin/shared/') && path.endsWith('.json')) ||
173
+ ['scripts/fix-slash-commands.cjs', 'scripts/gen-capability-registry.cjs', 'scripts/gen-loop-host-contract.cjs'].includes(path),
174
+ tests: [
175
+ 'tests/golden-install-parity.test.cjs',
176
+ 'tests/golden-install-tree.test.cjs',
177
+ ],
178
+ },
148
179
  {
149
180
  name: 'hooks',
150
181
  match: path => path.startsWith('hooks/'),
@@ -364,8 +395,15 @@ function classify(files) {
364
395
  for (const file of files) {
365
396
  // Determine if this file is product/pipeline code.
366
397
  // docs/ and root-level .md files are intentionally excluded.
398
+ // 'skills/' is shipped agent-skill content installed into every runtime by
399
+ // the installer (see the 'shipped install content' RULES entry below) — it
400
+ // must be product code, or a skills/-only change silently gets
401
+ // code_changed=false and skips the ENTIRE CI matrix, not merely golden-parity
402
+ // (found while verifying the #2267 golden-parity rule against skills/**: the
403
+ // rule fired in `reasons` but classify()'s codeChanged gate zeroed out every
404
+ // targeted test because 'skills/' was absent from this list).
367
405
  if (
368
- ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) ||
406
+ ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'skills/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) ||
369
407
  file === 'package.json' || file === 'package-lock.json' ||
370
408
  (file.startsWith('tsconfig') && file.endsWith('.json')) ||
371
409
  file.startsWith('.github/rulesets/')