@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 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 store-root sibling of `INDEX.md`, identical in the global
333
- store (`~/.plastic/store/`) and any project store (`~/.plastic/projects/{slug}/store/`).
334
- `roadmaps/` lists only live (open or in-flight) roadmaps: once a roadmap's goal is reached, it
335
- moves to `roadmaps/archived/{slug}.md`, a sibling subdirectory scaffolded once with a `.gitkeep`.
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, dated, written the way an engineering manager would brief a
349
- non-expert executive: what shipped and why it matters, no jargon or codenames, ending with a link
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; the session's bridge
89
- # (plastic-{session_id}.json) is the authoritative "what THIS session is driving".
90
- # This beats the shared, savepoint-recency heuristic so parallel sessions on one
91
- # store no longer overwrite each other's line. grep/sed only - no jq, no ruby.
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
- BRIDGE="${PLASTIC_TMP:-/tmp}/plastic-${SID}.json"
96
- if [ -f "$BRIDGE" ]; then
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.32",
3
+ "version": "1.0.0-beta.34",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,11 @@ def today
32
32
  s && !s.empty? ? Date.parse(s) : Date.today
33
33
  end
34
34
 
35
- STALE_DAYS = 14
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: has.("savepoint.md"),
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 deep refinement branch. Otherwise big.
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]) >= 4
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]} #{rec[:intent]}#{note}".rstrip }
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).map { |r| intent_line(r, QUADRANT_BULLET[q]) } }
543
- out["research"] = by_created_desc.call(research).map { |r| intent_line(r, "🔬") }
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
- .sort_by { |r| invert_ts(r[:last_accessed_at]) }
611
- .map { |r| intent_line(r, STATUS_GLYPH["active"]) },
612
- future: scoped.select { |r| r[:status] == "future" }
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)
@@ -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)
@@ -45,8 +45,18 @@ module Bridge
45
45
  (t.nil? || t.strip.empty?) ? "/tmp" : t
46
46
  end
47
47
 
48
- def self.path(session, tmp: tmp_dir)
49
- "#{tmp}/plastic-#{session}.json"
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
- # Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
127
- # for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
128
- # the one whose intent.store matches cwd, tie-break by newest mtime.
129
- def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
130
- if !blank?(session) && File.exist?(path(session, tmp: tmp))
131
- exact = read(session, tmp: tmp)
132
- return exact if bridge_valid?(exact)
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
- pool = auto.empty? ? parsed : auto
206
+ auto_pool = auto.empty? ? parsed : auto
161
207
 
162
208
  unless blank?(cwd)
163
209
  cwd_abs = File.expand_path(cwd)
164
- matching = pool.select do |c|
165
- store = c[:data].dig("intent", "store").to_s
166
- next false if store.empty?
167
- store_abs = File.expand_path(store)
168
- cwd_abs == store_abs ||
169
- cwd_abs.start_with?("#{store_abs}/") ||
170
- store_abs.start_with?("#{cwd_abs}/")
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
- pool.max_by { |c| c[:mtime] }&.fetch(:data)
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
- current = path(session, tmp: tmp)
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 == current
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
- def self.read(session, tmp: tmp_dir)
260
- p = path(session, tmp: tmp)
261
- return nil unless File.exist?(p)
262
- JSON.parse(File.read(p))
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
- p = path(session, tmp: tmp)
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
- def self.disarm_auto(session)
718
- data = read(session)
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",
@@ -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 the full doctor after a successful update and print a human-readable
62
- # summary. Informational only: does not raise and does not affect the update's
63
- # exit code. Accepts injected `doctor` and `out` for hermetic unit tests.
64
- def run_post_update_doctor(doctor: nil, out: $stdout)
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.
@@ -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). See `plastic-dashboard` for the fill rules and entry flow.
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 board only reaches
65
- the user when it is in the chat reply, not in tool-call stdout. Never describe the board
66
- instead of showing it.
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 deep refinement branches; big otherwise.
107
- - **Value high** when any of: explicit `value: high`; a human-authored **root** intent; an
108
- intent with a non-empty `chain`; or an intent that is a `source` of ≥1 other intent. Else low.
109
- - **Flags** `unblocked` only when a **future** intent has **all** its `sources` done;
110
- `stale` only on future intents past the staleness threshold. Both kept low-noise by design.
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.read(ENV["CLAUDE_CODE_SESSION_ID"]); Worktree.finish(b, merge: true) if b'
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
@@ -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 store-root
10
- sibling of `INDEX.md`, in both the global store (`~/.plastic/store/`) and any project store
11
- (`~/.plastic/projects/{slug}/store/`).
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 stores; do not invent a different
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 store-root sibling of `INDEX.md`. Same layout in the global store
6
- (`~/.plastic/store/roadmaps/{slug}.md`) and any project store
7
- (`~/.plastic/projects/{slug}/store/roadmaps/{slug}.md`). Create the `roadmaps/` directory the
8
- first time a store gets a roadmap.
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 store it lives in.
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, dated, in plain-language EM-to-CTO voice: what shipped and why it matters to a
53
- non-expert reader, no jargon or internal codenames, ending with a link to that entry-intent's
54
- `outcome.md`:
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 stop
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 store root (global `~/.plastic/store/` or the current project's
14
- `~/.plastic/projects/{slug}/store/`); create `roadmaps/` inside it if it does not exist yet.
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 dated plain-language note that the roadmap was created.
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, dated `YYYY-MM-DD`, appended at the bottom of `## Log`. Never edit or delete
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/` in the store root if it does not exist yet.
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: `- <YYYY-MM-DD> roadmap closed`.
77
+ 4. Append the final `## Log` line before or as part of the move:
78
+ `- <YYYY-MM-DD HH:MM UTC> roadmap closed`.
@@ -1,8 +1,9 @@
1
1
  # Roadmap: <name>
2
2
 
3
- (one-line meta: what this roadmap delivers, and which store it lives in. When this roadmap's goal
4
- is reached, move this file from `roadmaps/{slug}.md` to `roadmaps/archived/{slug}.md`; `roadmaps/`
5
- itself lists only live roadmaps.)
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.