@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 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, before the
217
- delivery lock is armed and Why begins: it reads the intent's `chain`/`sources` frontmatter,
218
- runs QMD-first discovery over completed predecessor work and related parked or future intents,
219
- and deposits findings to `resources/discovery--<slug>.md` in the intent directory ONLY. It
220
- never writes the intent file, `spec.md`, or any other lifecycle deliverable; the Why-stage
221
- `plastic-brainstorming` agent reads its deposit and enriches `## Context`.
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, before the lock and Why.</commentary></example>
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, before the lock is armed and Why begins, you gather the context
16
- that already exists and deposit it for the Why stage to consume.
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
- - Never take the delivery lock; you run before it is armed.
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
- console.error('Error: Ruby is required to run Plastic.')
54
- console.error(' macOS: Ruby is pre-installed')
55
- console.error(' Linux: sudo apt install ruby / dnf install ruby')
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; 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.33",
3
+ "version": "1.0.0-beta.35",
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)
@@ -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 Re-sync core files for the installed version (repair). Store untouched.
123
- --force Overwrite existing files without prompting
124
- -h, --help Show this help
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