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

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 (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
@@ -0,0 +1,155 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # GuardedAppend - the shared fail-closed write guard behind NodeLedger's transition
5
+ # lines and RoadmapSavepoint's roadmap ledger (intent 335, spec "Approach").
6
+ #
7
+ # One module function, #call: opens `path` RDWR|APPEND|CREAT, takes a non-blocking
8
+ # exclusive lock with a bounded number of retries, and under that ONE hold reads the
9
+ # current content, yields it to the caller's block, and appends whatever the block
10
+ # returns. A block returning nil is a refusal: nothing is written. Read, decide, and
11
+ # append happen inside one lock hold on purpose (spec "Approach"): that is what makes
12
+ # "is this subject already running" and "append running" atomic against a second
13
+ # writer, which a check followed by a separate append could never be.
14
+ #
15
+ # Pure and dependency-injected: `flock:` and `sleeper:` are constructor-style test
16
+ # seams (never an environment variable, never a global); this module reads no
17
+ # environment variable and shells out to nothing.
18
+ module GuardedAppend
19
+ # Raised when the lock could not be taken within `retries` attempts (real
20
+ # contention), or when flock itself is unsupported on this filesystem and
21
+ # `strict: true` (spec D12a). Nothing is written either way; the caller is told
22
+ # plainly that nothing landed and must retry.
23
+ class Unavailable < StandardError; end
24
+
25
+ module_function
26
+
27
+ # Five attempts, 20 ms apart: about 100 ms of wall time total (spec D12).
28
+ DEFAULT_RETRIES = 5
29
+ DEFAULT_BACKOFF = 0.02
30
+
31
+ # Default lock and sleep seams: a real flock call, a real sleep. Tests inject
32
+ # replacements to simulate contention, recovery, and a flock-less filesystem
33
+ # hermetically, with no need for a real flock-less mount or a slow test run.
34
+ DEFAULT_FLOCK = ->(handle, mode) { handle.flock(mode) }
35
+ private_constant :DEFAULT_FLOCK
36
+
37
+ DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
38
+ private_constant :DEFAULT_SLEEPER
39
+
40
+ # Open `path` (creating it if absent, never truncating it), take an exclusive
41
+ # non-blocking lock with up to `retries` attempts (`backoff` seconds apart), and
42
+ # under that one hold read the file's current content, yield it to the block, and
43
+ # append what the block returns.
44
+ #
45
+ # Returns :written when a line was appended, :refused when the block returned nil
46
+ # (nothing written, spec: a refusal). Raises Unavailable, writing nothing, when the
47
+ # lock could not be taken within `retries` attempts.
48
+ #
49
+ # `strict:` decides what happens when flock itself raises a SystemCallError OTHER
50
+ # than contention (EWOULDBLOCK/EAGAIN) - a filesystem without flock support,
51
+ # distinct from real contention (spec D12a): strict (the default) raises
52
+ # Unavailable; non-strict proceeds unguarded, since a single O_APPEND write still
53
+ # lands whole there. The SystemCallError rescue wraps the flock call only (spec
54
+ # D12b); an Errno::ENOENT from File.open (a missing parent directory) propagates
55
+ # as itself, never read as Unavailable.
56
+ def call(path, retries: DEFAULT_RETRIES, backoff: DEFAULT_BACKOFF, strict: true,
57
+ flock: DEFAULT_FLOCK, sleeper: DEFAULT_SLEEPER, &block)
58
+ created = !File.exist?(path)
59
+ handle = File.open(path, File::RDWR | File::APPEND | File::CREAT, 0o644)
60
+ begin
61
+ status = take_lock(handle, retries: retries, backoff: backoff, flock: flock, sleeper: sleeper)
62
+
63
+ case status
64
+ when :contended
65
+ raise Unavailable, "could not take an exclusive lock on #{path} after #{retries} attempts"
66
+ when :unsupported
67
+ if strict
68
+ raise Unavailable, "flock is unsupported on #{path} and strict: true refuses to proceed unguarded"
69
+ end
70
+
71
+ write_line(handle, &block)
72
+ when :locked
73
+ begin
74
+ write_line(handle, &block)
75
+ ensure
76
+ unlock(handle, flock: flock)
77
+ end
78
+ end
79
+ ensure
80
+ handle.close
81
+ end
82
+ rescue Unavailable
83
+ remove_freshly_created_empty_file(path) if created
84
+ raise
85
+ end
86
+
87
+ # Attempt the lock up to `retries` times. Returns :locked, :unsupported (a
88
+ # non-contention SystemCallError from flock, decided once, never retried), or
89
+ # :contended (every attempt failed with EWOULDBLOCK/EAGAIN or a false return).
90
+ # Sleeps `backoff` seconds after EVERY contended attempt, including the last, so
91
+ # the total backoff budget is exactly `retries` sleeps (spec D12: "about 100 ms of
92
+ # wall time in total" = 5 attempts * 20 ms, not 4).
93
+ def take_lock(handle, retries:, backoff:, flock:, sleeper:)
94
+ status = :contended
95
+ retries.times do
96
+ status = try_flock(handle, flock)
97
+ return status unless status == :contended
98
+
99
+ sleeper.call(backoff)
100
+ end
101
+ status
102
+ end
103
+ private_class_method :take_lock
104
+
105
+ # One attempt at the non-blocking exclusive lock. File#flock RAISES (does not
106
+ # return false) for every errno except EWOULDBLOCK/EAGAIN on most platforms, so
107
+ # both the "returns false" and the "raises EWOULDBLOCK" shapes read as contention;
108
+ # any other SystemCallError means flock is not supported on this filesystem.
109
+ def try_flock(handle, flock)
110
+ result = flock.call(handle, File::LOCK_EX | File::LOCK_NB)
111
+ result == false ? :contended : :locked
112
+ rescue Errno::EWOULDBLOCK, Errno::EAGAIN
113
+ :contended
114
+ rescue SystemCallError
115
+ :unsupported
116
+ end
117
+ private_class_method :try_flock
118
+
119
+ # Read the current content, yield it to the block, and write what it returns. A
120
+ # block returning nil writes nothing and reports :refused. Before a real write, if
121
+ # the content is non-empty and does not end in a newline, a newline is written
122
+ # first (spec D9a): a crash that truncated the previous write must not glue the
123
+ # next transition onto its tail. The handle is opened O_APPEND, so every write
124
+ # lands at the current end of file regardless of the read's cursor position.
125
+ def write_line(handle, &block)
126
+ content = handle.read
127
+ line = block.call(content)
128
+ return :refused if line.nil?
129
+
130
+ prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
131
+ handle.write("#{prefix}#{line}")
132
+ handle.flush
133
+ :written
134
+ end
135
+ private_class_method :write_line
136
+
137
+ def unlock(handle, flock:)
138
+ flock.call(handle, File::LOCK_UN)
139
+ rescue SystemCallError
140
+ nil
141
+ end
142
+ private_class_method :unlock
143
+
144
+ # Row 7.9 (post-execution review): a give-up (Unavailable) must not leave a zero-byte
145
+ # file behind that File::CREAT created for a target that did not exist before this
146
+ # call. Only ever removes a file this same call created (never a pre-existing file,
147
+ # spec matrix 1.11) and only when it is still empty (no write ever reached it on the
148
+ # give-up paths this rescues).
149
+ def remove_freshly_created_empty_file(path)
150
+ File.unlink(path) if File.exist?(path) && File.zero?(path)
151
+ rescue SystemCallError
152
+ nil
153
+ end
154
+ private_class_method :remove_freshly_created_empty_file
155
+ end
@@ -54,6 +54,20 @@ module HookRegistry
54
54
  { "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
55
55
  ] },
56
56
  ],
57
+ # This entry belongs to the Claude adapter half of Plastic's
58
+ # harness-agnostic-core / Claude-adapter split (intent 316a1, D3
59
+ # supersedes 316a's D6): MessageDisplay is not one of
60
+ # CODEX_LIVE_STATE_EVENTS, so codex_hooks_json (below) never picks it
61
+ # up; codex_hook_names stays exactly what it was (pinned by
62
+ # test/hook_registry_test.rb:82 and :110-111). Fires on every streamed
63
+ # chunk of every assistant message (D11); the launcher (hooks/message-
64
+ # display) decides with shell builtins and forks nothing on the common
65
+ # case, execing Ruby only for a candidate message.
66
+ "MessageDisplay" => [
67
+ { "matcher" => "", "hooks" => [
68
+ { "name" => "message-display", "status" => "" },
69
+ ] },
70
+ ],
57
71
  }
58
72
  end
59
73
 
@@ -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