@zalom/plastic 1.0.0-beta.33 → 1.0.0-beta.35
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 +8 -6
- package/agents/plastic-intent-discovery.md +7 -4
- package/bin/plastic.js +7 -3
- package/hooks/statusline +48 -6
- package/package.json +1 -1
- package/scripts/dashboard.rb +86 -21
- package/scripts/hook-continue +17 -0
- package/scripts/install.rb +42 -6
- package/scripts/lib/bridge.rb +134 -35
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/installer_core.rb +40 -6
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/plastic-lock +2 -2
- package/skills/auto/SKILL.md +4 -2
- package/skills/continuing/SKILL.md +5 -1
- package/skills/dashboard/SKILL.md +25 -8
- package/skills/doctor/SKILL.md +6 -6
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-discovery/SKILL.md +8 -7
- package/skills/intent-starting/SKILL.md +11 -8
- package/skills/releasing/SKILL.md +4 -1
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
package/PLASTIC.md
CHANGED
|
@@ -213,12 +213,14 @@ run the main session on the best available thinking model (Fable, Opus, or whate
|
|
|
213
213
|
them). This is advisory only: it changes no behavior and blocks nothing if ignored, and it
|
|
214
214
|
concerns the human's main session, never a dispatched subagent.
|
|
215
215
|
|
|
216
|
-
**`plastic-intent-discovery`.** The What-stage agent. It fires at intent activation,
|
|
217
|
-
delivery lock is armed and Why begins
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
216
|
+
**`plastic-intent-discovery`.** The What-stage agent. It fires at intent activation, after the
|
|
217
|
+
delivery lock is armed and before Why begins, running under that lock as the owner session (it
|
|
218
|
+
does not acquire the lock itself and is not blocked by it): it reads the intent's
|
|
219
|
+
`chain`/`sources` frontmatter, runs QMD-first discovery over completed predecessor work and
|
|
220
|
+
related parked or future intents, and deposits findings to `resources/discovery--<slug>.md` in
|
|
221
|
+
the intent directory ONLY. It never writes the intent file, `spec.md`, or any other lifecycle
|
|
222
|
+
deliverable; the Why-stage `plastic-brainstorming` agent reads its deposit and enriches
|
|
223
|
+
`## Context`.
|
|
222
224
|
|
|
223
225
|
`savepoint.md` — a deterministic, append-only ledger of cycle-step milestones (one line per
|
|
224
226
|
lifecycle boundary, newest at the bottom), written automatically by the gate hook. It is
|
|
@@ -7,13 +7,14 @@ description: |
|
|
|
7
7
|
<example>Context: An intent is being moved from Future to Active.
|
|
8
8
|
user: "Board this intent and gather what we already know"
|
|
9
9
|
assistant: "I'll use the intent-discovery agent to run QMD discovery and deposit findings to resources/"
|
|
10
|
-
<commentary>What-stage discovery runs at activation,
|
|
10
|
+
<commentary>What-stage discovery runs at activation, after the lock is armed, under it.</commentary></example>
|
|
11
11
|
model: sonnet
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
You are the Plastic Intent Discovery agent. You own the What stage: at intent
|
|
15
|
-
activation,
|
|
16
|
-
that already exists and deposit it
|
|
15
|
+
activation, after the lock is armed and before Why begins, under the lock as
|
|
16
|
+
the owner session, you gather the context that already exists and deposit it
|
|
17
|
+
for the Why stage to consume.
|
|
17
18
|
|
|
18
19
|
## Responsibilities
|
|
19
20
|
1. **Read the intent's links.** Load the activating intent file's `chain` and
|
|
@@ -32,6 +33,8 @@ that already exists and deposit it for the Why stage to consume.
|
|
|
32
33
|
## Constraints
|
|
33
34
|
- Read-only with respect to the intent: your single output is
|
|
34
35
|
`resources/discovery--<slug>.md`.
|
|
35
|
-
-
|
|
36
|
+
- You do not ACQUIRE the delivery lock; you run under the lock the
|
|
37
|
+
orchestrator armed (owner session, inherited session id) and are not
|
|
38
|
+
blocked by it.
|
|
36
39
|
- End with a structured completion report per the spawn preamble's report
|
|
37
40
|
contract.
|
package/bin/plastic.js
CHANGED
|
@@ -50,8 +50,12 @@ try {
|
|
|
50
50
|
})
|
|
51
51
|
} catch (err) {
|
|
52
52
|
if (err.status) process.exit(err.status)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.error('
|
|
53
|
+
// Ruby cannot run to print its own message when it is missing, so this
|
|
54
|
+
// mirrors scripts/lib/preflight.rb's FATAL block word for word.
|
|
55
|
+
console.error('Plastic needs Ruby 3.0.0 or newer to run its scripts (found not found).')
|
|
56
|
+
console.error('Install a pinned Ruby with mise:')
|
|
57
|
+
console.error(' curl https://mise.run | sh # only if mise is not installed yet')
|
|
58
|
+
console.error(' mise use --global ruby@3.3')
|
|
59
|
+
console.error('Then re-run the Plastic installer.')
|
|
56
60
|
process.exit(1)
|
|
57
61
|
}
|
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/install.rb
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
# so install.rb is the single file-syncer; the ledger action is contextual.
|
|
15
15
|
|
|
16
16
|
require_relative "lib/installer_core"
|
|
17
|
+
require_relative "lib/preflight"
|
|
17
18
|
|
|
18
19
|
class Install < InstallerCore
|
|
19
20
|
def cli(argv = ARGV)
|
|
@@ -22,6 +23,9 @@ class Install < InstallerCore
|
|
|
22
23
|
return 0
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
gate = preflight_gate
|
|
27
|
+
return gate unless gate.zero?
|
|
28
|
+
|
|
25
29
|
force = argv.include?("--force")
|
|
26
30
|
reinstall = argv.include?("--reinstall")
|
|
27
31
|
ledger_action = flag_value(argv, "--ledger-action")
|
|
@@ -42,19 +46,19 @@ class Install < InstallerCore
|
|
|
42
46
|
return 1
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action)
|
|
49
|
+
run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv)
|
|
46
50
|
0
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
# Hermetic entrypoint (no prompting / no exit). Returns the per-agent results array.
|
|
50
|
-
def run(selected:, force: false, reinstall: false, ledger_action: nil)
|
|
54
|
+
def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin)
|
|
51
55
|
fresh = !installed?
|
|
52
56
|
mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
|
|
53
57
|
|
|
54
58
|
distribute(mode)
|
|
55
59
|
bootstrap if fresh
|
|
56
60
|
|
|
57
|
-
results = selected.map { |key| install_for_agent(key, force) }
|
|
61
|
+
results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
|
|
58
62
|
|
|
59
63
|
action = ledger_action || (fresh ? "install" : "reinstall")
|
|
60
64
|
ledger_append(version, action)
|
|
@@ -72,8 +76,37 @@ class Install < InstallerCore
|
|
|
72
76
|
File.exist?(path) ? File.read(path).strip : nil
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
# Injectable pre-flight gate: real probes as default args, printing to an
|
|
80
|
+
# injectable `out:` IO so this is hermetically testable via StringIO. Returns
|
|
81
|
+
# 1 (stop the install) when Ruby is missing/too-old, else 0.
|
|
82
|
+
def preflight_gate(ruby_version: RUBY_VERSION, node_version: node_probe, git_present: git_probe,
|
|
83
|
+
mise_present: mise_probe, out: $stderr)
|
|
84
|
+
result = Preflight.check(ruby_version: ruby_version, node_version: node_version,
|
|
85
|
+
git_present: git_present, mise_present: mise_present)
|
|
86
|
+
result[:messages].each { |message| out.puts(message) }
|
|
87
|
+
result[:fatal] ? 1 : 0
|
|
88
|
+
end
|
|
89
|
+
|
|
75
90
|
private
|
|
76
91
|
|
|
92
|
+
def node_probe
|
|
93
|
+
`node --version`.strip
|
|
94
|
+
rescue StandardError
|
|
95
|
+
""
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def git_probe
|
|
99
|
+
!`command -v git`.strip.empty?
|
|
100
|
+
rescue StandardError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def mise_probe
|
|
105
|
+
!`command -v mise`.strip.empty?
|
|
106
|
+
rescue StandardError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
77
110
|
def flag_value(argv, name)
|
|
78
111
|
i = argv.index(name)
|
|
79
112
|
return nil unless i && argv[i + 1]
|
|
@@ -119,9 +152,12 @@ class Install < InstallerCore
|
|
|
119
152
|
--alpha Alpha channel
|
|
120
153
|
|
|
121
154
|
Other options:
|
|
122
|
-
--reinstall
|
|
123
|
-
--force
|
|
124
|
-
|
|
155
|
+
--reinstall Re-sync core files for the installed version (repair). Store untouched.
|
|
156
|
+
--force Overwrite existing files without prompting
|
|
157
|
+
--statusline VALUE keep or plastic. If an existing statusline is found, this
|
|
158
|
+
skips the interactive prompt. Interactive sessions ask by
|
|
159
|
+
default; non-interactive sessions default to keep.
|
|
160
|
+
-h, --help Show this help
|
|
125
161
|
|
|
126
162
|
Notes:
|
|
127
163
|
Install is one-shot. If Plastic is already installed, use `update` to upgrade or
|