@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- 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
|