@zalom/plastic 2.0.0-alpha.3 → 2.0.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/dashboard.rb +39 -10
- package/scripts/doctor.rb +163 -0
- package/scripts/hook-capture +19 -7
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/intent-screen +52 -0
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/installer_core.rb +20 -1
- package/scripts/lib/intent_screen.rb +221 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/skills/intent-continuing/SKILL.md +16 -17
- package/skills/intent-continuing/references/boarding-matrix.md +5 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/templates/intent-screen.md +22 -0
package/package.json
CHANGED
package/scripts/dashboard.rb
CHANGED
|
@@ -56,13 +56,26 @@ rescue StandardError
|
|
|
56
56
|
nil
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
#
|
|
59
|
+
# The link form's id: "- [id](path)...". The class excludes "[" (as well as
|
|
60
|
+
# "]" and whitespace) so this can never match a wikilink line's OWN opening
|
|
61
|
+
# bracket and key it as "[71" (row F, spec 315b): "- [[71]] ..." fails this
|
|
62
|
+
# pattern outright (the character right after "- [" is itself "[", which the
|
|
63
|
+
# class disallows), leaving it to WIKILINK_ID_RE below.
|
|
64
|
+
LINK_ID_RE = /^- \[([^\[\]\s]+)/
|
|
65
|
+
|
|
66
|
+
# The wikilink form's id: "- [[id]] name (em dash) description (date; ...)",
|
|
67
|
+
# the shape mihradesign's INDEX.md and others use. Bare id, no brackets.
|
|
68
|
+
WIKILINK_ID_RE = /^- \[\[([^\[\]]+)\]\]/
|
|
69
|
+
|
|
70
|
+
# Returns the set of intent ids listed under a given INDEX.md section, link
|
|
71
|
+
# form and wikilink form alike (row F5: Active, Abandoned, Completed, and a
|
|
72
|
+
# store's Future section can all mix both forms).
|
|
60
73
|
def index_section_ids(index_path, header)
|
|
61
74
|
return [] unless File.exist?(index_path)
|
|
62
75
|
body = File.read(index_path)
|
|
63
76
|
seg = body[/^#{Regexp.escape(header)}\s*\n(.*?)(?=^## |\z)/m, 1]
|
|
64
77
|
return [] unless seg
|
|
65
|
-
seg.scan(
|
|
78
|
+
seg.scan(LINK_ID_RE).flatten + seg.scan(WIKILINK_ID_RE).flatten
|
|
66
79
|
end
|
|
67
80
|
|
|
68
81
|
# Bug found at intent 202's gate review: the date used to be anchored to the END of the
|
|
@@ -77,21 +90,37 @@ end
|
|
|
77
90
|
# (end-intent's own Bridge.index_entry_match accepts either on read). Because regex
|
|
78
91
|
# alternation is leftmost-first, this only ever matches the date immediately after the
|
|
79
92
|
# link. It never continues scanning into the note prose, so a second date mentioned
|
|
80
|
-
# later in a note's free text cannot be mistaken for the completion date.
|
|
81
|
-
|
|
93
|
+
# later in a note's free text cannot be mistaken for the completion date. The id class
|
|
94
|
+
# excludes "[" (row F) so this can never fire on a wikilink line's own opening bracket.
|
|
95
|
+
COMPLETION_DATE_RE = /^- \[([^\[\]\s]+).*?\)\s*[\u2014-]\s*(\d{4}-\d{2}-\d{2})\b/
|
|
96
|
+
|
|
97
|
+
# The wikilink form's completion date: "- [[id]] name (em dash) description
|
|
98
|
+
# (date; other notes)". Real shape (mihradesign's INDEX.md): the date is the
|
|
99
|
+
# first thing inside the entry's trailing parenthetical. A SEPARATE regex
|
|
100
|
+
# from COMPLETION_DATE_RE (row F review finding), not one four-group
|
|
101
|
+
# alternation: a four-group `scan` breaks `String#scan(...).to_h`, and a bare
|
|
102
|
+
# `\(` alternative on the link form would harvest a date out of note prose on
|
|
103
|
+
# an entry with no canonical date of its own.
|
|
104
|
+
WIKILINK_COMPLETION_DATE_RE = /^- \[\[([^\[\]]+)\]\].*?\((\d{4}-\d{2}-\d{2})\b/
|
|
82
105
|
|
|
83
106
|
# Map of intent id -> completion date string, parsed from the "## Completed" section
|
|
84
|
-
# (lines like "- [12 (em dash) title](link) (em dash) 2026-06-10 optional note text"
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
107
|
+
# (lines like "- [12 (em dash) title](link) (em dash) 2026-06-10 optional note text",
|
|
108
|
+
# or the wikilink form "- [[12]] title (em dash) description (2026-06-10; notes)").
|
|
109
|
+
# Deterministic, content-derived. Two regexes scanned separately and merged into one
|
|
110
|
+
# hash (row F), so each form's own matching rules stay independent: the wikilink
|
|
111
|
+
# alternative can never steal a date out of a link-form entry's note prose, because it
|
|
112
|
+
# never even looks at a link-form line (its "- [[" anchor cannot match a line whose
|
|
113
|
+
# second character is not itself "["). Observability: a populated "## Completed"
|
|
114
|
+
# section that yields not one single dated entry is a parser regression, not a
|
|
115
|
+
# legitimately empty result, so it is surfaced with a stderr warning rather than
|
|
116
|
+
# rotting invisibly (the same silent-failure class intent 202's gate review caught this
|
|
117
|
+
# file already committing).
|
|
89
118
|
def completion_dates(index_path)
|
|
90
119
|
return {} unless File.exist?(index_path)
|
|
91
120
|
body = File.read(index_path)
|
|
92
121
|
seg = body[/^## Completed\s*\n(.*?)(?=^## |\z)/m, 1] || ""
|
|
93
122
|
entry_count = seg.scan(/^- \[/).size
|
|
94
|
-
dates = seg.scan(COMPLETION_DATE_RE).to_h
|
|
123
|
+
dates = seg.scan(COMPLETION_DATE_RE).to_h.merge(seg.scan(WIKILINK_COMPLETION_DATE_RE).to_h)
|
|
95
124
|
if entry_count.positive? && dates.empty?
|
|
96
125
|
warn "dashboard: completion_dates parsed 0/#{entry_count} dates from the " \
|
|
97
126
|
"\"## Completed\" section of #{index_path}; treat this as a parser regression, " \
|
package/scripts/doctor.rb
CHANGED
|
@@ -288,6 +288,167 @@ class Doctor
|
|
|
288
288
|
# content scanning. check_global_store (above) is store/full-scope and includes
|
|
289
289
|
# orphaned_intents / ghost_references, both explicitly forbidden at core by D1.
|
|
290
290
|
|
|
291
|
+
# Row E (spec D8, spec 315b): #check_core_files's own `version_match` only ever
|
|
292
|
+
# compares the SELECTED harness's `<dir>/plastic/VERSION` against the global
|
|
293
|
+
# VERSION, so a second installed harness left behind on an old version is never
|
|
294
|
+
# version-checked at all -- exactly how ten stale 1.14.1 Codex registrations
|
|
295
|
+
# survived a `pass`. This runs the same comparison for EVERY installed harness
|
|
296
|
+
# (agent_dir on disk), named `version_match_<key>` so the existing `version_match`
|
|
297
|
+
# name and position stay reserved for the selected harness (pinned tests:
|
|
298
|
+
# test/doctor_test.rb:1846,1861,1873). Full tier only (`run_checks`); never
|
|
299
|
+
# `run_core_checks`, which is `binary: true` and runs on the session-start boot
|
|
300
|
+
# path, where a second-harness warn would become a boot-time error for every user.
|
|
301
|
+
def check_harness_versions
|
|
302
|
+
global_version = read_version
|
|
303
|
+
return [] unless global_version
|
|
304
|
+
|
|
305
|
+
agents.filter_map do |key, config|
|
|
306
|
+
next unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
|
|
307
|
+
|
|
308
|
+
agent_version_path = File.join(config[:dir], "plastic", "VERSION")
|
|
309
|
+
name = "version_match_#{key}"
|
|
310
|
+
|
|
311
|
+
if !File.exist?(agent_version_path)
|
|
312
|
+
check(
|
|
313
|
+
category: "core_files", name: name, status: "warn",
|
|
314
|
+
message: "#{config[:name]}'s agent-side VERSION file not found at #{tilde(agent_version_path)}",
|
|
315
|
+
fixable: true,
|
|
316
|
+
fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
|
|
317
|
+
)
|
|
318
|
+
else
|
|
319
|
+
agent_version = File.read(agent_version_path).strip
|
|
320
|
+
if global_version == agent_version
|
|
321
|
+
check(
|
|
322
|
+
category: "core_files", name: name, status: "pass",
|
|
323
|
+
message: "Global VERSION (#{global_version}) matches #{config[:name]}'s agent-side VERSION"
|
|
324
|
+
)
|
|
325
|
+
else
|
|
326
|
+
check(
|
|
327
|
+
category: "core_files", name: name, status: "warn",
|
|
328
|
+
message: "Version mismatch for #{config[:name]}: global=#{global_version}, agent=#{agent_version}",
|
|
329
|
+
details: [
|
|
330
|
+
"#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
|
|
331
|
+
"#{tilde(agent_version_path)}: #{agent_version}",
|
|
332
|
+
],
|
|
333
|
+
fixable: true,
|
|
334
|
+
fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The Codex hook NAME expected per event, keyed off HookRegistry's own
|
|
342
|
+
# constants rather than a full built command string: naming it, not the
|
|
343
|
+
# literal dispatcher path, is what #check_codex_stale_registrations needs,
|
|
344
|
+
# so this never depends on plastic_home resolving to the same install the
|
|
345
|
+
# live hooks.json's commands were written under.
|
|
346
|
+
def codex_expected_names_by_event
|
|
347
|
+
names = {}
|
|
348
|
+
post_order = HookRegistry.events["PostToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
349
|
+
names["PostToolUse"] = HookRegistry::CODEX_POST_HOOKS & post_order
|
|
350
|
+
|
|
351
|
+
HookRegistry::CODEX_LIVE_STATE_EVENTS.each do |event|
|
|
352
|
+
names[event] = HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
end_order = HookRegistry.events["SessionEnd"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
356
|
+
names["SessionEnd"] = HookRegistry::CODEX_SESSION_END_HOOKS & end_order
|
|
357
|
+
names
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Row E (spec D8, spec 315b): #codex_hooks_registered_check computes
|
|
361
|
+
# `want - got` and iterates `expected.each`, so it can see a MISSING registration
|
|
362
|
+
# but never an EXTRA one, and it can never even visit an event `expected` does not
|
|
363
|
+
# list at all (a retired event such as PreToolUse, which a stale ~/.codex/hooks.json
|
|
364
|
+
# can still carry). This scans the UNION of expected and live event keys instead, so
|
|
365
|
+
# a stale registration parked in a retired event is visible. Compares hook NAMES
|
|
366
|
+
# (see #codex_expected_names_by_event), not full command strings: a full-command
|
|
367
|
+
# comparison would embed this Doctor instance's own `plastic_home` in the expected
|
|
368
|
+
# dispatcher path, which is only guaranteed to match the live hooks.json's own
|
|
369
|
+
# dispatcher path in a real, single install, never a test that fakes one without the
|
|
370
|
+
# other. Full tier only (`run_checks`); never `run_core_checks` (D8).
|
|
371
|
+
#
|
|
372
|
+
# Post-execution review item 5: name-only comparison alone is blind to two real
|
|
373
|
+
# cases, both closed here without reintroducing any plastic_home coupling: (a) the
|
|
374
|
+
# SAME (event, name) pair registered more than once (each copy's name is
|
|
375
|
+
# individually expected, so a "not in the expected set" scan never sees the
|
|
376
|
+
# duplicate), and (b) a correctly-named entry whose dispatcher path is an OLD
|
|
377
|
+
# install, different from the path every OTHER Plastic entry in this same file
|
|
378
|
+
# actually uses -- the "correct" path here is whichever path the file's own
|
|
379
|
+
# majority of entries already agrees on, never a path this Doctor instance derives
|
|
380
|
+
# from its own plastic_home.
|
|
381
|
+
def check_codex_stale_registrations
|
|
382
|
+
config = agents["codex"]
|
|
383
|
+
return [] unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
|
|
384
|
+
|
|
385
|
+
home_dir = config[:home_dir] || config[:dir]
|
|
386
|
+
hooks_json = File.join(home_dir, "hooks.json")
|
|
387
|
+
data = read_json_safe(hooks_json)
|
|
388
|
+
return [] if data.nil? || !data.is_a?(Hash)
|
|
389
|
+
|
|
390
|
+
expected_names = codex_expected_names_by_event
|
|
391
|
+
live = data["hooks"].is_a?(Hash) ? data["hooks"] : {}
|
|
392
|
+
|
|
393
|
+
# One pass over every live Plastic entry (event, hook name, and the literal
|
|
394
|
+
# dispatcher-path token it actually invokes), shared by all three checks below.
|
|
395
|
+
entries = []
|
|
396
|
+
(expected_names.keys | live.keys).each do |event|
|
|
397
|
+
Array(live[event]).each do |group|
|
|
398
|
+
Array(group["hooks"]).each do |h|
|
|
399
|
+
cmd = h["command"]
|
|
400
|
+
next unless HookRegistry.codex_purge_command?(cmd)
|
|
401
|
+
|
|
402
|
+
dispatcher_path = cmd.to_s.split(/\s+/).reject(&:empty?).first.to_s.delete("\"'")
|
|
403
|
+
name = HookRegistry.command_basenames(cmd).last
|
|
404
|
+
entries << { event: event, name: name, dispatcher_path: dispatcher_path, cmd: cmd }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
stale = []
|
|
410
|
+
|
|
411
|
+
# Not in the expected name set for this event at all (the original check).
|
|
412
|
+
entries.each do |e|
|
|
413
|
+
want_names = Array(expected_names[e[:event]])
|
|
414
|
+
stale << "#{e[:event]}: #{e[:cmd]}" unless want_names.include?(e[:name])
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# (a) the same (event, name) pair registered more than once.
|
|
418
|
+
entries.group_by { |e| [e[:event], e[:name]] }.each_value do |group|
|
|
419
|
+
next if group.size <= 1
|
|
420
|
+
|
|
421
|
+
group.each { |e| stale << "#{e[:event]}: #{e[:cmd]} (duplicate registration of #{e[:name]})" }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# (b) a dispatcher path that differs from the path every other Plastic entry in
|
|
425
|
+
# this same file actually uses.
|
|
426
|
+
unless entries.empty?
|
|
427
|
+
majority_path = entries.map { |e| e[:dispatcher_path] }.tally.max_by { |(_path, count)| count }&.first
|
|
428
|
+
entries.each do |e|
|
|
429
|
+
next if e[:dispatcher_path] == majority_path
|
|
430
|
+
|
|
431
|
+
stale << "#{e[:event]}: #{e[:cmd]} (dispatcher path #{e[:dispatcher_path]} differs from " \
|
|
432
|
+
"this file's other Plastic entries at #{majority_path})"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
stale.uniq!
|
|
437
|
+
|
|
438
|
+
if stale.empty?
|
|
439
|
+
[check(
|
|
440
|
+
category: "agent_registration", name: "codex_stale_registrations", status: "pass",
|
|
441
|
+
message: "No stale Plastic Codex registrations found outside the expected hook set"
|
|
442
|
+
)]
|
|
443
|
+
else
|
|
444
|
+
[check(
|
|
445
|
+
category: "agent_registration", name: "codex_stale_registrations", status: "warn",
|
|
446
|
+
message: "#{stale.size} stale Codex registration(s) found outside the expected hook set",
|
|
447
|
+
details: stale, fixable: true,
|
|
448
|
+
fix_hint: "Re-run the Plastic installer with --codex --reinstall to purge stale entries"
|
|
449
|
+
)]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
291
452
|
# --- Check category 2: Conventions ---
|
|
292
453
|
|
|
293
454
|
# When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
|
|
@@ -2235,6 +2396,8 @@ end
|
|
|
2235
2396
|
all_checks += check_conventions(scopes: ["global"])
|
|
2236
2397
|
all_checks += check_agent_registration(agent_key)
|
|
2237
2398
|
all_checks += check_core_files(agent_key)
|
|
2399
|
+
all_checks += check_harness_versions
|
|
2400
|
+
all_checks += check_codex_stale_registrations
|
|
2238
2401
|
all_checks += check_deprecations
|
|
2239
2402
|
all_checks += check_config_asks(agent_key)
|
|
2240
2403
|
all_checks += check_qmd
|
package/scripts/hook-capture
CHANGED
|
@@ -129,11 +129,20 @@ def truncate(text, max)
|
|
|
129
129
|
"#{text[0, max - 3]}..."
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
# --- (a) tmp root + heartbeat,
|
|
132
|
+
# --- (a) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
|
|
133
|
+
# Session start writes the `current` pointer; a capture with no pointer yet
|
|
134
|
+
# means session start never ran for this session at all (a `-p` print
|
|
135
|
+
# session, a resumed background job, an unregistered SessionStart hook), so
|
|
136
|
+
# neither the per-session dir nor its heartbeat is created. UserPromptSubmit
|
|
137
|
+
# fires before PostToolUse, so capture is the FIRST creator of every orphan
|
|
138
|
+
# `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
|
|
139
|
+
# its own (later) heartbeat write.
|
|
133
140
|
begin
|
|
134
|
-
SessionLedger.
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
if File.exist?(SessionLedger.pointer_path(store, sid))
|
|
142
|
+
SessionLedger.ensure_tmp_root(store)
|
|
143
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
144
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
145
|
+
end
|
|
137
146
|
rescue StandardError
|
|
138
147
|
nil
|
|
139
148
|
end
|
|
@@ -151,11 +160,14 @@ rescue StandardError
|
|
|
151
160
|
end
|
|
152
161
|
|
|
153
162
|
# --- (c) pending checklist line -------------------------------------------
|
|
163
|
+
# Gated on SessionLedger.capture_worthy?(prompt) (spec D2), a pure predicate
|
|
164
|
+
# over the RAW prompt so it sees the true first character and multi-line
|
|
165
|
+
# shape; the line text itself is still the sanitized, truncated copy.
|
|
154
166
|
unless skip_day_ledger
|
|
155
167
|
begin
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
if SessionLedger.capture_worthy?(prompt)
|
|
169
|
+
project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
|
|
170
|
+
clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
|
|
159
171
|
SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
|
|
160
172
|
line = SessionLedger.checklist_line(:pending, sid, project, clean)
|
|
161
173
|
SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
|
package/scripts/hook-record
CHANGED
|
@@ -135,13 +135,21 @@ unless skip_day_ledger
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
# --- (d) heartbeat,
|
|
138
|
+
# --- (d) heartbeat, guarded on the pointer (spec D9) -----------------------
|
|
139
|
+
# Session start writes the `current` pointer; with no pointer at all, session
|
|
140
|
+
# start never ran for this session (a `-p` print session, a resumed
|
|
141
|
+
# background job, an unregistered SessionStart hook), so neither the
|
|
142
|
+
# per-session dir nor its heartbeat is created here -- guarding only the
|
|
143
|
+
# write would still leave an empty directory that heartbeat_age's mtime
|
|
144
|
+
# fallback reports as an orphan just the same.
|
|
139
145
|
begin
|
|
140
146
|
store ||= File.join(plastic_home, "store")
|
|
141
147
|
sid ||= SessionLedger.short_session_id(nil, session_id)
|
|
142
|
-
SessionLedger.
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
if File.exist?(SessionLedger.pointer_path(store, sid))
|
|
149
|
+
SessionLedger.ensure_tmp_root(store)
|
|
150
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
151
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
152
|
+
end
|
|
145
153
|
rescue StandardError
|
|
146
154
|
nil
|
|
147
155
|
end
|
|
@@ -18,6 +18,24 @@ require_relative "lib/day_summary"
|
|
|
18
18
|
index_path, plastic_home, mode, plugin_root = ARGV
|
|
19
19
|
exit 0 unless index_path && plastic_home && mode
|
|
20
20
|
|
|
21
|
+
# --- session id: the stdin payload's session_id, then the env var, then the
|
|
22
|
+
# pid (spec 298 D1, row G, spec D4). Guarded on $stdin.tty? so a human running
|
|
23
|
+
# this hook by hand at a real terminal never blocks on a read that never gets
|
|
24
|
+
# an EOF; every real harness invocation pipes the SessionStart JSON payload,
|
|
25
|
+
# never attaches a tty. Malformed or empty stdin (or no payload id) falls
|
|
26
|
+
# through to the same env-var-then-pid chain this always had.
|
|
27
|
+
stdin_payload = begin
|
|
28
|
+
if $stdin.tty?
|
|
29
|
+
nil
|
|
30
|
+
else
|
|
31
|
+
raw = $stdin.read
|
|
32
|
+
raw && !raw.strip.empty? ? JSON.parse(raw) : nil
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
|
|
38
|
+
|
|
21
39
|
# Plastic home and the store are two different paths (intent 231). The shim passes
|
|
22
40
|
# home (~/.plastic) as argument 2; the store lives one level below it. Compose the
|
|
23
41
|
# store exactly once here, so no later line re-derives it and no path can gain a
|
|
@@ -361,7 +379,11 @@ begin
|
|
|
361
379
|
author = "session" if author.empty?
|
|
362
380
|
SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
|
|
363
381
|
|
|
364
|
-
session =
|
|
382
|
+
session = if !payload_session_id.empty?
|
|
383
|
+
payload_session_id
|
|
384
|
+
else
|
|
385
|
+
ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
|
|
386
|
+
end
|
|
365
387
|
sid = SessionLedger.short_session_id(nil, session)
|
|
366
388
|
SessionLedger.ensure_tmp_root(store_dir)
|
|
367
389
|
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
# intent-screen (intent 316) - prints the intent screen for one intent directory:
|
|
5
|
+
# the title, the field table (Store, Status, Stage, Savepoint, Progress, Next,
|
|
6
|
+
# Insight, each with a note), and the Steps table, filled from the record by
|
|
7
|
+
# scripts/lib/intent_screen.rb. The session adds the What-this-means bullets and
|
|
8
|
+
# the close; it never edits the numbers.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# intent-screen <intent_dir> [--template <path>]
|
|
12
|
+
#
|
|
13
|
+
# The store root is the directory two levels above the intent (<root>/store/<id--slug>);
|
|
14
|
+
# the template defaults to templates/intent-screen.md next to this script's dir,
|
|
15
|
+
# in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
|
|
16
|
+
# ~/.plastic/templates) alike.
|
|
17
|
+
#
|
|
18
|
+
# Exit codes:
|
|
19
|
+
# 0 - the screen is on stdout
|
|
20
|
+
# 2 - usage error, or the path is not an intent directory (one line on stderr)
|
|
21
|
+
|
|
22
|
+
require_relative "lib/intent_screen"
|
|
23
|
+
|
|
24
|
+
def usage_abort(message)
|
|
25
|
+
warn "intent-screen: #{message}"
|
|
26
|
+
exit 2
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
args = ARGV.dup
|
|
30
|
+
template_path = nil
|
|
31
|
+
positional = []
|
|
32
|
+
while (arg = args.shift)
|
|
33
|
+
case arg
|
|
34
|
+
when "--template"
|
|
35
|
+
template_path = args.shift or usage_abort("--template needs a path")
|
|
36
|
+
else
|
|
37
|
+
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
|
|
38
|
+
positional << arg
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
usage_abort("usage: intent-screen <intent_dir> [--template <path>]") unless positional.length == 1
|
|
43
|
+
intent_dir = File.expand_path(positional.first)
|
|
44
|
+
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
45
|
+
|
|
46
|
+
store_root = File.expand_path("../..", intent_dir)
|
|
47
|
+
template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
|
|
48
|
+
usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
|
|
49
|
+
|
|
50
|
+
$stdout.write IntentScreen.render(intent_dir: intent_dir, store_root: store_root,
|
|
51
|
+
template: File.read(template_path))
|
|
52
|
+
exit 0
|
|
@@ -18,6 +18,18 @@ require_relative "session_ledger"
|
|
|
18
18
|
module DoctorSessionLedger
|
|
19
19
|
ORPHAN_TTL_SECONDS = 24 * 60 * 60
|
|
20
20
|
|
|
21
|
+
# Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
|
|
22
|
+
# DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
|
|
23
|
+
# session start never ran at all (a `-p` print session, a resumed
|
|
24
|
+
# background job, an unregistered SessionStart hook), not a session that
|
|
25
|
+
# ran and then never closed. No choice of session id ever creates a
|
|
26
|
+
# pointer for that class, so it would otherwise stay invisible for the
|
|
27
|
+
# full 24 hours (or forever, once hook-capture/hook-record stop creating
|
|
28
|
+
# it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
|
|
29
|
+
# between its own start and its first pointer write is normal and must not
|
|
30
|
+
# be flagged, but that window is seconds, not hours.
|
|
31
|
+
NO_POINTER_TTL_SECONDS = 5 * 60
|
|
32
|
+
|
|
21
33
|
# Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
|
|
22
34
|
# else that file's mtime, else the directory's mtime.
|
|
23
35
|
def heartbeat_age(dir, now)
|
|
@@ -37,6 +49,7 @@ module DoctorSessionLedger
|
|
|
37
49
|
|
|
38
50
|
store_dir = File.join(plastic_home, "store")
|
|
39
51
|
orphans = orphaned_session_dirs(store_dir, now)
|
|
52
|
+
no_pointer = no_pointer_session_dirs(store_dir, now)
|
|
40
53
|
shape = day_ledger_shape_problems(store_dir)
|
|
41
54
|
|
|
42
55
|
checks = []
|
|
@@ -54,6 +67,20 @@ module DoctorSessionLedger
|
|
|
54
67
|
"session is gone: a live session rewrites its heartbeat on every " \
|
|
55
68
|
"prompt and edit, so only a listed directory may be removed, by hand.")
|
|
56
69
|
end
|
|
70
|
+
checks << if no_pointer.empty?
|
|
71
|
+
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
|
|
72
|
+
message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
|
|
73
|
+
"longer than #{NO_POINTER_TTL_SECONDS} seconds")
|
|
74
|
+
else
|
|
75
|
+
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
|
|
76
|
+
message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
|
|
77
|
+
"with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
|
|
78
|
+
"(session start never ran for this session)",
|
|
79
|
+
details: no_pointer, fixable: true,
|
|
80
|
+
fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
|
|
81
|
+
"session never started: no `current` pointer means session start never " \
|
|
82
|
+
"ran for it, so this is not a live session missing a checklist entry.")
|
|
83
|
+
end
|
|
57
84
|
checks << if shape.empty?
|
|
58
85
|
check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
|
|
59
86
|
message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
|
|
@@ -86,6 +113,31 @@ module DoctorSessionLedger
|
|
|
86
113
|
[] # unreadable .tmp/: nothing to report, never a crash
|
|
87
114
|
end
|
|
88
115
|
|
|
116
|
+
# Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
|
|
117
|
+
# NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
|
|
118
|
+
# that one is age-only and blind to whether a pointer exists at all, so a
|
|
119
|
+
# young no-pointer dir (a session between its start and its first pointer
|
|
120
|
+
# write) must not appear here even though it may well appear there once it
|
|
121
|
+
# ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
|
|
122
|
+
# and a dir can legitimately show up in neither, either, or both.
|
|
123
|
+
def no_pointer_session_dirs(store_dir, now)
|
|
124
|
+
tmp_root = SessionLedger.tmp_root(store_dir)
|
|
125
|
+
return [] unless File.directory?(tmp_root)
|
|
126
|
+
|
|
127
|
+
Dir.children(tmp_root).sort.filter_map do |name|
|
|
128
|
+
dir = File.join(tmp_root, name)
|
|
129
|
+
next unless File.directory?(dir)
|
|
130
|
+
next if File.exist?(File.join(dir, "current"))
|
|
131
|
+
|
|
132
|
+
age = heartbeat_age(dir, now)
|
|
133
|
+
next if age <= NO_POINTER_TTL_SECONDS
|
|
134
|
+
|
|
135
|
+
"global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
|
|
136
|
+
end
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
[] # unreadable .tmp/: nothing to report, never a crash
|
|
139
|
+
end
|
|
140
|
+
|
|
89
141
|
def day_ledger_shape_problems(store_dir)
|
|
90
142
|
root = SessionLedger.sessions_root(store_dir)
|
|
91
143
|
return [] unless File.directory?(root)
|
|
@@ -76,8 +76,25 @@ class InstallerCore
|
|
|
76
76
|
@agents = agents
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# Reads the package version from `package.json` when present (the source
|
|
80
|
+
# tree, and any dev checkout), falling back to the bare `VERSION` file the
|
|
81
|
+
# installer copies to `~/.plastic` (row B, spec 315b): an installed system
|
|
82
|
+
# carries VERSION but never package.json, so `rollback.rb` and `update.rb`
|
|
83
|
+
# constructing an InstallerCore with `package_root: ~/.plastic` crashed with
|
|
84
|
+
# a raw Errno::ENOENT before this fallback existed. Raises a named error,
|
|
85
|
+
# not a bare ENOENT, when neither file exists, so the caller is not
|
|
86
|
+
# misdirected toward the wrong missing file.
|
|
79
87
|
def read_package_version(root)
|
|
80
|
-
File.
|
|
88
|
+
package_json = File.join(root, "package.json")
|
|
89
|
+
version_file = File.join(root, "VERSION")
|
|
90
|
+
|
|
91
|
+
if File.exist?(package_json)
|
|
92
|
+
JSON.parse(File.read(package_json))["version"]
|
|
93
|
+
elsif File.exist?(version_file)
|
|
94
|
+
File.read(version_file).strip
|
|
95
|
+
else
|
|
96
|
+
raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
|
|
97
|
+
end
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
# --- Channel derivation (the channel is encoded in the version string) ---
|
|
@@ -423,6 +440,8 @@ class InstallerCore
|
|
|
423
440
|
"scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
|
|
424
441
|
"scripts/write-handoff" => "scripts/write-handoff",
|
|
425
442
|
"scripts/day-summary" => "scripts/day-summary",
|
|
443
|
+
"scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
|
|
444
|
+
"scripts/intent-screen" => "scripts/intent-screen",
|
|
426
445
|
"scripts/hook-savepoint" => "scripts/hook-savepoint",
|
|
427
446
|
}
|
|
428
447
|
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
# IntentScreen (intent 316) - fills templates/intent-screen.md from one intent's
|
|
4
|
+
# record: the intent file, the tier's INDEX.md, savepoint.md, and checklist.md.
|
|
5
|
+
# Every number on the screen comes from here so the session never writes one by
|
|
6
|
+
# eye. Pure: explicit paths in, a Markdown string out; no ENV, no Dir.pwd.
|
|
7
|
+
module IntentScreen
|
|
8
|
+
BAR_WIDTH = 20
|
|
9
|
+
ON = "█"
|
|
10
|
+
OFF = "░"
|
|
11
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
12
|
+
SECTIONS = %w[Active Future Completed Abandoned].freeze
|
|
13
|
+
ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
|
|
14
|
+
STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·]\s*/i
|
|
15
|
+
INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
|
|
16
|
+
SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
|
|
17
|
+
|
|
18
|
+
# Where a resume lands, from the ledger's last line (the boarding matrix).
|
|
19
|
+
def self.landing_stage(stage, milestone)
|
|
20
|
+
case stage
|
|
21
|
+
when "Done" then "Done"
|
|
22
|
+
when "What" then "Why"
|
|
23
|
+
when "Why" then milestone.to_s.include?("spec.md") ? "How" : "Why"
|
|
24
|
+
when "How" then milestone.to_s.include?("checklist.md") ? "Exec" : "How"
|
|
25
|
+
when "Exec" then milestone.to_s.include?("outcome.md") ? "ready to complete" : "Exec"
|
|
26
|
+
else "Why"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.intent_dir?(dir)
|
|
31
|
+
return false unless dir && File.directory?(dir)
|
|
32
|
+
|
|
33
|
+
base = File.basename(dir)
|
|
34
|
+
return false unless base.match?(/\A[0-9][0-9a-z]*--[\w-]+\z/)
|
|
35
|
+
|
|
36
|
+
File.exist?(File.join(dir, "#{base}.md"))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.render(intent_dir:, store_root:, template:)
|
|
40
|
+
base = File.basename(intent_dir)
|
|
41
|
+
id = base.split("--", 2).first
|
|
42
|
+
intent_text = File.read(File.join(intent_dir, "#{base}.md"))
|
|
43
|
+
|
|
44
|
+
fields = {}
|
|
45
|
+
fields.merge!(store_fields(store_root))
|
|
46
|
+
status, title = index_fields(store_root, id)
|
|
47
|
+
fields["status"] = status
|
|
48
|
+
fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
|
|
49
|
+
fields["id"] = id
|
|
50
|
+
fields["name"] = title || fallback_name(intent_text)
|
|
51
|
+
fields.merge!(savepoint_fields(intent_dir, intent_text))
|
|
52
|
+
items = checklist_items(intent_dir)
|
|
53
|
+
fields.merge!(progress_fields(items))
|
|
54
|
+
fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
|
|
55
|
+
fields.merge!(insight_fields(intent_text))
|
|
56
|
+
fields["steps.rows"] = steps_rows(items)
|
|
57
|
+
fields["meaning"] = ""
|
|
58
|
+
fields["close"] = ""
|
|
59
|
+
|
|
60
|
+
out = template.dup
|
|
61
|
+
fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
|
|
62
|
+
out.gsub(/\n{3,}/, "\n\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# --- store ---------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def self.store_fields(store_root)
|
|
68
|
+
parent = File.basename(File.dirname(store_root))
|
|
69
|
+
if parent == "projects"
|
|
70
|
+
slug = File.basename(store_root)
|
|
71
|
+
{ "store" => "project:#{slug}", "store.note" => "the #{slug} project store" }
|
|
72
|
+
else
|
|
73
|
+
{ "store" => "global", "store.note" => "the global store" }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- INDEX.md -----------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def self.index_fields(store_root, id)
|
|
80
|
+
path = File.join(store_root, "INDEX.md")
|
|
81
|
+
return ["unlisted", nil] unless File.exist?(path)
|
|
82
|
+
|
|
83
|
+
section = nil
|
|
84
|
+
File.foreach(path) do |line|
|
|
85
|
+
if line.start_with?("## ")
|
|
86
|
+
name = line[3..].strip
|
|
87
|
+
section = SECTIONS.include?(name) ? name : nil
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
next unless section && line.strip.start_with?("- [")
|
|
91
|
+
|
|
92
|
+
m = line.match(/\A\s*- \[#{Regexp.escape(id)}\s+[-—]\s+(.+?)\]\(/)
|
|
93
|
+
return [section, m[1].strip] if m
|
|
94
|
+
end
|
|
95
|
+
["unlisted", nil]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.fallback_name(intent_text)
|
|
99
|
+
m = intent_text.match(/^intent:\s*["']?(.+?)["']?\s*$/)
|
|
100
|
+
text = m ? m[1] : ""
|
|
101
|
+
text.length > 60 ? "#{text[0, 57]}..." : text
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- savepoint.md ---------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def self.savepoint_fields(intent_dir, intent_text)
|
|
107
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
108
|
+
lines = File.exist?(path) ? File.readlines(path).map(&:strip).reject(&:empty?) : []
|
|
109
|
+
last = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact.first
|
|
110
|
+
unless last
|
|
111
|
+
return { "stage" => "Why", "stage.note" => "no savepoint line yet",
|
|
112
|
+
"savepoint" => "none", "savepoint.note" => "" }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
ts, stage, milestone = last[1], last[2], last[3]
|
|
116
|
+
landing = landing_stage(stage, milestone)
|
|
117
|
+
delivered = lines.map { |l| l.match(SAVEPOINT_RE) }.compact.map { |m| m[2] }.uniq
|
|
118
|
+
delivered &= %w[What Why How Exec]
|
|
119
|
+
note = if landing == "Done"
|
|
120
|
+
"delivered; the record is immutable"
|
|
121
|
+
elsif landing == "ready to complete"
|
|
122
|
+
"outcome.md is real; run the ending procedure"
|
|
123
|
+
else
|
|
124
|
+
"#{delivered.join(', ')} delivered; the work is open"
|
|
125
|
+
end
|
|
126
|
+
{ "stage" => landing, "stage.note" => note,
|
|
127
|
+
"savepoint" => "#{stage} · #{milestone}", "savepoint.note" => human_time(ts) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.human_time(ts)
|
|
131
|
+
m = ts.match(/\A(\d{4}-\d\d-\d\d)T(\d\d:\d\d)/)
|
|
132
|
+
m ? "#{m[1]} #{m[2]} UTC" : ts
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --- checklist.md --------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def self.items_present?(intent_dir)
|
|
138
|
+
path = File.join(intent_dir, "checklist.md")
|
|
139
|
+
return false unless File.exist?(path)
|
|
140
|
+
|
|
141
|
+
!File.read(path).lstrip.start_with?(PLACEHOLDER_SENTINEL)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.checklist_items(intent_dir)
|
|
145
|
+
return [] unless items_present?(intent_dir)
|
|
146
|
+
|
|
147
|
+
File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
|
|
148
|
+
m = line.match(ITEM_RE)
|
|
149
|
+
next unless m
|
|
150
|
+
|
|
151
|
+
text = m[2].strip
|
|
152
|
+
next if text == "..."
|
|
153
|
+
|
|
154
|
+
{ done: m[1] != " ", text: text.sub(STEP_PREFIX_RE, "") }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.progress_fields(items)
|
|
159
|
+
total = items.length
|
|
160
|
+
done = items.count { |i| i[:done] }
|
|
161
|
+
on = total.zero? ? 0 : (done * BAR_WIDTH) / total
|
|
162
|
+
bar = (ON * on) + (OFF * (BAR_WIDTH - on))
|
|
163
|
+
note = if total.zero?
|
|
164
|
+
"no checklist yet"
|
|
165
|
+
elsif done == total
|
|
166
|
+
"all steps done"
|
|
167
|
+
else
|
|
168
|
+
"#{total - done} steps open"
|
|
169
|
+
end
|
|
170
|
+
{ "progress.bar" => bar, "progress.done" => done.to_s, "progress.total" => total.to_s,
|
|
171
|
+
"progress.note" => note }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.next_fields(items, status, checklist_present:)
|
|
175
|
+
return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
|
|
176
|
+
return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
|
|
177
|
+
|
|
178
|
+
idx = items.index { |i| !i[:done] }
|
|
179
|
+
return { "next" => "", "next.note" => "all steps done" } unless idx
|
|
180
|
+
|
|
181
|
+
head, = split_first_clause(items[idx][:text])
|
|
182
|
+
{ "next" => "S#{idx + 1} · #{escape(head)}", "next.note" => "first open step" }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.steps_rows(items)
|
|
186
|
+
return "| | | no steps yet |" if items.empty?
|
|
187
|
+
|
|
188
|
+
items.each_with_index.map do |item, i|
|
|
189
|
+
"| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(item[:text])} |"
|
|
190
|
+
end.join("\n")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.escape(text)
|
|
194
|
+
text.gsub("|", "\\|")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- ## Insights ----------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def self.insight_fields(intent_text)
|
|
200
|
+
section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
|
|
201
|
+
entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
|
|
202
|
+
return { "insight" => "none yet", "insight.note" => "" } unless entry
|
|
203
|
+
|
|
204
|
+
ts, text = entry[1], entry[2].strip
|
|
205
|
+
head, tail = split_first_clause(text)
|
|
206
|
+
note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
|
|
207
|
+
{ "insight" => escape(head), "insight.note" => escape(note) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def self.split_first_clause(text)
|
|
211
|
+
m = text.match(/\A(.+?)[.;](\s+.*|\z)/m)
|
|
212
|
+
head = m ? m[1] : text
|
|
213
|
+
tail = m ? m[2].to_s.strip : ""
|
|
214
|
+
if head.length > 60
|
|
215
|
+
cut = head[0, 60].rindex(" ") || 60
|
|
216
|
+
tail = "#{head[cut..].strip} #{tail}".strip
|
|
217
|
+
head = head[0, cut].strip
|
|
218
|
+
end
|
|
219
|
+
[head, tail]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -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.
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
where we left off", "where was I", "what should I work on", names a specific intent to
|
|
6
6
|
resume (by id or description, or `--intent {id}`), or names a roadmap or delivery batch to
|
|
7
7
|
resume (`--roadmap {slug}`, "where is the roadmap", "where did that batch land"). Presents
|
|
8
|
-
state and resumes at the last delivered
|
|
8
|
+
state and resumes at the last delivered stage; it never asks auto or guided, never boots
|
|
9
9
|
(the SessionStart hook owns boot), and never drives work autonomously (plastic-auto does).
|
|
10
10
|
Absorbs the former continuing, project-continuing, and roadmap-continuing skills and the
|
|
11
11
|
read half of the former intent-starting skill (intent 304).
|
|
@@ -79,19 +79,20 @@ QMD-first when the intent is named by description: run
|
|
|
79
79
|
authoritative intent file. The command is a no-op when QMD is absent; fall back to
|
|
80
80
|
`INDEX.md`.
|
|
81
81
|
|
|
82
|
-
If the intent is terminal (`## Completed` or `## Abandoned` in `INDEX.md`):
|
|
83
|
-
|
|
82
|
+
If the intent is terminal (`## Completed` or `## Abandoned` in `INDEX.md`): print the
|
|
83
|
+
intent screen (Status shows the terminal section, Next is empty), summarize its
|
|
84
|
+
`outcome.md`, and ask what is next; never reopen it.
|
|
84
85
|
|
|
85
86
|
For a live intent's directory:
|
|
86
87
|
|
|
87
88
|
1. **Read `savepoint.md` first.** It is a deterministic, append-only ledger, one line per
|
|
88
|
-
event, newest at the bottom: `{utc-iso8601} {Stage} {milestone}`. Classify the
|
|
89
|
+
event, newest at the bottom: `{utc-iso8601} {Stage} {milestone}`. Classify the stage
|
|
89
90
|
from the last line alone (the table in `references/boarding-matrix.md`, read when
|
|
90
91
|
classifying), then verify only that line's artifact is real (sentinel-aware:
|
|
91
92
|
`Savepoint.stage_file_present?`). Do not re-probe every lifecycle file.
|
|
92
|
-
2. **
|
|
93
|
+
2. **Stale ledger.** When the last line disagrees with the files on disk, rebuild the ledger from
|
|
93
94
|
disk and note the correction. A rebuilt ledger is the file-landing skeleton, which still
|
|
94
|
-
pins the
|
|
95
|
+
pins the stage:
|
|
95
96
|
```bash
|
|
96
97
|
ruby -r ~/.plastic/scripts/lib/savepoint -e 'Savepoint.rebuild_savepoint("<intent_dir>")'
|
|
97
98
|
```
|
|
@@ -99,18 +100,16 @@ For a live intent's directory:
|
|
|
99
100
|
else the newest prior day) is the prior session's own account of where things stand; read
|
|
100
101
|
it after the ledger, never instead of it.
|
|
101
102
|
4. **Derive the next step:** the first unchecked item in `checklist.md` when it exists, else
|
|
102
|
-
the next thing the
|
|
103
|
+
the next thing the stage needs (see the matrix). The newest `## Insights` entry supplies
|
|
103
104
|
the human-readable context; an entry marked `(autonomous)` means an auto team was
|
|
104
105
|
delivering it, so say so and offer to hand back to `plastic-auto`.
|
|
105
|
-
5. **
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Drift: [none | ledger rebuilt from disk]
|
|
113
|
-
```
|
|
106
|
+
5. **Print the intent screen, then continue at that stage.** Run
|
|
107
|
+
`ruby ~/.plastic/scripts/intent-screen <intent_dir>` and print its output as it is: the
|
|
108
|
+
title, the field table, and the Steps table come from the record, never by eye. Under it
|
|
109
|
+
write **What this means** as two to four bullets in plain words (what the intent is for,
|
|
110
|
+
what has landed, what is left, any defect named by step), then close with
|
|
111
|
+
**needs input:** naming the first open step. The screen's shape is
|
|
112
|
+
`~/.plastic/templates/intent-screen.md`; the script fills it, the session never edits the numbers.
|
|
114
113
|
Then continue the work in the session's current mode. In auto mode the running team
|
|
115
114
|
already holds the delivery lock; if a lock is held by a session that is gone, the
|
|
116
115
|
`plastic-doctor` skill's lock section repairs or reclaims it.
|
|
@@ -141,6 +140,6 @@ For a live intent's directory:
|
|
|
141
140
|
| Trigger | Read |
|
|
142
141
|
|---|---|
|
|
143
142
|
| Filling the board on the project route | `references/board-fill.md` |
|
|
144
|
-
| Classifying the
|
|
143
|
+
| Classifying the stage from the ledger's last line | `references/boarding-matrix.md` |
|
|
145
144
|
| Explaining why one roadmap ranked above another | `references/liveness-ranking.md` |
|
|
146
145
|
| Saving or restoring context across a long session, or debugging a resume | `references/context-management.md` |
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Boarding matrix: which
|
|
1
|
+
# Boarding matrix: which stage a resume lands at
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The stage is derived from `savepoint.md`'s last line plus the real artifacts on disk.
|
|
4
4
|
Classify from the last line alone, then verify only that line's artifact is real
|
|
5
|
-
(sentinel-aware).
|
|
5
|
+
(sentinel-aware). When the ledger is stale, rebuild it from disk and note it.
|
|
6
6
|
|
|
7
7
|
| savepoint last line | latest delivered | lands at | continue with |
|
|
8
8
|
|---|---|---|---|
|
|
@@ -10,11 +10,11 @@ Classify from the last line alone, then verify only that line's artifact is real
|
|
|
10
10
|
| `Why started` (spec still sentinel) | What | **Why** | continue the conversation; rulings land as insights |
|
|
11
11
|
| `Why spec.md created` | Why | **How** | the action files, `plan.md`, `checklist.md` |
|
|
12
12
|
| `How started` / `How plan.md created` | (How in progress) | **How** | finish `plan.md` and `checklist.md` |
|
|
13
|
-
| `How checklist.md created` / `Exec started` | How | **Exec** | do the work,
|
|
13
|
+
| `How checklist.md created` / `Exec started` | How | **Exec** | do the work, check off the checklist |
|
|
14
14
|
| `Exec outcome.md created` | Exec | **ready to complete** | the ending procedure (`plastic-intent-ending`) |
|
|
15
15
|
| `Done delivered` / `Done abandoned` | terminal | **report only** | immutable; ask what is next |
|
|
16
16
|
|
|
17
|
-
## Per-
|
|
17
|
+
## Per-stage behaviour (what "continue" means)
|
|
18
18
|
|
|
19
19
|
- **Why**: continue the conversation, or run the work directly when the request is already
|
|
20
20
|
clear; every ruling is recorded as it lands.
|
|
@@ -19,7 +19,7 @@ step looks stale):
|
|
|
19
19
|
(see `SKILL.md`'s `## Conditional Ledger-Resume` for the full state table).
|
|
20
20
|
2. Confirm the artifact that line implies (`plan.md`, `checklist.md`, `outcome.md`, ...) is
|
|
21
21
|
present and non-empty on disk.
|
|
22
|
-
3. If the two disagree, the ledger
|
|
22
|
+
3. If the two disagree, the ledger is stale: rebuild it rather than hand-editing:
|
|
23
23
|
`ruby -r ~/.plastic/scripts/lib/savepoint -e 'Savepoint.rebuild_savepoint("<intent_dir>")'`
|
|
24
24
|
4. Re-read the rebuilt last line and re-derive the next step from `checklist.md`'s first
|
|
25
25
|
unchecked item.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
## ▶ {{id}} · {{name}}
|
|
2
|
+
|
|
3
|
+
| | | |
|
|
4
|
+
| --- | --- | --- |
|
|
5
|
+
| **Store** | {{store}} | {{store.note}} |
|
|
6
|
+
| **Status** | {{status}} | {{status.note}} |
|
|
7
|
+
| **Stage** | {{stage}} | {{stage.note}} |
|
|
8
|
+
| **Savepoint** | {{savepoint}} | {{savepoint.note}} |
|
|
9
|
+
| **Progress** | {{progress.bar}} {{progress.done}} / {{progress.total}} | {{progress.note}} |
|
|
10
|
+
| **Next** | {{next}} | {{next.note}} |
|
|
11
|
+
| **Insight** | {{insight}} | {{insight.note}} |
|
|
12
|
+
|
|
13
|
+
**What this means**
|
|
14
|
+
{{meaning}}
|
|
15
|
+
|
|
16
|
+
**Steps**
|
|
17
|
+
|
|
18
|
+
| Step | Status | What |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
{{steps.rows}}
|
|
21
|
+
|
|
22
|
+
{{close}}
|