@zalom/plastic 2.0.0-alpha.9 → 2.0.2

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