@zalom/plastic 2.0.0-alpha.8 → 2.0.0-alpha.9

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.
@@ -49,8 +49,16 @@ if [ "$is_index_zero" = 1 ]; then
49
49
  # single "#", not "##" — a real screen's own first delta can be as short
50
50
  # as "## " — a "##" glob would filter out exactly the message this hook
51
51
  # exists to recognize.
52
+ # 317a (B11): a screen can open with "## " (state, delivered), a bare "▶"
53
+ # (roster), or a bare "✔" (delay); the JSON may carry the glyph raw or
54
+ # \u-escaped depending on the encoder. All globs are literal bytes - case
55
+ # matching is byte-wise, so multibyte literals are safe under bash 3.2.
52
56
  case $INPUT in
53
57
  *'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
58
+ *'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
59
+ *'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
60
+ *'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
61
+ *'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
54
62
  esac
55
63
  else
56
64
  # A later chunk: hand off when this message's directory already exists
@@ -62,7 +70,7 @@ else
62
70
  [ "$is_final" = 1 ] && handoff=1
63
71
  case $INPUT in
64
72
  *'"delta":"|'*|*'"delta": "|'*) handoff=1 ;;
65
- *'"delta":"**Steps**'*|*'"delta": "**Steps**'*) handoff=1 ;;
73
+ *'"delta":"**'*|*'"delta": "**'*) handoff=1 ;;
66
74
  *'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
67
75
  *'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
68
76
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.8",
3
+ "version": "2.0.0-alpha.9",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,6 +66,8 @@
66
66
  # refused before removal; pass --discard-worktree-changes to override
67
67
  # deliberately.
68
68
  # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
69
+ # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
70
+ # exception to report-and-proceed; --allow-hollow-report overrides.
69
71
 
70
72
  require "fileutils"
71
73
  require "date"
@@ -77,6 +79,7 @@ require_relative "lib/savepoint"
77
79
  require_relative "lib/lock"
78
80
  require_relative "lib/intent_validator"
79
81
  require_relative "lib/outcome_guard"
82
+ require_relative "lib/report_screen"
80
83
  require_relative "lib/backfill_intent"
81
84
 
82
85
  DISPOSITIONS = %w[delivered abandoned].freeze
@@ -99,6 +102,7 @@ def parse_args(argv)
99
102
  opts = {
100
103
  store: nil, id: nil, disposition: nil, index: nil,
101
104
  outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
105
+ allow_hollow_report: false,
102
106
  session: nil, discard_worktree_changes: false,
103
107
  }
104
108
  i = 0
@@ -115,6 +119,7 @@ def parse_args(argv)
115
119
  when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
116
120
  when "--no-commit" then opts[:no_commit] = true
117
121
  when "--dry-run" then opts[:dry_run] = true
122
+ when "--allow-hollow-report" then opts[:allow_hollow_report] = true
118
123
  else
119
124
  usage_abort("unknown argument #{arg.inspect}")
120
125
  end
@@ -215,7 +220,75 @@ end
215
220
  # Replace the body of the intent file's `## Outcome` section with `summary`,
216
221
  # leaving every other section and the frontmatter untouched. No-op (returns
217
222
  # content unchanged) when summary is blank or the section is not found.
223
+ # 317a S9 (D7): the self-render gate. Renders the delivered screen's sources on
224
+ # the intent's own record and names what would come out hollow. This is intent
225
+ # 308's ONE deliberate exception to report-and-proceed: a hollow DELIVERED
226
+ # close is refused with exit 7 (--allow-hollow-report overrides), because 317
227
+ # closed exactly this way and the owner read a screen full of "not recorded".
228
+ # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
229
+ # exempt: the reader cannot expect labels the writer never had.
230
+ def hollow_report_reason(intent_dir, disposition)
231
+ return nil unless disposition == "delivered"
232
+
233
+ outcome = File.join(intent_dir, "outcome.md")
234
+ return nil unless File.exist?(outcome)
235
+ text = File.read(outcome)
236
+ return nil if text.include?("<!-- backfilled from the record by end-intent on ")
237
+
238
+ # The gate's evidence bar (A7/B7): it judges only records that CLAIM the
239
+ # modern convention - at least one actions/*.md heading carrying an S-label.
240
+ # A terse legacy close with no labeled matrix stays report-and-proceed.
241
+ heading_labels = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.flat_map do |path|
242
+ ReportScreen.split_by_headings(File.read(path)).filter_map do |heading, _body|
243
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).find { |tok| tok.match?(/\AS\d+\z/) }
244
+ end
245
+ end.uniq
246
+ return nil if heading_labels.empty?
247
+
248
+ rows = ReportScreen.delivered_rows(intent_dir)
249
+ return "outcome.md ## Delivered renders no rows although a labeled action matrix exists" if rows.empty?
250
+
251
+ needs = ReportScreen.section_of(text, "## Needs you").gsub(/<!--.*?-->/m, "").strip
252
+ if !needs.empty? && needs != "None" && ReportScreen.needs_you_rows(intent_dir).empty?
253
+ return "## Needs you has content that would render as None"
254
+ end
255
+
256
+ labels = rows.map { |r| r[:label] }
257
+ if (labels & heading_labels).empty?
258
+ return "delivered row labels (#{labels.first(3).join(', ')}...) match no action-file heading (#{heading_labels.first(3).join(', ')}...)"
259
+ end
260
+ if rows.all? { |r| ReportScreen.proven_by(intent_dir, r[:label]) == ReportScreen::NOT_RECORDED }
261
+ return "every Proven-by cell renders not recorded although labeled action headings exist"
262
+ end
263
+
264
+ nil
265
+ rescue StandardError => e
266
+ warn "end-intent: hollow-report gate crashed (#{e.message}); proceeding without it"
267
+ nil
268
+ end
269
+
270
+ # 317a S7 (D5, A8): stamp the live lock's run_mode into outcome.md frontmatter
271
+ # INSIDE the close step - after the gate, before the INDEX move and the store
272
+ # commit - so the stamped file rides the close commit and mode survives disarm.
273
+ def stamp_outcome_mode(intent_dir)
274
+ data = Lock.read(intent_dir)
275
+ run_mode = data && data["run_mode"].to_s
276
+ return if run_mode.nil? || run_mode.empty?
277
+
278
+ path = File.join(intent_dir, "outcome.md")
279
+ return unless File.exist?(path)
280
+ text = File.read(path)
281
+ return unless text.start_with?("---")
282
+ head, body = text[3..].split("---", 2)
283
+ return if body.nil? || head.match?(/^mode:/)
284
+
285
+ File.write(path, "---#{head}mode: #{run_mode}\n---#{body}")
286
+ rescue StandardError => e
287
+ warn "end-intent: mode stamp failed (#{e.message}); proceeding without it"
288
+ end
289
+
218
290
  def stamp_outcome_summary(content, summary)
291
+
219
292
  return content if summary.nil? || summary.to_s.strip.empty?
220
293
 
221
294
  lines = content.lines
@@ -654,6 +727,26 @@ def main(argv)
654
727
  reason = OutcomeGuard.reason(intent_dir, disposition)
655
728
  warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
656
729
 
730
+ # 1a2. The self-render gate (317a S9, D7): refuse a hollow delivered close
731
+ # BEFORE any terminal mutation - INDEX, savepoint, and store stay untouched
732
+ # on exit 7 (A9).
733
+ hollow = hollow_report_reason(intent_dir, disposition)
734
+ if hollow
735
+ if opts[:allow_hollow_report]
736
+ warn "end-intent: hollow delivered report overridden (--allow-hollow-report): #{hollow}"
737
+ else
738
+ warn "end-intent: refusing a hollow delivered close: #{hollow}"
739
+ warn "end-intent: write outcome.md's ## Delivered as the labeled | Row | What | table " \
740
+ "matching the action-file headings (templates/outcome.md shows the shape), " \
741
+ "or pass --allow-hollow-report to close anyway"
742
+ exit 7
743
+ end
744
+ end
745
+
746
+ # 1a3. Mode stamp (317a S7, D5): run_mode into outcome frontmatter while the
747
+ # lock still exists, so the stamped file rides the close commit.
748
+ stamp_outcome_mode(intent_dir)
749
+
657
750
  # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
658
751
  if opts[:outcome_summary]
659
752
  intent_file = Savepoint.intent_file(intent_dir)
@@ -15,11 +15,18 @@
15
15
  #
16
16
  # Claude adapter: Claude Code only; the core is harness-agnostic.
17
17
 
18
- require "json"
19
- require "time"
20
- require "tmpdir"
21
- require "yaml"
22
- require_relative "lib/message_display"
18
+ # 317a (B1): the requires sit inside their own rescue - a missing or broken
19
+ # lib file must never surface as a non-zero exit or stderr on a chunk of an
20
+ # ordinary message (LoadError is a ScriptError, outside StandardError).
21
+ begin
22
+ require "json"
23
+ require "time"
24
+ require "tmpdir"
25
+ require "yaml"
26
+ require_relative "lib/message_display"
27
+ rescue ScriptError, StandardError
28
+ exit 0
29
+ end
23
30
 
24
31
  def color_enabled?(plastic_home)
25
32
  return false unless ENV["NO_COLOR"].to_s.empty?
@@ -12,6 +12,7 @@ require "time"
12
12
  require_relative "session_ledger"
13
13
  require_relative "handoff"
14
14
  require_relative "lock"
15
+ require_relative "report_screen"
15
16
 
16
17
  module DaySummary
17
18
  module_function
@@ -113,8 +114,12 @@ module DaySummary
113
114
  dir = File.join(store_dir, dirname)
114
115
  next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
115
116
  # A guided session's lock is live but not autonomous; a lock with no
116
- # run_mode (a 1.14 auto team) counts as auto.
117
- next if (Lock.read(dir) || {})["run_mode"].to_s == "guided"
117
+ # run_mode (a 1.14 auto team) counts as auto. 317a (B9): when the lock
118
+ # carries no run_mode, the outcome frontmatter stamp (D5) is asked
119
+ # before defaulting, so a guided close never reads as auto.
120
+ run_mode = (Lock.read(dir) || {})["run_mode"].to_s
121
+ run_mode = ReportScreen.outcome_frontmatter(dir)["mode"].to_s if run_mode.empty?
122
+ next if run_mode == "guided"
118
123
 
119
124
  id, slug = dirname.split("--", 2)
120
125
  "- #{id} #{slug}: #{last_savepoint_line(dir)}"
@@ -452,6 +452,7 @@ class InstallerCore
452
452
  # entries are their only protection (test/install_sync_test.rb:23-29
453
453
  # greps installer_core.rb's own source text for "scripts/<name>").
454
454
  "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
455
+ "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
455
456
  "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
456
457
  "scripts/hook-message-display" => "scripts/hook-message-display",
457
458
  }
@@ -24,8 +24,11 @@ module IntentScreen
24
24
  # Em dash and en dash added (intent 316a O1e): a checklist item written
25
25
  # "S1 — text" (the em dash every checklist this intent writes, and the one a
26
26
  # reviewer reads, uses) kept its prefix under the old character class and
27
- # rendered "S1 [ open ] S1 — text" on screen.
28
- STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·—–]\s*/i
27
+ # rendered "S1 [ open ] S1 — text" on screen. Intent 317a (A1) made the
28
+ # separator optional with required whitespace: "S1 text" (the shape real
29
+ # checklists use) kept its label and doubled in next_fields; a bare "S1"
30
+ # has no following text and stays prose.
31
+ STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*(?:[-:·—–]\s*|\s+)(?=\S)/i
29
32
  INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
30
33
  SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
31
34
  # Intent 317, D6: field-2 tokens that are genuine lifecycle stages. A ledger
@@ -2,10 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
- require_relative "intent_screen"
6
- require_relative "intent_screen_ansi"
7
- require_relative "store_discovery"
8
- require_relative "store_provisioning"
5
+ require_relative "screen_paint"
9
6
 
10
7
  # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
11
8
  # Code MessageDisplay hook handler. One process per streamed chunk of every
@@ -50,7 +47,10 @@ require_relative "store_provisioning"
50
47
  # buffered original, never nil, never "") and D12 (color: false never
51
48
  # buffers or blanks anything) are unchanged.
52
49
  class MessageDisplay
53
- MARKER_RE = /\A## ▶ (\S+) · /.freeze
50
+ # 317a (A4): engagement is grammar, not identity - any screen-family
51
+ # opener engages, with NO intent-id resolution (the roster and delay
52
+ # screens have none to resolve). ScreenPaint owns the full grammar.
53
+ ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
54
54
  BUFFER_DIR_NAME = "plastic-message-display"
55
55
  BUFFER_MAX_AGE_SECONDS = 3600
56
56
  SCREEN_FILE = "SCREEN"
@@ -118,17 +118,14 @@ class MessageDisplay
118
118
  # resolve the id, both before anything is buffered or blanked (F4). Either
119
119
  # failure writes NOSCREEN so every later chunk can decide instantly rather
120
120
  # than waiting out its own budget for a decision that will never arrive.
121
- def handle_chunk_zero(dir, delta, cwd, final)
121
+ def handle_chunk_zero(dir, delta, _cwd, final)
122
122
  stripped = delta.sub(/\A[ \t]+/, "")
123
- m = stripped.match(MARKER_RE)
124
- resolved = m && resolve_intent_dir(m[1], cwd)
125
-
126
- unless resolved
123
+ unless ENGAGE_RE.match?(stripped)
127
124
  write_noscreen(dir)
128
125
  return nil
129
126
  end
130
127
 
131
- write_screen(dir, resolved)
128
+ write_screen(dir)
132
129
  write_chunk(dir, 0, delta)
133
130
  final ? finalize_final(dir, 0) : ""
134
131
  end
@@ -177,7 +174,7 @@ class MessageDisplay
177
174
  # every ordinary prose message answers no, at zero cost.
178
175
  def maybe_screen?(delta)
179
176
  stripped = delta.lstrip
180
- stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**Steps**")
177
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
181
178
  end
182
179
 
183
180
  # The final chunk additionally waits (same budget) for every earlier chunk
@@ -190,8 +187,7 @@ class MessageDisplay
190
187
  buffered = nil
191
188
  begin
192
189
  buffered = read_buffered_chunks(dir, index)
193
- decision = read_screen_decision(dir)
194
- finalize(buffered, decision)
190
+ finalize(buffered, nil)
195
191
  rescue StandardError
196
192
  buffered
197
193
  ensure
@@ -226,28 +222,31 @@ class MessageDisplay
226
222
  end.join
227
223
  end
228
224
 
229
- def read_screen_decision(dir)
230
- content = File.read(File.join(dir, SCREEN_FILE))
231
- intent_dir, store_root = content.split("\n")
232
- { intent_dir: intent_dir, store_root: store_root }
233
- end
225
+ # 317a (D1/B10): paint what was printed. The buffered message's screen
226
+ # region - located and bounded by ScreenPaint's own grammar - is re-laid in
227
+ # the ANSI vocabulary; prose before and after survives verbatim. A region
228
+ # the painter cannot parse returns the buffered original (A3: chunks were
229
+ # already blanked, so nil here would truncate the message to its final
230
+ # delta; nil is only for the never-engaged path in handle).
231
+ #
232
+ # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
233
+ # processes displayContent even inside a raw ANSI block, so the Claude
234
+ # adapter asks the harness-agnostic core to strip markdown noise. A harness
235
+ # whose display surface passes raw ANSI through untouched would ask for
236
+ # false instead.
237
+ def finalize(buffered, _decision)
238
+ lines = buffered.lines
239
+ start = lines.index { |l| ScreenPaint.classify(l) == :opener }
240
+ return buffered unless start
234
241
 
235
- def finalize(buffered, decision)
236
- intent_dir = decision[:intent_dir]
237
- store_root = decision[:store_root]
238
- # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
239
- # processes displayContent even inside a raw ANSI block (316a's live
240
- # capture showed backticks silently stripped from step text), so the
241
- # Claude adapter asks the harness-agnostic core to strip markdown noise
242
- # before it ever reaches the block. A harness whose display surface
243
- # passes raw ANSI through untouched would ask for false instead.
244
- ansi = IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true, markdown_safe: true)
245
- plain = IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path))
246
- splice(buffered, plain, ansi)
247
- end
242
+ stop = ScreenPaint.region_end(lines, start)
243
+ painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
244
+ return buffered unless painted
248
245
 
249
- def template_path
250
- File.expand_path("../../templates/intent-screen.md", __dir__)
246
+ suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
247
+ out = +"#{lines[0...start].join}#{painted.rstrip}\n"
248
+ out << "\n#{suffix}" unless suffix.empty?
249
+ out
251
250
  end
252
251
 
253
252
  def write_chunk(dir, index, delta)
@@ -257,8 +256,8 @@ class MessageDisplay
257
256
  # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
258
257
  # store/ — e.g. .../projects/<slug> or plastic_home itself), never the
259
258
  # store/ directory itself; resolve_intent_dir's `root:` is already that.
260
- def write_screen(dir, resolved)
261
- atomic_write(File.join(dir, SCREEN_FILE), "#{resolved[:intent_dir]}\n#{resolved[:root]}\n")
259
+ def write_screen(dir)
260
+ atomic_write(File.join(dir, SCREEN_FILE), "")
262
261
  end
263
262
 
264
263
  def write_noscreen(dir)
@@ -272,98 +271,7 @@ class MessageDisplay
272
271
  File.rename(tmp_path, path)
273
272
  end
274
273
 
275
- # D16: replace the plain render's own text wherever it sits in the buffered
276
- # message, keeping everything after it verbatim. Falls back to a line-based
277
- # boundary (the "## ▶ " line through the last line starting with "|") only
278
- # when the buffered text does not start with the plain render exactly (the
279
- # model reformatted something, or a chunk gap broke the exact match) — the
280
- # fallback also has to work for a checklist-less intent, whose only Steps
281
- # row is "| | | no steps yet |".
282
- def splice(buffered, plain, ansi)
283
- suffix =
284
- if buffered.start_with?(plain)
285
- buffered[plain.length..]
286
- else
287
- line_based_suffix(buffered, plain)
288
- end
289
- return buffered if suffix.nil?
290
-
291
- "#{ansi.rstrip}\n\n#{suffix}"
292
- end
293
274
 
294
- # Bounded fallback (matrix, lead's B1): walk forward from the "## ▶ " line
295
- # only through the screen's OWN contiguous run of blank lines, "|"-prefixed
296
- # table rows and the "**Steps**" heading, and stop at the first line that is
297
- # none of those. The boundary is the last "|" line seen before that stop —
298
- # never the last "|" line anywhere in the message. Scanning to the end
299
- # unbounded (the old behavior) swallows any prose the model wrote between
300
- # the screen and an unrelated Markdown table further down (a real hazard:
301
- # Plastic replies carry tables often).
302
- def line_based_suffix(buffered, plain)
303
- lines = buffered.lines
304
- start_idx = lines.index { |l| l.start_with?("## ▶ ") }
305
- return nil unless start_idx
306
-
307
- last_pipe_idx = nil
308
- i = start_idx + 1
309
- while i < lines.length
310
- line = lines[i]
311
- stripped = line.strip
312
- break unless stripped.empty? || line.start_with?("|") || stripped == "**Steps**"
313
-
314
- last_pipe_idx = i if line.start_with?("|")
315
- i += 1
316
- end
317
- return nil unless last_pipe_idx
318
-
319
- # Guard: never let the bounded scan consume more lines than the freshly
320
- # rendered plain screen itself has. If it would, something about the
321
- # buffered text does not match the shape splice() expects at all — pass
322
- # the original through rather than risk eating real prose.
323
- consumed = last_pipe_idx + 1 - start_idx
324
- return nil if consumed > plain.lines.length
325
-
326
- lines[(last_pipe_idx + 1)..].join
327
- end
328
-
329
- # O5: candidates are every discovered store holding a "<id>--*" directory.
330
- # A single candidate resolves outright (no ambiguity to break). With two or
331
- # more, the store whose project root is a path prefix of the payload's cwd
332
- # decides; if that narrows to anything other than exactly one, pass through
333
- # rather than guess (matrix 36).
334
- #
335
- # "cwd is a path prefix" is checked against the project's REAL checkout
336
- # path (projects.yml's own `path:`, e.g. ~/apps/personal/plastic) — never
337
- # against StoreDiscovery's `root` (~/.plastic/projects/<slug>, which only
338
- # holds INDEX.md and store/). Those are two different directories; a real
339
- # session's cwd lives under the former, never the latter. The global store
340
- # has no such checkout path, so it never wins by cwd — only by being the
341
- # sole candidate.
342
- def resolve_intent_dir(id, cwd)
343
- pattern = "#{glob_escape(id)}--*"
344
- candidates = StoreDiscovery.discover(@plastic_home)[:stores].filter_map do |s|
345
- dir = Dir.glob(File.join(s[:store], pattern)).find { |d| File.directory?(d) }
346
- dir && { slug: s[:slug], root: s[:root], intent_dir: dir }
347
- end
348
- return nil if candidates.empty?
349
- return candidates.first if candidates.length == 1
350
-
351
- registered = StoreProvisioning.load_projects(@plastic_home)
352
- cwd_matches = candidates.select do |c|
353
- real_path = registered.dig(c[:slug], "path")
354
- real_path && (cwd == real_path || cwd.start_with?("#{real_path}#{File::SEPARATOR}"))
355
- end
356
- return cwd_matches.first if cwd_matches.length == 1
357
-
358
- nil
359
- end
360
-
361
- # A recognized id should just be [A-Za-z0-9]+, but the id comes out of the
362
- # assistant's own streamed text, not a trusted schema — escape glob
363
- # metacharacters rather than assume it is well-formed.
364
- def glob_escape(str)
365
- str.gsub(/([*?\[\]{}])/) { "\\#{Regexp.last_match(1)}" }
366
- end
367
275
 
368
276
  def prune_old_buffers
369
277
  root = File.join(@tmp_root, BUFFER_DIR_NAME)
@@ -139,6 +139,31 @@ module ReportScreen
139
139
  body.empty? ? NOT_RECORDED : body
140
140
  end
141
141
 
142
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
143
+
144
+ # 317a S3 (A6): the note under Asked. Bulleted decisions in a real spec keep
145
+ # the historic "N decisions in spec.md"; a prose ## Decisions falls back to
146
+ # the highest D<n> it names; a placeholder spec falls through to the intent
147
+ # record's "### Decisions" (which section_of's "^## " anchor cannot reach);
148
+ # nothing anywhere says "decisions not recorded" - never a false 0, and the
149
+ # scaffold's "- ..." never counts as 1.
150
+ def self.decision_note(intent_dir)
151
+ spec = spec_text(intent_dir)
152
+ if spec && !spec.lstrip.start_with?(PLACEHOLDER_SENTINEL)
153
+ n = decisions_in(section_of(spec, "## Decisions"))
154
+ return "#{n} decisions in spec.md" if n.positive?
155
+ end
156
+ n = decisions_in(intent_text(intent_dir).to_s.split(/^### Decisions\s*$/, 2)[1].to_s.split(/^#+ /, 2)[0])
157
+ return "#{n} decisions in the intent record" if n.positive?
158
+ "decisions not recorded"
159
+ end
160
+
161
+ def self.decisions_in(body)
162
+ bullets = body.to_s.lines.count { |l| s = l.lstrip; s.start_with?("- ") && s.strip != "- ..." }
163
+ return bullets if bullets.positive?
164
+ body.to_s.scan(/\bD(\d{1,3})\b/).flatten.map(&:to_i).max.to_i
165
+ end
166
+
142
167
  # Row 22: bullets under spec.md's ## Decisions only.
143
168
  def self.decision_count(intent_dir)
144
169
  text = spec_text(intent_dir)
@@ -156,10 +181,28 @@ module ReportScreen
156
181
  rows = table_rows(section)
157
182
  return rows.map { |cells| { label: cells[0].to_s, text: cells[1].to_s } } if rows.any?
158
183
 
159
- bullets = section.lines.select { |l| l.lstrip.start_with?("- ") }
160
- bullets.each_with_index.map do |line, i|
161
- { label: (i + 1).to_s, text: line.lstrip.sub(/\A-\s*/, "").strip }
184
+ bullet_rows(section).each_with_index.map do |text, i|
185
+ { label: (i + 1).to_s, text: text }
186
+ end
187
+ end
188
+
189
+ # 317a S1 (matrix S1a/S1b): a bullet row is its "- " line PLUS its wrapped
190
+ # continuation lines - outcome prose is hand-wrapped at ~100 columns, and
191
+ # taking one physical line truncated every real record mid-sentence. A blank
192
+ # line or a heading ends the row; prose after a blank is never swept in.
193
+ def self.bullet_rows(section)
194
+ rows = []
195
+ section.to_s.each_line do |line|
196
+ stripped = line.strip
197
+ if line.lstrip.start_with?("- ")
198
+ rows << line.lstrip.sub(/\A-\s*/, "").strip
199
+ elsif stripped.empty? || line.start_with?("#")
200
+ rows << nil unless rows.empty? || rows.last.nil?
201
+ elsif !rows.empty? && !rows.last.nil?
202
+ rows[rows.length - 1] = "#{rows.last} #{stripped}"
203
+ end
162
204
  end
205
+ rows.compact
163
206
  end
164
207
 
165
208
  # Rows 25-27: D19 - the label must appear as a standalone token in an action
@@ -189,9 +232,21 @@ module ReportScreen
189
232
  return [] unless text.include?("## Needs you")
190
233
  section = section_of(text, "## Needs you")
191
234
  rows = table_rows(section)
192
- rows.each_with_index.map do |cells, i|
193
- { n: "N#{i + 1}", what: cells[1].to_s, why: cells[2].to_s }
235
+ if rows.any?
236
+ return rows.each_with_index.map do |cells, i|
237
+ { n: "N#{i + 1}", what: cells[1].to_s, why: cells[2].to_s }
238
+ end
194
239
  end
240
+
241
+ # 317a S2 (matrix S2a): prose that exists must never render as None - the
242
+ # 317 record hid three owner picks behind exactly that. One joined row,
243
+ # why "not recorded"; a literal None (or an empty section) stays [].
244
+ content = section.gsub(/<!--.*?-->/m, "").strip
245
+ return [] if content.empty? || content == "None"
246
+
247
+ what = content.lines.map(&:strip).reject(&:empty?)
248
+ .join(" ").sub(/\A-\s*/, "").squeeze(" ")
249
+ [{ n: "N1", what: what, why: NOT_RECORDED }]
195
250
  end
196
251
 
197
252
  # Row 35: first-to-last savepoint timestamp, "1 h 51 min" / "n min".
@@ -212,9 +267,27 @@ module ReportScreen
212
267
  def self.mode(intent_dir)
213
268
  data = Lock.read(intent_dir)
214
269
  value = data && data["run_mode"]
270
+ return value.to_s if value && !value.to_s.empty?
271
+
272
+ # 317a S7 (D5): after the close the lock is gone; end-intent stamps the
273
+ # run_mode into outcome.md frontmatter, so mode stops being unknowable
274
+ # retrospectively. Live lock first - it is the source of truth mid-flight.
275
+ value = outcome_frontmatter(intent_dir)["mode"]
215
276
  value && !value.to_s.empty? ? value.to_s : NOT_RECORDED
216
277
  end
217
278
 
279
+ def self.outcome_frontmatter(intent_dir)
280
+ text = outcome_text(intent_dir)
281
+ return {} unless text && text.start_with?("---")
282
+ parts = text.split("---", 3)
283
+ return {} if parts.length < 3
284
+ require "yaml"
285
+ require "date"
286
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
287
+ rescue StandardError
288
+ {}
289
+ end
290
+
218
291
  # --- evidence rows (rows 28-33, 37) --------------------------------------------
219
292
 
220
293
  def self.suite_row(section)
@@ -460,7 +533,7 @@ module ReportScreen
460
533
  lines << ""
461
534
  lines << "**Asked**"
462
535
  lines << " #{asked(intent_dir)}"
463
- lines << " #{decision_count(intent_dir)} decisions in spec.md"
536
+ lines << " #{decision_note(intent_dir)}"
464
537
  lines << ""
465
538
  lines << "**Delivered**"
466
539
  lines << "| Row | What | Proven by |"
@@ -470,10 +543,18 @@ module ReportScreen
470
543
  end
471
544
  lines << ""
472
545
  lines << "**Evidence**"
473
- lines << "| Kind | What | Source |"
474
- lines << "| --- | --- | --- |"
475
- evidence_rows(intent_dir, tag_reader: tag_reader).each do |r|
476
- lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
546
+ ev = evidence_rows(intent_dir, tag_reader: tag_reader)
547
+ if ev.empty?
548
+ # 317a S4 (matrix S4a): a header-only table (319's live rendering) says
549
+ # nothing; the honest floor is the same phrase every other absent source
550
+ # prints.
551
+ lines << NOT_RECORDED
552
+ else
553
+ lines << "| Kind | What | Source |"
554
+ lines << "| --- | --- | --- |"
555
+ ev.each do |r|
556
+ lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
557
+ end
477
558
  end
478
559
  lines << ""
479
560
  needsyou = needs_you_rows(intent_dir)
@@ -564,23 +645,4 @@ module ReportScreen
564
645
  # A renderer file, when present, is expected to define IntentScreenAnsi.paint
565
646
  # (one plain-text string in, one string out). Wiring the real contract 316a
566
647
  # ships is left to a follow-up step once that file exists (see checklist S14).
567
- def self.maybe_paint(text, renderer_path:, enabled:)
568
- return text unless enabled
569
- return text unless renderer_path && File.exist?(renderer_path)
570
-
571
- begin
572
- require renderer_path
573
- rescue LoadError, StandardError
574
- return text
575
- end
576
-
577
- mod = Object.const_get(:IntentScreenAnsi) if Object.const_defined?(:IntentScreenAnsi)
578
- return text unless mod && mod.respond_to?(:paint)
579
-
580
- begin
581
- mod.paint(text)
582
- rescue StandardError
583
- text
584
- end
585
- end
586
648
  end
@@ -0,0 +1,218 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_screen_ansi"
5
+
6
+ # ScreenPaint (intent 317a, D1) - the paint seam 317's Needs-you named. Parses
7
+ # the plain Markdown screens our own renderers emit (intent, state, roster,
8
+ # delivered, delay) and re-lays them out in the shipped intent-screen ANSI
9
+ # vocabulary. A parser and RE-LAYOUTER, not a colorizer (A5): the plain
10
+ # screens are pipe tables whose scaffolding rows only disappear under a
11
+ # Markdown renderer, so the painter drops them and rebuilds the layout;
12
+ # content-survival is the contract - every value and note survives, nothing
13
+ # is invented, and text it does not recognize returns nil so every caller
14
+ # fails open to plain.
15
+ #
16
+ # Harness-agnostic core: no harness assumption lives here. No ENV, no TTY. Color, width, and
17
+ # markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
18
+ # (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
19
+ # colors, no box borders.
20
+ module ScreenPaint
21
+ A = IntentScreenAnsi
22
+
23
+ # A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
24
+ # "▶ In delivery · ...", "✔ id · name · delivered in ...".
25
+ OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
26
+
27
+ FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
28
+ STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
29
+ TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
30
+ COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
31
+ BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
32
+
33
+ module_function
34
+
35
+ # The classifier both paint and region_end share. `idx`/`opener_idx` give
36
+ # the positional rule its footing: the line right after a title is the meta
37
+ # line (delivered/delay print one), recognizable by its " · " separators.
38
+ def classify(line, idx: nil, opener_idx: nil)
39
+ text = line.chomp
40
+ stripped = text.strip
41
+ return :blank if stripped.empty?
42
+ return :opener if OPENER_RE.match?(stripped) && text == stripped
43
+ return :table if text.lstrip.start_with?("|")
44
+ return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
45
+ return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
46
+ return :indented if text.start_with?(" ")
47
+ return :field if FIELD_LINE_RE.match?(text)
48
+ return :step if STEP_LINE_RE.match?(text)
49
+ return :timeline if TIMELINE_RE.match?(text)
50
+ return :count if COUNT_LINE_RE.match?(stripped)
51
+ return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
52
+ :unknown
53
+ end
54
+
55
+ # Where the screen region ends inside a larger message (B10): walk from the
56
+ # opener while every line classifies; the first unknown line - ordinary
57
+ # prose, a prose bullet - is the boundary. Never consumes past the screen.
58
+ def region_end(lines, start_idx)
59
+ i = start_idx + 1
60
+ while i < lines.length
61
+ kind = classify(lines[i], idx: i, opener_idx: start_idx)
62
+ break if kind == :unknown
63
+ # A bare "**Section**" head belongs to the screen only when what follows
64
+ # is still grammar; "**What this means**" over prose bullets is the
65
+ # model's own commentary and stays outside, unsplit (B10).
66
+ if kind == :bold && bare_bold?(lines[i]) && !grammar_follows?(lines, i, start_idx)
67
+ break
68
+ end
69
+ i += 1
70
+ end
71
+ # Trailing blanks belong to the message, not the screen.
72
+ i -= 1 while i > start_idx + 1 && lines[i - 1].strip.empty?
73
+ i
74
+ end
75
+
76
+ def bare_bold?(line)
77
+ m = BOLD_LEAD_RE.match(line.strip)
78
+ m && m[2].to_s.strip.empty?
79
+ end
80
+
81
+ def grammar_follows?(lines, idx, opener_idx)
82
+ j = idx + 1
83
+ j += 1 while j < lines.length && lines[j].strip.empty?
84
+ return false if j >= lines.length
85
+ kind = classify(lines[j], idx: j, opener_idx: opener_idx)
86
+ kind != :unknown && kind != :opener
87
+ end
88
+
89
+ # The painter. Returns the ANSI (or plain re-laid, when color: false) text,
90
+ # or nil when the input does not open with a screen title or carries a line
91
+ # outside the grammar - the caller's cue to print the original untouched.
92
+ def paint(text, color: true, width: A::DEFAULT_WIDTH, markdown_safe: false)
93
+ lines = text.to_s.lines
94
+ first_idx = lines.index { |l| !l.strip.empty? }
95
+ return nil if first_idx.nil?
96
+ return nil unless classify(lines[first_idx]) == :opener
97
+
98
+ out = +""
99
+ table = []
100
+ ok = true
101
+
102
+ flush = lambda do
103
+ next if table.empty?
104
+ out << paint_table(table, color: color, width: width, markdown_safe: markdown_safe)
105
+ table.clear
106
+ end
107
+
108
+ lines.each_with_index do |line, idx|
109
+ kind = classify(line, idx: idx, opener_idx: first_idx)
110
+ if kind == :table
111
+ table << line.strip
112
+ next
113
+ end
114
+ flush.call
115
+ case kind
116
+ when :opener
117
+ t = clean(line.strip.sub(/\A## /, ""), markdown_safe)
118
+ out << A.fit(t, width) { |s| A.styled(s, color, A::BOLD, A::NEARWHITE) } << "\n"
119
+ when :meta
120
+ out << A.fit(clean(line.strip, markdown_safe), width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
121
+ when :bold
122
+ m = BOLD_LEAD_RE.match(line.strip)
123
+ head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
124
+ out << head << clean(m[2], markdown_safe) << "\n"
125
+ when :indented
126
+ out << A.fit_plain(clean(line.chomp, markdown_safe), width) << "\n"
127
+ when :field
128
+ m = FIELD_LINE_RE.match(line.chomp)
129
+ out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
130
+ when :step
131
+ m = STEP_LINE_RE.match(line.chomp)
132
+ badge = A.status_cell(m[2] == "done", color)
133
+ out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
134
+ when :timeline
135
+ m = TIMELINE_RE.match(line.chomp)
136
+ out << A.styled(m[1], color, A::MIDGREY) << " " << A.styled(m[2].ljust(6), color, A::BOLD) \
137
+ << " " << clean(m[3], markdown_safe) << "\n"
138
+ when :count
139
+ out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
140
+ when :closer
141
+ out << A.styled(line.strip, color, A::MIDGREY) << "\n"
142
+ when :blank
143
+ out << "\n"
144
+ else
145
+ ok = false
146
+ break
147
+ end
148
+ end
149
+ flush.call
150
+ return nil unless ok
151
+
152
+ paint_bars(out.gsub(/\n{3,}/, "\n\n"), color)
153
+ end
154
+
155
+ # --- tables -----------------------------------------------------------------
156
+
157
+ SEPARATOR_RE = /\A\|[\s:|-]+\|?\z/.freeze
158
+
159
+ def cells_of(row)
160
+ row.split("|", -1).map(&:strip)[1..-2].to_a
161
+ end
162
+
163
+ def field_table?(rows)
164
+ rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
165
+ end
166
+
167
+ # A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
168
+ # re-lays as the intent screen's vertical field block: bold key, value,
169
+ # mid-grey note on its own line. A data table re-lays as padded columns
170
+ # with a bold header, done/open cells colored, no pipes anywhere.
171
+ def paint_table(rows, color:, width:, markdown_safe:)
172
+ rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
173
+ return "" if rows.empty?
174
+
175
+ if cells_of(rows.first).first.to_s.start_with?("**")
176
+ out = +""
177
+ key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
178
+ rows.each do |row|
179
+ key, value, note = cells_of(row)
180
+ key = key.to_s.gsub("*", "")
181
+ out << " #{A.styled(key.ljust(key_w), color, A::BOLD)} #{clean(value.to_s, markdown_safe)}\n"
182
+ next if note.to_s.empty?
183
+ out << (" " * (key_w + 4)) << A.fit(clean(note, markdown_safe), width - key_w - 4) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
184
+ end
185
+ return out
186
+ end
187
+
188
+ grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
189
+ widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
190
+ out = +""
191
+ grid.each_with_index do |cols, ri|
192
+ cells = cols.each_with_index.map do |cell, ci|
193
+ padded = cell.to_s.ljust(widths[ci])
194
+ if ri.zero?
195
+ A.styled(padded, color, A::BOLD)
196
+ elsif cell == "done"
197
+ A.styled(padded, color, A::TEAL)
198
+ elsif cell == "open"
199
+ A.styled(padded, color, A::AMBER)
200
+ else
201
+ padded
202
+ end
203
+ end
204
+ out << " " << cells.join(" ").rstrip << "\n"
205
+ end
206
+ out
207
+ end
208
+
209
+ def paint_bars(text, color)
210
+ return text unless color
211
+ text.gsub(/█+/) { |run| "#{A::TEAL}#{run}#{A::RESET}" }
212
+ .gsub(/░+/) { |run| "#{A::MIDGREY}#{run}#{A::RESET}" }
213
+ end
214
+
215
+ def clean(text, markdown_safe)
216
+ markdown_safe ? A.clean(text) : text
217
+ end
218
+ end
@@ -12,9 +12,11 @@
12
12
  # report-screen delivered <intent_dir> [--ansi]
13
13
  # report-screen delay <intent_dir> [--ansi]
14
14
  #
15
- # --ansi delegates to 316a's renderer conventions when that file is present
16
- # (D2) and prints plain otherwise, so this never blocks on 316a and never
17
- # breaks when it lands; NO_COLOR forces plain regardless of --ansi.
15
+ # --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
16
+ # in the shared TUI core. Selection is by capability, never by harness:
17
+ # NO_COLOR forces plain, a non-TTY stdout stays plain unless
18
+ # PLASTIC_FORCE_COLOR=1 (the test seam), and an unparseable screen falls
19
+ # open to the plain text untouched.
18
20
  #
19
21
  # Exit codes:
20
22
  # 0 - the screen is on stdout
@@ -23,6 +25,7 @@
23
25
 
24
26
  require_relative "lib/report_screen"
25
27
  require_relative "lib/intent_screen"
28
+ require_relative "lib/screen_paint"
26
29
 
27
30
  def usage_abort(message)
28
31
  warn "report-screen: #{message}"
@@ -46,7 +49,6 @@ verb = args.shift
46
49
  changed = nil
47
50
  ansi = false
48
51
  template_path = nil
49
- renderer_path = nil
50
52
  positional = []
51
53
 
52
54
  while (arg = args.shift)
@@ -60,8 +62,6 @@ while (arg = args.shift)
60
62
  positional << "--all"
61
63
  when "--template"
62
64
  template_path = args.shift or usage_abort("--template needs a path")
63
- when "--renderer-path"
64
- renderer_path = args.shift or usage_abort("--renderer-path needs a path")
65
65
  else
66
66
  usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
67
67
  positional << arg
@@ -73,11 +73,12 @@ usage_abort("usage: report-screen state|delivered|delay <intent_dir> [--changed
73
73
  all_mode = positional.delete("--all") ? true : false
74
74
  target = positional.first
75
75
 
76
- renderer_path ||= File.expand_path("../lib/intent_screen_ansi.rb", __dir__)
77
- ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty?
76
+ ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty? &&
77
+ ($stdout.tty? || ENV["PLASTIC_FORCE_COLOR"] == "1")
78
78
 
79
- def paint(text, ansi_enabled, renderer_path)
80
- ReportScreen.maybe_paint(text, renderer_path: renderer_path, enabled: ansi_enabled)
79
+ def paint(text, ansi_enabled, _renderer_path = nil)
80
+ return text unless ansi_enabled
81
+ ScreenPaint.paint(text, color: true) || text
81
82
  end
82
83
 
83
84
  case verb
@@ -87,7 +88,7 @@ when "state"
87
88
  store_root = File.expand_path(target)
88
89
  usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
89
90
  out = ReportScreen.render_roster(store_root, changed: changed)
90
- $stdout.write paint(out, ansi_enabled, renderer_path)
91
+ $stdout.write paint(out, ansi_enabled)
91
92
  else
92
93
  usage_abort("usage: report-screen state <intent_dir> [--changed \"<text>\"]") unless target
93
94
  intent_dir = File.expand_path(target)
@@ -97,7 +98,7 @@ when "state"
97
98
  usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
98
99
  out = ReportScreen.render_state(intent_dir: intent_dir, store_root: store_root, changed: changed,
99
100
  template: File.read(template_path))
100
- $stdout.write paint(out, ansi_enabled, renderer_path)
101
+ $stdout.write paint(out, ansi_enabled)
101
102
  end
102
103
  when "delivered"
103
104
  usage_abort("usage: report-screen delivered <intent_dir>") unless target
@@ -105,13 +106,13 @@ when "delivered"
105
106
  usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
106
107
  repo_root = File.expand_path("..", __dir__)
107
108
  out = ReportScreen.render_delivered(intent_dir: intent_dir, tag_reader: git_tag_reader(repo_root))
108
- $stdout.write paint(out, ansi_enabled, renderer_path)
109
+ $stdout.write paint(out, ansi_enabled)
109
110
  when "delay"
110
111
  usage_abort("usage: report-screen delay <intent_dir>") unless target
111
112
  intent_dir = File.expand_path(target)
112
113
  usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
113
114
  out = ReportScreen.render_delay(intent_dir: intent_dir)
114
- $stdout.write paint(out, ansi_enabled, renderer_path)
115
+ $stdout.write paint(out, ansi_enabled)
115
116
  else
116
117
  usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay)")
117
118
  end
@@ -253,7 +253,8 @@ every choice is non-destructive and the team has full autonomy.
253
253
  Read `../plastic-conventions/references/completion-and-done.md` for what "intent done" means.
254
254
 
255
255
  1. Verify every checklist item is checked and the suite is green once on the branch.
256
- 2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered`.
256
+ 2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered`,
257
+ `## Delivered` as the labeled table whose row labels match the action-file headings (317a).
257
258
  3. Release, if configured: match the working directory against `~/.plastic/projects.yml`, read
258
259
  `project.yml`'s `release` block, and act on `on_complete` (`commit`, `commit_and_push`,
259
260
  `manual`), `verify` (green proceeds; red follows `on_red`: `fix_and_retry` up to twice,
@@ -103,3 +103,15 @@ what gets written down.
103
103
  | Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock | checklist boxes; savepoint milestones; the day-ledger line promotes when a project file lands |
104
104
  | End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the pointer is purge-eligible, and the QMD reindex runs LAST (after purge); `end-intent` backfills a placeholder `outcome.md` from the record and its structure check reports (never refuses) | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
105
105
  | Maintenance (Future, Terminal, or Active-with-a-stale-or-no-lock) | `revisions.md` move-and-record entries | detects (never acquires) `delivery.lock`; defers and reports while the target's lock is FRESH (`Lock.fresh?`); a stale or absent lock is not-active, maintenance proceeds | append-only, rule-tagged `revisions.md` entry written in the same operation as the change, or the change is refused; lands via a fresh branch off store main merged back as one closed op, never `git add -A` |
106
+
107
+ ## The write guard is not residue
108
+
109
+ `<type>.write.lock` (usually `delivery.write.lock`) is a deliberate sibling
110
+ inode used only for `flock`: no owner, no timestamp, no content, and it is
111
+ NEVER unlinked - deleting it while a writer holds the flock hands the next
112
+ writer a fresh inode at the same path, so two writers hold "the" guard at
113
+ once (see `scripts/lib/lock.rb`, the write-guard comment). A zero-byte
114
+ `*.write.lock` in a completed intent directory is by design; no cleaner may
115
+ sweep it, and it is already inside the store's `*.lock` gitignore rule.
116
+ (Intent 317a, A2: a review misread it as stale residue and nearly shipped
117
+ the sweep.)
@@ -63,11 +63,14 @@ on disk is what the record becomes, so before the call:
63
63
 
64
64
  Author outcome.md yourself when it deserves prose: copy `templates/outcome.md`,
65
65
  set the frontmatter to `disposition: delivered` or `disposition: abandoned`, and
66
- fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`. Each
67
- `## Delivered` row is one thing delivered in plain wording a reader recognizes,
68
- not a method name or an implementation summary (that detail belongs in
69
- `## Summary`) - it becomes a row of `report-screen delivered`'s post-delivery
70
- screen. On abandon, `## Summary` states the abandonment reason and the trail (see Pivot
66
+ fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`. `## Delivered` is a
67
+ `| Row | What |` table: one row per thing delivered, in plain wording a reader
68
+ recognizes, not a method name or an implementation summary (that detail
69
+ belongs in `## Summary`). Each row's label must appear as a standalone token
70
+ in an action-file heading (`### S1 - ...` proves row S1); that heading's
71
+ matrix rows become the row's Proven-by cell on `report-screen delivered`'s
72
+ post-delivery screen. `## Needs you` is the literal None or a
73
+ `| N | What | Why |` table. On abandon, `## Summary` states the abandonment reason and the trail (see Pivot
71
74
  below). A placeholder outcome.md is backfilled from the record instead, with the
72
75
  close's disposition and the `--outcome-summary` line as its summary. Also author
73
76
  the rich INDEX entry note now (a short line in the store's existing
@@ -8,13 +8,21 @@ disposition: delivered|abandoned
8
8
 
9
9
  ## Delivered
10
10
  <!-- One row per thing delivered, in plain wording a reader recognizes, not
11
- an implementation summary; the technical detail belongs in ## Summary. -->
12
- - ...
11
+ an implementation summary; the technical detail belongs in ## Summary. Each
12
+ row's label must appear as a standalone token in an actions/*.md heading
13
+ (for example "### S1 - ..." proves row S1): that heading's matrix rows become
14
+ the row's Proven-by cell on the delivered screen (intent 317 D19, 317a). -->
15
+ | Row | What |
16
+ | --- | --- |
17
+ | S1 | ... |
13
18
 
14
19
  ## Verification
15
20
  - <acceptance criterion> — verified by ... → result
16
21
 
17
22
  ## Needs you
23
+ <!-- The literal None, or a table shaped | N | What | Why | with one row per
24
+ open owner action. Prose is tolerated by the reader but renders as a single
25
+ untyped row - write the table. -->
18
26
  None
19
27
 
20
28
  ## Follow-ups