@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.
- package/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +81 -0
- package/hooks/savepoint +5 -5
- package/package.json +1 -1
- package/scripts/agent-report +8 -2
- package/scripts/append-ledger +16 -3
- package/scripts/dashboard.rb +39 -10
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +163 -0
- package/scripts/end-intent +93 -0
- package/scripts/hook-capture +21 -8
- package/scripts/hook-close +3 -1
- package/scripts/hook-message-display +74 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +34 -1
- package/scripts/intent-screen +77 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +211 -0
- package/scripts/lib/doctor_core.rb +52 -3
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/installer_core.rb +117 -11
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +290 -0
- package/scripts/lib/report_screen.rb +671 -0
- package/scripts/lib/savepoint.rb +14 -0
- package/scripts/lib/screen_paint.rb +276 -0
- package/scripts/lib/session_close.rb +22 -2
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -0
- package/scripts/report-screen +157 -0
- package/scripts/rollback.rb +6 -0
- package/scripts/savepoint-note +67 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/write-handoff +60 -0
- package/skills/auto/SKILL.md +15 -8
- package/skills/auto/references/human-report-contract.md +59 -53
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/intent-continuing/SKILL.md +31 -22
- package/skills/intent-continuing/references/boarding-matrix.md +5 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +8 -2
- package/skills/intent-executing/SKILL.md +6 -0
- package/templates/config.yml +5 -0
- package/templates/intent-screen.md +17 -0
- package/templates/outcome.md +14 -1
- 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`,
|
|
211
|
-
#
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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.
|
package/scripts/plastic-lock
CHANGED
|
@@ -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)
|
package/scripts/read-config
CHANGED
|
@@ -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",
|