@zalom/plastic 2.0.0-alpha.3 → 2.0.0-alpha.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.4",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ # intent-screen (intent 316) - prints the intent screen for one intent directory:
5
+ # the title, the field table (Store, Status, Stage, Savepoint, Progress, Next,
6
+ # Insight, each with a note), and the Steps table, filled from the record by
7
+ # scripts/lib/intent_screen.rb. The session adds the What-this-means bullets and
8
+ # the close; it never edits the numbers.
9
+ #
10
+ # Usage:
11
+ # intent-screen <intent_dir> [--template <path>]
12
+ #
13
+ # The store root is the directory two levels above the intent (<root>/store/<id--slug>);
14
+ # the template defaults to templates/intent-screen.md next to this script's dir,
15
+ # in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
16
+ # ~/.plastic/templates) alike.
17
+ #
18
+ # Exit codes:
19
+ # 0 - the screen is on stdout
20
+ # 2 - usage error, or the path is not an intent directory (one line on stderr)
21
+
22
+ require_relative "lib/intent_screen"
23
+
24
+ def usage_abort(message)
25
+ warn "intent-screen: #{message}"
26
+ exit 2
27
+ end
28
+
29
+ args = ARGV.dup
30
+ template_path = nil
31
+ positional = []
32
+ while (arg = args.shift)
33
+ case arg
34
+ when "--template"
35
+ template_path = args.shift or usage_abort("--template needs a path")
36
+ else
37
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
38
+ positional << arg
39
+ end
40
+ end
41
+
42
+ usage_abort("usage: intent-screen <intent_dir> [--template <path>]") unless positional.length == 1
43
+ intent_dir = File.expand_path(positional.first)
44
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
45
+
46
+ store_root = File.expand_path("../..", intent_dir)
47
+ template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
48
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
49
+
50
+ $stdout.write IntentScreen.render(intent_dir: intent_dir, store_root: store_root,
51
+ template: File.read(template_path))
52
+ exit 0
@@ -423,6 +423,8 @@ class InstallerCore
423
423
  "scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
424
424
  "scripts/write-handoff" => "scripts/write-handoff",
425
425
  "scripts/day-summary" => "scripts/day-summary",
426
+ "scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
427
+ "scripts/intent-screen" => "scripts/intent-screen",
426
428
  "scripts/hook-savepoint" => "scripts/hook-savepoint",
427
429
  }
428
430
  end
@@ -0,0 +1,221 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+ # IntentScreen (intent 316) - fills templates/intent-screen.md from one intent's
4
+ # record: the intent file, the tier's INDEX.md, savepoint.md, and checklist.md.
5
+ # Every number on the screen comes from here so the session never writes one by
6
+ # eye. Pure: explicit paths in, a Markdown string out; no ENV, no Dir.pwd.
7
+ module IntentScreen
8
+ BAR_WIDTH = 20
9
+ ON = "█"
10
+ OFF = "░"
11
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
12
+ SECTIONS = %w[Active Future Completed Abandoned].freeze
13
+ ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
14
+ STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·]\s*/i
15
+ INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
16
+ SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
17
+
18
+ # Where a resume lands, from the ledger's last line (the boarding matrix).
19
+ def self.landing_stage(stage, milestone)
20
+ case stage
21
+ when "Done" then "Done"
22
+ when "What" then "Why"
23
+ when "Why" then milestone.to_s.include?("spec.md") ? "How" : "Why"
24
+ when "How" then milestone.to_s.include?("checklist.md") ? "Exec" : "How"
25
+ when "Exec" then milestone.to_s.include?("outcome.md") ? "ready to complete" : "Exec"
26
+ else "Why"
27
+ end
28
+ end
29
+
30
+ def self.intent_dir?(dir)
31
+ return false unless dir && File.directory?(dir)
32
+
33
+ base = File.basename(dir)
34
+ return false unless base.match?(/\A[0-9][0-9a-z]*--[\w-]+\z/)
35
+
36
+ File.exist?(File.join(dir, "#{base}.md"))
37
+ end
38
+
39
+ def self.render(intent_dir:, store_root:, template:)
40
+ base = File.basename(intent_dir)
41
+ id = base.split("--", 2).first
42
+ intent_text = File.read(File.join(intent_dir, "#{base}.md"))
43
+
44
+ fields = {}
45
+ fields.merge!(store_fields(store_root))
46
+ status, title = index_fields(store_root, id)
47
+ fields["status"] = status
48
+ fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
49
+ fields["id"] = id
50
+ fields["name"] = title || fallback_name(intent_text)
51
+ fields.merge!(savepoint_fields(intent_dir, intent_text))
52
+ items = checklist_items(intent_dir)
53
+ fields.merge!(progress_fields(items))
54
+ fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
55
+ fields.merge!(insight_fields(intent_text))
56
+ fields["steps.rows"] = steps_rows(items)
57
+ fields["meaning"] = ""
58
+ fields["close"] = ""
59
+
60
+ out = template.dup
61
+ fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
62
+ out.gsub(/\n{3,}/, "\n\n")
63
+ end
64
+
65
+ # --- store ---------------------------------------------------------------------
66
+
67
+ def self.store_fields(store_root)
68
+ parent = File.basename(File.dirname(store_root))
69
+ if parent == "projects"
70
+ slug = File.basename(store_root)
71
+ { "store" => "project:#{slug}", "store.note" => "the #{slug} project store" }
72
+ else
73
+ { "store" => "global", "store.note" => "the global store" }
74
+ end
75
+ end
76
+
77
+ # --- INDEX.md -----------------------------------------------------------------
78
+
79
+ def self.index_fields(store_root, id)
80
+ path = File.join(store_root, "INDEX.md")
81
+ return ["unlisted", nil] unless File.exist?(path)
82
+
83
+ section = nil
84
+ File.foreach(path) do |line|
85
+ if line.start_with?("## ")
86
+ name = line[3..].strip
87
+ section = SECTIONS.include?(name) ? name : nil
88
+ next
89
+ end
90
+ next unless section && line.strip.start_with?("- [")
91
+
92
+ m = line.match(/\A\s*- \[#{Regexp.escape(id)}\s+[-—]\s+(.+?)\]\(/)
93
+ return [section, m[1].strip] if m
94
+ end
95
+ ["unlisted", nil]
96
+ end
97
+
98
+ def self.fallback_name(intent_text)
99
+ m = intent_text.match(/^intent:\s*["']?(.+?)["']?\s*$/)
100
+ text = m ? m[1] : ""
101
+ text.length > 60 ? "#{text[0, 57]}..." : text
102
+ end
103
+
104
+ # --- savepoint.md ---------------------------------------------------------------
105
+
106
+ def self.savepoint_fields(intent_dir, intent_text)
107
+ path = File.join(intent_dir, "savepoint.md")
108
+ lines = File.exist?(path) ? File.readlines(path).map(&:strip).reject(&:empty?) : []
109
+ last = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact.first
110
+ unless last
111
+ return { "stage" => "Why", "stage.note" => "no savepoint line yet",
112
+ "savepoint" => "none", "savepoint.note" => "" }
113
+ end
114
+
115
+ ts, stage, milestone = last[1], last[2], last[3]
116
+ landing = landing_stage(stage, milestone)
117
+ delivered = lines.map { |l| l.match(SAVEPOINT_RE) }.compact.map { |m| m[2] }.uniq
118
+ delivered &= %w[What Why How Exec]
119
+ note = if landing == "Done"
120
+ "delivered; the record is immutable"
121
+ elsif landing == "ready to complete"
122
+ "outcome.md is real; run the ending procedure"
123
+ else
124
+ "#{delivered.join(', ')} delivered; the work is open"
125
+ end
126
+ { "stage" => landing, "stage.note" => note,
127
+ "savepoint" => "#{stage} · #{milestone}", "savepoint.note" => human_time(ts) }
128
+ end
129
+
130
+ def self.human_time(ts)
131
+ m = ts.match(/\A(\d{4}-\d\d-\d\d)T(\d\d:\d\d)/)
132
+ m ? "#{m[1]} #{m[2]} UTC" : ts
133
+ end
134
+
135
+ # --- checklist.md --------------------------------------------------------------
136
+
137
+ def self.items_present?(intent_dir)
138
+ path = File.join(intent_dir, "checklist.md")
139
+ return false unless File.exist?(path)
140
+
141
+ !File.read(path).lstrip.start_with?(PLACEHOLDER_SENTINEL)
142
+ end
143
+
144
+ def self.checklist_items(intent_dir)
145
+ return [] unless items_present?(intent_dir)
146
+
147
+ File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
148
+ m = line.match(ITEM_RE)
149
+ next unless m
150
+
151
+ text = m[2].strip
152
+ next if text == "..."
153
+
154
+ { done: m[1] != " ", text: text.sub(STEP_PREFIX_RE, "") }
155
+ end
156
+ end
157
+
158
+ def self.progress_fields(items)
159
+ total = items.length
160
+ done = items.count { |i| i[:done] }
161
+ on = total.zero? ? 0 : (done * BAR_WIDTH) / total
162
+ bar = (ON * on) + (OFF * (BAR_WIDTH - on))
163
+ note = if total.zero?
164
+ "no checklist yet"
165
+ elsif done == total
166
+ "all steps done"
167
+ else
168
+ "#{total - done} steps open"
169
+ end
170
+ { "progress.bar" => bar, "progress.done" => done.to_s, "progress.total" => total.to_s,
171
+ "progress.note" => note }
172
+ end
173
+
174
+ def self.next_fields(items, status, checklist_present:)
175
+ return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
176
+ return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
177
+
178
+ idx = items.index { |i| !i[:done] }
179
+ return { "next" => "", "next.note" => "all steps done" } unless idx
180
+
181
+ head, = split_first_clause(items[idx][:text])
182
+ { "next" => "S#{idx + 1} · #{escape(head)}", "next.note" => "first open step" }
183
+ end
184
+
185
+ def self.steps_rows(items)
186
+ return "| | | no steps yet |" if items.empty?
187
+
188
+ items.each_with_index.map do |item, i|
189
+ "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(item[:text])} |"
190
+ end.join("\n")
191
+ end
192
+
193
+ def self.escape(text)
194
+ text.gsub("|", "\\|")
195
+ end
196
+
197
+ # --- ## Insights ----------------------------------------------------------------
198
+
199
+ def self.insight_fields(intent_text)
200
+ section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
201
+ entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
202
+ return { "insight" => "none yet", "insight.note" => "" } unless entry
203
+
204
+ ts, text = entry[1], entry[2].strip
205
+ head, tail = split_first_clause(text)
206
+ note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
207
+ { "insight" => escape(head), "insight.note" => escape(note) }
208
+ end
209
+
210
+ def self.split_first_clause(text)
211
+ m = text.match(/\A(.+?)[.;](\s+.*|\z)/m)
212
+ head = m ? m[1] : text
213
+ tail = m ? m[2].to_s.strip : ""
214
+ if head.length > 60
215
+ cut = head[0, 60].rindex(" ") || 60
216
+ tail = "#{head[cut..].strip} #{tail}".strip
217
+ head = head[0, cut].strip
218
+ end
219
+ [head, tail]
220
+ end
221
+ end
@@ -5,7 +5,7 @@ description: >-
5
5
  where we left off", "where was I", "what should I work on", names a specific intent to
6
6
  resume (by id or description, or `--intent {id}`), or names a roadmap or delivery batch to
7
7
  resume (`--roadmap {slug}`, "where is the roadmap", "where did that batch land"). Presents
8
- state and resumes at the last delivered station; it never asks auto or guided, never boots
8
+ state and resumes at the last delivered stage; it never asks auto or guided, never boots
9
9
  (the SessionStart hook owns boot), and never drives work autonomously (plastic-auto does).
10
10
  Absorbs the former continuing, project-continuing, and roadmap-continuing skills and the
11
11
  read half of the former intent-starting skill (intent 304).
@@ -79,19 +79,20 @@ QMD-first when the intent is named by description: run
79
79
  authoritative intent file. The command is a no-op when QMD is absent; fall back to
80
80
  `INDEX.md`.
81
81
 
82
- If the intent is terminal (`## Completed` or `## Abandoned` in `INDEX.md`): report only.
83
- Summarize its `outcome.md` and ask what is next; never reopen it.
82
+ If the intent is terminal (`## Completed` or `## Abandoned` in `INDEX.md`): print the
83
+ intent screen (Status shows the terminal section, Next is empty), summarize its
84
+ `outcome.md`, and ask what is next; never reopen it.
84
85
 
85
86
  For a live intent's directory:
86
87
 
87
88
  1. **Read `savepoint.md` first.** It is a deterministic, append-only ledger, one line per
88
- event, newest at the bottom: `{utc-iso8601} {Stage} {milestone}`. Classify the station
89
+ event, newest at the bottom: `{utc-iso8601} {Stage} {milestone}`. Classify the stage
89
90
  from the last line alone (the table in `references/boarding-matrix.md`, read when
90
91
  classifying), then verify only that line's artifact is real (sentinel-aware:
91
92
  `Savepoint.stage_file_present?`). Do not re-probe every lifecycle file.
92
- 2. **Drift.** When the last line disagrees with the files on disk, rebuild the ledger from
93
+ 2. **Stale ledger.** When the last line disagrees with the files on disk, rebuild the ledger from
93
94
  disk and note the correction. A rebuilt ledger is the file-landing skeleton, which still
94
- pins the station:
95
+ pins the stage:
95
96
  ```bash
96
97
  ruby -r ~/.plastic/scripts/lib/savepoint -e 'Savepoint.rebuild_savepoint("<intent_dir>")'
97
98
  ```
@@ -99,18 +100,16 @@ For a live intent's directory:
99
100
  else the newest prior day) is the prior session's own account of where things stand; read
100
101
  it after the ledger, never instead of it.
101
102
  4. **Derive the next step:** the first unchecked item in `checklist.md` when it exists, else
102
- the next thing the station needs (see the matrix). The newest `## Insights` entry supplies
103
+ the next thing the stage needs (see the matrix). The newest `## Insights` entry supplies
103
104
  the human-readable context; an entry marked `(autonomous)` means an auto team was
104
105
  delivering it, so say so and offer to hand back to `plastic-auto`.
105
- 5. **Announce, then continue at that station:**
106
- ```
107
- Resuming intent [ID] - [name]
108
- Store: [global | project:<slug>]
109
- Station: [from the ledger's last line]
110
- Next step: [first unchecked checklist item | what the station needs]
111
- Context: [newest ## Insights entry | hand-off summary]
112
- Drift: [none | ledger rebuilt from disk]
113
- ```
106
+ 5. **Print the intent screen, then continue at that stage.** Run
107
+ `ruby ~/.plastic/scripts/intent-screen <intent_dir>` and print its output as it is: the
108
+ title, the field table, and the Steps table come from the record, never by eye. Under it
109
+ write **What this means** as two to four bullets in plain words (what the intent is for,
110
+ what has landed, what is left, any defect named by step), then close with
111
+ **needs input:** naming the first open step. The screen's shape is
112
+ `~/.plastic/templates/intent-screen.md`; the script fills it, the session never edits the numbers.
114
113
  Then continue the work in the session's current mode. In auto mode the running team
115
114
  already holds the delivery lock; if a lock is held by a session that is gone, the
116
115
  `plastic-doctor` skill's lock section repairs or reclaims it.
@@ -141,6 +140,6 @@ For a live intent's directory:
141
140
  | Trigger | Read |
142
141
  |---|---|
143
142
  | Filling the board on the project route | `references/board-fill.md` |
144
- | Classifying the station from the ledger's last line | `references/boarding-matrix.md` |
143
+ | Classifying the stage from the ledger's last line | `references/boarding-matrix.md` |
145
144
  | Explaining why one roadmap ranked above another | `references/liveness-ranking.md` |
146
145
  | Saving or restoring context across a long session, or debugging a resume | `references/context-management.md` |
@@ -1,8 +1,8 @@
1
- # Boarding matrix: which station a resume lands at
1
+ # Boarding matrix: which stage a resume lands at
2
2
 
3
- The station is derived from `savepoint.md`'s last line plus the real artifacts on disk.
3
+ The stage is derived from `savepoint.md`'s last line plus the real artifacts on disk.
4
4
  Classify from the last line alone, then verify only that line's artifact is real
5
- (sentinel-aware). On drift, rebuild the ledger from disk and note it.
5
+ (sentinel-aware). When the ledger is stale, rebuild it from disk and note it.
6
6
 
7
7
  | savepoint last line | latest delivered | lands at | continue with |
8
8
  |---|---|---|---|
@@ -10,11 +10,11 @@ Classify from the last line alone, then verify only that line's artifact is real
10
10
  | `Why started` (spec still sentinel) | What | **Why** | continue the conversation; rulings land as insights |
11
11
  | `Why spec.md created` | Why | **How** | the action files, `plan.md`, `checklist.md` |
12
12
  | `How started` / `How plan.md created` | (How in progress) | **How** | finish `plan.md` and `checklist.md` |
13
- | `How checklist.md created` / `Exec started` | How | **Exec** | do the work, tick the checklist |
13
+ | `How checklist.md created` / `Exec started` | How | **Exec** | do the work, check off the checklist |
14
14
  | `Exec outcome.md created` | Exec | **ready to complete** | the ending procedure (`plastic-intent-ending`) |
15
15
  | `Done delivered` / `Done abandoned` | terminal | **report only** | immutable; ask what is next |
16
16
 
17
- ## Per-station behaviour (what "continue" means)
17
+ ## Per-stage behaviour (what "continue" means)
18
18
 
19
19
  - **Why**: continue the conversation, or run the work directly when the request is already
20
20
  clear; every ruling is recorded as it lands.
@@ -19,7 +19,7 @@ step looks stale):
19
19
  (see `SKILL.md`'s `## Conditional Ledger-Resume` for the full state table).
20
20
  2. Confirm the artifact that line implies (`plan.md`, `checklist.md`, `outcome.md`, ...) is
21
21
  present and non-empty on disk.
22
- 3. If the two disagree, the ledger has drifted: rebuild it rather than hand-editing:
22
+ 3. If the two disagree, the ledger is stale: rebuild it rather than hand-editing:
23
23
  `ruby -r ~/.plastic/scripts/lib/savepoint -e 'Savepoint.rebuild_savepoint("<intent_dir>")'`
24
24
  4. Re-read the rebuilt last line and re-derive the next step from `checklist.md`'s first
25
25
  unchecked item.
@@ -0,0 +1,22 @@
1
+ ## ▶ {{id}} · {{name}}
2
+
3
+ | | | |
4
+ | --- | --- | --- |
5
+ | **Store** | {{store}} | {{store.note}} |
6
+ | **Status** | {{status}} | {{status.note}} |
7
+ | **Stage** | {{stage}} | {{stage.note}} |
8
+ | **Savepoint** | {{savepoint}} | {{savepoint.note}} |
9
+ | **Progress** | {{progress.bar}} {{progress.done}} / {{progress.total}} | {{progress.note}} |
10
+ | **Next** | {{next}} | {{next.note}} |
11
+ | **Insight** | {{insight}} | {{insight.note}} |
12
+
13
+ **What this means**
14
+ {{meaning}}
15
+
16
+ **Steps**
17
+
18
+ | Step | Status | What |
19
+ | --- | --- | --- |
20
+ {{steps.rows}}
21
+
22
+ {{close}}