@zalom/plastic 2.0.0-alpha.1 → 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.
Files changed (55) hide show
  1. package/bin/lib/context_budget.rb +453 -0
  2. package/bin/plastic-bench +78 -0
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +81 -0
  5. package/hooks/savepoint +5 -5
  6. package/package.json +1 -1
  7. package/scripts/agent-report +8 -2
  8. package/scripts/append-ledger +16 -3
  9. package/scripts/dashboard.rb +39 -10
  10. package/scripts/day-summary +53 -0
  11. package/scripts/doctor.rb +163 -0
  12. package/scripts/end-intent +93 -0
  13. package/scripts/hook-capture +21 -8
  14. package/scripts/hook-close +3 -1
  15. package/scripts/hook-message-display +74 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-savepoint +45 -0
  18. package/scripts/hook-session-start +34 -1
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/arm.rb +26 -1
  21. package/scripts/lib/compact_instructions.rb +56 -0
  22. package/scripts/lib/day_summary.rb +211 -0
  23. package/scripts/lib/doctor_core.rb +52 -3
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/handoff.rb +184 -0
  26. package/scripts/lib/hook_registry.rb +14 -0
  27. package/scripts/lib/installer_core.rb +117 -11
  28. package/scripts/lib/intent_screen.rb +309 -0
  29. package/scripts/lib/intent_screen_ansi.rb +262 -0
  30. package/scripts/lib/message_display.rb +290 -0
  31. package/scripts/lib/report_screen.rb +671 -0
  32. package/scripts/lib/savepoint.rb +14 -0
  33. package/scripts/lib/screen_paint.rb +276 -0
  34. package/scripts/lib/session_close.rb +22 -2
  35. package/scripts/lib/session_git.rb +49 -18
  36. package/scripts/lib/session_ledger.rb +124 -0
  37. package/scripts/plastic-lock +8 -1
  38. package/scripts/read-config +3 -0
  39. package/scripts/report-screen +157 -0
  40. package/scripts/rollback.rb +6 -0
  41. package/scripts/savepoint-note +67 -0
  42. package/scripts/spawn-preamble +9 -2
  43. package/scripts/write-handoff +60 -0
  44. package/skills/auto/SKILL.md +15 -8
  45. package/skills/auto/references/human-report-contract.md +59 -53
  46. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  47. package/skills/intent-continuing/SKILL.md +31 -22
  48. package/skills/intent-continuing/references/boarding-matrix.md +5 -5
  49. package/skills/intent-continuing/references/context-management.md +1 -1
  50. package/skills/intent-ending/SKILL.md +8 -2
  51. package/skills/intent-executing/SKILL.md +6 -0
  52. package/templates/config.yml +5 -0
  53. package/templates/intent-screen.md +17 -0
  54. package/templates/outcome.md +14 -1
  55. package/templates/report-state.md +11 -0
@@ -0,0 +1,276 @@
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
+ # Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
34
+ # are chosen by the table's own header, never by position; a cell whose
35
+ # text is exactly "not recorded" greys wherever it appears.
36
+ EVIDENCE_PROOF_KINDS = %w[suite red ship doctor deposits verdict].freeze
37
+ EVIDENCE_DEVIATION_KINDS = %w[deviates].freeze
38
+ NOTE_HEADERS = %w[Source Why].freeze
39
+ NOT_RECORDED = "not recorded"
40
+
41
+ module_function
42
+
43
+ # The classifier both paint and region_end share. `idx`/`opener_idx` give
44
+ # the positional rule its footing: the line right after a title is the meta
45
+ # line (delivered/delay print one), recognizable by its " · " separators.
46
+ def classify(line, idx: nil, opener_idx: nil)
47
+ text = line.chomp
48
+ stripped = text.strip
49
+ return :blank if stripped.empty?
50
+ return :opener if OPENER_RE.match?(stripped) && text == stripped
51
+ return :table if text.lstrip.start_with?("|")
52
+ return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
53
+ return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
54
+ return :indented if text.start_with?(" ")
55
+ return :field if FIELD_LINE_RE.match?(text)
56
+ return :step if STEP_LINE_RE.match?(text)
57
+ return :timeline if TIMELINE_RE.match?(text)
58
+ return :count if COUNT_LINE_RE.match?(stripped)
59
+ return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
60
+ :unknown
61
+ end
62
+
63
+ # Where the screen region ends inside a larger message (B10): walk from the
64
+ # opener while every line classifies; the first unknown line - ordinary
65
+ # prose, a prose bullet - is the boundary. Never consumes past the screen.
66
+ def region_end(lines, start_idx)
67
+ i = start_idx + 1
68
+ while i < lines.length
69
+ kind = classify(lines[i], idx: i, opener_idx: start_idx)
70
+ break if kind == :unknown
71
+ # A bare "**Section**" head belongs to the screen only when what follows
72
+ # is still grammar; "**What this means**" over prose bullets is the
73
+ # model's own commentary and stays outside, unsplit (B10).
74
+ if kind == :bold && bare_bold?(lines[i]) && !grammar_follows?(lines, i, start_idx)
75
+ break
76
+ end
77
+ i += 1
78
+ end
79
+ # Trailing blanks belong to the message, not the screen.
80
+ i -= 1 while i > start_idx + 1 && lines[i - 1].strip.empty?
81
+ i
82
+ end
83
+
84
+ def bare_bold?(line)
85
+ m = BOLD_LEAD_RE.match(line.strip)
86
+ m && m[2].to_s.strip.empty?
87
+ end
88
+
89
+ def grammar_follows?(lines, idx, opener_idx)
90
+ j = idx + 1
91
+ j += 1 while j < lines.length && lines[j].strip.empty?
92
+ return false if j >= lines.length
93
+ kind = classify(lines[j], idx: j, opener_idx: opener_idx)
94
+ kind != :unknown && kind != :opener
95
+ end
96
+
97
+ # The painter. Returns the ANSI (or plain re-laid, when color: false) text,
98
+ # or nil when the input does not open with a screen title or carries a line
99
+ # outside the grammar - the caller's cue to print the original untouched.
100
+ def paint(text, color: true, width: A::DEFAULT_WIDTH, markdown_safe: false)
101
+ lines = text.to_s.lines
102
+ first_idx = lines.index { |l| !l.strip.empty? }
103
+ return nil if first_idx.nil?
104
+ return nil unless classify(lines[first_idx]) == :opener
105
+
106
+ out = +""
107
+ table = []
108
+ ok = true
109
+ indent_run = 0
110
+
111
+ flush = lambda do
112
+ next if table.empty?
113
+ out << paint_table(table, color: color, width: width, markdown_safe: markdown_safe)
114
+ table.clear
115
+ end
116
+
117
+ lines.each_with_index do |line, idx|
118
+ kind = classify(line, idx: idx, opener_idx: first_idx)
119
+ # Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
120
+ # lines so only the SECOND and later lines under a heading like
121
+ # "**Asked**" grey as a note; a :table line (which `next`s below) must
122
+ # reset it too, so a later unrelated indented block starts fresh.
123
+ indent_run = kind == :indented ? indent_run + 1 : 0
124
+ if kind == :table
125
+ table << line.strip
126
+ next
127
+ end
128
+ flush.call
129
+ case kind
130
+ when :opener
131
+ t = clean(line.strip.sub(/\A## /, ""), markdown_safe)
132
+ out << A.fit(t, width) { |s| A.styled(s, color, A::BOLD, A::NEARWHITE) } << "\n"
133
+ when :meta
134
+ out << A.fit(clean(line.strip, markdown_safe), width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
135
+ when :bold
136
+ m = BOLD_LEAD_RE.match(line.strip)
137
+ head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
138
+ out << head << clean(m[2], markdown_safe) << "\n"
139
+ when :indented
140
+ text = A.fit_plain(clean(line.chomp, markdown_safe), width)
141
+ out << (indent_run > 1 ? A.styled(text, color, A::MIDGREY) : text) << "\n"
142
+ when :field
143
+ m = FIELD_LINE_RE.match(line.chomp)
144
+ out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
145
+ when :step
146
+ m = STEP_LINE_RE.match(line.chomp)
147
+ badge = A.status_cell(m[2] == "done", color)
148
+ out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
149
+ when :timeline
150
+ # Intent 317a1 (O5, D6, D7): the time carries no escape at all; the
151
+ # kind label carries the color - amber for the review turning point,
152
+ # teal for a landed commit, mid-grey (no bold) otherwise.
153
+ m = TIMELINE_RE.match(line.chomp)
154
+ codes = case m[2]
155
+ when "Review" then [A::BOLD, A::AMBER]
156
+ when "Commit" then [A::BOLD, A::TEAL]
157
+ else [A::MIDGREY]
158
+ end
159
+ out << m[1] << " " << A.styled(m[2].ljust(6), color, *codes) << " " << clean(m[3], markdown_safe) << "\n"
160
+ when :count
161
+ out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
162
+ when :closer
163
+ out << A.styled(line.strip, color, A::MIDGREY) << "\n"
164
+ when :blank
165
+ out << "\n"
166
+ else
167
+ ok = false
168
+ break
169
+ end
170
+ end
171
+ flush.call
172
+ return nil unless ok
173
+
174
+ paint_bars(out.gsub(/\n{3,}/, "\n\n"), color)
175
+ end
176
+
177
+ # --- tables -----------------------------------------------------------------
178
+
179
+ SEPARATOR_RE = /\A\|[\s:|-]+\|?\z/.freeze
180
+
181
+ def cells_of(row)
182
+ row.split("|", -1).map(&:strip)[1..-2].to_a
183
+ end
184
+
185
+ def field_table?(rows)
186
+ rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
187
+ end
188
+
189
+ # A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
190
+ # re-lays as the intent screen's three-column field block: bold key,
191
+ # value, and a mid-grey note that shares the line when it fits, dropping
192
+ # to its own line only as a fallback (D9-D11). A data table re-lays as
193
+ # padded columns with a bold header, done/open cells colored, no pipes
194
+ # anywhere.
195
+ def paint_table(rows, color:, width:, markdown_safe:)
196
+ rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
197
+ return "" if rows.empty?
198
+
199
+ if cells_of(rows.first).first.to_s.start_with?("**")
200
+ return paint_field_table(rows, color: color, width: width, markdown_safe: markdown_safe)
201
+ end
202
+
203
+ paint_data_table(rows, color: color, markdown_safe: markdown_safe)
204
+ end
205
+
206
+ # Intent 317a1 (O3, D9-D11, D14, D15): the same three-column geometry as
207
+ # IntentScreenAnsi.render's field-row loop, on the SAME implementation
208
+ # (D12) - `IntentScreenAnsi.field_table_lines` - so the two renderers
209
+ # cannot drift apart. Notes arrive as plain text here, so `visible_width`
210
+ # equals `length`, but the shared helper is used anyway so both renderers
211
+ # read identically.
212
+ def paint_field_table(rows, color:, width:, markdown_safe:)
213
+ key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
214
+ field_rows = rows.map do |row|
215
+ key, value, note = cells_of(row)
216
+ key = key.to_s.gsub("*", "")
217
+ [key, clean(value.to_s, markdown_safe), clean(note.to_s, markdown_safe)]
218
+ end
219
+ A.field_table_lines(field_rows, width: width, color: color, key_width: key_w)
220
+ end
221
+
222
+ # Intent 317a1 (O4, D3-D5): a data table's kind and note columns are chosen
223
+ # by the header, never by position; a cell whose text is exactly "not
224
+ # recorded" greys wherever it appears. `widths[ci] || 0` (rather than a
225
+ # bare `widths[ci]`) is the ragged-row guard: `ReportScreen.escape` writes
226
+ # `\|` while `cells_of` still splits on every `|`, so a row can carry more
227
+ # cells than its header without either side ever raising.
228
+ def paint_data_table(rows, color:, markdown_safe:)
229
+ grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
230
+ widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
231
+ kind_col = grid.first.first == "Kind" ? 0 : nil
232
+ note_col = grid.first.index { |h| NOTE_HEADERS.include?(h) }
233
+
234
+ out = +""
235
+ grid.each_with_index do |cols, ri|
236
+ last_ci = cols.length - 1
237
+ cells = cols.each_with_index.map do |cell, ci|
238
+ padded = ci == last_ci ? cell.to_s : cell.to_s.ljust(widths[ci] || 0)
239
+ # An empty cell never gets styled (317a1 post-exec review, finding
240
+ # 1): `A.styled("", ...)` still emits a color-open/RESET pair around
241
+ # nothing visible, and that hides the join separator's own trailing
242
+ # spaces from `.rstrip` below, on the very line the reader sees.
243
+ next padded if cell.to_s.empty?
244
+ if ri.zero?
245
+ A.styled(padded, color, A::BOLD)
246
+ elsif ci == kind_col && EVIDENCE_PROOF_KINDS.include?(cell)
247
+ A.styled(padded, color, A::TEAL, A::BOLD)
248
+ elsif ci == kind_col && EVIDENCE_DEVIATION_KINDS.include?(cell)
249
+ A.styled(padded, color, A::AMBER, A::BOLD)
250
+ elsif ci == note_col
251
+ A.styled(padded, color, A::MIDGREY)
252
+ elsif cell == "done"
253
+ A.styled(padded, color, A::TEAL)
254
+ elsif cell == "open"
255
+ A.styled(padded, color, A::AMBER)
256
+ elsif cell == NOT_RECORDED
257
+ A.styled(padded, color, A::MIDGREY)
258
+ else
259
+ padded
260
+ end
261
+ end
262
+ out << " " << cells.join(" ").rstrip << "\n"
263
+ end
264
+ out
265
+ end
266
+
267
+ def paint_bars(text, color)
268
+ return text unless color
269
+ text.gsub(/█+/) { |run| "#{A::TEAL}#{run}#{A::RESET}" }
270
+ .gsub(/░+/) { |run| "#{A::MIDGREY}#{run}#{A::RESET}" }
271
+ end
272
+
273
+ def clean(text, markdown_safe)
274
+ markdown_safe ? A.clean(text) : text
275
+ end
276
+ end
@@ -9,12 +9,22 @@
9
9
 
10
10
  require "fileutils"
11
11
  require_relative "session_ledger"
12
+ require_relative "handoff"
12
13
 
13
14
  module SessionClose
14
15
  module_function
15
16
 
16
17
  NOOP_REASONS = %w[clear resume].freeze
17
18
 
19
+ # The default hand-off writer (intent 311, spec D6): renders this session's
20
+ # share of the pointer day into handoff--<session>.md before the tmp dir
21
+ # goes. The hook script builds it with the shipped templates dir.
22
+ def default_handoff(templates)
23
+ lambda do |store, day, session|
24
+ Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
25
+ end
26
+ end
27
+
18
28
  # The default spawner starts the day filer detached so a slow filing never
19
29
  # blocks the harness shutdown (Codex kills a SessionEnd hook after 3 s).
20
30
  def default_spawner(script)
@@ -26,8 +36,8 @@ module SessionClose
26
36
  end
27
37
 
28
38
  # Returns a small report hash; never raises.
29
- def run(payload:, store:, today:, spawner:, now: Time.now)
30
- report = { reason: nil, dropped: 0, removed_tmp: false, spawned: nil }
39
+ def run(payload:, store:, today:, spawner:, handoff: nil, now: Time.now)
40
+ report = { reason: nil, dropped: 0, removed_tmp: false, spawned: nil, handoff: false }
31
41
  reason = payload.is_a?(Hash) ? payload["reason"].to_s : ""
32
42
  report[:reason] = reason
33
43
  return report if NOOP_REASONS.include?(reason)
@@ -49,6 +59,16 @@ module SessionClose
49
59
  count
50
60
  end || 0
51
61
 
62
+ # The hand-off (intent 311, spec D6) is written after the drop, so it
63
+ # reflects it, and before the tmp dir goes, since the pointer lives there.
64
+ # An intent pointer falls back to today, the same as the drop above.
65
+ if handoff
66
+ report[:handoff] = safely do
67
+ handoff.call(store, pointer_day, session)
68
+ true
69
+ end || false
70
+ end
71
+
52
72
  report[:removed_tmp] = safely do
53
73
  dir = SessionLedger.session_tmp_dir(store, session)
54
74
  FileUtils.rm_rf(dir) if Dir.exist?(dir)
@@ -207,11 +207,37 @@ module SessionGit
207
207
 
208
208
  # --- commit message ------------------------------------------------------------
209
209
 
210
- # The first line of `summary`, truncated to MAX_SUBJECT_LENGTH characters,
211
- # with no trailer (spec D5).
210
+ # The first line of `summary`, cut at the last word boundary at or before
211
+ # MAX_SUBJECT_LENGTH characters, falling back to the hard slice when no
212
+ # boundary exists at or before the limit (spec D6, amends spec 300 D5's
213
+ # unconditional `first_line[0, MAX_SUBJECT_LENGTH]`, which cut mid-word).
214
+ # A subject at or under the limit is returned unchanged. Post-execution
215
+ # review item 7: when the character immediately after the 72-char prefix
216
+ # is ITSELF a space, the prefix already ends exactly on a word boundary and
217
+ # needs no trimming at all -- checked before consulting `rindex`, because
218
+ # an earlier internal space inside the 72-char prefix would otherwise make
219
+ # `rindex` walk back past a whole trailing word that fit perfectly.
212
220
  def subject_for(summary)
213
221
  first_line = summary.to_s.split(/\r?\n/, 2).first.to_s.strip
214
- first_line[0, MAX_SUBJECT_LENGTH]
222
+ return first_line if first_line.length <= MAX_SUBJECT_LENGTH
223
+
224
+ cut = first_line[0, MAX_SUBJECT_LENGTH]
225
+ return cut if first_line[MAX_SUBJECT_LENGTH] == " "
226
+
227
+ boundary = cut.rindex(" ")
228
+ boundary ? cut[0, boundary] : cut
229
+ end
230
+
231
+ # The commit body for `summary`/`subject` (spec D6): the full summary when
232
+ # `subject` is a cut-down copy of it, nil when the subject already carries
233
+ # the summary whole (no redundant body on a short, single-line summary).
234
+ # Post-execution review item 4: compares the STRIPPED raw summary, not the
235
+ # raw summary verbatim -- a summary with only trailing/leading whitespace
236
+ # around an otherwise-identical subject must not repeat the same sentence
237
+ # twice as a redundant body.
238
+ def body_for(summary, subject)
239
+ raw = summary.to_s
240
+ raw.strip == subject ? nil : raw
215
241
  end
216
242
 
217
243
  # --- git primitives (all use -C, never cwd) -------------------------------------
@@ -273,9 +299,11 @@ module SessionGit
273
299
  parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
274
300
  end
275
301
 
276
- def stage_and_commit(dir, subject, runner:)
302
+ def stage_and_commit(dir, subject, runner:, body: nil)
277
303
  runner.run("-C", dir, "add", "-A")
278
- runner.run("-C", dir, "commit", "-m", subject)
304
+ args = ["-C", dir, "commit", "-m", subject]
305
+ args += ["-m", body] if body
306
+ runner.run(*args)
279
307
  end
280
308
 
281
309
  def short_sha(dir, runner:)
@@ -310,14 +338,16 @@ module SessionGit
310
338
 
311
339
  flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
312
340
  subject = subject_for(summary)
341
+ body = body_for(summary, subject)
313
342
 
314
343
  result =
315
344
  if flow["mode"] == "pull_request"
316
- commit_pull_request(repo: repo, subject: subject, day: day, session: session,
345
+ commit_pull_request(repo: repo, subject: subject, body: body, day: day, session: session,
317
346
  store: effective_store, flow: flow, branch_now: branch_now,
318
347
  runner: runner, gh_runner: gh_runner)
319
348
  else
320
- commit_direct(repo: repo, subject: subject, day: day, flow: flow, branch_now: branch_now, runner: runner)
349
+ commit_direct(repo: repo, subject: subject, body: body, day: day, flow: flow, branch_now: branch_now,
350
+ runner: runner)
321
351
  end
322
352
 
323
353
  return result if flow_notes.empty?
@@ -331,7 +361,7 @@ module SessionGit
331
361
 
332
362
  # --- direct mode (spec D3) --------------------------------------------------------
333
363
 
334
- def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:)
364
+ def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:, body: nil)
335
365
  return note("nothing to commit") unless dirty?(repo, runner: runner)
336
366
  return note("summary is empty after truncation: no commit") if blank?(subject)
337
367
 
@@ -351,10 +381,10 @@ module SessionGit
351
381
  end
352
382
 
353
383
  if branch_now == base || branch_now == session_branch
354
- commit_on_session_branch(repo: repo, subject: subject, base: base,
384
+ commit_on_session_branch(repo: repo, subject: subject, base: base, body: body,
355
385
  session_branch: session_branch, branch_now: branch_now, runner: runner)
356
386
  else
357
- commit_on_other_branch(repo: repo, subject: subject, branch_now: branch_now, runner: runner)
387
+ commit_on_other_branch(repo: repo, subject: subject, body: body, branch_now: branch_now, runner: runner)
358
388
  end
359
389
  end
360
390
 
@@ -367,7 +397,7 @@ module SessionGit
367
397
  # session branch. `current_branch` is re-read after the switch and used
368
398
  # for the commit message instead of trusting the branch this method
369
399
  # intended to reach.
370
- def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:)
400
+ def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:, body: nil)
371
401
  unless branch_exists?(repo, session_branch, runner: runner)
372
402
  create = runner.run("-C", repo, "branch", session_branch, base.to_s)
373
403
  return note("could not create session branch #{session_branch}: #{diagnose(create)}") unless create.success?
@@ -383,11 +413,12 @@ module SessionGit
383
413
  return note("expected to be on #{session_branch} but the checkout is on #{actual_branch.inspect}")
384
414
  end
385
415
 
386
- commit_and_push(dir: repo, push_dir: repo, subject: subject, from: actual_branch, base: base, runner: runner)
416
+ commit_and_push(dir: repo, push_dir: repo, subject: subject, body: body, from: actual_branch, base: base,
417
+ runner: runner)
387
418
  end
388
419
 
389
- def commit_on_other_branch(repo:, subject:, branch_now:, runner:)
390
- res = stage_and_commit(repo, subject, runner: runner)
420
+ def commit_on_other_branch(repo:, subject:, branch_now:, runner:, body: nil)
421
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
391
422
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
392
423
 
393
424
  sha = short_sha(repo, runner: runner)
@@ -398,8 +429,8 @@ module SessionGit
398
429
  # shared tail. A non-fast-forward push (spec D3, "base moved ahead
399
430
  # independently") stays a Note: the commit itself already landed on the
400
431
  # session branch.
401
- def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:)
402
- res = stage_and_commit(dir, subject, runner: runner)
432
+ def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:, body: nil)
433
+ res = stage_and_commit(dir, subject, runner: runner, body: body)
403
434
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
404
435
 
405
436
  sha = short_sha(dir, runner: runner)
@@ -414,7 +445,7 @@ module SessionGit
414
445
 
415
446
  # --- pull request mode (spec D4) ------------------------------------------------
416
447
 
417
- def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:)
448
+ def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:, body: nil)
418
449
  return note("nothing to commit") unless dirty?(repo, runner: runner)
419
450
  return note("summary is empty after truncation: no commit") if blank?(subject)
420
451
 
@@ -439,7 +470,7 @@ module SessionGit
439
470
  return note("could not check out branch #{branch}: #{diagnose(switch)}") unless switch.success?
440
471
  end
441
472
 
442
- res = stage_and_commit(repo, subject, runner: runner)
473
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
443
474
  outcome =
444
475
  if res.success?
445
476
  pull_request_outcome(repo: repo, subject: subject, branch: branch, base: base, gh_runner: gh_runner, runner: runner)
@@ -186,6 +186,130 @@ module SessionLedger
186
186
  "#{collapsed[0, 197]}..."
187
187
  end
188
188
 
189
+ # --- capture_worthy? (spec D2, D7; supersedes 298 D2(c)) -------------------
190
+
191
+ # One complete top-level harness envelope tag block: "<name ...>...</name>".
192
+ # Non-greedy (.*?) so sibling blocks are each matched on their own rather
193
+ # than one match spanning from the first block's opening tag all the way to
194
+ # the LAST block's closing tag (post-execution review item 3: an envelope
195
+ # on both sides of real work, "<system-reminder>...</system-reminder>\nfix
196
+ # the parser\n<task-notification>...</task-notification>", must not be
197
+ # read as one giant envelope swallowing the work in the middle).
198
+ ENVELOPE_BLOCK_RE = /<([A-Za-z][\w-]*)(?:\s[^>]*)?>.*?<\/\1>/m
199
+ private_constant :ENVELOPE_BLOCK_RE
200
+
201
+ # The whole prompt, case- and whitespace-insensitively, and nothing else
202
+ # (rule 3, D2): a trigger word inside a longer real instruction ("continue
203
+ # the dashboard fix and then release") must not match this.
204
+ BARE_TRIGGERS = %w[continue auto].freeze
205
+ private_constant :BARE_TRIGGERS
206
+
207
+ # Words whose presence marks a prompt as actionable work (rule 4's escape
208
+ # hatch, D2's accept bias). Deliberately excludes common nouns that also
209
+ # read as everyday verbs in casual remarks (e.g. "release", "ship", "plan"):
210
+ # including them would make ordinary conversation about a past release or
211
+ # plan look like a work request. Matched with an optional inflection suffix
212
+ # (post-execution review BLOCKER): the bare stems alone missed "fixed",
213
+ # "updated", "added", "reviewed", "implemented" -- exactly the past-tense
214
+ # and -ing forms real work summaries use.
215
+ WORK_MARKER_WORDS = %w[
216
+ fix add remove delete update upgrade implement write build create refactor
217
+ debug investigate review test deploy commit merge revert rename configure
218
+ install migrate document generate draft resolve help need want make change
219
+ setup
220
+ ].freeze
221
+ private_constant :WORK_MARKER_WORDS
222
+
223
+ WORK_MARKER_PHRASES = [
224
+ "can you", "could you", "would you", "let's", "let us", "set up", "look into", "figure out",
225
+ ].freeze
226
+ private_constant :WORK_MARKER_PHRASES
227
+
228
+ WORK_MARKER_RE = /\b(?:#{WORK_MARKER_WORDS.join("|")})(?:s|d|ed|ing)?\b/i
229
+ private_constant :WORK_MARKER_RE
230
+
231
+ # A first word that reads as an interrogative opener, checked case-
232
+ # insensitively against the prompt's first whitespace-separated token.
233
+ # Post-execution review BLOCKER: trimmed from the original, wider list
234
+ # (which also carried "how", "when", "where", "was", "were", "do", "did",
235
+ # "will", "shall", "should") down to words that open a genuine QUESTION at
236
+ # least as often as an ordinary command or request. Measured against 43
237
+ # invented and 27 real day-ledger prompts: the dropped words open ordinary
238
+ # work requests ("do the release now...", "when you are done, tag the
239
+ # release...", "will you push that branch...", "should I bump the version
240
+ # files...") far more often than they open a bare question worth rejecting.
241
+ QUESTION_STARTERS = %w[
242
+ what why who whom whose which is are am does can could would
243
+ ].freeze
244
+ private_constant :QUESTION_STARTERS
245
+
246
+ # A narrow set of retrospective-remark shapes ("that release went smoother
247
+ # than the last one"): comparative or evaluative observations about how
248
+ # something already went. Deliberately narrow (D2's accept bias): a broad
249
+ # "any declarative sentence with no recognized verb" rule would also catch
250
+ # ordinary work summaries like "harness text wins the pending line", which
251
+ # must stay accepted.
252
+ REMARK_PATTERNS = [
253
+ /\bwent\s+\w+\s+than\b/i,
254
+ /\bwent\s+(?:well|badly|smoothly|great|poorly|terribly)\b/i,
255
+ ].freeze
256
+ private_constant :REMARK_PATTERNS
257
+
258
+ # True iff nothing but harness envelope tag block(s) -- and whitespace --
259
+ # remain once every complete top-level block is stripped out. A prompt
260
+ # that is one envelope alone, or several envelopes with no other content,
261
+ # matches; a prompt carrying real work anywhere outside an envelope (before,
262
+ # after, or between several of them) does not.
263
+ def whole_prompt_envelope?(stripped)
264
+ stripped.gsub(ENVELOPE_BLOCK_RE, "").strip.empty?
265
+ end
266
+
267
+ def bare_trigger?(stripped)
268
+ BARE_TRIGGERS.include?(stripped.downcase)
269
+ end
270
+
271
+ def work_marker?(text)
272
+ return true if WORK_MARKER_RE.match?(text)
273
+
274
+ downcased = text.downcase
275
+ WORK_MARKER_PHRASES.any? { |p| downcased.include?(p) }
276
+ end
277
+
278
+ def interrogative?(stripped)
279
+ return true if stripped.end_with?("?")
280
+
281
+ first_word = stripped.split(/\s+/).first.to_s.downcase.gsub(/[^a-z]/, "")
282
+ QUESTION_STARTERS.include?(first_word)
283
+ end
284
+
285
+ def bare_remark?(stripped)
286
+ REMARK_PATTERNS.any? { |re| re.match?(stripped) }
287
+ end
288
+
289
+ # Internal helpers only: #capture_worthy? is the sole public contract
290
+ # (post-execution review item 9).
291
+ private_class_method :whole_prompt_envelope?, :bare_trigger?, :work_marker?, :interrogative?, :bare_remark?
292
+
293
+ # Whether `prompt` earns a pending checklist line (spec D2). Bias is
294
+ # ACCEPT: this rejects only on four named rules -- the 10-char floor (on
295
+ # its own collapsed copy), a whole-prompt harness envelope, a bare
296
+ # continue/auto trigger, and an interrogative or bare-remark prompt
297
+ # carrying no work marker -- and accepts everything else, including a
298
+ # work-shaped question and an envelope followed by real work. Takes the
299
+ # RAW prompt (not the sanitized/truncated line text) so rule 2 sees the
300
+ # prompt's true first character and multi-line shape.
301
+ def capture_worthy?(prompt)
302
+ raw = prompt.to_s
303
+ return false if sanitize_summary(raw).length < 10
304
+
305
+ stripped = raw.strip
306
+ return false if whole_prompt_envelope?(stripped)
307
+ return false if bare_trigger?(stripped)
308
+ return false if !work_marker?(stripped) && (interrogative?(stripped) || bare_remark?(stripped))
309
+
310
+ true
311
+ end
312
+
189
313
  # One LF-terminated checklist line, byte exact per spec D5. The state
190
314
  # marker is fixed width across all three states, which is what lets a later
191
315
  # promote or tick be a one-byte write at a known offset.
@@ -70,6 +70,7 @@ until ARGV.empty?
70
70
  when "--model" then opts[:model] = ARGV.shift
71
71
  when "--thread" then opts[:thread] = ARGV.shift
72
72
  when "--mode" then opts[:mode] = ARGV.shift
73
+ when "--allow-inline" then opts[:allow_inline] = true
73
74
  when "--status" then opts[:status] = ARGV.shift
74
75
  else
75
76
  warn "unknown flag #{flag}"
@@ -154,7 +155,8 @@ end
154
155
  case verb
155
156
  when "arm"
156
157
  result = Arm.arm(intent_dir: dir, session: key, mode: opts[:mode] || "auto", home: home,
157
- harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread])
158
+ harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread],
159
+ allow_inline: opts[:allow_inline] || false)
158
160
  case result[:status]
159
161
  when :acquired, :owned
160
162
  wt = result[:worktree] || {}
@@ -178,6 +180,11 @@ when "arm"
178
180
  when :corrupt
179
181
  warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; #{doctor_hint('fix the lock', hint_harness)}"
180
182
  exit 1
183
+ when :inline_refused
184
+ warn "plastic-lock: refusing to arm intent #{intent_id} from a conversation session " \
185
+ "(owner rule 2026-08-31: no inline intent delivery). Dispatch the delivery team " \
186
+ "instead (plastic-auto), or pass --allow-inline with explicit owner approval."
187
+ exit 1
181
188
  end
182
189
  when "status"
183
190
  lock = Lock.read(dir)
@@ -14,6 +14,9 @@ require_relative "lib/agent_models"
14
14
  DEFAULTS = {
15
15
  "version" => 3,
16
16
  "stale_threshold_days" => 3,
17
+ # Absolute token counts for a 1M window, 35 and 50 percent (intent 312, 296 D38).
18
+ "context_offer_tokens" => 350_000,
19
+ "context_insist_tokens" => 500_000,
17
20
  "execution_mode" => "subagent-driven",
18
21
  "hash_length" => 6,
19
22
  "hash_algorithm" => "sha256-base36",