@zalom/plastic 2.0.0-alpha.21 → 2.0.0-alpha.23
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 +20 -18
- 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/doctor.rb +159 -10
- package/scripts/end-intent +3 -3
- package/scripts/graph-measure +249 -0
- package/scripts/hook-capture +1 -0
- package/scripts/hook-savepoint +24 -2
- package/scripts/hook-session-start +333 -321
- package/scripts/hook-stop +57 -0
- package/scripts/insight-append +18 -4
- package/scripts/lib/active_delivery.rb +61 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/engine_permissions.rb +88 -0
- 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 +408 -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 +36 -5
- 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_projection.rb +1 -1
- package/scripts/lib/installer_core.rb +112 -6
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/node_ledger.rb +8 -1
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.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/runner_absorb.rb +31 -5
- package/scripts/lib/runner_dispatch.rb +26 -11
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +3 -3
- package/scripts/lib/session_git.rb +2 -2
- 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/new-intent +1 -1
- package/scripts/node-run +224 -0
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/skill-lint +115 -6
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +54 -56
- package/skills/auto/references/agent-architecture.md +10 -8
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +7 -7
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/maintenance-and-revisions.md +1 -1
- package/skills/doctor/SKILL.md +3 -3
- package/skills/doctor/report.md +1 -1
- package/skills/intent-continuing/references/boarding-matrix.md +2 -2
- package/skills/intent-creating/SKILL.md +58 -133
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-ending/SKILL.md +48 -56
- package/skills/intent-ending/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +43 -136
- package/skills/intent-speccing/SKILL.md +3 -0
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/SKILL.md +2 -1
- package/skills/tutorial/references/track-1-guided.md +21 -40
- package/skills/tutorial/references/track-2-auto.md +3 -3
- package/templates/agents.md +2 -2
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require_relative "node_ledger"
|
|
7
|
+
require_relative "node_file"
|
|
8
|
+
require_relative "runner_policy"
|
|
9
|
+
require_relative "agent_models"
|
|
10
|
+
|
|
11
|
+
# GraphMeasureModels (intent 343, G10, n5): the store walk and the model
|
|
12
|
+
# comparison half of `graph-measure cohorts <store_dir>`. n6 adds the rates,
|
|
13
|
+
# the latency and the bar to the same verb; this module owns only the
|
|
14
|
+
# population, the qualified-field counts, and whether a recorded `model=`
|
|
15
|
+
# still matches what config resolves today.
|
|
16
|
+
#
|
|
17
|
+
# Read-only (spec D2): it opens every immediate child of `store_dir`, reads
|
|
18
|
+
# its `savepoint.md` and its `nodes/*.md`, and writes nothing. A store with
|
|
19
|
+
# no directories at all, an intent with no savepoint.md, and an intent
|
|
20
|
+
# directory this process cannot read all resolve to a counted, named
|
|
21
|
+
# exclusion rather than an exception (spec D4).
|
|
22
|
+
#
|
|
23
|
+
# D16: the ledger records `model=` with no role field. The comparison goes
|
|
24
|
+
# through `RunnerPolicy.model_for(kind, config:)`, the one place that maps a
|
|
25
|
+
# node's kind to the role the runner actually dispatched it against - never
|
|
26
|
+
# a hand-rolled kind-to-role table, and never `AgentModels::TIER_DEFAULTS`
|
|
27
|
+
# directly, which carries no `plastic-advisor` entry at all (that agent is a
|
|
28
|
+
# consultation agent, never auto-dispatched). Adds no second parser:
|
|
29
|
+
# `NodeLedger` owns the transition-line format, `NodeFile` owns the kind
|
|
30
|
+
# envelope and the `KIND_PREFIX` fallback, `RunnerPolicy`/`AgentModels` own
|
|
31
|
+
# the model resolution and the config-override chain.
|
|
32
|
+
module GraphMeasureModels
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
SAVEPOINT_FILE = "savepoint.md"
|
|
36
|
+
UNAVAILABLE = "unavailable"
|
|
37
|
+
|
|
38
|
+
# A ledger-wide existence check (spec row 5.3): "does this savepoint.md
|
|
39
|
+
# carry this marker anywhere", not a per-node parse. `DONE_LINE_RE` mirrors
|
|
40
|
+
# the exact two-space stage-line shape every `Savepoint.append_*` writer
|
|
41
|
+
# emits (spec D9's "223 Done lines" is counted this way against the real
|
|
42
|
+
# store).
|
|
43
|
+
DONE_LINE_RE = /(?:\A|\n)\S+ {2}Done {2}/.freeze
|
|
44
|
+
RUNNING_HOLDER_RE = /running holder=/.freeze
|
|
45
|
+
MODEL_FIELD_RE = /\bmodel=/.freeze
|
|
46
|
+
HOP_FIELD_RE = /\bhop=/.freeze
|
|
47
|
+
|
|
48
|
+
# The named kinds RunnerPolicy actually assigns a model role to (spec row
|
|
49
|
+
# 5.9): "decision" carries `model_role: nil` (it is never dispatched with a
|
|
50
|
+
# model at all) and is excluded here, the same way RunnerPolicy's own table
|
|
51
|
+
# excludes it from ever needing one.
|
|
52
|
+
MEASURED_KINDS = RunnerPolicy::KIND_TABLE.reject { |_, v| v[:model_role].nil? }.keys.freeze
|
|
53
|
+
|
|
54
|
+
# {ok:, population:, qualified:, excluded_no_model:, node_rows:, drift:,
|
|
55
|
+
# unmeasured_kinds:}. `project_config:`/`global_config:` are already-loaded
|
|
56
|
+
# config hashes (the `agents.models` shape `AgentModels` reads), never read
|
|
57
|
+
# from disk or ENV here (spec D2, this module's own DI convention): the
|
|
58
|
+
# caller owns loading real config.yml files.
|
|
59
|
+
def read(store_dir, project_config: {}, global_config: {})
|
|
60
|
+
dir = File.expand_path(store_dir.to_s)
|
|
61
|
+
config = config_for(project_config, global_config)
|
|
62
|
+
|
|
63
|
+
population = { total: 0, with_savepoint: 0, without_savepoint: 0, unreadable: [] }
|
|
64
|
+
qualified = { done: 0, running_holder: 0, model: 0, hop: 0 }
|
|
65
|
+
excluded_no_model = []
|
|
66
|
+
node_rows = []
|
|
67
|
+
|
|
68
|
+
each_child_name(dir).each do |name|
|
|
69
|
+
path = File.join(dir, name)
|
|
70
|
+
next unless File.directory?(path)
|
|
71
|
+
|
|
72
|
+
population[:total] += 1
|
|
73
|
+
status, payload = read_savepoint(path)
|
|
74
|
+
case status
|
|
75
|
+
when :missing
|
|
76
|
+
population[:without_savepoint] += 1
|
|
77
|
+
when :unreadable
|
|
78
|
+
population[:unreadable] << { id: name, reason: payload }
|
|
79
|
+
when :ok
|
|
80
|
+
population[:with_savepoint] += 1
|
|
81
|
+
walk_intent(name, path, payload, config, qualified, excluded_no_model, node_rows)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
record = {
|
|
86
|
+
ok: true,
|
|
87
|
+
population: population,
|
|
88
|
+
qualified: qualified,
|
|
89
|
+
excluded_no_model: excluded_no_model,
|
|
90
|
+
node_rows: node_rows,
|
|
91
|
+
drift: build_drift(node_rows),
|
|
92
|
+
unmeasured_kinds: unmeasured_kinds(node_rows),
|
|
93
|
+
}
|
|
94
|
+
deep_freeze(record)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# NEW-4 (v2 review, D22): the one code path both the `cohorts` verb
|
|
98
|
+
# (scripts/graph-measure) and the doctor's re-qualification rule
|
|
99
|
+
# (scripts/doctor.rb) call to name the project-scope agent config file for
|
|
100
|
+
# a given store - the store's sibling `config.yml`, the file that actually
|
|
101
|
+
# exists in the wild and shares the global config.yml schema
|
|
102
|
+
# (`~/.plastic/projects/knowdb/config.yml` is a real example), never
|
|
103
|
+
# `project.yml`, whose template carries no `agents:` key at all. Pure path
|
|
104
|
+
# arithmetic; the caller still owns loading it (this module's own DI
|
|
105
|
+
# convention above - `read` never touches disk or ENV itself).
|
|
106
|
+
#
|
|
107
|
+
# Reproduced by hand before this fix: `scripts/graph-measure:225` read
|
|
108
|
+
# `<store>/../project.yml`, so a real `plastic-executor: opus` override
|
|
109
|
+
# written to a store's sibling `config.yml` never reached the `cohorts`
|
|
110
|
+
# verb's own model comparison, while `scripts/doctor.rb`'s
|
|
111
|
+
# `model_requalification_checks` passed an empty project config on
|
|
112
|
+
# purpose - two different silences over the same store, agreeing with
|
|
113
|
+
# each other only by accident.
|
|
114
|
+
def project_config_path(store_dir)
|
|
115
|
+
File.join(File.dirname(store_dir.to_s), "config.yml")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- rendering ---------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def render_json(record)
|
|
121
|
+
JSON.generate(model(record))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_text(record)
|
|
125
|
+
m = model(record)
|
|
126
|
+
lines = []
|
|
127
|
+
lines.concat(population_block(m["population"]))
|
|
128
|
+
lines << ""
|
|
129
|
+
lines.concat(qualified_block(m["qualified"], m["population"]))
|
|
130
|
+
lines << ""
|
|
131
|
+
lines.concat(excluded_block(m["excluded_no_model"]))
|
|
132
|
+
lines << ""
|
|
133
|
+
lines.concat(drift_block(m["drift"]))
|
|
134
|
+
lines << ""
|
|
135
|
+
lines.concat(unmeasured_block(m["unmeasured_kinds"]))
|
|
136
|
+
"#{lines.join("\n")}\n"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def model(record)
|
|
140
|
+
{
|
|
141
|
+
"population" => population_model(record[:population]),
|
|
142
|
+
"qualified" => record[:qualified].transform_keys(&:to_s),
|
|
143
|
+
"excluded_no_model" => record[:excluded_no_model],
|
|
144
|
+
"drift" => drift_model(record[:drift]),
|
|
145
|
+
"unmeasured_kinds" => record[:unmeasured_kinds],
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def population_model(p)
|
|
150
|
+
{
|
|
151
|
+
"total_intent_dirs" => p[:total],
|
|
152
|
+
"with_savepoint" => p[:with_savepoint],
|
|
153
|
+
"without_savepoint" => p[:without_savepoint],
|
|
154
|
+
"unreadable" => p[:unreadable].map { |u| { "id" => u[:id], "reason" => u[:reason] } },
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
private_class_method :population_model
|
|
158
|
+
|
|
159
|
+
def drift_model(drift)
|
|
160
|
+
{
|
|
161
|
+
"executor" => Array(drift[:executor]).map { |r| drift_row_model(r) },
|
|
162
|
+
"advisor" => Array(drift[:advisor]).map { |r| drift_row_model(r) },
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
private_class_method :drift_model
|
|
166
|
+
|
|
167
|
+
def drift_row_model(r)
|
|
168
|
+
{
|
|
169
|
+
"intent" => r[:intent],
|
|
170
|
+
"node" => r[:node],
|
|
171
|
+
"kind" => r[:kind].to_s,
|
|
172
|
+
"kind_source" => r[:kind_source].to_s,
|
|
173
|
+
"recorded" => r[:recorded],
|
|
174
|
+
"expected" => r[:expected],
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
private_class_method :drift_row_model
|
|
178
|
+
|
|
179
|
+
def population_block(p)
|
|
180
|
+
lines = ["== Population =="]
|
|
181
|
+
lines << "intent directories: #{p['total_intent_dirs']}"
|
|
182
|
+
lines << "with savepoint.md: #{p['with_savepoint']}"
|
|
183
|
+
lines << "without savepoint.md (never started): #{p['without_savepoint']}"
|
|
184
|
+
if p["unreadable"].empty?
|
|
185
|
+
lines << "unreadable: 0"
|
|
186
|
+
else
|
|
187
|
+
lines << "unreadable: #{p['unreadable'].length}"
|
|
188
|
+
p["unreadable"].each { |u| lines << " #{u['id']}: #{u['reason']}" }
|
|
189
|
+
end
|
|
190
|
+
lines
|
|
191
|
+
end
|
|
192
|
+
private_class_method :population_block
|
|
193
|
+
|
|
194
|
+
def qualified_block(q, p)
|
|
195
|
+
[
|
|
196
|
+
"== Qualified fields (of #{p['with_savepoint']} ledgers) ==",
|
|
197
|
+
"Done line: #{q['done']}",
|
|
198
|
+
"running holder=: #{q['running_holder']}",
|
|
199
|
+
"model=: #{q['model']}",
|
|
200
|
+
"hop=: #{q['hop']}",
|
|
201
|
+
]
|
|
202
|
+
end
|
|
203
|
+
private_class_method :qualified_block
|
|
204
|
+
|
|
205
|
+
def excluded_block(excluded)
|
|
206
|
+
lines = ["== Excluded (no model= anywhere) =="]
|
|
207
|
+
if excluded.empty?
|
|
208
|
+
lines << "(none)"
|
|
209
|
+
else
|
|
210
|
+
excluded.each { |id| lines << id }
|
|
211
|
+
end
|
|
212
|
+
lines
|
|
213
|
+
end
|
|
214
|
+
private_class_method :excluded_block
|
|
215
|
+
|
|
216
|
+
def drift_block(drift)
|
|
217
|
+
lines = ["== Model drift =="]
|
|
218
|
+
if drift["executor"].empty? && drift["advisor"].empty?
|
|
219
|
+
lines << "(none)"
|
|
220
|
+
return lines
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
%w[executor advisor].each do |role|
|
|
224
|
+
next if drift[role].empty?
|
|
225
|
+
|
|
226
|
+
lines << "-- #{role} --"
|
|
227
|
+
lines << "| Intent | Node | Kind | Kind source | Recorded | Expected |"
|
|
228
|
+
lines << "| --- | --- | --- | --- | --- | --- |"
|
|
229
|
+
drift[role].each do |r|
|
|
230
|
+
lines << "| #{r['intent']} | #{r['node']} | #{r['kind']} | #{r['kind_source']} | #{r['recorded']} | #{r['expected']} |"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
lines
|
|
234
|
+
end
|
|
235
|
+
private_class_method :drift_block
|
|
236
|
+
|
|
237
|
+
def unmeasured_block(kinds)
|
|
238
|
+
lines = ["== Unmeasured kinds =="]
|
|
239
|
+
lines << (kinds.empty? ? "(none)" : kinds.join(", "))
|
|
240
|
+
lines
|
|
241
|
+
end
|
|
242
|
+
private_class_method :unmeasured_block
|
|
243
|
+
|
|
244
|
+
# --- the store walk ------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def each_child_name(dir)
|
|
247
|
+
return [] unless File.directory?(dir)
|
|
248
|
+
|
|
249
|
+
Dir.children(dir).reject { |e| e.start_with?(".") }.sort
|
|
250
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
251
|
+
[]
|
|
252
|
+
end
|
|
253
|
+
private_class_method :each_child_name
|
|
254
|
+
|
|
255
|
+
# [:ok, content] | [:missing, nil] | [:unreadable, message]. `File.exist?`
|
|
256
|
+
# silently returns false for a permission-denied path (it never raises), so
|
|
257
|
+
# a chmod-000 intent directory would otherwise misreport as "never
|
|
258
|
+
# started" (row 5.2) rather than "unreadable" (row 5.13) - reading the
|
|
259
|
+
# file directly and catching the two errnos separately is what tells the
|
|
260
|
+
# two cases apart.
|
|
261
|
+
def read_savepoint(intent_dir)
|
|
262
|
+
content = File.read(File.join(intent_dir, SAVEPOINT_FILE))
|
|
263
|
+
[:ok, content.scrub]
|
|
264
|
+
rescue Errno::ENOENT
|
|
265
|
+
[:missing, nil]
|
|
266
|
+
rescue Errno::EACCES => e
|
|
267
|
+
[:unreadable, e.message]
|
|
268
|
+
end
|
|
269
|
+
private_class_method :read_savepoint
|
|
270
|
+
|
|
271
|
+
def walk_intent(name, path, content, config, qualified, excluded_no_model, node_rows)
|
|
272
|
+
qualified[:done] += 1 if content.match?(DONE_LINE_RE)
|
|
273
|
+
qualified[:running_holder] += 1 if content.match?(RUNNING_HOLDER_RE)
|
|
274
|
+
qualified[:hop] += 1 if content.match?(HOP_FIELD_RE)
|
|
275
|
+
|
|
276
|
+
unless content.match?(MODEL_FIELD_RE)
|
|
277
|
+
excluded_no_model << name
|
|
278
|
+
return
|
|
279
|
+
end
|
|
280
|
+
qualified[:model] += 1
|
|
281
|
+
|
|
282
|
+
entries = NodeLedger.entries_from_content(content).reject { |e| e[:torn] }
|
|
283
|
+
entries.group_by { |e| e[:subject] }.each do |subject, subject_entries|
|
|
284
|
+
recorded = recorded_model(subject_entries)
|
|
285
|
+
next unless present?(recorded)
|
|
286
|
+
|
|
287
|
+
kind, kind_source = resolve_kind(path, subject)
|
|
288
|
+
role = RunnerPolicy.policy_for(kind)[:model_role]
|
|
289
|
+
next unless role
|
|
290
|
+
|
|
291
|
+
expected = RunnerPolicy.model_for(kind, config: config)
|
|
292
|
+
node_rows << { intent: name, node: subject, kind: kind, kind_source: kind_source,
|
|
293
|
+
role: role, recorded: recorded, expected: expected }
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
private_class_method :walk_intent
|
|
297
|
+
|
|
298
|
+
# The last (file-order) transition line for `subject` that carries a
|
|
299
|
+
# `model=`, mirroring GraphMeasure's own "most recent field wins" reading
|
|
300
|
+
# (spec row 1.25's shape, reused here rather than re-derived).
|
|
301
|
+
def recorded_model(subject_entries)
|
|
302
|
+
subject_entries.reverse_each do |e|
|
|
303
|
+
value = e[:fields]["model"]
|
|
304
|
+
return value if present?(value)
|
|
305
|
+
end
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
private_class_method :recorded_model
|
|
309
|
+
|
|
310
|
+
# --- config chain: spec row 5.6 -------------------------------------------------
|
|
311
|
+
|
|
312
|
+
# Project override, then global override, then RunnerPolicy's own shipped
|
|
313
|
+
# default (spec D16, row 5.6): `AgentModels.override_map` already encodes
|
|
314
|
+
# that precedence (global overlaid by project, project wins), so this
|
|
315
|
+
# module adds no second merge rule - it only re-wraps the flat map back
|
|
316
|
+
# into the `agents.models` shape `RunnerPolicy.model_for` reads.
|
|
317
|
+
def config_for(project_config, global_config)
|
|
318
|
+
merged = AgentModels.override_map(project_config: project_config, global_config: global_config)
|
|
319
|
+
{ "agents" => { "models" => merged } }
|
|
320
|
+
end
|
|
321
|
+
private_class_method :config_for
|
|
322
|
+
|
|
323
|
+
# --- kind resolution: spec D6, row 5.11 ------------------------------------------
|
|
324
|
+
|
|
325
|
+
# Kind from the node file when one exists; otherwise `NodeFile::KIND_PREFIX`,
|
|
326
|
+
# flagged (spec row 5.11). `GraphMeasure.resolve_kind` already does exactly
|
|
327
|
+
# this, but it is `private_class_method` (carried from n1: reuse the local
|
|
328
|
+
# equivalent, or define one in this module - never reopen another module to
|
|
329
|
+
# make its private method public), so this is this module's own copy over
|
|
330
|
+
# the same public `NodeFile` API.
|
|
331
|
+
# B6 (v1 review): NodeFile.parse reads its own path and does not scrub, so
|
|
332
|
+
# one bad byte anywhere under nodes/*.md raised out of it and took the
|
|
333
|
+
# whole `cohorts` verb down with it - reproduced by appending an invalid
|
|
334
|
+
# UTF-8 byte to a copy of 340's nodes/n1.md: `cohorts` exited 1 with
|
|
335
|
+
# "internal error: invalid byte sequence in UTF-8" (via this module's own
|
|
336
|
+
# store walk, since `cohorts` calls `GraphMeasureModels.read` for its
|
|
337
|
+
# model-comparison half), the same failure `budget` hit through its own
|
|
338
|
+
# direct `NodeFile.parse` call. `GraphMeasure.with_safe_path` already
|
|
339
|
+
# solves this; it is `private_class_method` (carried from n1), so this is
|
|
340
|
+
# a local copy, the same shape `resolve_kind` and `present?` already are
|
|
341
|
+
# in this module, never a reopen.
|
|
342
|
+
def resolve_kind(intent_dir, id)
|
|
343
|
+
path = find_node_file(intent_dir, id)
|
|
344
|
+
if path
|
|
345
|
+
parsed = with_safe_path(path) { |p| p ? NodeFile.parse(p) : nil }
|
|
346
|
+
return [parsed[:kind], :node_file] if parsed && parsed[:kind] && !parsed[:kind].to_s.empty?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
prefix = id.to_s[/\A[a-z]{1,2}/]
|
|
350
|
+
kind = prefix && NodeFile::KIND_PREFIX.invert[prefix]
|
|
351
|
+
[kind, :fallback]
|
|
352
|
+
end
|
|
353
|
+
private_class_method :resolve_kind
|
|
354
|
+
|
|
355
|
+
def find_node_file(intent_dir, id)
|
|
356
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md")).find do |f|
|
|
357
|
+
NodeFile.filename_matches_id?(File.basename(f, ".md"), id)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
private_class_method :find_node_file
|
|
361
|
+
|
|
362
|
+
def with_safe_path(path)
|
|
363
|
+
return yield(nil) unless path && File.exist?(path)
|
|
364
|
+
|
|
365
|
+
raw = File.read(path)
|
|
366
|
+
scrubbed = raw.scrub
|
|
367
|
+
return yield(path) if scrubbed == raw
|
|
368
|
+
|
|
369
|
+
Dir.mktmpdir("graph-measure-models-scrub") do |tmp|
|
|
370
|
+
safe_path = File.join(tmp, File.basename(path))
|
|
371
|
+
File.write(safe_path, scrubbed)
|
|
372
|
+
yield(safe_path)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
private_class_method :with_safe_path
|
|
376
|
+
|
|
377
|
+
# --- drift and unmeasured kinds: spec rows 5.7, 5.9 ------------------------------
|
|
378
|
+
|
|
379
|
+
def build_drift(node_rows)
|
|
380
|
+
executor = node_rows.select { |r| r[:role] == :executor && r[:recorded] != r[:expected] }
|
|
381
|
+
advisor = node_rows.select { |r| r[:role] == :advisor && r[:recorded] != r[:expected] }
|
|
382
|
+
{ executor: executor, advisor: advisor }
|
|
383
|
+
end
|
|
384
|
+
private_class_method :build_drift
|
|
385
|
+
|
|
386
|
+
def unmeasured_kinds(node_rows)
|
|
387
|
+
present_kinds = node_rows.map { |r| r[:kind] }.compact.uniq
|
|
388
|
+
MEASURED_KINDS.reject { |k| present_kinds.include?(k) }
|
|
389
|
+
end
|
|
390
|
+
private_class_method :unmeasured_kinds
|
|
391
|
+
|
|
392
|
+
# --- shared helpers ---------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
# NodeLedger.present? is private_class_method (carried from n1): this is
|
|
395
|
+
# the local equivalent, not a reopen.
|
|
396
|
+
def present?(value)
|
|
397
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
398
|
+
end
|
|
399
|
+
private_class_method :present?
|
|
400
|
+
|
|
401
|
+
def deep_freeze(obj)
|
|
402
|
+
case obj
|
|
403
|
+
when Hash
|
|
404
|
+
obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
|
|
405
|
+
when Array
|
|
406
|
+
obj.each { |v| deep_freeze(v) }
|
|
407
|
+
end
|
|
408
|
+
obj.freeze
|
|
409
|
+
end
|
|
410
|
+
private_class_method :deep_freeze
|
|
411
|
+
end
|