@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.10
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 +648 -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 +120 -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 +13 -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
package/scripts/append-ledger
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
# 3 - promote/tick could not take an exclusive lock (filesystem without flock support)
|
|
32
32
|
|
|
33
33
|
require_relative "lib/session_ledger"
|
|
34
|
+
require_relative "lib/handoff"
|
|
34
35
|
|
|
35
36
|
VERBS = %w[pending item promote tick savepoint].freeze
|
|
36
37
|
|
|
@@ -129,10 +130,21 @@ def run_transition(from, to, event, store, day, session, project, opts)
|
|
|
129
130
|
exit 2
|
|
130
131
|
end
|
|
131
132
|
|
|
132
|
-
|
|
133
|
+
if opts[:savepoint]
|
|
134
|
+
line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
|
|
135
|
+
SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
|
|
136
|
+
end
|
|
133
137
|
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
return unless to == :done
|
|
139
|
+
|
|
140
|
+
# The hand-off at every tick (intent 311, spec D4): derived and
|
|
141
|
+
# regenerable, so a failure here never fails the tick that was recorded.
|
|
142
|
+
begin
|
|
143
|
+
Handoff.write(store: store, day: day, session: session, trigger: "tick",
|
|
144
|
+
templates: opts[:templates] || default_templates_dir)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
136
148
|
end
|
|
137
149
|
|
|
138
150
|
def run_savepoint(store, day, session, project, opts)
|
|
@@ -153,6 +165,7 @@ def main(argv)
|
|
|
153
165
|
store = opts[:store] ? expand(opts[:store]) : File.join(plastic_home, "store")
|
|
154
166
|
templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
|
|
155
167
|
usage_abort("templates dir not found: #{templates}") unless Dir.exist?(templates)
|
|
168
|
+
opts[:templates] = templates
|
|
156
169
|
cwd = opts[:cwd] ? expand(opts[:cwd]) : Dir.pwd
|
|
157
170
|
|
|
158
171
|
day = opts[:day] || SessionLedger.day_id
|
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, " \
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# day-summary (intent 311): print the block SessionStart injects at boot
|
|
6
|
+
# (open items, the last five done, live auto intents, other active
|
|
7
|
+
# sessions). Prints nothing when there is nothing to say.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# day-summary [--store <dir>] [--day <YYYYMMDD>] [--session <id>] [--home <dir>]
|
|
11
|
+
#
|
|
12
|
+
# Defaults: the home is $PLASTIC_HOME (~/.plastic), the store is <home>/store,
|
|
13
|
+
# the day is today (local wall clock), the session is $CLAUDE_CODE_SESSION_ID.
|
|
14
|
+
#
|
|
15
|
+
# Exit codes: 0 done; 2 usage error.
|
|
16
|
+
|
|
17
|
+
require_relative "lib/session_ledger"
|
|
18
|
+
require_relative "lib/day_summary"
|
|
19
|
+
|
|
20
|
+
def usage_abort(message)
|
|
21
|
+
warn "day-summary: #{message}"
|
|
22
|
+
exit 2
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def expand(path)
|
|
26
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse_args(argv)
|
|
30
|
+
opts = {}
|
|
31
|
+
i = 0
|
|
32
|
+
while i < argv.length
|
|
33
|
+
flag = argv[i]
|
|
34
|
+
usage_abort("unknown argument #{flag.inspect}") unless flag.start_with?("--")
|
|
35
|
+
usage_abort("#{flag} requires a value") if i + 1 >= argv.length
|
|
36
|
+
key = flag.delete_prefix("--").to_sym
|
|
37
|
+
usage_abort("unknown flag #{flag.inspect}") unless %i[store day session home].include?(key)
|
|
38
|
+
opts[key] = argv[i + 1]
|
|
39
|
+
i += 2
|
|
40
|
+
end
|
|
41
|
+
opts
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
opts = parse_args(ARGV)
|
|
45
|
+
home = opts[:home] ? expand(opts[:home]) : expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
|
|
46
|
+
store = opts[:store] ? expand(opts[:store]) : File.join(home, "store")
|
|
47
|
+
day = opts[:day] || SessionLedger.day_id
|
|
48
|
+
usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}") unless SessionLedger.valid_day_id?(day)
|
|
49
|
+
session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
|
|
50
|
+
|
|
51
|
+
text = DaySummary.build(store: store, day: day, session: session, home: home)
|
|
52
|
+
puts text unless text.empty?
|
|
53
|
+
exit 0
|
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/end-intent
CHANGED
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
# refused before removal; pass --discard-worktree-changes to override
|
|
67
67
|
# deliberately.
|
|
68
68
|
# 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
|
|
69
|
+
# 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
|
|
70
|
+
# exception to report-and-proceed; --allow-hollow-report overrides.
|
|
69
71
|
|
|
70
72
|
require "fileutils"
|
|
71
73
|
require "date"
|
|
@@ -77,6 +79,7 @@ require_relative "lib/savepoint"
|
|
|
77
79
|
require_relative "lib/lock"
|
|
78
80
|
require_relative "lib/intent_validator"
|
|
79
81
|
require_relative "lib/outcome_guard"
|
|
82
|
+
require_relative "lib/report_screen"
|
|
80
83
|
require_relative "lib/backfill_intent"
|
|
81
84
|
|
|
82
85
|
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
@@ -99,6 +102,7 @@ def parse_args(argv)
|
|
|
99
102
|
opts = {
|
|
100
103
|
store: nil, id: nil, disposition: nil, index: nil,
|
|
101
104
|
outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
|
|
105
|
+
allow_hollow_report: false,
|
|
102
106
|
session: nil, discard_worktree_changes: false,
|
|
103
107
|
}
|
|
104
108
|
i = 0
|
|
@@ -115,6 +119,7 @@ def parse_args(argv)
|
|
|
115
119
|
when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
|
|
116
120
|
when "--no-commit" then opts[:no_commit] = true
|
|
117
121
|
when "--dry-run" then opts[:dry_run] = true
|
|
122
|
+
when "--allow-hollow-report" then opts[:allow_hollow_report] = true
|
|
118
123
|
else
|
|
119
124
|
usage_abort("unknown argument #{arg.inspect}")
|
|
120
125
|
end
|
|
@@ -215,7 +220,75 @@ end
|
|
|
215
220
|
# Replace the body of the intent file's `## Outcome` section with `summary`,
|
|
216
221
|
# leaving every other section and the frontmatter untouched. No-op (returns
|
|
217
222
|
# content unchanged) when summary is blank or the section is not found.
|
|
223
|
+
# 317a S9 (D7): the self-render gate. Renders the delivered screen's sources on
|
|
224
|
+
# the intent's own record and names what would come out hollow. This is intent
|
|
225
|
+
# 308's ONE deliberate exception to report-and-proceed: a hollow DELIVERED
|
|
226
|
+
# close is refused with exit 7 (--allow-hollow-report overrides), because 317
|
|
227
|
+
# closed exactly this way and the owner read a screen full of "not recorded".
|
|
228
|
+
# Abandoned closes and machine-backfilled outcomes (the backfill marker) are
|
|
229
|
+
# exempt: the reader cannot expect labels the writer never had.
|
|
230
|
+
def hollow_report_reason(intent_dir, disposition)
|
|
231
|
+
return nil unless disposition == "delivered"
|
|
232
|
+
|
|
233
|
+
outcome = File.join(intent_dir, "outcome.md")
|
|
234
|
+
return nil unless File.exist?(outcome)
|
|
235
|
+
text = File.read(outcome)
|
|
236
|
+
return nil if text.include?("<!-- backfilled from the record by end-intent on ")
|
|
237
|
+
|
|
238
|
+
# The gate's evidence bar (A7/B7): it judges only records that CLAIM the
|
|
239
|
+
# modern convention - at least one actions/*.md heading carrying an S-label.
|
|
240
|
+
# A terse legacy close with no labeled matrix stays report-and-proceed.
|
|
241
|
+
heading_labels = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.flat_map do |path|
|
|
242
|
+
ReportScreen.split_by_headings(File.read(path)).filter_map do |heading, _body|
|
|
243
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).find { |tok| tok.match?(/\AS\d+\z/) }
|
|
244
|
+
end
|
|
245
|
+
end.uniq
|
|
246
|
+
return nil if heading_labels.empty?
|
|
247
|
+
|
|
248
|
+
rows = ReportScreen.delivered_rows(intent_dir)
|
|
249
|
+
return "outcome.md ## Delivered renders no rows although a labeled action matrix exists" if rows.empty?
|
|
250
|
+
|
|
251
|
+
needs = ReportScreen.section_of(text, "## Needs you").gsub(/<!--.*?-->/m, "").strip
|
|
252
|
+
if !needs.empty? && needs != "None" && ReportScreen.needs_you_rows(intent_dir).empty?
|
|
253
|
+
return "## Needs you has content that would render as None"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
labels = rows.map { |r| r[:label] }
|
|
257
|
+
if (labels & heading_labels).empty?
|
|
258
|
+
return "delivered row labels (#{labels.first(3).join(', ')}...) match no action-file heading (#{heading_labels.first(3).join(', ')}...)"
|
|
259
|
+
end
|
|
260
|
+
if rows.all? { |r| ReportScreen.proven_by(intent_dir, r[:label]) == ReportScreen::NOT_RECORDED }
|
|
261
|
+
return "every Proven-by cell renders not recorded although labeled action headings exist"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
nil
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
warn "end-intent: hollow-report gate crashed (#{e.message}); proceeding without it"
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# 317a S7 (D5, A8): stamp the live lock's run_mode into outcome.md frontmatter
|
|
271
|
+
# INSIDE the close step - after the gate, before the INDEX move and the store
|
|
272
|
+
# commit - so the stamped file rides the close commit and mode survives disarm.
|
|
273
|
+
def stamp_outcome_mode(intent_dir)
|
|
274
|
+
data = Lock.read(intent_dir)
|
|
275
|
+
run_mode = data && data["run_mode"].to_s
|
|
276
|
+
return if run_mode.nil? || run_mode.empty?
|
|
277
|
+
|
|
278
|
+
path = File.join(intent_dir, "outcome.md")
|
|
279
|
+
return unless File.exist?(path)
|
|
280
|
+
text = File.read(path)
|
|
281
|
+
return unless text.start_with?("---")
|
|
282
|
+
head, body = text[3..].split("---", 2)
|
|
283
|
+
return if body.nil? || head.match?(/^mode:/)
|
|
284
|
+
|
|
285
|
+
File.write(path, "---#{head}mode: #{run_mode}\n---#{body}")
|
|
286
|
+
rescue StandardError => e
|
|
287
|
+
warn "end-intent: mode stamp failed (#{e.message}); proceeding without it"
|
|
288
|
+
end
|
|
289
|
+
|
|
218
290
|
def stamp_outcome_summary(content, summary)
|
|
291
|
+
|
|
219
292
|
return content if summary.nil? || summary.to_s.strip.empty?
|
|
220
293
|
|
|
221
294
|
lines = content.lines
|
|
@@ -654,6 +727,26 @@ def main(argv)
|
|
|
654
727
|
reason = OutcomeGuard.reason(intent_dir, disposition)
|
|
655
728
|
warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
|
|
656
729
|
|
|
730
|
+
# 1a2. The self-render gate (317a S9, D7): refuse a hollow delivered close
|
|
731
|
+
# BEFORE any terminal mutation - INDEX, savepoint, and store stay untouched
|
|
732
|
+
# on exit 7 (A9).
|
|
733
|
+
hollow = hollow_report_reason(intent_dir, disposition)
|
|
734
|
+
if hollow
|
|
735
|
+
if opts[:allow_hollow_report]
|
|
736
|
+
warn "end-intent: hollow delivered report overridden (--allow-hollow-report): #{hollow}"
|
|
737
|
+
else
|
|
738
|
+
warn "end-intent: refusing a hollow delivered close: #{hollow}"
|
|
739
|
+
warn "end-intent: write outcome.md's ## Delivered as the labeled | Row | What | table " \
|
|
740
|
+
"matching the action-file headings (templates/outcome.md shows the shape), " \
|
|
741
|
+
"or pass --allow-hollow-report to close anyway"
|
|
742
|
+
exit 7
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# 1a3. Mode stamp (317a S7, D5): run_mode into outcome frontmatter while the
|
|
747
|
+
# lock still exists, so the stamped file rides the close commit.
|
|
748
|
+
stamp_outcome_mode(intent_dir)
|
|
749
|
+
|
|
657
750
|
# 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
|
|
658
751
|
if opts[:outcome_summary]
|
|
659
752
|
intent_file = Savepoint.intent_file(intent_dir)
|
package/scripts/hook-capture
CHANGED
|
@@ -31,7 +31,8 @@ end
|
|
|
31
31
|
exit 0 unless payload.is_a?(Hash)
|
|
32
32
|
|
|
33
33
|
session_id = payload["session_id"].to_s
|
|
34
|
-
prompt = payload["
|
|
34
|
+
prompt = payload["prompt"].to_s
|
|
35
|
+
prompt = payload["user_prompt"].to_s if prompt.empty?
|
|
35
36
|
cwd = payload["cwd"].to_s
|
|
36
37
|
cwd = Dir.pwd if cwd.empty?
|
|
37
38
|
|
|
@@ -128,11 +129,20 @@ def truncate(text, max)
|
|
|
128
129
|
"#{text[0, max - 3]}..."
|
|
129
130
|
end
|
|
130
131
|
|
|
131
|
-
# --- (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.
|
|
132
140
|
begin
|
|
133
|
-
SessionLedger.
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
136
146
|
rescue StandardError
|
|
137
147
|
nil
|
|
138
148
|
end
|
|
@@ -150,11 +160,14 @@ rescue StandardError
|
|
|
150
160
|
end
|
|
151
161
|
|
|
152
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.
|
|
153
166
|
unless skip_day_ledger
|
|
154
167
|
begin
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
if SessionLedger.capture_worthy?(prompt)
|
|
169
|
+
project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
|
|
170
|
+
clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
|
|
158
171
|
SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
|
|
159
172
|
line = SessionLedger.checklist_line(:pending, sid, project, clean)
|
|
160
173
|
SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
|
package/scripts/hook-close
CHANGED
|
@@ -25,9 +25,11 @@ payload =
|
|
|
25
25
|
|
|
26
26
|
store = File.join(File.expand_path(plastic_home), "store")
|
|
27
27
|
filer = File.expand_path("file-session-intent", __dir__)
|
|
28
|
+
templates = File.expand_path("../templates", __dir__)
|
|
28
29
|
begin
|
|
29
30
|
SessionClose.run(payload: payload, store: store, today: SessionLedger.day_id,
|
|
30
|
-
spawner: SessionClose.default_spawner(filer)
|
|
31
|
+
spawner: SessionClose.default_spawner(filer),
|
|
32
|
+
handoff: SessionClose.default_handoff(templates))
|
|
31
33
|
rescue StandardError
|
|
32
34
|
nil
|
|
33
35
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# hook-message-display (intent 316a, O4): the MessageDisplay hook CLI. Reads
|
|
6
|
+
# the harness's per-chunk JSON payload from stdin, hands it to MessageDisplay
|
|
7
|
+
# (the pure handler class), and prints the hookSpecificOutput envelope when a
|
|
8
|
+
# String comes back. Always exits 0, whatever happens — a raised exception
|
|
9
|
+
# here must never surface as a non-zero exit or stray stderr on an ordinary
|
|
10
|
+
# chunk of an ordinary message (matrix 31).
|
|
11
|
+
#
|
|
12
|
+
# This script is the one place in the O4 stack allowed to read ENV, the
|
|
13
|
+
# clock, or Dir.tmpdir: MessageDisplay itself takes tmp_root/plastic_home/
|
|
14
|
+
# color/now as constructor arguments and touches none of them directly.
|
|
15
|
+
#
|
|
16
|
+
# Claude adapter: Claude Code only; the core is harness-agnostic.
|
|
17
|
+
|
|
18
|
+
# 317a (B1): the requires sit inside their own rescue - a missing or broken
|
|
19
|
+
# lib file must never surface as a non-zero exit or stderr on a chunk of an
|
|
20
|
+
# ordinary message (LoadError is a ScriptError, outside StandardError).
|
|
21
|
+
begin
|
|
22
|
+
require "json"
|
|
23
|
+
require "time"
|
|
24
|
+
require "tmpdir"
|
|
25
|
+
require "yaml"
|
|
26
|
+
require_relative "lib/message_display"
|
|
27
|
+
rescue ScriptError, StandardError
|
|
28
|
+
exit 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def color_enabled?(plastic_home)
|
|
32
|
+
return false unless ENV["NO_COLOR"].to_s.empty?
|
|
33
|
+
|
|
34
|
+
cfg_path = File.join(plastic_home, "config.yml")
|
|
35
|
+
return true unless File.exist?(cfg_path)
|
|
36
|
+
|
|
37
|
+
cfg = YAML.safe_load(File.read(cfg_path))
|
|
38
|
+
display = cfg.is_a?(Hash) ? cfg["display"] : nil
|
|
39
|
+
return true unless display.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
display.fetch("ansi_screen", true) != false
|
|
42
|
+
rescue StandardError
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
raw = $stdin.tty? ? "" : $stdin.read
|
|
48
|
+
payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
|
|
49
|
+
|
|
50
|
+
if payload.is_a?(Hash)
|
|
51
|
+
tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
|
|
52
|
+
plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
53
|
+
|
|
54
|
+
handler = MessageDisplay.new(
|
|
55
|
+
tmp_root: tmp_root,
|
|
56
|
+
plastic_home: plastic_home,
|
|
57
|
+
color: color_enabled?(plastic_home),
|
|
58
|
+
now: Time.now,
|
|
59
|
+
)
|
|
60
|
+
result = handler.handle(payload)
|
|
61
|
+
|
|
62
|
+
if result.is_a?(String)
|
|
63
|
+
puts JSON.generate(
|
|
64
|
+
"hookSpecificOutput" => {
|
|
65
|
+
"hookEventName" => "MessageDisplay",
|
|
66
|
+
"displayContent" => result,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
exit 0
|
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
|