bullswarm 0.35.2 → 0.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.35.3 — whole right edges in Ghostty and herdr, Enter opens the row you are on, named phases
4
+
5
+ - dashboard: every row is erased before it is painted instead of after, so a
6
+ row that fills the terminal keeps its last cell in Ghostty and herdr (both
7
+ cleared it, turning `50%` into `50` and `16` into `1` on Home's right edge).
8
+ - home: from 110 columns the spent-per-day chart grows to the height of the
9
+ stacked breakdowns beside it; the summary band sizes `Spent` to its text and
10
+ wraps at ` · ` rather than cutting a figure; the closing sentence wraps too.
11
+ - runs: Enter and a click open exactly the row the cursor is on. Tasks recorded
12
+ before the task ledger have no id, and all of them shared one empty key, so
13
+ Enter on a workflow row could open one of those tasks instead.
14
+ - run: phase rules name the phase: `Phase 2 · Build · time-box · docs`
15
+ (Build, Integrate, Verify, Digest, Design, Repair from the steps' kinds,
16
+ joined with ` + ` when a phase mixes them); the phase number and name are
17
+ never cut, step names shorten first.
18
+ - home: day keys no longer build an `Intl.DateTimeFormat` per call to learn
19
+ the zone (it is cached against `TZ`); a Home paint takes about 40% less time.
20
+
3
21
  ## 0.35.2 — the Step page reads like a transcript; prices without a command, honest totals, a home that stops growing
4
22
 
5
23
  - step: detail is now a scrollable transcript of every turn in order: the full
@@ -43,8 +43,8 @@ Home · Today · 20 Sep 2026 · top 3 runs (active first)
43
43
  Mon Tue Wed Thu Fri Sat Sun project-b ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 4
44
44
   ~ bars leave 66 unpriced attempts out project-c ▇▇▇▇▇░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1
45
45
 
46
- Workflows: 15 · verified 10 (67%) Favourite pool: codex Spent: at least $299.87 api · 66 unmeasured · sub unknown (no pl…
47
- Busiest project: project-a (10) Favourite model: gpt-5.6-luna Median run: 1h45m
46
+ Workflows: 15 · verified 10 (67%) Favourite pool: codex Spent: at least $299.87 api · 66 unmeasured · sub unknown (no plan price)
47
+ Busiest project: project-a (10) Favourite model: gpt-5.6-luna Median run: 1h45m
48
48
 
49
49
  Your 15 runs in this period recorded at least $299.87 api · 66 unmeasured of API-equivalent work
50
50
 
@@ -1,5 +1,5 @@
1
1
  Home Runs Budget Stats Fleet
2
- bullswarm · home · 1–57/86
2
+ bullswarm · home · 1–57/88
3
3
  Home · Today · 20 Sep · top 3 runs (active first)
4
4
  ┌─ sample goal for rensha nostrud lorem nostrud moll…─┐
5
5
  │ project-b · completed · verified │
@@ -14,15 +14,15 @@
14
14
  ── timeline · 5 phases · 5 attempts · Enter on a step opens it ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
15
15
  ── Preflight ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
16
16
  04:05 ● goal accepted · goal.json
17
- ── ✓ 1 · home-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
17
+ ── ✓ Phase 1 · Mechanical · home-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
18
18
  04:05 ✓ home-extraction · codex · gpt-5.6-luna · low 28m25s
19
- ── ✓ 2 · runs-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
19
+ ── ✓ Phase 2 · Mechanical · runs-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
20
20
  04:34 ✓ runs-extraction · codex · gpt-5.6-luna · low 30m32s
21
- ── ✓ 3 · run-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
21
+ ── ✓ Phase 3 · Mechanical · run-extraction ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
22
22
  05:04 ✓ run-extraction · codex · gpt-5.6-luna · low 31m52s
23
- ── ✓ 4 · integrate ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → 05:41 · 4m38s · 1/1
23
+ ── ✓ Phase 4 · Integrate · integrate ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → 05:41 · 4m38s · 1/1
24
24
  05:36 ✓ integrate · codex · gpt-5.6-sol · high 4m38s
25
- ── ✓ 5 · verify ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:41 → 06:01 · 20m27s · 1/1
25
+ ── ✓ Phase 5 · Verify · verify ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:41 → 06:01 · 20m27s · 1/1
26
26
  05:41 ✓ verify · claude-code · claude-opus-5 · high 20m27s
27
27
 
28
28
 
@@ -19,19 +19,19 @@
19
19
  ── timeline · 5 phases · 5 attempts ───────────────────
20
20
  ── Preflight
21
21
  04:05 ● goal accepted · goal.json
22
- ── ✓ 1 · home-extraction
22
+ ── ✓ Phase 1 · Mechanical · home-extraction
23
23
  04:05 → 04:34 · 28m25s · 1/1
24
24
  04:05 ✓ home-extraction · codex 28m25s
25
- ── ✓ 2 · runs-extraction
25
+ ── ✓ Phase 2 · Mechanical · runs-extraction
26
26
  04:34 → 05:04 · 30m32s · 1/1
27
27
  04:34 ✓ runs-extraction · codex 30m32s
28
- ── ✓ 3 · run-extraction
28
+ ── ✓ Phase 3 · Mechanical · run-extraction
29
29
  05:04 → 05:36 · 31m52s · 1/1
30
30
  05:04 ✓ run-extraction · codex 31m52s
31
- ── ✓ 4 · integrate
31
+ ── ✓ Phase 4 · Integrate · integrate
32
32
  05:36 → 05:41 · 4m38s · 1/1
33
33
  05:36 ✓ integrate · codex 4m38s
34
- ── ✓ 5 · verify
34
+ ── ✓ Phase 5 · Verify · verify
35
35
  05:41 → 06:01 · 20m27s · 1/1
36
36
  05:41 ✓ verify · claude-code 20m27s
37
37
 
@@ -14,15 +14,15 @@
14
14
  ── timeline · 5 phases · 4 attempts · Enter on a step opens it ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
15
15
  ── Preflight ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
16
16
  04:05 ● goal accepted · goal.json
17
- ── ✓ 1 · home-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
17
+ ── ✓ Phase 1 · Mechanical · home-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
18
18
  04:05 ✓ home-extraction · codex · gpt-5.6-luna · low 28m25s
19
- ── ✓ 2 · runs-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
19
+ ── ✓ Phase 2 · Mechanical · runs-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
20
20
  04:34 ✓ runs-extraction · codex · gpt-5.6-luna · low 30m32s
21
- ── ✓ 3 · run-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
21
+ ── ✓ Phase 3 · Mechanical · run-extraction ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
22
22
  05:04 ✓ run-extraction · codex · gpt-5.6-luna · low 31m52s
23
- ── ▶ 4 · integrate ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → now · 2m19s · 0/1
23
+ ── ▶ Phase 4 · Integrate · integrate ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → now · 2m19s · 0/1
24
24
  05:36 ▶ integrate · codex · gpt-5.6-sol · high · running 2m19s
25
- ── ○ 5 · verify ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── — → — · — · 0/1
25
+ ── ○ Phase 5 · Verify · verify ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── — → — · — · 0/1
26
26
 
27
27
 
28
28
 
@@ -20,19 +20,19 @@
20
20
  ── timeline · 5 phases · 4 attempts ───────────────────
21
21
  ── Preflight
22
22
  04:05 ● goal accepted · goal.json
23
- ── ✓ 1 · home-extraction
23
+ ── ✓ Phase 1 · Mechanical · home-extraction
24
24
  04:05 → 04:34 · 28m25s · 1/1
25
25
  04:05 ✓ home-extraction · codex 28m25s
26
- ── ✓ 2 · runs-extraction
26
+ ── ✓ Phase 2 · Mechanical · runs-extraction
27
27
  04:34 → 05:04 · 30m32s · 1/1
28
28
  04:34 ✓ runs-extraction · codex 30m32s
29
- ── ✓ 3 · run-extraction
29
+ ── ✓ Phase 3 · Mechanical · run-extraction
30
30
  05:04 → 05:36 · 31m52s · 1/1
31
31
  05:04 ✓ run-extraction · codex 31m52s
32
- ── ▶ 4 · integrate
32
+ ── ▶ Phase 4 · Integrate · integrate
33
33
  05:36 → now · 2m19s · 0/1
34
34
  05:36 ▶ integrate · codex · running 2m19s
35
- ── ○ 5 · verify
35
+ ── ○ Phase 5 · Verify · verify
36
36
  — → — · — · 0/1
37
37
 
38
38
 
@@ -43,8 +43,9 @@ Home · Today · 20 Sep 2026 · top 3 runs (active first)
43
43
  Mon Tue Wed Thu Fri Sat Sun project-b ▇▇▇▇▇▇▇▇▇▇░░░░░░░░░░░░░░░░░░░░░░░░░░ 4
44
44
  ~ bars leave 66 unpriced attempts out project-c ▇▇░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1
45
45
 
46
- Workflows: 15 · verified 10 (67%) Favourite pool: codex Spent: at least $299.87 api · 66 unme…
47
- Busiest project: project-a (10) Favourite model: gpt-5.6-luna Median run: 1h45m
46
+ Workflows: 15 · verified 10 (67%) Favourite pool: codex Spent: at least $299.87 api · 66 unmeasured
47
+ Busiest project: project-a (10) Favourite model: gpt-5.6-luna sub unknown (no plan price)
48
+ Median run: 1h45m
48
49
 
49
50
  Your 15 runs in this period recorded at least $299.87 api · 66 unmeasured of API-equivalent work
50
51
 
@@ -56,5 +57,4 @@ Home · Today · 20 Sep 2026 · top 3 runs (active first)
56
57
  ✓ va7k9a project-a · sample goal for va7k9a proident fugi… 2h46m · at least $15.27 api · 6 unmeasured … 15h56m ago
57
58
 
58
59
 
59
-
60
60
  [ ?.help ] [ quit ]
@@ -43,8 +43,8 @@ Home · Today · 20 Sep 2026 · top 3 runs (active first)
43
43
  Mon Tue Wed Thu Fri Sat Sun project-b ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 4
44
44
  ~ bars leave 66 unpriced attempts out project-c ▇▇▇▇▇░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1
45
45
 
46
- Workflows: 15 · verified 10 (67%) Favourite pool: codex Spent: at least $299.87 api · 66 unmeasured · sub unknown (no pl…
47
- Busiest project: project-a (10) Favourite model: gpt-5.6-luna Median run: 1h45m
46
+ Workflows: 15 · verified 10 (67%) Favourite pool: codex Spent: at least $299.87 api · 66 unmeasured · sub unknown (no plan price)
47
+ Busiest project: project-a (10) Favourite model: gpt-5.6-luna Median run: 1h45m
48
48
 
49
49
  Your 15 runs in this period recorded at least $299.87 api · 66 unmeasured of API-equivalent work
50
50
 
@@ -1,5 +1,5 @@
1
1
  Home Runs Budget Stats Fleet
2
- bullswarm · home · 1–57/86
2
+ bullswarm · home · 1–57/88
3
3
  Home · Today · 20 Sep · top 3 runs (active first)
4
4
  ┌─ sample goal for rensha nostrud lorem nostrud moll…─┐
5
5
  │ project-b · completed · verified │
@@ -14,15 +14,15 @@
14
14
  ── timeline · 5 phases · 5 attempts · Enter on a step opens it ─────────────────────────────────────────────────────────
15
15
  ── Preflight ───────────────────────────────────────────────────────────────────────────────────────────────────────────
16
16
  04:05 ● goal accepted · goal.json
17
- ── ✓ 1 · home-extraction ────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
17
+ ── ✓ Phase 1 · Mechanical · home-extraction ─────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
18
18
  04:05 ✓ home-extraction · codex · gpt-5.6-luna · low 28m25s
19
- ── ✓ 2 · runs-extraction ────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
19
+ ── ✓ Phase 2 · Mechanical · runs-extraction ─────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
20
20
  04:34 ✓ runs-extraction · codex · gpt-5.6-luna · low 30m32s
21
- ── ✓ 3 · run-extraction ─────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
21
+ ── ✓ Phase 3 · Mechanical · run-extraction ──────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
22
22
  05:04 ✓ run-extraction · codex · gpt-5.6-luna · low 31m52s
23
- ── ✓ 4 · integrate ───────────────────────────────────────────────────────────────────────── 05:36 → 05:41 · 4m38s · 1/1
23
+ ── ✓ Phase 4 · Integrate · integrate ─────────────────────────────────────────────────────── 05:36 → 05:41 · 4m38s · 1/1
24
24
  05:36 ✓ integrate · codex · gpt-5.6-sol · high 4m38s
25
- ── ✓ 5 · verify ─────────────────────────────────────────────────────────────────────────── 05:41 → 06:01 · 20m27s · 1/1
25
+ ── ✓ Phase 5 · Verify · verify ──────────────────────────────────────────────────────────── 05:41 → 06:01 · 20m27s · 1/1
26
26
  05:41 ✓ verify · claude-code · claude-opus-5 · high 20m27s
27
27
 
28
28
 
@@ -14,15 +14,15 @@
14
14
  ── timeline · 5 phases · 5 attempts · Enter on a step opens it ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
15
15
  ── Preflight ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
16
16
  04:05 ● goal accepted · goal.json
17
- ── ✓ 1 · home-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
17
+ ── ✓ Phase 1 · Mechanical · home-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
18
18
  04:05 ✓ home-extraction · codex · gpt-5.6-luna · low 28m25s
19
- ── ✓ 2 · runs-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
19
+ ── ✓ Phase 2 · Mechanical · runs-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
20
20
  04:34 ✓ runs-extraction · codex · gpt-5.6-luna · low 30m32s
21
- ── ✓ 3 · run-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
21
+ ── ✓ Phase 3 · Mechanical · run-extraction ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
22
22
  05:04 ✓ run-extraction · codex · gpt-5.6-luna · low 31m52s
23
- ── ✓ 4 · integrate ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → 05:41 · 4m38s · 1/1
23
+ ── ✓ Phase 4 · Integrate · integrate ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → 05:41 · 4m38s · 1/1
24
24
  05:36 ✓ integrate · codex · gpt-5.6-sol · high 4m38s
25
- ── ✓ 5 · verify ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:41 → 06:01 · 20m27s · 1/1
25
+ ── ✓ Phase 5 · Verify · verify ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:41 → 06:01 · 20m27s · 1/1
26
26
  05:41 ✓ verify · claude-code · claude-opus-5 · high 20m27s
27
27
 
28
28
 
@@ -19,19 +19,19 @@
19
19
  ── timeline · 5 phases · 5 attempts ───────────────────
20
20
  ── Preflight
21
21
  04:05 ● goal accepted · goal.json
22
- ── ✓ 1 · home-extraction
22
+ ── ✓ Phase 1 · Mechanical · home-extraction
23
23
  04:05 → 04:34 · 28m25s · 1/1
24
24
  04:05 ✓ home-extraction · codex 28m25s
25
- ── ✓ 2 · runs-extraction
25
+ ── ✓ Phase 2 · Mechanical · runs-extraction
26
26
  04:34 → 05:04 · 30m32s · 1/1
27
27
  04:34 ✓ runs-extraction · codex 30m32s
28
- ── ✓ 3 · run-extraction
28
+ ── ✓ Phase 3 · Mechanical · run-extraction
29
29
  05:04 → 05:36 · 31m52s · 1/1
30
30
  05:04 ✓ run-extraction · codex 31m52s
31
- ── ✓ 4 · integrate
31
+ ── ✓ Phase 4 · Integrate · integrate
32
32
  05:36 → 05:41 · 4m38s · 1/1
33
33
  05:36 ✓ integrate · codex 4m38s
34
- ── ✓ 5 · verify
34
+ ── ✓ Phase 5 · Verify · verify
35
35
  05:41 → 06:01 · 20m27s · 1/1
36
36
  05:41 ✓ verify · claude-code 20m27s
37
37
 
@@ -14,15 +14,15 @@
14
14
  ── timeline · 5 phases · 4 attempts · Enter on a step opens it ─────────────────────────────────────────────────────────
15
15
  ── Preflight ───────────────────────────────────────────────────────────────────────────────────────────────────────────
16
16
  04:05 ● goal accepted · goal.json
17
- ── ✓ 1 · home-extraction ────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
17
+ ── ✓ Phase 1 · Mechanical · home-extraction ─────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
18
18
  04:05 ✓ home-extraction · codex · gpt-5.6-luna · low 28m25s
19
- ── ✓ 2 · runs-extraction ────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
19
+ ── ✓ Phase 2 · Mechanical · runs-extraction ─────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
20
20
  04:34 ✓ runs-extraction · codex · gpt-5.6-luna · low 30m32s
21
- ── ✓ 3 · run-extraction ─────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
21
+ ── ✓ Phase 3 · Mechanical · run-extraction ──────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
22
22
  05:04 ✓ run-extraction · codex · gpt-5.6-luna · low 31m52s
23
- ── ▶ 4 · integrate ─────────────────────────────────────────────────────────────────────────── 05:36 → now · 2m19s · 0/1
23
+ ── ▶ Phase 4 · Integrate · integrate ───────────────────────────────────────────────────────── 05:36 → now · 2m19s · 0/1
24
24
  05:36 ▶ integrate · codex · gpt-5.6-sol · high · running 2m19s
25
- ── ○ 5 · verify ──────────────────────────────────────────────────────────────────────────────────────── — → — · — · 0/1
25
+ ── ○ Phase 5 · Verify · verify ───────────────────────────────────────────────────────────────────────── — → — · — · 0/1
26
26
 
27
27
 
28
28
 
@@ -14,15 +14,15 @@
14
14
  ── timeline · 5 phases · 4 attempts · Enter on a step opens it ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
15
15
  ── Preflight ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
16
16
  04:05 ● goal accepted · goal.json
17
- ── ✓ 1 · home-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
17
+ ── ✓ Phase 1 · Mechanical · home-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:05 → 04:34 · 28m25s · 1/1
18
18
  04:05 ✓ home-extraction · codex · gpt-5.6-luna · low 28m25s
19
- ── ✓ 2 · runs-extraction ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
19
+ ── ✓ Phase 2 · Mechanical · runs-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 04:34 → 05:04 · 30m32s · 1/1
20
20
  04:34 ✓ runs-extraction · codex · gpt-5.6-luna · low 30m32s
21
- ── ✓ 3 · run-extraction ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
21
+ ── ✓ Phase 3 · Mechanical · run-extraction ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:04 → 05:36 · 31m52s · 1/1
22
22
  05:04 ✓ run-extraction · codex · gpt-5.6-luna · low 31m52s
23
- ── ▶ 4 · integrate ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → now · 2m19s · 0/1
23
+ ── ▶ Phase 4 · Integrate · integrate ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 05:36 → now · 2m19s · 0/1
24
24
  05:36 ▶ integrate · codex · gpt-5.6-sol · high · running 2m19s
25
- ── ○ 5 · verify ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── — → — · — · 0/1
25
+ ── ○ Phase 5 · Verify · verify ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── — → — · — · 0/1
26
26
 
27
27
 
28
28
 
@@ -20,19 +20,19 @@
20
20
  ── timeline · 5 phases · 4 attempts ───────────────────
21
21
  ── Preflight
22
22
  04:05 ● goal accepted · goal.json
23
- ── ✓ 1 · home-extraction
23
+ ── ✓ Phase 1 · Mechanical · home-extraction
24
24
  04:05 → 04:34 · 28m25s · 1/1
25
25
  04:05 ✓ home-extraction · codex 28m25s
26
- ── ✓ 2 · runs-extraction
26
+ ── ✓ Phase 2 · Mechanical · runs-extraction
27
27
  04:34 → 05:04 · 30m32s · 1/1
28
28
  04:34 ✓ runs-extraction · codex 30m32s
29
- ── ✓ 3 · run-extraction
29
+ ── ✓ Phase 3 · Mechanical · run-extraction
30
30
  05:04 → 05:36 · 31m52s · 1/1
31
31
  05:04 ✓ run-extraction · codex 31m52s
32
- ── ▶ 4 · integrate
32
+ ── ▶ Phase 4 · Integrate · integrate
33
33
  05:36 → now · 2m19s · 0/1
34
34
  05:36 ▶ integrate · codex · running 2m19s
35
- ── ○ 5 · verify
35
+ ── ○ Phase 5 · Verify · verify
36
36
  — → — · — · 0/1
37
37
 
38
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.35.2",
3
+ "version": "0.35.3",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/lib/tasks.js CHANGED
@@ -126,6 +126,30 @@ function finishedRow(entry) {
126
126
  };
127
127
  }
128
128
 
129
+ // One identity for a finished task row, used everywhere a task can arrive from
130
+ // two sources at once (the day's rows AND `tasks.finished`). A task recorded
131
+ // before the single-task ledger has neither `id` nor `taskFile`, so keying on
132
+ // those alone silently deduplicated nothing and every legacy task was listed
133
+ // and counted twice. The fallback is the tuple the decision log always has.
134
+ export function taskIdentity(task) {
135
+ const id = task?.id ?? task?.taskFile;
136
+ if (id != null && id !== '') return `id:${id}`;
137
+ const at = task?.endedAt ?? task?.finishedAt ?? task?.ts ?? task?.startedAt ?? '';
138
+ const pool = task?.pool ?? task?.picked ?? '';
139
+ return `at:${at}|${pool}|${task?.lane ?? ''}|${task?.durationMs ?? ''}`;
140
+ }
141
+
142
+ /**
143
+ * The id a task row opens by: its ledger id or task file, else the identity
144
+ * tuple above. Never null: a row keyed `null` equalled the dashboard's "no
145
+ * task selected", so Enter on a workflow row opened the first task that
146
+ * predates the ledger instead (seen on the owner's home, 0.35.2).
147
+ */
148
+ export function taskKey(task) {
149
+ const id = task?.id ?? task?.taskFile;
150
+ return id != null && id !== '' ? id : taskIdentity(task);
151
+ }
152
+
129
153
  /**
130
154
  * List single-task work from a Bullswarm home.
131
155
  *
@@ -30,7 +30,7 @@ import { dayKey, historyDays } from './history.js';
30
30
  import { readRollups, rollupIndexPath } from './rollup.js';
31
31
  import { loadState } from '../lib/state.js';
32
32
  import { readMeterHistoryDays } from '../meters/registry.js';
33
- import { listTasks } from '../lib/tasks.js';
33
+ import { listTasks, taskKey } from '../lib/tasks.js';
34
34
  import { attemptOutputSeries } from './v2-state.js';
35
35
  import { formatMoneyPair } from '../lib/usage-basis.js';
36
36
  import {
@@ -2381,13 +2381,20 @@ export async function runDashboard(bullswarmDir, {
2381
2381
  // frame — the difference between smooth and laggy over a phone or a
2382
2382
  // remote terminal. A first paint, a resize, or a return from the setup
2383
2383
  // hand-off (lastPaintedFrame reset to null) writes the full frame.
2384
+ //
2385
+ // Each row is erased from its first column before it is painted, never
2386
+ // after: a row that fills the terminal leaves the cursor on its last cell
2387
+ // with a wrap pending, and an erase sent then clears that cell. Ghostty
2388
+ // does exactly that (its eraseLine starts at the cursor's column), which
2389
+ // cost every full-width row its last cell at 200 columns — `40%` read
2390
+ // `40` and a single-digit count vanished.
2384
2391
  if (lastPaintedFrame != null && Array.isArray(lastPaintedLines) && lastPaintedLines.length === lines.length) {
2385
2392
  let patch = '';
2386
2393
  let changed = 0;
2387
2394
  for (let row = 0; row < lines.length; row += 1) {
2388
2395
  if (lines[row] === lastPaintedLines[row]) continue;
2389
2396
  changed += 1;
2390
- patch += `${ESC}${String(row + 1)};1H${lines[row]}${ESC}K`;
2397
+ patch += `${ESC}${String(row + 1)};1H${ESC}K${lines[row]}`;
2391
2398
  }
2392
2399
  if (!changed) return;
2393
2400
  lastPaintedLines = lines.slice();
@@ -2395,7 +2402,7 @@ export async function runDashboard(bullswarmDir, {
2395
2402
  output.write(patch);
2396
2403
  return;
2397
2404
  }
2398
- const frame = `${ESC}H${lines.map((line) => `${line}${ESC}K`).join('\n')}`;
2405
+ const frame = `${ESC}H${lines.map((line) => `${ESC}K${line}`).join('\n')}`;
2399
2406
  lastPaintedLines = lines.slice();
2400
2407
  lastPaintedFrame = frame;
2401
2408
  output.write(frame);
@@ -2472,7 +2479,7 @@ export async function runDashboard(bullswarmDir, {
2472
2479
  if ((ui.page === 'run' || ui.page === 'step') && !selectedRunId) ui.page = 'home';
2473
2480
  const row = ui.page === 'run' || ui.page === 'step' ? currentRow() : null;
2474
2481
  const task = ui.page === 'task'
2475
- ? [...(tasks.inflight ?? []), ...(tasks.finished ?? [])].find((entry) => (entry.id ?? entry.taskFile) === selectedTaskId) ?? null
2482
+ ? [...(tasks.inflight ?? []), ...(tasks.finished ?? [])].find((entry) => taskKey(entry) === selectedTaskId) ?? null
2476
2483
  : null;
2477
2484
  if (row && !row.legacy) {
2478
2485
  // The follow flags track whatever the page last resolved as current.
@@ -2574,7 +2581,7 @@ export async function runDashboard(bullswarmDir, {
2574
2581
  const tableKeepsCursor = ui.page === 'runs'
2575
2582
  && (lastFrameResult?.runRows ?? []).some((row) => row.runId === previousRunId);
2576
2583
  const taskStillExists = [...(tasks.inflight ?? []), ...(tasks.finished ?? [])]
2577
- .some((row) => (row.id ?? row.taskFile) === previousTaskId);
2584
+ .some((row) => taskKey(row) === previousTaskId);
2578
2585
  const taskKeepsCursor = ui.page === 'runs'
2579
2586
  && (lastFrameResult?.taskRows ?? []).some((row) => row.taskId === previousTaskId)
2580
2587
  && taskStillExists;
@@ -2718,7 +2725,7 @@ export async function runDashboard(bullswarmDir, {
2718
2725
  /** Opens the compact detail page for one standalone task. */
2719
2726
  const openTask = (taskId) => {
2720
2727
  const found = [...(tasks.inflight ?? []), ...(tasks.finished ?? [])]
2721
- .find((entry) => (entry.id ?? entry.taskFile) === taskId);
2728
+ .find((entry) => taskKey(entry) === taskId);
2722
2729
  if (!found) {
2723
2730
  message = 'That task is no longer in the ledger.';
2724
2731
  return paint();
@@ -3237,7 +3244,7 @@ export async function runDashboard(bullswarmDir, {
3237
3244
  ].sort((a, b) => a.y - b.y);
3238
3245
  if (listed.length) {
3239
3246
  const at = listed.findIndex((entry) => entry.kind === 'task'
3240
- ? entry.taskId === selectedTaskId
3247
+ ? selectedTaskId != null && entry.taskId === selectedTaskId
3241
3248
  : !selectedTaskId && entry.runId === selectedRunId);
3242
3249
  const wanted = at < 0 ? (delta > 0 ? 0 : listed.length - 1) : at + delta;
3243
3250
  // Once the cursor reaches the end of the Home list, keep the old
@@ -3264,7 +3271,7 @@ export async function runDashboard(bullswarmDir, {
3264
3271
  ...(lastFrameResult?.taskRows ?? []).map((row) => ({ ...row, kind: 'task' })),
3265
3272
  ].sort((a, b) => a.y - b.y);
3266
3273
  const at = listed.findIndex((entry) => entry.kind === 'task'
3267
- ? entry.taskId === selectedTaskId
3274
+ ? selectedTaskId != null && entry.taskId === selectedTaskId
3268
3275
  : !selectedTaskId && entry.runId === selectedRunId);
3269
3276
  const target = listed[clamp(at < 0 ? (delta > 0 ? 0 : listed.length - 1) : at + delta, 0, Math.max(0, listed.length - 1))];
3270
3277
  if (target) {
@@ -3370,15 +3377,13 @@ export async function runDashboard(bullswarmDir, {
3370
3377
  /** Enter: open the selected run, then its agents, then the selected step. */
3371
3378
  const drillIn = () => {
3372
3379
  if (ui.page === 'runs' || ui.page === 'home') {
3373
- if (ui.page === 'runs') {
3374
- const task = lastFrameResult?.taskRows?.find((entry) => entry.taskId === selectedTaskId)
3375
- ?? (lastFrameResult?.cursorAction?.kind === 'task' ? lastFrameResult.cursorAction : null);
3376
- if (task) return openTask(task.taskId);
3377
- } else {
3378
- const action = lastFrameResult?.cursorAction;
3379
- if (action?.kind === 'task') return openTask(action.taskId);
3380
- if (action?.kind === 'run') return openRun(action.runId);
3381
- }
3380
+ // Enter opens the row the page drew its cursor on — the same action a
3381
+ // click on that row runs — never a row found again by id: a task that
3382
+ // predates the ledger had no id, so its row matched "no task selected"
3383
+ // and Enter on a workflow row opened that task.
3384
+ const action = lastFrameResult?.cursorAction;
3385
+ if (action?.kind === 'task') return openTask(action.taskId);
3386
+ if (action?.kind === 'run') return openRun(action.runId);
3382
3387
  selectedRunId = ui.page === 'runs'
3383
3388
  ? (lastFrameResult?.runRows?.find((row) => row.runId === selectedRunId) ?? lastFrameResult?.runRows?.[0])?.runId
3384
3389
  : rows[selected]?.runId ?? activeRuns[0]?.runId ?? selectedRunId;
@@ -3530,7 +3535,7 @@ export async function runDashboard(bullswarmDir, {
3530
3535
  if (stepLikePage) {
3531
3536
  const taskRecord = ui.page === 'task'
3532
3537
  ? [...(tasks.inflight ?? []), ...(tasks.finished ?? [])]
3533
- .find((entry) => (entry.id ?? entry.taskFile) === selectedTaskId) ?? null
3538
+ .find((entry) => taskKey(entry) === selectedTaskId) ?? null
3534
3539
  : null;
3535
3540
  const row = ui.page === 'step' ? detailRow(bullswarmDir, selectedRunId) : null;
3536
3541
  const step = ui.page === 'task'
@@ -18,6 +18,7 @@
18
18
  // does. Its `elapsedMinutes` is measured, never estimated.
19
19
 
20
20
  import { glyphs } from '../lib/glyphs.js';
21
+ import { taskKey } from '../lib/tasks.js';
21
22
  import { formatMoney, formatUsageBasis } from '../lib/usage-basis.js';
22
23
  import { poolUsageAggregate, spendFacts } from './spend-facts.js';
23
24
  import { compactRow, cut, formatDashboardValue, rule } from './dash-kit.js';
@@ -663,7 +664,7 @@ function taskRow(task, lines, regions, width, ansi, { durationWidth = null } = {
663
664
  ], { width: cols, gap: 1 });
664
665
  lines.push(fit(line, cols, ansi));
665
666
  addRegion(regions, lines, 4, Math.max(1, visible(line).length - 3), {
666
- kind: 'task', taskId: task?.id ?? task?.taskFile ?? null,
667
+ kind: 'task', taskId: taskKey(task),
667
668
  });
668
669
  }
669
670
 
@@ -50,9 +50,18 @@ import { readGoalProject } from './goal.js';
50
50
 
51
51
  const DAY_MS = 86_400_000;
52
52
 
53
+ // Resolving the zone builds an Intl.DateTimeFormat, and Home asks once per day
54
+ // key, which made it most of a Home paint. Node only changes the process zone
55
+ // when TZ is assigned, so the answer is cached against TZ's value.
56
+ let zoneTz;
57
+ let zoneName = null;
53
58
  function localTimeZone() {
54
- try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
55
- catch { return 'UTC'; }
59
+ const tz = process.env.TZ ?? '';
60
+ if (zoneName !== null && tz === zoneTz) return zoneName;
61
+ try { zoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
62
+ catch { zoneName = 'UTC'; }
63
+ zoneTz = tz;
64
+ return zoneName;
56
65
  }
57
66
 
58
67
  // Building an Intl.DateTimeFormat costs about 0.1 ms, and a 181-run history
@@ -22,6 +22,7 @@ import {
22
22
  worstTokenSource,
23
23
  } from './dashboard.js';
24
24
  import { withV2Cancellation } from './v2-cancellation.js';
25
+ import { taskIdentity, taskKey } from '../lib/tasks.js';
25
26
  import { runClockText, runDurationFacts } from './run-model.js';
26
27
  import { verifyRoundLabel } from './verify-rounds.js';
27
28
  import { honestApiTotalText, recordSpendFacts, spendFacts } from './spend-facts.js';
@@ -43,19 +44,6 @@ function isToday(value, nowMs, today = dayKey(nowMs)) {
43
44
  return dayKey(at) === today;
44
45
  }
45
46
 
46
- // One identity for a finished task row, used everywhere a task can arrive from
47
- // two sources at once (the day's rows AND `tasks.finished`). A task recorded
48
- // before the single-task ledger has neither `id` nor `taskFile`, so keying on
49
- // those alone silently deduplicated nothing and every legacy task was listed
50
- // and counted twice. The fallback is the tuple the decision log always has.
51
- function taskIdentity(task) {
52
- const id = task?.id ?? task?.taskFile;
53
- if (id != null && id !== '') return `id:${id}`;
54
- const at = task?.endedAt ?? task?.finishedAt ?? task?.ts ?? task?.startedAt ?? '';
55
- const pool = task?.pool ?? task?.picked ?? '';
56
- return `at:${at}|${pool}|${task?.lane ?? ''}|${task?.durationMs ?? ''}`;
57
- }
58
-
59
47
  function todayMinutesText(value) {
60
48
  const minutes = finiteOrNull(value);
61
49
  return minutes == null ? null : `${minutes.toFixed(1)}m`;
@@ -816,6 +804,7 @@ function recordCostInfo(record) {
816
804
  export {
817
805
  taskToday,
818
806
  taskIdentity,
807
+ taskKey,
819
808
  todayMinutesText,
820
809
  todayMinutesNumberText,
821
810
  measuredTaskMinutes,
@@ -53,9 +53,11 @@ import {
53
53
  usageBasisText,
54
54
  visibleLength,
55
55
  workflowRunLabel,
56
+ wrapLines,
56
57
  } from './dashboard.js';
57
58
  import { apiMoney, formatMoney } from '../lib/usage-basis.js';
58
59
  import { honestApiTotalText, recordSpendFacts, spendFacts } from './spend-facts.js';
60
+ import { taskKey } from '../lib/tasks.js';
59
61
 
60
62
  const BUDGET_WEEK_POOLS = 4;
61
63
  // From this width up the Today band and the period band are each two halves
@@ -166,6 +168,20 @@ function todayTableRow(row, width, { header = false } = {}) {
166
168
  return todayPadded(line, width);
167
169
  }
168
170
 
171
+ /**
172
+ * `parts` joined with ` · ` in lines of at most `width` cells, a line breaking
173
+ * only at a join: a part longer than a whole line wraps at its own spaces.
174
+ */
175
+ function joinedLines(parts, width) {
176
+ const lines = [];
177
+ for (const part of parts.map(String).filter(Boolean)) {
178
+ const last = lines.at(-1);
179
+ if (last != null && visibleLength(`${last} · ${part}`) <= width) lines[lines.length - 1] = `${last} · ${part}`;
180
+ else lines.push(...wrapLines([part], width));
181
+ }
182
+ return lines.length ? lines : [''];
183
+ }
184
+
169
185
  function todayBareRule(width) {
170
186
  return '─'.repeat(Math.max(0, width));
171
187
  }
@@ -245,7 +261,7 @@ function taskCardModel(task) {
245
261
  };
246
262
  return {
247
263
  record,
248
- id: task?.id ?? task?.taskFile ?? null,
264
+ id: taskKey(task),
249
265
  name: String(task?.goal ?? task?.lane ?? task?.id ?? task?.taskFile ?? 'task')
250
266
  .split(/\r?\n/).find((line) => line.trim())?.trim() ?? 'task',
251
267
  project: task?.project ?? '—',
@@ -633,8 +649,8 @@ function homeTodayBand(model, opts, body) {
633
649
  // workflows, so it remains workflow-only while a task-only home is useful.
634
650
  for (const task of today.tasks) {
635
651
  if (cards.length >= 3) break;
636
- const id = task?.id ?? task?.taskFile;
637
- if (!id || used.has(id)) continue;
652
+ const id = taskKey(task);
653
+ if (used.has(id)) continue;
638
654
  const card = taskCardModel(task);
639
655
  card.task = true;
640
656
  cards.push(card);
@@ -932,8 +948,14 @@ function slotLabelLine(slots, { cell, weekly, room }) {
932
948
  * approximate — an estimate, or a day with unpriced attempts left out — and
933
949
  * `$400` when every bar is a provider-reported whole), the day under each
934
950
  * bar, and at most one dim line saying what the bars leave out.
951
+ *
952
+ * `lines`, when given, is how many lines the chart fills: the half beside the
953
+ * breakdowns asks for their height, so no empty block sits under the chart.
954
+ * Each tick interval then takes a whole number of rows and the bars scale to
955
+ * them; the one or two rows that leave over sit above the top tick as
956
+ * headroom, so every tick still marks its exact amount.
935
957
  */
936
- function spendChartLines(model, opts, width) {
958
+ function spendChartLines(model, opts, width, { lines: fill = null } = {}) {
937
959
  const spend = model.stats?.spendPerDay ?? null;
938
960
  const period = PERIOD_ITEMS.find((item) => item.id === opts.period) ?? PERIOD_ITEMS[0];
939
961
  const weekly = spend?.bucketBy === 'week';
@@ -949,8 +971,20 @@ function spendChartLines(model, opts, width) {
949
971
  const ticks = spendTicks(Math.max(...drawn.map((slot) => slot.usd)));
950
972
  const top = ticks.at(-1);
951
973
  const intervals = ticks.length - 1;
952
- const perTick = Math.max(2, Math.round(chartRowCount(opts.height ?? 36) / intervals));
953
- const rows = intervals * perTick;
974
+ const unpriced = slots.reduce((sum, slot) => sum + slot.unpriced, 0);
975
+ const unknownDays = slots.filter((slot) => slot.unknown).length;
976
+ const notes = [
977
+ unpriced ? `~ bars leave ${unpriced} unpriced attempt${unpriced === 1 ? '' : 's'} out` : null,
978
+ unknownDays ? `${blank()} no price recorded` : null,
979
+ ].filter(Boolean);
980
+ // The plot's rows: what `lines` leaves after the baseline, the day labels
981
+ // and the note, else the terminal height's share.
982
+ const room = fill == null ? null : fill - 2 - (notes.length ? 1 : 0);
983
+ const perTick = room == null
984
+ ? Math.max(2, Math.round(chartRowCount(opts.height ?? 36) / intervals))
985
+ : Math.max(2, Math.floor(room / intervals));
986
+ const scaled = intervals * perTick;
987
+ const rows = room == null ? scaled : Math.max(scaled, room);
954
988
  const gutter = Math.max(...ticks.map((tick) => tickText(tick, mark).length));
955
989
  const ascii = asciiGlyphsPreferred();
956
990
  const plot = Math.max(slots.length, width - gutter - 3);
@@ -960,10 +994,10 @@ function spendChartLines(model, opts, width) {
960
994
  // A day that spent anything keeps at least one eighth, so it never reads
961
995
  // as the zero a day with no run draws.
962
996
  const eighths = slots.map((slot) => (slot.usd > 0
963
- ? Math.max(1, Math.round((slot.usd / top) * rows * 8)) : 0));
997
+ ? Math.max(1, Math.round((slot.usd / top) * scaled * 8)) : 0));
964
998
  const lines = [];
965
999
  for (let row = rows; row >= 1; row -= 1) {
966
- const tick = row % perTick === 0 ? ticks[row / perTick] : null;
1000
+ const tick = row % perTick === 0 ? ticks[row / perTick] ?? null : null;
967
1001
  const axis = tick == null ? (ascii ? '|' : '│') : (ascii ? '|' : '┤');
968
1002
  let line = ` ${(tick == null ? '' : tickText(tick, mark)).padStart(gutter)} ${dimText(axis, 1)}`;
969
1003
  slots.forEach((slot, index) => {
@@ -979,12 +1013,6 @@ function spendChartLines(model, opts, width) {
979
1013
  const baseline = `${ascii ? '+' : '┼'}${(ascii ? '-' : '─').repeat(cell * slots.length)}`;
980
1014
  lines.push(` ${tickText(0, mark).padStart(gutter)} ${dimText(baseline, baseline.length)}`);
981
1015
  lines.push(`${' '.repeat(gutter + 3)}${slotLabelLine(slots, { cell, weekly, room: Math.max(cell * slots.length, plot) })}`);
982
- const unpriced = slots.reduce((sum, slot) => sum + slot.unpriced, 0);
983
- const unknownDays = slots.filter((slot) => slot.unknown).length;
984
- const notes = [
985
- unpriced ? `~ bars leave ${unpriced} unpriced attempt${unpriced === 1 ? '' : 's'} out` : null,
986
- unknownDays ? `${blank()} no price recorded` : null,
987
- ].filter(Boolean);
988
1016
  if (notes.length) lines.push(dimText(` ${notes.join(' · ')}`, width));
989
1017
  return lines;
990
1018
  }
@@ -1023,9 +1051,10 @@ function breakdownSection(label, list, { width, role, runs = false, nameWidth })
1023
1051
  /**
1024
1052
  * The period band's cells: the spent-per-day chart, then `by pool`,
1025
1053
  * `by model` and `by project`, each `{ action, rows }` and `cellWidth` wide.
1026
- * The breakdowns share one name column, so their bars start together.
1054
+ * The breakdowns share one name column, so their bars start together. With
1055
+ * `chartLines` the chart cell is that many lines tall, its label included.
1027
1056
  */
1028
- function breakdownCells(model, opts, { cellWidth }) {
1057
+ function breakdownCells(model, opts, { cellWidth, chartLines = null }) {
1029
1058
  const breakdown = model.stats?.overview?.breakdown ?? { pools: [], models: [], projects: [] };
1030
1059
  const spend = model.stats?.spendPerDay ?? null;
1031
1060
  const lists = {
@@ -1044,7 +1073,7 @@ function breakdownCells(model, opts, { cellWidth }) {
1044
1073
  action: { kind: 'trend', metric: 'spend' },
1045
1074
  rows: [
1046
1075
  dimText(` spent per ${spend?.bucketBy === 'week' ? 'week' : 'day'}`, cellWidth),
1047
- ...spendChartLines(model, opts, cellWidth),
1076
+ ...spendChartLines(model, opts, cellWidth, { lines: chartLines == null ? null : chartLines - 1 }),
1048
1077
  ],
1049
1078
  },
1050
1079
  // The Stats tab each section opens: its `pool`, `model` and `project`
@@ -1060,17 +1089,20 @@ function breakdownCells(model, opts, { cellWidth }) {
1060
1089
  * breakdowns stacked in the right one, a blank row between them; below 110
1061
1090
  * columns the four read one under another. Each line keeps its cell's action,
1062
1091
  * so a click on the chart opens the spend trend and one on a section its tab.
1092
+ * In halves the chart is as tall as the stack beside it.
1063
1093
  */
1064
1094
  function periodBand(model, opts, body) {
1065
1095
  const { width } = opts;
1066
1096
  const split = width >= HALVES_WIDTH;
1067
1097
  const halves = split ? bandHalves(width) : null;
1068
- const [chart, ...sections] = breakdownCells(model, opts, { cellWidth: split ? halves.half : width });
1098
+ const cellWidth = split ? halves.half : width;
1099
+ const [, ...sections] = breakdownCells(model, opts, { cellWidth });
1069
1100
  const linesOf = (cell) => cell.rows.map((text) => ({ text, action: cell.action }));
1070
1101
  const stacked = sections.flatMap((cell, index) => [
1071
1102
  ...(index ? [{ text: '' }] : []),
1072
1103
  ...linesOf(cell),
1073
1104
  ]);
1105
+ const [chart] = breakdownCells(model, opts, { cellWidth, chartLines: split ? stacked.length : null });
1074
1106
  if (split) pushHalves(body, linesOf(chart), stacked, halves);
1075
1107
  else {
1076
1108
  pushStack(body, linesOf(chart));
@@ -1142,22 +1174,46 @@ function summaryBand(body, model, opts) {
1142
1174
  `Favourite pool: ${tint(named(keys.favouritePool), 'orange')}`,
1143
1175
  `Favourite model: ${tint(named(keys.favouriteModel), 'orange')}`,
1144
1176
  ],
1145
- [
1146
- `Spent: ${tint(money, 'orange')}`,
1147
- `Median run: ${tint(medianRunText(model), 'orange')}`,
1148
- ],
1149
1177
  ];
1178
+ const median = `Median run: ${tint(medianRunText(model), 'orange')}`;
1179
+ // The amount with its coverage is the longest figure: where its column is
1180
+ // too narrow it wraps at its ` · ` joins under its label, never cut short.
1181
+ const label = 'Spent: ';
1182
+ const spentRows = (own) => joinedLines(money.split(' · '), Math.max(8, own - label.length))
1183
+ .map((line, index) => `${index ? ' '.repeat(label.length) : label}${tint(line, 'orange')}`);
1150
1184
  body.push('');
1151
1185
  if (narrow) {
1152
1186
  for (const figure of figures.flat()) body.push(cut(` ${figure}`, width));
1187
+ for (const row of [...spentRows(width - 1), median]) body.push(cut(` ${row}`, width));
1153
1188
  } else {
1154
- pushColumns(body, figures.map((rows) => ({ rows })), { width: width - 1, gap: 2 });
1189
+ // Three even columns while every figure fits its third. Past that, which
1190
+ // an even split met at 200 columns by cutting the coverage off `Spent`,
1191
+ // `Spent` takes the width it needs out of what the other two leave, and
1192
+ // they share whatever is over.
1193
+ const gap = 2;
1194
+ const cells = width - 1;
1195
+ const inner = cells - gap * 2;
1196
+ const natural = [
1197
+ ...figures.map((rows) => Math.max(...rows.map((row) => visibleLength(row)))),
1198
+ visibleLength(`${label}${money}`),
1199
+ ];
1200
+ const even = Math.floor(inner / 3);
1201
+ const spentWidth = Math.max(even, Math.min(natural[2], inner - natural[0] - natural[1]));
1202
+ const over = inner - spentWidth - natural[0] - natural[1];
1203
+ const widths = natural[2] > even && over >= 0
1204
+ ? [natural[0] + Math.ceil(over / 2), natural[1] + Math.floor(over / 2), spentWidth]
1205
+ : null;
1206
+ pushColumns(body, [
1207
+ ...figures.map((rows) => ({ rows })),
1208
+ { rows: [...spentRows(widths ? spentWidth : even), median] },
1209
+ ].map((cell, index) => (widths ? { ...cell, width: widths[index] } : cell)), { width: cells, gap });
1155
1210
  }
1156
1211
  body.push('');
1157
1212
  const sentence = apiPart && !apiPart.includes('api unknown')
1158
1213
  ? `Your ${runs} run${runs === 1 ? '' : 's'} in this period recorded ${apiPart} of API-equivalent work`
1159
1214
  : `Your ${runs} run${runs === 1 ? '' : 's'} in this period recorded no API-equivalent estimate`;
1160
- body.push(cut(` ${tint(sentence, 'purple')}`, width));
1215
+ // Wrapped, not cut: a phone would otherwise cut the amount it states.
1216
+ for (const line of wrapLines([sentence], Math.max(8, width - 1))) body.push(` ${tint(line, 'purple')}`);
1161
1217
  }
1162
1218
 
1163
1219
  function homePage(model, opts, body) {
@@ -1262,7 +1318,7 @@ function activeRunLines(model, opts, body, title = 'running') {
1262
1318
  { text: elapsed, width: Math.max(5, visibleLength(elapsed)), align: 'right', gap: 2 },
1263
1319
  { text: ' ', width: 1, gap: 0 },
1264
1320
  ], { width });
1265
- body.row(line, { kind: 'task', taskId: task?.id ?? task?.taskFile ?? null });
1321
+ body.row(line, { kind: 'task', taskId: taskKey(task) });
1266
1322
  }
1267
1323
  if (unratedPools.size) {
1268
1324
  body.push(dimText(` ${blank()} no measured %/minute rate for ${[...unratedPools].join(', ')}, so no licence draw`, width));
@@ -8,7 +8,7 @@ import { finiteOrNull } from '../lib/num.js';
8
8
  import { formatMoney } from '../lib/usage-basis.js';
9
9
  import { isProgramWorkflow } from './execution-policy.js';
10
10
  import { presentationStageStatus, projectV2DependencyStages } from './v2-presentation.js';
11
- import { loopStageLabel } from './verify-rounds.js';
11
+ import { kernelRepairActionIds, loopStageLabel } from './verify-rounds.js';
12
12
  import { cut, progressBar } from './dash-kit.js';
13
13
  import {
14
14
  blank,
@@ -924,6 +924,37 @@ function phaseGlyph(status) {
924
924
  return table.pending;
925
925
  }
926
926
 
927
+ // What a phase does, named from the kinds of the steps it holds: the owner's
928
+ // words for the program's step kinds (0.35.3). A kind with no entry reads as
929
+ // the kind itself, capitalised.
930
+ const PHASE_KIND_NAMES = Object.freeze({
931
+ implement: 'Build',
932
+ integration: 'Integrate',
933
+ 'adversarial-acceptance': 'Verify',
934
+ digest: 'Digest',
935
+ architecture: 'Design',
936
+ });
937
+
938
+ /**
939
+ * A phase's name from its steps' kinds, in the order each kind first appears,
940
+ * joined with ` + ` when the phase mixes them (`Build + Verify`). A repair
941
+ * step the kernel's verify loop added reads `Repair`, whatever kind it was
942
+ * added as. Null when no step of the phase declares a kind.
943
+ */
944
+ function phaseKindName(state, actionIds) {
945
+ const repairs = new Set(kernelRepairActionIds(state));
946
+ const kinds = new Map((state?.program?.actions ?? []).map((action) => [action?.id, action?.kind]));
947
+ const names = [];
948
+ for (const id of actionIds) {
949
+ const kind = String(kinds.get(id) ?? '').trim();
950
+ const name = repairs.has(id) ? 'Repair'
951
+ : Object.hasOwn(PHASE_KIND_NAMES, kind) ? PHASE_KIND_NAMES[kind]
952
+ : kind ? `${kind[0].toUpperCase()}${kind.slice(1).replaceAll('-', ' ')}` : null;
953
+ if (name && !names.includes(name)) names.push(name);
954
+ }
955
+ return names.length ? names.join(' + ') : null;
956
+ }
957
+
927
958
  /** The tree facts consumed by both the desktop and phone timeline layouts. */
928
959
  function runTimelineFacts(row, { nowMs = Date.now() } = {}) {
929
960
  const state = row?.state ?? row ?? {};
@@ -943,6 +974,7 @@ function runTimelineFacts(row, { nowMs = Date.now() } = {}) {
943
974
  const failed = phaseActions.some((action) => ['failed', 'blocked', 'cancelled', 'interrupted'].includes(action.status));
944
975
  return {
945
976
  index, id: stage.id, name: planStageStepsName(stage, index), label: stage.label, stage,
977
+ kindName: phaseKindName(state, phaseActions.map((action) => action.id)),
946
978
  status: active ? 'active' : failed ? 'failed' : progress.completed === progress.total && progress.total > 0 ? 'completed' : 'pending',
947
979
  glyph: phaseGlyph(active ? 'active' : failed ? 'failed' : progress.completed === progress.total && progress.total > 0 ? 'completed' : 'pending'),
948
980
  startedAt, finishedAt, endAt: active ? null : finishedAt,
@@ -173,7 +173,9 @@ function paintPhaseRule(line, phase) {
173
173
  const start = phase?.startedAt ? clockText(phase.startedAt) : '—';
174
174
  const end = phase?.status === 'active' ? 'now' : phase?.finishedAt ? clockText(phase.finishedAt) : '—';
175
175
  const duration = runClockText(phase?.activeMinutes ?? phase?.spanMinutes);
176
- let factsFrom = 0;
176
+ // The facts sit after the label: a step named `now` is not the clock.
177
+ const labelAt = phase?.name ? source.indexOf(phase.name) : -1;
178
+ let factsFrom = labelAt >= 0 ? labelAt + phase.name.length : 0;
177
179
  const addFact = (value) => {
178
180
  const text = String(value ?? '');
179
181
  if (!text) return;
@@ -829,12 +831,13 @@ function workflowTimelineLines(model, width, spinnerFrame = 0, {
829
831
  const start = phase.startedAt ? clockText(phase.startedAt) : '—';
830
832
  const end = phase.status === 'active' ? 'now' : phase.finishedAt ? clockText(phase.finishedAt) : '—';
831
833
  const duration = runClockText(phase.activeMinutes ?? phase.spanMinutes);
832
- const right = `${start} → ${end} · ${duration} · ${phase.done}/${phase.total}`;
833
- const name = `${phase.glyph} ${phase.index + 1} · ${phase.name}`;
834
- const phaseLine = phone
835
- ? `── ${phase.glyph} ${phase.index + 1} · ${phase.name}`
836
- : phaseRule(name, right, safeWidth);
837
- push(phone ? paintPhaseRule(phaseLine, phase) : paintPhaseRule(phaseLine, phase), {
834
+ const tally = `${phase.done}/${phase.total}`;
835
+ const right = `${start} → ${end} · ${duration} · ${tally}`;
836
+ // The phone prints the facts on their own row under the rule.
837
+ const rule = phaseRule(phaseTitle(phase), phaseStepsText(phase), phone ? [] : [right, `${duration} · ${tally}`, tally], safeWidth);
838
+ // The name and the steps are bold, `Phase <n> · ` plain, as `<n> · ` was.
839
+ const named = rule.label.slice(`${phase.glyph} Phase ${phase.index + 1}`.length).replace(/^ · /, '');
840
+ push(paintPhaseRule(rule.line, { ...phase, name: named }), {
838
841
  header: true, segment: phase.label, phaseIndex: phase.index, phase,
839
842
  });
840
843
  if (phone) push(` ${dimCell(right)}`, { segment: phase.label, phaseIndex: phase.index, span: true });
@@ -899,27 +902,63 @@ function workflowTimelineLines(model, width, spinnerFrame = 0, {
899
902
  };
900
903
  }
901
904
 
905
+ /** `✓ Phase 2 · Build`: the phase's glyph, number and the name its kinds give it. */
906
+ function phaseTitle(phase) {
907
+ return `${phase.glyph} Phase ${phase.index + 1}${phase.kindName ? ` · ${phase.kindName}` : ''}`;
908
+ }
909
+
902
910
  /**
903
- * `── <glyph> <n> · <name> ──── <start> → <end> · <duration> · <done>/<total>`:
904
- * the v2 phase rule spends its dashes between the name and the facts and ends
911
+ * The step names a phase rule carries after its title. A kernel loop phase is
912
+ * named by its round (`repair · round 1 · 2 requirements`); the title already
913
+ * says `Repair`, so the round's own leading word is not said twice.
914
+ */
915
+ function phaseStepsText(phase) {
916
+ const name = String(phase.name ?? '').trim();
917
+ // Only a round's label: a phase whose first step is named `build` keeps it.
918
+ const lead = phase.stage?.loopLabel && phase.kindName ? `${phase.kindName.toLowerCase()} · ` : null;
919
+ return lead && name.toLowerCase().startsWith(lead) ? name.slice(lead.length) : name;
920
+ }
921
+
922
+ /**
923
+ * `── ✓ Phase 2 · Build · time-box · docs ──── <start> → <end> · <duration> · <done>/<total>`:
924
+ * the v2 phase rule spends its dashes between the label and the facts and ends
905
925
  * on the tally, so the count is the last thing the row says. Rule 6 of the
906
926
  * run-v2 record draws it that way; `rule()` keeps its closing dashes for the
907
- * rules that have a right-hand label to fence off. A phase whose steps are
908
- * named at length gives way before its facts do: the count, the clock and the
909
- * duration are what the rule is read for.
927
+ * rules that have a right-hand label to fence off. `rights` are the facts from
928
+ * the fullest down; the phone passes none, prints the facts on a row of their
929
+ * own, and draws no dashes after the label.
930
+ *
931
+ * The phase number and name are never cut. A phase whose steps are named at
932
+ * length gives way first — its step names shorten, then go — and only then
933
+ * the facts, shortest last: the count, the clock and the duration are what the
934
+ * rule is read for. Returns the line and the label it painted.
910
935
  */
911
- function phaseRule(name, right, width) {
936
+ function phaseRule(title, steps, rights, width) {
912
937
  const cols = Math.max(1, Number(width) || 1);
913
- const label = String(name ?? '');
914
- const tail = right == null || `${right}` === '' ? '' : ` ${right}`;
915
- const room = cols - 3 - 1 - 2 - visibleLength(tail);
916
- if (visibleLength(`── ${label} `) + visibleLength(tail) < cols) {
917
- const head = `── ${label} `;
918
- return `${head}${'─'.repeat(cols - visibleLength(head) - visibleLength(tail))}${tail}`;
938
+ const named = String(title ?? '');
939
+ const stepText = String(steps ?? '');
940
+ const fill = rights.length > 0;
941
+ const tails = [...rights.map((right) => ` ${right}`), ''];
942
+ const draw = (label, tail) => {
943
+ const head = `── ${label}`;
944
+ if (!fill) return visibleLength(head) <= cols ? head : null;
945
+ const dashes = cols - visibleLength(head) - 1 - visibleLength(tail);
946
+ return dashes >= (tail ? 2 : 0) ? `${head} ${'─'.repeat(dashes)}${tail}` : null;
947
+ };
948
+ for (const tail of tails) {
949
+ const whole = stepText ? `${named} · ${stepText}` : named;
950
+ const line = draw(whole, tail);
951
+ if (line) return { line, label: whole };
952
+ const room = cols - visibleLength(`── ${named} · `) - (fill ? 1 + (tail ? 2 : 0) + visibleLength(tail) : 0);
953
+ if (stepText && room >= 4) {
954
+ const label = `${named} · ${cut(stepText, room)}`;
955
+ return { line: draw(label, tail), label };
956
+ }
957
+ const bare = draw(named, tail);
958
+ if (bare) return { line: bare, label: named };
919
959
  }
920
- if (room < 1) return cut(`── ${label}${tail}`, cols);
921
- const head = `── ${cut(label, room)} `;
922
- return `${head}${'─'.repeat(Math.max(2, cols - visibleLength(head) - visibleLength(tail)))}${tail}`;
960
+ // Too narrow for the title itself: the one place a cut is left.
961
+ return { line: cut(`── ${named}`, cols), label: named };
923
962
  }
924
963
 
925
964
  function segmentHeader(name, elapsed, width) {