@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,211 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "open3"
6
+ require "timeout"
7
+
8
+ # HookReplay (intent 331a, T1; promoted to a production lib in 331e) - streams
9
+ # text through a MessageDisplay launcher the way Claude Code streams an
10
+ # assistant reply, chunk by chunk, and returns every chunk's raw stdout.
11
+ #
12
+ # 331a's test/support/hook_replay.rb held this as test-only code; 331e's
13
+ # doctor `display_hook_paints` check (scripts/doctor.rb) needs the exact same
14
+ # mechanics to replay the INSTALLED launcher for real, so the logic lives
15
+ # here and test/support/hook_replay.rb now delegates to it (a require, not a
16
+ # duplicate). Never require this from scripts/lib/doctor_core.rb: that file
17
+ # is the SessionStart boot path (test/doctor_core_split_test.rb T2 pins its
18
+ # exact require set), and the paint check that needs this lib runs only from
19
+ # the full scripts/doctor.rb.
20
+ module HookReplay
21
+ module_function
22
+
23
+ # Streams `text` through `hook_path` in fixed-size chunks. `session_id`/
24
+ # `message_id` default to fixed values since nothing about a replay depends
25
+ # on the ambient session at all.
26
+ #
27
+ # `env` (intent 331e): extra child-process environment, merged over the
28
+ # PLASTIC_TMP entry every call already sets (a caller's own key wins). A
29
+ # `nil` value unsets that variable in the child (Process.spawn's own
30
+ # convention) is how a caller forces NO_COLOR off regardless of the ambient
31
+ # environment. Default `{}` keeps every existing caller's behavior
32
+ # unchanged: this is an extension, not a fork.
33
+ #
34
+ # `timeout` (intent 331e): when given, bounds EACH chunk's spawn to that
35
+ # many seconds. A bare `Timeout.timeout` around `Open3.capture3` does not
36
+ # reliably bound a genuinely hanging child: capture3's own wait still
37
+ # blocks on Process.waitpid for the child regardless of the raised
38
+ # Timeout::Error (the same gotcha scripts/hook-record works around), so a
39
+ # timeout here spawns directly and kills the child on expiry instead.
40
+ # Default `nil` keeps every existing caller on the original unbounded
41
+ # Open3.capture3 path.
42
+ def replay(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay", message_id: "replay",
43
+ env: {}, timeout: nil)
44
+ chunks = text.scan(/.{1,#{chunk}}/m)
45
+ chunks = [""] if chunks.empty?
46
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
47
+
48
+ chunks.each_with_index.map do |delta, i|
49
+ payload = {
50
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
51
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
52
+ "hook_event_name" => "MessageDisplay",
53
+ }
54
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, timeout)
55
+ { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
56
+ end
57
+ end
58
+
59
+ # replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
60
+ # way `replay` does, but fires every chunk in its OWN thread, staggered by
61
+ # `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
62
+ # than run sequentially. This is what reproduces the decision-race defect
63
+ # 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
64
+ # in production, and `replay`'s strictly-sequential default never puts two
65
+ # chunks in flight at once, so it could never have reproduced the race in
66
+ # the first place.
67
+ #
68
+ # `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
69
+ # at once": firing all 335 chunks of the live session capture with no
70
+ # stagger at all takes about 8 s of wall clock on this 8-core machine,
71
+ # because EACH chunk boots its own real Ruby process and the completions
72
+ # cluster at the tail once every core is saturated - a load real streaming
73
+ # never produces (a real stream delivers a chunk every few tens of
74
+ # milliseconds, one at a time). One Ruby process per streamed chunk is the
75
+ # actual throughput ceiling here, not something this method works around.
76
+ #
77
+ # `replay`'s own signature and sequential default are UNCHANGED by this
78
+ # method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
79
+ # must keep working exactly as it does today) - this is a sibling method
80
+ # in the same module, never a replacement.
81
+ #
82
+ # Returns the SAME result shape `replay` returns (one Hash per chunk, keys
83
+ # index/exitstatus/stdout/stderr/final), ordered by index regardless of
84
+ # the order the threads actually finish in.
85
+ def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
86
+ message_id: "replay", env: {}, gap_ms: 5, jitter: true)
87
+ chunks = text.scan(/.{1,#{chunk}}/m)
88
+ chunks = [""] if chunks.empty?
89
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
90
+ gap = gap_ms / 1000.0
91
+
92
+ results = Array.new(chunks.length)
93
+ threads = chunks.each_with_index.map do |delta, i|
94
+ payload = {
95
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
96
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
97
+ "hook_event_name" => "MessageDisplay",
98
+ }
99
+ Thread.new do
100
+ begin
101
+ delay = i * gap
102
+ delay += (rand * gap / 2.0) if jitter
103
+ sleep(delay)
104
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
105
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
106
+ rescue StandardError => e
107
+ # A raise inside a thread body is invisible until join, and an
108
+ # unrescued one aborts `threads.each(&:join)` at the first dead
109
+ # thread: every later thread is then never joined and outlives the
110
+ # call, racing whatever the caller does next (typically removing
111
+ # the very tmp root those threads are still writing under). Report
112
+ # the failure as this chunk's own result instead, so the array is
113
+ # always complete, every thread is always joined, and a replay
114
+ # tells its caller what went wrong rather than throwing at it.
115
+ results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
116
+ final: payload["final"] }
117
+ end
118
+ end
119
+ end
120
+ threads.each { |thread| thread.join }
121
+ results
122
+ end
123
+
124
+ # Indices of the chunks that reached the terminal as raw Markdown: a
125
+ # non-final chunk that emitted nothing at all, after the engaging chunk.
126
+ # A chunk "passed through" when its stdout is empty; a chunk was
127
+ # "blanked" (correctly buffered, not shown raw) when its stdout contains
128
+ # `"displayContent":""`. Only chunks with an index greater than the
129
+ # engaging chunk's own index count - the engaging chunk is the first one
130
+ # (at or after `start_index`) whose stdout is non-empty, and chunks
131
+ # before it already reached the terminal live, verbatim, through the
132
+ # ordinary passthrough path (they were never candidates for buffering at
133
+ # all, so an empty stdout from one of them is not this defect).
134
+ def passthrough_indices(outs, start_index: 0)
135
+ engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
136
+ return [] unless engaging
137
+
138
+ outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
139
+ .map { |o| o[:index] }
140
+ end
141
+
142
+ def run_one(hook_path, payload, full_env, tmp_root, timeout)
143
+ return capture(hook_path, payload, full_env) unless timeout
144
+
145
+ run_bounded(hook_path, payload, full_env, tmp_root, timeout)
146
+ end
147
+
148
+ def capture(hook_path, payload, full_env)
149
+ out, err, status = Open3.capture3(full_env, hook_path, stdin_data: JSON.generate(payload))
150
+ [out, err, status.exitstatus]
151
+ end
152
+
153
+ # Spawn directly (never Open3.capture3) so a timeout can actually kill the
154
+ # child, with stdin/stdout/stderr routed through scratch files under the
155
+ # caller's own tmp_root, and never pipes, so a stalled or oversized write can
156
+ # never deadlock the read side, and never anywhere outside tmp_root, so a
157
+ # bounded replay carries the same "writes only under the injected tmp
158
+ # root" guarantee as the unbounded path.
159
+ def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
160
+ token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
161
+ in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
162
+ out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
163
+ err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
164
+ File.write(in_path, JSON.generate(payload))
165
+
166
+ pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
167
+ exitstatus =
168
+ begin
169
+ Timeout.timeout(timeout) { Process.wait(pid) }
170
+ $?.exitstatus
171
+ rescue Timeout::Error
172
+ kill_and_reap(pid)
173
+ nil # nil exitstatus is the caller's signal that this chunk timed out
174
+ end
175
+
176
+ out = File.exist?(out_path) ? File.read(out_path) : ""
177
+ err = File.exist?(err_path) ? File.read(err_path) : ""
178
+ [out, err, exitstatus]
179
+ ensure
180
+ [in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
181
+ end
182
+
183
+ def kill_and_reap(pid)
184
+ Process.kill("KILL", pid)
185
+ rescue StandardError
186
+ nil
187
+ ensure
188
+ begin
189
+ Process.wait(pid)
190
+ rescue StandardError
191
+ nil
192
+ end
193
+ end
194
+
195
+ # The final chunk's parsed displayContent, or nil when it emitted nothing
196
+ # (no envelope at all: the message never engaged).
197
+ def final_display_content(outs)
198
+ final = outs.last
199
+ return nil if final[:stdout].to_s.empty?
200
+
201
+ JSON.parse(final[:stdout]).dig("hookSpecificOutput", "displayContent")
202
+ rescue JSON::ParserError
203
+ nil
204
+ end
205
+
206
+ # True when any chunk's spawn hit its timeout (run_bounded's nil-exitstatus
207
+ # signal). A replay made with no `timeout:` never reports true.
208
+ def timed_out?(outs)
209
+ outs.any? { |o| o[:exitstatus].nil? }
210
+ end
211
+ end
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "doctor_exclusions"
5
+ require_relative "graph_file"
6
+ require_relative "atomic_write"
7
+
8
+ # IndexProjection (intent 337, n5): computes every intent's status from its
9
+ # own savepoint.md ledger, reads the status INDEX.md currently claims, and
10
+ # reports the drift between them. The ledger wins WHERE THE LEDGER SPEAKS
11
+ # (row 5.1/5.2): a REAL terminal line (Done delivered/abandoned, or a
12
+ # classifiable Done detail) beats a stale INDEX section. An intent whose
13
+ # ledger is silent (no terminal line) or absent (no savepoint.md at all)
14
+ # keeps the status INDEX already carries (row 5.13, folded at the
15
+ # 2026-09-10 plan review): 63 of 451 intents in the plastic store have no
16
+ # savepoint.md and 59 more never reach a Done line, and a literal reading
17
+ # would demote all of them. This module computes and compares only; it
18
+ # writes nothing (row 5.11) and reads no clock or environment variable.
19
+ module IndexProjection
20
+ module_function
21
+
22
+ INDEX_SECTIONS = %w[Active Future Completed Abandoned].freeze
23
+ TERMINAL_STATUSES = %w[Completed Abandoned].freeze
24
+ EXCLUSION_RULES = %w[savepoint_operational backfilled_complete].freeze
25
+
26
+ # `store_path` holds the intent directories (a project's own `store/`, or
27
+ # the global `~/.plastic/store`). `index_path` defaults to a sibling
28
+ # `INDEX.md` inside `store_path` (matching every fixture in this test
29
+ # file), but the real Plastic layout keeps INDEX.md one level ABOVE
30
+ # `store/` - callers there (doctor.rb, the CLI) pass it explicitly.
31
+ def analyze(store_path, index_path: nil)
32
+ index_path ||= File.join(store_path, "INDEX.md")
33
+ index_map = read_index(index_path)
34
+ dir_ids = store_intent_ids(store_path)
35
+
36
+ excluded = excluded_ids(index_path)
37
+
38
+ drift = index_map.filter_map do |id, index_status|
39
+ next if index_status == "Future" # 5.5: Future has no ledger counterpart
40
+ next if excluded.include?(id) # 5.12
41
+
42
+ ledger_status = ledger_status_for(store_path, id)
43
+ next unless TERMINAL_STATUSES.include?(ledger_status) # 5.3/5.13
44
+ next if ledger_status == index_status
45
+
46
+ { id: id, index_status: index_status, ledger_status: ledger_status }
47
+ end
48
+
49
+ index_only = (index_map.keys - dir_ids).reject { |id| excluded.include?(id) }
50
+ .map { |id| { id: id, index_status: index_map[id] } }
51
+ directory_only = (dir_ids - index_map.keys).reject { |id| excluded.include?(id) }
52
+ .map { |id| { id: id } }
53
+
54
+ { ok: true, drift: drift, index_only: index_only, directory_only: directory_only, errors: [] }
55
+ end
56
+
57
+ # Render the four status sections (## Active, ## Future, ## Completed,
58
+ # ## Abandoned) from the projection and write through AtomicWrite. Only
59
+ # entries `analyze` actually reported as `drift` ever move - an entry
60
+ # whose ledger is silent or absent never appears in `drift` (row 5.13),
61
+ # so --write can never demote it (row 6.14). Every other line, including
62
+ # ## Clusters and ## Relocated (or any other section), is untouched
63
+ # (row 6.13): only the four named headings are ever replaced.
64
+ def write(store_path, index_path:, renamer: File.method(:rename))
65
+ return { ok: false, written: false, error: "no INDEX.md at #{index_path}" } unless File.exist?(index_path)
66
+
67
+ analysis = analyze(store_path, index_path: index_path)
68
+ text = read_utf8(index_path)
69
+
70
+ original_lines = {}
71
+ INDEX_SECTIONS.each do |heading|
72
+ section_body(text, heading).each_line do |line|
73
+ next unless line.strip.start_with?("- [")
74
+
75
+ m = line.strip.match(/\A-\s*\[(\S+)\s/)
76
+ original_lines[m[1]] = line if m
77
+ end
78
+ end
79
+
80
+ moves = analysis[:drift].each_with_object({}) { |r, h| h[r[:id]] = r[:ledger_status] }
81
+
82
+ new_bodies = INDEX_SECTIONS.each_with_object({}) do |heading, h|
83
+ kept = section_body(text, heading).each_line.reject do |line|
84
+ stripped = line.strip
85
+ next false unless stripped.start_with?("- [")
86
+
87
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
88
+ m && moves.key?(m[1])
89
+ end
90
+ arriving = moves.select { |_id, target| target == heading }.keys
91
+ h[heading] = (kept + arriving.filter_map { |id| original_lines[id] }).join
92
+ end
93
+
94
+ content = text
95
+ INDEX_SECTIONS.each do |heading|
96
+ content = GraphFile.replace_or_append_section(content, "## #{heading}", new_bodies[heading].rstrip + "\n")
97
+ end
98
+
99
+ AtomicWrite.write(index_path, content, renamer: renamer)
100
+ { ok: true, written: true, moved: analysis[:drift], error: nil }
101
+ end
102
+
103
+ # --- INDEX -------------------------------------------------------------------
104
+
105
+ def read_index(index_path)
106
+ map = {}
107
+ return map unless File.exist?(index_path)
108
+
109
+ text = read_utf8(index_path)
110
+ INDEX_SECTIONS.each do |heading|
111
+ section_body(text, heading).each_line do |line|
112
+ stripped = line.strip
113
+ next unless stripped.start_with?("- [")
114
+
115
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
116
+ map[m[1]] = heading if m
117
+ end
118
+ end
119
+ map
120
+ end
121
+
122
+ def section_body(text, heading)
123
+ m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
124
+ m ? m[1] : ""
125
+ end
126
+
127
+ # --- store directories ---------------------------------------------------------
128
+
129
+ def store_intent_ids(store_path)
130
+ return [] unless Dir.exist?(store_path)
131
+
132
+ Dir.entries(store_path).select { |e| e.include?("--") && File.directory?(File.join(store_path, e)) }
133
+ .map { |e| e.split("--", 2).first }
134
+ end
135
+
136
+ def intent_dir_for(store_path, id)
137
+ Dir.glob(File.join(store_path, "#{id}--*")).find { |p| File.directory?(p) }
138
+ end
139
+
140
+ # --- the ledger (savepoint.md) --------------------------------------------------
141
+
142
+ # "Completed", "Abandoned", "Active" (no terminal line yet), "unknown" (no
143
+ # directory or no savepoint.md), or "indeterminate" (a Done line whose
144
+ # detail is neither delivered/merged nor abandoned, row 5.14).
145
+ def ledger_status_for(store_path, id)
146
+ dir = intent_dir_for(store_path, id)
147
+ return "unknown" unless dir
148
+
149
+ sp_path = File.join(dir, "savepoint.md")
150
+ return "unknown" unless File.exist?(sp_path)
151
+
152
+ parsed = complete_lines(read_utf8(sp_path)).filter_map { |l| parse_line(l) }
153
+ last_done = parsed.reverse.find { |p| p[:kind] == "Done" }
154
+ return "Active" unless last_done
155
+
156
+ classify_done_detail(last_done[:detail]) || "indeterminate"
157
+ end
158
+
159
+ # Drop the trailing torn fragment: a savepoint line that never got its
160
+ # closing newline because the process died mid-append (row 5.6). Works
161
+ # whether the file ends in a newline (the dropped element is the empty
162
+ # string split(-1) always yields after a final "\n") or not (the dropped
163
+ # element is the torn fragment itself).
164
+ def complete_lines(content)
165
+ return [] if content.to_s.empty?
166
+
167
+ lines = content.split("\n", -1)
168
+ lines.pop
169
+ lines.reject { |l| l.strip.empty? }
170
+ end
171
+
172
+ def parse_line(line)
173
+ parts = line.strip.split(/\s{2,}/, 3)
174
+ return nil unless parts.length == 3
175
+
176
+ { time: parts[0], kind: parts[1], detail: parts[2] }
177
+ end
178
+
179
+ def classify_done_detail(detail)
180
+ d = detail.to_s.strip
181
+ return "Abandoned" if d.start_with?("abandoned")
182
+ return "Completed" if d.start_with?("delivered") || d.start_with?("merged")
183
+
184
+ nil
185
+ end
186
+
187
+ # --- doctor exclusions (row 5.12) -----------------------------------------------
188
+
189
+ def excluded_ids(index_path)
190
+ loaded = DoctorExclusions.load(index_path)
191
+ EXCLUSION_RULES.flat_map { |rule| loaded[:rules][rule] || [] }.uniq
192
+ end
193
+
194
+ # --- utf-8 -----------------------------------------------------------------------
195
+
196
+ def read_utf8(path)
197
+ text = File.read(path)
198
+ text.force_encoding(Encoding::UTF_8)
199
+ text.valid_encoding? ? text : text.scrub("")
200
+ end
201
+ end
@@ -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.read(File.join(root, "package.json")).then { |s| JSON.parse(s)["version"] }
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) ---
@@ -314,12 +331,26 @@ class InstallerCore
314
331
  end
315
332
  end
316
333
 
334
+ # Intent 331a (D6/R7): a screen kind file (scripts/lib/screens/<kind>.rb)
335
+ # must reach an installed ~/.plastic the same way a new template or hook
336
+ # does - glob-derived, so "add a file, not a diff" is actually true for an
337
+ # installed Plastic, not just an in-repo one. This repo ships none yet;
338
+ # the glob answers {} until one exists.
339
+ def screen_files
340
+ Dir.glob(File.join(package_root, "scripts", "lib", "screens", "*.rb")).each_with_object({}) do |path, acc|
341
+ next unless File.file?(path)
342
+
343
+ rel = File.join("scripts", "lib", "screens", File.basename(path))
344
+ acc[rel] = rel
345
+ end
346
+ end
347
+
317
348
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
318
349
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
319
- # by install_sync_test). The templates half is glob-derived (template_files above); the
320
- # rest stays a hand-written literal.
350
+ # by install_sync_test). The templates and screen-kind halves are glob-derived
351
+ # (template_files, screen_files above); the rest stays a hand-written literal.
321
352
  def core_files
322
- hand_registered_files.merge(template_files).merge(hook_files)
353
+ hand_registered_files.merge(template_files).merge(hook_files).merge(screen_files)
323
354
  end
324
355
 
325
356
  def hand_registered_files
@@ -342,6 +373,9 @@ class InstallerCore
342
373
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
343
374
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
344
375
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
376
+ "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
377
+ "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
378
+ "scripts/node-transition" => "scripts/node-transition",
345
379
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
346
380
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
347
381
  "scripts/plastic-lock" => "scripts/plastic-lock",
@@ -383,6 +417,9 @@ class InstallerCore
383
417
  "scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
384
418
  "scripts/codex-hook" => "scripts/codex-hook",
385
419
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
420
+ "scripts/lib/report_screen.rb" => "scripts/lib/report_screen.rb",
421
+ "scripts/report-screen" => "scripts/report-screen",
422
+ "scripts/savepoint-note" => "scripts/savepoint-note",
386
423
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
387
424
  "scripts/provision-project-store" => "scripts/provision-project-store",
388
425
  "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
@@ -401,8 +438,11 @@ class InstallerCore
401
438
  "scripts/verify-intent" => "scripts/verify-intent",
402
439
  "scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
403
440
  "scripts/exec-worktree" => "scripts/exec-worktree",
441
+ "scripts/lib/session_usage.rb" => "scripts/lib/session_usage.rb",
442
+ "scripts/session-usage" => "scripts/session-usage",
404
443
  "scripts/doctor.rb" => "scripts/doctor.rb",
405
444
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
445
+ "scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
406
446
  "scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
407
447
  "scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
408
448
  "scripts/lib/doctor_session_ledger.rb" => "scripts/lib/doctor_session_ledger.rb",
@@ -423,7 +463,102 @@ class InstallerCore
423
463
  "scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
424
464
  "scripts/write-handoff" => "scripts/write-handoff",
425
465
  "scripts/day-summary" => "scripts/day-summary",
466
+ "scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
467
+ "scripts/intent-screen" => "scripts/intent-screen",
426
468
  "scripts/hook-savepoint" => "scripts/hook-savepoint",
469
+ # Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
470
+ # (see hook_files above), so the two lib files a require_relative
471
+ # between themselves are unguarded there — these three literal
472
+ # entries are their only protection (test/install_sync_test.rb:23-29
473
+ # greps installer_core.rb's own source text for "scripts/<name>").
474
+ "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
475
+ "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
476
+ "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
477
+ # Intent 331d (A1): scripts/dashboard.rb require_relatives this lib
478
+ # directly; templates/dashboard-screen.md and scripts/lib/screens/
479
+ # dashboard.rb are glob-derived (template_files, screen_files above)
480
+ # and need no entry here.
481
+ "scripts/lib/dashboard_screen.rb" => "scripts/lib/dashboard_screen.rb",
482
+ "scripts/hook-message-display" => "scripts/hook-message-display",
483
+ # Intent 334 (G1): the node file and graph.md library, plus its
484
+ # validator and CLI (327 D40/D41).
485
+ "scripts/lib/graph_edges.rb" => "scripts/lib/graph_edges.rb",
486
+ "scripts/lib/node_file.rb" => "scripts/lib/node_file.rb",
487
+ "scripts/lib/atomic_write.rb" => "scripts/lib/atomic_write.rb",
488
+ "scripts/lib/graph_file.rb" => "scripts/lib/graph_file.rb",
489
+ "scripts/lib/work_graph_validator.rb" => "scripts/lib/work_graph_validator.rb",
490
+ "scripts/validate-work-graph" => "scripts/validate-work-graph",
491
+ # Intent 335a: every id an intent has ever seen, so a deleted node's id
492
+ # is never reissued to a new node with the dead one's ledger history.
493
+ "scripts/lib/node_ids.rb" => "scripts/lib/node_ids.rb",
494
+ # Intent 339 (G6): the generated outcome.md library and its CLI.
495
+ "scripts/lib/outcome_report.rb" => "scripts/lib/outcome_report.rb",
496
+ "scripts/outcome-report" => "scripts/outcome-report",
497
+ # Intent 336 (G3): ReadySet, the one function that says what may run
498
+ # next, and its CLI. node-transition requires the lib as of n5;
499
+ # registered here (rather than waiting for n8's own CLI/doc unit) so
500
+ # test/install_sync_test.rb stays green across every intermediate unit.
501
+ "scripts/lib/ready_set.rb" => "scripts/lib/ready_set.rb",
502
+ "scripts/ready-set" => "scripts/ready-set",
503
+ # Intent 337 (G4): the roadmap graph model and its tree renderer,
504
+ # registered as they land (test/installer_core_test.rb requires every
505
+ # scripts/lib file to be manifest-covered as soon as it exists, not
506
+ # only once the CLI that ships it arrives at n4).
507
+ "scripts/lib/roadmap_graph.rb" => "scripts/lib/roadmap_graph.rb",
508
+ "scripts/lib/graph_tree.rb" => "scripts/lib/graph_tree.rb",
509
+ "scripts/lib/index_projection.rb" => "scripts/lib/index_projection.rb",
510
+ "scripts/lib/roadmap_render.rb" => "scripts/lib/roadmap_render.rb",
511
+ "scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
512
+ "scripts/roadmap-graph" => "scripts/roadmap-graph",
513
+ "scripts/index-projection" => "scripts/index-projection",
514
+ # Intent 338 (G5): the node packet command - the trust-boundary wrapper,
515
+ # the five-block gatherer/assembler, and the CLI 340's runner calls.
516
+ "scripts/lib/packet_wrapper.rb" => "scripts/lib/packet_wrapper.rb",
517
+ "scripts/lib/node_packet.rb" => "scripts/lib/node_packet.rb",
518
+ "scripts/node-packet" => "scripts/node-packet",
519
+ # Intent 342 (G9): the backward shim that presents actions/*.md as a
520
+ # node graph for any legacy intent, so WorkGraphValidator can require
521
+ # it without going red on contact with install_sync_test.
522
+ "scripts/lib/action_graph_shim.rb" => "scripts/lib/action_graph_shim.rb",
523
+ # Intent 340 (G7, n1): the runner command - the subcommand table over
524
+ # the declared node graph, its shared context (RunnerCore), and the
525
+ # installed-core integrity check (CoreIntegrity) the runner's trust
526
+ # boundary and doctor will both call.
527
+ "scripts/runner" => "scripts/runner",
528
+ "scripts/lib/runner_core.rb" => "scripts/lib/runner_core.rb",
529
+ "scripts/lib/core_integrity.rb" => "scripts/lib/core_integrity.rb",
530
+ # Intent 340 (G7, n2): the merge abort, the reclaim, and the extension -
531
+ # the first thing every `step` does, routed from scripts/runner's `sweep`
532
+ # verb.
533
+ "scripts/lib/runner_sweep.rb" => "scripts/lib/runner_sweep.rb",
534
+ # Intent 340 (G7, n3): a work node's own git worktree, cut from the
535
+ # intent branch tip, merged back into the intent branch, and swept once
536
+ # its node is terminal.
537
+ "scripts/lib/node_worktree.rb" => "scripts/lib/node_worktree.rb",
538
+ # Intent 340 (G7, n4): the return schema (NodeReturn) and the six-check
539
+ # gate (RunnerAbsorb) that turns one executor return into exactly one
540
+ # node ledger transition, required lazily by scripts/runner's `step`.
541
+ "scripts/lib/node_return.rb" => "scripts/lib/node_return.rb",
542
+ "scripts/lib/runner_absorb.rb" => "scripts/lib/runner_absorb.rb",
543
+ # Intent 340 (G7, n5): validation, policy, leases and the dispatch plan
544
+ # - RunnerPolicy (the kind table) and RunnerDispatch, required lazily
545
+ # by scripts/runner's `step`.
546
+ "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
547
+ "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
548
+ # Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
549
+ # is its cap table, above); its launcher (hooks/call-budget) ships via
550
+ # hook_files' own glob, so only the hook script itself needs an entry.
551
+ "scripts/hook-call-budget" => "scripts/hook-call-budget",
552
+ "scripts/meter-watch" => "scripts/meter-watch",
553
+ "scripts/lib/meter_watch.rb" => "scripts/lib/meter_watch.rb",
554
+ # Intent 340 (G7, n6): answer (closes a decision node or unparks a
555
+ # work node parked at needs_decision), proposals (mints ids for what
556
+ # an executor proposed), and rewind (resets the intent branch to a
557
+ # node's own commit and respins it) - routed from scripts/runner's
558
+ # `answer` and `rewind` verbs.
559
+ "scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
560
+ "scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
561
+ "scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
427
562
  }
428
563
  end
429
564
 
@@ -437,8 +572,8 @@ class InstallerCore
437
572
  version: 3
438
573
  execution_mode: subagent-driven
439
574
  stale_threshold_days: 3
440
- context_offer_tokens: 350000
441
- context_insist_tokens: 500000
575
+ context_offer_tokens: 150000
576
+ context_insist_tokens: 250000
442
577
  hash_length: 6
443
578
  hash_algorithm: sha256-base36
444
579
  max_slug_words: 5