From dd049ff8b366c98657afc5816df6d709e0ffbcdc Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 20 Aug 2026 23:41:35 +1000 Subject: [PATCH 1/2] ignore scratch and openspec --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ce87746..12cec6e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ __pycache__ .claude/skills/openspec* .claude/skills/speckit* .specify/ +# scratch dirs +.scratch/ +scratch/ # demo files demo/ @@ -15,7 +18,6 @@ demo/ # JetBrains IDEs .idea/ - # JSON import benchmark venv (ops/bench_json_import.py) ops/.bench-venv/ @@ -24,3 +26,7 @@ ops/research/ # ascii-mode render artifacts for eyeballing prototype/ + +# GIGANTOR files :( +openspec/ + From 85fb3483557e4fff805786488715b22b12c92996 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 20 Aug 2026 23:42:04 +1000 Subject: [PATCH 2/2] remove all openspec files - wayyy to big --- .../.openspec.yaml | 2 - .../2026-05-25-add-burndown-trend/design.md | 137 -------- .../2026-05-25-add-burndown-trend/proposal.md | 31 -- .../specs/rate-limit-burndown/spec.md | 139 -------- .../2026-05-25-add-burndown-trend/tasks.md | 58 ---- .../.openspec.yaml | 2 - .../design.md | 52 --- .../proposal.md | 36 -- .../specs/subagent-burn-metrics/spec.md | 49 --- .../2026-05-25-subagent-burn-metrics/tasks.md | 52 --- .../.openspec.yaml | 2 - .../design.md | 64 ---- .../proposal.md | 32 -- .../specs/statusline-config/spec.md | 173 --------- .../tasks.md | 75 ---- .../.openspec.yaml | 2 - .../2026-06-01-add-info-gather-seam/design.md | 73 ---- .../proposal.md | 26 -- .../specs/statusline-info/spec.md | 52 --- .../specs/statusline-packaging/spec.md | 20 -- .../2026-06-01-add-info-gather-seam/tasks.md | 49 --- .../.openspec.yaml | 2 - .../2026-06-01-agents-done-cohort/design.md | 72 ---- .../2026-06-01-agents-done-cohort/proposal.md | 29 -- .../specs/prompt-boundary-hook/spec.md | 43 --- .../specs/subagent-cohort/spec.md | 110 ------ .../2026-06-01-agents-done-cohort/tasks.md | 80 ----- .../.openspec.yaml | 2 - .../design.md | 55 --- .../proposal.md | 30 -- .../specs/statusline-packaging/spec.md | 69 ---- .../tasks.md | 79 ----- .../.openspec.yaml | 2 - .../design.md | 98 ------ .../proposal.md | 29 -- .../specs/statusline-packaging/spec.md | 57 --- .../tasks.md | 70 ---- .../.openspec.yaml | 2 - .../design.md | 117 ------- .../proposal.md | 40 --- .../specs/task-checklist/spec.md | 101 ------ .../2026-06-01-task-checklist-timers/tasks.md | 82 ----- .../.openspec.yaml | 2 - .../2026-06-02-add-cache-countdown/design.md | 57 --- .../proposal.md | 28 -- .../specs/cache-countdown/spec.md | 100 ------ .../specs/statusline-info/spec.md | 25 -- .../2026-06-02-add-cache-countdown/tasks.md | 76 ---- .../.openspec.yaml | 2 - .../2026-06-02-add-community-themes/design.md | 119 ------- .../proposal.md | 29 -- .../specs/.gitkeep | 12 - .../specs/no-spec-changes.md | 18 - .../2026-06-02-add-community-themes/tasks.md | 89 ----- .../.openspec.yaml | 2 - .../2026-06-05-add-install-script/design.md | 61 ---- .../2026-06-05-add-install-script/proposal.md | 28 -- .../specs/install-script/spec.md | 130 ------- .../2026-06-05-add-install-script/tasks.md | 32 -- .../.openspec.yaml | 2 - .../design.md | 47 --- .../proposal.md | 24 -- .../specs/untrusted-input-hardening/spec.md | 43 --- .../tasks.md | 28 -- .../.openspec.yaml | 2 - .../design.md | 32 -- .../proposal.md | 26 -- .../specs/context-percentage-accuracy/spec.md | 30 -- .../tasks.md | 37 -- .../.openspec.yaml | 2 - .../2026-06-06-fix-mon-config-dir/design.md | 30 -- .../2026-06-06-fix-mon-config-dir/proposal.md | 24 -- .../specs/mon-config-dir/spec.md | 20 -- .../2026-06-06-fix-mon-config-dir/tasks.md | 29 -- .../.openspec.yaml | 2 - .../2026-06-06-fix-session-elapsed/design.md | 31 -- .../proposal.md | 26 -- .../specs/session-elapsed-accuracy/spec.md | 25 -- .../2026-06-06-fix-session-elapsed/tasks.md | 36 -- .../.openspec.yaml | 2 - .../2026-06-06-fix-terminal-width/design.md | 29 -- .../2026-06-06-fix-terminal-width/proposal.md | 24 -- .../specs/terminal-width-resolution/spec.md | 34 -- .../2026-06-06-fix-terminal-width/tasks.md | 31 -- .../.openspec.yaml | 2 - .../design.md | 64 ---- .../proposal.md | 30 -- .../specs/side-by-side-sections/spec.md | 81 ----- .../specs/subagent-cohort/spec.md | 20 -- .../specs/subagent-row-layout/spec.md | 38 -- .../specs/task-checklist/spec.md | 25 -- .../tasks.md | 78 ----- .../.openspec.yaml | 2 - .../2026-06-08-compact-tokens-row/design.md | 123 ------- .../2026-06-08-compact-tokens-row/proposal.md | 57 --- .../specs/compact-tokens-row/spec.md | 84 ----- .../specs/statusline-config/spec.md | 107 ------ .../2026-06-08-compact-tokens-row/tasks.md | 36 -- .../.openspec.yaml | 2 - .../design.md | 47 --- .../proposal.md | 37 -- .../specs/openspec-bar-colour/spec.md | 34 -- .../tasks.md | 16 - .../.openspec.yaml | 2 - .../design.md | 62 ---- .../proposal.md | 40 --- .../specs/subagent-row-layout/spec.md | 39 --- .../tasks.md | 20 -- .../.openspec.yaml | 2 - .../design.md | 82 ----- .../proposal.md | 59 ---- .../specs/subagent-cohort/spec.md | 32 -- .../tasks.md | 17 - .../.openspec.yaml | 2 - .../2026-06-10-path-include-or-omit/design.md | 55 --- .../proposal.md | 38 -- .../specs/path-display/spec.md | 46 --- .../2026-06-10-path-include-or-omit/tasks.md | 23 -- .../.openspec.yaml | 2 - .../design.md | 67 ---- .../proposal.md | 30 -- .../specs/workflow-cohort/spec.md | 132 ------- .../tasks.md | 49 --- .../.openspec.yaml | 2 - .../design.md | 46 --- .../proposal.md | 26 -- .../specs/subagent-row-layout/spec.md | 33 -- .../specs/workflow-phase-display/spec.md | 35 -- .../specs/workflow-two-column-agents/spec.md | 27 -- .../tasks.md | 48 --- .../2026-06-16-restyle-top-row/.openspec.yaml | 2 - .../2026-06-16-restyle-top-row/design.md | 42 --- .../2026-06-16-restyle-top-row/proposal.md | 27 -- .../specs/cache-countdown/spec.md | 25 -- .../specs/top-row-format/spec.md | 86 ----- .../2026-06-16-restyle-top-row/tasks.md | 41 --- .../2026-06-17-add-clear-timer/.openspec.yaml | 2 - .../2026-06-17-add-clear-timer/design.md | 38 -- .../2026-06-17-add-clear-timer/proposal.md | 31 -- .../specs/statusline-info/spec.md | 25 -- .../specs/top-row-format/spec.md | 60 ---- .../2026-06-17-add-clear-timer/tasks.md | 36 -- .../.openspec.yaml | 2 - .../2026-06-17-justify-wide-top-row/design.md | 65 ---- .../proposal.md | 27 -- .../specs/justify-top-row/spec.md | 72 ---- .../specs/statusline-config/spec.md | 30 -- .../2026-06-17-justify-wide-top-row/tasks.md | 38 -- .../.openspec.yaml | 2 - .../2026-06-18-add-layout-labels/design.md | 56 --- .../2026-06-18-add-layout-labels/proposal.md | 31 -- .../specs/section-labels/spec.md | 135 -------- .../specs/statusline-config/spec.md | 25 -- .../2026-06-18-add-layout-labels/tasks.md | 44 --- .../2026-06-18-glyph-mode/.openspec.yaml | 2 - .../archive/2026-06-18-glyph-mode/design.md | 84 ----- .../archive/2026-06-18-glyph-mode/proposal.md | 29 -- .../specs/glyph-mode/spec.md | 75 ---- .../specs/statusline-config/spec.md | 182 ---------- .../archive/2026-06-18-glyph-mode/tasks.md | 32 -- .../.openspec.yaml | 2 - .../design.md | 99 ------ .../proposal.md | 28 -- .../specs/install-script/spec.md | 124 ------- .../tasks.md | 66 ---- .../.openspec.yaml | 2 - .../design.md | 208 ----------- .../proposal.md | 92 ----- .../specs/install-script/spec.md | 181 ---------- .../2026-06-20-interactive-installer/tasks.md | 72 ---- .../.openspec.yaml | 2 - .../2026-06-28-tool-use-counts-row/design.md | 210 ----------- .../proposal.md | 64 ---- .../specs/statusline-info/spec.md | 29 -- .../specs/tool-counts-row/spec.md | 194 ----------- .../2026-06-28-tool-use-counts-row/tasks.md | 49 --- .../.openspec.yaml | 2 - .../design.md | 108 ------ .../proposal.md | 49 --- .../specs/git-branch-display/spec.md | 38 -- .../tasks.md | 28 -- .../.openspec.yaml | 2 - .../design.md | 257 -------------- .../proposal.md | 80 ----- .../specs/compact-tokens-row/spec.md | 44 --- .../specs/line-counts/spec.md | 289 ---------------- .../specs/statusline-info/spec.md | 47 --- .../specs/subagent-row-layout/spec.md | 47 --- .../tasks.md | 77 ----- .../cache-transcript-parses/.openspec.yaml | 2 - .../changes/cache-transcript-parses/design.md | 226 ------------ .../cache-transcript-parses/proposal.md | 101 ------ .../specs/statusline-config/spec.md | 33 -- .../specs/statusline-info/spec.md | 82 ----- .../specs/subagent-cohort/spec.md | 49 --- .../specs/transcript-parse-cache/spec.md | 208 ----------- .../changes/cache-transcript-parses/tasks.md | 88 ----- .../.openspec.yaml | 2 - .../consolidate-claude-dir-layout/design.md | 187 ---------- .../consolidate-claude-dir-layout/proposal.md | 37 -- .../specs/claude-dir-layout/spec.md | 109 ------ .../specs/layout-migration/spec.md | 106 ------ .../consolidate-claude-dir-layout/tasks.md | 65 ---- openspec/config.yaml | 20 -- openspec/specs/cache-countdown/spec.md | 110 ------ openspec/specs/compact-tokens-row/spec.md | 111 ------ openspec/specs/git-branch-display/spec.md | 42 --- openspec/specs/glyph-mode/spec.md | 81 ----- openspec/specs/install-script/spec.md | 327 ------------------ openspec/specs/justify-top-row/spec.md | 78 ----- openspec/specs/line-counts/spec.md | 293 ---------------- openspec/specs/openspec-bar-colour/spec.md | 34 -- openspec/specs/path-display/spec.md | 52 --- openspec/specs/prompt-boundary-hook/spec.md | 49 --- openspec/specs/section-labels/spec.md | 141 -------- openspec/specs/side-by-side-sections/spec.md | 87 ----- openspec/specs/statusline-config/spec.md | 320 ----------------- openspec/specs/statusline-info/spec.md | 133 ------- openspec/specs/statusline-packaging/spec.md | 69 ---- openspec/specs/subagent-cohort/spec.md | 128 ------- openspec/specs/subagent-row-layout/spec.md | 144 -------- openspec/specs/task-checklist/spec.md | 132 ------- openspec/specs/tool-counts-row/spec.md | 205 ----------- openspec/specs/top-row-format/spec.md | 128 ------- .../specs/untrusted-input-hardening/spec.md | 47 --- openspec/specs/workflow-cohort/spec.md | 132 ------- openspec/specs/workflow-phase-display/spec.md | 50 --- .../specs/workflow-two-column-agents/spec.md | 54 --- 228 files changed, 13718 deletions(-) delete mode 100644 openspec/changes/archive/2026-05-25-add-burndown-trend/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-05-25-add-burndown-trend/design.md delete mode 100644 openspec/changes/archive/2026-05-25-add-burndown-trend/proposal.md delete mode 100644 openspec/changes/archive/2026-05-25-add-burndown-trend/specs/rate-limit-burndown/spec.md delete mode 100644 openspec/changes/archive/2026-05-25-add-burndown-trend/tasks.md delete mode 100644 openspec/changes/archive/2026-05-25-subagent-burn-metrics/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-05-25-subagent-burn-metrics/design.md delete mode 100644 openspec/changes/archive/2026-05-25-subagent-burn-metrics/proposal.md delete mode 100644 openspec/changes/archive/2026-05-25-subagent-burn-metrics/specs/subagent-burn-metrics/spec.md delete mode 100644 openspec/changes/archive/2026-05-25-subagent-burn-metrics/tasks.md delete mode 100644 openspec/changes/archive/2026-05-31-statusline-user-config/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-05-31-statusline-user-config/design.md delete mode 100644 openspec/changes/archive/2026-05-31-statusline-user-config/proposal.md delete mode 100644 openspec/changes/archive/2026-05-31-statusline-user-config/specs/statusline-config/spec.md delete mode 100644 openspec/changes/archive/2026-05-31-statusline-user-config/tasks.md delete mode 100644 openspec/changes/archive/2026-06-01-add-info-gather-seam/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-01-add-info-gather-seam/design.md delete mode 100644 openspec/changes/archive/2026-06-01-add-info-gather-seam/proposal.md delete mode 100644 openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-info/spec.md delete mode 100644 openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-packaging/spec.md delete mode 100644 openspec/changes/archive/2026-06-01-add-info-gather-seam/tasks.md delete mode 100644 openspec/changes/archive/2026-06-01-agents-done-cohort/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-01-agents-done-cohort/design.md delete mode 100644 openspec/changes/archive/2026-06-01-agents-done-cohort/proposal.md delete mode 100644 openspec/changes/archive/2026-06-01-agents-done-cohort/specs/prompt-boundary-hook/spec.md delete mode 100644 openspec/changes/archive/2026-06-01-agents-done-cohort/specs/subagent-cohort/spec.md delete mode 100644 openspec/changes/archive/2026-06-01-agents-done-cohort/tasks.md delete mode 100644 openspec/changes/archive/2026-06-01-reorganise-yas-package/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-01-reorganise-yas-package/design.md delete mode 100644 openspec/changes/archive/2026-06-01-reorganise-yas-package/proposal.md delete mode 100644 openspec/changes/archive/2026-06-01-reorganise-yas-package/specs/statusline-packaging/spec.md delete mode 100644 openspec/changes/archive/2026-06-01-reorganise-yas-package/tasks.md delete mode 100644 openspec/changes/archive/2026-06-01-split-statusline-modules/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-01-split-statusline-modules/design.md delete mode 100644 openspec/changes/archive/2026-06-01-split-statusline-modules/proposal.md delete mode 100644 openspec/changes/archive/2026-06-01-split-statusline-modules/specs/statusline-packaging/spec.md delete mode 100644 openspec/changes/archive/2026-06-01-split-statusline-modules/tasks.md delete mode 100644 openspec/changes/archive/2026-06-01-task-checklist-timers/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-01-task-checklist-timers/design.md delete mode 100644 openspec/changes/archive/2026-06-01-task-checklist-timers/proposal.md delete mode 100644 openspec/changes/archive/2026-06-01-task-checklist-timers/specs/task-checklist/spec.md delete mode 100644 openspec/changes/archive/2026-06-01-task-checklist-timers/tasks.md delete mode 100644 openspec/changes/archive/2026-06-02-add-cache-countdown/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-02-add-cache-countdown/design.md delete mode 100644 openspec/changes/archive/2026-06-02-add-cache-countdown/proposal.md delete mode 100644 openspec/changes/archive/2026-06-02-add-cache-countdown/specs/cache-countdown/spec.md delete mode 100644 openspec/changes/archive/2026-06-02-add-cache-countdown/specs/statusline-info/spec.md delete mode 100644 openspec/changes/archive/2026-06-02-add-cache-countdown/tasks.md delete mode 100644 openspec/changes/archive/2026-06-02-add-community-themes/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-02-add-community-themes/design.md delete mode 100644 openspec/changes/archive/2026-06-02-add-community-themes/proposal.md delete mode 100644 openspec/changes/archive/2026-06-02-add-community-themes/specs/.gitkeep delete mode 100644 openspec/changes/archive/2026-06-02-add-community-themes/specs/no-spec-changes.md delete mode 100644 openspec/changes/archive/2026-06-02-add-community-themes/tasks.md delete mode 100644 openspec/changes/archive/2026-06-05-add-install-script/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-05-add-install-script/design.md delete mode 100644 openspec/changes/archive/2026-06-05-add-install-script/proposal.md delete mode 100644 openspec/changes/archive/2026-06-05-add-install-script/specs/install-script/spec.md delete mode 100644 openspec/changes/archive/2026-06-05-add-install-script/tasks.md delete mode 100644 openspec/changes/archive/2026-06-05-harden-untrusted-input/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-05-harden-untrusted-input/design.md delete mode 100644 openspec/changes/archive/2026-06-05-harden-untrusted-input/proposal.md delete mode 100644 openspec/changes/archive/2026-06-05-harden-untrusted-input/specs/untrusted-input-hardening/spec.md delete mode 100644 openspec/changes/archive/2026-06-05-harden-untrusted-input/tasks.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-context-percentage/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-fix-context-percentage/design.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-context-percentage/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-context-percentage/specs/context-percentage-accuracy/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-context-percentage/tasks.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-mon-config-dir/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-fix-mon-config-dir/design.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-mon-config-dir/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-mon-config-dir/specs/mon-config-dir/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-mon-config-dir/tasks.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-session-elapsed/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-fix-session-elapsed/design.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-session-elapsed/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-session-elapsed/specs/session-elapsed-accuracy/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-session-elapsed/tasks.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-terminal-width/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-fix-terminal-width/design.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-terminal-width/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-terminal-width/specs/terminal-width-resolution/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-fix-terminal-width/tasks.md delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/design.md delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/proposal.md delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/side-by-side-sections/spec.md delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-cohort/spec.md delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-row-layout/spec.md delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/task-checklist/spec.md delete mode 100644 openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/tasks.md delete mode 100644 openspec/changes/archive/2026-06-08-compact-tokens-row/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-08-compact-tokens-row/design.md delete mode 100644 openspec/changes/archive/2026-06-08-compact-tokens-row/proposal.md delete mode 100644 openspec/changes/archive/2026-06-08-compact-tokens-row/specs/compact-tokens-row/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-compact-tokens-row/specs/statusline-config/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-compact-tokens-row/tasks.md delete mode 100644 openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/design.md delete mode 100644 openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/proposal.md delete mode 100644 openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/specs/openspec-bar-colour/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/tasks.md delete mode 100644 openspec/changes/archive/2026-06-08-subagent-activity-snippet/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-08-subagent-activity-snippet/design.md delete mode 100644 openspec/changes/archive/2026-06-08-subagent-activity-snippet/proposal.md delete mode 100644 openspec/changes/archive/2026-06-08-subagent-activity-snippet/specs/subagent-row-layout/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-subagent-activity-snippet/tasks.md delete mode 100644 openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/design.md delete mode 100644 openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/proposal.md delete mode 100644 openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/specs/subagent-cohort/spec.md delete mode 100644 openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/tasks.md delete mode 100644 openspec/changes/archive/2026-06-10-path-include-or-omit/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-10-path-include-or-omit/design.md delete mode 100644 openspec/changes/archive/2026-06-10-path-include-or-omit/proposal.md delete mode 100644 openspec/changes/archive/2026-06-10-path-include-or-omit/specs/path-display/spec.md delete mode 100644 openspec/changes/archive/2026-06-10-path-include-or-omit/tasks.md delete mode 100644 openspec/changes/archive/2026-06-16-display-workflow-agents/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-16-display-workflow-agents/design.md delete mode 100644 openspec/changes/archive/2026-06-16-display-workflow-agents/proposal.md delete mode 100644 openspec/changes/archive/2026-06-16-display-workflow-agents/specs/workflow-cohort/spec.md delete mode 100644 openspec/changes/archive/2026-06-16-display-workflow-agents/tasks.md delete mode 100644 openspec/changes/archive/2026-06-16-improve-workflow-display/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-16-improve-workflow-display/design.md delete mode 100644 openspec/changes/archive/2026-06-16-improve-workflow-display/proposal.md delete mode 100644 openspec/changes/archive/2026-06-16-improve-workflow-display/specs/subagent-row-layout/spec.md delete mode 100644 openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-phase-display/spec.md delete mode 100644 openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-two-column-agents/spec.md delete mode 100644 openspec/changes/archive/2026-06-16-improve-workflow-display/tasks.md delete mode 100644 openspec/changes/archive/2026-06-16-restyle-top-row/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-16-restyle-top-row/design.md delete mode 100644 openspec/changes/archive/2026-06-16-restyle-top-row/proposal.md delete mode 100644 openspec/changes/archive/2026-06-16-restyle-top-row/specs/cache-countdown/spec.md delete mode 100644 openspec/changes/archive/2026-06-16-restyle-top-row/specs/top-row-format/spec.md delete mode 100644 openspec/changes/archive/2026-06-16-restyle-top-row/tasks.md delete mode 100644 openspec/changes/archive/2026-06-17-add-clear-timer/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-17-add-clear-timer/design.md delete mode 100644 openspec/changes/archive/2026-06-17-add-clear-timer/proposal.md delete mode 100644 openspec/changes/archive/2026-06-17-add-clear-timer/specs/statusline-info/spec.md delete mode 100644 openspec/changes/archive/2026-06-17-add-clear-timer/specs/top-row-format/spec.md delete mode 100644 openspec/changes/archive/2026-06-17-add-clear-timer/tasks.md delete mode 100644 openspec/changes/archive/2026-06-17-justify-wide-top-row/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-17-justify-wide-top-row/design.md delete mode 100644 openspec/changes/archive/2026-06-17-justify-wide-top-row/proposal.md delete mode 100644 openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/justify-top-row/spec.md delete mode 100644 openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/statusline-config/spec.md delete mode 100644 openspec/changes/archive/2026-06-17-justify-wide-top-row/tasks.md delete mode 100644 openspec/changes/archive/2026-06-18-add-layout-labels/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-18-add-layout-labels/design.md delete mode 100644 openspec/changes/archive/2026-06-18-add-layout-labels/proposal.md delete mode 100644 openspec/changes/archive/2026-06-18-add-layout-labels/specs/section-labels/spec.md delete mode 100644 openspec/changes/archive/2026-06-18-add-layout-labels/specs/statusline-config/spec.md delete mode 100644 openspec/changes/archive/2026-06-18-add-layout-labels/tasks.md delete mode 100644 openspec/changes/archive/2026-06-18-glyph-mode/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-18-glyph-mode/design.md delete mode 100644 openspec/changes/archive/2026-06-18-glyph-mode/proposal.md delete mode 100644 openspec/changes/archive/2026-06-18-glyph-mode/specs/glyph-mode/spec.md delete mode 100644 openspec/changes/archive/2026-06-18-glyph-mode/specs/statusline-config/spec.md delete mode 100644 openspec/changes/archive/2026-06-18-glyph-mode/tasks.md delete mode 100644 openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/design.md delete mode 100644 openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/proposal.md delete mode 100644 openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/specs/install-script/spec.md delete mode 100644 openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/tasks.md delete mode 100644 openspec/changes/archive/2026-06-20-interactive-installer/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-20-interactive-installer/design.md delete mode 100644 openspec/changes/archive/2026-06-20-interactive-installer/proposal.md delete mode 100644 openspec/changes/archive/2026-06-20-interactive-installer/specs/install-script/spec.md delete mode 100644 openspec/changes/archive/2026-06-20-interactive-installer/tasks.md delete mode 100644 openspec/changes/archive/2026-06-28-tool-use-counts-row/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-28-tool-use-counts-row/design.md delete mode 100644 openspec/changes/archive/2026-06-28-tool-use-counts-row/proposal.md delete mode 100644 openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/statusline-info/spec.md delete mode 100644 openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/tool-counts-row/spec.md delete mode 100644 openspec/changes/archive/2026-06-28-tool-use-counts-row/tasks.md delete mode 100644 openspec/changes/archive/2026-07-04-branch-name-slash-support/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-07-04-branch-name-slash-support/design.md delete mode 100644 openspec/changes/archive/2026-07-04-branch-name-slash-support/proposal.md delete mode 100644 openspec/changes/archive/2026-07-04-branch-name-slash-support/specs/git-branch-display/spec.md delete mode 100644 openspec/changes/archive/2026-07-04-branch-name-slash-support/tasks.md delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/design.md delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/proposal.md delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/compact-tokens-row/spec.md delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/line-counts/spec.md delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/statusline-info/spec.md delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/subagent-row-layout/spec.md delete mode 100644 openspec/changes/archive/2026-07-26-add-subagent-line-counts/tasks.md delete mode 100644 openspec/changes/cache-transcript-parses/.openspec.yaml delete mode 100644 openspec/changes/cache-transcript-parses/design.md delete mode 100644 openspec/changes/cache-transcript-parses/proposal.md delete mode 100644 openspec/changes/cache-transcript-parses/specs/statusline-config/spec.md delete mode 100644 openspec/changes/cache-transcript-parses/specs/statusline-info/spec.md delete mode 100644 openspec/changes/cache-transcript-parses/specs/subagent-cohort/spec.md delete mode 100644 openspec/changes/cache-transcript-parses/specs/transcript-parse-cache/spec.md delete mode 100644 openspec/changes/cache-transcript-parses/tasks.md delete mode 100644 openspec/changes/consolidate-claude-dir-layout/.openspec.yaml delete mode 100644 openspec/changes/consolidate-claude-dir-layout/design.md delete mode 100644 openspec/changes/consolidate-claude-dir-layout/proposal.md delete mode 100644 openspec/changes/consolidate-claude-dir-layout/specs/claude-dir-layout/spec.md delete mode 100644 openspec/changes/consolidate-claude-dir-layout/specs/layout-migration/spec.md delete mode 100644 openspec/changes/consolidate-claude-dir-layout/tasks.md delete mode 100644 openspec/config.yaml delete mode 100644 openspec/specs/cache-countdown/spec.md delete mode 100644 openspec/specs/compact-tokens-row/spec.md delete mode 100644 openspec/specs/git-branch-display/spec.md delete mode 100644 openspec/specs/glyph-mode/spec.md delete mode 100644 openspec/specs/install-script/spec.md delete mode 100644 openspec/specs/justify-top-row/spec.md delete mode 100644 openspec/specs/line-counts/spec.md delete mode 100644 openspec/specs/openspec-bar-colour/spec.md delete mode 100644 openspec/specs/path-display/spec.md delete mode 100644 openspec/specs/prompt-boundary-hook/spec.md delete mode 100644 openspec/specs/section-labels/spec.md delete mode 100644 openspec/specs/side-by-side-sections/spec.md delete mode 100644 openspec/specs/statusline-config/spec.md delete mode 100644 openspec/specs/statusline-info/spec.md delete mode 100644 openspec/specs/statusline-packaging/spec.md delete mode 100644 openspec/specs/subagent-cohort/spec.md delete mode 100644 openspec/specs/subagent-row-layout/spec.md delete mode 100644 openspec/specs/task-checklist/spec.md delete mode 100644 openspec/specs/tool-counts-row/spec.md delete mode 100644 openspec/specs/top-row-format/spec.md delete mode 100644 openspec/specs/untrusted-input-hardening/spec.md delete mode 100644 openspec/specs/workflow-cohort/spec.md delete mode 100644 openspec/specs/workflow-phase-display/spec.md delete mode 100644 openspec/specs/workflow-two-column-agents/spec.md diff --git a/openspec/changes/archive/2026-05-25-add-burndown-trend/.openspec.yaml b/openspec/changes/archive/2026-05-25-add-burndown-trend/.openspec.yaml deleted file mode 100644 index 9e883bf..0000000 --- a/openspec/changes/archive/2026-05-25-add-burndown-trend/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-25 diff --git a/openspec/changes/archive/2026-05-25-add-burndown-trend/design.md b/openspec/changes/archive/2026-05-25-add-burndown-trend/design.md deleted file mode 100644 index ca68047..0000000 --- a/openspec/changes/archive/2026-05-25-add-burndown-trend/design.md +++ /dev/null @@ -1,137 +0,0 @@ -## Context - -The statusline renderer (`claude/statusline_command.py`) already consumes `rate_limits.five_hour` and `rate_limits.seven_day` from Claude Code's session JSON. Each bucket carries `used_percentage` and `resets_at` (unix timestamp of the next reset). The existing display: - -- `Renderer.helper()` formats the 5h bucket as `% T-`. -- `Renderer.model_right_section()` (wide) appends 7d as `| %`. -- `Renderer.model_right_section_compact()` (medium/narrow) shows 5h only as `% m`. - -The renderer is layered: pure math in `GradientEngine`, border math in `BorderRenderer`, section composition in `Renderer`. Tests live under `test/` partitioned by layer (`test_pure_helpers.py`, `test_helper.py`, `test_model_section.py`, etc.). All width math goes through `_visible_width` (ANSI-stripping, wide-char-aware). - -The trend feature derives a new value from existing inputs — no new fields on `RateBucket`, no schema change, no upstream API change. The math is pure given `(used_percentage, resets_at, now, window_minutes, warmup_minutes)`. - -## Goals / Non-Goals - -**Goals:** - -- Make velocity legible at a glance: a user mid-window can tell whether they are on track, ahead, or burning too fast for the remaining window. -- Keep the indicator additive and non-disruptive — when data is stale or noisy (no window, expired window, fresh window warmup), suppress rather than misinform. -- Preserve existing graceful-degradation across narrow → medium → wide layouts. -- Stay within the existing renderer architecture: pure math in a module-level helper, presentation on `Renderer`. - -**Non-Goals:** - -- **Per-subagent burn rates** — the original feedback bundles this with the trend ask, but subagent velocity is a separate signal with a different data source (`RunningSubagents` aggregations) and a different display surface (subagent rows). Out of scope for this change. -- **Real-time cache countdown** — the original feedback is ambiguous (Anthropic prompt-cache TTL? 5h window cache reset?) and needs its own grilling round. Out of scope. -- **Context-window burndown** — the context window has no time dimension, so the same formula doesn't apply. -- **Continuous-gradient colouring** — the renderer's existing fill helpers (`fill_colour`, `risk_zone_color`) use stepped buckets, and we match that aesthetic. -- **Fixed-width indicator** to prevent jitter — existing `%` already varies width (2–4 cols) and this hasn't caused layout problems; not adding complexity here. - -## Decisions - -### Decision 1 — Derive `elapsed_minutes` from `resets_at` and a window constant - -``` -window_start_ts = resets_at - window_minutes * 60 -elapsed_minutes = (now - window_start_ts) / 60 -ideal_pct = (elapsed_minutes / window_minutes) * 100 -delta = used_percentage - ideal_pct # +ve = over-burn -``` - -Window constants are hard-coded at module scope: `FIVE_HOUR_MINUTES = 300`, `SEVEN_DAY_MINUTES = 10080`. - -**Alternatives considered:** - -- *Have the upstream JSON include `started_at`* — would remove guesswork but requires an Anthropic-side change we don't control. -- *Derive `window_minutes` from `(now - resets_at)` plus heuristics* — fragile; can't disambiguate 5h-window-just-reset from 7d-window-near-end. - -Hard-coded constants are honest about the dependency: if Anthropic changes the plan structure, we update one line. - -### Decision 2 — Suppression rules (no indicator shown) - -The indicator returns an empty string under any of: - -1. `resets_at == 0` (no active window — matches existing `∞` semantics in `helper()`). -2. `now >= resets_at` (window already expired — `helper()` shows `∞` here too). -3. `elapsed_minutes < warmup_minutes` (fresh window, denominator too small to be informative). - -Warmup constants: `FIVE_HOUR_WARMUP_MINUTES = 5`, `SEVEN_DAY_WARMUP_MINUTES = 30`. - -**Alternatives considered:** - -- *Show the indicator unconditionally* — produces noisy `▲` flickering on the first prompt of every fresh window. Trains users to ignore it. -- *Clamp `ideal_pct` to a floor* — distorts the math; users would reasonably expect the trend to be exact. - -Suppression is the honest signal: "not enough data yet." - -### Decision 3 — Format and on-pace dot - -Format: `▲%` (over-burn) or `▼%` (under-burn). One decimal place. Sign is implied by the arrow. - -When `|delta| ≤ 0.5%`, render `·` (middle dot) instead of an arrow. This avoids the visual noise of `▲0.0%` flickering between directions at the on-pace threshold. - -**Alternatives considered:** - -- *Keep the negative sign in the down-arrow case* (matches the original spec literally) — redundant double-encoding with the arrow. -- *Integer percent* — loses signal at small but real deltas. -- *Suppress entirely at on-pace* — leaves a hole in the row that flickers in and out. - -### Decision 4 — Stepped, symmetric, magnitude-ramped colour buckets - -Three intensity buckets per direction, same cutoffs for both: - -| `|delta|` | `▲` colour | `▼` colour | -|------------------|---------------------|-------------------------| -| 0.5 – 5 % | `self.safe` (dim) | `self.safe` (dim green) | -| 5 – 15 % | `self.warn` | mid green | -| ≥ 15 % | `self.alert` | bright green | -| ≤ 0.5 % | `·` in dim grey | (same) | - -Same intensity structure across both directions; hue differs. Re-uses the existing `Renderer.fill_colour` palette where possible to avoid introducing new colour state. - -**Alternatives considered:** - -- *Asymmetric ramps* (no "alert" green at the top end) — more semantically accurate (deep under-burn isn't alarming) but adds branching and a special-case green palette. -- *Continuous interpolation* — matches the rainbow border aesthetic, but inconsistent with `fill_colour`/`risk_zone_color`. More code, no clear win. -- *Match the `%` colour* — loses the velocity signal whenever the absolute usage is high. - -### Decision 5 — Implementation seam: pure math + Renderer method - -```python -# Module-level (pure, no Renderer/ANSI state) -def burndown_delta(used_pct: float, resets_at: int, window_minutes: int, - warmup_minutes: int, now: float | None = None) -> float | None: - """Returns delta in percentage points, or None to suppress.""" - -# Renderer method (presentation, picks colour buckets + glyphs) -def burndown_trend(self, used_pct: float, resets_at: int, - window_minutes: int, warmup_minutes: int) -> str: - """Returns ANSI-formatted fragment, or '' when suppressed.""" -``` - -The pure helper is testable in `test_burndown.py` without ANSI noise. The Renderer method is tested through `test_helper.py` and `test_model_section.py` using the existing `strip_ansi` helper. - -**Alternatives considered:** - -- *Single bundled method on `Renderer`* — couples math to presentation, harder to test. -- *Dataclass (`BurndownTrend`) carried through `RowSpec`* — overengineered for one indicator. The trend is rendered inline in a content row; it doesn't drive layout decisions. - -### Decision 6 — Per-layout policy - -- **Wide:** trend for both 5h and 7d. Position: between `%` and the countdown. New format for 5h: `% T-`. New format for 7d: `% `. -- **Medium:** trend for 5h only (matches the existing pattern where 7d is dropped from compact). -- **Narrow:** no trend (matches existing pattern where countdown is the only adornment). - -This matches the existing "drop adornments outermost-first" degradation rule. - -### Decision 7 — `now` injection for tests - -`burndown_delta` accepts `now: float | None = None`, defaulting to `time.time()` when omitted. Tests pass a fixed `now`. No freezegun-style monkey-patching needed. - -## Risks / Trade-offs - -- **Risk:** Width jitter as the trend digit count changes (e.g. `▲4.9%` → `▲14.0%` adds one column) → Mitigation: existing `%` already varies the same way; layout has tolerated this for the lifetime of the renderer. No fix needed. -- **Risk:** `mon.py`'s dim post-processing washes the velocity-bucket colours, undermining the signal on inactive sessions → Mitigation: accept. Dim sessions are inactive by definition; the trend isn't actionable there. Document as a known minor limitation. -- **Risk:** First-prompt-of-window flicker on the 7d bucket lingers longer than 5h because the bucket's `used_percentage` updates on every request (so even after warmup, the first hour can show large `▲` deltas) → Mitigation: 30-min warmup absorbs the worst of it. After warmup the math is correct — a sustained big-▲ early in the week genuinely is over-pace, and that *is* the signal we want. -- **Risk:** Hard-coded window constants drift if Anthropic changes plan structure → Mitigation: constants live at module scope with comment pointing at the source of truth. Change is one-line if needed. -- **Trade-off:** Symmetric colour intensity (chosen) treats deep ▼ as visually "loud" even though it's good news. Asymmetric (rejected) would be more semantically faithful. Chose symmetric for simplicity; if user feedback says deep-green is jarring we can revisit. diff --git a/openspec/changes/archive/2026-05-25-add-burndown-trend/proposal.md b/openspec/changes/archive/2026-05-25-add-burndown-trend/proposal.md deleted file mode 100644 index 8f1bc80..0000000 --- a/openspec/changes/archive/2026-05-25-add-burndown-trend/proposal.md +++ /dev/null @@ -1,31 +0,0 @@ -## Why - -The statusline shows current rate-limit usage (`% T-`) but gives no signal about *velocity* — whether the user is on track to hit the cap before the window resets, or coasting well under quota. A user at 60% with 2.5 hours left looks identical to a user at 60% with 30 minutes left, even though the first is on-pace and the second is burning unsustainably fast. Surfacing a burndown trend lets the user catch runaway usage early (sub-agent spam, runaway loops) before they hit the limit. - -## What Changes - -- Add a **Burndown Trend** indicator to the wide and medium layouts, rendered alongside each rate-limit percentage. -- For each active rate-limit bucket (5h and 7d), compute the delta between actual `used_percentage` and the ideal linear burn at the current point in the window. -- Render the delta as `▲%` (over-burn, red ramp), `▼%` (under-burn, green ramp), or `·` (on-pace, within ±0.5%). Magnitude-ramped colour with stepped buckets at 5% / 15%, symmetric across direction. -- Suppress the indicator when `resets_at == 0`, when `resets_at` is in the past, and during the window's warmup period (first 5 min of 5h, first 30 min of 7d) to avoid noise. -- Wide layout shows trend for both 5h and 7d; medium shows 5h only; narrow shows nothing (consistent with existing graceful degradation). -- Update CONTEXT.md: add **Burndown Trend** to the glossary, fix the stale claim that Seven-Day Limit is "parsed but not rendered" (it is rendered today). - -## Capabilities - -### New Capabilities - -- `rate-limit-burndown`: Velocity-vs-quota indicator for rate-limit buckets — formula, suppression rules, colour buckets, and per-layout rendering policy. - -### Modified Capabilities - - - -## Impact - -- **Code**: `claude/statusline_command.py` — new pure-math helper (`burndown_delta`), new `Renderer.burndown_trend` method, threaded into `Renderer.helper`, `Renderer.model_right_section`, and `Renderer.model_right_section_compact`. New module-level constants `FIVE_HOUR_MINUTES`, `SEVEN_DAY_MINUTES`, warmup constants. -- **Tests**: new `test/test_burndown.py` for pure math; additions to `test/test_helper.py` and `test/test_model_section.py` for integration. -- **Docs**: `CONTEXT.md` — new glossary entry and stale-line fix. -- **No new dependencies.** No data-model changes to `RateBucket` or `SessionInfo` (everything derivable from existing `used_percentage` + `resets_at`). -- **No breaking changes.** The indicator is additive; suppressing it for stale data preserves existing behaviour. -- **Multi-session observer (`claude/mon.py`)**: the trend renders automatically because it's threaded through existing helpers; dim post-processing washes over it acceptably. Documented as known minor limitation; not addressed in this change. diff --git a/openspec/changes/archive/2026-05-25-add-burndown-trend/specs/rate-limit-burndown/spec.md b/openspec/changes/archive/2026-05-25-add-burndown-trend/specs/rate-limit-burndown/spec.md deleted file mode 100644 index 72b1236..0000000 --- a/openspec/changes/archive/2026-05-25-add-burndown-trend/specs/rate-limit-burndown/spec.md +++ /dev/null @@ -1,139 +0,0 @@ -## ADDED Requirements - -### Requirement: Burndown delta computation - -The system SHALL compute a burndown delta for each active rate-limit bucket, defined as the difference (in percentage points) between the bucket's `used_percentage` and the ideal linear-burn percentage at the current point in the window. - -The ideal linear-burn percentage SHALL be derived as: - -``` -window_start_ts = resets_at - window_minutes * 60 -elapsed_minutes = (now - window_start_ts) / 60 -ideal_pct = (elapsed_minutes / window_minutes) * 100 -delta = used_percentage - ideal_pct -``` - -Window length SHALL be specified by the caller via a `window_minutes` argument. Module-level constants `FIVE_HOUR_MINUTES = 300` and `SEVEN_DAY_MINUTES = 10080` SHALL be used by callers for the 5h and 7d buckets respectively. - -#### Scenario: Exact spec example -- **WHEN** `used_pct = 60.0`, `window_minutes = 300`, `resets_at` is 150 minutes in the future, `warmup_minutes = 5` -- **THEN** the helper returns a delta of `+10.5` - -#### Scenario: Under pace -- **WHEN** `used_pct = 30.0`, `window_minutes = 300`, `resets_at` is 150 minutes in the future, `warmup_minutes = 5` -- **THEN** the helper returns a delta of `-19.5` - -#### Scenario: Zero usage past warmup -- **WHEN** `used_pct = 0.0`, `window_minutes = 300`, `resets_at` is 120 minutes in the future (180 minutes elapsed), `warmup_minutes = 5` -- **THEN** the helper returns a delta of `-60.0` - -### Requirement: Burndown suppression rules - -The system SHALL suppress the burndown indicator (return `None` from the pure helper, return empty string from the renderer method) under any of the following conditions: - -1. `resets_at == 0` — there is no active window. -2. `now >= resets_at` — the window has already expired. -3. `elapsed_minutes < warmup_minutes` — the window is too fresh to produce a meaningful trend. - -Module-level warmup constants SHALL be `FIVE_HOUR_WARMUP_MINUTES = 5` and `SEVEN_DAY_WARMUP_MINUTES = 30`. - -#### Scenario: No active window -- **WHEN** `resets_at = 0` -- **THEN** the helper returns `None` - -#### Scenario: Expired window -- **WHEN** `resets_at` is in the past -- **THEN** the helper returns `None` - -#### Scenario: Window in warmup -- **WHEN** `resets_at` is 297 minutes in the future and `window_minutes = 300` and `warmup_minutes = 5` (so `elapsed_minutes = 3`) -- **THEN** the helper returns `None` - -#### Scenario: Window just past warmup -- **WHEN** `resets_at` is 294 minutes in the future and `window_minutes = 300` and `warmup_minutes = 5` (so `elapsed_minutes = 6`) -- **THEN** the helper returns a non-`None` delta - -### Requirement: Burndown indicator format - -When the burndown delta is not suppressed, the renderer SHALL format it as one of the following ANSI-coloured glyphs followed by the absolute delta to one decimal place and a `%` suffix: - -- `▲%` when `delta > +0.5` -- `▼%` when `delta < -0.5` -- `·` when `|delta| ≤ 0.5` (the on-pace dot — no number rendered) - -The arrow already encodes direction, so no `+`/`-` sign SHALL be rendered alongside the number. - -#### Scenario: Over-burn formatting -- **WHEN** delta is `+10.5` -- **THEN** the rendered string strips to `▲10.5%` - -#### Scenario: Under-burn formatting -- **WHEN** delta is `-19.5` -- **THEN** the rendered string strips to `▼19.5%` - -#### Scenario: On-pace dot -- **WHEN** delta is `+0.3` -- **THEN** the rendered string strips to `·` (no percentage) - -#### Scenario: On-pace dot at negative boundary -- **WHEN** delta is `-0.5` -- **THEN** the rendered string strips to `·` - -### Requirement: Burndown colour buckets - -The renderer SHALL colour the trend indicator using stepped, magnitude-ramped buckets that are symmetric across direction: - -| `|delta|` | `▲` colour | `▼` colour | -|--------------|---------------------|---------------| -| 0.5 – 5 % | dim (safe palette) | dim green | -| 5 – 15 % | warn palette | mid green | -| ≥ 15 % | alert palette | bright green | -| ≤ 0.5 % | dim grey (the `·` glyph) | | - -The renderer SHOULD re-use existing `Renderer.fill_colour` palette tones where possible rather than introducing new colour state. - -#### Scenario: Over-burn safe bucket -- **WHEN** delta is `+3.0` -- **THEN** the trend is rendered with the safe (dim) ANSI tone - -#### Scenario: Over-burn warn bucket -- **WHEN** delta is `+8.0` -- **THEN** the trend is rendered with the warn ANSI tone - -#### Scenario: Over-burn alert bucket -- **WHEN** delta is `+20.0` -- **THEN** the trend is rendered with the alert ANSI tone - -#### Scenario: Symmetric under-burn bucket -- **WHEN** delta is `-8.0` -- **THEN** the trend is rendered with the mid-green ANSI tone (same intensity bucket as `+8.0`) - -### Requirement: Per-layout rendering policy - -The system SHALL render the burndown indicator according to layout width: - -- **Wide layout:** trend SHALL be rendered for both the 5h and 7d buckets. -- **Medium layout:** trend SHALL be rendered for the 5h bucket only. -- **Narrow layout:** trend SHALL NOT be rendered. - -Where rendered, the trend SHALL be positioned immediately after the bucket's `%` and before any countdown (`T-` or `m`). - -#### Scenario: Wide layout shows both trends -- **WHEN** `model_right_section` is called with both buckets active and trend deltas of `+10.5` and `-3.2` -- **THEN** the helper text strips to a string containing `60% ▲10.5% T-` and the seven-day portion contains `▼3.2%` - -#### Scenario: Medium layout shows 5h trend only -- **WHEN** `model_right_section_compact` is called with both buckets active -- **THEN** the rendered string contains the 5h trend indicator and does NOT contain a 7d trend indicator - -#### Scenario: Narrow layout shows no trend -- **WHEN** the narrow-layout builder renders the rate-limit row -- **THEN** the rendered string contains neither a `▲` nor a `▼` glyph derived from burndown trend - -### Requirement: Data model invariance - -The system SHALL compute the burndown trend from existing `RateBucket` fields (`used_percentage`, `resets_at`) without introducing new fields on `RateBucket`, `RateLimits`, or `SessionInfo`. No upstream JSON schema change is required. - -#### Scenario: No schema change required -- **WHEN** the session JSON payload contains only the existing `rate_limits.five_hour.used_percentage` and `rate_limits.five_hour.resets_at` fields -- **THEN** the trend SHALL still be computable and renderable correctly diff --git a/openspec/changes/archive/2026-05-25-add-burndown-trend/tasks.md b/openspec/changes/archive/2026-05-25-add-burndown-trend/tasks.md deleted file mode 100644 index 57b9f73..0000000 --- a/openspec/changes/archive/2026-05-25-add-burndown-trend/tasks.md +++ /dev/null @@ -1,58 +0,0 @@ -## 1. Pre-edit prep - -- [x] 1.1 Run `uv run pytest -q` and record baseline pass count -- [x] 1.2 Run `make statusline/test` and confirm the demo animates cleanly at narrow/medium/wide widths -- [x] 1.3 Run the PUA-glyph audit from the skill on `claude/statusline_command.py` (just the lines we'll touch: `helper`, `model_right_section`, `model_right_section_compact`). Hoist any raw PUA bytes on those lines into module-level constants before any Edit. - -## 2. Pure math layer - -- [x] 2.1 Add module-level constants `FIVE_HOUR_MINUTES = 300`, `SEVEN_DAY_MINUTES = 10080`, `FIVE_HOUR_WARMUP_MINUTES = 5`, `SEVEN_DAY_WARMUP_MINUTES = 30` near the other module-level numeric constants -- [x] 2.2 Add module-level pure helper `burndown_delta(used_pct, resets_at, window_minutes, warmup_minutes, now=None) -> float | None` per the spec, returning `None` for the three suppression cases (no window, expired, in warmup) -- [x] 2.3 Create `test/test_burndown.py` covering: exact spec example (+10.5), under-pace (-19.5), zero-usage-past-warmup, no-window (`resets_at=0`), expired window, warmup boundary (just-inside and just-outside), 7d window (`window_minutes=10080`) -- [x] 2.4 `uv run pytest -q test/test_burndown.py` — green - -## 3. Renderer presentation layer - -- [x] 3.1 On `Renderer`, add `burndown_trend(self, used_pct, resets_at, window_minutes, warmup_minutes) -> str` that calls `burndown_delta`, picks the arrow/dot glyph, picks the colour bucket (safe/warn/alert at 0.5/5/15 cutoffs, symmetric across direction), formats with one decimal place, and wraps in ANSI + `self.R`. Returns `''` when `burndown_delta` returns `None`. -- [x] 3.2 If the renderer uses a green palette that doesn't already exist, add the minimum green palette needed (e.g. `self.green`, `self.green_brt`) following the existing palette pattern; otherwise re-use `self.safe`/`self.warn`/`self.alert` directly -- [x] 3.3 Add tests in `test/test_helper.py` (or a new section if file is already large) covering: each direction × each colour bucket, on-pace dot boundary at `±0.5`, suppressed cases return `''`. Use `strip_ansi` for glyph assertions and check ANSI substring presence for colour assertions. - -## 4. Integration — `Renderer.helper()` - -- [x] 4.1 Edit `Renderer.helper()` (~L2399) to insert the burndown trend between `%` and `T-`. Position: `% T-`. Trend is suppressed (empty string) → output is byte-identical to today for the suppressed cases. -- [x] 4.2 Add tests in `test/test_helper.py` for: pre-warmup (no trend in output), mid-window over-burn (trend appears between `%` and `T-`), expired window (existing `∞` behaviour unchanged), 5h window passed to `helper()` uses `FIVE_HOUR_MINUTES` / `FIVE_HOUR_WARMUP_MINUTES` - -## 5. Integration — `Renderer.model_right_section()` (wide) - -- [x] 5.1 Edit `model_right_section()` (~L1937) so the 7d block becomes `| % ` using `SEVEN_DAY_MINUTES` / `SEVEN_DAY_WARMUP_MINUTES`. The 5h portion gets the trend via the helper-call edit in step 4.1, so no further change needed here for 5h. -- [x] 5.2 Add tests in `test/test_model_section.py` covering: wide layout with both buckets active shows trends for both, 7d-idle (`resets_at=0` or `used_percentage=0`) still suppresses the entire 7d block, 5h-warmup still shows the 5h `%` but no trend -- [x] 5.3 Verify width accounting via `_visible_width` — the helper returns `(helper_text, right_text, right_w)`; `right_w` is the model pill, unchanged. `helper_text` width changes but is padded into the row by the caller. Confirm with a width-assertion test that includes the trend. - -## 6. Integration — `Renderer.model_right_section_compact()` (medium) - -- [x] 6.1 Edit `model_right_section_compact()` (~L1945) so `rate_text` becomes `% m`. Use `FIVE_HOUR_MINUTES` / `FIVE_HOUR_WARMUP_MINUTES`. No 7d trend in compact. -- [x] 6.2 Add tests in `test/test_model_section.py` for the compact path covering: warmup suppression, mid-window over-burn, no-window (no trend) - -## 7. Narrow layout — explicit no-op verification - -- [x] 7.1 Locate `model_section_compact()` (~L1843, narrow) and confirm by reading that it does NOT call into trend rendering. No edit needed. -- [x] 7.2 Add a regression test in `test/test_model_section.py` asserting that the narrow render output contains neither `▲` nor `▼` even when buckets carry mid-window over-burn data. - -## 8. CONTEXT.md updates - -- [x] 8.1 Fix the stale line under `### Rate limits` at L92: "Seven-Day Limit ... Currently parsed but not rendered" — replace with the accurate "rendered in the model row as `| %`" description. -- [x] 8.2 Add a new term **Burndown Trend** under `### Rate limits` covering: the formula, the ▲/▼/· glyphs and their semantics, suppression conditions (no window, expired window, warmup), and the per-layout rendering policy. -- [x] 8.3 If a glossary-style canonical-terms list exists in CONTEXT.md, add **Burndown Trend** there too. Check the "Flagged ambiguities" section for any related disambiguation worth recording. - -## 9. Demo + visual verification - -- [x] 9.1 Run `make statusline/test` — eyeball: trend renders alongside the 5h and 7d percentages, on-pace dot doesn't flicker, colours match the bucket policy as the demo cycles its sample data. -- [x] 9.2 Resize the terminal across narrow → medium → wide thresholds during the run; verify trend appears/disappears according to the per-layout policy. -- [x] 9.3 If a session-info fixture is missing rate-limit data, add a one-frame snapshot test in `test/fixtures/` that exercises `make statusline/test` with mid-window 5h and 7d data so future visual regressions are catchable. - -## 10. Final checks - -- [x] 10.1 `uv run pytest -q` — total pass count = baseline (step 1.1) + new tests -- [x] 10.2 Re-read `claude/statusline_command.py` diff with the PUA audit re-run, confirming no raw PUA bytes were introduced on edited lines -- [x] 10.3 Skim `claude/mon.py` once and confirm trend renders correctly when a session is in the bright (non-dim) state via `make mon/run` against a real session; log the dim-state behaviour as a known limitation in design.md if needed -- [x] 10.4 Run `openspec status --change add-burndown-trend` and confirm readiness to archive after merge diff --git a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/.openspec.yaml b/openspec/changes/archive/2026-05-25-subagent-burn-metrics/.openspec.yaml deleted file mode 100644 index 9e883bf..0000000 --- a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-25 diff --git a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/design.md b/openspec/changes/archive/2026-05-25-subagent-burn-metrics/design.md deleted file mode 100644 index fe05924..0000000 --- a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/design.md +++ /dev/null @@ -1,52 +0,0 @@ -## Context - -`claude/statusline_command.py` already renders a per-subagent row via `Renderer.subagent_row` (~L2064). Each `RunningSubagent` (~L870) carries `billed_in`, `cache_read_in`, `output`, `total_input` (= `billed_in + cache_read_in`), `first_timestamp`, `model`, and `last_activity`. The wide branch (`width > 100`) draws two lines; the narrow branch collapses to one. The main session's cumulative usage is available in `build_wide` as `usage = TranscriptUsage.from_transcript(...)` (~L2619). - -The main-row token rate (`tok_rate`, `TokenRate`, ~L621) is a live 60s-window delta persisted to `statusline-token-rate.log`, sampled once per render. The 5h/7d rate-limit "burndown" trend (`burndown_trend`, ~L2441) compares an account-wide `used_percentage` against an ideal linear pace; that percentage is opaque (no tokens→% factor) and account-wide, so it cannot be attributed to an individual subagent. - -PUA (Nerd Font) glyphs must be hoisted to module-level escape constants before any line containing them is edited (skill PUA refactor rule); width math must use `_visible_width`, never `len`. - -## Goals / Non-Goals - -**Goals:** -- Show, per wide subagent row, a cumulative **average t/m** (`(total_input + output) / duration_min`) and a **session token-share %** (`sub_inout / (main_inout + Σ subagent_inout)`). -- Make "which subagent dominates the session's burn" scannable via a magnitude-mapped colour on the share. -- Degrade gracefully: omit (not zero) figures in degenerate cases; drop the pair atomically when cramped. -- Keep the change additive — no new persisted state, no new runtime dependency. - -**Non-Goals:** -- No per-subagent attribution of the 5h/7d rate-limit burndown delta (not computable — see Context). -- No live recent-window token rate per subagent (subagents are too short-lived, `STALE_SECONDS = 20`, to sample reliably). -- No change to the narrow (≤100-col) subagent collapse. -- No change to the main row, token-rate log, or rate-limit rendering. - -## Decisions - -**D1 — Throughput basis is in+out cumulative average, not a live window.** -`(total_input + output) / duration_min`. Rationale: matches the main row's in+out t/m meaning so "t/m" reads identically everywhere; cumulative is computable from data already on `RunningSubagent` with no new persistence. Alternatives: live 60s window (rejected — needs new per-subagent sample log and is near-always empty for short-lived agents); output-only or input-only (rejected — would diverge from the main row's combined t/m and invite misreads). - -**D2 — Share denominator is main + all subagents.** -`session_inout = main_inout + Σ subagent_inout`, with `main_inout = (usage.billed_in + usage.cache_read) + usage.out`. Rationale: makes "share of session" literal and mutually consistent (main + all subagent shares sum to 100%). Alternatives: subagents-only denominator (rejected — hides how much the main thread burned); ratio-vs-main (rejected — can exceed 100%, reads oddly). The main and subagent transcripts are disjoint (subagents are separate sidechains), so summing is correct, not double-counting. - -**D3 — Denominator computed once in the builder, passed into `subagent_row`.** -`build_wide` computes `session_inout` from `usage` plus the already-loaded `subagents` list and passes it as a new `session_inout` argument to `subagent_row`. `build_medium` / `build_narrow` pass it through (or pass it but never render the cluster). Rationale: avoids recomputing the sum per row and keeps `subagent_row` a pure function of its inputs. - -**D4 — Glyphs: reuse `ICON_TOK_RATE` for t/m, add `GLYPH_PIE = '\uf200'` for share.** -Hoist `GLYPH_PIE` (nf-fa-pie_chart) to module scope alongside `ICON_COST`/`GLYPH_MODEL` per the PUA rule. Rationale: gauge glyph keeps t/m consistent with the main row; a distinct glyph for share aids scanning. - -**D5 — Colour: t/m flat (`self.TOK`), share gradient by magnitude.** -Share uses the existing fill gradient keyed on the share fraction (domain is a natural 0–1), so the dominant agent glows hot. t/m has no principled ceiling, so it stays flat. Alternative (gradient t/m) rejected for lack of a meaningful max. - -**D6 — Degenerate handling: omit, don't fake.** -Avg t/m omitted when `duration < 3s` or `first_timestamp == 0`; share omitted when `session_inout == 0`. Rationale: mirrors `burndown_delta` returning `None` during warmup rather than printing garbage; a 3s floor kills the first-second division spike. Omitting (vs. zeroing) avoids implying a busy young agent is idle. - -**D7 — Atomic, pad-based responsive drop, wide-row-only.** -Build the line-2 right cluster with the figure pair; if appending them would push `pad2` below its minimum gap (2 cols), omit both and fall back to `⧗tok · $cost`. Never render one without the other. The narrow branch never renders the pair. Rationale: matches the row's existing `pad2 = max(1, …)` computation; partial states look broken. - -## Risks / Trade-offs - -- **Cumulative average masks bursty behaviour** → Accepted as the deliberate trade for not adding a live-sample log (D1); a long-idle agent reads as low t/m, which is truthful for a *cumulative* average and labelled as such by the shared t/m semantics. -- **`GLYPH_PIE` lost through a chat/Edit round-trip** → Mitigated by hoisting to a `'\uf200'` escape constant before editing any line that references it (D4 / PUA rule). -- **Width miscalculation crooks the box** → Mitigated by computing cluster width with `_visible_width` (never `len`) and reusing the row's existing pad math (D7); covered by a boundary test. -- **Double-counting main vs. subagent tokens** → Not a risk: main and subagent transcripts are disjoint sidechains (D2). -- **`build_medium` / `build_narrow` signature drift** → Mitigated by threading `session_inout` through all callers in one change and asserting via the demo across narrow→medium→wide thresholds. diff --git a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/proposal.md b/openspec/changes/archive/2026-05-25-subagent-burn-metrics/proposal.md deleted file mode 100644 index 23b8182..0000000 --- a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/proposal.md +++ /dev/null @@ -1,36 +0,0 @@ -## Why - -The statusline shows per-subagent tokens, cost, and duration, but not how fast each subagent is burning tokens or how much of the session's total token spend it accounts for. When several subagents run concurrently, there is no way to see which one dominates the session's burn at a glance. - -## What Changes - -- Add two per-subagent figures to the **wide** subagent row (terminal width > 100): - - **avg t/m**: cumulative average token throughput — `(total_input + output) ÷ duration_minutes`. - - **session share %**: this subagent's fraction of the whole session's token spend — `sub_inout ÷ (main_inout + Σ subagent_inout)`. -- Append both to the line-2 right cluster after `⧗tok · $cost`, rendered as `· {gauge} 30 t/m · {pie} 28%`. -- Drop the pair atomically when there is not enough horizontal room (pad-based), falling back to the existing `⧗tok · $cost`. -- Omit a figure in degenerate cases: avg t/m omitted when `duration < 3s` or `first_timestamp == 0`; share omitted when the denominator is `0`. -- Colour: avg t/m flat (`self.TOK`, matching the main row's t/m); session share gradient-mapped by magnitude so the dominant agent glows hot. -- Wide-row-only: the narrow single-line subagent collapse (width ≤ 100) is unchanged. - -Explicitly **not** included (rejected during design): -- Per-subagent attribution of the 5h/7d rate-limit burndown delta — impossible: no tokens→% factor is exposed, the rate-limit window is account-wide, and the delta is not a per-agent additive quantity. -- A live recent-window token rate per subagent — subagents are too short-lived (`STALE_SECONDS = 20`) to gather ≥2 samples reliably, so it would be near-always empty. - -## Capabilities - -### New Capabilities -- `subagent-burn-metrics`: per-subagent average token throughput (t/m) and session token-share (%) on the wide subagent row, including their computation basis, degenerate-case handling, responsive drop behaviour, glyphs, and colour rules. - -### Modified Capabilities - - -## Impact - -- `claude/statusline_command.py`: - - New module-level glyph constant `GLYPH_PIE = '\uf200'` (nf-fa-pie_chart), hoisted per the PUA refactor rule. - - `Renderer.subagent_row` gains a `session_inout` argument and renders the two figures in the `width > 100` branch only. - - `build_wide` computes the session denominator (`main_inout + Σ subagent_inout`) once and threads it into `subagent_row`; `build_medium` / `build_narrow` pass it through without rendering the cluster. -- Tests: new coverage for the avg-t/m and share math, degenerate-case omission, and the atomic pad-drop boundary. -- `CONTEXT.md`: glossary entries for the two new displayed terms. -- No new runtime dependencies; no persisted state added. diff --git a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/specs/subagent-burn-metrics/spec.md b/openspec/changes/archive/2026-05-25-subagent-burn-metrics/specs/subagent-burn-metrics/spec.md deleted file mode 100644 index 01bc12b..0000000 --- a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/specs/subagent-burn-metrics/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -## ADDED Requirements - -### Requirement: Average token throughput per subagent - -The wide subagent row (terminal width greater than 100 columns) SHALL display the cumulative average token throughput of each running subagent, expressed in tokens per minute (t/m). The value SHALL be computed as `(total_input + output) / duration_minutes`, where `total_input` is `billed_in + cache_read_in`, `output` is the subagent's output tokens, and `duration_minutes` is `(now - first_timestamp) / 60`. The figure SHALL be prefixed with the gauge glyph (`ICON_TOK_RATE`) and rendered in the flat token colour (`self.TOK`), matching the main row's t/m styling. - -#### Scenario: Throughput shown for an established subagent -- **WHEN** a subagent has run for at least 3 seconds with a valid `first_timestamp` and non-zero tokens -- **THEN** the wide subagent row shows `{gauge} t/m` where `` is `(total_input + output)` divided by elapsed minutes - -#### Scenario: Throughput omitted for a just-spawned subagent -- **WHEN** a subagent's elapsed duration is less than 3 seconds, OR its `first_timestamp` is 0 -- **THEN** the average t/m figure is omitted from the row (no zero, no placeholder number) to avoid a first-second spike or divide-by-zero - -### Requirement: Session token-share per subagent - -The wide subagent row SHALL display each subagent's share of the whole session's token spend, expressed as a percentage. The share SHALL be computed as `sub_inout / session_inout`, where `sub_inout` is the subagent's `total_input + output` and `session_inout` is `main_inout + Σ subagent_inout` across the main thread and all running subagents. The main thread's contribution `main_inout` SHALL be `(billed_in + cache_read) + output` taken from the main session transcript usage. The figure SHALL be prefixed with the pie-chart glyph (`GLYPH_PIE`) and colour-mapped by magnitude via the fill gradient so that the dominant subagent is rendered hot and small slices stay cool. - -#### Scenario: Share reflects fraction of session burn -- **WHEN** the session denominator `session_inout` is greater than 0 -- **THEN** the row shows `{pie}

%` where `

` is `sub_inout / session_inout` as a percentage, and the percentage's colour intensity scales with its magnitude - -#### Scenario: Shares are computed against main plus all subagents -- **WHEN** the session has a main thread and one or more running subagents -- **THEN** the denominator includes the main thread's `main_inout` plus every running subagent's `sub_inout`, so the main thread and all subagents' shares are mutually consistent fractions of one total - -#### Scenario: Share omitted when nothing has been spent -- **WHEN** the session denominator `session_inout` is 0 -- **THEN** the share figure is omitted from the row (no divide-by-zero, no `0%` placeholder) - -### Requirement: Burn-metric cluster placement and responsive drop - -The average t/m and session-share figures SHALL be appended to the subagent row's line-2 right cluster, after the existing `⧗tok · $cost` segment, in the form `· {gauge} t/m · {pie}

%`. When the horizontal room remaining for the row falls below the threshold needed to fit the pair (the row's content padding would drop below its minimum gap), both figures SHALL be omitted together and the row SHALL fall back to the existing `⧗tok · $cost` segment. A partial state showing only one of the two figures SHALL NOT occur. - -#### Scenario: Cluster shown when room permits -- **WHEN** the wide subagent row has enough horizontal room to fit both figures while preserving the minimum content gap -- **THEN** both `{gauge} t/m` and `{pie}

%` are appended to the line-2 right cluster - -#### Scenario: Cluster dropped atomically when cramped -- **WHEN** including the figure pair would push the row's content padding below its minimum gap -- **THEN** both figures are omitted and the row renders only `⧗tok · $cost`, never just one of the two - -### Requirement: Burn metrics are confined to the wide subagent row - -The average t/m and session-share figures SHALL appear only on the wide subagent row (terminal width greater than 100 columns). The narrow single-line subagent collapse (width 100 or less) SHALL remain unchanged and SHALL NOT render either figure. - -#### Scenario: Narrow row unaffected -- **WHEN** the terminal width is 100 columns or less and the subagent row uses the single-line collapse -- **THEN** neither the average t/m nor the session-share figure is rendered, and the narrow row's existing content is unchanged diff --git a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/tasks.md b/openspec/changes/archive/2026-05-25-subagent-burn-metrics/tasks.md deleted file mode 100644 index d281f6c..0000000 --- a/openspec/changes/archive/2026-05-25-subagent-burn-metrics/tasks.md +++ /dev/null @@ -1,52 +0,0 @@ - - -## 1. Foundation (SERIAL — do first, blocks Groups 2-4) - -- [x] 1.1 Hoist `GLYPH_PIE = '\uf200' # nf-fa-pie_chart (subagent session share)` to module scope alongside `ICON_COST` / `GLYPH_MODEL` (~L143). Run the PUA catalogue command from the skill on the lines you touch. -- [x] 1.2 Add module-level pure helper `subagent_avg_tpm(total_input: int, output: int, first_timestamp: float, now: float, floor_seconds: float = 3.0) -> int | None` near `burndown_delta` (~L55): returns `None` when `first_timestamp == 0` or `now - first_timestamp < floor_seconds`, else `round((total_input + output) / ((now - first_timestamp) / 60))`. -- [x] 1.3 Add module-level pure helper `subagent_share(sub_inout: int, session_inout: int) -> float | None`: returns `None` when `session_inout <= 0`, else `sub_inout / session_inout` (0.0–1.0 fraction). - -## 2. Math unit tests (PARALLEL-SAFE after Group 1 — test files only) - -- [x] 2.1 Test `subagent_avg_tpm`: normal case returns expected t/m for a known duration and token total. -- [x] 2.2 Test `subagent_avg_tpm`: returns `None` when `first_timestamp == 0` and when elapsed `< 3s`. -- [x] 2.3 Test `subagent_share`: normal case returns expected fraction; main + all subagent shares sum to 1.0 for a constructed session. -- [x] 2.4 Test `subagent_share`: returns `None` when `session_inout == 0`. - -## 3. Docs / glossary (PARALLEL-SAFE after 1.1 — CONTEXT.md only) - -- [x] 3.1 Add `CONTEXT.md` glossary entries for the two new displayed terms: per-subagent **average t/m** (cumulative `(input+output)/min`) and **session share** (`sub_inout / (main_inout + Σ subagent_inout)`), noting the gauge and pie-chart glyphs. - -## 4. Rendering + plumbing (after Group 1 — single owner of statusline_command.py) - -- [x] 4.1 In `build_wide` (~L2625): compute `session_inout = (usage.billed_in + usage.cache_read) + usage.out + sum(s.total_input + s.output for s in subagents.subagents)` and pass it into each `subagent_row(...)` call. -- [x] 4.2 Thread `session_inout` through `build_medium` (~L2590) and `build_narrow` (~L2519) `subagent_row` calls as a pass-through argument (these layouts must NOT render the cluster). -- [x] 4.3 In `Renderer.subagent_row` (~L2064): add the `session_inout: int` parameter; in the `width > 100` branch only, compute `tpm = subagent_avg_tpm(...)` and `share = subagent_share(sub.total_input + sub.output, session_inout)`, build the cluster `· {ICON_TOK_RATE} {tpm} t/m · {GLYPH_PIE} {pct}%` with t/m in `self.TOK` (flat) and the share percentage coloured via the fill gradient on `share`; omit each figure when its helper returns `None`. -- [x] 4.4 Implement atomic pad-based drop in the line-2 assembly: build `right2` with the cluster appended; if including it makes `pad2` fall below the minimum gap (2 cols, via `_visible_width`), drop the WHOLE cluster and fall back to `⧗tok · $cost`. Never render only one of the two figures. - -## 5. Rendering tests (after Group 4) - -- [x] 5.1 Test the wide row (>100 cols, ample width) includes both `t/m` and `%` figures (assert via `strip_ansi`). -- [x] 5.2 Test the atomic drop at the width boundary: just below the threshold renders neither figure; just above renders both — never exactly one. -- [x] 5.3 Test the narrow row (≤100 cols) renders neither figure and is otherwise unchanged. - -## 6. Verification (LAST — after all groups) - -- [x] 6.1 `uv run pytest -q` is green; pass count = baseline + new tests. -- [x] 6.2 `make statusline/test` — eyeball the cluster across the narrow→medium→wide thresholds; confirm elbows/borders still align and the share colour scales with magnitude. -- [x] 6.3 Render one static frame for a wide width and confirm cluster placement: `COLUMNS=160 uv run python claude/statusline_command.py < claude/statusline/session-info-example.json`. diff --git a/openspec/changes/archive/2026-05-31-statusline-user-config/.openspec.yaml b/openspec/changes/archive/2026-05-31-statusline-user-config/.openspec.yaml deleted file mode 100644 index 927e3e8..0000000 --- a/openspec/changes/archive/2026-05-31-statusline-user-config/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-31 diff --git a/openspec/changes/archive/2026-05-31-statusline-user-config/design.md b/openspec/changes/archive/2026-05-31-statusline-user-config/design.md deleted file mode 100644 index 8dc72e5..0000000 --- a/openspec/changes/archive/2026-05-31-statusline-user-config/design.md +++ /dev/null @@ -1,64 +0,0 @@ -## Context - -`claude/statusline_command.py` is a zero-dependency, stdlib-only single script (`dependencies = []`, `requires-python = ">=3.10"`) symlinked/vendored into `~/.claude/`. Configuration today is a handful of module-level constants read from `os.environ` at import time with three inconsistent prefixes (`YAS_MAX_WIDTH`, `YAS_FULL_WIDTH`, `STATUSLINE_TOKEN_WINDOW`, `CLAUDE_STATUSLINE_THEME`) plus a hardcoded `SOFT_LIMIT = 150_000`. `resolve_theme()` already implements an ad-hoc layered lookup (CLI → env → `statusline-theme` file → default). Tests exercise env handling by reloading the module via `importlib.util` (see `conftest.py` and PR #32's `test_soft_limit_env.py`). The renderer is layout-spec driven: `render()` selects `build_narrow/medium/wide`, each returns a `LayoutSpec` of `RowSpec`s, and `render_layout()` walks them. Column math is hand-tuned and silent-bug-prone. - -## Goals / Non-Goals - -**Goals:** -- One `Config` dataclass + one `Config.load()` owning the entire precedence chain (CLI → `YAS_*` → legacy alias → `yas.toml` → default), eliminating scattered `os.environ.get` reads. -- A sectioned `yas.toml` in `CLAUDE_CONFIG_DIR` parsed with stdlib `tomllib`, degrading silently on Python 3.10. -- Six knobs: `max_width`, `full_width`, `soft_limit`, `token_window`, `theme`, `bg_shift`. -- Never crash on bad config; per-knob fallback; one compact visible error row naming rejected knobs. -- Backward compatibility: every existing env var keeps working; existing module constants and module-reload tests keep working. -- A commented `yas.example.toml` + README/CONTEXT.md docs. - -**Non-Goals:** -- Configurable layout breakpoints (narrow/medium/min width) — stays hardcoded. -- Auto-writing `yas.toml` (init does not create it; absence = defaults). -- Adding any third-party dependency or bumping `requires-python` above `>=3.10`. -- Live config reload / file watching — config is read once per render invocation. - -## Decisions - -**1. Single frozen `Config` dataclass, loaded at import time into a module singleton.** -`Config.load(env=os.environ, config_dir=CLAUDE_DIR, argv=None)` returns a frozen `Config` with one field per knob plus `errors: tuple[str, ...]` and `debug_lines: tuple[str, ...]`. A module-level `CONFIG = Config.load()` runs at import; `MAX_WIDTH`, `SOFT_LIMIT`, and the token-rate window are then assigned from `CONFIG` so existing call sites and module-reload tests are unchanged. *Alternative — thread Config through every `render`/`build_*` signature:* rejected as too large a blast radius for no user-visible gain. *Alternative — keep scattered constants with inline toml fallback:* rejected; duplicates precedence logic at every knob, no single schema. - -**2. Per-knob resolver helper centralises precedence + validation.** -A small internal resolver takes (canonical env name, alias env name(s), toml section/key, a parse/validate callable, default) and walks the chain, appending to `errors`/`debug_lines` on a rejected non-empty value. Each knob is one declarative call. This keeps precedence identical across knobs and makes the validation table the single source of truth. - -**3. `tomllib` via guarded import.** -`try: import tomllib\nexcept ImportError: tomllib = None`. `Config.load()` reads `config_dir/yas.toml`; if `tomllib is None` or the file is missing the toml layer is an empty dict. A `tomllib.TOMLDecodeError` (or any parse failure) records one error ("yas.toml: parse error") and treats the toml layer as empty. *Alternative — bump to 3.11 / vendor a parser / add `tomli`:* rejected (breaking floor / maintenance burden / breaks zero-dep). - -**4. Error surfacing = compact row + `YAS_DEBUG` stderr.** -`render()` checks `CONFIG.errors`; if non-empty it appends one `RowSpec` (a new `error` content row, or a plain content row via a shared helper) above the bottom border in each `build_*`. To avoid special-casing `render_layout`, a shared `append_error_row(rows, cfg, width, r)` helper is called at the end of each builder and re-threads the bottom-border `ups`. The row text is `⚠ yas.toml: N values ignored (k1, k2, …)`, truncated via `_visible_width`. `Config.load()` writes `debug_lines` to stderr at load time only when `YAS_DEBUG` is set. *Alternative — fail loud / vanish:* rejected; a statusline that disappears over a typo is worse than defaults. - -**5. `resolve_theme` and `bg_shift` fold into the chain.** -`theme` resolves CLI `--theme` → `YAS_THEME` → `CLAUDE_STATUSLINE_THEME` → `[appearance].theme` → `statusline-theme` file (kept as a lowest-priority legacy fallback) → `CLAUDE_DARK`. `bg_shift` resolves CLI `--bg-shift` → `YAS_BG_SHIFT` → `[appearance].bg_shift` → `warm`. CLI parsing stays in `main()` and is passed into `Config.load(argv=...)` (or applied as an override after load) so CLI keeps top precedence. - -**6. PR #32 merged first.** -The `YAS_SOFT_LIMIT` one-liner lands on `main` first (done — merge `99123d9`); this change then replaces that line with the `Config`-sourced *global* `soft_limit` and folds #32's three test cases (default / override / empty-string) into `test_config.py`. - -**7. `soft_limit` is global-or-per-model; resolution is render-time and two-tier.** -`Config` carries `soft_limit: int` (resolved global via `YAS_SOFT_LIMIT` → `[tokens].soft_limit` → `150_000`) and `soft_limit_models: tuple[(match: str, limit: int), ...]` (validated, order-preserving) parsed from the `[[tokens.model]]` array. `Config.soft_limit_for(model_name)` lowercases the session's `id`+`display_name`, keeps every entry whose `match` is a substring of either, returns the *longest* match's limit (ties → first in file order), else the global. `match` is a literal case-insensitive substring — no glob/regex, so there is no pattern-compile error surface. Because the session model is only known at render time, `SOFT_LIMIT` cannot be a single static module constant for the render paths: `render()` computes `eff = CONFIG.soft_limit_for(session.model_name)` once and threads the int through `build_narrow/medium/wide` into `context_line`/`context_line_compact` (which read module `SOFT_LIMIT` today); module `SOFT_LIMIT` is retained as the resolved global for back-compat and reload tests. **Per-model toml beats the global env** (`YAS_SOFT_LIMIT`): specificity wins over source precedence — the single documented carve-out to the blanket env > toml rule, since no sane per-model env var exists. *Alternatives:* family-bucket keying (opus/sonnet/haiku) — rejected, `model_key()` collapses the `[1m]` variant that motivates the feature; exact-id keying — rejected, brittle with no family fallback; auto-derive from `context_window_size` — rejected (PR #32's reasoning: changes alert semantics for short-context models), though the per-model `match` mechanism lets users approximate it explicitly. - -## Risks / Trade-offs - -- **[Module-reload tests break if load order changes]** → Keep `CONFIG = Config.load()` at import and assign `MAX_WIDTH`/`SOFT_LIMIT` from it immediately after, preserving the import-time semantics the reload-based tests rely on. -- **[Error row disturbs hand-tuned column math]** → Render it as an ordinary content `border_line` (no elbows/dividers), width-truncated with `_visible_width`; verify across narrow/medium/wide in the demo. Add a `test_config.py` assertion plus a visual check per the skill's post-edit checklist. -- **[3.10 users silently lose toml]** → Documented explicitly in README ("yas.toml requires Python 3.11+; env vars work everywhere"); env layer still fully functional, so no hard breakage. -- **[Alias precedence confusion]** → One resolver, one rule (canonical > alias), covered by an explicit test; aliases documented as deprecated. -- **[CLI/env/toml interaction for `full_width` vs `max_width`]** → `full_width` continues to win over `max_width` in `main()` width math (full width ignores the cap); documented and tested. -- **[Per-model carve-out surprises env users]** → `YAS_SOFT_LIMIT` no longer wins when a per-model entry matches; documented prominently in the README and the `yas.example.toml` comments, and covered by an explicit "per-model toml beats global env" test. -- **[Threading `soft_limit` widens `build_*`/`context_line` signatures]** → New optional/positional `soft_limit: int` parameter on `context_line`/`context_line_compact` and the three builders; default to module `SOFT_LIMIT` so existing direct callers/tests keep working. Verify with the demo across all three layouts. -- **[Substring match too greedy / ambiguous]** → Longest-match + file-order tie-break is deterministic and tested; empty `match` is rejected at load (would match everything) and surfaced in the error row. -- **[Stale `pct_soft` semantics with large `soft_limit`]** → Out of scope here; `risk_zone_color` bands stay absolute token counts (model-independent), matching PR #32's reasoning. - -## Migration Plan - -1. Merge PR #32 to `main`. -2. Land this change on the `user-config` branch; replace the `YAS_SOFT_LIMIT` one-liner with `Config`-sourced `soft_limit`. -3. No user action required: absent `yas.toml` + unchanged env = identical behaviour. Rollback is reverting the branch; no persisted state or schema migration is involved. - -## Open Questions - -- None blocking. (Whether `/yas:init` should later offer to copy `yas.example.toml` is deferred; v1 ships the example file + docs only.) diff --git a/openspec/changes/archive/2026-05-31-statusline-user-config/proposal.md b/openspec/changes/archive/2026-05-31-statusline-user-config/proposal.md deleted file mode 100644 index eb0a08d..0000000 --- a/openspec/changes/archive/2026-05-31-statusline-user-config/proposal.md +++ /dev/null @@ -1,32 +0,0 @@ -## Why - -Statusline behaviour is currently configured through a scattered, inconsistently-named set of environment variables (`YAS_MAX_WIDTH`, `YAS_FULL_WIDTH`, `STATUSLINE_TOKEN_WINDOW`, `CLAUDE_STATUSLINE_THEME`) plus one hardcoded constant (`SOFT_LIMIT = 150_000`). There is no persistent, file-based way to customise the statusline, the prefixes are a soup (`YAS_*` / `STATUSLINE_*` / `CLAUDE_STATUSLINE_*`), and several knobs users have asked for in issues/PRs (e.g. `soft_limit` for 1M-context models, PR #32) have no home. Users want to set these once per machine and forget them. - -## What Changes - -- Introduce a `yas.toml` config file read from `CLAUDE_CONFIG_DIR` (default `~/.claude/`), parsed with a sectioned schema (`[layout]`, `[tokens]`, `[appearance]`). -- Add a single, frozen `Config` dataclass with one `Config.load()` that resolves every knob through one precedence chain — **CLI flag → canonical `YAS_*` env → legacy-alias env → `yas.toml` → built-in default** — replacing the scattered module-level `os.environ.get` reads. -- Expose six knobs: `max_width`, `full_width`, `soft_limit`, `token_window`, `theme`, `bg_shift`. -- Make `soft_limit` configurable **globally and per-model**: a global `[tokens].soft_limit` default plus an optional `[[tokens.model]]` array of `{ match, soft_limit }` overrides. `match` is a case-insensitive plain substring tested against the model `id`/`display_name` (longest match wins), so users can target 1M-context variants distinctly from the family by giving the variant a longer, more-specific match (`match = "opus-4-8[1m]"` outranks `match = "opus"`). A matching per-model override beats the global value from any source — the single documented exception to env > toml (per-model is toml-only; there is no per-model env var). -- Standardise canonical env names on the `YAS_*` prefix (new: `YAS_SOFT_LIMIT`, `YAS_TOKEN_WINDOW`, `YAS_THEME`, `YAS_BG_SHIFT`); keep `STATUSLINE_TOKEN_WINDOW` and `CLAUDE_STATUSLINE_THEME` as **deprecated aliases** (canonical wins on conflict). No existing env var stops working. -- Parse TOML via stdlib `tomllib` with a graceful try-import; on Python 3.10 (no `tomllib`) the file is silently skipped and env + defaults still apply. The script stays zero-dependency. -- Never crash on bad config: malformed TOML → ignore the whole file; a bad/out-of-range/wrong-type value → drop only that knob to its default. When any value is rejected, append one compact, width-truncated **error row** at the bottom of the box naming the rejected knobs; full per-value reasons go to stderr only when `YAS_DEBUG` is set. -- Ship a commented `yas.example.toml` (every knob at its default) and document the full knob/env/toml/default/precedence matrix in the README and `CONTEXT.md`. `yas.toml` is **not** auto-written — absence means all defaults. -- Layout breakpoints (`narrow`/`medium`/`min` width) remain hardcoded and are intentionally **not** configurable (hand-tuned column math). - -## Capabilities - -### New Capabilities -- `statusline-config`: Layered configuration system for the statusline — the `Config` dataclass, the precedence chain, the `yas.toml` schema and parsing, per-knob validation with fail-safe fallback, the visible config-error row, and the canonical/alias env-var surface. - -### Modified Capabilities - - -## Impact - -- **Code**: `claude/statusline_command.py` — new `Config` dataclass + `Config.load()`; module constants (`MAX_WIDTH`, `SOFT_LIMIT` = resolved *global*, token window, theme/bg_shift resolution) re-sourced from the singleton; a `Config.soft_limit_for(model_name)` resolver threaded from `render()` through `build_narrow/medium/wide` into the `context_line` helpers (which read module `SOFT_LIMIT` today); `render()` appends the error row when `cfg.errors` is non-empty; `resolve_theme` folded into the precedence chain. -- **Tests**: new `test/test_config.py` (precedence, alias resolution, per-knob fallback, error collection, 3.10 no-`tomllib` degrade path); PR #32's three `soft_limit` cases folded in; existing `test_context_line.py` stays green. -- **Docs**: README knob matrix, `CONTEXT.md` config section + `⚠` error-row glossary entry, new `yas.example.toml`. -- **Dependencies**: none added (stdlib `tomllib` only); `requires-python` stays `>=3.10`. -- **Coordination**: PR #32 (`YAS_SOFT_LIMIT`) is merged first; this change then migrates that constant into `Config`. -- **Sibling tools**: `claude/mon.py` imports `render()` and is unaffected (it passes its own theme); the import-time singleton applies transparently. diff --git a/openspec/changes/archive/2026-05-31-statusline-user-config/specs/statusline-config/spec.md b/openspec/changes/archive/2026-05-31-statusline-user-config/specs/statusline-config/spec.md deleted file mode 100644 index efe9c0d..0000000 --- a/openspec/changes/archive/2026-05-31-statusline-user-config/specs/statusline-config/spec.md +++ /dev/null @@ -1,173 +0,0 @@ -## ADDED Requirements - -### Requirement: Layered configuration precedence - -The statusline SHALL resolve every configurable knob through a single, fixed precedence chain: CLI flag (where one exists) → canonical `YAS_*` environment variable → legacy-alias environment variable → `yas.toml` value → built-in default. A higher-precedence source that is present and valid SHALL override all lower sources for that knob; an absent or invalid source SHALL fall through to the next. - -#### Scenario: Env overrides config file - -- **WHEN** `[layout].max_width = 200` is set in `yas.toml` and `YAS_MAX_WIDTH=160` is set in the environment -- **THEN** the resolved `max_width` is `160` - -#### Scenario: Config file overrides default - -- **WHEN** `[tokens].soft_limit = 1000000` is set in `yas.toml` and no `YAS_SOFT_LIMIT` env var is set -- **THEN** the resolved `soft_limit` is `1000000` - -#### Scenario: Default when nothing is set - -- **WHEN** no `yas.toml` exists and no relevant env var is set -- **THEN** every knob resolves to its built-in default (`max_width=140`, `full_width=false`, `soft_limit=150000`, `token_window=60`, `theme=dark`, `bg_shift=warm`) - -#### Scenario: CLI flag overrides env and config - -- **WHEN** `--theme` is passed on the command line and `YAS_THEME` and `[appearance].theme` are also set -- **THEN** the CLI `--theme` value is used - -### Requirement: Canonical env vars and deprecated aliases - -The statusline SHALL accept canonical `YAS_*` environment variables for all six knobs (`YAS_MAX_WIDTH`, `YAS_FULL_WIDTH`, `YAS_SOFT_LIMIT`, `YAS_TOKEN_WINDOW`, `YAS_THEME`, `YAS_BG_SHIFT`). It SHALL continue to honor the legacy aliases `STATUSLINE_TOKEN_WINDOW` (for `token_window`) and `CLAUDE_STATUSLINE_THEME` (for `theme`). When both a canonical var and its alias are set, the canonical value SHALL win. - -#### Scenario: Legacy alias still works - -- **WHEN** only `STATUSLINE_TOKEN_WINDOW=30` is set -- **THEN** the resolved `token_window` is `30` - -#### Scenario: Canonical wins over alias - -- **WHEN** `YAS_TOKEN_WINDOW=45` and `STATUSLINE_TOKEN_WINDOW=30` are both set -- **THEN** the resolved `token_window` is `45` - -#### Scenario: Theme alias resolves - -- **WHEN** only `CLAUDE_STATUSLINE_THEME` names a known theme -- **THEN** that theme is used - -### Requirement: yas.toml location and sectioned schema - -The statusline SHALL read configuration from `yas.toml` located in `CLAUDE_CONFIG_DIR` (defaulting to `~/.claude/`). The file SHALL use a sectioned schema: `[layout]` for `max_width` and `full_width`, `[tokens]` for `soft_limit` (global default) and `token_window`, an optional `[[tokens.model]]` array of `{ match, soft_limit }` tables for per-model `soft_limit` overrides, and `[appearance]` for `theme` and `bg_shift`. Absence of the file SHALL be equivalent to all-defaults and SHALL NOT be an error. - -#### Scenario: Knobs read from their sections - -- **WHEN** `yas.toml` contains `[layout]` `max_width = 200`, `[tokens]` `soft_limit = 1000000`, and `[appearance]` `theme = "dark"` -- **THEN** those three values are resolved from the file - -#### Scenario: Missing file is not an error - -- **WHEN** no `yas.toml` exists in `CLAUDE_CONFIG_DIR` -- **THEN** the statusline renders normally using env + defaults and reports no config error - -#### Scenario: Unknown keys and sections are ignored - -- **WHEN** `yas.toml` contains a key or section that does not map to a known knob -- **THEN** the unknown entry is ignored and the rest of the config still resolves - -### Requirement: TOML parsing with graceful 3.10 degradation - -The statusline SHALL parse `yas.toml` using the standard-library `tomllib` module and SHALL remain zero-dependency. On a Python runtime where `tomllib` is unavailable (3.10), the statusline SHALL skip the `yas.toml` file silently and resolve every knob from env + defaults; it SHALL NOT crash and SHALL NOT add any third-party dependency. - -#### Scenario: TOML applied on 3.11+ - -- **WHEN** the runtime provides `tomllib` and a valid `yas.toml` exists -- **THEN** the file's values participate in precedence resolution - -#### Scenario: File skipped on 3.10 - -- **WHEN** the runtime does not provide `tomllib` and a `yas.toml` exists -- **THEN** the file is ignored, env + defaults are used, and the statusline renders without error - -### Requirement: Fail-safe validation of config values - -The statusline SHALL never crash or render garbage because of bad configuration. A syntactically broken `yas.toml` SHALL cause the entire file to be ignored (env + defaults still apply). A value that is the wrong type or out of range for its knob SHALL cause only that single knob to fall back to its default while all other valid knobs are still applied. Validation rules: `max_width` is an integer > 0; `full_width` is a boolean (env form accepts any non-empty value as true); `soft_limit` is an integer > 0; `token_window` is a number > 0; `theme` must be a known theme name; `bg_shift` must be one of `warm` or `cool`. - -#### Scenario: Broken TOML ignores whole file - -- **WHEN** `yas.toml` contains a TOML syntax error -- **THEN** no value from the file is applied, env + defaults are used, and a config error is recorded - -#### Scenario: One bad value falls back, others apply - -- **WHEN** `yas.toml` sets `max_width = "banana"` (invalid) and `soft_limit = 1000000` (valid) -- **THEN** `max_width` resolves to its default and `soft_limit` resolves to `1000000` - -#### Scenario: Out-of-range value rejected - -- **WHEN** `soft_limit = -5` is configured -- **THEN** `soft_limit` falls back to its default and the rejection is recorded - -#### Scenario: Unknown enum value rejected - -- **WHEN** `bg_shift = "purple"` is configured -- **THEN** `bg_shift` falls back to `warm` and the rejection is recorded - -#### Scenario: Malformed per-model entry dropped - -- **WHEN** a `[[tokens.model]]` entry has a missing/empty `match`, or a `soft_limit` that is non-integer or `<= 0` -- **THEN** only that entry is dropped (models it would have matched fall back to the global `soft_limit`), valid entries still apply, and the rejection is recorded referencing the entry (e.g. `tokens.model[2]`) - -### Requirement: Per-model soft_limit resolution - -The statusline SHALL support per-model `soft_limit` overrides declared as a `[[tokens.model]]` array of tables, each with a `match` string and a `soft_limit` integer. At render time the statusline SHALL select the effective `soft_limit` for the session's model by matching each entry's `match` as a case-insensitive plain substring against the lowercased model `id` and `display_name`; when multiple entries match, the entry with the longest `match` string SHALL win, and ties SHALL be broken by file order (first entry wins). `match` SHALL be a literal substring (no glob or regex). When no entry matches, the global `soft_limit` SHALL be used. A matching per-model override SHALL take precedence over the global value from ANY source, including the `YAS_SOFT_LIMIT` environment variable (specificity beats source precedence; this is the single documented exception to the env > toml rule). Per-model overrides SHALL be expressible only in `yas.toml`; there SHALL be no per-model environment variable. - -#### Scenario: Most-specific match wins - -- **WHEN** entries `match="opus"` (200000) and `match="opus-4-8[1m]"` (1000000) are present and the session model id is `claude-opus-4-8[1m]` -- **THEN** the effective `soft_limit` is `1000000` (the longer `match="opus-4-8[1m]"` wins over `match="opus"`) - -#### Scenario: Falls back to global when no entry matches - -- **WHEN** only `match="haiku"` overrides exist and the session model is an Opus model -- **THEN** the effective `soft_limit` is the resolved global value - -#### Scenario: Matches against display_name as well as id - -- **WHEN** an entry `match="1m context"` is present and the model `display_name` is `Opus 4.8 (1M context)` -- **THEN** that entry matches (case-insensitive) and its `soft_limit` is used - -#### Scenario: Per-model toml beats global env - -- **WHEN** `YAS_SOFT_LIMIT=200000` is set in the environment and a matching entry `match="1m", soft_limit=1000000` exists for a 1M-context session -- **THEN** the effective `soft_limit` is `1000000` - -#### Scenario: Tie broken by file order - -- **WHEN** two entries have equal-length `match` strings that both match the model -- **THEN** the entry appearing first in the file is used - -### Requirement: Visible config-error row - -When one or more configuration values are rejected, the statusline SHALL append a single compact error row at the bottom of the box, inside the border, naming the rejected knobs and truncated to the render width (e.g. `⚠ yas.toml: 2 values ignored (max_width, bg_shift)`). The row SHALL appear in all layouts (narrow, medium, wide). The error row SHALL NOT appear when no value was rejected. Full per-value reasons SHALL be written to stderr only when `YAS_DEBUG` is set in the environment. - -#### Scenario: Error row shown on rejection - -- **WHEN** two configured values are rejected -- **THEN** a single error row is appended above the bottom border naming the two rejected knobs - -#### Scenario: No error row when config is clean - -- **WHEN** all configured values are valid (or no config is present) -- **THEN** no error row is rendered - -#### Scenario: Detailed reasons gated by YAS_DEBUG - -- **WHEN** a value is rejected and `YAS_DEBUG` is set -- **THEN** a per-value reason line is written to stderr in addition to the compact row - -#### Scenario: Error row appears in narrow layout - -- **WHEN** a value is rejected and the terminal is narrow -- **THEN** the compact error row is still appended, truncated to the narrow width without breaking the box - -### Requirement: Single source of resolved configuration - -The statusline SHALL expose resolved configuration through one frozen `Config` object loaded once, and the existing module-level constants that callers and tests depend on (`MAX_WIDTH`, `SOFT_LIMIT`, the token-rate window) SHALL be sourced from that object so that current behaviour and module-reload tests continue to work. The module-level `SOFT_LIMIT` SHALL hold the resolved *global* value; per-model resolution SHALL be performed at render time via a `Config` method (e.g. `soft_limit_for(model_name)`) whose result is threaded into the rendering paths, not via the module constant. Layout breakpoints (narrow/medium/min width) SHALL remain hardcoded and SHALL NOT be user-configurable. - -#### Scenario: Module constant holds the global value - -- **WHEN** `YAS_MAX_WIDTH` and `YAS_SOFT_LIMIT` are set and the module is loaded -- **THEN** `MAX_WIDTH` equals the resolved value and `SOFT_LIMIT` equals the resolved global `soft_limit` (independent of any per-model overrides) - -#### Scenario: Layout breakpoints are not configurable - -- **WHEN** a user attempts to set a narrow/medium/min width via env or `yas.toml` -- **THEN** the layout breakpoints are unchanged (the setting has no effect) diff --git a/openspec/changes/archive/2026-05-31-statusline-user-config/tasks.md b/openspec/changes/archive/2026-05-31-statusline-user-config/tasks.md deleted file mode 100644 index ea9dcdd..0000000 --- a/openspec/changes/archive/2026-05-31-statusline-user-config/tasks.md +++ /dev/null @@ -1,75 +0,0 @@ - - -## 0. Prerequisite [SEQ] - -- [x] 0.1 Merge PR #32 (`YAS_SOFT_LIMIT`) to `main`, then rebase/merge `main` into the `user-config` branch so `SOFT_LIMIT` already reads the env var before refactor begins — DONE (merge `99123d9`; `SOFT_LIMIT = int(os.environ.get('YAS_SOFT_LIMIT') or 150_000)` and `test/test_soft_limit_env.py` are on the branch) - -## 1. Core Config scaffold [SEQ — single owner; module-scope region of claude/statusline_command.py; blocks Groups 2–5] - -- [x] 1.1 Add guarded TOML import near the top imports: `try: import tomllib` / `except ImportError: tomllib = None` -- [x] 1.2 Add a frozen `@dataclass` `Config` with fields `max_width: int`, `full_width: bool`, `soft_limit: int`, `token_window: float`, `theme: str`, `bg_shift: str`, plus `errors: tuple[str, ...]` and `debug_lines: tuple[str, ...]` -- [x] 1.3 Implement an internal per-knob resolver that takes (canonical env name, alias env name(s), toml `(section, key)`, parse/validate callable, default) and walks CLI→`YAS_*`→alias→toml→default, appending a human-readable message to `errors`/`debug_lines` when a *present* value fails validation -- [x] 1.4 Implement the `yas.toml` loader: read `CLAUDE_DIR / 'yas.toml'`; if `tomllib is None` or file missing → empty dict (no error); on parse failure → empty dict + record one `"yas.toml: parse error"` error -- [x] 1.5 Implement `Config.load(env=os.environ, config_dir=CLAUDE_DIR, argv=None)` wiring all six resolver calls with the validation table (max_width int>0; full_width bool/env-truthy; soft_limit int>0; token_window float>0; theme∈THEMES; bg_shift∈{warm,cool}) -- [x] 1.6 Add per-model `soft_limit` support: parse the `[[tokens.model]]` array into a validated, order-preserving `soft_limit_models: tuple[(match, limit), …]` field (drop entries with missing/empty `match` or non-int/`<=0` `soft_limit`, recording each as `tokens.model[i]` in `errors`) -- [x] 1.7 Implement `Config.soft_limit_for(model_name)`: lowercase the model id+display_name, keep entries whose `match` is a substring of either, return the longest match's limit (ties → first in file order), else the global `soft_limit` -- [x] 1.8 Create module singleton `CONFIG = Config.load()` at import and assign `MAX_WIDTH`, `SOFT_LIMIT` (= resolved *global*), and the token-rate window constant from it (preserve import-time semantics so module-reload tests keep working) -- [x] 1.9 Emit `CONFIG.debug_lines` to stderr at load time only when `YAS_DEBUG` is set in the environment - -## 2. Consuming sites + CLI precedence [SEQ — after Group 1; same file region] - -- [x] 2.1 Fold `resolve_theme` into the chain: CLI `--theme` → `YAS_THEME` → `CLAUDE_STATUSLINE_THEME` (alias) → `[appearance].theme` → `statusline-theme` file (lowest-priority legacy fallback) → `CLAUDE_DARK` -- [x] 2.2 Resolve `bg_shift` via Config: CLI `--bg-shift` → `YAS_BG_SHIFT` → `[appearance].bg_shift` → `warm`, keeping CLI at top precedence in `main()` -- [x] 2.3 Source the `full_width` / `max_width` width math in `main()` from Config (full_width continues to win over the max_width cap) -- [x] 2.4 Source `token_window` from Config, honoring `STATUSLINE_TOKEN_WINDOW` as the deprecated alias of `YAS_TOKEN_WINDOW` -- [x] 2.5 Add a `soft_limit: int` parameter to `context_line` / `context_line_compact` (defaulting to module `SOFT_LIMIT`) and thread it from `build_narrow/medium/wide`, which receive the effective value resolved once in `render()` via `CONFIG.soft_limit_for(session.model_name)` - -## 3. Visible config-error row [PARALLEL — after Group 2; builder/render region of statusline_command.py] - -- [x] 3.1 Add a shared `append_error_row(rows, cfg, width, r)` helper that builds one compact `border_line` content row `⚠ yas.toml: N values ignored (k1, k2, …)`, truncated via `_visible_width` (no elbows/dividers) -- [x] 3.2 Call the helper at the end of `build_narrow`, `build_medium`, and `build_wide` to append the error row above the bottom border, re-threading the bottom-border `ups` — do NOT special-case `render_layout` -- [x] 3.3 Ensure the row is appended only when `CONFIG.errors` is non-empty (no row on clean config) and verify it renders in all three layouts - -## 4. Tests [PARALLEL — after Group 1; isolated file test/test_config.py] - -- [x] 4.1 Test the precedence chain: env overrides toml; toml overrides default; default when nothing set -- [x] 4.2 Test alias resolution and canonical-wins (`YAS_TOKEN_WINDOW` vs `STATUSLINE_TOKEN_WINDOW`; `YAS_THEME` vs `CLAUDE_STATUSLINE_THEME`) -- [x] 4.3 Test per-knob validation/fallback (bad type, out-of-range, unknown enum) — one bad value falls back while others apply -- [x] 4.4 Test broken TOML → whole file ignored + error recorded; unknown keys/sections ignored -- [x] 4.5 Test the Python-3.10 degrade path by simulating `tomllib = None` (file skipped, env+defaults used, no crash, no error) -- [x] 4.6 Fold PR #32's three `soft_limit` cases (default / `YAS_SOFT_LIMIT=1000000` / empty-string fallback) into this file -- [x] 4.6a Test `soft_limit_for`: longest-match wins (`"opus-4-8[1m]"` over `"opus"` for `claude-opus-4-8[1m]`), match against display_name, fallback to global when nothing matches, file-order tie-break, and per-model toml beating `YAS_SOFT_LIMIT` -- [x] 4.6b Test malformed `[[tokens.model]]` entries (empty/missing `match`, `soft_limit <= 0` or non-int) are dropped, recorded as `tokens.model[i]`, while valid entries still apply -- [x] 4.7 Test error-row presence on rejection, absence on clean config, and narrow-width truncation without breaking the box -- [x] 4.8 Run `uv run pytest -q` — full suite green; pass count = baseline + new tests - -## 5. Docs & example file [PARALLEL — after Group 1 fixes final knob names; isolated files] - -- [x] 5.1 Add `yas.example.toml` at repo root: sectioned `[layout]`/`[tokens]`/`[appearance]`, every knob commented out at its default, with a one-line comment per knob, plus a commented `[[tokens.model]]` per-model `soft_limit` example (`match = "1m"`) -- [x] 5.2 Update README with the knob matrix (knob / canonical env / legacy alias / toml key / default), the precedence rule, the per-model `soft_limit` override + its "per-model toml beats global env" carve-out, and the "yas.toml needs Python 3.11+; env vars work everywhere" note -- [x] 5.3 Update `CONTEXT.md` with a config section and a glossary entry for the `⚠` config-error row - -## 6. Verification [SEQ — last; after Groups 2, 3, 4, 5] - -- [x] 6.1 `make statusline/test` — eyeball the animation across narrow↔medium↔wide thresholds; confirm the error row renders correctly (inject a bad `yas.toml` to trigger it) and the box stays aligned -- [x] 6.2 `uv run ruff check` clean on all touched files -- [x] 6.3 Run the PUA-glyph catalogue (skill pre-edit step 2) on every touched line of `statusline_command.py`; hoist any raw PUA glyph to a named constant before final commit diff --git a/openspec/changes/archive/2026-06-01-add-info-gather-seam/.openspec.yaml b/openspec/changes/archive/2026-06-01-add-info-gather-seam/.openspec.yaml deleted file mode 100644 index a2168c3..0000000 --- a/openspec/changes/archive/2026-06-01-add-info-gather-seam/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-01-add-info-gather-seam/design.md b/openspec/changes/archive/2026-06-01-add-info-gather-seam/design.md deleted file mode 100644 index 911361b..0000000 --- a/openspec/changes/archive/2026-06-01-add-info-gather-seam/design.md +++ /dev/null @@ -1,73 +0,0 @@ -## Context - -The render layer's three builders (`build_narrow/medium/wide` in `layout.py:90-348`) each gather their own data inline: twelve reader calls, the `session_inout` denominator arithmetic, and — in `build_wide` — the `TokenLog.update` / `TokenRate.update` disk writes followed by `compute_day_cost` reading the log back. The gather logic is triplicated and width-coupled, and `layout` imports the six filesystem readers directly, so a builder cannot be tested without touching the real filesystem. `SessionInfo` is a clean, deep parse of the stdin JSON, but the *derived* session state has no home. This change adds that home: a `SessionView` gather module (`info`) sitting below the render layer. - -The design was settled through an interface-design pass (four competing sketches). The chosen shape is the minimal one — a plain dataclass of lazy cached fields delegating to the existing readers — with three targeted grafts. - -## Goals / Non-Goals - -**Goals:** -- One `SessionView` module that turns `SessionInfo` + `Config` into all derived session state, gathered lazily and read at most once each. -- `layout` builders consume a view and do only geometry; they no longer import the readers or touch the filesystem. -- Per-render disk writes (`TokenLog`/`TokenRate`) move out of the render path into an `app`-owned `record_tick` step. -- The seam is testable directly: layout tests inject a view; a new `test_info.py` covers the gather logic. - -**Non-Goals:** -- No change to rendered output, config precedence, or any runtime/public contract. -- No change to `statusline_command.py`, the demo, themes, or the rendering algorithms. -- No change to the six readers' internals or their individually-stubbable classmethod seams. -- No performance work beyond preserving today's lazy I/O profile (the separate `improve-latency` concern). - -## Decisions - -### D1: Lazy `@cached_property` view, not eager-all or per-width - -`SessionView` holds `session` + `cfg`; each derived field is a `@cached_property` that reads on first access and caches. Narrow renders touch only the fields they draw, so the git subprocess / transcript scan / openspec walk never fire for a width that does not show them — preserving today's lazy I/O profile. - -- *Alternative — eager `gather()` reads everything*: simplest interface and a trivially-constructible frozen dataclass, but a narrow render pays for I/O it discards. Rejected on the statusline's per-tick latency budget. -- *Alternative — per-width `gather(session, cfg, tier)`*: preserves the I/O savings but returns a struct with half its fields `None` depending on a `tier` arg the caller must keep in sync. Dishonest interface, rejected. - -### D2: Pure-read view; writes isolated in `record_tick` / `TickRecord` - -The view performs no disk writes. The `TokenLog.update` / `TokenRate.update` writes (and the `compute_day_cost` that depends on them) move into `record_tick(session, usage) -> TickRecord`, owned by `app` and placed beside the existing per-render payload write (`app.py:65-69`) — the established home for per-render side effects. `app` threads the `TickRecord` into `build_wide`. Everything on `SessionView` is therefore derivable from `session` + `cfg` alone; Day Total / day-cost, which depend on the persist, are carried separately so they cannot masquerade as gathered facts. - -- *Alternative — view owns the writes*: one call yields a complete view, but constructing a view in any test would rewrite the real token log, and "gather" would dishonestly mutate state. Rejected. -- *Alternative — `day_cost` as a `SessionView` field threaded in*: convenient for `build_wide`, but conflates a persist-dependent value with self-derived facts and muddies the deletion test. Rejected in favour of the `TickRecord` carrier. - -### D3: Geometry stays render-side - -`fill`, the pill percentage, model anchor/shift, and `effort_for_bg` call `Renderer` and remain in the builders. `SessionView` holds only data. This keeps `info` strictly below the render layer in the DAG (it never imports `renderer`). - -### D4: Direct delegation, no injected port - -The view calls the readers' classmethods directly. The dependencies are all local-substitutable (filesystem reads with temp-dir/fixture stand-ins), so per seam discipline a port would be a single-production-adapter indirection with no second backend to justify it. Tests substitute by pointing a `SessionInfo` at fixtures, or by stubbing a reader classmethod. - -- *Alternative — `SessionSource` port + `FsSource`/`FakeSource` adapters*: makes laziness a mechanical call-count assertion, but adds a permanent production adapter and a threaded constructor arg for a seam that will never have a second real backend. The laziness invariant can be pinned with a counting monkeypatch in one test instead. Rejected. - -### D5: Frozen injectable clock - -`SessionView(session, cfg, *, now=None)` captures `now` once. `elapsed` and task-freshness decisions read through the view use that single value, giving cross-row consistency within one render and deterministic tests without monkeypatching `time.time`. - -### D6: `@cached_property`, not a `@fact` registry - -Adding a derived fact is already one `@cached_property` method, so a bespoke self-registering decorator buys no real extensibility and costs static legibility (a typo would fail at call time). Stdlib `functools.cached_property` is the whole mechanism. - -### D7: Create-then-collapse, fan-out-friendly task structure - -The new module is created additively first (the monolithic builders keep working against their inline copies), then the builders collapse onto it in one gated step. The task list is grouped so independent pieces — the `info` module body, the `record_tick` step, and the new `test_info.py` — can be picked up by separate workers in parallel before the serial collapse, mirroring the create-only-waves discipline of the prior module split. - -## Risks / Trade-offs - -- **Crooked borders / pill misalignment the unit tests miss** → the builders are width-sensitive and partly visual. Mitigation: run `make statusline/test` (the demo) after the collapse step, eyeballing elbow/pill alignment across the narrow/medium/wide thresholds. -- **A field accessed for its side-of-effect ordering** → `record_tick` must run so `day_cost` reflects this tick; it is sequenced explicitly in `app` before `build_wide`, not hidden in the view. Mitigation: the `TickRecord` is an explicit `build_wide` parameter, so the ordering is visible at the call site. -- **Double transcript read** → `record_tick` needs `usage` and so does the view; `app` reads `view.transcript_usage` (cached) and hands it to `record_tick`, so the transcript is scanned once. Mitigation: pass the view's cached usage into `record_tick` rather than re-reading. -- **The six leaf fields are thin pass-throughs** → individually shallow, but the module as a whole passes the deletion test (delete it and the twelve calls + the denominator + the writes re-smear across three builders). Accepted: the depth is in consolidation + laziness + the single `session_inout`, not in each leaf. -- **Transient duplication mid-change** → the gather logic exists in both `info` and the builders until the collapse step. Mitigation: the collapse is one commit, gated on a green suite + demo pass; the un-repointed builders keep passing against their inline copies until then. - -## Migration Plan - -Work on `refactor-into-modules` (or a fresh `add-info-gather-seam` branch). Create `info.py` and `record_tick` additively (suite stays green), then collapse the builders and repoint tests in one gated step, verifying with `uv run pytest -q` and `make statusline/test`. Rollback before the collapse is deleting the new files; the collapse commit is the only irreversible-feeling step. Finally update the `CONTEXT.md` Module map (the glossary terms are already added). - -## Open Questions - -- None blocking. Branch name (`refactor-into-modules` vs a fresh branch) is the implementer's choice. diff --git a/openspec/changes/archive/2026-06-01-add-info-gather-seam/proposal.md b/openspec/changes/archive/2026-06-01-add-info-gather-seam/proposal.md deleted file mode 100644 index 3614473..0000000 --- a/openspec/changes/archive/2026-06-01-add-info-gather-seam/proposal.md +++ /dev/null @@ -1,26 +0,0 @@ -## Why - -The data-gathering for a render is smeared inline across `build_narrow`, `build_medium`, and `build_wide` in `layout.py` — twelve reader calls plus the `session_inout` denominator and the `TokenLog`/`TokenRate` writes, triplicated and width-coupled. The render layer (`layout`) reaches all the way down through six filesystem reader seams, and `layout`'s builders can only be tested by touching the real filesystem. There is no `info` module: a render module is doing the gathering. This change introduces the missing seam. - -## What Changes - -- Add a new `claude/statusline/info.py` exposing `SessionView` — a lazy, pure-read view of all *derived* session state (git, loaded skills, running subagents, tasks, transcript usage, openspec changes, elapsed, session cost, `session_inout`), built once per render from a parsed `SessionInfo` + `Config`. Every field is a `@cached_property`, so a narrow render pays only for the sources it draws. -- Move the per-render disk writes out of `build_wide` into a `record_tick(session, usage) -> TickRecord` step owned by `app`, sitting beside the existing `statusline-output` payload write. `TickRecord` bundles `token_log`, `day_cost`, and the token rate; **Day Total** and day-cost are threaded into the wide builder rather than gathered. -- Relocate `elapsed_from_transcript` out of `layout.py` into `info`, split into an impure mtime read + a pure `_fmt_elapsed` formatter. -- Repoint `build_narrow/medium/wide` to consume a `SessionView` (wide also a `TickRecord`); delete the inline reader calls, the `session_inout` arithmetic, and the token-log writes from `layout.py`. `layout` no longer imports `git`, `skills`, `subagents`, `tasks`, `transcript`, or `openspec`. -- Repoint layout tests to construct a `SessionView`; keep the per-reader tests unchanged; add `test_info.py` (denominator math, `_fmt_elapsed`, laziness). -- No change to rendered output, config precedence, or any runtime contract. - -## Capabilities - -### New Capabilities -- `statusline-info`: the `SessionView` gather seam — a lazy, pure-read, render-independent view of derived session state, plus the `record_tick`/`TickRecord` boundary that keeps per-render disk writes out of the view. - -### Modified Capabilities -- `statusline-packaging`: the layered acyclic DAG gains the `info` module (after the readers/`tokens`, below `renderer`); `layout` consumes `info` instead of importing the six readers directly. - -## Impact - -- New file: `claude/statusline/info.py`. Modified: `claude/statusline/layout.py` (builders consume a view; readers/`elapsed_from_transcript` removed), `claude/statusline/app.py` (`record_tick`/`TickRecord`, view construction, width dispatch). -- Tests: layout tests repointed; new `test/test_info.py`; per-reader tests untouched. -- No change to `statusline_command.py`, the demo output, themes, or the rendering algorithms. No new third-party dependencies. diff --git a/openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-info/spec.md b/openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-info/spec.md deleted file mode 100644 index 698f7eb..0000000 --- a/openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-info/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -## ADDED Requirements - -### Requirement: Lazy pure-read SessionView gather - -The statusline SHALL gather all *derived* session state through a single `SessionView` module (`claude/statusline/info.py`), constructed once per render from a parsed `SessionInfo` plus a `Config`. `SessionView` SHALL expose the derived state as lazily-evaluated, cached fields: `git`, `skills`, `subagents`, `tasks`, `transcript_usage`, `changes` (OpenSpec changes), `elapsed`, `session_cost`, and `session_inout`. A field SHALL read its underlying source on first access and cache the result; a second access SHALL NOT re-read. Constructing a `SessionView` SHALL perform no source reads. `SessionView` SHALL perform no disk writes and SHALL NOT call `TokenLog.update` or `TokenRate.update`. - -#### Scenario: A narrow render reads only what it draws - -- **WHEN** a `SessionView` is constructed and a narrow-width build reads only `view.subagents` -- **THEN** the git subprocess, the transcript scan, and the openspec walk are not triggered (only the subagent source is read) - -#### Scenario: A field is read at most once per view - -- **WHEN** `view.session_inout` and `view.transcript_usage` are both accessed on one `SessionView` -- **THEN** the transcript is scanned exactly once (the cached value feeds both) - -#### Scenario: Constructing a view writes nothing - -- **WHEN** a `SessionView` is constructed and any subset of its fields is accessed -- **THEN** no token-log or token-rate file is written by the view - -### Requirement: Render-independent gather seam - -`SessionView` SHALL sit below the render layer: it MAY import `session`, the six reader modules (`git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`), `metrics`, `tokens` (cost computation only), and `config`, but SHALL NOT import `renderer`, `pill`, `gradient`, `borders`, or `layout`. `SessionView` SHALL hold no render geometry (bar fill ratio, pill percentage, model anchor/shift). It SHALL delegate to the readers' existing classmethods rather than inlining their logic, so each reader keeps its individually-stubbable seam. - -#### Scenario: Info carries no render dependency - -- **WHEN** `claude/statusline/info.py` is imported -- **THEN** it references no symbol from `renderer`, `pill`, `gradient`, `borders`, or `layout` - -#### Scenario: Time math uses one frozen clock - -- **WHEN** a `SessionView` is constructed with an explicit `now` -- **THEN** `elapsed` and task-freshness decisions derived through the view use that single `now` value - -### Requirement: Single source for the Session In/Out denominator - -`SessionView.session_inout` SHALL be the sole definition of the Session Share % denominator: `(billed_in + cache_read + out) + sum(total_input + output)` over the running subagents. No layout builder SHALL recompute this sum inline. - -#### Scenario: Denominator composes usage and subagents - -- **WHEN** `view.session_inout` is read with known transcript usage and a known set of running subagents -- **THEN** it equals the transcript billed-in plus cache-read plus output, plus each subagent's `total_input + output` - -### Requirement: Per-render writes isolated in record_tick - -The per-render token-log and token-rate writes SHALL be performed by a `record_tick(session, usage)` step owned by `app`, returning a `TickRecord` bundling `token_log`, `day_cost`, and the token rate. `app` SHALL thread the `TickRecord` into the wide layout builder. The Day Total and day-cost SHALL NOT be fields of `SessionView`. - -#### Scenario: The wide builder receives day totals as data - -- **WHEN** `app` renders a wide layout -- **THEN** `record_tick` performs the token-log and token-rate writes and the resulting `TickRecord` is passed into the wide builder, which reads `day_cost` from it rather than computing or persisting it diff --git a/openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-packaging/spec.md b/openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-packaging/spec.md deleted file mode 100644 index dcdceef..0000000 --- a/openspec/changes/archive/2026-06-01-add-info-gather-seam/specs/statusline-packaging/spec.md +++ /dev/null @@ -1,20 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Layered acyclic package - -The statusline source SHALL be organized as a Python package under `claude/statusline/`, one module per concern, forming a single-directional acyclic dependency graph: `constants → text → {config, session, metrics, tokens, git, skills, subagents, tasks, transcript, openspec} → info → pill → gradient → borders → renderer → layout → app`. A module SHALL import only from modules earlier in this order; no import cycle SHALL exist among the package modules. The `info` module (`SessionView`) SHALL be the gather seam between the readers and the render layer: `layout` SHALL obtain derived session state from `info` and SHALL NOT import the reader modules (`git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`) directly. - -#### Scenario: No import cycles - -- **WHEN** every module in `claude/statusline/` is imported -- **THEN** all imports resolve with no circular-import error - -#### Scenario: Readers carry no render dependency - -- **WHEN** the filesystem/transcript reader modules (`git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`) are imported -- **THEN** they reference no symbol from `gradient`, `borders`, `renderer`, or `layout` - -#### Scenario: Layout consumes the gather seam, not the readers - -- **WHEN** `claude/statusline/layout.py` is inspected after this change -- **THEN** it imports `statusline.info` for derived session state and imports none of `git`, `skills`, `subagents`, `tasks`, `transcript`, or `openspec` directly diff --git a/openspec/changes/archive/2026-06-01-add-info-gather-seam/tasks.md b/openspec/changes/archive/2026-06-01-add-info-gather-seam/tasks.md deleted file mode 100644 index 6c6027a..0000000 --- a/openspec/changes/archive/2026-06-01-add-info-gather-seam/tasks.md +++ /dev/null @@ -1,49 +0,0 @@ -# Tasks: add-info-gather-seam - -> **Working agreement — every agent, main or subagent, READ THIS FIRST.** -> -> 1. **Mark each subtask `- [x]` _immediately_ as soon as it is done** — the instant the -> edit lands and its local check passes, edit this file and tick the box. Do **not** batch -> ticks, do **not** wait until the end of a wave, do **not** tick ahead of finishing. -> The checkbox state in this file is the **single source of truth** for how far the change -> has progressed; another worker (or the human watching) reads it to decide what to pick up -> next. A done-but-unticked task looks unstarted and will be duplicated. -> 2. **File-ownership rule for parallel work:** within a wave that runs in parallel, no two -> workers may edit the same file concurrently. Each task below names the file(s) it owns. -> If two pending tasks touch the same file, they belong to one worker and run in sequence. -> 3. **Wave gating:** a wave starts only after every box in the waves it depends on is ticked. -> Waves 1 and 2 are mutually independent and run in parallel. Wave 3 may start once Wave 1 -> is ticked. Wave 4 (the collapse) is serial and starts only after Waves 1–3 are ticked. -> Wave 5 is serial and last. -> 4. If a task turns out to be already done or not needed, tick it and note `(no-op: )` -> inline — never leave a finished-in-effect task unticked. - -## 1. Wave A — create `info.py` (parallel with Wave 2; owns `claude/statusline/info.py`, a new file) - -- [x] 1.1 Create `claude/statusline/info.py` with a `SessionView` dataclass (`session: SessionInfo`, `cfg: Config`, `now: float = field(default_factory=time.time)`) and the six leaf `@cached_property` fields — `git`, `skills`, `subagents`, `tasks`, `transcript_usage`, `changes` — each delegating to its existing reader classmethod (`GitInfo.from_cwd`, `LoadedSkills.from_transcript`, `RunningSubagents.from_session`, `TaskList.from_session`, `TranscriptUsage.from_transcript`, `OpenSpec.from_cwd(...).changes`). -- [x] 1.2 Add the derived `@cached_property` fields to `info.py`: `session_cost` (`compute_session_cost(session.model, transcript_usage)`), `session_inout` (`(billed_in + cache_read + out) + Σ(subagent total_input + output)`), and `elapsed` (a `stat`-based read that calls a pure module-level `_fmt_elapsed(mtime: float | None) -> str`). -- [x] 1.3 Confirm `info.py` imports only downward (`session`, the six readers, `metrics`, `tokens` for cost only, `config`, stdlib) and references no symbol from `renderer`, `pill`, `gradient`, `borders`, or `layout`; verify `python -c "import statusline.info"` is clean. - -## 2. Wave A — new tests for `info` (parallel with Wave 1; owns `test/test_info.py`, a new file) - -- [x] 2.1 Create `test/test_info.py` asserting `session_inout` equals billed-in + cache-read + output plus each subagent's `total_input + output`, from a `SessionView` built over known usage and a known subagent set. -- [x] 2.2 Add `_fmt_elapsed` cases: `None` mtime → `''`, sub-hour → `Nm`, multi-hour → `HhMm`. -- [x] 2.3 Add a laziness test: wrap a reader classmethod with a call-counter (monkeypatch), access only `view.subagents`, and assert the git / transcript / openspec readers were not called. - -## 3. Wave B — `record_tick` (serial after Wave 1; owns `claude/statusline/app.py`, additive only) - -- [x] 3.1 Add a `TickRecord` dataclass (`token_log`, `day_cost`, `tok_rate`) and `record_tick(session, usage) -> TickRecord` to `app.py`, lifting `TokenLog.update` / `TokenRate.update` / `compute_day_cost` from `build_wide`. Additive — do **not** yet remove them from `build_wide`; suite stays green. - -## 4. Wave C — collapse the builders (serial, gated on Waves 1–3; one coordinated worker — touches `layout.py` and `app.py`) - -- [x] 4.1 Repoint `build_narrow` / `build_medium` / `build_wide` in `layout.py` to take a `SessionView` (wide also a `TickRecord`) and read every gathered value off the view; drop the redundant `session` param where `view.session` covers it. -- [x] 4.2 Delete from `layout.py` the inline reader calls, the `session_inout` arithmetic, the `TokenLog`/`TokenRate`/`compute_day_cost` block, and `elapsed_from_transcript`; remove the now-unused imports of `git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`. -- [x] 4.3 Rewire `app.render` / `app.main`: construct `SessionView(session, cfg)`, call `record_tick(session, view.transcript_usage)` (cached usage — no double transcript scan), and width-dispatch passing `view` (plus the `TickRecord` for wide). -- [x] 4.4 Repoint layout tests (`test_layout_seam`, `test_layout_subagent_rows`, and any other builder tests) to construct a `SessionView`; delete coverage that only exercised gather-through-the-builder; leave the per-reader tests untouched. - -## 5. Wave D — verify & document (serial, last; owns `CONTEXT.md`) - -- [x] 5.1 Run `uv run pytest -q` and confirm the full suite is green. -- [x] 5.2 Run `make statusline/test` and eyeball elbow/pill alignment across the narrow / medium / wide width thresholds. -- [x] 5.3 Update the `CONTEXT.md` Module map: add the `info` row, and update the `layout` row (consumes a `SessionView`, no longer imports the readers) and the `app` row (adds `record_tick` / `TickRecord`). -- [x] 5.4 Run `openspec validate add-info-gather-seam` and confirm the change validates. diff --git a/openspec/changes/archive/2026-06-01-agents-done-cohort/.openspec.yaml b/openspec/changes/archive/2026-06-01-agents-done-cohort/.openspec.yaml deleted file mode 100644 index a2168c3..0000000 --- a/openspec/changes/archive/2026-06-01-agents-done-cohort/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-01-agents-done-cohort/design.md b/openspec/changes/archive/2026-06-01-agents-done-cohort/design.md deleted file mode 100644 index fa5f695..0000000 --- a/openspec/changes/archive/2026-06-01-agents-done-cohort/design.md +++ /dev/null @@ -1,72 +0,0 @@ -## Context - -The statusline renders running subagents by scanning `~/.claude/projects///subagents/*.meta.json`, pairing each with its sibling `.jsonl` transcript, and dropping any agent whose transcript hasn't been written for `STALE_SECONDS = 20` (`RunningSubagents.from_session`, `claude/statusline_command.py:1240`). The render is **stateless**: every invocation re-derives everything from disk. Rows are built by `subagent_row` (`:2420`) and placed by three layout builders (`build_narrow`/`build_medium`/`build_wide`, ~`:2895`/`:2943`/`:3003`). - -Empirically, `stop_reason: "end_turn"` appears on the final assistant line in 78% of real subagent transcripts (117/150 sampled) and is reliably the last line when present (115/117). The other 22% — interrupted, killed, or errored agents — never emit it. This asymmetry is the central design constraint. - -This repo is itself a Claude Code **plugin** (`.claude-plugin/`, `hooks/hooks.json` currently `{}`), so it can ship hooks that travel with installation. - -## Goals / Non-Goals - -**Goals:** -- Replace per-agent file-staleness hiding with cohort-level retirement driven by a real completion signal. -- Keep a fan-out's full roster on screen until the whole wave is Done, then retire together after a readable 20s grace. -- Make a finished row visually honest: `✓`, dimmed, frozen elapsed. -- Scope the cohort to the current turn so a new wave doesn't drag in old agents. -- Never break rendering, and degrade gracefully where the `end_turn` signal or the prompt marker is absent. - -**Non-Goals:** -- Migrating Done-detection onto `SubagentStart`/`SubagentStop` hooks. Those are richer (they carry `agent_id`/`last_assistant_message`) but flagged underdocumented (GitHub #19170, #16424); this change keeps Done-detection stateless and transcript-based, and uses a hook only for the well-documented prompt boundary. -- Changing the per-row metric cluster (t/m, share %, token figures) or the wide/narrow layout breakpoints. -- Any change to the `statusline-config` capability. - -## Decisions - -### Done = `end_turn` only; staleness is a janitor, not a Done signal -A subagent is Done strictly when `end_turn` is seen. Staleness never paints the Done visual. This guarantees the `✓`/dim/frozen-elapsed state is **never a lie** — a healthy agent doing one slow operation (a long build) goes silent but is never falsely checked off, and a retiring section never resurrects. -- *Alternative — staleness counts as Done (20s window):* simplest, mirrors today, but a `✓` could un-check itself and a slow-but-alive agent would flicker. Rejected for honesty. -- *Alternative — widen liveness to 45–60s and let staleness mean Done:* a compromise that still flickers on genuinely slow ops and lingers dead agents. Rejected. - -### Cohort = this turn's agents, with a live-straggler exception -Membership = `first_timestamp ≥ last_prompt_ts` **OR** still actively writing (within the liveness window). A running agent is always shown; the cutoff only retires finished/idle agents. This is sharper than a pure recency window: a new prompt cleanly defines a new cohort, but a long-running agent spawned in the prior turn is never yanked off-screen mid-flight. -- *Alternative — strict turn boundary (`first_timestamp ≥ last_prompt_ts` only):* cleanest definition, but hides an actively-running pre-turn agent the instant a new prompt lands. Rejected. -- *Alternative — pure recency window / burst clustering:* no prompt parsing needed, but "turn" becomes "recent activity" and a slow drip of one-off agents confuses clustering. Kept as the *fallback*, not the primary. - -### Last-prompt timestamp comes from a hook, not transcript parsing -The main transcript marks real prompts, slash-command expansions, `` blocks, `tool_result` payloads, sidechain and `isMeta` lines all as `type: "user"`. Distinguishing a genuine prompt is an unreliable heuristic. A `UserPromptSubmit` hook receives `session_id` + can write files (well-documented, 30s timeout), giving an **authoritative** boundary. It ships in the plugin's `hooks/hooks.json` (no `settings.json` edits) and writes a single shared `session_id → timestamp` map via atomic read-merge-write (temp file + rename). -- *Alternative — parse the transcript for the last prompt:* fragile against the masquerading-user-line problem above. Rejected as primary; the recency-window fallback covers the no-hook case. -- *Alternative — per-session marker files instead of one shared map:* avoids all concurrency, but scatters many small files. The shared-map + atomic-rename pattern was chosen; per-session files remain a viable simplification if concurrency proves troublesome. - -### Three time constants -| Constant | Value | Role | -|---|---|---| -| Cohort grace | 20s | visible time after the last member's `end_ts` before a clean section retires | -| Janitor horizon | 60s | total-silence threshold to sweep a dirty cohort; also the no-hook recency fallback window | -| Liveness window | 30s | silence threshold for "idle" vs "still writing" (straggler-keep, running-vs-finished); widened from today's `STALE_SECONDS = 20` | - -Grace and janitor are deliberately separate: a clean finish retires fast (20s after the last `end_turn`); a dirty cohort needs the longer 60s backstop. Liveness was widened to 30s so a moderately slow op isn't misread as idle. - -### Frozen elapsed needs one new field -`_parse_transcript` already walks every line; it additionally captures `end_ts` (the `end_turn` line's timestamp). `RunningSubagent` gains `end_ts: float` — dual purpose: Done flag (`end_ts > 0`) and the basis for frozen elapsed (`end_ts − first_timestamp`). No second pass. - -### Visibility moves from per-agent to cohort-level -`from_session` stops `continue`-ing past stale agents (today's `:1273`). Instead it returns the full candidate set, and a new method on `RunningSubagents` (e.g. `visible(now, last_prompt_ts)`) computes membership + the clean-retire-vs-janitor decision over the whole set. The three layout builders ask the cohort "are you visible?" instead of `if subagents.subagents`. - -## Risks / Trade-offs - -- **Dirty cohort lingers up to 60s looking live** → Accepted. Only the 22% no-`end_turn` case, and only when a member dies without writing again; the janitor still guarantees eventual removal. Chosen over a faster sweep that would risk killing genuinely-slow agents. -- **`end_turn` schema could change** → Mitigation: parsing is defensive (`stop_reason == "end_turn"` guarded by try/except, same as existing transcript parsing); absence simply routes an agent to the janitor path, never crashes. -- **Hook absent (older install / fresh session / first prompt)** → Mitigation: recency-window fallback (60s) keeps the feature working, close to today's behaviour, with no error. -- **Shared state-file concurrency** → Mitigation: read-merge-write with atomic rename preserves sibling entries; readers always see a complete map. Per-session files remain a fallback simplification. -- **`SubagentStop` would be a cleaner Done signal** → Deferred, not adopted: underdocumented today. The design leaves room to migrate Done-detection onto it later without disturbing the cohort/visibility logic. - -## Migration Plan - -1. Land the statusline changes (`end_ts` field, cohort visibility method, `subagent_row` Done branch, layout builder call-site updates) behind no flag — behaviour is strictly additive and degrades to the recency window if the hook is absent. -2. Add the `UserPromptSubmit` hook to `hooks/hooks.json` and its small writer script. -3. Existing installs that update the plugin gain the hook on next prompt; until then they run the fallback path. No rollback coordination needed — removing the hook simply reverts to the recency window. - -## Open Questions - -- Liveness window pinned at **30s** (smaller end of the "30–45s" range chosen during design). Revisit if moderately slow ops still read as idle in practice. -- Exact path/name of the shared state file (e.g. `~/.claude/yas-last-prompt.json`) to be finalised during implementation. diff --git a/openspec/changes/archive/2026-06-01-agents-done-cohort/proposal.md b/openspec/changes/archive/2026-06-01-agents-done-cohort/proposal.md deleted file mode 100644 index f8fc545..0000000 --- a/openspec/changes/archive/2026-06-01-agents-done-cohort/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Why - -The statusline currently hides each running subagent independently, 20 seconds after its transcript file stops being written to. This file-activity proxy has two failure modes: a healthy agent doing one slow operation (a long build or test run) writes nothing and vanishes mid-flight, and a finished agent's row keeps ticking its elapsed clock as if still alive. Treating a fan-out as one **cohort** — kept on screen until the whole wave is done, then retired together after a readable grace period — matches how the work is actually dispatched and read. - -## What Changes - -- Detect subagent completion from the transcript's `stop_reason: "end_turn"` signal (the "Done" beat) instead of inferring it from file-write staleness. -- Render a finished agent as a **dimmed** row: `▶` marker becomes `✓`, and elapsed **freezes** at its completion time (`end_ts − first_ts`) instead of ticking forever. -- Keep all cohort agents visible until the **last** one is Done, then retire the whole section together after a **20s** grace window. -- Scope the cohort to **the current turn**: agents spawned since the last user prompt, plus any pre-turn agent still actively writing. **Always show a running agent** regardless of age — the cutoff only retires finished/idle agents. -- Add a **`UserPromptSubmit` hook** (shipped in the plugin's `hooks/hooks.json`) that records the last user-prompt timestamp per session to a shared state file via atomic read-merge-write. The statusline reads it as the authoritative cohort lower-bound. -- Add a **60s janitor**: a cohort containing an agent that died without `end_turn` (interrupted/killed/errored) is swept after 60s of total silence, so the section always retires. The same 60s window is the **graceful fallback** when the prompt-marker file is absent or stale (fresh install, first prompt, older plugin version) — behaviour degrades to a recency window, never breaks. - -## Capabilities - -### New Capabilities -- `subagent-cohort`: Lifecycle, visibility, and rendering of the statusline's running-subagent section — Done detection via `end_turn`, turn-scoped cohort membership, cohort-level retirement with grace and janitor windows, and the dimmed/✓/frozen-elapsed treatment for finished agents. -- `prompt-boundary-hook`: A plugin-shipped `UserPromptSubmit` hook that records the last user-prompt timestamp per session to a shared state file (atomic read-merge-write), consumed by the statusline to scope the cohort to the current turn. - -### Modified Capabilities - - -## Impact - -- **Code**: `claude/statusline_command.py` — `RunningSubagent` (new `end_ts` field), `RunningSubagents._parse_transcript` (capture `end_turn` timestamp), `RunningSubagents.from_session` (stop per-agent stale-dropping; move to cohort-level visibility), a new cohort visibility method, and the three layout builders (`build_narrow`/`build_medium`/`build_wide`) plus `subagent_row` (dim/✓/frozen-elapsed branch). -- **Plugin**: `hooks/hooks.json` gains a `UserPromptSubmit` entry; a small hook script writes the shared per-session prompt-timestamp state file. -- **Constants**: cohort grace `20s`, janitor horizon / recency fallback `60s`, liveness window `30s` (widened from today's `STALE_SECONDS = 20`). -- **Tests/demo**: new `demo.py` scenarios (all-running, mixed running+done-dimmed, all-done-in-grace, dirty-janitor) and pytest coverage for `end_ts` parsing, cohort scoping, retirement timing, janitor sweep, fallback, and frozen-elapsed rendering. -- **Distribution**: no per-user `settings.json` edits required — the hook travels with the YAS plugin. Older installs without the hook degrade gracefully via the recency-window fallback. diff --git a/openspec/changes/archive/2026-06-01-agents-done-cohort/specs/prompt-boundary-hook/spec.md b/openspec/changes/archive/2026-06-01-agents-done-cohort/specs/prompt-boundary-hook/spec.md deleted file mode 100644 index 5d16131..0000000 --- a/openspec/changes/archive/2026-06-01-agents-done-cohort/specs/prompt-boundary-hook/spec.md +++ /dev/null @@ -1,43 +0,0 @@ -## ADDED Requirements - -### Requirement: Plugin-shipped UserPromptSubmit hook - -The YAS plugin SHALL declare a `UserPromptSubmit` hook in its own `hooks/hooks.json` so the behaviour travels with the plugin and requires no per-user `settings.json` edits. The hook SHALL record the prompt-submit timestamp for the submitting session. - -#### Scenario: Hook fires on user prompt - -- **WHEN** the user submits a prompt in a session where the YAS plugin is installed -- **THEN** the hook runs and records the current timestamp for that `session_id` - -#### Scenario: Hook ships with the plugin - -- **WHEN** a user installs the YAS plugin -- **THEN** the `UserPromptSubmit` hook is present without the user editing `~/.claude/settings.json` - -### Requirement: Shared per-session state file with atomic writes - -The hook SHALL persist a mapping of `session_id` to the latest prompt-submit timestamp in a single shared state file. Writes SHALL be atomic and SHALL preserve other sessions' entries: read the existing map, update only the current session's entry, write to a temporary file, and rename it into place. - -#### Scenario: Concurrent sessions do not clobber each other - -- **WHEN** two sessions submit prompts close together -- **THEN** both sessions' timestamps are present in the state file after the writes settle - -#### Scenario: Atomic replace avoids partial reads - -- **WHEN** the statusline reads the state file while the hook is updating it -- **THEN** the reader sees either the old complete map or the new complete map, never a truncated file - -### Requirement: Statusline consumes the prompt timestamp - -The statusline SHALL read the current session's prompt-submit timestamp from the shared state file and use it as the authoritative lower bound for turn-scoped cohort membership. A missing or unreadable file SHALL trigger the subagent-cohort capability's recency-window fallback rather than an error. - -#### Scenario: Timestamp scopes the cohort - -- **WHEN** the state file contains a timestamp for the current `session_id` -- **THEN** the statusline uses it as the cohort lower bound - -#### Scenario: Absent entry falls back - -- **WHEN** the state file has no entry for the current `session_id` -- **THEN** the statusline falls back to the recency window without error diff --git a/openspec/changes/archive/2026-06-01-agents-done-cohort/specs/subagent-cohort/spec.md b/openspec/changes/archive/2026-06-01-agents-done-cohort/specs/subagent-cohort/spec.md deleted file mode 100644 index 09bbf11..0000000 --- a/openspec/changes/archive/2026-06-01-agents-done-cohort/specs/subagent-cohort/spec.md +++ /dev/null @@ -1,110 +0,0 @@ -## ADDED Requirements - -### Requirement: Done detection via end_turn - -The statusline SHALL treat a subagent as **Done** when, and only when, its transcript jsonl contains an assistant message whose `message.stop_reason` equals `"end_turn"`. The timestamp of that line SHALL be captured as the subagent's `end_ts`. Transcript-write staleness SHALL NOT, on its own, mark a subagent Done. - -#### Scenario: Clean finish marks Done - -- **WHEN** a subagent transcript's final assistant message carries `stop_reason: "end_turn"` -- **THEN** the subagent is Done and its `end_ts` is the timestamp of that line - -#### Scenario: Silence does not mark Done - -- **WHEN** a subagent transcript has had no writes for longer than the liveness window but contains no `end_turn` -- **THEN** the subagent is NOT marked Done (it is handled by the janitor sweep instead) - -#### Scenario: Interrupted agent never emits end_turn - -- **WHEN** a subagent was interrupted, killed, or errored and its transcript ends without `stop_reason: "end_turn"` -- **THEN** the subagent is never marked Done and never receives the Done visual treatment - -### Requirement: Turn-scoped cohort membership - -The statusline SHALL scope the visible subagent cohort to the current turn. A subagent is a member of the cohort when its `first_timestamp` is at or after the last user-prompt timestamp for the session, OR when it is still actively writing (its transcript was written within the liveness window) regardless of when it started. A running subagent SHALL always be shown regardless of age; the age cutoff applies only to finished or idle subagents. - -#### Scenario: Agent spawned this turn is in the cohort - -- **WHEN** a subagent's `first_timestamp` is at or after the last user-prompt timestamp -- **THEN** it is a member of the cohort - -#### Scenario: Pre-turn straggler still writing is kept - -- **WHEN** a subagent started before the last user prompt but its transcript was written within the liveness window -- **THEN** it remains a member of the cohort until it finishes or dies - -#### Scenario: Running agent is always shown - -- **WHEN** a subagent is still running (not Done and written within the liveness window) -- **THEN** it is shown regardless of how long ago it started - -#### Scenario: Old finished agent from a prior turn is excluded - -- **WHEN** a subagent finished in a previous turn and its `first_timestamp` is before the last user-prompt timestamp -- **THEN** it is not shown in the current turn's cohort - -### Requirement: Cohort-level retirement with grace window - -The statusline SHALL keep every cohort member visible until the **last** member is Done, then retire the entire section together after a grace window of 20 seconds measured from the most recent member's `end_ts`. Members that finished earlier SHALL remain visible (dimmed) until the whole section retires. - -#### Scenario: Section persists while any member runs - -- **WHEN** at least one cohort member is not yet Done -- **THEN** the whole section, including already-finished members, remains visible - -#### Scenario: Whole section retires after grace - -- **WHEN** every cohort member is Done and 20 seconds have elapsed since the most recent member's `end_ts` -- **THEN** the entire subagent section is removed - -#### Scenario: Finished member waits for stragglers - -- **WHEN** member A is Done but sibling member B is still running -- **THEN** member A stays on screen (dimmed) rather than dropping off individually - -### Requirement: Janitor sweep for dirty cohorts - -When a cohort contains at least one member that is not Done and has stopped writing (so the clean grace countdown can never arm), the statusline SHALL remove the entire section after 60 seconds of total silence across all of the cohort's transcripts. - -#### Scenario: Dirty cohort swept after silence - -- **WHEN** a cohort contains a member that never emitted `end_turn` and no member's transcript has been written for 60 seconds -- **THEN** the entire section is removed - -#### Scenario: Janitor does not fire while a member writes - -- **WHEN** any cohort member's transcript was written within the last 60 seconds -- **THEN** the janitor does not sweep the section - -### Requirement: Finished-agent visual treatment - -A Done subagent's row SHALL be visually distinguished from a running one: the leading marker SHALL be `✓` instead of the running `▶` marker, the row SHALL be dimmed (overriding the running row's rainbow marker and field colours), and the elapsed field SHALL be frozen at `end_ts − first_timestamp` rather than continuing to tick from the current time. - -#### Scenario: Done row shows check marker and dimmed styling - -- **WHEN** a subagent is Done and still within the cohort grace window -- **THEN** its row renders with a `✓` marker and dimmed colours - -#### Scenario: Elapsed freezes at completion - -- **WHEN** a subagent is Done -- **THEN** its elapsed field shows `end_ts − first_timestamp` and does not increase on subsequent renders - -#### Scenario: Running row is unchanged - -- **WHEN** a subagent is still running -- **THEN** its row renders with the `▶` marker, live colours, and a live-ticking elapsed field exactly as before this change - -### Requirement: Graceful fallback without a prompt marker - -When the last user-prompt timestamp is unavailable (the marker file is missing, unreadable, stale, or its session entry is absent), the statusline SHALL scope the cohort by a recency window of 60 seconds instead of by turn, and SHALL render without error. - -#### Scenario: Missing marker degrades to recency window - -- **WHEN** no usable last-prompt timestamp exists for the session -- **THEN** the cohort comprises subagents active or finished within the last 60 seconds, and rendering proceeds normally - -#### Scenario: Unreadable marker never breaks rendering - -- **WHEN** the marker file is truncated or contains invalid JSON -- **THEN** the statusline falls back to the recency window and does not raise diff --git a/openspec/changes/archive/2026-06-01-agents-done-cohort/tasks.md b/openspec/changes/archive/2026-06-01-agents-done-cohort/tasks.md deleted file mode 100644 index 69158f9..0000000 --- a/openspec/changes/archive/2026-06-01-agents-done-cohort/tasks.md +++ /dev/null @@ -1,80 +0,0 @@ -# Tasks — agents-done-cohort - -> **Fan-out execution.** These tasks are grouped so independent groups can be -> dispatched to multiple workers (main agent or subagents) in parallel. Each -> group's header notes its dependencies and whether it is parallel-safe. -> -> **⚠ MARK SUBTASKS DONE THE INSTANT THEY ARE DONE.** Every worker — the main -> agent and every subagent — MUST flip its checkbox from `- [ ]` to `- [x]` in -> this file *immediately* upon completing each subtask, before moving to the -> next one. Do not batch updates, do not wait until a group is finished, do not -> defer to the end. The apply phase and any observer read this file to track -> live progress; a delayed update makes the spec's progress unobservable and can -> cause two workers to collide on the same subtask. If a subtask is partially -> done, leave it unchecked. One completed subtask → one immediate checkbox flip. - -## 1. Foundation — data model & constants - -> Depends on: nothing. Do this first; groups 2–4 build on it. Single worker. - -- [x] 1.1 Add `end_ts: float = 0.0` field to the `RunningSubagent` dataclass (`claude/statusline_command.py:1221`), documented as the `end_turn` line timestamp and Done flag (`end_ts > 0` ⟺ Done) -- [x] 1.2 Define the three time constants near `RunningSubagents`: cohort grace `20s`, janitor horizon `60s`, liveness window `30s` (widening the existing `STALE_SECONDS = 20`); name them clearly and reference their roles -- [x] 1.3 Decide and document the shared state-file path constant (e.g. `~/.claude/yas-last-prompt.json`) used by both the hook writer and the statusline reader - -## 2. Done detection — transcript parsing - -> Depends on: group 1. Parallel-safe with groups 3 and 5 once group 1 lands. - -- [x] 2.1 In `RunningSubagents._parse_transcript` (`:1296`), capture `end_ts`: when a line is an assistant message with `message.stop_reason == "end_turn"`, record that line's timestamp (defensive parse, mirror existing try/except style) -- [x] 2.2 Thread `end_ts` through `_parse_transcript`'s return tuple and into the `RunningSubagent(...)` construction in `from_session` (`:1279`) -- [x] 2.3 Unit test: a transcript with a trailing `end_turn` yields `end_ts > 0` equal to that line's epoch; a transcript without `end_turn` yields `end_ts == 0.0` - -## 3. Cohort membership & retirement logic - -> Depends on: group 1 (and reads `end_ts` from group 2). Core logic — single worker. - -- [x] 3.1 Stop dropping stale agents per-agent in `from_session`: remove the `now - mtime > STALE_SECONDS: continue` filter (`:1272`) so the full candidate set is returned (still capturing each agent's transcript mtime for liveness/janitor decisions) -- [x] 3.2 Add a `RunningSubagents` method, e.g. `visible(now, last_prompt_ts) -> list[RunningSubagent]`, computing turn-scoped membership: `first_timestamp >= last_prompt_ts` OR written within the liveness window (still-writing straggler); a running agent is always included -- [x] 3.3 In that method, implement cohort retirement: if every member is Done, hide the section once `now - max(end_ts) > 20s` (cohort grace); otherwise apply the 60s janitor — hide when no member's transcript has been written for 60s -- [x] 3.4 Implement the no-marker fallback: when `last_prompt_ts` is absent, scope membership by the 60s recency window instead of by turn -- [x] 3.5 Unit tests: agent-this-turn included; pre-turn-still-writing kept; old finished agent excluded; running-agent-always-shown; clean retire at 20s; janitor sweep at 60s; recency fallback when no marker - -## 4. Prompt-boundary hook (independent track) - -> Depends on: group 1.3 (state-file path) only. Parallel-safe with groups 2–3. - -- [x] 4.1 Add a `UserPromptSubmit` entry to the plugin's `hooks/hooks.json` (currently `{}`), wiring a command hook -- [x] 4.2 Write the hook script: read `session_id` from stdin JSON, read the existing `session_id → timestamp` map, update only this session's entry, write to a temp file and atomically rename into place -- [x] 4.3 Make the hook robust: missing/corrupt state file → start from an empty map; never error out of the hook -- [x] 4.4 In the statusline, read the current session's timestamp from the state file (missing/unreadable → return `None` so group 3.4's fallback engages); never raise -- [x] 4.5 Unit tests: two-session concurrent write preserves both entries; truncated/invalid JSON read returns `None` and does not raise - -## 5. Rendering — Done row treatment - -> Depends on: group 1 (reads `end_ts`). Parallel-safe with groups 2–4. - -- [x] 5.1 In `subagent_row` (`:2420`), branch on Done (`sub.end_ts > 0`): compute frozen `dur = end_ts - first_timestamp` instead of `now - first_timestamp` -- [x] 5.2 Swap the leading marker from `▶` (`GLYPH_SUBAGENT_ROW`) to `✓` on Done rows; choose/define the checkmark glyph -- [x] 5.3 Apply dimmed styling to Done rows, overriding the rainbow marker colour (`rainbow_at`) and the per-field colours; keep running rows byte-for-byte unchanged -- [x] 5.4 Verify both wide (>100 col) and narrow (≤100 col) layouts render the Done treatment correctly -- [x] 5.5 Unit tests: Done row shows `✓` + frozen elapsed; running row unchanged; elapsed does not increase across two renders of a Done agent - -## 6. Layout integration - -> Depends on: groups 3 and 5. Single worker (touches all three builders). - -- [x] 6.1 Update `build_narrow` (`~:2895`), `build_medium` (`~:2943`), `build_wide` (`~:3003`) to call the cohort `visible(...)` method and gate the section on its result instead of `if subagents.subagents` -- [x] 6.2 Pass the last-prompt timestamp (from group 4.4) into the visibility call at each site -- [x] 6.3 Confirm the section separator / spacing rows only render when at least one member is visible - -## 7. Demo scenarios & verification - -> Depends on: groups 3, 5, 6. Do last. - -- [x] 7.1 Add `demo.py` scenario: all-running cohort (no Done rows) -- [x] 7.2 Add `demo.py` scenario: mixed running + Done-dimmed members -- [x] 7.3 Add `demo.py` scenario: all-done within the 20s grace window -- [x] 7.4 Add `demo.py` scenario: dirty cohort awaiting the 60s janitor -- [x] 7.5 Run `make demo` and visually confirm the four scenarios render as designed (✓, dimming, frozen elapsed, spacing) -- [x] 7.6 Run `make test` (full pytest suite) and confirm green, including the existing `test_running_subagents.py` / `test_subagent_rows.py` / `test_layout_subagent_rows.py` (update assertions that encoded the old per-agent 20s-stale-drop behaviour) -- [x] 7.7 Update `CONTEXT.md` "Running Subagent" language to describe cohort retirement, the `✓`/dim/frozen-elapsed Done state, and the three time constants diff --git a/openspec/changes/archive/2026-06-01-reorganise-yas-package/.openspec.yaml b/openspec/changes/archive/2026-06-01-reorganise-yas-package/.openspec.yaml deleted file mode 100644 index a2168c3..0000000 --- a/openspec/changes/archive/2026-06-01-reorganise-yas-package/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-01-reorganise-yas-package/design.md b/openspec/changes/archive/2026-06-01-reorganise-yas-package/design.md deleted file mode 100644 index 7f48990..0000000 --- a/openspec/changes/archive/2026-06-01-reorganise-yas-package/design.md +++ /dev/null @@ -1,55 +0,0 @@ -## Context - -`claude/statusline/` contains 21 flat Python files. With the addition of `info.py` (the `SessionView` gather seam) the three-layer structure — data-gather readers, renderer building-blocks, layout/app — is architecturally present but invisible in the directory tree. This change makes the grouping explicit by introducing two subpackages while keeping everything that is cross-cutting at the top level. - -## Goals / Non-Goals - -**Goals:** -- Rename the package root from `claude/statusline/` to `claude/yas/` so the top-level import prefix matches the project name. -- Group the six data-gather readers under `yas/info/` with `SessionView` as the subpackage's public face (`__init__.py`). -- Group the five renderer support modules under `yas/render/`. -- Reduce the flat top-level file count from 21 to 8. -- All import sites outside the package (`statusline_command.py`, `mon.py`, tests, `ops/demo.py`) updated to `yas.*`. - -**Non-Goals:** -- No change to rendered output, runtime contracts, config, themes, or demo scenarios. -- No change to the six readers' internals or any module's behaviour. -- No compat shim for the old `statusline.*` prefix — this is a first-party, single-repo rename. - -## Decisions - -### D1: `renderer.py` stays top-level - -`renderer.py` imports data types from all six gather readers (`GitInfo`, `LoadedSkills`, etc.) directly, not through `SessionView`. Moving it into `yas/render/` would create a `render/ → info/` cross-subpackage dependency that undermines the visual grouping goal. Keeping it top-level avoids that and is honest about its cross-cutting nature. - -### D2: `tokens.py` stays top-level - -`tokens.py` is consumed by three layers: `yas/info/__init__.py` (`compute_session_cost`), `renderer.py` (`TokenAccounting`, `TokenRate`), and `app.py` (`TickRecord`, `TokenLog`, `compute_day_cost`). Moving it into `info/` would create a `render/ → info/tokens` import, so it stays flat alongside the other cross-cutting modules. - -### D3: `info.py` becomes `yas/info/__init__.py` - -The subpackage *is* the gather seam. `SessionView` is the only symbol callers import from this layer; the six reader modules are internal detail. Promoting `info.py` to `__init__.py` means `from yas.info import SessionView` works without an extra module level, matching the stdlib convention (e.g. `from pathlib import Path`). - -### D4: `yas/render/__init__.py` is empty - -No single symbol dominates `render/` the way `SessionView` dominates `info/`. Callers (`renderer.py` exclusively) import from specific submodules (`yas.render.gradient`, `yas.render.borders`, etc.). An empty `__init__.py` is sufficient. - -### D5: Additive creation, then cutover - -Workers create `claude/yas/` alongside the still-intact `claude/statusline/`. The test suite continues to pass against the old package until a single serial cutover step updates all external callers and `session-info-example.json` moves. The old directory is deleted only after the suite is green against `yas.*`. - -This lets the fan-out waves (subpackage creation) proceed in parallel without leaving the suite broken mid-way. - -## Risks / Trade-offs - -- **Crooked borders / pill misalignment** → visual-only regressions that unit tests miss. Mitigation: run `make statusline/test` (the demo) after the cutover step and eyeball elbow/pill alignment across narrow/medium/wide thresholds. -- **Missed import site** → an overlooked `statusline.*` reference causes an `ImportError` at runtime. Mitigation: `grep -r "from statusline\|import statusline" .` in the verify step catches any stragglers before the old directory is deleted. -- **Transient duplication** → `claude/statusline/` and `claude/yas/` coexist during Wave 1. Mitigation: Wave 1 is additive-only; the old package remains untouched until the cutover commit. - -## Migration Plan - -1. **Wave 1 (parallel):** Create `claude/yas/` with updated imports; create `yas/info/` and `yas/render/` subpackages. Old `claude/statusline/` untouched — suite stays green. -2. **Wave 2 (serial):** Cutover: update `statusline_command.py`, `mon.py`, all tests, `ops/demo.py` to `yas.*`; move `session-info-example.json`. Run `uv run pytest -q` — must be green. -3. **Wave 3 (serial):** Delete `claude/statusline/`. Run `uv run pytest -q` + `make statusline/test`. Confirm green. - -Rollback before Wave 3 is deleting `claude/yas/` and reverting the external-caller edits. After Wave 3 (deletion), rollback is `git checkout HEAD claude/statusline/`. diff --git a/openspec/changes/archive/2026-06-01-reorganise-yas-package/proposal.md b/openspec/changes/archive/2026-06-01-reorganise-yas-package/proposal.md deleted file mode 100644 index 2c2d642..0000000 --- a/openspec/changes/archive/2026-06-01-reorganise-yas-package/proposal.md +++ /dev/null @@ -1,30 +0,0 @@ -## Why - -The `claude/statusline/` package has grown to 21 flat Python files, making it hard to see at a glance which modules belong together. Grouping them into `yas/info/` (data-gather readers) and `yas/render/` (renderer building-blocks) reduces the flat list to 8 top-level files and makes the three-layer structure (gather → render support → layout/app) visible in the directory tree. - -## What Changes - -- **BREAKING** Rename `claude/statusline/` → `claude/yas/`; all internal imports change from `statusline.*` to `yas.*`. -- Move the six data-gather readers (`git`, `openspec`, `skills`, `subagents`, `tasks`, `transcript`) into `claude/yas/info/`; promote `info.py` to `claude/yas/info/__init__.py` so `from yas.info import SessionView` continues to work. -- Move the five renderer support modules (`gradient`, `borders`, `pill`, `text`, `metrics`) into `claude/yas/render/` with an empty `__init__.py`. -- Keep at `claude/yas/` top-level: `app`, `config`, `constants`, `layout`, `renderer`, `session`, `themes`, `tokens`. -- Update all import sites outside the package: `claude/statusline_command.py`, `claude/mon.py`, all test files, and `ops/demo.py` (two hardcoded paths). -- `session-info-example.json` moves with the package to `claude/yas/`. - -## Capabilities - -### New Capabilities - -*(none — this is a structural reorganisation with no new runtime behaviour)* - -### Modified Capabilities - -- `statusline-packaging`: package root changes from `claude/statusline/` to `claude/yas/`; the acyclic DAG gains two subpackages (`info/`, `render/`); import prefix changes from `statusline.` to `yas.`; entrypoint shim (`statusline_command.py`) updates its single import. - -## Impact - -- Every `statusline.*` import in the repo becomes `yas.*` (package modules, tests, `mon.py`, `statusline_command.py`). -- `ops/demo.py` path constants `claude/statusline/…` → `claude/yas/…`. -- `test/conftest.py` path to `statusline_command.py` is unchanged (the shim file stays at `claude/statusline_command.py`). -- No change to rendered output, runtime contracts, config, themes, or the demo scenarios. -- No new third-party dependencies. diff --git a/openspec/changes/archive/2026-06-01-reorganise-yas-package/specs/statusline-packaging/spec.md b/openspec/changes/archive/2026-06-01-reorganise-yas-package/specs/statusline-packaging/spec.md deleted file mode 100644 index 5c7c301..0000000 --- a/openspec/changes/archive/2026-06-01-reorganise-yas-package/specs/statusline-packaging/spec.md +++ /dev/null @@ -1,69 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Frozen statusLine entrypoint - -The statusLine command SHALL remain invocable as `python /claude/statusline_command.py`, reading session JSON on stdin and writing the rendered statusline to stdout. The file `claude/statusline_command.py` SHALL continue to exist at that path and name, because installed `settings.json` configurations hardcode it. After the reorganisation it SHALL contain only the composition entrypoint (importing and calling `yas.app.main`), delegating all behaviour to the `yas` package. - -#### Scenario: Entrypoint still renders from stdin - -- **WHEN** `python claude/statusline_command.py` is run with a valid session-info JSON payload on stdin at a terminal width ≥ `MIN_WIDTH` -- **THEN** it writes the same rendered statusline string it produced before the reorganisation - -#### Scenario: Entrypoint is thin - -- **WHEN** `claude/statusline_command.py` is inspected after the reorganisation -- **THEN** it defines no renderer, reader, config, or layout logic of its own and only wires `yas.app.main` to `__main__` - -### Requirement: Layered acyclic package - -The statusline source SHALL be organised as a Python package under `claude/yas/`, forming a single-directional acyclic dependency graph. The package SHALL contain two subpackages: - -- `yas/info/` — the data-gather layer: `git`, `openspec`, `skills`, `subagents`, `tasks`, `transcript` (readers), with `SessionView` as the public face via `__init__.py`. -- `yas/render/` — renderer building-blocks: `gradient`, `borders`, `pill`, `text`, `metrics`. - -Top-level modules (cross-cutting): `constants`, `session`, `config`, `tokens`, `themes`, `renderer`, `layout`, `app`. - -The DAG order SHALL be: `constants → text → {config, session, metrics, tokens} → {info/*, render/*} → renderer → layout → app`. A module SHALL import only from modules earlier in this order; no import cycle SHALL exist. - -#### Scenario: No import cycles - -- **WHEN** every module in `claude/yas/` (including subpackages) is imported -- **THEN** all imports resolve with no circular-import error - -#### Scenario: Readers carry no render dependency - -- **WHEN** the filesystem reader modules under `yas/info/` (`git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`) are imported -- **THEN** they reference no symbol from `yas.render`, `yas.renderer`, or `yas.layout` - -#### Scenario: Subpackage public face - -- **WHEN** a caller writes `from yas.info import SessionView` -- **THEN** the import resolves without referencing a submodule explicitly, because `SessionView` is exported from `yas/info/__init__.py` - -### Requirement: Tests import real modules - -Test files SHALL import the concrete package modules they exercise (e.g. `import yas.borders as borders`) rather than a single flat `statusline_command` re-export namespace. The package SHALL NOT provide a catch-all re-export shim whose only purpose is to preserve the old flat namespace. - -#### Scenario: A test names its module - -- **WHEN** a test that exercises border math is read -- **THEN** it imports `yas.render.borders` (the module that owns `BorderRenderer`), not `statusline_command` - -#### Scenario: pytest resolves the package - -- **WHEN** the test suite is run via `uv run pytest -q` -- **THEN** `yas.*` modules import successfully because `pythonpath` includes the `claude` directory, with no per-test `spec_from_file_location` shim - -### Requirement: Live configuration resolution - -The statusline SHALL resolve configuration by calling `Config.load()` at render/command time rather than from a module-level singleton evaluated at import. No import-time global SHALL gate behaviour; functions needing a resolved limit SHALL receive it explicitly. Importing any `yas` module SHALL NOT read environment variables or `yas.toml`. - -#### Scenario: Env change takes effect without reimport - -- **WHEN** `YAS_SOFT_LIMIT` is set and `render()` is called in the same process where a `yas` module was already imported -- **THEN** the freshly set value is honoured (config is resolved live, not cached at import) - -#### Scenario: Importing a module is side-effect free - -- **WHEN** any `yas` package module is imported -- **THEN** no `yas.toml` read or `YAS_*` environment lookup occurs as a side effect of the import diff --git a/openspec/changes/archive/2026-06-01-reorganise-yas-package/tasks.md b/openspec/changes/archive/2026-06-01-reorganise-yas-package/tasks.md deleted file mode 100644 index c16ee9d..0000000 --- a/openspec/changes/archive/2026-06-01-reorganise-yas-package/tasks.md +++ /dev/null @@ -1,79 +0,0 @@ -# Tasks: reorganise-yas-package - -> **Working agreement — every agent, main or subagent, READ THIS FIRST.** -> -> 1. **Mark each subtask `- [x]` _immediately_ as soon as it is done** — the instant the -> edit lands and its local check passes, edit this file and tick the box. Do **not** batch -> ticks, do **not** wait until the end of a wave, do **not** tick ahead of finishing. -> The checkbox state in this file is the **single source of truth** for how far the change -> has progressed; another worker (or the human watching) reads it to decide what to pick up -> next. A done-but-unticked task looks unstarted and will be duplicated. -> 2. **File-ownership rule for parallel work:** within a wave that runs in parallel, no two -> workers may edit the same file concurrently. Each task below names the file(s) it owns. -> If two pending tasks touch the same file, they belong to one worker and run in sequence. -> 3. **Wave gating:** Wave 1 tasks are mutually independent and run in parallel. Wave 2 is -> serial and starts only after every Wave 1 box is ticked. Wave 3 is serial and starts only -> after every Wave 2 box is ticked. -> 4. If a task turns out to be already done or not needed, tick it and note `(no-op: )` -> inline — never leave a finished-in-effect task unticked. - -## 1. Wave 0 — scaffold (serial; owns new directories and `__init__.py` files) - -- [x] 1.1 Create `claude/yas/`, `claude/yas/info/`, `claude/yas/render/`. -- [x] 1.2 Create `claude/yas/__init__.py` (empty, matching `claude/statusline/__init__.py`). -- [x] 1.3 Create `claude/yas/render/__init__.py` (empty). -- [x] 1.4 Create `claude/yas/info/__init__.py` by copying `claude/statusline/info.py` and updating all `from statusline.` imports to `from yas.`. - -## 2. Wave 1A — `yas/info/` reader modules (parallel with 1B and 1C; owns only `claude/yas/info/*.py` files listed here) - -Copy each file to `claude/yas/info/.py`, replacing every `from statusline.` with `from yas.` and `import statusline.` with `import yas.`. - -- [x] 2.1 `claude/yas/info/git.py` (from `claude/statusline/git.py`) -- [x] 2.2 `claude/yas/info/openspec.py` (from `claude/statusline/openspec.py`) -- [x] 2.3 `claude/yas/info/skills.py` (from `claude/statusline/skills.py`) -- [x] 2.4 `claude/yas/info/subagents.py` (from `claude/statusline/subagents.py`) -- [x] 2.5 `claude/yas/info/tasks.py` (from `claude/statusline/tasks.py`) -- [x] 2.6 `claude/yas/info/transcript.py` (from `claude/statusline/transcript.py`) - -## 3. Wave 1B — `yas/render/` modules (parallel with 1A and 1C; owns only `claude/yas/render/*.py` files listed here) - -Copy each file to `claude/yas/render/.py`, replacing all `from statusline.` / `import statusline.` with `from yas.` / `import yas.`. - -- [x] 3.1 `claude/yas/render/gradient.py` (from `claude/statusline/gradient.py`) -- [x] 3.2 `claude/yas/render/borders.py` (from `claude/statusline/borders.py`) -- [x] 3.3 `claude/yas/render/pill.py` (from `claude/statusline/pill.py`) -- [x] 3.4 `claude/yas/render/text.py` (from `claude/statusline/text.py`) -- [x] 3.5 `claude/yas/render/metrics.py` (from `claude/statusline/metrics.py`) - -## 4. Wave 1C — top-level `yas/` modules (parallel with 1A and 1B; owns only `claude/yas/*.py` files listed here) - -Copy each file to `claude/yas/.py`, replacing all `from statusline.` / `import statusline.` with `from yas.` / `import yas.`. Additionally, update any intra-package imports that now point to a subpackage path (e.g. `from yas.borders` → `from yas.render.borders`, `from yas.gradient` → `from yas.render.gradient`, `from yas.git` → `from yas.info.git`, etc.). - -- [x] 4.1 `claude/yas/constants.py` (from `claude/statusline/constants.py`) -- [x] 4.2 `claude/yas/session.py` (from `claude/statusline/session.py`) -- [x] 4.3 `claude/yas/config.py` (from `claude/statusline/config.py`) -- [x] 4.4 `claude/yas/tokens.py` (from `claude/statusline/tokens.py`) -- [x] 4.5 `claude/yas/themes.py` (from `claude/statusline/themes.py`) -- [x] 4.6 `claude/yas/renderer.py` (from `claude/statusline/renderer.py`; imports `yas.render.borders`, `yas.render.gradient`, `yas.render.pill`, `yas.render.text`, `yas.render.metrics`, and `yas.info.*` for data types) -- [x] 4.7 `claude/yas/layout.py` (from `claude/statusline/layout.py`) -- [x] 4.8 `claude/yas/app.py` (from `claude/statusline/app.py`) - -## 5. Wave 2 — cutover external callers (serial, gated on Waves 1A–1C all ticked) - -Verify all Wave 1 boxes are ticked before starting this wave. - -- [x] 5.1 Update `claude/statusline_command.py`: change `from statusline.app import main` → `from yas.app import main`. -- [x] 5.2 Update `claude/mon.py`: change `from statusline.app import render, resolve_theme` → `from yas.app import …` and `from statusline.constants import MIN_WIDTH` → `from yas.constants import MIN_WIDTH`. -- [x] 5.3 Update `test/conftest.py`: replace all `import statusline.*` with `import yas.*` (six module references). The `_SRC` path to `statusline_command.py` is unchanged. -- [x] 5.4 Update all remaining test files — replace every `from statusline.` / `import statusline.` / `import statusline_command` (where it re-imports via the shim) with the `yas.*` equivalent. Files known to need updates: `test_pure_helpers.py`, `test_plugins_skills.py`, `test_task_row.py`, `test_layout_seam.py`, `test_layout_subagent_rows.py`, `test_model_section.py`, `test_session_elapsed.py`, `test_subagent_rows.py`, `test_themes.py`, `test_info.py`, and any others containing `statusline`. -- [x] 5.5 Update `ops/demo.py`: change the two hardcoded path constants from `claude/statusline/…` to `claude/yas/…`. -- [x] 5.6 Copy `claude/statusline/session-info-example.json` to `claude/yas/session-info-example.json`. -- [x] 5.7 Run `uv run pytest -q` and confirm the full suite is green before proceeding to Wave 3. - -## 6. Wave 3 — delete old package and final verification (serial, gated on Wave 2 all ticked) - -- [x] 6.1 Run `grep -r "from statusline\|import statusline" claude/ test/ ops/` and confirm zero hits. -- [x] 6.2 Delete the old package: `git rm -r claude/statusline/`. -- [x] 6.3 Run `uv run pytest -q` — must be green. -- [x] 6.4 Run `make statusline/test` (or `uv run python ops/demo.py`) and eyeball elbow/pill alignment across the narrow/medium/wide thresholds. -- [x] 6.5 Update `CONTEXT.md` Module map: rename `claude/statusline/` references to `claude/yas/` and add the two subpackages. diff --git a/openspec/changes/archive/2026-06-01-split-statusline-modules/.openspec.yaml b/openspec/changes/archive/2026-06-01-split-statusline-modules/.openspec.yaml deleted file mode 100644 index 927e3e8..0000000 --- a/openspec/changes/archive/2026-06-01-split-statusline-modules/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-31 diff --git a/openspec/changes/archive/2026-06-01-split-statusline-modules/design.md b/openspec/changes/archive/2026-06-01-split-statusline-modules/design.md deleted file mode 100644 index ba2180b..0000000 --- a/openspec/changes/archive/2026-06-01-split-statusline-modules/design.md +++ /dev/null @@ -1,98 +0,0 @@ -## Context - -`claude/statusline_command.py` is a single 3,200-line module. It is the file the `yas` plugin invokes directly (`settings.json` hardcodes `.../claude/statusline_command.py`), and ~40 test files plus `claude/mon.py` couple to it as a flat namespace (`import statusline_command as sl`). The code already falls into clean horizontal bands, and a prior investigation confirmed the bands form an acyclic dependency graph: the filesystem readers reference zero render-layer symbols, and `config`/`session`/`tokens` reference zero glyph/colour constants. - -Four hard constraints frame the work: - -1. **Frozen entrypoint filename** — `claude/statusline_command.py` must keep its path and name. -2. **Script-by-path execution** — the runtime invokes `python /claude/statusline_command.py`, so `sys.path[0]` is `claude/` and `import statusline.x` resolves at runtime. pytest, however, sets no `pythonpath` today and loads the module via a `spec_from_file_location` shim in `conftest.py`; `themes.py` is loaded via a second `__file__`-relative shim. -3. **Plugin distribution** — the whole `claude/` tree ships; no manifest enumerates files, so new modules ship for free. -4. **PUA glyph hazard** — Nerd Font icons live in the Private Use Area; raw glyphs get dropped through agent round-trips. The repo already hoists them to escape-encoded module constants. - -## Goals / Non-Goals - -**Goals:** -- Decompose the monolith into a layered, acyclic `claude/statusline/` package, one module per concern. -- Reduce `statusline_command.py` to a thin composition entrypoint. -- Give each module a real, independently-importable seam; repoint every test to the module it exercises. -- Remove import-time global config state; resolve `Config` live. -- Structure extraction so independent modules are carved by **parallel subagents** with zero merge conflicts. - -**Non-Goals:** -- No behavior change to the rendered output, config precedence, or any public/runtime contract. -- No re-export compatibility shim preserving the flat `sl.*` namespace (this is option b: full repoint). -- No change to `themes.py` internals, the demo, or the rendering algorithms themselves. -- No performance work (that is the separate `improve-latency` concern). - -## Decisions - -### D1: Module layout (the DAG) - -One module per band, importing only from earlier layers: - -``` -constants ANSI colours, GLYPH_/ICON_/SPARK_/PILL_ escapes, BarChars, - RESET/BOLD/ITALIC, width + rate-limit-window consts, _ANSI_RE -text _visible_width, _is_wide, _middle_ellipsis, fmt_tok, fmt_dur, - sparkline_width, terminal_width -config _parse_*, _resolve, _env_sources, _load_toml, _parse_models, Config -session Model…SessionInfo, _as_*, _parse_iso_to_epoch -metrics burndown_delta, subagent_avg_tpm, subagent_share -tokens TokenAccounting, compute_session_cost/day_cost, TokenLog, TokenRate -git skills subagents tasks transcript openspec one filesystem/transcript reader each -pill Pill + pill glyph helpers -gradient GradientEngine, rainbow_*, model_key, _scale, paint_bg_span, pill_gradient_fg -borders BorderRenderer -renderer Renderer (all section helpers) -layout RowSpec, LayoutSpec, append_error_row, build_*, render_layout -app render, resolve_theme, main -``` - -Leaf-module names (`constants`, `text`, `metrics`, `pill`, `app`) confirmed with the user. **Alternatives considered:** one `render.py` for the whole painter (rejected — keeps the 1,300-line wall, and the three layers already have separate test files); one `sources.py` for all readers (rejected — bundles six independent I/O seams the user wants individually stubbable). - -### D2: Parallel execution model — "create-only waves, collapse once" - -The danger in parallelizing is N subagents editing the *same* monolith. The rule that removes all conflict: **during the parallel phase, no subagent edits `statusline_command.py`.** Each extraction job only (a) creates its new `statusline/.py` by copying its band out of a read-only snapshot of the monolith, and (b) repoints the test file(s) that exercise it. The monolith is left fully intact, so any not-yet-repointed test keeps passing against it (transient code duplication is accepted for the life of the branch). - -A module can only be imported once its DAG dependencies exist, so extraction proceeds in **waves**; modules *within* a wave are mutually independent (same DAG layer) and run as parallel subagents: - -- **Wave A:** `constants` -- **Wave B (∥):** `text`, `session`, `metrics`, `config` -- **Wave C (∥ — the big fan-out):** `tokens`, `git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec` -- **Wave D (∥):** `pill`, `gradient` -- **Wave E:** `borders` -- **Wave F:** `renderer` -- **Wave G:** `layout` -- **Wave H (serial collapse):** create `app`; rewrite `statusline_command.py` to the 3-line entrypoint; delete the now-duplicated bands from the monolith; add `pyproject` pytest `pythonpath`; delete the conftest spec shim and the themes `__file__` shim; repoint `mon.py`; remove the `CONFIG`/`SOFT_LIMIT`/`MAX_WIDTH` globals and thread `soft_limit` explicitly. - -Each parallel job's **definition of done** is local: its new module imports cleanly and its repointed test file(s) pass. The full suite stays green throughout because un-repointed tests still hit the intact monolith. The render chain (D→G) is inherently serial; the parallelism win is Waves B and C (4 + 7 modules). - -**Alternative considered:** per-subagent git worktrees with a merge at the end (rejected — every job would still need to edit the one monolith to remove its band, producing guaranteed conflicts; the create-only rule sidesteps this entirely). - -### D3: Import path and shim removal - -Add `[tool.pytest.ini_options] pythonpath = ["claude"]`. This makes both `import statusline_command` and `import statusline.` resolve under pytest, which in turn lets us delete the `conftest.py` `spec_from_file_location` block and replace the monolith's `__file__`-relative themes loader with `from statusline.themes import Theme, ModelColors, THEMES, CLAUDE_DARK`. Runtime already has `claude/` on `sys.path[0]`, so the same plain imports work for the live command. - -### D4: Test import convention - -Module-qualified: `import statusline.borders as borders; borders.BorderRenderer(...)`. Preserves the `sl.Foo → borders.Foo` feel, makes each symbol's home obvious at the call site, and keeps from-import lists short. Use `test/conftest.py`'s `strip_ansi` / `_visible_width` helpers unchanged. - -### D5: Live config, no globals - -`render()` calls `Config.load()` (or accepts an injected `Config`) like `main()`/`resolve_theme()` already do, fixing the documented live-vs-cached inconsistency. `build_narrow/medium/wide` already accept `soft_limit`; callers pass `cfg.soft_limit_for(...)`. The 11 `sl.MAX_WIDTH`/`sl.SOFT_LIMIT` test references become explicit literals or `config.DEFAULT_*` constants. Importing any module performs no env/`yas.toml` read. - -## Risks / Trade-offs - -- **PUA glyph byte loss during copy-out** → The single highest risk in this repo. Mitigation: the `constants` module (Wave A) is created by copying the existing escape-encoded glyph lines verbatim; subagents reference glyphs only by constant name and never transcribe raw PUA characters. A post-Wave-A check greps the new `constants.py` for raw PUA codepoints and compares the glyph-constant count to the monolith's. -- **Crooked borders pytest won't catch** → Column math is width-sensitive and partly visual. Mitigation: run `make statusline/test` (the subprocess demo) after Waves F, G, and H, eyeballing elbow/pill alignment across the narrow/medium/wide thresholds. -- **Transient code duplication mid-branch** → bands exist in both the monolith and the new module until Wave H. Mitigation: Wave H deletes the bands and a final check asserts the monolith defines no symbol that also lives in a package module (no orphaned duplicates). -- **A repointed test outruns its dependency module** → e.g. `test_borders` repointed before `gradient` exists. Mitigation: the wave ordering is a hard barrier — a wave starts only after the previous wave's modules are committed and importable. -- **`mon.py` / `test_render_callable` reach `render`** → both repointed in Wave H to `statusline.app`; `render`/`resolve_theme`/`MIN_WIDTH` remain importable, just from their real homes. - -## Migration Plan - -Work on the `improve-latency` branch (or a dedicated `split-statusline-modules` branch). Execute Waves A→H; after each wave run `uv run pytest -q` (must stay green) and, for Waves F/G/H, `make statusline/test`. Rollback is per-wave: because the monolith stays intact until Wave H, aborting before H leaves a working tree with extra (unused) module files and can be reverted by deleting them. Wave H is the only irreversible-feeling step and is gated on a full green suite + demo pass. Finally, add the "Module map" section to `CONTEXT.md`. - -## Open Questions - -- None blocking. If an external consumer outside this repo ever imported `statusline_command` symbols directly, they would break — but the only known importers are this repo's tests and `mon.py`, both repointed here. diff --git a/openspec/changes/archive/2026-06-01-split-statusline-modules/proposal.md b/openspec/changes/archive/2026-06-01-split-statusline-modules/proposal.md deleted file mode 100644 index 7da431c..0000000 --- a/openspec/changes/archive/2026-06-01-split-statusline-modules/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Why - -`claude/statusline_command.py` is a 3,200-line flat module: every symbol — config resolution, session-JSON intake, token accounting, six filesystem readers, the three-layer painter, and the layout pipeline — lives in one namespace that ~40 test files reach into via `import statusline_command as sl`. The interface is as large as the implementation, which is the definition of a shallow module: there are no real seams, the file is hard to navigate, and the I/O readers can only be tested through the whole monolith. Splitting it into a layered package turns each band into an independently-importable module with its own test surface, and reduces the plugin entrypoint to a thin composition root. - -## What Changes - -- Introduce a layered `claude/statusline/` package (the directory already exists with an empty `__init__.py`) holding one module per concern, arranged as an acyclic dependency DAG: `constants → text → {config, session, metrics, tokens, 6 readers} → pill → gradient → borders → renderer → layout → app`. -- Reduce `claude/statusline_command.py` to a ~3-line entrypoint that imports and calls `statusline.app.main`. **Its filename is frozen** — `settings.json` hardcodes `.../claude/statusline_command.py`, so the file must stay put and stay named that or every installed plugin user breaks. -- **BREAKING (internal test API only):** repoint all ~40 test files from the flat `import statusline_command as sl` namespace to the real modules, module-qualified (`import statusline.borders as borders`). No public/runtime contract changes. -- Remove the import-time global singletons `CONFIG`, `SOFT_LIMIT`, `MAX_WIDTH`. `render()` resolves `Config.load()` live (matching what `main()` and `resolve_theme()` already do, and fixing the documented live-vs-cached inconsistency); `build_*` take explicit `soft_limit`. -- Add `[tool.pytest.ini_options] pythonpath = ["claude"]` so `statusline.*` and `statusline_command` resolve under pytest, and **delete two bespoke import shims**: conftest's `spec_from_file_location` block and the `__file__`-relative themes loader (replaced by a normal `from statusline.themes import ...`). -- Repoint `claude/mon.py` to `from statusline.app import render, resolve_theme` + `from statusline.constants import MIN_WIDTH` (dropping its manual `sys.path.insert`). -- Structure the work so the per-module extractions run as **independent parallel subagents**: the DAG is published up front, each leaf/mid module is a self-contained extraction job (move code → repoint its test file → green), and only the entrypoint-collapse step serializes at the end. - -## Capabilities - -### New Capabilities -- `statusline-packaging`: the architectural contract for how the statusline source is organized — the frozen entrypoint filename, the layered acyclic package, the live (non-import-time) configuration resolution, and the test-imports-real-modules rule. Encodes invariants future changes must respect. - -### Modified Capabilities - - -## Impact - -- **Code:** `claude/statusline_command.py` (gutted to entrypoint), new modules under `claude/statusline/`, `claude/mon.py` (repointed), `pyproject.toml` (pytest pythonpath). -- **Tests:** ~40 files in `test/` repointed to `statusline.*`; `test/conftest.py` simplified (spec shim removed). -- **Distribution:** none — the `yas` plugin ships the whole `claude/` tree and neither `plugin.json` nor `marketplace.json` enumerates files, so new modules ship automatically. -- **Runtime/public API:** none — `python claude/statusline_command.py` still reads session JSON on stdin and writes the rendered statusline; `render`/`resolve_theme`/`MIN_WIDTH` remain importable (from `statusline.app`/`statusline.constants`). -- **Docs:** `CONTEXT.md` gains a "Module map" section naming each module against its canonical concept. diff --git a/openspec/changes/archive/2026-06-01-split-statusline-modules/specs/statusline-packaging/spec.md b/openspec/changes/archive/2026-06-01-split-statusline-modules/specs/statusline-packaging/spec.md deleted file mode 100644 index 7149c6a..0000000 --- a/openspec/changes/archive/2026-06-01-split-statusline-modules/specs/statusline-packaging/spec.md +++ /dev/null @@ -1,57 +0,0 @@ -## ADDED Requirements - -### Requirement: Frozen statusLine entrypoint - -The statusLine command SHALL remain invocable as `python /claude/statusline_command.py`, reading session JSON on stdin and writing the rendered statusline to stdout. The file `claude/statusline_command.py` SHALL continue to exist at that path and name, because installed `settings.json` configurations hardcode it. After the split it SHALL contain only the composition entrypoint (importing and calling `statusline.app.main`), delegating all behavior to the `statusline` package. - -#### Scenario: Entrypoint still renders from stdin - -- **WHEN** `python claude/statusline_command.py` is run with a valid session-info JSON payload on stdin at a terminal width ≥ `MIN_WIDTH` -- **THEN** it writes the same rendered statusline string it produced before the split - -#### Scenario: Entrypoint is thin - -- **WHEN** `claude/statusline_command.py` is inspected after the split -- **THEN** it defines no renderer, reader, config, or layout logic of its own and only wires `statusline.app.main` to `__main__` - -### Requirement: Layered acyclic package - -The statusline source SHALL be organized as a Python package under `claude/statusline/`, one module per concern, forming a single-directional acyclic dependency graph: `constants → text → {config, session, metrics, tokens, git, skills, subagents, tasks, transcript, openspec} → pill → gradient → borders → renderer → layout → app`. A module SHALL import only from modules earlier in this order; no import cycle SHALL exist among the package modules. - -#### Scenario: No import cycles - -- **WHEN** every module in `claude/statusline/` is imported -- **THEN** all imports resolve with no circular-import error - -#### Scenario: Readers carry no render dependency - -- **WHEN** the filesystem/transcript reader modules (`git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`) are imported -- **THEN** they reference no symbol from `gradient`, `borders`, `renderer`, or `layout` - -### Requirement: Tests import real modules - -Test files SHALL import the concrete package modules they exercise (e.g. `import statusline.borders as borders`) rather than a single flat `statusline_command` re-export namespace. The package SHALL NOT provide a catch-all re-export shim whose only purpose is to preserve the old flat namespace. - -#### Scenario: A test names its module - -- **WHEN** a test that exercises border math is read -- **THEN** it imports `statusline.borders` (the module that owns `BorderRenderer`), not `statusline_command` - -#### Scenario: pytest resolves the package - -- **WHEN** the test suite is run via `uv run pytest -q` -- **THEN** `statusline.*` modules import successfully because `pythonpath` includes the `claude` directory, with no per-test `spec_from_file_location` shim - -### Requirement: Live configuration resolution - -The statusline SHALL resolve configuration by calling `Config.load()` at render/command time rather than from a module-level singleton evaluated at import. No import-time global (`CONFIG`, `SOFT_LIMIT`, `MAX_WIDTH`) SHALL gate behavior; functions needing a resolved limit SHALL receive it explicitly (e.g. `build_*` taking `soft_limit`). Importing any `statusline` module SHALL NOT read environment variables or `yas.toml`. - -#### Scenario: Env change takes effect without reimport - -- **WHEN** `YAS_SOFT_LIMIT` is set and `render()` is called in the same process where a `statusline` module was already imported -- **THEN** the freshly set value is honored (config is resolved live, not cached at import) - -#### Scenario: Importing a module is side-effect free - -- **WHEN** any `statusline` package module is imported -- **THEN** no `yas.toml` read or `YAS_*` environment lookup occurs as a side effect of the import diff --git a/openspec/changes/archive/2026-06-01-split-statusline-modules/tasks.md b/openspec/changes/archive/2026-06-01-split-statusline-modules/tasks.md deleted file mode 100644 index a987dbe..0000000 --- a/openspec/changes/archive/2026-06-01-split-statusline-modules/tasks.md +++ /dev/null @@ -1,70 +0,0 @@ -## 1. Setup & baseline (serial) - -- [x] 1.1 Record baseline: run `uv run pytest -q` and note the pass count; run `make statusline/test` and confirm the demo renders cleanly across narrow/medium/wide. -- [x] 1.2 Add `[tool.pytest.ini_options]` with `pythonpath = ["claude"]` to `pyproject.toml` so `import statusline.` and `import statusline_command` resolve under pytest. Run `uv run pytest -q` — still green (no new modules yet). -- [x] 1.3 Snapshot the monolith for read-only copy-out: `git show HEAD:claude/statusline_command.py` (or keep the working copy untouched) as the source of truth all Wave A–G jobs copy bands from. Establish the rule: **Wave A–G jobs create only new files under `claude/statusline/` and repoint their own test files; none edits `statusline_command.py`.** - -## 2. Wave A — constants (serial, gates everything) - -- [x] 2.1 Create `claude/statusline/constants.py` by copying verbatim: ANSI colour constants (`CLR_*`, `RESET`, `BOLD`, `ITALIC`), the escape-encoded glyph constants (`GLYPH_*`, `ICON_*`, `SPARK_*`, `PILL_*`), `BarChars`, width constants (`MIN_WIDTH`, `NARROW_WIDTH`, `MEDIUM_WIDTH`, `DEFAULT_MAX_WIDTH`, `DEFAULT_SOFT_LIMIT`, `DEFAULT_TOKEN_WINDOW`, `DEFAULT_THEME`), rate-limit window constants, `RAINBOW_PALETTE`, `BG_LUM_THRESHOLD`, `LIVE_DIM`, `_ANSI_RE`, `HOME`, `CLAUDE_DIR`. Copy glyph lines as the existing `\uXXXX` escapes — never transcribe raw PUA characters. -- [x] 2.2 PUA integrity check: grep `constants.py` for raw PUA codepoints (U+E000–F8FF, U+F0000–FFFFD) — expect zero raw hits; confirm the glyph-constant count matches the monolith's. -- [x] 2.3 Repoint pure-constant tests to `import statusline.constants as constants` (the constants-only assertions in `test_pure_helpers` for `RAINBOW_PALETTE`, the `SPARK_*` set used by `test_sparkline`). Run those test files green. - -## 3. Wave B — text / session / metrics / config (parallel; each creates only new files) - -- [x] 3.1 `[parallel]` Create `claude/statusline/text.py`: `_is_wide`, `_visible_width`, `_middle_ellipsis`, `fmt_tok`, `fmt_dur`, `sparkline_width`, `terminal_width` (import width consts + `_ANSI_RE` from `constants`). Repoint `test_pure_helpers` (the `_visible_width`/`_middle_ellipsis`/`fmt_tok`/`sparkline_width` cases). -- [x] 3.2 `[parallel]` Create `claude/statusline/session.py`: `_as_int/_as_float/_as_str`, `_parse_iso_to_epoch`, and `Model`, `OutputStyle`, `Effort`, `Thinking`, `CurrentUsage`, `RateBucket`, `Workspace`, `Cost`, `ContextWindow`, `RateLimits`, `SessionInfo` (+ their `from_dict`). Repoint `test_session_info_pure`, `test_from_dict_parsers`, `test_workspace_plugins`. -- [x] 3.3 `[parallel]` Create `claude/statusline/metrics.py`: `burndown_delta`, `subagent_avg_tpm`, `subagent_share`. Repoint `test_burndown`, `test_subagent_metrics`. -- [x] 3.4 `[parallel]` Create `claude/statusline/config.py`: `_parse_pos_int/_parse_pos_float/_parse_bool/_parse_theme/_parse_bg_shift`, `_env_sources`, `_resolve`, `_legacy_theme_sources`, `_parse_argv`, `_load_toml`, `_parse_models`, `Config` (with `.load`, `.soft_limit_for`). Import `DEFAULT_*` from `constants`. **Do not** create a module-level `CONFIG`/`SOFT_LIMIT`/`MAX_WIDTH` singleton. Repoint `test_config`, `test_soft_limit_env`. -- [x] 3.5 Run `uv run pytest -q` — full suite green (un-repointed tests still hit the intact monolith). - -## 4. Wave C — tokens + filesystem/transcript readers (parallel fan-out) - -- [x] 4.1 `[parallel]` Create `claude/statusline/tokens.py`: `TokenAccounting`, `compute_session_cost`, `compute_day_cost`, `TokenLog`, `TokenRate` (import `Model`/usage types from `session`, `CLAUDE_DIR` from `constants`). Repoint `test_token_log`, `test_token_rate`, `test_model_cost_rates`, `test_session_cost_math`. -- [x] 4.2 `[parallel]` Create `claude/statusline/git.py`: `GitInfo`. Repoint `test_git_info`. -- [x] 4.3 `[parallel]` Create `claude/statusline/skills.py`: `LoadedSkills`. Repoint `test_loaded_skills`. -- [x] 4.4 `[parallel]` Create `claude/statusline/subagents.py`: `RunningSubagent`, `RunningSubagents`. Repoint `test_running_subagents`. -- [x] 4.5 `[parallel]` Create `claude/statusline/tasks.py`: `Task`, `TaskList`. Repoint `test_task_list`. -- [x] 4.6 `[parallel]` Create `claude/statusline/transcript.py`: `TranscriptUsage`. Repoint `test_transcript_usage`, `test_transcript_usage_props`. -- [x] 4.7 `[parallel]` Create `claude/statusline/openspec.py`: `OpenSpec`. Repoint `test_openspec`. -- [x] 4.8 Run `uv run pytest -q` — full suite green. - -## 5. Wave D — pill / gradient (parallel) - -- [x] 5.1 `[parallel]` Create `claude/statusline/gradient.py`: `GradientEngine`, `rainbow_step/rainbow_at/rainbow_color`, `model_key`, `_scale`, `paint_bg_span`, `pill_gradient_fg` (import glyphs/palette from `constants`, `_visible_width` from `text`). Repoint `test_gradient_math` and the `Renderer`-free cases of `test_sparkline`. -- [x] 5.2 `[parallel]` Create `claude/statusline/pill.py`: `Pill` + its `border_char`/`border_fg`/`gradient_fg` helpers (import `PILL_*` from `constants`, gradient fg from `gradient` if needed — confirm direction keeps the DAG acyclic; if `Pill` needs `pill_gradient_fg`, that function moves to `gradient` and `pill` imports it). -- [x] 5.3 Run `uv run pytest -q` — full suite green. - -## 6. Wave E — borders (serial) - -- [x] 6.1 Create `claude/statusline/borders.py`: `BorderRenderer` (imports `gradient`, `pill`, `constants`, `text`). Repoint `test_borders`. -- [x] 6.2 Run `uv run pytest -q` — green. - -## 7. Wave F — renderer (serial) - -- [x] 7.1 Create `claude/statusline/renderer.py`: `Renderer` and all section helpers (`path_git`, `path_git_compact`, `model_section*`, `path_model_row`, `plugins_skills`, `tokens_cost`, `context_line*`, `openspec_bar`, `helper`, colour pickers including the risk-zone colour). Imports `borders`, `gradient`, `constants`, `text`, `session`, `tokens`, and the reader modules as needed. -- [x] 7.2 Repoint single-module Renderer tests: `test_model_section`, `test_context_line`, `test_tokens_cost`, `test_renderer_colour_pickers`, `test_helper`, `test_path_git`, `test_plugins_skills`, `test_openspec_bar`, `test_risk_zone_color`, `test_bar_builders`. -- [x] 7.3 Run `uv run pytest -q` — green; run `make statusline/test` and eyeball elbow/pill alignment. - -## 8. Wave G — layout + integration tests (serial) - -- [x] 8.1 Create `claude/statusline/layout.py`: `RowSpec`, `LayoutSpec`, `append_error_row`, `build_narrow`, `build_medium`, `build_wide`, `render_layout` (import `renderer`, `session`, reader modules, `config` for `soft_limit`). `build_*` keep their explicit `soft_limit` parameter. -- [x] 8.2 Repoint layout + cross-module integration tests (these pull `Renderer` + `build_*` + data classes together, so they import several `statusline.*` modules module-qualified): `test_layout_seam`, `test_layout_subagent_rows`, `test_subagent_rows`, `test_task_row`, `test_themes`. Replace `sl.MAX_WIDTH`/`sl.SOFT_LIMIT` references with explicit literals or `config.DEFAULT_*`. -- [x] 8.3 Run `uv run pytest -q` — green; `make statusline/test` — alignment intact. - -## 9. Wave H — collapse to thin entrypoint (serial) - -- [x] 9.1 Create `claude/statusline/app.py`: `render` (resolving `Config.load()` live, threading `soft_limit` into `build_*`), `resolve_theme`, `main`. Import `themes` via `from statusline.themes import Theme, ModelColors, THEMES, CLAUDE_DARK`. Repoint `test_render_callable` to `statusline.app`. -- [x] 9.2 Rewrite `claude/statusline_command.py` to the thin entrypoint (`from statusline.app import main` + `if __name__ == '__main__': main()`); delete all extracted bands from it. -- [x] 9.3 Remove the import-time globals (`CONFIG`, `SOFT_LIMIT`, `MAX_WIDTH`) entirely; confirm nothing references them. -- [x] 9.4 Simplify `test/conftest.py`: remove the `spec_from_file_location` shim (pytest `pythonpath` now resolves `statusline.*`); keep `strip_ansi`/`_visible_width` helpers and the hooks header. -- [x] 9.5 Repoint `claude/mon.py`: `from statusline.app import render, resolve_theme` and `from statusline.constants import MIN_WIDTH`; drop its manual `sys.path.insert`. Confirm `test_mon_*` still pass. -- [x] 9.6 Repoint `test_themes` (and any remaining tests) to `from statusline.themes import ...` where they read `THEMES`/`CLAUDE_DARK`/`Theme`. - -## 10. Verification & docs (serial) - -- [x] 10.1 Orphan check: assert `statusline_command.py` defines no symbol that also lives in a `statusline` package module (no leftover duplicated bands). -- [x] 10.2 Cycle check: import every `statusline` module in isolation — no circular-import error; confirm the readers reference no render-layer symbol. -- [x] 10.3 Final `uv run pytest -q` — pass count ≥ baseline; `make statusline/test` — narrow/medium/wide all align. -- [x] 10.4 Runtime smoke: `COLUMNS=160 uv run python claude/statusline_command.py < claude/statusline/session-info-example.json` renders a box identical to pre-split. -- [x] 10.5 Add a "Module map" section to `CONTEXT.md` mapping each module to its canonical concept; add `mypy`/`ruff` pass over `claude/statusline/`. diff --git a/openspec/changes/archive/2026-06-01-task-checklist-timers/.openspec.yaml b/openspec/changes/archive/2026-06-01-task-checklist-timers/.openspec.yaml deleted file mode 100644 index fd706ac..0000000 --- a/openspec/changes/archive/2026-06-01-task-checklist-timers/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-26 diff --git a/openspec/changes/archive/2026-06-01-task-checklist-timers/design.md b/openspec/changes/archive/2026-06-01-task-checklist-timers/design.md deleted file mode 100644 index bc72b26..0000000 --- a/openspec/changes/archive/2026-06-01-task-checklist-timers/design.md +++ /dev/null @@ -1,117 +0,0 @@ -## Context - -The statusline is a stateless single-pass terminal painter (`claude/statusline_command.py`), layered `GradientEngine` → `BorderRenderer` → `Renderer`, with layouts assembled by `build_narrow` / `build_medium` / `build_wide` into a `LayoutSpec` of `RowSpec`s and walked by `render_layout`. Tasks today: - -- `Task` (~L1025): `id`, `subject`, `active_form`, `status` (`pending`/`in_progress`/`completed`). -- `TaskList.from_session` (~L1042): walks the session jsonl, folds `TaskCreate`/`TaskUpdate` `tool_use` items by id, keeps only `last_event_ts`. Accumulates **all** session tasks. -- `TaskList.is_visible` (~L1118): hidden `FRESHNESS_CAP` (120s) after `last_event_ts`; 20s grace once all-complete. -- `Renderer.task_row` (~L2201): returns a single `str` — glyph + `done/total`, plus (non-compact) the one active task's `active_form`. `build_medium` calls it `compact=True` (count only); `build_wide` non-compact; `build_narrow` does **not** render tasks at all. - -Sibling modules load via `importlib` because the script runs top-level, not as a package (the `themes.py` loader block, ~L19-26). `_visible_width` (L153) is the only correct column measure (ANSI-stripping, wide-char-aware); never `len()`. Nerd Font PUA glyphs must be hoisted to module-scope escape constants (the **PUA refactor rule**) or they get dropped through edit/chat round-trips. The repo already re-renders ~1s (`rainbow_step` is `int(time.time()) % …`), so a `now`-based live timer ticks at that cadence. - -**In-flight coupling:** `deepen-transcript-reader` adds `claude/statusline/transcript.py` with `fold_tasks(read_events(path)) -> TaskList` and intends to repoint `TaskList.from_session` to it. At apply time, the **live source** of the `TaskList` (either `from_session`'s body or `fold_tasks`) owns the new generation/timestamp logic. The spec is written against behaviour, so it binds whichever exists. - -## Goals / Non-Goals - -**Goals:** -- A live task checklist: all items of the current plan generation, marked off as they complete, each with a start-to-finish timer. -- Per-task timing with clear live-vs-frozen semantics; a header Total Elapsed. -- Bounded height (≤6 content rows, active-anchored window) so a long plan never overruns the terminal. -- A decomposition that **parallel subagents can execute** with minimal cross-task coupling. - -**Non-Goals:** -- No change to token/cost/context/subagent/openspec rows or colours beyond the task block. -- No persisted state; no smooth sub-render-cadence ticking. -- No new runtime dependency. - -## Decisions - -**D1 — Per-task timestamps; latest-run wins, clear on reopen.** -`Task` gains `started_at: float | None` and `completed_at: float | None`. On a `TaskUpdate` to `in_progress`, set `started_at` to that event's timestamp (overwrite) and clear `completed_at`. On a `TaskUpdate` to `completed`, set `completed_at`. Live duration = `now − started_at`; frozen = `completed_at − started_at`. No `started_at` ⇒ no duration. Rationale: a reopened task shows a fresh live timer; paused/resumed tasks aren't inflated by wall-clock; pending→completed jumps degrade gracefully. - -**D2 — Plan Generation = the latest all-completed-delimited batch.** -While folding, track whether **all** currently-known tasks are `completed`. A `TaskCreate` seen in that state opens a new generation (discard prior, restart ids at 1). A `TaskCreate` while any task is still open appends. `from_session` returns only the latest generation. Rationale: keeps `done/total` and Total Elapsed about *this* plan; the freshness cap used to mask stale rounds, but D5's pinning would otherwise resurface them. Chosen over a time-gap split (rejected in grilling: mis-splits slow planning). - -**D3 — Active-anchored window, ≤6 content rows including collapse lines.** -`select_window(tasks, budget=6)` returns the slice to render plus `done_hidden` / `more_hidden` counts. It keeps the `in_progress` item visible, biases toward upcoming pending, and counts any `+N done` / `+N more` collapse line against the budget (hard ceiling of 6 content rows). No active task ⇒ window the first pending items; all complete ⇒ window the last completed. Rationale: bounded height regardless of plan length; mirrors `mon.py` overflow clipping. - -**D4 — Live timer vs frozen duration; m:ss → h:mm:ss; right-aligned column.** -`fmt_duration(secs)` → `m:ss` (`0:07`, `12:04`), rolling to `h:mm:ss` past an hour. completed items show the frozen value dim; the in_progress item shows a bright live value; pending none. Timers right-align in a fixed trailing column (width = widest shown timer); subjects truncate with `…` before that column via `_visible_width`. Rationale: scannable alignment; reuses the existing truncation discipline in `task_row`. - -**D5 — Pinned visibility while any task is in_progress.** -`is_visible` returns true whenever a task is `in_progress`, regardless of `FRESHNESS_CAP`; otherwise the existing 120s cap + 20s all-complete grace apply. Rationale: a long step emits no new event, so the cap would hide the list and its live timer exactly when it matters. The live timer is itself proof of freshness. - -**D6 — Header carries glyph + done/total + Total Elapsed (wall-clock span).** -`total_elapsed(tasks, now)` = earliest `started_at` → (`now` while any task is `in_progress`, else latest `completed_at`); `None` when no task ever started. Rationale: "how long this plan has been going," consistent with the now-based per-item timer; chosen over sum-of-durations to avoid a second timing semantic. - -**D7 — Layout coverage: full list in wide + medium; compact line in narrow.** -Wide and medium render the header + windowed items. Narrow (no task info today) gains one compact line: glyph + `done/total` + the active task's live timer (omitted when nothing in progress), no subject. Rationale: narrow is width-constrained; the compact line still surfaces the timer. - -**D8 — State glyphs as hoisted module-scope constants.** -`GLYPH_TASK_PENDING = '\ue640'`, `GLYPH_TASK_ACTIVE = '\U000f0117'`, `GLYPH_TASK_DONE = '\uf4a7'`, alongside the existing `GLYPH_TASKS` header glyph. Checkbox set, dim/bright contrast (completed dim, active bright, pending dim). Rationale: the PUA refactor rule is mandatory; escapes survive edit/chat round-trips. - -**D9 — Pure view logic in `claude/statusline/tasks_view.py` (importlib-loaded).** -`fmt_duration`, `total_elapsed`, `select_window` are pure (no ANSI, no I/O) and live in a new sibling module mirroring the `themes.py` loader. `Renderer.task_row` composes ANSI/colour around their results. Rationale: isolates the testable maths, keeps `task_row` thin, and — critically — lets the parser, the helpers, the renderer, and the layout wiring be built and tested **independently and in parallel** (see Parallel Execution). - -**D10 — `task_row` returns `list[str]`.** -`Renderer.task_row(tasks, width, *, compact=False) -> list[str]`. Non-compact (wide/medium) returns header line + item lines + any collapse lines; compact (narrow) returns a single-element list. Builders iterate the result into `RowSpec('content', content=line)`. The task rows carry no internal `│` divider, so **no elbow / `ups` / `downs` threading** is required — they are plain content rows like subagent rows. Rationale: fixing the return type up front is the contract that lets the renderer (D9 consumer) and the layout wiring proceed in parallel. - -## Parallel Execution - -This change is explicitly decomposed for parallel subagents. One **Foundation** unit lands the shared contract; then four units run concurrently with disjoint file/region ownership; a final **Verify** unit serialises. - -``` - ┌─────────────────────────────────────────────┐ - F (Foundation) │ glyph consts + Task fields + tasks_view.py │ serial, first - ──────────────► │ stubs + importlib bind + task_row signature │ - └─────────────────────────────────────────────┘ - │ - ┌───────────────┬─────────────┴───────────────┬───────────────┐ - ▼ ▼ ▼ ▼ - A Parser B View helpers C Renderer D Layout wiring (parallel) - TaskList.* tasks_view.py bodies task_row body build_* - test_task_list test_tasks_view (new) test_task_row (+ test_layout_*) - └───────────────┴─────────────┬───────────────┴───────────────┘ - ▼ - V (Verify) pytest + demo + CONTEXT.md + validate serial, last - G (Docs: CONTEXT.md glossary) — independent, may run any time after F. -``` - -**Why these seams are independent:** -- **A** owns the `TaskList` class region of `statusline_command.py` and `test_task_list.py`. It implements D1/D2/D5. It does **not** touch `task_row` or builders. -- **B** owns `claude/statusline/tasks_view.py` (filling F's stubs) and a new `test_tasks_view.py`. Pure functions; touches no other file. Implements D3/D4/D6. -- **C** owns the `Renderer.task_row` method region and `test_task_row.py`. It imports B's helpers and F's glyph constants, and builds `TaskList`/`Task` fixtures **directly** (not via `from_session`), so it does not depend on A's implementation — only on F's `Task` field contract. Implements D4/D7/D8/D10. -- **D** owns `build_narrow` / `build_medium` / `build_wide` and any `test_layout_*`. It consumes `task_row`'s `list[str]` contract (D10) and emits `RowSpec`s; it can stub `task_row` to a fixed list in its own tests, so it does not block on C. -- **G** owns `CONTEXT.md` only. - -**Conflict-avoidance rules for subagents:** -- All top-of-file edits to `statusline_command.py` (glyph constants, the `tasks_view` importlib block, `Task` field additions) are done **only in F**. A/C/D never edit the import region or `Task` definition. -- A, C, D edit **disjoint function regions** of `statusline_command.py`; integrate in the order A→C→D if a sequential merge is needed, but they are developed concurrently. -- Each unit owns its own test file; no two units edit the same test file. - -**Foundation deliverables (the contract):** -- Glyph constants (D8) and the `Task` fields (D1) with defaults `= None`. -- `claude/statusline/tasks_view.py` with **signatures + docstrings** and trivial placeholder bodies for `fmt_duration`, `total_elapsed`, `select_window` (including the `select_window` return shape — e.g. a `WindowSlice(items, done_hidden, more_hidden)` dataclass), plus the `importlib` load+bind block in `statusline_command.py`. -- The `task_row(...) -> list[str]` signature (D10) committed (body may still return the placeholder single line until C). - -## Risks / Trade-offs - -- **Two task parsers drift** (`from_session` vs `deepen-transcript-reader`'s `fold_tasks`) → Mitigation: A implements against the live source at apply time; a Verify task asserts both carry the generation/timestamp semantics if both exist. -- **Window jitter** (height changes as items complete / collapse) → Accepted: bounded at 6 rows; the active-anchored window keeps the relevant slice stable. -- **Live timer appears frozen during a silent long tool call** → Accepted: identical to the existing rainbow animation; documented in proposal. -- **PUA glyphs dropped through edits** → Mitigation: D8 hoists them to escape constants in Foundation before any rendering edit. -- **Medium grows tall** (now full list vs old count) → Accepted: same 6-row ceiling as wide; narrow stays compact. -- **`select_window` off-by-one against the 6-row ceiling including collapse lines** → Mitigation: B's `test_tasks_view.py` asserts the total rendered-row count never exceeds budget across plan sizes and active positions. - -## Migration Plan - -1. **F** — land the contract (constants, `Task` fields, `tasks_view.py` stubs + importlib bind, `task_row` signature). `pytest -q` stays green (placeholders preserve behaviour). -2. **A / B / C / D** — concurrently implement parser, helpers, renderer, layout against the contract, each red→green in its own test file. -3. Integrate A→C→D edits to `statusline_command.py`; **G** updates `CONTEXT.md`. -4. **V** — full `pytest -q` green; `make statusline/test` eyeball across narrow→medium→wide; reconcile `fold_tasks` if present; `openspec validate task-checklist-timers`. - -Rollback: revert `tasks_view.py`, the `task_row`/builder edits, the `Task` fields and constants. No data/state migration — the feature is render-time only. - -## Open Questions - -- None blocking. If `deepen-transcript-reader` is applied first, A's parser work moves into `fold_tasks` rather than `from_session` (same behaviour, different location). diff --git a/openspec/changes/archive/2026-06-01-task-checklist-timers/proposal.md b/openspec/changes/archive/2026-06-01-task-checklist-timers/proposal.md deleted file mode 100644 index 08f3e2a..0000000 --- a/openspec/changes/archive/2026-06-01-task-checklist-timers/proposal.md +++ /dev/null @@ -1,40 +0,0 @@ -## Why - -Today the statusline shows tasks as a **single** line: a `done/total` count plus the one active task's text (`task_row`, ~L2201). You can't see what's already done, what's coming, or how long the current step has been running. `TaskList.from_session` (~L1042) also accumulates **every** `TaskCreate` in the session into one ever-growing list and throws away per-task transition timestamps — so there is no data for a duration, and the count drifts as stale rounds pile up. - -We want the task block to read like a live checklist: every item in the current plan, marked off one-by-one as it completes, each with a timer that starts when work begins on it and freezes at its final duration when done. - -## What Changes - -- **Per-task timestamps.** `Task` gains `started_at` / `completed_at`. `TaskList.from_session` stamps them from each `TaskUpdate`'s timestamp (latest-run wins; `completed_at` cleared on reopen). A `pending → completed` task that was never `in_progress` has no duration. -- **Plan generation scoping.** A `TaskCreate` that arrives after *all* existing tasks are `completed` starts a new generation; only the latest generation is rendered/counted. A `TaskCreate` while any task is still open appends to the current generation. This keeps `done/total` and the new total-elapsed about *this* plan, not the whole session. -- **Pinned visibility.** `is_visible` stays true while any task is `in_progress`, ignoring `FRESHNESS_CAP` — so a long-running step's live timer is never hidden. The 2-min cap + 20s all-complete grace still apply once nothing is in progress. -- **Full windowed checklist (wide + medium).** `task_row` renders a header (glyph + `done/total` + **Total Elapsed**) followed by an active-anchored window of item rows, capped at **6 content rows including** any `+N done` / `+N more` collapse lines. Each item: state glyph + subject (truncated) + a right-aligned **Task Timer** column. completed = frozen duration; in_progress = live; pending = none. -- **Compact row (narrow).** Narrow — which shows no task info today — gains a single compact line: glyph + `done/total` + the active task's live timer. -- **Pure view helpers** extracted to a new `claude/statusline/tasks_view.py` (importlib-loaded like `themes.py`): `fmt_duration`, `total_elapsed`, `select_window`. This isolates the testable maths from the ANSI/colour composition in `Renderer.task_row` and lets the work parallelise cleanly. - -Explicitly **not** included: -- No change to token/cost/context/subagent/openspec rows. -- No new persisted state; the statusline stays a stateless single-pass render. The live timer advances at the harness's existing ~1s refresh cadence (same assumption `rainbow_step` relies on) and freezes during a silent long tool call. - -## Capabilities - -### New Capabilities -- `task-checklist`: the rendered task checklist contract — plan-generation scoping, per-task timing (live vs frozen), Total Elapsed, the active-anchored 6-row window with collapse affordances, pinned-while-active visibility, and the wide/medium/narrow render variants. - -### Modified Capabilities - - -## Impact - -- `claude/statusline_command.py`: - - `Task` (~L1025): `+ started_at`, `+ completed_at`. - - `TaskList.from_session` (~L1042): timestamp capture + generation scoping. - - `TaskList.is_visible` (~L1118): pinned-while-`in_progress`. - - `Renderer.task_row` (~L2201): rewritten to return `list[str]` (header + windowed items + collapse lines), with a compact single-line variant. - - `build_narrow` / `build_medium` / `build_wide` (~L2537 / L2584 / L2643): emit the returned lines as `content` `RowSpec`s. - - New module-scope glyph constants `GLYPH_TASK_PENDING = '\ue640'`, `GLYPH_TASK_ACTIVE = '\U000f0117'`, `GLYPH_TASK_DONE = '\uf4a7'` (hoisted per the PUA rule); new `importlib` load+bind block for `tasks_view.py`. -- New `claude/statusline/tasks_view.py`: pure `fmt_duration`, `total_elapsed`, `select_window`. -- Tests: parser/visibility/generation in `test/test_task_list.py`; rendering in `test/test_task_row.py`; pure helpers in new `test/test_tasks_view.py`. -- `CONTEXT.md`: glossary entries for **Task Checklist**, **Plan Generation**, **Task Timer**, **Total Elapsed**, **Active Window**. -- **Coordination with `deepen-transcript-reader`** (in flight): that change adds `fold_tasks` in `claude/statusline/transcript.py` and will repoint `TaskList.from_session` to it. Whichever is the live source of the `TaskList` at apply time owns the generation/timestamp logic; the spec describes behaviour, not location. If `fold_tasks` exists, it must carry the same semantics. diff --git a/openspec/changes/archive/2026-06-01-task-checklist-timers/specs/task-checklist/spec.md b/openspec/changes/archive/2026-06-01-task-checklist-timers/specs/task-checklist/spec.md deleted file mode 100644 index ff6daba..0000000 --- a/openspec/changes/archive/2026-06-01-task-checklist-timers/specs/task-checklist/spec.md +++ /dev/null @@ -1,101 +0,0 @@ -## ADDED Requirements - -### Requirement: Plan Generation scoping - -The task parser SHALL render and count only the **latest plan generation**, not every task created in the session. A new generation SHALL begin when a `TaskCreate` event is folded while **all** currently-known tasks are `completed`; that event discards the prior generation and restarts task ids at `#1`. A `TaskCreate` folded while any task is still `pending` or `in_progress` SHALL append to the current generation. The resulting `done/total` count SHALL reflect only the latest generation. - -#### Scenario: New batch after completion starts a fresh generation -- **WHEN** every task in the list is `completed` and a later `TaskCreate` is folded -- **THEN** the prior tasks are dropped and the new task is `#1` of a fresh generation - -#### Scenario: Create while work is open appends to the current generation -- **WHEN** a `TaskCreate` is folded while at least one task is still `pending` or `in_progress` -- **THEN** the new task is appended to the current generation, keeping the existing tasks - -#### Scenario: Count reflects only the latest generation -- **WHEN** earlier completed generations exist before the current one -- **THEN** `done/total` counts only the tasks of the latest generation - -### Requirement: Per-task timing - -A **Task** SHALL carry `started_at` and `completed_at` (epoch seconds, or absent). On a `TaskUpdate` transitioning a task **to** `in_progress`, the parser SHALL set `started_at` to that event's timestamp and clear `completed_at`. On a `TaskUpdate` transitioning a task **to** `completed`, it SHALL set `completed_at`. A task's **Task Timer** SHALL read the live elapsed `now − started_at` while `in_progress`, the frozen duration `completed_at − started_at` while `completed`, and SHALL be absent for a `pending` task or any task that was never `in_progress`. - -#### Scenario: Timer starts when work begins -- **WHEN** a task transitions to `in_progress` -- **THEN** its `started_at` is the timestamp of that transition and its Task Timer counts up live from then - -#### Scenario: Timer freezes on completion -- **WHEN** a task that was `in_progress` transitions to `completed` -- **THEN** its Task Timer shows the frozen duration `completed_at − started_at` and no longer advances - -#### Scenario: Reopened task restarts its timer -- **WHEN** a `completed` task transitions back to `in_progress` -- **THEN** `started_at` is overwritten with the new timestamp, `completed_at` is cleared, and the live timer counts from the new start - -#### Scenario: Task that never started shows no duration -- **WHEN** a task goes `pending → completed` with no intervening `in_progress` -- **THEN** it has no `started_at` and renders no Task Timer - -### Requirement: Total Elapsed - -The checklist header SHALL show a **Total Elapsed** wall-clock span for the current generation: from the earliest task `started_at` to `now` while any task is `in_progress`, or to the latest `completed_at` once nothing is in progress. When no task in the generation has ever started, Total Elapsed SHALL be absent. - -#### Scenario: Total runs live while work is active -- **WHEN** any task in the generation is `in_progress` -- **THEN** Total Elapsed spans the earliest `started_at` to `now` and advances each render - -#### Scenario: Total freezes when the plan is done -- **WHEN** no task is `in_progress` -- **THEN** Total Elapsed spans the earliest `started_at` to the latest `completed_at` - -### Requirement: Active Window - -When rendered as a full list, the checklist SHALL show an **Active Window**: an active-anchored slice of at most **6 content rows, inclusive of any `+N done` / `+N more` collapse lines**. The window SHALL keep the `in_progress` item visible and bias toward upcoming `pending` items. Completed items above the window SHALL collapse into a `+N done` line and pending items below into a `+N more` line, each counting against the 6-row budget. With no `in_progress` task the window SHALL start at the first pending items; with all tasks completed it SHALL show the most recent completed items. - -#### Scenario: Long plan stays within the row budget -- **WHEN** the generation has more tasks than fit the budget -- **THEN** the rendered task rows (items plus any collapse lines) total at most 6, and the `in_progress` item is among them - -#### Scenario: Clipped completed and pending collapse into affordances -- **WHEN** completed items fall above the window or pending items below it -- **THEN** a `+N done` line and/or a `+N more` line represents the hidden counts, within the budget - -### Requirement: Pinned visibility while active - -The checklist SHALL remain visible while any task is `in_progress`, regardless of the freshness cap, so a long-running step's live timer is never hidden. When no task is `in_progress`, the existing freshness behaviour SHALL apply: hidden after the 120s cap since the last task event, with a 20s grace once all tasks are `completed`. - -#### Scenario: Long step keeps the list visible -- **WHEN** a task has been `in_progress` longer than the freshness cap with no new task event -- **THEN** the checklist stays visible and its live timer keeps counting - -#### Scenario: Finished plan still fades out -- **WHEN** all tasks are `completed` and the grace period elapses -- **THEN** the checklist is hidden - -### Requirement: Layout-specific rendering - -The checklist SHALL render the full header + Active Window in the **wide** and **medium** layouts. In the **narrow** layout it SHALL render a single compact line — the checklist glyph, `done/total`, and the active task's live timer when a task is `in_progress` — and no subject text. Each full-list item row SHALL show a state glyph (distinct constants for `pending`, `in_progress`, `completed`), the task subject truncated to fit, and a right-aligned Task Timer column; completed durations render dim, the in_progress live timer renders bright, pending rows show no timer. All column widths SHALL be measured with the visible-width helper, never `len()`. - -#### Scenario: Wide and medium show the full checklist -- **WHEN** a wide or medium layout is built and the checklist is visible -- **THEN** it renders the header (glyph + done/total + Total Elapsed) followed by the Active Window of item rows - -#### Scenario: Narrow shows the compact line -- **WHEN** a narrow layout is built and the checklist is visible -- **THEN** it renders one line of glyph + done/total + the active task's live timer (omitted if nothing is in_progress), with no per-item rows - -#### Scenario: Timers align in a trailing column -- **WHEN** multiple item rows carry timers -- **THEN** the timer values right-align in a fixed trailing column and subjects truncate with an ellipsis before that column - -### Requirement: Timer formatting - -A duration SHALL be formatted as `m:ss` (for example `0:07`, `12:04`) and SHALL roll over to `h:mm:ss` once it reaches one hour. The same formatting SHALL apply to per-task Task Timers and to Total Elapsed. - -#### Scenario: Sub-hour durations use m:ss -- **WHEN** a duration is under one hour -- **THEN** it renders as `m:ss` with a zero-padded seconds field - -#### Scenario: Durations of an hour or more use h:mm:ss -- **WHEN** a duration is one hour or more -- **THEN** it renders as `h:mm:ss` with zero-padded minutes and seconds diff --git a/openspec/changes/archive/2026-06-01-task-checklist-timers/tasks.md b/openspec/changes/archive/2026-06-01-task-checklist-timers/tasks.md deleted file mode 100644 index 7748cb3..0000000 --- a/openspec/changes/archive/2026-06-01-task-checklist-timers/tasks.md +++ /dev/null @@ -1,82 +0,0 @@ - - -## 1. Baseline (serial, before anything) - -- [x] 1.1 Run `uv run pytest -q` and record the pass count as the green baseline -- [x] 1.2 Run `make statusline/test` and eyeball the demo across narrow→medium→wide as the visual baseline - -## 2. Foundation [F] — serial, lands the contract first - -- [x] 2.1 Hoist state-glyph constants at module scope (per the PUA rule), alongside `GLYPH_TASKS`: `GLYPH_TASK_PENDING = '\ue640'`, `GLYPH_TASK_ACTIVE = '\U000f0117'`, `GLYPH_TASK_DONE = '\uf4a7'`, each with a `# nf-…` comment -- [x] 2.2 Add `started_at: float | None = None` and `completed_at: float | None = None` to the `Task` dataclass (~L1025); defaults preserve back-compat -- [x] 2.3 Create `claude/statusline/tasks_view.py` with **signatures + docstrings + placeholder bodies** for: `fmt_duration(secs: float) -> str`, `total_elapsed(tasks, now: float) -> float | None`, `select_window(tasks, budget: int = 6) -> WindowSlice`, and a `WindowSlice` dataclass (`items: list[Task]`, `done_hidden: int`, `more_hidden: int`). No ANSI, no I/O -- [x] 2.4 Add the `importlib` load+bind block for `tasks_view.py` in `statusline_command.py`, mirroring the `themes.py` loader (~L19-26); bind `fmt_duration`, `total_elapsed`, `select_window`, `WindowSlice` -- [x] 2.5 Change the `Renderer.task_row` signature to `task_row(self, tasks: TaskList, width: int, *, compact: bool = False) -> list[str]` returning the current single line wrapped in a one-element list; update the two call sites in `build_medium`/`build_wide` to iterate (`for line in r.task_row(...): rows.append(RowSpec('content', content=line))`) -- [x] 2.6 Run `uv run pytest -q` — still green (placeholders + list-wrapping preserve behaviour); adjust only the directly-affected task_row assertion if it asserted a `str` - -## 3. Parser [A] — parallel — TaskList only - -- [x] 3.1 In `test_task_list.py`, add timing tests: `TaskUpdate→in_progress` sets `started_at` and clears `completed_at`; `→completed` sets `completed_at`; reopen overwrites `started_at` + clears `completed_at`; `pending→completed` leaves `started_at` `None` -- [x] 3.2 Implement timestamp capture in the live `TaskList` source (`from_session`, or `fold_tasks` if `deepen-transcript-reader` has already repointed it) per D1 -- [x] 3.3 Add generation-scoping tests: `TaskCreate` while all-completed starts a fresh generation (ids restart at 1, prior dropped); `TaskCreate` while any task open appends; `total`/`completed` count only the latest generation -- [x] 3.4 Implement generation scoping per D2 -- [x] 3.5 Add visibility tests: `in_progress` keeps `is_visible()` true past `FRESHNESS_CAP`; with nothing in progress the 120s cap + 20s all-complete grace still apply -- [x] 3.6 Implement pinned visibility in `is_visible` per D5 -- [x] 3.7 `uv run pytest -q test/test_task_list.py` green - -## 4. View helpers [B] — parallel — pure, new module + new test file - -- [x] 4.1 In new `test_tasks_view.py`, add `fmt_duration` tests: `0:07`, `12:04`, zero-padded seconds, rollover to `h:mm:ss` at ≥3600s, `0:00` at 0 -- [x] 4.2 Implement `fmt_duration` per D4 -- [x] 4.3 Add `total_elapsed` tests: live span (earliest `started_at`→`now`) while any in_progress; frozen span (→latest `completed_at`) otherwise; `None` when nothing ever started -- [x] 4.4 Implement `total_elapsed` per D6 -- [x] 4.5 Add `select_window` tests: short plan returns all items, no collapse; long plan keeps the `in_progress` item and **total rows (items + `+N done`/`+N more`) ≤ budget** across active positions; no-active → first pendings; all-complete → last completeds; `done_hidden`/`more_hidden` counts correct -- [x] 4.6 Implement `select_window` per D3 -- [x] 4.7 `uv run pytest -q test/test_tasks_view.py` green - -## 5. Renderer [C] — parallel — Renderer.task_row only - -- [x] 5.1 In `test_task_row.py`, build `TaskList`/`Task` fixtures **directly** (not via `from_session`) and assert (via `strip_ansi`/`_visible_width`): full-list header line shows glyph + `done/total` + Total Elapsed; each item shows the correct state glyph; completed shows frozen duration, in_progress shows live, pending shows none; timers right-align; subjects truncate with `…` before the timer column; `+N done`/`+N more` lines appear when `select_window` reports hidden counts -- [x] 5.2 Implement the full-list branch of `task_row` (wide/medium) using `select_window`, `total_elapsed`, `fmt_duration`, and the §2.1 glyph constants; dim completed timers, bright the live one (D4/D7/D8/D10) -- [x] 5.3 Add narrow-compact tests: single line of glyph + `done/total` + active live timer; timer omitted when nothing in_progress; no subject -- [x] 5.4 Implement the `compact=True` branch returning a one-element list per D7 -- [x] 5.5 `uv run pytest -q test/test_task_row.py` green - -## 6. Layout wiring [D] — parallel — build_* only - -- [x] 6.1 Add/extend `test_layout_*` coverage: `build_wide` and `build_medium` emit one `content` `RowSpec` per line returned by `task_row` (header + items + collapse), gated on `tasks.is_visible()`; `build_narrow` emits the single compact line when visible (it renders no tasks today). Tests may stub `task_row` to a fixed `list[str]` -- [x] 6.2 Update `build_wide` (~L2643) and `build_medium` (~L2584) to iterate `task_row(tasks, width)` lines into `content` rows, preserving the seam/`sep_kind` threading already around the task block -- [x] 6.3 Add the compact task line to `build_narrow` (~L2537): when `tasks.is_visible()`, append `RowSpec('content', content=...)` from `task_row(tasks, width, compact=True)` and the surrounding `separator_dim`, matching the subagent-row pattern -- [x] 6.4 `uv run pytest -q test/test_layout_*.py` green - -## 7. Docs [G] — parallel after F — CONTEXT.md only - -- [x] 7.1 Add glossary entries to `CONTEXT.md`: **Task Checklist**, **Plan Generation**, **Task Timer** (live vs frozen), **Total Elapsed**, **Active Window** — names matching the implemented identifiers - -## 8. Verify [V] — serial, last - -- [x] 8.1 Integrate the A→C→D edits to `statusline_command.py` (disjoint regions; resolve any line-number-only overlaps) -- [x] 8.2 `uv run pytest -q` green — pass count ≥ baseline plus the new tests (§3–§6) -- [x] 8.3 `make statusline/test` — eyeball the checklist across narrow→medium→wide: counts, glyphs per state, live vs frozen timers, right-aligned timer column, `+N` collapse lines, and that the box stays ≤6 task content rows on a long plan -- [x] 8.4 If `deepen-transcript-reader` is also in flight, confirm `fold_tasks` and `TaskList.from_session` carry identical generation/timestamp semantics (no drift) -- [x] 8.5 Confirm `CONTEXT.md` glossary terms match the implemented names -- [x] 8.6 `npx @fission-ai/openspec validate task-checklist-timers` passes diff --git a/openspec/changes/archive/2026-06-02-add-cache-countdown/.openspec.yaml b/openspec/changes/archive/2026-06-02-add-cache-countdown/.openspec.yaml deleted file mode 100644 index a2168c3..0000000 --- a/openspec/changes/archive/2026-06-02-add-cache-countdown/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-02-add-cache-countdown/design.md b/openspec/changes/archive/2026-06-02-add-cache-countdown/design.md deleted file mode 100644 index 345b179..0000000 --- a/openspec/changes/archive/2026-06-02-add-cache-countdown/design.md +++ /dev/null @@ -1,57 +0,0 @@ -## Context - -The renderer is a layered, single-pass terminal painter. Derived session state is gathered once per render by `SessionView` (lazy `@cached_property` fields, one frozen `now`), and the wide layout is assembled by `build_wide` from `RowSpec`s with hand-tuned elbow/divider column math. Token usage already comes from `TranscriptUsage.from_transcript`, a single forward scan of the transcript jsonl that filters `"usage"` + `"assistant"` lines and sums token counts — but it currently ignores each line's `timestamp`. - -The reference implementation is a PowerShell statusline (rodboev gist) that scans the transcript backward for the last cache-bearing line, anchors a TTL countdown on its timestamp, persists the epoch to a per-session state file, and prints `cache ` coloured green→red by percent-of-TTL-elapsed. - -This design ports that behaviour onto the existing primitives, wide layout only. - -## Goals / Non-Goals - -**Goals:** -- Show time remaining until the prompt cache expires, as a vsep-delimited section on the wide path/model row. -- Reuse the single transcript scan, the frozen `now`, the `fill_colour` ladder, and `fmt_dur` — minimal new surface. -- Keep `SessionView` pure (raw data, no ANSI/geometry); keep all width/elbow math in `build_wide`/renderer. -- Hide cleanly (section + divider) on expiry, no-event, and width pressure. - -**Non-Goals:** -- Medium/narrow rendering (unchanged). -- A persisted cache-state file (we re-derive every render). -- Tracking cache state for sessions other than the one being rendered (the multi-session observer is out of scope here). -- Changing how `Cache Read` token counts are displayed in the tokens row. - -## Decisions - -**Anchor = transcript timestamp of the last cache-bearing line.** Alternatives: (a) transcript file mtime — cheapest, but moves on any write, not just cache events, so it drifts; (b) the `TokenRate` activity log's newest epoch — already epoch-stamped, but keyed on token-delta growth, not cache events. Chosen the transcript timestamp because it is the actual cache-write wall-clock time; the other two are proxies that misfire. A cache *read* (`cache_read_input_tokens > 0`) counts as activity because reading the cache refreshes its TTL, so every cached turn re-anchors the countdown. - -**TTL tier detected per anchor line.** `3600` s when `cache_creation.ephemeral_1h_input_tokens > 0`, else `300` s. Alternative: hardcode 300 s (simplest) or a config knob. Chosen per-line detection to match real behaviour for 1h-ephemeral users without configuration; the TTL becomes part of the raw anchor data, not a constant. - -**Hybrid reader/derivation split.** Raw anchor (`cache_anchor_epoch`, `cache_ttl`) lives on `TranscriptUsage`, populated in the existing single scan (retain the most-recent cache line's raw timestamp string, parse once after the loop via the `session` ISO helper). The `now`-relative math (`remaining`, `elapsed_pct`) lives on `SessionView.cache_countdown`. Rationale: the file-touching half can only be produced during the scan and cannot be peeled into a standalone property without a second read (which would break the "scan once" invariant); the time math depends on `now`, which `TranscriptUsage` neither has nor should have. So the split is raw-extraction vs now-relative-derivation, not reader-vs-reader. - -**No persisted state file.** The gist writes `.sl_cache_` to survive render ticks. We re-derive the anchor from the transcript every render and compute against the fresh frozen `now`, exactly as `elapsed` and the Task Timers already do — so the state file is unnecessary. One fewer disk artifact and no stale-state class of bug. - -**Placement: own vsep section on the path/model row, one left divider, before the model section.** Lands in the existing `pad` gap between the rate-limit helper (`helper_text`) and the flush-right model section/pill (`right_text`). Alternatives: append to the tokens-row cluster (adjacent to `Cache Read`), a standalone row, the model-row helper suffix like the rate limits, or the context row. Chosen the path/model row per the design owner; it co-locates the cache time with the other live per-turn stats and avoids spending a whole vertical line. - -**Colour via `fill_colour(elapsed_pct)`.** Reuses the theme safe/warn/alert ladder (same as the rate-limit percentages and Compaction-Risk Zone) rather than introducing the gist's parallel 4-stop ramp or a continuous gradient. `elapsed_pct = 100 − round(remaining·100/ttl)` rises as the cache ages, and `fill_colour` reds-out at high pct, so fresh = green, near-expiry = red. - -**Format via `fmt_dur`.** Already emits `42s` / `3m07s` / `1h05m` — the exact gist format — so no new formatter. - -**Glyph `GLYPH_CACHE = ''` (nf-oct-cache).** Hoisted to `constants.py` as a named escape per the PUA rule (literal PUA bytes get dropped through edit/chat round-trips). - -**Data shape: view returns `(remaining, elapsed_pct)` or `None`; `build_wide` owns width-shed.** `None` covers the two gist hide cases (no anchor, expired). The third hide case (width pressure) is a render concern that needs the section's visible width, so it lives in `build_wide`, which sheds the Cache Countdown first (before truncating the path). - -## Risks / Trade-offs - -- **Elbow threading next to the flush-right pill** → This is the trickiest column math in the renderer (pill border-math + a new adjacent divider). Mitigation: thread exactly one new elbow column into `top_border.downs` / `separator_dim.ups`, subtract the section's visible width from `target_w`/`pad`, and verify with `make demo` across wide widths both with and without the thinking pill. -- **Divider must drop-and-rethread on three independent hide conditions** → A stale elbow with no `│` beneath draws a crooked box. Mitigation: compute section presence once in `build_wide`, derive the row's `downs`/`ups` from that single boolean, and add a test asserting only the path elbow remains when hidden. -- **Width-shed threshold is fuzzy** → Too eager hides a fitting countdown; too lax shoves the pill. Mitigation: derive the threshold from the section's own visible width plus a 1-column minimum gap to the model section; cover with a width-boundary test. -- **`elapsed_pct` rounding at the edges** → `round()` can yield `0` or `100` at the boundaries; ensure colour-band lookups are clamped to `[0, 100]`. -- **Timestamp parse failures** → Malformed or missing `timestamp` on the anchor line. Mitigation: treat parse failure as "no anchor" (`cache_anchor_epoch = 0.0` → `cache_countdown = None`), never raise into the render. - -## Migration Plan - -Additive and wide-only; medium/narrow and all token-accounting outputs are byte-for-byte unchanged. No data migration, no new dependency, no config. Rollback is reverting the change — there is no persisted state to clean up. - -## Open Questions - -- Exact width-shed threshold (column count) — to be pinned during implementation from the section's measured width plus a 1-column gap; not expected to need design-level resolution. diff --git a/openspec/changes/archive/2026-06-02-add-cache-countdown/proposal.md b/openspec/changes/archive/2026-06-02-add-cache-countdown/proposal.md deleted file mode 100644 index 4e97f92..0000000 --- a/openspec/changes/archive/2026-06-02-add-cache-countdown/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -The prompt cache has a short TTL (5 minutes by default, 1 hour on the ephemeral-1h tier), and once it lapses the next turn pays full input price instead of the 0.1× cache-read rate. Nothing on the statusline tells the user how long they have before that happens, so a pause that silently crosses the expiry boundary is invisible until the cost shows up. A live **Cache Countdown** makes the remaining cache window observable at a glance. - -## What Changes - -- Add a **Cache Countdown** section to the **wide** layout's path/model row: ` ` (e.g. `3m07s`, `42s`) in its own vsep-delimited section between the rate-limit helper and the model pill, with a single left divider; the model pill stays flush-right. -- Derive the anchor from the transcript: the `timestamp` of the most recent line that touched the prompt cache (`cache_read_input_tokens > 0` or `cache_creation_input_tokens > 0`). `remaining = ttl − (now − anchor)`, re-derived every render against the frozen `now` — **no per-session state file**. -- Detect the TTL tier per anchor line: 300 s default, 3600 s when `cache_creation.ephemeral_1h_input_tokens > 0`. -- Colour the figure by `fill_colour(elapsed_pct)` (theme safe/warn/alert), where `elapsed_pct = 100 − round(remaining·100/ttl)` — green when fresh → red near expiry. -- Hide the whole section (divider included) when there has never been a cache event, when `remaining ≤ 0` (expired), or under width pressure — in which case it sheds **first**, before the path truncates. -- Extend the gather layer: `TranscriptUsage` carries the raw cache anchor; `SessionView` exposes a derived `cache_countdown`. -- Medium and narrow layouts are unchanged. Not a breaking change. - -## Capabilities - -### New Capabilities -- `cache-countdown`: deriving the prompt-cache expiry countdown (transcript anchor extraction, TTL-tier detection, remaining/elapsed-pct math) and rendering it as a width-shed-able, vsep-delimited section on the wide path/model row. - -### Modified Capabilities -- `statusline-info`: `SessionView`'s enumerated derived-field set gains a lazily-evaluated `cache_countdown`, and `TranscriptUsage` gains raw cache-anchor fields populated in its existing single transcript scan. - -## Impact - -- **Code**: `claude/yas/constants.py` (glyph + TTL constants), `claude/yas/info/transcript.py` (`TranscriptUsage` anchor fields), `claude/yas/info/__init__.py` (`SessionView.cache_countdown`), `claude/yas/renderer.py` (cache-section helper), `claude/yas/layout.py` (`build_wide` section insertion, elbow threading, width-shed). -- **Docs**: `CONTEXT.md` glossary already carries the **Cache Countdown** / **Cache TTL** terms. -- **Tests**: `test/` additions for anchor extraction, countdown math, divider drop-and-rethread, and width-shed. -- **Dependencies / APIs**: none added; reads only existing transcript fields and the frozen-`now` clock. diff --git a/openspec/changes/archive/2026-06-02-add-cache-countdown/specs/cache-countdown/spec.md b/openspec/changes/archive/2026-06-02-add-cache-countdown/specs/cache-countdown/spec.md deleted file mode 100644 index 9d34f5d..0000000 --- a/openspec/changes/archive/2026-06-02-add-cache-countdown/specs/cache-countdown/spec.md +++ /dev/null @@ -1,100 +0,0 @@ -## ADDED Requirements - -### Requirement: Prompt-cache anchor extraction from the transcript - -`TranscriptUsage` SHALL capture, during its existing single forward scan of the transcript jsonl, the wall-clock `timestamp` of the most recent line whose `message.usage` shows prompt-cache activity — that is, `cache_read_input_tokens > 0` OR `cache_creation_input_tokens > 0`. It SHALL expose this as a raw `cache_anchor_epoch` (Unix seconds, `0.0` when no such line exists). The scan SHALL NOT add a second pass over the file; it SHALL retain the most-recent matching line's raw timestamp string and convert it to epoch exactly once, after the scan completes, using the existing `session` ISO-to-epoch helper. - -#### Scenario: Anchor is the latest cache-bearing line - -- **WHEN** a transcript contains several assistant lines with cache activity at increasing timestamps followed by a non-cache line -- **THEN** `cache_anchor_epoch` equals the epoch of the LAST cache-bearing line, not the last line overall - -#### Scenario: No cache activity yields a zero anchor - -- **WHEN** a transcript contains no line with `cache_read_input_tokens > 0` or `cache_creation_input_tokens > 0` -- **THEN** `cache_anchor_epoch` is `0.0` - -#### Scenario: The transcript is still scanned exactly once - -- **WHEN** `cache_anchor_epoch` and the token-usage totals are both read from one `TranscriptUsage` -- **THEN** the transcript file is read a single time (the anchor is captured in the same pass that sums tokens) - -### Requirement: Cache TTL tier detection - -`TranscriptUsage` SHALL expose a raw `cache_ttl` (seconds) taken from the same anchor line as `cache_anchor_epoch`. The TTL SHALL be `3600` when that line reports `cache_creation.ephemeral_1h_input_tokens > 0`, and `300` otherwise. When there is no anchor line, `cache_ttl` SHALL be `0`. The 300 s and 3600 s values SHALL be named constants in `constants.py` (`CACHE_TTL_SECONDS`, `CACHE_TTL_1H_SECONDS`). - -#### Scenario: One-hour ephemeral tier - -- **WHEN** the anchor line's usage contains `cache_creation.ephemeral_1h_input_tokens > 0` -- **THEN** `cache_ttl` is `3600` - -#### Scenario: Default five-minute tier - -- **WHEN** the anchor line has cache activity but no `ephemeral_1h_input_tokens` -- **THEN** `cache_ttl` is `300` - -### Requirement: Cache Countdown derivation on SessionView - -`SessionView` SHALL expose a lazily-evaluated, cached `cache_countdown` derived from `transcript_usage`'s raw anchor and the view's single frozen `now`. It SHALL compute `remaining = cache_ttl − (now − cache_anchor_epoch)` and `elapsed_pct = 100 − round(remaining · 100 / cache_ttl)`, returning the pair `(remaining, elapsed_pct)`. `cache_countdown` SHALL be `None` when there is no anchor (`cache_anchor_epoch == 0.0` or `cache_ttl == 0`) OR when `remaining <= 0` (expired). `cache_countdown` SHALL hold no ANSI, formatting, or render geometry. - -#### Scenario: Fresh cache reports remaining time - -- **WHEN** the anchor was 90 s before `now` on the 300 s tier -- **THEN** `cache_countdown` is non-`None` with `remaining == 210` and `elapsed_pct == 30` - -#### Scenario: Expired cache is None - -- **WHEN** the anchor was 400 s before `now` on the 300 s tier -- **THEN** `cache_countdown` is `None` - -#### Scenario: No cache event is None - -- **WHEN** `cache_anchor_epoch` is `0.0` -- **THEN** `cache_countdown` is `None` - -#### Scenario: Derivation uses the frozen clock - -- **WHEN** `cache_countdown` is read on a `SessionView` constructed with an explicit `now` -- **THEN** the remaining time is computed against that single `now`, not a fresh wall-clock read - -### Requirement: Cache Countdown rendering on the wide path/model row - -The wide layout SHALL render the **Cache Countdown** as its own vsep-delimited section on the path/model content row, positioned between the rate-limit helper and the model section, with a single left divider `│`; the model section (plain text or flush-right pill) SHALL remain flush to the right edge. The section SHALL display the cache glyph (`GLYPH_CACHE`, nf-oct-cache ``) followed by the remaining time formatted by `fmt_dur` (e.g. `42s`, `3m07s`, `1h05m`). The remaining-time figure SHALL be coloured by `fill_colour(elapsed_pct)` so it runs green when fresh and red near expiry. The new divider SHALL be threaded as an elbow column into the row's top border (`downs`) and following separator (`ups`) so the `┬`/`│`/`┴` stay aligned. Medium and narrow layouts SHALL NOT render the Cache Countdown. - -#### Scenario: Section renders with glyph, value, and divider - -- **WHEN** a wide render has a live `cache_countdown` of `(187, 38)` -- **THEN** the path/model row contains a `│`-delimited section showing the cache glyph and `3m07s`, and that divider's column appears in the top border `downs` and the following separator `ups` - -#### Scenario: Colour tracks elapsed percentage - -- **WHEN** `elapsed_pct` crosses from the safe band into the alert band -- **THEN** the remaining-time figure's colour changes from the theme safe colour to the theme alert colour (the same ladder as the rate-limit percentages) - -#### Scenario: Narrow and medium omit it - -- **WHEN** the same session renders at narrow and medium widths -- **THEN** no Cache Countdown section appears in either layout - -### Requirement: Cache Countdown visibility and width-shed - -The wide layout SHALL omit the Cache Countdown section AND its divider when `view.cache_countdown` is `None` (no cache event or expired), re-threading the row's elbows back to only the path divider. Independently, when the row cannot fit the path, rate-limit helper, Cache Countdown, and model section together, the Cache Countdown SHALL be shed FIRST — before the path is truncated further — and its divider dropped and re-threaded. When the Cache Countdown is shed for width, the path SHALL keep its normal truncation behaviour as if the section were never present. - -#### Scenario: Hidden when expired drops the divider - -- **WHEN** `view.cache_countdown` is `None` -- **THEN** the path/model row renders with only the path divider, and the top border / separator carry only the path elbow column (no cache elbow) - -#### Scenario: Shed first under width pressure - -- **WHEN** the row is too narrow to hold path + helper + Cache Countdown + model section, but wide enough for path + helper + model section -- **THEN** the Cache Countdown section and its divider are dropped, and the path renders without extra truncation caused by the section - -### Requirement: Re-derived per render with no persisted state - -The Cache Countdown SHALL be recomputed from the transcript and the frozen `now` on every render. The implementation SHALL NOT write, read, or depend on any per-session cache-state file; the anchor's sole source SHALL be the transcript scan already performed for token accounting. - -#### Scenario: No cache-state file is created - -- **WHEN** a wide render computes and displays the Cache Countdown -- **THEN** no per-session cache-state file is written under the Claude config directory diff --git a/openspec/changes/archive/2026-06-02-add-cache-countdown/specs/statusline-info/spec.md b/openspec/changes/archive/2026-06-02-add-cache-countdown/specs/statusline-info/spec.md deleted file mode 100644 index be92a2c..0000000 --- a/openspec/changes/archive/2026-06-02-add-cache-countdown/specs/statusline-info/spec.md +++ /dev/null @@ -1,25 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Lazy pure-read SessionView gather - -The statusline SHALL gather all *derived* session state through a single `SessionView` module (`claude/statusline/info.py`), constructed once per render from a parsed `SessionInfo` plus a `Config`. `SessionView` SHALL expose the derived state as lazily-evaluated, cached fields: `git`, `skills`, `subagents`, `tasks`, `transcript_usage`, `changes` (OpenSpec changes), `elapsed`, `session_cost`, `session_inout`, and `cache_countdown`. A field SHALL read its underlying source on first access and cache the result; a second access SHALL NOT re-read. Constructing a `SessionView` SHALL perform no source reads. `SessionView` SHALL perform no disk writes and SHALL NOT call `TokenLog.update` or `TokenRate.update`. The `cache_countdown` field SHALL be derived from `transcript_usage`'s raw cache anchor and the view's single frozen `now`, reusing the already-cached transcript scan rather than re-reading the transcript. - -#### Scenario: A narrow render reads only what it draws - -- **WHEN** a `SessionView` is constructed and a narrow-width build reads only `view.subagents` -- **THEN** the git subprocess, the transcript scan, and the openspec walk are not triggered (only the subagent source is read) - -#### Scenario: A field is read at most once per view - -- **WHEN** `view.session_inout` and `view.transcript_usage` are both accessed on one `SessionView` -- **THEN** the transcript is scanned exactly once (the cached value feeds both) - -#### Scenario: Cache countdown reuses the cached transcript scan - -- **WHEN** `view.transcript_usage` and `view.cache_countdown` are both accessed on one `SessionView` -- **THEN** the transcript is scanned exactly once (the cached usage feeds both, and `cache_countdown` triggers no additional read) - -#### Scenario: Constructing a view writes nothing - -- **WHEN** a `SessionView` is constructed and any subset of its fields is accessed -- **THEN** no token-log or token-rate file is written by the view diff --git a/openspec/changes/archive/2026-06-02-add-cache-countdown/tasks.md b/openspec/changes/archive/2026-06-02-add-cache-countdown/tasks.md deleted file mode 100644 index 9a55c91..0000000 --- a/openspec/changes/archive/2026-06-02-add-cache-countdown/tasks.md +++ /dev/null @@ -1,76 +0,0 @@ - - -## 1. Constants (foundational — do first, unblocks 2 & 4) - -- [x] 1.1 Add `GLYPH_CACHE = '' # nf-oct-cache` to the PUA glyph block in `claude/yas/constants.py`, encoded as a named escape per the PUA hoist rule -- [x] 1.2 Add `CACHE_TTL_SECONDS = 300` and `CACHE_TTL_1H_SECONDS = 3600` alongside the existing rate-limit window constants in `claude/yas/constants.py` - -## 2. Transcript anchor extraction (needs Group 1; parallel with Group 4) - -- [x] 2.1 Add raw fields `cache_anchor_epoch: float` (default `0.0`) and `cache_ttl: int` (default `0`) to `TranscriptUsage` in `claude/yas/info/transcript.py` -- [x] 2.2 In the existing single forward scan, retain the most-recent line whose usage has `cache_read_input_tokens > 0` or `cache_creation_input_tokens > 0` — keep its raw `timestamp` string and its 1h-tier flag (`cache_creation.ephemeral_1h_input_tokens > 0`); do NOT add a second pass -- [x] 2.3 After the loop, convert the retained timestamp string to epoch exactly once via the `session` ISO-to-epoch helper; on missing/malformed timestamp leave `cache_anchor_epoch = 0.0`. Set `cache_ttl` to `CACHE_TTL_1H_SECONDS` when the 1h flag is set, `CACHE_TTL_SECONDS` when there is an anchor without it, and `0` when there is no anchor - -## 3. SessionView derivation (needs Group 2) - -- [x] 3.1 Add a lazy `@cached_property cache_countdown` to `SessionView` in `claude/yas/info/__init__.py` reading `self.transcript_usage`'s raw anchor and the frozen `self.now` -- [x] 3.2 Compute `remaining = cache_ttl - (now - cache_anchor_epoch)` and `elapsed_pct = clamp(100 - round(remaining * 100 / cache_ttl), 0, 100)`; return `(remaining, elapsed_pct)` or `None` when there is no anchor (`cache_anchor_epoch == 0.0` or `cache_ttl == 0`) or `remaining <= 0`. Hold no ANSI/geometry - -## 4. Renderer section helper (needs Group 1; parallel with Groups 2 & 3) - -- [x] 4.1 Add a cache-section helper to `Renderer` in `claude/yas/renderer.py` that takes `(remaining, elapsed_pct)` and returns `(text, visible_width)`, rendering `GLYPH_CACHE` + a space + `fmt_dur(remaining)` with the value tinted by `self.fill_colour(elapsed_pct)` -- [x] 4.2 Compute the returned width with `_visible_width` (not `len`), counting `GLYPH_CACHE` as width 1 - -## 5. build_wide integration & elbow threading (needs Groups 1–4; single owner) - -- [x] 5.1 In `build_wide` (`claude/yas/layout.py`), read `view.cache_countdown`; when present, build the cache section and insert it as a vsep-delimited block in the `pad` gap between `helper_text` and the model section, with a single left divider `│` -- [x] 5.2 Thread the new divider's 1-indexed column as an elbow into the path/model `top_border.downs` and the following `separator_dim.ups`, keeping the path divider elbow; verify `┬`/`│`/`┴` alignment -- [x] 5.3 Subtract the cache section's visible width (divider + glyph + value) from `target_w` and the `pad` computation so the model section/pill stays flush-right and `fit_path` truncates correctly -- [x] 5.4 Implement width-shed: when the row cannot fit path + helper + cache + model section, drop the cache section AND its divider FIRST (before truncating the path further) and re-thread elbows to only the path divider -- [x] 5.5 Ensure the hidden case (`cache_countdown is None`) also drops the divider and re-threads to only the path elbow — derive `downs`/`ups` from a single "section present" boolean so border math can never reference a missing `│` -- [x] 5.6 Confirm medium and narrow builders are untouched (no Cache Countdown there) - -## 6. Tests - -- [x] 6.1 (parallel w/ Group 2/3) `test_info.py`: countdown math — fresh (`90s`/`300s` → remaining 210, elapsed_pct 30), near-expiry colour band, expired → `None`, no-event → `None`, 1h tier, and frozen-`now` usage -- [x] 6.2 (parallel w/ Group 2) transcript test: `cache_anchor_epoch`/`cache_ttl` extraction — latest cache line wins, no-cache → `0.0`/`0`, 1h tier → `3600`, malformed timestamp → `0.0` -- [x] 6.3 (parallel w/ Group 2/3) `test_info.py`: assert the transcript is scanned exactly once when both `transcript_usage` and `cache_countdown` are read -- [x] 6.4 (needs Group 5) `test_layout_seam.py`: section renders with glyph + value + divider; divider column appears in top-border `downs` and separator `ups`; inject a `SessionView` with a known countdown -- [x] 6.5 (needs Group 5) `test_layout_seam.py`: divider drops and elbows re-thread to only the path divider when `cache_countdown is None`; width-shed drops the section first without extra path truncation; narrow/medium render no section - -## 7. Verification (last; needs everything) - -- [x] 7.1 Run `make test` — green, pass count = baseline + new tests -- [x] 7.2 Run the PUA catalogue check on touched files; confirm `GLYPH_CACHE` is referenced as the named constant, never a raw glyph -- [x] 7.3 Run `make demo` — eyeball the path/model row elbow alignment across wide widths, WITH and WITHOUT the thinking pill, and through the width-shed boundary -- [x] 7.4 Confirm `CONTEXT.md` Cache Countdown / Cache TTL glossary entry matches the shipped behaviour (labels, glyph, colour, hide rules) diff --git a/openspec/changes/archive/2026-06-02-add-community-themes/.openspec.yaml b/openspec/changes/archive/2026-06-02-add-community-themes/.openspec.yaml deleted file mode 100644 index db47328..0000000 --- a/openspec/changes/archive/2026-06-02-add-community-themes/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-02 diff --git a/openspec/changes/archive/2026-06-02-add-community-themes/design.md b/openspec/changes/archive/2026-06-02-add-community-themes/design.md deleted file mode 100644 index 3b54b04..0000000 --- a/openspec/changes/archive/2026-06-02-add-community-themes/design.md +++ /dev/null @@ -1,119 +0,0 @@ -## Context - -The statusline currently ships 4 hardcoded themes. Users request more options aligned with popular community color schemes. The alacritty-theme repo (400+ themes) is well-maintained, canonical, and provides tested palettes. - -A prior grill-me session evaluated three storage approaches: -- **TOML loading**: 449.9 µs/render (too slow) -- **Pickle caching**: 22.8 µs/render (500× slower than hardcoded, added complexity) -- **Hardcoded**: 41.6 µs/render (baseline, zero latency) - -Decision: **hardcode themes as Python dataclasses** for zero latency impact on every statusline render. - -## Goals / Non-Goals - -**Goals:** -- Add 11 community themes (dracula, gruvbox-dark/light, nord, one-dark/light, solarized-dark/light, tokyo-night, palenight) -- Maintain 2 custom themes (claude-dark, claude-light) as branded defaults -- Build a repeatable converter script for future theme updates -- All themes hardcoded in `claude/yas/themes.py` -- Zero render-time latency impact - -**Non-Goals:** -- Dynamic TOML/YAML theme loading (rejected due to latency) -- User-supplied custom themes via config file (future phase, not phase 1) -- Automatic syncing with upstream alacritty-theme repo -- Per-model theme customization (model pills use fixed derivation) - -## Decisions - -### Decision: Converter script (ops/extract_themes.py) - -**Rationale**: Manual hand-porting 11 themes is slow and error-prone. A script: -- Reads alacritty YAML (already cloned locally by user) -- Extracts color values -- Maps to statusline field names -- Generates Python code automatically - -**Alternatives considered**: -- Hand-port each theme manually (rejected: tedious, hard to update) -- Runtime YAML parsing (rejected: latency, dependency) - -### Decision: Fixed color mapping (alacritty slots → statusline fields) - -Alacritty defines colors in `colors.normal.*` and `colors.bright.*` slots. Mapping: -- `colors.normal.black` → `bar_empty`, `ctx_dim` (dims/backgrounds) -- `colors.normal.red` → `alert`, `dirty` -- `colors.normal.green` → `safe`, `branch`, `bar_fill` -- `colors.normal.yellow` → `warn`, `yellow` -- `colors.normal.blue` → `pwd`, `model` -- `colors.normal.cyan` → `ctx`, `tok`, `tok_day` -- `colors.bright.red` → `cost` -- `colors.bright.yellow` → `tok_arrow`, `tok_icon` -- `colors.bright.white` → `white_brt` - -**Rationale**: Sensible defaults that respect semantic meaning (green=safe, red=alert, blue=pwd, etc.). All 11 themes use the same mapping, ensuring consistency. - -**Trade-off**: Some themes may have mis-mapped colors. Mitigation: visual demo loop validates each theme; user hand-tweaks only themes that look wrong. - -### Decision: Model pill colors via algorithmic derivation - -Model pills need 4 colors per model (opus/sonnet/haiku/other). Rather than hardcode generics, **derive from the theme's color palette**: -- Extract 8 brightest/most-saturated colors from alacritty palette -- Assign opus → yellow family, sonnet → green family, haiku → blue family, other → magenta family -- For each family, pick anchor (primary), warm_shift (warm hue), cool_shift (cool hue) - -**Rationale**: Ensures pill colors harmonize with the theme. Each theme gets visually coherent model indicators. - -**Script logic**: -``` -1. Read alacritty colors -2. Group by hue family (reds, greens, blues, magentas) -3. Pick brightest from each family -4. Assign to models: opus=yellow, sonnet=green, haiku=blue, other=magenta -5. Generate anchor, warm_shift, cool_shift by shifting hues -6. Output to Python code -``` - -**Validation**: User reviews in `make demo` and hand-tweaks if needed. - -### Decision: Gradient and sparkline colors - -For `grad_stops` (rainbow border) and `spec_gradients` (spec-level sparklines), **generate sensible defaults**: -- Use theme's primary colors (bright, saturated) to build a gradient -- Fallback: derive from model pill colors - -**Validation**: Visual check in demo; hand-tweak if the gradient doesn't match the theme's aesthetic. - -### Decision: Remove catppuccin variants, replace with canonical versions - -Current themes include `catppuccin-latte` and `catppuccin-mocha` (hand-tuned). Alacritty-theme provides official Catppuccin variants. **Replace with upstream versions** to reduce maintenance. - -## Risks / Trade-offs - -**[Risk] Alacritty theme color interpretation** → **Mitigation**: Fixed mapping is sensible but imperfect. User visually validates all 13 themes with `make demo` and hand-tweaks model/gradient colors for any themes that don't look right. - -**[Risk] Script is one-time-use / not maintained** → **Mitigation**: Document the script in `ops/README.md` with examples. If new themes are added later, rerun the script. - -**[Risk] Model pill derivation produces clashing colors** → **Mitigation**: Script generates reasonable defaults; user adjusts in `themes.py` if needed. Demo catches these issues. - -**[Risk] Gradient colors don't harmonize with theme** → **Mitigation**: Gradients generated from theme's own colors, so they're inherently coherent. User tweaks only if demo reveals issues. - -## Migration Plan - -1. Write `ops/extract_themes.py` converter script -2. Run script on user's local alacritty-theme clone → generates Python code appending to `themes.py` -3. Review generated code: spot-check color values -4. Append generated themes to `themes.py` and remove catppuccin variants -5. Run `make demo` for all 13 themes, visually validate -6. Hand-tweak model/gradient colors for any themes that look off -7. Run `make test` to ensure no regressions -8. Single PR: "Add 11 community themes from alacritty-theme" - -## Open Questions - -- **What's the exact alacritty-theme repo path?** (User provides or we auto-detect?) - - **Decision**: User must provide path (already cloned locally) -- **How to handle themes with missing colors in alacritty YAML?** (Some themes may have sparse color definitions) - - **Mitigation**: Script validates and reports missing colors; user fixes or skips theme -- **Which alacritty-theme commit SHA to reference?** (For traceability and future updates) - - **Decision**: Document in commit message the alacritty-theme ref used diff --git a/openspec/changes/archive/2026-06-02-add-community-themes/proposal.md b/openspec/changes/archive/2026-06-02-add-community-themes/proposal.md deleted file mode 100644 index 098ea82..0000000 --- a/openspec/changes/archive/2026-06-02-add-community-themes/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Why - -Users want more color theme options beyond the current 4 hardcoded themes. Popular color schemes (Dracula, Gruvbox, Nord, Solarized, etc.) from the community alacritty-theme repo provide well-tested palettes that work across many terminal applications. Adding these increases accessibility and user satisfaction with minimal maintenance burden. - -## What Changes - -- Extract 11 community-sourced themes from [alacritty-theme](https://github.com/alacritty/alacritty-theme) repo using a converter script -- Hardcode these themes as `Theme` dataclass instances in `claude/yas/themes.py` (benchmarks show hardcoded is ~500× faster than TOML loading with pickle caching) -- Maintain 2 custom themes: `claude-dark` and `claude-light` (branded defaults) -- Remove `catppuccin-latte` and `catppuccin-mocha` (replaced by canonical versions from alacritty-theme) -- Result: 13 total themes (2 custom + 11 community), all hardcoded, zero latency impact -- Create `ops/extract_themes.py` — converter script that reads alacritty-theme YAML, extracts colors, maps to statusline fields, derives model pill colors algorithmically, and generates Python code to append to `themes.py` - -## Capabilities - -### New Capabilities - -None. Theme selection already exists and is governed by `statusline-config` specification. - -### Modified Capabilities - -None. No spec-level behavior changes—existing configuration precedence and theme resolution remain identical. We're expanding the available theme options, which is an implementation detail. - -## Impact - -- **Code**: `claude/yas/themes.py` (adds ~11 Theme instances), `ops/extract_themes.py` (new converter script, ~150 lines) -- **Dependencies**: None (colors extracted from alacritty-theme repo, not added as a dependency) -- **User-visible**: `THEMES` registry grows from 4 to 13 entries; users gain `dracula`, `gruvbox-dark`, `gruvbox-light`, `nord`, `one-dark`, `one-light`, `solarized-dark`, `solarized-light`, `tokyo-night`, `palenight` (plus existing `claude-dark`, `claude-light`) -- **Testing**: Visual validation via `make demo` for each of the 13 themes; hand-tweak model pill colors / gradients as needed diff --git a/openspec/changes/archive/2026-06-02-add-community-themes/specs/.gitkeep b/openspec/changes/archive/2026-06-02-add-community-themes/specs/.gitkeep deleted file mode 100644 index ff3d92e..0000000 --- a/openspec/changes/archive/2026-06-02-add-community-themes/specs/.gitkeep +++ /dev/null @@ -1,12 +0,0 @@ -# No spec changes required - -This change expands the set of available hardcoded themes but does not modify spec-level behavior. - -Theme selection is governed by the existing `statusline-config` specification, which: -- Defines the precedence chain for resolving theme configuration -- Specifies that theme names must be "known" -- Requires valid theme names in precedence sources - -This change only expands what "known" theme names are allowed (from 4 to 13). The configuration resolution behavior and validation logic remain identical. - -Therefore, no new or modified spec files are required. diff --git a/openspec/changes/archive/2026-06-02-add-community-themes/specs/no-spec-changes.md b/openspec/changes/archive/2026-06-02-add-community-themes/specs/no-spec-changes.md deleted file mode 100644 index 174bc83..0000000 --- a/openspec/changes/archive/2026-06-02-add-community-themes/specs/no-spec-changes.md +++ /dev/null @@ -1,18 +0,0 @@ -# No Specification Changes Required - -## Rationale - -This change expands the available hardcoded themes from 4 to 13 but does not introduce new capabilities or modify spec-level behavior. - -Theme selection is fully governed by the existing `statusline-config` specification, which: -- Defines the configuration precedence chain (CLI → env → config file → defaults) -- Specifies that theme names must be "known" -- Requires validation that a theme name is in the set of known themes - -This change only expands the set of valid theme names. The resolution logic, validation rules, and configuration behavior remain identical. - -## Impact - -- `statusline-config` requirement "theme must be a known theme name" now accepts: `claude-dark`, `claude-light`, `dracula`, `gruvbox-dark`, `gruvbox-light`, `nord`, `one-dark`, `one-light`, `solarized-dark`, `solarized-light`, `tokyo-night`, `palenight` (was: only the first 2 + 2 catppuccin variants) -- No change to the requirement's enforcement or precedence logic -- No change to the `--theme` CLI flag, `YAS_THEME` env var, `[appearance].theme` config file key, or `CLAUDE_STATUSLINE_THEME` legacy alias behavior diff --git a/openspec/changes/archive/2026-06-02-add-community-themes/tasks.md b/openspec/changes/archive/2026-06-02-add-community-themes/tasks.md deleted file mode 100644 index 60ef7d8..0000000 --- a/openspec/changes/archive/2026-06-02-add-community-themes/tasks.md +++ /dev/null @@ -1,89 +0,0 @@ -## 1. Converter Script Development - -- [x] 1.1 Create `ops/extract_themes.py` with color mapping logic - - Read alacritty-theme YAML files - - Map alacritty colors (normal/bright slots) → statusline fields per design - - Generate Python Theme dataclass code - - Mark complete when script runs without errors on one test theme - -- [x] 1.2 Implement model pill color derivation algorithm - - Extract bright/saturated colors from theme palette - - Assign to model families (opus=yellow, sonnet=green, haiku=blue, other=magenta) - - Generate anchor, warm_shift, cool_shift per model - - Mark complete when script outputs valid ModelColors for all 4 models - -- [x] 1.3 Test converter script on a few themes - - Extract colors for dracula, gruvbox-dark, nord - - Verify output is valid Python that appends to themes.py - - Check for missing color errors in alacritty YAML - - Mark complete when script successfully generates 3 themes without manual fixes - -## 2. Theme Extraction & Integration (Can start after 1.3) - -- [x] 2.1 Extract all 11 community themes using the converter script - - Run `ops/extract_themes.py` with all 11 theme names - - Verify all 11 themes extract successfully - - Mark complete when script outputs all 11 themes with no errors - -- [x] 2.2 Append extracted themes to `claude/yas/themes.py` - - Insert generated code after existing claude-dark/light definitions - - Verify Python syntax is valid (`python -m py_compile`) - - Update `THEMES` registry to include all 13 themes - - Mark complete when `import yas.themes` succeeds with all 13 in THEMES dict - -- [x] 2.3 Remove catppuccin-latte and catppuccin-mocha from themes.py - - Delete class definitions - - Remove from THEMES registry - - Verify no other code references these themes - - Mark complete when tests run without references to catppuccin-latte/mocha - -## 3. Visual Validation & Tweaking (Can happen in parallel with 2.2–2.3) - -- [x] 3.1 Run demo for all 13 themes - - `make demo` through each theme (narrow/medium/wide) - - Check border alignment, color harmony, readability - - Note which themes need model/gradient color adjustments - - Mark complete when you've visually validated all 13 and identified tweaks needed - -- [x] 3.2 Hand-tweak model pill colors / gradients for themes that need it - - Edit `grad_stops`, `spec_gradients`, `models` in `themes.py` for flagged themes - - Re-run `make demo` for each adjusted theme - - Iterate until colors look good - - Mark complete when all 13 themes pass visual review - -## 4. Testing & Code Quality (Can start after 2.2) - -- [x] 4.1 Run unit tests - - `make test` (or `uv run pytest -q`) - - Verify no regressions, all tests pass - - Mark complete when test output shows 100% pass - -- [x] 4.2 Verify theme resolution and config loading - - Test that all 13 theme names are recognized by config resolution - - Test CLI `--theme dracula`, env `YAS_THEME=nord`, config file `[appearance] theme = "tokyo-night"` - - Mark complete when config tests pass for at least 3 of the new themes - -## 5. Documentation & ADR Updates (Can happen in parallel with 3–4) - -- [x] 5.1 Check ADR 0002 (theme system) for updates - - If it lists available themes by name, update to mention "11 community themes from alacritty-theme" - - Document that themes are hardcoded for latency (include benchmark ratios from grill-me) - - Mark complete when ADR is reviewed and any updates are committed - -- [x] 5.2 Document the converter script - - Add usage example to `ops/README.md` or new `ops/THEMES.md` - - Explain how to re-run on future alacritty-theme updates - - Note the alacritty-theme commit SHA used - - Mark complete when docs include command and example output - -## 6. Finalization - -- [ ] 6.1 Create git commit with all changes - - Message: "Add 11 community themes from alacritty-theme" - - Include reference to alacritty-theme commit used - - Verify `make test` passes one final time - - Mark complete when commit is created and branch is clean - -- [ ] 6.2 (Optional) Open PR with `/yas-pr` skill - - Or manually: `gh pr create --draft` and request review - - Mark complete when PR is open and CI/checks pass diff --git a/openspec/changes/archive/2026-06-05-add-install-script/.openspec.yaml b/openspec/changes/archive/2026-06-05-add-install-script/.openspec.yaml deleted file mode 100644 index c53ef21..0000000 --- a/openspec/changes/archive/2026-06-05-add-install-script/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-05-add-install-script/design.md b/openspec/changes/archive/2026-06-05-add-install-script/design.md deleted file mode 100644 index da6b84f..0000000 --- a/openspec/changes/archive/2026-06-05-add-install-script/design.md +++ /dev/null @@ -1,61 +0,0 @@ -## Context - -The `yas:init` skill (`.claude/skills/init/SKILL.md`) carries ~100 lines of inline bash that locates the newest installed plugin renderer, cleans legacy files, detects Python 3.10+, and atomically writes `statusLine.command` into `settings.json` with a backup/validate/restore safety net. There is no committed `.sh` in the repo and no `curl | bash` install path — humans must run `claude plugin marketplace add`, `claude plugin install`, then `claude -p "/yas:init"` by hand. We want one script that humans and the skill both run, with the wiring logic in exactly one place and a regression test guarding the file-munging half. - -The plugin CLI surface is fixed: `claude plugin marketplace add `, `claude plugin install --scope user`, `claude plugin update --scope user`. On-disk state is inspectable: marketplace presence in `$CLAUDE_CONFIG_DIR/plugins/known_marketplaces.json` (key `yet-another-statusline`), plugin presence in `installed_plugins.json` (key `yas@yet-another-statusline`). The skill already depends on reading `installed_plugins.json`, so coupling to that schema is pre-existing, not new. - -## Goals / Non-Goals - -**Goals:** -- One committed `ops/install.sh` as the single source of truth for settings-wiring. -- A `curl … | bash` entrypoint that bootstraps a fresh machine end-to-end. -- The `yas:init` skill reduced to a single delegating call, with identical observable behaviour. -- A hermetic regression test for the wire-only path (where a bug actually corrupts a user's `settings.json`). - -**Non-Goals:** -- Release-tag pinning or self-re-execution of the installer (explicitly dropped — the branch serves the latest installer, the marketplace serves the latest plugin). -- Pinning the installed plugin *code* to a tag (the plugin CLI owns ref resolution). -- A standalone code-distribution path that bypasses the plugin/marketplace system (the script orchestrates the CLI, it does not clone the repo). -- Auto-restart of Claude Code (empirically the statusline reflects the new `settings.json` immediately; no restart nag). -- Behavioural test coverage of full-mode CLI orchestration beyond `--dry-run` decision assertions (real install needs network + `claude`). - -## Decisions - -### One script, two modes, auto-detected by `CLAUDE_PLUGIN_ROOT` -The script dispatches on environment, not on the caller knowing a flag. `CLAUDE_PLUGIN_ROOT` is set when the skill invokes the script from inside the plugin and unset under `curl | bash`. Set ⇒ **wire-only** (skip marketplace/install/update, wire *that exact root*, no scan, no network, no nested `claude`). Unset ⇒ **full** (ensure marketplace → install/update → wire). Explicit `--wire-only` / `--full` override the detection for testability. *Alternative considered:* a mandatory flag — rejected because it forces caller knowledge and is easy to mis-call; *always-full* — rejected because it would trigger nested `claude` calls and surprise mid-session plugin updates every `/yas:init`. - -### Inspect-then-act idempotency (jq on the JSON), not CLI-error tolerance -Full mode reads `known_marketplaces.json` / `installed_plugins.json` with `jq` to decide add-vs-skip and install-vs-update, giving honest progress output ("Adding marketplace…", "Installing…", "Updating…"). Guarded with `2>/dev/null`; a parse failure degrades to "treat as absent" (try to add/install) rather than crashing. *Alternative considered:* fire idempotent CLI commands and swallow failures — rejected as it depends on undocumented exit codes/error strings and can't cleanly distinguish install from update. The skill already couples to `installed_plugins.json`, so this adds no new coupling. - -### The skill calls the local copy, not curl -`yas:init` runs `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh"`. Because `claude plugin install` always pulls the latest marketplace version, the shipped `ops/install.sh` *is* the current installer — same file, same wire-only branch. Calling local avoids a network dependency and the supply-chain footgun of piping remote code to bash inside an automated agent session. "Same path" is satisfied by it being one file, not by the fetch mechanism. *Alternative considered:* skill curls the branch copy — rejected; it reintroduces un-vetted-`main` execution *inside* an unattended session for zero benefit (the wire-only guard fires either way). - -### No installer pinning / no re-exec -Earlier exploration considered resolving the latest release tag via the GitHub API and re-fetching a pinned installer. Dropped per decision: the curl entrypoint always runs the branch copy (`…/main/ops/install.sh`), which installs the latest marketplace plugin. Simpler, no API rate-limit failure mode, no self-re-exec machinery. `--main` remains reserved as an explicit dev/edge selector but does not change the default branch-tracking behaviour. - -### `--scope user` everywhere; `set -uo pipefail`; per-mode preflight -Install/update get explicit `--scope user` so plugin and `settings.json` land in the same scope. `set -uo pipefail` *without* `-e` — the presence probes branch on exit codes and `-e` would abort on the first "absent" result; genuinely fatal steps get explicit `|| { echo …; exit 1; }`. Preflight checks only what the mode needs: full mode requires `claude` + `curl` + `jq`; wire-only requires only `jq` + Python 3.10+. Output matches the skill's existing style (two-space `printf` progress lines, `!`-prefixed errors, no color). Portable across macOS + Linux using the same primitives the skill's proven block already uses (`date -u`, `mktemp`, `sort -Vr`, `find -maxdepth`). - -### `--dry-run` for testable orchestration -A `--dry-run` flag prints the would-do decisions (add-marketplace? install-vs-update? wire?) without shelling `claude` or touching `settings.json`, making full-mode decision logic CI-exercisable and giving cautious users a preview. - -## Risks / Trade-offs - -- **GitHub default branch is `main`, not `master`** → the README one-liner and any raw URL must use `main`; a `master` URL would 404. Verified `origin/HEAD → origin/main`. -- **`claude plugin install` interactivity under a non-TTY pipe** → if it prompts, `curl | bash` would hang. Mitigation: verify non-interactivity against the installed CLI before finalizing; if it prompts, document reading from `/dev/tty` or hunt a non-interactive flag. Must be confirmed, not assumed. -- **Coupling to `known_marketplaces.json` / `installed_plugins.json` schema (`version: 2`)** → Anthropic could change it. Mitigation: `2>/dev/null`-guarded reads that degrade to "absent" so a schema change yields "try to add/install" rather than a crash. Pre-existing coupling for the plugin half. -- **Gutting `SKILL.md` removes the only existing copy of the wiring logic** → all wiring safety now lives in one script. Mitigation: the hermetic wire-only regression test (backup, exact-match skip, legacy cleanup, corrupt-write rollback) plus `shellcheck` in CI. -- **`curl | bash` is inherently trust-on-first-use** → mitigated by also documenting the manual `claude plugin` + `/yas:init` path for users who won't pipe remote code to a shell. - -## Migration Plan - -1. Add `ops/install.sh` (full + wire-only) and make it executable. -2. Verify `claude plugin install` non-interactivity; adjust the script if it prompts. -3. Gut `init/SKILL.md` to the single delegating call; narrow `allowed-tools` to `Bash`; keep `permissions-allow.json` scoped to the wire-only call's commands. -4. Update `README.md` install section (curl primary, manual alternative). -5. Add `shellcheck ops/install.sh` to `ci.yml` and the hermetic wire-only test. -6. Rollback is trivial: the change is additive plus a skill edit; reverting the `SKILL.md` edit restores the inline implementation. - -## Open Questions - -- Confirm whether `claude plugin install` / `marketplace add` run non-interactively under a piped stdin, or require a `/dev/tty` redirect or a yet-unknown flag. To be answered against the CLI during implementation, not by assumption. diff --git a/openspec/changes/archive/2026-06-05-add-install-script/proposal.md b/openspec/changes/archive/2026-06-05-add-install-script/proposal.md deleted file mode 100644 index 5b3e3e2..0000000 --- a/openspec/changes/archive/2026-06-05-add-install-script/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -Today the only way to wire YAS into Claude Code is a multi-step manual dance (`claude plugin marketplace add …`, `claude plugin install …`, then `claude -p "/yas:init"`), and the settings-wiring logic lives *inline* in the `init` skill's `SKILL.md`. That makes one-line `curl | bash` bootstrapping impossible and leaves the tricky discovery/atomic-write logic with no single home or regression test. We want a single committed script that both a human (`curl | bash`) and the `yas:init` skill can run, so the wiring logic has exactly one source of truth. - -## What Changes - -- **Add `ops/install.sh`** — one script, two modes: - - **Full mode** (human, `curl … | bash`): ensure the marketplace is added, install-or-update the `yas` plugin, then wire `settings.json`. Orchestrates `claude plugin marketplace add` / `install` / `update` with explicit `--scope user`, branching on `jq` reads of `known_marketplaces.json` / `installed_plugins.json`. - - **Wire-only mode** (the skill, auto-detected when `CLAUDE_PLUGIN_ROOT` is set): skip all plugin management, point `settings.json` at the renderer under that exact plugin root. No network, no nested `claude` calls. -- **Flags**: `--wire-only` / `--full` to override mode detection; `--dry-run` to print intended actions without shelling `claude` or touching `settings.json`; `--main` reserved as the explicit dev/edge selector. `set -uo pipefail`, per-mode preflight dependency checks (`claude`/`curl` only required in full mode), macOS+Linux portable. -- **Gut `init/SKILL.md`** — its `` collapses to `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh"`; `allowed-tools` narrows to `Bash`. **BREAKING** only in the sense that the skill's internal implementation moves; its observable behaviour (wire-only `statusLine.command` write) is preserved. -- **README**: add the `curl … | bash` one-liner as the primary install path, keeping the manual `claude plugin` + `/yas:init` flow as a documented alternative. -- **CI/tests**: add `shellcheck ops/install.sh` to CI; add a hermetic wire-only regression test (fresh `CLAUDE_CONFIG_DIR`/`CLAUDE_PLUGIN_ROOT`, fake renderer) asserting correct `statusLine.command`, backup creation, exact-match skip, legacy-file removal, and corrupt-write rollback. - -## Capabilities - -### New Capabilities -- `install-script`: the `ops/install.sh` contract — its two modes, mode-detection rules, full-mode plugin orchestration, the wire-only `settings.json` write semantics (discovery, backup, atomic write, validate/restore, legacy cleanup), flags, and preflight behaviour. - -### Modified Capabilities - - -## Impact - -- **New file**: `ops/install.sh`. -- **Modified**: `.claude/skills/init/SKILL.md` (gutted to a single call), `README.md` (install section), `.github/workflows/ci.yml` (shellcheck step), test suite (new wire-only behavioural test), possibly `.claude-plugin/permissions-allow.json` (kept scoped to what the skill's wire-only call triggers). -- **Dependencies**: full mode requires `claude` CLI + `curl` on PATH; both modes require `jq` (already a skill dependency) and Python 3.10+ (already required by the renderer). -- **No change** to the renderer (`claude/yas/**`) or any runtime statusline behaviour. diff --git a/openspec/changes/archive/2026-06-05-add-install-script/specs/install-script/spec.md b/openspec/changes/archive/2026-06-05-add-install-script/specs/install-script/spec.md deleted file mode 100644 index eb26612..0000000 --- a/openspec/changes/archive/2026-06-05-add-install-script/specs/install-script/spec.md +++ /dev/null @@ -1,130 +0,0 @@ -## ADDED Requirements - -### Requirement: Single install script with two modes - -The repository SHALL provide a single executable bash script at `ops/install.sh` that operates in one of two modes — full mode or wire-only mode — and that serves as the single source of truth for wiring YAS into Claude Code. The same script SHALL be runnable both by a human via `curl … | bash` and by the `yas:init` skill. - -#### Scenario: Mode auto-detection via CLAUDE_PLUGIN_ROOT - -- **WHEN** the script runs with the `CLAUDE_PLUGIN_ROOT` environment variable set and no overriding flag -- **THEN** it selects wire-only mode and skips all plugin-management steps - -#### Scenario: Default to full mode - -- **WHEN** the script runs with `CLAUDE_PLUGIN_ROOT` unset and no overriding flag -- **THEN** it selects full mode (marketplace + plugin management followed by settings wiring) - -#### Scenario: Explicit mode override - -- **WHEN** the script is invoked with `--wire-only` or `--full` -- **THEN** that flag overrides the `CLAUDE_PLUGIN_ROOT`-based auto-detection - -### Requirement: curl-pipe bootstrap entrypoint - -The script SHALL be installable by humans via a single command that fetches it from the repository's default branch and pipes it to bash: - -``` -curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/main/ops/install.sh | bash -``` - -This entrypoint SHALL run full mode, installing the latest plugin version served by the marketplace using the latest installer on the branch. The script SHALL NOT perform any release-tag pinning or self-re-execution. - -#### Scenario: curl bootstrap runs unattended - -- **WHEN** a user pipes the branch copy of `ops/install.sh` to bash on a machine where YAS is not yet installed -- **THEN** the script completes the full flow without requiring interactive input - -### Requirement: Full-mode plugin orchestration - -In full mode the script SHALL ensure the marketplace and plugin are present and current before wiring settings, branching on inspection of Claude Code's on-disk state. - -#### Scenario: Marketplace absent - -- **WHEN** `known_marketplaces.json` does not contain the `yet-another-statusline` key -- **THEN** the script runs `claude plugin marketplace add tmck-code/yet-another-statusline` - -#### Scenario: Marketplace already present - -- **WHEN** `known_marketplaces.json` already contains the `yet-another-statusline` key -- **THEN** the script does not re-add the marketplace - -#### Scenario: Plugin not installed - -- **WHEN** `installed_plugins.json` does not contain `yas@yet-another-statusline` -- **THEN** the script runs `claude plugin install yas@yet-another-statusline --scope user` - -#### Scenario: Plugin already installed - -- **WHEN** `installed_plugins.json` already contains `yas@yet-another-statusline` -- **THEN** the script runs `claude plugin update yas@yet-another-statusline --scope user` - -#### Scenario: All plugin CLI invocations are user-scoped - -- **WHEN** the script shells `claude plugin marketplace add`, `install`, or `update` -- **THEN** it passes `--scope user` explicitly on the install and update commands - -### Requirement: Wire-only settings write - -In both modes the script SHALL write `statusLine.command` into `settings.json` under `$CLAUDE_CONFIG_DIR` (default `~/.claude/`), pointing at the newest installed renderer, and SHALL do so safely. In wire-only mode it SHALL target the renderer under `CLAUDE_PLUGIN_ROOT` directly without scanning, and SHALL NOT perform plugin management, network access, or nested `claude` invocations. - -#### Scenario: Renderer discovery in full mode - -- **WHEN** the script wires settings in full mode -- **THEN** it locates the newest plugin root whose `claude/statusline_command.py` exists on disk, preferring `installed_plugins.json` and falling back to a version-sorted cache scan - -#### Scenario: Atomic write with backup - -- **WHEN** `settings.json` already exists and its `statusLine.command` differs from the target -- **THEN** the script backs the file up, writes the new value via a temp file and atomic rename, and validates the result - -#### Scenario: Exact-match skip - -- **WHEN** the existing `statusLine.command` exactly equals the target command -- **THEN** the script makes no change and reports the skip - -#### Scenario: Corrupt write rollback - -- **WHEN** the written `settings.json` fails JSON validation -- **THEN** the script restores the pre-write backup and exits non-zero - -#### Scenario: Legacy file cleanup - -- **WHEN** legacy `statusline-info-*` files exist under `$CLAUDE_CONFIG_DIR` -- **THEN** the script removes them - -#### Scenario: Missing Python interpreter - -- **WHEN** no Python 3.10+ interpreter is found on PATH -- **THEN** the script reports the error and exits non-zero without modifying `settings.json` - -### Requirement: Dry-run mode - -The script SHALL support a `--dry-run` flag that prints the intended actions for the selected mode without shelling `claude` or modifying `settings.json`. - -#### Scenario: Dry-run prints decisions only - -- **WHEN** the script runs with `--dry-run` -- **THEN** it prints whether it would add the marketplace, install vs update the plugin, and wire settings, but performs none of those side effects - -### Requirement: Per-mode preflight and strictness - -The script SHALL run under `set -uo pipefail`, check only the dependencies its selected mode needs, and remain portable across macOS and Linux. - -#### Scenario: Full-mode dependency check - -- **WHEN** full mode runs and `claude` is not on PATH -- **THEN** the script reports the missing dependency with an install hint and exits non-zero - -#### Scenario: Wire-only dependency check - -- **WHEN** wire-only mode runs -- **THEN** the script does not require `claude` or `curl` to be present, only `jq` and a Python interpreter - -### Requirement: Skill delegates to the script - -The `yas:init` skill SHALL delegate its wiring work to `ops/install.sh` rather than carrying an inline implementation, preserving its observable behaviour of writing a wire-only `statusLine.command`. - -#### Scenario: Skill invokes the shipped script - -- **WHEN** the `yas:init` skill runs -- **THEN** it invokes `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh"`, which detects wire-only mode and writes `settings.json` against that plugin root diff --git a/openspec/changes/archive/2026-06-05-add-install-script/tasks.md b/openspec/changes/archive/2026-06-05-add-install-script/tasks.md deleted file mode 100644 index 42e1785..0000000 --- a/openspec/changes/archive/2026-06-05-add-install-script/tasks.md +++ /dev/null @@ -1,32 +0,0 @@ -## 1. Preflight verification - -- [x] 1.1 Verify whether `claude plugin install` / `claude plugin marketplace add` run non-interactively under a piped (non-TTY) stdin; record the finding and decide if a `/dev/tty` redirect or non-interactive flag is needed (resolves the design's Open Question) - -## 2. Write `ops/install.sh` - -- [x] 2.1 Create `ops/install.sh` with `#!/usr/bin/env bash`, `set -uo pipefail`, executable bit, and the skill's two-space `printf` / `!`-prefixed output style -- [x] 2.2 Implement arg/env parsing and mode dispatch: `CLAUDE_PLUGIN_ROOT` set ⇒ wire-only; unset ⇒ full; `--wire-only` / `--full` / `--dry-run` / `--main` overrides -- [x] 2.3 Implement per-mode preflight dependency checks (full: `claude` + `curl` + `jq`; wire-only: `jq` + Python 3.10+), erroring with install hints and non-zero exit on missing deps -- [x] 2.4 Implement `ensure_marketplace` (full only): jq-read `known_marketplaces.json`, `claude plugin marketplace add tmck-code/yet-another-statusline` only if the `yet-another-statusline` key is absent -- [x] 2.5 Implement `ensure_plugin` (full only): jq-read `installed_plugins.json`, `claude plugin install … --scope user` if `yas@yet-another-statusline` absent else `claude plugin update … --scope user` -- [x] 2.6 Implement `do_wire`: port the skill's wiring logic — renderer discovery (prefer `CLAUDE_PLUGIN_ROOT` in wire-only, else version-sorted `installed_plugins.json` then cache-scan fallback), legacy `statusline-info-*` cleanup, Python 3.10+ detection, exact-match skip, backup → atomic temp-write + rename → JSON-validate → restore-on-corruption -- [x] 2.7 Wire `--dry-run` through every side-effecting step so it prints intended actions (would add/install/update/wire) without shelling `claude` or touching `settings.json` -- [x] 2.8 Guard jq reads with `2>/dev/null` so a schema/parse failure degrades to "treat as absent" rather than crashing - -## 3. Gut the skill - -- [x] 3.1 Replace `init/SKILL.md` `` with a single `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh"` call and a one-line objective -- [x] 3.2 Narrow `allowed-tools` to `Bash`; update `.claude-plugin/permissions-allow.json` to stay scoped to what the wire-only call triggers -- [x] 3.3 Manually run the skill path (`CLAUDE_PLUGIN_ROOT` set) and confirm `settings.json` is wired identically to the old inline behaviour - -## 4. Tests & CI - -- [x] 4.1 Add a hermetic wire-only test (fresh `CLAUDE_CONFIG_DIR` + `CLAUDE_PLUGIN_ROOT`, fake `claude/statusline_command.py`) asserting: correct `statusLine.command`, `.bak` created, exact-match skip, legacy-file removal, corrupt-write rollback -- [x] 4.2 Add `--dry-run` assertions exercising full-mode decision logic (would-add vs skip marketplace, install vs update) without network/`claude` -- [x] 4.3 Add a `shellcheck ops/install.sh` step to `.github/workflows/ci.yml` -- [x] 4.4 Run `make test` and `shellcheck` locally; confirm green - -## 5. Docs - -- [x] 5.1 Update `README.md` Install/Update: add the `curl -fsSL …/main/ops/install.sh | bash` one-liner as the primary path, keeping the manual `claude plugin` + `/yas:init` flow as a documented alternative -- [x] 5.2 Confirm the README raw URL uses the `main` branch (not `master`) diff --git a/openspec/changes/archive/2026-06-05-harden-untrusted-input/.openspec.yaml b/openspec/changes/archive/2026-06-05-harden-untrusted-input/.openspec.yaml deleted file mode 100644 index c53ef21..0000000 --- a/openspec/changes/archive/2026-06-05-harden-untrusted-input/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-05-harden-untrusted-input/design.md b/openspec/changes/archive/2026-06-05-harden-untrusted-input/design.md deleted file mode 100644 index 9f145df..0000000 --- a/openspec/changes/archive/2026-06-05-harden-untrusted-input/design.md +++ /dev/null @@ -1,47 +0,0 @@ -## Context - -The statusline ingests a JSON payload from Claude Code on stdin plus on-disk repo artifacts (`.git/HEAD`, refs, transcript JSONL, `settings.json`). Several of these are attacker-influenceable the moment a cloned/malicious repo is opened. Today the only escape filtering is `_ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')` (`claude/yas/constants.py:18`), which matches **only** SGR colour codes — OSC (`\x1b]…`), C1, and non-`m` CSI/DCS pass through verbatim. PR #35's audit reproduced an OSC-52 clipboard write and an OSC-0 title spoof through these sinks (SEC-1) and a trust-boundary read of `project_dir/.claude/settings.json` (SEC-2). - -This codebase is the current `claude/yas/` package. PR #35's fixes were authored against a different, now-closed refactor branch (`claude/statusline/`), so they cannot be cherry-picked — only their approach is reused. The session model is built through `from_dict` classmethods in `claude/yas/session.py`, with `_as_str` (`session.py:41`) as the central string-coercion helper. Repo/transcript-derived fields are captured in `claude/yas/info/{git,tasks,subagents,skills,transcript}.py`, several of which bypass `_as_str`. - -## Goals / Non-Goals - -**Goals:** -- No untrusted OSC/CSI/C1 escape can reach stdout, eliminating the zero-interaction OSC-52 and OSC-0/2 attacks. -- Plugin state is sourced only from the user's own config dir; a cloned repo's `settings.json` is never read. -- Each fix is small and single-purpose (the repo owner asked for one-fix-per-change), with a regression test pinning it. -- `ruff` and `mypy --strict` stay clean. - -**Non-Goals:** -- Broadening `_ANSI_RE` or the width helpers (`render/text.py`) to parse OSC for width accounting. With capture-time sanitization, only plain text reaches the width math, so it is correct by construction — widening the width regex is unnecessary and (per the audit) would risk slicing mid-escape on the colour-preserving truncation path. Out of scope. -- OSC-8 hyperlink support or any new rendering capability. -- Pricing/context/layout fixes from PR #35 (separate concerns). - -## Decisions - -**D1 — Sanitize at capture, with a single shared helper.** -Add one `_sanitize(s: str) -> str` in `claude/yas/session.py` (or a small `textutil`-style home) using `re.sub(r'[\x00-\x08\x0b-\x1f\x7f-\x9f]', '', s)`. Route the common fields through it by calling it inside `_as_str`, which already coerces `display_name`, model `id`, `cwd`/`current_dir`, `project_dir`, `session_id`, and output-style name. *Why at `_as_str`*: it is the existing chokepoint, so most sinks are covered by one edit and stay covered as new `_as_str`-based fields are added. *Why not at render*: the final line legitimately contains the renderer's own SGR; stripping there would break colour. *Alternative considered*: a decorator/validator per field — rejected as more surface area for the same effect. - -**D2 — Cover the capture sites that bypass `_as_str`.** -The git branch (`info/git.py:_read_head`, read from `.git/HEAD` and `refs/heads/*`) and transcript-derived strings (`info/tasks.py` subject/active_form, `info/subagents.py` description/tool-input, `info/skills.py` names, and any raw slices in `info/transcript.py`) do not pass through `_as_str`. Apply `_sanitize` explicitly at each of these capture points. To avoid a circular import from `info/*` → `session`, the shared helper should live where both layers can import it cleanly (e.g. `constants.py` alongside `_ANSI_RE`, or a new `textutil.py`); pick whichever keeps the import graph acyclic — `constants.py` is the low-risk default. - -**D3 — Drop the project-dir settings candidate (SEC-2).** -In `Workspace.plugins` (`session.py:150-168`) remove the `project_dir/.claude/settings.json` entry so the candidate list is exactly `[CLAUDE_DIR / 'settings.json']`. This kills the trust-boundary read outright; once it's gone, the `enabledPlugins`-key escape sink for that path disappears regardless of D1. *Alternative considered*: keep reading it but sanitize the keys — rejected: it still reads attacker-controlled config across a trust boundary, which is itself the finding. - -**D4 — Tests mirror the audit's `test_security_hardening.py`.** -One new `test/` module: assert each sink (model name, git branch, task/subagent/skill text) renders inert when fed an OSC-52/OSC-0 payload (no `\x1b`/`\x07` in output), assert plain/CJK text is unchanged, and assert a malicious `project_dir/.claude/settings.json` contributes nothing while `CLAUDE_DIR/settings.json` still does. - -## Risks / Trade-offs - -- **A legitimate branch/path with a stray control byte loses that byte** → Acceptable and intended; statusline fields are single-line display strings and control bytes have no business there. -- **`project_dir`-local plugins no longer shown** → This is the explicit behavior change. It only affected plugins enabled solely via a repo-local settings file, which is exactly the untrusted read being removed; document in the proposal's Impact. -- **A future capture site bypasses both `_as_str` and the explicit calls** → Mitigate by keeping `_as_str` the funnel and adding a test that exercises each known sink, so a regression on a covered sink fails loudly. -- **Helper placement causing an import cycle** → Mitigated by D2's guidance to home the helper in a dependency-free module (`constants.py`). - -## Migration Plan - -Pure hardening; no data migration. Land as one change (optionally two commits: SEC-1 sanitization, then SEC-2 settings read) so either can be reverted independently. Rollback is a straight revert — no persisted state changes. - -## Open Questions - -- Helper home: `constants.py` vs a new `textutil.py`. Default to `constants.py` unless the import graph or existing convention favors a dedicated module — resolve during implementation. diff --git a/openspec/changes/archive/2026-06-05-harden-untrusted-input/proposal.md b/openspec/changes/archive/2026-06-05-harden-untrusted-input/proposal.md deleted file mode 100644 index 56cea27..0000000 --- a/openspec/changes/archive/2026-06-05-harden-untrusted-input/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Why - -The statusline renders attacker-influenceable strings — a repo's git branch (`.git/HEAD`), the host-supplied `cwd`/`project_dir`/`session_id`/model name, transcript-derived task/subagent/skill text, and a project's `enabledPlugins` keys — straight to stdout with no control-character sanitization. An adversarial audit (PR #35, SEC-1/SEC-2) empirically reproduced two zero-interaction attacks fired merely by rendering: an OSC-52 clipboard hijack and an OSC-0/2 window-title spoof, plus an unexpected trust-boundary read of a cloned repo's `.claude/settings.json`. The current `_ANSI_RE` strips only SGR colour codes, so OSC/C1/non-`m` CSI escapes pass through verbatim. - -## What Changes - -- **SEC-1 — sanitize untrusted field values at capture.** Strip C0/C1/DEL bytes (which include `ESC` `0x1b` and `BEL` `0x07`, the introducers/terminators for OSC and CSI) from every untrusted string as it is captured — centrally at the `_as_str` chokepoint (`display_name`, model `id`, `cwd`, `project_dir`, `session_id`, output-style name) and at the remaining capture sites that bypass it: the git branch read (`info/git.py`), and transcript-derived task subject/active_form, subagent description/tool-input, and skill names. Sanitization happens **at capture only** — the final rendered line is left untouched so the renderer's own legitimate SGR survives. -- **SEC-2 — stop reading a cloned repo's settings.** `Workspace.plugins` no longer reads `project_dir/.claude/settings.json`; it reads only the user's own `CLAUDE_DIR/settings.json`. This removes an attacker-controlled trust-boundary read that also doubled as an escape-injection sink. -- Add regression tests covering both: a malicious OSC payload in each sink renders inert, and a malicious `project_dir` settings file is never read. - -## Capabilities - -### New Capabilities -- `untrusted-input-hardening`: defines the trust boundary for host- and repo-supplied input — which fields are untrusted, the control-character sanitization applied at capture, and the restriction that only the user's own config directory is read for plugin state. - -### Modified Capabilities - - -## Impact - -- **Code**: `claude/yas/session.py` (`_as_str` sanitization helper; `Workspace.plugins` candidate list), `claude/yas/info/git.py` (`_read_head` branch), `claude/yas/info/tasks.py`, `claude/yas/info/subagents.py`, `claude/yas/info/skills.py`, `claude/yas/info/transcript.py` (transcript-derived captures). -- **Tests**: new `test/` module mirroring the audit's `test_security_hardening.py` (OSC-52/OSC-0 inert across all sinks; cloned-repo settings not read). -- **Behavior**: the only user-visible change is that a project-local `.claude/settings.json` no longer contributes to the rendered plugins list. No rendering/layout change for legitimate input. -- **Quality gates**: must keep `ruff check` and `mypy --strict` clean; tests run via `pytest`. diff --git a/openspec/changes/archive/2026-06-05-harden-untrusted-input/specs/untrusted-input-hardening/spec.md b/openspec/changes/archive/2026-06-05-harden-untrusted-input/specs/untrusted-input-hardening/spec.md deleted file mode 100644 index d692090..0000000 --- a/openspec/changes/archive/2026-06-05-harden-untrusted-input/specs/untrusted-input-hardening/spec.md +++ /dev/null @@ -1,43 +0,0 @@ -## ADDED Requirements - -### Requirement: Untrusted field values are sanitized at capture - -The statusline SHALL strip terminal control characters from every host- or repo-supplied string value as it is captured into the session model, before that value can reach stdout. The sanitizer MUST remove the C0 control bytes `0x00`–`0x08` and `0x0b`–`0x1f`, `DEL` (`0x7f`), and all C1 control bytes (`0x80`–`0x9f`). This range includes `ESC` (`0x1b`) and `BEL` (`0x07`), the introducers and terminators for OSC and CSI sequences, so no untrusted OSC/CSI escape can be emitted. (Statusline fields are single-line, so no in-band `\t`/`\n` needs to be preserved.) - -The untrusted fields are: model `display_name` and `id`, `cwd`/`current_dir`, `project_dir`, `session_id`, output-style name, the git branch name read from `.git/HEAD` and refs, transcript-derived task subject and active_form, subagent description and tool-input text, skill names, and `enabledPlugins` keys. - -Sanitization MUST be applied at the point of capture, NOT to the final rendered line, so that the renderer's own legitimate SGR colour escapes are preserved. - -#### Scenario: OSC-52 clipboard payload in a model name is neutralized - -- **WHEN** the host supplies a model `display_name` containing `\x1b]52;c;\x07` -- **THEN** the captured value contains no `\x1b` or `\x07` bytes and the rendered statusline emits no OSC-52 sequence - -#### Scenario: OSC-0 title-spoof payload in a git branch is neutralized - -- **WHEN** a repo's `.git/HEAD` resolves to a branch name containing `\x1b]0;PWNED\x07` -- **THEN** the captured branch contains no `\x1b` or `\x07` bytes and the rendered statusline emits no OSC-0 sequence - -#### Scenario: Control bytes in transcript-derived task and subagent text are stripped - -- **WHEN** a transcript yields a task subject, subagent description, or tool-input string containing C0/C1/DEL control bytes -- **THEN** those bytes are removed from the captured value before rendering - -#### Scenario: Legitimate plain text is unchanged - -- **WHEN** an untrusted field contains only printable characters (including non-ASCII/CJK text) -- **THEN** the sanitized value is byte-for-byte identical to the input - -### Requirement: Plugin state is read only from the user's own config directory - -The statusline SHALL determine the enabled-plugins list solely from the user's own `CLAUDE_DIR/settings.json`. It MUST NOT read `project_dir/.claude/settings.json` (or any settings file under a host-supplied workspace path), because `project_dir` is attacker-controlled for a cloned repository and constitutes both an unexpected trust-boundary read and an escape-injection sink. - -#### Scenario: A cloned repo's settings file is ignored - -- **WHEN** `project_dir/.claude/settings.json` exists and lists `enabledPlugins` -- **THEN** none of its keys appear in the rendered plugins list - -#### Scenario: The user's own settings still drive the plugins list - -- **WHEN** `CLAUDE_DIR/settings.json` lists `enabledPlugins` -- **THEN** its enabled keys appear in the rendered plugins list as before diff --git a/openspec/changes/archive/2026-06-05-harden-untrusted-input/tasks.md b/openspec/changes/archive/2026-06-05-harden-untrusted-input/tasks.md deleted file mode 100644 index 9376454..0000000 --- a/openspec/changes/archive/2026-06-05-harden-untrusted-input/tasks.md +++ /dev/null @@ -1,28 +0,0 @@ -## 1. Shared sanitizer (SEC-1 foundation) - -- [x] 1.1 Add `_sanitize(s: str) -> str` using `re.sub(r'[\x00-\x08\x0b-\x1f\x7f-\x9f]', '', s)` in a dependency-free home importable by both `session.py` and `info/*` (default: `claude/yas/constants.py`, alongside `_ANSI_RE`); confirm no import cycle is introduced. -- [x] 1.2 Add a focused unit test for `_sanitize`: strips `ESC`/`BEL`/C0/C1/DEL; leaves printable ASCII and CJK/non-ASCII text byte-for-byte unchanged. - -## 2. Route untrusted fields through the sanitizer (SEC-1) - -- [x] 2.1 Call `_sanitize` inside `_as_str` (`claude/yas/session.py:41`) so `display_name`, model `id`, `current_dir`/`cwd`, `project_dir`, `session_id`, and output-style name are covered centrally. -- [x] 2.2 Sanitize the git branch in `claude/yas/info/git.py:_read_head` (value read from `.git/HEAD` and `refs/heads/*`). -- [x] 2.3 Sanitize transcript-derived captures that bypass `_as_str`: task subject/active_form (`info/tasks.py`), subagent description/tool-input (`info/subagents.py`), skill names (`info/skills.py`), and any raw string slices in `info/transcript.py`. - -## 3. Remove the cloned-repo settings read (SEC-2) - -- [x] 3.1 In `Workspace.plugins` (`claude/yas/session.py:150-168`), drop the `Path(self.project_dir)/'.claude'/'settings.json'` candidate so only `CLAUDE_DIR/settings.json` is read. - -## 4. Regression tests (mirror PR #35 `test_security_hardening.py`) - -- [x] 4.1 SEC-1 sinks: feed an OSC-52 payload to model `display_name` and an OSC-0 payload to a git branch; assert the rendered output contains no `\x1b`/`\x07` and no OSC sequence. -- [x] 4.2 SEC-1 transcript sinks: feed control bytes into task subject, subagent description/tool-input, and skill name; assert they are stripped from the captured/rendered values. -- [x] 4.3 SEC-1 no-op: assert plain and CJK field values render unchanged (no over-stripping). -- [x] 4.4 SEC-2: a malicious `project_dir/.claude/settings.json` listing `enabledPlugins` contributes nothing to the plugins list; a `CLAUDE_DIR/settings.json` still does. - -## 5. Quality gates - -- [x] 5.1 Run `pytest` (full suite) — all green, including the new tests. -- [x] 5.2 Run `ruff check claude/ test/` — clean. -- [x] 5.3 Run `mypy --strict` over the touched modules — clean. -- [x] 5.4 Sanity-check the demo render (`make demo/img` or equivalent) shows no layout/colour regression for legitimate input. diff --git a/openspec/changes/archive/2026-06-06-fix-context-percentage/.openspec.yaml b/openspec/changes/archive/2026-06-06-fix-context-percentage/.openspec.yaml deleted file mode 100644 index c53ef21..0000000 --- a/openspec/changes/archive/2026-06-06-fix-context-percentage/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-06-fix-context-percentage/design.md b/openspec/changes/archive/2026-06-06-fix-context-percentage/design.md deleted file mode 100644 index 838f4a3..0000000 --- a/openspec/changes/archive/2026-06-06-fix-context-percentage/design.md +++ /dev/null @@ -1,32 +0,0 @@ -## Context - -`context_line()` and `context_line_compact()` in `renderer.py` compute the fill ratio as `(total_input_tokens + total_output_tokens) / soft_limit`. Claude Code's own `/context` panel uses input-only tokens and provides a pre-calculated `context_window.used_percentage` field on the stdin payload. The mismatch means the statusline can show a noticeably different percentage than the authoritative source. - -`ContextWindow.used_percentage` is already parsed into `session.py:214` and available on the `ctx` argument passed to both helpers; it just isn't used. - -## Goals / Non-Goals - -**Goals:** -- Display a context percentage that matches Claude Code's `/context` panel -- Use the host-supplied `used_percentage` when present (avoids any rounding mismatch) -- Fall back to an input-only manual calc (`total_input_tokens / context_window_size`) when `used_percentage` is `None` -- Clamp negative values to zero - -**Non-Goals:** -- Changing the visual layout, bar shape, or surrounding labels -- Altering how `soft_limit` is resolved (unchanged) - -## Decisions - -**Primary source — `ctx.used_percentage`**: The host pre-calculates this value using the same logic as `/context`. Prefer it to avoid any divergence. - -**Fallback — input-only manual calc**: When `used_percentage` is `None` (older Claude Code versions that don't emit the field), compute `ctx.total_input_tokens / ctx.context_window_size` (not soft_limit, to stay consistent with the input-only intent). Guard against `context_window_size == 0`. - -**Remove output tokens from numerator**: `total_output_tokens` is not counted by Claude Code's context panel. Removing it from both the fill ratio and the displayed percentage eliminates the discrepancy. - -**`soft_limit` as the bar's visual ceiling stays unchanged**: The bar still fills to 100% at `soft_limit` tokens, capped at the model window. Only the percentage label source changes. - -## Risks / Trade-offs - -- Users on older Claude Code builds (pre-`used_percentage` field) will see the fallback calculation. The fallback is more correct than the current formula even without the host field. -- Snapshot baselines that include a context row will shift and need re-baselining. diff --git a/openspec/changes/archive/2026-06-06-fix-context-percentage/proposal.md b/openspec/changes/archive/2026-06-06-fix-context-percentage/proposal.md deleted file mode 100644 index cc78ba2..0000000 --- a/openspec/changes/archive/2026-06-06-fix-context-percentage/proposal.md +++ /dev/null @@ -1,26 +0,0 @@ -## Why - -The context bar and percentage shown in the statusline are calculated as `(total_input_tokens + total_output_tokens) / soft_limit`, which diverges from what Claude Code's own `/context` command displays. Claude Code tracks context fill as input-only and provides a pre-calculated `context_window.used_percentage` on the stdin payload; the statusline should prefer that authoritative value rather than recomputing it incorrectly. - -## What Changes - -- `context_line()` and `context_line_compact()` in `renderer.py` will use `ctx.used_percentage` (the host-provided value) as the primary source for the fill ratio and the displayed percentage, falling back to an input-only manual calculation (`total_input_tokens / context_window_size`) when `used_percentage` is `None`. -- Output tokens will no longer be added to the numerator of the context fill calculation. -- Negative fill ratios will be clamped to zero. -- Snapshot baselines that include a context row will be re-baselined. - -## Capabilities - -### New Capabilities - -- `context-percentage-accuracy`: The context bar fill ratio and the displayed `%` figure match what Claude Code's `/context` panel shows — input-only, host-calculated when available. - -### Modified Capabilities - -*(none — this is a correctness fix to an existing renderer helper, not a spec-level interface change)* - -## Impact - -- `claude/yas/renderer.py`: `context_line`, `context_line_compact` methods -- `claude/yas/session.py`: `ContextWindow.used_percentage` field (already parsed; newly consumed) -- Tests: any snapshot or assertion that pins the context percentage value will need re-baselining diff --git a/openspec/changes/archive/2026-06-06-fix-context-percentage/specs/context-percentage-accuracy/spec.md b/openspec/changes/archive/2026-06-06-fix-context-percentage/specs/context-percentage-accuracy/spec.md deleted file mode 100644 index 8130a44..0000000 --- a/openspec/changes/archive/2026-06-06-fix-context-percentage/specs/context-percentage-accuracy/spec.md +++ /dev/null @@ -1,30 +0,0 @@ -## ADDED Requirements - -### Requirement: Context percentage matches Claude Code's authoritative value - -The context bar fill ratio and displayed percentage SHALL be derived from the host-supplied `context_window.used_percentage` field when that field is present and non-negative. When `used_percentage` is absent (`None`), the statusline SHALL fall back to an input-only manual calculation: `total_input_tokens / context_window_size`, clamped to `[0, 1]`. The statusline SHALL NOT add `total_output_tokens` to the numerator in either path. Negative derived values SHALL be clamped to zero. - -#### Scenario: Host-supplied percentage is preferred - -- **WHEN** `context_window.used_percentage` is `42.7` (host-provided) -- **THEN** the displayed percentage is `43%` (rounded) and the bar fill ratio is `0.427`, regardless of the raw token counts - -#### Scenario: Fallback to input-only when field is absent - -- **WHEN** `context_window.used_percentage` is `None` and `total_input_tokens` is `80000` with `context_window_size` of `200000` -- **THEN** the displayed percentage is `40%` and the fill ratio is `0.40` - -#### Scenario: Output tokens are not counted - -- **WHEN** `context_window.used_percentage` is `None`, `total_input_tokens` is `60000`, `total_output_tokens` is `40000`, and `context_window_size` is `200000` -- **THEN** the fill ratio is `0.30` (input-only), not `0.50` (input+output) - -#### Scenario: Negative value is clamped to zero - -- **WHEN** `context_window.used_percentage` is `-2.0` (malformed host payload) -- **THEN** the fill ratio is `0.0` and the displayed percentage is `0%` - -#### Scenario: Zero context_window_size does not divide by zero - -- **WHEN** `used_percentage` is `None` and `context_window_size` is `0` -- **THEN** the fill ratio is `0.0` and no exception is raised diff --git a/openspec/changes/archive/2026-06-06-fix-context-percentage/tasks.md b/openspec/changes/archive/2026-06-06-fix-context-percentage/tasks.md deleted file mode 100644 index 3309ce9..0000000 --- a/openspec/changes/archive/2026-06-06-fix-context-percentage/tasks.md +++ /dev/null @@ -1,37 +0,0 @@ - - -## 1. Understand current code - -- [x] 1.1 Read `claude/yas/renderer.py` lines 969–1020 (`context_line`, `context_line_compact`) and `claude/yas/session.py` lines 198–214 (`ContextWindow`, `used_percentage`) to confirm the current formula and what the `ctx` argument carries -- [x] 1.2 Run `make test` and record the baseline pass count - -## 2. Fix context_line (wide/medium) - -- [x] 2.1 In `context_line()`: replace `total_tokens = ctx.total_input_tokens + ctx.total_output_tokens` with a helper that returns `(fill_ratio, pct_soft)` — using `ctx.used_percentage / 100` when it is not `None` and `>= 0`, falling back to `ctx.total_input_tokens / ctx.context_window_size` (guarded against divide-by-zero), clamped to `[0, 1]` -- [x] 2.2 Remove any remaining addition of `ctx.total_output_tokens` in `context_line()` -- [x] 2.3 Ensure `pct_soft` (the displayed `%` figure) is derived from the same `fill_ratio * 100`, not recomputed from raw tokens - -## 3. Fix context_line_compact (narrow) - -- [x] 3.1 Apply the same `used_percentage`-preferred / input-only-fallback logic to `context_line_compact()` — tasks 2.1–2.3 replicated for the compact variant - -## 4. Tests (can be done in parallel with step 5) - -- [x] 4.1 In `test/test_context_line.py` (or create it): add scenario — host-supplied `used_percentage=42.7` → fill `0.427`, label `43%` -- [x] 4.2 Add scenario — `used_percentage=None`, `total_input=80000`, `context_window_size=200000` → fill `0.40`, label `40%` -- [x] 4.3 Add scenario — `used_percentage=None`, output tokens present → fill uses input-only (output tokens excluded) -- [x] 4.4 Add scenario — `used_percentage=-2.0` → fill `0.0` -- [x] 4.5 Add scenario — `used_percentage=None`, `context_window_size=0` → fill `0.0`, no exception - -## 5. Re-baseline snapshots (can be done in parallel with step 4) - -- [x] 5.1 Run `make demo/img` and inspect any PNG snapshots that include the context row; update any stored baselines that changed - -## 6. Verify - -- [x] 6.1 Run `make test` — must be green with count ≥ baseline + new tests added -- [x] 6.2 Run `make demo` — eyeball the context row percentage in the animation; confirm it is plausible (not inflated by output tokens) diff --git a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/.openspec.yaml b/openspec/changes/archive/2026-06-06-fix-mon-config-dir/.openspec.yaml deleted file mode 100644 index c53ef21..0000000 --- a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/design.md b/openspec/changes/archive/2026-06-06-fix-mon-config-dir/design.md deleted file mode 100644 index 3fbac5a..0000000 --- a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/design.md +++ /dev/null @@ -1,30 +0,0 @@ -## Context - -`mon/discovery.py` defines two path constants as default arguments: - -```python -projects_root: Path = Path.home() / '.claude' / 'projects' # line 21 -payloads_root: Path = Path.home() / '.claude' / 'statusline-output' # line 43 -``` - -`CLAUDE_DIR` in `yas.constants` already resolves this path correctly (honoring `CLAUDE_CONFIG_DIR`), and is used by `yas.app`, `yas.config`, `yas.session`, and `yas.info.subagents`. The `mon` module simply missed the abstraction. - -## Goals / Non-Goals - -**Goals:** -- Replace both hardcoded `Path.home() / '.claude'` prefixes with `CLAUDE_DIR` from `yas.constants` -- Zero behaviour change for users with the default `~/.claude` directory - -**Non-Goals:** -- Restructuring `mon/discovery.py` beyond the two literal replacements -- Changing how `CLAUDE_DIR` itself is resolved (already correct in `constants.py`) - -## Decisions - -**Import `CLAUDE_DIR` from `yas.constants`**: Same import that `yas.app` already uses. No new dependency. - -**Replace default argument literals**: Default arguments are evaluated once at import time, so replacing the literal with `CLAUDE_DIR` (also a module-level constant) is safe and equivalent. - -## Risks / Trade-offs - -None — purely mechanical substitution of a hardcoded value with the existing abstraction. diff --git a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/proposal.md b/openspec/changes/archive/2026-06-06-fix-mon-config-dir/proposal.md deleted file mode 100644 index e0ce46d..0000000 --- a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Why - -The multi-session observer (`mon`) hardcodes `Path.home() / '.claude'` for both the projects root and the statusline-output payloads root. Users who set `CLAUDE_CONFIG_DIR` (a Claude Code supported env var) store their data elsewhere and see "(no active sessions)" even while sessions are running. The single source of truth for this path is `config.CLAUDE_DIR`, which the rest of the statusline already honours. - -## What Changes - -- `mon/discovery.py` will replace both `Path.home() / '.claude' / 'projects'` and `Path.home() / '.claude' / 'statusline-output'` with `CLAUDE_DIR / 'projects'` and `CLAUDE_DIR / 'statusline-output'`. -- `CLAUDE_DIR` will be imported from `yas.constants` (already available there). -- No behavioural change for users with the default `~/.claude` location. - -## Capabilities - -### New Capabilities - -- `mon-config-dir`: The `mon` observer resolves session and payload roots from `CLAUDE_DIR`, honouring `CLAUDE_CONFIG_DIR`. - -### Modified Capabilities - -*(none — correctness fix, same observable contract for default-config users)* - -## Impact - -- `claude/mon/discovery.py`: the two default-argument path literals -- Tests: `test_mon_discovery.py` — any test that relies on the hardcoded `~/.claude` path will need to patch or use the `tmp_home` fixture instead diff --git a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/specs/mon-config-dir/spec.md b/openspec/changes/archive/2026-06-06-fix-mon-config-dir/specs/mon-config-dir/spec.md deleted file mode 100644 index 9e756d7..0000000 --- a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/specs/mon-config-dir/spec.md +++ /dev/null @@ -1,20 +0,0 @@ -## ADDED Requirements - -### Requirement: Mon observer resolves session roots from CLAUDE_DIR - -The `mon` observer SHALL derive the projects root and the statusline-output payloads root from `CLAUDE_DIR` (the same constant used by the statusline's main render path), rather than hardcoding `Path.home() / '.claude'`. When `CLAUDE_CONFIG_DIR` is set in the environment, `CLAUDE_DIR` resolves to that directory, and the `mon` observer SHALL find sessions and payloads there. - -#### Scenario: Custom CLAUDE_CONFIG_DIR is respected - -- **WHEN** `CLAUDE_CONFIG_DIR=/custom/claude` is set and sessions exist under `/custom/claude/projects/` -- **THEN** the observer discovers those sessions (rather than finding nothing under `~/.claude/projects/`) - -#### Scenario: Default behaviour is unchanged - -- **WHEN** `CLAUDE_CONFIG_DIR` is not set and sessions exist under `~/.claude/projects/` -- **THEN** the observer discovers sessions exactly as before - -#### Scenario: Payloads root also uses CLAUDE_DIR - -- **WHEN** `CLAUDE_CONFIG_DIR=/custom/claude` is set and payload files exist under `/custom/claude/statusline-output/` -- **THEN** the observer reads those payloads for session data diff --git a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/tasks.md b/openspec/changes/archive/2026-06-06-fix-mon-config-dir/tasks.md deleted file mode 100644 index c710d7d..0000000 --- a/openspec/changes/archive/2026-06-06-fix-mon-config-dir/tasks.md +++ /dev/null @@ -1,29 +0,0 @@ - - -## 1. Understand current code - -- [x] 1.1 Read `claude/mon/discovery.py` lines 1–55 to confirm both hardcoded `Path.home() / '.claude'` occurrences and verify `CLAUDE_DIR` is not already imported there -- [x] 1.2 Read `claude/yas/constants.py` to confirm the name and import path for `CLAUDE_DIR` -- [x] 1.3 Run `make test` and record the baseline pass count - -## 2. Apply the fix (two-line change) - -- [x] 2.1 Add `from yas.constants import CLAUDE_DIR` to the imports in `claude/mon/discovery.py` -- [x] 2.2 Replace `Path.home() / '.claude' / 'projects'` (the default argument on the projects-root parameter, line ~21) with `CLAUDE_DIR / 'projects'` -- [x] 2.3 Replace `Path.home() / '.claude' / 'statusline-output'` (the default argument on the payloads-root parameter, line ~43) with `CLAUDE_DIR / 'statusline-output'` - -## 3. Tests (can be done in parallel with step 4) - -- [x] 3.1 In `test/test_mon_discovery.py`: add a scenario that sets `CLAUDE_CONFIG_DIR` to a temp directory and confirms the discovery functions look there rather than `~/.claude` -- [x] 3.2 Confirm the existing discovery tests still pass (they should be unaffected if they use the `tmp_home` fixture or pass explicit paths) - -## 4. Grep check (can be done in parallel with step 3) - -- [x] 4.1 Run `grep -rn "Path.home.*\.claude" claude/mon/` and confirm no remaining hardcoded occurrences after the fix - -## 5. Verify - -- [x] 5.1 Run `make test` — must be green with count ≥ baseline + new tests added diff --git a/openspec/changes/archive/2026-06-06-fix-session-elapsed/.openspec.yaml b/openspec/changes/archive/2026-06-06-fix-session-elapsed/.openspec.yaml deleted file mode 100644 index c53ef21..0000000 --- a/openspec/changes/archive/2026-06-06-fix-session-elapsed/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-06-fix-session-elapsed/design.md b/openspec/changes/archive/2026-06-06-fix-session-elapsed/design.md deleted file mode 100644 index fa4b8a2..0000000 --- a/openspec/changes/archive/2026-06-06-fix-session-elapsed/design.md +++ /dev/null @@ -1,31 +0,0 @@ -## Context - -`SessionView.elapsed` in `info/__init__.py` computes `now − transcript_mtime` — the wall time since the last line was appended to the transcript JSONL. This is an idle metric: it resets to zero on every tool call, not a running total of session duration. - -Claude Code already tracks the real session wall-clock duration in milliseconds as `cost.total_duration_ms` on the stdin payload. This field is parsed into `session.cost.total_duration_ms` (`session.py:178,187`) but is never consumed by the renderer. - -`_fmt_elapsed` currently accepts a Unix timestamp (`mtime`) and a `now` to compute a delta. It needs to accept a raw duration in milliseconds instead. - -## Goals / Non-Goals - -**Goals:** -- Derive `SessionView.elapsed` from `session.cost.total_duration_ms` (ms → formatted string) -- Remove the transcript mtime stat from the `elapsed` property (transcript is still scanned for `transcript_usage`; only the elapsed-specific mtime read is removed) -- Update `_fmt_elapsed` (or replace with a `_fmt_duration_ms` helper) to take milliseconds - -**Non-Goals:** -- Changing the position, label, or visual style of the elapsed field in the layout -- Altering any other use of the transcript scan - -## Decisions - -**Replace `_fmt_elapsed(mtime, now)` with `_fmt_duration_ms(ms)`**: The new signature is simpler (no `now` needed) and clarifies the input unit. Keep `_fmt_elapsed` as a one-line wrapper calling `_fmt_duration_ms(max(0, now - mtime) * 1000)` if any test or caller still needs it, otherwise delete it. - -**`elapsed` property reads `self.session.cost.total_duration_ms`**: No file stat, no `now` dependency. The value is already in memory from the parsed payload. - -**Format**: Preserve the existing display format — `Nm` for under an hour, `HhMm` for ≥ 1 h — so the layout geometry is unchanged. - -## Risks / Trade-offs - -- Sessions started before Claude Code began populating `total_duration_ms` will show `0s` or `0m` rather than an idle-based estimate. This is a minor regression for very old sessions but is the correct trade-off for accuracy. -- Snapshot baselines that show an elapsed tail need re-baselining. diff --git a/openspec/changes/archive/2026-06-06-fix-session-elapsed/proposal.md b/openspec/changes/archive/2026-06-06-fix-session-elapsed/proposal.md deleted file mode 100644 index 5f95241..0000000 --- a/openspec/changes/archive/2026-06-06-fix-session-elapsed/proposal.md +++ /dev/null @@ -1,26 +0,0 @@ -## Why - -The wide layout's "elapsed" field is currently derived from the transcript file's `mtime` — the time since the last transcript write, which is an **idle metric** (how long since something was logged), not the session's actual compute duration. Claude Code already provides the authoritative wall-clock session duration as `cost.total_duration_ms` on the stdin payload. Using it makes the elapsed figure accurate and consistent with what Claude Code tracks internally. - -## What Changes - -- `SessionView.elapsed` in `info/__init__.py` will be rewritten to return a formatted duration derived from `session.cost.total_duration_ms` (milliseconds → `fmt_dur` / human-readable string), instead of computing `now − transcript_mtime`. -- `_fmt_elapsed` will be updated or replaced to accept a duration in milliseconds. -- The `transcript_mtime` path that was used solely for `elapsed` will be removed from `SessionView.elapsed` (the transcript is still scanned for `transcript_usage`; only the elapsed-specific mtime read is removed). -- Wide-layout snapshot baselines showing an elapsed tail will be re-baselined. - -## Capabilities - -### New Capabilities - -- `session-elapsed-accuracy`: The displayed elapsed value reflects actual session compute time from the host payload, not the idle-since-last-transcript-write heuristic. - -### Modified Capabilities - -*(none — replaces the implementation of an existing field; the rendered label and position are unchanged)* - -## Impact - -- `claude/yas/info/__init__.py`: `SessionView.elapsed`, `_fmt_elapsed` -- `claude/yas/session.py`: `Cost.total_duration_ms` (already parsed; newly consumed by `elapsed`) -- Tests: `test_info.py` — the elapsed scenario will change from mtime-based to duration-based inputs diff --git a/openspec/changes/archive/2026-06-06-fix-session-elapsed/specs/session-elapsed-accuracy/spec.md b/openspec/changes/archive/2026-06-06-fix-session-elapsed/specs/session-elapsed-accuracy/spec.md deleted file mode 100644 index 9366a04..0000000 --- a/openspec/changes/archive/2026-06-06-fix-session-elapsed/specs/session-elapsed-accuracy/spec.md +++ /dev/null @@ -1,25 +0,0 @@ -## ADDED Requirements - -### Requirement: Elapsed is derived from host-supplied session duration - -`SessionView.elapsed` SHALL be derived from `session.cost.total_duration_ms` (the host-supplied wall-clock session duration in milliseconds), not from the modification time of the transcript file. The displayed format SHALL remain `Nm` for durations under one hour and `HhMm` for durations of one hour or more. - -#### Scenario: Duration under one hour formats as minutes - -- **WHEN** `cost.total_duration_ms` is `807000` (13 minutes 27 seconds) -- **THEN** `elapsed` is `'13m'` - -#### Scenario: Duration of one hour or more formats as hours and minutes - -- **WHEN** `cost.total_duration_ms` is `5580000` (1 hour 33 minutes) -- **THEN** `elapsed` is `'1h33m'` - -#### Scenario: Zero duration returns empty string or zero representation - -- **WHEN** `cost.total_duration_ms` is `0` -- **THEN** `elapsed` is `''` or `'0m'` (consistent with existing behaviour for unknown duration) - -#### Scenario: Elapsed does not trigger a transcript file stat - -- **WHEN** `view.elapsed` is accessed on a `SessionView` whose `transcript_usage` has not been accessed -- **THEN** no file stat is performed for the elapsed property alone (the value comes from the in-memory payload) diff --git a/openspec/changes/archive/2026-06-06-fix-session-elapsed/tasks.md b/openspec/changes/archive/2026-06-06-fix-session-elapsed/tasks.md deleted file mode 100644 index add7db7..0000000 --- a/openspec/changes/archive/2026-06-06-fix-session-elapsed/tasks.md +++ /dev/null @@ -1,36 +0,0 @@ - - -## 1. Understand current code - -- [x] 1.1 Read `claude/yas/info/__init__.py` lines 30–135 (`_fmt_elapsed`, `SessionView.elapsed`) to understand the current mtime-based implementation -- [x] 1.2 Read `claude/yas/session.py` lines 175–190 (`Cost` dataclass) to confirm `total_duration_ms` field name and type -- [x] 1.3 Run `make test` and record the baseline pass count - -## 2. Update the format helper - -- [x] 2.1 Add a `_fmt_duration_ms(ms: int) -> str` function in `info/__init__.py` that converts milliseconds to `Nm` (< 1 h) or `HhMm` (≥ 1 h) — same output format as `_fmt_elapsed` produces today -- [x] 2.2 If `_fmt_elapsed` is used by any other caller, keep it as a wrapper: `_fmt_elapsed(mtime, now) -> str` calls `_fmt_duration_ms(int(max(0, now - mtime) * 1000))`. If it has no other callers, remove it. - -## 3. Rewrite SessionView.elapsed - -- [x] 3.1 Replace the body of `SessionView.elapsed` with: return `_fmt_duration_ms(self.session.cost.total_duration_ms)` -- [x] 3.2 Remove the `transcript_path` stat / mtime read that was done exclusively for `elapsed` (ensure the transcript is still scanned normally for `transcript_usage`) - -## 4. Tests (can be done in parallel with step 5) - -- [x] 4.1 In `test/test_info.py`: add scenario — `total_duration_ms=807000` (13m27s) → `elapsed == '13m'` -- [x] 4.2 Add scenario — `total_duration_ms=5580000` (1h33m) → `elapsed == '1h33m'` -- [x] 4.3 Add scenario — `total_duration_ms=0` → `elapsed` is `''` or `'0m'` (match chosen behaviour) -- [x] 4.4 Add scenario — accessing `view.elapsed` alone does NOT trigger a file stat (assert no `Path.stat` call) - -## 5. Re-baseline snapshots (can be done in parallel with step 4) - -- [x] 5.1 Run `make demo/img` and update any wide-layout snapshot baselines that show an elapsed tail value - -## 6. Verify - -- [x] 6.1 Run `make test` — must be green with count ≥ baseline + new tests added -- [x] 6.2 Run `make demo` — confirm the elapsed field in the wide layout shows a sensible duration (not a time-since-last-write idle value) diff --git a/openspec/changes/archive/2026-06-06-fix-terminal-width/.openspec.yaml b/openspec/changes/archive/2026-06-06-fix-terminal-width/.openspec.yaml deleted file mode 100644 index c53ef21..0000000 --- a/openspec/changes/archive/2026-06-06-fix-terminal-width/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-06-fix-terminal-width/design.md b/openspec/changes/archive/2026-06-06-fix-terminal-width/design.md deleted file mode 100644 index e5685e6..0000000 --- a/openspec/changes/archive/2026-06-06-fix-terminal-width/design.md +++ /dev/null @@ -1,29 +0,0 @@ -## Context - -`terminal_width()` in `render/text.py` probes width in this order: tmux (subprocess) → `CLAUDE_DIR/terminal-width` file → `COLUMNS` env → `shutil.get_terminal_size` → fd probes → `/dev/tty`. - -Claude Code v2.1.153+ sets `COLUMNS` to the exact width it allocated for the statusline before invoking the process. This is always the right answer when present, but it's currently checked third. Meanwhile, the tmux subprocess (checked first) has no timeout; a wedged tmux server blocks every render until the OS kills the subprocess. - -## Goals / Non-Goals - -**Goals:** -- Check `COLUMNS` first — it is instant, zero-subprocess, and authoritative when Claude Code sets it -- Add `timeout=0.2` to the `subprocess.run` tmux probe so a wedged server is bounded to 200 ms -- Preserve the rest of the fallback chain unchanged - -**Non-Goals:** -- Removing any fallback source -- Changing default width or any threshold constant - -## Decisions - -**`COLUMNS` first**: Claude Code's allocated width is the correct answer. No subprocess or file read can be more authoritative. Move the `os.environ.get('COLUMNS')` block to position 1. - -**tmux stays second** (after COLUMNS): The tmux width is useful for users who run the statusline outside Claude Code. It must be after `COLUMNS` so Claude Code's value wins. - -**`timeout=0.2`**: 200 ms is enough for a healthy tmux to respond and negligible for a normal render cycle. On timeout, `subprocess.run` raises `subprocess.TimeoutExpired`; add it to the existing except clause. - -## Risks / Trade-offs - -- Users who rely on a tmux-set width and also have `COLUMNS` set to something else (unusual) will now see `COLUMNS`. This is the correct behaviour since `COLUMNS` is set by the calling process. -- 200 ms timeout may be tight on extremely slow systems; the fallback chain still resolves width from other sources. diff --git a/openspec/changes/archive/2026-06-06-fix-terminal-width/proposal.md b/openspec/changes/archive/2026-06-06-fix-terminal-width/proposal.md deleted file mode 100644 index 55f499e..0000000 --- a/openspec/changes/archive/2026-06-06-fix-terminal-width/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Why - -The `terminal_width()` function probes width sources in the wrong order, and the tmux probe has no timeout. Claude Code (v2.1.153+) sets the `COLUMNS` environment variable to the exact pixel-accurate width it has allocated for the statusline; checking `COLUMNS` first makes the width instantaneous and correct. Meanwhile, the current tmux probe (checked first) can hang indefinitely when a tmux server is wedged, blocking every statusline render until Claude Code kills the process. - -## What Changes - -- `terminal_width()` in `render/text.py` will check `COLUMNS` as its **first** source, before any subprocess or file read. -- The `subprocess.run` tmux probe will gain `timeout=0.2` so a wedged tmux server blocks the render for at most 200 ms rather than indefinitely. -- Source order after the fix: `COLUMNS` → tmux (with timeout) → `CLAUDE_DIR/terminal-width` file → `shutil.get_terminal_size` → `os.get_terminal_size` fds → `/dev/tty`. - -## Capabilities - -### New Capabilities - -- `terminal-width-resolution`: Width is resolved by checking `COLUMNS` first (instant, authoritative when Claude Code sets it), with a bounded tmux fallback. - -### Modified Capabilities - -*(none — same function, corrected probe order and timeout)* - -## Impact - -- `claude/yas/render/text.py`: `terminal_width()` function -- Tests: any test that stubs `COLUMNS` or mocks the tmux probe may need updating diff --git a/openspec/changes/archive/2026-06-06-fix-terminal-width/specs/terminal-width-resolution/spec.md b/openspec/changes/archive/2026-06-06-fix-terminal-width/specs/terminal-width-resolution/spec.md deleted file mode 100644 index 1c9f16a..0000000 --- a/openspec/changes/archive/2026-06-06-fix-terminal-width/specs/terminal-width-resolution/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -## ADDED Requirements - -### Requirement: COLUMNS environment variable is the first width source - -`terminal_width()` SHALL check the `COLUMNS` environment variable as its first width source, before any subprocess invocation or file read. When `COLUMNS` is set to a positive integer, that value SHALL be returned immediately without probing tmux, reading `CLAUDE_DIR/terminal-width`, or calling `shutil.get_terminal_size`. - -#### Scenario: COLUMNS is returned immediately when set - -- **WHEN** `COLUMNS=160` is set in the environment -- **THEN** `terminal_width()` returns `160` without invoking the tmux subprocess or reading any file - -#### Scenario: Falls through to tmux when COLUMNS is absent - -- **WHEN** `COLUMNS` is not set and a tmux pane is active -- **THEN** the tmux pane width is used - -#### Scenario: Falls through to file fallback when COLUMNS is zero - -- **WHEN** `COLUMNS=0` is set in the environment -- **THEN** `terminal_width()` continues to the next source (tmux or file) - -### Requirement: Tmux subprocess probe has a bounded timeout - -The `subprocess.run` call that probes the tmux pane width SHALL pass `timeout=0.2` (200 milliseconds). When the subprocess times out or raises `subprocess.TimeoutExpired`, the function SHALL catch the exception and continue to the next width source without blocking further. - -#### Scenario: Wedged tmux server does not hang the render - -- **WHEN** the tmux subprocess does not respond within 200 ms -- **THEN** `terminal_width()` catches `TimeoutExpired`, skips the tmux result, and continues to the next source - -#### Scenario: Healthy tmux still returns the pane width - -- **WHEN** the tmux subprocess responds within the timeout with a positive integer -- **THEN** that integer is returned (assuming `COLUMNS` was not set) diff --git a/openspec/changes/archive/2026-06-06-fix-terminal-width/tasks.md b/openspec/changes/archive/2026-06-06-fix-terminal-width/tasks.md deleted file mode 100644 index df5d3b2..0000000 --- a/openspec/changes/archive/2026-06-06-fix-terminal-width/tasks.md +++ /dev/null @@ -1,31 +0,0 @@ - - -## 1. Understand current code - -- [x] 1.1 Read `claude/yas/render/text.py` lines 1–55 (`terminal_width()` function) to confirm the current probe order and the tmux `subprocess.run` call signature -- [x] 1.2 Run `make test` and record the baseline pass count - -## 2. Fix probe order and tmux timeout - -- [x] 2.1 Move the `COLUMNS` env-var block (currently the third probe) to be the **first** check in `terminal_width()`, before the tmux subprocess block -- [x] 2.2 Add `timeout=0.2` to the `subprocess.run(["tmux", ...])` call -- [x] 2.3 Add `subprocess.TimeoutExpired` to the existing `except` tuple on the tmux block so a timeout is caught and the function falls through to the next source - -## 3. Tests (can be done in parallel with step 4) - -- [x] 3.1 In `test/test_terminal_width.py` (or the closest existing test file): add scenario — `COLUMNS=160` set → `terminal_width()` returns `160` without calling `subprocess.run` -- [x] 3.2 Add scenario — `COLUMNS` not set, tmux returns `120` within timeout → returns `120` -- [x] 3.3 Add scenario — `COLUMNS` not set, tmux `subprocess.run` raises `TimeoutExpired` → function continues to next source (does not raise) -- [x] 3.4 Add scenario — `COLUMNS=0` → function skips to next source - -## 4. Review other tests (can be done in parallel with step 3) - -- [x] 4.1 Search existing tests for any that mock `subprocess.run` for the tmux probe or patch `os.environ['COLUMNS']`; update them if the probe-order change breaks their assumptions - -## 5. Verify - -- [x] 5.1 Run `make test` — must be green with count ≥ baseline + new tests added -- [x] 5.2 Manually confirm: `COLUMNS=99 uv run python claude/statusline_command.py < ops/session-info-example.json` produces a 99-column render diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/.openspec.yaml b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/.openspec.yaml deleted file mode 100644 index b4c82a0..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-06 diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/design.md b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/design.md deleted file mode 100644 index e3e95eb..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/design.md +++ /dev/null @@ -1,64 +0,0 @@ -## Context - -The wide layout is built by `build_wide` in `claude/yas/layout.py`. It emits a flat list of `RowSpec`s: a path/model row, a context line, the tokens block, then a sequence of optional **dynamic** sections — plugins, the task checklist, the subagent cohort, and openspec bars — each separated by a dim dotted separator (the first such separator is a heavy "seam" marking the static→dynamic split). `render_layout` walks the rows and dispatches each `kind` to a `Renderer`/`BorderRenderer` method. - -Two section renderers are involved: -- `Renderer.task_row(tasks, width, *, compact)` returns a list of content lines: a header (Total Elapsed + glyph + `done/total`) and an active-anchored window of item rows (timer column + `N.` + subject). It derives its inner width from the terminal `width` (`inner_w = width - 3`). -- `Renderer.subagent_row(sub, width, session_inout)` returns one (`\n`-joined) string. Above `width > 100` it produces a two-line form (identity line `▶ type · desc`; continuation line `└ activity … t/m · share · tok↑out · dur · model`); otherwise a one-line collapse. It derives `target_w = width - 4` and self-selects the form from `width`. - -Both sections are appended to the row list independently, each preceded by its own separator. When both are present the checklist's right margin is mostly empty while the cohort consumes extra vertical rows. - -Border elbows are threaded via `RowSpec.downs`/`ups` (1-indexed visual columns). `border_separator_dim` already supports both `downs` and `ups` (drawing `┬`/`┴`/`┼`); `border_separator` (used for the seam and openspec separators) currently supports only `ups`. - -## Goals / Non-Goals - -**Goals:** -- Place the task checklist and subagent cohort in two columns within one bordered block in the wide layout when both are present and there is room. -- Redesign the subagent two-line row to be denser (duration-first, `share% · tok · model` cluster, activity-only line 2) and drop the t/m and ↑output fields everywhere. -- Keep all geometry decisions in the layout builder; keep the section renderers pure width-parametric functions. -- Fall back cleanly to today's stacked layout when there isn't room, and leave medium/narrow untouched. - -**Non-Goals:** -- No change to medium or narrow layouts beyond the subagent field-set removals that apply to all forms. -- No change to which subagents are visible (`RunningSubagents.visible`) or to the Session Share % denominator (`statusline-info`). -- No combined-height cap beyond what the existing visibility/window logic already produces. -- No new run-state marker glyph to replace `▶`/`✓` — dim/colour carries the distinction. - -## Decisions - -### D1 — Side-by-side lives entirely in `build_wide` -The composition is a wide-only special case computed inside `build_wide`. Medium/narrow builders are unchanged. *Alternative considered:* a shared helper used by medium too — rejected because medium rarely has the ~92+ columns needed and the added conditional complexity isn't worth it. - -### D2 — Two columns: left = tasks (capped), right = subagents (remainder) -`inner = width - 4`; divider is ` │ ` (3 visible cols). `left_w = min(longest_task_line, floor(inner * 0.45))`; `right_w = inner - 3 - left_w`. If `right_w < 40`, abandon and stack. *Alternative:* fixed 50/50 — rejected: wastes space on short task lists and risks a too-narrow cohort column. - -### D3 — Render each column independently, then zip -Each column is rendered to a list of lines at its own content width, then combined row-by-row to `max(len(left), len(right))`, padding the shorter column with blank lines of its own width (top-aligned). Each combined row is `f'{left_padded} {divider} {right_padded}'`, emitted as a plain `content` `RowSpec`. *Alternative:* interleave at the builder per agent/task — rejected: couples the two sections' internal layout and breaks the clean per-column renderers. - -### D4 — Content-width + form parametrization on the renderers -`task_row` and `subagent_row` gain an explicit content-width parameter; `subagent_row`'s `width > 100` self-decision becomes a builder-supplied form flag (e.g. `twoline: bool`). This lets a ~48-col side-by-side column still use the two-line form. The full-width callers pass the form they'd have selected from terminal width, preserving current behaviour. *Alternative:* dedicated column-variant renderers — rejected: duplicates layout logic. - -### D5 — Divider elbows via `border_separator` `downs` -Add a `downs` parameter to `border_separator` mirroring `border_separator_dim`'s `┬`/`┴`/`┼` branch, so the heavy seam above the block can grow a `┬` at the divider column. The separator (or bottom border) below grows a matching `┴`. The divider column is `3 + left_w + 1` (1-indexed visual: content starts at visual col 3, the left column occupies `left_w`, one pad space, then `│`). *Alternative:* don't connect the divider to the separator above — rejected per the grilling decision; the floating top reads as a bug. - -### D6 — Subagent two-line redesign (applies to all wide two-line uses) -Line 1: `{dur} {type} · {description}` with right-aligned `· {share%} {tok} · {model}`. No `▶`/`✓` marker. Done → dim + frozen duration; running → coloured + ticking duration. Line 2: activity-only `└ {glyph} {Tool[arg]}`. Drop the t/m rate and ↑output fields. Line-1 cluster sheds share% → tok; model + duration always retained; description truncates first. The one-line form only loses ↑output, otherwise unchanged. - -### D7 — Section ordering and the seam -The side-by-side block occupies the position the checklist currently holds (before the cohort, before openspec). It removes the separator that used to sit between checklist and cohort. The block's top separator carries the existing `pending_ups` seam threading **and** the new divider `downs`; the following separator/border carries the divider `ups`. - -## Risks / Trade-offs - -- **[Seam can't grow a `┬` today]** → D5 extends `border_separator` with `downs`; covered by a `test_borders.py` case. -- **[Column math off-by-one draws a crooked box]** → divider column derived once and threaded into both bracketing separators; verified by `make demo` across thresholds and an alignment assertion in `test_layout_seam.py`. -- **[Narrow right column makes the two-line cluster illegible]** → the `right_w < 40` fallback to stacked, plus the documented shed order (share% → tok), bound the worst case. -- **[Dropping the run-state marker loses the at-a-glance running/done cue]** → mitigated by retaining dim styling for Done and live colour + ticking timer for running (D6); `subagent-cohort` spec updated accordingly. -- **[Removing t/m and ↑output is a visible contract change]** → reflected in the `subagent-row-layout` spec and `CONTEXT.md` glossary; Session Share % denominator is unchanged. - -## Migration Plan - -Pure rendering change; no data migration. Ships in one PR. Rollback is reverting the PR — no persisted state or schema is affected. Medium/narrow output is byte-identical except for the universal ↑output/t-m removals in subagent rows. - -## Open Questions - -None outstanding — the design decisions above were resolved during grilling. diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/proposal.md b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/proposal.md deleted file mode 100644 index 6f789a4..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/proposal.md +++ /dev/null @@ -1,30 +0,0 @@ -## Why - -In the wide layout the task checklist wastes most of its horizontal room — short task subjects leave a large empty right margin — while the subagent cohort, stacked below it, fights for vertical space. When both sections are present there is enough width to place them side-by-side, using the idle right margin of the checklist for the subagent column and shortening the box. At the same time the subagent two-line row carries low-signal fields (t/m rate, ↑output) that crowd the cluster and can be dropped to make the denser side-by-side column legible. - -## What Changes - -- **Side-by-side composition (wide only):** when both the task checklist and at least one visible subagent are present and the terminal is wide enough, render the checklist as a left column and the subagent cohort as a right column, separated by a single gradient `│` divider, instead of stacking them as two full-width sections. -- **Content-driven split:** the left (task) column is sized to fit its content up to 45% of the inner width; the subagent column takes the remainder. If the remainder would be narrower than 40 columns, the side-by-side layout is abandoned and the two sections stack full-width exactly as today (graceful fallback). -- **Height reconciliation:** the two columns are rendered independently to their own widths, then zipped top-aligned to the taller column's height, padding the shorter column with blank lines so the divider runs straight from the separator above to the separator below. -- **Divider elbows:** `border_separator` gains `downs` support (mirroring `border_separator_dim`) so the heavy static→dynamic seam can grow a `┬` at the divider column; the separator below grows a matching `┴`. -- **Subagent two-line row redesign (applies in all wide uses, not only side-by-side):** the run-state `▶`/`✓` marker is removed; the elapsed duration moves to the front of line 1; the right cluster becomes `share% · tok · model` (shedding share% → tok under width pressure, always keeping model and duration); line 2 becomes activity-only. The t/m rate and ↑output fields are removed. -- **Subagent one-line row:** the ↑output field is removed; the row is otherwise unchanged. -- **Builder-driven geometry:** `task_row` and `subagent_row` take an explicit content-width, and the subagent two-line/one-line form becomes a builder-supplied flag rather than a `width > 100` self-decision, so a narrow side-by-side column can still use the two-line form. - -## Capabilities - -### New Capabilities -- `side-by-side-sections`: the wide-layout two-column composition of the task checklist and subagent cohort — the both-present-and-wide-enough trigger, the content-driven column split, the shared gradient divider with border-elbow threading, top-aligned height reconciliation, and the right-column-width fallback to stacked rendering. -- `subagent-row-layout`: the field set and structure of a rendered subagent row — the front-anchored duration, the line-1 `share% · tok · model` cluster and its shed order, the activity-only continuation line, the one-line collapse form, and the removal of the t/m rate and ↑output fields across all forms. - -### Modified Capabilities -- `subagent-cohort`: the **Finished-agent visual treatment** requirement no longer distinguishes running from Done via the `▶`/`✓` markers (the duration now occupies that leading position); Done remains dim with a frozen duration, running remains coloured with a live-ticking duration. -- `task-checklist`: the **Layout-specific rendering** requirement is extended so the wide checklist renders into a builder-supplied content width and may appear as the left column of a side-by-side section. - -## Impact - -- **Code:** `claude/yas/layout.py` (`build_wide` composition, divider/elbow threading, fallback guard), `claude/yas/renderer.py` (`task_row`, `subagent_row`, content-width + form parametrization), `claude/yas/render/borders.py` (`border_separator` `downs` support). -- **Display contract:** the subagent row drops the t/m rate and ↑output; `CONTEXT.md` glossary updated if either term is documented there. Session Share % denominator (`statusline-info`) is unchanged — share% is still computed, only conditionally shed. -- **Tests:** `test/test_borders.py` (`border_separator` downs), `test/test_subagent_rows.py` (new two-line + one-line content), `test/test_layout_seam.py` (side-by-side composition, fallback guard). Visual check via `make demo` across the narrow ↔ medium ↔ wide thresholds. -- **No new dependencies.** Medium and narrow layouts are untouched. diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/side-by-side-sections/spec.md b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/side-by-side-sections/spec.md deleted file mode 100644 index ba8da46..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/side-by-side-sections/spec.md +++ /dev/null @@ -1,81 +0,0 @@ -## ADDED Requirements - -### Requirement: Side-by-side trigger - -In the **wide** layout only, when the task checklist is visible AND at least one subagent is visible, the renderer SHALL attempt to compose the two sections as adjacent columns within a single bordered block rather than stacking them as two full-width sections. In the **medium** and **narrow** layouts the two sections SHALL continue to stack full-width. When only one of the two sections is present, that section SHALL render full-width exactly as before this change. - -#### Scenario: Both sections present in a wide layout - -- **WHEN** a wide layout is built, the checklist is visible, and at least one subagent is visible -- **THEN** the checklist and subagent cohort are composed as side-by-side columns in one block (subject to the width fallback below) - -#### Scenario: Only one section present - -- **WHEN** a wide layout is built and exactly one of the checklist or subagent cohort is present -- **THEN** that section renders full-width and stacked exactly as before this change - -#### Scenario: Medium and narrow always stack - -- **WHEN** a medium or narrow layout is built with both sections present -- **THEN** the sections stack full-width and the side-by-side composition is not used - -### Requirement: Content-driven column split with fallback - -The left column SHALL render the task checklist and the right column SHALL render the subagent cohort. The left column width SHALL be the smaller of the widest task line and 45% of the inner content width. The right column SHALL receive the remaining inner width after subtracting the left column and the divider. If the resulting right column would be narrower than 40 visible columns, the renderer SHALL abandon the side-by-side composition and stack the two sections full-width. - -#### Scenario: Short task list yields a narrow left column - -- **WHEN** the widest task line is narrower than 45% of the inner width -- **THEN** the left column is sized to the widest task line and the right column receives the rest - -#### Scenario: Long task list is capped - -- **WHEN** the widest task line exceeds 45% of the inner width -- **THEN** the left column is capped at 45% of the inner width and task subjects truncate to fit - -#### Scenario: Insufficient remaining width falls back to stacked - -- **WHEN** the computed right column would be narrower than 40 visible columns -- **THEN** the side-by-side composition is abandoned and both sections render full-width and stacked - -### Requirement: Column divider and elbow threading - -The two columns SHALL be separated by a single vertical `│` drawn in the border gradient at a fixed divider column, padded by one space on each side. Every combined content row SHALL carry the divider at the same column. The separator above the block SHALL grow a `┬` at the divider column and the separator (or bottom border) below the block SHALL grow a matching `┴`, so the divider connects to the box top and bottom. To support a `┬` on the heavy static→dynamic seam, `border_separator` SHALL accept downward elbow columns. - -#### Scenario: Divider is continuous top to bottom - -- **WHEN** a side-by-side block is rendered -- **THEN** a `┬` appears on the separator above at the divider column, a `│` appears in every combined row at that column, and a `┴` appears on the separator (or bottom border) below at that column - -#### Scenario: Divider colour follows the border gradient - -- **WHEN** the divider is drawn at its column -- **THEN** its colour is taken from the border gradient at that column, consistent with other vertical seams - -### Requirement: Height reconciliation - -The two columns SHALL be rendered independently to their own content widths, then combined row-by-row up to the height of the taller column. The shorter column SHALL be padded with blank lines of its own column width, top-aligned, so the divider and the right edge remain straight and every combined row spans the full inner width. - -#### Scenario: Subagent column taller than task column - -- **WHEN** the subagent column produces more lines than the task column -- **THEN** the task column is padded with blank lines at the bottom and the divider runs straight through the padded rows - -#### Scenario: Task column taller than subagent column - -- **WHEN** the task column produces more lines than the subagent column -- **THEN** the subagent column is padded with blank lines at the bottom and every combined row spans the full inner width - -### Requirement: Builder-driven content width - -`Renderer.task_row` and `Renderer.subagent_row` SHALL render into an explicit content width supplied by the layout builder rather than deriving width from the terminal width. The subagent two-line versus one-line form SHALL be selected by a builder-supplied flag rather than an internal terminal-width threshold, so a narrow side-by-side column can still use the two-line form. - -#### Scenario: Section renderers honour the supplied width - -- **WHEN** a layout builder calls `task_row` or `subagent_row` with a content width -- **THEN** the produced lines fit within that width as measured by the visible-width helper - -#### Scenario: Two-line form forced in a narrow column - -- **WHEN** the builder composes a side-by-side block and requests the subagent two-line form for a narrow column -- **THEN** the subagent rows render in two-line form regardless of the column width diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-cohort/spec.md b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-cohort/spec.md deleted file mode 100644 index 45fcfa6..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-cohort/spec.md +++ /dev/null @@ -1,20 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Finished-agent visual treatment - -A Done subagent's row SHALL be visually distinguished from a running one by dimming and a frozen timer, not by a leading marker glyph. The running/Done distinction SHALL NOT use the `▶`/`✓` markers — the elapsed duration now occupies that leading position. A Done row SHALL be dimmed (overriding the running row's rainbow marker and field colours) and its elapsed field SHALL be frozen at `end_ts − first_timestamp` rather than continuing to tick from the current time. A running row SHALL render with live colours and a live-ticking elapsed field. - -#### Scenario: Done row shows dimmed styling and a frozen timer - -- **WHEN** a subagent is Done and still within the cohort grace window -- **THEN** its row renders with dimmed colours and a frozen elapsed duration, with no `▶`/`✓` marker - -#### Scenario: Elapsed freezes at completion - -- **WHEN** a subagent is Done -- **THEN** its elapsed field shows `end_ts − first_timestamp` and does not increase on subsequent renders - -#### Scenario: Running row uses live colours and a ticking timer - -- **WHEN** a subagent is still running -- **THEN** its row renders with live colours and a live-ticking elapsed field, with no `▶`/`✓` marker diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-row-layout/spec.md b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-row-layout/spec.md deleted file mode 100644 index f6df4ab..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/subagent-row-layout/spec.md +++ /dev/null @@ -1,38 +0,0 @@ -## ADDED Requirements - -### Requirement: Two-line row field set - -A subagent rendered in two-line form SHALL place the elapsed duration at the front of line 1, followed by the agent type, a `·` separator, and the description. Line 1 SHALL end with a right-aligned cluster of `share% · tok · model`. Line 2 SHALL show only the activity continuation (`└` + activity glyph + tool/verb), with no right-aligned metrics. The t/m rate and ↑output fields SHALL NOT appear in either line. - -#### Scenario: Two-line row renders duration-first with the line-1 cluster - -- **WHEN** a subagent is rendered in two-line form with room for all fields -- **THEN** line 1 reads ` · ` with a right-aligned `share% · tok · model` cluster, and line 2 reads `└ ` - -#### Scenario: No t/m rate or output token field - -- **WHEN** any subagent row is rendered -- **THEN** neither the t/m rate field nor the ↑output field appears - -### Requirement: Line-1 cluster shedding - -When line 1 lacks room for the full `share% · tok · model` cluster, the description SHALL truncate first. If the cluster still does not fit, fields SHALL shed in order: share% first, then tok. The model and the front duration SHALL always be retained. - -#### Scenario: Description truncates before the cluster sheds - -- **WHEN** line 1 is too wide for the full description plus cluster -- **THEN** the description truncates with an ellipsis while the full cluster is retained - -#### Scenario: Cluster sheds share% then tok under width pressure - -- **WHEN** the truncated description plus full cluster still exceeds the width -- **THEN** share% is dropped first, then tok, while model and the front duration remain - -### Requirement: One-line collapse form - -A subagent rendered in one-line (collapsed) form SHALL omit the ↑output field. Its remaining structure — leading marker, agent type, model, activity verb, and the right-aligned token and duration fields — SHALL be unchanged. - -#### Scenario: One-line form drops output but keeps token and duration - -- **WHEN** a subagent is rendered in one-line collapsed form -- **THEN** the ↑output field is absent and the token count and duration fields remain diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/task-checklist/spec.md b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/task-checklist/spec.md deleted file mode 100644 index aa9c1e9..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/specs/task-checklist/spec.md +++ /dev/null @@ -1,25 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Layout-specific rendering - -The checklist SHALL render the full header + Active Window in the **wide** and **medium** layouts. In the **narrow** layout it SHALL render a single compact line — the checklist glyph, `done/total`, and the active task's live timer when a task is `in_progress` — and no subject text. Each full-list item row SHALL show a state glyph (distinct constants for `pending`, `in_progress`, `completed`), the task subject truncated to fit, and a right-aligned Task Timer column; completed durations render dim, the in_progress live timer renders bright, pending rows show no timer. All column widths SHALL be measured with the visible-width helper, never `len()`. The wide checklist SHALL render into a content width supplied by the layout builder, and MAY appear as the left column of a side-by-side section when a subagent cohort is also present; in that case it renders into the narrower left-column width using the same header + Active Window structure. - -#### Scenario: Wide and medium show the full checklist - -- **WHEN** a wide or medium layout is built and the checklist is visible -- **THEN** it renders the header (glyph + done/total + Total Elapsed) followed by the Active Window of item rows - -#### Scenario: Narrow shows the compact line - -- **WHEN** a narrow layout is built and the checklist is visible -- **THEN** it renders one line of glyph + done/total + the active task's live timer (omitted if nothing is in_progress), with no per-item rows - -#### Scenario: Timers align in a trailing column - -- **WHEN** multiple item rows carry timers -- **THEN** the timer values right-align in a fixed trailing column and subjects truncate with an ellipsis before that column - -#### Scenario: Checklist renders into a side-by-side left column - -- **WHEN** a wide layout composes a side-by-side section and the checklist is the left column -- **THEN** the checklist renders its header + Active Window into the supplied left-column width, with subjects truncating to fit that width diff --git a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/tasks.md b/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/tasks.md deleted file mode 100644 index 50e2257..0000000 --- a/openspec/changes/archive/2026-06-07-side-by-side-tasks-subagents/tasks.md +++ /dev/null @@ -1,78 +0,0 @@ - - -## 1. Shared baseline [SEQUENTIAL — orchestrator, do first] - -- [x] 1.1 Read `CONTEXT.md` and note the canonical terms touched (subagent row fields, t/m rate, output); confirm whether "t/m"/"↑output" appear in the glossary -- [x] 1.2 Run the PUA glyph catalogue over `claude/yas/renderer.py`, `claude/yas/render/borders.py`, `claude/yas/layout.py`; record which touched lines carry PUA glyphs (hoist to `constants.py` before any Edit that hits them) -- [x] 1.3 Baseline `make test` and record the pass count; baseline `make demo` and eyeball the current stacked tasks+subagents at wide width - -## 2. Border separator downs support [PARALLEL — Worker A; file: render/borders.py] - -- [x] 2.1 Add a `downs: tuple[int, ...]` parameter to `BorderRenderer.border_separator`, drawing `┬` at down columns, `┴` at up columns, and `┼` where both coincide (mirror the branch in `border_separator_dim`) -- [x] 2.2 Update the `Renderer.border_separator` delegator and the `separator`/`separator_seam` branches in `render_layout` to pass `downs` through -- [x] 2.3 Add `test/test_borders.py` cases: `border_separator` draws `┬` at a down column, `┴` at an up column, and `┼` where they coincide; assert positions via `_visible_width` -- [x] 2.4 `make test` green for the borders module - -## 3. Subagent row redesign [PARALLEL — Worker B; isolated worktree; files: renderer.py `subagent_row`, layout.py call sites, constants.py if PUA] - -- [x] 3.1 Add an explicit content-width parameter and a `twoline: bool` form flag to `Renderer.subagent_row`; remove the internal `width > 100` self-decision (D4) -- [x] 3.2 Two-line form (D6): move elapsed duration to the front of line 1; drop the `▶`/`✓` marker; render line 1 as `{dur} {type} · {description}` -- [x] 3.3 Two-line line-1 right cluster: `· {share%} {tok} · {model}`; REMOVE the t/m rate and ↑output fields entirely -- [x] 3.4 Two-line line 2: activity-only `└ {glyph} {Tool[arg]}`, no right-aligned metrics -- [x] 3.5 Done vs running treatment (subagent-cohort delta): Done → dim colours + frozen duration; running → live colours + ticking duration; no marker glyph either way -- [x] 3.6 Line-1 cluster shedding (D6 / subagent-row-layout): truncate description first, then shed share% → tok; always keep model and the front duration -- [x] 3.7 One-line collapse form: remove the ↑output field only; leave marker/type/model/verb/token/duration otherwise unchanged -- [x] 3.8 Update `subagent_row` call sites in `build_narrow`/`build_medium`/`build_wide` to pass the content-width and the form flag the builder would have chosen from terminal width (preserve current full-width behaviour) -- [x] 3.9 Update/add `test/test_subagent_rows.py`: two-line duration-first + `share%·tok·model` cluster, absence of t/m and ↑output, line-2 activity-only, shed order, done-dim/frozen vs running-live, one-line drops ↑output; widths via `_visible_width` -- [x] 3.10 `make test` green for the subagent row tests - -## 4. Task row content-width [PARALLEL — Worker C; isolated worktree; files: renderer.py `task_row`, layout.py call sites] - -- [x] 4.1 Add an explicit content-width parameter to `Renderer.task_row`; render header + Active Window into the supplied width instead of deriving from terminal `width` (`inner_w`) (D4 / task-checklist delta) -- [x] 4.2 Confirm subject truncation and the trailing timer-column alignment still hold at narrow supplied widths (the future left-column width) -- [x] 4.3 Update `task_row` call sites in `build_narrow`/`build_medium`/`build_wide` to pass the content-width (preserve current full-width behaviour) -- [x] 4.4 Update/add `test/test_task_checklist`-area tests: `task_row` honours a supplied narrow width, subjects truncate to fit, timers right-align; widths via `_visible_width` -- [x] 4.5 `make test` green for the task row tests - -## 5. Side-by-side composition in build_wide [SEQUENTIAL — after 2, 3, 4 merged; file: layout.py] - -- [x] 5.1 Add a helper that, given the rendered left (task) and right (subagent) line lists and their column widths, zips them top-aligned to the taller height, padding the shorter with blank lines of its own width, and joins each row as `{left_pad} {divider} {right_pad}` (D3 / height-reconciliation requirement) -- [x] 5.2 Compute the split in `build_wide`: `inner = width - 4`; `left_w = min(longest_task_line, floor(inner*0.45))`; `right_w = inner - 3 - left_w`; divider ` │ ` at `divider_col = 3 + left_w + 1` (D2) -- [x] 5.3 Gate the composition: only when the wide layout has BOTH a visible checklist AND ≥1 visible subagent; if `right_w < 40`, fall back to today's stacked sections (side-by-side-sections trigger + fallback) -- [x] 5.4 Render the left column via `task_row(content_width=left_w)` and the right column via `subagent_row(content_width=right_w, twoline=True)` for each visible subagent; build the combined `content` RowSpecs -- [x] 5.5 Thread divider elbows: the separator above the block gets `downs=(divider_col,)` alongside the existing `pending_ups` seam threading; the separator/border below gets `ups=(divider_col,)` (D5/D7); remove the now-defunct separator that sat between checklist and cohort -- [x] 5.6 Preserve the unchanged stacked path for the one-section and below-threshold cases; verify medium/narrow builders are untouched - -## 6. Integration, docs, and visual check [SEQUENTIAL — after 5] - -- [x] 6.1 Add `test/test_layout_seam.py` cases: inject a `SessionView` with both a checklist and a subagent cohort at a wide width → assert a side-by-side block with a continuous divider column (matching `┬`/`│`/`┴`); and at a width that forces `right_w < 40` → assert stacked fallback -- [x] 6.2 Add a layout test asserting the single-section cases (tasks-only, subagents-only) still render full-width and stacked -- [x] 6.3 Update `CONTEXT.md` if "t/m" / "↑output" are documented — record their removal from the subagent row; confirm Session Share % denominator wording is unchanged -- [x] 6.4 Full `make test` green at or above the baseline pass count plus the added tests -- [x] 6.5 `make demo` across narrow ↔ medium ↔ wide: confirm every `┬` lines up with the divider `│` and a `┴`; the box closes straight; the side-by-side block appears only when both sections are present and wide enough -- [x] 6.6 `openspec validate "side-by-side-tasks-subagents"` passes diff --git a/openspec/changes/archive/2026-06-08-compact-tokens-row/.openspec.yaml b/openspec/changes/archive/2026-06-08-compact-tokens-row/.openspec.yaml deleted file mode 100644 index 11967fc..0000000 --- a/openspec/changes/archive/2026-06-08-compact-tokens-row/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-08-compact-tokens-row/design.md b/openspec/changes/archive/2026-06-08-compact-tokens-row/design.md deleted file mode 100644 index a0386d9..0000000 --- a/openspec/changes/archive/2026-06-08-compact-tokens-row/design.md +++ /dev/null @@ -1,123 +0,0 @@ -## Context - -The wide layout's tokens/cost row is produced by `Renderer.tokens_cost` -(`claude/yas/renderer.py`), which returns **two** content lines: - -- line 1 — session `↓ in (cache) ↑ out` │ session cost │ rate label + sparkline **top** half -- line 2 — day `↓ in (cache) ↑ out` │ day cost │ (blank) + sparkline **bottom** half - -The two-row height has two independent causes: (a) session figures are stacked -over day figures, and (b) the sparkline is two rows tall, built from "Symbols for -Legacy Computing" half-block rise/fall glyphs (`SPARK_RISE_*` / `SPARK_FALL_*`, -U+1FBxx) via `GradientEngine._spark_rise/_spark_fall/_spark_flat` and `sparkline`. -The history window is `TokenRate.WINDOW * 2` (120s) at the `tokens_cost` call site. - -`build_wide` (`claude/yas/layout.py`) consumes `tokens_cost`'s -`(line_tokens, vsep_cols, spark_mark_col)` triple: it threads `vsep_cols` as the -elbow columns on the separator above, appends each line of `line_tokens` as a -`content` row, and uses `spark_mark_col` for the 60s tick marker. A throwaway -prototype (`ops/proto_compact_tokens_row.py`) validated three single-line shapes; -the chosen shape is the `session/day` slash-merge with a paired cache parenthetical. - -The config layer (`claude/yas/config.py`, `Config` frozen dataclass) resolves six -knobs through CLI → `YAS_*` env → legacy alias → `yas.toml` → default, with -fail-safe validation and a config-error row. `token_window` already defaults to 60. - -## Goals / Non-Goals - -**Goals:** -- Collapse the tokens/cost row to a single content line in the wide layout. -- Merge session and day figures as `session/day` per field, with paired cache. -- Replace the 2-row legacy-glyph sparkline with a 1-row block-element sparkline - (` ▁▂▃▄▅▆▇█`) over a 60s window. -- Add a `show_day_stats` knob (default on) that drops day figures when off. -- Keep the three-column elbow alignment exact (every `│` ↔ `┬`/`┴`). - -**Non-Goals:** -- Changing the narrow/medium layouts (they do not render `tokens_cost`). -- Changing token-rate accounting, cost math, or the on-disk rate log format. -- Reworking the gradient/colour stops or the rate label glyph. -- A per-model or CLI form of `show_day_stats` (env + toml only, like other knobs). - -## Decisions - -**D1 — `tokens_cost` returns a one-element line list.** Keep the existing return -shape `(list[str], (col1, col2), mark_col)` so `build_wide` keeps threading -`vsep_cols`/`spark_mark_col` unchanged, but the list has length 1. Rationale: the -builder's elbow-threading and seam logic already iterate `for lt in line_tokens`; -returning one line needs no builder change beyond the separator that previously sat -between the two lines (there was none — both lines were consecutive `content` -rows), so the row simply shrinks. Alternative (return a bare `str`) was rejected: it -breaks the builder's uniform `line_tokens` iteration and the existing tests' shape. - -**D2 — Column widths from the merged strings, not fixed `IN_W/CACHE_W/OUT_W`.** -The merged `session/day` fields are variable-width, so the right-justify columns -(`IN_W=6` etc.) no longer make sense for the merged form. Compute the tokens and -cost column strings directly and measure with `_visible_width`. The session-only -variant keeps the original per-field justification. Rationale: slash-merged fields -have no natural fixed width; padding them to one looks ragged. - -**D3 — New `GradientEngine.sparkline_1row(history, live)` method.** Add a pure -single-row sparkline: map each value to `round(ratio*8)` over `[0,8]`, index into -` ▁▂▃▄▅▆▇█`, colour by ratio via the existing `spark_color`, dim the last cell when -`live`. Remove the old two-row `sparkline` and the `_spark_rise/_spark_fall/ -_spark_flat` helpers and their `SPARK_RISE_*`/`SPARK_FALL_*` constants once no -caller remains. Rationale: block elements (U+2581–U+2588) have near-universal font -coverage; the legacy half-block glyphs do not. A single row is sufficient at the -8-level resolution the rate display needs. Alternative (keep both, switch by config) -was rejected as needless surface area — the legacy path has no advocate. - -**D4 — 60s window at the call site; drop the tick marker.** Change the history -fetch from `TokenRate.WINDOW * 2` to `TokenRate.WINDOW`. The 60s tick marker -(`spark_mark_col`) marked the 120s bar's midpoint; now that the whole bar is 60s it -marks nothing meaningful, so **remove it**. `tokens_cost` returns `mark_col = 0` -(or the triple drops to `(lines, vsep_cols)` if the builder is updated to match), -and `build_wide` no longer threads a spark-mark elbow. Rationale: the doubled -window predates the single-row design; 60s matches the resolved `token_window` and -the rate label's own averaging window, and a midpoint marker has no referent. - -**D5 — `show_day_stats` as the seventh `Config` knob.** Add `show_day_stats: bool` -to `Config` (default `True`), resolved by the same precedence machinery: canonical -`YAS_SHOW_DAY_STATS`, `[tokens].show_day_stats`, boolean validation (env form: -`0`/`false`/`no` → false, any other non-empty → true; matching the existing -`full_width` boolean handling but with explicit false-y tokens). Thread it into -`tokens_cost` (via the builder, which already holds the `Config`/`SessionView`). -Rationale: reuses the entire config path; no new resolution code, only a new field -+ validator entry + default. - -## Risks / Trade-offs - -- **[Lost vertical separation of session vs day]** → The slash-merge keeps both - numbers adjacent (`session/day`) with the session value first, and the cache pair - in parentheses, so the reading order is preserved; the prototype confirmed it - reads cleanly. -- **[Width pressure: merged line is wider per column]** → At ~100 cols the merged - form is tight (the prototype overflowed two of three candidate shapes at 100). - Mitigation: `show_day_stats=0` gives users on narrow terminals the session-only - form; the wide layout only triggers above `MEDIUM_WIDTH=80` anyway. If needed, the - builder can shed day stats automatically under a width threshold — deferred unless - testing shows it necessary. -- **[Removing the legacy sparkline glyphs breaks callers/tests]** → Grep for - `SPARK_RISE`/`SPARK_FALL`/`_spark_rise`/`_spark_fall`/`_spark_flat`/`.sparkline(` - before deleting; update `test_gradient_math.py`. The two-row `sparkline` is only - called from `tokens_cost`, so the blast radius is contained. -- **[Elbow drift]** → A single line changes which separator carries the tokens - elbows. Verify via `make demo` across the width thresholds that every `┬`/`┴` - still lines up, and assert divider columns in `test_layout_seam.py`. - -## Migration Plan - -Pure rendering + config change; no data migration. Rollout is the normal version -bump. Rollback is reverting the commit. `show_day_stats` defaults to the current -(day-stats-shown) behaviour, so existing users see only the height reduction and -the new sparkline unless they opt out. Delete `ops/proto_compact_tokens_row.py` and -its NOTES file as part of applying. - -## Open Questions - -_None — both resolved:_ - -- Cache parenthetical units: **keep `fmt_tok` suffixes** (`(1.2M/18.3M)`); bare - numbers are ambiguous. -- 60s tick marker: **removed** (see D4) — a midpoint marker has no referent once the - whole bar is 60s. diff --git a/openspec/changes/archive/2026-06-08-compact-tokens-row/proposal.md b/openspec/changes/archive/2026-06-08-compact-tokens-row/proposal.md deleted file mode 100644 index a961cfb..0000000 --- a/openspec/changes/archive/2026-06-08-compact-tokens-row/proposal.md +++ /dev/null @@ -1,57 +0,0 @@ -## Why - -The tokens/cost row (the wide layout's "3rd row", `Renderer.tokens_cost`) is two -terminal lines tall: session figures stacked over day figures, with a 2-row-tall -sparkline drawn from "Symbols for Legacy Computing" half-block glyphs (U+1FBxx) -that render inconsistently across fonts and span 120s of history. Collapsing it to -a single line reclaims a full row of vertical space in every wide render and lets -the live rate sparkline use the well-supported block elements (U+2581–U+2588). - -## What Changes - -- Collapse the tokens/cost row from two content lines to **one**. Session and day - figures merge per field as `session/day`: - - tokens: `↓ 128.4K/1.9M (1.2M/18.3M) ↑ 47.3K/612.5K` — input, paired cache in - parentheses, output; each `session/day`. - - cost: `$3.27 / $41.88` — session cost / day cost. - - rate + sparkline: `󱢧 2.3K t/m ▂▃▄▅…` — unchanged rate label, single-row spark. -- Replace the 2-row half-block sparkline (`SPARK_RISE_*` / `SPARK_FALL_*`, U+1FBxx) - with a **single-row, 8-level block-element sparkline** (` ▁▂▃▄▅▆▇█`, U+2581–U+2588), - coloured by height ratio as today. -- The sparkline reads the **last 60s** of token-rate history (`TokenRate.WINDOW`) - instead of today's `TokenRate.WINDOW * 2` (120s). -- Keep the row's three-column structure (tokens │ cost │ rate+spark) with matching - `┬`/`┴` elbows on the borders above and below — now over one content row. -- Add a `show_day_stats` knob (env `YAS_SHOW_DAY_STATS=0`, `[tokens] - show_day_stats = false`; default **on**). When disabled, the row drops every day - figure and renders session-only: `↓ 128.4K (1.2M) ↑ 47.3K │ $3.27 │ 󱢧 2.3K t/m …`. - -## Capabilities - -### New Capabilities -- `compact-tokens-row`: the single-line tokens/cost/rate row — the `session/day` - slash-merged token and cost format, the paired cache parenthetical, the - single-row block-element sparkline over a 60s window, the three-column elbow - structure, and the day-stats-disabled session-only variant. - -### Modified Capabilities -- `statusline-config`: add a seventh knob `show_day_stats` (canonical - `YAS_SHOW_DAY_STATS`, `[tokens].show_day_stats`), default `true`, resolved through - the existing precedence chain and fail-safe validation (boolean; env form treats - any non-empty value as true, `0`/`false`/`no` as false). - -## Impact - -- `claude/yas/renderer.py` — `tokens_cost` returns a single content line; new - session/day merge + paired-cache formatting; `show_day_stats` branch. -- `claude/yas/render/gradient.py` — new single-row block sparkline on - `GradientEngine`; the 2-row `_spark_rise/_spark_fall/_spark_flat` path and the - `SPARK_RISE_*`/`SPARK_FALL_*` constants in `constants.py` are removed if unused. -- `claude/yas/layout.py` — `build_wide` threads one tokens line and its elbow - columns (`vsep_cols`, `spark_mark_col`) for a single row instead of two. -- `claude/yas/config.py` + `constants.py` — `show_day_stats` field, default, - env/toml resolution, validation. -- `CONTEXT.md` — glossary update if any displayed term changes. -- Tests: `test_tokens_cost.py`, `test_gradient_math.py`, `test_config.py`, - `test_layout_seam.py`. -- Prototype `ops/proto_compact_tokens_row.py` (+ NOTES) is deleted once applied. diff --git a/openspec/changes/archive/2026-06-08-compact-tokens-row/specs/compact-tokens-row/spec.md b/openspec/changes/archive/2026-06-08-compact-tokens-row/specs/compact-tokens-row/spec.md deleted file mode 100644 index db4fb27..0000000 --- a/openspec/changes/archive/2026-06-08-compact-tokens-row/specs/compact-tokens-row/spec.md +++ /dev/null @@ -1,84 +0,0 @@ -## ADDED Requirements - -### Requirement: Single-line tokens/cost/rate row - -The wide layout's tokens/cost row (`Renderer.tokens_cost`) SHALL render as exactly -**one** content line, not two. The line SHALL retain three columns in order — -tokens, then cost, then rate-and-sparkline — separated by the standard gradient -`│` vertical dividers. `tokens_cost` SHALL return a single-element list of content -lines together with the divider columns so the builder can thread one set of -matching `┬`/`┴` elbows onto the separators above and below the row. The previous -60s sparkline tick marker (`spark_mark_col`) SHALL be removed, since a midpoint -marker has no referent once the whole bar spans 60s. - -#### Scenario: Row occupies one content line - -- **WHEN** the wide layout renders the tokens/cost row -- **THEN** `tokens_cost` returns exactly one content line -- **AND** every `│` in that line has a matching `┬` on the separator above and `┴` - on the separator below at the same visual column - -#### Scenario: Three columns preserved in order - -- **WHEN** the single-line row is rendered with day stats enabled -- **THEN** the content reads tokens, then cost, then rate-and-sparkline, left to - right, divided by the gradient `│` separators - -### Requirement: Session/day slash-merged figures - -With day stats enabled, the tokens column SHALL merge each session figure with its -day counterpart as `session/day`: input as `↓ /`, the paired -cache read in parentheses as `(/)`, and output as -`↑ /`. The cost column SHALL render `$ / $`. -All token counts SHALL be formatted with the existing `fmt_tok` abbreviation -(e.g. `128.4K`, `1.9M`). - -#### Scenario: Merged tokens and cost - -- **WHEN** session totals are in=128.4K, cache=1.2M, out=47.3K and day totals are - in=1.9M, cache=18.3M, out=612.5K, session cost $3.27 and day cost $41.88 -- **THEN** the tokens column reads `↓ 128.4K/1.9M (1.2M/18.3M) ↑ 47.3K/612.5K` -- **AND** the cost column reads `$3.27 / $41.88` - -### Requirement: Single-row block-element sparkline over a 60s window - -The token-rate sparkline SHALL be drawn on a single row using the block-element -glyphs ` ▁▂▃▄▅▆▇█` (U+2581–U+2588, plus a blank for zero), with each cell's glyph -chosen by its value's ratio to the window peak and coloured by that same ratio as -today (the most recent cell dimmed when live). The sparkline SHALL read the last -`TokenRate.WINDOW` seconds of history (the resolved `token_window`, default 60s), -not `TokenRate.WINDOW * 2`. The two-row half-block sparkline built from the -`SPARK_RISE_*` / `SPARK_FALL_*` "Symbols for Legacy Computing" glyphs SHALL be -removed. - -#### Scenario: Sparkline is one row of block elements - -- **WHEN** the rate sparkline is rendered for a non-empty history -- **THEN** it is a single row of glyphs drawn only from the set ` ▁▂▃▄▅▆▇█` -- **AND** no glyph in the U+1FBxx "Symbols for Legacy Computing" range appears - -#### Scenario: Window is 60s - -- **WHEN** the resolved `token_window` is 60 -- **THEN** the sparkline history spans 60 seconds of buckets, not 120 - -### Requirement: Day-stats-disabled session-only row - -When `show_day_stats` resolves to false, the tokens/cost row SHALL drop every day -figure and render session-only: tokens `↓ () ↑ `, -cost `$`, with the rate-and-sparkline column unchanged. The row SHALL -remain a single content line with the same three-column elbow structure. - -#### Scenario: Session-only content - -- **WHEN** `show_day_stats` is false and session totals are in=128.4K, cache=1.2M, - out=47.3K, session cost $3.27 -- **THEN** the tokens column reads `↓ 128.4K (1.2M) ↑ 47.3K` -- **AND** the cost column reads `$3.27` -- **AND** no day token count or day cost appears anywhere in the row - -#### Scenario: Default keeps day stats - -- **WHEN** no `YAS_SHOW_DAY_STATS` env var and no `[tokens].show_day_stats` toml - value are set -- **THEN** day stats are shown (the merged `session/day` form is used) diff --git a/openspec/changes/archive/2026-06-08-compact-tokens-row/specs/statusline-config/spec.md b/openspec/changes/archive/2026-06-08-compact-tokens-row/specs/statusline-config/spec.md deleted file mode 100644 index e7dcac7..0000000 --- a/openspec/changes/archive/2026-06-08-compact-tokens-row/specs/statusline-config/spec.md +++ /dev/null @@ -1,107 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Layered configuration precedence - -The statusline SHALL resolve every configurable knob through a single, fixed precedence chain: CLI flag (where one exists) → canonical `YAS_*` environment variable → legacy-alias environment variable → `yas.toml` value → built-in default. A higher-precedence source that is present and valid SHALL override all lower sources for that knob; an absent or invalid source SHALL fall through to the next. - -#### Scenario: Env overrides config file - -- **WHEN** `[layout].max_width = 200` is set in `yas.toml` and `YAS_MAX_WIDTH=160` is set in the environment -- **THEN** the resolved `max_width` is `160` - -#### Scenario: Config file overrides default - -- **WHEN** `[tokens].soft_limit = 1000000` is set in `yas.toml` and no `YAS_SOFT_LIMIT` env var is set -- **THEN** the resolved `soft_limit` is `1000000` - -#### Scenario: Default when nothing is set - -- **WHEN** no `yas.toml` exists and no relevant env var is set -- **THEN** every knob resolves to its built-in default (`max_width=140`, `full_width=false`, `soft_limit=150000`, `token_window=60`, `theme=dark`, `bg_shift=warm`, `show_day_stats=true`) - -#### Scenario: CLI flag overrides env and config - -- **WHEN** `--theme` is passed on the command line and `YAS_THEME` and `[appearance].theme` are also set -- **THEN** the CLI `--theme` value is used - -### Requirement: Canonical env vars and deprecated aliases - -The statusline SHALL accept canonical `YAS_*` environment variables for all seven knobs (`YAS_MAX_WIDTH`, `YAS_FULL_WIDTH`, `YAS_SOFT_LIMIT`, `YAS_TOKEN_WINDOW`, `YAS_THEME`, `YAS_BG_SHIFT`, `YAS_SHOW_DAY_STATS`). It SHALL continue to honor the legacy aliases `STATUSLINE_TOKEN_WINDOW` (for `token_window`) and `CLAUDE_STATUSLINE_THEME` (for `theme`). When both a canonical var and its alias are set, the canonical value SHALL win. - -#### Scenario: Legacy alias still works - -- **WHEN** only `STATUSLINE_TOKEN_WINDOW=30` is set -- **THEN** the resolved `token_window` is `30` - -#### Scenario: Canonical wins over alias - -- **WHEN** `YAS_TOKEN_WINDOW=45` and `STATUSLINE_TOKEN_WINDOW=30` are both set -- **THEN** the resolved `token_window` is `45` - -#### Scenario: Theme alias resolves - -- **WHEN** only `CLAUDE_STATUSLINE_THEME` names a known theme -- **THEN** that theme is used - -#### Scenario: Day-stats env var resolves - -- **WHEN** `YAS_SHOW_DAY_STATS=0` is set -- **THEN** the resolved `show_day_stats` is `false` - -### Requirement: yas.toml location and sectioned schema - -The statusline SHALL read configuration from `yas.toml` located in `CLAUDE_CONFIG_DIR` (defaulting to `~/.claude/`). The file SHALL use a sectioned schema: `[layout]` for `max_width` and `full_width`, `[tokens]` for `soft_limit` (global default), `token_window`, and `show_day_stats`, an optional `[[tokens.model]]` array of `{ match, soft_limit }` tables for per-model `soft_limit` overrides, and `[appearance]` for `theme` and `bg_shift`. Absence of the file SHALL be equivalent to all-defaults and SHALL NOT be an error. - -#### Scenario: Knobs read from their sections - -- **WHEN** `yas.toml` contains `[layout]` `max_width = 200`, `[tokens]` `soft_limit = 1000000`, and `[appearance]` `theme = "dark"` -- **THEN** those three values are resolved from the file - -#### Scenario: Day-stats read from tokens section - -- **WHEN** `yas.toml` contains `[tokens]` `show_day_stats = false` and no `YAS_SHOW_DAY_STATS` env var is set -- **THEN** the resolved `show_day_stats` is `false` - -#### Scenario: Missing file is not an error - -- **WHEN** no `yas.toml` exists in `CLAUDE_CONFIG_DIR` -- **THEN** the statusline renders normally using env + defaults and reports no config error - -#### Scenario: Unknown keys and sections are ignored - -- **WHEN** `yas.toml` contains a key or section that does not map to a known knob -- **THEN** the unknown entry is ignored and the rest of the config still resolves - -### Requirement: Fail-safe validation of config values - -The statusline SHALL never crash or render garbage because of bad configuration. A syntactically broken `yas.toml` SHALL cause the entire file to be ignored (env + defaults still apply). A value that is the wrong type or out of range for its knob SHALL cause only that single knob to fall back to its default while all other valid knobs are still applied. Validation rules: `max_width` is an integer > 0; `full_width` is a boolean (env form accepts any non-empty value as true); `soft_limit` is an integer > 0; `token_window` is a number > 0; `theme` must be a known theme name; `bg_shift` must be one of `warm` or `cool`; `show_day_stats` is a boolean (env form treats `0`, `false`, and `no` as false and any other non-empty value as true). - -#### Scenario: Broken TOML ignores whole file - -- **WHEN** `yas.toml` contains a TOML syntax error -- **THEN** no value from the file is applied, env + defaults are used, and a config error is recorded - -#### Scenario: One bad value falls back, others apply - -- **WHEN** `yas.toml` sets `max_width = "banana"` (invalid) and `soft_limit = 1000000` (valid) -- **THEN** `max_width` resolves to its default and `soft_limit` resolves to `1000000` - -#### Scenario: Out-of-range value rejected - -- **WHEN** `soft_limit = -5` is configured -- **THEN** `soft_limit` falls back to its default and the rejection is recorded - -#### Scenario: Unknown enum value rejected - -- **WHEN** `bg_shift = "purple"` is configured -- **THEN** `bg_shift` falls back to `warm` and the rejection is recorded - -#### Scenario: Non-boolean day-stats rejected - -- **WHEN** `[tokens].show_day_stats = "banana"` is configured -- **THEN** `show_day_stats` falls back to its default (`true`) and the rejection is recorded - -#### Scenario: Malformed per-model entry dropped - -- **WHEN** a `[[tokens.model]]` entry has a missing/empty `match`, or a `soft_limit` that is non-integer or `<= 0` -- **THEN** only that entry is dropped (models it would have matched fall back to the global `soft_limit`), valid entries still apply, and the rejection is recorded referencing the entry (e.g. `tokens.model[2]`) diff --git a/openspec/changes/archive/2026-06-08-compact-tokens-row/tasks.md b/openspec/changes/archive/2026-06-08-compact-tokens-row/tasks.md deleted file mode 100644 index f6c1751..0000000 --- a/openspec/changes/archive/2026-06-08-compact-tokens-row/tasks.md +++ /dev/null @@ -1,36 +0,0 @@ -## 1. Config knob: show_day_stats - -- [x] 1.1 Add `show_day_stats: bool = True` field to the `Config` frozen dataclass in `claude/yas/config.py`. -- [x] 1.2 Resolve it through the existing precedence chain: canonical `YAS_SHOW_DAY_STATS` env, `[tokens].show_day_stats` toml, default `True`. Add a boolean validator where env form treats `0`/`false`/`no` (case-insensitive) as false and any other non-empty value as true; an invalid toml value falls back to default and is recorded as a config error. -- [x] 1.3 Add a default constant to `constants.py` if the other defaults live there (mirror `token_window`/`DEFAULT_*`). -- [x] 1.4 Update `test_config.py`: env `0` → false, toml `false` → false, default true, non-boolean toml rejected to default + error recorded, `YAS_SHOW_DAY_STATS` beats toml. - -## 2. Single-row block-element sparkline - -- [x] 2.1 Add `GradientEngine.sparkline_1row(history, live=False) -> str` in `claude/yas/render/gradient.py`: map each value to `round(ratio*8)` over `[0,8]`, index ` ▁▂▃▄▅▆▇█`, colour by ratio via `spark_color`, dim the last cell when `live`. -- [x] 2.2 Add the ` ▁▂▃▄▅▆▇█` block string as a named constant (reuse/extend `GradientEngine.SPARK_CHARS = '▁▂▃▄▅▆▇█'` — note it already exists; add the leading blank handling in the method). -- [x] 2.3 Remove the two-row `sparkline` and the `_spark_rise`/`_spark_fall`/`_spark_flat` helpers from `GradientEngine` once no caller remains. -- [x] 2.4 Remove the now-unused `SPARK_RISE_*` / `SPARK_FALL_*` constants from `constants.py` (grep first: `SPARK_RISE`, `SPARK_FALL`, `_spark_rise`, `_spark_fall`, `_spark_flat`). -- [x] 2.5 Update `test_gradient_math.py`: assert glyphs come only from ` ▁▂▃▄▅▆▇█`, none from U+1FBxx; cover empty history, flat history, and a rising/falling series. - -## 3. Collapse tokens_cost to one line - -- [x] 3.1 Rewrite `Renderer.tokens_cost` (`claude/yas/renderer.py`) to build ONE content line: tokens column, gradient `│`, cost column, gradient `│`, rate label + `sparkline_1row`. Keep the return shape `([single_line], (col1, col2), mark_col)`. -- [x] 3.2 With day stats on, format the tokens column as `↓ / (/) ↑ /` and the cost column as `$ / $`, using `fmt_tok` (keep `M`/`K` suffixes in the cache parenthetical) and `_visible_width` for column geometry (drop the fixed `IN_W/CACHE_W/OUT_W` right-justify for the merged form). -- [x] 3.3 Add the `show_day_stats=False` branch: session-only `↓ () ↑ `, cost `$`, rate+spark unchanged; same three-column structure. -- [x] 3.4 Thread `show_day_stats` into `tokens_cost` (add a parameter; `build_wide` passes it from `view.cfg`). -- [x] 3.5 Fetch sparkline history over `TokenRate.WINDOW` (60s) instead of `TokenRate.WINDOW * 2`. Remove the `spark_mark_col` tick marker (D4): return `mark_col = 0` and stop computing the midpoint. - -## 4. Layout threading - -- [x] 4.1 Update `build_wide` in `claude/yas/layout.py` to pass `show_day_stats` into `tokens_cost` and handle the single-element `line_tokens` (the loop already iterates; confirm the separators/seam and `vsep_cols`/`spark_mark_col` elbow threading are correct for one row). -- [x] 4.2 Verify the `┬`/`┴` elbows on the separators above and below the row align with every `│` in the single line (`vsep_cols`); confirm no spark-mark elbow is threaded now that the tick marker is removed. -- [x] 4.3 Update `test_layout_seam.py` and `test_tokens_cost.py`: assert one content line is returned, the merged `session/day` content, the session-only variant under `show_day_stats=False`, and that divider columns match the rendered `│` positions. - -## 5. Verify & document - -- [x] 5.1 Run the PUA-glyph catalogue over touched files; hoist any raw PUA glyph on an edited line to a `constants.py` constant before editing. -- [x] 5.2 `make test` green (baseline pass count + new tests). -- [x] 5.3 `make demo` across narrow→medium→wide thresholds: tokens row is one line, elbows aligned, sparkline reads as block elements, day-stats toggle behaves. -- [x] 5.4 Update `CONTEXT.md` glossary if any displayed term changed (e.g. the merged `session/day` figures, the new sparkline). -- [x] 5.5 Delete `ops/proto_compact_tokens_row.py` and `ops/NOTES.proto_compact_tokens_row.md`. diff --git a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/.openspec.yaml b/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/.openspec.yaml deleted file mode 100644 index 11967fc..0000000 --- a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/design.md b/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/design.md deleted file mode 100644 index 26791fd..0000000 --- a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/design.md +++ /dev/null @@ -1,47 +0,0 @@ -## Context - -`Renderer.openspec_bar(name, done, total, box_width, title_w, idx)` -(`claude/yas/renderer.py:1102`) calls `spec_gradient_bar(filled, bar_w, idx)`, -which indexes `SPEC_GRADIENTS[idx % len(SPEC_GRADIENTS)]` (12 gradients). The -caller `layout.py:270` passes `idx` as the `enumerate` position: -`[r.openspec_bar(name, d, t, width, title_w, i) for i, (name, d, t) in enumerate(changes)]`. -So colour is positional and reshuffles on reorder. - -The statusline renders as a fresh subprocess every tick, so any per-render -randomness source must be derived from stable inputs. - -## Goals / Non-Goals - -**Goals:** -- Tie each bar's colour to its change name, stably across ticks and list order. -- Keep the 12-entry `SPEC_GRADIENTS` palette and `spec_gradient_bar` math - unchanged. - -**Non-Goals:** -- Changing the gradient palette or the bar geometry. -- Persisting colour state to disk. -- Guaranteeing uniqueness (collisions across `% 12` are acceptable). - -## Decisions - -- **Stable hash:** compute `idx = zlib.crc32(name.encode()) % len(SPEC_GRADIENTS)`. - `zlib.crc32` is in the stdlib, fast, and process-stable (unlike builtin - `hash()` which is `PYTHONHASHSEED`-salted). `hashlib` would also work but - `crc32` is lighter and sufficient for a 12-way bucket. -- **Where to compute:** derive `idx` inside `openspec_bar` from `name`, so the - caller no longer needs to thread a colour index. `layout.py` may drop the - `enumerate` index from the colour argument (it can still enumerate for other - needs, but the colour no longer depends on it). -- **Signature:** keep `openspec_bar`'s parameters backward-compatible where - practical; if `idx` is removed, update the single call site and any tests that - pass it positionally. - -## Risks / Trade-offs - -- **Collisions:** with 12 gradients, distinct names can share a colour. Accepted - — the goal is variety and stability, not uniqueness. -- **Builtin-hash trap:** the whole point is avoiding `hash()`; the spec and a - cross-invocation test guard against a regression to salted hashing. -- **Signature churn:** removing the positional `idx` touches the call site and - existing `test_openspec_bar.py` cases; mitigated by updating them in the same - change. diff --git a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/proposal.md b/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/proposal.md deleted file mode 100644 index 426b93b..0000000 --- a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/proposal.md +++ /dev/null @@ -1,37 +0,0 @@ -## Why - -OpenSpec change bars are coloured by their **row position** (`idx` is the -`enumerate` index in `layout.py`), so the same gradient palette entries always -appear in the same order and a change's colour shifts whenever the list -reorders. The colours should feel varied and be tied to the change itself, not -to where it happens to sit in the list. - -## What Changes - -- Colour each OpenSpec bar by a **stable hash of the change name** rather than - its list position: `idx = stable_hash(name) % len(SPEC_GRADIENTS)`. -- A given change keeps a consistent gradient across renders and regardless of - list order; different changes scatter across the palette. -- **Must use a process-stable hash** (e.g. `zlib.crc32` / `hashlib`), **not** - Python's builtin `hash()` — builtin `str` hashing is salted per process - (`PYTHONHASHSEED`), and since the statusline runs as a fresh subprocess each - render tick, builtin `hash()` would re-roll the colour every tick (strobing). - -## Capabilities - -### New Capabilities -- `openspec-bar-colour`: how an OpenSpec change bar selects its gradient — a - deterministic, name-derived, render-stable mapping. - -### Modified Capabilities - - -## Impact - -- `claude/yas/renderer.py` — `Renderer.openspec_bar` (derive `idx` from a stable - hash of `name`). -- `claude/yas/layout.py` — the call site no longer needs to pass the - `enumerate` index for colour purposes. -- Tests: `test/test_openspec_bar.py` (stable mapping; same name → same gradient - across calls; distinct names spread). -- No change to bar geometry, width math, or border alignment. diff --git a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/specs/openspec-bar-colour/spec.md b/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/specs/openspec-bar-colour/spec.md deleted file mode 100644 index 5ae2a79..0000000 --- a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/specs/openspec-bar-colour/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -## ADDED Requirements - -### Requirement: Name-derived gradient selection - -An OpenSpec change bar SHALL select its gradient from `SPEC_GRADIENTS` using a -hash of the change name modulo the palette length, not the change's position in -the list. The same change name SHALL always map to the same gradient, and the -mapping SHALL be independent of the change's order among the rendered bars. - -#### Scenario: Same name maps to the same gradient - -- **WHEN** a change with a given name is rendered in two different list - positions -- **THEN** it uses the same gradient in both cases - -#### Scenario: Distinct names spread across the palette - -- **WHEN** several differently-named changes are rendered -- **THEN** their gradient selection is driven by their names rather than their - ordinal positions - -### Requirement: Render-stable hashing - -The hash used to select the gradient SHALL be stable across separate process -invocations. The system SHALL NOT use Python's builtin `hash()` on the name -(which is salted per process via `PYTHONHASHSEED`); it SHALL use a -deterministic hash such as `zlib.crc32` or a `hashlib` digest so the colour does -not change between render ticks. - -#### Scenario: Colour does not change across render ticks - -- **WHEN** the same change is rendered in two separate statusline subprocess - invocations -- **THEN** it is assigned the same gradient both times (no strobing) diff --git a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/tasks.md b/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/tasks.md deleted file mode 100644 index 22757a6..0000000 --- a/openspec/changes/archive/2026-06-08-openspec-bar-colour-hash/tasks.md +++ /dev/null @@ -1,16 +0,0 @@ -## 1. Name-derived colour selection - -- [x] 1.1 In `Renderer.openspec_bar` (`claude/yas/renderer.py`), compute `idx = zlib.crc32(name.encode()) % len(self.SPEC_GRADIENTS)` and pass it to `spec_gradient_bar` instead of the caller-supplied positional index. -- [x] 1.2 Add `import zlib` (module top) if not already present. -- [x] 1.3 Update the call site in `claude/yas/layout.py:270` so the colour no longer depends on the `enumerate` index (drop the `i` colour argument or stop passing it). - -## 2. Tests - -- [x] 2.1 In `test/test_openspec_bar.py`, assert the same name yields the same gradient when rendered at different list positions. -- [x] 2.2 Assert the selected index equals `zlib.crc32(name.encode()) % len(SPEC_GRADIENTS)` (locks the stable-hash contract; guards against a regression to builtin `hash()`). -- [x] 2.3 Assert several distinct names do not all collapse to one gradient (spread check). - -## 3. Verification - -- [x] 3.1 Run `make test` — green. -- [x] 3.2 Run `make demo` with multiple OpenSpec changes present; confirm bar colours are varied and stable across frames (no strobing). diff --git a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/.openspec.yaml b/openspec/changes/archive/2026-06-08-subagent-activity-snippet/.openspec.yaml deleted file mode 100644 index 11967fc..0000000 --- a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/design.md b/openspec/changes/archive/2026-06-08-subagent-activity-snippet/design.md deleted file mode 100644 index cbf2ff5..0000000 --- a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/design.md +++ /dev/null @@ -1,62 +0,0 @@ -## Context - -`RunningSubagents._parse_transcript` (`claude/yas/info/subagents.py`) walks a -subagent's `.jsonl` transcript and, for the latest assistant message with a -usage block, records `last_activity` from `content[-1]`: - -- `tool_use` → `('tool_use', name, input_dict)` -- `thinking` → `('thinking', '', {})` -- `text` → `('text', '', {})` ← snippet is discarded - -`Renderer.subagent_activity` (`claude/yas/renderer.py:588`) maps `text` to the -bare `f'{GLYPH_REPLYING} (replying)'`. - -Within a single assistant message Claude usually emits `[text, tool_use]`, so -`content[-1]` is already the tool — `(replying)` predominantly appears when a -message *ends* in text (a narration or final summary), where there is genuine -text we are throwing away. - -## Goals / Non-Goals - -**Goals:** -- Replace bare `(replying)` with the agent's actual narration text when no tool - call is present. -- Prefer a `tool_use` block over text within the same message, even when text - is the trailing block. -- Keep the change confined to the data-selection (`_parse_transcript`) and - render (`subagent_activity`) seams; no layout/geometry change. - -**Non-Goals:** -- Scanning backwards across multiple messages for the "most meaningful" action. -- Multi-line snippets or rich formatting. -- Any change to token/duration/cluster fields or border math. - -## Decisions - -- **Selection in `_parse_transcript`:** iterate the message `content` and pick - the last `tool_use` block if any exists; otherwise the last `text` block; - otherwise `thinking`. This makes tool-use win regardless of position, fixing - interleaved `[text, tool_use, text]` shapes too. -- **Snippet extraction:** take the first non-empty line of the chosen text - block (`.splitlines()` → first stripped truthy line), run it through - `_sanitize` (untrusted-input hardening), and store it in the `last_activity` - tuple as `('text', snippet, {})` — reusing the existing `name` slot rather - than widening the tuple. -- **Rendering:** `subagent_activity` renders the `text` case as - `f'{GLYPH_REPLYING} {snippet}'`, applying the existing 36-visible-column cap - (the same `_visible_width(raw) > 36` → `raw[:36] + '…'` logic already used for - `tool_use` args). When the snippet is empty (no text content at all), fall - back to the current `(replying)` string so the row is never blank. -- **Constants:** `GLYPH_REPLYING` already exists in `constants.py`; no new PUA - glyph needed. - -## Risks / Trade-offs - -- **Untrusted text in the statusline:** subagent narration is model/file-derived - text. Mitigated by routing it through `_sanitize` exactly as tool args are. -- **Snippet relevance:** the first line of a long narration may be a preamble - rather than the salient action. Accepted — it is strictly more informative - than `(replying)`, and the 36-col cap bounds it. -- **Truncation byte-safety:** slicing `raw[:36]` on a sanitized string matches - the existing tool-arg behavior; `_visible_width` governs the threshold so - ANSI/wide chars are accounted for consistently. diff --git a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/proposal.md b/openspec/changes/archive/2026-06-08-subagent-activity-snippet/proposal.md deleted file mode 100644 index 334a8e5..0000000 --- a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/proposal.md +++ /dev/null @@ -1,40 +0,0 @@ -## Why - -A subagent's activity-continuation line very often shows a bare `(replying)` -with no information. This happens because the activity verb is taken from the -*last* content block of the latest assistant message; assistant messages -frequently end with a `text` block (a narration or final summary), which the -renderer collapses to the literal `(replying)`. The line consumes space while -telling the user nothing about what the agent is doing. - -## What Changes - -- Change how the activity verb is derived from a subagent transcript message: - prefer the **last `tool_use` block** in the message; only when no `tool_use` - is present fall back to the **first non-empty line of the last `text` block**. -- Carry that text snippet (sanitized) through `last_activity` instead of - discarding it, so a genuinely-narrating agent shows what it is saying rather - than a contentless `(replying)`. -- Render the text case as the replying glyph followed by the snippet, reusing - the existing 36-visible-column ellipsis cap already applied to tool args. -- `thinking` blocks are unchanged (`(thinking)`). - -## Capabilities - -### New Capabilities - - -### Modified Capabilities -- `subagent-row-layout`: the activity continuation's verb derivation is - refined — last-`tool_use`-wins, with a text-snippet fallback replacing the - bare `(replying)` placeholder. - -## Impact - -- `claude/yas/info/subagents.py` — `RunningSubagents._parse_transcript` - (activity selection from message content; `last_activity` tuple now carries a - text snippet). -- `claude/yas/renderer.py` — `Renderer.subagent_activity` (render the text - snippet after `GLYPH_REPLYING`). -- Tests: `test/test_subagent_rows.py`, `test/test_subagent_metrics.py`. -- No change to stdin payload, layout geometry, or border math. diff --git a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/specs/subagent-row-layout/spec.md b/openspec/changes/archive/2026-06-08-subagent-activity-snippet/specs/subagent-row-layout/spec.md deleted file mode 100644 index 479a3bc..0000000 --- a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/specs/subagent-row-layout/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -## ADDED Requirements - -### Requirement: Activity verb derivation - -The activity continuation's verb SHALL be derived from the latest assistant -message in the subagent transcript by preferring the last `tool_use` content -block in that message. When the message contains no `tool_use` block, the verb -SHALL fall back to the first non-empty line of the last `text` block, passed -through the untrusted-input sanitizer. A `thinking` block SHALL continue to -render as the thinking indicator. The system SHALL NOT render a contentless -`(replying)` placeholder when text content is available. - -The rendered text snippet SHALL reuse the existing activity truncation cap of -36 visible columns (measured via the visible-width helper), appending a single -`…` when it exceeds that cap. - -#### Scenario: Tool use wins over trailing text in the same message - -- **WHEN** the latest assistant message contains both a `tool_use` block and a - trailing `text` block -- **THEN** the activity continuation shows the tool verb (` Tool[arg]`), - not the text snippet - -#### Scenario: Text-only message shows a snippet instead of bare replying - -- **WHEN** the latest assistant message ends with a `text` block and contains - no `tool_use` block -- **THEN** the activity continuation shows the replying glyph followed by the - first non-empty line of that text, sanitized - -#### Scenario: Long text snippet truncates at the activity cap - -- **WHEN** the first non-empty line of the text block exceeds 36 visible columns -- **THEN** the snippet is truncated to the cap with a trailing `…` - -#### Scenario: Thinking block is unchanged - -- **WHEN** the latest assistant message's selected block is a `thinking` block -- **THEN** the activity continuation shows the thinking indicator diff --git a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/tasks.md b/openspec/changes/archive/2026-06-08-subagent-activity-snippet/tasks.md deleted file mode 100644 index 9c5cecb..0000000 --- a/openspec/changes/archive/2026-06-08-subagent-activity-snippet/tasks.md +++ /dev/null @@ -1,20 +0,0 @@ -## 1. Activity selection in the data layer - -- [x] 1.1 In `RunningSubagents._parse_transcript` (`claude/yas/info/subagents.py`), replace the `content[-1]` check with: scan `content` for the last `tool_use` block and record it; if none, find the last `text` block and record its first non-empty line; else record `thinking`. -- [x] 1.2 Sanitize the extracted text line with `_sanitize` and store it as `('text', snippet, {})` in `last_activity` (keep `tool_use` and `thinking` tuples as they are). - -## 2. Rendering - -- [x] 2.1 In `Renderer.subagent_activity` (`claude/yas/renderer.py`), render the `text` case as `f'{GLYPH_REPLYING} {snippet}'`, applying the existing `_visible_width(raw) > 36` → `raw[:36] + '…'` cap. -- [x] 2.2 When the snippet is empty, fall back to the existing `(replying)` string so the line is never blank. - -## 3. Tests - -- [x] 3.1 Add a test (in `test/test_subagent_rows.py`) asserting a text-only latest message yields `GLYPH_REPLYING ` with the first non-empty line. -- [x] 3.2 Add a test asserting a message with both `tool_use` and trailing `text` renders the tool verb, not the snippet. -- [x] 3.3 Add a test asserting a snippet longer than 36 visible columns is truncated with `…`, and that an empty/absent text content falls back to `(replying)`. - -## 4. Verification - -- [x] 4.1 Run `make test` — green, baseline + new tests. -- [x] 4.2 Run `make demo` — eyeball a subagent row; confirm the activity line shows snippets and the box stays aligned. diff --git a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/.openspec.yaml b/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/.openspec.yaml deleted file mode 100644 index 11967fc..0000000 --- a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/design.md b/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/design.md deleted file mode 100644 index cb02339..0000000 --- a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/design.md +++ /dev/null @@ -1,82 +0,0 @@ -## Context - -`RunningSubagents._parse_transcript` (`claude/yas/info/subagents.py`) walks a -subagent `.jsonl` and, for each assistant line carrying a `usage` block, -accumulates tokens and records terminal state. To avoid double-counting tokens -when the same assistant message is written multiple times during streaming, it -dedupes on `message.id`: - -```python -mid = msg.get('id') -if not mid or mid in seen: - continue # <-- skips EVERYTHING below, including end_turn capture -seen.add(mid) -... # token accumulation -if msg.get('stop_reason') == 'end_turn': - end_ts = _parse_iso_to_epoch(d.get('timestamp', '')) -``` - -Streaming emits the same `message.id` several times: early partials carry -`stop_reason: null`; the final write carries `stop_reason: "end_turn"`. The -early partial enters `seen` first, so the final end_turn write hits -`mid in seen` and is `continue`d — the `end_ts` capture never runs. The agent is -never marked Done, never dims, and lingers looking active. This was observed -with a discovery agent that ran before a batch of implementation agents. - -The dedup is correct for its real purpose (token accumulation must happen once). -The defect is that it also gates the terminal-state check, which must run on -every line. - -## Goals / Non-Goals - -**Goals:** -- Capture `end_ts` from a `stop_reason: "end_turn"` line even when that - `message.id` was already counted by an earlier streaming partial. -- Keep token/usage accumulation deduped exactly once per `message.id`. -- Leave every other `subagent-cohort` requirement untouched: turn-scoped - membership, retire-as-a-unit + 20s grace, 60s janitor sweep, and the dimmed - Done treatment. - -**Non-Goals:** -- Changing cohort retirement policy (no per-member individual retirement; the - section still retires as a unit — once detection is fixed, the finished member - correctly dims while siblings run, which is the intended behaviour). -- Any `subagentStatusLine` sidecar / CC `status` field integration (deferred; - see proposal Out of scope). -- Changes to token math, duration, `first_timestamp`, `model`, or - `last_activity` selection. - -## Decisions - -- **Lift the terminal-state check out of the dedup branch.** Evaluate - `stop_reason == 'end_turn'` (and capture `end_ts`) for every assistant+usage - line, then `continue` the *token accumulation* only when `mid in seen`. - Concretely, restructure so the end_turn capture runs before — or regardless of - — the `if mid in seen: continue` guard, while token sums and `model`/`seen` - bookkeeping stay behind it. - - *Alternative considered:* dedupe end_turn by remembering whether `end_ts` was - already set and only updating on the latest timestamp. Rejected as redundant — - re-running the cheap check on a duplicate line is harmless and yields the same - `end_ts`; the simpler structural fix has no extra state. - - *Alternative considered:* the `subagentStatusLine` sidecar as the source of - truth. Rejected for this change — the terminal signal is recoverable from the - transcript YAS already reads; the sidecar adds a second hook surface and three - undocumented unknowns. Deferred as a gated fallback. - -- **`end_ts` value on duplicates.** When multiple end_turn-bearing lines share an - id (rare), the **last** one wins, matching the existing "timestamp of that - line" semantics and keeping `end_ts` monotonic with the final write. - -## Risks / Trade-offs - -- **Re-evaluating end_turn on every duplicate line** → negligible cost: a dict - `.get` and a string compare per assistant line; transcripts are small and read - once per render. -- **A mid-stream partial that transiently reports `end_turn`** → not observed in - practice (only the final write carries it), and the last-write-wins rule means - a later non-terminal line cannot un-set `end_ts` because non-terminal lines - never touch `end_ts`. Accepted. -- **Token double-count regression** → guarded by an explicit test asserting a - duplicated id accumulates `usage` exactly once while still capturing `end_ts`. diff --git a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/proposal.md b/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/proposal.md deleted file mode 100644 index f46b5fb..0000000 --- a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/proposal.md +++ /dev/null @@ -1,59 +0,0 @@ -## Why - -A finished subagent (observed with a "discovery" agent that ran before a batch -of implementation agents) kept showing as **active** in the statusline long -after it completed. The cause is a detection gap, not a visibility-policy gap: -`RunningSubagents._parse_transcript` dedupes assistant messages by `message.id` -and `continue`s on an already-seen id **before** it inspects `stop_reason`. -Streaming writes the same `message.id` several times — an early partial with -`stop_reason: null`, then a final write carrying `stop_reason: "end_turn"`. The -early partial enters the `seen` set, so the final end_turn write is skipped and -`end_ts` is never set. The agent is never marked Done, never receives the -dimmed Done treatment, and lingers looking busy. - -## What Changes - -- Harden Done detection so the `end_turn` signal is evaluated on **every** - assistant+usage transcript line, independent of the `message.id` dedup. The - dedup SHALL continue to guard token/usage accumulation only — never the - terminal-state check. -- Capture `end_ts` from the end_turn line even when that message id was already - counted for tokens by an earlier streaming partial. -- No change to the deliberate cohort behaviour: turn-scoped membership, - retire-the-section-as-a-unit after the 20s grace, the 60s janitor sweep, and - the dimmed visual treatment for finished members all stay exactly as - specified. Once detection is correct, a finished member correctly dims instead - of looking active. - -## Capabilities - -### New Capabilities - - -### Modified Capabilities -- `subagent-cohort`: the **Done detection via end_turn** requirement is - strengthened — message-id dedup MUST NOT suppress the `end_turn` check, so a - duplicated final-message id can no longer drop the Done signal. - -## Impact - -- `claude/yas/info/subagents.py` — `RunningSubagents._parse_transcript`: move the - `stop_reason == 'end_turn'` / `end_ts` capture out from behind the - `if mid in seen: continue` dedup guard; the dedup keeps gating only token - accumulation. -- Tests: `test/test_running_subagents.py` (detection unit) and - `test/test_cohort_visibility.py` (Done state armed) — add a fixture where the - final end_turn message id duplicates an earlier streaming partial. -- No change to stdin payload, layout geometry, border math, or any other - `subagent-cohort` requirement. - -### Out of scope (deferred) - -- The `subagentStatusLine` sidecar (writing CC's authoritative `status` field to - a state file for YAS to read) is **explicitly deferred**. It is a gated - fallback to revisit only if, after this fix, a finished agent is reproduced - with no detectable `end_turn` in its transcript at all. It carries undocumented - unknowns (the `tasks[].id` join target, the `status` enum, and whether a - finished agent even still appears in the `tasks` feed) and adds a second hook - surface — not justified while the terminal signal is recoverable from the - transcript YAS already reads. diff --git a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/specs/subagent-cohort/spec.md b/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/specs/subagent-cohort/spec.md deleted file mode 100644 index 65752e8..0000000 --- a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/specs/subagent-cohort/spec.md +++ /dev/null @@ -1,32 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Done detection via end_turn - -The statusline SHALL treat a subagent as **Done** when, and only when, its transcript jsonl contains an assistant message whose `message.stop_reason` equals `"end_turn"`. The timestamp of that line SHALL be captured as the subagent's `end_ts`. Transcript-write staleness SHALL NOT, on its own, mark a subagent Done. - -The `end_turn` check SHALL be evaluated on every assistant message line that carries a usage block, **independent of message-id deduplication**. Deduplication by `message.id` SHALL guard only token/usage accumulation; it SHALL NOT cause a line bearing `stop_reason: "end_turn"` to be skipped. A streaming partial that wrote the same `message.id` earlier (with `stop_reason: null`) SHALL NOT suppress the terminal-state capture from the final write of that message. - -#### Scenario: Clean finish marks Done - -- **WHEN** a subagent transcript's final assistant message carries `stop_reason: "end_turn"` -- **THEN** the subagent is Done and its `end_ts` is the timestamp of that line - -#### Scenario: Duplicated final-message id still marks Done - -- **WHEN** the assistant message bearing `stop_reason: "end_turn"` shares its `message.id` with an earlier streaming partial line that had `stop_reason: null` -- **THEN** the dedup guard does not skip the terminal check, the subagent is marked Done, and `end_ts` is captured from the end_turn line - -#### Scenario: Dedup still prevents double-counting tokens - -- **WHEN** the same `message.id` appears across multiple transcript lines -- **THEN** that message's `usage` tokens are accumulated exactly once, while the `end_turn` check still runs on each line - -#### Scenario: Silence does not mark Done - -- **WHEN** a subagent transcript has had no writes for longer than the liveness window but contains no `end_turn` -- **THEN** the subagent is NOT marked Done (it is handled by the janitor sweep instead) - -#### Scenario: Interrupted agent never emits end_turn - -- **WHEN** a subagent was interrupted, killed, or errored and its transcript ends without `stop_reason: "end_turn"` -- **THEN** the subagent is never marked Done and never receives the Done visual treatment diff --git a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/tasks.md b/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/tasks.md deleted file mode 100644 index fb437f2..0000000 --- a/openspec/changes/archive/2026-06-10-harden-subagent-finished-detection/tasks.md +++ /dev/null @@ -1,17 +0,0 @@ -## 1. Harden the detection in the data layer - -- [x] 1.1 In `RunningSubagents._parse_transcript` (`claude/yas/info/subagents.py`), restructure the per-line handling so the `stop_reason == 'end_turn'` / `end_ts` capture runs for every assistant+usage line, independent of the `if mid in seen: continue` dedup guard. -- [x] 1.2 Keep token/usage accumulation and `model`/`seen` bookkeeping behind the dedup guard so a duplicated `message.id` is still counted exactly once. -- [x] 1.3 Preserve last-write-wins for `end_ts` (a later end_turn line overwrites an earlier one; non-terminal lines never touch `end_ts`). - -## 2. Tests - -- [x] 2.1 In `test/test_running_subagents.py`, add a fixture transcript where the final `stop_reason: "end_turn"` message shares its `message.id` with an earlier streaming partial (`stop_reason: null`); assert `end_ts > 0` (Done detected). -- [x] 2.2 Assert that the same duplicated-id transcript accumulates `usage` tokens exactly once (no double-count regression). -- [x] 2.3 In `test/test_cohort_visibility.py`, add a case asserting the duplicated-final-id agent reaches the Done state and is eligible for the dimmed treatment / cohort grace, rather than appearing active. -- [x] 2.4 Confirm existing detection scenarios still hold: clean single-write end_turn marks Done; an interrupted transcript with no end_turn stays not-Done. - -## 3. Verification - -- [x] 3.1 Run `make test` — green, baseline + new tests. -- [x] 3.2 Run `make demo` — eyeball a subagent that finishes mid-run; confirm it transitions to the dimmed Done treatment instead of lingering as active, and the box stays aligned. diff --git a/openspec/changes/archive/2026-06-10-path-include-or-omit/.openspec.yaml b/openspec/changes/archive/2026-06-10-path-include-or-omit/.openspec.yaml deleted file mode 100644 index 11967fc..0000000 --- a/openspec/changes/archive/2026-06-10-path-include-or-omit/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-10-path-include-or-omit/design.md b/openspec/changes/archive/2026-06-10-path-include-or-omit/design.md deleted file mode 100644 index 6ad2917..0000000 --- a/openspec/changes/archive/2026-06-10-path-include-or-omit/design.md +++ /dev/null @@ -1,55 +0,0 @@ -## Context - -`Renderer.fit_path` (`claude/yas/renderer.py:340`) currently degrades through -six stages: full `path_git`; drop commit; drop commit+dirty; `path_git_compact`; -compact with **middle-ellipsis on `short_pwd`**; compact with **middle-ellipsis -on both `short_pwd` and the branch**. `short_pwd` (`session.py:278`) already -collapses parent segments to initials and keeps the basename full. - -Both layout call sites use it: `build_narrow` with `compact_only=True` -(line 184) and the wide builder with `compact_only=False` (line 302). - -## Goals / Non-Goals - -**Goals:** -- Treat the cwd path as a whole unit — included in full or omitted, never - middle-ellipsized. -- Make the degradation ladder shorter and predictable, with branch outliving - path. -- Keep an overflow-safe terminal state so the box border never breaks. - -**Non-Goals:** -- Changing `short_pwd`'s initial-collapsing scheme. -- Changing border/elbow math or the section's position in the row. -- Ellipsizing the branch as a path-ladder step (branch is whole-or-omitted too; - glyph-only is the floor). - -## Decisions - -- **New ladder** in `fit_path`, first candidate that fits via `_visible_width`: - 1. `path_git(...)` — path + branch + commit + dirty - 2. `path_git(..., show_commit=False)` - 3. `path_git(..., show_commit=False, show_dirty=False)` - 4. **path omitted, branch kept** — a branch-only form (glyph + arrow + branch) - 5. **glyph only** — presence indicator, guaranteed to fit -- **Remove** the two middle-ellipsis tail stages entirely. -- **Branch-only form:** introduce a small render path that emits the git glyph + - branch without the cwd segment (either a new helper or a flag on the existing - path renderer). Reuses existing colour constants; no new glyph. -- **`compact_only=True`:** the narrow builder skips the full `path_git` stages - and enters at the compact/branch-only rungs, preserving today's narrow entry - behavior while gaining the whole-omit semantics. -- **Glyph-only floor:** 1–2 visible columns; always ≤ target width, so the - terminal state can never overflow. - -## Risks / Trade-offs - -- **More abrupt transitions:** at medium widths a path that used to show as - `~/d/long…name` now disappears entirely once it stops fitting. This is the - intended behavior (legibility over partial detail) and is the explicit ask. -- **Branch-only helper surface:** adds one rendering path; mitigated by keeping - it a thin variant of the existing path renderer rather than a parallel - implementation. -- **Test coverage:** width-threshold transitions must be asserted via - `_visible_width` at several target widths to lock the new ladder and prove the - glyph-only floor never overflows. diff --git a/openspec/changes/archive/2026-06-10-path-include-or-omit/proposal.md b/openspec/changes/archive/2026-06-10-path-include-or-omit/proposal.md deleted file mode 100644 index ac727e8..0000000 --- a/openspec/changes/archive/2026-06-10-path-include-or-omit/proposal.md +++ /dev/null @@ -1,38 +0,0 @@ -## Why - -The cwd/path section degrades under width pressure by *middle-ellipsizing* the -path (and, at the extreme, the branch too). Chopping the middle out of an -already-abbreviated path (`~/d/yet-another-statusline`) is hard to read and the -ladder is more elaborate than it needs to be. The path should be treated as a -whole: included when it fits, omitted when it doesn't — never partially mangled. - -## What Changes - -- Replace `fit_path`'s middle-ellipsis fallback stages with a whole include/omit - ladder for the cwd path. -- New degradation order: full (path + branch + commit + dirty) → drop commit → - drop dirty → **drop the cwd path entirely** (branch retained) → **drop the - branch** (presence glyph only). -- Priority is branch over cwd: the branch survives longer than the path. -- No middle-ellipsis is applied to the path at any stage; the glyph-only final - state is overflow-safe (cannot break the box border math). - -## Capabilities - -### New Capabilities -- `path-display`: the cwd/branch section's width-degradation behavior — - whole-unit include/omit with a fixed drop priority and an overflow-safe - terminal state. - -### Modified Capabilities - - -## Impact - -- `claude/yas/renderer.py` — `Renderer.fit_path` (degradation ladder; removal of - the middle-ellipsis tail). `path_git` / `path_git_compact` unchanged in shape. -- Both call sites in `claude/yas/layout.py` (narrow `compact_only=True` and wide - `compact_only=False`) inherit the new ladder. -- Tests: a `test/` module covering `fit_path` at decreasing widths. -- No change to border/elbow math; the section's visible width still governed by - `_visible_width`. diff --git a/openspec/changes/archive/2026-06-10-path-include-or-omit/specs/path-display/spec.md b/openspec/changes/archive/2026-06-10-path-include-or-omit/specs/path-display/spec.md deleted file mode 100644 index 4710684..0000000 --- a/openspec/changes/archive/2026-06-10-path-include-or-omit/specs/path-display/spec.md +++ /dev/null @@ -1,46 +0,0 @@ -## ADDED Requirements - -### Requirement: Whole-unit cwd include or omit - -The cwd path SHALL be rendered as a whole unit: it is either included in full -(using the existing initial-collapsed `short_pwd` form) or omitted entirely. The -system SHALL NOT apply middle-ellipsis or any partial truncation to the cwd path -at any width. - -#### Scenario: Path included when it fits - -- **WHEN** the available width fits the path-plus-branch line -- **THEN** the full `short_pwd` is shown alongside the branch - -#### Scenario: Path omitted whole when it does not fit - -- **WHEN** the available width cannot fit the path-plus-branch line but can fit - the branch alone -- **THEN** the cwd path is dropped entirely and the branch is retained, with no - ellipsized path fragment shown - -### Requirement: Degradation priority and terminal state - -Under decreasing width the section SHALL shed fields in this order: commit, then -dirty markers, then the cwd path (whole), then the branch. The branch SHALL be -retained for longer than the cwd path. When even the branch does not fit, the -section SHALL fall back to a presence glyph only. This terminal state SHALL be -overflow-safe — it SHALL NOT exceed the available width or disturb the box -border alignment. - -#### Scenario: Branch outlives the path - -- **WHEN** width shrinks past the point where path-plus-branch fits -- **THEN** the path is dropped before the branch - -#### Scenario: Glyph-only terminal state - -- **WHEN** the available width cannot fit even the branch alone -- **THEN** only the presence glyph is shown and the rendered width stays within - the available width - -#### Scenario: No partial path or partial-branch ellipsis in the path ladder - -- **WHEN** the section degrades at any width -- **THEN** neither the cwd path nor the branch is rendered with a middle-ellipsis - fragment; each is shown in full or omitted as a whole diff --git a/openspec/changes/archive/2026-06-10-path-include-or-omit/tasks.md b/openspec/changes/archive/2026-06-10-path-include-or-omit/tasks.md deleted file mode 100644 index c1cdbb2..0000000 --- a/openspec/changes/archive/2026-06-10-path-include-or-omit/tasks.md +++ /dev/null @@ -1,23 +0,0 @@ -## 1. Branch-only render path - -- [x] 1.1 Add a way to render the git glyph + arrow + branch with no cwd segment (a new thin helper in `renderer.py`, or a `show_path=False` flag on the existing path renderer), reusing existing colour constants. -- [x] 1.2 Ensure a glyph-only form (presence indicator, 1–2 visible columns) is available as the terminal fallback. - -## 2. Rewrite the fit_path ladder - -- [x] 2.1 In `Renderer.fit_path`, replace the candidate list with: full → drop commit → drop commit+dirty → branch-only (path omitted) → glyph-only. -- [x] 2.2 Remove the two middle-ellipsis tail stages (`short_pwd` ellipsis and `short_pwd`+branch ellipsis). -- [x] 2.3 Preserve `compact_only=True` semantics: skip the full `path_git` stages and enter at the compact/branch-only rungs. -- [x] 2.4 Select the first candidate whose `_visible_width` is `<= target_w`; guarantee the glyph-only floor always fits. - -## 3. Tests - -- [x] 3.1 Add a test module asserting `fit_path` returns the full form at wide widths and drops commit then dirty as width shrinks. -- [x] 3.2 Assert that at a width too small for path+branch, the path is omitted whole (no ellipsis fragment) and the branch remains. -- [x] 3.3 Assert that at a width too small for the branch alone, only the glyph remains and `_visible_width(result) <= target_w` (overflow-safe floor). -- [x] 3.4 Assert no result at any tested width contains a middle-ellipsis path fragment. - -## 4. Verification - -- [x] 4.1 Run `make test` — green. -- [x] 4.2 Run `make demo` and resize across narrow/medium/wide; confirm the path appears/disappears as a whole and the border stays aligned at every width. (Interactive TUI resize can't run headless; verified the equivalent invariant programmatically — `fit_path` swept across widths 0–200 shows the path included/omitted as a whole with no ellipsis at any width, and `_visible_width(result) <= target_w` always holds, so border alignment is preserved.) diff --git a/openspec/changes/archive/2026-06-16-display-workflow-agents/.openspec.yaml b/openspec/changes/archive/2026-06-16-display-workflow-agents/.openspec.yaml deleted file mode 100644 index 2cb8041..0000000 --- a/openspec/changes/archive/2026-06-16-display-workflow-agents/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-10 diff --git a/openspec/changes/archive/2026-06-16-display-workflow-agents/design.md b/openspec/changes/archive/2026-06-16-display-workflow-agents/design.md deleted file mode 100644 index 608fc4a..0000000 --- a/openspec/changes/archive/2026-06-16-display-workflow-agents/design.md +++ /dev/null @@ -1,67 +0,0 @@ -## Context - -The Workflow tool spawns multi-agent runs whose subagents are written to disk under the session, but one directory level below where yas looks. Today `RunningSubagents.from_session` globs `subagents/*.meta.json`; workflow agents sit at `subagents/workflows//agent-*.{jsonl,meta.json}` and their `meta.json` is just `{"agentType":"workflow-subagent"}` — no `description`, no per-agent type. The richer per-run data (name, phases, an `agentId → label` map, status) lives in a session-level snapshot `workflows/.json`, and an append-only `journal.jsonl` sits beside the agents recording `started`/`result` events. - -The existing subagent pipeline is reusable in two places: the transcript parser (`RunningSubagents._parse_transcript`) already extracts tokens, the activity snippet, `first_timestamp`, `mtime`, and `end_ts` from any `agent-*.jsonl`; and `Renderer.subagent_row` already renders a single agent at any width. What's missing is (a) a reader that discovers runs and groups their agents, (b) a run-scoped liveness model, and (c) grouped header/footer rendering. - -Constraints: the renderer is a single-pass column-math painter (see `tmck-code-statusline` skill); new glyphs must be hoisted to `constants.py` as escapes; width math goes through `_visible_width`; new on-disk readers live under `info/` and surface via a `SessionView` `@cached_property`; layout decisions live in `build_*`, not `render_layout`. - -## Goals / Non-Goals - -**Goals:** -- Surface live workflow agents grouped by their run, with meaningful labels and the run name/phase when available. -- Make detection robust to the run JSON's unknown liveness behaviour — a run is detectable from the filesystem alone. -- Reuse `_parse_transcript` and `subagent_row` rather than duplicating parsing/rendering. -- Keep the normal subagent cohort behaviour untouched. - -**Non-Goals:** -- No per-phase nesting of agent rows. Phase is a header hint only; agents render flat. -- No dependency on `journal.jsonl` for Done/running status — `end_ts` from the transcript is the single source, matching the subagent cohort. -- No trust in the run JSON's `totalTokens`/`status` as load-bearing; they are hints/enrichment. -- No change to ordinary subagent detection, rows, or windows. - -## Decisions - -### Decision: Filesystem is the detection spine; run JSON enriches - -A run is detected by the `subagents/workflows//` directory existing with at least one agent transcript. The directory and `agent-*.jsonl` files exist the moment an agent starts, so live detection cannot depend on when (or whether) `workflows/.json` is written. - -*Why over JSON-as-spine:* **confirmed empirically** by running a live two-agent workflow — while both agents were active (transcripts growing, `journal.jsonl` present with `started`/`result` events), `workflows/.json` did **not exist**; only `workflows/scripts/` was present. The run JSON is written at **completion only**. A JSON-spine design would therefore show workflows *only after they finish* — the exact opposite of the goal. Filesystem detection is immune to that. - -*Enrichment:* when `workflows/.json` parses, take `workflowName`, the `workflowProgress` `agentId → label` map, and the current phase. Match labels to agents by `agentId` (the transcript filename stem). **Because the JSON is completion-only, during a live run the per-field fallbacks are the _primary_ path, not an edge case:** name → `runId`, label → sanitized first prompt line, phase → omitted. The JSON enrichment effectively only upgrades the labels/name once the run has already finished (and is in its retirement grace window). - -### Decision: New `info/workflows.py` reader, parallel to `info/subagents.py` - -Add `RunningWorkflow` (one run: `run_id`, `name`, `phase`, `agents: list[RunningSubagent]`, plus derived counts) and `RunningWorkflows` with `from_session(session_id, project_dir)` and a `visible(now, last_prompt_ts)` method. Reuse `RunningSubagent` as the per-agent dataclass and call the existing `_parse_transcript` (lift it to a shared helper if needed) so token/activity/Done logic is identical to the cohort. The session/project slug logic is copied from `RunningSubagents.from_session`. - -*Why a new module over extending `RunningSubagents`:* the grouping unit (a run) and the liveness window differ; folding both into one class would tangle two cohorts with different retirement rules. A sibling reader keeps each cohort's rules legible and testable in isolation (`test_workflow_cohort.py` parallel to `test_cohort_visibility.py`). - -### Decision: Run-scoped liveness, ~120s, independent of subagent windows - -A run stays visible while any agent `mtime` is within `WORKFLOW_LIVENESS_SECONDS` (default 120) OR the JSON status is non-terminal; it retires once terminal (JSON terminal status, or all agents `end_ts > 0`) AND newest `mtime` is older than a grace window (~20–30s, may reuse the cohort grace constant). - -*Why not reuse the cohort's 30/60s windows:* workflows run for minutes and an agent can sit Done-and-idle between phases longer than 60s; the cohort windows would retire a live run mid-flight and then re-show it, causing flicker. The longer window rides through lulls. - -### Decision: Reuse `subagent_row`; new helpers only for header/footer - -Per-agent rows call the existing `Renderer.subagent_row(sub, width, twoline=width>100, ...)`. New `Renderer` helpers render only the group header (`▸ []`) and summary footer (`└ N agents · M done · `). Group glyphs (`▸`, `└`) and thresholds are constants in `constants.py`. The block is assembled in the `build_*` builders as a sequence of `RowSpec(kind='content')` rows placed after the subagent cohort and task row. - -*Why:* visual consistency with the normal cohort for free, and no new border `kind` — the block is plain content rows inside the existing box. - -### Decision: Caps and collapse handled in `build_*`, summarised in the footer - -Narrow (<`MEDIUM_WIDTH`) → header+summary only. Per-run agent rows cap at 6 (ordered by `first_timestamp`), overflow folded into the footer (`+K hidden`). Concurrent runs cap at 2 (most-recently-active by newest `mtime`), overflow on a single `+N more workflows` line. Any silent truncation is reflected in the footer/overflow text, never dropped invisibly. - -## Risks / Trade-offs - -- **[Run JSON is actually completion-only] →** Filesystem-spine design already covers this; labels degrade to prompt lines and the header to `runId`, but agents still appear live. Acceptable. -- **[Run JSON is written but lags / has stale `workflowProgress`] →** Labels may briefly be the fallback prompt line until the JSON catches up; never blocks detection. Acceptable. -- **[Leftover run directories from prior runs] →** The liveness window + terminal check retire them; a stale terminal dir older than the window never shows. -- **[Phase derivation is fragile]** — in the one observed JSON, agent `phase` fields were null and phase boundaries were separate `workflow_phase` progress events, so position-based phase assignment is unreliable. *Mitigation:* treat phase as a best-effort header hint derived from the latest `workflow_phase` progress index; omit it entirely rather than guess wrong. -- **[Vertical space blowout]** — many agents across multiple runs could dominate the box. *Mitigation:* the 6-agent and 2-run caps plus narrow collapse bound the height. -- **[Untrusted input]** — agent labels/snippets and the workflow name come from model/tool output. *Mitigation:* route every displayed string through the existing `_sanitize` used by the subagent path. - -## Open Questions - -- Exact terminal-status string(s) in `workflows/.json` (`completed` confirmed; are there `failed`/`cancelled`?). Treat any non-`running`/non-empty terminal-looking status as terminal, and rely on the `all end_ts > 0` filesystem check as the real retirement signal so this is not load-bearing. -- Whether `journal.jsonl` is worth reading at all. Current design does not need it; left as a future enrichment if `end_ts` proves insufficient for interrupted agents. diff --git a/openspec/changes/archive/2026-06-16-display-workflow-agents/proposal.md b/openspec/changes/archive/2026-06-16-display-workflow-agents/proposal.md deleted file mode 100644 index 8a23ef6..0000000 --- a/openspec/changes/archive/2026-06-16-display-workflow-agents/proposal.md +++ /dev/null @@ -1,30 +0,0 @@ -## Why - -The Workflow tool runs multi-agent orchestrations whose subagents are invisible to the statusline. yas globs `subagents/*.meta.json`, but workflow agents live a directory level deeper (`subagents/workflows//`) and their `meta.json` carries only `{"agentType":"workflow-subagent"}` — no `description`, no per-agent type. So a user running a workflow sees nothing, even though several agents are actively burning tokens. We want to surface those agents, grouped by their workflow run, so the statusline reflects what is actually executing. - -## What Changes - -- Detect live workflow runs by globbing `subagents/workflows//` directories under the session (a directory exists the instant an agent starts). -- Read the session-level `workflows/.json` opportunistically to enrich a run with its `workflowName`, current phase, and the `workflowProgress` `agentId → label` map. When the JSON is absent or stale, fall back to the transcript's first prompt line for the label and the `runId` for the header — detection never depends on JSON liveness. -- Reuse the existing transcript parser (`_parse_transcript`) for every workflow agent's tokens, activity snippet, and Done detection (`end_ts > 0`) — no new per-agent reader. -- Give workflow runs their own liveness window (~120s), separate from the subagent cohort's 30/60s windows, so a run survives between-phase lulls without flickering. -- Render each live run as a **distinct grouped block** after the normal subagent cohort and task row: a header (`▸ []`), per-agent rows reusing `subagent_row`, and a summary footer (`└ N agents · M done · `). Narrow widths (<80) collapse a run to header+summary only. Per-run agent rows cap at 6 with overflow rolled into the summary. At most 2 workflow blocks render concurrently. - -## Capabilities - -### New Capabilities -- `workflow-cohort`: Detection, run-scoped grouping, liveness/retirement, and grouped rendering of agents spawned by the Workflow tool, including the run-JSON enrichment spine and its filesystem fallback. - -### Modified Capabilities - - -## Impact - -- New reader module under `claude/yas/info/` (e.g. `workflows.py`) exposing a `RunningWorkflows.from_session(...)` analogous to `RunningSubagents`. -- New `@cached_property` on `SessionView` (`info/__init__.py`) constructing it. -- New `Renderer` helpers for the workflow header/footer rows; per-agent rows reuse `subagent_row`. -- New `RowSpec` wiring in `layout.py` `build_*` builders (narrow collapse, wide full, placement after the subagent cohort). -- New constants (the `▸`/`└` group glyphs, the liveness/cap thresholds) in `constants.py`. -- New tests: detection + label fallback, liveness/retirement, narrow collapse, agent cap, multi-run cap. -- `CONTEXT.md` glossary gains the workflow-run terms if any displayed label is canonical. diff --git a/openspec/changes/archive/2026-06-16-display-workflow-agents/specs/workflow-cohort/spec.md b/openspec/changes/archive/2026-06-16-display-workflow-agents/specs/workflow-cohort/spec.md deleted file mode 100644 index a80cdd8..0000000 --- a/openspec/changes/archive/2026-06-16-display-workflow-agents/specs/workflow-cohort/spec.md +++ /dev/null @@ -1,132 +0,0 @@ -## ADDED Requirements - -### Requirement: Workflow run detection via filesystem - -The statusline SHALL detect a workflow run by the existence of a `subagents/workflows//` directory under the session's project directory. A run SHALL be discovered the instant any agent transcript appears in that directory, independently of whether the session-level `workflows/.json` exists yet. Each `agent-.jsonl` in the run directory SHALL be parsed with the same transcript parser used for ordinary subagents to obtain tokens, the activity snippet, `first_timestamp`, `mtime`, and `end_ts`. - -#### Scenario: Run discovered before its JSON is written - -- **WHEN** `subagents/workflows//` contains at least one `agent-*.jsonl` but no `workflows/.json` exists yet -- **THEN** the run is detected and each agent is parsed from its transcript - -#### Scenario: Agent identity comes from the transcript filename - -- **WHEN** a workflow agent transcript is named `agent-.jsonl` -- **THEN** its `agentId` is ``, used to match against the run JSON's `workflowProgress` entries - -#### Scenario: Non-workflow subagents are unaffected - -- **WHEN** the session has both ordinary `subagents/agent-*.jsonl` files and `subagents/workflows//agent-*.jsonl` files -- **THEN** the ordinary subagents remain in the normal cohort and only the nested agents are grouped into workflow runs - -### Requirement: Run enrichment from the run JSON - -The statusline SHALL read `workflows/.json` opportunistically to enrich a detected run. When present and parseable, the run's display name SHALL be its `workflowName`, each agent's label SHALL be the `label` of the matching `agentId` entry in `workflowProgress`, and the current phase SHALL be derived from the run's phase progress. The run JSON SHALL NOT be required for detection and SHALL NOT, on its own, mark a run live or retired. - -#### Scenario: Name and labels taken from the JSON - -- **WHEN** `workflows/.json` exists with a `workflowName` and `workflowProgress` mapping agentIds to labels -- **THEN** the run header shows `workflowName` and each agent row shows its mapped label - -#### Scenario: Malformed JSON degrades to fallback - -- **WHEN** `workflows/.json` is missing, empty, or fails to parse -- **THEN** detection still succeeds and the run renders using fallback identity (see Fallback identity requirement) - -### Requirement: Fallback identity without the run JSON - -When the run JSON does not supply a name or a label for an agent, the statusline SHALL fall back deterministically. The run header name SHALL fall back to the `runId`. An agent's label SHALL fall back to the first non-empty line of the first user message in its transcript, sanitized for untrusted input and middle-ellipsised to the available width. The phase SHALL be omitted from the header when no phase is available. - -#### Scenario: Header falls back to runId - -- **WHEN** a run has no `workflowName` available -- **THEN** the run header shows the `runId` (e.g. `wf_d8212a1d-34a`) - -#### Scenario: Label falls back to the prompt line - -- **WHEN** an agent has no mapped label in `workflowProgress` -- **THEN** its row label is the sanitized first non-empty line of its first user message, middle-ellipsised to fit - -#### Scenario: Phase omitted when unknown - -- **WHEN** no current phase can be derived for a run -- **THEN** the run header renders without a phase segment - -### Requirement: Done detection reused from the subagent parser - -A workflow agent SHALL be treated as **Done** under the same rule as ordinary subagents: when, and only when, its transcript contains an assistant message whose `message.stop_reason` equals `"end_turn"`, with that line's timestamp captured as `end_ts`. The run's summary count of completed agents SHALL be the number of agents with `end_ts > 0`. - -#### Scenario: Completed agent counts toward the summary - -- **WHEN** an agent transcript's final assistant message carries `stop_reason: "end_turn"` -- **THEN** that agent is Done and is included in the summary's `M done` count - -#### Scenario: Still-running agent is not counted Done - -- **WHEN** an agent transcript contains no `end_turn` -- **THEN** that agent is not Done and is excluded from the `M done` count - -### Requirement: Run-scoped liveness and retirement - -The statusline SHALL apply a workflow-sized liveness window that is independent of, and longer than, the subagent cohort's windows, so a run survives between-phase lulls. A run SHALL remain visible while any of its agents has a transcript `mtime` within the workflow liveness window (default 120 seconds), OR while its run JSON reports a non-terminal status. A run SHALL retire once it is settled — its run JSON reports a terminal status (or, in the filesystem-only case, every agent has `end_ts > 0`) AND its most recently written agent transcript is older than a grace window. - -#### Scenario: Run survives a between-phase lull - -- **WHEN** all of a run's currently-spawned agents have finished but the run is between phases and the most recent transcript write is within the workflow liveness window -- **THEN** the run remains visible - -#### Scenario: Settled run retires after grace - -- **WHEN** a run is terminal (JSON terminal status, or all agents `end_ts > 0`) AND its newest agent transcript `mtime` is older than the grace window -- **THEN** the run is no longer visible - -#### Scenario: Stale leftover run directory is not shown - -- **WHEN** a `subagents/workflows//` directory exists from a prior run whose newest transcript `mtime` is older than the workflow liveness window and it is terminal -- **THEN** the run is not displayed - -### Requirement: Grouped run rendering - -The statusline SHALL render each visible workflow run as a distinct grouped block, placed after the normal subagent cohort and the task row. The block SHALL consist of a header row, zero or more per-agent rows, and a summary footer row. The header SHALL show a group glyph, the run name, and the current phase when known. Per-agent rows SHALL reuse the existing subagent row renderer so a workflow agent is visually identical to an ordinary subagent row at the same width. The summary footer SHALL show the agent count, the Done count, and the run's aggregate token total summed from the per-agent transcript parse. - -#### Scenario: Wide block shows header, agents, and summary - -- **WHEN** a run is visible at medium or wider width -- **THEN** the block renders a header (`▸ []`), one row per agent (capped, see below), and a summary footer (`└ N agents · M done · `) - -#### Scenario: Per-agent rows match the subagent row format - -- **WHEN** a workflow agent row is rendered at a given width -- **THEN** it uses the same row renderer and field set as an ordinary subagent row (two-line above width 100, one-line otherwise) - -#### Scenario: Aggregate tokens summed locally - -- **WHEN** a run's summary footer renders its token total -- **THEN** the total is the sum of the per-agent transcript token parse, not the run JSON's reported total - -### Requirement: Narrow-width collapse - -At narrow width (below the medium threshold), the statusline SHALL collapse a workflow run to its header and summary only, omitting all per-agent rows, to protect vertical space. - -#### Scenario: Narrow run shows header and summary only - -- **WHEN** a run is visible and the layout width is below the medium threshold -- **THEN** only the header and summary rows render and no per-agent rows are shown - -### Requirement: Per-run agent cap - -The statusline SHALL render at most 6 per-agent rows for a single run. When a run has more than 6 agents, only the first 6 (ordered by `first_timestamp`) SHALL render and the remaining count SHALL be reflected in the summary footer. - -#### Scenario: Overflowing run caps at six rows - -- **WHEN** a run has 9 agents at a width that shows per-agent rows -- **THEN** 6 agent rows render and the summary notes the 3 hidden (e.g. `└ 9 agents · 4 done · +3 hidden`) - -### Requirement: Concurrent run cap - -The statusline SHALL render at most 2 workflow run blocks concurrently. When more than 2 runs are visible, the 2 most-recently-active runs (by newest agent `mtime`) SHALL render and the remaining count SHALL be noted on a single overflow line. - -#### Scenario: Third concurrent run is summarised - -- **WHEN** 3 runs are simultaneously visible -- **THEN** the 2 most-recently-active runs render as blocks and a one-line `+1 more workflows` note is shown diff --git a/openspec/changes/archive/2026-06-16-display-workflow-agents/tasks.md b/openspec/changes/archive/2026-06-16-display-workflow-agents/tasks.md deleted file mode 100644 index c0fe978..0000000 --- a/openspec/changes/archive/2026-06-16-display-workflow-agents/tasks.md +++ /dev/null @@ -1,49 +0,0 @@ -## 1. Constants and glyphs - -- [x] 1.1 Add `GLYPH_WF_HEADER` (`▸`) and `GLYPH_WF_SUMMARY` (`└`) to `constants.py` as escape-encoded literals, alongside `ICON_COST`/`GLYPH_MODEL` -- [x] 1.2 Add `WORKFLOW_LIVENESS_SECONDS = 120`, `WORKFLOW_AGENT_CAP = 6`, and `WORKFLOW_RUN_CAP = 2` to `constants.py` - -## 2. Reader: detection and parsing - -- [x] 2.1 Lift `RunningSubagents._parse_transcript` to a shared module-level helper (or import it) so the workflow reader can call it without duplication -- [x] 2.2 Create `claude/yas/info/workflows.py` with a `RunningWorkflow` dataclass (`run_id`, `name`, `phase`, `agents: list[RunningSubagent]`) and derived `done_count`/`agent_count`/`total_tokens` properties -- [x] 2.3 Implement `RunningWorkflows.from_session(session_id, project_dir)`: glob `subagents/workflows//agent-*.jsonl`, parse each via the shared transcript helper, group by ``, reusing the project-slug logic from `RunningSubagents.from_session` -- [x] 2.4 Implement run-JSON enrichment: parse `workflows/.json` when present; set `name` from `workflowName`, map `workflowProgress` `agentId → label` onto each agent, derive current phase from the latest `workflow_phase` progress index; never raise on missing/malformed JSON -- [x] 2.5 Implement fallback identity: name → `runId`; per-agent label → sanitized first non-empty line of the first user message in the transcript, middle-ellipsised; phase → omitted. Route all displayed strings through `_sanitize` - -## 3. Liveness and visibility - -- [x] 3.1 Implement `RunningWorkflows.visible(now, last_prompt_ts)`: keep a run while any agent `mtime` is within `WORKFLOW_LIVENESS_SECONDS` OR JSON status is non-terminal -- [x] 3.2 Implement retirement: drop a run once terminal (JSON terminal status OR all agents `end_ts > 0`) AND newest agent `mtime` is older than the grace window -- [x] 3.3 Order visible runs by newest agent `mtime` and cap concurrent runs at `WORKFLOW_RUN_CAP`, exposing the hidden-run count - -## 4. SessionView seam - -- [x] 4.1 Add a `workflows` `@cached_property` to `SessionView` (`info/__init__.py`) constructing `RunningWorkflows.from_session(...)` from the session id and project dir - -## 5. Rendering - -- [x] 5.1 Add `Renderer.workflow_header(run, width)` → `▸ []` (phase omitted when unknown), width-clamped via `_visible_width` -- [x] 5.2 Add `Renderer.workflow_summary(run, width, *, hidden_agents)` → `└ N agents · M done · ` with `+K hidden` when agents are capped -- [x] 5.3 Reuse `Renderer.subagent_row` for per-agent rows (`twoline=width>100`); cap at `WORKFLOW_AGENT_CAP` ordered by `first_timestamp` - -## 6. Layout wiring - -- [x] 6.1 In the wide/medium `build_*` builders, after the subagent cohort and task row, append a workflow block per visible run: header row, capped agent rows, summary row (as `RowSpec(kind='content')`) -- [x] 6.2 In the narrow `build_*` builder, collapse each run to header + summary rows only (no per-agent rows) -- [x] 6.3 When `WORKFLOW_RUN_CAP` is exceeded, append a single `+N more workflows` content row -- [x] 6.4 Re-thread surrounding border `ups`/`downs` for the added rows so elbows stay aligned - -## 7. Tests - -- [x] 7.1 `test_workflow_cohort.py`: detection from FS with no JSON; agentId from filename; ordinary subagents unaffected -- [x] 7.2 Enrichment: name + labels + phase from a fixture run JSON; malformed/missing JSON degrades to fallback identity -- [x] 7.3 Liveness: run survives a between-phase lull; settled run retires after grace; stale leftover dir not shown -- [x] 7.4 Rendering/layout: wide block (header/agents/summary), narrow collapse, 6-agent cap with `+K hidden`, 2-run cap with `+N more workflows` -- [x] 7.5 Done count uses `end_ts`; aggregate tokens summed from per-agent parse (not JSON `totalTokens`) - -## 8. Verification and docs - -- [x] 8.1 `make test` green (baseline + new tests); `ruff check` and `mypy .` clean -- [x] 8.2 `make demo` — eyeball a synthesised workflow block at narrow/medium/wide; verify `┬`/`│`/`┴` elbow alignment around the new rows -- [x] 8.3 Update `CONTEXT.md` glossary if any displayed workflow term (run name, phase, agent label) is canonical diff --git a/openspec/changes/archive/2026-06-16-improve-workflow-display/.openspec.yaml b/openspec/changes/archive/2026-06-16-improve-workflow-display/.openspec.yaml deleted file mode 100644 index e767a17..0000000 --- a/openspec/changes/archive/2026-06-16-improve-workflow-display/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-15 diff --git a/openspec/changes/archive/2026-06-16-improve-workflow-display/design.md b/openspec/changes/archive/2026-06-16-improve-workflow-display/design.md deleted file mode 100644 index 7b809e3..0000000 --- a/openspec/changes/archive/2026-06-16-improve-workflow-display/design.md +++ /dev/null @@ -1,46 +0,0 @@ -## Context - -YAS discovers workflow runs from the filesystem (`subagents/workflows//`) and enriches them from a completion-only run JSON (`workflows/.json`). During a live run the run JSON does not exist; only the agent transcript files and a `journal.jsonl` (containing `started`/`result` entries with agent IDs, no phase data) are present. The workflow script is always written to `workflows/scripts/-.js` at run start and contains the full `meta.phases` array. - -Agent labels (`scan:injection` etc.) and the current phase string come from the run JSON's `workflowProgress` entries and are only available post-completion. During a live run, agent labels fall back to the first line of each agent's prompt. - -## Goals / Non-Goals - -**Goals:** -- Strip newlines from tool-argument display in all agent rows (workflow and ordinary subagents) -- Show all phase titles inline in the workflow header, from the script file (live) and with current-phase highlight from the run JSON (post-completion) -- Verify done-agent greying reaches workflow agents (existing code path, no new logic) -- Pair workflow agents side-by-side at width ≥ 160 to halve vertical space usage - -**Non-Goals:** -- Per-phase agent grouping or phase-scoped agent counts (requires phase→agent mapping not available live) -- Knowing the current phase during a live run (not in any on-disk structure) -- Changing the liveness/retirement logic for workflows - -## Decisions - -### Phase data source: script file, not journal -The `journal.jsonl` carries only `started`/`result` entries — no phase data. The run JSON only exists at completion. The workflow script (`workflows/scripts/*-.js`) is available from run start and contains `meta.phases` with all phase titles. Decision: parse phases from the script file with a narrow regex (`phases:\s*[` ... `]`). - -*Alternative considered*: parse `phase()` call sites from the script body to infer which phase an agent belongs to. Rejected — brittle, requires JS parsing, and per-phase counts aren't in scope. - -### Phase list rendering: inline in header row -Putting the phase list in the existing header row (`▸ name P1 · ❯P2 · P3`) keeps the row count fixed and avoids new RowSpec kinds. The name middle-ellipsises when the phase list is wide; the phase list truncates with `…` only when the name is already at minimum width. - -*Alternative considered*: dedicated phase row below the header. Rejected — adds a row per workflow even when phase info is sparse; the inline form is more compact. - -### Two-column threshold: 160 columns -Below 160, one-line agent rows already compress well. At 160+, pairing halves the row count without making either half too narrow (each half gets `(width - 4 - 5) // 2 ≈ 75` columns at the threshold). Pairs are formed sequentially by `first_timestamp` order; done+running can be mixed in a pair. - -### Newline stripping: at display time, not parse time -`subagent_activity` is the only consumer that renders tool args into a single terminal line. Stripping at parse time (`parse_transcript`) would silently discard data that callers might want (e.g. future multi-line rendering). Strip in `subagent_activity` only, immediately after `raw` is assigned. - -## Risks / Trade-offs - -- **Script regex fragility**: If a workflow script uses backtick strings or unusual formatting for `title:`, the regex may miss phases. Mitigation: the fallback is an empty list, which degrades gracefully to the existing `[phase]` bracket style. -- **Two-column width math**: The right column must be padded to exactly `half_w` columns (using `_visible_width`) to avoid border misalignment. ANSI codes must not be counted in the width. Mitigation: use `_visible_width` for all column math, pad with spaces to `half_w` before joining. -- **Phase list overflow**: A workflow with many long phase names can overflow the header. Mitigation: truncate the phase list string with `…` when it would crowd the name below a minimum (e.g. 8 chars). - -## Migration Plan - -No on-disk format changes. The `phases` field on `RunningWorkflow` is a new in-memory field populated on each render; existing completed-run JSON files continue to work unchanged. No rollback needed — reverting any file restores prior behaviour. diff --git a/openspec/changes/archive/2026-06-16-improve-workflow-display/proposal.md b/openspec/changes/archive/2026-06-16-improve-workflow-display/proposal.md deleted file mode 100644 index d3ee5fd..0000000 --- a/openspec/changes/archive/2026-06-16-improve-workflow-display/proposal.md +++ /dev/null @@ -1,26 +0,0 @@ -## Why - -Workflow runs in YAS currently display agent prompts verbatim, which means multi-line tool commands bleed across rows and phase progress (a first-class concept in the claude workflow UI) is invisible. With workflows becoming a common usage pattern, the statusline needs to surface phase structure, handle multi-line content cleanly, and use screen real estate efficiently when wide terminals are available. - -## What Changes - -- Tool arguments in agent activity rows are truncated to the first line (no multi-line bleed) -- Workflow headers show the full ordered phase list inline (`Discover · ❯Scan · Verify · Synthesize`) with the current phase highlighted -- Done workflow agents render greyed with a frozen timer (verified to reach the existing `is_done` dim path) -- At terminal widths ≥ 160, workflow agents are paired side-by-side in two columns per row, halving vertical space usage - -## Capabilities - -### New Capabilities -- `workflow-phase-display`: Inline phase list in workflow run headers, parsed live from the workflow script's `meta.phases` and enriched with current-phase highlighting from the completion JSON -- `workflow-two-column-agents`: Two-column agent layout for workflow runs at wide terminal widths (≥ 160) - -### Modified Capabilities -- `subagent-row-layout`: Tool-argument display now strips newlines to show only the first line - -## Impact - -- `claude/yas/info/workflows.py`: new `_parse_script_phases` function; `phases: list[str]` field on `RunningWorkflow`; `_enrich` debug logging removed -- `claude/yas/renderer.py`: `workflow_header` updated to render phase list; `subagent_activity` strips newlines from tool args -- `claude/yas/layout.py`: `build_workflow_rows` gains two-column pairing logic at width ≥ 160 -- Tests: new cases for phase parsing, newline stripping, and two-column layout diff --git a/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/subagent-row-layout/spec.md b/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/subagent-row-layout/spec.md deleted file mode 100644 index a8cafa4..0000000 --- a/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/subagent-row-layout/spec.md +++ /dev/null @@ -1,33 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Activity verb derivation -The activity continuation's verb SHALL be derived from the latest assistant -message in the subagent transcript by preferring the last `tool_use` content -block in that message. When the message contains no `tool_use` block, the verb -SHALL fall back to the first non-empty line of the last `text` block, passed -through the untrusted-input sanitizer. A `thinking` block SHALL continue to -render as the thinking indicator. The system SHALL NOT render a contentless -`(replying)` placeholder when text content is available. - -The rendered text snippet (and tool-arg) SHALL use a dynamic activity -truncation cap that grows with the available line-2 width, measured via the -visible-width helper, appending a single `…` when the content exceeds that cap. -The cap defaults to 36 visible columns when no wider space is available. - -When the tool argument contains newline characters, only the first line SHALL -be used for display. Subsequent lines SHALL be discarded before the width cap -is applied. - -#### Scenario: Two-line row renders duration-first with the line-1 cluster - -- **WHEN** a subagent is rendered in two-line form with room for all fields -- **THEN** line 1 reads ` · ` with a right-aligned `share% · tok · model` cluster, and line 2 reads `└ ` - -#### Scenario: No t/m rate or output token field - -- **WHEN** any subagent row is rendered -- **THEN** neither the t/m rate field nor the ↑output field appears - -#### Scenario: Multi-line tool argument shows only first line -- **WHEN** the tool argument string contains newline characters (e.g. a multi-line Bash command) -- **THEN** only the content before the first newline is displayed; subsequent lines are not rendered diff --git a/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-phase-display/spec.md b/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-phase-display/spec.md deleted file mode 100644 index 7ac7abc..0000000 --- a/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-phase-display/spec.md +++ /dev/null @@ -1,35 +0,0 @@ -## ADDED Requirements - -### Requirement: Phase list parsed from workflow script -The system SHALL parse phase titles from the workflow script file located at `workflows/scripts/*-.js` using a regex on the `meta.phases` array. Parsing SHALL extract each `title:` string in order. When no script file exists or parsing fails, the phase list SHALL be empty and the system SHALL fall back to the existing `[phase]` bracket display. Parsing SHALL never raise an exception. - -#### Scenario: Phases extracted from script meta block -- **WHEN** a workflow script exists at `workflows/scripts/*-.js` containing a `meta.phases` array with `title:` fields -- **THEN** `RunningWorkflow.phases` is populated with the titles in order - -#### Scenario: Missing script yields empty phase list -- **WHEN** no matching script file exists in `workflows/scripts/` -- **THEN** `RunningWorkflow.phases` is `[]` and the header falls back to `[phase]` bracket style - -#### Scenario: Malformed script yields empty phase list -- **WHEN** the script file exists but has no parseable `phases:` array -- **THEN** `RunningWorkflow.phases` is `[]` and no exception is raised - -### Requirement: Inline phase list in workflow header -When `RunningWorkflow.phases` is non-empty, the workflow header SHALL render phases inline as a dot-separated list after the workflow name, using the form `▸ P1 · P2 · P3`. Each phase title SHALL be rendered in a dim colour. The phase matching `run.phase` (the current phase from the completion JSON) SHALL be rendered in a highlight colour with a `❯` prefix. When `run.phase` is empty (live run), all phases SHALL be dimmed with no `❯` marker. The workflow name SHALL be middle-ellipsised to fit the available width after the glyph and phase list. When the phase list itself is too wide, it SHALL be truncated with `…` rather than further truncating the name. - -#### Scenario: All phases shown with current highlighted post-completion -- **WHEN** `run.phases = ['Discover', 'Scan', 'Verify']` and `run.phase = 'Scan'` -- **THEN** the header renders `▸ Discover · ❯Scan · Verify` with `❯Scan` in highlight colour and the others dimmed - -#### Scenario: All phases shown dimmed during live run -- **WHEN** `run.phases = ['Discover', 'Scan']` and `run.phase = ''` -- **THEN** the header renders `▸ Discover · Scan` with both phases dimmed and no `❯` marker - -#### Scenario: Empty phases falls back to bracket style -- **WHEN** `run.phases = []` and `run.phase = 'Scan'` -- **THEN** the header renders `▸ [Scan]` (existing bracket form) - -#### Scenario: Phase list truncated when too wide -- **WHEN** the phase list plus name exceed `content_width` -- **THEN** the phase list is truncated with `…` and the name is preserved at its minimum width diff --git a/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-two-column-agents/spec.md b/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-two-column-agents/spec.md deleted file mode 100644 index 9b4b0c6..0000000 --- a/openspec/changes/archive/2026-06-16-improve-workflow-display/specs/workflow-two-column-agents/spec.md +++ /dev/null @@ -1,27 +0,0 @@ -## ADDED Requirements - -### Requirement: Two-column agent layout at wide terminal widths -When `per_agent` is True and the terminal width is ≥ 160, workflow agents SHALL be rendered in pairs side-by-side within a single content row, separated by a ` │ ` vertical divider. Each half SHALL receive `(inner - 5) // 2` columns where `inner = width - 4`. Agents SHALL be paired sequentially by `first_timestamp` order. When the agent count is odd, the final agent SHALL be rendered full-width. Done and running agents MAY be mixed within a pair. When terminal width is < 160, agents SHALL render one per row as before. - -#### Scenario: Agents paired at width ≥ 160 -- **WHEN** `per_agent` is True, width ≥ 160, and there are 4 agents -- **THEN** the layout emits 2 content rows, each containing 2 agents separated by ` │ ` - -#### Scenario: Odd agent rendered full-width -- **WHEN** `per_agent` is True, width ≥ 160, and there are 3 agents -- **THEN** the layout emits 2 content rows: one with agents 1+2 paired, one with agent 3 full-width - -#### Scenario: Below threshold renders one per row -- **WHEN** `per_agent` is True and width < 160 -- **THEN** each agent occupies its own content row (existing behaviour) - -#### Scenario: Done and running agents may be paired together -- **WHEN** width ≥ 160 and agents 1 (done) and 2 (running) are adjacent in timestamp order -- **THEN** they appear side-by-side in the same row; agent 1 renders with dim done styling - -### Requirement: Two-column mode uses one-line agent form -In two-column layout, each agent SHALL be rendered using the one-line (non-twoline) form regardless of terminal width. The `twoline=True` path SHALL only apply in single-column layout. - -#### Scenario: One-line form used in two-column mode -- **WHEN** width ≥ 160 and agents are rendered in two-column mode -- **THEN** each agent is rendered with `twoline=False` (single-line form) diff --git a/openspec/changes/archive/2026-06-16-improve-workflow-display/tasks.md b/openspec/changes/archive/2026-06-16-improve-workflow-display/tasks.md deleted file mode 100644 index 9af841e..0000000 --- a/openspec/changes/archive/2026-06-16-improve-workflow-display/tasks.md +++ /dev/null @@ -1,48 +0,0 @@ -## 1. Pre-edit baseline - -- [x] 1.1 Run `make test` and record pass count -- [x] 1.2 Run `make demo` and confirm borders/elbows are clean - -## 2. Newline stripping in tool-arg display - -- [x] 2.1 In `claude/yas/renderer.py` `subagent_activity`: after `raw = str(inp[key])`, add `raw = raw.split('\n')[0]` -- [x] 2.2 In `claude/yas/renderer.py` `subagent_activity`: after `raw = str(next(iter(inp.values())))`, add `raw = raw.split('\n')[0]` -- [x] 2.3 Add test in `test/test_subagent_rows.py`: tool arg with `\n` shows only first line - -## 3. Remove debug logging - -- [x] 3.1 In `claude/yas/info/workflows.py` `_enrich`: restore `data = json.loads(json_path.read_text())` (remove `raw_text` variable and the `yas-wf-debug.json` write block) — already clean in tree (no-op) -- [x] 3.2 In `claude/yas/info/workflows.py` `from_session`: remove the large debug `try:` block that writes `yas-wf-debug.json` — already clean in tree (no-op) - -## 4. Phase parsing data layer - -- [x] 4.1 Add `_parse_script_phases(scripts_dir: Path, run_id: str) -> list[str]` to `claude/yas/info/workflows.py` (regex on `phases:\s*[...]` block, extract `title:` strings, return `[]` on any error) -- [x] 4.2 Add `phases: list[str] = field(default_factory=list)` to `RunningWorkflow` dataclass -- [x] 4.3 In `from_session`, after `cls._enrich(wf, session_dir)`, set `wf.phases = _parse_script_phases(session_dir / 'workflows' / 'scripts', wf.run_id)` -- [x] 4.4 Add test in `test/test_workflow_cohort.py`: script with 3 phases → `wf.phases` has 3 titles in order -- [x] 4.5 Add test: missing script → `wf.phases == []`, no exception - -## 5. Phase list rendering in workflow header - -- [x] 5.1 Update `workflow_header` in `claude/yas/renderer.py` to render inline phase list when `run.phases` is non-empty -- [x] 5.2 Current phase (matching `run.phase`) rendered with `SKILLS` colour and `❯` prefix; others `CTX_DIM` -- [x] 5.3 When `run.phase` is empty, all phases rendered `CTX_DIM`, no `❯` marker -- [x] 5.4 When `run.phases` is empty, fall back to existing `[phase]` bracket style -- [x] 5.5 Phase list truncated with `…` when too wide rather than truncating the name below minimum -- [x] 5.6 Add test in `test/test_workflow_cohort.py` or a new `test_workflow_header.py`: header with phases renders correct dim/highlight -- [x] 5.7 Add test: header with empty phases falls back to bracket style - -## 6. Two-column agent layout - -- [x] 6.1 In `build_workflow_rows` in `claude/yas/layout.py`, add two-column pairing branch: `if per_agent and width >= 160` -- [x] 6.2 Each half gets `(inner - 5) // 2` columns (`inner = width - 4`) -- [x] 6.3 Pair agents sequentially by `first_timestamp` order; odd agent rendered full-width -- [x] 6.4 Right-pad each half to its column width using `_visible_width` before joining with `f' {r.BORDER}│{r.R} '` -- [x] 6.5 Both halves rendered with `twoline=False`; `session_inout=0` unchanged -- [x] 6.6 Add test in `test/test_layout_seam.py` or `test/test_subagent_rows.py`: width=160 → agents paired; width=159 → one per row - -## 7. Post-edit verification - -- [x] 7.1 Run `make test` — pass count must be ≥ baseline plus new tests (835 → 842, +7 new, zero failures) -- [x] 7.2 Run `make demo` — confirm borders/elbows still align, pill flows correctly -- [x] 7.3 If possible, observe a live workflow run in the statusline and confirm: phase list visible, no multi-line tool-arg bleed, two-column layout active at wide width — no live run available; verified equivalently via direct `workflow_header` render smoke checks (phase list visible, current-phase `❯` marker) and the new unit tests for newline-stripping and two-column pairing diff --git a/openspec/changes/archive/2026-06-16-restyle-top-row/.openspec.yaml b/openspec/changes/archive/2026-06-16-restyle-top-row/.openspec.yaml deleted file mode 100644 index a903f7f..0000000 --- a/openspec/changes/archive/2026-06-16-restyle-top-row/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-16 diff --git a/openspec/changes/archive/2026-06-16-restyle-top-row/design.md b/openspec/changes/archive/2026-06-16-restyle-top-row/design.md deleted file mode 100644 index 11651bf..0000000 --- a/openspec/changes/archive/2026-06-16-restyle-top-row/design.md +++ /dev/null @@ -1,42 +0,0 @@ -## Context - -The wide top row is painted by section helpers on `Renderer` (`elapsed_section`, `helper`, `cache_section`, `model_right_section`, `burndown_trend`) plus the two compact model helpers, with the session-timer string formatted upstream by `_fmt_elapsed_clock` in `info/__init__.py`. Column geometry is recomputed by `build_wide` from each section's visible width, so changes that alter a section's width re-thread the box elbows automatically — provided the helpers return accurate `_visible_width` and `div_offset` values. All glyphs are Nerd Font PUA and live as named escapes in `constants.py`. - -This change is presentation-only. No data source, gather seam, or token accounting changes. Several of the requested tweaks are inferred from the user's mockup rather than the bullet list, and were confirmed in a grilling session before this proposal (icon→limit mapping, reset-countdown reposition, dotted separator, fixed-width timer). - -## Goals / Non-Goals - -**Goals:** -- A single, legible time/percentage vocabulary across the top row. -- Stable column geometry — the timer column must not shift when the clock gains an hours digit. -- Cross-width consistency for the model glyph. -- Keep box-elbow alignment correct at every width threshold (validated by `make demo`). - -**Non-Goals:** -- No change to the data layer (`SessionView`, `TranscriptUsage`, `cache_countdown` derivation, token accounting). -- No change to medium/narrow layout *structure* — only the shared model glyph and percentage precision propagate there. -- No new rows, sections, or dividers; the set of top-row dividers (path, timer, cache) is unchanged. - -## Decisions - -- **Icon→limit mapping follows the mockup, not the bullet labels.** The user's written labels assigned timer-outline to 7-day and calendar-week to 5-hour, which is both semantically backwards and contradicts the mockup. Resolved: 5-hour → `ICON_LIMIT_5H` (timer-outline 󰔛, U+F051B), 7-day → `ICON_LIMIT_7D` (calendar-week 󰨴, U+F0A34). These replace the previous `GLYPH_HELPER` lead on the rate segment. - -- **Model glyph is a third, new glyph.** Neither monitor (`GLYPH_MODEL`) nor brain (`GLYPH_THINKING`) survives; the single lead becomes `GLYPH_MODEL_LIGHT` (lightbulb-on 󱩑, U+F1A51), applied in the wide pill and both compact pills. Fast mode continues to swap the lead to `GLYPH_BURN_FAST`. Rationale: the user explicitly chose this glyph during grilling; one icon de-clutters the pill. - -- **Effort/thinking is parenthesised text, not a glyph.** The pill renders `… (medium)`; when the effort/thinking value is empty, the parens are omitted entirely (no empty `()`). - -- **Timer reserves a fixed 8-char field.** `_fmt_elapsed_clock` drops the leading `0:` under an hour (`MM:SS`), keeping `H:MM:SS`/`HH:MM:SS` otherwise; `elapsed_section` right-justifies the result into 8 columns (`HH:MM:SS` worst case). Chosen over natural width so the box column is stable across the whole session; chosen over a 7-char reserve so 10h+ sessions don't overflow by a column. - -- **Two distinct time reformats, deliberately different.** The 5-hour *reset countdown* becomes `(-H:MM)` (hours kept, seconds dropped) because it spans hours; the *cache countdown* becomes `MM:SS` rolling to `H:MM:SS` because it is normally seconds-to-minutes. They are not unified. - -- **Percentage precision centralised where possible.** `burndown_trend` moves from `{:05.2f}` to `{:.1f}`, covering both 5h and 7d trends in one edit. Usage percentages are wrapped at each render site (`helper`, the 7-day branch, and both compact helpers) as `{float(pct):.1f}`. - -- **Width re-threading is automatic.** Because `build_wide` derives divider columns from section widths, no manual elbow column edits are needed; correctness is confirmed visually rather than by hand-computed offsets. - -## Risks / Trade-offs - -- **Crooked box from a stale width** → Every helper that changed its content must return a matching `_visible_width`; verify with `make demo` across narrow/medium/wide, watching `┬`/`│`/`┴` alignment. -- **PUA glyphs lost through edits** → All four glyphs are added to `constants.py` as `\u`/`\U` escapes first, then imported by name; no raw glyph is typed into renderer edits (per the skill's PUA rule). -- **`MM:SS` overflow if a cache TTL exceeds an hour** → Handled by rolling to `H:MM:SS` at/over 60 min rather than printing minutes ≥ 60. -- **Fixed 8-char timer wastes ~3 columns for short sessions** → Accepted; geometry stability is worth more than the columns, and the wide layout has the room. -- **Compact glyph swap touches narrow/medium output** → Intentional for consistency; covered by updating the corresponding tests. diff --git a/openspec/changes/archive/2026-06-16-restyle-top-row/proposal.md b/openspec/changes/archive/2026-06-16-restyle-top-row/proposal.md deleted file mode 100644 index 485c6e3..0000000 --- a/openspec/changes/archive/2026-06-16-restyle-top-row/proposal.md +++ /dev/null @@ -1,27 +0,0 @@ -## Why - -The wide top row's session timer, rate-limit segments, cache countdown, and model pill have grown inconsistent: mixed time formats (`0:13:27`, `T-2:00:00`, `4m29s`), two-decimal trend percentages next to one-decimal usage percentages, two glyphs on the model pill, and a column that shifts as the session clock gains an hours digit. This change settles a single, legible presentation for the whole row. - -## What Changes - -- **Session timer**: drop the leading `0:` under an hour (`0:13:27` → `13:27`); right-justify into a fixed 8-char field (`HH:MM:SS`) so the column never shifts as the clock crosses `MM:SS` → `H:MM:SS` → `HH:MM:SS`. -- **5-hour segment**: lead with a timer-outline icon (󰔛); move the reset countdown to the front of the segment as `(-H:MM)` (seconds dropped, parens, single-digit hour), ahead of the usage and trend percentages. -- **7-day segment**: lead with a calendar-week icon (󰨴); change the divider between the 5-hour and 7-day segments from ` | ` to a dotted ` ┆ `. -- **Percentages**: render both usage and trend percentages at exactly one decimal place, in wide and compact layouts. -- **Cache countdown**: replace the `fmt_dur` format (`4m29s`) with `MM:SS`, rolling to `H:MM:SS` at or above an hour; keep the existing cache glyph. -- **Model pill**: collapse to a single leading glyph (lightbulb 󱩑), dropping both the monitor and brain glyphs; add one space of left padding after the pill edge; wrap the effort/thinking value in parentheses `(medium)`, omitted entirely when empty; fast mode still swaps the lead glyph to the burn glyph. Compact model pills swap to the same lightbulb glyph for cross-width consistency. - -## Capabilities - -### New Capabilities -- `top-row-format`: presentation rules for the wide top row — session-timer format and fixed-width reservation, rate-limit segment styling (5h/7d icons, reset-countdown placement and format, percentage precision, dotted inter-segment separator), and model-pill styling (single lightbulb glyph, left padding, parenthesised effort, fast-mode glyph swap) including compact-layout glyph consistency. - -### Modified Capabilities -- `cache-countdown`: the wide-layout Cache Countdown rendering requirement changes its time format from `fmt_dur` (`42s`, `3m07s`, `1h05m`) to `MM:SS`, rolling to `H:MM:SS` at or above an hour. The cache glyph, positioning, colour, and elbow threading are unchanged. - -## Impact - -- `claude/yas/constants.py`: new glyph constants (`ICON_LIMIT_5H`, `ICON_LIMIT_7D`, `GLYPH_MODEL_LIGHT`, `SEP_RATE`). -- `claude/yas/info/__init__.py`: `_fmt_elapsed_clock` drops the leading `0:` under an hour. -- `claude/yas/renderer.py`: `elapsed_section` (fixed-width reservation), `cache_section` (MM:SS/H:MM:SS), `helper` (5h icon, countdown reposition/format, 1dp), `model_right_section` (7d icon, dotted separator, 1dp, single glyph, padding, parens), `burndown_trend` (1dp), `model_section_compact` / `model_right_section_compact` (lightbulb glyph, 1dp). -- Tests under `test/` and the term glossary in `CONTEXT.md`. diff --git a/openspec/changes/archive/2026-06-16-restyle-top-row/specs/cache-countdown/spec.md b/openspec/changes/archive/2026-06-16-restyle-top-row/specs/cache-countdown/spec.md deleted file mode 100644 index f5aca2e..0000000 --- a/openspec/changes/archive/2026-06-16-restyle-top-row/specs/cache-countdown/spec.md +++ /dev/null @@ -1,25 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Cache Countdown rendering on the wide path/model row - -The wide layout SHALL render the **Cache Countdown** as its own vsep-delimited section on the path/model content row, positioned between the rate-limit helper and the model section, with a single left divider `│`; the model section (plain text or flush-right pill) SHALL remain flush to the right edge. The section SHALL display the cache glyph (`GLYPH_CACHE`, nf-oct-cache ``) followed by the remaining time formatted as `MM:SS` (zero-padded minutes and seconds, e.g. `04:29`), rolling to `H:MM:SS` when the remaining time is at or above one hour (e.g. `1:05:00`). The remaining-time figure SHALL be coloured by `fill_colour(elapsed_pct)` so it runs green when fresh and red near expiry. The new divider SHALL be threaded as an elbow column into the row's top border (`downs`) and following separator (`ups`) so the `┬`/`│`/`┴` stay aligned. Medium and narrow layouts SHALL NOT render the Cache Countdown. - -#### Scenario: Section renders with glyph, value, and divider - -- **WHEN** a wide render has a live `cache_countdown` of `(187, 38)` -- **THEN** the path/model row contains a `│`-delimited section showing the cache glyph and `03:07`, and that divider's column appears in the top border `downs` and the following separator `ups` - -#### Scenario: Remaining time at or above an hour rolls to H:MM:SS - -- **WHEN** a wide render has a live `cache_countdown` whose remaining time is `3905` seconds (1 h 5 m 5 s) -- **THEN** the section displays `1:05:05` - -#### Scenario: Colour tracks elapsed percentage - -- **WHEN** `elapsed_pct` crosses from the safe band into the alert band -- **THEN** the remaining-time figure's colour changes from the theme safe colour to the theme alert colour (the same ladder as the rate-limit percentages) - -#### Scenario: Narrow and medium omit it - -- **WHEN** the same session renders at narrow and medium widths -- **THEN** no Cache Countdown section appears in either layout diff --git a/openspec/changes/archive/2026-06-16-restyle-top-row/specs/top-row-format/spec.md b/openspec/changes/archive/2026-06-16-restyle-top-row/specs/top-row-format/spec.md deleted file mode 100644 index 1f4f666..0000000 --- a/openspec/changes/archive/2026-06-16-restyle-top-row/specs/top-row-format/spec.md +++ /dev/null @@ -1,86 +0,0 @@ -## ADDED Requirements - -### Requirement: Session timer format and fixed-width reservation - -The session timer SHALL be formatted as `MM:SS` when the elapsed time is under one hour (no leading hours digit or `0:` prefix, e.g. `13:27`), and as `H:MM:SS` or `HH:MM:SS` when one or more hours have elapsed. `_fmt_elapsed_clock` SHALL continue to return the empty string for zero or negative durations. The wide layout's `elapsed_section` SHALL right-justify the formatted timer into a fixed field of 8 visible columns (the `HH:MM:SS` worst case) so that the timer's divider column does not shift as the clock crosses `MM:SS` → `H:MM:SS` → `HH:MM:SS`. - -#### Scenario: Under an hour drops the hours digit - -- **WHEN** the session has run for 13 minutes 27 seconds -- **THEN** the timer string is `13:27` (no `0:` prefix) - -#### Scenario: An hour or more keeps the hours digit - -- **WHEN** the session has run for 1 hour 13 minutes 27 seconds -- **THEN** the timer string is `1:13:27` - -#### Scenario: Column stays put as the clock grows - -- **WHEN** the timer renders first as `13:27` and later as `1:13:27` at the same width -- **THEN** the timer occupies the same 8-column field (right-justified) and the timer divider column is unchanged - -### Requirement: Rate-limit segment icons and separator - -In the wide layout the 5-hour rate-limit segment SHALL lead with the timer-outline icon `ICON_LIMIT_5H` (nf-md-timer_outline, U+F051B) and the 7-day segment SHALL lead with the calendar-week icon `ICON_LIMIT_7D` (nf-md-calendar_week_begin, U+F0A34), replacing the previous shared helper glyph. When both segments are present, they SHALL be separated by a dotted vertical divider ` ┆ ` (`SEP_RATE`, U+2506) rather than ` | `. - -#### Scenario: Both segments render with their icons and dotted separator - -- **WHEN** a wide render has both a 5-hour and a 7-day rate-limit value -- **THEN** the 5-hour segment is preceded by the timer-outline icon, the 7-day segment by the calendar-week icon, and the two are joined by ` ┆ ` - -#### Scenario: Seven-day segment absent - -- **WHEN** the 7-day rate limit has no usage and no reset -- **THEN** only the 5-hour segment (with its timer-outline icon) renders and no ` ┆ ` separator appears - -### Requirement: Reset-countdown placement and format - -The 5-hour reset countdown SHALL be positioned at the front of the 5-hour segment, immediately after its icon and ahead of the usage and trend percentages, formatted as `(-H:MM)` — parenthesised, leading minus, hours kept, seconds dropped (e.g. `(-2:00)`, `(-0:45)`). When the limit has no reset (infinite/unknown), the existing infinite indicator SHALL render and no countdown SHALL appear. - -#### Scenario: Countdown leads the segment - -- **WHEN** the 5-hour limit resets in 2 hours exactly with 30.0% used -- **THEN** the segment renders the timer icon, then `(-2:00)`, then `30.0%`, then the trend - -#### Scenario: Under an hour keeps the single-digit hour - -- **WHEN** the 5-hour limit resets in 45 minutes -- **THEN** the countdown renders as `(-0:45)` - -### Requirement: One-decimal percentages - -All rate-limit usage percentages and burndown trend percentages SHALL render at exactly one decimal place (e.g. `30.0%`, `-30.1%`) in both the wide and the compact layouts. `burndown_trend` SHALL format its delta to one decimal place. - -#### Scenario: Usage and trend both at one decimal - -- **WHEN** a usage percentage is 30 and a trend delta is -30.14 -- **THEN** they render as `30.0%` and `-30.1%` respectively - -#### Scenario: Compact layout matches - -- **WHEN** a compact (narrow or medium) layout renders a usage percentage -- **THEN** it renders at one decimal place - -### Requirement: Model pill single glyph and parenthesised effort - -The model pill SHALL lead with a single glyph `GLYPH_MODEL_LIGHT` (nf-md-lightbulb_on_40, U+F1A51), replacing both the previous monitor and brain glyphs, with one extra space of padding between the pill's left edge and the glyph. In fast mode the lead glyph SHALL be swapped to `GLYPH_BURN_FAST`. The effort/thinking value SHALL be rendered as parenthesised text (e.g. `(medium)`) following the model name, and SHALL be omitted entirely — including the parentheses — when the effort/thinking value is empty. The compact model pills SHALL use the same `GLYPH_MODEL_LIGHT` lead glyph for cross-width consistency. - -#### Scenario: Wide pill with effort - -- **WHEN** a wide render shows model `Sonnet 4.6` with effort `medium` -- **THEN** the pill renders the lightbulb glyph (with leading padding), the model name, then `(medium)` - -#### Scenario: Empty effort omits the parentheses - -- **WHEN** the model has no effort/thinking value -- **THEN** the pill renders the lightbulb glyph and model name with no trailing parentheses - -#### Scenario: Fast mode swaps the lead glyph - -- **WHEN** fast mode is active -- **THEN** the lead glyph is the burn glyph rather than the lightbulb - -#### Scenario: Compact pills share the glyph - -- **WHEN** a narrow or medium layout renders the model pill -- **THEN** its lead glyph is `GLYPH_MODEL_LIGHT` diff --git a/openspec/changes/archive/2026-06-16-restyle-top-row/tasks.md b/openspec/changes/archive/2026-06-16-restyle-top-row/tasks.md deleted file mode 100644 index 6ee78d1..0000000 --- a/openspec/changes/archive/2026-06-16-restyle-top-row/tasks.md +++ /dev/null @@ -1,41 +0,0 @@ -## 1. Constants - -- [x] 1.1 Add `ICON_LIMIT_5H = '\U000f051b'` (nf-md-timer_outline 󰔛) to `constants.py` alongside the existing glyph block, encoded as an escape. -- [x] 1.2 Add `ICON_LIMIT_7D = '\U000f0a34'` (nf-md-calendar_week_begin 󰨴). -- [x] 1.3 Add `GLYPH_MODEL_LIGHT = '\U000f1a51'` (nf-md-lightbulb_on_40 󱩑). -- [x] 1.4 Add `SEP_RATE = '┆'` (dotted vertical ┆). - -## 2. Session timer - -- [x] 2.1 In `info/__init__.py`, change `_fmt_elapsed_clock` to drop the hours field when hours == 0 (return `MM:SS`), keeping `H:MM:SS`/`HH:MM:SS` when hours > 0; preserve the empty-string return for ms <= 0. -- [x] 2.2 In `renderer.py` `elapsed_section`, right-justify the timer string into a fixed 8-column field (`HH:MM:SS` worst case) and return the padded width via `_visible_width`. - -## 3. Rate-limit segments - -- [x] 3.1 In `renderer.py` `helper` (5-hour), lead the segment with `ICON_LIMIT_5H` (replacing the prior helper glyph) and move the reset countdown to the front as `(-H:MM)` (parens, leading minus, hours kept, seconds dropped, single-digit hour); keep the existing infinite-indicator path with no countdown. -- [x] 3.2 Format the 5-hour usage percentage to one decimal place (`{float(pct):.1f}`). -- [x] 3.3 In `renderer.py` `model_right_section`, lead the 7-day segment with `ICON_LIMIT_7D`, format its usage percentage to one decimal place, and change the 5h/7d separator from ` | ` to ` ┆ ` (`SEP_RATE`). -- [x] 3.4 In `renderer.py` `burndown_trend`, change the delta format from `{:05.2f}` to `{:.1f}` (covers both 5h and 7d trends). - -## 4. Cache countdown - -- [x] 4.1 In `renderer.py` `cache_section`, replace `fmt_dur(remaining)` with `MM:SS` (zero-padded), rolling to `H:MM:SS` at or above 3600 s; keep `GLYPH_CACHE` and the `fill_colour(elapsed_pct)` colour. - -## 5. Model pill - -- [x] 5.1 In `renderer.py` `model_right_section`, collapse to a single lead glyph `GLYPH_MODEL_LIGHT` (drop both monitor and brain), add one space of left padding after the pill edge, and keep the fast-mode swap to `GLYPH_BURN_FAST` on the lead glyph. -- [x] 5.2 Render the effort/thinking value as parenthesised text `(value)` after the model name; omit the parens entirely when the value is empty. -- [x] 5.3 In `model_section_compact` and `model_right_section_compact`, swap `GLYPH_MODEL` to `GLYPH_MODEL_LIGHT` and format the usage percentage to one decimal place. - -## 6. Tests - -- [x] 6.1 Update/add `test/test_model_section.py` for the single lightbulb glyph, leading padding, parenthesised effort, empty-effort omission, fast-mode swap, and compact glyph. -- [x] 6.2 Update/add rate-limit tests for the 5h/7d icons, `(-H:MM)` countdown placement, ` ┆ ` separator, and one-decimal usage/trend percentages. -- [x] 6.3 Update/add `test/test_info.py` (or equivalent) for `_fmt_elapsed_clock` MM:SS vs H:MM:SS, and a renderer test for the 8-column timer reservation. -- [x] 6.4 Update the cache-countdown rendering test(s) for the `MM:SS` / `H:MM:SS` format (e.g. `(187, 38)` → `03:07`, 3905 s → `1:05:05`). - -## 7. Verification & docs - -- [x] 7.1 Run `make test` — green, pass count >= baseline plus added tests. -- [x] 7.2 Run `make demo` — eyeball narrow/medium/wide; confirm every `┬` aligns with a `│` and a `┴`, pill colours flow, and the timer column is stable. -- [x] 7.3 Update `CONTEXT.md` glossary for any changed displayed term (cache time format, rate-limit icons, model glyph, timer format). diff --git a/openspec/changes/archive/2026-06-17-add-clear-timer/.openspec.yaml b/openspec/changes/archive/2026-06-17-add-clear-timer/.openspec.yaml deleted file mode 100644 index 3ac681e..0000000 --- a/openspec/changes/archive/2026-06-17-add-clear-timer/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-17 diff --git a/openspec/changes/archive/2026-06-17-add-clear-timer/design.md b/openspec/changes/archive/2026-06-17-add-clear-timer/design.md deleted file mode 100644 index 1be14b5..0000000 --- a/openspec/changes/archive/2026-06-17-add-clear-timer/design.md +++ /dev/null @@ -1,38 +0,0 @@ -## Context - -The wide top row renders a single session timer in `elapsed_section` (`renderer.py`), fed by `SessionView.elapsed` (= `_fmt_elapsed_clock(cost.total_duration_ms)`). The timer lives in one vsep-delimited cell in `build_wide` (`layout.py`) with a single divider/elbow (`elapsed_div_col`); the cell sheds entirely when the path would drop below 5 visible columns. Narrow/medium layouts never show the timer. - -Empirically, `/clear` forks a **new** transcript file with a new `session_id` and writes a `/clear` user marker near the top (line 3 in every observed sample). The user confirmed `cost.total_duration_ms` keeps counting across the fork, so the session timer already represents the whole session — the new clear timer is a genuinely distinct, shorter span. There is at most one `/clear` marker per transcript (a second clear forks again). - -## Goals / Non-Goals - -**Goals:** -- Add a "since last `/clear`" timer to the wide elapsed cell, leftmost, with a distinguishing glyph + accent colour. -- Preserve fresh-session rendering byte-identically (no `/clear` ⇒ exactly today's output). -- Degrade both → clear-only → shed, with path protection as the outermost guard. -- Bounded, cheap detection that never full-scans the transcript. - -**Non-Goals:** -- No change to narrow/medium layouts (they have no timer). -- No new border divider/elbow — both timers share the existing single cell divider. -- No reconstruction of a multi-file clear lineage; the session timer stays `total_duration_ms` as-is. -- No config flag to toggle the feature (out of scope for this change). - -## Decisions - -**Detection — bounded head-scan.** New reader under `info/` (e.g. `info/clear.py`) opens the transcript, iterates with a hard cap of 30 lines, cheap pre-filters (`'/clear' in ln and 'command-name' in ln`), JSON-parses candidates, and returns the first marker's `timestamp` parsed to epoch (reusing the `Z`→`+00:00` / `datetime.fromisoformat` idiom from `transcript.py`). Early-exit on first match; no match within budget, empty/missing path, or any parse error ⇒ `None`. Rationale: at most one marker exists and it sits at the top, so a 30-line cap is complete in practice and keeps cost O(30 lines) every render even on huge transcripts. - -**Gather seam.** Expose as a `@cached_property` on `SessionView` in `info/__init__.py` (e.g. `clear_epoch: float | None`), constructed from the new reader. Formatting stays out of the gather layer; the renderer/layout formats `_fmt_elapsed_clock(max(0, (now − clear_epoch)) * 1000)` using `view.now` for clock-skew safety. - -**Display — single cell, two timers.** `elapsed_section` is extended to accept the optional clear-timer string and compose: ` CLEAR SESSION`, clear-first. It returns the rendered content plus its visible width, as today. A new glyph constant goes in `constants.py` (my pick, e.g. nf-md-refresh `\U000f0450`, tuned in `make demo`); accent colour drawn from the existing non-grey palette (`CLR_GREEN_OK` / `CLR_CYAN` / `CLR_PEACH`). The session timer keeps its 8-column right-justified field so its divider column is stable. - -**Degradation ladder in `build_wide`.** Compute the both-timers content width and the clear-only content width. Apply the existing `(width - 4) - vsep_w - - helper_w - cache_section_w - right_w >= 5` test (path protection) against the both-width first; if it fails, retry with the clear-only width; if that also fails, shed the cell (`elapsed_section_w = 0`), exactly as today. Fresh session (no `clear_epoch`) ⇒ original single-timer path, unchanged. Only the chosen content string and its width change — `elapsed_div_col`, the vsep, and the elbow threading are untouched. - -**Tests.** `test_info.py` for the reader (cleared / fresh / bounded / malformed) and the `SessionView` cached_property; `test_model_section.py` (or the elapsed-section test home) for `elapsed_section` composing one vs two timers and the clock formatting; `test_layout_seam.py` for the degradation ladder (both / clear-only / shed) via an injected `SessionView`. Width assertions go through `_visible_width`. - -## Risks / Trade-offs - -- **Head-scan cap could miss a deeply-buried marker.** Mitigation: observed markers are always ~line 3; 30 lines is generous. If a future Claude Code layout pushes it lower, the timer silently falls back to "fresh session" (safe degradation, no crash). The cap is a named constant, easy to raise. -- **Semantic mismatch:** session timer is `total_duration_ms` (active-ish) while the clear timer is pure wall-clock. Accepted — "time since last /clear" reads naturally as wall-clock, and the user confirmed the two should differ. -- **Glyph round-trip hazard.** The new PUA glyph is added as an escaped constant in `constants.py` per the repo's PUA rule; never embedded as a raw literal in edited lines. -- **Width pressure:** at mid-wide widths the second timer competes with path/helper/cache. The ladder explicitly prefers the clear timer and falls back cleanly, so the box never overruns or detaches its elbow. diff --git a/openspec/changes/archive/2026-06-17-add-clear-timer/proposal.md b/openspec/changes/archive/2026-06-17-add-clear-timer/proposal.md deleted file mode 100644 index eef1d2c..0000000 --- a/openspec/changes/archive/2026-06-17-add-clear-timer/proposal.md +++ /dev/null @@ -1,31 +0,0 @@ -## Why - -The wide top-row session timer shows elapsed time for the whole session (`cost.total_duration_ms`), which keeps counting across a `/clear`. After a `/clear` the conversation context is reset but the session-wide clock gives no sense of how long the *current* (post-clear) working context has been running. Adding a "since last `/clear`" timer restores that signal without losing the whole-session view. - -## What Changes - -- Add a second timer to the wide top-row elapsed section: **time since the most recent `/clear`** in the current transcript. -- Detection: a `/clear` forks a new transcript file (new `session_id`) and writes a `/clear` user marker near the top. The current transcript therefore contains **at most one** such marker. A new gather field reads its `timestamp` via a **bounded head-scan** (first ~30 lines) so fresh sessions and large transcripts never pay a full-file scan. -- Display rules (wide layout only — narrow/medium are unaffected): - - **Fresh session** (no `/clear` marker) → render the existing session timer unchanged (byte-identical to today). - - **Cleared session** → show the clear timer (glyph + accent colour) **first/leftmost**, then the session timer (bare grey), inside the single existing elapsed cell. - - **Degradation ladder:** path protection stays the outermost guard (the whole elapsed cell still sheds if the path would drop below 5 visible columns); within the cell, if both timers don't fit, prefer the clear timer alone over the session timer. -- The clear timer reuses `_fmt_elapsed_clock` (`MM:SS` / `H:MM:SS`) and is computed wall-clock as `now − clear_epoch`, clamped at 0 for clock skew. - -## Capabilities - -### New Capabilities - - -### Modified Capabilities -- `statusline-info`: add a render-independent `SessionView` gather field exposing the most-recent `/clear` epoch (or `None`) from a bounded head-scan of the current transcript. -- `top-row-format`: the wide elapsed section gains an optional second (since-`/clear`) timer with clear-first ordering, a distinguishing glyph + accent colour, and a both → clear-only → shed degradation ladder; the fresh-session single-timer rendering is preserved unchanged. - -## Impact - -- `claude/yas/info/` — new bounded head-scan reader for the `/clear` marker timestamp. -- `claude/yas/info/__init__.py` — new `@cached_property` on `SessionView`. -- `claude/yas/renderer.py` — `elapsed_section` composes one or two timers; new glyph/accent colour. -- `claude/yas/constants.py` — new Nerd Font glyph constant for the clear timer. -- `claude/yas/layout.py` — `build_wide` threads the clear timer and the both → clear-only → shed width ladder through the existing elapsed cell (single divider/elbow unchanged). -- Tests under `test/` (model/section, layout seam, info) and `CONTEXT.md` glossary if a new displayed term is introduced. diff --git a/openspec/changes/archive/2026-06-17-add-clear-timer/specs/statusline-info/spec.md b/openspec/changes/archive/2026-06-17-add-clear-timer/specs/statusline-info/spec.md deleted file mode 100644 index 958ec3e..0000000 --- a/openspec/changes/archive/2026-06-17-add-clear-timer/specs/statusline-info/spec.md +++ /dev/null @@ -1,25 +0,0 @@ -## ADDED Requirements - -### Requirement: Clear-marker epoch gather field - -`SessionView` SHALL expose a render-independent, lazily-computed field giving the Unix epoch (seconds) of the most recent `/clear` in the current transcript, or `None` when the session has never been cleared. The field SHALL be read by a **bounded head-scan** of the transcript: it SHALL inspect at most the first 30 lines, match the `/clear` user marker, and parse that line's ISO-8601 `timestamp` to an epoch. Because each `/clear` forks a new transcript file, at most one such marker exists per transcript, so the first match is the only match. The scan SHALL early-exit on the first match and SHALL never read the whole file, so fresh sessions and large transcripts pay only the bounded cost. Malformed lines, an unreadable transcript, an empty `transcript_path`, or no marker within the budget SHALL yield `None` rather than raising. The field SHALL hold no ANSI or render geometry and SHALL be cached for the lifetime of the view. - -#### Scenario: Cleared session exposes the marker epoch - -- **WHEN** the current transcript contains a `/clear` marker within the first 30 lines with a parseable timestamp -- **THEN** the gather field returns that timestamp as a Unix epoch - -#### Scenario: Fresh session exposes None - -- **WHEN** the current transcript contains no `/clear` marker within the first 30 lines -- **THEN** the gather field returns `None` - -#### Scenario: Bounded cost on a large transcript - -- **WHEN** the transcript is hundreds of lines long and has no `/clear` marker in its first 30 lines -- **THEN** the scan reads at most 30 lines and returns `None` without scanning the remainder - -#### Scenario: Unreadable or malformed input degrades to None - -- **WHEN** the transcript path is empty, missing, or the candidate marker line is not valid JSON or lacks a parseable timestamp -- **THEN** the gather field returns `None` and does not raise diff --git a/openspec/changes/archive/2026-06-17-add-clear-timer/specs/top-row-format/spec.md b/openspec/changes/archive/2026-06-17-add-clear-timer/specs/top-row-format/spec.md deleted file mode 100644 index de44e12..0000000 --- a/openspec/changes/archive/2026-06-17-add-clear-timer/specs/top-row-format/spec.md +++ /dev/null @@ -1,60 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Session timer format and fixed-width reservation - -The session timer SHALL be formatted as `MM:SS` when the elapsed time is under one hour (no leading hours digit or `0:` prefix, e.g. `13:27`), and as `H:MM:SS` or `HH:MM:SS` when one or more hours have elapsed. `_fmt_elapsed_clock` SHALL continue to return the empty string for zero or negative durations. The wide layout's `elapsed_section` SHALL right-justify the formatted session timer into a fixed field of 8 visible columns (the `HH:MM:SS` worst case) so that the timer's divider column does not shift as the clock crosses `MM:SS` → `H:MM:SS` → `HH:MM:SS`. When the session has never been cleared (no `/clear` marker in the current transcript), the elapsed cell SHALL render only this session timer, byte-identical to the single-timer behaviour prior to this change. - -#### Scenario: Under an hour drops the hours digit - -- **WHEN** the session has run for 13 minutes 27 seconds -- **THEN** the timer string is `13:27` (no `0:` prefix) - -#### Scenario: An hour or more keeps the hours digit - -- **WHEN** the session has run for 1 hour 13 minutes 27 seconds -- **THEN** the timer string is `1:13:27` - -#### Scenario: Column stays put as the clock grows - -- **WHEN** the timer renders first as `13:27` and later as `1:13:27` at the same width -- **THEN** the timer occupies the same 8-column field (right-justified) and the timer divider column is unchanged - -#### Scenario: Fresh session renders the session timer unchanged - -- **WHEN** the wide layout renders and the current transcript has no `/clear` marker -- **THEN** the elapsed cell shows only the session timer, with no clear-timer glyph, identical to the pre-change rendering - -## ADDED Requirements - -### Requirement: Since-clear timer in the wide elapsed cell - -When the current transcript has been cleared (a `/clear` marker epoch is available), the wide layout's elapsed cell SHALL show a second "since last `/clear`" timer in addition to the session timer. The clear timer SHALL be computed wall-clock as `now − clear_epoch`, clamped to a minimum of 0 for clock skew, and formatted with the same `_fmt_elapsed_clock` (`MM:SS` / `H:MM:SS`) convention as the session timer. The clear timer SHALL be rendered first (leftmost) within the cell, led by a distinguishing Nerd Font glyph and an accent colour distinct from the grey session timer; the session timer SHALL follow in its existing grey. The clear timer and session timer SHALL share the single existing elapsed-cell divider/elbow — no additional border divider is introduced. - -#### Scenario: Cleared session shows both timers, clear first - -- **WHEN** the wide layout renders, the transcript was cleared, and both timers fit the available width -- **THEN** the elapsed cell shows the glyphed accent clear timer leftmost followed by the grey session timer, both inside one vsep-delimited cell with a single divider - -#### Scenario: Clear timer is wall-clock from the marker - -- **WHEN** the most recent `/clear` occurred 18 minutes 33 seconds ago -- **THEN** the clear timer reads `18:33` - -### Requirement: Elapsed-cell degradation ladder - -The wide elapsed cell SHALL degrade under width pressure in a fixed order. Path protection SHALL remain the outermost guard: the entire elapsed cell SHALL still shed (render nothing) whenever including it would leave the path fewer than 5 visible columns, exactly as before this change. Within the elapsed cell's own budget, when both timers cannot fit, the layout SHALL prefer the clear timer alone over the session timer — dropping the session timer first. The resulting tiers, widest to narrowest, SHALL be: both timers → clear timer only → cell shed entirely. - -#### Scenario: Both timers do not fit, clear timer wins - -- **WHEN** the elapsed cell can fit one timer but not both while still protecting the path -- **THEN** only the glyphed clear timer renders and the session timer is dropped - -#### Scenario: Path protection drops the whole cell - -- **WHEN** including even the clear-timer-only cell would leave the path fewer than 5 visible columns -- **THEN** the entire elapsed cell sheds and neither timer renders - -#### Scenario: Ample width shows both - -- **WHEN** the width comfortably fits both timers and the path -- **THEN** both the clear timer and the session timer render diff --git a/openspec/changes/archive/2026-06-17-add-clear-timer/tasks.md b/openspec/changes/archive/2026-06-17-add-clear-timer/tasks.md deleted file mode 100644 index 190d834..0000000 --- a/openspec/changes/archive/2026-06-17-add-clear-timer/tasks.md +++ /dev/null @@ -1,36 +0,0 @@ -## 1. Pre-flight (skill checklist) - -- [x] 1.1 Read `CONTEXT.md`; baseline `make test` (note pass count) and `make demo` (eyeball wide elapsed cell) -- [x] 1.2 Run the PUA-glyph scan over any lines to be edited in `renderer.py` / `constants.py` - -## 2. Constants - -- [x] 2.1 Add the clear-timer Nerd Font glyph constant to `constants.py` as an escaped literal (e.g. `GLYPH_CLEAR = '\\U000f0450' # nf-md-refresh`), in the existing PUA block -- [x] 2.2 Add a head-scan line-budget constant (e.g. `CLEAR_SCAN_MAX_LINES = 30`) - -## 3. Clear-marker reader (data source) - -- [x] 3.1 Add `info/clear.py` with a bounded head-scan that returns the most-recent `/clear` epoch or `None` (pre-filter `'/clear' in ln and 'command-name' in ln`, cap at `CLEAR_SCAN_MAX_LINES`, reuse the `Z`→`+00:00` / `fromisoformat` timestamp idiom; swallow OSError/JSON/parse errors → `None`) -- [x] 3.2 Add `clear_epoch: float | None` as a `@cached_property` on `SessionView` in `info/__init__.py` - -## 4. Renderer — two-timer composition - -- [x] 4.1 Extend `elapsed_section` to accept an optional clear-timer string and compose clear-first (glyph + accent colour) followed by the grey 8-col session timer; return `(text, visible_width)` -- [x] 4.2 Pick the accent colour from the existing non-grey palette; keep the session-timer field at 8 right-justified columns - -## 5. Layout — degradation ladder - -- [x] 5.1 In `build_wide`, format the clear timer from `view.clear_epoch` and `view.now` (`_fmt_elapsed_clock(max(0, now − clear_epoch) * 1000)`) -- [x] 5.2 Compute both-timers and clear-only content widths; apply the existing `>= 5` path-protection test to both-width, then clear-only width, else shed (`elapsed_section_w = 0`) -- [x] 5.3 Verify the chosen content threads through the unchanged `elapsed_div_col` / vsep / elbow path (no new divider); fresh session (no `clear_epoch`) takes the original single-timer path unchanged - -## 6. Tests - -- [x] 6.1 `test_info.py`: reader returns epoch for a cleared transcript, `None` for fresh, bounded (no full scan past the cap), and `None` on malformed/missing input; `SessionView.clear_epoch` wiring -- [x] 6.2 elapsed-section test: composes one vs two timers, clear-first order, glyph/accent present only when cleared, `MM:SS` / `H:MM:SS` formatting, clock-skew clamp -- [x] 6.3 `test_layout_seam.py`: degradation ladder (both → clear-only → shed) and fresh-session byte-identical single-timer via an injected `SessionView` - -## 7. Verification & docs - -- [x] 7.1 `make test` green (baseline + new tests); `make demo` — elbows aligned, cell degrades correctly across widths, fresh session unchanged -- [x] 7.2 Update `CONTEXT.md` glossary if a new displayed term/glyph meaning is introduced diff --git a/openspec/changes/archive/2026-06-17-justify-wide-top-row/.openspec.yaml b/openspec/changes/archive/2026-06-17-justify-wide-top-row/.openspec.yaml deleted file mode 100644 index 3ac681e..0000000 --- a/openspec/changes/archive/2026-06-17-justify-wide-top-row/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-17 diff --git a/openspec/changes/archive/2026-06-17-justify-wide-top-row/design.md b/openspec/changes/archive/2026-06-17-justify-wide-top-row/design.md deleted file mode 100644 index 0932469..0000000 --- a/openspec/changes/archive/2026-06-17-justify-wide-top-row/design.md +++ /dev/null @@ -1,65 +0,0 @@ -## Context - -The wide layout's top content row is assembled in `build_wide` (`layout.py`) as a sequence of sections separated by vsep blocks (` │ `). Currently all horizontal slack — `target_w - path_w` — flows into the path section as trailing space, leaving the other sections (elapsed, helper, cache) at their natural minimum widths and a large blank stretch before the right pill. - -The sections present in a wide top row, in order, are: -1. **path** — always active; content left-aligned -2. **elapsed** — optional (only when elapsed or since-/clear timer exists); content centered -3. **helper** — always active (`helper_text` from `model_right_section`); content centered -4. **cache** — optional (only when a cache countdown exists); content centered -5. **last-slot** — always active; the space between the final section and the right pill/text - -Each section is delimited on its right by a vsep block whose absolute column is tracked in `path_row_cols` for border elbow threading (`ups`/`downs` on `RowSpec`). - -## Goals / Non-Goals - -**Goals:** -- Distribute horizontal slack evenly across active top-row sections -- Keep path content left-aligned within its wider slot -- Center content in elapsed, helper, and cache slots -- Shift all vsep column references to match the new positions (elbow math stays correct) -- Gate the feature behind `cfg.justify` (default `false`) - -**Non-Goals:** -- Justification in medium or narrow layouts -- Changing section content or rendering logic -- Centering the path content itself - -## Decisions - -### D1: Equal distribution, not proportional - -Distribute `total_slack = target_w - path_w` as `extra_per = total_slack // N` per section, with the integer remainder spread one column at a time from left to right. Rationale: proportional distribution would give disproportionate space to the path (already the widest section), undermining the goal of visual balance. Equal distribution matches the mockup and is simpler. - -### D2: N includes the last slot - -The space between the end of the assembled `middle` string and the right pill is treated as a full slot. This ensures the pill appears well-separated from the last content section and gives the most uniform visual result. If the pill is active (`pill_pct` is set), the extra space is appended to `middle` before the right-pill painting path; in non-pill mode it adds to the existing `pad` calculation. - -### D3: Fallback when total_slack == 0 - -When the path already fills `target_w`, there is no slack to distribute. `build_wide` falls through to normal layout silently. Sub-N slack (0 < total_slack < N) still distributes its remainder columns — a 1-column shift is still worth applying. - -### D4: Div col arithmetic, not re-rendering - -Rather than re-computing sections with different widths, the implementation inserts literal space strings around section content and offsets every tracked column by the cumulative padding of all preceding sections. This is purely arithmetic and requires no changes to section helpers. - -The offsets accumulate as: -- `path_shift = path_extra` (trailing spaces after path, before vsep) -- `elapsed_shift = path_shift + elapsed_extra` (left + right centering padding) -- `helper_shift = elapsed_shift + helper_extra` -- `cache_shift = helper_shift + cache_extra` -- `sep_rate_col` (the `┆` inside `helper_text`) shifts by `elapsed_shift + h_left` - -### D5: Config wiring follows existing `full_width` pattern - -`DEFAULT_JUSTIFY = False` goes in `constants.py`. The `Config` dataclass gets a `justify: bool = DEFAULT_JUSTIFY` field. `Config.load` resolves it from `YAS_JUSTIFY` env and `[layout].justify` TOML key using the existing `_parse_bool` / `_env_sources` / `toml_src` pattern. No CLI flag is added (no existing precedent for boolean layout knobs as CLI flags). - -## Risks / Trade-offs - -- **Elbow misalignment if column arithmetic is off by one** → Risk is low because the existing `path_row_cols` accumulation pattern is well-tested; the new code follows the same pattern with explicit offset variables that are easy to inspect. -- **Subtle centering off-by-one for odd extra amounts** → Resolved by `left = extra // 2`, `right = extra - left`, which always sums to `extra`. -- **Feature is opt-in by default** → No risk to existing users. - -## Open Questions - -None — all design decisions were resolved during the proposal interview. diff --git a/openspec/changes/archive/2026-06-17-justify-wide-top-row/proposal.md b/openspec/changes/archive/2026-06-17-justify-wide-top-row/proposal.md deleted file mode 100644 index dcdd3c4..0000000 --- a/openspec/changes/archive/2026-06-17-justify-wide-top-row/proposal.md +++ /dev/null @@ -1,27 +0,0 @@ -## Why - -The wide layout's top row concentrates all horizontal slack in the path section, leaving inner sections (elapsed timer, rate limits, cache) cramped while a large blank gap sits between them and the right pill. Distributing that slack evenly across all sections produces a more balanced, readable bar at any terminal width. - -## What Changes - -- New boolean config knob `justify` (default `false`) under `[layout]` in `yas.toml`, with canonical env var `YAS_JUSTIFY`. -- When enabled, `build_wide` distributes the horizontal slack evenly across the active sections of the top content row instead of concentrating it in the path section. -- Path content stays left-aligned within its wider slot; inner sections (elapsed, helper, cache, and the pre-pill last slot) receive symmetric (centered) padding. -- All vsep divider columns shift accordingly so border elbows (`┬`/`┴`) stay aligned. -- Medium and narrow layouts are unaffected. - -## Capabilities - -### New Capabilities -- `justify-top-row`: Even distribution of horizontal slack across the top-row sections of the wide layout. - -### Modified Capabilities -- `statusline-config`: New `[layout].justify` boolean knob added to the config precedence chain (`YAS_JUSTIFY` env var, `yas.toml` `[layout]` key, default `false`). - -## Impact - -- `yas/constants.py` — add `DEFAULT_JUSTIFY = False` -- `yas/config.py` — wire `justify` knob into `Config.load` and the `Config` dataclass -- `yas/layout.py` — `build_wide`: compute and apply justify padding when `cfg.justify` is true -- No changes to rendering primitives (`renderer.py`, `render/borders.py`, `render/gradient.py`) -- No public API changes; `render()` in `app.py` is unchanged diff --git a/openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/justify-top-row/spec.md b/openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/justify-top-row/spec.md deleted file mode 100644 index 25113fa..0000000 --- a/openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/justify-top-row/spec.md +++ /dev/null @@ -1,72 +0,0 @@ -## ADDED Requirements - -### Requirement: Even slack distribution across wide top-row sections - -When `cfg.justify` is `true`, the wide layout's top content row SHALL distribute horizontal slack evenly across its active sections rather than concentrating all slack in the path section. The active sections, in order, are: path (always), elapsed (when present), helper (always), cache (when present), and last-slot (always — the space between the final section and the right pill/text). Let N be the count of active sections and `total_slack = target_w - path_w`. Each section i (0-indexed) SHALL receive `extra_per + (1 if i < remainder else 0)` extra columns, where `extra_per = total_slack // N` and `remainder = total_slack % N`. When `total_slack == 0` the layout SHALL fall through to the normal (non-justify) rendering unchanged. - -#### Scenario: Slack distributed across four active sections - -- **WHEN** `cfg.justify` is true, elapsed and cache are active (N=5), and `total_slack` is 20 -- **THEN** each section receives 4 extra columns (`20 // 5 = 4`, remainder 0) - -#### Scenario: Remainder spread left-to-right - -- **WHEN** `cfg.justify` is true, N=5, and `total_slack` is 22 -- **THEN** sections 0–1 receive 5 extra columns each and sections 2–4 receive 4 columns each - -#### Scenario: Zero slack falls through - -- **WHEN** `cfg.justify` is true and `total_slack == 0` (path fills its full target width) -- **THEN** the layout renders identically to the non-justify layout - -#### Scenario: Sub-N slack still distributes remainders - -- **WHEN** `cfg.justify` is true, N=5, and `total_slack` is 3 -- **THEN** sections 0–2 receive 1 extra column each and sections 3–4 receive 0 - -### Requirement: Padding placement per section - -The path section SHALL remain left-aligned; its `extra` columns SHALL be added as trailing spaces after the path content and before the vsep block. The elapsed, helper, and cache sections SHALL each have their content centered within the wider slot: `left_pad = extra // 2` spaces SHALL be prepended to the section content and `right_pad = extra - left_pad` spaces SHALL be appended, with the result sitting between the surrounding vsep blocks. The last-slot SHALL receive its `extra` columns as trailing space before the right pill or `right_text`. - -#### Scenario: Path extra goes to trailing side - -- **WHEN** the path section receives 6 extra columns in justify mode -- **THEN** 6 spaces are appended after the path content and before the adjacent vsep, and path content remains left-aligned - -#### Scenario: Helper content centered symmetrically - -- **WHEN** the helper section receives 8 extra columns -- **THEN** 4 spaces are prepended and 4 spaces are appended around the helper content - -#### Scenario: Odd extra splits left-biased - -- **WHEN** a middle section receives 7 extra columns -- **THEN** 3 spaces are prepended (left) and 4 spaces are appended (right) - -### Requirement: Divider column adjustment for border elbow alignment - -When justify padding is applied, every vsep divider column SHALL be shifted by the cumulative extra padding of all sections preceding it (including the section's own left-pad where applicable) before being recorded in `path_row_cols` for `ups`/`downs` elbow threading. The `sep_rate_col` (the `┆` inside `helper_text`) SHALL likewise be shifted by the cumulative padding up to and including the helper section's left-pad. The resulting `path_row_downs` and `path_row_ups` tuples SHALL have every column in the same relative position relative to their vsep blocks as in the non-justify layout. - -#### Scenario: Path divider column shifts by path extra - -- **WHEN** justify mode adds 6 extra columns to the path section -- **THEN** `path_div_col` increases by 6 and the `┬` on the top border aligns with the `│` in the content row - -#### Scenario: All downstream columns shift cumulatively - -- **WHEN** path receives 4 extra, elapsed receives 6 extra (left=3, right=3), helper receives 4 extra (left=2, right=2) -- **THEN** `elapsed_div_col` shifts by 4 (path extra) + 6 (elapsed total), `helper's sep_rate_col` shifts by 4 + 6 + 2 (helper left) - -### Requirement: Justify applies in both pill and non-pill mode - -When the model pill is active, the extra columns for the last slot SHALL be appended to the `middle` string before the `right_pill` rendering branch. When the pill is not active, the extra columns SHALL be added to the `pad` value before the `right_text` concatenation. In both cases the pill or `right_text` SHALL remain flush to the right edge. - -#### Scenario: Non-pill mode last-slot padding - -- **WHEN** justify mode is active and the pill is not active, and the last slot receives 8 extra columns -- **THEN** `pad` is increased by 8, placing 8 additional spaces before `right_text` - -#### Scenario: Pill mode last-slot padding - -- **WHEN** justify mode is active and the pill is active, and the last slot receives 8 extra columns -- **THEN** 8 spaces are appended to `middle` before the pill branch, and the pill remains at the right edge diff --git a/openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/statusline-config/spec.md b/openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/statusline-config/spec.md deleted file mode 100644 index 7a1d747..0000000 --- a/openspec/changes/archive/2026-06-17-justify-wide-top-row/specs/statusline-config/spec.md +++ /dev/null @@ -1,30 +0,0 @@ -## ADDED Requirements - -### Requirement: justify layout knob - -The statusline SHALL support a `justify` boolean knob that controls whether the wide layout distributes horizontal slack evenly across top-row sections. The knob SHALL resolve through the standard precedence chain: `YAS_JUSTIFY` environment variable → `[layout].justify` in `yas.toml` → built-in default of `false`. The env var SHALL accept the same boolean forms as other boolean knobs (`1`/`0`/`true`/`false`). An invalid value SHALL cause the knob to fall back to `false` and be recorded in debug output; a `yas.toml`-sourced rejection SHALL also be surfaced in the visible config-error row. The constant `DEFAULT_JUSTIFY = False` SHALL be defined in `constants.py` and imported by `config.py`. - -#### Scenario: Default is false - -- **WHEN** no `YAS_JUSTIFY` env var is set and no `[layout].justify` key exists in `yas.toml` -- **THEN** `cfg.justify` is `false` and the wide layout behaves as before - -#### Scenario: Env var enables justify - -- **WHEN** `YAS_JUSTIFY=1` is set in the environment -- **THEN** `cfg.justify` is `true` - -#### Scenario: yas.toml enables justify - -- **WHEN** `yas.toml` contains `[layout]` with `justify = true` and no `YAS_JUSTIFY` env var is set -- **THEN** `cfg.justify` is `true` - -#### Scenario: Env var overrides yas.toml - -- **WHEN** `YAS_JUSTIFY=0` is set in the environment and `[layout].justify = true` is in `yas.toml` -- **THEN** `cfg.justify` is `false` - -#### Scenario: Invalid env value falls back to default - -- **WHEN** `YAS_JUSTIFY=banana` is set -- **THEN** `cfg.justify` is `false` and the rejection is recorded in debug output diff --git a/openspec/changes/archive/2026-06-17-justify-wide-top-row/tasks.md b/openspec/changes/archive/2026-06-17-justify-wide-top-row/tasks.md deleted file mode 100644 index 6e794b5..0000000 --- a/openspec/changes/archive/2026-06-17-justify-wide-top-row/tasks.md +++ /dev/null @@ -1,38 +0,0 @@ -## 1. Config wiring - -- [x] 1.1 Add `DEFAULT_JUSTIFY = False` constant to `yas/constants.py` -- [x] 1.2 Add `justify: bool = DEFAULT_JUSTIFY` field to the `Config` dataclass in `yas/config.py` -- [x] 1.3 Wire `justify` into `Config.load`: resolve from `YAS_JUSTIFY` env var and `[layout].justify` TOML key using `_parse_bool` / `_env_sources` / `toml_src` -- [x] 1.4 Add `justify` to the `cls(...)` constructor call at the end of `Config.load` - -## 2. Justify layout logic in build_wide - -- [x] 2.1 After `line_path` and `path_w` are computed, compute `total_slack = target_w - path_w` and short-circuit to normal layout when `cfg.justify` is false or `total_slack == 0` -- [x] 2.2 Count active sections N: always 3 (path + helper + last-slot), +1 for elapsed, +1 for cache -- [x] 2.3 Compute `extra_per = total_slack // N` and `remainder = total_slack % N`; build a list of per-section extras using `extra_per + (1 if i < remainder else 0)` -- [x] 2.4 Apply path extra: append `path_extra` trailing spaces to `line_path` (or a separate `path_pad` string inserted before `vsep`) -- [x] 2.5 Apply elapsed extra (when active): prepend `e_left = extra // 2` spaces and append `e_right = extra - e_left` spaces around `elapsed_content` -- [x] 2.6 Apply helper extra: prepend `h_left = extra // 2` and append `h_right = extra - h_left` spaces around `helper_text` -- [x] 2.7 Apply cache extra (when active): prepend `c_left = extra // 2` and append `c_right = extra - c_left` spaces around `cache_content` -- [x] 2.8 Apply last-slot extra: in non-pill mode add to `pad`; in pill mode append spaces to `middle` before the pill branch - -## 3. Divider column adjustment - -- [x] 3.1 Shift `path_div_col` by `path_extra` after applying path padding -- [x] 3.2 Shift `elapsed_div_col` by `path_extra + elapsed_extra` (left + right combined) -- [x] 3.3 Shift `sep_rate_col` by the cumulative column offset up to and including `h_left` of the helper section -- [x] 3.4 Shift `cache_div_col` by the cumulative offset of all preceding sections' extras -- [x] 3.5 Verify all shifted columns are used in the re-computed `path_row_cols` list passed to `path_row_downs` / `path_row_ups` - -## 4. Tests - -- [x] 4.1 Add tests to `test/test_config.py`: `YAS_JUSTIFY=1` enables justify, `YAS_JUSTIFY=0` disables, `[layout].justify = true` in TOML enables, env overrides TOML, invalid value falls back to false -- [x] 4.2 Add tests to `test/test_layout_seam.py` (or a new `test_justify.py`): verify that with `cfg.justify=True` and known `total_slack`, the content row visible width equals `width - border_overhead` and all section extras sum to `total_slack` -- [x] 4.3 Add scenario: `total_slack == 0` with justify enabled produces output identical to justify-disabled -- [x] 4.4 Add scenario: N=3 (no elapsed, no cache) distributes slack across 3 sections correctly - -## 5. Visual verification - -- [x] 5.1 Run `make test` and confirm all tests pass (count matches baseline + new tests) -- [x] 5.2 Run `make demo` and confirm border elbows align correctly at all width thresholds with `YAS_JUSTIFY=1` -- [x] 5.3 Confirm pill mode renders correctly in justify mode by checking the pill stays flush-right diff --git a/openspec/changes/archive/2026-06-18-add-layout-labels/.openspec.yaml b/openspec/changes/archive/2026-06-18-add-layout-labels/.openspec.yaml deleted file mode 100644 index 3ac681e..0000000 --- a/openspec/changes/archive/2026-06-18-add-layout-labels/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-17 diff --git a/openspec/changes/archive/2026-06-18-add-layout-labels/design.md b/openspec/changes/archive/2026-06-18-add-layout-labels/design.md deleted file mode 100644 index 5623394..0000000 --- a/openspec/changes/archive/2026-06-18-add-layout-labels/design.md +++ /dev/null @@ -1,56 +0,0 @@ -## Context - -The wide statusline is a single-pass terminal painter with hand-tuned column math. Its top border and separator rows are drawn in `claude/yas/render/borders.py` (`BorderRenderer.border_top`, `border_separator`, `border_separator_dim`) as an ordered list of `grad_at(pos) + char` parts joined into one string; the rainbow gradient is strictly positional (`t = col / (width - 1)`), so any out-of-order insertion decoheres colour from column. Section geometry (divider columns, section widths) is computed in `build_wide` (`claude/yas/layout.py`) and threaded to the border methods as `ups`/`downs` elbow columns via `RowSpec`. Config knobs resolve through a fixed precedence chain in `claude/yas/config.py` onto a frozen `Config`; `layout.justify` is the closest existing analog (wide-only, boolean, `view.cfg.justify`). - -This change adds an opt-in `layout.labels` mode that overlays superscript section labels onto those existing border/separator rows, without adding any content rows. - -## Goals / Non-Goals - -**Goals:** -- A `layout.labels` boolean knob plumbed exactly like `layout.justify`. -- Superscript labels painted into the wide top border and separators, anchored above the values they name. -- Labels colourise positionally with the border gradient (and inherit the separator dim ramp) — no flat colour. -- Labels yield to elbows, frame corners, the session id, and the model pill; they truncate or drop rather than shift anything. -- Zero behaviour change when the flag is off, and in narrow/medium at any flag value. - -**Non-Goals:** -- Labels in narrow/medium layouts. -- Per-helper column export from `renderer.py` (rejected below; anchoring instead measures the content strings `build_wide` already holds). -- Configurable label text or per-label toggles. -- Any change to the stdin payload, token/cost math, or on-disk formats. - -## Decisions - -### Decision: Per-column buffer overlay, then a single gradient pass - -Refactor the three border methods to first build a per-column `chars: list[str]` buffer (length `width`) holding the base glyph for every column — frame corners, fill (`─`/`┄`), elbows (`┬`/`┴`/`┼`), and the embedded session id — exactly as today, then **overlay** label glyphs onto fill-only columns, then run a single ordered pass emitting `grad_at(col) + chars[col]`. Because the gradient pass still walks columns in order, colour stays coherent and labels colourise for free, including the dim factor on `border_separator_dim`. - -*Why over splicing into the existing parts list:* the research showed inserting label parts mid-loop decoheres `grad_at(pos)` from the visual column. A column-indexed buffer makes overwrite-in-place trivial and keeps the gradient positional by construction. - -*Alternative considered — paint labels as a post-process on the finished ANSI string:* rejected; re-parsing ANSI to find fill columns is fragile and re-derives width math the buffer already has. - -### Decision: Labels yield to structural glyphs via a fill-only mask - -Before overlay, mark which buffer columns are fill (the only overwritable kind). A label writes left-to-right from its anchor only across contiguous fill columns; it stops (truncates) at the first non-fill column (elbow, session id, pill, corner) and is dropped if its anchor is not fill. This guarantees no elbow/pill/session-id/column ever shifts — the spec's core invariant. - -### Decision: `RowSpec.labels` carries `(text, start_col)` tuples; `build_wide` owns positions - -Add `labels: list[tuple[str, int]] = field(default_factory=list)` to `RowSpec`. `build_wide` computes each label's 1-indexed start column by measuring the row's already-rendered content string — stripping ANSI and finding the whitespace-delimited token offset of the value the label names — guarded by `view.cfg.labels`. The divider/section-width variables it holds (`path_div_col`, `helper_anchor`, `sep_rate_col`, `cache_div_col`, the tokens/cost vsep columns) bound each measurement to the correct cell. `render_layout` passes `labels=row.labels` into `border_top`/`border_separator`/`border_separator_dim`. The superscript mapping is applied inside the border method (it stores ASCII; the method maps at paint time) so width math and the buffer stay in raw columns. - -*Why measured content-column anchoring over both a tuned position table and per-value column export (refinement of the earlier decision):* the earlier draft chose a hand-tuned offset table over exact anchoring because exact anchoring seemed to require every section helper in `renderer.py` to export value→column maps — a large, fragile surface. The refined approach gets exactness without that export: `build_wide` already holds each row's rendered content string, so it strips ANSI and locates each value by its whitespace-delimited token offset, anchoring labels (including the per-sub-value 5h/7d labels) over the real columns. This stays contained to `layout.py` + the border primitive — no per-helper column export — and tracks the values precisely even as content shifts, retiring the tuned offset table. - -### Decision: Superscript mapping lives in `render/text.py` - -Add `superscript(s: str) -> str` next to the other width/format primitives. It maps letters/digits/`+`/`/`/space to their Unicode superscript glyphs via a module-level table, passing unmapped characters through unchanged so the output width always equals the input length. The glyphs are non-PUA (e.g. `ᵗ` U+1D57, `⁵` U+2075) and width-1 per `_visible_width`, so they need no PUA-constant hoisting. - -## Risks / Trade-offs - -- **Crooked box from an off-by-one in label columns** → labels never touch elbow columns (fill-only mask), so a wrong column can misplace a *label* but cannot move an elbow or break the frame; the demo visual check and border tests catch misplacement. -- **Gradient decoherence if the buffer pass regresses** → the single ordered `grad_at(col)` pass is the one invariant to preserve; a border test asserts a labelled row and its label-free counterpart are byte-identical except at the overlaid columns. -- **Tuned offsets drift as section content changes** → labels degrade gracefully (truncate/drop) and are cosmetic; they never affect content layout, so drift is a visual nicety, not a correctness bug. -- **A superscript glyph missing from a font** → unmapped/again-unsupported characters pass through as their ASCII form; worst case a label shows plain letters, still readable. -- **Label collides with the session id or pill** → fill-only mask drops/truncates it; covered by spec scenarios and tests. - -## Migration Plan - -Additive and off by default; no migration. Rollback is setting `labels = false` (the default) or reverting the change. No data or config format changes. diff --git a/openspec/changes/archive/2026-06-18-add-layout-labels/proposal.md b/openspec/changes/archive/2026-06-18-add-layout-labels/proposal.md deleted file mode 100644 index fb918d2..0000000 --- a/openspec/changes/archive/2026-06-18-add-layout-labels/proposal.md +++ /dev/null @@ -1,31 +0,0 @@ -## Why - -The wide statusline packs many sections and sub-values (path/git changes, clear/session timers, 5h and 7d limit stats, cache countdown, token/cost columns, skills) into a dense box, but nothing names them — a new user has to memorise what each number means. An opt-in `layout.labels` mode paints small superscript labels into the border and separator lines directly above each value, turning the frame's empty fill into a legend without consuming any content rows. - -## What Changes - -- Add a new `layout.labels` boolean knob (default `false`), resolved through the existing precedence chain (`YAS_LABELS` env var → `[layout].labels` in `yas.toml` → default), mirroring `layout.justify`. -- When enabled (wide layout only, matching `justify`), paint superscript section labels into the rainbow top border and the dotted/solid separators above each section, anchored above the value they name (e.g. `clear`/`session` over the two elapsed timers, `5h`/`remain`/`used`/`burn rate` over the 5h stats). -- Labels colourise positionally with the border gradient — each glyph picks up the rainbow colour of the column it occupies — and yield to elbows (`┬┴┼`), the session id, and the model pill (never overwrite them; truncate/drop a label that would not fit). -- Add a superscript text primitive that maps ASCII label text to Unicode superscript glyphs, with graceful fallback for characters that have no superscript form. -- Document `layout.labels` in `yas.example.toml`. -- Narrow and medium layouts ignore the flag (no behaviour change). - -## Capabilities - -### New Capabilities -- `section-labels`: How the wide layout, when `cfg.labels` is enabled, overlays gradient-coloured superscript labels onto the top border and separator rows above each section/sub-value, and how those labels yield to elbows, session id, and the pill. - -### Modified Capabilities -- `statusline-config`: Adds the `labels` knob (canonical `YAS_LABELS`, `[layout].labels`, default `false`) to the layered configuration set. - -## Impact - -- `claude/yas/constants.py`: new `DEFAULT_LABELS = False`. -- `claude/yas/config.py`: new `labels: bool` field on the `Config` dataclass plus its resolution. -- `claude/yas/render/text.py`: new superscript mapping primitive. -- `claude/yas/render/borders.py`: `border_top` / `border_separator` / `border_separator_dim` gain a `labels` parameter and overlay labels onto a per-column character buffer before the positional gradient pass. -- `claude/yas/layout.py`: `RowSpec` gains a `labels` field; `build_wide` computes label columns from its existing divider/width variables; `render_layout` threads `labels` into the border methods. -- Tests under `test/` (new `test_labels.py`, plus border/layout test updates). -- Docs: `yas.example.toml` and `CONTEXT.md` glossary. -- No change to narrow/medium layouts, the stdin payload, or on-disk formats. diff --git a/openspec/changes/archive/2026-06-18-add-layout-labels/specs/section-labels/spec.md b/openspec/changes/archive/2026-06-18-add-layout-labels/specs/section-labels/spec.md deleted file mode 100644 index c2e632b..0000000 --- a/openspec/changes/archive/2026-06-18-add-layout-labels/specs/section-labels/spec.md +++ /dev/null @@ -1,135 +0,0 @@ -## ADDED Requirements - -### Requirement: Opt-in wide-only label activation - -The statusline SHALL paint superscript section labels onto the wide layout's border and separator rows only when `cfg.labels` is `true`. When `cfg.labels` is `false` the layout SHALL render identically to today. The narrow and medium layouts SHALL ignore `cfg.labels` entirely and never paint labels. - -#### Scenario: Labels off renders unchanged - -- **WHEN** `cfg.labels` is false in the wide layout -- **THEN** the border and separator rows render identically to the pre-feature output (no superscript glyphs present) - -#### Scenario: Labels on paints the wide frame - -- **WHEN** `cfg.labels` is true and the terminal width selects the wide layout -- **THEN** superscript labels appear on the top border and the separator rows above their sections - -#### Scenario: Narrow and medium ignore the flag - -- **WHEN** `cfg.labels` is true but the width selects the narrow or medium layout -- **THEN** no labels are painted and the output is identical to `cfg.labels` false - -### Requirement: Labels anchored above the value they name - -When `cfg.labels` is true, each label SHALL be positioned so that its starting column sits above the value or sub-value it names in the content row immediately below the border/separator that carries it. Label start columns SHALL be derived by measuring the already-rendered content strings that `build_wide` holds for each row — stripping ANSI and finding the whitespace-delimited token offsets — rather than from a fixed tuned-position table. A label SHALL only be emitted for a value that is actually present in the measured content. A section that displays multiple distinct sub-values MAY carry one label per sub-value, anchored over each. - -The 5h cell SHALL carry `5h` over its glyph and, in its **full** form, additionally `remain` (over the reset countdown), `used` (over the used percentage), and `burn rate` (over the burn-rate trend); in its **compact/reset** form (no countdown or trend rendered) it SHALL carry only `5h` + `used`. The 7d cell SHALL carry `7d` over its glyph plus `used` (over the used percentage) and `burn rate` (over the burn-rate trend, when present). The git dirty block SHALL carry a `changes` label over the `•N*M` cluster when that block is present. - -The elapsed/timers cell SHALL carry a `session` label over the session timer always, and a `clear` label over the clear timer only when the clear timer is actually displayed (its rendered content is non-empty). When no clear timer is shown only `session` SHALL be emitted, anchored over the single timer. Both timer labels SHALL be anchored by measuring the rendered elapsed content. - -#### Scenario: Elapsed section carries two labels - -- **WHEN** `cfg.labels` is true and the elapsed section is present with a clear time and a session time -- **THEN** the top border above it shows a `clear` label over the clear time and a `session` label over the session time - -#### Scenario: Clear label omitted when no clear timer - -- **WHEN** `cfg.labels` is true and the elapsed cell shows only the session timer (no `/clear` marker, clear content empty) -- **THEN** only a `session` label is emitted, anchored over the single timer, and no `clear` label appears - -#### Scenario: Changes label over the git dirty block - -- **WHEN** `cfg.labels` is true and the path/git section renders a dirty block (`•N*M`) -- **THEN** the top border above it shows a `changes` label anchored over that block, and when there is no dirty block no `changes` label is emitted - -#### Scenario: 5h cell carries sub-value labels in full form - -- **WHEN** `cfg.labels` is true and the 5h cell renders its full form (glyph, reset countdown, used percentage, and burn-rate trend) -- **THEN** the top border above it shows `5h` over the glyph, `remain` over the countdown, `used` over the used percentage, and `burn rate` over the trend - -#### Scenario: 5h compact form omits remain and burn rate - -- **WHEN** `cfg.labels` is true and the 5h cell renders its compact/reset form (no countdown or trend) -- **THEN** only `5h` and `used` are emitted, and no `remain` or `burn rate` label appears - -#### Scenario: 7d cell carries used and burn-rate labels - -- **WHEN** `cfg.labels` is true and the 7d cell is present with a used percentage and a burn-rate trend -- **THEN** the top border above it shows `7d` over the glyph, `used` over the used percentage, and `burn rate` over the trend; when no trend is rendered the `burn rate` label is omitted - -#### Scenario: Context separator labels its columns - -- **WHEN** `cfg.labels` is true and the context row is present -- **THEN** the separator above it carries a `tokens` label over the token count (e.g. `70.0K`), a `limit` label over the context-window percent (e.g. `(7%)`), and an `until dumb` label over the compaction-risk percent (e.g. `47%`) - -#### Scenario: Tokens/cost separator labels its columns - -- **WHEN** `cfg.labels` is true and the tokens/cost row is present -- **THEN** the separator above it carries `input sess/day`, `cache sess/day`, and `output sess/day` labels over the three token columns, a `cost sess/day` label over the cost column, and a `tokens over time` label over the sparkline column - -#### Scenario: Skills separator labels the skills row - -- **WHEN** `cfg.labels` is true and the skills/plugins row is present -- **THEN** the separator above it carries a `skills + plugins` label - -#### Scenario: Dynamic section separators carry content-start captions - -- **WHEN** `cfg.labels` is true and a dynamic section row is present -- **THEN** the separator above it carries a caption anchored at content-start (like `skills + plugins`): `plan` over the todo-checklist / task row, `subagents` over the subagent cohort, `workflow` over the workflow cohort, and `specs` over the OpenSpec change bars - -#### Scenario: Side-by-side checklist and subagents split the caption - -- **WHEN** `cfg.labels` is true and the checklist and subagents render in a side-by-side block -- **THEN** the separator above carries `plan` at content start and `subagents` over the right column - -### Requirement: Labels colourise positionally with the border gradient - -Each label glyph SHALL take the gradient colour of the column it occupies, identical to the fill character it replaces. Labels SHALL NOT be painted in a single flat colour. On dimmed separator rows each label glyph SHALL inherit the same per-column dim factor as the surrounding fill. - -#### Scenario: Top-border label follows the rainbow - -- **WHEN** a label occupies columns 60 through 66 of the rainbow top border -- **THEN** each of its glyphs is coloured with the gradient colour for its own column (60..66), matching the colour the fill would have had at that column - -#### Scenario: Separator label inherits dim ramp - -- **WHEN** a label sits on a dimmed separator row away from any elbow -- **THEN** its glyphs are dimmed by the same `_dim_for_col` factor as the adjacent dotted fill - -### Requirement: Labels yield to structural glyphs - -A label SHALL only overwrite fill characters (`─` on borders, `┄` on dim separators). It SHALL never overwrite an elbow (`┬`, `┴`, `┼`), the opening/closing frame corners, the embedded session id on the top border, or any column owned by an active model pill. When a label's full text would not fit in the available run of fill columns before the next structural glyph, it SHALL be truncated to fit, and if no fill columns are available it SHALL be dropped entirely. Dropping or truncating a label SHALL NOT shift any other column, elbow, or content. - -#### Scenario: Label truncated before an elbow - -- **WHEN** a label's text is longer than the run of fill columns between its start and the next elbow -- **THEN** the label is truncated so its last glyph sits before the elbow and the elbow is preserved - -#### Scenario: Label dropped when no room - -- **WHEN** there is no fill column available at a label's anchor (e.g. it collides with the session id or pill) -- **THEN** the label is omitted and all elbows, session id, pill, and column positions are unchanged - -#### Scenario: Session id preserved on top border - -- **WHEN** a label's anchor overlaps the embedded session id region of the top border -- **THEN** the session id is rendered intact and the label is truncated or dropped to avoid it - -### Requirement: Superscript text mapping with fallback - -The statusline SHALL map ASCII label text to Unicode superscript glyphs for rendering (e.g. `tokens` → `ᵗᵒᵏᵉⁿˢ`). Characters that have a defined superscript form (letters, digits, `+`, `/`, space) SHALL be mapped to that form. A character with no superscript form SHALL be passed through unchanged rather than dropped, and the mapped string SHALL have the same visible column width as the input (each glyph counting as one column). - -#### Scenario: Lowercase word maps to superscripts - -- **WHEN** the label text is `cache` -- **THEN** it renders as the superscript glyph sequence for `c`, `a`, `c`, `h`, `e` - -#### Scenario: Digits and width preserved - -- **WHEN** the label text is `5h` -- **THEN** it renders as superscript `5` followed by superscript `h`, occupying exactly two columns - -#### Scenario: Unmapped character passes through - -- **WHEN** a label contains a character with no defined superscript form -- **THEN** that character is emitted unchanged and the total visible width still equals the input length diff --git a/openspec/changes/archive/2026-06-18-add-layout-labels/specs/statusline-config/spec.md b/openspec/changes/archive/2026-06-18-add-layout-labels/specs/statusline-config/spec.md deleted file mode 100644 index 06b8f2a..0000000 --- a/openspec/changes/archive/2026-06-18-add-layout-labels/specs/statusline-config/spec.md +++ /dev/null @@ -1,25 +0,0 @@ -## ADDED Requirements - -### Requirement: Section-labels knob - -The statusline SHALL expose a boolean `labels` knob that toggles wide-layout section labels. It SHALL resolve through the standard precedence chain: canonical `YAS_LABELS` environment variable → `[layout].labels` in `yas.toml` → built-in default `false`. The resolved value SHALL be exposed as `cfg.labels` on the frozen `Config`. An absent or unparseable source SHALL fall through to the next, ending at the `false` default. The knob SHALL accept the same boolean spellings as other boolean knobs (`1`/`0`/`true`/`false`, case-insensitive, for env values; native booleans in `yas.toml`). - -#### Scenario: Default is false - -- **WHEN** no `yas.toml` sets `[layout].labels` and `YAS_LABELS` is unset -- **THEN** the resolved `cfg.labels` is `false` - -#### Scenario: Config file enables labels - -- **WHEN** `[layout].labels = true` is set in `yas.toml` and `YAS_LABELS` is unset -- **THEN** the resolved `cfg.labels` is `true` - -#### Scenario: Env overrides config file - -- **WHEN** `[layout].labels = true` is set in `yas.toml` and `YAS_LABELS=0` is set in the environment -- **THEN** the resolved `cfg.labels` is `false` - -#### Scenario: Invalid value falls through to default - -- **WHEN** `YAS_LABELS=maybe` is set and nothing else configures the knob -- **THEN** the resolved `cfg.labels` is `false` diff --git a/openspec/changes/archive/2026-06-18-add-layout-labels/tasks.md b/openspec/changes/archive/2026-06-18-add-layout-labels/tasks.md deleted file mode 100644 index 4c22d40..0000000 --- a/openspec/changes/archive/2026-06-18-add-layout-labels/tasks.md +++ /dev/null @@ -1,44 +0,0 @@ -## 1. Config knob - -- [x] 1.1 Add `DEFAULT_LABELS = False` to `claude/yas/constants.py` -- [x] 1.2 Add `labels: bool = DEFAULT_LABELS` field to the `Config` dataclass in `claude/yas/config.py` -- [x] 1.3 Resolve `labels` via `_resolve('labels', _env_sources(env, 'YAS_LABELS') + toml_src(layout, 'labels'), _parse_bool, DEFAULT_LABELS, …)` and pass `labels=labels` into the `Config(...)` constructor -- [x] 1.4 Add config tests: default `false`, `[layout].labels=true` enables, `YAS_LABELS=0` overrides the file, invalid value falls through to `false` - -## 2. Superscript primitive - -- [x] 2.1 Add `superscript(s: str) -> str` to `claude/yas/render/text.py` with a module-level ASCII→superscript map (letters, digits, `+`, `/`, space), passing unmapped characters through unchanged -- [x] 2.2 Add tests asserting `superscript('cache')`/`superscript('5h')` map correctly, an unmapped character passes through, and `_visible_width(superscript(s)) == len(s)` for the label vocabulary - -## 3. Border label overlay - -- [x] 3.1 Refactor `border_top` to build a per-column `chars` buffer (corners, fill, elbows, session id) plus a fill-only mask, then run the existing single ordered `grad_at(col) + chars[col]` pass over the buffer -- [x] 3.2 Apply the same buffer refactor to `border_separator` and `border_separator_dim`, preserving the dim factor per column -- [x] 3.3 Add a `labels: tuple[tuple[str, int], ...] = ()` parameter to the three methods; overlay each `(text, start_col)` left-to-right across contiguous fill columns only, mapping text through `superscript`, truncating at the first non-fill column and dropping when the anchor is not fill -- [x] 3.4 Confirm a pill-active row never has pill columns overwritten by a label -- [x] 3.5 Add border tests: a labelled row equals its label-free counterpart except at overlaid columns; label truncates before an elbow; label dropped at session id / non-fill anchor; label glyphs carry the per-column gradient (and dim) colour - -## 4. Layout wiring - -- [x] 4.1 Add `labels: list[tuple[str, int]] = field(default_factory=list)` to `RowSpec` in `claude/yas/layout.py` -- [x] 4.2 In `build_wide`, when `view.cfg.labels`, compute label start columns from existing variables (`path_div_col`, `helper_anchor` + helper sub-widths, `sep_rate_col`, `cache_div_col`, tokens/cost vsep columns) for the top border and each separator, and attach them to the corresponding `RowSpec.labels` -- [x] 4.3 Thread `labels=row.labels` through `render_layout` into `border_top` / `border_separator` / `border_separator_dim` -- [x] 4.4 Add a layout-seam test constructing a wide `SessionView` with `Config(labels=True)` asserting the expected labels appear on the right rows, and that `Config(labels=False)` output is unchanged - -## 5. Measured-anchor refactor and per-value labels - -- [x] 5.1 Replace the tuned-offset placement in `build_wide` with content-column measurement: strip ANSI from each row's rendered string and locate values by their whitespace-delimited token offsets, bounded by the existing divider/width variables -- [x] 5.2 Add the `changes` label over the git dirty block (`•N*M`), emitted only when that block is present -- [x] 5.3 Add the 5h sub-value labels: `5h` over the glyph plus `remain`/`used`/`burn rate` in full form, and only `5h` + `used` in the compact/reset form -- [x] 5.4 Add the 7d sub-value labels: `7d` over the glyph plus `used`, and `burn rate` when a trend is rendered -- [x] 5.5 Add the context-separator labels `tokens` (over the token count), `limit` (over the context-window percent), and `until dumb` (over the compaction-risk percent) -- [x] 5.6 Carry the ` sess/day` suffix on the tokens-separator labels: `input sess/day`, `cache sess/day`, `output sess/day`, `cost sess/day` -- [x] 5.7 Emit the elapsed-cell `clear` label only when the clear timer is displayed (clear content non-empty); when absent emit only `session` over the single timer, both anchored by measuring the rendered elapsed content -- [x] 5.8 Add the dynamic-section separator captions at content start (like `skills + plugins`): `plan` (todo-checklist / task row), `subagents` (subagent cohort), `workflow` (workflow cohort), `specs` (OpenSpec change bars); in the side-by-side checklist+subagents block place `plan` at content start and `subagents` over the right column -- [x] 5.9 Update `test/test_labels.py` / `test/test_labels_layout.py` for the measured anchors and the new label set - -## 6. Docs and verification - -- [x] 6.1 Document `[layout].labels` (and `YAS_LABELS`) in `yas.example.toml` -- [x] 6.2 Update the `CONTEXT.md` glossary with the section-label terms (value labels, `until dumb`, `limit`) if any displayed term changed -- [x] 6.3 Run `make test` (green, baseline + new tests) and `make demo` with labels on across the narrow→medium→wide thresholds; eyeball elbow alignment, gradient continuity, and label placement diff --git a/openspec/changes/archive/2026-06-18-glyph-mode/.openspec.yaml b/openspec/changes/archive/2026-06-18-glyph-mode/.openspec.yaml deleted file mode 100644 index 95ae5a2..0000000 --- a/openspec/changes/archive/2026-06-18-glyph-mode/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-18 diff --git a/openspec/changes/archive/2026-06-18-glyph-mode/design.md b/openspec/changes/archive/2026-06-18-glyph-mode/design.md deleted file mode 100644 index e16cbb1..0000000 --- a/openspec/changes/archive/2026-06-18-glyph-mode/design.md +++ /dev/null @@ -1,84 +0,0 @@ -## Context - -The renderer composes a fully-styled string in `app.render`, then (today) optionally runs `out.translate(ASCII_TRANSLATE)` when `Config.ascii_mode` is set. `ASCII_TRANSLATE` is a `{codepoint: ascii-char}` map built from `ASCII_GLYPHS` in `constants.py` and covers every non-ASCII glyph the statusline emits. All of the statusline's own glyphs are visible width 1 (verified by prior audits — PUA icons, box-drawing, blocks, arrows, punctuation), and the box geometry is hand-tuned around that. `render/text.py` owns the width model: `_is_wide(ch)` returns `True` only for the emoji plane `U+1F300–U+1FAFF` (minus Supplemental Arrows-C), and `_visible_width` counts everything else — including all BMP symbols and PUA — as width 1. - -This change generalizes the single boolean into a three-value enum (`glyph_mode`) plus an orthogonal `single_width` boolean fold, keeping the same single-seam application model. - -## Goals / Non-Goals - -**Goals:** -- One enum knob `glyph_mode ∈ {nerdfont, ascii, unicode}`, default `nerdfont`, plus an orthogonal boolean `single_width` (default `false`), each resolved through the existing CLI → env → toml → default chain (both reading from the `[appearance.glyphs]` subtable). -- The mode is a single, total transform applied once at the `app.render` seam; the `single_width` fold is a second, independent pass applied immediately after. The three **modes** are mutually exclusive (one enum value), but `single_width` **composes** with any of them. -- Preserve the column-math invariant: every transform — mode pass and fold alike — keeps each rendered line's `_visible_width` unchanged. -- Reuse the existing `ASCII_TRANSLATE` table verbatim for `ascii`. - -**Non-Goals:** -- No stacking of the *modes* themselves (e.g. "ascii + unicode" in one setting) — they remain a single enum. `single_width` is deliberately a separate knob precisely so wide-content folding can combine with any mode without exploding the enum. -- No backward-compat alias for `YAS_ASCII_MODE` — it shipped days ago with no external consumers; this is a clean rename (BREAKING). -- The `single_width` fold does **not** narrow the statusline's own width-1 glyphs (PUA included) — they are already width-1; it only collapses genuinely double-width *dynamic* content. -- Not attempting semantic transliteration of arbitrary emoji/CJK (e.g. 🔥→"fire"); the fold yields a width-1 placeholder when no narrow form exists. - -## Decisions - -### 1. Config: enum knob + orthogonal boolean replacing the boolean -`Config.ascii_mode: bool` → `Config.glyph_mode: str` (default `'nerdfont'`) **and** `Config.single_width: bool` (default `False`). New `_parse_glyph_mode(raw, origin)` accepts a case-insensitive member of the three-value set (`nerdfont|ascii|unicode`) and raises `ValueError` otherwise (so an invalid value falls back to the default and is recorded, exactly like `bg_shift`/`theme`); `single_width` reuses the existing `_parse_bool`. Both knobs read from the `[appearance.glyphs]` subtable. Resolution source order — mode: `cli_src('glyph_mode')` + `_env_sources(env, 'YAS_GLYPH_MODE')` + `toml_src(glyphs, 'mode')`; single_width: `cli_src('single_width')` + `_env_sources(env, 'YAS_GLYPH_SINGLE_WIDTH')` + `toml_src(glyphs, 'single_width')`, where `glyphs` is the nested table fetched from `appearance` (returns `{}` when absent or not-a-dict). `_parse_argv` gains `--glyph-mode ` / `--glyph-mode=` and `--glyph-single-width ` / `--glyph-single-width=`, and loses `--ascii-mode`. The `ascii_mode` field, its resolve block, and the `YAS_ASCII_MODE` env source are removed. - -### 2. Dispatch at the seam -`app.render` replaces `ascii_mode: bool | None` with `glyph_mode: str | None = None` and `single_width: bool | None = None`, each falling back to the internally-loaded `cfg.glyph_mode` / `cfg.single_width` when the caller passes nothing (so `mon.py` keeps working unchanged via env/toml). The return becomes: -``` -out = '\n'.join(render_layout(spec, r)) -return apply_glyphs(out, glyph_mode, single_width) -``` -`apply_glyphs(s, mode, single_width)` lives in `render/text.py`: it runs the mode pass first, then the fold when `single_width` is true. -`apply_glyph_mode(s, mode)` (the mode pass) dispatches: -- `nerdfont` → `s` (identity; no pass — the default path pays nothing). -- `ascii` → `s.translate(ASCII_TRANSLATE)` (unchanged behavior). -- `unicode` → `s.translate(UNICODE_TRANSLATE)`. - -Then `apply_glyphs` applies `to_singlewidth(s)` iff `single_width`. `nerdfont` + `single_width=false` therefore pays nothing. `main()` passes `glyph_mode=cfg.glyph_mode, single_width=cfg.single_width`. - -### 3. `unicode` mode — PUA-only translate map -`UNICODE_PUA` in `constants.py` maps each **PUA** glyph constant (the 21 `ICON_*`/`GLYPH_*` icons + `BarChars.MID`) to a non-PUA, width-1 BMP replacement; `UNICODE_TRANSLATE = {ord(g): u for g, u in UNICODE_PUA.items()}`. Box-drawing, block/sparkline, arrow, and punctuation glyphs are deliberately **absent** — they are standard Unicode and stay intact. Replacement chars are drawn from Geometric Shapes (`U+25xx`), Arrows (`U+21xx`), and plain technical/punctuation symbols that are reliably text-presentation width-1; emoji-presentation symbols (⚡ U+26A1, ⚙ U+2699, ⌛ U+231B, ✉ U+2709, …) are avoided because many terminals render them double-width even though our `_visible_width` counts them as 1. Decided mapping (apply-time may swap any char that proves wide on a real terminal — it is a one-line table edit, geometric-shape-preferred): - -| PUA constant | meaning | unicode repl | codepoint | -|---|---|---|---| -| `ICON_COST` | currency-usd | `$` | U+0024 (ASCII, unambiguous) | -| `ICON_TOK_RATE` | gauge | `◷` | U+25F7 | -| `GLYPH_MODEL` | monitor-dashboard | `▦` | U+25A6 | -| `GLYPH_THINKING` | brain | `◍` | U+25CD | -| `GLYPH_BURN_FAST` | zap | `↯` | U+21AF | -| `GLYPH_BURN_SLOW` | flame | `∿` | U+223F | -| `GLYPH_FOLDER` | folder | `▭` | U+25AD | -| `GLYPH_SUBAGENT` | tasks | `☰` | U+2630 | -| `GLYPH_TASKS` | clipboard-check | `▤` | U+25A4 | -| `GLYPH_TASK_PENDING` | circle | `○` | U+25CB | -| `GLYPH_TASK_ACTIVE` | arrow-right | `▸` | U+25B8 | -| `GLYPH_TASK_DONE` | check-circle-fill | `◉` | U+25C9 | -| `GLYPH_SKILLS` | skills | `◆` | U+25C6 | -| `GLYPH_PLUGINS` | plug | `⌁` | U+2301 | -| `GLYPH_HELPER` | star-circle | `★` | U+2605 | -| `GLYPH_TRASH` | trash-can | `⌫` | U+232B | -| `GLYPH_RENAMED` | file-move | `⇄` | U+21C4 | -| `GLYPH_REPLYING` | message | `»` | U+00BB | -| `GLYPH_HOURGLASS` | hourglass | `⧖` | U+29D6 | -| `GLYPH_PIE` | pie-chart | `◕` | U+25D5 | -| `GLYPH_CACHE` | cache | `↻` | U+21BB | -| `BarChars.MID` | progress sep | `▪` | U+25AA | - -### 4. `single_width` fold — orthogonal scanning pass, not a translate map -`str.translate` cannot make width-conditional decisions, so `to_singlewidth(s)` in `render/text.py` walks the string char-by-char (ANSI escape bytes are ASCII, so `_is_wide` is `False` for them and they pass through untouched — no special escape handling needed). For each `ch`: -1. if not `_is_wide(ch)`: keep it. -2. else try `unicodedata.normalize('NFKC', ch)`; if it yields a single non-wide char (e.g. Fullwidth Forms `U+FF01–FF5E` → ASCII), use it. -3. else emit `SINGLEWIDTH_PLACEHOLDER` (`·`, U+00B7 — already a constant, width-1). - -Because the statusline's own glyphs are never `_is_wide`, this is a no-op on the frame/icons and only collapses wide *dynamic* content (emoji in a branch name, CJK in a path). The fold is a **separate, orthogonal** knob rather than a mode: it runs after the mode pass when `single_width` is true, so it composes with any mode (`nerdfont`+fold keeps the Nerd Font icons but aligns wide content; `unicode`+fold does both PUA replacement and folding). It solves the wide-content alignment problem, not the missing-font problem — so with `mode = nerdfont` it still needs a Nerd Font for the icons. - -### 5. Tests & invariant -`test/test_ascii_render.py` becomes the glyph-mode suite: parametrize over the three modes × `single_width ∈ {false, true}` asserting, for each combination, per-line `_visible_width` equals the `nerdfont`/no-fold render at widths 50/70/160. Mode-specific extra assertions: `ascii` → zero codepoints ≥ 128; `unicode` → zero PUA codepoints (both ranges) but box/block/arrow glyphs preserved. Fold assertions: with a wide char injected into dynamic content (branch/cwd) and `single_width=true`, output contains no `_is_wide` char and width is preserved — verified for `nerdfont`+fold and `unicode`+fold. Plus a coverage guard that every PUA constant has a `UNICODE_PUA` entry, and `test_config.py` resolution (mode enum + `single_width` boolean: env/toml-subtable/cli/default + invalid-falls-back, and the two knobs combining). - -## Risks / Trade-offs - -- **BREAKING rename**: `YAS_ASCII_MODE=1` stops working; users migrate to `YAS_GLYPH_MODE=ascii`. Acceptable given the knob is days old. The error row / `YAS_DEBUG` path already surfaces an unknown value falling back to default, so a stale `YAS_ASCII_MODE` simply has no effect (the new knob defaults to `nerdfont`). -- **Emoji-presentation width drift (unicode mode)**: a replacement BMP symbol that a particular terminal renders width-2 would visually misalign even though `_visible_width` (and the test) think it is width-1. Mitigated by preferring Geometric-Shapes/Arrows; residual risk is per-terminal and fixable with a one-line table swap. CI cannot assert real-terminal cell width. -- **Lossy single-width folding**: a CJK path or emoji with no NFKC narrow form collapses to `·`, harming readability. This is inherent to "fold double-width → single-width" and is opt-in; documented as the knob's trade-off. -- **Two divergent tables to maintain**: a newly-added PUA glyph now needs entries in both `ASCII_GLYPHS` and `UNICODE_PUA`. The coverage guard test fails the build if either is missing, so drift is caught immediately. diff --git a/openspec/changes/archive/2026-06-18-glyph-mode/proposal.md b/openspec/changes/archive/2026-06-18-glyph-mode/proposal.md deleted file mode 100644 index eacc9d0..0000000 --- a/openspec/changes/archive/2026-06-18-glyph-mode/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Why - -The statusline currently has a single boolean `YAS_ASCII_MODE` that does one thing — replace every non-ASCII glyph with an ASCII equivalent. That is the right escape hatch for the most hostile terminals, but it is the *only* fallback. Users whose terminal has full Unicode support but no Nerd Font get either tofu boxes (`nerdfont`) or a needlessly degraded all-ASCII render (`ascii`); users whose font renders some glyphs double-width get a misaligned box with no remedy short of going full ASCII. A single enum knob with graded fallbacks lets each terminal pick the richest representation it can actually display. - -## What Changes - -- **BREAKING**: Replace the boolean `YAS_ASCII_MODE` env var / `[appearance].ascii_mode` toml key / `Config.ascii_mode` field with an enum `YAS_GLYPH_MODE` / `[appearance.glyphs].mode` / `Config.glyph_mode`. `YAS_ASCII_MODE=1` is superseded by `YAS_GLYPH_MODE=ascii`; the old name is removed (no legacy alias — the feature shipped recently and has no external consumers). -- Add three glyph modes, resolved through the existing config precedence chain (CLI → env → toml → default): - - **`nerdfont`** (default): display all characters as-is. No translation. Full fidelity; requires a Nerd Font. - - **`ascii`**: translate every non-ASCII glyph to a width-1 ASCII equivalent. The current `ascii_mode` behavior. Maximum compatibility. - - **`unicode`**: translate **only** Nerd Font PUA icons to width-1 non-PUA Unicode equivalents, leaving box-drawing, block/sparkline, arrow, and punctuation glyphs intact. For terminals with good Unicode coverage but no Nerd Font. -- Add an **orthogonal** `single_width` boolean knob (`YAS_GLYPH_SINGLE_WIDTH` / `[appearance.glyphs].single_width` / `Config.single_width`, default `false`), resolved through the same precedence chain. When enabled it folds every double-width character (wide emoji, CJK) in the rendered output to a width-1 equivalent, so column math holds under fonts that render some glyphs double-width. It targets dynamic content (branch names, paths); the statusline's own glyphs are already width-1. Because it is a separate knob, it combines with **any** mode — e.g. `mode = unicode` + `single_width = true`. -- Both knobs reuse the single final-pass seam in `app.render`: the mode transform is applied first (picking which translation map applies; `nerdfont` is the identity, no pass), then the `single_width` fold when enabled. `nerdfont` + `single_width = false` is a full no-op. -- New `--glyph-mode ` and `--glyph-single-width ` CLI flags replace `--ascii-mode`. - -## Capabilities - -### New Capabilities -- `glyph-mode`: the glyph-rendering contract — what each of the three modes `nerdfont` / `ascii` / `unicode` does to the rendered output, the orthogonal `single_width` fold that composes with any mode, the width-preservation invariant every mode and the fold must hold, and the single-seam application model (mode transform first, then the fold). - -### Modified Capabilities -- `statusline-config`: the config-knob set gains `glyph_mode` (enum `nerdfont|ascii|unicode`, default `nerdfont`) resolved via CLI `--glyph-mode` → `YAS_GLYPH_MODE` → `[appearance.glyphs].mode` → default, and `single_width` (boolean, default `false`) resolved via CLI `--glyph-single-width` → `YAS_GLYPH_SINGLE_WIDTH` → `[appearance.glyphs].single_width` → default. Both fall back to their default on an invalid value like every other knob. The transient `ascii_mode` boolean is removed. - -## Impact - -- **Code**: `claude/yas/config.py` (`glyph_mode` + `single_width` fields, parsers, resolution from the `[appearance.glyphs]` subtable, argv for both flags), `claude/yas/constants.py` (per-mode translation tables: keep `ASCII_TRANSLATE`, add a PUA→Unicode map; the singlewidth fold lives in `text.py`), `claude/yas/render/text.py` (`apply_glyph_mode` for the mode pass, `to_singlewidth` for the fold, and an `apply_glyphs` combiner that runs the mode then the optional fold), `claude/yas/app.py` (apply mode then fold at the render seam; `render()`/`main()` thread `glyph_mode` + `single_width`), `claude/mon.py` (honors both knobs via the same fallback — no change needed). -- **Tests**: `test/test_ascii_render.py` extended/renamed to cover the three modes, the `single_width` fold, their combinations, and the width-preservation invariant; `test/test_config.py` for the enum + boolean resolution. -- **Docs**: `CONTEXT.md` glyph-mode wording. -- **Migration**: anyone setting `YAS_ASCII_MODE=1` must switch to `YAS_GLYPH_MODE=ascii`. diff --git a/openspec/changes/archive/2026-06-18-glyph-mode/specs/glyph-mode/spec.md b/openspec/changes/archive/2026-06-18-glyph-mode/specs/glyph-mode/spec.md deleted file mode 100644 index fbee57c..0000000 --- a/openspec/changes/archive/2026-06-18-glyph-mode/specs/glyph-mode/spec.md +++ /dev/null @@ -1,75 +0,0 @@ -## ADDED Requirements - -### Requirement: Three glyph rendering modes - -The statusline SHALL support exactly three mutually-exclusive glyph rendering modes, selected by the `glyph_mode` configuration knob: `nerdfont`, `ascii`, and `unicode`. Each mode SHALL be a single total transform applied to the fully-rendered statusline string. The default mode SHALL be `nerdfont`. - -- `nerdfont` SHALL display every character unchanged (identity transform; full fidelity, requires a Nerd Font). -- `ascii` SHALL replace every non-ASCII character the statusline emits with a width-1 ASCII equivalent, producing output containing only codepoints below U+0080. -- `unicode` SHALL replace only Nerd Font Private Use Area (PUA) icon glyphs with non-PUA, width-1 Unicode equivalents, leaving box-drawing, block/sparkline, arrow, and punctuation glyphs unchanged. - -#### Scenario: Nerdfont mode is identity - -- **WHEN** `glyph_mode` is `nerdfont` and `single_width` is false -- **THEN** the rendered output is byte-for-byte identical to the untransformed render - -#### Scenario: Ascii mode produces only ASCII - -- **WHEN** `glyph_mode` is `ascii` -- **THEN** the rendered output contains no codepoint at or above U+0080 - -#### Scenario: Unicode mode removes PUA but keeps other Unicode - -- **WHEN** `glyph_mode` is `unicode` -- **THEN** the output contains no Private Use Area codepoint (U+E000–U+F8FF or U+F0000–U+FFFFD) -- **AND** box-drawing, block, and arrow glyphs are still present unchanged - -### Requirement: Orthogonal single-width folding - -The statusline SHALL expose a `single_width` boolean knob, independent of `glyph_mode`, that folds every double-width character in the rendered output to a width-1 equivalent, leaving all already-width-1 characters (including the statusline's own PUA glyphs) unchanged. When enabled, the fold SHALL be applied after the selected glyph mode's transform, so it composes with any mode. The default SHALL be false (no fold). - -#### Scenario: Single-width folds wide dynamic content - -- **WHEN** `single_width` is true and dynamic content (e.g. a git branch name or cwd path) contains a double-width character -- **THEN** that character is replaced by a width-1 equivalent and the output contains no double-width character -- **AND** the statusline's own width-1 glyphs are left unchanged - -#### Scenario: Single-width composes with nerdfont - -- **WHEN** `glyph_mode` is `nerdfont` and `single_width` is true -- **THEN** the statusline's PUA icons are preserved unchanged -- **AND** any double-width dynamic content is folded to width-1 - -#### Scenario: Single-width composes with unicode - -- **WHEN** `glyph_mode` is `unicode` and `single_width` is true -- **THEN** PUA icons are replaced by their non-PUA Unicode equivalents -- **AND** any double-width dynamic content is folded to width-1 - -### Requirement: Column-math preservation across modes - -Every glyph **mode** SHALL preserve column geometry: for any session and render width, each output line's visible width (per the renderer's width model) SHALL equal the visible width of the same line rendered in `nerdfont` mode. No mode SHALL move a border, elbow, or divider. The `single_width` fold is the deliberate exception — it narrows genuinely double-width dynamic content from two cells to one, so for content the width model counts as wide it intentionally changes that model's measured width. Its purpose is to make such content align on terminals whose font renders those glyphs as single cells; it is a no-op on the statusline's own already-width-1 chrome, which it never shifts. - -#### Scenario: Visible width is mode-invariant - -- **WHEN** the same session (with no genuinely double-width dynamic content) is rendered at a given width in each of the three modes, with `single_width` false -- **THEN** every line's visible width is identical across all three modes - -#### Scenario: Fold leaves width-1 chrome unmoved - -- **WHEN** a session is rendered with `single_width` true and its dynamic content contains no double-width character -- **THEN** every line's visible width is identical to the same render with `single_width` false - -### Requirement: Single-seam application - -The selected glyph mode and the `single_width` fold SHALL be applied exactly once each, at the final render boundary, after the layout is fully composed — the mode transform first, then the fold when enabled. The `nerdfont` mode with `single_width` false SHALL incur no transformation pass. Callers that do not specify a mode or fold SHALL inherit the resolved `glyph_mode` / `single_width` configuration values, so all render entry points (the statusline command and the multi-session observer) honor both knobs without per-call wiring. - -#### Scenario: Observer honors the knobs without explicit wiring - -- **WHEN** `YAS_GLYPH_MODE=ascii` is set and the multi-session observer renders a session -- **THEN** that session's box is rendered in ascii mode - -#### Scenario: Observer honors single-width without explicit wiring - -- **WHEN** `YAS_GLYPH_SINGLE_WIDTH=1` is set and the multi-session observer renders a session -- **THEN** that session's box has its double-width dynamic content folded to width-1 diff --git a/openspec/changes/archive/2026-06-18-glyph-mode/specs/statusline-config/spec.md b/openspec/changes/archive/2026-06-18-glyph-mode/specs/statusline-config/spec.md deleted file mode 100644 index 03b8a7c..0000000 --- a/openspec/changes/archive/2026-06-18-glyph-mode/specs/statusline-config/spec.md +++ /dev/null @@ -1,182 +0,0 @@ -## ADDED Requirements - -### Requirement: Glyph mode knob - -The statusline SHALL expose a `glyph_mode` knob that selects the glyph rendering mode, resolved through the standard precedence chain: CLI `--glyph-mode ` → `YAS_GLYPH_MODE` env var → `[appearance.glyphs].mode` in `yas.toml` → default. The accepted values SHALL be exactly `nerdfont`, `ascii`, and `unicode` (case-insensitive), and the default SHALL be `nerdfont`. Any other value SHALL be rejected and fall back to the default like every other knob. There SHALL be no per-mode environment variable beyond `YAS_GLYPH_MODE`. - -#### Scenario: Env var selects a mode - -- **WHEN** `YAS_GLYPH_MODE=ascii` is set -- **THEN** the resolved `glyph_mode` is `ascii` - -#### Scenario: CLI flag overrides env and toml - -- **WHEN** `--glyph-mode unicode` is passed and `YAS_GLYPH_MODE=ascii` and `[appearance.glyphs].mode = "nerdfont"` are also set -- **THEN** the resolved `glyph_mode` is `unicode` - -#### Scenario: Default is nerdfont - -- **WHEN** no `glyph_mode` is configured from any source -- **THEN** the resolved `glyph_mode` is `nerdfont` - -#### Scenario: Unknown mode rejected - -- **WHEN** `glyph_mode = "fancy"` is configured -- **THEN** `glyph_mode` falls back to `nerdfont` and the rejection is recorded - -### Requirement: Single-width knob - -The statusline SHALL expose a `single_width` boolean knob, independent of `glyph_mode`, resolved through the standard precedence chain: CLI `--glyph-single-width ` → `YAS_GLYPH_SINGLE_WIDTH` env var → `[appearance.glyphs].single_width` in `yas.toml` → default. The value SHALL be a boolean (env form treats `0`, `false`, and `no` as false and any other non-empty value as true) and the default SHALL be `false`. An invalid value SHALL fall back to the default like every other knob. The knob SHALL be combinable with any `glyph_mode` value. - -#### Scenario: Env var enables the fold - -- **WHEN** `YAS_GLYPH_SINGLE_WIDTH=1` is set -- **THEN** the resolved `single_width` is `true` - -#### Scenario: CLI flag overrides env and toml - -- **WHEN** `--glyph-single-width false` is passed and `YAS_GLYPH_SINGLE_WIDTH=1` and `[appearance.glyphs].single_width = true` are also set -- **THEN** the resolved `single_width` is `false` - -#### Scenario: Default is false - -- **WHEN** no `single_width` is configured from any source -- **THEN** the resolved `single_width` is `false` - -#### Scenario: Combines with a mode - -- **WHEN** `glyph_mode = "unicode"` and `single_width = true` are configured together -- **THEN** both knobs resolve to their configured values independently - -## MODIFIED Requirements - -### Requirement: Layered configuration precedence - -The statusline SHALL resolve every configurable knob through a single, fixed precedence chain: CLI flag (where one exists) → canonical `YAS_*` environment variable → legacy-alias environment variable → `yas.toml` value → built-in default. A higher-precedence source that is present and valid SHALL override all lower sources for that knob; an absent or invalid source SHALL fall through to the next. - -#### Scenario: Env overrides config file - -- **WHEN** `[layout].max_width = 200` is set in `yas.toml` and `YAS_MAX_WIDTH=160` is set in the environment -- **THEN** the resolved `max_width` is `160` - -#### Scenario: Config file overrides default - -- **WHEN** `[tokens].soft_limit = 1000000` is set in `yas.toml` and no `YAS_SOFT_LIMIT` env var is set -- **THEN** the resolved `soft_limit` is `1000000` - -#### Scenario: Default when nothing is set - -- **WHEN** no `yas.toml` exists and no relevant env var is set -- **THEN** every knob resolves to its built-in default (`max_width=140`, `full_width=false`, `soft_limit=150000`, `token_window=60`, `theme=dark`, `bg_shift=warm`, `show_day_stats=true`, `glyph_mode=nerdfont`, `single_width=false`) - -#### Scenario: CLI flag overrides env and config - -- **WHEN** `--theme` is passed on the command line and `YAS_THEME` and `[appearance].theme` are also set -- **THEN** the CLI `--theme` value is used - -### Requirement: Canonical env vars and deprecated aliases - -The statusline SHALL accept canonical `YAS_*` environment variables for all nine knobs (`YAS_MAX_WIDTH`, `YAS_FULL_WIDTH`, `YAS_SOFT_LIMIT`, `YAS_TOKEN_WINDOW`, `YAS_THEME`, `YAS_BG_SHIFT`, `YAS_SHOW_DAY_STATS`, `YAS_GLYPH_MODE`, `YAS_GLYPH_SINGLE_WIDTH`). It SHALL continue to honor the legacy aliases `STATUSLINE_TOKEN_WINDOW` (for `token_window`) and `CLAUDE_STATUSLINE_THEME` (for `theme`). When both a canonical var and its alias are set, the canonical value SHALL win. - -#### Scenario: Legacy alias still works - -- **WHEN** only `STATUSLINE_TOKEN_WINDOW=30` is set -- **THEN** the resolved `token_window` is `30` - -#### Scenario: Canonical wins over alias - -- **WHEN** `YAS_TOKEN_WINDOW=45` and `STATUSLINE_TOKEN_WINDOW=30` are both set -- **THEN** the resolved `token_window` is `45` - -#### Scenario: Theme alias resolves - -- **WHEN** only `CLAUDE_STATUSLINE_THEME` names a known theme -- **THEN** that theme is used - -#### Scenario: Day-stats env var resolves - -- **WHEN** `YAS_SHOW_DAY_STATS=0` is set -- **THEN** the resolved `show_day_stats` is `false` - -#### Scenario: Glyph-mode env var resolves - -- **WHEN** `YAS_GLYPH_MODE=unicode` is set -- **THEN** the resolved `glyph_mode` is `unicode` - -#### Scenario: Glyph single-width env var resolves - -- **WHEN** `YAS_GLYPH_SINGLE_WIDTH=1` is set -- **THEN** the resolved `single_width` is `true` - -### Requirement: yas.toml location and sectioned schema - -The statusline SHALL read configuration from `yas.toml` located in `CLAUDE_CONFIG_DIR` (defaulting to `~/.claude/`). The file SHALL use a sectioned schema: `[layout]` for `max_width` and `full_width`, `[tokens]` for `soft_limit` (global default), `token_window`, and `show_day_stats`, an optional `[[tokens.model]]` array of `{ match, soft_limit }` tables for per-model `soft_limit` overrides, `[appearance]` for `theme` and `bg_shift`, and the `[appearance.glyphs]` subtable for `mode` and `single_width`. Absence of the file SHALL be equivalent to all-defaults and SHALL NOT be an error. - -#### Scenario: Knobs read from their sections - -- **WHEN** `yas.toml` contains `[layout]` `max_width = 200`, `[tokens]` `soft_limit = 1000000`, and `[appearance]` `theme = "dark"` -- **THEN** those three values are resolved from the file - -#### Scenario: Day-stats read from tokens section - -- **WHEN** `yas.toml` contains `[tokens]` `show_day_stats = false` and no `YAS_SHOW_DAY_STATS` env var is set -- **THEN** the resolved `show_day_stats` is `false` - -#### Scenario: Glyph mode read from appearance.glyphs subtable - -- **WHEN** `yas.toml` contains `[appearance.glyphs]` `mode = "ascii"` and no `YAS_GLYPH_MODE` env var is set -- **THEN** the resolved `glyph_mode` is `ascii` - -#### Scenario: Single-width read from appearance.glyphs subtable - -- **WHEN** `yas.toml` contains `[appearance.glyphs]` `single_width = true` and no `YAS_GLYPH_SINGLE_WIDTH` env var is set -- **THEN** the resolved `single_width` is `true` - -#### Scenario: Missing file is not an error - -- **WHEN** no `yas.toml` exists in `CLAUDE_CONFIG_DIR` -- **THEN** the statusline renders normally using env + defaults and reports no config error - -#### Scenario: Unknown keys and sections are ignored - -- **WHEN** `yas.toml` contains a key or section that does not map to a known knob -- **THEN** the unknown entry is ignored and the rest of the config still resolves - -### Requirement: Fail-safe validation of config values - -The statusline SHALL never crash or render garbage because of bad configuration. A syntactically broken `yas.toml` SHALL cause the entire file to be ignored (env + defaults still apply). A value that is the wrong type or out of range for its knob SHALL cause only that single knob to fall back to its default while all other valid knobs are still applied. Validation rules: `max_width` is an integer > 0; `full_width` is a boolean (env form accepts any non-empty value as true); `soft_limit` is an integer > 0; `token_window` is a number > 0; `theme` must be a known theme name; `bg_shift` must be one of `warm` or `cool`; `show_day_stats` is a boolean (env form treats `0`, `false`, and `no` as false and any other non-empty value as true); `glyph_mode` must be one of `nerdfont`, `ascii`, or `unicode`; `single_width` is a boolean (same env-form rules as `show_day_stats`). - -#### Scenario: Broken TOML ignores whole file - -- **WHEN** `yas.toml` contains a TOML syntax error -- **THEN** no value from the file is applied, env + defaults are used, and a config error is recorded - -#### Scenario: One bad value falls back, others apply - -- **WHEN** `yas.toml` sets `max_width = "banana"` (invalid) and `soft_limit = 1000000` (valid) -- **THEN** `max_width` resolves to its default and `soft_limit` resolves to `1000000` - -#### Scenario: Out-of-range value rejected - -- **WHEN** `soft_limit = -5` is configured -- **THEN** `soft_limit` falls back to its default and the rejection is recorded - -#### Scenario: Unknown enum value rejected - -- **WHEN** `bg_shift = "purple"` is configured -- **THEN** `bg_shift` falls back to `warm` and the rejection is recorded - -#### Scenario: Unknown glyph-mode value rejected - -- **WHEN** `[appearance].glyph_mode = "fancy"` is configured -- **THEN** `glyph_mode` falls back to `nerdfont` and the rejection is recorded - -#### Scenario: Non-boolean day-stats rejected - -- **WHEN** `[tokens].show_day_stats = "banana"` is configured -- **THEN** `show_day_stats` falls back to its default (`true`) and the rejection is recorded - -#### Scenario: Malformed per-model entry dropped - -- **WHEN** a `[[tokens.model]]` entry has a missing/empty `match`, or a `soft_limit` that is non-integer or `<= 0` -- **THEN** only that entry is dropped (models it would have matched fall back to the global `soft_limit`), valid entries still apply, and the rejection is recorded referencing the entry (e.g. `tokens.model[2]`) diff --git a/openspec/changes/archive/2026-06-18-glyph-mode/tasks.md b/openspec/changes/archive/2026-06-18-glyph-mode/tasks.md deleted file mode 100644 index 70d96bf..0000000 --- a/openspec/changes/archive/2026-06-18-glyph-mode/tasks.md +++ /dev/null @@ -1,32 +0,0 @@ -## 1. Config: enum mode + orthogonal boolean - -- [x] 1.1 In `claude/yas/config.py`, replace the `ascii_mode: bool = False` field with `glyph_mode: str = 'nerdfont'` on the frozen `Config` dataclass. -- [x] 1.2 Add a `single_width: bool = False` field to the `Config` dataclass. -- [x] 1.3 `_parse_glyph_mode(raw, origin)` accepts one of `nerdfont|ascii|unicode` (case-insensitive), raising `ValueError` otherwise (mirrors `_parse_bg_shift`); `single_width` reuses `_parse_bool`. -- [x] 1.4 In `_parse_argv`, keep `--glyph-mode ` / `--glyph-mode=` and add `--glyph-single-width ` / `--glyph-single-width=` → `out['single_width']`. (Replaced `--ascii-mode`.) -- [x] 1.5 Read both knobs from the `[appearance.glyphs]` subtable: fetch `glyphs` as a nested table off `appearance` (`{}` when absent/not-a-dict). Resolve `glyph_mode` from `cli_src('glyph_mode') + _env_sources(env, 'YAS_GLYPH_MODE') + toml_src(glyphs, 'mode')`; resolve `single_width` from `cli_src('single_width') + _env_sources(env, 'YAS_GLYPH_SINGLE_WIDTH') + toml_src(glyphs, 'single_width')` with `_parse_bool`, default `False`. Add `single_width=single_width` to the `cls(...)` return. - -## 2. Translation tables and transforms - -- [x] 2.1 In `claude/yas/constants.py`, keep `ASCII_GLYPHS`/`ASCII_TRANSLATE` unchanged. `UNICODE_PUA` maps each PUA glyph constant (21 `ICON_*`/`GLYPH_*` + `BarChars.MID`) to its width-1 non-PUA replacement; `UNICODE_TRANSLATE = {ord(g): u for g, u in UNICODE_PUA.items()}`. -- [x] 2.2 In `claude/yas/render/text.py`, `SINGLEWIDTH_PLACEHOLDER` (reuse `MIDDLE_DOT`) and `to_singlewidth(s)`: walk chars, keep non-`_is_wide`; for wide chars use an NFKC single-char narrow form if one exists, else the placeholder. -- [x] 2.3 In `claude/yas/render/text.py`, `apply_glyph_mode(s, mode)` dispatches only `nerdfont`→`s`, `ascii`→`s.translate(ASCII_TRANSLATE)`, `unicode`→`s.translate(UNICODE_TRANSLATE)` (no `singlewidth` branch). Add `apply_glyphs(s, mode, single_width)` = `apply_glyph_mode` then `to_singlewidth` iff `single_width`. - -## 3. Wire the seam - -- [x] 3.1 In `claude/yas/app.py`, `render(...)` takes `glyph_mode: str | None = None` and `single_width: bool | None = None`; each falls back to `cfg.glyph_mode` / `cfg.single_width` when `None`; the return is `apply_glyphs('\n'.join(render_layout(spec, r)), glyph_mode, single_width)`. Update the `yas.render.text` import. -- [x] 3.2 In `main()`, pass `glyph_mode=cfg.glyph_mode, single_width=cfg.single_width` to `render(...)`. -- [x] 3.3 Confirm `claude/mon.py` needs no change (kwarg-less `render` call inherits both knobs via the `is None` fallback). - -## 4. Tests - -- [x] 4.1 Rework `test/test_ascii_render.py` into a glyph suite: parametrize the three modes × `single_width ∈ {false, true}` and assert per-line `_visible_width` equals the `nerdfont`/no-fold render at widths 50/70/160. -- [x] 4.2 `ascii` → zero codepoints ≥ U+0080; `unicode` → zero PUA codepoints (both ranges) AND a representative box/block/arrow glyph still present; fold (`single_width=true`) → inject a double-width char into dynamic content and assert no `_is_wide` char remains and width is preserved, for `nerdfont`+fold and `unicode`+fold; `nerdfont`+no-fold → byte-identical to untransformed render. -- [x] 4.3 Coverage guard: every PUA glyph constant has an entry in BOTH `ASCII_GLYPHS` and `UNICODE_PUA`; every `UNICODE_PUA` value is a single non-PUA char. -- [x] 4.4 In `test/test_config.py`, add resolution tests: `glyph_mode` env/toml-subtable/cli select a mode, default `nerdfont`, invalid falls back + recorded; `single_width` env (`YAS_GLYPH_SINGLE_WIDTH`)/toml-subtable/cli resolve the bool, default `false`; the two knobs combine independently. - -## 5. Docs and verification - -- [x] 5.1 Update `CONTEXT.md`, `README.md`, and `yas.example.toml` to describe the three modes, the orthogonal `single_width` knob, the `[appearance.glyphs]` subtable, and the `YAS_GLYPH_MODE`/`YAS_GLYPH_SINGLE_WIDTH` env vars + `--glyph-mode`/`--glyph-single-width` CLI flags (and the removal of `YAS_ASCII_MODE`). -- [x] 5.2 Run `make test`; all green. Manually verify combinations via `YAS_GLYPH_MODE= YAS_GLYPH_SINGLE_WIDTH=<0|1> COLUMNS=160 python3 claude/statusline_command.py < ops/session-info-example.json`. -- [x] 5.3 Visual check: `make demo/img` + `yas-demo-text` diff against the `nerdfont`/no-fold baseline to confirm no column drift (default render must be byte-identical). diff --git a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/.openspec.yaml b/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/.openspec.yaml deleted file mode 100644 index ff1fbc8..0000000 --- a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/design.md b/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/design.md deleted file mode 100644 index d99d8dc..0000000 --- a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/design.md +++ /dev/null @@ -1,99 +0,0 @@ -## Context - -`ops/install.sh` (NOT repo root) currently depends on `jq` for every JSON read/transform/validate. There are nine `jq` call-sites: - -- `ensure_marketplace` — `jq -r 'has("yet-another-statusline")'` on `known_marketplaces.json` (~line 163). -- `ensure_plugin` — `jq -r '.plugins | has("yas@yet-another-statusline")'` on `installed_plugins.json` (~line 187). -- `do_wire` discovery — the `.plugins | to_entries[] | … | .installPath` query on `installed_plugins.json` (~lines 231–238). -- `do_wire` exact-match — `jq -r '.statusLine.command // ""'` on `settings.json` (~line 288). -- `do_wire` merge — `jq --arg cmd … '.statusLine = {…}'` (~lines 312–313). -- `do_wire` validate — `jq empty "$SETTINGS"` (~line 323). -- `do_uninstall` has-key — `jq 'has("statusLine")'` (~line 359). -- `do_uninstall` del — `jq 'del(.statusLine)'` (~line 371). -- `do_uninstall` validate — `jq empty "$SETTINGS"` (~line 380). -- `do_uninstall` discovery + present-check — the same `.installPath` query (~line 396) and `jq -r '.plugins | has(...)'` (~line 425). - -`preflight_full` (~line 131) requires `{claude, curl, jq}` then `find_python`. `preflight_wire_only` (~line 144) requires `jq` then `find_python`. `do_uninstall` separately re-checks `jq` (~line 342). - -On this branch the script already gained `provision_python PLUGIN_ROOT` (~line 75) — it returns non-zero when `uv` is absent, installs CPython 3.15 into `$PLUGIN_ROOT/.python` via `UV_PYTHON_INSTALL_DIR=… uv python install 3.15`, and resolves the concrete binary via `uv python find 3.15 --managed-python` with a `find`-glob fallback. `find_python` (~line 109) picks a system interpreter ≥3.10, treating 3.14 as a last resort (3.14 starts slower than 3.13/3.15). `do_wire` (~line 277) prefers `provision_python`, falling back to `find_python`. `do_uninstall` (~lines 389–419) already removes `$PLUGIN_ROOT/.python` best-effort, `DRY_RUN`-aware. - -The atomic settings write (mktemp → write → `mv` → validate → restore-from-backup) is correct and load-bearing; this change must preserve it exactly and swap only the JSON-producing command. - -Tests live in `test/test_install_script.py` (hermetic). The full-dry tests are guarded by `requires_full_preflight`, which skips when any of `claude`/`curl`/`jq` is missing from PATH (lines ~132–140). A prototype wire-only Docker harness already exists at `ops/install-docker-test/` (`run.sh`, `container-test.sh`, `Dockerfile`) — treat it as throwaway reference. - -## Goals / Non-Goals - -**Goals:** -- Remove `jq` as a dependency of `ops/install.sh` entirely, routing all JSON through the Python interpreter the script already resolves. -- Make `uv` a guaranteed provisioning engine: bootstrap it plugin-locally when absent, without touching the user's shell rc / PATH / system. -- Relax preflight to gate on a system Python ≥3.10 substrate (not `uv`, not `jq`), fixing the uv-but-no-system-python rejection gap. -- Keep the atomic backup/validate/restore write semantics byte-for-byte. -- Extend uninstall to remove `$PLUGIN_ROOT/.uv` alongside `$PLUGIN_ROOT/.python`. -- Prove the local-branch code end-to-end via a Docker harness (S1 uv-present, S2 uv-hidden→bootstrap, S3 uninstall) against the **stock** container image. - -**Non-Goals:** -- Replacing `uv`'s own provisioning behaviour or pinning the CPython 3.15 build (see the prerelease caveat below). -- Changing renderer code (`claude/yas/**`) or runtime statusline behaviour. -- Changing the full-mode marketplace/plugin orchestration (CLI calls, `--scope user`, dry-run decisions) beyond swapping `jq` reads for `json_py`. -- Adding a non-`uv` CPython download path (system Python ≥3.10 remains the only fallback when uv cannot be obtained). -- Vendoring or pinning a specific `uv` version in the bootstrap (the official installer serves the latest, matching the "branch serves latest installer" posture of the existing curl entrypoint). - -## Decisions - -### 1. One `json_py()` helper, values passed as argv (injection-safe) - -Add a single bash helper `json_py(op, ...args)` that pipes a small Python heredoc to the resolved interpreter (`"$PYTHON_BIN"`, or a preflight-resolved system python for the marketplace/plugin reads that run before `do_wire` selects an interpreter). The heredoc reads `sys.argv` for the op selector, file paths, and any values — **never** string-interpolates them into the Python source. This is the injection-safe rule: a `settings.json` path or a command string containing quotes/backslashes/`$()` cannot corrupt the JSON or inject code, because it arrives as `sys.argv[n]`, not as concatenated source. - -Sub-ops the nine call-sites need: -- `get-key FILE KEY` → print value of a top-level/dotted key, empty string if absent (replaces `jq -r '.statusLine.command // ""'` and the `has(...)` reads framed as a boolean op). -- `has-key FILE KEY` → print `true`/`false` (replaces `jq 'has("statusLine")'`, `jq -r 'has("yet-another-statusline")'`, `jq -r '.plugins | has("yas@yet-another-statusline")'`). -- `installpaths FILE` → print, one per line, the `installPath` of every `.plugins` entry whose key (ascii-lower) contains `yas` (replaces the `to_entries[] | select(... contains("yas")) | .value[] | .installPath` discovery query). The existing bash `while read | sort -Vr | head -1` post-filter (checks `statusline_command.py` / `.python` on disk) stays. -- `set-statusline FILE CMD` → load JSON (or `{}`), set `.statusLine = {"async":true,"command":CMD,"refreshInterval":1,"type":"command","padding":1}`, print the serialized result to stdout (replaces the `jq --arg cmd … '.statusLine = {…}'` merge; the bash side still writes stdout to the mktemp temp file and atomic-renames). -- `del-key FILE KEY` → load JSON, `pop` the key, print serialized result (replaces `jq 'del(.statusLine)'`). -- `validate FILE` → exit 0 iff `json.load` succeeds, non-zero otherwise (replaces `jq empty`). Implemented as `try: json.load(open(path)) except Exception: sys.exit(1)`. - -Each Python op reads its target file defensively: missing/unparseable input degrades to "absent" / `{}` for read ops (mirroring the existing `2>/dev/null || present="false"` degradation), and only the explicit `validate` op signals corruption. The bash callers keep their existing `|| fallback` guards so behaviour on parse failure is unchanged. - -### 2. Resolve a Python for the pre-wire reads - -`ensure_marketplace` and `ensure_plugin` (and the uninstall present-check) run before `do_wire` selects `PYTHON_BIN`. They need *a* Python to call `json_py`. Use `find_python` (the system ≥3.10 substrate that preflight now guarantees) for these early reads — they only parse small Claude-managed JSON, so the private 3.15 isn't needed. Resolve it once near the top of each mode path (or lazily inside `json_py` defaulting to `find_python` when `PYTHON_BIN` is unset). The wiring reads/writes inside `do_wire` use the already-selected `PYTHON_BIN` (private 3.15 or system fallback) — either works; both are ≥3.10 and parse JSON identically. - -### 3. uv bootstrap in `provision_python`, plugin-local, no system mutation - -`provision_python` currently `return 1`s immediately when `uv` is absent. Replace that early return with a bootstrap step: -- If `command -v uv` succeeds → use that `uv` (current behaviour). -- Else, under `DRY_RUN`, print "Would bootstrap uv → `$PLUGIN_ROOT/.uv`" and continue the dry-run preview (no download). -- Else fetch and run the official installer: `curl -LsSf https://astral.sh/uv/install.sh | sh`, exported with `INSTALLER_NO_MODIFY_PATH=1` (skip shell-rc/PATH edits) and `UV_INSTALL_DIR="$PLUGIN_ROOT/.uv"` (the official installer honours `UV_INSTALL_DIR` for the binary destination). The resolved binary is then `$PLUGIN_ROOT/.uv/uv`; reference it by absolute path everywhere `uv` is currently called inside `provision_python` (the `uv python install` and `uv python find` invocations). Verify it is executable after bootstrap; if the bootstrap fails, `return 1` so `do_wire` falls back to `find_python`. - -Concrete: introduce a local `UV_BIN` resolved to either `$(command -v uv)` or `$PLUGIN_ROOT/.uv/uv`, and call `"$UV_BIN"` rather than bare `uv` for the rest of the function. The `UV_PYTHON_INSTALL_DIR="$pydir"` scoping of `uv python install 3.15` and `uv python find` is unchanged. - -### 4. Preflight gates on system Python ≥3.10, not uv, not jq - -Drop `jq` from `preflight_full`'s tool loop (leave `claude`, `curl`). Delete the `jq` check from `preflight_wire_only` and the standalone `jq` re-check in `do_uninstall`. Both preflights keep their `find_python > /dev/null || exit 1` gate — that system ≥3.10 interpreter is exactly the substrate the uv bootstrap (and the `json_py` reads) run on. This closes the latent gap: today preflight gates on `find_python` while `do_wire` prefers `provision_python`, so the gate and the actual wiring engine disagree; after this change the gate guarantees the substrate both the bootstrap and the JSON helper require, and uv is no longer demanded up-front because it can be installed. - -### 5. Uninstall also removes `$PLUGIN_ROOT/.uv` - -Mirror the existing `.python` cleanup block in `do_uninstall`: after (or alongside) removing `$(yas_python_dir "$UN_ROOT")`, remove `$UN_ROOT/.uv` if present, `DRY_RUN`-aware ("Would remove bootstrapped uv dir …" vs `rm -rf`). Reuse the same `UN_ROOT` discovery (env `CLAUDE_PLUGIN_ROOT` if its `.python`/`.uv` exists, else the `installpaths` `json_py` scan). Consider a small `yas_uv_dir() { printf '%s/.uv\n' "$1"; }` helper symmetric with `yas_python_dir`. - -### 6. Docker harness against the stock image, three scenarios - -Once `jq` is gone, the harness no longer needs a Dockerfile that apt-installs `jq` — it can run the stock `ghcr.io/tmck-code/claude-container:python` image directly (it ships bash/curl/git/claude/uv and a non-root `claude` user; it has no system `python3`, which exercises provisioning). Rework `ops/install-docker-test/`: -- **Delete the `Dockerfile`** (no jq augmentation needed) and have `run.sh` `docker run` the stock image with `-v "$REPO_ROOT":/repo:ro` and an isolated `CLAUDE_PLUGIN_ROOT` / `CLAUDE_CONFIG_DIR` under a writable stage dir (the existing `container-test.sh` already copies `/repo` → a writable `/app/work` because the mount is read-only). -- **S1 (uv present):** run `install.sh --wire-only`; assert it provisions 3.15 under `.python`, wires the command, renders `ops/session-info-example.json` non-empty, settings shape is correct. (This is the current A1–A3, minus the jq dependency in the assertions — switch the harness's own `jq` reads to a python one-liner so the container needs no jq either.) -- **S2 (uv hidden):** run with `PATH=/uv/.venv/bin:/usr/bin:/bin` (or otherwise hide the stock `uv`) so `command -v uv` fails; assert the script **bootstraps uv** into `$CLAUDE_PLUGIN_ROOT/.uv`, then provisions 3.15 and wires. Confirm `$CLAUDE_PLUGIN_ROOT/.uv/uv` exists and is executable. -- **S3 (uninstall):** run `install.sh --uninstall`; assert `.statusLine` is stripped AND both `$CLAUDE_PLUGIN_ROOT/.python` and `$CLAUDE_PLUGIN_ROOT/.uv` are gone. (Extends the current A4 to also check `.uv`.) - -This is a verification vehicle, not a unit test — tasks.md tracks it but it is exercised manually / in CI, not in `pytest`. - -## Risks / Trade-offs - -- **`uv python install 3.15` resolves to a 3.15 *prerelease*.** 3.15 is not yet stable, so `uv` currently installs a prerelease CPython 3.15. This is an accepted trade-off: the startup win (~6–8 ms faster than 3.13; 3.14 is slower) outweighs running a prerelease for the short-lived statusline subprocess, which executes a small, well-exercised renderer. **Future work:** repin / re-verify when CPython 3.15 ships stable. Documented here so it is a known, deliberate caveat rather than a surprise. -- **uv bootstrap adds a network fetch on machines without uv.** The official installer download is one-time and plugin-local; subsequent runs reuse `$PLUGIN_ROOT/.uv/uv`. `--dry-run` previews it without downloading. Bootstrap failure degrades cleanly to the system-Python fallback (no hard failure). -- **Trusting `astral.sh/uv/install.sh`.** The bootstrap pipes a remote installer to `sh` — the same trust-on-first-use posture the existing curl entrypoint already takes. Constrained with `INSTALLER_NO_MODIFY_PATH=1` and a plugin-local `UV_INSTALL_DIR` so it cannot mutate the user's shell rc, PATH, or system locations. -- **`json_py` heredoc must stay injection-safe.** The whole point of argv-passing is defeated if any future edit interpolates a path/value into the heredoc body. The spec encodes this as a normative scenario; reviewers should treat any `$var` inside the Python source (vs `sys.argv`) as a defect. -- **Two interpreters in play (system substrate for early reads, private 3.15 for wiring).** Both are ≥3.10 and parse JSON identically; the split exists only because the marketplace/plugin reads run before `do_wire` selects the private interpreter. Keeping the early reads on `find_python` avoids provisioning 3.15 just to read `known_marketplaces.json`. - -## Open Questions (resolved empirically against `ghcr.io/tmck-code/claude-container:python`) - -- **Exact `uv` binary path after a custom-dir bootstrap — RESOLVED.** Running `INSTALLER_NO_MODIFY_PATH=1 UV_INSTALL_DIR=$PLUGIN_ROOT/.uv curl -LsSf https://astral.sh/uv/install.sh | sh` installs **flat**: the binary lands at `$PLUGIN_ROOT/.uv/uv` (plus `$PLUGIN_ROOT/.uv/uvx`), no `bin/` nesting. So `UV_BIN="$plugin_root/.uv/uv"` is correct; keep the `find … -name uv` glob only as defensive fallback. -- **S2 PATH-hiding technique — RESOLVED, original assumption was WRONG.** `uv` exists at **two** on-disk locations in the image: `/usr/local/bin/uv` and `/uv/.venv/bin/uv`. The latter is **co-located with `python`** (`/uv/.venv/bin/python`, version **3.14.6** — the only system interpreter), so the assumed `PATH=/uv/.venv/bin:/usr/bin:/bin` would NOT hide uv. Correct S2 hide: create a curated dir (e.g. `/app/pybin`) with a `python`/`python3` symlink to `/uv/.venv/bin/python`, then run with `PATH=/app/pybin:/usr/bin:/bin` — this excludes both uv dirs (uv is absent from `/usr/bin` and `/bin`) while keeping `python` (substrate, 3.14.6 → `find_python` last-resort) and `curl` (in `/usr/bin`, for the bootstrap). `command -v uv` then genuinely fails, exercising the bootstrap path. diff --git a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/proposal.md b/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/proposal.md deleted file mode 100644 index c317393..0000000 --- a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -`ops/install.sh` hard-depends on `jq` for all nine settings.json reads/transforms/validations, yet the installer's whole job is wiring a *Python* statusline command — it already requires (and now provisions) a Python interpreter, so `jq` is a redundant second dependency that machines (notably the stock `ghcr.io/tmck-code/claude-container:python` image) routinely lack. Separately, the new `provision_python` path gates the speed win behind `uv` being pre-installed, and preflight rejects a machine that has `uv` but no system Python even though that machine can be fully provisioned — so the installer fails on environments it could actually serve. - -## What Changes - -- **Remove the `jq` dependency entirely.** Route every JSON read/transform/validate through the Python interpreter the script already resolves, via a single injection-safe `json_py()` bash helper (heredoc Python script, file paths and values passed as **argv**, never string-interpolated). Convert all nine `jq` call-sites (`ensure_marketplace`, `ensure_plugin`, `do_wire` discovery + merge + validate + exact-match, `do_uninstall` has-key + del + discovery + present-check). Keep the mktemp/backup/validate/restore atomic-write flow byte-for-byte — only the JSON-producing command changes. **BREAKING** for the dependency contract: `jq` is no longer required or checked in either mode. -- **Bootstrap `uv` when absent.** `provision_python` grows a bootstrap step: if `command -v uv` succeeds, use it; otherwise fetch the official installer (`curl -LsSf https://astral.sh/uv/install.sh | sh`) with `INSTALLER_NO_MODIFY_PATH=1` and the install target pointed at `$PLUGIN_ROOT/.uv` (plugin-local, no shell-rc / PATH / system mutation), then reference the resolved `uv` binary by absolute path. `uv` thus becomes the guaranteed engine for `uv python install 3.15` → `$PLUGIN_ROOT/.python`. Honour `DRY_RUN` (preview, no download) in the new path. -- **Relax preflight.** The up-front gate becomes "a system Python ≥3.10 is present" (the bootstrap substrate), not "uv present". This closes a latent gap where preflight gated on `find_python` while `do_wire` prefers `provision_python`, wrongly rejecting uv-but-no-system-python machines. Wiring fallback order: prefer uv→3.15; only if uv genuinely cannot be obtained, wire a system Python ≥3.10 (still avoiding 3.14). -- **Uninstall symmetry.** `do_uninstall` also removes `$PLUGIN_ROOT/.uv` (best-effort, `DRY_RUN`-aware), mirroring the existing `.python` cleanup. -- **Verification harness.** Rework `ops/install-docker-test/` to run the local-branch `install.sh` against the **stock** `ghcr.io/tmck-code/claude-container:python` image (no Dockerfile needed once `jq` is gone), with a read-only repo mount and isolated `CLAUDE_PLUGIN_ROOT` / `CLAUDE_CONFIG_DIR`. Three scenarios: S1 uv-present, S2 uv-hidden (bootstraps uv), S3 uninstall removes `.python` and `.uv`. - -## Capabilities - -### New Capabilities - - -### Modified Capabilities -- `install-script`: drops `jq` from the dependency/preflight contract (JSON handled via the resolved Python); makes `uv` a bootstrappable engine (installed plugin-locally under `$PLUGIN_ROOT/.uv` when absent) rather than a hard precondition for the speed win; relaxes preflight to require a system Python ≥3.10 substrate instead of `uv`; and extends uninstall to remove the plugin-local `.uv` alongside `.python`. - -## Impact - -- **Modified**: `ops/install.sh` (new `json_py()` helper; 9 jq → json_py conversions; `provision_python` uv bootstrap; `preflight_full` / `preflight_wire_only` relaxation; `do_uninstall` `.uv` cleanup). -- **Modified**: `openspec/specs/install-script/spec.md` (dependency, preflight, provisioning, uninstall requirements). -- **Modified**: `test/test_install_script.py` (drop the `jq`-present guard from full-dry tests; add a no-jq wire-only assertion; add uv-bootstrap coverage where hermetically feasible). -- **Modified/Replaced**: `ops/install-docker-test/` (`run.sh`, `container-test.sh`, removal of the `Dockerfile` — stock image once jq is gone), three scenarios S1–S3. -- **Dependencies**: `jq` removed entirely. Full mode now requires `claude` + `curl` + a system Python ≥3.10. Wire-only requires a system Python ≥3.10. `uv` is auto-bootstrapped via `curl` when absent. -- **No change** to the renderer (`claude/yas/**`) or any runtime statusline behaviour. diff --git a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/specs/install-script/spec.md b/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/specs/install-script/spec.md deleted file mode 100644 index 69d0e4d..0000000 --- a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/specs/install-script/spec.md +++ /dev/null @@ -1,124 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Wire-only settings write - -In both modes the script SHALL write `statusLine.command` into `settings.json` under `$CLAUDE_CONFIG_DIR` (default `~/.claude/`), pointing at the newest installed renderer, and SHALL do so safely. In wire-only mode it SHALL target the renderer under `CLAUDE_PLUGIN_ROOT` directly without scanning, and SHALL NOT perform plugin management, network access (other than the `uv`/CPython provisioning described under "Private interpreter provisioning"), or nested `claude` invocations. All JSON reads, transforms, and validation SHALL be performed through the resolved Python interpreter and SHALL NOT depend on `jq` or any other external JSON tool. - -#### Scenario: Renderer discovery in full mode - -- **WHEN** the script wires settings in full mode -- **THEN** it locates the newest plugin root whose `claude/statusline_command.py` exists on disk, preferring `installed_plugins.json` and falling back to a version-sorted cache scan - -#### Scenario: Atomic write with backup - -- **WHEN** `settings.json` already exists and its `statusLine.command` differs from the target -- **THEN** the script backs the file up, writes the new value via a temp file and atomic rename, and validates the result - -#### Scenario: Exact-match skip - -- **WHEN** the existing `statusLine.command` exactly equals the target command -- **THEN** the script makes no change and reports the skip - -#### Scenario: Corrupt write rollback - -- **WHEN** the written `settings.json` fails JSON validation -- **THEN** the script restores the pre-write backup and exits non-zero - -#### Scenario: Legacy file cleanup - -- **WHEN** legacy `statusline-info-*` files exist under `$CLAUDE_CONFIG_DIR` -- **THEN** the script removes them - -#### Scenario: Existing settings keys preserved - -- **WHEN** `settings.json` already contains unrelated top-level keys -- **THEN** the script merges `statusLine` in without dropping or altering those keys - -#### Scenario: JSON handling needs no jq - -- **WHEN** the script reads, merges, or validates `settings.json` (or `known_marketplaces.json` / `installed_plugins.json`) -- **THEN** it does so via the resolved Python interpreter and never shells `jq`, so the flow succeeds on a machine where `jq` is absent - -#### Scenario: Values are passed safely to the JSON helper - -- **WHEN** the script passes a file path or a value (such as the command string) into the Python JSON helper -- **THEN** it passes them as process arguments (argv), never string-interpolated into the Python source, so paths or values containing quotes, backslashes, or shell metacharacters cannot corrupt the JSON or inject code - -### Requirement: Per-mode preflight and strictness - -The script SHALL run under `set -uo pipefail`, check only the dependencies its selected mode needs, and remain portable across macOS and Linux. The preflight substrate gate SHALL be the presence of a system Python ≥3.10 (the bootstrap substrate), NOT the presence of `uv` and NOT the presence of `jq`. - -#### Scenario: Full-mode dependency check - -- **WHEN** full mode runs and `claude` is not on PATH -- **THEN** the script reports the missing dependency with an install hint and exits non-zero - -#### Scenario: Wire-only dependency check - -- **WHEN** wire-only mode runs -- **THEN** the script does not require `claude`, `curl`, or `jq` to be present, only a system Python interpreter ≥3.10 - -#### Scenario: Missing Python substrate - -- **WHEN** no system Python ≥3.10 interpreter is found on PATH -- **THEN** the script reports the error and exits non-zero without modifying `settings.json` - -#### Scenario: uv is not a preflight precondition - -- **WHEN** preflight runs on a machine that has a system Python ≥3.10 but no `uv` on PATH -- **THEN** preflight passes (it does not reject for a missing `uv`), because `uv` is bootstrapped later during provisioning - -## ADDED Requirements - -### Requirement: Private interpreter provisioning via uv - -The script SHALL provision a private, plugin-local CPython under `$PLUGIN_ROOT/.python` using `uv`, and wire `statusLine.command` to that interpreter for the fastest statusline startup, without mutating the user's system Python, shell rc, or PATH. `uv` SHALL be the guaranteed provisioning engine: when `uv` is already on PATH the script SHALL use it; when `uv` is absent the script SHALL bootstrap a plugin-local copy of `uv` rather than failing or skipping provisioning. - -#### Scenario: uv already present - -- **WHEN** `uv` is on PATH at provisioning time -- **THEN** the script uses that `uv` and does not bootstrap a second copy - -#### Scenario: uv bootstrapped when absent - -- **WHEN** `uv` is not on PATH at provisioning time and the script is not in dry-run -- **THEN** the script installs `uv` from the official installer (`curl -LsSf https://astral.sh/uv/install.sh | sh`) into `$PLUGIN_ROOT/.uv`, and then references the resulting `uv` binary by absolute path - -#### Scenario: uv bootstrap does not mutate the user environment - -- **WHEN** the script bootstraps `uv` -- **THEN** it runs the installer with `INSTALLER_NO_MODIFY_PATH=1` and targets `$PLUGIN_ROOT/.uv`, so the user's shell rc files, PATH, and system locations are not modified - -#### Scenario: Provision a private CPython 3.15 - -- **WHEN** `uv` is available (already present or just bootstrapped) and the script is not in dry-run -- **THEN** the script installs CPython 3.15 into `$PLUGIN_ROOT/.python` via `uv python install 3.15` and wires `statusLine.command` at the resolved 3.15 interpreter binary - -#### Scenario: Fallback to a system interpreter only when uv cannot be obtained - -- **WHEN** `uv` is neither present nor obtainable (the bootstrap genuinely fails) -- **THEN** the script wires a system Python ≥3.10 (avoiding 3.14, which starts slower) instead, and still succeeds - -#### Scenario: Dry-run previews provisioning without downloading - -- **WHEN** the script runs with `--dry-run` and would provision the interpreter -- **THEN** it prints what it would bootstrap/install/wire and downloads nothing (no `uv` installer fetch, no `uv python install`) - -### Requirement: Uninstall removes plugin-local provisioning artifacts - -The uninstall flow SHALL remove the plugin-local provisioning artifacts it created — both `$PLUGIN_ROOT/.python` and `$PLUGIN_ROOT/.uv` — on a best-effort basis, honouring `--dry-run`. - -#### Scenario: Uninstall removes the private CPython directory - -- **WHEN** uninstall runs and `$PLUGIN_ROOT/.python` exists -- **THEN** the script removes it (or, under `--dry-run`, reports that it would remove it) - -#### Scenario: Uninstall removes the bootstrapped uv directory - -- **WHEN** uninstall runs and `$PLUGIN_ROOT/.uv` exists -- **THEN** the script removes it (or, under `--dry-run`, reports that it would remove it) - -#### Scenario: Uninstall needs no jq - -- **WHEN** uninstall removes `statusLine` from `settings.json` and checks plugin presence -- **THEN** it performs all JSON reads/edits via the resolved Python interpreter and never shells `jq` diff --git a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/tasks.md b/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/tasks.md deleted file mode 100644 index 13b5f56..0000000 --- a/openspec/changes/archive/2026-06-19-remove-jq-bootstrap-uv/tasks.md +++ /dev/null @@ -1,66 +0,0 @@ -## 1. Add the `json_py()` helper in `ops/install.sh` - -- [x] 1.1 Add a `json_py OP [ARGS...]` bash function near the top of `ops/install.sh` (after the `CLAUDE_CONFIG_DIR` setup, before the provisioning block). It pipes a single-quoted Python heredoc to a resolved interpreter and passes the op selector, file paths, and values **as argv** (`"$@"` → `sys.argv`), never interpolated into the heredoc body. Resolve the interpreter as: use a caller-provided `$PYTHON_BIN` if set, else `find_python` (the system ≥3.10 substrate). Injection-safety is a hard requirement — no `$var` may appear inside the Python source. -- [x] 1.2 Implement sub-op `get-key FILE KEY`: load JSON (or `{}` on missing/unparseable), print the value at top-level `KEY` as a string, empty string if absent. Replaces `jq -r '.statusLine.command // ""'`. -- [x] 1.3 Implement sub-op `has-key FILE KEY`: print `true`/`false` for top-level key presence. Supports a dotted form for `.plugins | has("yas@…")` (e.g. accept `plugins.yas@yet-another-statusline` or a two-arg `PARENT CHILD`). Replaces the three `has(...)` reads (`ensure_marketplace` ~163, `ensure_plugin` ~187, `do_uninstall` ~359 and ~425). -- [x] 1.4 Implement sub-op `installpaths FILE`: print, one per line, the `installPath` of every `.plugins` entry whose key (ascii-lower) contains `yas`. Replaces the `to_entries[] | select(... contains("yas")) | .value[] | .installPath` discovery (`do_wire` ~231, `do_uninstall` ~396). The existing bash `while read | … | sort -Vr | head -1` on-disk post-filter stays. -- [x] 1.5 Implement sub-op `set-statusline FILE CMD`: load JSON (or `{}`), set `.statusLine = {"async":true,"command":CMD,"refreshInterval":1,"type":"command","padding":1}`, print the serialized JSON to stdout. Replaces the `jq --arg cmd … '.statusLine = {…}'` merge (~312–313). The bash caller still writes stdout to the mktemp temp file and atomic-renames. -- [x] 1.6 Implement sub-op `del-key FILE KEY`: load JSON, remove top-level `KEY`, print serialized JSON to stdout. Replaces `jq 'del(.statusLine)'` (~371). -- [x] 1.7 Implement sub-op `validate FILE`: `exit 0` iff `json.load(open(FILE))` succeeds, non-zero otherwise. Replaces `jq empty` (~323, ~380). - -## 2. Convert the nine jq call-sites to `json_py` - -- [x] 2.1 `ensure_marketplace` (~163): `present=$(json_py has-key "$CLAUDE_CONFIG_DIR/plugins/known_marketplaces.json" yet-another-statusline)`, keeping the `|| present="false"` degradation. -- [x] 2.2 `ensure_plugin` (~187): `present=$(json_py has-key "…/installed_plugins.json" plugins yas@yet-another-statusline)`, keeping the degradation. -- [x] 2.3 `do_wire` discovery (~231–242): replace the `jq` query with `json_py installpaths "…/installed_plugins.json"`, leaving the `while IFS= read -r d; … | sort -Vr | head -1` post-filter unchanged. -- [x] 2.4 `do_wire` exact-match (~288): `OLD_CMD=$(json_py get-key "$SETTINGS" statusLine.command)` (support the dotted `statusLine.command` read in `get-key`, or read `statusLine` then `.command` — pick one and keep it consistent), keeping `|| printf ''`. -- [x] 2.5 `do_wire` merge (~311–316): `_result=$(json_py set-statusline "$SETTINGS" "$NEW_CMD")`, keeping the `|| [ -z "$_result" ]` failure guard and the existing mktemp → `mv` atomic write that follows. Do NOT alter the backup/mktemp/restore flow. -- [x] 2.6 `do_wire` validate (~323): `if ! json_py validate "$SETTINGS"; then … restore backup … fi`, unchanged restore semantics. -- [x] 2.7 `do_uninstall` has-key (~359): `HAS_KEY=$(json_py has-key "$SETTINGS" statusLine)`, keeping `|| HAS_KEY="false"`. -- [x] 2.8 `do_uninstall` del (~371): `_result=$(json_py del-key "$SETTINGS" statusLine)`, keeping the `|| [ -z "$_result" ]` guard and the existing mktemp atomic write. -- [x] 2.9 `do_uninstall` validate (~380): `if ! json_py validate "$SETTINGS"; then … restore backup … fi`. -- [x] 2.10 `do_uninstall` discovery + present-check (~396, ~425): `installpaths` for the `.python`/`.uv` root scan and `has-key … plugins yas@yet-another-statusline` for the plugin-present gate. -- [x] 2.11 Update the script's top-of-file mode/Requires comment block (~lines 4–10) to drop every `jq` mention. - -## 3. uv bootstrap in `provision_python` - -- [x] 3.1 In `provision_python` (~75) replace the early `command -v uv … || return 1` with engine resolution: set `local UV_BIN`; if `command -v uv` succeeds, `UV_BIN=$(command -v uv)`; else bootstrap (3.2). Reference `"$UV_BIN"` for every `uv` call in the function (the `uv python install 3.15` and `uv python find` lines), keeping the `UV_PYTHON_INSTALL_DIR="$pydir"` scoping. -- [x] 3.2 Bootstrap branch (uv absent, not dry-run): run `INSTALLER_NO_MODIFY_PATH=1 UV_INSTALL_DIR="$plugin_root/.uv" sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'` (or equivalent), then resolve `UV_BIN="$plugin_root/.uv/uv"`. Confirm the binary path against the live installer's layout; if it nests, fall back to a `find "$plugin_root/.uv" -name uv -type f -perm -u+x | head -1` glob (mirrors the existing 3.15 binary resolution). If `UV_BIN` is missing/not executable after bootstrap, `return 1` (→ `find_python` fallback). -- [x] 3.3 Dry-run path: when `DRY_RUN=1` and uv is absent, print ` Would bootstrap uv → $plugin_root/.uv` to stderr and continue emitting the synthetic `` preview path (no download). When uv is present in dry-run, keep current behaviour. Ensure no network call happens under dry-run on either branch. -- [x] 3.4 Add a `yas_uv_dir() { printf '%s/.uv\n' "$1"; }` helper alongside `yas_python_dir` (~59) for symmetry, used by provisioning and uninstall. - -## 4. Relax preflight - -- [x] 4.1 `preflight_full` (~131): drop `jq` from the `for tool in claude curl jq` loop (leave `claude curl`); remove the `jq)` case arm. Keep the `find_python > /dev/null || exit 1` substrate gate. -- [x] 4.2 `preflight_wire_only` (~144): delete the `command -v jq … || exit 1` line; keep only the `find_python` gate. -- [x] 4.3 `do_uninstall` (~342): delete the standalone `command -v jq … || exit 1` re-check (JSON now goes through `json_py`). -- [x] 4.4 Confirm `do_wire`'s engine selection (~277) already encodes the intended order — prefer `provision_python` (uv→3.15, now bootstrappable), fall back to `find_python` (system ≥3.10, avoiding 3.14). Adjust only if needed so the relaxed preflight and wiring agree. - -## 5. Uninstall removes `$PLUGIN_ROOT/.uv` - -- [x] 5.1 In `do_uninstall`'s artifact-cleanup block (~389–419), after/alongside the `.python` removal, remove `$(yas_uv_dir "$UN_ROOT")` if present, `DRY_RUN`-aware (` Would remove bootstrapped uv dir %s` vs `rm -rf … && printf …`). Reuse the existing `UN_ROOT` discovery (env `CLAUDE_PLUGIN_ROOT` if its `.python`/`.uv` exists, else the `installpaths` scan), extending the existence test to also match a `.uv` dir. - -## 6. Spec - -- [x] 6.1 Apply the delta in `openspec/changes/remove-jq-bootstrap-uv/specs/install-script/spec.md` to `openspec/specs/install-script/spec.md` at archive: the MODIFIED "Wire-only settings write" and "Per-mode preflight and strictness" requirements, plus the ADDED "Private interpreter provisioning via uv" and "Uninstall removes plugin-local provisioning artifacts" requirements. (Handled by `openspec archive`; verify the wording matches the shipped behaviour before archiving.) - -## 7. Tests (`test/test_install_script.py`) - -- [x] 7.1 Remove the `jq` member from the `requires_full_preflight` guard list (~133): change `('claude', 'curl', 'jq')` to `('claude', 'curl')` so full-dry tests no longer skip when `jq` is absent. -- [x] 7.2 Add a wire-only test that proves no jq dependency: run `install.sh` (wire-only) with `PATH` scrubbed of `jq` (e.g. a tmp PATH that contains bash/python/coreutils but not jq, or stub a failing `jq`) and assert `settings.json` is wired correctly and the run exits 0. Reuse the `wire_env` fixture. -- [x] 7.3 Add (where hermetically feasible) uv-bootstrap coverage: assert that with `uv` absent and `DRY_RUN` set, the dry-run output mentions bootstrapping uv → `.uv` and the run makes no network call / writes nothing. Full network-dependent provisioning (real `uv python install 3.15`) stays in the Docker harness, not pytest. -- [x] 7.4 Confirm the existing uninstall tests still pass; add an assertion (hermetic, dry-run) that uninstall reports it would remove the `.uv` dir when one exists under the plugin root. -- [x] 7.5 Run `make test` (or `uv run pytest -q test/test_install_script.py`) and `shellcheck ops/install.sh`; confirm green. - -## 8. Docker verification harness (`ops/install-docker-test/`) - -- [x] 8.1 Delete `ops/install-docker-test/Dockerfile` (no jq augmentation needed once jq is gone) and point `run.sh` at the stock `ghcr.io/tmck-code/claude-container:python` image directly (`docker run` it, dropping the `docker build` step), keeping `-v "$REPO_ROOT":/repo:ro`, the writable `/app/work` stage, and isolated `CLAUDE_PLUGIN_ROOT` / `CLAUDE_CONFIG_DIR`. -- [x] 8.2 Replace the harness's own internal `jq` reads in `container-test.sh` with a python one-liner (the container has no jq), so the harness proves a jq-free environment end-to-end. -- [x] 8.3 S1 (uv present): `install.sh --wire-only` → assert wired to a private uv-managed CPython 3.15 under `.python` (not bare system python, not 3.14), renders `ops/session-info-example.json` non-empty, settings shape correct (carry over A1–A3). -- [x] 8.4 S2 (uv hidden): re-run with the stock `uv` removed from PATH (confirm the actual `uv` location in the image first; resolves the design Open Question) so `command -v uv` fails → assert the script **bootstraps** `$CLAUDE_PLUGIN_ROOT/.uv/uv` (exists, executable), then provisions 3.15 and wires. -- [x] 8.5 S3 (uninstall): `install.sh --uninstall` → assert `.statusLine` stripped AND both `$CLAUDE_PLUGIN_ROOT/.python` and `$CLAUDE_PLUGIN_ROOT/.uv` removed (extend A4). -- [x] 8.6 Run `bash ops/install-docker-test/run.sh` and confirm S1–S3 all PASS. - -## 9. Final verification - -- [x] 9.1 `shellcheck ops/install.sh` clean; `make test` green; the Docker harness green. Confirm a grep for `\bjq\b` over `ops/install.sh` returns nothing. diff --git a/openspec/changes/archive/2026-06-20-interactive-installer/.openspec.yaml b/openspec/changes/archive/2026-06-20-interactive-installer/.openspec.yaml deleted file mode 100644 index ff1fbc8..0000000 --- a/openspec/changes/archive/2026-06-20-interactive-installer/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-20-interactive-installer/design.md b/openspec/changes/archive/2026-06-20-interactive-installer/design.md deleted file mode 100644 index f266f28..0000000 --- a/openspec/changes/archive/2026-06-20-interactive-installer/design.md +++ /dev/null @@ -1,208 +0,0 @@ -## Context - -`ops/install.sh` is a single bash script (`#!/usr/bin/env bash`, `set -uo -pipefail`, POSIX-ish, no bash-4 features) with two auto-detected modes keyed on -`CLAUDE_PLUGIN_ROOT`: **full** (`ensure_marketplace` → `ensure_plugin` → -`do_wire`) and **wire-only** (`do_wire` only), plus `--uninstall`, `--dry-run`, -`--main`. It has **zero TTY/interactivity**. All JSON goes through the -injection-safe `json_py()` bash→Python heredoc (jq was dropped). `do_wire` -discovers `$PLUGIN_ROOT`, calls `provision_python "$PLUGIN_ROOT"` (which -bootstraps `uv` into `$PLUGIN_ROOT/.uv` with `INSTALLER_NO_MODIFY_PATH=1` and -installs CPython **3.15** into `$PLUGIN_ROOT/.python`, hardcoded), falls back to -`find_python` (system ≥3.10, avoids 3.14), then atomically writes -`statusLine.command` into `$CLAUDE_CONFIG_DIR/settings.json` (backup → temp → mv -→ `json_py validate` → restore-on-failure). - -The README install command pipes the script into bash: -`curl -fsSL …/main/ops/install.sh | bash`. Under a pipe, the shell's stdin is -the pipe, not a terminal — so any naive `read` would consume the script body or -hit EOF. - -Config lives in `claude/yas/config.py` (`Config.load`, precedence CLI → `YAS_*` -env → legacy-alias env → `yas.toml` → default). The four user-facing knobs: -`appearance.glyphs.mode` / `YAS_GLYPH_MODE` (`nerdfont` default / `ascii` / -`unicode` / `github`, validated by `_parse_glyph_mode`); `layout.labels` / -`YAS_LABELS` (bool, default `False`); `appearance.theme` / `YAS_THEME` (the 14 -keys in `THEMES` in `claude/yas/themes.py`, `claude-dark` default, validated by -`_parse_theme`); `tokens.soft_limit` / `YAS_SOFT_LIMIT` (int > 0, default -150000; per-model overrides via `[[tokens.model]]`). `yas.toml` lives at -`$CLAUDE_CONFIG_DIR/yas.toml`; `yas.example.toml` is the committed template; -`tomllib` is read-only (3.11+) — there is **no stdlib TOML writer**. - -**Untracked-asset finding (load-bearing):** the logo `yas.dos_rebel.plain.txt`, -plus `select.sh`, `checkbox.sh`, and `yas.dos_rebel.txt`, are **git-untracked** -(verified via `git ls-files`). Claude Code plugin packaging ships only -git-tracked files, so none of these reach `$PLUGIN_ROOT` after install, and none -exist under `curl | bash`. `ops/session-info-example.json` and -`claude/statusline_command.py` **are** tracked and shipped. - -## Goals / Non-Goals - -**Goals:** -- A guided, default-on first-run experience (logo → Python prompt → 4 config - prompts with live previews → write `yas.toml`) when attached to a terminal. -- Never block on input in CI / non-TTY / `YAS_NO_TTY=1` contexts; the - non-interactive path stays behaviourally identical **except** the Python - default moves 3.15 → 3.13. -- Self-containment: zero runtime dependency on untracked assets; logo + selector - embedded in the script. -- Stop silently shipping a prerelease Python; make 3.15 an explicit opt-in. -- An in-app reconfigure entry point (`/yas:config` → `--reconfigure`). - -**Non-Goals:** -- Editing the renderer (`claude/yas/**`) or any statusline runtime behaviour. -- A TOML round-trip merge that preserves a user's existing comments/keys — the - keep-as-is vs reconfigure and overwrite vs print choices cover that instead. -- Free-form numeric soft-limit entry or per-model `[[tokens.model]]` editing in - the wizard — preset menu only, with a README pointer for advanced config. -- Multi-select prompts (`checkbox.sh`) — all four prompts are single-select / - yes-no. -- CI-testing the live interactive TTY render path (no terminal in CI). -- Re-downloading or re-`exec`ing the script to obtain a TTY. - -## Decisions - -### 1. Interactivity / TTY model -- **Interactive is the default.** Force non-interactive when `YAS_NO_TTY=1` - **OR** no readable `/dev/tty`. A single predicate (e.g. `is_interactive()`, - `INTERACTIVE=1` when `[ -z "${YAS_NO_TTY:-}" ] && [ "$YAS_NO_TTY" != "1" ]` - and `[ -r /dev/tty ]`) gates every prompt. No prompt is ever issued when the - predicate is false — the script must never hang. -- **Reopen stdin once via `exec < /dev/tty`** at the start of the interactive - branch. This works under `curl | bash` because bash has already read the - entire script body from the pipe before `main` runs, so the pipe is exhausted - and reattaching fd 0 to the terminal is safe. No re-download, no re-exec, no - subshell. The embedded selector additionally reads `/dev/tty` directly for - keystrokes so it is robust regardless of fd-0 state. -- `--reconfigure` implies interactive intent; if its preconditions for a TTY are - not met it errors out rather than silently doing nothing (it has no useful - non-interactive behaviour). - -### 2. Self-contained embedded assets -- **Logo**: embed `yas.dos_rebel.plain.txt`'s 8 lines (42 cols, plain unicode - block art) as a single-quoted heredoc printed by a `print_logo()` function. - Source of truth is the heredoc, **not** a file read — chosen over git-tracking - the logo so the script is self-contained under `curl | bash` and inside - `$PLUGIN_ROOT`. The untracked logo file stays as the dev-time authoring source. -- **Selector**: embed a minimal single-select bash function derived from - blurayne's `select.sh` (the vendored gist). It MUST: read `/dev/tty` for input - (arrow keys + enter), return the chosen index/value via a global (matching the - upstream `UI_WIDGET_RC` convention), be **bash-3.2-safe** (no associative - arrays, no `${var,,}`, no `mapfile`), and accept an **optional preview - callback** invoked on each highlight change with the highlighted value so the - caller can render a live sample. Preserve a **CC BY 4.0 attribution comment** - for blurayne above the embedded function. -- `select.sh` / `checkbox.sh` remain dev-only and are NOT a runtime dependency. - -### 3. Python version policy (`provision_python` reads `VER`) -- `provision_python` currently hardcodes `3.15` in five places (the dry-run - messages at lines ~239 / ~241, the `uv python install 3.15`, the - `uv python find 3.15`, and the `find … -name python3.15 -path '*/cpython-3.15*'` - fallback glob). Parameterise on a `VER` resolved as `${YAS_PYTHON:-3.13}`, - overridden to the interactive choice when the user is prompted. Every literal - `3.15` token in the function becomes `$VER` (including the glob's - `python$VER` / `cpython-$VER*`). -- **Default by mode**: non-interactive / wire-only → `3.13`. Interactive → prompt - "Use Python 3.15 (faster, prerelease)?" → yes sets `VER=3.15`, no keeps `3.13`. - `YAS_PYTHON=3.15` (any mode) forces `3.15` and suppresses the prompt's effect. -- The `find_python` system fallback already prefers non-3.14 and includes - `python3.13`/`python3.15` candidates, so it needs no change for the new default. - -### 4. Config wizard flow (interactive only) -Full-mode order — the Python prompt and logo fire **before** plugin install -(they need no shipped asset, being embedded); the live previews fire **after** -`provision_python` (they need the interpreter plus the shipped -`$PLUGIN_ROOT/claude/statusline_command.py` and -`$PLUGIN_ROOT/ops/session-info-example.json`): - -1. `print_logo` -2. Python-version prompt (Decision 3) -3. `preflight_full` -4. `ensure_marketplace` -5. `ensure_plugin` (lands renderer + sample JSON at `$PLUGIN_ROOT`) -6. `provision_python` → `PYTHON_BIN` -7. Config questions with live previews (below) -8. Write `yas.toml` (Decision 5) -9. `do_wire` settings.json write -10. Post-install message → `/yas:config` - -The four prompts: -- **glyph mode** (4 options) and **theme** (14 options): **render-on-highlight** - preview. The preview callback runs the provisioned `PYTHON_BIN` on - `$PLUGIN_ROOT/claude/statusline_command.py` with stdin = - `$PLUGIN_ROOT/ops/session-info-example.json` and `YAS_GLYPH_MODE` / - `YAS_THEME` set to the highlighted value, plus a fixed `COLUMNS` (e.g. 100) so - the sample box geometry is stable, and prints the rendered statusline beneath - the menu. -- **labels**: yes/no, prompt **defaults to on** for new users (rationale: aids - familiarity with the labelled rows). Maps to `layout.labels`. -- **soft limit**: **preset menu only** — 150k / 200k / 500k / 1M — followed by a - one-line pointer to the README / `yas.example.toml` for per-model and advanced - config. **No** free-form numeric entry. Maps to `tokens.soft_limit`. - -### 5. yas.toml write, keep, and print (interactive only) -- The wizard generates `yas.toml` from a **commented template** baked into the - script (a `build_yas_toml()` function that interpolates the four chosen values - into a heredoc mirroring `yas.example.toml`'s structure: - `[layout] labels`, `[tokens] soft_limit`, `[appearance] theme`, - `[appearance.glyphs] mode`). No merge of an existing file's comments/keys is - attempted. -- **Existing `yas.toml` detected** → prompt **keep as-is** vs **reconfigure**. - Keep skips the four questions and the write entirely. -- **After the questions** → prompt **overwrite the file** vs **print to STDOUT** - (for manual copy/paste). The overwrite path writes atomically (temp + mv) and - validates by re-parsing with the provisioned Python's `tomllib` where available - (3.11+); print just emits to stdout. -- **Non-interactive mode writes NO `yas.toml`** — env vars and defaults govern. - -### 6. `--reconfigure` mode + `/yas:config` skill -- New `--reconfigure` flag in the arg loop sets `MODE="reconfigure"`. The - `reconfigure` branch in `main()`: `print_logo` → Python prompt → run the full - interactive wizard (4 options + write `yas.toml`) → re-run `do_wire`. It - **skips** `ensure_marketplace` / `ensure_plugin` (plugin already present) and - reuses `$PLUGIN_ROOT` via `CLAUDE_PLUGIN_ROOT` / the same `do_wire` discovery. -- New `skills/config/SKILL.md` (mirroring `skills/init/SKILL.md`'s frontmatter: - `allowed-tools: Bash`, `effort: low`, `model: haiku`) whose workflow is - `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh" --reconfigure`. Resolves as - `/yas:config`. -- The post-install message (end of `do_wire` / the wizard) points users to - `/yas:config`, explicitly noting it as the way to switch to Python 3.15 later. - -### 7. Side-effect-free previews -`app.main()` (`claude/yas/app.py`, lines ~88–95) writes -`$CLAUDE_DIR/statusline-output/statusline..json` keyed on the stdin -payload's `session_id`, and (when `show_render_time` is on) touches a -`RenderTiming` cache. Previews must not pollute a real session's output. The -preview invocation SHALL neutralise this by pointing `CLAUDE_CONFIG_DIR` (hence -`CLAUDE_DIR`, the base for `statusline-output`) at a throwaday temp dir for the -duration of the preview subprocess, so any payload write lands in scratch space -and is discarded. (`session-info-example.json`'s `session_id` is itself a sample -id; isolating `CLAUDE_DIR` is the robust guarantee.) The write is already wrapped -in `try/except OSError`, so a read-only scratch dir would also be safe, but an -isolated writable temp dir keeps the render path identical to production. - -## Risks / Trade-offs - -- **3.13 default is a behaviour change (BREAKING for the silent default).** - Silent installs lose the ~6–8 ms 3.15 startup win. Accepted: shipping a Python - **alpha** without consent is worse, and it is recoverable via `YAS_PYTHON=3.15` - or `/yas:config`. Documented in README + the post-install message. -- **Preview side effects** — mitigated by Decision 7 (isolated `CLAUDE_CONFIG_DIR` - per preview). The exact env knob and the throwaway-dir lifecycle are specified - in tasks; verify against `app.main()`'s `CLAUDE_DIR / 'statusline-output'` - path construction before wiring previews. -- **Embedded selector portability** — must be bash-3.2-safe and read `/dev/tty`. - Risk that `exec < /dev/tty` interacts badly with `do_wire`'s later `json_py` / - `uv` subprocesses; mitigated because those subprocesses get their stdin from - heredocs / files (`json_py` uses `<<'PY'`; `uv`/`curl` read no stdin), not from - fd 0, so reattaching fd 0 to the terminal is inert for them. Validate in the - docker harness. -- **CC BY 4.0 attribution** must be preserved verbatim in the embedded selector; - the upstream `checkbox.sh` (Pedro) is not embedded. -- **Logo as embedded heredoc vs git-tracked file** — recommended: embedded - heredoc as the single source of truth (self-contained). Git-tracking the logo - would be an alternative but still requires the heredoc for `curl | bash`, so it - buys nothing; the untracked file remains the dev authoring source. -- **No CI coverage of the live TTY path** — accepted. Tests target the - non-interactive branches and the pure builders (`build_yas_toml`, the selector - logic in isolation); the docker harness covers end-to-end provision/wire. diff --git a/openspec/changes/archive/2026-06-20-interactive-installer/proposal.md b/openspec/changes/archive/2026-06-20-interactive-installer/proposal.md deleted file mode 100644 index b0d25cc..0000000 --- a/openspec/changes/archive/2026-06-20-interactive-installer/proposal.md +++ /dev/null @@ -1,92 +0,0 @@ -## Why - -`ops/install.sh` today has zero interactivity: full mode silently registers the -marketplace, installs the plugin, provisions a **3.15 alpha** interpreter, and -wires `settings.json` with no prompt and no opportunity to pick a theme, glyph -mode, labels, or a token soft-limit. A first-time user under `curl … | bash` -gets an opinionated, prerelease-Python install they never consented to, and the -only way to discover the four user-facing config knobs is to read the source or -`yas.example.toml`. There is also no in-app reconfigure path — no `/yas:config` -skill exists. We can offer a guided first-run experience without breaking the -non-interactive CI / plugin-wiring path the script already serves. - -## What Changes - -- **Interactive-by-default install.** When `install.sh` runs attached to a - terminal it shows the YAS logo, prompts for the Python version, runs a config - wizard (glyph mode, labels, theme, soft-limit) with **live render previews**, - and writes `$CLAUDE_CONFIG_DIR/yas.toml`. Interactivity is suppressed when - `YAS_NO_TTY=1` **or** no readable `/dev/tty` exists (CI safety — the installer - must never block on input). Under `curl | bash` the script reopens stdin once - via `exec < /dev/tty` (no re-download, no re-exec). -- **Self-contained, no untracked-asset dependency.** The plain logo and a - minimal **single-select function derived from blurayne's `select.sh`** (CC - BY 4.0) are embedded directly in `install.sh` as a heredoc / bash function. - `select.sh` / `checkbox.sh` / `yas.dos_rebel*.txt` stay dev-only — they are - git-untracked and so are never shipped in `$PLUGIN_ROOT`. The embedded - selector is **bash-3.2-safe** (no associative arrays, no `${var,,}`) so stock - macOS bash works, reads `/dev/tty`, and supports a render-on-highlight preview - callback. -- **Python version policy change. BREAKING (default behaviour).** Provision - `VER=${YAS_PYTHON:-3.13}` instead of the current hardcoded 3.15. The - non-interactive / plugin (wire-only) path now defaults to the **stable 3.13** - rather than silently shipping a Python **alpha**. Interactive mode prompts - "Use Python 3.15 (faster, prerelease)?". `YAS_PYTHON=3.15` forces 3.15 in any - mode. This trades the ~6–8 ms startup win on silent installs for not - installing a prerelease without consent; it is fully recoverable later via - `/yas:config`. -- **Config wizard writes `yas.toml` (interactive only).** Four prompts map to - `appearance.glyphs.mode`, `layout.labels`, `appearance.theme`, - `tokens.soft_limit`, rendered from a commented template. Glyph mode and theme - use render-on-highlight live samples; labels defaults to **on** for new users; - soft-limit is a **preset menu** (150k / 200k / 500k / 1M) with a pointer to the - README for advanced/per-model config. Non-interactive mode writes **no** - `yas.toml`. An existing `yas.toml` triggers a keep-as-is vs reconfigure prompt, - and after the questions an overwrite-file vs print-to-STDOUT choice. -- **`--reconfigure` mode + `/yas:config` skill.** A new `--reconfigure` mode - re-runs the logo + full wizard (Python + 4 options + write `yas.toml` + - re-wire) and **skips** marketplace/plugin install. A new plugin skill - `skills/config/SKILL.md` exposes it as `/yas:config`, which the post-install - message points users to (including as the way to switch to 3.15 later). -- **Docs + tests.** README documents interactive-default behaviour, `YAS_NO_TTY`, - `YAS_PYTHON`, and `/yas:config`. Tests cover the non-interactive 3.13 default, - `YAS_NO_TTY=1` forcing non-interactive, the `YAS_PYTHON=3.15` override, - `--reconfigure`, and the yas.toml template builder in isolation. The live TTY - path is not CI-testable; the docker harness covers end-to-end provision/wire. - -## Capabilities - -### New Capabilities - - -### Modified Capabilities -- `install-script`: adds an interactive mode (TTY-detected, default-on, with - `YAS_NO_TTY` / `/dev/tty` escape hatches), an embedded logo + bash-3.2-safe - single-select with render-on-highlight previews, a `YAS_PYTHON`-driven version - policy whose non-interactive default changes from 3.15 to 3.13, an interactive - config wizard that writes `yas.toml` (with keep/reconfigure and - overwrite/print choices), a `--reconfigure` mode, and a `/yas:config` skill - delegating to it. - -## Impact - -- **Modified**: `ops/install.sh` (TTY detection + `YAS_NO_TTY`; `exec < /dev/tty`; - embedded logo heredoc; embedded `select.sh`-derived single-select; `VER`-driven - `provision_python` replacing the hardcoded 3.15; interactive Python prompt; - config wizard with live previews; `yas.toml` template builder + write/keep/print; - `--reconfigure` mode + arg parsing; post-install `/yas:config` pointer). -- **New**: `skills/config/SKILL.md` → `/yas:config` runs - `bash "$CLAUDE_PLUGIN_ROOT/ops/install.sh" --reconfigure`. -- **Modified**: `openspec/specs/install-script/spec.md` (interactive mode, TTY - detection, embedded assets, Python version policy, config wizard, reconfigure, - skill). -- **Modified**: `test/test_install_script.py` (non-interactive 3.13 default; - `YAS_NO_TTY=1`; `YAS_PYTHON=3.15`; `--reconfigure`; yas.toml builder in - isolation). **Modified**: `ops/install-docker-test/` end-to-end provision/wire. -- **Modified**: `README.md` (interactive-default, `YAS_NO_TTY`, `YAS_PYTHON`, - `/yas:config`); `yas.example.toml` if a new commented knob is surfaced. -- **No change** to the renderer (`claude/yas/**`) or runtime statusline behaviour. - The wizard *invokes* `statusline_command.py` read-only for previews. -- **Dependencies**: unchanged tool contract (`claude` + `curl` + system Python - ≥3.10 for full; system Python ≥3.10 for wire-only; `uv` auto-bootstrapped). - `/dev/tty` is used only when present. diff --git a/openspec/changes/archive/2026-06-20-interactive-installer/specs/install-script/spec.md b/openspec/changes/archive/2026-06-20-interactive-installer/specs/install-script/spec.md deleted file mode 100644 index ef5598e..0000000 --- a/openspec/changes/archive/2026-06-20-interactive-installer/specs/install-script/spec.md +++ /dev/null @@ -1,181 +0,0 @@ -## MODIFIED Requirements - -### Requirement: curl-pipe bootstrap entrypoint - -The script SHALL be installable by humans via a single command that fetches it from the repository's default branch and pipes it to bash: - -``` -curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/main/ops/install.sh | bash -``` - -This entrypoint SHALL run full mode, installing the latest plugin version served by the marketplace using the latest installer on the branch. The script SHALL NOT perform any release-tag pinning or self-re-execution. When a readable terminal is available, the script MAY reopen its own standard input from `/dev/tty` once (via `exec < /dev/tty`) to drive the interactive flow; this SHALL NOT involve re-downloading or re-executing the script. - -#### Scenario: curl bootstrap runs unattended in a non-interactive context - -- **WHEN** a user pipes the branch copy of `ops/install.sh` to bash on a machine with no readable `/dev/tty` (or with `YAS_NO_TTY=1`) -- **THEN** the script completes the full flow without requiring interactive input and never blocks on a prompt - -#### Scenario: curl bootstrap reopens the terminal when one is available - -- **WHEN** a user pipes the branch copy of `ops/install.sh` to bash attached to a terminal and `YAS_NO_TTY` is unset -- **THEN** the script reopens standard input from `/dev/tty` once and runs the interactive flow without re-fetching or re-executing itself - -### Requirement: Private interpreter provisioning via uv - -The script SHALL provision a private, plugin-local CPython under `$PLUGIN_ROOT/.python` using `uv`, and wire `statusLine.command` to that interpreter for the fastest statusline startup, without mutating the user's system Python, shell rc, or PATH. `uv` SHALL be the guaranteed provisioning engine: when `uv` is already on PATH the script SHALL use it; when `uv` is absent the script SHALL bootstrap a plugin-local copy of `uv` rather than failing or skipping provisioning. The provisioned CPython version SHALL be resolved as `${YAS_PYTHON:-3.13}` — i.e. the non-interactive default SHALL be the stable 3.13 rather than a prerelease — overridable per Decision (interactive prompt) and by the `YAS_PYTHON` environment variable. - -#### Scenario: uv already present - -- **WHEN** `uv` is on PATH at provisioning time -- **THEN** the script uses that `uv` and does not bootstrap a second copy - -#### Scenario: uv bootstrapped when absent - -- **WHEN** `uv` is not on PATH at provisioning time and the script is not in dry-run -- **THEN** the script installs `uv` from the official installer (`curl -LsSf https://astral.sh/uv/install.sh | sh`) into `$PLUGIN_ROOT/.uv`, and then references the resulting `uv` binary by absolute path - -#### Scenario: uv bootstrap does not mutate the user environment - -- **WHEN** the script bootstraps `uv` -- **THEN** it runs the installer with `INSTALLER_NO_MODIFY_PATH=1` and targets `$PLUGIN_ROOT/.uv`, so the user's shell rc files, PATH, and system locations are not modified - -#### Scenario: Non-interactive default is the stable 3.13 - -- **WHEN** the script provisions a private CPython in a non-interactive context with `YAS_PYTHON` unset and the script is not in dry-run -- **THEN** it installs CPython 3.13 (not 3.15) into `$PLUGIN_ROOT/.python` via `uv python install 3.13` and wires `statusLine.command` at the resolved 3.13 interpreter binary - -#### Scenario: YAS_PYTHON overrides the version in any mode - -- **WHEN** the script provisions a private CPython with `YAS_PYTHON=3.15` set and the script is not in dry-run -- **THEN** it installs CPython 3.15 into `$PLUGIN_ROOT/.python` and wires `statusLine.command` at the resolved 3.15 interpreter binary, in interactive or non-interactive mode alike - -#### Scenario: Fallback to a system interpreter only when uv cannot be obtained - -- **WHEN** `uv` is neither present nor obtainable (the bootstrap genuinely fails) -- **THEN** the script wires a system Python ≥3.10 (avoiding 3.14, which starts slower) instead, and still succeeds - -#### Scenario: Dry-run previews provisioning without downloading - -- **WHEN** the script runs with `--dry-run` and would provision the interpreter -- **THEN** it prints what it would bootstrap/install/wire at the resolved version and downloads nothing (no `uv` installer fetch, no `uv python install`) - -### Requirement: Skill delegates to the script - -The `yas:init` skill SHALL delegate its wiring work to `ops/install.sh` rather than carrying an inline implementation, preserving its observable behaviour of writing a wire-only `statusLine.command`. The plugin SHALL additionally ship a `yas:config` skill that delegates reconfiguration to `ops/install.sh --reconfigure`. - -#### Scenario: Init skill invokes the shipped script - -- **WHEN** the `yas:init` skill runs -- **THEN** it invokes `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh"`, which detects wire-only mode and writes `settings.json` against that plugin root - -#### Scenario: Config skill invokes reconfigure - -- **WHEN** the `yas:config` skill runs -- **THEN** it invokes `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh" --reconfigure`, which re-runs the interactive wizard against that plugin root without performing marketplace or plugin install - -## ADDED Requirements - -### Requirement: TTY detection and interactivity gating - -The script SHALL run interactively by default and SHALL fall back to a fully non-interactive flow when interactivity is unavailable or suppressed. The script SHALL be considered interactive only when `YAS_NO_TTY` is unset or not equal to `1` AND a readable `/dev/tty` exists. When not interactive, the script SHALL issue no prompts and SHALL never block waiting for input. This guarantees CI safety: the non-interactive flow's behaviour SHALL be identical to the prior script except for the provisioned Python version default. - -#### Scenario: YAS_NO_TTY forces non-interactive - -- **WHEN** the script runs with `YAS_NO_TTY=1` -- **THEN** it issues no interactive prompts, writes no `yas.toml`, and completes the selected mode non-interactively - -#### Scenario: No terminal forces non-interactive - -- **WHEN** the script runs with no readable `/dev/tty` available (e.g. CI, a detached process) -- **THEN** it issues no interactive prompts and never blocks on input - -#### Scenario: Terminal present enables interactive flow - -- **WHEN** the script runs with a readable `/dev/tty` and `YAS_NO_TTY` unset -- **THEN** it runs the interactive flow (logo, Python prompt, config wizard) by reading keystrokes from the terminal - -### Requirement: Self-contained embedded logo and selector - -The script SHALL be self-contained and SHALL NOT depend at runtime on any git-untracked repository asset (the logo file, `select.sh`, or `checkbox.sh`), because Claude Code plugin packaging ships only git-tracked files and a `curl | bash` run has no repository checkout. The logo SHALL be embedded as a heredoc inside the script, and a single-select menu function SHALL be embedded inside the script. The embedded selector SHALL read keystrokes from `/dev/tty`, SHALL be compatible with bash 3.2 (no associative arrays, no `${var,,}` lowercasing expansion, no `mapfile`), and SHALL support an optional preview callback invoked on each highlight change with the highlighted value. The embedded selector SHALL retain a CC BY 4.0 attribution comment crediting blurayne's `select.sh`. - -#### Scenario: Logo renders without a logo file present - -- **WHEN** the interactive flow starts and no `yas.dos_rebel.plain.txt` file exists on disk -- **THEN** the script prints the embedded logo from its heredoc - -#### Scenario: Selector works on bash 3.2 - -- **WHEN** the embedded single-select runs under bash 3.2 (e.g. stock macOS bash) -- **THEN** it presents the options, moves the highlight with arrow keys read from `/dev/tty`, and returns the chosen value without using bash-4+ features - -#### Scenario: Selector drives a live preview - -- **WHEN** a single-select is configured with a preview callback and the highlight moves to a new option -- **THEN** the callback is invoked with the newly highlighted value so the caller can render a live sample beneath the menu - -### Requirement: Interactive configuration wizard - -When interactive, the script SHALL run a configuration wizard that prompts for the four user-facing options — glyph mode (`appearance.glyphs.mode`), labels (`layout.labels`), theme (`appearance.theme`), and token soft limit (`tokens.soft_limit`) — and SHALL render live samples for glyph mode and theme. The glyph-mode and theme prompts SHALL render the sample session by invoking the provisioned interpreter on the shipped `statusline_command.py` with the shipped `session-info-example.json` on stdin and the corresponding `YAS_GLYPH_MODE` / `YAS_THEME` set, under a fixed `COLUMNS`. The labels prompt SHALL default to enabled for new users. The soft-limit prompt SHALL offer a fixed preset menu (150000, 200000, 500000, 1000000) only, with no free-form numeric entry, and SHALL close with a pointer to the README / `yas.example.toml` for per-model and advanced configuration. Preview renders SHALL NOT pollute any real session's statusline output (the preview invocation SHALL isolate the renderer's output directory, e.g. by pointing `CLAUDE_CONFIG_DIR` at a throwaway directory). - -#### Scenario: Live preview for glyph mode and theme - -- **WHEN** the user highlights a glyph mode or a theme in the wizard after the interpreter has been provisioned -- **THEN** the script renders the example statusline using that value beneath the menu, at the fixed preview width - -#### Scenario: Labels default on - -- **WHEN** the labels prompt is shown to a user with no existing configuration -- **THEN** the default selection is "on" - -#### Scenario: Soft limit is a preset menu - -- **WHEN** the soft-limit prompt is shown -- **THEN** the user chooses from the fixed presets 150000 / 200000 / 500000 / 1000000 and is then pointed to the README for advanced and per-model configuration, with no free-form numeric entry offered - -#### Scenario: Previews do not pollute real session output - -- **WHEN** the wizard renders preview statuslines -- **THEN** any statusline payload the renderer writes lands in a throwaway directory and not under the user's real `$CLAUDE_CONFIG_DIR/statusline-output/` - -### Requirement: Interactive yas.toml generation with keep and print choices - -When interactive, the wizard SHALL generate `$CLAUDE_CONFIG_DIR/yas.toml` from a commented template populated with the four chosen values, and SHALL NOT attempt to merge or preserve an existing file's comments or keys. When a `yas.toml` already exists the script SHALL offer to keep it as-is (skipping the questions and the write) or to reconfigure. After the questions the script SHALL offer to overwrite the file or to print the generated content to standard output for manual copy/paste. In non-interactive mode the script SHALL NOT write `yas.toml` at all. A write SHALL be performed safely (atomic temp-then-move). - -#### Scenario: Existing yas.toml offers keep vs reconfigure - -- **WHEN** the wizard starts and `$CLAUDE_CONFIG_DIR/yas.toml` already exists -- **THEN** the script offers to keep it as-is (skipping all questions and any write) or to reconfigure it - -#### Scenario: Overwrite vs print after the questions - -- **WHEN** the wizard has collected the four choices -- **THEN** the script offers to overwrite `$CLAUDE_CONFIG_DIR/yas.toml` with the generated content or to print that content to standard output without writing the file - -#### Scenario: Generated toml carries the four chosen values - -- **WHEN** the wizard generates `yas.toml` -- **THEN** the content sets `appearance.glyphs.mode`, `layout.labels`, `appearance.theme`, and `tokens.soft_limit` to the chosen values within the commented template - -#### Scenario: Non-interactive writes no yas.toml - -- **WHEN** the script runs non-interactively (CI, `YAS_NO_TTY=1`, or no `/dev/tty`) -- **THEN** it writes no `yas.toml` and relies on environment variables and built-in defaults - -### Requirement: Reconfigure mode - -The script SHALL support a `--reconfigure` flag that re-runs the interactive logo, Python-version prompt, configuration wizard, `yas.toml` write, and settings re-wire against the already-installed plugin, while skipping marketplace registration and plugin install/update. It SHALL reuse the existing plugin root via `CLAUDE_PLUGIN_ROOT` or the same renderer-discovery used by wiring. The post-install message of the install flow SHALL point users to `/yas:config` for later reconfiguration, including switching to Python 3.15. - -#### Scenario: Reconfigure skips plugin management - -- **WHEN** the script runs with `--reconfigure` -- **THEN** it re-runs the wizard and re-wires settings but does not add the marketplace or install/update the plugin - -#### Scenario: Reconfigure reuses the existing plugin root - -- **WHEN** `--reconfigure` runs with `CLAUDE_PLUGIN_ROOT` set or with the plugin discoverable on disk -- **THEN** it provisions/wires against that existing plugin root without reinstalling the plugin - -#### Scenario: Post-install message points to the config skill - -- **WHEN** the install flow completes -- **THEN** it prints guidance that `/yas:config` re-runs the wizard, noting it as the way to switch to Python 3.15 later diff --git a/openspec/changes/archive/2026-06-20-interactive-installer/tasks.md b/openspec/changes/archive/2026-06-20-interactive-installer/tasks.md deleted file mode 100644 index 7685aa0..0000000 --- a/openspec/changes/archive/2026-06-20-interactive-installer/tasks.md +++ /dev/null @@ -1,72 +0,0 @@ -## 1. Arg parsing, mode selection, and TTY gating (`ops/install.sh`) - -- [x] 1.1 Add `--reconfigure` to the `for arg in "$@"` case loop (around lines 34–46) with a `RECONFIGURE_FLAG=1`, alongside the existing `WIRE_ONLY_FLAG` / `FULL_FLAG` / `UNINSTALL_FLAG` / `DRY_RUN` / `--main` handling. -- [x] 1.2 In the mode-selection block (lines 49–54), add a `reconfigure` mode: `elif [ "$RECONFIGURE_FLAG" = "1" ]; then MODE="reconfigure"` ahead of the `CLAUDE_PLUGIN_ROOT` auto-detect branches. -- [x] 1.3 Add an `is_interactive()` predicate near the top (after `CLAUDE_CONFIG_DIR` is set, line 56): returns true only when `[ "${YAS_NO_TTY:-}" != "1" ]` AND `[ -r /dev/tty ]`. Set an `INTERACTIVE` flag once from it. The predicate MUST be safe under `set -u`. -- [x] 1.4 In the interactive branches only, run `exec < /dev/tty` exactly once before any prompt, so `curl | bash` (where fd 0 is the consumed pipe) reattaches to the terminal. Do NOT re-download or re-exec. - -## 2. Embedded logo and bash-3.2-safe single-select (`ops/install.sh`) - -- [x] 2.1 Add `print_logo()` that prints the 8-line, 42-col plain logo from `yas.dos_rebel.plain.txt` as a single-quoted heredoc (`<<'EOF'`). The heredoc is the source of truth — do NOT read the untracked file at runtime. Copy the exact glyph bytes from the dev file. -- [x] 2.2 Embed a minimal single-select function derived from blurayne's `select.sh`. Requirements: reads keystrokes from `/dev/tty` (arrow up/down + enter), returns the chosen index and value via a global (mirror the upstream `UI_WIDGET_RC` convention), is bash-3.2-safe (no associative arrays, no `${var,,}`, no `mapfile`), and accepts an **optional preview-callback name** invoked with the highlighted value on each highlight change. -- [x] 2.3 Preserve a verbatim **CC BY 4.0 attribution comment** crediting blurayne immediately above the embedded selector. Do NOT embed `checkbox.sh` (multi-select is a non-goal). -- [x] 2.4 Add a `prompt_yes_no(question, default)` helper (bash-3.2-safe, reads `/dev/tty`) for the Python and labels prompts; `default` selects the highlighted/empty-enter answer. - -## 3. Python version policy (`provision_python` reads `VER`) - -- [x] 3.1 Parameterise `provision_python` (lines 208–259) on a version: resolve `local VER="${YAS_PYTHON:-3.13}"` at function entry (or accept it as `$2` set by the caller). Replace EVERY hardcoded `3.15` token: the two dry-run `printf` lines (~239, ~241), `uv python install 3.15` (~247), `uv python find 3.15` (~251), and the fallback glob `-name python3.15 -path '*/cpython-3.15*/bin/*'` (~255) → `python$VER` / `cpython-$VER*`. -- [x] 3.2 Update the `do_wire` printf at line 423 (`(private uv-managed 3.15)`) to print the resolved `$VER` instead of a literal 3.15. -- [x] 3.3 Update the function header comment (lines 194–207) and the file-top comment about 3.15 provisioning to describe the `${YAS_PYTHON:-3.13}` default and the 3.15-as-opt-in policy. -- [x] 3.4 Add an interactive Python prompt (using `prompt_yes_no`, Task 2.4) "Use Python 3.15 (faster, prerelease)?" that sets `VER=3.15` on yes / keeps `3.13` on no; `YAS_PYTHON=3.15` (any mode) forces 3.15 and short-circuits the prompt. Thread the chosen `VER` into `provision_python`. - -## 4. Side-effect-free preview rendering (`ops/install.sh`) - -- [x] 4.1 Add a `render_preview(glyph_mode, theme)` helper that invokes `"$PYTHON_BIN" "$PLUGIN_ROOT/claude/statusline_command.py"` with stdin = `$PLUGIN_ROOT/ops/session-info-example.json`, env `YAS_GLYPH_MODE` / `YAS_THEME` set to the highlighted values, and a fixed `COLUMNS` (e.g. 100). Both shipped paths are git-tracked, so they exist under `$PLUGIN_ROOT`. -- [x] 4.2 Isolate side effects: point `CLAUDE_CONFIG_DIR` (hence `CLAUDE_DIR`, the base `app.main` uses for `statusline-output/`, see `claude/yas/app.py` ~lines 88–95) at a throwaway `mktemp -d` for the preview subprocess only, so the payload write lands in scratch and is discarded. Do NOT export the throwaway dir into the surrounding installer environment. -- [x] 4.3 Wire `render_preview` as the preview callback for the glyph-mode (Task 5.1) and theme (Task 5.2) selectors so highlight changes redraw the sample beneath the menu. - -## 5. Config wizard (`ops/install.sh`, interactive only) - -- [x] 5.1 **Glyph mode** prompt: single-select over the 4 values validated by `_parse_glyph_mode` (`claude/yas/config.py`): `nerdfont` (default), `ascii`, `unicode`, `github`, with the live preview callback. Capture the chosen value. -- [x] 5.2 **Theme** prompt: single-select over the 14 keys in `THEMES` (`claude/yas/themes.py`): `claude-dark` (default), `claude-light`, `catppuccin-latte`, `catppuccin-mocha`, `dracula`, `gruvbox-dark`, `gruvbox-light`, `nord`, `one-dark`, `one-light`, `solarized-dark`, `solarized-light`, `tokyo-night`, `palenight`, with the live preview callback. -- [x] 5.3 **Labels** prompt: `prompt_yes_no` defaulting to **on**; maps to `layout.labels`. -- [x] 5.4 **Soft limit** prompt: single-select preset menu `150000 / 200000 / 500000 / 1000000` (no free-form entry); after selection print a one-line pointer to the README / `yas.example.toml` for per-model `[[tokens.model]]` and advanced config. Maps to `tokens.soft_limit`. -- [x] 5.5 Order the wizard per design: in full mode the logo + Python prompt fire BEFORE `ensure_marketplace`/`ensure_plugin`; the four config prompts fire AFTER `provision_python` sets `PYTHON_BIN` (previews need the interpreter + shipped assets). - -## 6. yas.toml generation: build, keep, write/print (`ops/install.sh`) - -- [x] 6.1 Add `build_yas_toml(glyph_mode, labels, theme, soft_limit)` that emits a commented template mirroring `yas.example.toml`'s structure (sections `[layout]` `labels`, `[tokens]` `soft_limit`, `[appearance]` `theme`, `[appearance.glyphs]` `mode`) with the four chosen values interpolated. Do NOT merge an existing file's comments/keys. -- [x] 6.2 **Existing-file check**: before the questions, if `$CLAUDE_CONFIG_DIR/yas.toml` exists, prompt keep-as-is vs reconfigure. Keep skips the questions and any write. -- [x] 6.3 **After the questions**: prompt overwrite-file vs print-to-STDOUT. Overwrite writes atomically (`mktemp` + `mv`, matching the `do_wire` settings pattern at lines 461–464). Print emits `build_yas_toml` output to stdout only. -- [x] 6.4 Validate an overwrite by re-parsing with the provisioned Python's `tomllib` where available (`"$PYTHON_BIN" -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],"rb"))' "$TOML"`); on failure, do not leave a corrupt file in place. -- [x] 6.5 Ensure non-interactive mode writes NO `yas.toml` (guard the whole wizard + write behind `INTERACTIVE`). - -## 7. Reconfigure mode and main() dispatch (`ops/install.sh`) - -- [x] 7.1 In `main()` (lines 582–595) add a `reconfigure` branch: require interactivity (error out if not interactive, since it has no non-interactive behaviour), then `print_logo` → Python prompt → discover `$PLUGIN_ROOT` (reuse `CLAUDE_PLUGIN_ROOT` or the `do_wire` discovery) → run wizard (Tasks 5–6) → re-run `do_wire`. SKIP `ensure_marketplace` / `ensure_plugin`. -- [x] 7.2 In the `full` branch of `main()`, thread the interactive flow per Task 5.5 (logo + Python prompt before marketplace; wizard + yas.toml after provisioning, before/around `do_wire`). -- [x] 7.3 Add a post-install message at the end of the wire/wizard flow pointing users to `/yas:config`, explicitly noting it as the way to switch to Python 3.15 later. - -## 8. `/yas:config` skill - -- [x] 8.1 Create `skills/config/SKILL.md` mirroring `skills/init/SKILL.md`'s frontmatter (`name: config`, `allowed-tools: Bash`, `effort: low`, `model: haiku`) and a description naming reconfiguration of glyph mode / theme / labels / soft-limit / Python version. -- [x] 8.2 Its `` runs `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh" --reconfigure`. Confirm `.claude-plugin/plugin.json` `source: "./"` packs `skills/config/` automatically (no allowlist to update). - -## 9. Docs - -- [x] 9.1 Update `README.md`: document interactive-default behaviour, the `YAS_NO_TTY=1` escape hatch, the `YAS_PYTHON` override and the new stable-3.13 default (note the 3.15 opt-in and ~6–8 ms tradeoff), and the `/yas:config` reconfigure skill. -- [x] 9.2 Update `yas.example.toml` only if a new commented knob is surfaced by the wizard; otherwise leave it (the four wizard knobs already appear there). No new knob surfaced — left unchanged. - -## 10. Tests (`test/test_install_script.py` + docker harness) - -- [x] 10.1 Add a test asserting the non-interactive default provisions/dry-run-previews **3.13** (assert the dry-run output names 3.13, mirroring the existing `test_wire_only_dry_run_previews_uv_bootstrap_when_uv_absent` pattern using `--dry-run`). Tests invoke `subprocess.run(['bash', INSTALL_SH, *args])` with no piped stdin (already non-TTY). -- [x] 10.2 Add a test that `YAS_NO_TTY=1` forces non-interactive: no prompt, no `yas.toml` written, selected mode completes (use `--dry-run` + env). -- [x] 10.3 Add a test that `YAS_PYTHON=3.15` overrides the version in the dry-run preview (output names 3.15). -- [x] 10.4 Add a `--reconfigure` test: with `CLAUDE_PLUGIN_ROOT` set and `YAS_NO_TTY=1`, assert it errors/exits cleanly without marketplace/plugin calls (non-interactive reconfigure has no behaviour) — i.e. assert no plugin management is attempted. -- [x] 10.5 Add an isolated yas.toml-builder test: invoke the `build_yas_toml` logic (e.g. via a small `bash -c` shim sourcing the function, or by exercising the print-to-STDOUT path) and assert the four chosen values appear under the right tables, then parse the output with `tomllib` to confirm validity. Note in a comment that the live interactive TTY path is not CI-testable (no tty). -- [x] 10.6 Extend `ops/install-docker-test/container-test.sh` to cover the end-to-end non-interactive provision/wire with the new 3.13 default (adjust the existing `assert_wired_3_15` S1/S2 assertions to the resolved version, or add a 3.13 assertion path). - -## 11. Validation - -- [x] 11.1 Run the install-script tests via the verifier (`uv run pytest -q test/test_install_script.py`) and `uv run ruff check`; full suite green once before merge. -- [x] 11.2 Run `openspec validate interactive-installer --strict` and confirm the change is apply-ready. diff --git a/openspec/changes/archive/2026-06-28-tool-use-counts-row/.openspec.yaml b/openspec/changes/archive/2026-06-28-tool-use-counts-row/.openspec.yaml deleted file mode 100644 index f9be753..0000000 --- a/openspec/changes/archive/2026-06-28-tool-use-counts-row/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-27 diff --git a/openspec/changes/archive/2026-06-28-tool-use-counts-row/design.md b/openspec/changes/archive/2026-06-28-tool-use-counts-row/design.md deleted file mode 100644 index 4404a18..0000000 --- a/openspec/changes/archive/2026-06-28-tool-use-counts-row/design.md +++ /dev/null @@ -1,210 +0,0 @@ -## Context - -The wide layout (`build_wide` in `claude/yas/layout.py`) already reads the full -main transcript once per render via `view.transcript_usage` -(`TranscriptUsage.from_transcript`, `claude/yas/info/transcript.py`) and parses -every `agent-*.jsonl` subagent transcript via `view.subagents` -(`RunningSubagents.from_session` → `parse_transcript`, -`claude/yas/info/subagents.py`). Both already iterate the `message.content` -arrays and inspect `tool_use` blocks (subagents.py reads them for the activity -snippet). Counting `tool_use` blocks per tool name is therefore additive work on -bytes already in hand — no new file reads, no cache, no offset/incremental logic. - -`SessionView` (`claude/yas/info/__init__.py`) is the lazy gather seam: every -derived field is a `@cached_property`, and `info` never imports `renderer`/ -`layout`. It already exposes `transcript_usage`, `subagents`, and `clear_epoch` -(`read_clear_epoch`, `claude/yas/info/clear.py`, which returns the epoch of the -most-recent `/clear` marker or `None`). - -The wide session-totals band is built in `build_wide` after the top/path row: a -labelled `separator_dim` (the "seam") then the `tokens_cost` content rows, then -conditional plugins / tasks / subagent / workflow / openspec rows. Conditional -rows follow a fixed pattern: append to a local `rows` list, thread `pending_ups` -through `sep_kind(...)`, and re-thread elbows when a row drops out. The new tool -row slots in immediately after the `tokens_cost` rows and before the plugins row. - -Section labels are superscript captions overlaid on a separator's fill columns by -`_overlay_labels` (`claude/yas/render/borders.py`), which calls `superscript()` -(`claude/yas/render/text.py`). `superscript('tools main/sub')` yields exactly -`ᵗᵒᵒˡˢ ᵐᵃⁱⁿᐟˢᵘᵇ` (the `/`→`ᐟ` mapping is in `_SUPERSCRIPT`). Labels are gated on -`cfg.labels`, consistent with every other section's caption. - -## Goals / Non-Goals - -**Goals:** -- Show per-tool `main/sub` `tool_use` counts as a standalone full-width wide-only - row under the tokens/cost row, greedy-filled to the available width. -- Correctly count the **final** streamed write per `message.id` (last-wins), since - partial writes can contain fewer `tool_use` blocks than the final write. -- Window counts to "since last `/clear`"; whole-session when `clear_epoch` is None. -- Reuse the existing scans (`transcript_usage`'s file, the subagent cohort) — no - new I/O, no cache. - -**Non-Goals:** -- No timing/duration stats (min/max/avg/current) — counts only. Explicitly dropped. -- No per-subagent breakdown — `sub` is a single summed column across all subagents. -- No narrow/medium row — wide tier only. -- No config knob to toggle the row beyond the existing `cfg.labels` (which only - controls the caption, as for every other section). The row itself is always - present in wide when counts are non-zero. - -## Decisions - -### 1. New aggregator module `claude/yas/info/toolcounts.py` - -A new module mirroring the shape of `transcript.py`/`subagents.py`: a small -`ToolCounts` dataclass plus parsing classmethods. `info` stays below `renderer`/ -`layout` in the DAG. - -```python -class ToolCounts: - # tool name (MCP-normalized) -> (main_count, sub_count) - counts: dict[str, tuple[int, int]] - # convenience: number of distinct tool types (for +k overflow math) -``` - -API: -- `count_transcript(path: str, clear_epoch: float | None) -> dict[str, int]` — - parse one transcript file, return `{tool_name: count}` for `tool_use` blocks at - or after `clear_epoch`, deduped by `message.id` keeping the **last** occurrence, - meta-excluded, MCP-normalized. Used for both the main file and each subagent - file. Never raises (mirrors the `except OSError` / `try/except json` guards in - the sibling parsers). -- `ToolCounts.gather(main_path, subagents, clear_epoch) -> ToolCounts` — call - `count_transcript` on the main transcript (→ main column) and on each - `RunningSubagent`'s transcript (→ summed into the sub column), merging into the - `counts` dict. Subagents whose entire activity predates `clear_epoch` contribute - nothing naturally (their tool_use messages are all filtered out by timestamp). - -Reuse the ISO→epoch helper pattern: import `_parse_iso_to_epoch` from -`yas.info.subagents` (it already exists module-level there) rather than -re-implementing it, so the timestamp parse semantics match the rest of `info`. - -`RunningSubagent` does not currently store its transcript path — it stores -`agent_id` (the filename stem) and parsed fields, not the `Path`. **Decision:** add -a `jsonl_path: str = ''` field to `RunningSubagent.__slots__`/`__init__` and set it -in `RunningSubagents.from_session` (the `jsonl` Path is already in scope there at -construction). This is the minimal seam to let `ToolCounts.gather` re-open each -subagent transcript. (Alternative — re-derive the path from `agent_id` + project -slug inside `toolcounts.py` — was rejected: it duplicates the slug logic and the -`from_session` directory walk.) - -### 2. Dedup keeps the LAST occurrence per `message.id` (differs from siblings) - -`tool_use` blocks carry no stable id of their own. Streaming writes the same -`message.id` several times: early partials (`stop_reason: null`) may contain -**fewer** `tool_use` blocks than the final write. To count the true number of tool -calls, we must count the content of the **last** write per `message.id`. - -This is the opposite of `transcript.py` and `subagents.py`, which keep the -**first** occurrence per id (`if not mid or mid in seen: continue`). That is -correct for *token* accounting (usage is stable across the streamed writes and -first-wins avoids double-counting) but wrong for *tool-block counting* (first-wins -would undercount when the final write added blocks). Implementation: accumulate -`per_id: dict[str, list[str]]` mapping `message.id → list of tool names from the -most recent line seen for that id` (overwrite on each line for that id), then sum -the lists across ids after the scan. Lines with no `message.id` are skipped (same -as siblings). **This first-vs-last divergence is called out here explicitly so a -future reader does not "fix" it to match the sibling parsers.** - -### 3. Window = since last `/clear` via per-message timestamp - -Each transcript line carries a top-level `timestamp` (ISO-8601). Count a -`tool_use`-bearing line only when its `_parse_iso_to_epoch(timestamp) >= -clear_epoch`. When `clear_epoch is None`, count the whole transcript (no filter). -A subagent whose every line predates `clear_epoch` contributes zero — no separate -"drop the subagent" step is needed; the per-line timestamp filter subsumes it. - -### 4. Eligibility: exclude a small meta set, KEEP `Task` - -`META_EXCLUDE_TOOLS = frozenset({'TodoWrite', 'ExitPlanMode', 'AskUserQuestion'})` -in `constants.py`. These are todo/UI-plumbing tools, not "work". `Task` is **kept**: -it is the subagent-spawn tool (already mapped to `subagent_type` in -`renderer.TOOL_ARG_KEY`) and represents a delegation — a meaningful main-column -entry. The `Task` double-count is **intended**: a `Task` spawn counts once in the -main column, and that subagent's own Read/Bash/etc. count in the sub column. They -are different columns and additive, telling the delegations-vs-delegated-work -story. (StructuredOutput is a workflow-internal completion marker; it is not in the -exclude set but will simply appear as another tool if present — no special-casing.) - -### 5. MCP name normalization to last segment - -MCP tool names arrive as `mcp__server__tool`. Normalize to the last `__`-split -segment (`name.split('__')[-1]`) before counting/keying so the row stays readable. -Non-MCP names pass through unchanged. Done inside `count_transcript` so the -`ToolCounts.counts` keys are already display-ready. - -### 6. Renderer helper `Renderer.tool_counts_row(counts, width, *, fill=1.0)` - -A new section helper in `renderer.py`. Full-width content (no internal `│` -divider), so it returns just a single `str` line — **no `div_offset`**, no -`ups`/`downs` threading for the row itself. - -- **Selection / ordering:** sort `counts.items()` by combined `(main + sub)` - descending, ties broken alphabetically by name (stable frame-to-frame). -- **Greedy fill:** emit `Name m/s` entries separated by a fixed gap until the next - entry would exceed the content width (`width - 4`), measured with - `_visible_width` (never `len()`). Track how many tool **types** were emitted vs - total; the remainder is `k`. -- **Overflow marker:** when `k > 0`, append `+k` (k = count of additional tool - *types* not shown, NOT the sum of their calls). If `+k` itself would overflow, - drop the last fitted entry to make room (so the marker is never clipped). -- **Visual:** `main` painted bright (a bright theme colour, e.g. `self.TOK` or - `BOLD`+colour), the `/` painted dim (`self.LABEL`/`self.TOK_DIM`), `sub` painted - SGR-faint via a new `FAINT = '\033[2m'` constant, reusing the active-bright / - quiet-dim idiom already used elsewhere (mon's `apply_dim`, the `LABEL`/`TOK_DIM` - pairs). Always render both sides of the slash (`5/0`, never bare `5`). - -### 7. Placement, label, and zero-state in `build_wide` - -Insert immediately after the `tokens_cost` content rows (the `for lt in -line_tokens` loop) and before the `plugins_line` block. Pattern: - -```python -tc = view.tool_counts -if tc.counts: - tool_line = r.tool_counts_row(tc.counts, width, fill=fill) - tc_labels = [(TOOL_COUNTS_LABEL, 3)] if view.cfg.labels else [] - rows.append(RowSpec(sep_kind('separator_dim'), ups=pending_ups, labels=tc_labels)) - rows.append(RowSpec('content', content=tool_line)) - pending_ups = () -``` - -The leading separator uses `sep_kind(...)` so it correctly becomes the -`separator_seam` if it is the first post-tokens separator (closing the tokens -vseps), and threads `pending_ups` exactly like the plugins/tasks/subagent blocks -do. The label is the superscript-rendered `tools main/sub` (the overlay applies -`superscript()`); store the plain caption string as `TOOL_COUNTS_LABEL = 'tools -main/sub'` in `constants.py` so no raw superscript glyphs live in `layout.py`. - -**Zero-state:** when `tc.counts` is empty (zero counted tool uses since `/clear`), -neither the separator nor the content row is appended — the row disappears, exactly -like `plugins_line` / `task_row` / `openspec_bars` already do. `pending_ups` is -left untouched so the next section (or the bottom border) inherits the tokens -vseps' `┴` elbows. - -## Risks / Trade-offs - -- [Double scan of subagent transcripts] `subagents.py` already parses each - `agent-*.jsonl` for tokens/activity; `ToolCounts.gather` re-opens and re-reads - them for tool counts. → Accepted: the design explicitly chose "rescan every - render, no cache" for simplicity and correctness; subagent transcripts are small - and the cohort is capped. A future optimization could have `parse_transcript` - emit tool counts alongside its existing tuple, but that widens an already-wide - return type and is out of scope. -- [Last-wins memory] Holding `per_id: dict[str, list[str]]` keeps a small list per - message id for the duration of one file scan. → Bounded by message count of a - single transcript; freed after each file. Negligible. -- [Row competes for vertical space] Adds one separator + one content row to the - wide box when any tool ran. → Acceptable; it is conditional and only at wide - width, alongside the other conditional band rows. - -## Open Questions - -- **Main bright colour choice.** Design specifies "bright" for `main` but leaves - the exact theme token to the implementer (`self.TOK` vs `BOLD`+`self.CTX`). The - visual test pins whatever is chosen; no semantic dependency. (Assumption made: - reuse `self.TOK` bright / `FAINT` for sub / `self.LABEL` dim for the slash.) -- **Inter-entry gap width.** Example uses ~3 spaces between entries; the - implementer may pick 2–3. The greedy-fill and `+k` math are independent of the - exact gap. (Assumption: 3 spaces, matching the example row.) diff --git a/openspec/changes/archive/2026-06-28-tool-use-counts-row/proposal.md b/openspec/changes/archive/2026-06-28-tool-use-counts-row/proposal.md deleted file mode 100644 index 5981baf..0000000 --- a/openspec/changes/archive/2026-06-28-tool-use-counts-row/proposal.md +++ /dev/null @@ -1,64 +0,0 @@ -## Why - -The wide statusline shows token/cost totals but no signal about *what kind of -work* drove them — how many times each tool ran, and how that work split between -the main session and its delegated subagents. A per-tool count row turns the -abstract token total into a legible "5 Bash, 8 Reads, 4 delegations" story, and -the main-vs-sub split makes the "delegations vs delegated work" pattern visible -at a glance. The counts are essentially free: the bytes are already read every -render by `transcript_usage` and the subagent cohort scan. - -## What Changes - -- Add a new wide-only statusline row in the session-totals band, directly **under** - the tokens/cost row, showing per-tool `Name main/sub` counts (e.g. - `Bash 5/12 Read 8/40 Edit 3/0 Task 4/0 +1`) where `main` = count in the - main transcript and `sub` = summed count across **all** this session's subagents. -- Add a new gather aggregator `claude/yas/info/toolcounts.py` that counts - `tool_use` blocks by tool name, split main vs sub, deduping by `message.id` - keeping the **last** occurrence per id, filtered to messages at/after the last - `/clear` (`SessionView.clear_epoch`), excluding a small meta set of tools, and - normalizing MCP tool names to their last segment. -- Expose it as a new `tool_counts` `@cached_property` on `SessionView` - (`claude/yas/info/__init__.py`). -- Render the row via a new `Renderer.tool_counts_row(...)` helper that greedy-fills - to the wide width, sorts tools by combined `(main+sub)` total descending (alpha - tie-break), colours `main` bright / `sub` SGR-faint / `/` dim, and emits an - overflow marker `+k` where `k` = number of additional tool **types** not shown. -- Thread the row into `build_wide` as a conditional `RowSpec('content', ...)` - preceded by a `separator_dim` carrying the superscript label `ᵗᵒᵒˡˢ ᵐᵃⁱⁿᐟˢᵘᵇ`. - The row (and its separator) **disappear** entirely when there are zero counted - tool uses since the last `/clear`. -- Add `META_EXCLUDE_TOOLS` (frozenset) and a `FAINT` SGR constant to - `claude/yas/constants.py`. - -Narrow and medium layouts are unaffected. No on-disk cache and no incremental -reading are introduced — the aggregator is a full rescan piggybacked on the scans -that already run each render. - -## Capabilities - -### New Capabilities -- `tool-counts-row`: per-tool main/sub `tool_use` counts as a wide-only session- - totals row — the aggregation semantics (dedup, clear-window, meta-exclude, MCP - normalization, main-vs-sub split), the top-N-by-combined selection with `+k` - type overflow, the bright/faint/dim visual treatment, and the placement/label/ - zero-state rules in `build_wide`. - -### Modified Capabilities -- `statusline-info`: add a render-independent `SessionView.tool_counts` - `@cached_property` exposing per-tool `(main, sub)` counts and the total - tool-type count, constructed from the main transcript, the subagent cohort, and - `clear_epoch` (all already on the view). - -## Impact - -- `claude/yas/info/toolcounts.py` — new aggregator module + `ToolCounts` dataclass. -- `claude/yas/info/__init__.py` — new `tool_counts` `@cached_property` on `SessionView`. -- `claude/yas/renderer.py` — new `tool_counts_row(...)` section helper. -- `claude/yas/layout.py` — `build_wide` appends the conditional row + labelled - separator under the tokens/cost row, re-threading `pending_ups`/`tail_ups`. -- `claude/yas/constants.py` — new `META_EXCLUDE_TOOLS` frozenset, `FAINT` SGR - constant, and the `ᵗᵒᵒˡˢ ᵐᵃⁱⁿᐟˢᵘᵇ` label text constant. -- `test/test_tool_counts.py` (new, parser) and a render/layout test for the row. -- `CONTEXT.md` — glossary entries for the new row and the `main/sub` column meaning. diff --git a/openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/statusline-info/spec.md b/openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/statusline-info/spec.md deleted file mode 100644 index 88a4ebe..0000000 --- a/openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/statusline-info/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -## ADDED Requirements - -### Requirement: Tool-counts gather field - -`SessionView` SHALL expose a `tool_counts` `@cached_property` returning a -`ToolCounts` value that holds, per tool name, the `(main, sub)` `tool_use` counts -and the total number of distinct tool types. It SHALL be constructed from the main -transcript, the subagent cohort, and `clear_epoch` — all fields already available -on the view — and SHALL perform no I/O beyond reopening those same transcript -files. As a `@cached_property`, it SHALL be computed at most once per view and -SHALL NOT be evaluated when a render path never reads it (narrow/medium). The -`info` layer SHALL NOT import `renderer` or `layout` to provide it. - -#### Scenario: Field exposes per-tool main/sub counts - -- **WHEN** a `SessionView` is constructed and `tool_counts` is read -- **THEN** it returns a `ToolCounts` whose per-tool entries each carry a `main` and - a `sub` count derived from the main transcript and the subagent cohort - respectively - -#### Scenario: Field is lazy - -- **WHEN** a narrow or medium render is produced without reading `tool_counts` -- **THEN** the tool-counts aggregation is never computed - -#### Scenario: Field respects the clear window - -- **WHEN** `clear_epoch` is set on the view -- **THEN** `tool_counts` reflects only `tool_use` messages at or after that epoch diff --git a/openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/tool-counts-row/spec.md b/openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/tool-counts-row/spec.md deleted file mode 100644 index 0995550..0000000 --- a/openspec/changes/archive/2026-06-28-tool-use-counts-row/specs/tool-counts-row/spec.md +++ /dev/null @@ -1,194 +0,0 @@ -## ADDED Requirements - -### Requirement: Per-tool tool_use counting with main-vs-sub split - -The system SHALL count `tool_use` blocks per tool name across the session, -splitting each tool's total into a `main` count (occurrences in the main -transcript) and a `sub` count (the sum of occurrences across all of this -session's subagent transcripts). The aggregator SHALL read only bytes already -loaded by the per-render transcript and subagent scans — it SHALL NOT introduce -an on-disk cache, an offset file, or incremental/partial reading. - -#### Scenario: Tool runs in main session only - -- **WHEN** the main transcript contains 3 `Edit` `tool_use` blocks and no - subagent ran `Edit` -- **THEN** `Edit` is reported with `(main=3, sub=0)` - -#### Scenario: Tool runs only inside subagents - -- **WHEN** two subagent transcripts ran `Grep` 6 and 9 times respectively and the - main transcript never ran `Grep` -- **THEN** `Grep` is reported with `(main=0, sub=15)` - -#### Scenario: Both columns are always present - -- **WHEN** any tool has a non-zero count on either side -- **THEN** both the `main` and the `sub` value are reported, even when one side is - zero (e.g. `(main=4, sub=0)`) - -### Requirement: Streaming dedup keeps the last write per message id - -The aggregator SHALL deduplicate by `message.id` keeping the **last** occurrence -per id, counting the `tool_use` blocks of that final write, and SHALL skip lines -with no `message.id`. This is required because `tool_use` blocks carry no stable id -and streaming writes the same `message.id` several times where earlier partial -writes may contain fewer `tool_use` blocks than the final write. - -#### Scenario: Final write supersedes an earlier partial - -- **WHEN** a `message.id` appears first with 1 `tool_use` block and later with 2 - `tool_use` blocks -- **THEN** that message contributes 2 to the relevant tool counts (the last write), - not 1 - -#### Scenario: Repeated final write is not double-counted - -- **WHEN** the same `message.id` with the same 2 `tool_use` blocks is written twice -- **THEN** that message contributes 2, not 4 - -### Requirement: Counts are windowed to the last /clear - -The aggregator SHALL count only `tool_use`-bearing messages whose `timestamp` is -at or after `SessionView.clear_epoch`. When `clear_epoch` is `None`, the aggregator -SHALL count the whole session. A subagent whose every message predates -`clear_epoch` SHALL contribute zero to all counts. - -#### Scenario: Messages before /clear are excluded - -- **WHEN** `clear_epoch` is set and a `tool_use` message's `timestamp` is before it -- **THEN** that message is not counted - -#### Scenario: No clear marker counts the whole session - -- **WHEN** `clear_epoch` is `None` -- **THEN** every `tool_use` message in the transcript is eligible for counting - -### Requirement: Meta tools are excluded, Task is kept - -The aggregator SHALL exclude tools in the meta set -`{TodoWrite, ExitPlanMode, AskUserQuestion}` from all counts. The `Task` tool -SHALL be counted (it represents a subagent delegation). A `Task` spawn counted in -the main column and the spawned subagent's own tool uses counted in the sub column -SHALL both be retained — the resulting double-representation across columns is -intended. - -#### Scenario: TodoWrite is dropped - -- **WHEN** the main transcript runs `TodoWrite` 5 times -- **THEN** `TodoWrite` does not appear in the counts - -#### Scenario: Task is retained in the main column - -- **WHEN** the main transcript spawns 4 subagents via `Task` -- **THEN** `Task` is reported with `main=4` - -#### Scenario: Delegated work counts in the sub column independently - -- **WHEN** a `Task` spawn runs in main and the spawned subagent runs `Read` twice -- **THEN** `Task` shows `main` incremented by 1 AND `Read` shows `sub` incremented - by 2 - -### Requirement: MCP tool names normalize to their last segment - -The aggregator SHALL normalize MCP tool names of the form `mcp__server__tool` to -their last `__`-delimited segment before counting and keying. Non-MCP names SHALL -pass through unchanged. - -#### Scenario: MCP name is shortened - -- **WHEN** a `tool_use` block names the tool `mcp__github__create_issue` -- **THEN** it is counted under the key `create_issue` - -### Requirement: Tool-counts row is rendered wide-only under the tokens row - -The system SHALL render a per-tool counts row only in the wide layout -(`build_wide`), placed in the session-totals band directly under the tokens/cost -row and before the plugins row. Narrow and medium layouts SHALL NOT render the row. -The row SHALL be full-width content with no internal divider, so it contributes no -`┬`/`┴` elbows of its own. - -#### Scenario: Row appears in wide layout - -- **WHEN** the wide layout is built and at least one tool has been counted since - the last `/clear` -- **THEN** a content row of per-tool counts appears immediately after the - tokens/cost rows - -#### Scenario: Narrow and medium omit the row - -- **WHEN** the narrow or medium layout is built -- **THEN** no tool-counts row is rendered - -### Requirement: Row format is Name main/sub with bright/faint/dim treatment - -Each entry SHALL render as the tool NAME, a space, the `main` count, a `/`, and the -`sub` count (e.g. `Bash 5/12`). The `main` count SHALL be painted bright, the `sub` -count SHALL be painted SGR-faint, and the `/` SHALL be painted dim. Both sides of -the slash SHALL always be shown. - -#### Scenario: Entry shows name and both counts - -- **WHEN** `Bash` has `(main=5, sub=12)` -- **THEN** the entry reads `Bash 5/12` with `5` bright, `/` dim, and `12` faint - -#### Scenario: Zero sub still renders both sides - -- **WHEN** `Edit` has `(main=3, sub=0)` -- **THEN** the entry reads `Edit 3/0` - -### Requirement: Top-N-by-combined selection with greedy width fill and +k type overflow - -Entries SHALL be ordered by combined `(main + sub)` total descending, with ties -broken alphabetically by tool name for frame-to-frame stability. The row SHALL -greedy-fill entries to the available wide content width measured via -`_visible_width` (never `len()`). When tool types remain unshown, the row SHALL -append an overflow marker `+k` where `k` is the number of additional tool TYPES not -shown (NOT the summed count of their calls). The `+k` marker SHALL NOT be clipped; -if necessary the last fitted entry is dropped to make room for it. - -#### Scenario: Highest combined totals come first - -- **WHEN** `Read` has combined 48 and `Bash` has combined 17 -- **THEN** `Read` is ordered before `Bash` - -#### Scenario: Alphabetical tie-break - -- **WHEN** `Edit` and `Glob` both have the same combined total -- **THEN** `Edit` is ordered before `Glob` - -#### Scenario: Overflow counts types not calls - -- **WHEN** 9 tool types are counted but only 6 fit in the width and the 3 unshown - types together account for 40 calls -- **THEN** the row ends with `+3`, not `+40` - -### Requirement: Row disappears in the zero state - -When there are zero counted tool uses since the last `/clear`, the system SHALL -omit both the tool-counts content row and its leading separator from the wide -layout, leaving the surrounding elbow threading (`pending_ups`) intact, exactly as -the existing conditional plugins/task/openspec rows behave. - -#### Scenario: No tools counted yields no row - -- **WHEN** the wide layout is built and no tool has been counted since the last - `/clear` -- **THEN** neither the tool-counts row nor its separator is present in the layout - -### Requirement: Row carries the superscript tools main/sub label - -The separator above the tool-counts row SHALL carry the superscript caption `ᵗᵒᵒˡˢ ᵐᵃⁱⁿᐟˢᵘᵇ` -(the superscript rendering of `tools main/sub`) anchored at content start when -section labels are enabled (`cfg.labels`), and SHALL carry no caption when labels -are disabled. - -#### Scenario: Label shown when captions enabled - -- **WHEN** `cfg.labels` is true and the tool-counts row is present -- **THEN** the separator above it shows the superscript `tools main/sub` caption - -#### Scenario: No label when captions disabled - -- **WHEN** `cfg.labels` is false -- **THEN** the separator above the row carries no caption diff --git a/openspec/changes/archive/2026-06-28-tool-use-counts-row/tasks.md b/openspec/changes/archive/2026-06-28-tool-use-counts-row/tasks.md deleted file mode 100644 index 3fe9a46..0000000 --- a/openspec/changes/archive/2026-06-28-tool-use-counts-row/tasks.md +++ /dev/null @@ -1,49 +0,0 @@ -## 1. Constants - -- [x] 1.1 In `claude/yas/constants.py` add `META_EXCLUDE_TOOLS = frozenset({'TodoWrite', 'ExitPlanMode', 'AskUserQuestion'})`. -- [x] 1.2 In `claude/yas/constants.py` add `FAINT = '\033[2m'` (SGR faint/dim) alongside the existing `BOLD`/`ITALIC` codes. -- [x] 1.3 In `claude/yas/constants.py` add `TOOL_COUNTS_LABEL = 'tools main/sub'` (plain ASCII; the separator overlay applies `superscript()` so no raw superscript glyphs live in source). - -## 2. Subagent transcript path seam - -- [x] 2.1 In `claude/yas/info/subagents.py` add a `jsonl_path: str = ''` field to `RunningSubagent.__slots__`, its `__init__` signature, the assignment, `_key()`, and `__repr__` (keep the existing field order/style). -- [x] 2.2 In `RunningSubagents.from_session`, pass `jsonl_path=str(jsonl)` when constructing each `RunningSubagent` (the `jsonl` Path is already in scope). -- [x] 2.3 Update existing subagent tests/fixtures that compare `RunningSubagent` equality or repr to account for the new field (search `test/test_subagent_rows.py`, `test/test_cohort_visibility.py`, `test/test_subagent_metrics.py`). - -## 3. Aggregator module - -- [x] 3.1 Create `claude/yas/info/toolcounts.py` with a `ToolCounts` class (slots or dataclass, matching the `info` module style) holding `counts: dict[str, tuple[int, int]]` (tool name → `(main, sub)`) and a `type_count` / `total_types` accessor for `+k` math. -- [x] 3.2 Implement `count_transcript(path: str, clear_epoch: float | None) -> dict[str, int]`: open the file with `errors='ignore'`; for each line, fast-reject non-`tool_use` lines (`'"tool_use"' not in ln`) before `json.loads`; parse `message.id` and the top-level `timestamp`; skip lines with no `message.id`; when `clear_epoch is not None`, skip lines whose `_parse_iso_to_epoch(timestamp) < clear_epoch`; build `per_id: dict[str, list[str]]` overwriting each id with the tool names of the **current** line (last-write-wins); after the scan, flatten `per_id` values into a `dict[str, int]` count. Apply `META_EXCLUDE_TOOLS` filtering and `name.split('__')[-1]` MCP normalization when collecting tool names. Guard all parsing with `try/except (ValueError, TypeError)` and the file open with `try/except OSError`, returning `{}` on failure (mirror `transcript.py`/`subagents.py`). -- [x] 3.3 Import `_parse_iso_to_epoch` from `yas.info.subagents` (reuse, do not reimplement). -- [x] 3.4 Implement `ToolCounts.gather(main_path: str, subagents: list[RunningSubagent], clear_epoch: float | None) -> ToolCounts`: call `count_transcript(main_path, clear_epoch)` into the main column; for each subagent call `count_transcript(sub.jsonl_path, clear_epoch)` and sum into the sub column; merge both into `counts` so every key has a `(main, sub)` tuple (zero-fill the missing side). -- [x] 3.5 Add a module docstring that explicitly states the last-write-wins dedup and why it differs from `transcript.py`/`subagents.py` (which keep first-wins — correct for tokens, wrong for tool-block counting). - -## 4. SessionView seam - -- [x] 4.1 In `claude/yas/info/__init__.py` import `ToolCounts` from `yas.info.toolcounts` and add a `tool_counts` `@cached_property` to `SessionView` that returns `ToolCounts.gather(self.session.transcript_path, self.subagents.subagents, self.clear_epoch)`. Place it near `transcript_usage`/`session_inout`, following the existing cached_property pattern. Do not import `renderer`/`layout`. - -## 5. Renderer helper - -- [x] 5.1 In `claude/yas/renderer.py` add `tool_counts_row(self, counts: dict[str, tuple[int, int]], width: int, *, fill: float = 1.0) -> str`. -- [x] 5.2 Sort `counts.items()` by `(-(main + sub), name)` so combined total descends with alphabetical tie-break. -- [x] 5.3 Greedy-fill `Name m/s` entries (gap of 3 spaces between entries) into the content width `width - 4`, measuring with `_visible_width` (import from `yas.render.text`); track shown vs total tool types. -- [x] 5.4 Colour each entry: name in a neutral colour, `main` bright (`self.TOK`), `/` dim (`self.LABEL`), `sub` faint (`FAINT` from `yas.constants`); reset with `self.R`. Always emit both sides of the slash. -- [x] 5.5 When unshown types remain, append `+k` (k = unshown TYPE count) styled like the existing `+N` overflow markers (`self.LABEL`); if appending `+k` would exceed the content width, drop the last fitted entry first so the marker is never clipped. -- [x] 5.6 Return a single `str` (no `div_offset`, no internal `│`). - -## 6. Layout wiring - -- [x] 6.1 In `claude/yas/layout.py` `build_wide`, import `TOOL_COUNTS_LABEL` from `yas.constants`. -- [x] 6.2 Immediately after the `tokens_cost` content rows (`for lt in line_tokens: rows.append(...)`) and before the `plugins_line` block, read `tc = view.tool_counts`; when `tc.counts` is non-empty, append `RowSpec(sep_kind('separator_dim'), ups=pending_ups, labels=[(TOOL_COUNTS_LABEL, 3)] if view.cfg.labels else [])` then `RowSpec('content', content=r.tool_counts_row(tc.counts, width, fill=fill))`, then set `pending_ups = ()`. -- [x] 6.3 When `tc.counts` is empty, append nothing and leave `pending_ups` untouched (zero-state; surrounding elbow threading inherits the tokens vseps' `┴`). -- [x] 6.4 Verify the `sep_kind`/`seam_pending` interaction: when the tool row is the first post-tokens section it correctly draws as `separator_seam`, and subsequent sections (plugins/tasks/...) then use normal `separator_dim` (no regression to the existing seam behaviour). - -## 7. Tests - -- [x] 7.1 Create `test/test_tool_counts.py` for the parser, importing `from yas.info.toolcounts import ToolCounts, count_transcript`. Cover: per-tool counting; `message.id` dedup keeping the LAST write (partial-then-fuller-final); repeated identical final write not double-counted; `clear_epoch` filtering (before excluded, None counts all); meta-exclude drops `TodoWrite`/`ExitPlanMode`/`AskUserQuestion` and KEEPS `Task`; MCP `mcp__server__tool` → last-segment normalization; main-vs-sub aggregation summed across multiple subagent transcripts. Use `tmp_path`-written JSONL fixtures with real `timestamp` fields. -- [x] 7.2 Add a render/layout test (e.g. in `test/test_layout_seam.py` or a new `test/test_tool_counts_row.py`) that injects a `SessionView` constructed from a known `SessionInfo` + `Config` (not raw reader data) and asserts: the row appears in `build_wide` and is absent in narrow/medium; greedy-fill respects width with `+k` showing the TYPE count (not call sum); bright/faint/dim treatment present (assert the `FAINT` code appears on the sub value); zero-state omits both the row and its separator. Width assertions go through `_visible_width`. -- [x] 7.3 Run `make test` via the verifier and confirm pass count = baseline + new tests, and `make demo/img` + the yas-demo-text diff shows the new row only at wide width with straight elbows. - -## 8. Docs - -- [x] 8.1 In `CONTEXT.md` add glossary entries for the new tool-counts row and the `main/sub` column meaning (main = main-session tool_use count since last `/clear`; sub = summed count across all subagents), keeping the displayed-terms glossary in sync per the post-edit checklist. diff --git a/openspec/changes/archive/2026-07-04-branch-name-slash-support/.openspec.yaml b/openspec/changes/archive/2026-07-04-branch-name-slash-support/.openspec.yaml deleted file mode 100644 index d86f152..0000000 --- a/openspec/changes/archive/2026-07-04-branch-name-slash-support/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-07-04 diff --git a/openspec/changes/archive/2026-07-04-branch-name-slash-support/design.md b/openspec/changes/archive/2026-07-04-branch-name-slash-support/design.md deleted file mode 100644 index bf0e5c6..0000000 --- a/openspec/changes/archive/2026-07-04-branch-name-slash-support/design.md +++ /dev/null @@ -1,108 +0,0 @@ -## Context - -`GitInfo._read_head` (`claude/yas/info/git.py:65-99`) reads `.git/HEAD` and derives -the branch label. For a normal checkout the file contains a symbolic ref such as -`ref: refs/heads/feat/123`. The current code (`git.py:78`) computes the label as: - -```python -branch = head.rsplit('/', 1)[-1] -``` - -For `ref: refs/heads/feat/123` this splits on the **last** `/` and keeps only -`123`, discarding the `feat/` prefix. Git branch names legitimately contain `/` -(prefixed branches are the dominant convention), so this truncation is wrong for a -large fraction of real repos. `refs/heads/main` happens to render correctly only -because it has no embedded slash after the prefix. - -The full ref always begins with the literal marker `refs/heads/`. Everything after -that marker is the branch name, slashes included. Stripping the prefix — rather than -splitting on the last separator — reconstructs the true name. - -Downstream, `_read_head` uses the derived `branch` twice: -1. Control-char sanitisation via `_sanitize(branch)` (`git.py:83`) — `.git/HEAD` is - attacker-controllable for a cloned repo, so the label is scrubbed of escapes. -2. Commit lookup: `Path(gitdir) / 'refs' / 'heads' / branch` (`git.py:86`). With - the corrected `branch='feat/123'` this resolves to the nested loose-ref path - `refs/heads/feat/123`, which is exactly where git stores that ref — so the fix - makes the commit lookup *more* correct, not less. Packed-ref repos (where the - loose file is absent) already fall through to `ORIG_HEAD` (`git.py:92-98`) today - and continue to do so, unchanged. - -## Goals / Non-Goals - -**Goals:** -- Display git branch names in full, preserving `/` separators (`feat/123`, `a/b/c`). -- Keep `refs/heads/main` and other slash-free names working exactly as before. -- Preserve a sensible fallback for symbolic refs that do not point under - `refs/heads/` (e.g. an unusual `ref: refs/something/x`). -- Keep the change surgical — a single line of derivation logic plus tests. - -**Non-Goals:** -- Changing the detached-HEAD label (`d:`) — untouched. -- Changing sanitisation, commit lookup, dirty-count, or repo-discovery logic. -- Handling worktree/gitlink `.git` files or packed-refs resolution beyond today's - behaviour. -- Truncating, eliding, or restyling long branch labels in the renderer. Any width - interaction with the header row is handled by the existing layout/truncation - path and validated by the demo gate; no renderer code changes here. - -## Decisions - -### 1. Strip the `refs/heads/` prefix instead of taking the basename - -Replace `branch = head.rsplit('/', 1)[-1]` with logic that, for a `ref:` HEAD, -first extracts the ref target (the token after `ref:`), then removes the -`refs/heads/` prefix: - -- Parse the ref target from `head` (strip the leading `ref:` and surrounding - whitespace). -- If the target starts with `refs/heads/`, the branch is the remainder after that - prefix (`target[len('refs/heads/'):]`) — this preserves every embedded `/`. -- Otherwise (marker absent), fall back to the existing basename behaviour - `target.rsplit('/', 1)[-1]` so unusual refs still yield a non-empty, bounded - label. - -Rationale over alternatives: -- **`rsplit('/', 1)[-1]` (status quo)** — wrong: drops the prefix segment. -- **`split('/', 2)[-1]` on the whole `refs/heads/...`** — works for the common - case but is positional and brittle if the marker is absent; an explicit - `refs/heads/` prefix check is clearer and directly expresses intent. -- **Prefix strip with basename fallback (chosen)** — correct for the common case, - robust for the odd case, minimal blast radius. - -### 2. Keep sanitisation exactly where it is - -`_sanitize(branch)` still runs after derivation (`git.py:83`). The branch name is -now longer/richer but still repo-supplied, so it must stay scrubbed. No change to -the call site or ordering. - -### 3. No renderer or width-model changes - -A slashed branch is simply a longer string flowing into the same header pipeline. -Column math already uses the visible-width model, not `len()`, and long labels are -handled by existing truncation. Because a longer branch label *can* interact with -header width/truncation, the change is validated with the demo visual gate -(`make demo/img`) in addition to `make test`; no code in the renderer is edited. - -## Risks / Trade-offs - -- [A branch name could contain an unusual embedded newline or control sequence] → - Mitigated: `_sanitize` still runs and is unchanged; the derivation only affects - which substring is passed to it. -- [A longer branch label overflows the header row] → Mitigated: existing - layout/truncation handles overflow; verified by the demo gate. No new truncation - logic is introduced (Non-Goal). -- [Symbolic ref not under `refs/heads/`] → Mitigated: basename fallback preserves - today's behaviour for those refs; no worse than before. - -## Migration Plan - -Pure bug-fix; no data, config, or interface migration. Rollback is reverting the -single derivation change in `_read_head`. - -## Open Questions - -- None blocking. Assumption made: symbolic refs outside `refs/heads/` are rare - enough that preserving the legacy basename fallback (rather than showing the full - odd ref) is acceptable. Confirm before apply if full-ref display is preferred for - those cases. diff --git a/openspec/changes/archive/2026-07-04-branch-name-slash-support/proposal.md b/openspec/changes/archive/2026-07-04-branch-name-slash-support/proposal.md deleted file mode 100644 index 0e6bf32..0000000 --- a/openspec/changes/archive/2026-07-04-branch-name-slash-support/proposal.md +++ /dev/null @@ -1,49 +0,0 @@ -## Why - -Git branch names that contain `/` — the near-universal convention for prefixed -branches like `feat/123` or `release/2.0` — are displayed truncated to only their -last path segment. A branch checked out as `feat/123` renders as `123` in the -statusline, hiding the prefix that carries the branch's meaning. The root cause is -that `_read_head` derives the branch label by taking the basename of the ref path -(`head.rsplit('/', 1)[-1]`) instead of stripping the `refs/heads/` prefix. - -## What Changes - -- Fix branch-name derivation in `GitInfo._read_head` (`claude/yas/info/git.py`) to - strip the `refs/heads/` **prefix** from a symbolic ref rather than taking the last - `/`-delimited segment, so `refs/heads/feat/123` yields `feat/123` and - `refs/heads/a/b/c` yields `a/b/c`. -- Preserve the current basename behaviour (`rsplit('/', 1)[-1]`) as a fallback only - when the `refs/heads/` marker is absent (unusual / non-branch refs). -- Leave the detached-HEAD path (`d:`), control-char sanitisation, commit - lookup, and dirty-count logic untouched. -- Add regression tests to `test/test_git_info.py` covering single-slash, multi-slash, - and unchanged no-slash branch names. - -Not breaking: no config keys, layout contracts, or public APIs change. The output -for a slashed branch simply becomes correct (fuller) rather than truncated. - -## Capabilities - -### New Capabilities - -- `git-branch-display`: The statusline SHALL display a git branch name in full, - including any `/` path separators, deriving the label by stripping the - `refs/heads/` prefix from the symbolic ref in `.git/HEAD`. - -### Modified Capabilities - -*(none — there is no existing spec that pins branch-name derivation; this is a new -capability spec for previously-unspecified, incorrect behaviour)* - -## Impact - -- `claude/yas/info/git.py`: `GitInfo._read_head` staticmethod, the `head.startswith('ref:')` - branch (line 78). -- `test/test_git_info.py`: new assertions in / alongside `test_read_head_ref_branch`. -- No change to `constants.py`, the renderer, layout, or the width model. A slashed - branch produces a longer label, which flows through the existing header - width/truncation path unchanged — validated via the demo visual gate. -- Downstream commit lookup at `git.py:85-91` (`Path(gitdir) / 'refs' / 'heads' / branch`) - now receives `feat/123` and resolves the correct nested loose-ref path - `refs/heads/feat/123` — no regression. diff --git a/openspec/changes/archive/2026-07-04-branch-name-slash-support/specs/git-branch-display/spec.md b/openspec/changes/archive/2026-07-04-branch-name-slash-support/specs/git-branch-display/spec.md deleted file mode 100644 index 2565c1c..0000000 --- a/openspec/changes/archive/2026-07-04-branch-name-slash-support/specs/git-branch-display/spec.md +++ /dev/null @@ -1,38 +0,0 @@ -## ADDED Requirements - -### Requirement: Branch name is displayed in full including slashes - -The statusline SHALL derive a git branch label from `.git/HEAD` by stripping the `refs/heads/` prefix from the symbolic ref target, preserving every `/` path separator, and SHALL NOT reduce the label to only its final `/`-delimited segment. When the ref target does not begin with `refs/heads/`, the statusline SHALL fall back to the final `/`-delimited segment of the target. The derived label SHALL continue to be passed through control-character sanitisation before display, and the detached-HEAD label (`d:`) SHALL be unaffected. - -#### Scenario: Single-slash prefixed branch is preserved - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/feat/123` -- **THEN** `_read_head` returns branch `feat/123` -- **AND** the label is not truncated to `123` - -#### Scenario: Multi-slash branch is preserved in full - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/a/b/c` -- **THEN** `_read_head` returns branch `a/b/c` - -#### Scenario: Slash-free branch is unchanged - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/main` -- **THEN** `_read_head` returns branch `main` - -#### Scenario: Commit is still resolved for a slashed branch - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/feat/123` -- **AND** the loose ref file `refs/heads/feat/123` exists holding a commit sha -- **THEN** `_read_head` resolves the commit from `refs/heads/feat/123` -- **AND** returns branch `feat/123` alongside the first 9 chars of the sha - -#### Scenario: Symbolic ref outside refs/heads falls back to basename - -- **WHEN** `.git/HEAD` contains a symbolic ref target that does not start with `refs/heads/` -- **THEN** `_read_head` returns the final `/`-delimited segment of the ref target - -#### Scenario: Detached HEAD is unaffected - -- **WHEN** `.git/HEAD` contains a raw 40-char commit sha (no `ref:` prefix) -- **THEN** `_read_head` returns branch `d:` and commit `''`, exactly as before diff --git a/openspec/changes/archive/2026-07-04-branch-name-slash-support/tasks.md b/openspec/changes/archive/2026-07-04-branch-name-slash-support/tasks.md deleted file mode 100644 index d635020..0000000 --- a/openspec/changes/archive/2026-07-04-branch-name-slash-support/tasks.md +++ /dev/null @@ -1,28 +0,0 @@ - - -## 1. Understand current code - -- [x] 1.1 Read `claude/yas/info/git.py` lines 65–99 (`GitInfo._read_head`), confirming the current derivation `branch = head.rsplit('/', 1)[-1]` at line 78 and the surrounding `head.startswith('ref:')` / `elif head:` (detached) branches, `_sanitize(branch)` at line 83, and the commit lookup `Path(gitdir) / 'refs' / 'heads' / branch` at line 86. -- [x] 1.2 Read `test/test_git_info.py`, noting the `_make_git_dir` helper (writes `ref: refs/heads/{branch}\n` to HEAD and a loose ref file at `refs/heads/{branch}`) and `test_read_head_ref_branch` as the template for new assertions. -- [x] 1.3 Record the baseline `make test` pass count (via `verifier`). - -## 2. Fix branch derivation in `_read_head` - -- [x] 2.1 In `claude/yas/info/git.py`, inside the `if head.startswith('ref:'):` branch (line 77–78), replace `branch = head.rsplit('/', 1)[-1]` with logic that: (a) extracts the ref target by stripping the leading `ref:` marker and surrounding whitespace (e.g. `target = head[4:].strip()`); (b) if `target.startswith('refs/heads/')`, set `branch = target[len('refs/heads/'):]` to preserve embedded `/`; (c) otherwise fall back to `branch = target.rsplit('/', 1)[-1]`. -- [x] 2.2 Leave the `elif head:` detached-HEAD branch (`branch = f'd:{head[:7]}'`, line 79–80), the `_sanitize(branch)` call (line 83), the commit lookup block (lines 85–91), and the `ORIG_HEAD` fallback (lines 92–98) completely unchanged. -- [x] 2.3 Confirm no other call sites derive the branch label — `_read_head` is the sole producer (`grep -rn "rsplit\|refs/heads" claude/`). - -## 3. Tests - -- [x] 3.1 In `test/test_git_info.py`, add `test_read_head_slashed_branch`: build a git dir with `_make_git_dir(tmp_path, branch='feat/123', commit='abcdef1234567890')` (the helper's `refs/heads/feat/123` mkdir handles the nested dir via `parents=True`), call `git.GitInfo._read_head(str(gitdir))`, assert `branch == 'feat/123'` and `commit == 'abcdef123'`. -- [x] 3.2 Add `test_read_head_multi_slash_branch`: same pattern with `branch='a/b/c'`, assert `branch == 'a/b/c'`. -- [x] 3.3 Confirm the existing `test_read_head_ref_branch` (branch `main`) still passes unchanged — this is the no-regression / slash-free case. Add an inline comment noting it guards the slash-free path if not already clear. -- [x] 3.4 (Optional, if `_make_git_dir` needs it) verify the loose-ref write for a slashed branch lands at `refs/heads/feat/123` so the commit assertion in 3.1 exercises the corrected `Path(gitdir) / 'refs' / 'heads' / branch` lookup. - -## 4. Verify (via `verifier`) - -- [x] 4.1 Run `make test` — green, pass count ≥ baseline + 2 new tests. -- [x] 4.2 Run the demo visual gate (`make demo/img` then `.claude/skills/yas-demo-text/scripts/demo-text.sh`); eyeball the header row with a slashed branch to confirm a longer branch label flows through header width/truncation without breaking column math. Re-baseline any `demo/text/*.txt` that legitimately changed. diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/.openspec.yaml b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/.openspec.yaml deleted file mode 100644 index 2bc06e0..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-07-26 diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/design.md b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/design.md deleted file mode 100644 index faa53dd..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/design.md +++ /dev/null @@ -1,257 +0,0 @@ -## Context - -`claude/yas/info/toolcounts.py` already walks every transcript once per render: -`ToolCounts.gather(main_path, subagents, clear_epoch)` calls `count_transcript` -on the main session `.jsonl` and once per `subagents/agent-*.jsonl`. That walk -today parses only `tool_use` blocks (last-write-wins per `message.id`) and -returns `{tool_name: count}`. It is exposed as `SessionView.tool_counts` -(`claude/yas/info/__init__.py:122-134`), a `@cached_property`, so a wide render -pays for it once and narrow/medium never touch it. - -Three separate parsers currently walk the same bytes each render: -`count_transcript` (toolcounts), `TranscriptUsage.from_transcript` -(`info/transcript.py`), and `parse_transcript` (`info/subagents.py`). YAS -re-execs per statusline tick, so an in-process cache buys nothing across ticks — -this is why the `_TailCacheEntry` byte-offset cache in `subagents.py` is largely -ineffective in practice. - -Transcript shapes, verified against real sessions on this machine (see -`.scratch/explore-sidechain-doublecount.md`): - -- A text `Read`'s `tool_result.content` is a plain string in `cat -n` shape: - `"1\t\n2\t\n…"`. There is no line-count field. -- An image `Read`'s `tool_result.content` is a **list**: - `[{"type":"image","source":{...}}]`. -- `Edit`'s result is the fixed string `"The file … has been updated - successfully…"`; `Write`'s is `"File created successfully at: …"`. Neither - carries a diff or a line count, so the sizes must come from the `tool_use` - input. -- `tool_use` ids are **fully disjoint** between the top-level session `.jsonl` - and `subagents/agent-*.jsonl` (249 subagent ids, 0 overlap, across 8 concurrent - subagents). Summing main + all subagents does not double-count. -- No session on this machine emits `isSidechain: true` records; this setup uses - the async `Agent` tool with one `agent-*.jsonl` per subagent, not the - synchronous `Task` inline-sidechain convention. - -The wide tokens/cost row (`Renderer.tokens_cost`, `renderer.py:1316-1509`) is -three segments and two `│` dividers: `tokens_col │ cost_col │ leader`. Segments 1 -and 2 are content-measured; segment 3 (rate label + sparkline) is the sole -slack-absorbing segment (`leader_w = max(label_w + 1, inner - w_middle - w_end)`) -and already self-drops the sparkline below `bar_w < 10`. `build_wide` gates the -whole row on `tokens_fits = width >= max(tokens_min_w, TOKENS_COST_MIN_WIDTH)` -with `TOKENS_COST_MIN_WIDTH = 85`; below that the row disappears and the context -line degrades to `context_line_compact`. - -The per-subagent row (`Renderer.subagent_row`, `renderer.py:753+`) ends line 1 -with a `· share% tok · model` cluster built by a nested `build_cluster(show_share, -show_tok)` and a shed ladder that tries `(True, True)`, then `(False, True)`, then -the model-only fallback. - -## Goals / Non-Goals - -**Goals:** -- Two numbers, lines read and lines changed, per subagent (self-scoped) and for - the session as a whole (main + every subagent). -- Fuse the counting into the existing `count_transcript` walk — one pass per - file, no cache, no state file, no new I/O. -- Keep the invariant "session total == main thread + sum of subagent rows" true - **by construction**. -- Zero rendering regression between 85 and 103 columns: the tokens/cost row must - render exactly as it does today in that band. - -**Non-Goals:** -- No `NotebookEdit` accounting, no `MultiEdit`, no Bash-side file writes. -- No subtree rollup: a parent subagent does **not** absorb its children's counts; - a `fork` counts only to itself. -- No separate config flag for the session segment — it rides the tokens/cost row. -- No narrow/medium display of either surface. -- No per-file, mtime/size-keyed gather cache shared by the three parsers (see - Decision 9 — recorded as a follow-up, explicitly out of scope here). - -## Decisions - -### 1. Tools in scope: `Read`, `Write`, `Edit` only - -`Read` feeds `lines_read`; `Write` and `Edit` feed `lines_changed`. -`NotebookEdit` is excluded (rare, and its cell model does not map onto a line -count). There is no `Update` tool. MCP-normalised names (`name.split('__')[-1]`, -as `count_transcript` already does) are matched, so an MCP-wrapped `Read` counts -the same as the built-in. - -*Alternative rejected:* counting every tool that touches a file (including `Bash` -heredocs / `sed`) — unbounded parsing surface with no reliable size signal. - -### 2. `lines_read` comes from the paired `tool_result`, gated on a `1\t` sniff - -For each `Read` `tool_use`, the count is the number of `\n` in the **paired** -`tool_result.content`, and only when that content is a `str` that -`startswith('1\t')`. This is the `cat -n` shape produced by a text read. - -- Image/document reads have list-valued content and fail the sniff, so they - contribute 0 rather than a garbage count. -- `offset`/`limit` from the `tool_use` input are **not** usable: `limit` is - usually absent (the tool defaults to 2000), so `limit`-based accounting would - be wildly wrong. Verified in real transcripts. - -Pairing is by `tool_use_id`: the walk remembers, per `tool_use` id, whether that -id was a `Read`, and attributes the newline count of the matching `tool_result` -to it. A `tool_result` whose id was never seen as a `Read` is ignored. - -### 3. `lines_changed` comes from the `tool_use` input - -- `Edit` → `max(newlines(old_string), newlines(new_string))` — the size of the - hunk touched, which is the honest "how big was this change" for both - insertions and deletions. -- `Write` → `newlines(content)` — the whole file written. - -`replace_all: true` is counted **once regardless of the number of occurrences -replaced**, so a bulk rename undercounts. This is accepted and documented in -`CONTEXT.md`; the alternative (re-reading the target file to count occurrences) -would add real I/O per edit for a cosmetic gain. - -Newline counting is `s.count('\n')` on the raw string. A file with no trailing -newline undercounts its final line by one — accepted, sub-1% error at any -realistic size. - -### 4. Sidechain skip on the main transcript, full count on subagent files - -Records with `isSidechain: true` in the **main** transcript are skipped; -`agent-*.jsonl` files are counted in **full** with no sidechain filter. This -asymmetry is load-bearing: an earlier benchmark draft applied the sidechain skip -to subagent files too and silently zeroed the entire subagent contribution, -because under some dispatch conventions every subagent record carries -`isSidechain: true`. - -Together with the verified id-disjointness (Context), this makes -`session_total == main + Σ(subagents)` true by construction — the same record is -never counted on both sides. - -`count_transcript` therefore takes a new `skip_sidechain: bool` parameter, -`True` for the main transcript and `False` for every subagent transcript. - -### 5. Counters reset on `/clear` for free - -`count_transcript` already skips records whose `timestamp` predates -`clear_epoch`. Line counts accumulate inside the same windowed loop, so they -inherit the reset with no extra code. - -### 6. V2 byte-level pre-filters, benchmarked - -The file is opened in binary and filtered before any `json.loads`: - -- Skip any line containing neither `b'"tool_use"'` nor `b'"tool_result"'`. -- For a line that carries `tool_result` but no `tool_use`, require the literal - `1\t` marker (`b'1\\t'` as it appears JSON-escaped) before decoding — this - rejects the vast majority of large `tool_result` payloads (Bash output, Edit - confirmations) without paying JSON decode. -- Byte-level `b'"isSidechain":true'` test on the main transcript, before decode. - -Benchmarked (harness at `.scratch/bench-lines/bench.py`, raw results in -`run-warm.json` / `run-cold.json`) as **result-identical** to a naive full JSON -walk, and costing **+2.9 ms median** on the largest real YAS session (4.2 MB -across 11 files) — +6% of the ~48 ms in-process render, +2% of the ~130 ms -end-to-end tick. Worst case found (8.8 MB) was +9.1 ms. - -*Alternative rejected:* a second dedicated walk over the same files (~2× the -measured cost for no structural benefit). - -### 7. Return-shape change: `TranscriptToolStats` and `ToolCounts.per_agent` - -`count_transcript` returns a small `TranscriptToolStats` value — -`counts: dict[str, int]`, `lines_read: int`, `lines_changed: int` — instead of a -bare dict. This is an internal breaking change with two call sites -(`ToolCounts.gather` and `test/test_tool_counts.py`). - -`ToolCounts` gains `lines_read` / `lines_changed` (session totals: main plus every -subagent) and `per_agent: dict[str, tuple[int, int]]` keyed by transcript path — -the same key `gather` already iterates — so `build_wide` can look up a subagent's -own numbers by `sub.jsonl_path` without the renderer reaching into the view. - -*Alternative rejected:* keying `per_agent` by `agent_id` — `jsonl_path` is what -`gather` already has in hand and is unique by construction. - -### 8. Session segment sheds below 103 columns; `TOKENS_COST_MIN_WIDTH` stays 85 - -`tokens_cost` computes the with-segment `min_width` first and includes the new -segment only when -`box_width >= max(min_width_with_segment, LINES_SEGMENT_MIN_WIDTH)` with -`LINES_SEGMENT_MIN_WIDTH = 103`. Otherwise the segment **and its `│` divider** -are dropped and the method returns exactly today's shape — three segments, a -2-tuple of divider columns, and the without-segment `min_width`. - -This mirrors the shed pattern already used by `elapsed_section` -(`layout.py:665-677`) and `cache_section` (`layout.py:651`). Bumping -`TOKENS_COST_MIN_WIDTH` to ~103 instead was explicitly rejected: it would drop -the whole tokens/cost row — and degrade the context line to -`context_line_compact` — for every terminal between 85 and 103 columns. - -Because inclusion is gated on the *with-segment* floor, the row can never be -included at a width where it would overflow, so `tokens_fits` in `build_wide` is -unaffected. - -Structural consequence: `tokens_cost`'s divider-column return becomes a -**variable-length tuple** — `(col1, col2)` when shed, `(col1, col2, col3)` when -present. `build_wide` (`layout.py:602`, `:999-1002`) must index from the end -(`vsep_cols[-2]`, `vsep_cols[-1]`) for the `cost` and `tokens over time` -captions, add a centred `lines read/changed` caption between `vsep_cols[0]` and -`vsep_cols[1]` when `len(vsep_cols) == 3`, and thread all of `vsep_cols` as -`downs`/`ups` (it already passes the tuple wholesale, so the elbow count follows -automatically). - -### 9. Follow-up (out of scope): shared per-file gather cache - -The three parsers (`count_transcript`, `TranscriptUsage.from_transcript`, -`parse_transcript`) each walk every transcript once per render — three passes -over the same bytes. An in-process, `(mtime, size)`-keyed per-file gather cache -shared by all three would collapse that to one. It is **not** part of this -change. Note the ceiling: YAS re-execs per tick, so such a cache only helps -*within* one render (and under the long-lived `mon` process) — it cannot amortise -across ticks. That is also why `subagents.py`'s existing `_TailCacheEntry` is -largely ineffective. - -### 10. Per-subagent field is self-scoped and shed first - -Each subagent row shows only its **own** transcript's numbers. No subtree -rollup onto parents; a `fork` counts to itself. The field joins the fixed-width -`tree_single` stats cluster next to share% / tokens / model, humanised with -`fmt_tok` (so `1.2k`, consistent with the token field), and renders **blank** -(spaces, preserving the fixed cluster width) when both numbers are zero — a `0` -would add noise to the many subagents that neither read nor write. - -It is the **first** field dropped by `build_cluster`'s shed ladder, so any -terminal narrow enough to shed today behaves exactly as it does today. - -*Alternative rejected:* rolling children into parents — it would double-count -against the session total and break the by-construction invariant of Decision 4. - -### 11. Glyphs follow the existing PUA + fallback-table convention - -Two new PUA constants in `constants.py` (`GLYPH_LINES_READ` = nf-md-eye -`U+F0208`, `GLYPH_LINES_CHANGED` = nf-fa-pencil `U+F040`), written as the -escapes `'\U000f0208'` and `'\uf040'` per the repo's PUA rule (never raw glyphs -in source). Glyph mode is a post-render -`str.translate` (`render/text.py:apply_glyphs`), so both constants need entries in -`ASCII_GLYPHS` (`R` and `W`) and `UNICODE_PUA`, plus a `GITHUB_ICON_OVERRIDE` -entry for any unicode target whose `unicodedata.east_asian_width` is `A`. - -## Risks / Trade-offs - -- **[Sidechain skip is not gate-defended]** → No session on this machine emits - `isSidechain: true` records (this setup uses the async `Agent` tool with one - `agent-*.jsonl` per subagent, not the synchronous `Task` inline-sidechain - convention), so **no demo or transcript fixture can cover it end-to-end**. It is - correctness-by-construction, defended only by a synthetic unit-test fixture - written by hand. -- **[+2.9 ms per render]** → Measured, bounded (+9.1 ms worst case on an 8.8 MB - session), and paid only on wide renders that read `tool_counts`. Mitigated by - the V2 byte pre-filters; further mitigation deferred to Decision 9. -- **[`replace_all` undercounts]** → Accepted and documented; the alternative - requires reading the edited file. -- **[21 demo fixtures need re-goldening]** → The tokens/cost row appears in 21 of - ~23 `demo/text/*.txt` fixtures. Re-golden in one commit via `make demo/img` + - the `yas-demo-text` skill, and review the diff for column drift rather than - content change. -- **[Variable-length `vsep_cols`]** → A 2-or-3-tuple return is easy to index - wrongly. Mitigated by indexing from the end for the two pre-existing captions - and asserting divider/elbow agreement in `test_tokens_cost.py` and - `test_layout_seam.py` at widths straddling 103. diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/proposal.md b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/proposal.md deleted file mode 100644 index cb54476..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/proposal.md +++ /dev/null @@ -1,80 +0,0 @@ -## Why - -YAS already tells you how many tokens a session burned and how many times each -tool ran, but not the thing a human actually recognises as *work done*: how much -source was read and how much was written. Two numbers — lines read and lines -changed — turn "Read 40/188" into "we read 9.4k lines and changed 1.2k", and per -subagent they make the difference between a researcher and an editor visible at a -glance. The bytes are already read every render by `count_transcript`, so the -numbers cost one extra field per line, not an extra file walk. - -## What Changes - -- Count **lines read** and **lines changed** per transcript, fused into the - existing `count_transcript` walk in `claude/yas/info/toolcounts.py` (one pass - per file, no second walk, no cache, no state file). - - `lines_read`: newlines in the `tool_result` paired with each `Read` `tool_use`, - counted only when that result content is a string starting with `1\t` (the - `cat -n` shape). Image/document reads (list-valued content) are skipped. - - `lines_changed`: `Edit` → `max(newlines(old_string), newlines(new_string))`; - `Write` → `newlines(content)`. `Read`/`Write`/`Edit` only — **not** - `NotebookEdit`. -- **BREAKING (internal):** `count_transcript` returns a `TranscriptToolStats` - value (`counts`, `lines_read`, `lines_changed`) instead of a bare - `dict[str, int]`. `ToolCounts` grows `lines_read` / `lines_changed` session - totals and a `per_agent` map keyed by transcript path. -- Add a **new second segment** to the wide tokens/cost row - (`Renderer.tokens_cost`), between the tokens column and the cost column, giving - the row order `tokens │ lines │ cost │ leader(rate+sparkline)`. The segment - shows the session total (main transcript **plus every** subagent transcript) as - one read/changed pair, humanised (`1.2k`) like the token fields. -- The new segment and its `│` divider **shed** below a new - `LINES_SEGMENT_MIN_WIDTH` (103) so `TOKENS_COST_MIN_WIDTH` stays at **85** and - the row renders byte-identically to today between 85 and 103 columns. The - sparkline leader absorbs the width cost above 103. -- Add a self-scoped **lines field** to the per-subagent stats cluster - (`Renderer.subagent_row`), alongside share% / tokens / model. It is the *first* - field shed under width pressure and renders blank (not `0`) when the subagent - read and changed nothing. -- Add two Nerd Font PUA glyph constants (read/changed) with `ascii`, `unicode` - and `github` fallbacks, plus the `lines read/changed` section caption. - -## Capabilities - -### New Capabilities -- `line-counts`: the lines-read / lines-changed measurement — which tools are in - scope, the `cat -n` sniff test, the `Edit`/`Write` counting rules, the - sidechain/subagent double-count rules, the `/clear` window — and its two display - surfaces: the session-total segment in the tokens/cost row (with its shed rule) - and the self-scoped per-subagent field (with its shed order). - -### Modified Capabilities -- `statusline-info`: the `tool_counts` gather field additionally exposes session - lines-read / lines-changed totals and a per-transcript breakdown, still computed - from the same files in the same single pass. -- `compact-tokens-row`: the wide tokens/cost row gains a fourth segment and a - third `│` divider above `LINES_SEGMENT_MIN_WIDTH`, and keeps exactly today's - three-segment two-divider form below it. -- `subagent-row-layout`: the line-1 stats cluster gains a lines field, and the - shed order becomes lines → share% → tok (model and duration still always kept). - -## Impact - -- `claude/yas/info/toolcounts.py` — fused counting, `TranscriptToolStats`, - `ToolCounts.lines_read` / `.lines_changed` / `.per_agent`, V2 byte-level - pre-filters. -- `claude/yas/info/__init__.py` — `SessionView.tool_counts` docstring/contract. -- `claude/yas/renderer.py` — `tokens_cost` (new segment, third divider column), - `subagent_row` (new cluster field + shed ladder). -- `claude/yas/layout.py` — `build_wide` handles a variable-length `vsep_cols` - (2- or 3-tuple), a new centred `lines read/changed` caption, and passes each - subagent's own counts into `subagent_row`. -- `claude/yas/constants.py` — `LINES_SEGMENT_MIN_WIDTH`, two PUA glyph constants, - their `ASCII_GLYPHS` / `UNICODE_PUA` / `GITHUB_ICON_OVERRIDE` entries, and the - `LINES_LABEL` caption. -- `test/test_tokens_cost.py`, `test/test_layout_seam.py`, - `test/test_tool_counts.py`, `test/test_subagent_rows.py` — extended; new - counting-semantics tests. -- `demo/text/*.txt` — 21 of ~23 fixtures contain the tokens/cost row and need - re-goldening. -- `CONTEXT.md` — glossary entries for Lines Read and Lines Changed. diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/compact-tokens-row/spec.md b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/compact-tokens-row/spec.md deleted file mode 100644 index 5095bc7..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/compact-tokens-row/spec.md +++ /dev/null @@ -1,44 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Single-line tokens/cost/rate row - -The wide layout's tokens/cost row (`Renderer.tokens_cost`) SHALL render as exactly -**one** content line, not two. The line SHALL retain its columns in order — -tokens, then the optional lines column, then cost, then rate-and-sparkline — -separated by the standard gradient `│` vertical dividers. The lines column and its -divider SHALL be present only at or above `LINES_SEGMENT_MIN_WIDTH` and the row's -measured with-lines minimum width; below that the row SHALL be the original -three-column, two-divider form. `tokens_cost` SHALL return a single-element list of -content lines together with the divider columns — three columns when the lines -column is present, two when it is shed — so the builder can thread one matching -`┬`/`┴` elbow per rendered `│` onto the separators above and below the row. The -previous 60s sparkline tick marker (`spark_mark_col`) SHALL be removed, since a -midpoint marker has no referent once the whole bar spans 60s. - -#### Scenario: Row occupies one content line - -- **WHEN** the wide layout renders the tokens/cost row -- **THEN** `tokens_cost` returns exactly one content line -- **AND** every `│` in that line has a matching `┬` on the separator above and `┴` - on the separator below at the same visual column - -#### Scenario: Three columns preserved in order - -- **WHEN** the single-line row is rendered with day stats enabled below - `LINES_SEGMENT_MIN_WIDTH` -- **THEN** the content reads tokens, then cost, then rate-and-sparkline, left to - right, divided by the gradient `│` separators - -#### Scenario: Four columns at wide widths - -- **WHEN** the single-line row is rendered at or above `LINES_SEGMENT_MIN_WIDTH` - and its with-lines minimum width -- **THEN** the content reads tokens, then lines, then cost, then - rate-and-sparkline, and `tokens_cost` returns three divider columns - -#### Scenario: Row still renders across the whole 85-plus band - -- **WHEN** the wide layout renders at any box width from `TOKENS_COST_MIN_WIDTH` - (85) upward -- **THEN** the tokens/cost row is present, and the context line is NOT degraded to - `context_line_compact` diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/line-counts/spec.md b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/line-counts/spec.md deleted file mode 100644 index c639ace..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/line-counts/spec.md +++ /dev/null @@ -1,289 +0,0 @@ -## ADDED Requirements - -### Requirement: Lines-read and lines-changed measurement scope - -The system SHALL derive two per-transcript numbers, `lines_read` and -`lines_changed`, from `Read`, `Write`, and `Edit` tool activity only. `Read` -SHALL feed `lines_read`; `Write` and `Edit` SHALL feed `lines_changed`. -`NotebookEdit` SHALL NOT contribute to either number, and no other tool -(including `Bash`) SHALL contribute. Tool names SHALL be matched after the -existing MCP normalisation (last `__`-delimited segment), so an MCP-wrapped -`Read` counts as `Read`. - -#### Scenario: Read feeds lines read - -- **WHEN** a transcript contains a `Read` whose result is a 120-line text file -- **THEN** `lines_read` increases by 120 and `lines_changed` is unchanged - -#### Scenario: Write and Edit feed lines changed - -- **WHEN** a transcript contains a `Write` and an `Edit` -- **THEN** both contribute to `lines_changed` and neither contributes to - `lines_read` - -#### Scenario: NotebookEdit is ignored - -- **WHEN** a transcript contains a `NotebookEdit` tool use -- **THEN** neither `lines_read` nor `lines_changed` changes - -### Requirement: lines_read counts newlines in the paired cat -n tool_result - -For each `Read` `tool_use`, the system SHALL add the number of newline characters -in the content of the `tool_result` paired with that `tool_use` by `tool_use_id`, -and SHALL do so ONLY when that content is a string beginning with `1\t` (the -`cat -n` shape of a text read). Content that is not a string, or that is a string -not beginning with `1\t`, SHALL contribute zero. The system SHALL NOT derive -`lines_read` from the `offset` or `limit` fields of the `Read` `tool_use` input, -because `limit` is usually absent (the tool defaults it) and would produce a -wildly wrong count. A `tool_result` whose `tool_use_id` was never seen as a `Read` -SHALL be ignored. - -#### Scenario: Text read counts its numbered lines - -- **WHEN** a `Read` tool_result content is the string `"1\tfoo\n2\tbar\n3\tbaz\n"` -- **THEN** `lines_read` increases by 3 - -#### Scenario: Image read is skipped by the sniff test - -- **WHEN** a `Read` tool_result content is the list - `[{"type":"image","source":{"type":"base64","data":"..."}}]` -- **THEN** `lines_read` is unchanged - -#### Scenario: Non-cat-n string result is skipped - -- **WHEN** a `Read` tool_result content is a string that does not begin with `1\t` - (for example an error message) -- **THEN** `lines_read` is unchanged - -#### Scenario: Offset and limit are not used - -- **WHEN** a `Read` `tool_use` input carries `offset` but no `limit` and its - result is a 30-line `cat -n` string -- **THEN** `lines_read` increases by 30, not by the tool's default limit - -### Requirement: lines_changed counts newlines in the tool_use input - -For an `Edit` `tool_use`, the system SHALL add -`max(newlines(old_string), newlines(new_string))` to `lines_changed`. For a -`Write` `tool_use`, the system SHALL add `newlines(content)`. An `Edit` with -`replace_all: true` SHALL be counted once regardless of how many occurrences were -replaced; this undercount is accepted and SHALL be documented in `CONTEXT.md`. - -#### Scenario: Edit takes the larger side - -- **WHEN** an `Edit` replaces a 2-line `old_string` with a 9-line `new_string` -- **THEN** `lines_changed` increases by 9 - -#### Scenario: Deletion counts the removed hunk - -- **WHEN** an `Edit` replaces a 12-line `old_string` with a 1-line `new_string` -- **THEN** `lines_changed` increases by 12 - -#### Scenario: Write counts the whole content - -- **WHEN** a `Write` creates a file whose `content` has 250 newlines -- **THEN** `lines_changed` increases by 250 - -#### Scenario: replace_all counts once - -- **WHEN** an `Edit` with `replace_all: true` replaces a 1-line string at 40 sites -- **THEN** `lines_changed` increases by 1 - -### Requirement: Sidechain skip on the main transcript only - -When counting the main session transcript, the system SHALL skip records whose -top-level `isSidechain` field is `true`. When counting a subagent -`agent-*.jsonl` transcript, the system SHALL NOT apply any sidechain filter — the -subagent file SHALL be counted in full. This asymmetry SHALL be preserved: applying -the sidechain skip to subagent files zeroes the entire subagent contribution. -Together with the disjointness of `tool_use` ids between the main transcript and -subagent transcripts, this SHALL make -`session total == main thread + sum of every subagent` true by construction. - -#### Scenario: Sidechain record in the main transcript is skipped - -- **WHEN** the main transcript contains a `Read` record with `isSidechain: true` -- **THEN** it contributes nothing to the main transcript's `lines_read` - -#### Scenario: Subagent transcript is counted in full - -- **WHEN** every record in a subagent `agent-*.jsonl` carries `isSidechain: true` -- **THEN** all of its `Read`/`Write`/`Edit` records are still counted - -#### Scenario: Session total equals main plus subagents - -- **WHEN** the session total and each per-transcript figure are computed -- **THEN** the session `lines_read` equals the main transcript's `lines_read` plus - the sum over every subagent transcript, and likewise for `lines_changed` - -### Requirement: Line counts reset at the last /clear - -The system SHALL count only records at or after `clear_epoch`, inheriting the -existing `count_transcript` clear-window behaviour. When `clear_epoch` is `None` -the whole transcript SHALL be counted. - -#### Scenario: Pre-clear activity is excluded - -- **WHEN** a `Read` record's timestamp precedes `clear_epoch` -- **THEN** it contributes nothing to `lines_read` - -#### Scenario: No clear marker counts the whole session - -- **WHEN** `clear_epoch` is `None` -- **THEN** every eligible record in the transcript is counted - -### Requirement: Counting is fused into the existing single transcript walk - -The system SHALL accumulate both line counts inside the existing -`count_transcript` pass over each transcript file. It SHALL NOT add a second walk -of the same file, an on-disk cache, a state file, or any incremental/offset -reading. Pre-filtering MAY reject lines before JSON decoding, and any such -pre-filter SHALL yield results identical to decoding every line. - -#### Scenario: One pass per file - -- **WHEN** the gather runs for a session with a main transcript and N subagent - transcripts -- **THEN** each of those N+1 files is opened and walked exactly once - -#### Scenario: Pre-filters do not change results - -- **WHEN** the byte-level pre-filtered walk and a naive full-JSON walk are run - over the same transcript -- **THEN** both produce identical `counts`, `lines_read`, and `lines_changed` - -### Requirement: Session totals render as a segment in the tokens/cost row - -The wide tokens/cost row SHALL render a lines segment between the tokens column -and the cost column, giving the segment order tokens, lines, cost, then the -rate-and-sparkline leader. The segment SHALL show one pair of numbers — the -session total, being the main transcript plus every subagent transcript combined — -NOT a main/sub split. Each number SHALL be humanised in the same form as the token -fields (for example `1.2k`). The segment SHALL be gated by no configuration flag -of its own: it renders whenever the tokens/cost row renders and the width rule -below permits. - -#### Scenario: Segment sits between tokens and cost - -- **WHEN** the wide tokens/cost row renders at a width that permits the segment -- **THEN** the content reads tokens, then lines, then cost, then the leader, left - to right, divided by gradient `│` separators - -#### Scenario: Value is the combined session total - -- **WHEN** the main transcript read 400 lines and two subagents read 900 and 100 -- **THEN** the segment shows a read figure of 1.4k - -#### Scenario: Numbers are humanised - -- **WHEN** the session has changed 1,200 lines -- **THEN** the segment renders `1.2k`, matching the token field's formatting - -### Requirement: Lines segment sheds below its own minimum width - -The lines segment AND its `│` divider SHALL be omitted when the box width is -below `LINES_SEGMENT_MIN_WIDTH` (103) or below the row's measured with-segment -minimum width. When omitted, the tokens/cost row SHALL render exactly as it does -without this change: three segments, two dividers, and a reported `min_width` -computed without the segment. `TOKENS_COST_MIN_WIDTH` SHALL remain 85, so the -tokens/cost row SHALL continue to render — rather than degrading to -`context_line_compact` — at every width between 85 and 103 columns. Above the -threshold the added width SHALL be absorbed by the rate-and-sparkline leader, -which already drops its sparkline below 10 columns. - -#### Scenario: Row is unchanged in the 85-to-103 band - -- **WHEN** the wide layout renders at box width 90 -- **THEN** the tokens/cost row renders with exactly three segments and two - dividers, identical to its pre-change output - -#### Scenario: Segment appears at wide widths - -- **WHEN** the wide layout renders at box width 140 -- **THEN** the tokens/cost row includes the lines segment and three dividers - -#### Scenario: Row is never dropped by the new segment - -- **WHEN** the box width is at or above `max(tokens_min_w, 85)` but below the - with-segment minimum -- **THEN** the tokens/cost row still renders, with the lines segment shed - -#### Scenario: Leader absorbs the added width - -- **WHEN** the lines segment is included at a wide width -- **THEN** the sparkline shortens by the segment's footprint and the tokens and - cost columns keep their measured widths - -### Requirement: Lines segment threads its own elbow and caption - -When present, the lines segment SHALL contribute a third divider column to the -value `tokens_cost` returns for elbow threading, so every `│` in the row has a -matching `┬` above and `┴` below. When shed, the returned divider columns SHALL be -the two-column form. With section labels enabled (`cfg.labels`), the segment SHALL -carry the caption `lines read/changed`, centred over the segment, and the existing -`cost` and `tokens over time` captions SHALL remain anchored to their own -segments in both the shed and present forms. - -#### Scenario: Three elbows when present - -- **WHEN** the row renders with the lines segment -- **THEN** three `┬` marks appear on the separator above and three `┴` below, each - aligned with a rendered `│` - -#### Scenario: Two elbows when shed - -- **WHEN** the row renders with the lines segment shed -- **THEN** exactly two elbows are threaded, as today - -#### Scenario: Caption shown when labels are on - -- **WHEN** `cfg.labels` is true and the lines segment is present -- **THEN** the separator above shows the `lines read/changed` caption over the - segment, and the `cost` and `tokens over time` captions remain over theirs - -### Requirement: Per-subagent lines field is self-scoped - -Each subagent row SHALL show the line counts of its OWN transcript only. A parent -SHALL NOT roll up its descendants' counts, and a `fork` subagent SHALL count to -itself. The field SHALL sit in the line-1 stats cluster alongside share%, tokens, -and model, and SHALL humanise both numbers in the same form as the tokens field. - -#### Scenario: Parent excludes its children - -- **WHEN** a parent subagent read 100 lines and its child read 900 -- **THEN** the parent's row shows 100 and the child's row shows 900 - -#### Scenario: Fork counts to itself - -- **WHEN** a `fork` subagent reads 250 lines -- **THEN** those 250 lines appear on the fork's own row - -#### Scenario: Rows sum to the session segment - -- **WHEN** every subagent row's figure is added to the main thread's contribution -- **THEN** the total equals the figure shown in the tokens/cost row's lines segment - -### Requirement: Per-subagent lines field is blank when idle and sheds first - -The lines field SHALL render as blank padding — not `0` — when the subagent has -neither read nor changed anything, preserving the fixed cluster width. Under width -pressure the field SHALL be the FIRST cluster field dropped, before share% and -before tok, so the shed order becomes lines, then share%, then tok, with the model -and the front duration always retained. Narrow terminals SHALL therefore render -exactly as they do today. - -#### Scenario: Idle subagent shows blank - -- **WHEN** a subagent has read 0 lines and changed 0 lines -- **THEN** its lines field renders as spaces, not as `0`, and the cluster keeps its - width - -#### Scenario: Lines sheds before share% - -- **WHEN** the cluster does not fit at the available width -- **THEN** the lines field is dropped first while share%, tok, and model remain - -#### Scenario: Existing shed ladder is preserved below - -- **WHEN** the cluster still does not fit after the lines field is dropped -- **THEN** share% is dropped, then tok, with model and duration always retained diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/statusline-info/spec.md b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/statusline-info/spec.md deleted file mode 100644 index 609d32a..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/statusline-info/spec.md +++ /dev/null @@ -1,47 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Tool-counts gather field - -`SessionView` SHALL expose a `tool_counts` `@cached_property` returning a -`ToolCounts` value that holds, per tool name, the `(main, sub)` `tool_use` counts -and the total number of distinct tool types. The same value SHALL additionally -hold the session's `lines_read` and `lines_changed` totals (the main transcript -plus every subagent transcript) and a per-transcript breakdown keyed by transcript -path, so a caller can look up any one subagent's own figures. It SHALL be -constructed from the main -transcript, the subagent cohort, and `clear_epoch` — all fields already available -on the view — and SHALL perform no I/O beyond reopening those same transcript -files, walking each file exactly once for both the tool counts and the line -counts. As a `@cached_property`, it SHALL be computed at most once per view and -SHALL NOT be evaluated when a render path never reads it (narrow/medium). The -`info` layer SHALL NOT import `renderer` or `layout` to provide it. - -#### Scenario: Field exposes per-tool main/sub counts - -- **WHEN** a `SessionView` is constructed and `tool_counts` is read -- **THEN** it returns a `ToolCounts` whose per-tool entries each carry a `main` and - a `sub` count derived from the main transcript and the subagent cohort - respectively - -#### Scenario: Field exposes session line totals - -- **WHEN** `tool_counts` is read -- **THEN** it also exposes `lines_read` and `lines_changed` totalled over the main - transcript and every subagent transcript - -#### Scenario: Field exposes a per-transcript breakdown - -- **WHEN** a caller has a subagent's transcript path -- **THEN** it can obtain that subagent's own `(lines_read, lines_changed)` pair - from the same `ToolCounts` value - -#### Scenario: Field is lazy - -- **WHEN** a narrow or medium render is produced without reading `tool_counts` -- **THEN** the tool-counts aggregation is never computed - -#### Scenario: Field respects the clear window - -- **WHEN** `clear_epoch` is set on the view -- **THEN** `tool_counts` reflects only `tool_use` messages at or after that epoch, - and the line totals reflect only activity at or after that epoch diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/subagent-row-layout/spec.md b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/subagent-row-layout/spec.md deleted file mode 100644 index 5b2b278..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/specs/subagent-row-layout/spec.md +++ /dev/null @@ -1,47 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Line-1 cluster shedding - -When line 1 lacks room for the full `lines · share% · tok · model` cluster, the description SHALL truncate first. If the cluster still does not fit, fields SHALL shed in order: the lines field first, then share%, then tok. The model and the front duration SHALL always be retained. - -#### Scenario: Description truncates before the cluster sheds - -- **WHEN** line 1 is too wide for the full description plus cluster -- **THEN** the description truncates with an ellipsis while the full cluster is retained - -#### Scenario: Cluster sheds lines, then share%, then tok under width pressure - -- **WHEN** the truncated description plus full cluster still exceeds the width -- **THEN** the lines field is dropped first, then share%, then tok, while model and the front duration remain - -#### Scenario: Narrow widths behave as before the lines field existed - -- **WHEN** the width is tight enough that the lines field is shed -- **THEN** the remaining cluster is byte-identical to the pre-change cluster at that width - -## ADDED Requirements - -### Requirement: Line-1 lines field - -The line-1 stats cluster SHALL carry a lines field showing the subagent's own -`lines_read` and `lines_changed`, each humanised in the same form as the tok field -(for example `1.2k`), each preceded by its glyph. The field SHALL be -fixed-width in the `tree_single` cluster so the constant activity gap after the -cluster lands at the same absolute column down the cohort. The field SHALL render -as blank padding of that same width, not `0`, when the subagent has neither read -nor changed anything. - -#### Scenario: Field shows the subagent's own figures - -- **WHEN** a subagent has read 1,200 lines and changed 30 -- **THEN** its row's lines field shows `1.2k` for read and `30` for changed - -#### Scenario: Field is blank when idle - -- **WHEN** a subagent has read 0 lines and changed 0 lines -- **THEN** the field renders as spaces and the cluster's total width is unchanged - -#### Scenario: Cluster width stays deterministic across the cohort - -- **WHEN** several subagent rows with differing figures render in tree mode -- **THEN** every row's activity column begins at the same absolute column diff --git a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/tasks.md b/openspec/changes/archive/2026-07-26-add-subagent-line-counts/tasks.md deleted file mode 100644 index 87cd22a..0000000 --- a/openspec/changes/archive/2026-07-26-add-subagent-line-counts/tasks.md +++ /dev/null @@ -1,77 +0,0 @@ -## 1. Constants and glyphs - -- [x] 1.1 In `claude/yas/constants.py`, alongside `TOKENS_COST_MIN_WIDTH = 85` (line ~55), add `LINES_SEGMENT_MIN_WIDTH = 103` with a comment stating that it gates ONLY the lines segment, that `TOKENS_COST_MIN_WIDTH` must stay at 85, and why (bumping it would regress every 85–103-column terminal into `context_line_compact`). -- [x] 1.2 In `claude/yas/constants.py`, in the Nerd Font PUA block (near `GLYPH_MODEL`, line ~151), add `GLYPH_LINES_READ = '\U000f0208' # nf-md-eye` and `GLYPH_LINES_CHANGED = '\uf040' # nf-fa-pencil`. Write them as escapes — never raw PUA glyphs in source (see the PUA refactor rule in the `tmck-code-statusline` skill). -- [x] 1.3 Add both glyphs to `ASCII_GLYPHS` (line ~229): `GLYPH_LINES_READ: 'R'`, `GLYPH_LINES_CHANGED: 'W'`. -- [x] 1.4 Add both glyphs to `UNICODE_PUA` (line ~324) with single non-PUA width-1 BMP substitutes (suggested: `⌖` for read, `✎` for changed). Then run `python3 -c "import unicodedata as u; print(u.east_asian_width('⌖'), u.east_asian_width('✎'))"` and, for any target reporting `A`, add an EAW-narrow entry to `GITHUB_ICON_OVERRIDE` (line ~372) as the existing five do. -- [x] 1.5 Add `LINES_LABEL = 'lines read/changed'` next to `TOOL_COUNTS_LABEL` (line ~109). Plain ASCII — the separator overlay applies `superscript()` itself. - -## 2. Counting: `claude/yas/info/toolcounts.py` - -- [x] 2.1 Add a `TranscriptToolStats` value (slots class or `NamedTuple`, matching the module's style) with fields `counts: dict[str, int]`, `lines_read: int`, `lines_changed: int`. -- [x] 2.2 Change `count_transcript(path, clear_epoch)` (line 27) to `count_transcript(path: str, clear_epoch: float | None, *, skip_sidechain: bool) -> TranscriptToolStats`. Keep the existing last-write-wins-per-`message.id` tool_use dedup and the `META_EXCLUDE_TOOLS` / `name.split('__')[-1]` handling exactly as they are — the module docstring's warning about first-wins undercounting still applies. -- [x] 2.3 Switch the file open to binary (`open(path, 'rb')`) and apply the V2 pre-filters per line, before any `json.loads`: - (a) skip when the line contains neither `b'"tool_use"'` nor `b'"tool_result"'`; - (b) when the line contains `b'"tool_result"'` but not `b'"tool_use"'`, skip unless it also contains the JSON-escaped `cat -n` marker `b'1\\t'`; - (c) when `skip_sidechain` is true, skip lines containing `b'"isSidechain":true'` (also test the spaced variant `b'"isSidechain": true'`). - Decode surviving lines with `json.loads(raw)` (`json` accepts bytes) inside the existing `try/except (ValueError, TypeError)`. -- [x] 2.4 Keep the existing `clear_epoch` guard (`_parse_iso_to_epoch(d.get('timestamp',''))< clear_epoch → continue`) as the single window for BOTH the tool counts and the new line counts. -- [x] 2.5 While walking `msg['content']` blocks for `tool_use`, additionally record file-activity per block: for `name == 'Read'`, remember `block['id']` in a `read_ids: set[str]`; for `name == 'Edit'`, add `max(newlines(old_string), newlines(new_string))` to `lines_changed`; for `name == 'Write'`, add `newlines(content)`. Use a local `def _nl(s: object) -> int: return s.count('\n') if isinstance(s, str) else 0`. Do NOT count `NotebookEdit`. -- [x] 2.6 Handle `tool_result` blocks: they live under `message.content[]` on `user`-type records. For each block with `type == 'tool_result'` whose `tool_use_id` is in `read_ids`, take `content`; if it `isinstance(content, str)` and `content.startswith('1\t')`, add `content.count('\n')` to `lines_read`. Skip list-valued content (image/document reads) and strings not starting with `1\t`. Never use `offset`/`limit` from the `Read` input. -- [x] 2.7 Note the ordering constraint in a comment: a `tool_result` always appears on a LATER line than its `tool_use`, so a single forward pass with `read_ids` accumulated as it goes is sufficient — no second pass, no lookahead. -- [x] 2.8 The `lines_changed` accumulation must respect the same last-write-wins dedup as the tool counts: accumulate per `message.id` into a `per_id_changed: dict[str, int]` overwritten on each write of that id, and sum at the end — otherwise a streamed message's partial writes double-count its edits. -- [x] 2.9 Extend `ToolCounts.__slots__` (line 80) with `lines_read`, `lines_changed`, and `per_agent: dict[str, tuple[int, int]]` (key: transcript path). Update `__init__`, `__eq__`, and `__repr__` to include them. -- [x] 2.10 Update `ToolCounts.gather` (line 104): call `count_transcript(main_path, clear_epoch, skip_sidechain=True)` for the main transcript and `count_transcript(agent.jsonl_path, clear_epoch, skip_sidechain=False)` for each subagent; sum `lines_read`/`lines_changed` across main + all subagents into the session totals, and store each subagent's own pair in `per_agent[agent.jsonl_path]`. Add a comment stating the asymmetry is deliberate and that skipping sidechain records in subagent files zeroes the entire subagent contribution. -- [x] 2.11 Extend the module docstring to document: the three in-scope tools, the `1\t` sniff test, the `replace_all` undercount, and the main-vs-subagent sidechain asymmetry. - -## 3. Gather seam - -- [x] 3.1 In `claude/yas/info/__init__.py`, update the `tool_counts` `@cached_property` docstring (line ~122) to state that the same single pass now also yields the session `lines_read`/`lines_changed` totals and the `per_agent` breakdown. No signature change: `ToolCounts.gather(self.session.transcript_path, self.subagents.subagents, self.clear_epoch)` is unchanged. - -## 4. Renderer: tokens/cost row segment - -- [x] 4.1 In `claude/yas/renderer.py`, add a keyword parameter `lines: tuple[int, int] | None = None` to `Renderer.tokens_cost` (line 1316) and widen its return annotation's divider element to `tuple[int, ...]`. -- [x] 4.2 Build the segment with a local `build_lines()` returning `f'{GLYPH_LINES_READ} {fmt_tok(read)}{sep}{GLYPH_LINES_CHANGED} {fmt_tok(changed)}'` in the row's existing colour vocabulary (`self.TOK` for values, `self.LABEL` for the glyphs/separator), measured with `_visible_width` — never `len()`. -- [x] 4.3 Compute `min_width` twice: the existing without-segment value (line 1416, unchanged formula) and a with-segment value adding `lines_w + vsep_lines_w` (use `vsep_lines_w = 4`, matching `vsep_w`/`vsep_leader_w`). Include the segment only when `lines is not None and box_width >= max(min_width_with_lines, LINES_SEGMENT_MIN_WIDTH)`; otherwise shed it. Return the WITHOUT-segment `min_width` when shed so `tokens_fits` in `build_wide` is unaffected. -- [x] 4.4 When included: subtract `vsep_lines_w` from `inner` (line 1388), give the segment a content-measured budget floored at its own width (same "honest floor" pattern as `w_middle`/`w_end`, lines 1472-1475), place its divider column between `col1` and the cost divider, and emit `self.vsep_block(...)` for it. The leader keeps absorbing the remainder via `leader_w = max(label_w + 1, inner - w_middle - w_lines - w_end)`. -- [x] 4.5 Return `[line], (col1, col2, col3), 0, min_width` when included and `[line], (col1, col2), 0, min_width` when shed. Update the method docstring to describe both shapes and the shed rule. -- [x] 4.6 Leave the `justify` block (lines 1418-1454) semantics intact: the new segment takes no justify pad slot in this change; note that in a comment so a later reader does not assume an omission. - -## 5. Renderer: per-subagent lines field - -- [x] 5.1 In `Renderer.subagent_row` (line 753), add a keyword parameter `lines: tuple[int, int] | None = None`. -- [x] 5.2 Near the `share_str`/`tok_field` construction (lines 856-874), build `lines_field` as `f'{GLYPH_LINES_READ} {fmt_tok(read)} {GLYPH_LINES_CHANGED} {fmt_tok(changed)}'`; when `lines` is `None` or both values are 0, set it to `' ' * ` so the cluster width stays deterministic (same reasoning as the `rjust(6)` comment at lines 858-862 and `SUBAGENT_STATS_ACTIVITY_GAP`). In `tree_single` mode pad each humanised number to a fixed width (`rjust(6)`, matching `tok_field`). -- [x] 5.3 Change `build_cluster` (line 894) to `build_cluster(show_lines, show_share, show_tok)` and emit the lines field first in the segment, before share%, using the same done/live colour split (`self.CTX_DIM` when `is_done`). -- [x] 5.4 Update the shed ladder at line 924 from `((True, True), (False, True))` to `((True, True, True), (False, True, True), (False, False, True))`, and update the model-only fallback calls at lines 916 and 923 to `build_cluster(False, False, False)`. This makes lines the first field shed. -- [x] 5.5 Verify the non-`tree_single` (classic two-line) path still renders correctly: the lines field participates in the same ladder, so a narrow classic row sheds it first and matches today's output. - -## 6. Layout wiring - -- [x] 6.1 In `claude/yas/layout.py` `build_wide`, pass the session totals into the row: `lines=(view.tool_counts.lines_read, view.tool_counts.lines_changed)` in the `r.tokens_cost(...)` call at line 602. Note this now forces `view.tool_counts` evaluation on every wide render (previously only when `cfg.show_tool_uses`); that is the accepted +2.9 ms measured in design.md Decision 6. -- [x] 6.2 `vsep_cols` is now variable-length. Update the label block at lines 995-1001 to index from the END: `_cost_mid = (vsep_cols[-2] + vsep_cols[-1]) // 2`, `tok_labels.append((_cost_lbl, max(vsep_cols[-2] + 1, ...)))`, and `tok_labels.append(('tokens over time', vsep_cols[-1] + 2))`. -- [x] 6.3 When `len(vsep_cols) == 3`, append a `LINES_LABEL` caption centred between `vsep_cols[0]` and `vsep_cols[1]`, mirroring the `cost` centring (with the same `max(vsep_cols[0] + 1, ...)` left clamp so it never cannibalises the token labels). -- [x] 6.4 `RowSpec('separator_dim', downs=vsep_cols, labels=tok_labels)` (line 1002) and `pending_ups = vsep_cols if tokens_fits else ()` (line 1010) already pass the tuple wholesale — confirm the elbow count follows automatically for both the 2- and 3-tuple forms and that no call site indexes `vsep_cols[1]` assuming it is the last element. -- [x] 6.5 At every `r.subagent_row(...)` call site in `build_wide` (tree and non-tree paths, around lines 1048-1147), pass `lines=view.tool_counts.per_agent.get(sub.jsonl_path)`. Do NOT add this to `build_narrow`/`build_medium` — those paths must keep `tool_counts` unevaluated (see the `statusline-info` laziness requirement). - -## 7. Tests - -- [x] 7.1 `test/test_tool_counts.py`: update every existing `count_transcript` call for the new `skip_sidechain` keyword and the `TranscriptToolStats` return (assert on `.counts` instead of the bare dict). Confirm the existing tool-count assertions still pass unchanged. -- [x] 7.2 New counting tests (same file or a new `test/test_line_counts.py`), each from a hand-written jsonl fixture in `tmp_path`: (a) `cat -n` string result counts its newlines; (b) list-valued image result contributes 0; (c) non-`1\t` string result contributes 0; (d) `Edit` takes `max(old, new)`; (e) `Write` counts `content`; (f) `NotebookEdit` contributes 0; (g) `replace_all: true` counts once (document the accepted undercount in the test name). -- [x] 7.3 Sidechain tests: a main-transcript fixture with `isSidechain: true` records is skipped under `skip_sidechain=True`; the SAME fixture under `skip_sidechain=False` counts in full. State in a comment that no real session on this machine emits these records, so this synthetic fixture is the only defence (design.md Risks). -- [x] 7.4 `clear_epoch` test: records before the epoch contribute nothing to `lines_read`/`lines_changed`; `clear_epoch=None` counts everything. -- [x] 7.5 Invariant test: build a main fixture plus two subagent fixtures, call `ToolCounts.gather`, and assert `lines_read == main + sum(per_agent)` and likewise for `lines_changed`. -- [x] 7.6 Pre-filter equivalence test: run the pre-filtered walk and a naive "decode every line" reference over the same fixture and assert identical `counts`, `lines_read`, `lines_changed`. -- [x] 7.7 `test/test_tokens_cost.py` (23 existing tests): extend the divider/min-width/justify assertions for the variable-length `vsep_cols`. Add: segment absent at box 85–102 with a 2-tuple returned and output byte-identical to the pre-change render; segment present at box 103+ with a 3-tuple; every `│` in the rendered line matches its reported divider column in both forms (extend `test_tokens_cost_divider_cols_match_rendered_bars`, line 49, and `test_tokens_cost_dividers_match_rendered_at_narrow_boxes`, line 190); `min_width` never rises above the pre-change value when the segment is shed (extend `test_tokens_cost_min_width_is_consistent_with_fit`, line 303); the sparkline still degrades at `bar_w < 10` with the segment present (extend line 199). -- [x] 7.8 `test/test_layout_seam.py`: assert three elbows are threaded at a wide box and two at a box in the 85–102 band, and that the row is NOT dropped to `context_line_compact` anywhere at or above 85. -- [x] 7.9 `test/test_subagent_rows.py`: lines field appears in the cluster with the correct humanised values; blank (spaces, not `0`) when both are 0; cluster width identical between an idle and a populated row; shed order is lines → share% → tok under decreasing widths; a narrow width that sheds the field renders byte-identically to the pre-change output. -- [x] 7.10 Self-scoping test: a parent and a child subagent with different figures render their own numbers, with no rollup onto the parent. -- [x] 7.11 Run the gate via the `verifier` agent (`make test`), not on the main thread. - -## 8. Visual gate and docs - -- [x] 8.1 Capture the baseline BEFORE any renderer edits: `make demo/img && .claude/skills/yas-demo-text/scripts/demo-text.sh && cp -r demo/text /tmp/yas-base`. -- [x] 8.2 Re-golden the fixtures: the tokens/cost row appears in 21 of ~23 `demo/text/*.txt` files (`kitchen-sink`, `full-context`, `openspec`, `tasks`, `subagents`, `workflows`, `opus-thinking`, `sonnet-thinking`, `config-error`, all `cohort-*`, all `subagent-tree-*`). Re-run `make demo/img` + `.claude/skills/yas-demo-text/scripts/demo-text.sh`, `diff -ru /tmp/yas-base demo/text`, and review every diff for column drift rather than content change. Commit the re-goldened fixtures. -- [x] 8.3 Check the demo scenarios' rendering widths: confirm at least one fixture renders in the 85–102 band (segment shed) and one at 103+ (segment present); if none exists in the shed band, add a scenario or note the gap explicitly. -- [x] 8.4 Verify glyph modes end to end: render once per `glyph_mode` (`nerdfont`, `ascii`, `unicode`, `github`) and confirm the two new glyphs fold to width-1 replacements and the box geometry is unchanged (`ascii` must show `R` and `W`). -- [x] 8.5 `CONTEXT.md`: add glossary entries for **Lines Read** (newlines in the `cat -n` result of a `Read`; image reads excluded) and **Lines Changed** (`Edit` → larger of old/new hunk, `Write` → whole content; `replace_all` counted once — a documented undercount), plus a note that the tokens/cost row's lines segment is the session total (main + all subagents) while the per-subagent field is self-scoped. -- [x] 8.6 Run `uv run ruff check` (via `verifier`). diff --git a/openspec/changes/cache-transcript-parses/.openspec.yaml b/openspec/changes/cache-transcript-parses/.openspec.yaml deleted file mode 100644 index 0c73c8f..0000000 --- a/openspec/changes/cache-transcript-parses/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-08-15 diff --git a/openspec/changes/cache-transcript-parses/design.md b/openspec/changes/cache-transcript-parses/design.md deleted file mode 100644 index ea489a1..0000000 --- a/openspec/changes/cache-transcript-parses/design.md +++ /dev/null @@ -1,226 +0,0 @@ -## Context - -The statusline re-execs per tick (`claude/statusline_command.py`), so every -module-level cache starts empty. Three whole-file walkers dominate a -subagent-heavy render: - -- `parse_transcript(jsonl, resume_after)` (`claude/yas/info/subagents.py:391`) - returns a plain 8-tuple `(billed_in, cache_read_in, output, first_ts, model, - last_activity, end_ts, run_start_ts)`; `last_activity` is - `(kind, name, input_dict)`. Called once per agent from - `RunningSubagents.from_session` (`:942`, call site `:1114`) — 101 ms / 48 - agents in the profile. -- `_tail_read_notifications(path)` (`:134`) and `_tail_read_tool_results(path)` - (`:238`) share one algorithm: stat, hit-test `(mtime, size)`, else seek to the - cached `offset` and read to the last complete newline. Their state lives in - `_notif_tail_cache: dict[str, _TailCacheEntry]` (`:56-70`) and - `_tool_result_tail_cache: dict[str, _ToolResultCacheEntry]` (`:188-198`), both - `(mtime, size, offset, findings)`. In production the offset is always 0 — - 36 ms wasted per render. -- `count_transcript(path, clear_epoch, *, skip_sidechain)` - (`claude/yas/info/toolcounts.py:94`) returns a `TranscriptToolStats(counts, - lines_read, lines_changed)` dataclass. `ToolCounts.gather` (`:351`) walks the - main transcript plus every agent transcript a second time — 125 ms. - -`build_wide` (`claude/yas/layout.py:915-920`) forces `view.tool_counts` on every -wide render for the lines segment; layout consumes only `lines_read`, -`lines_changed` and `per_agent` (`:1596`, `:1609`, `:1638`, `:1702`) unless -`cfg.show_tool_uses` is on. - -`SessionView` (`claude/yas/info/__init__.py:78-140`) is a pure-read, -`@cached_property` façade; the `statusline-info` spec states it performs **no** -disk writes. The house persistence pattern is `RenderTiming` -(`claude/yas/tokens.py:274-330`): a `CLAUDE_DIR`-rooted file, `read`/`write`, a -`KEEP` retention constant, all I/O in `try/except OSError`. `app.py:88-100` -already writes one file per session under `CLAUDE_DIR / 'statusline-output'`. - -## Goals / Non-Goals - -**Goals:** -- Subagent-heavy render (48 agents, 23.5 MB) drops from ~319 ms to ~60–80 ms on a - warm cache. -- Fresh-session render unchanged or faster; the cache must not add measurable - cost when there is nothing to cache. -- Byte-identical rendering: same visible agents, same token figures, same tool - and line counts, same `session_inout` denominator. -- Corruption, staleness, version skew and partial writes fail safe to a full - re-parse. -- Reuse the existing incremental tail-read machinery rather than replacing it. - -**Non-Goals:** -- No change to what is rendered, no new row, glyph, colour or width threshold — - therefore **no demo golden churn**. -- Not removing `build_wide`'s force of `view.tool_counts` (Decision 7): the lines - segment genuinely needs the session totals; the fix is to make the work cheap, - not conditional. -- No cross-session or global cache; no shared cache between concurrent renders of - *different* sessions. -- No caching of `TranscriptUsage.from_transcript`, `LoadedSkills`, `TaskList`, - `read_clear_epoch` or the git subprocess — each is ≤5 ms and does not scale - with agent count (report §"Per-phase timings"). -- No interpreter/import startup work (report recommendation #5). -- No archiving or deletion of the user's `*.meta.json` / `agent-*.jsonl` files. - Recommendation #4 is implemented only as cache-side pruning. - -## Decisions - -### 1. One JSON cache file per session, `CLAUDE_DIR`-rooted - -`CLAUDE_DIR / 'yas-cache' / f'transcripts.{session_id}.json'`, mirroring -`app.py:88-100`'s `statusline-output` convention (`mkdir(parents=True, -exist_ok=True)`, whole-file overwrite, `except OSError: pass`). - -Per-session rather than one global file: the natural working set is exactly one -session's transcripts, the file stays small (~48 entries), and abandoning a -session abandons its cache file wholesale. Rejected: SQLite (a new dependency and -concurrency surface for ~50 records) and one file per transcript (48 opens per -render — the very cost being removed). - -### 2. Envelope: `{"v": , "session": , "saved": , "entries": {...}}` - -`v` is a module constant (`CACHE_VERSION`) bumped by hand whenever any stored -shape changes; a mismatch discards the file. This is the migration story — there -is no reader for old versions, because everything in the file is re-derivable. - -### 3. Entry shape: one record per transcript path, sub-keyed by parse inputs - -``` -entries[str(path)] = { - "mtime": float, "size": int, "seen": float, "terminal": bool, - "parse": {"": [8-tuple]}, - "counts": {"|": {"counts":{}, "lines_read":n, "lines_changed":n}}, - "notif": {"offset": int, "items": [[task_id, tool_use_id, status, ts], ...]}, - "tres": {"offset": int, "results": {"": [status, ts]}} -} -``` - -The sub-keys are load-bearing: `resume_after` is an *input* to `parse_transcript` -that changes `run_start_ts`, and `clear_epoch` + `skip_sidechain` both change -`count_transcript`'s result. Keying on `(path, mtime, size)` alone would be a -correctness bug (research report, Hazards 1–2). Float sub-keys are formatted with -`repr(float)` so they round-trip exactly. Each sub-map is capped (keep the most -recent 4 entries per transcript) so a drifting `resume_after` cannot grow the -file without bound. - -`_Notification` is a `__slots__` class, not JSON-native, so `notif.items` uses a -positional 4-list codec (`task_id, tool_use_id, status, ts`) with an explicit -`_notif_to_json` / `_notif_from_json` pair. Tuples in `tres.results` and -`last_activity` round-trip through lists and are re-tupled on load. - -### 4. Validity: exact `(mtime, size)` for whole-file results, `size >=` for tails - -A `parse`/`counts` hit requires `st_mtime == entry.mtime and st_size == -entry.size` **and** a matching sub-key; anything else is a miss and a full -re-read. Float mtime is compared exactly (as the existing tail hit-test at `:150` -already does) — no epsilon, because a false hit is a wrong render and a false -miss only costs the status quo. - -Tail state is seeded into `_notif_tail_cache` / `_tool_result_tail_cache` before -the first read and then left entirely to the existing algorithm, which already -handles "grew" (resume from `offset`) and "shrank" (`cached.size <= st.st_size` -fails → rescan from 0). This is why the change touches so little of the hot path: -the incremental logic already exists and is correct, it just never had a warm -start. - -### 5. Load once in `app`, save once in `app`; `SessionView` stays write-free - -`app` loads the cache alongside its existing `RenderTiming.read(session_id)` call -(`app.py:104-110`), hands the instance to `SessionView`, and calls `flush()` after -the render completes. The `statusline-info` requirement "SessionView SHALL perform -no disk writes" is preserved verbatim in spirit and amended in text to name the -cache save as an `app`-owned step — the same treatment `record_tick` already got. - -The readers (`parse_transcript`, `count_transcript`, the tail readers) receive the -cache as an **optional** argument defaulting to `None`, meaning "no cache" — so -every existing call site and every existing test keeps working unchanged, and -`mon` (which benefits from the in-process caches) is unaffected. - -Rejected: writing from inside each reader. It would put 3–50 writes on a render, -break the view's no-write contract, and interleave badly with concurrent renders. - -### 6. Cold-cache fallback: totals-only parse for conclusively retired agents - -`session_inout` (`info/__init__.py:149-155`) sums `total_input + output` over -**all** subagents, not just visible ones, so a retired agent cannot simply be -stubbed to zero — that would change a rendered number. Instead -`parse_transcript` gains `totals_only: bool = False`, which: - -- byte-pre-filters each raw line to those containing `b'"usage"'` before - `json.loads` (the same style as the existing `b''` filter at - `:176`), so the token sums stay exact; -- still records `first_ts`, `end_ts` and `run_start_ts` (needed by `visible()`); -- returns `model=''` and `last_activity=('', '', {})` — fields only ever read by a - rendered row. - -An agent qualifies as conclusively retired when it has a terminal status from the -cheap tier-1/tier-2 maps **and** `now - end_ts` exceeds -`max(FINISHED_LINGER_SECONDS, COHORT_GRACE_SECONDS)` **and** `now - mtime > -ABANDONED_HORIZON_SECONDS`, all with a safety margin (`+ TERMINAL_SKEW_SECONDS`). - -**Fail-safe:** `from_session` records which agents were built totals-only; after -`visible(now, last_prompt_ts)` is computed for the first time, any totals-only -agent appearing in the visible list is re-parsed in full and its row rebuilt -before it can render. In practice this never fires; when it does it costs exactly -one full parse. This makes the optimisation unobservable rather than -merely-usually-right. - -Rejected: skipping the parse entirely (changes `session_inout`), and trusting -`visible()`'s predicate without the re-parse check (a predicate drift becomes a -blank model column in production). - -### 7. `ToolCounts.gather` reuses the cache; `build_wide` keeps forcing it - -`gather` threads the cache into each `count_transcript` call. With a warm cache -the 125 ms second pass becomes ~48 dict lookups. Restricting `gather` to the -*visible* cohort (report recommendation #3's alternative) was rejected: the -session `lines_read`/`lines_changed` totals are documented as "main plus every -subagent transcript" (`line-counts` spec), so narrowing the cohort would silently -shrink a rendered number — exactly the behavioural change this change forbids. - -### 8. Pruning and the terminal flag - -On `save()`: drop entries whose path no longer exists, and entries whose `seen` -is older than `CACHE_KEEP_SECONDS` (default 24 h — comfortably beyond -`ABANDONED_HORIZON_SECONDS`), following `RenderTiming.KEEP`. Entries flagged -`terminal` (agent finished and older than the abandoned horizon) are kept; the -flag lets `from_session` skip re-stating them beyond the single stat it already -does. This is recommendation #4, scoped to the cache only. - -### 9. Config knob `transcript_cache`, default on - -Five-touch-point boolean in `claude/yas/config.py` (slots list `:354`, typed -attribute `:372`, `__init__` + setter `:396/:419`, `__repr__` `:440`, `_resolve` -`:534`), env `YAS_TRANSCRIPT_CACHE`, TOML `[cache].transcript_cache`, documented -in `yas.example.toml`. When false, `app` passes `None` and every reader takes the -existing uncached path — the one-line rollback for a suspected staleness bug. - -### 10. Atomic-enough writes - -Write to `.tmp` then `os.replace`, so a render killed mid-write leaves the -previous good file rather than a truncated one. Combined with the version stamp -and the blanket `except Exception -> empty cache` on load, there is no corrupt -state that survives one render. - -## Risks / Trade-offs - -- **[A same-second write is invisible to `(mtime, size)`]** → `st_mtime` is a - float with sub-second resolution on every filesystem YAS targets, and an append - always changes `size`. A truncate-and-rewrite to exactly the same size within - the same mtime tick is the only blind spot; transcripts are append-only, so - this is accepted. -- **[Stale cache renders stale numbers]** → validity is exact-match; every stored - value is re-derivable; the knob disables the cache outright; the version stamp - invalidates on any shape change. -- **[Concurrent renders of the same session race on the cache file]** → - last-writer-wins with `os.replace`; both writers hold correct supersets of the - truth, so a lost write costs one re-parse, never a wrong value. -- **[The totals-only stub leaks into a rendered row]** → the post-`visible()` - re-parse check (Decision 6) makes it unobservable; a test asserts a - deliberately-mispredicted agent still renders its full model and last activity. -- **[Cache load itself costs time on a fresh session]** → one `open` + `json.loads` - of a file that is absent or a few hundred bytes; guard by returning immediately - when the file does not exist, and measure the SMALL-session payload before and - after (task 7.4). -- **[`json.loads` of a 48-entry cache is not free on the BIG session]** → the file - holds derived scalars, not transcript text; expected well under 100 KB versus - 23.5 MB re-read. Task 7.3 measures it. diff --git a/openspec/changes/cache-transcript-parses/proposal.md b/openspec/changes/cache-transcript-parses/proposal.md deleted file mode 100644 index 6ce0ae7..0000000 --- a/openspec/changes/cache-transcript-parses/proposal.md +++ /dev/null @@ -1,101 +0,0 @@ -## Why - -A statusline render is a fresh process, so the carefully-built in-memory tail -caches in `claude/yas/info/subagents.py` never survive a tick: every render -re-reads every subagent transcript from byte 0, twice (once for -`parse_transcript`, once for `count_transcript`). A measured session with 48 -accumulated subagents (23.5 MB of `agent-*.jsonl`) spends **319 ms** per render — -137 ms in `RunningSubagents.from_session`, 126 ms in `ToolCounts.gather` — to -produce a subagent section with **zero visible agents**, while a fresh session in -the same project renders in 61 ms (40 ms of which is interpreter + import). The -cost is linear in agents-ever-spawned and never goes down, so every long session -gets permanently slower after each subagent burst. A finished agent's transcript -is immutable; re-deriving its token totals and tool counts from raw JSON on every -tick is pure waste. - -## What Changes - -- Add a **per-session, on-disk transcript parse cache** (new module - `claude/yas/info/parsecache.py`) holding, per transcript path, everything the - render derives from that file, validated by `(mtime, size)`: - - the `parse_transcript` 8-tuple (sub-keyed by `resume_after`), - - the notification tail state (`offset` + `_Notification` list) and the - tool-result tail state (`offset` + `tool_use_id -> (status, ts)` map), - - the `count_transcript` `TranscriptToolStats` (sub-keyed by - `(clear_epoch, skip_sidechain)`). - One JSON file per session under `CLAUDE_DIR / 'yas-cache'`, loaded once at the - start of a render and written once at the end by `app` — `SessionView` stays - write-free. -- **Seed the existing in-memory tail caches from disk** so `_tail_read_notifications` - (`subagents.py:134`) and `_tail_read_tool_results` (`subagents.py:238`) resume - from the cached byte offset instead of 0. Their existing incremental algorithm - is unchanged; only its starting state changes. -- **`parse_transcript` and `count_transcript` become cache-backed**: an exact - `(mtime, size)` match on a fully-keyed entry returns the stored result with no - file open. In the measured session that is 47 of 48 agents plus (partially) the - main transcript. -- **Cold-cache fallback for retired agents:** `RunningSubagents.from_session` - (`subagents.py:942`) gains a cheap conclusively-retired predicate; a retired - agent with no cache entry is parsed in a new **totals-only** mode that - byte-pre-filters to `"usage"` lines and skips model/last-activity/tag - extraction. If such an agent nonetheless survives `visible()`, it is re-parsed - in full before rendering, so no rendered row can ever be built from a stub. -- **`ToolCounts.gather` (`toolcounts.py:351`) reuses the cache**, so the second - full byte-level pass over the same 23.5 MB disappears. `build_wide` - (`layout.py:915`) keeps forcing `view.tool_counts` — the lines segment needs the - session totals — but the forced work becomes dict lookups. -- **Bound the growth:** cache entries for transcripts that no longer exist, or - whose last-seen time is older than a retention horizon, are pruned on save, and - entries recorded as terminal-and-ancient carry a flag so the per-render - `*.meta.json` glob can skip re-stating them. -- New config knob `transcript_cache` (`YAS_TRANSCRIPT_CACHE`, `[cache]` table, - default on) to disable the cache entirely — the escape hatch for any suspected - staleness bug. -- **Fail-safe by construction:** a missing, unreadable, wrong-version, malformed - or partially-corrupt cache file behaves exactly like an empty cache (full - re-parse). Cache I/O never raises into the render path. - -## Capabilities - -### New Capabilities -- `transcript-parse-cache`: the on-disk per-session cache — what is stored, the - `(path, mtime, size)` validity key and the per-entry sub-keys (`resume_after`, - `clear_epoch`, `skip_sidechain`), incremental tail resumption from a cached - offset, the load-once/save-once lifecycle, pruning and retention, the config - knob, and the fail-safe-to-full-reparse rule. - -### Modified Capabilities -- `statusline-info`: the "`SessionView` performs no disk writes" guarantee is - restated to name the cache save as an `app`-owned step (not a view step), and - the `tool_counts` gather field is required to satisfy itself from the cache - when the transcripts are unchanged, still walking each changed file at most - once. -- `subagent-cohort`: cohort assembly SHALL be allowed to derive a conclusively - retired agent's fields via a totals-only parse, with the hard constraint that - visibility decisions and `session_inout` are byte-identical to a full parse, and - that any stubbed agent that turns out to be visible is re-parsed in full. -- `statusline-config`: adds the `transcript_cache` boolean knob with the standard - five-layer precedence. - -## Impact - -- `claude/yas/info/parsecache.py` — **new**: `TranscriptCache` (load / lookup / - record / save / prune), JSON codec for `_Notification` and the tail entries, - version stamp, `CLAUDE_DIR`-rooted paths. -- `claude/yas/info/subagents.py` — tail caches seeded from and written back to the - disk cache (`:70`, `:198`, `:134`, `:238`); `parse_transcript` cache-backed and - gaining a `totals_only` mode (`:391`); `from_session` widened stat (`:1006`, - keep `st_size`), retired predicate, stub-then-verify pass (`:942-1166`). -- `claude/yas/info/toolcounts.py` — `count_transcript` (`:94`) cache-backed; - `ToolCounts.gather` (`:351`) threads the cache through. -- `claude/yas/info/__init__.py` — `SessionView` owns the loaded cache instance and - passes it to the readers; still performs no writes. -- `claude/yas/app.py` — load the cache next to the existing statusline-output - write (`:88-100`) / `RenderTiming` read, and flush it once after the render. -- `claude/yas/config.py`, `claude/yas/constants.py`, `yas.example.toml` — the - `transcript_cache` knob and its retention/version constants. -- `test/conftest.py` — register the new module in the `tmp_home` `CLAUDE_DIR` - monkeypatch list; new `test/test_parse_cache.py`; extensions to - `test/test_running_subagents.py`, `test/test_tool_counts.py`, - `test/test_cohort_visibility.py`. -- No demo-fixture churn expected: rendering output is unchanged by design. diff --git a/openspec/changes/cache-transcript-parses/specs/statusline-config/spec.md b/openspec/changes/cache-transcript-parses/specs/statusline-config/spec.md deleted file mode 100644 index 46c6ae9..0000000 --- a/openspec/changes/cache-transcript-parses/specs/statusline-config/spec.md +++ /dev/null @@ -1,33 +0,0 @@ -## ADDED Requirements - -### Requirement: Transcript-cache knob - -The statusline SHALL expose a `transcript_cache` boolean knob, resolved through -the standard precedence chain: canonical `YAS_TRANSCRIPT_CACHE` env var → -`[cache].transcript_cache` in `yas.toml` → default. The value SHALL be a boolean -parsed by the shared boolean parser (`0`, `false`, `no` are false; any other -non-empty value is true) and the default SHALL be `true`. An invalid value SHALL -fall back to the default like every other knob, and SHALL be reported through the -same visible config-error path. When the knob resolves false, the statusline -SHALL neither read nor write the transcript parse cache file and SHALL take the -uncached read path everywhere, producing identical rendered output. - -#### Scenario: Default is on - -- **WHEN** no `transcript_cache` is configured from any source -- **THEN** the resolved `transcript_cache` is `true` - -#### Scenario: Env var disables the cache - -- **WHEN** `YAS_TRANSCRIPT_CACHE=0` is set -- **THEN** the resolved `transcript_cache` is `false` and no cache file is read or written during a render - -#### Scenario: Env overrides toml - -- **WHEN** `YAS_TRANSCRIPT_CACHE=0` is set and `[cache].transcript_cache = true` is configured -- **THEN** the resolved `transcript_cache` is `false` - -#### Scenario: Invalid value falls back - -- **WHEN** `[cache].transcript_cache = "maybe"` is configured -- **THEN** the resolved value is the default `true` and a config error is recorded diff --git a/openspec/changes/cache-transcript-parses/specs/statusline-info/spec.md b/openspec/changes/cache-transcript-parses/specs/statusline-info/spec.md deleted file mode 100644 index 7b84fcf..0000000 --- a/openspec/changes/cache-transcript-parses/specs/statusline-info/spec.md +++ /dev/null @@ -1,82 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Lazy pure-read SessionView gather - -The statusline SHALL gather all *derived* session state through a single `SessionView` module (`claude/statusline/info.py`), constructed once per render from a parsed `SessionInfo` plus a `Config`. `SessionView` SHALL expose the derived state as lazily-evaluated, cached fields: `git`, `skills`, `subagents`, `tasks`, `transcript_usage`, `changes` (OpenSpec changes), `elapsed`, `session_cost`, `session_inout`, and `cache_countdown`. A field SHALL read its underlying source on first access and cache the result; a second access SHALL NOT re-read. Constructing a `SessionView` SHALL perform no source reads. `SessionView` SHALL perform no disk writes and SHALL NOT call `TokenLog.update` or `TokenRate.update`; in particular, the transcript parse cache SHALL be *loaded* before the view is constructed and *saved* by `app` after the render, never written by a view field. `SessionView` MAY hold a loaded transcript parse cache and pass it to the readers, since holding it performs no I/O. The `cache_countdown` field SHALL be derived from `transcript_usage`'s raw cache anchor and the view's single frozen `now`, reusing the already-cached transcript scan rather than re-reading the transcript. - -#### Scenario: A narrow render reads only what it draws - -- **WHEN** a `SessionView` is constructed and a narrow-width build reads only `view.subagents` -- **THEN** the git subprocess, the transcript scan, and the openspec walk are not triggered (only the subagent source is read) - -#### Scenario: A field is read at most once per view - -- **WHEN** `view.session_inout` and `view.transcript_usage` are both accessed on one `SessionView` -- **THEN** the transcript is scanned exactly once (the cached value feeds both) - -#### Scenario: Cache countdown reuses the cached transcript scan - -- **WHEN** `view.transcript_usage` and `view.cache_countdown` are both accessed on one `SessionView` -- **THEN** the transcript is scanned exactly once (the cached usage feeds both, and `cache_countdown` triggers no additional read) - -#### Scenario: Constructing a view writes nothing - -- **WHEN** a `SessionView` is constructed and any subset of its fields is accessed -- **THEN** no token-log, token-rate, or transcript-parse-cache file is written by the view - -### Requirement: Tool-counts gather field - -`SessionView` SHALL expose a `tool_counts` `@cached_property` returning a -`ToolCounts` value that holds, per tool name, the `(main, sub)` `tool_use` counts -and the total number of distinct tool types. The same value SHALL additionally -hold the session's `lines_read` and `lines_changed` totals (the main transcript -plus every subagent transcript) and a per-transcript breakdown keyed by transcript -path, so a caller can look up any one subagent's own figures. It SHALL be -constructed from the main -transcript, the subagent cohort, and `clear_epoch` — all fields already available -on the view — and SHALL perform no I/O beyond reopening those same transcript -files, walking each file exactly once for both the tool counts and the line -counts. A transcript whose counts are already held in the transcript parse cache -for the same `clear_epoch` and sidechain setting, and whose mtime and size are -unchanged, SHALL NOT be reopened at all; the totals and the per-transcript -breakdown SHALL be identical to those a full walk would produce. The cohort -covered by the totals SHALL remain the main transcript plus **every** subagent -transcript, not only the visible cohort. As a `@cached_property`, it SHALL be -computed at most once per view and SHALL NOT be evaluated when a render path never -reads it (narrow/medium). The `info` layer SHALL NOT import `renderer` or `layout` -to provide it. - -#### Scenario: Field exposes per-tool main/sub counts - -- **WHEN** a `SessionView` is constructed and `tool_counts` is read -- **THEN** it returns a `ToolCounts` whose per-tool entries each carry a `main` and - a `sub` count derived from the main transcript and the subagent cohort - respectively - -#### Scenario: Field exposes session line totals - -- **WHEN** `tool_counts` is read -- **THEN** it also exposes `lines_read` and `lines_changed` totalled over the main - transcript and every subagent transcript - -#### Scenario: Field exposes a per-transcript breakdown - -- **WHEN** a caller has a subagent's transcript path -- **THEN** it can obtain that subagent's own `(lines_read, lines_changed)` pair - from the same `ToolCounts` value - -#### Scenario: Field is satisfied from the cache when nothing changed - -- **WHEN** every transcript's counts are cached under the current `clear_epoch` and their mtime and size are unchanged -- **THEN** `tool_counts` opens no transcript file and returns the same value a full walk would produce - -#### Scenario: Field is lazy - -- **WHEN** a narrow or medium render is produced without reading `tool_counts` -- **THEN** the tool-counts aggregation is never computed - -#### Scenario: Field respects the clear window - -- **WHEN** `clear_epoch` is set on the view -- **THEN** `tool_counts` reflects only `tool_use` messages at or after that epoch, - and the line totals reflect only activity at or after that epoch diff --git a/openspec/changes/cache-transcript-parses/specs/subagent-cohort/spec.md b/openspec/changes/cache-transcript-parses/specs/subagent-cohort/spec.md deleted file mode 100644 index 057328c..0000000 --- a/openspec/changes/cache-transcript-parses/specs/subagent-cohort/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -## ADDED Requirements - -### Requirement: Conclusively-retired agents may be parsed in totals-only mode - -Cohort assembly SHALL be permitted to reduce work for retired agents as follows. -When it has no cached parse for an agent's transcript and that agent -is *conclusively retired* — it has a terminal status from the cheap -notification/tool-result signals, its `end_ts` is older than both the finished -linger and cohort grace windows, and its transcript mtime is older than the -abandoned horizon, each with a skew margin — the statusline MAY derive that -agent's fields with a reduced, totals-only parse instead of a full parse. A -totals-only parse SHALL still produce exact `billed_in`, `cache_read_in`, -`output`, `first_timestamp`, `end_ts` and `run_start_ts` values, so that the -Session In/Out denominator and every retirement decision are identical to those a -full parse would yield. It MAY omit only fields that a rendered row consumes — -the model and the last-activity triple. - -#### Scenario: Retired agent contributes exact token totals - -- **WHEN** a conclusively-retired agent is built with a totals-only parse -- **THEN** its `total_input` and `output` equal the values a full parse produces -- **AND** the Session In/Out denominator is unchanged - -#### Scenario: Retirement timestamps are exact - -- **WHEN** a conclusively-retired agent is built with a totals-only parse -- **THEN** its `first_timestamp`, `end_ts` and `mtime` equal the full-parse values, so visibility decisions are unchanged - -#### Scenario: A live agent is never reduced - -- **WHEN** an agent's transcript was written within the abandoned horizon, or it has no terminal status, or it finished within the grace windows -- **THEN** it is parsed in full - -### Requirement: A totals-only agent that turns out visible is re-parsed in full - -Cohort assembly SHALL record which agents were built with a totals-only parse. -If any such agent appears in the visible cohort, the statusline SHALL re-parse -that agent's transcript in full and rebuild its record before any row is -rendered. No rendered row SHALL ever be built from a totals-only record. - -#### Scenario: Mispredicted retirement still renders correctly - -- **WHEN** an agent built totals-only is nonetheless returned by `visible()` -- **THEN** it is re-parsed in full and its row shows the same model, last activity and figures as a fully-parsed render - -#### Scenario: The common case costs nothing extra - -- **WHEN** no totals-only agent is visible -- **THEN** no transcript is re-parsed diff --git a/openspec/changes/cache-transcript-parses/specs/transcript-parse-cache/spec.md b/openspec/changes/cache-transcript-parses/specs/transcript-parse-cache/spec.md deleted file mode 100644 index 994f6de..0000000 --- a/openspec/changes/cache-transcript-parses/specs/transcript-parse-cache/spec.md +++ /dev/null @@ -1,208 +0,0 @@ -## ADDED Requirements - -### Requirement: Per-session on-disk transcript parse cache - -The statusline SHALL persist, between renders, everything it derives from a -transcript file, in a single JSON cache file per session located under -`CLAUDE_DIR / 'yas-cache'` and named for the session id. The file SHALL carry a -version stamp, the session id, a save timestamp, and a map keyed by absolute -transcript path. Because the statusline re-execs per render, this file SHALL be -the only mechanism by which derived transcript state survives a tick; no -behaviour SHALL depend on a process-lifetime cache persisting. - -#### Scenario: Cache file is written after a render - -- **WHEN** a render completes with the cache enabled and at least one transcript parsed -- **THEN** a JSON cache file for that session exists under `CLAUDE_DIR / 'yas-cache'` -- **AND** it contains a version stamp and one entry per transcript read during the render - -#### Scenario: Second render reads without reopening unchanged transcripts - -- **WHEN** a render completes and a second render runs with no transcript file modified -- **THEN** no `agent-*.jsonl` file is opened during the second render -- **AND** the second render produces byte-identical output to the first - -### Requirement: Cached entries are keyed by path plus mtime and size - -Each cache entry SHALL record the transcript's `st_mtime` and `st_size` as -observed when the entry was written. A stored whole-file result SHALL be reused -only when the current `st_mtime` and `st_size` are exactly equal to the recorded -values. Any difference — larger, smaller, or a changed mtime at equal size — SHALL -be treated as a miss and SHALL cause the file to be re-read. - -#### Scenario: Unchanged file hits - -- **WHEN** a transcript's mtime and size match its cache entry -- **THEN** the stored result is returned and the file is not opened - -#### Scenario: Appended file misses - -- **WHEN** a transcript has grown since its entry was written -- **THEN** the whole-file entry is not reused for that transcript - -#### Scenario: Truncated file misses - -- **WHEN** a transcript is smaller than its recorded size -- **THEN** the whole-file entry is not reused and the file is re-read from the start - -### Requirement: Parse-input sub-keys are part of the cache key - -Results whose value depends on a caller-supplied input SHALL be stored under a -sub-key naming that input, and SHALL be reused only for an identical input. The -`parse_transcript` result SHALL be sub-keyed by its `resume_after` argument, -because `resume_after` determines `run_start_ts`. The `count_transcript` result -SHALL be sub-keyed by the pair `(clear_epoch, skip_sidechain)`, because both -change the counts. Float sub-keys SHALL round-trip exactly through the JSON file. -Each sub-key map SHALL be bounded, retaining only the most recent few entries per -transcript, so a drifting input cannot grow the file without bound. - -#### Scenario: Different resume_after does not hit - -- **WHEN** a cached parse exists for `resume_after = 0.0` and a parse is requested with `resume_after = 1700000000.0` -- **THEN** the cached result is not returned and the transcript is parsed - -#### Scenario: Different clear epoch does not hit - -- **WHEN** a cached count exists for one `clear_epoch` and counts are requested for another -- **THEN** the cached counts are not returned - -#### Scenario: Sidechain flag is part of the key - -- **WHEN** counts were cached with `skip_sidechain=True` and are requested with `skip_sidechain=False` for the same file -- **THEN** the cached counts are not returned - -### Requirement: Tail-read state resumes from the cached byte offset - -The cache SHALL store, per transcript, the notification tail state and the -tool-result tail state as `(mtime, size, offset, findings)`. At the start of a -render these SHALL be loaded into the existing in-memory tail caches so that the -existing incremental tail-read algorithm resumes from the stored byte offset -rather than from byte 0. The algorithm itself — its hit test, its shrunk-file -rescan, and its "stop at the last complete newline" rule — SHALL be unchanged. A -transcript that has grown SHALL be read only from the cached offset to the end of -file. - -#### Scenario: Grown transcript is read incrementally - -- **WHEN** a cached tail offset exists for a transcript and new lines have been appended -- **THEN** only the appended bytes are read -- **AND** the resulting findings equal those of a full read of the whole file - -#### Scenario: Shrunk transcript is rescanned - -- **WHEN** a cached tail offset exists and the transcript is now smaller than the recorded size -- **THEN** the transcript is rescanned from byte 0 and the cached findings are discarded - -#### Scenario: Partial trailing line is not consumed - -- **WHEN** the appended bytes end without a newline -- **THEN** the stored offset does not advance past the last complete line - -### Requirement: Cache is loaded once and saved once per render - -The cache SHALL be loaded exactly once at the start of a render by the -application layer and passed to the readers, and SHALL be written back exactly -once after the render completes. Readers SHALL NOT write the cache file. The -cache instance SHALL be an optional argument to every reader it serves, -defaulting to absent, and an absent cache SHALL select exactly today's uncached -behaviour. - -#### Scenario: One write per render - -- **WHEN** a render reads forty-eight transcripts -- **THEN** the cache file is written exactly once - -#### Scenario: Readers work without a cache - -- **WHEN** a reader is called with no cache argument -- **THEN** it performs its full read and returns the same result as before this change - -### Requirement: Cache failures degrade to a full re-parse - -Every cache failure SHALL degrade to a full re-parse. A missing, unreadable, -truncated, non-JSON, wrong-version, or structurally -invalid cache file SHALL be treated as an empty cache. An individual entry that -fails to decode SHALL be discarded without discarding the rest of the file. No -cache read or write error SHALL propagate into the render path or alter rendered -output. Cache writes SHALL be made to a temporary file and moved into place, so -that a render interrupted mid-write leaves the previous file intact. - -#### Scenario: Corrupt file is ignored - -- **WHEN** the cache file contains invalid JSON or truncated content -- **THEN** the render proceeds with a full re-parse and produces correct output -- **AND** the render does not raise - -#### Scenario: Version bump invalidates - -- **WHEN** the cache file's version stamp differs from the current version -- **THEN** the whole file is discarded and every transcript is re-parsed - -#### Scenario: One bad entry does not poison the file - -- **WHEN** a single entry has a malformed stored result -- **THEN** that transcript is re-parsed and the remaining entries are still used - -#### Scenario: Interrupted write leaves the previous file - -- **WHEN** a render is killed while the cache is being written -- **THEN** the previously saved cache file is still valid and loadable - -### Requirement: Cache contents are pruned and bounded - -On save, entries whose transcript path no longer exists SHALL be dropped, and -entries not seen within the retention horizon SHALL be dropped. Each entry SHALL -record when it was last seen. An entry for an agent that has reached a terminal -status and is older than the abandoned horizon MAY be flagged terminal so that -cohort assembly can avoid redundant work for it, and that flag SHALL never -suppress a transcript whose mtime or size has since changed. - -#### Scenario: Vanished transcripts are dropped - -- **WHEN** a cached transcript path no longer exists on disk at save time -- **THEN** its entry is not written to the new cache file - -#### Scenario: Ancient entries expire - -- **WHEN** an entry has not been seen within the retention horizon -- **THEN** it is dropped on the next save - -#### Scenario: A changed terminal-flagged file is still re-read - -- **WHEN** a terminal-flagged entry's transcript has a new mtime or size -- **THEN** the transcript is re-read and the entry is refreshed - -### Requirement: Cache is disableable by configuration - -A boolean configuration knob SHALL enable or disable the transcript parse cache, -resolved through the standard configuration precedence, defaulting to enabled. -When disabled, no cache file SHALL be read or written and every reader SHALL take -its uncached path, producing output identical to the enabled case. - -#### Scenario: Disabled cache performs no cache I/O - -- **WHEN** the knob is set false -- **THEN** no cache file is read or written during a render - -#### Scenario: Output is identical either way - -- **WHEN** the same session is rendered with the knob true and with it false -- **THEN** the rendered output is byte-identical - -### Requirement: Rendered output is invariant to cache state - -For any given set of transcripts and a fixed clock, the rendered statusline SHALL -be byte-identical whether the cache is cold, warm, partially warm, disabled, or -corrupt. The cache SHALL be a performance mechanism only, with no observable -effect on the visible agent cohort, token figures, tool counts, line counts, or -the Session In/Out denominator. - -#### Scenario: Cold and warm renders agree - -- **WHEN** a session is rendered with no cache file and again with a warm cache at the same frozen clock -- **THEN** both renders produce identical output - -#### Scenario: Partial warmth agrees - -- **WHEN** some transcripts are cached and others have changed since -- **THEN** the render matches a fully cold render at the same frozen clock diff --git a/openspec/changes/cache-transcript-parses/tasks.md b/openspec/changes/cache-transcript-parses/tasks.md deleted file mode 100644 index 2d99ff9..0000000 --- a/openspec/changes/cache-transcript-parses/tasks.md +++ /dev/null @@ -1,88 +0,0 @@ -# Tasks - -## 1. Constants and config knob - -- [x] 1.1 In `claude/yas/constants.py`, add `TRANSCRIPT_CACHE_VERSION = 1`, `TRANSCRIPT_CACHE_KEEP_SECONDS = 86400.0` (24 h — comfortably beyond `ABANDONED_HORIZON_SECONDS = 1800`), `TRANSCRIPT_CACHE_SUBKEY_MAX = 4` (max sub-keys retained per transcript per result kind) and `DEFAULT_TRANSCRIPT_CACHE = True`, each with a one-line comment. Do NOT add a `CLAUDE_DIR`-derived path constant here; the cache module derives its own from `CLAUDE_DIR` (so `conftest.py`'s monkeypatch works — task 6.1). -- [x] 1.2 In `claude/yas/config.py`, add the `transcript_cache` boolean knob at all five touch points, copying `show_render_time` exactly: the field-name tuple (~`:354`), the typed attribute declaration (~`:372`), the `__init__` keyword `transcript_cache: bool = DEFAULT_TRANSCRIPT_CACHE` (~`:396`) plus `s(self, 'transcript_cache', transcript_cache)` (~`:419`), the `__repr__` list (~`:440`), and the `_resolve(...)` block (~`:534`) using `_env_sources(env, 'YAS_TRANSCRIPT_CACHE') + toml_src(cache_tbl, 'transcript_cache')`, `_parse_bool`, default `DEFAULT_TRANSCRIPT_CACHE`. -- [x] 1.3 In the same `_resolve` region of `config.py`, add a `cache_tbl = _table(toml, 'cache')` lookup alongside the existing `layout` / `tokens` / `appearance` table lookups, following whatever helper those use verbatim. No CLI flag for this knob (it is a support/debug escape hatch, not a display option). -- [x] 1.4 In `yas.example.toml`, add a `[cache]` table documenting `# transcript_cache = true # bool; persist per-transcript parse results between renders (disable to force a full re-parse every tick)`. - -## 2. New module `claude/yas/info/parsecache.py` - -Model the class on `RenderTiming` (`claude/yas/tokens.py:274-330`): `CLAUDE_DIR`-rooted paths, `mkdir(parents=True, exist_ok=True)`, every I/O in `try/except`, a `KEEP`-style retention constant, no raising. - -- [x] 2.1 Module docstring: this is a pure performance cache; every stored value is re-derivable; any doubt about validity resolves to a miss; nothing here may ever change rendered output. -- [x] 2.2 `def cache_path(session_id: str) -> Path` returning `CLAUDE_DIR / 'yas-cache' / f'transcripts.{session_id}.json'`. Import `CLAUDE_DIR` at module level (a module-local name is required so `conftest.tmp_home` can monkeypatch it — task 6.1). -- [x] 2.3 `class TranscriptCache` with `__slots__ = ('session_id', '_entries', '_dirty')`. `_entries: dict[str, dict[str, object]]` keyed by `str(path)`. -- [x] 2.4 `@classmethod def load(cls, session_id: str) -> TranscriptCache` — returns an empty instance when the file is missing, unreadable, non-JSON, not a dict, has `v != TRANSCRIPT_CACHE_VERSION`, or has a `session` field that does not match. Blanket `except Exception` (matching `read_last_prompt_ts`, `subagents.py:15-33`). Per-entry decode is also individually guarded so one malformed entry is dropped without discarding the rest. -- [x] 2.5 `def _entry(self, path: str, st: os.stat_result) -> dict | None` — returns the stored entry only when `entry['mtime'] == st.st_mtime and entry['size'] == st.st_size`; otherwise drops the stale entry's whole-file results (`parse`, `counts`) but retains nothing stale, and returns `None`. Exact float comparison, no epsilon — mirror the existing tail hit-test at `subagents.py:150`. -- [x] 2.6 Parse accessors: - `def get_parse(self, path, st, resume_after) -> tuple | None` and - `def put_parse(self, path, st, resume_after, result: tuple) -> None`. - Sub-key is `repr(float(resume_after))` so floats round-trip exactly. On load, re-tuple the stored 8-list into `(int, int, int, float, str, (str, str, dict), float, float)` — validate the shape (length 8, element 5 a 3-sequence) and return `None` on any mismatch. On put, trim the sub-map to the newest `TRANSCRIPT_CACHE_SUBKEY_MAX` entries. -- [x] 2.7 Counts accessors: `get_counts(self, path, st, clear_epoch, skip_sidechain)` / `put_counts(...)` storing a `TranscriptToolStats`-shaped dict `{'counts': {...}, 'lines_read': int, 'lines_changed': int}`. Sub-key is `f'{clear_epoch!r}|{int(skip_sidechain)}'`. Same shape validation + sub-map trim. -- [x] 2.8 Tail-state accessors: `get_notif(self, path)` / `put_notif(self, path, mtime, size, offset, items)` and `get_tool_results(...)` / `put_tool_results(...)`. Include `_notif_to_json(n) -> list` / `_notif_from_json(seq) -> _Notification | None` codecs for the `__slots__` `_Notification` class (`subagents.py:73-84`, fields `task_id, tool_use_id, status, ts`) — import `_Notification` lazily inside the function to avoid a circular import with `subagents`. `tool_results` values round-trip `(status, ts)` through a 2-list and are re-tupled on load. -- [x] 2.9 `def mark_terminal(self, path: str) -> None` setting `entry['terminal'] = True`, and `def is_terminal(self, path: str, st) -> bool` returning True only when the flag is set AND `(mtime, size)` still match (so a changed file is always re-read). -- [x] 2.10 Every `put_*` stamps `entry['mtime']`, `entry['size']`, `entry['seen'] = time.time()` and sets `self._dirty = True`. -- [x] 2.11 `def save(self) -> None` — no-op when not `_dirty`. Prune first: drop entries whose path does not exist (`os.path.exists`) and entries whose `seen` is older than `TRANSCRIPT_CACHE_KEEP_SECONDS`. Then write `{'v': TRANSCRIPT_CACHE_VERSION, 'session': self.session_id, 'saved': time.time(), 'entries': self._entries}` to `.tmp` and `os.replace(tmp, path)`. Whole body in `try/except (OSError, TypeError, ValueError)`; best-effort unlink of the tmp file on failure. - -## 3. Wire the cache into `claude/yas/info/subagents.py` - -- [x] 3.1 Add an optional `cache: TranscriptCache | None = None` parameter to `_tail_read_notifications(path)` (`:134`) and `_tail_read_tool_results(path)` (`:238`). Before the existing hit-test, if the module-level dict has no entry for `str(path)` and `cache` has stored tail state for it, seed `_notif_tail_cache[str(path)]` / `_tool_result_tail_cache[str(path)]` with a `_TailCacheEntry` / `_ToolResultCacheEntry` built from the stored `(mtime, size, offset, findings)`. Do NOT otherwise touch the algorithm at `:145-184` / `:246-285`. -- [x] 3.2 In both readers, at the point where the module-level cache entry is stored (the end of the read), also call `cache.put_notif(...)` / `cache.put_tool_results(...)` with the same `(mtime, size, offset, findings)` when `cache is not None`. Note in a comment that the store happens even for the "no complete newline" branch, which deliberately keeps the OLD offset. -- [x] 3.3 Thread `cache` through `_collect_task_notifications(session_jsonl, subagents_dir, cache=None)` (call site `subagents.py:967`) to both tail readers. -- [x] 3.4 In `parse_transcript` (`:391`), add `*, cache: TranscriptCache | None = None, st: os.stat_result | None = None, totals_only: bool = False`. At the top: when `cache` is not None and `totals_only` is False, `st = st or jsonl.stat()` (guarded) and return `cache.get_parse(str(jsonl), st, resume_after)` if it hits. After a full parse, `cache.put_parse(...)`. **Never cache a `totals_only` result** — it has blanked fields and would poison a later full-fidelity read; add that as an inline comment. -- [x] 3.5 Implement `totals_only` inside `parse_transcript`: iterate the file in binary and skip any raw line not containing `b'"usage"'` before `json.loads` (same style as the `b''` pre-filter at `:176`), except that the FIRST and LAST complete lines are always decoded so `first_ts` / `end_ts` / `run_start_ts` stay exact. Skip model resolution, tag/regex extraction and last-activity tracking entirely; return `model=''` and `last_activity=('', '', {})` in the 8-tuple's positions 4 and 5. Every other element MUST equal the full-parse value — assert this in a test (7.2). -- [x] 3.6 Add `def _conclusively_retired(now, status, end_ts, mtime) -> bool` near the constants block (`:911-940`): True only when `status` is terminal AND `end_ts > 0` AND `now - end_ts > max(FINISHED_LINGER_SECONDS, COHORT_GRACE_SECONDS) + TERMINAL_SKEW_SECONDS` AND `now - mtime > ABANDONED_HORIZON_SECONDS + TERMINAL_SKEW_SECONDS`. Docstring must state it is a *conservative* predicate: false negatives are free, false positives are caught by task 3.10. -- [x] 3.7 In `RunningSubagents.from_session` (`:942`), add `cache: TranscriptCache | None = None` as a keyword parameter after `now`. -- [x] 3.8 Widen the stat at `:1006-1009`: keep the whole `st = jsonl.stat()` result (still `except OSError: continue`) and use `st.st_mtime` where `mtime` is used today, so `st` can be passed to `parse_transcript` and the cache accessors without a second `stat()` call. -- [x] 3.9 At the parse call site (`:1114-1116`), pass `cache=cache, st=st`. When the cache misses AND `_conclusively_retired(now, status, end_ts_signal, st.st_mtime)` holds — using the status/`end_ts` already derived from the tier-1/tier-2 maps at `:1035-1060`, before the parse — call with `totals_only=True` and record the agent id in a local `totals_only_ids: set[str]`. -- [x] 3.10 Store `totals_only_ids` on the returned `RunningSubagents` (new `__slots__` field, default empty frozenset). In `visible()` (`:1193`) — or in a small wrapper the caller uses — after the visible list is computed, if any returned agent's id is in `totals_only_ids`, re-run `parse_transcript(jsonl, boundary_ts)` in full for that agent, rebuild its `RunningSubagent`, replace it in both `self.subagents` and the returned list, and clear it from the set. Keep the re-parse idempotent (re-entering `visible()` must not re-parse again). -- [x] 3.11 Use `cache.mark_terminal(str(jsonl))` for agents that satisfy `_conclusively_retired`, and consult `cache.is_terminal(...)` only as an additional signal — it MUST NOT skip the `stat()` (the stat is what proves the flag is still valid). - -## 4. Wire the cache into `claude/yas/info/toolcounts.py` - -- [x] 4.1 Add `*, cache: TranscriptCache | None = None, st: os.stat_result | None = None` to `count_transcript(path, clear_epoch, *, skip_sidechain)` (`:94`). On entry (cache present, path truthy): `st = st or os.stat(path)` guarded by `except OSError`, then `cache.get_counts(path, st, clear_epoch, skip_sidechain)`; on a hit, return a `TranscriptToolStats` rebuilt from the stored dict without opening the file. After a full walk, `cache.put_counts(...)`. -- [x] 4.2 Add `cache: TranscriptCache | None = None` to `ToolCounts.gather(main_path, subagents, clear_epoch)` (`:351`) and pass it to every `count_transcript` call — the main transcript (`:367`, `skip_sidechain=True`) and the per-agent loop (`:378-395`, `skip_sidechain=False`). Keep the cohort exactly as it is: **every** subagent, not the visible subset (see design Decision 7). -- [x] 4.3 Do NOT change `claude/yas/layout.py`. Confirm by reading `layout.py:915-920` that the force of `view.tool_counts` and the existing "+2.9 ms accepted" comment still hold, and extend that comment with a pointer to this change (the cost is now cache lookups when the transcripts are unchanged). This is the only edit permitted in `layout.py`. - -## 5. Wire the lifecycle into `SessionView` and `app` - -- [x] 5.1 In `claude/yas/info/__init__.py`, give `SessionView.__init__` (`:78-86`) an optional `cache: TranscriptCache | None = None` parameter stored as `self.parse_cache`. Assigning it performs no I/O, so the "constructing a view performs no source reads" contract holds. -- [x] 5.2 Pass `cache=self.parse_cache` from the `subagents` `@cached_property` (`:96-101`) into `RunningSubagents.from_session`, and from `tool_counts` (`:122-140`) into `ToolCounts.gather`. Do NOT pass it to `workflows` (`:103-108`) in this change — out of scope, and `RunningWorkflows` has its own shape. -- [x] 5.3 Update the `SessionView` module/class docstrings to state that the view may hold a loaded cache but never writes it. -- [x] 5.4 In `claude/yas/app.py`, next to the existing `RenderTiming.read(session_id)` call (`:104-110`), add `parse_cache = TranscriptCache.load(session_id) if cfg.transcript_cache else None`, pass it into the `SessionView(...)` construction, and after the render output is produced call `parse_cache.save()` if it is not None. The save must be the last thing before/after the print — never between gather and render — and must not be able to delay or break output (it is already internally exception-guarded). -- [x] 5.5 Check `claude/mon.py` / `claude/mon/*.py` for direct `SessionView`, `RunningSubagents.from_session` or `ToolCounts.gather` construction. Because every new parameter is keyword-with-default, mon should need no change; if it constructs `SessionView` per session in a long-lived process, leave it uncached (its in-memory module caches already work there) and note that in a comment. - -## 6. Tests - -- [x] 6.1 `test/conftest.py:71-84` — add the new `parsecache` module to the `tmp_home` fixture's list of modules whose `CLAUDE_DIR` is monkeypatched (`yas.app`, `yas.config`, `yas.constants`, `yas.session`, `yas.info.subagents`, `yas.info.workflows`, `yas.tokens` → plus `yas.info.parsecache`). Without this, cache tests write into the real `~/.claude`. -- [x] 6.2 New `test/test_parse_cache.py`, using `tmp_home` plus `test/test_running_subagents.py`'s `_subagents_dir` / `_write_agent` helpers (`:19-37`; `_write_agent`'s `mtime=` argument drives `os.utime`, the existing lever for hit/miss tests): - (a) round-trip: put a parse result, `save()`, `load()`, get the identical tuple (including the nested `last_activity` triple re-tupled); - (b) mtime change → miss; size change at equal mtime → miss; both unchanged → hit; - (c) different `resume_after` → miss; different `clear_epoch` → miss; different `skip_sidechain` → miss; - (d) corrupt file (truncated JSON, `{}`, a JSON list, wrong `v`) → empty cache, no exception; - (e) one malformed entry among three → the other two still hit; - (f) prune: an entry whose path was deleted, and one with an ancient `seen`, are both gone after `save()`; - (g) `save()` leaves no `.tmp` file behind, and an existing good file survives a `save()` that raises mid-write (monkeypatch `os.replace` to raise). -- [x] 6.3 Tail resumption test: build an agent jsonl with N notification lines, run `_tail_read_notifications` with a cache, `save()`, clear the module-level `_notif_tail_cache`, append M more lines, re-run with a freshly loaded cache — assert the findings equal a full cold read and that only the appended bytes were read (monkeypatch/spy on `Path.open` or assert via the recorded offset). Repeat for `_tail_read_tool_results`. -- [x] 6.4 Equivalence test (the headline guarantee): build a fixture session with several agents, render/gather twice at a single frozen clock (`conftest.frozen_clock`) — once cold, once warm — and assert the `RunningSubagents` fields, `ToolCounts` totals and `per_agent` map, and `view.session_inout` are all equal. Add the partially-warm variant: touch one agent jsonl with new content between the runs. -- [x] 6.5 `totals_only` equivalence: for a fixture transcript, assert `parse_transcript(p, r, totals_only=True)` equals `parse_transcript(p, r)` in every element except positions 4 (`model`) and 5 (`last_activity`). Include a transcript with a resumed run so `run_start_ts` is non-trivial. -- [x] 6.6 Retirement tests in `test/test_cohort_visibility.py` style: (a) a conclusively-retired agent is built totals-only and its `total_input`/`output` still feed `session_inout` exactly; (b) an agent that is terminal but recent is NOT reduced; (c) the mispredict path — force `_conclusively_retired` to return True for an agent that `visible()` returns, and assert the rendered row carries the real model and last activity (task 3.10's re-parse). -- [x] 6.7 `test/test_tool_counts.py` — update for the new keyword-only `cache`/`st` parameters (they default, so existing calls should be untouched; assert that explicitly with one no-cache test) and add a cached-hit test asserting `count_transcript` does not reopen the file (monkeypatch `open` to raise). -- [x] 6.8 `test/test_config.py` — the `transcript_cache` knob: default true, env false, env-over-toml, invalid value falls back and records an error. -- [x] 6.9 `test/test_info.py` — assert `SessionView` still writes nothing when a cache is attached and fields are accessed (the cache file appears only after an explicit `save()`). - -## 7. Verification and measurement - -- [x] 7.1 `uv run ruff check` clean; full `uv run pytest -q` green. Run via the `verifier` agent, not inline. -- [x] 7.2 Visual gate: `make demo/img` then `.claude/skills/yas-demo-text/scripts/demo-text.sh` and diff `demo/text/*.txt`. **Expect zero diff** — a non-empty diff means a behavioural regression, not a fixture to re-golden. -- [x] 7.3 Measure the BIG payload (the persisted `~/.claude/statusline-output/statusline..json` for a subagent-heavy session, replayed via `python3 claude/statusline_command.py < payload` with `COLUMNS=140`) with `hyperfine --warmup 2`: record cold-cache and warm-cache means. Target warm ≈ 60–80 ms against the recorded 318.6 ms ± 24.8 baseline. Record both numbers in the change's completion notes. -- [x] 7.4 Measure the SMALL (fresh-session) payload the same way; assert no regression against the 61.1 ms ± 4.8 baseline (the cache-file miss must cost effectively nothing). -- [x] 7.5 Sanity-check the cache file size for the 48-agent session (`ls -l` the written file) and note it in the completion notes; if it exceeds ~250 KB, revisit `TRANSCRIPT_CACHE_SUBKEY_MAX` and whether `last_activity`'s arbitrary tool-input dict should be stored truncated. -- [x] 7.6 Byte-equality check outside pytest: render the BIG payload cold and warm at a frozen clock and `diff` the two outputs — they must be identical. - -## 8. Documentation - -- [x] 8.1 `CONTEXT.md` — add a glossary entry for the **Transcript Parse Cache** (what it stores, its `(path, mtime, size)` key, that it is purely a performance mechanism, where the file lives, and the `transcript_cache` knob), in the house voice with an `_Avoid_:` line distinguishing it from the **Cache Read** token figure and the **Cache Countdown**. -- [x] 8.2 `claude/yas/info/subagents.py` module docstring — replace any claim that the tail caches are process-local-only with a pointer to `parsecache` and the warm-start behaviour. diff --git a/openspec/changes/consolidate-claude-dir-layout/.openspec.yaml b/openspec/changes/consolidate-claude-dir-layout/.openspec.yaml deleted file mode 100644 index f161d5c..0000000 --- a/openspec/changes/consolidate-claude-dir-layout/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-08-16 diff --git a/openspec/changes/consolidate-claude-dir-layout/design.md b/openspec/changes/consolidate-claude-dir-layout/design.md deleted file mode 100644 index 3a93535..0000000 --- a/openspec/changes/consolidate-claude-dir-layout/design.md +++ /dev/null @@ -1,187 +0,0 @@ -## Context - -Every YAS path today is composed ad hoc at its use site from a module-level `CLAUDE_DIR` constant: - -| Legacy path | Code | -|---|---| -| `statusline-tokens.log` | `tokens.py:116` | -| `statusline-token-rate.log` | `tokens.py:171, 207, 248` | -| `statusline-render.log` | `tokens.py:292, 308` | -| `statusline-output/statusline..json` | write `app.py:100-102`, read `mon/discovery.py:45` | -| `yas.toml.cache` | `config.py:254` | -| `statusline-theme` | `config.py:154-160` | -| `yas-last-prompt.json` | read `info/subagents.py:36`, write `hooks/yas-prompt-hook.py:23-24` | -| `terminal-width` | read `render/text.py:36`, write `ops/alacritty.py:26` (hardcoded `$HOME`) | - -`CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(HOME / '.claude')))` is declared at `constants.py:14` and duplicated verbatim at `session.py:21-22`. Because it is a module-level constant frozen at import, `test/conftest.py:72-86`'s `tmp_home` fixture must monkeypatch it in **eight** modules; every new module that touches disk has to be added there or its tests write to the real `~/.claude`. - -Constraints: -- The statusline runs on every render tick; the hot path must not pay for migration after the first run. -- `hooks/yas-prompt-hook.py` is executed standalone by Claude Code with no `yas` package on `sys.path` — it cannot import `constants`. -- `ops/install.sh` is POSIX-ish bash and already owns legacy cleanup (`:825-882` install, `:966-979` uninstall) and the `yas.toml` writer (`:1144-1240`). -- Demo (`ops/demo.py:1670`) and installer preview (`install.sh:1114`) already point `CLAUDE_CONFIG_DIR` at scratch dirs, so they get the new layout for free. - -## Goals / Non-Goals - -**Goals:** -- One `yas/` subtree under `$CLAUDE_CONFIG_DIR` holding everything YAS owns except `yas.toml`. -- A visible cache/state split so `yas/cache/` is documented as safe to `rm -rf` at any time. -- Existing users migrated automatically, with no data the user cares about lost. -- One patch point for tests; one place in source that knows a path. -- A real uninstall that leaves no YAS files behind except the user's `yas.toml`. - -**Non-Goals:** -- XDG base directories (`$XDG_STATE_HOME` etc.). Rejected: Claude Code itself centralises on `$CLAUDE_CONFIG_DIR`, and a second root would make "where are my files" worse, not better. -- Changing any rendered output, config key, or knob semantics. -- Migrating or relocating Claude Code's own files (`settings.json`, `projects/`, `plugins/`). -- Backwards-compatible dual reads (read new, fall back to old at every call site). The migration makes them unnecessary. -- File locking or cross-process coordination. - -## Decisions - -### 1. Root is `$CLAUDE_CONFIG_DIR/yas/`; `yas.toml` stays put - -Target layout: - -``` -$CLAUDE_DIR/ - yas.toml # user config — NOT moved - yas/ - cache/ # regenerable; safe to delete at any time - config.toml.cache # was yas.toml.cache - state/ - version.json # {schema_version, yas_version, migrated_at} - runtime/ # yas writes and reads these - tokens.log # was statusline-tokens.log - token-rate.log # was statusline-token-rate.log - render.log # was statusline-render.log - signals/ # written by external processes, read by yas - last-prompt.json # was yas-last-prompt.json (UserPromptSubmit hook) - terminal-width # was terminal-width (ops/alacritty.py) - sessions/.json # was statusline-output/statusline..json -``` - -`yas.toml` stays at `$CLAUDE_DIR/yas.toml` because it is the one path users type, document, and share; moving it would break every existing README, blog post, and dotfile repo. Its *cache*, being ours and regenerable, does move (and is renamed `config.toml.cache` since it no longer sits beside the file it caches). - -`signals/` is separated from `runtime/` because those two files are the only ones written by a process that is not the renderer (the prompt hook, and a terminal integration script); the boundary documents which paths a third party may write. - -### 2. Central path API in `constants.py`, resolved at call time - -No new module — the paths join the existing constants. Each path is a **function** that reads the module-global `CLAUDE_DIR` when called: - -```python -def yas_root() -> Path: return CLAUDE_DIR / 'yas' -def cache_dir() -> Path: return yas_root() / 'cache' -def state_dir() -> Path: return yas_root() / 'state' -def runtime_dir() -> Path: return state_dir() / 'runtime' -def signals_dir() -> Path: return state_dir() / 'signals' -def sessions_dir() -> Path: return state_dir() / 'sessions' -def version_file() -> Path: return state_dir() / 'version.json' -def config_path() -> Path: return CLAUDE_DIR / 'yas.toml' -def toml_cache_path() -> Path: return cache_dir() / 'config.toml.cache' -def tokens_log() -> Path: return runtime_dir() / 'tokens.log' -def token_rate_log() -> Path: return runtime_dir() / 'token-rate.log' -def render_log() -> Path: return runtime_dir() / 'render.log' -def last_prompt_path() -> Path: return signals_dir() / 'last-prompt.json' -def terminal_width_path() -> Path: return signals_dir() / 'terminal-width' -def session_payload_path(session_id: str) -> Path: return sessions_dir() / f'{session_id}.json' -def projects_dir() -> Path: return CLAUDE_DIR / 'projects' -def settings_path() -> Path: return CLAUDE_DIR / 'settings.json' -``` - -Functions over module-level `Path` constants: a constant would freeze at import exactly like today's `CLAUDE_DIR` and re-create the eight-module patching problem. Functions close over `constants.__dict__`, so patching `constants.CLAUDE_DIR` alone redirects every path in the process regardless of which module imported which helper. `conftest.tmp_home` therefore drops to a single `monkeypatch.setattr(_sl_constants, 'CLAUDE_DIR', claude_dir)`. - -Consequence: **no module outside `constants.py` may import `CLAUDE_DIR`.** `projects_dir()` and `settings_path()` exist purely so `subagents.py`, `workflows.py`, and `session.py` have no reason to. `session.py:21-22`'s duplicate declaration is deleted. - -Second consequence: default arguments evaluated at import (`mon/discovery.py:23,45`) must become `None` sentinels resolved inside the function body, otherwise a patched `CLAUDE_DIR` is ignored. - -`hooks/yas-prompt-hook.py` is the documented exception — it runs without the package on `sys.path`, so it keeps its self-contained resolution and hardcodes `/yas/state/signals/last-prompt.json`, with a comment pointing at `constants.last_prompt_path()` as the source of truth. Same for `ops/alacritty.py`, which additionally starts honouring `CLAUDE_CONFIG_DIR` instead of hardcoding `$HOME` (a latent bug: a `CLAUDE_CONFIG_DIR` user's width signal was written where nothing reads it). - -### 3. Per-file disposition: move what's durable, delete what regenerates - -| Legacy | Disposition | Rationale | -|---|---|---| -| `statusline-tokens.log` | **MOVE** → `state/runtime/tokens.log` | Day totals; losing it resets today's figure. | -| `yas-last-prompt.json` | **MOVE** → `state/signals/last-prompt.json` | Cross-process handshake; a stale-free rebuild needs a new user prompt. | -| `terminal-width` | **MOVE** → `state/signals/terminal-width` | Written by an external script that may not run again soon. | -| `statusline-token-rate.log` | **DELETE** | 300 s rolling window (`tokens.py:167`); self-heals in five minutes. | -| `statusline-render.log` | **DELETE** | 300 s rolling window; cosmetic, off by default. | -| `yas.toml.cache` | **DELETE** | Pure parse cache; regenerates on next render. | -| `statusline-output/` (whole dir) | **DELETE** | Payloads are rewritten every render tick; `mon` recovers within one tick per live session. | -| `statusline-theme` | **FOLD, then DELETE** | See decision 4. | - -Deleting beats moving wherever the file regenerates within one render tick or one rolling window: a move is more code, more failure modes, and buys at most minutes of history. - -### 4. Retire the legacy `statusline-theme` file - -`config._legacy_theme_sources` (`config.py:154-160`) is deleted along with its entry in the theme-precedence chain. The migration preserves user intent instead: if `statusline-theme` exists, is non-empty, and `yas.toml` does **not** already set `[appearance] theme`, its value is written into `yas.toml`; then the file is deleted either way. - -The `yas.toml` write is owned exclusively by `ops/install.sh` (which already has an atomic, validating TOML writer at `:1144-1240`). The runtime migration never edits `yas.toml` — a render tick must not rewrite the user's config file, and a renderer that can only be reached through a working `yas.toml` read path has no business writing one. So a user who never re-runs the installer loses the deprecated theme file's value: acceptable, given it has been documented as deprecated (`README.md:123`) and the theme is a one-line re-set in `yas.toml`. - -### 5. Migration runs lazily at render time and eagerly at install time - -**Lazy (runtime).** `app.main` calls a single guard before anything else touches disk: - -```python -if not version_file().exists(): - from yas.migrate import migrate # imported only on the cold path - migrate() -``` - -Cost on the steady-state path is one `stat()` — well inside the render budget — and the `yas.migrate` import is not paid at all once migrated. The guard and module are marked in-source as removable a few releases after ship (the deletion is a two-line diff plus one file). - -**Eager (installer).** `ops/install.sh do_wire` runs the same migration through the resolved interpreter (`"$PYTHON_BIN" -c 'from yas.migrate import migrate; migrate()'` with `PYTHONPATH="$PLUGIN_ROOT/claude"`), immediately after the existing legacy `statusline-info-*` sweep. The installer additionally performs the `statusline-theme` → `yas.toml` fold (decision 4) *before* invoking the migration, so the migration only has to delete the file. A migration failure inside the installer is reported and non-fatal — the lazy path will retry on the next render. - -Both paths, rather than one: the installer gives a clean, observable, one-shot migration for the common upgrade route, while the lazy path covers users who update the plugin through `claude plugin update` without ever running `install.sh`. - -### 6. `version.json` is the completion marker, written last and atomically - -```json -{"schema_version": 1, "yas_version": "0.8.0", "migrated_at": 1770000000.0} -``` - -Written via `mkstemp` in `state/` + `os.replace`, as the final step of `migrate()`. `schema_version` is the layout contract version (bumped by any future relayout); `yas_version` is `constants.VERSION` at migration time, for support/debugging; `migrated_at` is epoch seconds. - -Since it is written last, a crash at any earlier point leaves the marker absent and the next run re-runs the whole migration — which is safe because every step is idempotent (decision 7). Presence of the file is the *only* thing the runtime guard checks; its contents are never parsed on the hot path. - -### 7. Concurrency: idempotent, per-file atomic, no locks - -Every step is one of: -- `mkdir(parents=True, exist_ok=True)` for the six directories, -- a move that is skipped when the destination already exists, otherwise `os.rename(src, dst)` — atomic within a filesystem, and never clobbering, -- a delete that tolerates `FileNotFoundError` (`unlink(missing_ok=True)` / `shutil.rmtree(..., ignore_errors=True)`). - -Every individual step is wrapped so that an `OSError` is swallowed and recorded but does not abort the remaining steps — except that any failure means `version.json` is **not** written, so the migration retries next run. - -Two processes racing (a render tick and the installer, or two sessions) is therefore harmless: the loser's rename fails with the destination already present and is skipped, and both write the same marker content. No lock file, no lock-file staleness problem. - -Accepted trade-off: during the upgrade window an *old* renderer process may still be writing `statusline-tokens.log` while a new one writes `state/runtime/tokens.log`. The worst case is a day-total figure that undercounts a handful of ticks, and it resolves the moment every process is on the new code. Not worth a compatibility shim. - -### 8. Uninstall removes the subtree and every legacy path - -`ops/install.sh do_uninstall` extends the existing `statusline-info-*` sweep to remove: -- `$CLAUDE_CONFIG_DIR/yas/` (recursive), and -- each legacy path from decision 3's table (both MOVE and DELETE rows, plus `statusline-theme`), - -and explicitly **never** touches `$CLAUDE_CONFIG_DIR/yas.toml` or anything owned by Claude Code. Under `--dry-run` each existing target is printed as `Would remove ` and nothing is deleted, matching the existing dry-run idiom at `:966-979`. - -## Risks / Trade-offs - -- **A user's day-total token count is lost if the tokens.log move fails** → the move is a same-filesystem `os.rename` (both paths are under `$CLAUDE_CONFIG_DIR`), the only realistic failure is a permissions problem that would equally break writing the new file; the counter self-heals from the next tick. -- **`mon` shows "(no active sessions)" briefly after upgrade** → `statusline-output/` is deleted, not moved, so payloads are absent until each session's next render tick (sub-second for an active session). Documented in the proposal as an accepted, self-healing regression. -- **Deprecated theme file silently stops working for non-installer upgraders** → mitigated by the installer fold; residual risk accepted per decision 4, and `README.md`/`CONTEXT.md` gain an explicit "removed in this release, set `[appearance] theme` in yas.toml" note. -- **The lazy guard is dead weight forever if nobody removes it** → the guard, the `yas/migrate.py` module, and the legacy-path table carry an in-source `# REMOVE AFTER ` marker naming the release, and a task in this change adds that marker. -- **A test that forgets the `tmp_home` fixture now writes to the real `~/.claude/yas/`** → unchanged in kind from today, but the blast radius is smaller (one subtree) and a session-scoped autouse safety net is out of scope here. - -## Migration Plan - -1. Ship the new path API and rewire every reader/writer in one release (no dual-read period). -2. On first render after upgrade the lazy guard fires; on `install.sh` re-run the eager path fires first. -3. `version.json` marks completion; subsequent runs pay one `stat()`. -4. A later release deletes `yas/migrate.py`, the guard in `app.main`, and the legacy tables in `install.sh`. Users who skip that many releases can re-run `install.sh`, or lose only regenerable data. - -Rollback: downgrading to a pre-change release makes YAS write the legacy paths again from scratch (all of them are created on demand); the `yas/` subtree is left orphaned and can be deleted by hand. - -## Open Questions - -- Whether the day-total tokens log should be pruned to the current day during the move (it is a whole-file rewrite per render anyway). Deferred: this change moves bytes, it does not change formats. diff --git a/openspec/changes/consolidate-claude-dir-layout/proposal.md b/openspec/changes/consolidate-claude-dir-layout/proposal.md deleted file mode 100644 index c3b8ca6..0000000 --- a/openspec/changes/consolidate-claude-dir-layout/proposal.md +++ /dev/null @@ -1,37 +0,0 @@ -## Why - -YAS scatters seven files and one directory directly across `$CLAUDE_CONFIG_DIR` (`~/.claude` by default): `statusline-tokens.log`, `statusline-token-rate.log`, `statusline-render.log`, `statusline-theme`, `terminal-width`, `yas-last-prompt.json`, `yas.toml.cache`, and `statusline-output/`. They sit next to Claude Code's own `settings.json`, `projects/`, and `plugins/`, with no naming discipline, no separation of regenerable cache from durable state, and — apart from a one-line `statusline-info-*` sweep — no cleanup on uninstall. A user cannot tell which files are ours, which are safe to delete, or how to remove YAS's footprint. - -## What Changes - -- All YAS-owned files move under a single `$CLAUDE_CONFIG_DIR/yas/` subtree, split into `cache/` (regenerable, delete-anytime) and `state/` (`runtime/` yas-written logs, `signals/` externally-written inputs, `sessions/` render payloads). `yas.toml` stays at `$CLAUDE_CONFIG_DIR/yas.toml`. -- A one-shot, idempotent migration moves or deletes every legacy path. It runs in two places: lazily at render time (guarded by a single stat of `yas/state/version.json`) and eagerly from `ops/install.sh`. -- `yas/state/version.json` (`{schema_version, yas_version, migrated_at}`) is written last and atomically, and is the migration's completion marker. -- **BREAKING (internal paths)**: `statusline-token-rate.log`, `statusline-render.log`, `yas.toml.cache`, and `statusline-output/` are deleted rather than moved — the first two are 300-second rolling windows, the last two are regenerable caches/payloads. -- **BREAKING (deprecated feature removal)**: the legacy `statusline-theme` file is retired. Migration folds its value into `yas.toml` (only when `yas.toml` sets no theme), deletes the file, and `config._legacy_theme_sources` is removed from the theme-precedence chain. -- All YAS paths are defined centrally in `claude/yas/constants.py` as call-time path functions, so `test/conftest.py` patches one symbol (`constants.CLAUDE_DIR`) instead of eight module-local copies. `session.py`'s duplicate `CLAUDE_DIR` declaration is removed. -- `ops/alacritty.py` is fixed to honour `CLAUDE_CONFIG_DIR` (it currently hardcodes `$HOME/.claude`) and writes the new signals path. -- `ops/install.sh uninstall` removes the whole `yas/` subtree plus every legacy path, preserving `yas.toml`; `--dry-run` lists what it would remove. -- `claude/mon/discovery.py` reads render payloads from the new `yas/state/sessions/` directory. - -## Capabilities - -### New Capabilities - -- `claude-dir-layout`: the canonical on-disk layout of YAS-owned files under `$CLAUDE_CONFIG_DIR`, the central path API in `constants.py`, and the uninstall contract. -- `layout-migration`: the one-shot, idempotent, crash-safe migration from the legacy flat layout to the `yas/` subtree, its completion marker, and its two trigger points. - -### Modified Capabilities - -*(none — no existing spec under `openspec/specs/` owns these paths; the behaviour users observe from the statusline is unchanged.)* - -## Impact - -- `claude/yas/constants.py` — new path API (`yas_root()`, `cache_dir()`, `state_dir()`, `runtime_dir()`, `signals_dir()`, `sessions_dir()`, and per-file helpers). -- `claude/yas/app.py` (`statusline-output` write, `Config.load(config_dir=…)`, migration hook), `claude/yas/tokens.py` (three log paths), `claude/yas/config.py` (toml cache path, legacy theme removal), `claude/yas/session.py` (duplicate `CLAUDE_DIR`), `claude/yas/info/subagents.py` (last-prompt read + projects paths), `claude/yas/info/workflows.py` (projects path), `claude/yas/render/text.py` (terminal-width read). -- New module-free migration code in `claude/yas/` (single function, imported lazily by `app.main`). -- `claude/mon/discovery.py` — payloads root default. -- `hooks/yas-prompt-hook.py` — new signals path (stays self-contained, no yas import). -- `ops/install.sh` — eager migration + theme fold + uninstall sweep; `ops/alacritty.py` — config-dir resolution + new path. -- `test/conftest.py` — `tmp_home` patches one symbol; `test/test_mon_discovery.py`, token-log, parse-cache, config-theme, and subagent tests follow the new paths. New tests for migration. -- Docs: `README.md` (theme file deprecation at ~:123, terminal-width at ~:230), `CONTEXT.md` (:21, :24, :35, :78, :112). diff --git a/openspec/changes/consolidate-claude-dir-layout/specs/claude-dir-layout/spec.md b/openspec/changes/consolidate-claude-dir-layout/specs/claude-dir-layout/spec.md deleted file mode 100644 index 6b0c064..0000000 --- a/openspec/changes/consolidate-claude-dir-layout/specs/claude-dir-layout/spec.md +++ /dev/null @@ -1,109 +0,0 @@ -## ADDED Requirements - -### Requirement: All YAS-owned files live under a single `yas/` subtree - -Every file YAS creates under `$CLAUDE_CONFIG_DIR` SHALL live under `$CLAUDE_CONFIG_DIR/yas/`, with the sole exception of the user config file `yas.toml`, which SHALL remain at `$CLAUDE_CONFIG_DIR/yas.toml`. The subtree SHALL use this layout: - -``` -yas/cache/config.toml.cache -yas/state/version.json -yas/state/runtime/tokens.log -yas/state/runtime/token-rate.log -yas/state/runtime/render.log -yas/state/signals/last-prompt.json -yas/state/signals/terminal-width -yas/state/sessions/.json -``` - -YAS SHALL NOT create or write any other file directly in `$CLAUDE_CONFIG_DIR`, and SHALL NOT modify files owned by Claude Code (`settings.json` outside the installer, `projects/`, `plugins/`). - -#### Scenario: Renderer writes only inside the subtree - -- **WHEN** a full render tick runs against an empty `$CLAUDE_CONFIG_DIR` -- **THEN** every file created is under `$CLAUDE_CONFIG_DIR/yas/` -- **AND** no `statusline-*` file, `statusline-output/` directory, `terminal-width` file, or `yas.toml.cache` is created at the top level of `$CLAUDE_CONFIG_DIR` - -#### Scenario: Config file is not relocated - -- **WHEN** the user's config lives at `$CLAUDE_CONFIG_DIR/yas.toml` -- **THEN** YAS reads it from that exact path -- **AND** never creates `$CLAUDE_CONFIG_DIR/yas/yas.toml` - -#### Scenario: Missing directories are created on demand - -- **WHEN** a writer (token log, session payload, toml cache) runs and its parent directory does not exist -- **THEN** the directory is created with `parents=True, exist_ok=True` and the write succeeds - -### Requirement: Cache directory is safe to delete at any time - -`$CLAUDE_CONFIG_DIR/yas/cache/` SHALL contain only regenerable data (the parsed-config cache and per-session transcript parse caches). Deleting the directory, or any file in it, SHALL NOT change rendered output, only performance. - -#### Scenario: Cache deleted between renders - -- **WHEN** `yas/cache/` is removed entirely and a render tick runs -- **THEN** the rendered output is byte-identical to the same render with the cache present -- **AND** the cache files are recreated - -### Requirement: Central path API resolved at call time - -All YAS paths SHALL be defined by functions in `claude/yas/constants.py` that read the module-global `CLAUDE_DIR` at call time. No module other than `constants.py` SHALL import or hold its own copy of `CLAUDE_DIR`, and no module SHALL evaluate a YAS path at import time (including as a default argument value). - -#### Scenario: One patch point redirects every path - -- **WHEN** a test patches only `yas.constants.CLAUDE_DIR` to a temporary directory -- **THEN** every subsequent read and write from the renderer, the token logs, the caches, the session payloads, and the `mon` observer resolves under that temporary directory -- **AND** no file is created under the real `~/.claude` - -#### Scenario: No duplicated root constants - -- **WHEN** the tree is searched for `CLAUDE_CONFIG_DIR` / `.claude` root resolution in Python source -- **THEN** the only occurrences are `claude/yas/constants.py`, `hooks/yas-prompt-hook.py`, and `ops/alacritty.py` (both standalone scripts that cannot import the package) - -### Requirement: Standalone writers honour CLAUDE_CONFIG_DIR and the new paths - -The `UserPromptSubmit` hook (`hooks/yas-prompt-hook.py`) SHALL write `$CLAUDE_CONFIG_DIR/yas/state/signals/last-prompt.json`, and the terminal-width helper (`ops/alacritty.py`) SHALL write `$CLAUDE_CONFIG_DIR/yas/state/signals/terminal-width`. Both SHALL resolve `$CLAUDE_CONFIG_DIR` from the environment, falling back to `~/.claude`, and SHALL create the `signals/` directory if absent. - -#### Scenario: Hook writes where the renderer reads - -- **WHEN** the prompt hook runs with `CLAUDE_CONFIG_DIR` set to a temporary directory and stamps a session -- **THEN** the renderer's subagent-cohort code reads that timestamp from `yas/state/signals/last-prompt.json` under the same directory - -#### Scenario: Width helper honours a custom config dir - -- **WHEN** `CLAUDE_CONFIG_DIR=/custom/claude` is set and `ops/alacritty.py` writes a width -- **THEN** the file is written to `/custom/claude/yas/state/signals/terminal-width` -- **AND** not to `$HOME/.claude/terminal-width` - -### Requirement: Observer reads session payloads from the new sessions directory - -The `mon` observer SHALL index render payloads from `$CLAUDE_CONFIG_DIR/yas/state/sessions/`, where each file is named `.json`, and SHALL resolve that root at call time rather than at import. - -#### Scenario: Payload round-trip - -- **WHEN** a render tick writes a payload for session `abc` and `mon` then indexes payloads -- **THEN** `mon` finds the payload for session `abc` from `yas/state/sessions/abc.json` - -### Requirement: Legacy theme file is no longer read - -The deprecated `$CLAUDE_CONFIG_DIR/statusline-theme` file SHALL NOT be consulted as a theme source. The theme precedence chain SHALL be CLI flag, then environment, then `yas.toml` `[appearance] theme`, then the built-in default. - -#### Scenario: Legacy theme file is ignored - -- **WHEN** `statusline-theme` exists containing a valid theme name and `yas.toml` sets no theme -- **THEN** the rendered theme is the built-in default, not the file's value - -### Requirement: Uninstall leaves no YAS files behind except yas.toml - -`ops/install.sh uninstall` SHALL remove `$CLAUDE_CONFIG_DIR/yas/` recursively and every legacy top-level path (`statusline-tokens.log`, `statusline-token-rate.log`, `statusline-render.log`, `statusline-theme`, `terminal-width`, `yas-last-prompt.json`, `yas.toml.cache`, `statusline-output/`, `statusline-info-*`). It SHALL NOT remove `$CLAUDE_CONFIG_DIR/yas.toml` or any Claude Code-owned file. - -#### Scenario: Full sweep - -- **WHEN** `install.sh uninstall` runs against a config dir containing both a populated `yas/` subtree and every legacy path -- **THEN** all of them are gone afterwards -- **AND** `yas.toml`, `settings.json`, `projects/`, and `plugins/` are untouched apart from the existing `statusLine`/hook key removal in `settings.json` - -#### Scenario: Dry run removes nothing - -- **WHEN** `install.sh uninstall --dry-run` runs against the same config dir -- **THEN** each existing target is listed as "Would remove" -- **AND** every file still exists afterwards diff --git a/openspec/changes/consolidate-claude-dir-layout/specs/layout-migration/spec.md b/openspec/changes/consolidate-claude-dir-layout/specs/layout-migration/spec.md deleted file mode 100644 index a8dd1a3..0000000 --- a/openspec/changes/consolidate-claude-dir-layout/specs/layout-migration/spec.md +++ /dev/null @@ -1,106 +0,0 @@ -## ADDED Requirements - -### Requirement: One-shot migration from the legacy flat layout - -YAS SHALL provide a single migration routine that converts a legacy `$CLAUDE_CONFIG_DIR` to the `yas/` subtree layout. The routine SHALL create `yas/cache/`, `yas/state/`, `yas/state/runtime/`, `yas/state/signals/`, and `yas/state/sessions/`, and then apply exactly these dispositions: - -| Legacy path | Disposition | -|---|---| -| `statusline-tokens.log` | move → `yas/state/runtime/tokens.log` | -| `yas-last-prompt.json` | move → `yas/state/signals/last-prompt.json` | -| `terminal-width` | move → `yas/state/signals/terminal-width` | -| `statusline-token-rate.log` | delete | -| `statusline-render.log` | delete | -| `yas.toml.cache` | delete | -| `statusline-output/` (recursive) | delete | -| `statusline-theme` | delete (after the installer's fold, see below) | - -#### Scenario: Full legacy layout is migrated - -- **WHEN** the migration runs against a config dir containing all nine legacy paths -- **THEN** the three moved files exist at their new paths with their original contents -- **AND** the six deleted paths no longer exist -- **AND** all six new directories exist - -#### Scenario: Nothing to migrate - -- **WHEN** the migration runs against a config dir with no legacy paths at all -- **THEN** it completes without error -- **AND** the directory skeleton and `version.json` are created - -### Requirement: Migration is idempotent and never clobbers a destination - -Every migration step SHALL be safe to repeat. Directory creation SHALL use `exist_ok=True`. A move SHALL be skipped when the destination already exists, and otherwise performed with a single atomic rename. A delete SHALL tolerate an already-absent target. An `OSError` in any individual step SHALL be swallowed so the remaining steps still run, but SHALL prevent the completion marker from being written. - -#### Scenario: Second run is a no-op - -- **WHEN** the migration runs twice in a row against the same config dir -- **THEN** the second run changes no file contents and raises no error - -#### Scenario: Destination already populated - -- **WHEN** `statusline-tokens.log` exists AND `yas/state/runtime/tokens.log` already exists with different contents -- **THEN** the new file's contents are left untouched -- **AND** no exception propagates - -#### Scenario: Concurrent migrations - -- **WHEN** two processes run the migration simultaneously against the same config dir -- **THEN** each moved file ends up exactly once at its destination with intact contents -- **AND** neither process raises - -### Requirement: Completion marker written last and atomically - -On successful completion the migration SHALL write `$CLAUDE_CONFIG_DIR/yas/state/version.json` as its final action, via a temporary file in the same directory replaced into place with `os.replace`. Its contents SHALL be a JSON object with `schema_version` (integer, `1` for this layout), `yas_version` (the string from `constants.VERSION`), and `migrated_at` (epoch seconds). - -#### Scenario: Marker contents - -- **WHEN** the migration completes -- **THEN** `yas/state/version.json` parses as JSON with `schema_version == 1`, `yas_version` equal to `constants.VERSION`, and a numeric `migrated_at` - -#### Scenario: Crash before completion re-runs cleanly - -- **WHEN** the migration is interrupted after moving some files but before writing `version.json` -- **THEN** the marker is absent -- **AND** a subsequent run completes the remaining steps and writes the marker, leaving already-moved files intact - -### Requirement: Lazy migration at render time behind a single stat - -`app.main` SHALL check for the existence of `yas/state/version.json` before any other filesystem access, and SHALL invoke the migration only when the marker is absent. The migration module SHALL be imported lazily inside that branch, so a migrated installation pays one `stat()` and no import cost. The guard and its module SHALL carry an in-source marker identifying them as removable in a later release. - -#### Scenario: Migrated installation skips the migration - -- **WHEN** `version.json` exists and a render tick runs -- **THEN** the migration routine is not invoked and the migration module is not imported - -#### Scenario: Un-migrated installation migrates then renders - -- **WHEN** `version.json` is absent, legacy files are present, and a render tick runs -- **THEN** the migration runs first, the marker is written, and the render output is identical to the same render on an already-migrated dir - -### Requirement: Eager migration and theme fold at install time - -`ops/install.sh` SHALL run the migration during `do_wire`, using the interpreter and plugin root it has already resolved. Before invoking it, the installer SHALL fold the deprecated `statusline-theme` file into `yas.toml`: when that file exists and is non-empty AND `yas.toml` does not already set `[appearance] theme`, the installer SHALL write that theme name into `yas.toml` using its existing atomic, validating TOML write path. A migration failure SHALL be reported but SHALL NOT abort the install. - -#### Scenario: Theme folded into yas.toml - -- **WHEN** `statusline-theme` contains `gruvbox`, `yas.toml` exists and sets no theme, and the installer runs -- **THEN** `yas.toml` afterwards sets `[appearance] theme = "gruvbox"` and still parses -- **AND** `statusline-theme` no longer exists - -#### Scenario: Existing yas.toml theme wins - -- **WHEN** `statusline-theme` contains `gruvbox` and `yas.toml` already sets `theme = "claude-dark"` -- **THEN** `yas.toml` is left unchanged -- **AND** `statusline-theme` is still deleted - -#### Scenario: Runtime migration never writes yas.toml - -- **WHEN** the lazy render-time migration runs with a `statusline-theme` file present -- **THEN** `yas.toml` is not created or modified -- **AND** `statusline-theme` is deleted - -#### Scenario: Migration failure does not fail the install - -- **WHEN** the migration invocation exits non-zero during `do_wire` -- **THEN** the installer prints a warning and continues to the settings.json wiring step diff --git a/openspec/changes/consolidate-claude-dir-layout/tasks.md b/openspec/changes/consolidate-claude-dir-layout/tasks.md deleted file mode 100644 index 005c15d..0000000 --- a/openspec/changes/consolidate-claude-dir-layout/tasks.md +++ /dev/null @@ -1,65 +0,0 @@ - - -## 1. Central path API in constants.py - -- [x] 1.1 In `claude/yas/constants.py`, directly below `CLAUDE_DIR` (line 14), add a `# --- YAS on-disk layout ---` block of path helper **functions** (not constants, so a patched `constants.CLAUDE_DIR` redirects everything): `yas_root()`, `cache_dir()`, `state_dir()`, `runtime_dir()`, `signals_dir()`, `sessions_dir()`, `version_file()`. Each returns a `Path` built from the module-global `CLAUDE_DIR` at call time; add a docstring on `yas_root()` naming the full layout and stating `yas.toml` is deliberately excluded. -- [x] 1.2 Add the per-file helpers in the same block: `config_path()` → `CLAUDE_DIR/'yas.toml'`; `toml_cache_path()` → `cache_dir()/'config.toml.cache'`; `tokens_log()`, `token_rate_log()`, `render_log()` → `runtime_dir()/{'tokens.log','token-rate.log','render.log'}`; `last_prompt_path()` → `signals_dir()/'last-prompt.json'`; `terminal_width_path()` → `signals_dir()/'terminal-width'`; `session_payload_path(session_id: str)` → `sessions_dir()/f'{session_id}.json'`; `projects_dir()` → `CLAUDE_DIR/'projects'`; `settings_path()` → `CLAUDE_DIR/'settings.json'`. -- [x] 1.3 Add `LAYOUT_SCHEMA_VERSION = 1` to `constants.py` next to `VERSION` (line 11), with a comment that it is bumped by any future relayout and stamped into `state/version.json`. -- [x] 1.4 Add a module-level comment above the block stating the invariant: no module outside `constants.py` may import `CLAUDE_DIR`, and no YAS path may be evaluated at import time (including as a default argument). - -## 2. Migration module - -- [x] 2.1 Create `claude/yas/migrate.py` with a module docstring carrying a `# REMOVE AFTER 0.11.0` marker explaining that the module and its `app.main` guard exist only to convert pre-0.9 flat layouts and are deletable a few releases after ship. -- [x] 2.2 Define the legacy disposition tables as module constants: `_MOVES: tuple[tuple[str, Callable[[], Path]], ...]` = `('statusline-tokens.log', tokens_log)`, `('yas-last-prompt.json', last_prompt_path)`, `('terminal-width', terminal_width_path)`; `_DELETE_FILES` = `('statusline-token-rate.log', 'statusline-render.log', 'yas.toml.cache', 'statusline-theme')`; `_DELETE_DIRS` = `('statusline-output',)`. Export them so `test/` and any future tooling read one list. -- [x] 2.3 Implement `migrate() -> bool`: create the six directories (`cache_dir()`, `cache_dir()/'transcripts'`, `state_dir()`, `runtime_dir()`, `signals_dir()`, `sessions_dir()`) with `mkdir(parents=True, exist_ok=True)`; then apply `_MOVES` via a `_move(src, dst)` helper that returns early when `dst.exists()` and otherwise calls `os.rename(src, dst)`; then `Path.unlink(missing_ok=True)` each `_DELETE_FILES` entry and `shutil.rmtree(..., ignore_errors=True)` each `_DELETE_DIRS` entry. Wrap each individual step in `try/except OSError`, tracking a local `ok` flag. -- [x] 2.4 As the final step, when `ok` is still true, write `version_file()` atomically: `json.dumps({'schema_version': LAYOUT_SCHEMA_VERSION, 'yas_version': VERSION, 'migrated_at': time.time()})` to a `tempfile.mkstemp(dir=state_dir(), prefix='.version-', suffix='.tmp')` file, then `os.replace`. Unlink the temp file on failure. Return `ok`. -- [x] 2.5 Add a `main()` entry (`if __name__ == '__main__': raise SystemExit(0 if migrate() else 1)`) so `ops/install.sh` can invoke it as `python -m yas.migrate` with a meaningful exit code. - -## 3. Rewire every reader/writer to the new API - -- [x] 3.1 `claude/yas/app.py`: replace the `CLAUDE_DIR` import (line 9) with `config_path`/`session_payload_path`/`sessions_dir`/`version_file` imports from `yas.constants`. At the top of `main()` — before `Config.load` and before any other disk access — add the lazy guard: `if not version_file().exists(): from yas.migrate import migrate; migrate()`, with a `# REMOVE AFTER 0.11.0` comment. -- [x] 3.2 `claude/yas/app.py:87`: pass `config_dir=config_path().parent` (or keep `Config.load(config_dir=...)`'s signature and pass `constants.CLAUDE_DIR` via a new `config_dir()` accessor — do NOT re-introduce a module-level `CLAUDE_DIR` import). -- [x] 3.3 `claude/yas/app.py:99-104`: replace the `CLAUDE_DIR / 'statusline-output'` block with `sessions_dir().mkdir(parents=True, exist_ok=True)` + `session_payload_path(session_id).write_text(json.dumps(info))`, keeping the surrounding `try/except OSError: pass`. Update the comment above it (lines 93-97) to name the new path. -- [x] 3.4 `claude/yas/tokens.py`: drop the `CLAUDE_DIR` import (line 14) for `tokens_log`, `token_rate_log`, `render_log`; replace the six path expressions at lines 116, 171, 207, 248, 292, 308 with the helper calls. Ensure each writer `mkdir(parents=True, exist_ok=True)`s `runtime_dir()` before its first write (`TokenLog.update` at :116 and the rate/render writers) so a fresh install works before migration has ever run. -- [x] 3.5 `claude/yas/config.py`: delete `_legacy_theme_sources` (lines 154-160) entirely and remove its call site from the theme precedence chain; update the chain's docstring/comment to read CLI → env → yas.toml → default. -- [x] 3.6 `claude/yas/config.py:240-259` (`_load_toml`): keep `toml_path = config_dir / 'yas.toml'` but source the cache path from `constants.toml_cache_path()` instead of `config_dir / 'yas.toml.cache'`, and `mkdir(parents=True, exist_ok=True)` the cache dir inside `_write_toml_cache` before the temp write (line 218-232). Update the docstring at :250-251 which currently says the cache "lives next to the source". -- [x] 3.7 `claude/yas/session.py`: delete the duplicated `HOME`/`CLAUDE_DIR` declaration (lines 21-22) and any now-unused `os`/`Path` imports; change line 171's `candidates = [CLAUDE_DIR / 'settings.json']` to use `settings_path()` imported from `yas.constants` (session.py already imports `_sanitize` from there — extend that import). -- [x] 3.9 `claude/yas/info/subagents.py`: replace the `CLAUDE_DIR` import (:21) with `last_prompt_path` and `projects_dir`; update the last-prompt read at :36, the docstring at :30-31, and the two projects paths at :1172 and :1180. -- [x] 3.10 `claude/yas/info/workflows.py`: replace the `CLAUDE_DIR` import (:20) with `projects_dir` and update the session-dir expression at :186. -- [x] 3.11 `claude/yas/render/text.py`: replace the `CLAUDE_DIR` import (:10) with `terminal_width_path` and update the read at :36. -- [x] 3.12 `claude/mon/discovery.py`: change the `projects_root` (:23) and `payloads_root` (:45) default arguments to `None` and resolve them in the function body via `projects_dir()` / `sessions_dir()` — import-time defaults would ignore a patched `CLAUDE_DIR`. Update the payload glob/filename handling for the new `.json` naming (was `statusline..json`) in `index_payloads_by_session`. -- [x] 3.13 `hooks/yas-prompt-hook.py:21-24`: point the self-contained resolver at `/yas/state/signals/last-prompt.json`, `mkdir(parents=True, exist_ok=True)` the parent before the `mkstemp` write at :54, and add a comment naming `yas.constants.last_prompt_path()` as the source of truth this file deliberately duplicates (it runs without the package on `sys.path`). Update the module docstring at :6-7. -- [x] 3.14 `ops/alacritty.py:26`: resolve `os.environ.get('CLAUDE_CONFIG_DIR', os.path.expanduser('~/.claude'))` instead of hardcoding `$HOME`, write to `/yas/state/signals/terminal-width`, and create the parent dir first. -- [x] 3.15 Run `grep -rn "CLAUDE_DIR" claude/ hooks/ ops/` and confirm the only Python hits are inside `claude/yas/constants.py`; grep for the nine legacy basenames across `claude/` and confirm the only hits are in `claude/yas/migrate.py`. - -## 4. Installer: eager migration, theme fold, uninstall sweep - -- [x] 4.1 `ops/install.sh do_wire`, immediately after the existing `statusline-info-*` legacy sweep (~:880-882) and after `PYTHON_BIN` is resolved (~:895-905): add a `migrate_layout()` step invoking `PYTHONPATH="$PLUGIN_ROOT/claude" "$PYTHON_BIN" -m yas.migrate`. On non-zero exit print a `fail`-style warning and continue (never `exit`). Honour `DRY_RUN=1` by printing "Would migrate layout" and skipping. -- [x] 4.2 Add a `fold_legacy_theme()` shell function called from `do_wire` **before** `migrate_layout()`: if `$CLAUDE_CONFIG_DIR/statusline-theme` exists and is non-empty, and `yas.toml` either doesn't exist or sets no `theme` key, write the theme name into `$CLAUDE_CONFIG_DIR/yas.toml` reusing the existing atomic `mktemp "${toml_path}.XXXXXXXXXX"` + parse-validate + `mv` pattern from the yas.toml generator (~:1144-1240). Print what it did. Do not delete the file here — `migrate()` owns that. -- [x] 4.3 `ops/install.sh do_uninstall` (~:966-979): extend the legacy sweep to `rm -rf "$CLAUDE_CONFIG_DIR/yas"` plus each of the eight legacy paths (`statusline-tokens.log`, `statusline-token-rate.log`, `statusline-render.log`, `statusline-theme`, `terminal-width`, `yas-last-prompt.json`, `yas.toml.cache`, `statusline-output/`) alongside the existing `statusline-info-*` glob. Keep the existing `DRY_RUN` "Would remove " idiom for every target, and add an explicit comment that `$CLAUDE_CONFIG_DIR/yas.toml` is deliberately preserved. -- [x] 4.4 Confirm the installer preview block (~:1114-1133) still works: it sets `CLAUDE_CONFIG_DIR="$scratch"`, so the preview render now creates `$scratch/yas/` — verify the `rm -rf "$scratch"` cleanup covers it (it should, unchanged). - -## 5. Tests - -- [x] 5.1 `test/conftest.py:71-86`: reduce `tmp_home` to a single `monkeypatch.setattr(_sl_constants, 'CLAUDE_DIR', claude_dir)`, delete the seven other `setattr` calls and any now-unused module imports at the top of the file, and rewrite the comment to explain the call-time-function design that makes one patch sufficient. -- [x] 5.2 Add `test/test_migrate.py` covering: full nine-path migration (moves land with contents, deletes are gone, six dirs exist); idempotent second run; move skipped when destination exists (destination contents preserved, no raise); `version.json` shape (`schema_version == 1`, `yas_version == constants.VERSION`, numeric `migrated_at`); crash-resume (delete `version.json` after a run, re-run, everything still intact); empty config dir (marker written, no error). -- [x] 5.3 Add a test that `app.main` does not import `yas.migrate` when `version.json` exists (e.g. assert on `sys.modules` after popping it, or monkeypatch a sentinel) and does migrate when it is absent. -- [x] 5.4 Add a layout-containment test: run a render tick against a fresh `tmp_home` and assert the set of entries created directly in `$CLAUDE_CONFIG_DIR` is a subset of `{'yas', 'yas.toml'}`. -- [x] 5.5 Update existing tests that reference old paths: token-log tests (`statusline-tokens.log` / `-token-rate.log` / `-render.log`), `test_mon_discovery.py` (`statusline-output/statusline..json` → `yas/state/sessions/.json`), any config test asserting `yas.toml.cache` placement, and any theme test exercising the legacy `statusline-theme` source (that one is deleted or inverted to assert the file is ignored). -- [x] 5.6 Add a hook test asserting `hooks/yas-prompt-hook.py` writes to `yas/state/signals/last-prompt.json` under a temp `CLAUDE_CONFIG_DIR`, and that `subagents.last_prompt_ts` reads it back. - -## 6. Docs - -- [x] 6.1 `CONTEXT.md`: update the path references at :21 (tokens log), :24 (token-rate log), :78 (legacy theme file — now removed), :112 (last-prompt file), and add a short "on-disk layout" block showing the `yas/` tree with the cache-is-disposable note. -- [x] 6.2 `README.md`: update ~:230 (terminal-width helper path) and replace the ~:123 deprecated `statusline-theme` section with a note that the file is no longer read, that the installer folds its value into `yas.toml` once, and how to set `[appearance] theme` by hand. -- [x] 6.3 Add a short migration note to the README/CHANGELOG-facing text: rate-limit history, render timings, and `mon`'s payloads are regenerated rather than moved, so a brief post-upgrade cold start is expected. - -## 7. Verify - -- [x] 7.1 Via the `verifier` agent: `uv run pytest -q` full suite green, and `uv run ruff check` clean. -- [x] 7.2 Via the `verifier` agent: `make demo/img` + `.claude/skills/yas-demo-text/scripts/demo-text.sh`, diff `demo/text/*.txt` — expect **zero** rendered-output change; any diff is a bug in this change. -- [x] 7.3 Manual smoke: in a scratch dir, `CLAUDE_CONFIG_DIR=$scratch` with a hand-built legacy layout, run one render, and confirm the tree matches the target layout and `version.json` is present; then run `ops/install.sh uninstall --dry-run` and confirm the listed targets, then the real uninstall and confirm only `yas.toml` remains. diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 392946c..0000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -schema: spec-driven - -# Project context (optional) -# This is shown to AI when creating artifacts. -# Add your tech stack, conventions, style guides, domain knowledge, etc. -# Example: -# context: | -# Tech stack: TypeScript, React, Node.js -# We use conventional commits -# Domain: e-commerce platform - -# Per-artifact rules (optional) -# Add custom rules for specific artifacts. -# Example: -# rules: -# proposal: -# - Keep proposals under 500 words -# - Always include a "Non-goals" section -# tasks: -# - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/cache-countdown/spec.md b/openspec/specs/cache-countdown/spec.md deleted file mode 100644 index 7a69356..0000000 --- a/openspec/specs/cache-countdown/spec.md +++ /dev/null @@ -1,110 +0,0 @@ -# cache-countdown Specification - -## Purpose - -Define the cache countdown display: extract the prompt-cache anchor from the transcript, derive remaining TTL, and render a glyph + time section on the wide path/model row that runs green when fresh and red near expiry. -## Requirements -### Requirement: Prompt-cache anchor extraction from the transcript - -`TranscriptUsage` SHALL capture, during its existing single forward scan of the transcript jsonl, the wall-clock `timestamp` of the most recent line whose `message.usage` shows prompt-cache activity — that is, `cache_read_input_tokens > 0` OR `cache_creation_input_tokens > 0`. It SHALL expose this as a raw `cache_anchor_epoch` (Unix seconds, `0.0` when no such line exists). The scan SHALL NOT add a second pass over the file; it SHALL retain the most-recent matching line's raw timestamp string and convert it to epoch exactly once, after the scan completes, using the existing `session` ISO-to-epoch helper. - -#### Scenario: Anchor is the latest cache-bearing line - -- **WHEN** a transcript contains several assistant lines with cache activity at increasing timestamps followed by a non-cache line -- **THEN** `cache_anchor_epoch` equals the epoch of the LAST cache-bearing line, not the last line overall - -#### Scenario: No cache activity yields a zero anchor - -- **WHEN** a transcript contains no line with `cache_read_input_tokens > 0` or `cache_creation_input_tokens > 0` -- **THEN** `cache_anchor_epoch` is `0.0` - -#### Scenario: The transcript is still scanned exactly once - -- **WHEN** `cache_anchor_epoch` and the token-usage totals are both read from one `TranscriptUsage` -- **THEN** the transcript file is read a single time (the anchor is captured in the same pass that sums tokens) - -### Requirement: Cache TTL tier detection - -`TranscriptUsage` SHALL expose a raw `cache_ttl` (seconds) taken from the same anchor line as `cache_anchor_epoch`. The TTL SHALL be `3600` when that line reports `cache_creation.ephemeral_1h_input_tokens > 0`, and `300` otherwise. When there is no anchor line, `cache_ttl` SHALL be `0`. The 300 s and 3600 s values SHALL be named constants in `constants.py` (`CACHE_TTL_SECONDS`, `CACHE_TTL_1H_SECONDS`). - -#### Scenario: One-hour ephemeral tier - -- **WHEN** the anchor line's usage contains `cache_creation.ephemeral_1h_input_tokens > 0` -- **THEN** `cache_ttl` is `3600` - -#### Scenario: Default five-minute tier - -- **WHEN** the anchor line has cache activity but no `ephemeral_1h_input_tokens` -- **THEN** `cache_ttl` is `300` - -### Requirement: Cache Countdown derivation on SessionView - -`SessionView` SHALL expose a lazily-evaluated, cached `cache_countdown` derived from `transcript_usage`'s raw anchor and the view's single frozen `now`. It SHALL compute `remaining = cache_ttl − (now − cache_anchor_epoch)` and `elapsed_pct = 100 − round(remaining · 100 / cache_ttl)`, returning the pair `(remaining, elapsed_pct)`. `cache_countdown` SHALL be `None` when there is no anchor (`cache_anchor_epoch == 0.0` or `cache_ttl == 0`) OR when `remaining <= 0` (expired). `cache_countdown` SHALL hold no ANSI, formatting, or render geometry. - -#### Scenario: Fresh cache reports remaining time - -- **WHEN** the anchor was 90 s before `now` on the 300 s tier -- **THEN** `cache_countdown` is non-`None` with `remaining == 210` and `elapsed_pct == 30` - -#### Scenario: Expired cache is None - -- **WHEN** the anchor was 400 s before `now` on the 300 s tier -- **THEN** `cache_countdown` is `None` - -#### Scenario: No cache event is None - -- **WHEN** `cache_anchor_epoch` is `0.0` -- **THEN** `cache_countdown` is `None` - -#### Scenario: Derivation uses the frozen clock - -- **WHEN** `cache_countdown` is read on a `SessionView` constructed with an explicit `now` -- **THEN** the remaining time is computed against that single `now`, not a fresh wall-clock read - -### Requirement: Cache Countdown rendering on the wide path/model row - -The wide layout SHALL render the **Cache Countdown** as its own vsep-delimited section on the path/model content row, positioned between the rate-limit helper and the model section, with a single left divider `│`; the model section (plain text or flush-right pill) SHALL remain flush to the right edge. The section SHALL display the cache glyph (`GLYPH_CACHE`, nf-oct-cache ``) followed by the remaining time formatted as `MM:SS` (zero-padded minutes and seconds, e.g. `04:29`), rolling to `H:MM:SS` when the remaining time is at or above one hour (e.g. `1:05:00`). The remaining-time figure SHALL be coloured by `fill_colour(elapsed_pct)` so it runs green when fresh and red near expiry. The new divider SHALL be threaded as an elbow column into the row's top border (`downs`) and following separator (`ups`) so the `┬`/`│`/`┴` stay aligned. Medium and narrow layouts SHALL NOT render the Cache Countdown. - -#### Scenario: Section renders with glyph, value, and divider - -- **WHEN** a wide render has a live `cache_countdown` of `(187, 38)` -- **THEN** the path/model row contains a `│`-delimited section showing the cache glyph and `03:07`, and that divider's column appears in the top border `downs` and the following separator `ups` - -#### Scenario: Remaining time at or above an hour rolls to H:MM:SS - -- **WHEN** a wide render has a live `cache_countdown` whose remaining time is `3905` seconds (1 h 5 m 5 s) -- **THEN** the section displays `1:05:05` - -#### Scenario: Colour tracks elapsed percentage - -- **WHEN** `elapsed_pct` crosses from the safe band into the alert band -- **THEN** the remaining-time figure's colour changes from the theme safe colour to the theme alert colour (the same ladder as the rate-limit percentages) - -#### Scenario: Narrow and medium omit it - -- **WHEN** the same session renders at narrow and medium widths -- **THEN** no Cache Countdown section appears in either layout - -### Requirement: Cache Countdown visibility and width-shed - -The wide layout SHALL omit the Cache Countdown section AND its divider when `view.cache_countdown` is `None` (no cache event or expired), re-threading the row's elbows back to only the path divider. Independently, when the row cannot fit the path, rate-limit helper, Cache Countdown, and model section together, the Cache Countdown SHALL be shed FIRST — before the path is truncated further — and its divider dropped and re-threaded. When the Cache Countdown is shed for width, the path SHALL keep its normal truncation behaviour as if the section were never present. - -#### Scenario: Hidden when expired drops the divider - -- **WHEN** `view.cache_countdown` is `None` -- **THEN** the path/model row renders with only the path divider, and the top border / separator carry only the path elbow column (no cache elbow) - -#### Scenario: Shed first under width pressure - -- **WHEN** the row is too narrow to hold path + helper + Cache Countdown + model section, but wide enough for path + helper + model section -- **THEN** the Cache Countdown section and its divider are dropped, and the path renders without extra truncation caused by the section - -### Requirement: Re-derived per render with no persisted state - -The Cache Countdown SHALL be recomputed from the transcript and the frozen `now` on every render. The implementation SHALL NOT write, read, or depend on any per-session cache-state file; the anchor's sole source SHALL be the transcript scan already performed for token accounting. - -#### Scenario: No cache-state file is created - -- **WHEN** a wide render computes and displays the Cache Countdown -- **THEN** no per-session cache-state file is written under the Claude config directory - diff --git a/openspec/specs/compact-tokens-row/spec.md b/openspec/specs/compact-tokens-row/spec.md deleted file mode 100644 index 3049d1c..0000000 --- a/openspec/specs/compact-tokens-row/spec.md +++ /dev/null @@ -1,111 +0,0 @@ -# compact-tokens-row Specification - -## Purpose - -Define the wide layout's single-line tokens/cost/rate row: the `session/day` -slash-merged token and cost format, the paired cache parenthetical, the -single-row block-element sparkline over a 60s window, the three-column elbow -structure, and the day-stats-disabled session-only variant. -## Requirements -### Requirement: Single-line tokens/cost/rate row - -The wide layout's tokens/cost row (`Renderer.tokens_cost`) SHALL render as exactly -**one** content line, not two. The line SHALL retain its columns in order — -tokens, then the optional lines column, then cost, then rate-and-sparkline — -separated by the standard gradient `│` vertical dividers. The lines column and its -divider SHALL be present only at or above `LINES_SEGMENT_MIN_WIDTH` and the row's -measured with-lines minimum width; below that the row SHALL be the original -three-column, two-divider form. `tokens_cost` SHALL return a single-element list of -content lines together with the divider columns — three columns when the lines -column is present, two when it is shed — so the builder can thread one matching -`┬`/`┴` elbow per rendered `│` onto the separators above and below the row. The -previous 60s sparkline tick marker (`spark_mark_col`) SHALL be removed, since a -midpoint marker has no referent once the whole bar spans 60s. - -#### Scenario: Row occupies one content line - -- **WHEN** the wide layout renders the tokens/cost row -- **THEN** `tokens_cost` returns exactly one content line -- **AND** every `│` in that line has a matching `┬` on the separator above and `┴` - on the separator below at the same visual column - -#### Scenario: Three columns preserved in order - -- **WHEN** the single-line row is rendered with day stats enabled below - `LINES_SEGMENT_MIN_WIDTH` -- **THEN** the content reads tokens, then cost, then rate-and-sparkline, left to - right, divided by the gradient `│` separators - -#### Scenario: Four columns at wide widths - -- **WHEN** the single-line row is rendered at or above `LINES_SEGMENT_MIN_WIDTH` - and its with-lines minimum width -- **THEN** the content reads tokens, then lines, then cost, then - rate-and-sparkline, and `tokens_cost` returns three divider columns - -#### Scenario: Row still renders across the whole 85-plus band - -- **WHEN** the wide layout renders at any box width from `TOKENS_COST_MIN_WIDTH` - (85) upward -- **THEN** the tokens/cost row is present, and the context line is NOT degraded to - `context_line_compact` - -### Requirement: Session/day slash-merged figures - -With day stats enabled, the tokens column SHALL merge each session figure with its -day counterpart as `session/day`: input as `↓ /`, the paired -cache read in parentheses as `(/)`, and output as -`↑ /`. The cost column SHALL render `$ / $`. -All token counts SHALL be formatted with the existing `fmt_tok` abbreviation -(e.g. `128.4K`, `1.9M`). - -#### Scenario: Merged tokens and cost - -- **WHEN** session totals are in=128.4K, cache=1.2M, out=47.3K and day totals are - in=1.9M, cache=18.3M, out=612.5K, session cost $3.27 and day cost $41.88 -- **THEN** the tokens column reads `↓ 128.4K/1.9M (1.2M/18.3M) ↑ 47.3K/612.5K` -- **AND** the cost column reads `$3.27 / $41.88` - -### Requirement: Single-row block-element sparkline over a 60s window - -The token-rate sparkline SHALL be drawn on a single row using the block-element -glyphs ` ▁▂▃▄▅▆▇█` (U+2581–U+2588, plus a blank for zero), with each cell's glyph -chosen by its value's ratio to the window peak and coloured by that same ratio as -today (the most recent cell dimmed when live). The sparkline SHALL read the last -`TokenRate.WINDOW` seconds of history (the resolved `token_window`, default 60s), -not `TokenRate.WINDOW * 2`. The two-row half-block sparkline built from the -`SPARK_RISE_*` / `SPARK_FALL_*` "Symbols for Legacy Computing" glyphs SHALL be -removed. - -#### Scenario: Sparkline is one row of block elements - -- **WHEN** the rate sparkline is rendered for a non-empty history -- **THEN** it is a single row of glyphs drawn only from the set ` ▁▂▃▄▅▆▇█` -- **AND** no glyph in the U+1FBxx "Symbols for Legacy Computing" range appears - -#### Scenario: Window is 60s - -- **WHEN** the resolved `token_window` is 60 -- **THEN** the sparkline history spans 60 seconds of buckets, not 120 - -### Requirement: Day-stats-disabled session-only row - -When `show_day_stats` resolves to false, the tokens/cost row SHALL drop every day -figure and render session-only: tokens `↓ () ↑ `, -cost `$`, with the rate-and-sparkline column unchanged. The row SHALL -remain a single content line with the same three-column elbow structure. - -#### Scenario: Session-only content - -- **WHEN** `show_day_stats` is false and session totals are in=128.4K, cache=1.2M, - out=47.3K, session cost $3.27 -- **THEN** the tokens column reads `↓ 128.4K (1.2M) ↑ 47.3K` -- **AND** the cost column reads `$3.27` -- **AND** no day token count or day cost appears anywhere in the row - -#### Scenario: Default keeps day stats - -- **WHEN** no `YAS_SHOW_DAY_STATS` env var and no `[tokens].show_day_stats` toml - value are set -- **THEN** day stats are shown (the merged `session/day` form is used) - diff --git a/openspec/specs/git-branch-display/spec.md b/openspec/specs/git-branch-display/spec.md deleted file mode 100644 index 6ab673a..0000000 --- a/openspec/specs/git-branch-display/spec.md +++ /dev/null @@ -1,42 +0,0 @@ -# git-branch-display Specification - -## Purpose -TBD - created by archiving change branch-name-slash-support. Update Purpose after archive. -## Requirements -### Requirement: Branch name is displayed in full including slashes - -The statusline SHALL derive a git branch label from `.git/HEAD` by stripping the `refs/heads/` prefix from the symbolic ref target, preserving every `/` path separator, and SHALL NOT reduce the label to only its final `/`-delimited segment. When the ref target does not begin with `refs/heads/`, the statusline SHALL fall back to the final `/`-delimited segment of the target. The derived label SHALL continue to be passed through control-character sanitisation before display, and the detached-HEAD label (`d:`) SHALL be unaffected. - -#### Scenario: Single-slash prefixed branch is preserved - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/feat/123` -- **THEN** `_read_head` returns branch `feat/123` -- **AND** the label is not truncated to `123` - -#### Scenario: Multi-slash branch is preserved in full - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/a/b/c` -- **THEN** `_read_head` returns branch `a/b/c` - -#### Scenario: Slash-free branch is unchanged - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/main` -- **THEN** `_read_head` returns branch `main` - -#### Scenario: Commit is still resolved for a slashed branch - -- **WHEN** `.git/HEAD` contains `ref: refs/heads/feat/123` -- **AND** the loose ref file `refs/heads/feat/123` exists holding a commit sha -- **THEN** `_read_head` resolves the commit from `refs/heads/feat/123` -- **AND** returns branch `feat/123` alongside the first 9 chars of the sha - -#### Scenario: Symbolic ref outside refs/heads falls back to basename - -- **WHEN** `.git/HEAD` contains a symbolic ref target that does not start with `refs/heads/` -- **THEN** `_read_head` returns the final `/`-delimited segment of the ref target - -#### Scenario: Detached HEAD is unaffected - -- **WHEN** `.git/HEAD` contains a raw 40-char commit sha (no `ref:` prefix) -- **THEN** `_read_head` returns branch `d:` and commit `''`, exactly as before - diff --git a/openspec/specs/glyph-mode/spec.md b/openspec/specs/glyph-mode/spec.md deleted file mode 100644 index 4feb853..0000000 --- a/openspec/specs/glyph-mode/spec.md +++ /dev/null @@ -1,81 +0,0 @@ -# glyph-mode Specification - -## Purpose - -Define the glyph-rendering contract: what each of the three modes `nerdfont` / `ascii` / `unicode` does to the rendered output, the orthogonal `single_width` fold that composes with any mode, the column-math (visible width) invariant every mode and the fold must hold, and the single-seam application model (mode transform first, then the fold) that every render entry point inherits. - -## Requirements - -### Requirement: Three glyph rendering modes - -The statusline SHALL support exactly three mutually-exclusive glyph rendering modes, selected by the `glyph_mode` configuration knob: `nerdfont`, `ascii`, and `unicode`. Each mode SHALL be a single total transform applied to the fully-rendered statusline string. The default mode SHALL be `nerdfont`. - -- `nerdfont` SHALL display every character unchanged (identity transform; full fidelity, requires a Nerd Font). -- `ascii` SHALL replace every non-ASCII character the statusline emits with a width-1 ASCII equivalent, producing output containing only codepoints below U+0080. -- `unicode` SHALL replace only Nerd Font Private Use Area (PUA) icon glyphs with non-PUA, width-1 Unicode equivalents, leaving box-drawing, block/sparkline, arrow, and punctuation glyphs unchanged. - -#### Scenario: Nerdfont mode is identity - -- **WHEN** `glyph_mode` is `nerdfont` and `single_width` is false -- **THEN** the rendered output is byte-for-byte identical to the untransformed render - -#### Scenario: Ascii mode produces only ASCII - -- **WHEN** `glyph_mode` is `ascii` -- **THEN** the rendered output contains no codepoint at or above U+0080 - -#### Scenario: Unicode mode removes PUA but keeps other Unicode - -- **WHEN** `glyph_mode` is `unicode` -- **THEN** the output contains no Private Use Area codepoint (U+E000–U+F8FF or U+F0000–U+FFFFD) -- **AND** box-drawing, block, and arrow glyphs are still present unchanged - -### Requirement: Orthogonal single-width folding - -The statusline SHALL expose a `single_width` boolean knob, independent of `glyph_mode`, that folds every double-width character in the rendered output to a width-1 equivalent, leaving all already-width-1 characters (including the statusline's own PUA glyphs) unchanged. When enabled, the fold SHALL be applied after the selected glyph mode's transform, so it composes with any mode. The default SHALL be false (no fold). - -#### Scenario: Single-width folds wide dynamic content - -- **WHEN** `single_width` is true and dynamic content (e.g. a git branch name or cwd path) contains a double-width character -- **THEN** that character is replaced by a width-1 equivalent and the output contains no double-width character -- **AND** the statusline's own width-1 glyphs are left unchanged - -#### Scenario: Single-width composes with nerdfont - -- **WHEN** `glyph_mode` is `nerdfont` and `single_width` is true -- **THEN** the statusline's PUA icons are preserved unchanged -- **AND** any double-width dynamic content is folded to width-1 - -#### Scenario: Single-width composes with unicode - -- **WHEN** `glyph_mode` is `unicode` and `single_width` is true -- **THEN** PUA icons are replaced by their non-PUA Unicode equivalents -- **AND** any double-width dynamic content is folded to width-1 - -### Requirement: Column-math preservation across modes - -Every glyph **mode** SHALL preserve column geometry: for any session and render width, each output line's visible width (per the renderer's width model) SHALL equal the visible width of the same line rendered in `nerdfont` mode. No mode SHALL move a border, elbow, or divider. The `single_width` fold is the deliberate exception — it narrows genuinely double-width dynamic content from two cells to one, so for content the width model counts as wide it intentionally changes that model's measured width. Its purpose is to make such content align on terminals whose font renders those glyphs as single cells; it is a no-op on the statusline's own already-width-1 chrome, which it never shifts. - -#### Scenario: Visible width is mode-invariant - -- **WHEN** the same session (with no genuinely double-width dynamic content) is rendered at a given width in each of the three modes, with `single_width` false -- **THEN** every line's visible width is identical across all three modes - -#### Scenario: Fold leaves width-1 chrome unmoved - -- **WHEN** a session is rendered with `single_width` true and its dynamic content contains no double-width character -- **THEN** every line's visible width is identical to the same render with `single_width` false - -### Requirement: Single-seam application - -The selected glyph mode and the `single_width` fold SHALL be applied exactly once each, at the final render boundary, after the layout is fully composed — the mode transform first, then the fold when enabled. The `nerdfont` mode with `single_width` false SHALL incur no transformation pass. Callers that do not specify a mode or fold SHALL inherit the resolved `glyph_mode` / `single_width` configuration values, so all render entry points (the statusline command and the multi-session observer) honor both knobs without per-call wiring. - -#### Scenario: Observer honors the knobs without explicit wiring - -- **WHEN** `YAS_GLYPH_MODE=ascii` is set and the multi-session observer renders a session -- **THEN** that session's box is rendered in ascii mode - -#### Scenario: Observer honors single-width without explicit wiring - -- **WHEN** `YAS_GLYPH_SINGLE_WIDTH=1` is set and the multi-session observer renders a session -- **THEN** that session's box has its double-width dynamic content folded to width-1 diff --git a/openspec/specs/install-script/spec.md b/openspec/specs/install-script/spec.md deleted file mode 100644 index 3df6a78..0000000 --- a/openspec/specs/install-script/spec.md +++ /dev/null @@ -1,327 +0,0 @@ -## Purpose - -Define the behaviour of `ops/install.sh`, the single source of truth for wiring YAS into Claude Code. It covers full vs wire-only modes, the curl-pipe bootstrap entrypoint, plugin orchestration, safe `settings.json` writes performed entirely through Python (no `jq`), private CPython provisioning via `uv` (bootstrapped when absent), per-mode preflight, dry-run, uninstall cleanup, and the `yas:init` skill's delegation to the script. - -## Requirements - -### Requirement: Single install script with two modes - -The repository SHALL provide a single executable bash script at `ops/install.sh` that operates in one of two modes — full mode or wire-only mode — and that serves as the single source of truth for wiring YAS into Claude Code. The same script SHALL be runnable both by a human via `curl … | bash` and by the `yas:init` skill. - -#### Scenario: Mode auto-detection via CLAUDE_PLUGIN_ROOT - -- **WHEN** the script runs with the `CLAUDE_PLUGIN_ROOT` environment variable set and no overriding flag -- **THEN** it selects wire-only mode and skips all plugin-management steps - -#### Scenario: Default to full mode - -- **WHEN** the script runs with `CLAUDE_PLUGIN_ROOT` unset and no overriding flag -- **THEN** it selects full mode (marketplace + plugin management followed by settings wiring) - -#### Scenario: Explicit mode override - -- **WHEN** the script is invoked with `--wire-only` or `--full` -- **THEN** that flag overrides the `CLAUDE_PLUGIN_ROOT`-based auto-detection - -### Requirement: curl-pipe bootstrap entrypoint - -The script SHALL be installable by humans via a single command that fetches it from the repository's default branch and pipes it to bash: - -``` -curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/main/ops/install.sh | bash -``` - -This entrypoint SHALL run full mode, installing the latest plugin version served by the marketplace using the latest installer on the branch. The script SHALL NOT perform any release-tag pinning or self-re-execution. When a readable terminal is available, the script MAY reopen its own standard input from `/dev/tty` once (via `exec < /dev/tty`) to drive the interactive flow; this SHALL NOT involve re-downloading or re-executing the script. - -#### Scenario: curl bootstrap runs unattended in a non-interactive context - -- **WHEN** a user pipes the branch copy of `ops/install.sh` to bash on a machine with no readable `/dev/tty` (or with `YAS_NO_TTY=1`) -- **THEN** the script completes the full flow without requiring interactive input and never blocks on a prompt - -#### Scenario: curl bootstrap reopens the terminal when one is available - -- **WHEN** a user pipes the branch copy of `ops/install.sh` to bash attached to a terminal and `YAS_NO_TTY` is unset -- **THEN** the script reopens standard input from `/dev/tty` once and runs the interactive flow without re-fetching or re-executing itself - -### Requirement: Full-mode plugin orchestration - -In full mode the script SHALL ensure the marketplace and plugin are present and current before wiring settings, branching on inspection of Claude Code's on-disk state. - -#### Scenario: Marketplace absent - -- **WHEN** `known_marketplaces.json` does not contain the `yet-another-statusline` key -- **THEN** the script runs `claude plugin marketplace add tmck-code/yet-another-statusline` - -#### Scenario: Marketplace already present - -- **WHEN** `known_marketplaces.json` already contains the `yet-another-statusline` key -- **THEN** the script does not re-add the marketplace - -#### Scenario: Plugin not installed - -- **WHEN** `installed_plugins.json` does not contain `yas@yet-another-statusline` -- **THEN** the script runs `claude plugin install yas@yet-another-statusline --scope user` - -#### Scenario: Plugin already installed - -- **WHEN** `installed_plugins.json` already contains `yas@yet-another-statusline` -- **THEN** the script runs `claude plugin update yas@yet-another-statusline --scope user` - -#### Scenario: All plugin CLI invocations are user-scoped - -- **WHEN** the script shells `claude plugin marketplace add`, `install`, or `update` -- **THEN** it passes `--scope user` explicitly on the install and update commands - -### Requirement: Wire-only settings write - -In both modes the script SHALL write `statusLine.command` into `settings.json` under `$CLAUDE_CONFIG_DIR` (default `~/.claude/`), pointing at the newest installed renderer, and SHALL do so safely. In wire-only mode it SHALL target the renderer under `CLAUDE_PLUGIN_ROOT` directly without scanning, and SHALL NOT perform plugin management, network access (other than the `uv`/CPython provisioning described under "Private interpreter provisioning"), or nested `claude` invocations. All JSON reads, transforms, and validation SHALL be performed through the resolved Python interpreter and SHALL NOT depend on `jq` or any other external JSON tool. - -#### Scenario: Renderer discovery in full mode - -- **WHEN** the script wires settings in full mode -- **THEN** it locates the newest plugin root whose `claude/statusline_command.py` exists on disk, preferring `installed_plugins.json` and falling back to a version-sorted cache scan - -#### Scenario: Atomic write with backup - -- **WHEN** `settings.json` already exists and its `statusLine.command` differs from the target -- **THEN** the script backs the file up, writes the new value via a temp file and atomic rename, and validates the result - -#### Scenario: Exact-match skip - -- **WHEN** the existing `statusLine.command` exactly equals the target command -- **THEN** the script makes no change and reports the skip - -#### Scenario: Corrupt write rollback - -- **WHEN** the written `settings.json` fails JSON validation -- **THEN** the script restores the pre-write backup and exits non-zero - -#### Scenario: Legacy file cleanup - -- **WHEN** legacy `statusline-info-*` files exist under `$CLAUDE_CONFIG_DIR` -- **THEN** the script removes them - -#### Scenario: Existing settings keys preserved - -- **WHEN** `settings.json` already contains unrelated top-level keys -- **THEN** the script merges `statusLine` in without dropping or altering those keys - -#### Scenario: JSON handling needs no jq - -- **WHEN** the script reads, merges, or validates `settings.json` (or `known_marketplaces.json` / `installed_plugins.json`) -- **THEN** it does so via the resolved Python interpreter and never shells `jq`, so the flow succeeds on a machine where `jq` is absent - -#### Scenario: Values are passed safely to the JSON helper - -- **WHEN** the script passes a file path or a value (such as the command string) into the Python JSON helper -- **THEN** it passes them as process arguments (argv), never string-interpolated into the Python source, so paths or values containing quotes, backslashes, or shell metacharacters cannot corrupt the JSON or inject code - -### Requirement: Dry-run mode - -The script SHALL support a `--dry-run` flag that prints the intended actions for the selected mode without shelling `claude` or modifying `settings.json`. - -#### Scenario: Dry-run prints decisions only - -- **WHEN** the script runs with `--dry-run` -- **THEN** it prints whether it would add the marketplace, install vs update the plugin, and wire settings, but performs none of those side effects - -### Requirement: Per-mode preflight and strictness - -The script SHALL run under `set -uo pipefail`, check only the dependencies its selected mode needs, and remain portable across macOS and Linux. The preflight substrate gate SHALL be the presence of a system Python ≥3.10 (the bootstrap substrate), NOT the presence of `uv` and NOT the presence of `jq`. - -#### Scenario: Full-mode dependency check - -- **WHEN** full mode runs and `claude` is not on PATH -- **THEN** the script reports the missing dependency with an install hint and exits non-zero - -#### Scenario: Wire-only dependency check - -- **WHEN** wire-only mode runs -- **THEN** the script does not require `claude`, `curl`, or `jq` to be present, only a system Python interpreter ≥3.10 - -#### Scenario: Missing Python substrate - -- **WHEN** no system Python ≥3.10 interpreter is found on PATH -- **THEN** the script reports the error and exits non-zero without modifying `settings.json` - -#### Scenario: uv is not a preflight precondition - -- **WHEN** preflight runs on a machine that has a system Python ≥3.10 but no `uv` on PATH -- **THEN** preflight passes (it does not reject for a missing `uv`), because `uv` is bootstrapped later during provisioning - -### Requirement: Private interpreter provisioning via uv - -The script SHALL provision a private, plugin-local CPython under `$PLUGIN_ROOT/.python` using `uv`, and wire `statusLine.command` to that interpreter for the fastest statusline startup, without mutating the user's system Python, shell rc, or PATH. `uv` SHALL be the guaranteed provisioning engine: when `uv` is already on PATH the script SHALL use it; when `uv` is absent the script SHALL bootstrap a plugin-local copy of `uv` rather than failing or skipping provisioning. The provisioned CPython version SHALL be resolved as `${YAS_PYTHON:-3.13}` — i.e. the non-interactive default SHALL be the stable 3.13 rather than a prerelease — overridable per Decision (interactive prompt) and by the `YAS_PYTHON` environment variable. - -#### Scenario: uv already present - -- **WHEN** `uv` is on PATH at provisioning time -- **THEN** the script uses that `uv` and does not bootstrap a second copy - -#### Scenario: uv bootstrapped when absent - -- **WHEN** `uv` is not on PATH at provisioning time and the script is not in dry-run -- **THEN** the script installs `uv` from the official installer (`curl -LsSf https://astral.sh/uv/install.sh | sh`) into `$PLUGIN_ROOT/.uv`, and then references the resulting `uv` binary by absolute path - -#### Scenario: uv bootstrap does not mutate the user environment - -- **WHEN** the script bootstraps `uv` -- **THEN** it runs the installer with `INSTALLER_NO_MODIFY_PATH=1` and targets `$PLUGIN_ROOT/.uv`, so the user's shell rc files, PATH, and system locations are not modified - -#### Scenario: Non-interactive default is the stable 3.13 - -- **WHEN** the script provisions a private CPython in a non-interactive context with `YAS_PYTHON` unset and the script is not in dry-run -- **THEN** it installs CPython 3.13 (not 3.15) into `$PLUGIN_ROOT/.python` via `uv python install 3.13` and wires `statusLine.command` at the resolved 3.13 interpreter binary - -#### Scenario: YAS_PYTHON overrides the version in any mode - -- **WHEN** the script provisions a private CPython with `YAS_PYTHON=3.15` set and the script is not in dry-run -- **THEN** it installs CPython 3.15 into `$PLUGIN_ROOT/.python` and wires `statusLine.command` at the resolved 3.15 interpreter binary, in interactive or non-interactive mode alike - -#### Scenario: Fallback to a system interpreter only when uv cannot be obtained - -- **WHEN** `uv` is neither present nor obtainable (the bootstrap genuinely fails) -- **THEN** the script wires a system Python ≥3.10 (avoiding 3.14, which starts slower) instead, and still succeeds - -#### Scenario: Dry-run previews provisioning without downloading - -- **WHEN** the script runs with `--dry-run` and would provision the interpreter -- **THEN** it prints what it would bootstrap/install/wire at the resolved version and downloads nothing (no `uv` installer fetch, no `uv python install`) - -### Requirement: Uninstall removes plugin-local provisioning artifacts - -The uninstall flow SHALL remove the plugin-local provisioning artifacts it created — both `$PLUGIN_ROOT/.python` and `$PLUGIN_ROOT/.uv` — on a best-effort basis, honouring `--dry-run`. - -#### Scenario: Uninstall removes the private CPython directory - -- **WHEN** uninstall runs and `$PLUGIN_ROOT/.python` exists -- **THEN** the script removes it (or, under `--dry-run`, reports that it would remove it) - -#### Scenario: Uninstall removes the bootstrapped uv directory - -- **WHEN** uninstall runs and `$PLUGIN_ROOT/.uv` exists -- **THEN** the script removes it (or, under `--dry-run`, reports that it would remove it) - -#### Scenario: Uninstall needs no jq - -- **WHEN** uninstall removes `statusLine` from `settings.json` and checks plugin presence -- **THEN** it performs all JSON reads/edits via the resolved Python interpreter and never shells `jq` - -### Requirement: Skill delegates to the script - -The `yas:init` skill SHALL delegate its wiring work to `ops/install.sh` rather than carrying an inline implementation, preserving its observable behaviour of writing a wire-only `statusLine.command`. The plugin SHALL additionally ship a `yas:config` skill that delegates reconfiguration to `ops/install.sh --reconfigure`. - -#### Scenario: Init skill invokes the shipped script - -- **WHEN** the `yas:init` skill runs -- **THEN** it invokes `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh"`, which detects wire-only mode and writes `settings.json` against that plugin root - -#### Scenario: Config skill invokes reconfigure - -- **WHEN** the `yas:config` skill runs -- **THEN** it invokes `bash "${CLAUDE_PLUGIN_ROOT}/ops/install.sh" --reconfigure`, which re-runs the interactive wizard against that plugin root without performing marketplace or plugin install - -### Requirement: TTY detection and interactivity gating - -The script SHALL run interactively by default and SHALL fall back to a fully non-interactive flow when interactivity is unavailable or suppressed. The script SHALL be considered interactive only when `YAS_NO_TTY` is unset or not equal to `1` AND a readable `/dev/tty` exists. When not interactive, the script SHALL issue no prompts and SHALL never block waiting for input. This guarantees CI safety: the non-interactive flow's behaviour SHALL be identical to the prior script except for the provisioned Python version default. - -#### Scenario: YAS_NO_TTY forces non-interactive - -- **WHEN** the script runs with `YAS_NO_TTY=1` -- **THEN** it issues no interactive prompts, writes no `yas.toml`, and completes the selected mode non-interactively - -#### Scenario: No terminal forces non-interactive - -- **WHEN** the script runs with no readable `/dev/tty` available (e.g. CI, a detached process) -- **THEN** it issues no interactive prompts and never blocks on input - -#### Scenario: Terminal present enables interactive flow - -- **WHEN** the script runs with a readable `/dev/tty` and `YAS_NO_TTY` unset -- **THEN** it runs the interactive flow (logo, Python prompt, config wizard) by reading keystrokes from the terminal - -### Requirement: Self-contained embedded logo and selector - -The script SHALL be self-contained and SHALL NOT depend at runtime on any git-untracked repository asset (the logo file, `select.sh`, or `checkbox.sh`), because Claude Code plugin packaging ships only git-tracked files and a `curl | bash` run has no repository checkout. The logo SHALL be embedded as a heredoc inside the script, and a single-select menu function SHALL be embedded inside the script. The embedded selector SHALL read keystrokes from `/dev/tty`, SHALL be compatible with bash 3.2 (no associative arrays, no `${var,,}` lowercasing expansion, no `mapfile`), and SHALL support an optional preview callback invoked on each highlight change with the highlighted value. The embedded selector SHALL retain a CC BY 4.0 attribution comment crediting blurayne's `select.sh`. - -#### Scenario: Logo renders without a logo file present - -- **WHEN** the interactive flow starts and no `yas.dos_rebel.plain.txt` file exists on disk -- **THEN** the script prints the embedded logo from its heredoc - -#### Scenario: Selector works on bash 3.2 - -- **WHEN** the embedded single-select runs under bash 3.2 (e.g. stock macOS bash) -- **THEN** it presents the options, moves the highlight with arrow keys read from `/dev/tty`, and returns the chosen value without using bash-4+ features - -#### Scenario: Selector drives a live preview - -- **WHEN** a single-select is configured with a preview callback and the highlight moves to a new option -- **THEN** the callback is invoked with the newly highlighted value so the caller can render a live sample beneath the menu - -### Requirement: Interactive configuration wizard - -When interactive, the script SHALL run a configuration wizard that prompts for the four user-facing options — glyph mode (`appearance.glyphs.mode`), labels (`layout.labels`), theme (`appearance.theme`), and token soft limit (`tokens.soft_limit`) — and SHALL render live samples for glyph mode and theme. The glyph-mode and theme prompts SHALL render the sample session by invoking the provisioned interpreter on the shipped `statusline_command.py` with the shipped `session-info-example.json` on stdin and the corresponding `YAS_GLYPH_MODE` / `YAS_THEME` set, under a fixed `COLUMNS`. The labels prompt SHALL default to enabled for new users. The soft-limit prompt SHALL offer a fixed preset menu (150000, 200000, 500000, 1000000) only, with no free-form numeric entry, and SHALL close with a pointer to the README / `yas.example.toml` for per-model and advanced configuration. Preview renders SHALL NOT pollute any real session's statusline output (the preview invocation SHALL isolate the renderer's output directory, e.g. by pointing `CLAUDE_CONFIG_DIR` at a throwaway directory). - -#### Scenario: Live preview for glyph mode and theme - -- **WHEN** the user highlights a glyph mode or a theme in the wizard after the interpreter has been provisioned -- **THEN** the script renders the example statusline using that value beneath the menu, at the fixed preview width - -#### Scenario: Labels default on - -- **WHEN** the labels prompt is shown to a user with no existing configuration -- **THEN** the default selection is "on" - -#### Scenario: Soft limit is a preset menu - -- **WHEN** the soft-limit prompt is shown -- **THEN** the user chooses from the fixed presets 150000 / 200000 / 500000 / 1000000 and is then pointed to the README for advanced and per-model configuration, with no free-form numeric entry offered - -#### Scenario: Previews do not pollute real session output - -- **WHEN** the wizard renders preview statuslines -- **THEN** any statusline payload the renderer writes lands in a throwaway directory and not under the user's real `$CLAUDE_CONFIG_DIR/statusline-output/` - -### Requirement: Interactive yas.toml generation with keep and print choices - -When interactive, the wizard SHALL generate `$CLAUDE_CONFIG_DIR/yas.toml` from a commented template populated with the four chosen values, and SHALL NOT attempt to merge or preserve an existing file's comments or keys. When a `yas.toml` already exists the script SHALL offer to keep it as-is (skipping the questions and the write) or to reconfigure. After the questions the script SHALL offer to overwrite the file or to print the generated content to standard output for manual copy/paste. In non-interactive mode the script SHALL NOT write `yas.toml` at all. A write SHALL be performed safely (atomic temp-then-move). - -#### Scenario: Existing yas.toml offers keep vs reconfigure - -- **WHEN** the wizard starts and `$CLAUDE_CONFIG_DIR/yas.toml` already exists -- **THEN** the script offers to keep it as-is (skipping all questions and any write) or to reconfigure it - -#### Scenario: Overwrite vs print after the questions - -- **WHEN** the wizard has collected the four choices -- **THEN** the script offers to overwrite `$CLAUDE_CONFIG_DIR/yas.toml` with the generated content or to print that content to standard output without writing the file - -#### Scenario: Generated toml carries the four chosen values - -- **WHEN** the wizard generates `yas.toml` -- **THEN** the content sets `appearance.glyphs.mode`, `layout.labels`, `appearance.theme`, and `tokens.soft_limit` to the chosen values within the commented template - -#### Scenario: Non-interactive writes no yas.toml - -- **WHEN** the script runs non-interactively (CI, `YAS_NO_TTY=1`, or no `/dev/tty`) -- **THEN** it writes no `yas.toml` and relies on environment variables and built-in defaults - -### Requirement: Reconfigure mode - -The script SHALL support a `--reconfigure` flag that re-runs the interactive logo, Python-version prompt, configuration wizard, `yas.toml` write, and settings re-wire against the already-installed plugin, while skipping marketplace registration and plugin install/update. It SHALL reuse the existing plugin root via `CLAUDE_PLUGIN_ROOT` or the same renderer-discovery used by wiring. The post-install message of the install flow SHALL point users to `/yas:config` for later reconfiguration, including switching to Python 3.15. - -#### Scenario: Reconfigure skips plugin management - -- **WHEN** the script runs with `--reconfigure` -- **THEN** it re-runs the wizard and re-wires settings but does not add the marketplace or install/update the plugin - -#### Scenario: Reconfigure reuses the existing plugin root - -- **WHEN** `--reconfigure` runs with `CLAUDE_PLUGIN_ROOT` set or with the plugin discoverable on disk -- **THEN** it provisions/wires against that existing plugin root without reinstalling the plugin - -#### Scenario: Post-install message points to the config skill - -- **WHEN** the install flow completes -- **THEN** it prints guidance that `/yas:config` re-runs the wizard, noting it as the way to switch to Python 3.15 later diff --git a/openspec/specs/justify-top-row/spec.md b/openspec/specs/justify-top-row/spec.md deleted file mode 100644 index 3dc60a4..0000000 --- a/openspec/specs/justify-top-row/spec.md +++ /dev/null @@ -1,78 +0,0 @@ -# justify-top-row Specification - -## Purpose - -Define how the wide-layout top content row distributes horizontal slack evenly across its active sections when `cfg.justify` is enabled. - -## Requirements - -### Requirement: Even slack distribution across wide top-row sections - -When `cfg.justify` is `true`, the wide layout's top content row SHALL distribute horizontal slack evenly across its active sections rather than concentrating all slack in the path section. The active sections, in order, are: path (always), elapsed (when present), helper (always), cache (when present), and last-slot (always — the space between the final section and the right pill/text). Let N be the count of active sections and `total_slack = target_w - path_w`. Each section i (0-indexed) SHALL receive `extra_per + (1 if i < remainder else 0)` extra columns, where `extra_per = total_slack // N` and `remainder = total_slack % N`. When `total_slack == 0` the layout SHALL fall through to the normal (non-justify) rendering unchanged. - -#### Scenario: Slack distributed across four active sections - -- **WHEN** `cfg.justify` is true, elapsed and cache are active (N=5), and `total_slack` is 20 -- **THEN** each section receives 4 extra columns (`20 // 5 = 4`, remainder 0) - -#### Scenario: Remainder spread left-to-right - -- **WHEN** `cfg.justify` is true, N=5, and `total_slack` is 22 -- **THEN** sections 0–1 receive 5 extra columns each and sections 2–4 receive 4 columns each - -#### Scenario: Zero slack falls through - -- **WHEN** `cfg.justify` is true and `total_slack == 0` (path fills its full target width) -- **THEN** the layout renders identically to the non-justify layout - -#### Scenario: Sub-N slack still distributes remainders - -- **WHEN** `cfg.justify` is true, N=5, and `total_slack` is 3 -- **THEN** sections 0–2 receive 1 extra column each and sections 3–4 receive 0 - -### Requirement: Padding placement per section - -The path section SHALL remain left-aligned; its `extra` columns SHALL be added as trailing spaces after the path content and before the vsep block. The elapsed, helper, and cache sections SHALL each have their content centered within the wider slot: `left_pad = extra // 2` spaces SHALL be prepended to the section content and `right_pad = extra - left_pad` spaces SHALL be appended, with the result sitting between the surrounding vsep blocks. The last-slot SHALL receive its `extra` columns as trailing space before the right pill or `right_text`. - -#### Scenario: Path extra goes to trailing side - -- **WHEN** the path section receives 6 extra columns in justify mode -- **THEN** 6 spaces are appended after the path content and before the adjacent vsep, and path content remains left-aligned - -#### Scenario: Helper content centered symmetrically - -- **WHEN** the helper section receives 8 extra columns -- **THEN** 4 spaces are prepended and 4 spaces are appended around the helper content - -#### Scenario: Odd extra splits left-biased - -- **WHEN** a middle section receives 7 extra columns -- **THEN** 3 spaces are prepended (left) and 4 spaces are appended (right) - -### Requirement: Divider column adjustment for border elbow alignment - -When justify padding is applied, every vsep divider column SHALL be shifted by the cumulative extra padding of all sections preceding it (including the section's own left-pad where applicable) before being recorded in `path_row_cols` for `ups`/`downs` elbow threading. The `sep_rate_col` (the `┆` inside `helper_text`) SHALL likewise be shifted by the cumulative padding up to and including the helper section's left-pad. The resulting `path_row_downs` and `path_row_ups` tuples SHALL have every column in the same relative position relative to their vsep blocks as in the non-justify layout. - -#### Scenario: Path divider column shifts by path extra - -- **WHEN** justify mode adds 6 extra columns to the path section -- **THEN** `path_div_col` increases by 6 and the `┬` on the top border aligns with the `│` in the content row - -#### Scenario: All downstream columns shift cumulatively - -- **WHEN** path receives 4 extra, elapsed receives 6 extra (left=3, right=3), helper receives 4 extra (left=2, right=2) -- **THEN** `elapsed_div_col` shifts by 4 (path extra) + 6 (elapsed total), `helper's sep_rate_col` shifts by 4 + 6 + 2 (helper left) - -### Requirement: Justify applies in both pill and non-pill mode - -When the model pill is active, the extra columns for the last slot SHALL be appended to the `middle` string before the `right_pill` rendering branch. When the pill is not active, the extra columns SHALL be added to the `pad` value before the `right_text` concatenation. In both cases the pill or `right_text` SHALL remain flush to the right edge. - -#### Scenario: Non-pill mode last-slot padding - -- **WHEN** justify mode is active and the pill is not active, and the last slot receives 8 extra columns -- **THEN** `pad` is increased by 8, placing 8 additional spaces before `right_text` - -#### Scenario: Pill mode last-slot padding - -- **WHEN** justify mode is active and the pill is active, and the last slot receives 8 extra columns -- **THEN** 8 spaces are appended to `middle` before the pill branch, and the pill remains at the right edge diff --git a/openspec/specs/line-counts/spec.md b/openspec/specs/line-counts/spec.md deleted file mode 100644 index 939ba79..0000000 --- a/openspec/specs/line-counts/spec.md +++ /dev/null @@ -1,293 +0,0 @@ -# line-counts Specification - -## Purpose -TBD - created by archiving change add-subagent-line-counts. Update Purpose after archive. -## Requirements -### Requirement: Lines-read and lines-changed measurement scope - -The system SHALL derive two per-transcript numbers, `lines_read` and -`lines_changed`, from `Read`, `Write`, and `Edit` tool activity only. `Read` -SHALL feed `lines_read`; `Write` and `Edit` SHALL feed `lines_changed`. -`NotebookEdit` SHALL NOT contribute to either number, and no other tool -(including `Bash`) SHALL contribute. Tool names SHALL be matched after the -existing MCP normalisation (last `__`-delimited segment), so an MCP-wrapped -`Read` counts as `Read`. - -#### Scenario: Read feeds lines read - -- **WHEN** a transcript contains a `Read` whose result is a 120-line text file -- **THEN** `lines_read` increases by 120 and `lines_changed` is unchanged - -#### Scenario: Write and Edit feed lines changed - -- **WHEN** a transcript contains a `Write` and an `Edit` -- **THEN** both contribute to `lines_changed` and neither contributes to - `lines_read` - -#### Scenario: NotebookEdit is ignored - -- **WHEN** a transcript contains a `NotebookEdit` tool use -- **THEN** neither `lines_read` nor `lines_changed` changes - -### Requirement: lines_read counts newlines in the paired cat -n tool_result - -For each `Read` `tool_use`, the system SHALL add the number of newline characters -in the content of the `tool_result` paired with that `tool_use` by `tool_use_id`, -and SHALL do so ONLY when that content is a string beginning with `1\t` (the -`cat -n` shape of a text read). Content that is not a string, or that is a string -not beginning with `1\t`, SHALL contribute zero. The system SHALL NOT derive -`lines_read` from the `offset` or `limit` fields of the `Read` `tool_use` input, -because `limit` is usually absent (the tool defaults it) and would produce a -wildly wrong count. A `tool_result` whose `tool_use_id` was never seen as a `Read` -SHALL be ignored. - -#### Scenario: Text read counts its numbered lines - -- **WHEN** a `Read` tool_result content is the string `"1\tfoo\n2\tbar\n3\tbaz\n"` -- **THEN** `lines_read` increases by 3 - -#### Scenario: Image read is skipped by the sniff test - -- **WHEN** a `Read` tool_result content is the list - `[{"type":"image","source":{"type":"base64","data":"..."}}]` -- **THEN** `lines_read` is unchanged - -#### Scenario: Non-cat-n string result is skipped - -- **WHEN** a `Read` tool_result content is a string that does not begin with `1\t` - (for example an error message) -- **THEN** `lines_read` is unchanged - -#### Scenario: Offset and limit are not used - -- **WHEN** a `Read` `tool_use` input carries `offset` but no `limit` and its - result is a 30-line `cat -n` string -- **THEN** `lines_read` increases by 30, not by the tool's default limit - -### Requirement: lines_changed counts newlines in the tool_use input - -For an `Edit` `tool_use`, the system SHALL add -`max(newlines(old_string), newlines(new_string))` to `lines_changed`. For a -`Write` `tool_use`, the system SHALL add `newlines(content)`. An `Edit` with -`replace_all: true` SHALL be counted once regardless of how many occurrences were -replaced; this undercount is accepted and SHALL be documented in `CONTEXT.md`. - -#### Scenario: Edit takes the larger side - -- **WHEN** an `Edit` replaces a 2-line `old_string` with a 9-line `new_string` -- **THEN** `lines_changed` increases by 9 - -#### Scenario: Deletion counts the removed hunk - -- **WHEN** an `Edit` replaces a 12-line `old_string` with a 1-line `new_string` -- **THEN** `lines_changed` increases by 12 - -#### Scenario: Write counts the whole content - -- **WHEN** a `Write` creates a file whose `content` has 250 newlines -- **THEN** `lines_changed` increases by 250 - -#### Scenario: replace_all counts once - -- **WHEN** an `Edit` with `replace_all: true` replaces a 1-line string at 40 sites -- **THEN** `lines_changed` increases by 1 - -### Requirement: Sidechain skip on the main transcript only - -When counting the main session transcript, the system SHALL skip records whose -top-level `isSidechain` field is `true`. When counting a subagent -`agent-*.jsonl` transcript, the system SHALL NOT apply any sidechain filter — the -subagent file SHALL be counted in full. This asymmetry SHALL be preserved: applying -the sidechain skip to subagent files zeroes the entire subagent contribution. -Together with the disjointness of `tool_use` ids between the main transcript and -subagent transcripts, this SHALL make -`session total == main thread + sum of every subagent` true by construction. - -#### Scenario: Sidechain record in the main transcript is skipped - -- **WHEN** the main transcript contains a `Read` record with `isSidechain: true` -- **THEN** it contributes nothing to the main transcript's `lines_read` - -#### Scenario: Subagent transcript is counted in full - -- **WHEN** every record in a subagent `agent-*.jsonl` carries `isSidechain: true` -- **THEN** all of its `Read`/`Write`/`Edit` records are still counted - -#### Scenario: Session total equals main plus subagents - -- **WHEN** the session total and each per-transcript figure are computed -- **THEN** the session `lines_read` equals the main transcript's `lines_read` plus - the sum over every subagent transcript, and likewise for `lines_changed` - -### Requirement: Line counts reset at the last /clear - -The system SHALL count only records at or after `clear_epoch`, inheriting the -existing `count_transcript` clear-window behaviour. When `clear_epoch` is `None` -the whole transcript SHALL be counted. - -#### Scenario: Pre-clear activity is excluded - -- **WHEN** a `Read` record's timestamp precedes `clear_epoch` -- **THEN** it contributes nothing to `lines_read` - -#### Scenario: No clear marker counts the whole session - -- **WHEN** `clear_epoch` is `None` -- **THEN** every eligible record in the transcript is counted - -### Requirement: Counting is fused into the existing single transcript walk - -The system SHALL accumulate both line counts inside the existing -`count_transcript` pass over each transcript file. It SHALL NOT add a second walk -of the same file, an on-disk cache, a state file, or any incremental/offset -reading. Pre-filtering MAY reject lines before JSON decoding, and any such -pre-filter SHALL yield results identical to decoding every line. - -#### Scenario: One pass per file - -- **WHEN** the gather runs for a session with a main transcript and N subagent - transcripts -- **THEN** each of those N+1 files is opened and walked exactly once - -#### Scenario: Pre-filters do not change results - -- **WHEN** the byte-level pre-filtered walk and a naive full-JSON walk are run - over the same transcript -- **THEN** both produce identical `counts`, `lines_read`, and `lines_changed` - -### Requirement: Session totals render as a segment in the tokens/cost row - -The wide tokens/cost row SHALL render a lines segment between the tokens column -and the cost column, giving the segment order tokens, lines, cost, then the -rate-and-sparkline leader. The segment SHALL show one pair of numbers — the -session total, being the main transcript plus every subagent transcript combined — -NOT a main/sub split. Each number SHALL be humanised in the same form as the token -fields (for example `1.2k`). The segment SHALL be gated by no configuration flag -of its own: it renders whenever the tokens/cost row renders and the width rule -below permits. - -#### Scenario: Segment sits between tokens and cost - -- **WHEN** the wide tokens/cost row renders at a width that permits the segment -- **THEN** the content reads tokens, then lines, then cost, then the leader, left - to right, divided by gradient `│` separators - -#### Scenario: Value is the combined session total - -- **WHEN** the main transcript read 400 lines and two subagents read 900 and 100 -- **THEN** the segment shows a read figure of 1.4k - -#### Scenario: Numbers are humanised - -- **WHEN** the session has changed 1,200 lines -- **THEN** the segment renders `1.2k`, matching the token field's formatting - -### Requirement: Lines segment sheds below its own minimum width - -The lines segment AND its `│` divider SHALL be omitted when the box width is -below `LINES_SEGMENT_MIN_WIDTH` (103) or below the row's measured with-segment -minimum width. When omitted, the tokens/cost row SHALL render exactly as it does -without this change: three segments, two dividers, and a reported `min_width` -computed without the segment. `TOKENS_COST_MIN_WIDTH` SHALL remain 85, so the -tokens/cost row SHALL continue to render — rather than degrading to -`context_line_compact` — at every width between 85 and 103 columns. Above the -threshold the added width SHALL be absorbed by the rate-and-sparkline leader, -which already drops its sparkline below 10 columns. - -#### Scenario: Row is unchanged in the 85-to-103 band - -- **WHEN** the wide layout renders at box width 90 -- **THEN** the tokens/cost row renders with exactly three segments and two - dividers, identical to its pre-change output - -#### Scenario: Segment appears at wide widths - -- **WHEN** the wide layout renders at box width 140 -- **THEN** the tokens/cost row includes the lines segment and three dividers - -#### Scenario: Row is never dropped by the new segment - -- **WHEN** the box width is at or above `max(tokens_min_w, 85)` but below the - with-segment minimum -- **THEN** the tokens/cost row still renders, with the lines segment shed - -#### Scenario: Leader absorbs the added width - -- **WHEN** the lines segment is included at a wide width -- **THEN** the sparkline shortens by the segment's footprint and the tokens and - cost columns keep their measured widths - -### Requirement: Lines segment threads its own elbow and caption - -When present, the lines segment SHALL contribute a third divider column to the -value `tokens_cost` returns for elbow threading, so every `│` in the row has a -matching `┬` above and `┴` below. When shed, the returned divider columns SHALL be -the two-column form. With section labels enabled (`cfg.labels`), the segment SHALL -carry the caption `LOC read/write` (abbreviated `LOC r/w` under width pressure via -`LABEL_ABBREVIATIONS`), centred over the segment, and the existing -`cost` and `tokens over time` captions SHALL remain anchored to their own -segments in both the shed and present forms. - -#### Scenario: Three elbows when present - -- **WHEN** the row renders with the lines segment -- **THEN** three `┬` marks appear on the separator above and three `┴` below, each - aligned with a rendered `│` - -#### Scenario: Two elbows when shed - -- **WHEN** the row renders with the lines segment shed -- **THEN** exactly two elbows are threaded, as today - -#### Scenario: Caption shown when labels are on - -- **WHEN** `cfg.labels` is true and the lines segment is present -- **THEN** the separator above shows the `LOC read/write` caption over the - segment, and the `cost` and `tokens over time` captions remain over theirs - -### Requirement: Per-subagent lines field is self-scoped - -Each subagent row SHALL show the line counts of its OWN transcript only. A parent -SHALL NOT roll up its descendants' counts, and a `fork` subagent SHALL count to -itself. The field SHALL sit in the line-1 stats cluster alongside tokens and -model, and SHALL humanise both numbers in the same form as the tokens field. - -#### Scenario: Parent excludes its children - -- **WHEN** a parent subagent read 100 lines and its child read 900 -- **THEN** the parent's row shows 100 and the child's row shows 900 - -#### Scenario: Fork counts to itself - -- **WHEN** a `fork` subagent reads 250 lines -- **THEN** those 250 lines appear on the fork's own row - -#### Scenario: Rows sum to the session segment - -- **WHEN** every subagent row's figure is added to the main thread's contribution -- **THEN** the total equals the figure shown in the tokens/cost row's lines segment - -### Requirement: Per-subagent lines field is blank when idle and sheds first - -The lines field SHALL render as blank padding — not `0` — when the subagent has -neither read nor changed anything, preserving the fixed cluster width. Under width -pressure the field SHALL be the FIRST cluster field dropped, before tok, so the -shed order becomes lines, then tok, with the model and the front duration always -retained. Narrow terminals SHALL therefore render exactly as they do today. - -#### Scenario: Idle subagent shows blank - -- **WHEN** a subagent has read 0 lines and changed 0 lines -- **THEN** its lines field renders as spaces, not as `0`, and the cluster keeps its - width - -#### Scenario: Lines sheds before tok - -- **WHEN** the cluster does not fit at the available width -- **THEN** the lines field is dropped first while tok and model remain - -#### Scenario: Existing shed ladder is preserved below - -- **WHEN** the cluster still does not fit after the lines field is dropped -- **THEN** tok is dropped, with model and duration always retained - diff --git a/openspec/specs/openspec-bar-colour/spec.md b/openspec/specs/openspec-bar-colour/spec.md deleted file mode 100644 index 5ae2a79..0000000 --- a/openspec/specs/openspec-bar-colour/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -## ADDED Requirements - -### Requirement: Name-derived gradient selection - -An OpenSpec change bar SHALL select its gradient from `SPEC_GRADIENTS` using a -hash of the change name modulo the palette length, not the change's position in -the list. The same change name SHALL always map to the same gradient, and the -mapping SHALL be independent of the change's order among the rendered bars. - -#### Scenario: Same name maps to the same gradient - -- **WHEN** a change with a given name is rendered in two different list - positions -- **THEN** it uses the same gradient in both cases - -#### Scenario: Distinct names spread across the palette - -- **WHEN** several differently-named changes are rendered -- **THEN** their gradient selection is driven by their names rather than their - ordinal positions - -### Requirement: Render-stable hashing - -The hash used to select the gradient SHALL be stable across separate process -invocations. The system SHALL NOT use Python's builtin `hash()` on the name -(which is salted per process via `PYTHONHASHSEED`); it SHALL use a -deterministic hash such as `zlib.crc32` or a `hashlib` digest so the colour does -not change between render ticks. - -#### Scenario: Colour does not change across render ticks - -- **WHEN** the same change is rendered in two separate statusline subprocess - invocations -- **THEN** it is assigned the same gradient both times (no strobing) diff --git a/openspec/specs/path-display/spec.md b/openspec/specs/path-display/spec.md deleted file mode 100644 index 1ce1ec1..0000000 --- a/openspec/specs/path-display/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -# path-display Specification - -## Purpose - -Define the cwd/branch section's width-degradation behavior: the cwd path is treated as a whole unit (included in full or omitted entirely, never middle-ellipsized), the fields are shed in a fixed priority order with the branch surviving longer than the path, and the terminal glyph-only state is overflow-safe. - -## Requirements - -### Requirement: Whole-unit cwd include or omit - -The cwd path SHALL be rendered as a whole unit: it is either included in full -(using the existing initial-collapsed `short_pwd` form) or omitted entirely. The -system SHALL NOT apply middle-ellipsis or any partial truncation to the cwd path -at any width. - -#### Scenario: Path included when it fits - -- **WHEN** the available width fits the path-plus-branch line -- **THEN** the full `short_pwd` is shown alongside the branch - -#### Scenario: Path omitted whole when it does not fit - -- **WHEN** the available width cannot fit the path-plus-branch line but can fit - the branch alone -- **THEN** the cwd path is dropped entirely and the branch is retained, with no - ellipsized path fragment shown - -### Requirement: Degradation priority and terminal state - -Under decreasing width the section SHALL shed fields in this order: commit, then -dirty markers, then the cwd path (whole), then the branch. The branch SHALL be -retained for longer than the cwd path. When even the branch does not fit, the -section SHALL fall back to a presence glyph only. This terminal state SHALL be -overflow-safe — it SHALL NOT exceed the available width or disturb the box -border alignment. - -#### Scenario: Branch outlives the path - -- **WHEN** width shrinks past the point where path-plus-branch fits -- **THEN** the path is dropped before the branch - -#### Scenario: Glyph-only terminal state - -- **WHEN** the available width cannot fit even the branch alone -- **THEN** only the presence glyph is shown and the rendered width stays within - the available width - -#### Scenario: No partial path or partial-branch ellipsis in the path ladder - -- **WHEN** the section degrades at any width -- **THEN** neither the cwd path nor the branch is rendered with a middle-ellipsis - fragment; each is shown in full or omitted as a whole diff --git a/openspec/specs/prompt-boundary-hook/spec.md b/openspec/specs/prompt-boundary-hook/spec.md deleted file mode 100644 index 751d0d4..0000000 --- a/openspec/specs/prompt-boundary-hook/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -# prompt-boundary-hook Specification - -## Purpose - -Define how the YAS plugin ships a `UserPromptSubmit` hook that records prompt-submit timestamps in a shared, atomically-written state file, and how the statusline consumes that file to establish a per-session turn boundary for cohort scoping. - -## Requirements - -### Requirement: Plugin-shipped UserPromptSubmit hook - -The YAS plugin SHALL declare a `UserPromptSubmit` hook in its own `hooks/hooks.json` so the behaviour travels with the plugin and requires no per-user `settings.json` edits. The hook SHALL record the prompt-submit timestamp for the submitting session. - -#### Scenario: Hook fires on user prompt - -- **WHEN** the user submits a prompt in a session where the YAS plugin is installed -- **THEN** the hook runs and records the current timestamp for that `session_id` - -#### Scenario: Hook ships with the plugin - -- **WHEN** a user installs the YAS plugin -- **THEN** the `UserPromptSubmit` hook is present without the user editing `~/.claude/settings.json` - -### Requirement: Shared per-session state file with atomic writes - -The hook SHALL persist a mapping of `session_id` to the latest prompt-submit timestamp in a single shared state file. Writes SHALL be atomic and SHALL preserve other sessions' entries: read the existing map, update only the current session's entry, write to a temporary file, and rename it into place. - -#### Scenario: Concurrent sessions do not clobber each other - -- **WHEN** two sessions submit prompts close together -- **THEN** both sessions' timestamps are present in the state file after the writes settle - -#### Scenario: Atomic replace avoids partial reads - -- **WHEN** the statusline reads the state file while the hook is updating it -- **THEN** the reader sees either the old complete map or the new complete map, never a truncated file - -### Requirement: Statusline consumes the prompt timestamp - -The statusline SHALL read the current session's prompt-submit timestamp from the shared state file and use it as the authoritative lower bound for turn-scoped cohort membership. A missing or unreadable file SHALL trigger the subagent-cohort capability's recency-window fallback rather than an error. - -#### Scenario: Timestamp scopes the cohort - -- **WHEN** the state file contains a timestamp for the current `session_id` -- **THEN** the statusline uses it as the cohort lower bound - -#### Scenario: Absent entry falls back - -- **WHEN** the state file has no entry for the current `session_id` -- **THEN** the statusline falls back to the recency window without error diff --git a/openspec/specs/section-labels/spec.md b/openspec/specs/section-labels/spec.md deleted file mode 100644 index 9f2b0b5..0000000 --- a/openspec/specs/section-labels/spec.md +++ /dev/null @@ -1,141 +0,0 @@ -# section-labels Specification - -## Purpose - -Define how the wide layout, when `cfg.labels` is enabled, overlays gradient-coloured superscript labels onto the top border and separator rows above each section and sub-value, how those labels are anchored by measuring rendered content, how they colourise positionally with the border gradient, and how they yield to structural glyphs (elbows, session id, model pill) by truncating or dropping rather than shifting any column. - -## Requirements - -### Requirement: Opt-in wide-only label activation - -The statusline SHALL paint superscript section labels onto the wide layout's border and separator rows only when `cfg.labels` is `true`. When `cfg.labels` is `false` the layout SHALL render identically to today. The narrow and medium layouts SHALL ignore `cfg.labels` entirely and never paint labels. - -#### Scenario: Labels off renders unchanged - -- **WHEN** `cfg.labels` is false in the wide layout -- **THEN** the border and separator rows render identically to the pre-feature output (no superscript glyphs present) - -#### Scenario: Labels on paints the wide frame - -- **WHEN** `cfg.labels` is true and the terminal width selects the wide layout -- **THEN** superscript labels appear on the top border and the separator rows above their sections - -#### Scenario: Narrow and medium ignore the flag - -- **WHEN** `cfg.labels` is true but the width selects the narrow or medium layout -- **THEN** no labels are painted and the output is identical to `cfg.labels` false - -### Requirement: Labels anchored above the value they name - -When `cfg.labels` is true, each label SHALL be positioned so that its starting column sits above the value or sub-value it names in the content row immediately below the border/separator that carries it. Label start columns SHALL be derived by measuring the already-rendered content strings that `build_wide` holds for each row — stripping ANSI and finding the whitespace-delimited token offsets — rather than from a fixed tuned-position table. A label SHALL only be emitted for a value that is actually present in the measured content. A section that displays multiple distinct sub-values MAY carry one label per sub-value, anchored over each. - -The 5h cell SHALL carry `5h` over its glyph and, in its **full** form, additionally `remain` (over the reset countdown), `used` (over the used percentage), and `burn rate` (over the burn-rate trend); in its **compact/reset** form (no countdown or trend rendered) it SHALL carry only `5h` + `used`. The 7d cell SHALL carry `7d` over its glyph plus `used` (over the used percentage) and `burn rate` (over the burn-rate trend, when present). The git dirty block SHALL carry a `changes` label over the `•N*M` cluster when that block is present. - -The elapsed/timers cell SHALL carry a `session` label over the session timer always, and a `clear` label over the clear timer only when the clear timer is actually displayed (its rendered content is non-empty). When no clear timer is shown only `session` SHALL be emitted, anchored over the single timer. Both timer labels SHALL be anchored by measuring the rendered elapsed content. - -#### Scenario: Elapsed section carries two labels - -- **WHEN** `cfg.labels` is true and the elapsed section is present with a clear time and a session time -- **THEN** the top border above it shows a `clear` label over the clear time and a `session` label over the session time - -#### Scenario: Clear label omitted when no clear timer - -- **WHEN** `cfg.labels` is true and the elapsed cell shows only the session timer (no `/clear` marker, clear content empty) -- **THEN** only a `session` label is emitted, anchored over the single timer, and no `clear` label appears - -#### Scenario: Changes label over the git dirty block - -- **WHEN** `cfg.labels` is true and the path/git section renders a dirty block (`•N*M`) -- **THEN** the top border above it shows a `changes` label anchored over that block, and when there is no dirty block no `changes` label is emitted - -#### Scenario: 5h cell carries sub-value labels in full form - -- **WHEN** `cfg.labels` is true and the 5h cell renders its full form (glyph, reset countdown, used percentage, and burn-rate trend) -- **THEN** the top border above it shows `5h` over the glyph, `remain` over the countdown, `used` over the used percentage, and `burn rate` over the trend - -#### Scenario: 5h compact form omits remain and burn rate - -- **WHEN** `cfg.labels` is true and the 5h cell renders its compact/reset form (no countdown or trend) -- **THEN** only `5h` and `used` are emitted, and no `remain` or `burn rate` label appears - -#### Scenario: 7d cell carries used and burn-rate labels - -- **WHEN** `cfg.labels` is true and the 7d cell is present with a used percentage and a burn-rate trend -- **THEN** the top border above it shows `7d` over the glyph, `used` over the used percentage, and `burn rate` over the trend; when no trend is rendered the `burn rate` label is omitted - -#### Scenario: Context separator labels its columns - -- **WHEN** `cfg.labels` is true and the context row is present -- **THEN** the separator above it carries a `context` label over the token count (e.g. `70.0K`), a `fill` label over the context-window percent (e.g. `(7%)`), and a `dumb` label over the compaction-risk percent (e.g. `47%`) - -#### Scenario: Tokens/cost separator labels its columns - -- **WHEN** `cfg.labels` is true and the tokens/cost row is present -- **THEN** the separator above it carries `input sess/day`, `cache sess/day`, and `output sess/day` labels over the three token columns, a `cost sess/day` label over the cost column, and a `tokens over time` label over the sparkline column - -#### Scenario: Skills separator labels the skills row - -- **WHEN** `cfg.labels` is true and the skills/plugins row is present -- **THEN** the separator above it carries a `skills + plugins` label - -#### Scenario: Dynamic section separators carry content-start captions - -- **WHEN** `cfg.labels` is true and a dynamic section row is present -- **THEN** the separator above it carries a caption anchored at content-start (like `skills + plugins`): `plan` over the todo-checklist / task row, `subagents` over the subagent cohort, `workflow` over the workflow cohort, and `specs` over the OpenSpec change bars - -#### Scenario: Side-by-side checklist and subagents split the caption - -- **WHEN** `cfg.labels` is true and the checklist and subagents render in a side-by-side block -- **THEN** the separator above carries `plan` at content start and `subagents` over the right column - -### Requirement: Labels colourise positionally with the border gradient - -Each label glyph SHALL take the gradient colour of the column it occupies, identical to the fill character it replaces. Labels SHALL NOT be painted in a single flat colour. On dimmed separator rows each label glyph SHALL inherit the same per-column dim factor as the surrounding fill. - -#### Scenario: Top-border label follows the rainbow - -- **WHEN** a label occupies columns 60 through 66 of the rainbow top border -- **THEN** each of its glyphs is coloured with the gradient colour for its own column (60..66), matching the colour the fill would have had at that column - -#### Scenario: Separator label inherits dim ramp - -- **WHEN** a label sits on a dimmed separator row away from any elbow -- **THEN** its glyphs are dimmed by the same `_dim_for_col` factor as the adjacent dotted fill - -### Requirement: Labels yield to structural glyphs - -A label SHALL only overwrite fill characters (`─` on borders, `┄` on dim separators). It SHALL never overwrite an elbow (`┬`, `┴`, `┼`), the opening/closing frame corners, the embedded session id on the top border, or any column owned by an active model pill. When a label's full text would not fit in the available run of fill columns before the next structural glyph, it SHALL be truncated to fit, and if no fill columns are available it SHALL be dropped entirely. Dropping or truncating a label SHALL NOT shift any other column, elbow, or content. - -#### Scenario: Label truncated before an elbow - -- **WHEN** a label's text is longer than the run of fill columns between its start and the next elbow -- **THEN** the label is truncated so its last glyph sits before the elbow and the elbow is preserved - -#### Scenario: Label dropped when no room - -- **WHEN** there is no fill column available at a label's anchor (e.g. it collides with the session id or pill) -- **THEN** the label is omitted and all elbows, session id, pill, and column positions are unchanged - -#### Scenario: Session id preserved on top border - -- **WHEN** a label's anchor overlaps the embedded session id region of the top border -- **THEN** the session id is rendered intact and the label is truncated or dropped to avoid it - -### Requirement: Superscript text mapping with fallback - -The statusline SHALL map ASCII label text to Unicode superscript glyphs for rendering (e.g. `tokens` → `ᵗᵒᵏᵉⁿˢ`). Characters that have a defined superscript form (letters, digits, `+`, `/`, space) SHALL be mapped to that form. A character with no superscript form SHALL be passed through unchanged rather than dropped, and the mapped string SHALL have the same visible column width as the input (each glyph counting as one column). - -#### Scenario: Lowercase word maps to superscripts - -- **WHEN** the label text is `cache` -- **THEN** it renders as the superscript glyph sequence for `c`, `a`, `c`, `h`, `e` - -#### Scenario: Digits and width preserved - -- **WHEN** the label text is `5h` -- **THEN** it renders as superscript `5` followed by superscript `h`, occupying exactly two columns - -#### Scenario: Unmapped character passes through - -- **WHEN** a label contains a character with no defined superscript form -- **THEN** that character is emitted unchanged and the total visible width still equals the input length diff --git a/openspec/specs/side-by-side-sections/spec.md b/openspec/specs/side-by-side-sections/spec.md deleted file mode 100644 index 40546ba..0000000 --- a/openspec/specs/side-by-side-sections/spec.md +++ /dev/null @@ -1,87 +0,0 @@ -# side-by-side-sections Specification - -## Purpose - -Define how the wide layout composes the task checklist and subagent cohort as adjacent columns within a single bordered block: the trigger conditions, the content-driven column split with a stacked fallback, the divider and elbow threading that connects the columns to the box top and bottom, height reconciliation between the two columns, and the builder-driven content width that the section renderers consume. - -## Requirements - -### Requirement: Side-by-side trigger - -In the **wide** layout only, when the task checklist is visible AND at least one subagent is visible, the renderer SHALL attempt to compose the two sections as adjacent columns within a single bordered block rather than stacking them as two full-width sections. In the **medium** and **narrow** layouts the two sections SHALL continue to stack full-width. When only one of the two sections is present, that section SHALL render full-width exactly as before this change. - -#### Scenario: Both sections present in a wide layout - -- **WHEN** a wide layout is built, the checklist is visible, and at least one subagent is visible -- **THEN** the checklist and subagent cohort are composed as side-by-side columns in one block (subject to the width fallback below) - -#### Scenario: Only one section present - -- **WHEN** a wide layout is built and exactly one of the checklist or subagent cohort is present -- **THEN** that section renders full-width and stacked exactly as before this change - -#### Scenario: Medium and narrow always stack - -- **WHEN** a medium or narrow layout is built with both sections present -- **THEN** the sections stack full-width and the side-by-side composition is not used - -### Requirement: Content-driven column split with fallback - -The left column SHALL render the task checklist and the right column SHALL render the subagent cohort. The left column width SHALL be the smaller of the widest task line and 45% of the inner content width. The right column SHALL receive the remaining inner width after subtracting the left column and the divider. If the resulting right column would be narrower than 40 visible columns, the renderer SHALL abandon the side-by-side composition and stack the two sections full-width. - -#### Scenario: Short task list yields a narrow left column - -- **WHEN** the widest task line is narrower than 45% of the inner width -- **THEN** the left column is sized to the widest task line and the right column receives the rest - -#### Scenario: Long task list is capped - -- **WHEN** the widest task line exceeds 45% of the inner width -- **THEN** the left column is capped at 45% of the inner width and task subjects truncate to fit - -#### Scenario: Insufficient remaining width falls back to stacked - -- **WHEN** the computed right column would be narrower than 40 visible columns -- **THEN** the side-by-side composition is abandoned and both sections render full-width and stacked - -### Requirement: Column divider and elbow threading - -The two columns SHALL be separated by a single vertical `│` drawn in the border gradient at a fixed divider column, padded by one space on each side. Every combined content row SHALL carry the divider at the same column. The separator above the block SHALL grow a `┬` at the divider column and the separator (or bottom border) below the block SHALL grow a matching `┴`, so the divider connects to the box top and bottom. To support a `┬` on the heavy static→dynamic seam, `border_separator` SHALL accept downward elbow columns. - -#### Scenario: Divider is continuous top to bottom - -- **WHEN** a side-by-side block is rendered -- **THEN** a `┬` appears on the separator above at the divider column, a `│` appears in every combined row at that column, and a `┴` appears on the separator (or bottom border) below at that column - -#### Scenario: Divider colour follows the border gradient - -- **WHEN** the divider is drawn at its column -- **THEN** its colour is taken from the border gradient at that column, consistent with other vertical seams - -### Requirement: Height reconciliation - -The two columns SHALL be rendered independently to their own content widths, then combined row-by-row up to the height of the taller column. The shorter column SHALL be padded with blank lines of its own column width, top-aligned, so the divider and the right edge remain straight and every combined row spans the full inner width. - -#### Scenario: Subagent column taller than task column - -- **WHEN** the subagent column produces more lines than the task column -- **THEN** the task column is padded with blank lines at the bottom and the divider runs straight through the padded rows - -#### Scenario: Task column taller than subagent column - -- **WHEN** the task column produces more lines than the subagent column -- **THEN** the subagent column is padded with blank lines at the bottom and every combined row spans the full inner width - -### Requirement: Builder-driven content width - -`Renderer.task_row` and `Renderer.subagent_row` SHALL render into an explicit content width supplied by the layout builder rather than deriving width from the terminal width. The subagent two-line versus one-line form SHALL be selected by a builder-supplied flag rather than an internal terminal-width threshold, so a narrow side-by-side column can still use the two-line form. - -#### Scenario: Section renderers honour the supplied width - -- **WHEN** a layout builder calls `task_row` or `subagent_row` with a content width -- **THEN** the produced lines fit within that width as measured by the visible-width helper - -#### Scenario: Two-line form forced in a narrow column - -- **WHEN** the builder composes a side-by-side block and requests the subagent two-line form for a narrow column -- **THEN** the subagent rows render in two-line form regardless of the column width diff --git a/openspec/specs/statusline-config/spec.md b/openspec/specs/statusline-config/spec.md deleted file mode 100644 index f1d5bb5..0000000 --- a/openspec/specs/statusline-config/spec.md +++ /dev/null @@ -1,320 +0,0 @@ -# statusline-config Specification - -## Purpose - -Define how the statusline resolves user configuration: a fixed precedence chain across CLI flags, canonical and legacy environment variables, a sectioned `yas.toml` file, and built-in defaults; fail-safe validation; per-model `soft_limit` overrides; a visible config-error row; and a single frozen source of resolved configuration. - -## Requirements - -### Requirement: Layered configuration precedence - -The statusline SHALL resolve every configurable knob through a single, fixed precedence chain: CLI flag (where one exists) → canonical `YAS_*` environment variable → legacy-alias environment variable → `yas.toml` value → built-in default. A higher-precedence source that is present and valid SHALL override all lower sources for that knob; an absent or invalid source SHALL fall through to the next. - -#### Scenario: Env overrides config file - -- **WHEN** `[layout].max_width = 200` is set in `yas.toml` and `YAS_MAX_WIDTH=160` is set in the environment -- **THEN** the resolved `max_width` is `160` - -#### Scenario: Config file overrides default - -- **WHEN** `[tokens].soft_limit = 1000000` is set in `yas.toml` and no `YAS_SOFT_LIMIT` env var is set -- **THEN** the resolved `soft_limit` is `1000000` - -#### Scenario: Default when nothing is set - -- **WHEN** no `yas.toml` exists and no relevant env var is set -- **THEN** every knob resolves to its built-in default (`max_width=140`, `full_width=false`, `soft_limit=150000`, `token_window=60`, `theme=dark`, `bg_shift=warm`, `show_day_stats=true`, `glyph_mode=nerdfont`, `single_width=false`) - -#### Scenario: CLI flag overrides env and config - -- **WHEN** `--theme` is passed on the command line and `YAS_THEME` and `[appearance].theme` are also set -- **THEN** the CLI `--theme` value is used - -### Requirement: Canonical env vars and deprecated aliases - -The statusline SHALL accept canonical `YAS_*` environment variables for all nine knobs (`YAS_MAX_WIDTH`, `YAS_FULL_WIDTH`, `YAS_SOFT_LIMIT`, `YAS_TOKEN_WINDOW`, `YAS_THEME`, `YAS_BG_SHIFT`, `YAS_SHOW_DAY_STATS`, `YAS_GLYPH_MODE`, `YAS_GLYPH_SINGLE_WIDTH`). It SHALL continue to honor the legacy aliases `STATUSLINE_TOKEN_WINDOW` (for `token_window`) and `CLAUDE_STATUSLINE_THEME` (for `theme`). When both a canonical var and its alias are set, the canonical value SHALL win. - -#### Scenario: Legacy alias still works - -- **WHEN** only `STATUSLINE_TOKEN_WINDOW=30` is set -- **THEN** the resolved `token_window` is `30` - -#### Scenario: Canonical wins over alias - -- **WHEN** `YAS_TOKEN_WINDOW=45` and `STATUSLINE_TOKEN_WINDOW=30` are both set -- **THEN** the resolved `token_window` is `45` - -#### Scenario: Theme alias resolves - -- **WHEN** only `CLAUDE_STATUSLINE_THEME` names a known theme -- **THEN** that theme is used - -#### Scenario: Day-stats env var resolves - -- **WHEN** `YAS_SHOW_DAY_STATS=0` is set -- **THEN** the resolved `show_day_stats` is `false` - -#### Scenario: Glyph-mode env var resolves - -- **WHEN** `YAS_GLYPH_MODE=unicode` is set -- **THEN** the resolved `glyph_mode` is `unicode` - -#### Scenario: Glyph single-width env var resolves - -- **WHEN** `YAS_GLYPH_SINGLE_WIDTH=1` is set -- **THEN** the resolved `single_width` is `true` - -### Requirement: yas.toml location and sectioned schema - -The statusline SHALL read configuration from `yas.toml` located in `CLAUDE_CONFIG_DIR` (defaulting to `~/.claude/`). The file SHALL use a sectioned schema: `[layout]` for `max_width` and `full_width`, `[tokens]` for `soft_limit` (global default), `token_window`, and `show_day_stats`, an optional `[[tokens.model]]` array of `{ match, soft_limit }` tables for per-model `soft_limit` overrides, `[appearance]` for `theme` and `bg_shift`, and the `[appearance.glyphs]` subtable for `mode` and `single_width`. Absence of the file SHALL be equivalent to all-defaults and SHALL NOT be an error. - -#### Scenario: Knobs read from their sections - -- **WHEN** `yas.toml` contains `[layout]` `max_width = 200`, `[tokens]` `soft_limit = 1000000`, and `[appearance]` `theme = "dark"` -- **THEN** those three values are resolved from the file - -#### Scenario: Day-stats read from tokens section - -- **WHEN** `yas.toml` contains `[tokens]` `show_day_stats = false` and no `YAS_SHOW_DAY_STATS` env var is set -- **THEN** the resolved `show_day_stats` is `false` - -#### Scenario: Glyph mode read from appearance.glyphs subtable - -- **WHEN** `yas.toml` contains `[appearance.glyphs]` `mode = "ascii"` and no `YAS_GLYPH_MODE` env var is set -- **THEN** the resolved `glyph_mode` is `ascii` - -#### Scenario: Single-width read from appearance.glyphs subtable - -- **WHEN** `yas.toml` contains `[appearance.glyphs]` `single_width = true` and no `YAS_GLYPH_SINGLE_WIDTH` env var is set -- **THEN** the resolved `single_width` is `true` - -#### Scenario: Missing file is not an error - -- **WHEN** no `yas.toml` exists in `CLAUDE_CONFIG_DIR` -- **THEN** the statusline renders normally using env + defaults and reports no config error - -#### Scenario: Unknown keys and sections are ignored - -- **WHEN** `yas.toml` contains a key or section that does not map to a known knob -- **THEN** the unknown entry is ignored and the rest of the config still resolves - -### Requirement: TOML parsing with graceful 3.10 degradation - -The statusline SHALL parse `yas.toml` using the standard-library `tomllib` module and SHALL remain zero-dependency. On a Python runtime where `tomllib` is unavailable (3.10), the statusline SHALL skip the `yas.toml` file silently and resolve every knob from env + defaults; it SHALL NOT crash and SHALL NOT add any third-party dependency. - -#### Scenario: TOML applied on 3.11+ - -- **WHEN** the runtime provides `tomllib` and a valid `yas.toml` exists -- **THEN** the file's values participate in precedence resolution - -#### Scenario: File skipped on 3.10 - -- **WHEN** the runtime does not provide `tomllib` and a `yas.toml` exists -- **THEN** the file is ignored, env + defaults are used, and the statusline renders without error - -### Requirement: Fail-safe validation of config values - -The statusline SHALL never crash or render garbage because of bad configuration. A syntactically broken `yas.toml` SHALL cause the entire file to be ignored (env + defaults still apply). A value that is the wrong type or out of range for its knob SHALL cause only that single knob to fall back to its default while all other valid knobs are still applied. Validation rules: `max_width` is an integer > 0; `full_width` is a boolean (env form accepts any non-empty value as true); `soft_limit` is an integer > 0; `token_window` is a number > 0; `theme` must be a known theme name; `bg_shift` must be one of `warm` or `cool`; `show_day_stats` is a boolean (env form treats `0`, `false`, and `no` as false and any other non-empty value as true); `glyph_mode` must be one of `nerdfont`, `ascii`, or `unicode`; `single_width` is a boolean (same env-form rules as `show_day_stats`). - -#### Scenario: Broken TOML ignores whole file - -- **WHEN** `yas.toml` contains a TOML syntax error -- **THEN** no value from the file is applied, env + defaults are used, and a config error is recorded - -#### Scenario: One bad value falls back, others apply - -- **WHEN** `yas.toml` sets `max_width = "banana"` (invalid) and `soft_limit = 1000000` (valid) -- **THEN** `max_width` resolves to its default and `soft_limit` resolves to `1000000` - -#### Scenario: Out-of-range value rejected - -- **WHEN** `soft_limit = -5` is configured -- **THEN** `soft_limit` falls back to its default and the rejection is recorded - -#### Scenario: Unknown enum value rejected - -- **WHEN** `bg_shift = "purple"` is configured -- **THEN** `bg_shift` falls back to `warm` and the rejection is recorded - -#### Scenario: Unknown glyph-mode value rejected - -- **WHEN** `[appearance].glyph_mode = "fancy"` is configured -- **THEN** `glyph_mode` falls back to `nerdfont` and the rejection is recorded - -#### Scenario: Non-boolean day-stats rejected - -- **WHEN** `[tokens].show_day_stats = "banana"` is configured -- **THEN** `show_day_stats` falls back to its default (`true`) and the rejection is recorded - -#### Scenario: Malformed per-model entry dropped - -- **WHEN** a `[[tokens.model]]` entry has a missing/empty `match`, or a `soft_limit` that is non-integer or `<= 0` -- **THEN** only that entry is dropped (models it would have matched fall back to the global `soft_limit`), valid entries still apply, and the rejection is recorded referencing the entry (e.g. `tokens.model[2]`) - -### Requirement: Per-model soft_limit resolution - -The statusline SHALL support per-model `soft_limit` overrides declared as a `[[tokens.model]]` array of tables, each with a `match` string and a `soft_limit` integer. At render time the statusline SHALL select the effective `soft_limit` for the session's model by matching each entry's `match` as a case-insensitive plain substring against the lowercased model `id` and `display_name`; when multiple entries match, the entry with the longest `match` string SHALL win, and ties SHALL be broken by file order (first entry wins). `match` SHALL be a literal substring (no glob or regex). When no entry matches, the global `soft_limit` SHALL be used. A matching per-model override SHALL take precedence over the global value from ANY source, including the `YAS_SOFT_LIMIT` environment variable (specificity beats source precedence; this is the single documented exception to the env > toml rule). Per-model overrides SHALL be expressible only in `yas.toml`; there SHALL be no per-model environment variable. - -#### Scenario: Most-specific match wins - -- **WHEN** entries `match="opus"` (200000) and `match="opus-4-8[1m]"` (1000000) are present and the session model id is `claude-opus-4-8[1m]` -- **THEN** the effective `soft_limit` is `1000000` (the longer `match="opus-4-8[1m]"` wins over `match="opus"`) - -#### Scenario: Falls back to global when no entry matches - -- **WHEN** only `match="haiku"` overrides exist and the session model is an Opus model -- **THEN** the effective `soft_limit` is the resolved global value - -#### Scenario: Matches against display_name as well as id - -- **WHEN** an entry `match="1m context"` is present and the model `display_name` is `Opus 4.8 (1M context)` -- **THEN** that entry matches (case-insensitive) and its `soft_limit` is used - -#### Scenario: Per-model toml beats global env - -- **WHEN** `YAS_SOFT_LIMIT=200000` is set in the environment and a matching entry `match="1m", soft_limit=1000000` exists for a 1M-context session -- **THEN** the effective `soft_limit` is `1000000` - -#### Scenario: Tie broken by file order - -- **WHEN** two entries have equal-length `match` strings that both match the model -- **THEN** the entry appearing first in the file is used - -### Requirement: Visible config-error row - -When one or more configuration values are rejected, the statusline SHALL append a single compact error row at the bottom of the box, inside the border, naming the rejected knobs and truncated to the render width (e.g. `⚠ yas.toml: 2 values ignored (max_width, bg_shift)`). The row SHALL appear in all layouts (narrow, medium, wide). The error row SHALL NOT appear when no value was rejected. Full per-value reasons SHALL be written to stderr only when `YAS_DEBUG` is set in the environment. - -#### Scenario: Error row shown on rejection - -- **WHEN** two configured values are rejected -- **THEN** a single error row is appended above the bottom border naming the two rejected knobs - -#### Scenario: No error row when config is clean - -- **WHEN** all configured values are valid (or no config is present) -- **THEN** no error row is rendered - -#### Scenario: Detailed reasons gated by YAS_DEBUG - -- **WHEN** a value is rejected and `YAS_DEBUG` is set -- **THEN** a per-value reason line is written to stderr in addition to the compact row - -#### Scenario: Error row appears in narrow layout - -- **WHEN** a value is rejected and the terminal is narrow -- **THEN** the compact error row is still appended, truncated to the narrow width without breaking the box - -### Requirement: Single source of resolved configuration - -The statusline SHALL expose resolved configuration through one frozen `Config` object loaded once, and the existing module-level constants that callers and tests depend on (`MAX_WIDTH`, `SOFT_LIMIT`, the token-rate window) SHALL be sourced from that object so that current behaviour and module-reload tests continue to work. The module-level `SOFT_LIMIT` SHALL hold the resolved *global* value; per-model resolution SHALL be performed at render time via a `Config` method (e.g. `soft_limit_for(model_name)`) whose result is threaded into the rendering paths, not via the module constant. Layout breakpoints (narrow/medium/min width) SHALL remain hardcoded and SHALL NOT be user-configurable. - -#### Scenario: Module constant holds the global value - -- **WHEN** `YAS_MAX_WIDTH` and `YAS_SOFT_LIMIT` are set and the module is loaded -- **THEN** `MAX_WIDTH` equals the resolved value and `SOFT_LIMIT` equals the resolved global `soft_limit` (independent of any per-model overrides) - -#### Scenario: Layout breakpoints are not configurable - -- **WHEN** a user attempts to set a narrow/medium/min width via env or `yas.toml` -- **THEN** the layout breakpoints are unchanged (the setting has no effect) - -### Requirement: justify layout knob - -The statusline SHALL support a `justify` boolean knob that controls whether the wide layout distributes horizontal slack evenly across top-row sections. The knob SHALL resolve through the standard precedence chain: `YAS_JUSTIFY` environment variable → `[layout].justify` in `yas.toml` → built-in default of `false`. The env var SHALL accept the same boolean forms as other boolean knobs (`1`/`0`/`true`/`false`). An invalid value SHALL cause the knob to fall back to `false` and be recorded in debug output; a `yas.toml`-sourced rejection SHALL also be surfaced in the visible config-error row. The constant `DEFAULT_JUSTIFY = False` SHALL be defined in `constants.py` and imported by `config.py`. - -#### Scenario: Default is false - -- **WHEN** no `YAS_JUSTIFY` env var is set and no `[layout].justify` key exists in `yas.toml` -- **THEN** `cfg.justify` is `false` and the wide layout behaves as before - -#### Scenario: Env var enables justify - -- **WHEN** `YAS_JUSTIFY=1` is set in the environment -- **THEN** `cfg.justify` is `true` - -#### Scenario: yas.toml enables justify - -- **WHEN** `yas.toml` contains `[layout]` with `justify = true` and no `YAS_JUSTIFY` env var is set -- **THEN** `cfg.justify` is `true` - -#### Scenario: Env var overrides yas.toml - -- **WHEN** `YAS_JUSTIFY=0` is set in the environment and `[layout].justify = true` is in `yas.toml` -- **THEN** `cfg.justify` is `false` - -#### Scenario: Invalid env value falls back to default - -- **WHEN** `YAS_JUSTIFY=banana` is set -- **THEN** `cfg.justify` is `false` and the rejection is recorded in debug output - -### Requirement: Section-labels knob - -The statusline SHALL expose a boolean `labels` knob that toggles wide-layout section labels. It SHALL resolve through the standard precedence chain: canonical `YAS_LABELS` environment variable → `[layout].labels` in `yas.toml` → built-in default `false`. The resolved value SHALL be exposed as `cfg.labels` on the frozen `Config`. An absent or unparseable source SHALL fall through to the next, ending at the `false` default. The knob SHALL accept the same boolean spellings as other boolean knobs (`1`/`0`/`true`/`false`, case-insensitive, for env values; native booleans in `yas.toml`). - -#### Scenario: Default is false - -- **WHEN** no `yas.toml` sets `[layout].labels` and `YAS_LABELS` is unset -- **THEN** the resolved `cfg.labels` is `false` - -#### Scenario: Config file enables labels - -- **WHEN** `[layout].labels = true` is set in `yas.toml` and `YAS_LABELS` is unset -- **THEN** the resolved `cfg.labels` is `true` - -#### Scenario: Env overrides config file - -- **WHEN** `[layout].labels = true` is set in `yas.toml` and `YAS_LABELS=0` is set in the environment -- **THEN** the resolved `cfg.labels` is `false` - -#### Scenario: Invalid value falls through to default - -- **WHEN** `YAS_LABELS=maybe` is set and nothing else configures the knob -- **THEN** the resolved `cfg.labels` is `false` - -### Requirement: Glyph mode knob - -The statusline SHALL expose a `glyph_mode` knob that selects the glyph rendering mode, resolved through the standard precedence chain: CLI `--glyph-mode ` → `YAS_GLYPH_MODE` env var → `[appearance.glyphs].mode` in `yas.toml` → default. The accepted values SHALL be exactly `nerdfont`, `ascii`, and `unicode` (case-insensitive), and the default SHALL be `nerdfont`. Any other value SHALL be rejected and fall back to the default like every other knob. There SHALL be no per-mode environment variable beyond `YAS_GLYPH_MODE`. - -#### Scenario: Env var selects a mode - -- **WHEN** `YAS_GLYPH_MODE=ascii` is set -- **THEN** the resolved `glyph_mode` is `ascii` - -#### Scenario: CLI flag overrides env and toml - -- **WHEN** `--glyph-mode unicode` is passed and `YAS_GLYPH_MODE=ascii` and `[appearance.glyphs].mode = "nerdfont"` are also set -- **THEN** the resolved `glyph_mode` is `unicode` - -#### Scenario: Default is nerdfont - -- **WHEN** no `glyph_mode` is configured from any source -- **THEN** the resolved `glyph_mode` is `nerdfont` - -#### Scenario: Unknown mode rejected - -- **WHEN** `glyph_mode = "fancy"` is configured -- **THEN** `glyph_mode` falls back to `nerdfont` and the rejection is recorded - -### Requirement: Single-width knob - -The statusline SHALL expose a `single_width` boolean knob, independent of `glyph_mode`, resolved through the standard precedence chain: CLI `--glyph-single-width ` → `YAS_GLYPH_SINGLE_WIDTH` env var → `[appearance.glyphs].single_width` in `yas.toml` → default. The value SHALL be a boolean (env form treats `0`, `false`, and `no` as false and any other non-empty value as true) and the default SHALL be `false`. An invalid value SHALL fall back to the default like every other knob. The knob SHALL be combinable with any `glyph_mode` value. - -#### Scenario: Env var enables the fold - -- **WHEN** `YAS_GLYPH_SINGLE_WIDTH=1` is set -- **THEN** the resolved `single_width` is `true` - -#### Scenario: CLI flag overrides env and toml - -- **WHEN** `--glyph-single-width false` is passed and `YAS_GLYPH_SINGLE_WIDTH=1` and `[appearance.glyphs].single_width = true` are also set -- **THEN** the resolved `single_width` is `false` - -#### Scenario: Default is false - -- **WHEN** no `single_width` is configured from any source -- **THEN** the resolved `single_width` is `false` - -#### Scenario: Combines with a mode - -- **WHEN** `glyph_mode = "unicode"` and `single_width = true` are configured together -- **THEN** both knobs resolve to their configured values independently diff --git a/openspec/specs/statusline-info/spec.md b/openspec/specs/statusline-info/spec.md deleted file mode 100644 index 9198537..0000000 --- a/openspec/specs/statusline-info/spec.md +++ /dev/null @@ -1,133 +0,0 @@ -# statusline-info - -## Purpose - -Define the gather seam between raw session state and the renderer: how derived -session state is collected, cached, and exposed to the render pipeline. -## Requirements -### Requirement: Lazy pure-read SessionView gather - -The statusline SHALL gather all *derived* session state through a single `SessionView` module (`claude/statusline/info.py`), constructed once per render from a parsed `SessionInfo` plus a `Config`. `SessionView` SHALL expose the derived state as lazily-evaluated, cached fields: `git`, `skills`, `subagents`, `tasks`, `transcript_usage`, `changes` (OpenSpec changes), `elapsed`, `session_cost`, `session_inout`, and `cache_countdown`. A field SHALL read its underlying source on first access and cache the result; a second access SHALL NOT re-read. Constructing a `SessionView` SHALL perform no source reads. `SessionView` SHALL perform no disk writes and SHALL NOT call `TokenLog.update` or `TokenRate.update`. The `cache_countdown` field SHALL be derived from `transcript_usage`'s raw cache anchor and the view's single frozen `now`, reusing the already-cached transcript scan rather than re-reading the transcript. - -#### Scenario: A narrow render reads only what it draws - -- **WHEN** a `SessionView` is constructed and a narrow-width build reads only `view.subagents` -- **THEN** the git subprocess, the transcript scan, and the openspec walk are not triggered (only the subagent source is read) - -#### Scenario: A field is read at most once per view - -- **WHEN** `view.session_inout` and `view.transcript_usage` are both accessed on one `SessionView` -- **THEN** the transcript is scanned exactly once (the cached value feeds both) - -#### Scenario: Cache countdown reuses the cached transcript scan - -- **WHEN** `view.transcript_usage` and `view.cache_countdown` are both accessed on one `SessionView` -- **THEN** the transcript is scanned exactly once (the cached usage feeds both, and `cache_countdown` triggers no additional read) - -#### Scenario: Constructing a view writes nothing - -- **WHEN** a `SessionView` is constructed and any subset of its fields is accessed -- **THEN** no token-log or token-rate file is written by the view - -### Requirement: Render-independent gather seam - -`SessionView` SHALL sit below the render layer: it MAY import `session`, the six reader modules (`git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`), `metrics`, `tokens` (cost computation only), and `config`, but SHALL NOT import `renderer`, `pill`, `gradient`, `borders`, or `layout`. `SessionView` SHALL hold no render geometry (bar fill ratio, pill percentage, model anchor/shift). It SHALL delegate to the readers' existing classmethods rather than inlining their logic, so each reader keeps its individually-stubbable seam. - -#### Scenario: Info carries no render dependency - -- **WHEN** `claude/statusline/info.py` is imported -- **THEN** it references no symbol from `renderer`, `pill`, `gradient`, `borders`, or `layout` - -#### Scenario: Time math uses one frozen clock - -- **WHEN** a `SessionView` is constructed with an explicit `now` -- **THEN** `elapsed` and task-freshness decisions derived through the view use that single `now` value - -### Requirement: Single source for the Session In/Out denominator - -`SessionView.session_inout` SHALL be the sole definition of the Session Share % denominator: `(billed_in + cache_read + out) + sum(total_input + output)` over the running subagents. No layout builder SHALL recompute this sum inline. - -#### Scenario: Denominator composes usage and subagents - -- **WHEN** `view.session_inout` is read with known transcript usage and a known set of running subagents -- **THEN** it equals the transcript billed-in plus cache-read plus output, plus each subagent's `total_input + output` - -### Requirement: Per-render writes isolated in record_tick - -The per-render token-log and token-rate writes SHALL be performed by a `record_tick(session, usage)` step owned by `app`, returning a `TickRecord` bundling `token_log`, `day_cost`, and the token rate. `app` SHALL thread the `TickRecord` into the wide layout builder. The Day Total and day-cost SHALL NOT be fields of `SessionView`. - -#### Scenario: The wide builder receives day totals as data - -- **WHEN** `app` renders a wide layout -- **THEN** `record_tick` performs the token-log and token-rate writes and the resulting `TickRecord` is passed into the wide builder, which reads `day_cost` from it rather than computing or persisting it - -### Requirement: Clear-marker epoch gather field - -`SessionView` SHALL expose a render-independent, lazily-computed field giving the Unix epoch (seconds) of the most recent `/clear` in the current transcript, or `None` when the session has never been cleared. The field SHALL be read by a **bounded head-scan** of the transcript: it SHALL inspect at most the first 30 lines, match the `/clear` user marker, and parse that line's ISO-8601 `timestamp` to an epoch. Because each `/clear` forks a new transcript file, at most one such marker exists per transcript, so the first match is the only match. The scan SHALL early-exit on the first match and SHALL never read the whole file, so fresh sessions and large transcripts pay only the bounded cost. Malformed lines, an unreadable transcript, an empty `transcript_path`, or no marker within the budget SHALL yield `None` rather than raising. The field SHALL hold no ANSI or render geometry and SHALL be cached for the lifetime of the view. - -#### Scenario: Cleared session exposes the marker epoch - -- **WHEN** the current transcript contains a `/clear` marker within the first 30 lines with a parseable timestamp -- **THEN** the gather field returns that timestamp as a Unix epoch - -#### Scenario: Fresh session exposes None - -- **WHEN** the current transcript contains no `/clear` marker within the first 30 lines -- **THEN** the gather field returns `None` - -#### Scenario: Bounded cost on a large transcript - -- **WHEN** the transcript is hundreds of lines long and has no `/clear` marker in its first 30 lines -- **THEN** the scan reads at most 30 lines and returns `None` without scanning the remainder - -#### Scenario: Unreadable or malformed input degrades to None - -- **WHEN** the transcript path is empty, missing, or the candidate marker line is not valid JSON or lacks a parseable timestamp -- **THEN** the gather field returns `None` and does not raise - -### Requirement: Tool-counts gather field - -`SessionView` SHALL expose a `tool_counts` `@cached_property` returning a -`ToolCounts` value that holds, per tool name, the `(main, sub)` `tool_use` counts -and the total number of distinct tool types. The same value SHALL additionally -hold the session's `lines_read` and `lines_changed` totals (the main transcript -plus every subagent transcript) and a per-transcript breakdown keyed by transcript -path, so a caller can look up any one subagent's own figures. It SHALL be -constructed from the main -transcript, the subagent cohort, and `clear_epoch` — all fields already available -on the view — and SHALL perform no I/O beyond reopening those same transcript -files, walking each file exactly once for both the tool counts and the line -counts. As a `@cached_property`, it SHALL be computed at most once per view and -SHALL NOT be evaluated when a render path never reads it (narrow/medium). The -`info` layer SHALL NOT import `renderer` or `layout` to provide it. - -#### Scenario: Field exposes per-tool main/sub counts - -- **WHEN** a `SessionView` is constructed and `tool_counts` is read -- **THEN** it returns a `ToolCounts` whose per-tool entries each carry a `main` and - a `sub` count derived from the main transcript and the subagent cohort - respectively - -#### Scenario: Field exposes session line totals - -- **WHEN** `tool_counts` is read -- **THEN** it also exposes `lines_read` and `lines_changed` totalled over the main - transcript and every subagent transcript - -#### Scenario: Field exposes a per-transcript breakdown - -- **WHEN** a caller has a subagent's transcript path -- **THEN** it can obtain that subagent's own `(lines_read, lines_changed)` pair - from the same `ToolCounts` value - -#### Scenario: Field is lazy - -- **WHEN** a narrow or medium render is produced without reading `tool_counts` -- **THEN** the tool-counts aggregation is never computed - -#### Scenario: Field respects the clear window - -- **WHEN** `clear_epoch` is set on the view -- **THEN** `tool_counts` reflects only `tool_use` messages at or after that epoch, - and the line totals reflect only activity at or after that epoch - diff --git a/openspec/specs/statusline-packaging/spec.md b/openspec/specs/statusline-packaging/spec.md deleted file mode 100644 index 5c7c301..0000000 --- a/openspec/specs/statusline-packaging/spec.md +++ /dev/null @@ -1,69 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Frozen statusLine entrypoint - -The statusLine command SHALL remain invocable as `python /claude/statusline_command.py`, reading session JSON on stdin and writing the rendered statusline to stdout. The file `claude/statusline_command.py` SHALL continue to exist at that path and name, because installed `settings.json` configurations hardcode it. After the reorganisation it SHALL contain only the composition entrypoint (importing and calling `yas.app.main`), delegating all behaviour to the `yas` package. - -#### Scenario: Entrypoint still renders from stdin - -- **WHEN** `python claude/statusline_command.py` is run with a valid session-info JSON payload on stdin at a terminal width ≥ `MIN_WIDTH` -- **THEN** it writes the same rendered statusline string it produced before the reorganisation - -#### Scenario: Entrypoint is thin - -- **WHEN** `claude/statusline_command.py` is inspected after the reorganisation -- **THEN** it defines no renderer, reader, config, or layout logic of its own and only wires `yas.app.main` to `__main__` - -### Requirement: Layered acyclic package - -The statusline source SHALL be organised as a Python package under `claude/yas/`, forming a single-directional acyclic dependency graph. The package SHALL contain two subpackages: - -- `yas/info/` — the data-gather layer: `git`, `openspec`, `skills`, `subagents`, `tasks`, `transcript` (readers), with `SessionView` as the public face via `__init__.py`. -- `yas/render/` — renderer building-blocks: `gradient`, `borders`, `pill`, `text`, `metrics`. - -Top-level modules (cross-cutting): `constants`, `session`, `config`, `tokens`, `themes`, `renderer`, `layout`, `app`. - -The DAG order SHALL be: `constants → text → {config, session, metrics, tokens} → {info/*, render/*} → renderer → layout → app`. A module SHALL import only from modules earlier in this order; no import cycle SHALL exist. - -#### Scenario: No import cycles - -- **WHEN** every module in `claude/yas/` (including subpackages) is imported -- **THEN** all imports resolve with no circular-import error - -#### Scenario: Readers carry no render dependency - -- **WHEN** the filesystem reader modules under `yas/info/` (`git`, `skills`, `subagents`, `tasks`, `transcript`, `openspec`) are imported -- **THEN** they reference no symbol from `yas.render`, `yas.renderer`, or `yas.layout` - -#### Scenario: Subpackage public face - -- **WHEN** a caller writes `from yas.info import SessionView` -- **THEN** the import resolves without referencing a submodule explicitly, because `SessionView` is exported from `yas/info/__init__.py` - -### Requirement: Tests import real modules - -Test files SHALL import the concrete package modules they exercise (e.g. `import yas.borders as borders`) rather than a single flat `statusline_command` re-export namespace. The package SHALL NOT provide a catch-all re-export shim whose only purpose is to preserve the old flat namespace. - -#### Scenario: A test names its module - -- **WHEN** a test that exercises border math is read -- **THEN** it imports `yas.render.borders` (the module that owns `BorderRenderer`), not `statusline_command` - -#### Scenario: pytest resolves the package - -- **WHEN** the test suite is run via `uv run pytest -q` -- **THEN** `yas.*` modules import successfully because `pythonpath` includes the `claude` directory, with no per-test `spec_from_file_location` shim - -### Requirement: Live configuration resolution - -The statusline SHALL resolve configuration by calling `Config.load()` at render/command time rather than from a module-level singleton evaluated at import. No import-time global SHALL gate behaviour; functions needing a resolved limit SHALL receive it explicitly. Importing any `yas` module SHALL NOT read environment variables or `yas.toml`. - -#### Scenario: Env change takes effect without reimport - -- **WHEN** `YAS_SOFT_LIMIT` is set and `render()` is called in the same process where a `yas` module was already imported -- **THEN** the freshly set value is honoured (config is resolved live, not cached at import) - -#### Scenario: Importing a module is side-effect free - -- **WHEN** any `yas` package module is imported -- **THEN** no `yas.toml` read or `YAS_*` environment lookup occurs as a side effect of the import diff --git a/openspec/specs/subagent-cohort/spec.md b/openspec/specs/subagent-cohort/spec.md deleted file mode 100644 index d95bc56..0000000 --- a/openspec/specs/subagent-cohort/spec.md +++ /dev/null @@ -1,128 +0,0 @@ -# subagent-cohort Specification - -## Purpose - -Define how the statusline detects subagent completion, scopes the visible cohort to the current turn, retires the section as a unit after a grace window, sweeps dirty cohorts after silence, and renders finished agents with a distinct visual treatment. - -## Requirements - -### Requirement: Done detection via end_turn - -The statusline SHALL treat a subagent as **Done** when, and only when, its transcript jsonl contains an assistant message whose `message.stop_reason` equals `"end_turn"`. The timestamp of that line SHALL be captured as the subagent's `end_ts`. Transcript-write staleness SHALL NOT, on its own, mark a subagent Done. - -The `end_turn` check SHALL be evaluated on every assistant message line that carries a usage block, **independent of message-id deduplication**. Deduplication by `message.id` SHALL guard only token/usage accumulation; it SHALL NOT cause a line bearing `stop_reason: "end_turn"` to be skipped. A streaming partial that wrote the same `message.id` earlier (with `stop_reason: null`) SHALL NOT suppress the terminal-state capture from the final write of that message. - -#### Scenario: Clean finish marks Done - -- **WHEN** a subagent transcript's final assistant message carries `stop_reason: "end_turn"` -- **THEN** the subagent is Done and its `end_ts` is the timestamp of that line - -#### Scenario: Duplicated final-message id still marks Done - -- **WHEN** the assistant message bearing `stop_reason: "end_turn"` shares its `message.id` with an earlier streaming partial line that had `stop_reason: null` -- **THEN** the dedup guard does not skip the terminal check, the subagent is marked Done, and `end_ts` is captured from the end_turn line - -#### Scenario: Dedup still prevents double-counting tokens - -- **WHEN** the same `message.id` appears across multiple transcript lines -- **THEN** that message's `usage` tokens are accumulated exactly once, while the `end_turn` check still runs on each line - -#### Scenario: Silence does not mark Done - -- **WHEN** a subagent transcript has had no writes for longer than the liveness window but contains no `end_turn` -- **THEN** the subagent is NOT marked Done (it is handled by the janitor sweep instead) - -#### Scenario: Interrupted agent never emits end_turn - -- **WHEN** a subagent was interrupted, killed, or errored and its transcript ends without `stop_reason: "end_turn"` -- **THEN** the subagent is never marked Done and never receives the Done visual treatment - -### Requirement: Turn-scoped cohort membership - -The statusline SHALL scope the visible subagent cohort to the current turn. A subagent is a member of the cohort when its `first_timestamp` is at or after the last user-prompt timestamp for the session, OR when it is still actively writing (its transcript was written within the liveness window) regardless of when it started. A running subagent SHALL always be shown regardless of age; the age cutoff applies only to finished or idle subagents. - -#### Scenario: Agent spawned this turn is in the cohort - -- **WHEN** a subagent's `first_timestamp` is at or after the last user-prompt timestamp -- **THEN** it is a member of the cohort - -#### Scenario: Pre-turn straggler still writing is kept - -- **WHEN** a subagent started before the last user prompt but its transcript was written within the liveness window -- **THEN** it remains a member of the cohort until it finishes or dies - -#### Scenario: Running agent is always shown - -- **WHEN** a subagent is still running (not Done and written within the liveness window) -- **THEN** it is shown regardless of how long ago it started - -#### Scenario: Old finished agent from a prior turn is excluded - -- **WHEN** a subagent finished in a previous turn and its `first_timestamp` is before the last user-prompt timestamp -- **THEN** it is not shown in the current turn's cohort - -### Requirement: Cohort-level retirement with grace window - -The statusline SHALL keep every cohort member visible until the **last** member is Done, then retire the entire section together after a grace window of 20 seconds measured from the most recent member's `end_ts`. Members that finished earlier SHALL remain visible (dimmed) until the whole section retires. - -#### Scenario: Section persists while any member runs - -- **WHEN** at least one cohort member is not yet Done -- **THEN** the whole section, including already-finished members, remains visible - -#### Scenario: Whole section retires after grace - -- **WHEN** every cohort member is Done and 20 seconds have elapsed since the most recent member's `end_ts` -- **THEN** the entire subagent section is removed - -#### Scenario: Finished member waits for stragglers - -- **WHEN** member A is Done but sibling member B is still running -- **THEN** member A stays on screen (dimmed) rather than dropping off individually - -### Requirement: Janitor sweep for dirty cohorts - -When a cohort contains at least one member that is not Done and has stopped writing (so the clean grace countdown can never arm), the statusline SHALL remove the entire section after 60 seconds of total silence across all of the cohort's transcripts. - -#### Scenario: Dirty cohort swept after silence - -- **WHEN** a cohort contains a member that never emitted `end_turn` and no member's transcript has been written for 60 seconds -- **THEN** the entire section is removed - -#### Scenario: Janitor does not fire while a member writes - -- **WHEN** any cohort member's transcript was written within the last 60 seconds -- **THEN** the janitor does not sweep the section - -### Requirement: Finished-agent visual treatment - -A Done subagent's row SHALL be visually distinguished from a running one by dimming and a frozen timer, not by a leading marker glyph. The running/Done distinction SHALL NOT use the `▶`/`✓` markers — the elapsed duration now occupies that leading position. A Done row SHALL be dimmed (overriding the running row's rainbow marker and field colours) and its elapsed field SHALL be frozen at `end_ts − first_timestamp` rather than continuing to tick from the current time. A running row SHALL render with live colours and a live-ticking elapsed field. - -#### Scenario: Done row shows dimmed styling and a frozen timer - -- **WHEN** a subagent is Done and still within the cohort grace window -- **THEN** its row renders with dimmed colours and a frozen elapsed duration, with no `▶`/`✓` marker - -#### Scenario: Elapsed freezes at completion - -- **WHEN** a subagent is Done -- **THEN** its elapsed field shows `end_ts − first_timestamp` and does not increase on subsequent renders - -#### Scenario: Running row uses live colours and a ticking timer - -- **WHEN** a subagent is still running -- **THEN** its row renders with live colours and a live-ticking elapsed field, with no `▶`/`✓` marker - -### Requirement: Graceful fallback without a prompt marker - -When the last user-prompt timestamp is unavailable (the marker file is missing, unreadable, stale, or its session entry is absent), the statusline SHALL scope the cohort by a recency window of 60 seconds instead of by turn, and SHALL render without error. - -#### Scenario: Missing marker degrades to recency window - -- **WHEN** no usable last-prompt timestamp exists for the session -- **THEN** the cohort comprises subagents active or finished within the last 60 seconds, and rendering proceeds normally - -#### Scenario: Unreadable marker never breaks rendering - -- **WHEN** the marker file is truncated or contains invalid JSON -- **THEN** the statusline falls back to the recency window and does not raise diff --git a/openspec/specs/subagent-row-layout/spec.md b/openspec/specs/subagent-row-layout/spec.md deleted file mode 100644 index be7faea..0000000 --- a/openspec/specs/subagent-row-layout/spec.md +++ /dev/null @@ -1,144 +0,0 @@ -# subagent-row-layout Specification - -## Purpose - -Define the field set and layout of an individual subagent row: the two-line form (duration-first line 1 with a right-aligned ` · · ` cluster and an activity-continuation line 2), the shedding order under width pressure, and the one-line collapse form. The t/m rate, ↑output, and session-share% fields are removed from all forms. -## Requirements -### Requirement: Two-line row field set - -A subagent rendered in two-line form SHALL place the elapsed duration at the front of line 1, followed by the agent type, a `·` separator, and the description. Line 1 SHALL end with a right-aligned cluster of ` · · `. Line 2 SHALL show only the activity continuation (`└` + activity glyph + tool/verb), with no right-aligned metrics. The t/m rate, ↑output, and session-share% fields SHALL NOT appear in either line. - -#### Scenario: Two-line row renders duration-first with the line-1 cluster - -- **WHEN** a subagent is rendered in two-line form with room for all fields -- **THEN** line 1 reads ` · ` with a right-aligned ` · · ` cluster, and line 2 reads `└ ` - -#### Scenario: No t/m rate, output token, or share% field - -- **WHEN** any subagent row is rendered -- **THEN** neither the t/m rate field, the ↑output field, nor a session-share `(N.N%)` suffix appears - -### Requirement: Line-1 cluster shedding - -When line 1 lacks room for the full ` · · ` cluster, the description SHALL truncate first. If the cluster still does not fit, fields SHALL shed in order: the lines field first, then the `` token count. The model and the front duration SHALL always be retained. - -#### Scenario: Description truncates before the cluster sheds - -- **WHEN** line 1 is too wide for the full description plus cluster -- **THEN** the description truncates with an ellipsis while the full cluster is retained - -#### Scenario: Cluster sheds lines, then tok, under width pressure - -- **WHEN** the truncated description plus full cluster still exceeds the width -- **THEN** the lines field is dropped first, then the `` token count, while model and the front duration remain - -#### Scenario: Narrow widths behave as before the lines field existed - -- **WHEN** the width is tight enough that the lines field is shed -- **THEN** the remaining cluster is byte-identical to the pre-change cluster at that width - -### Requirement: One-line collapse form - -A subagent rendered in one-line (collapsed) form SHALL omit the ↑output field. Its remaining structure — leading marker, agent type, model, activity verb, and the right-aligned token and duration fields — SHALL be unchanged. - -#### Scenario: One-line form drops output but keeps token and duration - -- **WHEN** a subagent is rendered in one-line collapsed form -- **THEN** the ↑output field is absent and the token count and duration fields remain - -### Requirement: Activity verb derivation - -The activity continuation's verb SHALL be derived from the latest assistant -message in the subagent transcript by preferring the last `tool_use` content -block in that message. When the message contains no `tool_use` block, the verb -SHALL fall back to the first non-empty line of the last `text` block, passed -through the untrusted-input sanitizer. A `thinking` block SHALL continue to -render as the thinking indicator. The system SHALL NOT render a contentless -`(replying)` placeholder when text content is available. - -The rendered text snippet (and tool-arg) SHALL use a dynamic activity -truncation cap that grows with the available line-2 width, measured via the -visible-width helper, appending a single `…` when the content exceeds that cap. -The cap defaults to 36 visible columns when no wider space is available and for -callers without width context (the floor); the line-2 renderer passes -`min(100, available_width)`, so the cap rises up to a ceiling of 100 visible -columns when the terminal has spare horizontal space. - -When the tool argument contains newline characters, only the first line SHALL -be used for display. Subsequent lines SHALL be discarded before the width cap -is applied. - -#### Scenario: Multi-line tool argument shows only first line - -- **WHEN** the tool argument string contains newline characters (e.g. a multi-line Bash command) -- **THEN** only the content before the first newline is displayed; subsequent lines are not rendered - -#### Scenario: Tool use wins over trailing text in the same message - -- **WHEN** the latest assistant message contains both a `tool_use` block and a - trailing `text` block -- **THEN** the activity continuation shows the tool verb (` Tool[arg]`), - not the text snippet - -#### Scenario: Text-only message shows a snippet instead of bare replying - -- **WHEN** the latest assistant message ends with a `text` block and contains - no `tool_use` block -- **THEN** the activity continuation shows the replying glyph followed by the - first non-empty line of that text, sanitized - -#### Scenario: Snippet within the available width is shown in full - -- **WHEN** the first non-empty line of the text block exceeds 36 visible columns - but fits within the available line-2 width (≤ 100 visible columns) -- **THEN** the snippet is shown in full with no trailing `…` - -#### Scenario: Long text snippet truncates at the dynamic cap - -- **WHEN** the first non-empty line of the text block exceeds the cap derived - from the available line-2 width (`min(100, available_width)`) -- **THEN** the snippet is truncated to that cap with a trailing `…` - -#### Scenario: Snippet beyond the ceiling truncates at 100 columns - -- **WHEN** the first non-empty line of the text block exceeds the 100-column - ceiling -- **THEN** the snippet is truncated to 100 visible columns with a trailing `…` - -#### Scenario: Thinking block is unchanged - -- **WHEN** the latest assistant message's selected block is a `thinking` block -- **THEN** the activity continuation shows the thinking indicator - -### Requirement: Line-1 lines field - -The line-1 stats cluster SHALL carry a lines field showing the subagent's own -`lines_read` and `lines_changed`, each humanised in the same form as the tok field -(for example `1.2k`), rendered as a tight ` / ` ratio — a -space on both sides of the `/`, with no icon on either side. This no-icon -notation was adopted (commit ad10872) in place of an earlier per-figure-glyph -form specifically because per-figure icons cost extra width the crowded -subagent cohort display could not spare. The field SHALL be fixed-width in the -`tree_single` cluster so the constant activity gap after the cluster lands at -the same absolute column down the cohort. The field's read and changed sides -SHALL shed independently: a subagent that only wrote renders a blank (not `0`) -on the read side while still showing the changed side, and vice versa, with -each blank occupying exactly the width the populated value would have. The -field renders as blank padding of the full field width, not `0`, when the -subagent has neither read nor changed anything. - -#### Scenario: Field shows the subagent's own figures - -- **WHEN** a subagent has read 1,200 lines and changed 30 -- **THEN** its row's lines field shows `1.2k /30` - -#### Scenario: Field is blank when idle - -- **WHEN** a subagent has read 0 lines and changed 0 lines -- **THEN** the field renders as spaces and the cluster's total width is unchanged - -#### Scenario: Cluster width stays deterministic across the cohort - -- **WHEN** several subagent rows with differing figures render in tree mode -- **THEN** every row's activity column begins at the same absolute column - diff --git a/openspec/specs/task-checklist/spec.md b/openspec/specs/task-checklist/spec.md deleted file mode 100644 index 5a203fb..0000000 --- a/openspec/specs/task-checklist/spec.md +++ /dev/null @@ -1,132 +0,0 @@ -# task-checklist Specification - -## Purpose - -Define how the statusline derives and renders the live task checklist from `TaskCreate`/`TaskUpdate` events: scoping output to the latest plan generation, per-task and total timing, an active-anchored display window, pinned visibility while work is in progress, layout-specific rendering, and consistent timer formatting. - -## Requirements - -### Requirement: Plan Generation scoping - -The task parser SHALL render and count only the **latest plan generation**, not every task created in the session. A new generation SHALL begin when a `TaskCreate` event is folded while **all** currently-known tasks are `completed`; that event discards the prior generation and restarts task ids at `#1`. A `TaskCreate` folded while any task is still `pending` or `in_progress` SHALL append to the current generation. The resulting `done/total` count SHALL reflect only the latest generation. - -#### Scenario: New batch after completion starts a fresh generation - -- **WHEN** every task in the list is `completed` and a later `TaskCreate` is folded -- **THEN** the prior tasks are dropped and the new task is `#1` of a fresh generation - -#### Scenario: Create while work is open appends to the current generation - -- **WHEN** a `TaskCreate` is folded while at least one task is still `pending` or `in_progress` -- **THEN** the new task is appended to the current generation, keeping the existing tasks - -#### Scenario: Count reflects only the latest generation - -- **WHEN** earlier completed generations exist before the current one -- **THEN** `done/total` counts only the tasks of the latest generation - -### Requirement: Per-task timing - -A **Task** SHALL carry `started_at` and `completed_at` (epoch seconds, or absent). On a `TaskUpdate` transitioning a task **to** `in_progress`, the parser SHALL set `started_at` to that event's timestamp and clear `completed_at`. On a `TaskUpdate` transitioning a task **to** `completed`, it SHALL set `completed_at`. A task's **Task Timer** SHALL read the live elapsed `now − started_at` while `in_progress`, the frozen duration `completed_at − started_at` while `completed`, and SHALL be absent for a `pending` task or any task that was never `in_progress`. - -#### Scenario: Timer starts when work begins - -- **WHEN** a task transitions to `in_progress` -- **THEN** its `started_at` is the timestamp of that transition and its Task Timer counts up live from then - -#### Scenario: Timer freezes on completion - -- **WHEN** a task that was `in_progress` transitions to `completed` -- **THEN** its Task Timer shows the frozen duration `completed_at − started_at` and no longer advances - -#### Scenario: Reopened task restarts its timer - -- **WHEN** a `completed` task transitions back to `in_progress` -- **THEN** `started_at` is overwritten with the new timestamp, `completed_at` is cleared, and the live timer counts from the new start - -#### Scenario: Task that never started shows no duration - -- **WHEN** a task goes `pending → completed` with no intervening `in_progress` -- **THEN** it has no `started_at` and renders no Task Timer - -### Requirement: Total Elapsed - -The checklist header SHALL show a **Total Elapsed** wall-clock span for the current generation: from the earliest task `started_at` to `now` while any task is `in_progress`, or to the latest `completed_at` once nothing is in progress. When no task in the generation has ever started, Total Elapsed SHALL be absent. - -#### Scenario: Total runs live while work is active - -- **WHEN** any task in the generation is `in_progress` -- **THEN** Total Elapsed spans the earliest `started_at` to `now` and advances each render - -#### Scenario: Total freezes when the plan is done - -- **WHEN** no task is `in_progress` -- **THEN** Total Elapsed spans the earliest `started_at` to the latest `completed_at` - -### Requirement: Active Window - -When rendered as a full list, the checklist SHALL show an **Active Window**: an active-anchored slice of at most **6 content rows, inclusive of any `+N done` / `+N more` collapse lines**. The window SHALL keep the `in_progress` item visible and bias toward upcoming `pending` items. Completed items above the window SHALL collapse into a `+N done` line and pending items below into a `+N more` line, each counting against the 6-row budget. With no `in_progress` task the window SHALL start at the first pending items; with all tasks completed it SHALL show the most recent completed items. - -#### Scenario: Long plan stays within the row budget - -- **WHEN** the generation has more tasks than fit the budget -- **THEN** the rendered task rows (items plus any collapse lines) total at most 6, and the `in_progress` item is among them - -#### Scenario: Clipped completed and pending collapse into affordances - -- **WHEN** completed items fall above the window or pending items below it -- **THEN** a `+N done` line and/or a `+N more` line represents the hidden counts, within the budget - -### Requirement: Pinned visibility while active - -The checklist SHALL remain visible while any task is `in_progress`, regardless of the freshness cap, so a long-running step's live timer is never hidden. When no task is `in_progress`, the existing freshness behaviour SHALL apply: hidden after the 120s cap since the last task event, with a 20s grace once all tasks are `completed`. - -#### Scenario: Long step keeps the list visible - -- **WHEN** a task has been `in_progress` longer than the freshness cap with no new task event -- **THEN** the checklist stays visible and its live timer keeps counting - -#### Scenario: Finished plan still fades out - -- **WHEN** all tasks are `completed` and the grace period elapses -- **THEN** the checklist is hidden - -### Requirement: Layout-specific rendering - -The checklist SHALL render the full header + Active Window in the **wide** and **medium** layouts. In the **narrow** layout it SHALL render a single compact line — the checklist glyph, `done/total`, and the active task's live timer when a task is `in_progress` — and no subject text. Each full-list item row SHALL show a state glyph (distinct constants for `pending`, `in_progress`, `completed`), the task subject truncated to fit, and a right-aligned Task Timer column; completed durations render dim, the in_progress live timer renders bright, pending rows show no timer. All column widths SHALL be measured with the visible-width helper, never `len()`. The wide checklist SHALL render into a content width supplied by the layout builder, and MAY appear as the left column of a side-by-side section when a subagent cohort is also present; in that case it renders into the narrower left-column width using the same header + Active Window structure. - -#### Scenario: Wide and medium show the full checklist - -- **WHEN** a wide or medium layout is built and the checklist is visible -- **THEN** it renders the header (glyph + done/total + Total Elapsed) followed by the Active Window of item rows - -#### Scenario: Narrow shows the compact line - -- **WHEN** a narrow layout is built and the checklist is visible -- **THEN** it renders one line of glyph + done/total + the active task's live timer (omitted if nothing is in_progress), with no per-item rows - -#### Scenario: Timers align in a trailing column - -- **WHEN** multiple item rows carry timers -- **THEN** the timer values right-align in a fixed trailing column and subjects truncate with an ellipsis before that column - -#### Scenario: Checklist renders into a side-by-side left column - -- **WHEN** a wide layout composes a side-by-side section and the checklist is the left column -- **THEN** the checklist renders its header + Active Window into the supplied left-column width, with subjects truncating to fit that width - -### Requirement: Timer formatting - -A duration SHALL be formatted as `m:ss` (for example `0:07`, `12:04`) and SHALL roll over to `h:mm:ss` once it reaches one hour. The same formatting SHALL apply to per-task Task Timers and to Total Elapsed. - -#### Scenario: Sub-hour durations use m:ss - -- **WHEN** a duration is under one hour -- **THEN** it renders as `m:ss` with a zero-padded seconds field - -#### Scenario: Durations of an hour or more use h:mm:ss - -- **WHEN** a duration is one hour or more -- **THEN** it renders as `h:mm:ss` with zero-padded minutes and seconds - - diff --git a/openspec/specs/tool-counts-row/spec.md b/openspec/specs/tool-counts-row/spec.md deleted file mode 100644 index 4df2b23..0000000 --- a/openspec/specs/tool-counts-row/spec.md +++ /dev/null @@ -1,205 +0,0 @@ -# tool-counts-row Specification - -## Purpose - -Define the wide layout's per-tool `tool_use` counts row: how `tool_use` blocks are -counted per tool with a main-vs-sub split, deduplicated by `message.id`, windowed to -the last `/clear`, filtered of meta tools, and normalized for MCP names; and how the -resulting counts are selected, ordered, and rendered as a wide-only row under the -tokens row with a `Name main/sub` entry format, greedy width fill, `+k` type -overflow, zero-state suppression, and a superscript `tools main/sub` label. - -## Requirements - -### Requirement: Per-tool tool_use counting with main-vs-sub split - -The system SHALL count `tool_use` blocks per tool name across the session, -splitting each tool's total into a `main` count (occurrences in the main -transcript) and a `sub` count (the sum of occurrences across all of this -session's subagent transcripts). The aggregator SHALL read only bytes already -loaded by the per-render transcript and subagent scans — it SHALL NOT introduce -an on-disk cache, an offset file, or incremental/partial reading. - -#### Scenario: Tool runs in main session only - -- **WHEN** the main transcript contains 3 `Edit` `tool_use` blocks and no - subagent ran `Edit` -- **THEN** `Edit` is reported with `(main=3, sub=0)` - -#### Scenario: Tool runs only inside subagents - -- **WHEN** two subagent transcripts ran `Grep` 6 and 9 times respectively and the - main transcript never ran `Grep` -- **THEN** `Grep` is reported with `(main=0, sub=15)` - -#### Scenario: Both columns are always present - -- **WHEN** any tool has a non-zero count on either side -- **THEN** both the `main` and the `sub` value are reported, even when one side is - zero (e.g. `(main=4, sub=0)`) - -### Requirement: Streaming dedup keeps the last write per message id - -The aggregator SHALL deduplicate by `message.id` keeping the **last** occurrence -per id, counting the `tool_use` blocks of that final write, and SHALL skip lines -with no `message.id`. This is required because `tool_use` blocks carry no stable id -and streaming writes the same `message.id` several times where earlier partial -writes may contain fewer `tool_use` blocks than the final write. - -#### Scenario: Final write supersedes an earlier partial - -- **WHEN** a `message.id` appears first with 1 `tool_use` block and later with 2 - `tool_use` blocks -- **THEN** that message contributes 2 to the relevant tool counts (the last write), - not 1 - -#### Scenario: Repeated final write is not double-counted - -- **WHEN** the same `message.id` with the same 2 `tool_use` blocks is written twice -- **THEN** that message contributes 2, not 4 - -### Requirement: Counts are windowed to the last /clear - -The aggregator SHALL count only `tool_use`-bearing messages whose `timestamp` is -at or after `SessionView.clear_epoch`. When `clear_epoch` is `None`, the aggregator -SHALL count the whole session. A subagent whose every message predates -`clear_epoch` SHALL contribute zero to all counts. - -#### Scenario: Messages before /clear are excluded - -- **WHEN** `clear_epoch` is set and a `tool_use` message's `timestamp` is before it -- **THEN** that message is not counted - -#### Scenario: No clear marker counts the whole session - -- **WHEN** `clear_epoch` is `None` -- **THEN** every `tool_use` message in the transcript is eligible for counting - -### Requirement: Meta tools are excluded, Task is kept - -The aggregator SHALL exclude tools in the meta set -`{TodoWrite, ExitPlanMode, AskUserQuestion}` from all counts. The `Task` tool -SHALL be counted (it represents a subagent delegation). A `Task` spawn counted in -the main column and the spawned subagent's own tool uses counted in the sub column -SHALL both be retained — the resulting double-representation across columns is -intended. - -#### Scenario: TodoWrite is dropped - -- **WHEN** the main transcript runs `TodoWrite` 5 times -- **THEN** `TodoWrite` does not appear in the counts - -#### Scenario: Task is retained in the main column - -- **WHEN** the main transcript spawns 4 subagents via `Task` -- **THEN** `Task` is reported with `main=4` - -#### Scenario: Delegated work counts in the sub column independently - -- **WHEN** a `Task` spawn runs in main and the spawned subagent runs `Read` twice -- **THEN** `Task` shows `main` incremented by 1 AND `Read` shows `sub` incremented - by 2 - -### Requirement: MCP tool names normalize to their last segment - -The aggregator SHALL normalize MCP tool names of the form `mcp__server__tool` to -their last `__`-delimited segment before counting and keying. Non-MCP names SHALL -pass through unchanged. - -#### Scenario: MCP name is shortened - -- **WHEN** a `tool_use` block names the tool `mcp__github__create_issue` -- **THEN** it is counted under the key `create_issue` - -### Requirement: Tool-counts row is rendered wide-only under the tokens row - -The system SHALL render a per-tool counts row only in the wide layout -(`build_wide`), placed in the session-totals band directly under the tokens/cost -row and before the plugins row. Narrow and medium layouts SHALL NOT render the row. -The row SHALL be full-width content with no internal divider, so it contributes no -`┬`/`┴` elbows of its own. - -#### Scenario: Row appears in wide layout - -- **WHEN** the wide layout is built and at least one tool has been counted since - the last `/clear` -- **THEN** a content row of per-tool counts appears immediately after the - tokens/cost rows - -#### Scenario: Narrow and medium omit the row - -- **WHEN** the narrow or medium layout is built -- **THEN** no tool-counts row is rendered - -### Requirement: Row format is Name main/sub with bright/faint/dim treatment - -Each entry SHALL render as the tool NAME, a space, the `main` count, a `/`, and the -`sub` count (e.g. `Bash 5/12`). The `main` count SHALL be painted bright, the `sub` -count SHALL be painted SGR-faint, and the `/` SHALL be painted dim. Both sides of -the slash SHALL always be shown. - -#### Scenario: Entry shows name and both counts - -- **WHEN** `Bash` has `(main=5, sub=12)` -- **THEN** the entry reads `Bash 5/12` with `5` bright, `/` dim, and `12` faint - -#### Scenario: Zero sub still renders both sides - -- **WHEN** `Edit` has `(main=3, sub=0)` -- **THEN** the entry reads `Edit 3/0` - -### Requirement: Top-N-by-combined selection with greedy width fill and +k type overflow - -Entries SHALL be ordered by combined `(main + sub)` total descending, with ties -broken alphabetically by tool name for frame-to-frame stability. The row SHALL -greedy-fill entries to the available wide content width measured via -`_visible_width` (never `len()`). When tool types remain unshown, the row SHALL -append an overflow marker `+k` where `k` is the number of additional tool TYPES not -shown (NOT the summed count of their calls). The `+k` marker SHALL NOT be clipped; -if necessary the last fitted entry is dropped to make room for it. - -#### Scenario: Highest combined totals come first - -- **WHEN** `Read` has combined 48 and `Bash` has combined 17 -- **THEN** `Read` is ordered before `Bash` - -#### Scenario: Alphabetical tie-break - -- **WHEN** `Edit` and `Glob` both have the same combined total -- **THEN** `Edit` is ordered before `Glob` - -#### Scenario: Overflow counts types not calls - -- **WHEN** 9 tool types are counted but only 6 fit in the width and the 3 unshown - types together account for 40 calls -- **THEN** the row ends with `+3`, not `+40` - -### Requirement: Row disappears in the zero state - -When there are zero counted tool uses since the last `/clear`, the system SHALL -omit both the tool-counts content row and its leading separator from the wide -layout, leaving the surrounding elbow threading (`pending_ups`) intact, exactly as -the existing conditional plugins/task/openspec rows behave. - -#### Scenario: No tools counted yields no row - -- **WHEN** the wide layout is built and no tool has been counted since the last - `/clear` -- **THEN** neither the tool-counts row nor its separator is present in the layout - -### Requirement: Row carries the superscript tools main/sub label - -The separator above the tool-counts row SHALL carry the superscript caption `ᵗᵒᵒˡˢ ᵐᵃⁱⁿᐟˢᵘᵇ` -(the superscript rendering of `tools main/sub`) anchored at content start when -section labels are enabled (`cfg.labels`), and SHALL carry no caption when labels -are disabled. - -#### Scenario: Label shown when captions enabled - -- **WHEN** `cfg.labels` is true and the tool-counts row is present -- **THEN** the separator above it shows the superscript `tools main/sub` caption - -#### Scenario: No label when captions disabled - -- **WHEN** `cfg.labels` is false -- **THEN** the separator above the row carries no caption diff --git a/openspec/specs/top-row-format/spec.md b/openspec/specs/top-row-format/spec.md deleted file mode 100644 index 232f5ad..0000000 --- a/openspec/specs/top-row-format/spec.md +++ /dev/null @@ -1,128 +0,0 @@ -# top-row-format Specification - -## Purpose -TBD - created by archiving change restyle-top-row. Update Purpose after archive. -## Requirements -### Requirement: Session timer format and fixed-width reservation - -The session timer SHALL be formatted as `MM:SS` when the elapsed time is under one hour (no leading hours digit or `0:` prefix, e.g. `13:27`), and as `H:MM:SS` or `HH:MM:SS` when one or more hours have elapsed. `_fmt_elapsed_clock` SHALL continue to return the empty string for zero or negative durations. The wide layout's `elapsed_section` SHALL right-justify the formatted session timer into a fixed field of 8 visible columns (the `HH:MM:SS` worst case) so that the timer's divider column does not shift as the clock crosses `MM:SS` → `H:MM:SS` → `HH:MM:SS`. When the session has never been cleared (no `/clear` marker in the current transcript), the elapsed cell SHALL render only this session timer, byte-identical to the single-timer behaviour prior to this change. - -#### Scenario: Under an hour drops the hours digit - -- **WHEN** the session has run for 13 minutes 27 seconds -- **THEN** the timer string is `13:27` (no `0:` prefix) - -#### Scenario: An hour or more keeps the hours digit - -- **WHEN** the session has run for 1 hour 13 minutes 27 seconds -- **THEN** the timer string is `1:13:27` - -#### Scenario: Column stays put as the clock grows - -- **WHEN** the timer renders first as `13:27` and later as `1:13:27` at the same width -- **THEN** the timer occupies the same 8-column field (right-justified) and the timer divider column is unchanged - -#### Scenario: Fresh session renders the session timer unchanged - -- **WHEN** the wide layout renders and the current transcript has no `/clear` marker -- **THEN** the elapsed cell shows only the session timer, with no clear-timer glyph, identical to the pre-change rendering - -### Requirement: Since-clear timer in the wide elapsed cell - -When the current transcript has been cleared (a `/clear` marker epoch is available), the wide layout's elapsed cell SHALL show a second "since last `/clear`" timer in addition to the session timer. The clear timer SHALL be computed wall-clock as `now − clear_epoch`, clamped to a minimum of 0 for clock skew, and formatted with the same `_fmt_elapsed_clock` (`MM:SS` / `H:MM:SS`) convention as the session timer. The clear timer SHALL be rendered first (leftmost) within the cell, led by a distinguishing Nerd Font glyph and an accent colour distinct from the grey session timer; the session timer SHALL follow in its existing grey. The clear timer and session timer SHALL share the single existing elapsed-cell divider/elbow — no additional border divider is introduced. - -#### Scenario: Cleared session shows both timers, clear first - -- **WHEN** the wide layout renders, the transcript was cleared, and both timers fit the available width -- **THEN** the elapsed cell shows the glyphed accent clear timer leftmost followed by the grey session timer, both inside one vsep-delimited cell with a single divider - -#### Scenario: Clear timer is wall-clock from the marker - -- **WHEN** the most recent `/clear` occurred 18 minutes 33 seconds ago -- **THEN** the clear timer reads `18:33` - -### Requirement: Elapsed-cell degradation ladder - -The wide elapsed cell SHALL degrade under width pressure in a fixed order. Path protection SHALL remain the outermost guard: the entire elapsed cell SHALL still shed (render nothing) whenever including it would leave the path fewer than 5 visible columns, exactly as before this change. Within the elapsed cell's own budget, when both timers cannot fit, the layout SHALL prefer the clear timer alone over the session timer — dropping the session timer first. The resulting tiers, widest to narrowest, SHALL be: both timers → clear timer only → cell shed entirely. - -#### Scenario: Both timers do not fit, clear timer wins - -- **WHEN** the elapsed cell can fit one timer but not both while still protecting the path -- **THEN** only the glyphed clear timer renders and the session timer is dropped - -#### Scenario: Path protection drops the whole cell - -- **WHEN** including even the clear-timer-only cell would leave the path fewer than 5 visible columns -- **THEN** the entire elapsed cell sheds and neither timer renders - -#### Scenario: Ample width shows both - -- **WHEN** the width comfortably fits both timers and the path -- **THEN** both the clear timer and the session timer render - -### Requirement: Rate-limit segment icons and separator - -In the wide layout the 5-hour rate-limit segment SHALL lead with the timer-outline icon `ICON_LIMIT_5H` (nf-md-timer_outline, U+F051B) and the 7-day segment SHALL lead with the calendar-week icon `ICON_LIMIT_7D` (nf-md-calendar_week_begin, U+F0A34), replacing the previous shared helper glyph. When both segments are present, they SHALL be separated by a dotted vertical divider ` ┆ ` (`SEP_RATE`, U+2506) rather than ` | `. - -#### Scenario: Both segments render with their icons and dotted separator - -- **WHEN** a wide render has both a 5-hour and a 7-day rate-limit value -- **THEN** the 5-hour segment is preceded by the timer-outline icon, the 7-day segment by the calendar-week icon, and the two are joined by ` ┆ ` - -#### Scenario: Seven-day segment absent - -- **WHEN** the 7-day rate limit has no usage and no reset -- **THEN** only the 5-hour segment (with its timer-outline icon) renders and no ` ┆ ` separator appears - -### Requirement: Reset-countdown placement and format - -The 5-hour reset countdown SHALL be positioned at the front of the 5-hour segment, immediately after its icon and ahead of the usage and trend percentages, formatted as `(-H:MM)` — parenthesised, leading minus, hours kept, seconds dropped (e.g. `(-2:00)`, `(-0:45)`). When the limit has no reset (infinite/unknown), the existing infinite indicator SHALL render and no countdown SHALL appear. - -#### Scenario: Countdown leads the segment - -- **WHEN** the 5-hour limit resets in 2 hours exactly with 30.0% used -- **THEN** the segment renders the timer icon, then `(-2:00)`, then `30.0%`, then the trend - -#### Scenario: Under an hour keeps the single-digit hour - -- **WHEN** the 5-hour limit resets in 45 minutes -- **THEN** the countdown renders as `(-0:45)` - -### Requirement: One-decimal percentages - -All rate-limit usage percentages and burndown trend percentages SHALL render at exactly one decimal place (e.g. `30.0%`, `-30.1%`) in both the wide and the compact layouts. `burndown_trend` SHALL format its delta to one decimal place. - -#### Scenario: Usage and trend both at one decimal - -- **WHEN** a usage percentage is 30 and a trend delta is -30.14 -- **THEN** they render as `30.0%` and `-30.1%` respectively - -#### Scenario: Compact layout matches - -- **WHEN** a compact (narrow or medium) layout renders a usage percentage -- **THEN** it renders at one decimal place - -### Requirement: Model pill single glyph and parenthesised effort - -The model pill SHALL lead with a single glyph `GLYPH_MODEL_LIGHT` (nf-md-lightbulb_on_40, U+F1A51), replacing both the previous monitor and brain glyphs, with one extra space of padding between the pill's left edge and the glyph. In fast mode the lead glyph SHALL be swapped to `GLYPH_BURN_FAST`. The effort/thinking value SHALL be rendered as parenthesised text (e.g. `(medium)`) following the model name, and SHALL be omitted entirely — including the parentheses — when the effort/thinking value is empty. The compact model pills SHALL use the same `GLYPH_MODEL_LIGHT` lead glyph for cross-width consistency. - -#### Scenario: Wide pill with effort - -- **WHEN** a wide render shows model `Sonnet 4.6` with effort `medium` -- **THEN** the pill renders the lightbulb glyph (with leading padding), the model name, then `(medium)` - -#### Scenario: Empty effort omits the parentheses - -- **WHEN** the model has no effort/thinking value -- **THEN** the pill renders the lightbulb glyph and model name with no trailing parentheses - -#### Scenario: Fast mode swaps the lead glyph - -- **WHEN** fast mode is active -- **THEN** the lead glyph is the burn glyph rather than the lightbulb - -#### Scenario: Compact pills share the glyph - -- **WHEN** a narrow or medium layout renders the model pill -- **THEN** its lead glyph is `GLYPH_MODEL_LIGHT` - diff --git a/openspec/specs/untrusted-input-hardening/spec.md b/openspec/specs/untrusted-input-hardening/spec.md deleted file mode 100644 index 39adfd4..0000000 --- a/openspec/specs/untrusted-input-hardening/spec.md +++ /dev/null @@ -1,47 +0,0 @@ -# untrusted-input-hardening Specification - -## Purpose -TBD - created by archiving change harden-untrusted-input. Update Purpose after archive. -## Requirements -### Requirement: Untrusted field values are sanitized at capture - -The statusline SHALL strip terminal control characters from every host- or repo-supplied string value as it is captured into the session model, before that value can reach stdout. The sanitizer MUST remove the C0 control bytes `0x00`–`0x08` and `0x0b`–`0x1f`, `DEL` (`0x7f`), and all C1 control bytes (`0x80`–`0x9f`). This range includes `ESC` (`0x1b`) and `BEL` (`0x07`), the introducers and terminators for OSC and CSI sequences, so no untrusted OSC/CSI escape can be emitted. (Statusline fields are single-line, so no in-band `\t`/`\n` needs to be preserved.) - -The untrusted fields are: model `display_name` and `id`, `cwd`/`current_dir`, `project_dir`, `session_id`, output-style name, the git branch name read from `.git/HEAD` and refs, transcript-derived task subject and active_form, subagent description and tool-input text, skill names, and `enabledPlugins` keys. - -Sanitization MUST be applied at the point of capture, NOT to the final rendered line, so that the renderer's own legitimate SGR colour escapes are preserved. - -#### Scenario: OSC-52 clipboard payload in a model name is neutralized - -- **WHEN** the host supplies a model `display_name` containing `\x1b]52;c;\x07` -- **THEN** the captured value contains no `\x1b` or `\x07` bytes and the rendered statusline emits no OSC-52 sequence - -#### Scenario: OSC-0 title-spoof payload in a git branch is neutralized - -- **WHEN** a repo's `.git/HEAD` resolves to a branch name containing `\x1b]0;PWNED\x07` -- **THEN** the captured branch contains no `\x1b` or `\x07` bytes and the rendered statusline emits no OSC-0 sequence - -#### Scenario: Control bytes in transcript-derived task and subagent text are stripped - -- **WHEN** a transcript yields a task subject, subagent description, or tool-input string containing C0/C1/DEL control bytes -- **THEN** those bytes are removed from the captured value before rendering - -#### Scenario: Legitimate plain text is unchanged - -- **WHEN** an untrusted field contains only printable characters (including non-ASCII/CJK text) -- **THEN** the sanitized value is byte-for-byte identical to the input - -### Requirement: Plugin state is read only from the user's own config directory - -The statusline SHALL determine the enabled-plugins list solely from the user's own `CLAUDE_DIR/settings.json`. It MUST NOT read `project_dir/.claude/settings.json` (or any settings file under a host-supplied workspace path), because `project_dir` is attacker-controlled for a cloned repository and constitutes both an unexpected trust-boundary read and an escape-injection sink. - -#### Scenario: A cloned repo's settings file is ignored - -- **WHEN** `project_dir/.claude/settings.json` exists and lists `enabledPlugins` -- **THEN** none of its keys appear in the rendered plugins list - -#### Scenario: The user's own settings still drive the plugins list - -- **WHEN** `CLAUDE_DIR/settings.json` lists `enabledPlugins` -- **THEN** its enabled keys appear in the rendered plugins list as before - diff --git a/openspec/specs/workflow-cohort/spec.md b/openspec/specs/workflow-cohort/spec.md deleted file mode 100644 index a80cdd8..0000000 --- a/openspec/specs/workflow-cohort/spec.md +++ /dev/null @@ -1,132 +0,0 @@ -## ADDED Requirements - -### Requirement: Workflow run detection via filesystem - -The statusline SHALL detect a workflow run by the existence of a `subagents/workflows//` directory under the session's project directory. A run SHALL be discovered the instant any agent transcript appears in that directory, independently of whether the session-level `workflows/.json` exists yet. Each `agent-.jsonl` in the run directory SHALL be parsed with the same transcript parser used for ordinary subagents to obtain tokens, the activity snippet, `first_timestamp`, `mtime`, and `end_ts`. - -#### Scenario: Run discovered before its JSON is written - -- **WHEN** `subagents/workflows//` contains at least one `agent-*.jsonl` but no `workflows/.json` exists yet -- **THEN** the run is detected and each agent is parsed from its transcript - -#### Scenario: Agent identity comes from the transcript filename - -- **WHEN** a workflow agent transcript is named `agent-.jsonl` -- **THEN** its `agentId` is ``, used to match against the run JSON's `workflowProgress` entries - -#### Scenario: Non-workflow subagents are unaffected - -- **WHEN** the session has both ordinary `subagents/agent-*.jsonl` files and `subagents/workflows//agent-*.jsonl` files -- **THEN** the ordinary subagents remain in the normal cohort and only the nested agents are grouped into workflow runs - -### Requirement: Run enrichment from the run JSON - -The statusline SHALL read `workflows/.json` opportunistically to enrich a detected run. When present and parseable, the run's display name SHALL be its `workflowName`, each agent's label SHALL be the `label` of the matching `agentId` entry in `workflowProgress`, and the current phase SHALL be derived from the run's phase progress. The run JSON SHALL NOT be required for detection and SHALL NOT, on its own, mark a run live or retired. - -#### Scenario: Name and labels taken from the JSON - -- **WHEN** `workflows/.json` exists with a `workflowName` and `workflowProgress` mapping agentIds to labels -- **THEN** the run header shows `workflowName` and each agent row shows its mapped label - -#### Scenario: Malformed JSON degrades to fallback - -- **WHEN** `workflows/.json` is missing, empty, or fails to parse -- **THEN** detection still succeeds and the run renders using fallback identity (see Fallback identity requirement) - -### Requirement: Fallback identity without the run JSON - -When the run JSON does not supply a name or a label for an agent, the statusline SHALL fall back deterministically. The run header name SHALL fall back to the `runId`. An agent's label SHALL fall back to the first non-empty line of the first user message in its transcript, sanitized for untrusted input and middle-ellipsised to the available width. The phase SHALL be omitted from the header when no phase is available. - -#### Scenario: Header falls back to runId - -- **WHEN** a run has no `workflowName` available -- **THEN** the run header shows the `runId` (e.g. `wf_d8212a1d-34a`) - -#### Scenario: Label falls back to the prompt line - -- **WHEN** an agent has no mapped label in `workflowProgress` -- **THEN** its row label is the sanitized first non-empty line of its first user message, middle-ellipsised to fit - -#### Scenario: Phase omitted when unknown - -- **WHEN** no current phase can be derived for a run -- **THEN** the run header renders without a phase segment - -### Requirement: Done detection reused from the subagent parser - -A workflow agent SHALL be treated as **Done** under the same rule as ordinary subagents: when, and only when, its transcript contains an assistant message whose `message.stop_reason` equals `"end_turn"`, with that line's timestamp captured as `end_ts`. The run's summary count of completed agents SHALL be the number of agents with `end_ts > 0`. - -#### Scenario: Completed agent counts toward the summary - -- **WHEN** an agent transcript's final assistant message carries `stop_reason: "end_turn"` -- **THEN** that agent is Done and is included in the summary's `M done` count - -#### Scenario: Still-running agent is not counted Done - -- **WHEN** an agent transcript contains no `end_turn` -- **THEN** that agent is not Done and is excluded from the `M done` count - -### Requirement: Run-scoped liveness and retirement - -The statusline SHALL apply a workflow-sized liveness window that is independent of, and longer than, the subagent cohort's windows, so a run survives between-phase lulls. A run SHALL remain visible while any of its agents has a transcript `mtime` within the workflow liveness window (default 120 seconds), OR while its run JSON reports a non-terminal status. A run SHALL retire once it is settled — its run JSON reports a terminal status (or, in the filesystem-only case, every agent has `end_ts > 0`) AND its most recently written agent transcript is older than a grace window. - -#### Scenario: Run survives a between-phase lull - -- **WHEN** all of a run's currently-spawned agents have finished but the run is between phases and the most recent transcript write is within the workflow liveness window -- **THEN** the run remains visible - -#### Scenario: Settled run retires after grace - -- **WHEN** a run is terminal (JSON terminal status, or all agents `end_ts > 0`) AND its newest agent transcript `mtime` is older than the grace window -- **THEN** the run is no longer visible - -#### Scenario: Stale leftover run directory is not shown - -- **WHEN** a `subagents/workflows//` directory exists from a prior run whose newest transcript `mtime` is older than the workflow liveness window and it is terminal -- **THEN** the run is not displayed - -### Requirement: Grouped run rendering - -The statusline SHALL render each visible workflow run as a distinct grouped block, placed after the normal subagent cohort and the task row. The block SHALL consist of a header row, zero or more per-agent rows, and a summary footer row. The header SHALL show a group glyph, the run name, and the current phase when known. Per-agent rows SHALL reuse the existing subagent row renderer so a workflow agent is visually identical to an ordinary subagent row at the same width. The summary footer SHALL show the agent count, the Done count, and the run's aggregate token total summed from the per-agent transcript parse. - -#### Scenario: Wide block shows header, agents, and summary - -- **WHEN** a run is visible at medium or wider width -- **THEN** the block renders a header (`▸ []`), one row per agent (capped, see below), and a summary footer (`└ N agents · M done · `) - -#### Scenario: Per-agent rows match the subagent row format - -- **WHEN** a workflow agent row is rendered at a given width -- **THEN** it uses the same row renderer and field set as an ordinary subagent row (two-line above width 100, one-line otherwise) - -#### Scenario: Aggregate tokens summed locally - -- **WHEN** a run's summary footer renders its token total -- **THEN** the total is the sum of the per-agent transcript token parse, not the run JSON's reported total - -### Requirement: Narrow-width collapse - -At narrow width (below the medium threshold), the statusline SHALL collapse a workflow run to its header and summary only, omitting all per-agent rows, to protect vertical space. - -#### Scenario: Narrow run shows header and summary only - -- **WHEN** a run is visible and the layout width is below the medium threshold -- **THEN** only the header and summary rows render and no per-agent rows are shown - -### Requirement: Per-run agent cap - -The statusline SHALL render at most 6 per-agent rows for a single run. When a run has more than 6 agents, only the first 6 (ordered by `first_timestamp`) SHALL render and the remaining count SHALL be reflected in the summary footer. - -#### Scenario: Overflowing run caps at six rows - -- **WHEN** a run has 9 agents at a width that shows per-agent rows -- **THEN** 6 agent rows render and the summary notes the 3 hidden (e.g. `└ 9 agents · 4 done · +3 hidden`) - -### Requirement: Concurrent run cap - -The statusline SHALL render at most 2 workflow run blocks concurrently. When more than 2 runs are visible, the 2 most-recently-active runs (by newest agent `mtime`) SHALL render and the remaining count SHALL be noted on a single overflow line. - -#### Scenario: Third concurrent run is summarised - -- **WHEN** 3 runs are simultaneously visible -- **THEN** the 2 most-recently-active runs render as blocks and a one-line `+1 more workflows` note is shown diff --git a/openspec/specs/workflow-phase-display/spec.md b/openspec/specs/workflow-phase-display/spec.md deleted file mode 100644 index e6ede75..0000000 --- a/openspec/specs/workflow-phase-display/spec.md +++ /dev/null @@ -1,50 +0,0 @@ -# workflow-phase-display Specification - -## Purpose - -Define how a workflow run's ordered phase list is surfaced in the run header: the phase titles are parsed live from the workflow script's `meta.phases` array, rendered inline after the workflow name as a dot-separated list, with the current phase (from the completion JSON) highlighted. When no phase list is available the header degrades gracefully to the existing `[phase]` bracket style. - -## Requirements - -### Requirement: Phase list parsed from workflow script - -The system SHALL parse phase titles from the workflow script file located at `workflows/scripts/*-.js` using a regex on the `meta.phases` array. Parsing SHALL extract each `title:` string in order. When no script file exists or parsing fails, the phase list SHALL be empty and the system SHALL fall back to the existing `[phase]` bracket display. Parsing SHALL never raise an exception. - -#### Scenario: Phases extracted from script meta block - -- **WHEN** a workflow script exists at `workflows/scripts/*-.js` containing a `meta.phases` array with `title:` fields -- **THEN** `RunningWorkflow.phases` is populated with the titles in order - -#### Scenario: Missing script yields empty phase list - -- **WHEN** no matching script file exists in `workflows/scripts/` -- **THEN** `RunningWorkflow.phases` is `[]` and the header falls back to `[phase]` bracket style - -#### Scenario: Malformed script yields empty phase list - -- **WHEN** the script file exists but has no parseable `phases:` array -- **THEN** `RunningWorkflow.phases` is `[]` and no exception is raised - -### Requirement: Inline phase list in workflow header - -When `RunningWorkflow.phases` is non-empty, the workflow header SHALL render phases inline as a dot-separated list after the workflow name, using the form `▸ P1 · P2 · P3`. Each phase title SHALL be rendered in a dim colour. The phase matching `run.phase` (the current phase from the completion JSON) SHALL be rendered in a highlight colour with a `❯` prefix. When `run.phase` is empty (live run), all phases SHALL be dimmed with no `❯` marker. The workflow name SHALL be middle-ellipsised to fit the available width after the glyph and phase list. When the phase list itself is too wide, it SHALL be truncated with `…` rather than further truncating the name. - -#### Scenario: All phases shown with current highlighted post-completion - -- **WHEN** `run.phases = ['Discover', 'Scan', 'Verify']` and `run.phase = 'Scan'` -- **THEN** the header renders `▸ Discover · ❯Scan · Verify` with `❯Scan` in highlight colour and the others dimmed - -#### Scenario: All phases shown dimmed during live run - -- **WHEN** `run.phases = ['Discover', 'Scan']` and `run.phase = ''` -- **THEN** the header renders `▸ Discover · Scan` with both phases dimmed and no `❯` marker - -#### Scenario: Empty phases falls back to bracket style - -- **WHEN** `run.phases = []` and `run.phase = 'Scan'` -- **THEN** the header renders `▸ [Scan]` (existing bracket form) - -#### Scenario: Phase list truncated when too wide - -- **WHEN** the phase list plus name exceed `content_width` -- **THEN** the phase list is truncated with `…` and the name is preserved at its minimum width diff --git a/openspec/specs/workflow-two-column-agents/spec.md b/openspec/specs/workflow-two-column-agents/spec.md deleted file mode 100644 index 4d830a5..0000000 --- a/openspec/specs/workflow-two-column-agents/spec.md +++ /dev/null @@ -1,54 +0,0 @@ -# workflow-two-column-agents Specification - -## Purpose - -Define the two-column agent layout for workflow runs at wide terminal widths: when `per_agent` rendering is active and the terminal is at least 120 columns wide (the TWO_COL_WF_WIDTH threshold), workflow agents are paired side-by-side within a single content row to halve vertical space usage. Each agent in two-column mode uses the one-line form. Below the threshold, agents render one per row as before. - -## Requirements - -### Requirement: Two-column agent layout at wide terminal widths - -When `per_agent` is True and the terminal width is ≥ 120, workflow agents SHALL be rendered in pairs side-by-side within a single content row, separated by a ` │ ` vertical divider. Each half SHALL receive `(inner - 5) // 2` columns where `inner = width - 4`. Agents SHALL be paired sequentially by `first_timestamp` order. When the agent count is odd, the final agent SHALL be rendered in the left column only, with a blank right half, so it stays inside the L/R section and the column divider remains unbroken. Done and running agents MAY be mixed within a pair. When terminal width is < 120, agents SHALL render one per row as before. - -#### Scenario: Agents paired at width ≥ 120 - -- **WHEN** `per_agent` is True, width ≥ 120, and there are 4 agents -- **THEN** the layout emits 2 content rows, each containing 2 agents separated by ` │ ` - -#### Scenario: Odd agent rendered in the left column - -- **WHEN** `per_agent` is True, width ≥ 120, and there are 3 agents -- **THEN** the layout emits 2 content rows: one with agents 1+2 paired, one with agent 3 in the left column and a blank right half, both carrying the divider - -#### Scenario: Below threshold renders one per row - -- **WHEN** `per_agent` is True and width < 120 -- **THEN** each agent occupies its own content row (existing behaviour) - -### Requirement: Column divider spans the whole run block and joins the box - -In two-column mode the column divider `│` SHALL be embedded in every row of a run block — the header, every paired/odd agent row, the summary, and any `+N more workflows` overflow row — at the shared column `workflow_divider_col(width)`, so the bar runs unbroken from the header down to the summary. The block SHALL carry no internal separator rows. `build_wide` SHALL thread the matching `┬` onto the separator above the first header and carry the matching `┴` down to the border (or separator) below the last summary, so the bar joins the box at both ends rather than floating. - -#### Scenario: Divider runs through header and summary - -- **WHEN** `per_agent` is True, width ≥ 120, and a run is rendered -- **THEN** the header row and the summary row each contain the divider `│` at `workflow_divider_col(width)`, and no `separator_dim` rows appear between the header and summary - -#### Scenario: Divider joins the box at top and bottom - -- **WHEN** `build_wide` renders a two-column workflow block -- **THEN** the separator above the header carries a `┬` and the border/separator below the summary carries a `┴`, both at the divider column - -#### Scenario: Done and running agents may be paired together - -- **WHEN** width ≥ 120 and agents 1 (done) and 2 (running) are adjacent in timestamp order -- **THEN** they appear side-by-side in the same row; agent 1 renders with dim done styling - -### Requirement: Two-column mode uses one-line agent form - -In two-column layout, each agent SHALL be rendered using the one-line (non-twoline) form regardless of terminal width. The `twoline=True` path SHALL only apply in single-column layout. - -#### Scenario: One-line form used in two-column mode - -- **WHEN** width ≥ 120 and agents are rendered in two-column mode -- **THEN** each agent is rendered with `twoline=False` (single-line form)