@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24
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/PLASTIC.md +6 -5
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# HarnessAdapter (intent 340b, G7c, n1): the one module in Plastic that knows
|
|
5
|
+
# a harness by name. It resolves the current key from config `agent.type`,
|
|
6
|
+
# renders a dispatch plan into that harness's instructions, and answers what
|
|
7
|
+
# the ledger should record. `runner step` calls it; nothing else in the
|
|
8
|
+
# runner does (spec D1).
|
|
9
|
+
#
|
|
10
|
+
# Pure: every input is an argument, nothing here reads ENV or a real
|
|
11
|
+
# config.yml itself - the caller (scripts/runner) owns loading real config,
|
|
12
|
+
# this module only resolves values out of what it is handed, the same
|
|
13
|
+
# discipline RunnerPolicy already holds for model resolution.
|
|
14
|
+
module HarnessAdapter
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# The only two harnesses this module knows (spec D2). An unknown key -
|
|
18
|
+
# config-authored or from `--harness` - always falls back to this rather
|
|
19
|
+
# than to no adapter at all (matrix row 1.3): a plan with no rendering
|
|
20
|
+
# under it is worse than a plan rendered for the wrong harness, because the
|
|
21
|
+
# first is silent.
|
|
22
|
+
DEFAULT_KEY = "claude-code"
|
|
23
|
+
KNOWN_KEYS = %w[claude-code codex].freeze
|
|
24
|
+
|
|
25
|
+
# The exact sentence 340a's watch prints to stderr and exits 1 with,
|
|
26
|
+
# before any write, when `--dispatch` is asked of a harness
|
|
27
|
+
# #unattended_start? refuses (graph.md D7). Quoted verbatim in
|
|
28
|
+
# docs/reference/harness-adapters.md (D12); change the wording in one
|
|
29
|
+
# place only.
|
|
30
|
+
UNATTENDED_START_SENTENCE =
|
|
31
|
+
"Unattended start is delivered only where a Ruby loop owns dispatch (Codex, through runner " \
|
|
32
|
+
"watch --dispatch); on Claude Code the runner is the harness session (327 Q6), so arm /loop " \
|
|
33
|
+
"over runner watch in a live session instead."
|
|
34
|
+
|
|
35
|
+
# unattended_start?(key) -> true only for "codex" (graph.md D7): the one
|
|
36
|
+
# harness where a Ruby loop (RunnerUntilEmpty, through `runner watch
|
|
37
|
+
# --dispatch`) owns dispatch end to end with nobody on the other end
|
|
38
|
+
# making subagent calls. Claude Code IS the harness session (327 Q6), and
|
|
39
|
+
# an unknown key is never granted unattended start either - this module
|
|
40
|
+
# stays the only place that names a harness, so RunnerWatch asks this
|
|
41
|
+
# predicate and never compares a key against "codex" itself.
|
|
42
|
+
def unattended_start?(key)
|
|
43
|
+
key.to_s == "codex"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# kind -> the Claude Code agent type dispatched for it (spec Approach).
|
|
47
|
+
# Verify and research carry no `Bash` in their own frontmatter (n2); this
|
|
48
|
+
# table only names WHICH agent a kind maps to. An unknown kind gets
|
|
49
|
+
# `work`'s row, the widest of the three, never no agent type at all
|
|
50
|
+
# (matrix row 1.10), mirroring RunnerPolicy's own kind-table fallback.
|
|
51
|
+
AGENT_TYPE_BY_KIND = {
|
|
52
|
+
"work" => "plastic-node-work",
|
|
53
|
+
"verify" => "plastic-node-verify",
|
|
54
|
+
"research" => "plastic-node-research",
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
def agent_type_for_kind(kind)
|
|
58
|
+
AGENT_TYPE_BY_KIND.fetch(kind.to_s, AGENT_TYPE_BY_KIND["work"])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# resolve_key(config:, override:) -> the harness key for one call (matrix
|
|
62
|
+
# rows 1.1-1.3, 1.18). `override` (the CLI's `--harness KEY`, spec D2) wins
|
|
63
|
+
# over `config`'s own `agent.type` for that one call; either source is
|
|
64
|
+
# squashed (whitespace collapsed, matrix row 1.18) before it is even
|
|
65
|
+
# compared, so a config-authored value carrying a tab or a newline can
|
|
66
|
+
# never reach `NodeLedger` unsquashed and raise `ArgumentError` mid-dispatch,
|
|
67
|
+
# after the node input is already built. A value that still is not one of
|
|
68
|
+
# KNOWN_KEYS after squashing falls back to DEFAULT_KEY with a warning
|
|
69
|
+
# (never to no adapter), so this method's return is always one of the two
|
|
70
|
+
# known, clean strings - safe to hand straight to a ledger field.
|
|
71
|
+
def resolve_key(config: {}, override: nil)
|
|
72
|
+
raw = blank?(override) ? agent_type_from_config(config) : override.to_s
|
|
73
|
+
squashed = squash(raw)
|
|
74
|
+
return squashed if KNOWN_KEYS.include?(squashed)
|
|
75
|
+
|
|
76
|
+
warn "harness_adapter: unknown harness #{raw.inspect}, falling back to #{DEFAULT_KEY.inspect}"
|
|
77
|
+
DEFAULT_KEY
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def agent_type_from_config(config)
|
|
81
|
+
return DEFAULT_KEY unless config.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
value = config.dig("agent", "type")
|
|
84
|
+
value = config.dig(:agent, :type) if blank?(value)
|
|
85
|
+
blank?(value) ? DEFAULT_KEY : value.to_s
|
|
86
|
+
end
|
|
87
|
+
private_class_method :agent_type_from_config
|
|
88
|
+
|
|
89
|
+
def squash(text)
|
|
90
|
+
text.to_s.strip.gsub(/\s+/, " ")
|
|
91
|
+
end
|
|
92
|
+
private_class_method :squash
|
|
93
|
+
|
|
94
|
+
def blank?(value)
|
|
95
|
+
value.nil? || value.to_s.strip.empty?
|
|
96
|
+
end
|
|
97
|
+
private_class_method :blank?
|
|
98
|
+
|
|
99
|
+
# render(dispatched, harness:, return_contract:) -> the rendered
|
|
100
|
+
# instruction block for `harness` (a String), or nil for an empty dispatch
|
|
101
|
+
# list (matrix row 1.14: a step that dispatched nothing prints no
|
|
102
|
+
# instruction block for a session to act on). `dispatched` is the runner's
|
|
103
|
+
# own plan entries (RunnerDispatch's `:dispatched` shape): [{node:, kind:,
|
|
104
|
+
# role:, model:, worktree:, input:}, ...]. The model and the node input path
|
|
105
|
+
# ride straight from each entry - never a fresh lookup (matrix row 1.11)
|
|
106
|
+
# and never a summary (matrix row 1.12) - and `return_contract` (the exact
|
|
107
|
+
# text RunnerDispatch::RETURN_CONTRACT already carries in the YAML plan)
|
|
108
|
+
# renders exactly once, above every node, never per node (matrix row 1.13).
|
|
109
|
+
def render(dispatched, return_contract:, harness: DEFAULT_KEY)
|
|
110
|
+
entries = Array(dispatched)
|
|
111
|
+
return nil if entries.empty?
|
|
112
|
+
|
|
113
|
+
key = KNOWN_KEYS.include?(harness.to_s) ? harness.to_s : DEFAULT_KEY
|
|
114
|
+
key == "codex" ? render_codex(entries, return_contract) : render_claude_code(entries, return_contract)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The Claude Code rendering (spec Approach): per dispatched node, the agent
|
|
118
|
+
# type from the kind, the model the plan already resolved, and the node input
|
|
119
|
+
# path as the whole prompt.
|
|
120
|
+
def render_claude_code(entries, return_contract)
|
|
121
|
+
blocks = entries.map do |d|
|
|
122
|
+
"Dispatch #{agent_type_for_kind(d[:kind])} for #{d[:node]} (model: #{d[:model]}):\n prompt: #{d[:input]}"
|
|
123
|
+
end
|
|
124
|
+
"#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
|
|
125
|
+
end
|
|
126
|
+
private_class_method :render_claude_code
|
|
127
|
+
|
|
128
|
+
# The Codex rendering: a Codex node runs end to end through `scripts/
|
|
129
|
+
# node-run` (n6), which reads the ledger's own `running` line directly and
|
|
130
|
+
# never through this printed block - so this names, per node, the same
|
|
131
|
+
# facts the block gives Claude Code (kind, model, node input path) framed as
|
|
132
|
+
# `node-run` calls, proving the seam renders SOMETHING for the second
|
|
133
|
+
# harness rather than nothing (matrix row 1.27), without inventing detail
|
|
134
|
+
# that belongs to n6's own adapter.
|
|
135
|
+
def render_codex(entries, return_contract)
|
|
136
|
+
blocks = entries.map do |d|
|
|
137
|
+
"node-run #{d[:node]} (#{d[:kind]}, model: #{d[:model]}):\n input: #{d[:input]}"
|
|
138
|
+
end
|
|
139
|
+
"#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
|
|
140
|
+
end
|
|
141
|
+
private_class_method :render_codex
|
|
142
|
+
|
|
143
|
+
# cross_harness_resume(entries) -> [{node:, harnesses: [...], commits: {...}}, ...]
|
|
144
|
+
# (spec D12, 340b n8). `entries` is a node ledger's own parsed lines (the
|
|
145
|
+
# shape `NodeLedger.entries`/`entries_from_content` return): every declared
|
|
146
|
+
# node's own `harness=` values, read across every non-torn line for that
|
|
147
|
+
# subject in file order, deduplicated by first appearance (matrix row 8.6:
|
|
148
|
+
# the starting harness is whichever key showed up first). A `reclaimed`
|
|
149
|
+
# line carries no `harness=` of its own and is never a boundary here (row
|
|
150
|
+
# 8.7) - it is simply a line with nothing to contribute, scanned like any
|
|
151
|
+
# other. A node whose lines carry one key, or none at all, reports nothing
|
|
152
|
+
# (rows 8.2, 8.3): an old ledger with no `harness=` field anywhere must
|
|
153
|
+
# stay silent rather than raise.
|
|
154
|
+
#
|
|
155
|
+
# Every node named by ANY entry is scanned (row 8.8), not only the first;
|
|
156
|
+
# `RunnerAbsorb` is what put `harness=` on both the running line and the
|
|
157
|
+
# terminal line, so the commit that lands with the finishing harness's own
|
|
158
|
+
# terminal line is exactly the evidence a resumed node actually left
|
|
159
|
+
# behind (row 8.5).
|
|
160
|
+
def cross_harness_resume(entries)
|
|
161
|
+
live = Array(entries).reject { |e| e[:torn] }
|
|
162
|
+
nodes = live.map { |e| e[:subject] }.uniq
|
|
163
|
+
|
|
164
|
+
nodes.filter_map do |node|
|
|
165
|
+
node_lines = live.select { |e| e[:subject] == node }
|
|
166
|
+
keys = []
|
|
167
|
+
commits = Hash.new { |h, k| h[k] = [] }
|
|
168
|
+
|
|
169
|
+
node_lines.each do |e|
|
|
170
|
+
fields = e[:fields] || {}
|
|
171
|
+
key = fields["harness"]
|
|
172
|
+
next if blank?(key)
|
|
173
|
+
|
|
174
|
+
keys << key unless keys.include?(key)
|
|
175
|
+
commit = fields["commit"]
|
|
176
|
+
commits[key] << commit unless blank?(commit) || commits[key].include?(commit)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
next if keys.size < 2
|
|
180
|
+
|
|
181
|
+
{ node: node, harnesses: keys, commits: keys.to_h { |k| [k, commits[k]] } }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -79,6 +79,18 @@ module HookRegistry
|
|
|
79
79
|
{ "name" => "message-display", "status" => "" },
|
|
80
80
|
] },
|
|
81
81
|
],
|
|
82
|
+
# Continuation on Claude Code (intent 340b, G7c, n4, D5/D7): registered
|
|
83
|
+
# statically here, like every other hook, and decided at runtime by
|
|
84
|
+
# StopGate, which reads runner.stop_hook itself (default false, D9).
|
|
85
|
+
# Not one of CODEX_LIVE_STATE_EVENTS, CODEX_POST_HOOKS or
|
|
86
|
+
# CODEX_SESSION_END_HOOKS, so codex_hooks_json never picks it up and
|
|
87
|
+
# codex_hook_names stays at its pinned six (test/hook_registry_test.rb,
|
|
88
|
+
# test/codex_hooks_test.rb).
|
|
89
|
+
"Stop" => [
|
|
90
|
+
{ "matcher" => "", "hooks" => [
|
|
91
|
+
{ "name" => "stop", "status" => "" },
|
|
92
|
+
] },
|
|
93
|
+
],
|
|
82
94
|
}
|
|
83
95
|
end
|
|
84
96
|
|
|
@@ -161,7 +173,7 @@ module HookRegistry
|
|
|
161
173
|
# name here in the SAME change. Skip it and every existing install keeps a dead
|
|
162
174
|
# registration no update will ever clean up.
|
|
163
175
|
#
|
|
164
|
-
# Never
|
|
176
|
+
# Never merge these into claude_launcher_names: that method is what doctor's
|
|
165
177
|
# hooks_exist demands be present on disk, so a retired name there makes a correct
|
|
166
178
|
# install report missing launchers.
|
|
167
179
|
RETIRED_HOOK_NAMES = %w[
|
|
@@ -83,22 +83,40 @@ module HookReplay
|
|
|
83
83
|
# index/exitstatus/stdout/stderr/final), ordered by index regardless of
|
|
84
84
|
# the order the threads actually finish in.
|
|
85
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)
|
|
86
|
+
message_id: "replay", env: {}, gap_ms: 5, jitter: true, lead_until_engaged: false)
|
|
87
87
|
chunks = text.scan(/.{1,#{chunk}}/m)
|
|
88
88
|
chunks = [""] if chunks.empty?
|
|
89
89
|
full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
|
|
90
90
|
gap = gap_ms / 1000.0
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
payload = {
|
|
92
|
+
payloads = chunks.each_with_index.map do |delta, i|
|
|
93
|
+
{
|
|
95
94
|
"session_id" => session_id, "message_id" => message_id, "index" => i,
|
|
96
95
|
"final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
|
|
97
96
|
"hook_event_name" => "MessageDisplay",
|
|
98
97
|
}
|
|
98
|
+
end
|
|
99
|
+
results = Array.new(chunks.length)
|
|
100
|
+
|
|
101
|
+
# `lead_until_engaged` runs chunks one at a time until one engages, so
|
|
102
|
+
# the decision marker is on disk before the rest fire concurrently. A
|
|
103
|
+
# caller proving "no late passthrough once the decision is in place"
|
|
104
|
+
# then tests exactly that, instead of whether chunk 0's own process
|
|
105
|
+
# booted inside a later chunk's poll budget on a loaded machine.
|
|
106
|
+
lead = 0
|
|
107
|
+
if lead_until_engaged
|
|
108
|
+
payloads.each_with_index do |payload, i|
|
|
109
|
+
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
|
|
110
|
+
results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
|
|
111
|
+
lead = i + 1
|
|
112
|
+
break unless out.to_s.empty?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
threads = payloads.each_with_index.drop(lead).map do |payload, i|
|
|
99
117
|
Thread.new do
|
|
100
118
|
begin
|
|
101
|
-
delay = i * gap
|
|
119
|
+
delay = (i - lead) * gap
|
|
102
120
|
delay += (rand * gap / 2.0) if jitter
|
|
103
121
|
sleep(delay)
|
|
104
122
|
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# IndexEntry - the shared INDEX.md entry matcher and Active check (intent 188,
|
|
5
|
+
# D12/D13; moved off the retired shared-helpers module by intent 344, D2).
|
|
6
|
+
#
|
|
7
|
+
# ONE definition site for the "- [ID <sep> Title](link)" shape both `active?`
|
|
8
|
+
# (below) and `scripts/end-intent`'s own INDEX-move parser depend on, so the
|
|
9
|
+
# two regexes can never drift apart. Accepts a real em dash (U+2014) OR a
|
|
10
|
+
# plain hyphen as the id/title separator on READ; every WRITE still emits the
|
|
11
|
+
# real em dash (D10). The separator is built from the codepoint, not a
|
|
12
|
+
# literal byte, so this file stays em-dash free.
|
|
13
|
+
module IndexEntry
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
EM_DASH = "\u2014".freeze
|
|
17
|
+
ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
18
|
+
|
|
19
|
+
# Match `line` (already chomped) against the shared INDEX entry shape.
|
|
20
|
+
# Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
|
|
21
|
+
def match(line)
|
|
22
|
+
line.to_s.match(ENTRY_RE)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True iff the intent is Active in its store's INDEX.md, which lives at the
|
|
26
|
+
# PARENT of the store/ dir. Non-raising: any failure (missing or unreadable
|
|
27
|
+
# INDEX, bad arg) returns false. `index_active_ids` is a pure-data test
|
|
28
|
+
# seam: when an Array of id strings is supplied, membership is checked
|
|
29
|
+
# against it directly with no file read.
|
|
30
|
+
def active?(intent_id, store:, index_active_ids: nil)
|
|
31
|
+
target = intent_id.to_s
|
|
32
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
35
|
+
return false unless File.exist?(index)
|
|
36
|
+
|
|
37
|
+
in_active = false
|
|
38
|
+
File.foreach(index) do |line|
|
|
39
|
+
stripped = line.chomp
|
|
40
|
+
if stripped == "## Active"
|
|
41
|
+
in_active = true
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
next unless in_active
|
|
45
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
46
|
+
m = match(stripped)
|
|
47
|
+
return true if m && m[1] == target
|
|
48
|
+
end
|
|
49
|
+
false
|
|
50
|
+
rescue StandardError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -11,7 +11,7 @@ require_relative "atomic_write"
|
|
|
11
11
|
# (row 5.1/5.2): a REAL terminal line (Done delivered/abandoned, or a
|
|
12
12
|
# classifiable Done detail) beats a stale INDEX section. An intent whose
|
|
13
13
|
# ledger is silent (no terminal line) or absent (no savepoint.md at all)
|
|
14
|
-
# keeps the status INDEX already carries (row 5.13,
|
|
14
|
+
# keeps the status INDEX already carries (row 5.13, merged at the
|
|
15
15
|
# 2026-09-10 plan review): 63 of 451 intents in the plastic store have no
|
|
16
16
|
# savepoint.md and 59 more never reach a Done line, and a literal reading
|
|
17
17
|
# would demote all of them. This module computes and compares only; it
|
package/scripts/lib/insights.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
|
|
4
4
|
# insights.rb - the blessed write path for an intent's `## Insights` section
|
|
5
|
-
# (intent 82). A
|
|
5
|
+
# (intent 82). A standalone library so the insight write path stays
|
|
6
6
|
# independent of the savepoint ledger (the spec forbids touching the ledger).
|
|
7
7
|
#
|
|
8
8
|
# Every entry leads with a fixed, machine-parseable prefix
|
|
@@ -6,10 +6,12 @@ require "yaml"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "digest"
|
|
8
8
|
require "time"
|
|
9
|
+
require "tmpdir"
|
|
9
10
|
require_relative "hook_registry"
|
|
10
11
|
require_relative "agent_models"
|
|
11
12
|
require_relative "harness_text"
|
|
12
13
|
require_relative "compact_instructions"
|
|
14
|
+
require_relative "engine_permissions"
|
|
13
15
|
|
|
14
16
|
# Shared installer machinery, instantiable with injected package root / store / agent
|
|
15
17
|
# map so the verb scripts (install/update/uninstall/rollback) and their tests can run
|
|
@@ -253,7 +255,10 @@ class InstallerCore
|
|
|
253
255
|
|
|
254
256
|
# --- Distribution phase ---
|
|
255
257
|
|
|
256
|
-
|
|
258
|
+
# `tmp_dirs:` defaults to the real system tmp directory plus `/tmp` (where a
|
|
259
|
+
# retired session-state leftover would sit); a test overrides it (often to
|
|
260
|
+
# `[]`) so the removal never scans the real filesystem's tmp directories.
|
|
261
|
+
def distribute(mode, tmp_dirs: [Dir.tmpdir, "/tmp"].uniq)
|
|
257
262
|
puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
|
|
258
263
|
|
|
259
264
|
manifest_path = File.join(plastic_home, "manifest.json")
|
|
@@ -295,6 +300,8 @@ class InstallerCore
|
|
|
295
300
|
write_manifest(global_files, manifest_path)
|
|
296
301
|
|
|
297
302
|
pruned = prune_removed_files(old_files - global_files, root: plastic_home)
|
|
303
|
+
removed = remove_retired_session_state(plastic_home: plastic_home, tmp_dirs: tmp_dirs)
|
|
304
|
+
puts " \u{1f9f9} #{removed} retired session state file(s) removed" if removed.positive?
|
|
298
305
|
|
|
299
306
|
puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
|
|
300
307
|
end
|
|
@@ -371,10 +378,13 @@ class InstallerCore
|
|
|
371
378
|
"scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
|
|
372
379
|
"scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
|
|
373
380
|
"scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
|
|
374
|
-
"scripts/lib/
|
|
381
|
+
"scripts/lib/index_entry.rb" => "scripts/lib/index_entry.rb",
|
|
382
|
+
"scripts/lib/project_config.rb" => "scripts/lib/project_config.rb",
|
|
375
383
|
"scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
|
|
376
384
|
"scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
|
|
377
385
|
"scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
|
|
386
|
+
"scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
|
|
387
|
+
"scripts/lib/node_progress.rb" => "scripts/lib/node_progress.rb",
|
|
378
388
|
"scripts/node-transition" => "scripts/node-transition",
|
|
379
389
|
"scripts/lib/arm.rb" => "scripts/lib/arm.rb",
|
|
380
390
|
"scripts/lib/lock.rb" => "scripts/lib/lock.rb",
|
|
@@ -511,11 +521,11 @@ class InstallerCore
|
|
|
511
521
|
"scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
|
|
512
522
|
"scripts/roadmap-graph" => "scripts/roadmap-graph",
|
|
513
523
|
"scripts/index-projection" => "scripts/index-projection",
|
|
514
|
-
# Intent 338 (G5): the node
|
|
524
|
+
# Intent 338 (G5): the node input command - the trust-boundary wrapper,
|
|
515
525
|
# the five-block gatherer/assembler, and the CLI 340's runner calls.
|
|
516
|
-
"scripts/lib/
|
|
517
|
-
"scripts/lib/
|
|
518
|
-
"scripts/node-
|
|
526
|
+
"scripts/lib/data_boundary.rb" => "scripts/lib/data_boundary.rb",
|
|
527
|
+
"scripts/lib/node_input.rb" => "scripts/lib/node_input.rb",
|
|
528
|
+
"scripts/node-input" => "scripts/node-input",
|
|
519
529
|
# Intent 342 (G9): the backward shim that presents actions/*.md as a
|
|
520
530
|
# node graph for any legacy intent, so WorkGraphValidator can require
|
|
521
531
|
# it without going red on contact with install_sync_test.
|
|
@@ -545,6 +555,26 @@ class InstallerCore
|
|
|
545
555
|
# by scripts/runner's `step`.
|
|
546
556
|
"scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
|
|
547
557
|
"scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
|
|
558
|
+
# Intent 340b (G7c, n1): the harness seam - HarnessAdapter, required
|
|
559
|
+
# by scripts/lib/runner_dispatch.rb and by scripts/runner's `step`
|
|
560
|
+
# directly.
|
|
561
|
+
"scripts/lib/harness_adapter.rb" => "scripts/lib/harness_adapter.rb",
|
|
562
|
+
# Intent 340b (G7c, n6): the Codex leg - CodexAdapter (the `codex exec`
|
|
563
|
+
# argv, the sandbox per kind, and the bounded subprocess) and
|
|
564
|
+
# scripts/node-run, the CLI that runs one node's whole attempt over
|
|
565
|
+
# it and writes only a return file, never a ledger transition.
|
|
566
|
+
"scripts/lib/codex_adapter.rb" => "scripts/lib/codex_adapter.rb",
|
|
567
|
+
"scripts/node-run" => "scripts/node-run",
|
|
568
|
+
# Intent 340b (G7c, n3): the engine deny rule - the frozen permissions.deny
|
|
569
|
+
# entry list, merged into settings.json at install and removed surgically
|
|
570
|
+
# at uninstall.
|
|
571
|
+
"scripts/lib/engine_permissions.rb" => "scripts/lib/engine_permissions.rb",
|
|
572
|
+
# Intent 340b (G7c, n4): the Stop gate, the shared ActiveDelivery walk
|
|
573
|
+
# it and the PreCompact hand-off both call, and hook-stop, the Stop
|
|
574
|
+
# hook body scripts/hook-stop's launcher (hooks/stop) relays into.
|
|
575
|
+
"scripts/lib/stop_gate.rb" => "scripts/lib/stop_gate.rb",
|
|
576
|
+
"scripts/lib/active_delivery.rb" => "scripts/lib/active_delivery.rb",
|
|
577
|
+
"scripts/hook-stop" => "scripts/hook-stop",
|
|
548
578
|
# Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
|
|
549
579
|
# is its cap table, above); its launcher (hooks/call-budget) ships via
|
|
550
580
|
# hook_files' own glob, so only the hook script itself needs an entry.
|
|
@@ -559,6 +589,35 @@ class InstallerCore
|
|
|
559
589
|
"scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
|
|
560
590
|
"scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
|
|
561
591
|
"scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
|
|
592
|
+
# Intent 343 (G10, n1): the ledger reader - GraphMeasure.read turns one
|
|
593
|
+
# intent directory into one frozen measurement record. No CLI yet
|
|
594
|
+
# (scripts/graph-measure ships at n2); registered here so it is never
|
|
595
|
+
# a lib that exists on disk but never reaches an install.
|
|
596
|
+
"scripts/lib/graph_measure.rb" => "scripts/lib/graph_measure.rb",
|
|
597
|
+
# Intent 343 (G10, n2): the graph-measure command - the subcommand
|
|
598
|
+
# table over the ledger reader, and the two renderers (text, JSON)
|
|
599
|
+
# over the one record it returns.
|
|
600
|
+
"scripts/graph-measure" => "scripts/graph-measure",
|
|
601
|
+
"scripts/lib/graph_measure_report.rb" => "scripts/lib/graph_measure_report.rb",
|
|
602
|
+
# Intent 343 (G10, n4): the `budget` verb - whether the `budget:` a
|
|
603
|
+
# node's envelope declares (327 C19) is a ceiling that ever actually
|
|
604
|
+
# held, read from the ledger and the real input files.
|
|
605
|
+
"scripts/lib/graph_measure_budget.rb" => "scripts/lib/graph_measure_budget.rb",
|
|
606
|
+
# Intent 343 (G10, n5): the `cohorts` verb's model section - the store
|
|
607
|
+
# walk and whether a recorded model= still matches what config
|
|
608
|
+
# resolves today through RunnerPolicy.
|
|
609
|
+
"scripts/lib/graph_measure_models.rb" => "scripts/lib/graph_measure_models.rb",
|
|
610
|
+
# Intent 343 (G10, n6): the rest of the `cohorts` verb - approve-then-
|
|
611
|
+
# fix per verify model, hop on versus off, delivery latency, the
|
|
612
|
+
# evidence bar, and the two concurrency ceilings.
|
|
613
|
+
"scripts/lib/graph_measure_cohorts.rb" => "scripts/lib/graph_measure_cohorts.rb",
|
|
614
|
+
# Intent 340b (G7c, n7): the Codex loop - composes `step` and
|
|
615
|
+
# `node-run` itself (concurrency two, serial absorb, iteration-capped),
|
|
616
|
+
# routed from scripts/runner's internal `until-empty` verb.
|
|
617
|
+
"scripts/lib/runner_until_empty.rb" => "scripts/lib/runner_until_empty.rb",
|
|
618
|
+
# Intent 340a (G7b, n1): the delivery watch - one tick over disk truth,
|
|
619
|
+
# stalled and done-unreported classification, no CLI and no dispatch.
|
|
620
|
+
"scripts/lib/runner_watch.rb" => "scripts/lib/runner_watch.rb",
|
|
562
621
|
}
|
|
563
622
|
end
|
|
564
623
|
|
|
@@ -834,6 +893,111 @@ class InstallerCore
|
|
|
834
893
|
removed
|
|
835
894
|
end
|
|
836
895
|
|
|
896
|
+
# Intent 344 (G11, D10): leftovers from the retired per-session day pointer
|
|
897
|
+
# and the retired inter-hook coordination key. A `current` candidate is
|
|
898
|
+
# real only when reached with no symlink between plastic_home and the
|
|
899
|
+
# session directory: a store root's own real path must equal the plain
|
|
900
|
+
# join of plastic_home's real path with the store's lexical relative path
|
|
901
|
+
# (a symlinked store, or a symlinked projects/<slug> above it, breaks
|
|
902
|
+
# that equality), and neither the store's `.tmp` directory nor the
|
|
903
|
+
# session directory beneath it may itself be a symlink. A tmp-file
|
|
904
|
+
# candidate is judged by its containing directory's real path instead,
|
|
905
|
+
# so a symlinked tmp root such as macOS's real /tmp still works; its
|
|
906
|
+
# name is matched on raw bytes so an undecodable name never raises.
|
|
907
|
+
# Never opens or parses a candidate: a torn or malformed leftover is
|
|
908
|
+
# deleted by name alone, exactly like a well-formed one. Only a regular
|
|
909
|
+
# file that is not a symlink is ever a candidate for deletion. Returns
|
|
910
|
+
# the count removed; a delete failure warns once per path and the sync
|
|
911
|
+
# continues, since a stuck leftover must never fail an install.
|
|
912
|
+
def remove_retired_session_state(plastic_home:, tmp_dirs:)
|
|
913
|
+
candidates = []
|
|
914
|
+
|
|
915
|
+
real_store_roots(plastic_home).each do |store_root|
|
|
916
|
+
candidates.concat(current_candidates(store_root))
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
Array(tmp_dirs).compact.each do |dir|
|
|
920
|
+
candidates.concat(tmp_json_candidates(dir))
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
removed = 0
|
|
924
|
+
candidates.uniq.each do |candidate|
|
|
925
|
+
begin
|
|
926
|
+
next unless File.lstat(candidate).file?
|
|
927
|
+
rescue SystemCallError
|
|
928
|
+
next
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
begin
|
|
932
|
+
File.delete(candidate)
|
|
933
|
+
removed += 1
|
|
934
|
+
rescue SystemCallError => e
|
|
935
|
+
warn " \u{26a0}\u{fe0f} Could not remove retired session state #{candidate}: #{e.message}"
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
removed
|
|
939
|
+
rescue StandardError
|
|
940
|
+
removed || 0
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# Every store root (the global store, plus each project store) whose real
|
|
944
|
+
# path is reached with no symlink between it and plastic_home.
|
|
945
|
+
def real_store_roots(plastic_home)
|
|
946
|
+
roots = [File.join(plastic_home, "store")] +
|
|
947
|
+
Dir.glob(File.join(plastic_home, "projects", "*", "store"))
|
|
948
|
+
roots.select { |root| real_store_root?(plastic_home, root) }
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def real_store_root?(plastic_home, store_root)
|
|
952
|
+
return false unless File.directory?(store_root)
|
|
953
|
+
|
|
954
|
+
real_home = File.realpath(plastic_home)
|
|
955
|
+
relative = store_root.sub(/\A#{Regexp.escape(plastic_home)}\/?/, "")
|
|
956
|
+
File.realpath(store_root) == File.join(real_home, relative)
|
|
957
|
+
rescue SystemCallError
|
|
958
|
+
false
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# `current` candidates under one validated store root: `.tmp` and every
|
|
962
|
+
# session directory beneath it must be real directories, never symlinks.
|
|
963
|
+
def current_candidates(store_root)
|
|
964
|
+
tmp_root = File.join(store_root, ".tmp")
|
|
965
|
+
return [] unless File.lstat(tmp_root).directory?
|
|
966
|
+
|
|
967
|
+
Dir.children(tmp_root).filter_map do |child|
|
|
968
|
+
begin
|
|
969
|
+
session_dir = File.join(tmp_root, child)
|
|
970
|
+
next unless File.lstat(session_dir).directory?
|
|
971
|
+
|
|
972
|
+
File.join(session_dir, "current")
|
|
973
|
+
rescue SystemCallError
|
|
974
|
+
nil
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
rescue SystemCallError
|
|
978
|
+
[]
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# Retired session-state leftover files sitting directly in `dir`, resolved through
|
|
982
|
+
# `dir`'s own real path so a symlinked tmp root (macOS's real /tmp) still
|
|
983
|
+
# works; the name match runs on raw bytes so an undecodable name is
|
|
984
|
+
# simply not a match, never a raised error.
|
|
985
|
+
def tmp_json_candidates(dir)
|
|
986
|
+
real = File.realpath(dir)
|
|
987
|
+
Dir.children(real).select { |name| retired_tmp_name?(name) }.map { |name| File.join(real, name) }
|
|
988
|
+
rescue SystemCallError
|
|
989
|
+
[]
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# True when `name`'s raw bytes match the retired session-state leftover shape
|
|
993
|
+
# (`plastic-<session>--<id>.json`). Runs on `name.b` so a name that is
|
|
994
|
+
# not valid UTF-8 is judged on bytes instead of raising.
|
|
995
|
+
def retired_tmp_name?(name)
|
|
996
|
+
name.b.match?(/\Aplastic-[^\/]+--[^\/]+\.json\z/n)
|
|
997
|
+
rescue StandardError
|
|
998
|
+
false
|
|
999
|
+
end
|
|
1000
|
+
|
|
837
1001
|
# True when `path`, once expanded, is one of `roots` itself or lives underneath
|
|
838
1002
|
# one of them. String-prefix containment guarded by a trailing separator so a
|
|
839
1003
|
# sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
|
|
@@ -932,6 +1096,11 @@ class InstallerCore
|
|
|
932
1096
|
choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
|
|
933
1097
|
merge_claude_hooks(settings_path, choice: choice)
|
|
934
1098
|
|
|
1099
|
+
# The engine deny rule (intent 340b, G7c, n3, D4): a second ownership
|
|
1100
|
+
# mechanism from the hook merge above, so it is its own call rather than a
|
|
1101
|
+
# branch inside merge_claude_hooks.
|
|
1102
|
+
merge_engine_permissions(settings_path)
|
|
1103
|
+
|
|
935
1104
|
# Instruction injection (intent 312): the compact-instructions block into
|
|
936
1105
|
# ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
|
|
937
1106
|
# (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
|
|
@@ -1485,6 +1654,44 @@ class InstallerCore
|
|
|
1485
1654
|
removed
|
|
1486
1655
|
end
|
|
1487
1656
|
|
|
1657
|
+
# --- The engine deny rule (intent 340b, G7c, n3) ---
|
|
1658
|
+
#
|
|
1659
|
+
# A second ownership mechanism from merge_claude_hooks above (D4, node n3): a
|
|
1660
|
+
# permissions.deny entry is a bare string with no marker in it, so EnginePermissions
|
|
1661
|
+
# owns its four entries by exact-string membership, not by a plastic- launcher
|
|
1662
|
+
# basename. This pair only does the read-modify-write; EnginePermissions.merge_into
|
|
1663
|
+
# and .remove_from are the pure transforms.
|
|
1664
|
+
|
|
1665
|
+
# Merges EnginePermissions::ENTRIES into settings.json. Unlike merge_claude_hooks,
|
|
1666
|
+
# this refuses rather than starting from {} when the existing file cannot be
|
|
1667
|
+
# parsed (row 3.11): a hand-edited settings file is never silently replaced.
|
|
1668
|
+
# Returns true when it wrote, false when it refused.
|
|
1669
|
+
def merge_engine_permissions(settings_path)
|
|
1670
|
+
if File.exist?(settings_path)
|
|
1671
|
+
settings = read_json_safe(settings_path)
|
|
1672
|
+
return false if settings.nil?
|
|
1673
|
+
else
|
|
1674
|
+
settings = {}
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
write_json_atomic(settings_path, EnginePermissions.merge_into(settings))
|
|
1678
|
+
true
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
# Removes exactly EnginePermissions::ENTRIES from settings.json, leaving every
|
|
1682
|
+
# other deny entry (the owner's own, and any Plastic entry the owner has since
|
|
1683
|
+
# edited) in place. Returns true when it wrote, false on a missing or
|
|
1684
|
+
# unparseable file, or a permissions/deny shape it does not recognize.
|
|
1685
|
+
def remove_engine_permissions(settings_path)
|
|
1686
|
+
return false unless File.exist?(settings_path)
|
|
1687
|
+
|
|
1688
|
+
settings = read_json_safe(settings_path)
|
|
1689
|
+
return false unless settings.is_a?(Hash)
|
|
1690
|
+
|
|
1691
|
+
write_json_atomic(settings_path, EnginePermissions.remove_from(settings))
|
|
1692
|
+
true
|
|
1693
|
+
end
|
|
1694
|
+
|
|
1488
1695
|
# --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
|
|
1489
1696
|
# New primitive: markdown marked-section merge, the analog of merge_claude_hooks'
|
|
1490
1697
|
# JSON read-modify-write for a partial-ownership text file. Three states
|
|
@@ -1674,6 +1881,7 @@ class InstallerCore
|
|
|
1674
1881
|
if key == "claude"
|
|
1675
1882
|
settings_path = File.join(config[:dir], "settings.json")
|
|
1676
1883
|
remove_claude_hooks(settings_path) if File.exist?(settings_path)
|
|
1884
|
+
remove_engine_permissions(settings_path) if File.exist?(settings_path)
|
|
1677
1885
|
removed.concat(migrate_legacy_plugin(config[:dir]))
|
|
1678
1886
|
|
|
1679
1887
|
# The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
|
|
@@ -1822,11 +2030,22 @@ class InstallerCore
|
|
|
1822
2030
|
|
|
1823
2031
|
def read_json_safe(path)
|
|
1824
2032
|
return nil unless File.exist?(path)
|
|
2033
|
+
|
|
1825
2034
|
JSON.parse(File.read(path))
|
|
1826
2035
|
rescue JSON::ParserError
|
|
1827
|
-
#
|
|
1828
|
-
content
|
|
1829
|
-
|
|
2036
|
+
# The comment/trailing-comma-stripped retry below can itself raise
|
|
2037
|
+
# JSON::ParserError on genuinely malformed content (a truncated file, or
|
|
2038
|
+
# plain garbage). A nested begin/rescue is required here because a
|
|
2039
|
+
# method-level `rescue` clause never catches an exception raised from
|
|
2040
|
+
# INSIDE a sibling rescue clause's own body (only from the main body);
|
|
2041
|
+
# doctor_core.rb#read_json_safe carries the same fix for the same reason
|
|
2042
|
+
# (intent 331e, F5).
|
|
2043
|
+
begin
|
|
2044
|
+
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
2045
|
+
JSON.parse(content)
|
|
2046
|
+
rescue
|
|
2047
|
+
nil
|
|
2048
|
+
end
|
|
1830
2049
|
rescue
|
|
1831
2050
|
nil
|
|
1832
2051
|
end
|
|
@@ -287,7 +287,7 @@ module IntentScreen
|
|
|
287
287
|
# Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
|
|
288
288
|
# the remainder means either no boundary exists at all, or the boundary
|
|
289
289
|
# sits at the absolute end of `text` (a single trailing clause with nothing
|
|
290
|
-
# after it) — both cases where there is no SECOND clause to
|
|
290
|
+
# after it) — both cases where there is no SECOND clause to merge in.
|
|
291
291
|
def self.clause_and_rest(text)
|
|
292
292
|
m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
|
|
293
293
|
return [text, nil] unless m
|