@zalom/plastic 2.0.0-alpha.10 → 2.0.0-alpha.11

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.10",
3
+ "version": "2.0.0-alpha.11",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -303,10 +303,33 @@ module ReportScreen
303
303
  { kind: "red", what: "#{sha} proven test-only and red", source: "outcome.md ## Verification" }
304
304
  end
305
305
 
306
- def self.ship_row(text, intent_dir, tag_reader)
307
- line = text.to_s.lines.find { |l| l =~ /\bmerge(d)?\b/i && l =~ /\b[0-9a-f]{7,40}\b/ }
308
- sha = line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
309
- version = tag_reader.call(intent_dir)
306
+ # Fix 2026-09-01: the record is the truth of delivery (D14: never a guess).
307
+ # The shipped version comes from outcome.md's own ship line first ("Shipped
308
+ # as `v2.0.0-alpha.10`", "released as **v2.0.0-alpha.5**", "released
309
+ # v2.0.0-alpha.9", "Tagged v1.14.1", "Delivered in", "Release v"); the injected tag reader (git) is the
310
+ # fallback when the record is silent. A bare version with no ship verb
311
+ # ("from 1.14.1") is not a shipped version.
312
+ SHIP_VERSION_RE = /\b(?:shipped|released?|delivered|tagged)\b(?:\s+(?:as|in))?[\s`*]*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)/i.freeze
313
+
314
+ def self.shipped_version(intent_dir)
315
+ text = outcome_text(intent_dir)
316
+ return nil unless text
317
+ m = text.match(SHIP_VERSION_RE)
318
+ m && m[1]
319
+ end
320
+
321
+ # The merge commit named on outcome.md's merge line, or nil. The CLI's tag
322
+ # reader asks git which tag contains it; the ship row prints it.
323
+ def self.merge_sha(intent_dir)
324
+ text = outcome_text(intent_dir)
325
+ return nil unless text
326
+ line = text.lines.find { |l| l =~ /\bmerge(d)?\b/i && l =~ /\b[0-9a-f]{7,40}\b/ }
327
+ line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
328
+ end
329
+
330
+ def self.ship_row(_text, intent_dir, tag_reader)
331
+ sha = merge_sha(intent_dir)
332
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
310
333
  return nil if sha.nil? && (version.nil? || version.to_s.empty?)
311
334
  ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
312
335
  sha_text = sha || NOT_RECORDED
@@ -524,7 +547,7 @@ module ReportScreen
524
547
  ts = delivered_timestamp(intent_dir)
525
548
  m = mode(intent_dir)
526
549
  dur = duration(intent_dir)
527
- version = tag_reader.call(intent_dir)
550
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
528
551
  ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
529
552
 
530
553
  lines = []
@@ -9,7 +9,7 @@
9
9
  # Usage:
10
10
  # report-screen state <intent_dir> [--changed "<text>"] [--ansi]
11
11
  # report-screen state --all <store_root> [--changed "<text>"] [--ansi]
12
- # report-screen delivered <intent_dir> [--ansi]
12
+ # report-screen delivered <intent_dir> [--ansi] [--repo <dir>]
13
13
  # report-screen delay <intent_dir> [--ansi]
14
14
  #
15
15
  # --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
@@ -23,6 +23,8 @@
23
23
  # 2 - usage error, or the path/store is not what the verb expects (one line
24
24
  # on stderr, stdout empty)
25
25
 
26
+ require "yaml"
27
+ require "shellwords"
26
28
  require_relative "lib/report_screen"
27
29
  require_relative "lib/intent_screen"
28
30
  require_relative "lib/screen_paint"
@@ -32,15 +34,47 @@ def usage_abort(message)
32
34
  exit 2
33
35
  end
34
36
 
35
- # The one place allowed to shell out (D2/D20): resolves the nearest git tag
36
- # reachable from the repo this script lives in, so `render_delivered`'s
37
- # version field and the `ship` evidence row are never invented. The pure
38
- # module never reads git itself; this is injected as `tag_reader:`.
39
- def git_tag_reader(repo_root)
40
- lambda do |_intent_dir|
41
- return nil unless File.exist?(File.join(repo_root, ".git"))
42
- tag = `git -C #{repo_root} describe --tags --abbrev=0 2>/dev/null`.strip
43
- tag.empty? ? nil : tag
37
+ # The one place allowed to shell out (D2/D20): resolves the tag that CONTAINS
38
+ # the intent's merge commit, in the project's own repository, so the version
39
+ # on the delivered screen is the release that merge shipped in. The
40
+ # repository comes from --repo, else from projects.yml beside the store (the
41
+ # installed layout, <home>/projects/<slug>/store/<id>), else the repository
42
+ # this script lives in (the in-repo layout). No merge sha, no repository, or
43
+ # no containing tag all answer nil, and the screen says "not recorded"
44
+ # rather than guessing from HEAD (D14). The pure module never reads git.
45
+ def resolve_repo(intent_dir, explicit_repo, script_dir)
46
+ candidates = []
47
+ candidates << File.expand_path(explicit_repo) if explicit_repo
48
+ if (m = intent_dir.match(%r{\A(.*)/projects/([^/]+)/store/[^/]+\z}))
49
+ home, slug = m[1], m[2]
50
+ projects_yml = File.join(home, "projects.yml")
51
+ if File.exist?(projects_yml)
52
+ data = begin
53
+ YAML.safe_load(File.read(projects_yml))
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ entry = data.is_a?(Hash) && data["projects"].is_a?(Hash) ? data["projects"][slug] : nil
58
+ path = entry.is_a?(Hash) ? entry["path"].to_s : ""
59
+ candidates << File.expand_path(path) unless path.empty?
60
+ end
61
+ end
62
+ candidates << File.expand_path("..", script_dir)
63
+ candidates.find { |c| File.exist?(File.join(c, ".git")) }
64
+ end
65
+
66
+ def git_tag_reader(explicit_repo:, script_dir:)
67
+ lambda do |intent_dir|
68
+ sha = ReportScreen.merge_sha(intent_dir)
69
+ return nil unless sha
70
+ repo = resolve_repo(intent_dir, explicit_repo, script_dir)
71
+ return nil unless repo
72
+ tags = `git -C #{repo.shellescape} tag --contains #{sha.shellescape} 2>/dev/null`
73
+ versions = tags.lines.map(&:strip).reject(&:empty?).filter_map do |tag|
74
+ v = tag.sub(/\Av/, "")
75
+ Gem::Version.correct?(v) ? [Gem::Version.new(v), tag] : nil
76
+ end
77
+ versions.min_by(&:first)&.last
44
78
  end
45
79
  end
46
80
 
@@ -49,6 +83,7 @@ verb = args.shift
49
83
  changed = nil
50
84
  ansi = false
51
85
  template_path = nil
86
+ repo_flag = nil
52
87
  positional = []
53
88
 
54
89
  while (arg = args.shift)
@@ -60,6 +95,8 @@ while (arg = args.shift)
60
95
  ansi = true
61
96
  when "--all"
62
97
  positional << "--all"
98
+ when "--repo"
99
+ repo_flag = args.shift or usage_abort("--repo needs a path")
63
100
  when "--template"
64
101
  template_path = args.shift or usage_abort("--template needs a path")
65
102
  else
@@ -104,8 +141,8 @@ when "delivered"
104
141
  usage_abort("usage: report-screen delivered <intent_dir>") unless target
105
142
  intent_dir = File.expand_path(target)
106
143
  usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
107
- repo_root = File.expand_path("..", __dir__)
108
- out = ReportScreen.render_delivered(intent_dir: intent_dir, tag_reader: git_tag_reader(repo_root))
144
+ out = ReportScreen.render_delivered(intent_dir: intent_dir,
145
+ tag_reader: git_tag_reader(explicit_repo: repo_flag, script_dir: __dir__))
109
146
  $stdout.write paint(out, ansi_enabled)
110
147
  when "delay"
111
148
  usage_abort("usage: report-screen delay <intent_dir>") unless target
@@ -204,7 +204,8 @@ Then How.
204
204
  5. Print `ruby ~/.plastic/scripts/report-screen state <intent_dir> --changed "How written, plan review next"`
205
205
  (D15; see `references/human-report-contract.md` for the full trigger list). This replaces
206
206
  the old prose State/Risk/Call briefing at this boundary. In auto mode the screen informs;
207
- it does not wait.
207
+ it does not wait. The screen opens the reply with nothing before it and no code fence: the
208
+ `MessageDisplay` hook paints only a reply whose first characters are the screen marker.
208
209
 
209
210
  Then Exec.
210
211
 
@@ -276,7 +277,8 @@ Read `../plastic-conventions/references/completion-and-done.md` for what "intent
276
277
  disarm (`/plastic-doctor check the lock status`); 6 means the structure check refused. Never
277
278
  leave an orphaned worktree; run `git worktree prune` on a stale reference.
278
279
  6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15): this is the
279
- owner report at End, replacing the old prose Done briefing.
280
+ owner report at End, replacing the old prose Done briefing. Print it as the first thing in
281
+ the reply, nothing before it and no code fence, or the hook cannot paint it.
280
282
 
281
283
  ## Error Handling
282
284