@zalom/plastic 2.0.0-alpha.22 → 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.
Files changed (67) hide show
  1. package/agents/plastic-enforcer.md +6 -3
  2. package/agents/plastic-executor.md +4 -0
  3. package/agents/plastic-node-research.md +28 -0
  4. package/agents/plastic-node-verify.md +27 -0
  5. package/agents/plastic-node-work.md +32 -0
  6. package/bin/lib/context_budget.rb +1 -1
  7. package/hooks/hooks.json +12 -0
  8. package/hooks/statusline +28 -0
  9. package/hooks/stop +5 -0
  10. package/package.json +1 -1
  11. package/scripts/doctor.rb +80 -6
  12. package/scripts/end-intent +3 -3
  13. package/scripts/graph-measure +249 -0
  14. package/scripts/hook-capture +1 -0
  15. package/scripts/hook-savepoint +24 -2
  16. package/scripts/hook-session-start +40 -0
  17. package/scripts/hook-stop +57 -0
  18. package/scripts/lib/active_delivery.rb +61 -0
  19. package/scripts/lib/agent_models.rb +10 -1
  20. package/scripts/lib/codex_adapter.rb +197 -0
  21. package/scripts/lib/doctor_core.rb +8 -3
  22. package/scripts/lib/engine_permissions.rb +88 -0
  23. package/scripts/lib/graph_edges.rb +4 -4
  24. package/scripts/lib/graph_file.rb +4 -4
  25. package/scripts/lib/graph_measure.rb +645 -0
  26. package/scripts/lib/graph_measure_budget.rb +408 -0
  27. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  28. package/scripts/lib/graph_measure_models.rb +411 -0
  29. package/scripts/lib/graph_measure_report.rb +532 -0
  30. package/scripts/lib/graph_tree.rb +2 -2
  31. package/scripts/lib/handoff.rb +36 -5
  32. package/scripts/lib/harness_adapter.rb +184 -0
  33. package/scripts/lib/hook_registry.rb +13 -1
  34. package/scripts/lib/hook_replay.rb +23 -5
  35. package/scripts/lib/index_projection.rb +1 -1
  36. package/scripts/lib/installer_core.rb +109 -3
  37. package/scripts/lib/intent_screen.rb +1 -1
  38. package/scripts/lib/intent_validator.rb +2 -2
  39. package/scripts/lib/meter_watch.rb +15 -9
  40. package/scripts/lib/node_file.rb +3 -3
  41. package/scripts/lib/node_ledger.rb +8 -1
  42. package/scripts/lib/node_progress.rb +153 -0
  43. package/scripts/lib/outcome_report.rb +1 -1
  44. package/scripts/lib/report_screen.rb +10 -6
  45. package/scripts/lib/roadmap_graph.rb +1 -1
  46. package/scripts/lib/roadmap_queue.rb +1 -1
  47. package/scripts/lib/roadmap_render.rb +1 -1
  48. package/scripts/lib/runner_absorb.rb +31 -5
  49. package/scripts/lib/runner_dispatch.rb +26 -11
  50. package/scripts/lib/runner_until_empty.rb +252 -0
  51. package/scripts/lib/runner_watch.rb +389 -0
  52. package/scripts/lib/savepoint.rb +3 -3
  53. package/scripts/lib/session_git.rb +2 -2
  54. package/scripts/lib/stop_gate.rb +95 -0
  55. package/scripts/lib/verify_intent.rb +2 -2
  56. package/scripts/lib/work_graph_validator.rb +6 -6
  57. package/scripts/new-intent +1 -1
  58. package/scripts/node-run +224 -0
  59. package/scripts/read-config +6 -0
  60. package/scripts/runner +203 -19
  61. package/scripts/verify-intent +1 -1
  62. package/skills/auto/SKILL.md +1 -1
  63. package/skills/conventions/references/knowledge-graph.md +9 -0
  64. package/skills/doctor/SKILL.md +3 -3
  65. package/skills/intent-creating/evals/evals.json +1 -1
  66. package/skills/intent-executing/SKILL.md +6 -0
  67. package/skills/tutorial/references/track-2-auto.md +1 -1
@@ -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