@zalom/plastic 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/PLASTIC-reference.md +2 -0
  2. package/PLASTIC.md +160 -42
  3. package/agents/plastic-intent-curator.md +10 -2
  4. package/package.json +1 -1
  5. package/scripts/codex-hook +122 -8
  6. package/scripts/dashboard.rb +323 -71
  7. package/scripts/doctor.rb +271 -15
  8. package/scripts/end-intent +32 -7
  9. package/scripts/hook-lock-gate +8 -3
  10. package/scripts/install.rb +51 -6
  11. package/scripts/lib/bridge.rb +79 -30
  12. package/scripts/lib/hook_registry.rb +44 -2
  13. package/scripts/lib/installer_core.rb +45 -6
  14. package/scripts/lib/lock.rb +186 -11
  15. package/scripts/lib/maintenance_git.rb +94 -0
  16. package/scripts/lib/revisions_writer.rb +69 -0
  17. package/scripts/lib/worktree.rb +14 -32
  18. package/scripts/lib/worktree_sweep.rb +129 -0
  19. package/scripts/maintenance-run +236 -0
  20. package/scripts/plastic-lock +76 -9
  21. package/scripts/project-links +127 -24
  22. package/scripts/rebuild-graph +37 -3
  23. package/scripts/restore-intent-v1 +37 -3
  24. package/scripts/sweep-store-worktrees +53 -0
  25. package/skills/auto/SKILL.md +29 -6
  26. package/skills/auto/references/agent-architecture.md +7 -0
  27. package/skills/auto/references/end-tail.md +8 -6
  28. package/skills/dashboard/SKILL.md +48 -25
  29. package/skills/dashboard/evals/evals.json +4 -4
  30. package/skills/dashboard/templates/dashboard-global.md +3 -5
  31. package/skills/dashboard/templates/dashboard-project.md +6 -18
  32. package/skills/doctor/SKILL.md +6 -0
  33. package/skills/intent-locking/SKILL.md +20 -2
  34. package/skills/intent-starting/SKILL.md +6 -4
  35. package/skills/project-continuing/SKILL.md +10 -0
  36. package/skills/project-continuing/evals/evals.json +3 -3
  37. package/skills/project-continuing/references/board-fill.md +13 -11
  38. package/skills/releasing/SKILL.md +3 -3
  39. package/skills/store-curating/SKILL.md +9 -0
  40. package/skills/store-curating/evals/evals.json +16 -0
  41. package/skills/tutorial/SKILL.md +4 -4
  42. package/skills/tutorial/references/track-1-guided.md +2 -1
  43. package/skills/tutorial/references/track-2-auto.md +2 -1
  44. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -1
@@ -11,6 +11,8 @@
11
11
  #
12
12
  # Usage:
13
13
  # ruby dashboard.rb [continue|project <slug>|all] [--json]
14
+ # ruby dashboard.rb [continue|project <slug>] --data [--limit-active N] [--limit-next N] [--all]
15
+ # ruby dashboard.rb [continue|project <slug>] --plain
14
16
  # Default mode: continue.
15
17
  #
16
18
  # Env overrides (for deterministic tests):
@@ -24,6 +26,7 @@ require "yaml"
24
26
  require "date"
25
27
  require_relative "doctor"
26
28
  require_relative "lib/bridge"
29
+ require_relative "lib/lock"
27
30
 
28
31
  PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
29
32
 
@@ -62,13 +65,39 @@ def index_section_ids(index_path, header)
62
65
  seg.scan(/^- \[([^\]\s]+)/).flatten
63
66
  end
64
67
 
68
+ # Bug found at intent 202's gate review: the date used to be anchored to the END of the
69
+ # line, which was true of old INDEX.md entries but stopped being true the moment
70
+ # `scripts/end-intent --index-note "..."` started writing the date FOLLOWED BY the note
71
+ # text (that has been the documented completion path for every completion since it
72
+ # shipped). Anchored-to-end-of-line silently missed the date on every noted entry.
73
+ # Fixed by anchoring to the position right after the markdown link's closing paren
74
+ # instead, with no end-of-line requirement, so trailing note prose is irrelevant. The
75
+ # separator there may be a real em dash (U+2014, INDEX.md's normal on-write convention,
76
+ # built from the codepoint so this source line stays em-dash free) or a plain hyphen
77
+ # (end-intent's own Bridge.index_entry_match accepts either on read). Because regex
78
+ # alternation is leftmost-first, this only ever matches the date immediately after the
79
+ # link. It never continues scanning into the note prose, so a second date mentioned
80
+ # later in a note's free text cannot be mistaken for the completion date.
81
+ COMPLETION_DATE_RE = /^- \[([^\]\s]+).*?\)\s*[\u2014-]\s*(\d{4}-\d{2}-\d{2})\b/
82
+
65
83
  # Map of intent id -> completion date string, parsed from the "## Completed" section
66
- # (lines like "- [12 ...](...) 2026-06-10"). Deterministic, content-derived.
84
+ # (lines like "- [12 (em dash) title](link) (em dash) 2026-06-10 optional note text").
85
+ # Deterministic, content-derived. Observability: a populated "## Completed" section that
86
+ # yields not one single dated entry is a parser regression, not a legitimately empty
87
+ # result, so it is surfaced with a stderr warning rather than rotting invisibly (the same
88
+ # silent-failure class intent 202's gate review caught this file already committing).
67
89
  def completion_dates(index_path)
68
90
  return {} unless File.exist?(index_path)
69
91
  body = File.read(index_path)
70
92
  seg = body[/^## Completed\s*\n(.*?)(?=^## |\z)/m, 1] || ""
71
- seg.scan(/^- \[([^\]\s]+).*?—\s*(\d{4}-\d{2}-\d{2})\s*$/).to_h
93
+ entry_count = seg.scan(/^- \[/).size
94
+ dates = seg.scan(COMPLETION_DATE_RE).to_h
95
+ if entry_count.positive? && dates.empty?
96
+ warn "dashboard: completion_dates parsed 0/#{entry_count} dates from the " \
97
+ "\"## Completed\" section of #{index_path}; treat this as a parser regression, " \
98
+ "not a genuinely undated store."
99
+ end
100
+ dates
72
101
  end
73
102
 
74
103
  # All stores: global + every registered project. -> [{scope, store, index}]
@@ -109,6 +138,29 @@ def last_accessed_at(dir, created)
109
138
  ""
110
139
  end
111
140
 
141
+ # D3 fix (intent 202 gate review): the actual moment an intent finished, distinct from
142
+ # completion_dates' day-granularity date. Reads savepoint.md bottom-up like
143
+ # last_accessed_at above, but matches on the stage token specifically ("Done") instead of
144
+ # taking whatever the last line says, so a ledger touched again after Done (which would
145
+ # violate the completed-intents-immutable convention) still reports the true Done moment
146
+ # rather than whatever ran later. Returns nil when there is no savepoint or no Done line,
147
+ # so callers get an honest "no signal" instead of a fabricated time.
148
+ def done_timestamp(dir)
149
+ sp = File.join(dir, "savepoint.md")
150
+ return nil unless File.exist?(sp)
151
+ File.readlines(sp).reverse_each do |line|
152
+ stripped = line.strip
153
+ next if stripped.empty?
154
+ fields = stripped.split(/\s+/)
155
+ next unless fields[1] == "Done"
156
+ m = fields[0].match(ISO8601_RE)
157
+ return m[0] if m
158
+ end
159
+ nil
160
+ rescue StandardError
161
+ nil
162
+ end
163
+
112
164
  # True iff the savepoint ledger's last non-blank line shows real post-birth
113
165
  # activity, not just the one-line birth stamp every intent gets at creation
114
166
  # (scripts/new-intent's Bridge.append_savepoint call, stage "What"). Reads the
@@ -170,6 +222,11 @@ def parse_intent(store_info, dir_name, status_index)
170
222
  checklist_partial: real.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
171
223
  body_has_context: body.include?("## Context"),
172
224
  last_accessed_at: last_accessed_at(dir, (fm["created"].to_s rescue "")),
225
+ done_at: done_timestamp(dir),
226
+ # Kept only in the internal record so project Active rows can inspect the
227
+ # durable lock beside the intent. intent_line deliberately does not expose
228
+ # this filesystem path in --data or any rendered surface.
229
+ intent_dir: File.expand_path(dir),
173
230
  }
174
231
  end
175
232
 
@@ -371,13 +428,25 @@ end
371
428
 
372
429
  CELL_CAP = 6
373
430
 
374
- # Markdown-board caps (Task 5, D6/D7): the ASCII renderer already caps via CELL_CAP/
375
- # cap_cell, but the Markdown board's next_work list and the project board's
376
- # active/future lists had no cap and no per-line truncation, so a large store printed
377
- # hundreds of full-length lines. These two constants fix that on the Markdown side only.
378
- NEXT_WORK_CAP = 8
431
+ # Markdown-board caps (Task 5, D6/D7 of intent 37; re-tuned by intent 202 D1/D5): the ASCII
432
+ # renderer still caps via CELL_CAP/cap_cell, untouched. ACTIVE_CAP/NEXT_WORK_CAP are the
433
+ # --data board's DEFAULTS only: --limit-active/--limit-next override either one per call,
434
+ # and --all lifts both to unbounded (see main). Whatever is actually shown, the payload
435
+ # always reports the true pool size alongside it, so the footer can state an honest count
436
+ # instead of a silent "+N more" row (D5).
437
+ ACTIVE_CAP = 3
438
+ NEXT_WORK_CAP = 5
379
439
  INTENT_LINE_MAX_CHARS = 120
380
440
 
441
+ # D1 fix (intent 202 gate review): the "most recently delivered" summary must read as 2 to
442
+ # 3 short sentences, never the longest thing on the board. SUMMARY_CHAR_BUDGET is the hard
443
+ # ceiling on the fully assembled string (about what 2 to 3 plain sentences read as).
444
+ # SUMMARY_TITLE_MAX_CHARS bounds each per-intent label before assembly, so the normal case
445
+ # (short titles) never needs the hard cap at all; the hard cap is the safety net that holds
446
+ # even when every intent's `intent` field is a full paragraph.
447
+ SUMMARY_CHAR_BUDGET = 320
448
+ SUMMARY_TITLE_MAX_CHARS = 48
449
+
381
450
  def matrix(records, scope_tag: false)
382
451
  cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
383
452
  research = []
@@ -544,11 +613,14 @@ def invert_ts(ts)
544
613
  ts.to_s.ljust(20).chars.map { |c| 255 - c.ord }
545
614
  end
546
615
 
547
- def within_24h?(rec)
548
- ts = rec[:last_accessed_at]
549
- return false if ts.nil? || ts.empty?
550
- d = (Date.parse(ts[0, 10]) rescue nil)
551
- d && d >= (today - 1)
616
+ # D2 "finish first": lifecycle stage descending (Exec, How, Why, What). "done" is included
617
+ # only defensively; it never appears in Active (status must be "active" to reach this sort).
618
+ # A later savepoint timestamp breaks a tie within the same stage. This is a NEW sort key,
619
+ # separate from rank_key: D3 leaves ranking untouched, so rank_key itself is not edited.
620
+ LIFECYCLE_FINISH_RANK = { "exec" => 0, "how" => 1, "why" => 2, "what" => 3, "done" => 4 }.freeze
621
+
622
+ def finish_first_key(rec)
623
+ [LIFECYCLE_FINISH_RANK.fetch(rec[:lifecycle], 9), invert_ts(rec[:last_accessed_at])]
552
624
  end
553
625
 
554
626
  # Collapse whitespace to single spaces, strip, then escape Markdown table pipes so
@@ -564,61 +636,157 @@ def truncate_intent(text)
564
636
  t.length > INTENT_LINE_MAX_CHARS ? "#{t[0, INTENT_LINE_MAX_CHARS]}…" : t
565
637
  end
566
638
 
567
- def worked_row(rec, project_scope)
568
- glyph = STATUS_GLYPH[rec[:status]]
569
- proj = rec[:scope] == "global" ? "global" : rec[:scope].sub("project:", "")
570
- status_word = rec[:status] == "completed" ? "done" : rec[:status]
571
- prefix = project_scope ? "" : "#{proj} | "
572
- {
573
- id: rec[:id], status: rec[:status], glyph: glyph,
574
- last_accessed_at: rec[:last_accessed_at],
575
- what: cell(truncate_intent(rec[:intent])), state: status_word, scope: cell(proj),
576
- line: "#{glyph} #{prefix}#{status_word}: #{rec[:id]} #{rec[:intent]}".strip,
577
- }
639
+ # Truncate `text` to at most `max_chars`, cutting at the last whitespace at or before the
640
+ # limit (never mid-word) and appending a single ellipsis character when truncation happens.
641
+ # recent_delivery_summary (D1 fix) uses this twice: once per intent label, so a
642
+ # paragraph-long `intent` field collapses to a short name, and once on the fully assembled
643
+ # summary string, the hard budget cap that holds no matter how the per-label math adds up.
644
+ def truncate_on_word_boundary(text, max_chars)
645
+ t = text.to_s
646
+ return t if t.length <= max_chars
647
+ ellipsis = "…"
648
+ limit = [max_chars - ellipsis.length, 0].max
649
+ slice = t[0, limit]
650
+ cut = slice.rindex(/\s/)
651
+ slice = slice[0, cut] if cut && cut.positive?
652
+ "#{slice.rstrip}#{ellipsis}"
653
+ end
654
+
655
+ # D3 fix (intent 202 gate review): completion dates only carry day granularity, and many
656
+ # intents can share a single day, so completed_on alone left Array#sort_by to break the tie
657
+ # however it fell out (not documented as stable, so nothing here relied on input order being
658
+ # preserved). This key makes the tie-break real, in priority order:
659
+ # 1. completed_on: the date already recorded in the store's "## Completed" section.
660
+ # 2. done_at (see done_timestamp above): the savepoint's own "Done" timestamp, when the
661
+ # intent has one. This is the true completion moment, so it breaks a same-day tie
662
+ # honestly instead of arbitrarily. Missing it (nil, mapped to "") sorts behind any
663
+ # intent that does have one, since we never invent a plausible-looking time for it.
664
+ # 3. scope + id: always present and always unique (no two records share both), so two
665
+ # intents can never compare equal here. This is the deterministic fallback of last
666
+ # resort for intents with no Done timestamp on either side, chosen for exactly one
667
+ # reason: it removes every remaining "whatever sort_by happened to do" case.
668
+ # Ascending; callers reverse the sorted list to read most-recent-first.
669
+ def delivery_recency_key(rec)
670
+ [rec[:completed_on].to_s, rec[:done_at] || "", rec[:scope].to_s, rec[:id].to_s]
578
671
  end
579
672
 
580
- def recently_worked(records, project_scope: nil)
581
- pool = records.select do |r|
582
- %w[active completed].include?(r[:status]) && within_24h?(r) &&
583
- (project_scope.nil? || r[:scope] == project_scope)
673
+ # Prose "what was delivered most recently" (D1), built once here so both the Markdown
674
+ # board and --plain get byte-identical wording for the same store state. Sourced from
675
+ # completed intents with a non-empty completed_on, most-recent-first - the SAME basis
676
+ # render_continue already uses for its "last touched" line, generalized to take an optional
677
+ # project scope. Deliberately NOT recently_worked's 24h window: a project not touched today
678
+ # would otherwise wrongly report nothing delivered even when its last delivery was real and
679
+ # recent.
680
+ def recent_delivery_summary(records, project_scope: nil, limit: 3)
681
+ in_scope = records.select { |r| r[:status] == "completed" && !r[:completed_on].to_s.empty? &&
682
+ (project_scope.nil? || r[:scope] == project_scope) }
683
+ done_total = records.count { |r| r[:status] == "completed" &&
684
+ (project_scope.nil? || r[:scope] == project_scope) }
685
+ recent = in_scope.sort_by { |r| delivery_recency_key(r) }.reverse.first(limit)
686
+ scope_phrase = project_scope.nil? ? " across all projects" : ""
687
+ return "Nothing has been delivered#{scope_phrase} yet." if recent.empty?
688
+
689
+ # Short per-intent label (D1): never the full `intent` paragraph, always id + a
690
+ # word-boundary-truncated title, so one huge intent text cannot dominate the summary.
691
+ titles = recent.map { |r| "#{r[:id]} #{truncate_on_word_boundary(r[:intent], SUMMARY_TITLE_MAX_CHARS)}".strip }
692
+ headline =
693
+ if titles.size == 1
694
+ "Most recently delivered: #{titles.first} (#{recent.first[:completed_on]})."
695
+ else
696
+ "Most recently delivered: #{titles[0...-1].join(', ')} and #{titles.last} " \
697
+ "(most recent #{recent.first[:completed_on]})."
698
+ end
699
+ summary = "#{headline} #{done_total} intent#{'s' unless done_total == 1} completed#{scope_phrase} in total."
700
+ # Hard cap (D1): guarantees the invariant regardless of how the per-title math above adds
701
+ # up, e.g. very long ids or an unusually large done_total driving the closing sentence
702
+ # over budget on its own.
703
+ truncate_on_word_boundary(summary, SUMMARY_CHAR_BUDGET)
704
+ end
705
+
706
+ # Honest-totals footer (D5): built once here so the Markdown board and --plain state the
707
+ # same true counts in the same words. active_shown/active_total are omitted on the global
708
+ # board (it has no Active list of its own).
709
+ def footer_line(next_shown:, next_total:, active_shown: nil, active_total: nil)
710
+ if active_shown
711
+ "Showing #{active_shown} of #{active_total} active, #{next_shown} of #{next_total} " \
712
+ "next work. Ask for \"more\" or \"all\" to see everything, or run " \
713
+ "`dashboard.rb project <slug> --plain` for the full plain-text board."
714
+ else
715
+ "Showing #{next_shown} of #{next_total} next work across all projects. Ask for " \
716
+ "\"more\" or \"all\" to see everything, or run `dashboard.rb continue --plain` for " \
717
+ "the full plain-text board."
584
718
  end
585
- ordered = pool.sort_by { |r| [r[:status] == "active" ? 0 : 1, invert_ts(r[:last_accessed_at])] }
586
- ordered = ordered.first(5) if ordered.size > 15
587
- ordered.map { |r| worked_row(r, project_scope) }
588
719
  end
589
720
 
590
- def intent_line(rec, bullet)
721
+ ANSI_ESCAPE_RE = /\e\[[0-?]*[ -\/]*[@-~]/
722
+
723
+ def safe_active_value(value, max_chars: INTENT_LINE_MAX_CHARS)
724
+ text = value.to_s.gsub(ANSI_ESCAPE_RE, "").gsub(/[[:cntrl:]]+/, " ")
725
+ # Idempotent because the composed worker/activity values pass through this
726
+ # helper once per component and once after composition.
727
+ normalized = text.gsub(/\s+/, " ").strip.gsub(/(?<!\\)\|/) { "\\|" }
728
+ normalized.length > max_chars ? "#{normalized[0, max_chars]}…" : normalized
729
+ end
730
+
731
+ def display_provenance(value)
732
+ # Worker combines agent and harness; bound each half so one hostile or merely
733
+ # verbose value cannot crowd the other identity out of the shared line budget.
734
+ text = safe_active_value(value, max_chars: INTENT_LINE_MAX_CHARS / 2 - 4)
735
+ return "Unknown" if text.empty? || text.casecmp("unknown").zero?
736
+ %w[claude codex].include?(text.downcase) ? text.capitalize : text
737
+ end
738
+
739
+ def active_lock_fields(rec, now:)
740
+ view = Lock.who(rec.fetch(:intent_dir), now: now)
741
+ owner = view["owner"] || {}
742
+ worker = "#{display_provenance(owner['agent'])} · #{display_provenance(owner['harness'])}"
743
+ activity = case view["state"]
744
+ when "fresh" then "Fresh"
745
+ when "stale" then "Stale"
746
+ when "corrupt" then "Corrupt"
747
+ else "No lock"
748
+ end
749
+ if view["state"] == "fresh"
750
+ claim = Array(view["claims"]).find { |item| item["fresh"] && !item["corrupt"] }
751
+ writer = claim && safe_active_value(claim["delegate"] || claim["owner_session"],
752
+ max_chars: INTENT_LINE_MAX_CHARS - 20)
753
+ activity = "#{activity} · writer #{writer}" unless writer.to_s.empty?
754
+ end
755
+ [worker, activity]
756
+ end
757
+
758
+ def intent_line(rec, bullet, now: Time.now)
591
759
  note = rec[:status] == "active" ? " (#{rec[:lifecycle].to_s.capitalize})" : ""
592
760
  text = rec[:intent].to_s
593
761
  text = "#{text[0, INTENT_LINE_MAX_CHARS]}…" if text.length > INTENT_LINE_MAX_CHARS
594
- { id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
762
+ row = { id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
595
763
  scope: rec[:scope], what: cell(text), stage: rec[:lifecycle].to_s.capitalize,
596
764
  line: "#{bullet} #{rec[:id]} #{text}#{note}".rstrip }
765
+ if rec[:status] == "active"
766
+ worker, activity = active_lock_fields(rec, now: now)
767
+ row[:worker] = safe_active_value(worker)
768
+ row[:activity] = safe_active_value(activity)
769
+ row[:line] = "#{row[:line]} · #{row[:worker]} · #{row[:activity]}"
770
+ end
771
+ row
597
772
  end
598
773
 
599
- # Cap a raw record list to NEXT_WORK_CAP entries, then map to intent_line-shaped
600
- # hashes, appending a plain "+N more" line (no id, not a real record) when truncated.
601
- # Caps the record list first so the "+N more" entry never goes through intent_line.
602
- def cap_lines(list, bullet)
603
- capped = list.first(NEXT_WORK_CAP)
604
- lines = capped.map { |r| intent_line(r, bullet) }
605
- if list.size > NEXT_WORK_CAP
606
- lines << { id: "", intent: "", created: "", bullet: bullet, scope: "",
607
- what: "+#{list.size - NEXT_WORK_CAP} more", stage: "",
608
- line: "#{bullet} +#{list.size - NEXT_WORK_CAP} more" }
609
- end
610
- lines
774
+ # Cap an already-sorted record list to `cap` entries and map to intent_line-shaped hashes.
775
+ # No trailing "+N more" marker (D5, intent 202): the payload's own *_total/*_shown fields
776
+ # (see render_data_project) carry the honest count, and the footer states it in prose, so a
777
+ # fake blank row is no longer needed here.
778
+ def capped_rows(list, bullet, cap, now: Time.now)
779
+ list.first(cap).map { |r| intent_line(r, bullet, now: now) }
611
780
  end
612
781
 
613
- # Flat, rank-ordered "most-valuable next work" list (intent 149). Replaces the
614
- # quadrant-keyed matrix_data with a single list ranked by the same rank_key every
615
- # other ordering uses, capped at NEXT_WORK_CAP with a trailing "+N more" marker
616
- # when the pool overflows. No grid glyph on the line; the prose surface supplies
617
- # its own bullet.
618
- def next_work(records)
782
+ # Flat, rank-ordered "most-valuable next work" list (intent 149), capped at `cap` (default
783
+ # NEXT_WORK_CAP; --data mode overrides via --limit-next or lifts it via --all, see main). No
784
+ # trailing "+N more" marker (D5, intent 202): render_data_project/render_data_global report
785
+ # the true pool size alongside this capped list, and the footer states it in prose. No grid
786
+ # glyph on the line; the prose surface supplies its own bullet.
787
+ def next_work(records, cap: NEXT_WORK_CAP)
619
788
  ranked = records.sort_by { |r| rank_key(r) }
620
- capped = ranked.first(NEXT_WORK_CAP)
621
- lines = capped.map do |r|
789
+ ranked.first(cap).map do |r|
622
790
  text = r[:intent].to_s
623
791
  text = "#{text[0, INTENT_LINE_MAX_CHARS]}…" if text.length > INTENT_LINE_MAX_CHARS
624
792
  { id: r[:id], intent: r[:intent], scope: r[:scope], lifecycle: r[:lifecycle],
@@ -626,12 +794,6 @@ def next_work(records)
626
794
  what: cell(text), flags_label: cell(Array(r[:flags]).join(", ")),
627
795
  line: "#{r[:id]} #{text}" }
628
796
  end
629
- if ranked.size > NEXT_WORK_CAP
630
- lines << { id: "", intent: "", scope: "", lifecycle: "", value: "", disposition: "",
631
- flags: [], what: "+#{ranked.size - NEXT_WORK_CAP} more", flags_label: "",
632
- line: "+#{ranked.size - NEXT_WORK_CAP} more" }
633
- end
634
- lines
635
797
  end
636
798
 
637
799
  def short_description(scope)
@@ -670,36 +832,91 @@ def counts_of(records)
670
832
  future: records.count { |r| r[:status] == "future" } }
671
833
  end
672
834
 
673
- def render_data_global(records)
835
+ def render_data_global(records, next_limit: NEXT_WORK_CAP, all: false)
674
836
  global = records.select { |r| r[:scope] == "global" }
675
837
  matrix_pool = global.select { |r| actionable?(r) && r[:status] != "active" }
838
+ next_cap = all ? matrix_pool.size : next_limit
839
+ next_shown = [next_cap, matrix_pool.size].min
676
840
  projs = project_summaries(records)
677
841
  { mode: "global", date: today.to_s,
678
842
  store_health: store_health(:global),
679
- recently_worked: recently_worked(records),
680
- next_work: next_work(matrix_pool),
843
+ summary: recent_delivery_summary(records, project_scope: nil),
844
+ next_work: next_work(matrix_pool, cap: next_cap),
845
+ next_total: matrix_pool.size,
846
+ next_shown: next_shown,
681
847
  counts: counts_of(global),
682
848
  projects: projs,
683
849
  project_totals: {
684
850
  active: projs.sum { |p| p[:active] }, done: projs.sum { |p| p[:done] },
685
851
  future: projs.sum { |p| p[:future] }
686
- } }
852
+ },
853
+ footer: footer_line(next_shown: next_shown, next_total: matrix_pool.size) }
687
854
  end
688
855
 
689
- def render_data_project(records, slug)
856
+ def render_data_project(records, slug, active_limit: ACTIVE_CAP, next_limit: NEXT_WORK_CAP,
857
+ all: false, now: Time.now)
690
858
  scope = "project:#{slug}"
691
859
  scoped = records.select { |r| r[:scope] == scope }
692
- matrix_pool = scoped.select { |r| r[:status] == "future" }
860
+ active_pool = scoped.select { |r| r[:status] == "active" }.sort_by { |r| finish_first_key(r) }
861
+ next_pool = scoped.select { |r| r[:status] == "future" }
862
+
863
+ active_cap = all ? active_pool.size : active_limit
864
+ next_cap = all ? next_pool.size : next_limit
865
+ active_shown = [active_cap, active_pool.size].min
866
+ next_shown = [next_cap, next_pool.size].min
867
+
693
868
  { mode: "project", date: today.to_s, slug: slug,
694
869
  store_health: store_health(slug),
695
870
  description: short_description(scope),
696
- recently_worked: recently_worked(records, project_scope: scope),
697
- next_work: next_work(matrix_pool),
871
+ summary: recent_delivery_summary(records, project_scope: scope),
698
872
  counts: counts_of(scoped),
699
- active: cap_lines(scoped.select { |r| r[:status] == "active" }
700
- .sort_by { |r| invert_ts(r[:last_accessed_at]) }, STATUS_GLYPH["active"]),
701
- future: cap_lines(scoped.select { |r| r[:status] == "future" }
702
- .sort_by { |r| invert_ts(r[:created]) }, STATUS_GLYPH["future"]) }
873
+ active: capped_rows(active_pool, STATUS_GLYPH["active"], active_cap, now: now),
874
+ active_total: active_pool.size,
875
+ active_shown: active_shown,
876
+ next_work: next_work(next_pool, cap: next_cap),
877
+ next_total: next_pool.size,
878
+ next_shown: next_shown,
879
+ footer: footer_line(active_shown: active_shown, active_total: active_pool.size,
880
+ next_shown: next_shown, next_total: next_pool.size) }
881
+ end
882
+
883
+ # --plain (D4): the full, uncapped board as plain text, no Markdown table syntax, meant to
884
+ # pipe cleanly into a real pager (`less`). Reuses the exact same payload builders --data
885
+ # uses, with all: true, and prints the `summary`/`.line`/`footer` fields those builders
886
+ # already compute -- no separate selection or sorting logic lives here.
887
+ def render_plain_project(records, slug)
888
+ payload = render_data_project(records, slug, all: true)
889
+ lines = []
890
+ lines << "#{slug} - Project Board, #{payload[:date]}"
891
+ lines << payload[:description] unless payload[:description].to_s.empty?
892
+ lines << ""
893
+ lines << payload[:summary]
894
+ lines << ""
895
+ lines << "Active (#{payload[:active_total]})"
896
+ lines.concat(payload[:active].empty? ? ["(none)"] : payload[:active].map { |r| r[:line] })
897
+ lines << ""
898
+ lines << "Most-valuable next work (#{payload[:next_total]})"
899
+ lines.concat(payload[:next_work].empty? ? ["(none)"] : payload[:next_work].map { |r| r[:line] })
900
+ lines << ""
901
+ lines << payload[:footer]
902
+ lines.join("\n") + "\n"
903
+ end
904
+
905
+ def render_plain_global(records)
906
+ payload = render_data_global(records, all: true)
907
+ counts = payload[:counts]
908
+ lines = []
909
+ lines << "Plastic - Global Board, #{payload[:date]}"
910
+ lines << ""
911
+ lines << payload[:summary]
912
+ lines << ""
913
+ lines << "#{counts[:active]} intents active, #{counts[:done]} done, #{counts[:future]} queued for later."
914
+ lines << ""
915
+ lines << "Most-valuable next work (#{payload[:next_total]})"
916
+ lines.concat(payload[:next_work].empty? ? ["(none)"] : payload[:next_work].map { |r| r[:line] })
917
+ lines << ""
918
+ lines << payload[:footer]
919
+ lines.join("\n") + "\n"
703
920
  end
704
921
 
705
922
  # ---------------------------------------------------------------------------
@@ -737,9 +954,23 @@ def canonical_pretty_json(payload)
737
954
  JSON.pretty_generate(payload).gsub(/\[\s*\n\s*\]/, "[]").gsub(/\{\s*\n\s*\}/, "{}")
738
955
  end
739
956
 
957
+ # Pull a "--flag value" pair out of argv, mutating it in place; returns the value string,
958
+ # or nil when the flag is absent. Unlike --json/--data/--all (bare toggles),
959
+ # --limit-active/--limit-next carry a value.
960
+ def extract_flag_value(argv, flag)
961
+ idx = argv.index(flag)
962
+ return nil unless idx
963
+ argv.delete_at(idx)
964
+ argv.delete_at(idx)
965
+ end
966
+
740
967
  def main(argv)
741
968
  json = argv.delete("--json")
742
969
  data = argv.delete("--data")
970
+ plain = argv.delete("--plain")
971
+ all = argv.delete("--all")
972
+ limit_active = extract_flag_value(argv, "--limit-active")
973
+ limit_next = extract_flag_value(argv, "--limit-next")
743
974
  mode = argv.shift || "continue"
744
975
  slug = argv.shift
745
976
 
@@ -747,11 +978,32 @@ def main(argv)
747
978
  records = raw.map { |r| classify(r, done_ids, referenced, completed_on_map) }
748
979
 
749
980
  if data
750
- payload = mode == "project" ? render_data_project(records, slug) : render_data_global(records)
981
+ payload =
982
+ if mode == "project"
983
+ render_data_project(records, slug,
984
+ active_limit: (limit_active || ACTIVE_CAP).to_i,
985
+ next_limit: (limit_next || NEXT_WORK_CAP).to_i,
986
+ all: !!all)
987
+ else
988
+ render_data_global(records, next_limit: (limit_next || NEXT_WORK_CAP).to_i, all: !!all)
989
+ end
751
990
  puts canonical_pretty_json(payload)
752
991
  return 0
753
992
  end
754
993
 
994
+ if plain
995
+ if mode == "project"
996
+ if slug.nil? || slug.empty?
997
+ warn "usage: dashboard.rb project <slug> --plain"
998
+ return 2
999
+ end
1000
+ print render_plain_project(records, slug)
1001
+ else
1002
+ print render_plain_global(records)
1003
+ end
1004
+ return 0
1005
+ end
1006
+
755
1007
  if json
756
1008
  subset = mode == "project" ? records.select { |r| r[:scope] == "project:#{slug}" } : records
757
1009
  label = mode == "project" ? "project:#{slug}" : "all"