@zalom/plastic 1.3.0 → 1.4.1
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/PLASTIC-reference.md +8 -6
- package/PLASTIC.md +52 -14
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +393 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +105 -27
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +34 -3
- package/scripts/lib/installer_core.rb +70 -13
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/lock.rb +186 -11
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/plastic-lock +76 -9
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +45 -16
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
package/scripts/dashboard.rb
CHANGED
|
@@ -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
|
|
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(/^- \[
|
|
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
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
|
600
|
-
#
|
|
601
|
-
#
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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)
|
|
614
|
-
#
|
|
615
|
-
#
|
|
616
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
697
|
-
next_work: next_work(matrix_pool),
|
|
871
|
+
summary: recent_delivery_summary(records, project_scope: scope),
|
|
698
872
|
counts: counts_of(scoped),
|
|
699
|
-
active:
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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 =
|
|
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"
|