@zalom/plastic 1.0.0-beta.32 → 1.0.0-beta.34
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.md +9 -6
- package/hooks/statusline +48 -6
- package/package.json +1 -1
- package/scripts/dashboard.rb +86 -21
- package/scripts/hook-continue +17 -0
- package/scripts/lib/bridge.rb +123 -32
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/installer_core.rb +1 -0
- package/scripts/plastic-lock +2 -2
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +4 -2
- package/skills/continuing/SKILL.md +5 -1
- package/skills/dashboard/SKILL.md +25 -8
- package/skills/releasing/SKILL.md +4 -1
- package/skills/roadmap/SKILL.md +8 -5
- package/skills/roadmap/references/file-format.md +14 -11
- package/skills/roadmap/references/operations.md +12 -8
- package/templates/roadmap.md +5 -4
package/PLASTIC.md
CHANGED
|
@@ -329,10 +329,12 @@ is a named, ordered, delivery-side collection of intents: the delivery-side coun
|
|
|
329
329
|
release (completion-side, tracked in `CHANGELOG.md`). Use `plastic-roadmap` to create, order,
|
|
330
330
|
close, and consume one.
|
|
331
331
|
|
|
332
|
-
File location: `roadmaps/{slug}.md`, a
|
|
333
|
-
store (
|
|
334
|
-
|
|
335
|
-
|
|
332
|
+
File location: `roadmaps/{slug}.md`, a sibling of `INDEX.md`, wherever `INDEX.md` lives, never
|
|
333
|
+
inside `store/` (store holds intent directories, not project artifacts). For a project that is its
|
|
334
|
+
root, `~/.plastic/projects/{slug}/roadmaps/`, beside `project.yml`; for the global tier it is
|
|
335
|
+
`~/.plastic/roadmaps/`, beside `~/.plastic/INDEX.md`. `roadmaps/` lists only live (open or
|
|
336
|
+
in-flight) roadmaps: once a roadmap's goal is reached, it moves to `roadmaps/archived/{slug}.md`,
|
|
337
|
+
a sibling subdirectory scaffolded once with a `.gitkeep`.
|
|
336
338
|
|
|
337
339
|
A roadmap file has four sections, in order: a title/meta header, `## Goal`, `## Waves`, and an
|
|
338
340
|
append-only dated `## Log`. `## Goal` is a checkable prose condition read by a human or agent, not
|
|
@@ -345,8 +347,9 @@ on any conflict INDEX wins and the roadmap entry is corrected to match.
|
|
|
345
347
|
|
|
346
348
|
**Human-comprehension surface.** A roadmap is also written to be read cold. Wave entries render as
|
|
347
349
|
checkboxes (checked once delivered, unchecked otherwise) next to the status token, and each `## Log`
|
|
348
|
-
line is one plain-language sentence,
|
|
349
|
-
non-expert executive: what shipped and why it matters, no jargon
|
|
350
|
+
line is one plain-language sentence, starting `YYYY-MM-DD HH:MM UTC`, written the way an
|
|
351
|
+
engineering manager would brief a non-expert executive: what shipped and why it matters, no jargon
|
|
352
|
+
or codenames, ending with a link
|
|
350
353
|
to that intent's `outcome.md`. The log points at the detail instead of repeating it, so a person
|
|
351
354
|
opening the file with no other context can tell what shipped, what is running now, and what is
|
|
352
355
|
next in under a minute.
|
package/hooks/statusline
CHANGED
|
@@ -85,15 +85,57 @@ fi
|
|
|
85
85
|
INDEX_DIR=$(dirname "$INDEX")
|
|
86
86
|
|
|
87
87
|
# --- Current-session work-unit: the live bridge wins over savepoint recency ---
|
|
88
|
-
# The statusline receives the real session_id on stdin
|
|
89
|
-
# (plastic-{session_id}.json)
|
|
90
|
-
#
|
|
91
|
-
#
|
|
88
|
+
# The statusline receives the real session_id on stdin. A session can now own
|
|
89
|
+
# SEVERAL per-intent bridges at once (intent 131, plastic-{session_id}--{id}.json),
|
|
90
|
+
# one per concurrent delivery, so the statusline must pick which of them to show:
|
|
91
|
+
# prefer a candidate whose worktree.code (or intent dir) prefixes REAL_CWD, else
|
|
92
|
+
# the newest by mtime (ls -t is 3.2-safe); tolerate a legacy single-key
|
|
93
|
+
# plastic-{session_id}.json when no per-intent file exists. This beats the shared,
|
|
94
|
+
# savepoint-recency heuristic so parallel sessions on one store no longer
|
|
95
|
+
# overwrite each other's line. grep/sed/ls only - no jq, no ruby.
|
|
92
96
|
WORK=""
|
|
93
97
|
SID=$(json_str "session_id")
|
|
94
98
|
if [ -n "$SID" ]; then
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
BR_TMP="${PLASTIC_TMP:-/tmp}"
|
|
100
|
+
BRIDGE=""
|
|
101
|
+
CANDIDATES=$(ls -1t "${BR_TMP}"/plastic-"${SID}"--*.json 2>/dev/null)
|
|
102
|
+
if [ -n "$CANDIDATES" ]; then
|
|
103
|
+
while IFS= read -r cand; do
|
|
104
|
+
[ -z "$cand" ] && continue
|
|
105
|
+
WT_BLOCK=$(sed -n '/"worktree"[[:space:]]*:[[:space:]]*{/,/}/p' "$cand" 2>/dev/null)
|
|
106
|
+
WC=$(printf '%s\n' "$WT_BLOCK" | grep -o '"code"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
|
|
107
|
+
| sed 's/.*"code"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
|
108
|
+
IN_BLOCK=$(sed -n '/"intent"[[:space:]]*:[[:space:]]*{/,/}/p' "$cand" 2>/dev/null)
|
|
109
|
+
ISTORE=$(printf '%s\n' "$IN_BLOCK" | grep -o '"store"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
|
|
110
|
+
| sed 's/.*"store"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
|
111
|
+
IDIR=$(printf '%s\n' "$IN_BLOCK" | grep -o '"dir"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
|
|
112
|
+
| sed 's/.*"dir"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
|
113
|
+
MATCH=""
|
|
114
|
+
if [ -n "$WC" ] && [ -n "$REAL_CWD" ]; then
|
|
115
|
+
case "$REAL_CWD" in "$WC"|"$WC"/*) MATCH=1 ;; esac
|
|
116
|
+
fi
|
|
117
|
+
if [ -z "$MATCH" ] && [ -n "$ISTORE" ] && [ -n "$IDIR" ] && [ -n "$REAL_CWD" ]; then
|
|
118
|
+
IPATH="$ISTORE/$IDIR"
|
|
119
|
+
case "$REAL_CWD" in "$IPATH"|"$IPATH"/*) MATCH=1 ;; esac
|
|
120
|
+
fi
|
|
121
|
+
if [ -n "$MATCH" ]; then
|
|
122
|
+
BRIDGE="$cand"
|
|
123
|
+
break
|
|
124
|
+
fi
|
|
125
|
+
done <<BRIDGE_CAND_EOF
|
|
126
|
+
$CANDIDATES
|
|
127
|
+
BRIDGE_CAND_EOF
|
|
128
|
+
# No cwd/worktree signal on any candidate: fail open to the newest (ls -t
|
|
129
|
+
# already sorted CANDIDATES newest-first).
|
|
130
|
+
[ -z "$BRIDGE" ] && BRIDGE=$(printf '%s\n' "$CANDIDATES" | head -1)
|
|
131
|
+
else
|
|
132
|
+
# Legacy tolerance: no per-intent bridge for this session, but an old
|
|
133
|
+
# single-key file may still exist.
|
|
134
|
+
LEGACY="${BR_TMP}/plastic-${SID}.json"
|
|
135
|
+
[ -f "$LEGACY" ] && BRIDGE="$LEGACY"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
if [ -n "$BRIDGE" ] && [ -f "$BRIDGE" ]; then
|
|
97
139
|
# Bridge JSON is pretty-printed (one field per line). Scope extraction to the
|
|
98
140
|
# "intent" object so a sibling top-level "id"/"name" can never win (the intent
|
|
99
141
|
# object holds only string fields, so the first "}" closes it). grep/sed only.
|
package/package.json
CHANGED
package/scripts/dashboard.rb
CHANGED
|
@@ -32,7 +32,11 @@ def today
|
|
|
32
32
|
s && !s.empty? ? Date.parse(s) : Date.today
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# A generic "about a month" threshold, not tuned to any one store's item count.
|
|
36
|
+
# Deliberately independent from plastic-continuing's separate stale_threshold_days
|
|
37
|
+
# config: that one is a proactive boot-time triage nudge, this is a board annotation.
|
|
38
|
+
# Unifying the two is a follow-up, not this intent.
|
|
39
|
+
STALE_DAYS = 30
|
|
36
40
|
|
|
37
41
|
# ---------------------------------------------------------------------------
|
|
38
42
|
# Parsing — reuses the conventions in scripts/doctor.rb
|
|
@@ -105,6 +109,26 @@ def last_accessed_at(dir, created)
|
|
|
105
109
|
""
|
|
106
110
|
end
|
|
107
111
|
|
|
112
|
+
# True iff the savepoint ledger's last non-blank line shows real post-birth
|
|
113
|
+
# activity, not just the one-line birth stamp every intent gets at creation
|
|
114
|
+
# (scripts/new-intent's Bridge.append_savepoint call, stage "What"). Reads the
|
|
115
|
+
# last line, extracts the stage token (second whitespace-separated field, same
|
|
116
|
+
# ledger shape last_accessed_at already parses), and treats any stage other
|
|
117
|
+
# than "What" as progress. Returns false when the file is missing/empty.
|
|
118
|
+
def savepoint_shows_progress?(path)
|
|
119
|
+
return false unless File.exist?(path)
|
|
120
|
+
last_line = nil
|
|
121
|
+
File.readlines(path).reverse_each do |line|
|
|
122
|
+
stripped = line.strip
|
|
123
|
+
next if stripped.empty?
|
|
124
|
+
last_line = stripped
|
|
125
|
+
break
|
|
126
|
+
end
|
|
127
|
+
return false unless last_line
|
|
128
|
+
stage = last_line.split(/\s+/)[1]
|
|
129
|
+
!stage.nil? && stage != "What"
|
|
130
|
+
end
|
|
131
|
+
|
|
108
132
|
# Parse one intent directory into a raw record.
|
|
109
133
|
def parse_intent(store_info, dir_name, status_index)
|
|
110
134
|
dir = File.join(store_info[:store], dir_name)
|
|
@@ -113,7 +137,6 @@ def parse_intent(store_info, dir_name, status_index)
|
|
|
113
137
|
return nil unless fm && fm["id"]
|
|
114
138
|
|
|
115
139
|
id = fm["id"].to_s
|
|
116
|
-
has = ->(f) { File.exist?(File.join(dir, f)) }
|
|
117
140
|
# Sentinel-aware presence for lifecycle files (intent 60b): a scaffolded
|
|
118
141
|
# placeholder spec/plan/checklist/outcome reads as absent, so a freshly
|
|
119
142
|
# scaffolded intent reports What/Why and is never marked completed/advanced.
|
|
@@ -143,7 +166,7 @@ def parse_intent(store_info, dir_name, status_index)
|
|
|
143
166
|
plan: real.("plan.md"),
|
|
144
167
|
checklist: real.("checklist.md"),
|
|
145
168
|
outcome: real.("outcome.md"),
|
|
146
|
-
savepoint:
|
|
169
|
+
savepoint: savepoint_shows_progress?(File.join(dir, "savepoint.md")),
|
|
147
170
|
checklist_partial: real.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
|
|
148
171
|
body_has_context: body.include?("## Context"),
|
|
149
172
|
last_accessed_at: last_accessed_at(dir, (fm["created"].to_s rescue "")),
|
|
@@ -163,6 +186,7 @@ end
|
|
|
163
186
|
def load_all
|
|
164
187
|
all = []
|
|
165
188
|
done_ids = {}
|
|
189
|
+
completed_on_map = {}
|
|
166
190
|
stores.each do |si|
|
|
167
191
|
idx = {
|
|
168
192
|
active: index_section_ids(si[:index], "## Active"),
|
|
@@ -176,11 +200,12 @@ def load_all
|
|
|
176
200
|
rec[:completed_on] = comp[rec[:id]] || ""
|
|
177
201
|
all << rec
|
|
178
202
|
done_ids[[rec[:scope], rec[:id]]] = true if rec[:status] == "completed"
|
|
203
|
+
completed_on_map[[rec[:scope], rec[:id]]] = comp[rec[:id]] if comp[rec[:id]] && !comp[rec[:id]].empty?
|
|
179
204
|
end
|
|
180
205
|
end
|
|
181
206
|
referenced = {}
|
|
182
207
|
all.each { |r| r[:sources].each { |s| referenced[[r[:scope], s]] = true } }
|
|
183
|
-
[all, done_ids, referenced]
|
|
208
|
+
[all, done_ids, referenced, completed_on_map]
|
|
184
209
|
end
|
|
185
210
|
|
|
186
211
|
# ---------------------------------------------------------------------------
|
|
@@ -214,11 +239,14 @@ end
|
|
|
214
239
|
|
|
215
240
|
# Effort -> :small | :big
|
|
216
241
|
# Small when the work is bounded: a non-implementation type, an already-scoped intent
|
|
217
|
-
# (plan/checklist exists), or a
|
|
242
|
+
# (plan/checklist exists), or a branch id. By Plastic's own Zettelkasten convention, a
|
|
243
|
+
# branch id is a narrower refinement of its parent, so any branch (not only deep
|
|
244
|
+
# sub-branches) defaults to smaller effort than an untouched root idea; root ids
|
|
245
|
+
# (`root_intent?`) are unaffected since `folgezettel_depth` on a bare number is always 1.
|
|
218
246
|
def effort_of(rec, type)
|
|
219
247
|
return :small if %w[research exploration bugfix].include?(type)
|
|
220
248
|
return :small if rec[:plan] || rec[:checklist]
|
|
221
|
-
return :small if folgezettel_depth(rec[:id]) >=
|
|
249
|
+
return :small if folgezettel_depth(rec[:id]) >= 2
|
|
222
250
|
:big
|
|
223
251
|
end
|
|
224
252
|
|
|
@@ -238,11 +266,12 @@ def value_of(rec, referenced = {})
|
|
|
238
266
|
:low
|
|
239
267
|
end
|
|
240
268
|
|
|
241
|
-
def flags_of(rec, done_ids)
|
|
269
|
+
def flags_of(rec, done_ids, completed_on_map = {})
|
|
242
270
|
flags = []
|
|
243
271
|
flags << "in-progress" if rec[:savepoint] || rec[:checklist_partial]
|
|
244
272
|
if rec[:status] == "future" && !rec[:sources].empty? &&
|
|
245
|
-
rec[:sources].all? { |s| done_ids[[rec[:scope], s]] }
|
|
273
|
+
rec[:sources].all? { |s| done_ids[[rec[:scope], s]] } &&
|
|
274
|
+
genuine_wait?(rec, completed_on_map)
|
|
246
275
|
flags << "unblocked"
|
|
247
276
|
end
|
|
248
277
|
age = stale_age(rec)
|
|
@@ -250,6 +279,22 @@ def flags_of(rec, done_ids)
|
|
|
250
279
|
flags
|
|
251
280
|
end
|
|
252
281
|
|
|
282
|
+
# True iff at least one declared source's completion date is strictly later
|
|
283
|
+
# than this intent's own `created` date — i.e. the intent actually waited on
|
|
284
|
+
# something, rather than being born already-satisfied (the common case: a
|
|
285
|
+
# branch's declared source is almost always finished before the branch exists,
|
|
286
|
+
# since the child is created from the parent's own lifecycle).
|
|
287
|
+
def genuine_wait?(rec, completed_on_map)
|
|
288
|
+
created_date = (Date.parse(rec[:created]) rescue nil)
|
|
289
|
+
return false unless created_date
|
|
290
|
+
rec[:sources].any? do |s|
|
|
291
|
+
source_date_str = completed_on_map[[rec[:scope], s]]
|
|
292
|
+
next false unless source_date_str
|
|
293
|
+
source_date = (Date.parse(source_date_str) rescue nil)
|
|
294
|
+
source_date && source_date > created_date
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
253
298
|
def stale_age(rec)
|
|
254
299
|
return nil if rec[:created].nil? || rec[:created].empty?
|
|
255
300
|
(today - Date.parse(rec[:created])).to_i
|
|
@@ -274,13 +319,13 @@ def disposition_of(type, quadrant)
|
|
|
274
319
|
end
|
|
275
320
|
end
|
|
276
321
|
|
|
277
|
-
def classify(rec, done_ids, referenced = {})
|
|
322
|
+
def classify(rec, done_ids, referenced = {}, completed_on_map = {})
|
|
278
323
|
type = intent_type(rec)
|
|
279
324
|
value = value_of(rec, referenced)
|
|
280
325
|
effort = effort_of(rec, type)
|
|
281
326
|
quadrant = QUADRANTS[[value, effort]]
|
|
282
327
|
disposition = disposition_of(type, quadrant)
|
|
283
|
-
flags = flags_of(rec, done_ids)
|
|
328
|
+
flags = flags_of(rec, done_ids, completed_on_map)
|
|
284
329
|
rec.merge(
|
|
285
330
|
type: type, value: value, effort: effort, quadrant: quadrant,
|
|
286
331
|
lifecycle: lifecycle_stage(rec), flags: flags, disposition: disposition,
|
|
@@ -329,6 +374,13 @@ end
|
|
|
329
374
|
|
|
330
375
|
CELL_CAP = 6
|
|
331
376
|
|
|
377
|
+
# Markdown-board caps (Task 5, D6/D7): the ASCII renderer already caps via CELL_CAP/
|
|
378
|
+
# cap_cell, but the Markdown board's matrix_data quadrants and the project board's
|
|
379
|
+
# active/future lists had no cap and no per-line truncation, so a large store printed
|
|
380
|
+
# hundreds of full-length lines. These two constants fix that on the Markdown side only.
|
|
381
|
+
MATRIX_DATA_CAP = 8
|
|
382
|
+
INTENT_LINE_MAX_CHARS = 120
|
|
383
|
+
|
|
332
384
|
def matrix(records, scope_tag: false)
|
|
333
385
|
cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
|
|
334
386
|
research = []
|
|
@@ -526,8 +578,23 @@ end
|
|
|
526
578
|
|
|
527
579
|
def intent_line(rec, bullet)
|
|
528
580
|
note = rec[:status] == "active" ? " (#{rec[:lifecycle].to_s.capitalize})" : ""
|
|
581
|
+
text = rec[:intent].to_s
|
|
582
|
+
text = "#{text[0, INTENT_LINE_MAX_CHARS]}…" if text.length > INTENT_LINE_MAX_CHARS
|
|
529
583
|
{ id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
|
|
530
|
-
scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{
|
|
584
|
+
scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{text}#{note}".rstrip }
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Cap a raw record list to MATRIX_DATA_CAP entries, then map to intent_line-shaped
|
|
588
|
+
# hashes, appending a plain "+N more" line (no id, not a real record) when truncated.
|
|
589
|
+
# Caps the record list first so the "+N more" entry never goes through intent_line.
|
|
590
|
+
def cap_lines(list, bullet)
|
|
591
|
+
capped = list.first(MATRIX_DATA_CAP)
|
|
592
|
+
lines = capped.map { |r| intent_line(r, bullet) }
|
|
593
|
+
if list.size > MATRIX_DATA_CAP
|
|
594
|
+
lines << { id: "", intent: "", created: "", bullet: bullet, scope: "",
|
|
595
|
+
line: "#{bullet} +#{list.size - MATRIX_DATA_CAP} more" }
|
|
596
|
+
end
|
|
597
|
+
lines
|
|
531
598
|
end
|
|
532
599
|
|
|
533
600
|
def matrix_data(records)
|
|
@@ -539,8 +606,8 @@ def matrix_data(records)
|
|
|
539
606
|
end
|
|
540
607
|
by_created_desc = ->(list) { list.sort_by { |r| invert_ts(r[:created]) } }
|
|
541
608
|
out = {}
|
|
542
|
-
cells.each { |q, list| out[q] = by_created_desc.call(list)
|
|
543
|
-
out["research"] = by_created_desc.call(research)
|
|
609
|
+
cells.each { |q, list| out[q] = cap_lines(by_created_desc.call(list), QUADRANT_BULLET[q]) }
|
|
610
|
+
out["research"] = cap_lines(by_created_desc.call(research), "🔬")
|
|
544
611
|
out
|
|
545
612
|
end
|
|
546
613
|
|
|
@@ -606,12 +673,10 @@ def render_data_project(records, slug)
|
|
|
606
673
|
recently_worked: recently_worked(records, project_scope: scope),
|
|
607
674
|
matrix: matrix_data(matrix_pool),
|
|
608
675
|
counts: counts_of(scoped),
|
|
609
|
-
active: scoped.select { |r| r[:status] == "active" }
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
.sort_by { |r| invert_ts(r[:created]) }
|
|
614
|
-
.map { |r| intent_line(r, STATUS_GLYPH["future"]) } }
|
|
676
|
+
active: cap_lines(scoped.select { |r| r[:status] == "active" }
|
|
677
|
+
.sort_by { |r| invert_ts(r[:last_accessed_at]) }, STATUS_GLYPH["active"]),
|
|
678
|
+
future: cap_lines(scoped.select { |r| r[:status] == "future" }
|
|
679
|
+
.sort_by { |r| invert_ts(r[:created]) }, STATUS_GLYPH["future"]) }
|
|
615
680
|
end
|
|
616
681
|
|
|
617
682
|
# ---------------------------------------------------------------------------
|
|
@@ -645,8 +710,8 @@ def main(argv)
|
|
|
645
710
|
mode = argv.shift || "continue"
|
|
646
711
|
slug = argv.shift
|
|
647
712
|
|
|
648
|
-
raw, done_ids, referenced = load_all
|
|
649
|
-
records = raw.map { |r| classify(r, done_ids, referenced) }
|
|
713
|
+
raw, done_ids, referenced, completed_on_map = load_all
|
|
714
|
+
records = raw.map { |r| classify(r, done_ids, referenced, completed_on_map) }
|
|
650
715
|
|
|
651
716
|
if data
|
|
652
717
|
payload = mode == "project" ? render_data_project(records, slug) : render_data_global(records)
|
package/scripts/hook-continue
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
require "json"
|
|
9
9
|
require "open3"
|
|
10
|
+
require_relative "lib/dashboard_banner"
|
|
10
11
|
|
|
11
12
|
index_path, _store_root, _mode = ARGV
|
|
12
13
|
exit 0 unless index_path && File.exist?(index_path)
|
|
@@ -28,4 +29,20 @@ payload = {
|
|
|
28
29
|
"additionalContext" => context
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
|
|
33
|
+
# Hook-owned systemMessage floor (intent 125, Task 6): a hard fallback summary
|
|
34
|
+
# independent of the agent's reply, mirroring hook-session-start's BootBanner
|
|
35
|
+
# pattern. Best-effort only — any failure here (subprocess, JSON, renderer)
|
|
36
|
+
# degrades silently, omitting systemMessage; this hook must never crash
|
|
37
|
+
# UserPromptSubmit over a summary line.
|
|
38
|
+
begin
|
|
39
|
+
data_json, _data_err, data_status = Open3.capture3("ruby", dashboard, "continue", "--data")
|
|
40
|
+
if data_status.success?
|
|
41
|
+
banner = DashboardBanner.render(JSON.parse(data_json))
|
|
42
|
+
payload["systemMessage"] = banner if banner
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
puts JSON.generate(payload)
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -45,8 +45,18 @@ module Bridge
|
|
|
45
45
|
(t.nil? || t.strip.empty?) ? "/tmp" : t
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
# Per-intent bridge key (intent 131): `plastic-<session>--<intent_id>.json`
|
|
49
|
+
# when intent_id is present, else the legacy single-key
|
|
50
|
+
# `plastic-<session>.json`. The per-intent key is what lets two concurrent
|
|
51
|
+
# deliveries under ONE session id keep separate bridge files instead of
|
|
52
|
+
# clobbering a shared one; the legacy form is still produced (and read) when
|
|
53
|
+
# no intent_id is given, so old single-key files stay valid.
|
|
54
|
+
def self.path(session, intent_id: nil, tmp: tmp_dir)
|
|
55
|
+
if blank?(intent_id)
|
|
56
|
+
"#{tmp}/plastic-#{session}.json"
|
|
57
|
+
else
|
|
58
|
+
"#{tmp}/plastic-#{session}--#{intent_id}.json"
|
|
59
|
+
end
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
# --- Session resolution (intent 52) ----------------------------------------
|
|
@@ -123,15 +133,46 @@ module Bridge
|
|
|
123
133
|
data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
|
|
124
134
|
end
|
|
125
135
|
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
# Tiered cwd discriminator for one bridge candidate (intent 131). A session
|
|
137
|
+
# now owns SEVERAL bridges (one per concurrent intent), so the discriminator
|
|
138
|
+
# that used to be "cwd overlaps intent.store" is too coarse: every sibling
|
|
139
|
+
# under the same store shares it. worktree.code is the only field that
|
|
140
|
+
# differs between siblings, so it is the strongest signal; the intent dir is
|
|
141
|
+
# next; the shared store is a last-resort coarse tie.
|
|
142
|
+
# 2 - cwd is the intent's provisioned code worktree (or under it)
|
|
143
|
+
# 1 - cwd is the intent's own dir (or under it)
|
|
144
|
+
# 0 - cwd merely overlaps the intent's store (shared by every sibling)
|
|
145
|
+
# -1 - no signal at all
|
|
146
|
+
def self.bridge_cwd_tier(data, cwd_abs)
|
|
147
|
+
worktree_code = data.dig("worktree", "code")
|
|
148
|
+
if !blank?(worktree_code)
|
|
149
|
+
wc_abs = File.expand_path(worktree_code)
|
|
150
|
+
return 2 if cwd_abs == wc_abs || cwd_abs.start_with?("#{wc_abs}/")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
dir_abs = bridge_intent_dir(data)
|
|
154
|
+
if dir_abs
|
|
155
|
+
return 1 if cwd_abs == dir_abs || cwd_abs.start_with?("#{dir_abs}/")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
store = data.dig("intent", "store").to_s
|
|
159
|
+
unless store.empty?
|
|
160
|
+
store_abs = File.expand_path(store)
|
|
161
|
+
return 0 if cwd_abs == store_abs || cwd_abs.start_with?("#{store_abs}/") ||
|
|
162
|
+
store_abs.start_with?("#{cwd_abs}/")
|
|
133
163
|
end
|
|
134
164
|
|
|
165
|
+
-1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
|
|
169
|
+
# legacy-keyed files), keep only valid bridges, filter to the caller's own
|
|
170
|
+
# session when it has one, prefer auto-armed, then disambiguate by cwd tier
|
|
171
|
+
# (see bridge_cwd_tier), tie-break by newest mtime. No exact-session fast
|
|
172
|
+
# path: a session now legitimately owns several bridges (one per concurrent
|
|
173
|
+
# intent), so filename lookup alone cannot pick the right one; cwd must
|
|
174
|
+
# decide (intent 131).
|
|
175
|
+
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
|
|
135
176
|
candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
|
|
136
177
|
parsed = candidates.filter_map do |f|
|
|
137
178
|
data = (JSON.parse(File.read(f)) rescue nil)
|
|
@@ -156,26 +197,34 @@ module Bridge
|
|
|
156
197
|
return nil if parsed.empty?
|
|
157
198
|
end
|
|
158
199
|
|
|
200
|
+
# Auto-preference pool: a build-armed bridge is preferred over a merely
|
|
201
|
+
# derived one, but ONLY as a fallback when cwd cannot decide (below). cwd
|
|
202
|
+
# must win over auto-preference, so this pool is not applied before the
|
|
203
|
+
# cwd tiering (intent 131: a guided sibling in the caller's own worktree
|
|
204
|
+
# must beat an auto sibling in another worktree).
|
|
159
205
|
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
160
|
-
|
|
206
|
+
auto_pool = auto.empty? ? parsed : auto
|
|
161
207
|
|
|
162
208
|
unless blank?(cwd)
|
|
163
209
|
cwd_abs = File.expand_path(cwd)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
210
|
+
# Tier the FULL session pool by cwd BEFORE the auto-preference filter.
|
|
211
|
+
# When cwd overlaps ANY candidate (tier >= 0) it decides outright, even
|
|
212
|
+
# against a newer or auto-armed sibling: worktree.code (tier 2) and the
|
|
213
|
+
# intent dir (tier 1) disambiguate same-store siblings (intent 131), and
|
|
214
|
+
# a store overlap (tier 0) still selects the overlapping bridge over an
|
|
215
|
+
# off-cwd one in another store (the intent 90/52 store filter, preserved).
|
|
216
|
+
# Only when NO candidate overlaps cwd (max tier -1) do we fall through to
|
|
217
|
+
# the auto-preference pool and newest mtime, so a lone armed bridge
|
|
218
|
+
# off-cwd still resolves (intent 52 headless).
|
|
219
|
+
tiered = parsed.map { |c| [bridge_cwd_tier(c[:data], cwd_abs), c] }
|
|
220
|
+
max_tier = tiered.map(&:first).max
|
|
221
|
+
if max_tier && max_tier >= 0
|
|
222
|
+
winners = tiered.select { |tier, _| tier == max_tier }.map { |_, c| c }
|
|
223
|
+
return winners.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
171
224
|
end
|
|
172
|
-
# Hard cwd filter when the caller has a session (intent 90): a non-matching store
|
|
173
|
-
# excludes the candidate outright. Without a session, keep the best-effort revert
|
|
174
|
-
# (intent 52) so a lone armed bridge is still found when cwd does not overlap its store.
|
|
175
|
-
pool = has_session ? matching : (matching.empty? ? pool : matching)
|
|
176
225
|
end
|
|
177
226
|
|
|
178
|
-
|
|
227
|
+
auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
179
228
|
end
|
|
180
229
|
|
|
181
230
|
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
@@ -221,10 +270,16 @@ module Bridge
|
|
|
221
270
|
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
222
271
|
# clean at deterministic work boundaries.
|
|
223
272
|
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
224
|
-
|
|
273
|
+
# Own-bridge predicate (intent 131): a session now legitimately owns
|
|
274
|
+
# SEVERAL bridges (one per concurrent intent), so "current" is no longer
|
|
275
|
+
# one filename. Skip the legacy single-key file for this session AND every
|
|
276
|
+
# per-intent-keyed file for this session; none of the session's own live
|
|
277
|
+
# bridges may be reaped mid-run.
|
|
278
|
+
own_legacy_name = File.basename(path(session, tmp: tmp))
|
|
279
|
+
own_prefix = "plastic-#{session}--"
|
|
225
280
|
removed = []
|
|
226
281
|
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
227
|
-
next if f ==
|
|
282
|
+
next if File.basename(f) == own_legacy_name || File.basename(f).start_with?(own_prefix)
|
|
228
283
|
begin
|
|
229
284
|
data = JSON.parse(File.read(f)) rescue nil
|
|
230
285
|
keep = false
|
|
@@ -256,17 +311,35 @@ module Bridge
|
|
|
256
311
|
removed || []
|
|
257
312
|
end
|
|
258
313
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
314
|
+
# Try the per-intent path first; when it is absent and an intent_id was
|
|
315
|
+
# given, fall back to the legacy single-key path (migration + legacy
|
|
316
|
+
# tolerance, intent 131): a live `plastic-<session>.json` from before this
|
|
317
|
+
# intent keeps resolving during the transition. The legacy fallback is
|
|
318
|
+
# honored for a specific intent_id ONLY when the legacy file actually carries
|
|
319
|
+
# that intent (or carries none), so a caller asking for intent A never acts
|
|
320
|
+
# on a legacy file that still holds sibling B.
|
|
321
|
+
def self.read(session, intent_id: nil, tmp: tmp_dir)
|
|
322
|
+
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
323
|
+
return JSON.parse(File.read(p)) if File.exist?(p)
|
|
324
|
+
return nil if blank?(intent_id)
|
|
325
|
+
legacy = path(session, tmp: tmp)
|
|
326
|
+
return nil unless File.exist?(legacy)
|
|
327
|
+
data = JSON.parse(File.read(legacy))
|
|
328
|
+
id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
329
|
+
(blank?(id) || id.to_s == intent_id.to_s) ? data : nil
|
|
263
330
|
rescue JSON::ParserError
|
|
264
331
|
nil
|
|
265
332
|
end
|
|
266
333
|
|
|
334
|
+
# Self-keying (intent 131): the file `write` targets is derived from
|
|
335
|
+
# `data.dig("intent", "id")`, not a caller-supplied intent_id, so every
|
|
336
|
+
# existing `write(session, data)` call site keys itself correctly for free
|
|
337
|
+
# as long as `data["intent"]["id"]` is set (arm/derive/disarm_auto/
|
|
338
|
+
# repair_lock/hook-gate-check/plastic-lock all carry it).
|
|
267
339
|
def self.write(session, data, tmp: tmp_dir)
|
|
268
340
|
raise ArgumentError, "bridge session must be present" if blank?(session)
|
|
269
|
-
|
|
341
|
+
intent_id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
342
|
+
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
270
343
|
# Atomic write: tmp file + rename to prevent partial reads
|
|
271
344
|
tmp_file = "#{p}.tmp.#{Process.pid}"
|
|
272
345
|
File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
|
|
@@ -709,13 +782,31 @@ module Bridge
|
|
|
709
782
|
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
|
|
710
783
|
end
|
|
711
784
|
|
|
785
|
+
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
786
|
+
# session's sole per-intent bridge when there is exactly one, else the
|
|
787
|
+
# legacy single-key file. Keeps the common single-intent auto path working
|
|
788
|
+
# without every caller having to name the intent id explicitly.
|
|
789
|
+
def self.sole_bridge_data(session, tmp: tmp_dir)
|
|
790
|
+
matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
|
|
791
|
+
if matches.length == 1
|
|
792
|
+
data = (JSON.parse(File.read(matches.first)) rescue nil)
|
|
793
|
+
return data if data
|
|
794
|
+
end
|
|
795
|
+
read(session, tmp: tmp)
|
|
796
|
+
end
|
|
797
|
+
|
|
712
798
|
# Disarm. No-op if no bridge exists for the session. End-tail order (D6):
|
|
713
799
|
# worktrees are merged/removed FIRST (the verify step is the caller's,
|
|
714
800
|
# before disarm), then the delivery lock is cleared, and only then does the
|
|
715
801
|
# bridge become purge-eligible. purge_done_bridges enforces the same order
|
|
716
802
|
# defensively by skipping any bridge whose intent still holds a lock.
|
|
717
|
-
|
|
718
|
-
|
|
803
|
+
#
|
|
804
|
+
# Now takes intent_id (intent 131): a session can own SEVERAL live bridges
|
|
805
|
+
# (one per concurrent intent), so disarm must target ONE of them. When
|
|
806
|
+
# intent_id is nil, degrades to the session's sole bridge (see
|
|
807
|
+
# sole_bridge_data) so the common single-intent path keeps working.
|
|
808
|
+
def self.disarm_auto(session, intent_id: nil)
|
|
809
|
+
data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
|
|
719
810
|
return nil unless data
|
|
720
811
|
data["build"] ||= {}
|
|
721
812
|
data["build"]["auto"] = false
|
|
@@ -783,7 +874,7 @@ module Bridge
|
|
|
783
874
|
actions << "lock #{status}"
|
|
784
875
|
end
|
|
785
876
|
|
|
786
|
-
previous = read(key, tmp: tmp)
|
|
877
|
+
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
787
878
|
auto = !!(previous && previous.dig("build", "auto"))
|
|
788
879
|
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
789
880
|
name: name, tmp: tmp)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renders a one-line dashboard summary for the hook-owned systemMessage floor
|
|
5
|
+
# (intent 125, Task 6). Pure and dependency-injected, mirroring BootBanner: it
|
|
6
|
+
# takes the already-parsed `dashboard.rb continue --data` JSON payload and
|
|
7
|
+
# returns a single line, with no file I/O and no subprocess calls in here, so
|
|
8
|
+
# it is unit-testable in isolation while the hook feeds it real data.
|
|
9
|
+
module DashboardBanner
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# payload: the Hash from JSON.parse(`dashboard.rb continue --data`), or nil
|
|
13
|
+
# when the subprocess call failed or produced unusable JSON.
|
|
14
|
+
#
|
|
15
|
+
# Returns a single summary line, or nil when the payload has nothing usable
|
|
16
|
+
# (the caller degrades silently in that case, omitting systemMessage).
|
|
17
|
+
def render(payload)
|
|
18
|
+
return nil unless payload.is_a?(Hash)
|
|
19
|
+
counts = payload["counts"]
|
|
20
|
+
return nil unless counts.is_a?(Hash)
|
|
21
|
+
active = counts["active"].to_i
|
|
22
|
+
future = counts["future"].to_i
|
|
23
|
+
line = "Plastic: #{active} active · #{future} next · run /plastic-dashboard to see the board"
|
|
24
|
+
nbt = next_big_thing_id(payload)
|
|
25
|
+
line += " · next big thing: #{nbt}" if nbt
|
|
26
|
+
line
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The id of the top-ranked next_big candidate, when the payload's matrix carries
|
|
30
|
+
# exactly the shape dashboard.rb emits (a "next_big" list of {id, ...} hashes,
|
|
31
|
+
# already rank-sorted). Returns nil for any other shape rather than raising.
|
|
32
|
+
def next_big_thing_id(payload)
|
|
33
|
+
matrix = payload["matrix"]
|
|
34
|
+
return nil unless matrix.is_a?(Hash)
|
|
35
|
+
list = matrix["next_big"]
|
|
36
|
+
return nil unless list.is_a?(Array) && !list.empty?
|
|
37
|
+
top = list.first
|
|
38
|
+
return nil unless top.is_a?(Hash)
|
|
39
|
+
id = top["id"].to_s
|
|
40
|
+
id.empty? ? nil : id
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -224,6 +224,7 @@ class InstallerCore
|
|
|
224
224
|
"scripts/lib/insights.rb" => "scripts/lib/insights.rb",
|
|
225
225
|
"scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
|
|
226
226
|
"scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
|
|
227
|
+
"scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
|
|
227
228
|
"scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
|
|
228
229
|
"scripts/qmd-sync" => "scripts/qmd-sync",
|
|
229
230
|
"scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
|
package/scripts/plastic-lock
CHANGED
|
@@ -72,7 +72,7 @@ key = Bridge.resolve_session(session, intent_id: intent_id, store: store)
|
|
|
72
72
|
case verb
|
|
73
73
|
when "status"
|
|
74
74
|
lock = Lock.read(dir)
|
|
75
|
-
bridge = Bridge.read(key)
|
|
75
|
+
bridge = Bridge.read(key, intent_id: intent_id)
|
|
76
76
|
report = {
|
|
77
77
|
"intent_dir" => dir,
|
|
78
78
|
"session" => key,
|
|
@@ -100,7 +100,7 @@ when "release"
|
|
|
100
100
|
warn "plastic-lock: not the owner; run plastic-lock status"
|
|
101
101
|
exit 1
|
|
102
102
|
end
|
|
103
|
-
data = Bridge.read(key)
|
|
103
|
+
data = Bridge.read(key, intent_id: intent_id)
|
|
104
104
|
if data
|
|
105
105
|
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
106
106
|
"host" => nil, "type" => nil, "delegates" => [] }
|
package/scripts/update.rb
CHANGED
|
@@ -53,18 +53,22 @@ class Update < InstallerCore
|
|
|
53
53
|
end
|
|
54
54
|
puts "\u{2b06}\u{fe0f} Updating Plastic #{iv} \u{2192} #{res[:target]}"
|
|
55
55
|
exit_code = perform_switch(res[:target], agent_args(argv))
|
|
56
|
-
run_post_update_doctor if exit_code == 0
|
|
56
|
+
run_post_update_doctor(full: argv.include?("--full-doctor")) if exit_code == 0
|
|
57
57
|
exit_code
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
# Run
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
61
|
+
# Run doctor after a successful update and print a human-readable summary.
|
|
62
|
+
# Defaults to the fast core tier (agent registration + core files + manifest
|
|
63
|
+
# sync, binary pass|fail, no store walk) so a newcomer's first post-update
|
|
64
|
+
# run is not buried in convention warns they cannot act on. `full: true`
|
|
65
|
+
# (via `--full-doctor`) runs the complete store walk instead. Informational
|
|
66
|
+
# only: does not raise and does not affect the update's exit code. Accepts
|
|
67
|
+
# injected `doctor` and `out` for hermetic unit tests.
|
|
68
|
+
def run_post_update_doctor(doctor: nil, out: $stdout, full: false)
|
|
65
69
|
doctor ||= Doctor.new
|
|
66
|
-
out.puts "\nRunning full doctor after update..."
|
|
67
|
-
result = doctor.run_checks("claude")
|
|
70
|
+
out.puts full ? "\nRunning full doctor after update..." : "\nRunning core doctor after update..."
|
|
71
|
+
result = full ? doctor.run_checks("claude") : doctor.run_core_checks("claude")
|
|
68
72
|
s = result[:summary]
|
|
69
73
|
out.puts " Doctor status: #{result[:status]} " \
|
|
70
74
|
"(pass: #{s[:pass]}, warn: #{s[:warn]}, fail: #{s[:fail]}, total: #{s[:total]})"
|
|
@@ -152,6 +156,11 @@ class Update < InstallerCore
|
|
|
152
156
|
Agent options (default: --claude):
|
|
153
157
|
--claude --codex --hermes --all
|
|
154
158
|
|
|
159
|
+
Post-update doctor:
|
|
160
|
+
By default, a successful update runs the fast core doctor sync (agent
|
|
161
|
+
registration, core files, manifest — binary pass|fail, no store walk).
|
|
162
|
+
--full-doctor Run the full doctor (complete store walk) after updating.
|
|
163
|
+
|
|
155
164
|
Behaviour:
|
|
156
165
|
No flag advances to the next version on your current channel. Switching toward a
|
|
157
166
|
more stable channel is frictionless; switching toward bleeding edge is confirmed.
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -275,9 +275,11 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
275
275
|
```
|
|
276
276
|
(Use `"abandoned"` instead when the intent is being moved to `## Abandoned`.) Idempotent.
|
|
277
277
|
7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
|
|
278
|
-
8. Disarm the lifecycle gate (auto delivery is finished)
|
|
278
|
+
8. Disarm the lifecycle gate (auto delivery is finished). Substitute the intent's own id for
|
|
279
|
+
`<ID>` (a session can be delivering more than one intent at once, intent 131, so disarm must
|
|
280
|
+
name which of the session's bridges to clear):
|
|
279
281
|
```bash
|
|
280
|
-
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"])'
|
|
282
|
+
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"], intent_id: "<ID>")'
|
|
281
283
|
```
|
|
282
284
|
Disarm runs the ordered End tail: it releases the worktrees first, then clears the
|
|
283
285
|
intent's `delivery.lock` (and the bridge's lock cache), and only then is the bridge
|
|
@@ -38,7 +38,11 @@ here — run the data payload and fill + present the matching template:
|
|
|
38
38
|
- Otherwise → `ruby ~/.plastic/scripts/dashboard.rb continue --data`
|
|
39
39
|
|
|
40
40
|
Fill the matching template from this skill's `templates/` and **present the filled Markdown
|
|
41
|
-
in your reply** (every time).
|
|
41
|
+
in your reply** (every time, non-optional). If the reply does not contain the filled Markdown,
|
|
42
|
+
the user sees nothing — tool-call stdout and hook `additionalContext` are both invisible to
|
|
43
|
+
them. `hook-continue` also emits a one-line `systemMessage` summary as a hook-owned fallback;
|
|
44
|
+
treat it as a floor only, never as a substitute for presenting the full board here. See
|
|
45
|
+
`plastic-dashboard` for the fill rules and entry flow.
|
|
42
46
|
|
|
43
47
|
The board load runs the scoped store check on every load (`doctor --store <scope>`): the
|
|
44
48
|
global board runs `--store global` and a project board runs `--store <slug>`. The result
|
|
@@ -61,9 +61,17 @@ Fill mechanically — no rewriting, no re-sorting:
|
|
|
61
61
|
|
|
62
62
|
### Step 3 — Present it (mandatory, every invocation)
|
|
63
63
|
|
|
64
|
-
**Paste the filled Markdown into your reply.** This is non-optional: the
|
|
65
|
-
the
|
|
66
|
-
instead of showing
|
|
64
|
+
**Paste the filled Markdown into your reply.** This is non-optional: if the reply does not
|
|
65
|
+
contain the filled Markdown, the user sees nothing — tool-call stdout and hook
|
|
66
|
+
`additionalContext` are both invisible to them. Never describe the board instead of showing
|
|
67
|
+
it, and never assume a hook already showed it for you.
|
|
68
|
+
|
|
69
|
+
`hook-continue` also emits a one-line `systemMessage` summary (counts, and the next big thing
|
|
70
|
+
when there is one) as a hook-owned fallback, independent of the agent's reply. Treat that line
|
|
71
|
+
as a floor only, not a substitute for this step: it carries no matrix, no recently-worked
|
|
72
|
+
section, and no entry-flow prompt. Presenting the full board here remains mandatory regardless
|
|
73
|
+
of whether the summary line fired. This stays a soft, agent-followed mechanism — there is no
|
|
74
|
+
stronger enforcement for a full multi-section Markdown document in this harness today.
|
|
67
75
|
|
|
68
76
|
### Step 4 — Entry flow (the board is the menu)
|
|
69
77
|
|
|
@@ -103,13 +111,22 @@ a raw terminal. The Markdown board (`--data` + template) is the surface for the
|
|
|
103
111
|
## How classification works (deterministic)
|
|
104
112
|
|
|
105
113
|
- **Effort** — small for `research`/`exploration`/`bugfix`, for already-scoped intents
|
|
106
|
-
(plan/checklist exists), or
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`
|
|
114
|
+
(plan/checklist exists), or a **branch id** (folgezettel depth ≥ 2, e.g. `4a`, `12b3`); big
|
|
115
|
+
otherwise. A root id (a bare number) is always depth 1, so it is never demoted by this rule.
|
|
116
|
+
- **Value → high** when any of: explicit `value: high`; a human-authored **root** intent; or
|
|
117
|
+
an intent that is a `source` of ≥1 other intent (it has spawned follow-on work). A purely
|
|
118
|
+
relational `chain` entry alone is **not** a value signal (intent 68) — else low.
|
|
119
|
+
- **Flags** — `unblocked` only when a **future** intent has **all** its `sources` done AND at
|
|
120
|
+
least one source's completion date is strictly later than the intent's own `created` date (a
|
|
121
|
+
genuine wait, not a birth-time default); `in-progress` only when the savepoint ledger shows
|
|
122
|
+
real post-birth activity, not just the creation stamp; `stale` only on future intents past
|
|
123
|
+
the staleness threshold. All three kept low-noise by design.
|
|
111
124
|
- **Override** — a `value: high|low` frontmatter field always wins (pre-stamped data, never
|
|
112
125
|
model judgment at render time).
|
|
126
|
+
- **Caps** — quadrant lists and the project board's `active`/`future` lists are capped at 8
|
|
127
|
+
entries plus a trailing "+N more" line; each entry's text is truncated to 120 characters
|
|
128
|
+
with a trailing ellipsis. Applies to the Markdown board only (the ASCII renderer has its own
|
|
129
|
+
separate `CELL_CAP`).
|
|
113
130
|
|
|
114
131
|
## Eval
|
|
115
132
|
|
|
@@ -243,9 +243,12 @@ from the bridge:
|
|
|
243
243
|
|
|
244
244
|
```bash
|
|
245
245
|
ruby -r ~/.plastic/scripts/lib/worktree -r ~/.plastic/scripts/lib/bridge -e \
|
|
246
|
-
'b = Bridge.
|
|
246
|
+
'b = Bridge.discover_bridge(session: ENV["CLAUDE_CODE_SESSION_ID"], cwd: Dir.pwd); Worktree.finish(b, merge: true) if b'
|
|
247
247
|
```
|
|
248
248
|
|
|
249
|
+
(Uses `discover_bridge`, not a bare session-keyed `Bridge.read`, because a session can own more
|
|
250
|
+
than one live bridge now — intent 131 — and `discover_bridge` resolves the right one for this cwd.)
|
|
251
|
+
|
|
249
252
|
`finish` is fail-open and idempotent: a conflicting merge is aborted and logged (the worktree
|
|
250
253
|
is still removed rather than stranded), and a second call with the block already cleared is a
|
|
251
254
|
no-op. Honor the worktree-cleanup rule: never leave an orphaned worktree, and run `git worktree
|
package/skills/roadmap/SKILL.md
CHANGED
|
@@ -6,9 +6,11 @@ description: Use when the user wants to plan a delivery batch, order waves of in
|
|
|
6
6
|
# Roadmap
|
|
7
7
|
|
|
8
8
|
A roadmap is a named, ordered, delivery-side collection of intents: the delivery-side counterpart
|
|
9
|
-
to a release (completion-side, `CHANGELOG.md`). It lives at `roadmaps/{slug}.md`, a
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
to a release (completion-side, `CHANGELOG.md`). It lives at `roadmaps/{slug}.md`, a sibling of
|
|
10
|
+
`INDEX.md` wherever `INDEX.md` lives: the global tier's `~/.plastic/roadmaps/` (beside
|
|
11
|
+
`~/.plastic/INDEX.md`), or a project's root, `~/.plastic/projects/{slug}/roadmaps/` (beside that
|
|
12
|
+
project's `INDEX.md` and `project.yml`). It never sits inside `store/`, which holds intent
|
|
13
|
+
directories, not project artifacts.
|
|
12
14
|
|
|
13
15
|
A roadmap file has four parts: a title/meta header, `## Goal` (prose), `## Waves` (ordered; entries
|
|
14
16
|
inside a wave are parallel-safe, waves run sequentially), and an append-only dated `## Log`. Each
|
|
@@ -37,8 +39,9 @@ verb above.
|
|
|
37
39
|
|
|
38
40
|
## Notes
|
|
39
41
|
|
|
40
|
-
- File location and the four-section shape are identical across
|
|
41
|
-
layout per project.
|
|
42
|
+
- File location and the four-section shape are identical across tiers; do not invent a different
|
|
43
|
+
layout per project. The general rule: `roadmaps/` is a sibling of `INDEX.md`, wherever `INDEX.md`
|
|
44
|
+
lives.
|
|
42
45
|
- `## Goal` is a checkable prose condition read by a human or agent, not an executable checker.
|
|
43
46
|
- Wave entries render as checkboxes (`- [x] ... — delivered` / `- [ ] ... — <status>`); a human
|
|
44
47
|
reading cold should see shipped/running/next within a minute. `## Log` lines are one-sentence,
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Location
|
|
4
4
|
|
|
5
|
-
`roadmaps/{slug}.md`, a
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
`roadmaps/{slug}.md`, a sibling of `INDEX.md`, wherever `INDEX.md` lives. For the global tier
|
|
6
|
+
that is `~/.plastic/roadmaps/{slug}.md` (beside `~/.plastic/INDEX.md`); for any project it is
|
|
7
|
+
that project's root, `~/.plastic/projects/{slug}/roadmaps/{slug}.md` (beside that project's
|
|
8
|
+
`INDEX.md` and `project.yml`). `roadmaps/` never sits inside `store/`: `store/` holds intent
|
|
9
|
+
directories, not project artifacts. Create the `roadmaps/` directory the first time a tier gets a
|
|
10
|
+
roadmap.
|
|
9
11
|
|
|
10
12
|
`roadmaps/` lists only live (open or in-flight) roadmaps. Once a roadmap's `## Goal` is reached,
|
|
11
13
|
its file moves to `roadmaps/archived/{slug}.md` (see Close/archive in `operations.md`); the
|
|
@@ -14,7 +16,7 @@ its file moves to `roadmaps/archived/{slug}.md` (see Close/archive in `operation
|
|
|
14
16
|
## The four sections (in order)
|
|
15
17
|
|
|
16
18
|
1. **Title/meta header** — `# Roadmap: <name>` plus a one-line meta sentence naming what the
|
|
17
|
-
roadmap delivers and which
|
|
19
|
+
roadmap delivers and which tier (project or global) it lives in.
|
|
18
20
|
2. **`## Goal`** — a checkable prose condition: one or a few sentences a human or coordinator reads
|
|
19
21
|
to decide the roadmap is done. Not an executable checker, not a list of tasks.
|
|
20
22
|
3. **`## Waves`** — ordered waves (`### Wave 1`, `### Wave 2`, ...). Entries inside a wave are
|
|
@@ -49,12 +51,13 @@ it. The roadmap never sets a status that INDEX does not already reflect.
|
|
|
49
51
|
|
|
50
52
|
## Log line shape
|
|
51
53
|
|
|
52
|
-
One line per event,
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
One line per event, starting `YYYY-MM-DD HH:MM UTC` (human-readable, sortable, zone-explicit so
|
|
55
|
+
same-day parallel deliveries can still be ordered), in plain-language EM-to-CTO voice: what shipped
|
|
56
|
+
and why it matters to a non-expert reader, no jargon or internal codenames, ending with a link to
|
|
57
|
+
that entry-intent's `outcome.md`:
|
|
55
58
|
|
|
56
59
|
```
|
|
57
|
-
- <YYYY-MM-DD> <one plain-language sentence: what shipped, its impact> — see store/<id>--<slug>/outcome.md
|
|
60
|
+
- <YYYY-MM-DD HH:MM UTC> <one plain-language sentence: what shipped, its impact> — see store/<id>--<slug>/outcome.md
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
The log line never restates `outcome.md` detail; it points at it (lossless-by-reference). This
|
|
@@ -83,6 +86,6 @@ abandoned | blocked); INDEX wins on any conflict.
|
|
|
83
86
|
- [x] 124 Roadmap feature — delivered
|
|
84
87
|
|
|
85
88
|
## Log
|
|
86
|
-
- 2026-07-06 Shipped the bash-gate redirect fix so quoted arrows and heredoc trailers
|
|
87
|
-
blocking legitimate commits — see store/121--fix-bash-gate-redirect-parsing/outcome.md.
|
|
89
|
+
- 2026-07-06 14:32 UTC Shipped the bash-gate redirect fix so quoted arrows and heredoc trailers
|
|
90
|
+
stop blocking legitimate commits — see store/121--fix-bash-gate-redirect-parsing/outcome.md.
|
|
88
91
|
```
|
|
@@ -10,14 +10,16 @@ next" in under a minute, just from this one file.
|
|
|
10
10
|
## Create
|
|
11
11
|
|
|
12
12
|
1. Pick a `slug` (kebab-case, descriptive) and a `title`.
|
|
13
|
-
2. Resolve the
|
|
14
|
-
`~/.plastic
|
|
13
|
+
2. Resolve the tier root: the directory that holds `INDEX.md` (a project's root, beside
|
|
14
|
+
`project.yml`, or `~/.plastic/` for the global tier). `roadmaps/` is always a sibling of
|
|
15
|
+
`INDEX.md`, never inside `store/`. Create `roadmaps/` there if it does not exist yet.
|
|
15
16
|
3. Copy `templates/roadmap.md` to `roadmaps/{slug}.md`.
|
|
16
17
|
4. Fill the header (`# Roadmap: <title>` + the one-line meta) and write a real `## Goal` prose
|
|
17
18
|
condition.
|
|
18
19
|
5. Add at least one `## Waves` wave with real entries (see Add / reorder below), each entry's
|
|
19
20
|
status mirroring that intent's current `INDEX.md` status.
|
|
20
|
-
6. Append the first `## Log` line, a short
|
|
21
|
+
6. Append the first `## Log` line, a short `YYYY-MM-DD HH:MM UTC`-prefixed plain-language note
|
|
22
|
+
that the roadmap was created.
|
|
21
23
|
|
|
22
24
|
## Add / reorder entries
|
|
23
25
|
|
|
@@ -29,7 +31,7 @@ next" in under a minute, just from this one file.
|
|
|
29
31
|
entries) earlier or later. Reordering never changes an entry's status; it only changes when the
|
|
30
32
|
entry is eligible to run.
|
|
31
33
|
- After any add/reorder, append a `## Log` line describing the change (e.g.
|
|
32
|
-
`- <YYYY-MM-DD> added 132 to wave 2`).
|
|
34
|
+
`- <YYYY-MM-DD HH:MM UTC> added 132 to wave 2`).
|
|
33
35
|
|
|
34
36
|
## Sync status mirror
|
|
35
37
|
|
|
@@ -46,8 +48,8 @@ next" in under a minute, just from this one file.
|
|
|
46
48
|
|
|
47
49
|
## Append a log line
|
|
48
50
|
|
|
49
|
-
- One line per event,
|
|
50
|
-
an existing line (append-only).
|
|
51
|
+
- One line per event, starting `YYYY-MM-DD HH:MM UTC`, appended at the bottom of `## Log`. Never
|
|
52
|
+
edit or delete an existing line (append-only).
|
|
51
53
|
- Every line is plain language a non-expert can read, never a codename or a raw `field -> value`.
|
|
52
54
|
A delivery event follows the EM-to-CTO one-line shape with an `outcome.md` link (see
|
|
53
55
|
`file-format.md`); bookkeeping events (created, an intent added to a wave, a wave completed, a
|
|
@@ -68,7 +70,9 @@ next" in under a minute, just from this one file.
|
|
|
68
70
|
|
|
69
71
|
1. Confirm the roadmap's `## Goal` prose condition is met (every entry `delivered` or explicitly
|
|
70
72
|
`abandoned` with a recorded reason, plus whatever else the goal states).
|
|
71
|
-
2. Create `roadmaps/archived/`
|
|
73
|
+
2. Create `roadmaps/archived/` beside `roadmaps/` (both siblings of `INDEX.md`) if it does not
|
|
74
|
+
exist yet.
|
|
72
75
|
3. Move the file: `roadmaps/{slug}.md` -> `roadmaps/archived/{slug}.md`. `roadmaps/` itself then
|
|
73
76
|
lists only live (open or in-flight) roadmaps.
|
|
74
|
-
4. Append the final `## Log` line before or as part of the move:
|
|
77
|
+
4. Append the final `## Log` line before or as part of the move:
|
|
78
|
+
`- <YYYY-MM-DD HH:MM UTC> roadmap closed`.
|
package/templates/roadmap.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Roadmap: <name>
|
|
2
2
|
|
|
3
|
-
(one-line meta: what this roadmap delivers, and which
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
(one-line meta: what this roadmap delivers, and which tier it lives in. `roadmaps/` is a sibling
|
|
4
|
+
of `INDEX.md` — a project's root or the global `~/.plastic/`, never inside `store/`. When this
|
|
5
|
+
roadmap's goal is reached, move this file from `roadmaps/{slug}.md` to
|
|
6
|
+
`roadmaps/archived/{slug}.md`; `roadmaps/` itself lists only live roadmaps.)
|
|
6
7
|
|
|
7
8
|
## Goal
|
|
8
9
|
(a checkable prose condition — one or a few sentences a human or coordinator reads to decide the
|
|
@@ -26,4 +27,4 @@ any conflict between the checkbox/token here and INDEX's real status.
|
|
|
26
27
|
what shipped and its impact for a non-expert reader, no jargon or internal codenames, ending with a
|
|
27
28
|
link to that entry-intent's `outcome.md`. Never restate outcome detail here; link to it instead.
|
|
28
29
|
Newest at the bottom.)
|
|
29
|
-
- 2026-01-01 Shipped the first wave of this roadmap; see store/<intent-id>--<slug>/outcome.md.
|
|
30
|
+
- 2026-01-01 00:00 UTC Shipped the first wave of this roadmap; see store/<intent-id>--<slug>/outcome.md.
|