@zalom/plastic 2.0.0-alpha.9 → 2.0.1

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 (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  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 +6 -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/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  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 +2 -2
  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 +153 -30
  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 +48 -83
  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 +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -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 +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -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 +71 -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 +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -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 +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -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 +46 -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 +229 -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 +531 -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 +913 -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 +158 -8
  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 +525 -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 +25 -18
  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/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +177 -16
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +8 -3
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -296
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -176
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -37,7 +37,7 @@
37
37
  # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
38
38
  # (delivered) or `## Abandoned` (abandoned), dated today, appending
39
39
  # --index-note (if given). Accepts a real em dash OR a plain hyphen as
40
- # the id/title separator on READ (shared matcher, Bridge.index_entry_match);
40
+ # the id/title separator on READ (shared matcher, IndexEntry.match);
41
41
  # always EMITS the real em dash on write. An id that resolves to neither
42
42
  # `## Active` nor the terminal section is a loud failure (exit 1); an id
43
43
  # already correctly in the terminal section is a quiet, idempotent success.
@@ -46,10 +46,9 @@
46
46
  # 5. Disarm (intent 188, D2/D16; intent 307): check the code worktree (derived
47
47
  # from projects.yml and the intent id, Arm.worktree_block) for uncommitted
48
48
  # changes first (exit 5 if dirty, unless --discard-worktree-changes); call the
49
- # disarm seam (default Arm.disarm: worktree released, lock cleared, the session
50
- # pointer back on the day ledger); verify the durable lock file is actually gone
51
- # afterward, and release it directly when a foreign-keyed disarm left it (the
52
- # /tmp bridge this step once read was removed in 2.0, intent 307).
49
+ # disarm seam (default Arm.disarm: worktree released, lock cleared); verify
50
+ # the durable lock file is actually gone afterward, and release it directly
51
+ # when a foreign-keyed disarm left it.
53
52
  #
54
53
  # Exit codes:
55
54
  # 0 ok - the intent is closed and no delivery.lock remains.
@@ -57,7 +56,7 @@
57
56
  # nor the terminal section (D11). Deliberately double duty; see plan.md.
58
57
  # 2 retired in 2.0 (intent 308): the outcome.md guard reports and proceeds.
59
58
  # 3 steps 1-4 already committed, but the delivery lock genuinely could not
60
- # be cleared (not a bridge cache miss; see step 5 above).
59
+ # be cleared (see step 5 above).
61
60
  # 4 pre-flight refusal: a FRESH foreign lock is held by a live,
62
61
  # non-delegate session; a FRESH lock that will not parse (corrupt); or a
63
62
  # lock file with no resolvable session identity at all. Authors nothing.
@@ -69,18 +68,20 @@
69
68
  # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
70
69
  # exception to report-and-proceed; --allow-hollow-report overrides.
71
70
 
71
+ require_relative "lib/store_layout"
72
72
  require "fileutils"
73
73
  require "date"
74
74
  require "open3"
75
75
  require "pathname"
76
- require_relative "lib/bridge"
77
76
  require_relative "lib/arm"
77
+ require_relative "lib/index_entry"
78
78
  require_relative "lib/savepoint"
79
79
  require_relative "lib/lock"
80
80
  require_relative "lib/intent_validator"
81
81
  require_relative "lib/outcome_guard"
82
82
  require_relative "lib/report_screen"
83
83
  require_relative "lib/backfill_intent"
84
+ require_relative "lib/outcome_report"
84
85
 
85
86
  DISPOSITIONS = %w[delivered abandoned].freeze
86
87
 
@@ -88,7 +89,7 @@ DISPOSITIONS = %w[delivered abandoned].freeze
88
89
  # dash (U+2014), the existing INDEX.md convention (store files are exempt from
89
90
  # the no-em-dash shipped-file rule). Built from the codepoint, not a literal
90
91
  # byte in this source file, so the script's own source stays em-dash free.
91
- # READING accepts a plain hyphen too, via the shared Bridge.index_entry_match
92
+ # READING accepts a plain hyphen too, via the shared IndexEntry.match
92
93
  # (intent 188, D9/D12): see move_index_to_terminal below.
93
94
  EM_DASH = "\u2014"
94
95
 
@@ -98,6 +99,10 @@ class UnresolvedIndexEntry < StandardError; end
98
99
 
99
100
  # --- Explicit flag parsing (no eval, no global injection) -------------------
100
101
 
102
+ def blank?(value)
103
+ value.nil? || value.to_s.strip.empty?
104
+ end
105
+
101
106
  def parse_args(argv)
102
107
  opts = {
103
108
  store: nil, id: nil, disposition: nil, index: nil,
@@ -166,14 +171,65 @@ end
166
171
  # "projects" (the StoreDiscovery convention, scripts/lib/store_discovery.rb), never by
167
172
  # hardcoding ".plastic" as a name.
168
173
  def resolve_plastic_home_and_scope(store)
169
- parent = File.dirname(store)
170
- grandparent = File.dirname(parent)
171
- if File.basename(grandparent) == "projects"
172
- slug = File.basename(parent)
173
- [File.dirname(grandparent), "project:#{slug}"]
174
+ Plastic::StoreLayout.home_and_scope(store)
175
+ end
176
+
177
+ # --- outcome generation, before the backfill (intent 339, G6, spec D10) -----
178
+ #
179
+ # Generates outcome.md from graph.md, nodes/, and the ledger, BEFORE the
180
+ # backfill step runs, and only into a file that is missing or still the
181
+ # scaffold placeholder (`Savepoint.stage_file_present?` false) - a real,
182
+ # hand-written outcome.md is never touched (row 7.2). Only when a graph.md
183
+ # exists at all (row 7.3): every intent without one closes exactly as it did
184
+ # before this intent. The model is parsed AFTER the `stage_file_present?`
185
+ # guard, not before: a real hand-written outcome.md returns at that guard
186
+ # regardless of graph.md's own health, so a close that was never going to
187
+ # generate anything must never print a parse warning about it. Refuses a
188
+ # report model that reports itself broken (row v1f.2, B2): a malformed
189
+ # `## Graph` fails `model[:ok]` and the close falls straight through to the
190
+ # backfill, rather than adopting parse damage as delivery fact - agreeing
191
+ # with the shipped `outcome-report` CLI, which exits 3 on the same
192
+ # directory. The generated text is then checked against
193
+ # BOTH close gates - `OutcomeGuard` and the hollow-report gate below - before
194
+ # being adopted (row 7.4); when either would refuse it, the write is
195
+ # reverted and the close falls through to intent 308's unchanged backfill
196
+ # (row 7.5), so no close gains an exit 7 it did not have before this intent.
197
+ # Fails open on any exception (row 7.6), the same report-and-proceed shape
198
+ # every other close step in this file uses. `generator:` is the injectable
199
+ # seam (mirrors run_structure_check's `gate:`).
200
+ def run_generate_outcome(intent_dir, disposition:, generator: OutcomeReport.method(:write))
201
+ graph_path = File.join(intent_dir, "graph.md")
202
+ return unless File.exist?(graph_path)
203
+
204
+ outcome_path = File.join(intent_dir, "outcome.md")
205
+ return if Savepoint.stage_file_present?(outcome_path)
206
+
207
+ report_model = OutcomeReport.model(intent_dir)
208
+ unless report_model[:ok]
209
+ warn "end-intent: graph.md failed to parse (#{report_model[:errors].join('; ')}); falling back to the backfill"
210
+ return
211
+ end
212
+
213
+ original_exists = File.exist?(outcome_path)
214
+ original_content = original_exists ? File.read(outcome_path) : nil
215
+
216
+ generator.call(intent_dir, disposition: disposition)
217
+
218
+ guard_reason = OutcomeGuard.reason(intent_dir, disposition)
219
+ hollow = hollow_report_reason(intent_dir, disposition)
220
+ if guard_reason || hollow
221
+ if original_exists
222
+ File.write(outcome_path, original_content)
223
+ elsif File.exist?(outcome_path)
224
+ File.delete(outcome_path)
225
+ end
226
+ warn "end-intent: generated outcome.md would be refused (#{[guard_reason, hollow].compact.join('; ')}); " \
227
+ "falling back to the backfill"
174
228
  else
175
- [parent, "global"]
229
+ warn "end-intent: outcome.md generated from graph.md and the ledger"
176
230
  end
231
+ rescue StandardError => e
232
+ warn "end-intent: outcome generation crashed (#{e.message}); proceeding without it"
177
233
  end
178
234
 
179
235
  # Backfill (intent 308): write the judgment documents that are missing or still the
@@ -227,6 +283,48 @@ end
227
283
  # closed exactly this way and the owner read a screen full of "not recorded".
228
284
  # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
229
285
  # exempt: the reader cannot expect labels the writer never had.
286
+ #
287
+ # Intent 334 (G1, review A3; post-execution review, blocking 2): the legacy
288
+ # "S3" label grammar applies only to actions/*.md headings, and the node id
289
+ # grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
290
+ # headings - never both over both directories. This repo's own house words
291
+ # for plan review and post-execution review are "v1" and "v2"; widening the
292
+ # node-id alternative onto actions/ headings turned that prose into a
293
+ # manufactured label and refused legitimate legacy closes at exit 7.
294
+ ACTION_ID_LABEL_RE = /\AS\d+\z/.freeze
295
+ NODE_ID_LABEL_RE = /\A[nvdr][1-9]\d*\z/.freeze
296
+
297
+ # The id-shaped tokens found in `paths`' headings, matched against
298
+ # `id_label_re` only - the caller picks the grammar for the directory.
299
+ def heading_id_labels(paths, id_label_re)
300
+ paths.flat_map do |path|
301
+ ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
302
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).select { |tok| tok.match?(id_label_re) }
303
+ end
304
+ end
305
+ end
306
+
307
+ # 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
308
+ # but only when it stands alone as one of the heading's " - "-delimited
309
+ # segments - this repo's own convention, "### S1 - n1 - Title" (this action
310
+ # file's own headings, post-merge) or the bare "### n1 - Title". Never as a
311
+ # free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
312
+ # records exactly why intent 334 restricted the node-id grammar to
313
+ # nodes/*.md headings in the first place, and that reasoning does not change
314
+ # here - a word-scan widened onto actions/ headings would let this repo's own
315
+ # house words ("v1", "v2" for plan review and post-execution review)
316
+ # manufacture a label out of ordinary prose and refuse a legitimate legacy
317
+ # close. Segment position is what a word-scan cannot see: "v2" embedded
318
+ # mid-sentence is never its own " - "-delimited segment, but a heading built
319
+ # to this convention always puts the node id in one.
320
+ def heading_node_id_segments(paths, id_label_re)
321
+ paths.flat_map do |path|
322
+ ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
323
+ heading.to_s.sub(/\A#+\s*/, "").split(/\s+-\s+/).map(&:strip).select { |seg| seg.match?(id_label_re) }
324
+ end
325
+ end
326
+ end
327
+
230
328
  def hollow_report_reason(intent_dir, disposition)
231
329
  return nil unless disposition == "delivered"
232
330
 
@@ -235,14 +333,34 @@ def hollow_report_reason(intent_dir, disposition)
235
333
  text = File.read(outcome)
236
334
  return nil if text.include?("<!-- backfilled from the record by end-intent on ")
237
335
 
238
- # The gate's evidence bar (A7/B7): it judges only records that CLAIM the
239
- # modern convention - at least one actions/*.md heading carrying an S-label.
240
- # A terse legacy close with no labeled matrix stays report-and-proceed.
241
- heading_labels = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.flat_map do |path|
242
- ReportScreen.split_by_headings(File.read(path)).filter_map do |heading, _body|
243
- heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).find { |tok| tok.match?(/\AS\d+\z/) }
244
- end
245
- end.uniq
336
+ # The gate's evidence bar (A7/B7): it judges only records that CLAIM a
337
+ # labeled convention - at least one actions/*.md heading carrying an
338
+ # S-label or a node-id segment, or at least one nodes/*.md heading carrying
339
+ # a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
340
+ # matrix stays report-and-proceed (intent 334, G1, D10r).
341
+ #
342
+ # Every matching token per heading is kept, not just the first (review A3):
343
+ # a legacy actions/ heading can carry BOTH an S-label and prose that reads
344
+ # like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
345
+ # node-id grammar to nodes/*.md headings (post-execution review, blocking
346
+ # 2) means "v2" in that heading is never even tested against it, so the
347
+ # heading's real "S3" label is never at risk.
348
+ #
349
+ # 339 S9 (D18): a graph-era action file headed by node id alone ("### n1 -
350
+ # Title", no S-label) used to yield an empty label set here and the gate
351
+ # returned before judging anything - every such record closed unjudged by
352
+ # silent exemption. `heading_node_id_segments` widens the actions/*.md side
353
+ # to see that shape without reopening the word-scan hole above: it matches
354
+ # a node id only when the heading's own " - "-delimited segments carry one,
355
+ # never a free word in prose. The node_paths call stays exactly as it was;
356
+ # nodes/*.md headings already resolve node ids correctly (test_node_id_
357
+ # labels_are_recognized) and their heading grammar is controlled, so a
358
+ # word-scan there carries none of the prose risk.
359
+ action_paths = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort
360
+ node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
361
+ heading_labels = (heading_id_labels(action_paths, ACTION_ID_LABEL_RE) +
362
+ heading_node_id_segments(action_paths, NODE_ID_LABEL_RE) +
363
+ heading_id_labels(node_paths, NODE_ID_LABEL_RE)).uniq
246
364
  return nil if heading_labels.empty?
247
365
 
248
366
  rows = ReportScreen.delivered_rows(intent_dir)
@@ -317,10 +435,10 @@ def section_stop(lines, heading_idx)
317
435
  stop
318
436
  end
319
437
 
320
- # Shared matcher (Bridge.index_entry_match, intent 188 D12): accepts a real em
438
+ # Shared matcher (IndexEntry.match, intent 188 D12): accepts a real em
321
439
  # dash OR a plain hyphen as the id/title separator on READ.
322
440
  def active_line_id(line)
323
- m = Bridge.index_entry_match(line)
441
+ m = IndexEntry.match(line)
324
442
  m && m[1]
325
443
  end
326
444
 
@@ -399,7 +517,7 @@ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
399
517
  raise UnresolvedIndexEntry, "INDEX.md has no #{target_heading} heading; cannot complete the move"
400
518
  end
401
519
 
402
- m = Bridge.index_entry_match(lines[entry_idx])
520
+ m = IndexEntry.match(lines[entry_idx])
403
521
  title, link = m[2], m[3]
404
522
  note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
405
523
  new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
@@ -488,13 +606,13 @@ end
488
606
  # exclusion with a raw backtrace. nil is the only safe "could not resolve"
489
607
  # signal; main refuses explicitly on it rather than ever reaching takeover.
490
608
  def resolve_end_session(explicit, intent_dir)
491
- return explicit.to_s.strip unless Bridge.blank?(explicit)
609
+ return explicit.to_s.strip unless blank?(explicit)
492
610
  env = ENV["CLAUDE_CODE_SESSION_ID"]
493
- return env.to_s.strip unless Bridge.blank?(env)
611
+ return env.to_s.strip unless blank?(env)
494
612
  lock = Lock.read(intent_dir)
495
613
  return nil unless lock
496
614
  owner = lock["owner_session"].to_s
497
- Bridge.blank?(owner) ? nil : owner
615
+ blank?(owner) ? nil : owner
498
616
  end
499
617
 
500
618
  # Read-only pre-flight verdict (D4). Returns [:proceed, lock_or_nil],
@@ -542,7 +660,7 @@ end
542
660
  def run_disarm(intent_dir, id, session, discard_worktree_changes:,
543
661
  worktree_reader: ->(dir) { Arm.worktree_block(intent_dir: dir) },
544
662
  disarm: ->(sess, dir) { Arm.disarm(intent_dir: dir, session: sess) })
545
- return :ok if Bridge.blank?(session)
663
+ return :ok if blank?(session)
546
664
 
547
665
  block = worktree_reader.call(intent_dir)
548
666
  worktree_code = block.is_a?(Hash) ? block["code"] : nil
@@ -667,7 +785,7 @@ def main(argv)
667
785
  # Lock.takeover (blank session reaches Lock.acquire's blank? guard AFTER the
668
786
  # existing lock file was already deleted) or silently mis-arbitrate. Refuse
669
787
  # rather than guess; nothing has been written yet.
670
- if Bridge.blank?(session) && File.exist?(Lock.path(intent_dir))
788
+ if blank?(session) && File.exist?(Lock.path(intent_dir))
671
789
  warn "end-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
672
790
  "identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's " \
673
791
  "own recorded owner are all blank); refusing rather than guessing. Pass " \
@@ -718,6 +836,11 @@ def main(argv)
718
836
  end
719
837
  end
720
838
 
839
+ # 0a. Generate outcome.md from graph.md and the ledger, when there is one
840
+ # (intent 339, G6, spec D10) - before the backfill, so a real graph never
841
+ # falls through to the terse backfill it exists to replace.
842
+ run_generate_outcome(intent_dir, disposition: disposition)
843
+
721
844
  # 1. Backfill from the record (intent 308), then the structure self-check and the
722
845
  # outcome guard, both report-only since 308.
723
846
  run_backfill(intent_dir, store: store, id: id, disposition: disposition,
@@ -21,15 +21,15 @@
21
21
  # (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
22
22
  # recorded owner_session when non-blank, else nil; the lock-owner fallback IS
23
23
  # used here, matching end-intent's teardown resolution order).
24
- # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block;
25
- # the /tmp bridge this once read was removed in 2.0, intent 307). No code worktree on
24
+ # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block).
25
+ # No code worktree on
26
26
  # disk means nothing was provisioned (exit 0).
27
27
  # 1. The dirty-worktree guard (delivered only): git status --porcelain on the code
28
28
  # worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
29
29
  # end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
30
30
  # branch survives and can be reclaimed, so a dirty worktree is not a reason to
31
31
  # refuse.
32
- # 2. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
32
+ # 2. Finish: Worktree.finish(delivery, home:, runner:, merge: disposition ==
33
33
  # "delivered"). Worktree.finish is fail-open and never raises, so failure is
34
34
  # detected by checking, for a delivered disposition only, whether the worktree
35
35
  # directory still exists on disk afterward (release removes it on success).
@@ -38,8 +38,8 @@
38
38
  # one concern per script (spec D8).
39
39
  #
40
40
  # Exit codes:
41
- # 0 finished (merged or removed per disposition), or nothing to finish (no bridge
42
- # resolved, or no code worktree recorded on the bridge)
41
+ # 0 finished (merged or removed per disposition), or nothing to finish (no delivery
42
+ # resolved, or no code worktree recorded on it)
43
43
  # 1 usage or path-resolution failure
44
44
  # 2 retired in 2.0 (intent 302): was the order precondition; never emitted, never reused
45
45
  # 3 the worktree is not clean, or the merge/removal did not complete (delivered only)
@@ -14,6 +14,7 @@
14
14
  # and on any filing error (reported on stderr; the next boot tries again), 2 on
15
15
  # a usage error.
16
16
 
17
+ require_relative "lib/store_layout"
17
18
  require_relative "lib/session_ledger"
18
19
  require_relative "lib/session_backfill"
19
20
 
@@ -48,7 +49,7 @@ if opts[:carry_to] && !SessionLedger.valid_day_id?(opts[:carry_to])
48
49
  usage_abort("--carry-to must be a real calendar date, YYYYMMDD")
49
50
  end
50
51
 
51
- store = opts[:store] || File.join(File.expand_path(ENV.fetch("PLASTIC_HOME", "~/.plastic")), "store")
52
+ store = opts[:store] || Plastic::StoreLayout.global_store(File.expand_path(ENV.fetch("PLASTIC_HOME", "~/.plastic")))
52
53
  templates = opts[:templates] || File.expand_path("../templates", __dir__)
53
54
 
54
55
  begin
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require "json"
6
+ require "yaml"
7
+ require "date"
8
+ require_relative "lib/savepoint"
9
+
10
+ # graph-measure - the one read-only executable over the node ledger (intent
11
+ # 343, G10, n2). A subcommand table over one library, following
12
+ # scripts/runner's shape (327 spec): the public verbs are named up front even
13
+ # before every module lands, a flag outside a verb's own vocabulary is
14
+ # refused by name, and each verb's module is required LAZILY inside its own
15
+ # branch so a verb whose module has not landed yet reports plainly instead of
16
+ # taking `intent`, the verb an operator runs constantly, down with it.
17
+ #
18
+ # Usage:
19
+ # graph-measure <intent|budget|cohorts> <dir> [--format text|json]
20
+ #
21
+ # `intent` (n2) and `budget` (n4) take an INTENT directory. `cohorts` (n5,
22
+ # its model section; n6 adds the rates, the latency and the bar to the same
23
+ # verb) takes a STORE directory instead - the directory holding `id--slug/`
24
+ # intent folders for one project - and is validated against that shape, not
25
+ # `intent_directory?`.
26
+ #
27
+ # Exit codes:
28
+ # 0 - a report rendered (anomalies never change this: a ledger with torn
29
+ # lines still renders, lists them, and exits 0)
30
+ # 1 - the directory has no savepoint.md to measure at all, named
31
+ # 2 - usage: no args, an unknown subcommand, an unknown flag, or the path
32
+ # is not the right shape for its verb (an intent directory for
33
+ # `intent`/`budget`, a store directory for `cohorts`)
34
+ # 3 - the verb's module has not been delivered yet
35
+ module GraphMeasureCLI
36
+ module_function
37
+
38
+ VERBS = %w[intent budget cohorts].freeze
39
+ SAVEPOINT_FILE = "savepoint.md"
40
+
41
+ # verb -> [[ModuleName, lib_file], ...], required lazily inside the verb's
42
+ # own branch (row 2.6). "budget" and "cohorts" name libraries n4 and n5
43
+ # ship; requiring them today raises LoadError, caught below.
44
+ LAZY_MODULES = {
45
+ "intent" => [["GraphMeasure", "graph_measure"], ["GraphMeasureReport", "graph_measure_report"]],
46
+ "budget" => [["GraphMeasureBudget", "graph_measure_budget"]],
47
+ "cohorts" => [["GraphMeasureModels", "graph_measure_models"], ["GraphMeasureCohorts", "graph_measure_cohorts"]],
48
+ }.freeze
49
+
50
+ KNOWN_FLAGS = {
51
+ "intent" => %w[--format],
52
+ "budget" => %w[--format],
53
+ "cohorts" => %w[--format],
54
+ }.freeze
55
+
56
+ # "intent" and "budget" take one intent directory; "cohorts" takes the
57
+ # store directory that holds many of them (row 5.15).
58
+ DIR_KIND = { "intent" => :intent, "budget" => :intent, "cohorts" => :store }.freeze
59
+
60
+ def usage
61
+ warn "Usage: graph-measure <#{VERBS.join('|')}> <dir> [--format text|json]"
62
+ end
63
+
64
+ def intent_directory?(dir)
65
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
66
+ end
67
+
68
+ def store_directory?(dir)
69
+ dir && File.directory?(dir)
70
+ end
71
+
72
+ def valid_dir?(verb, dir)
73
+ DIR_KIND.fetch(verb, :intent) == :store ? store_directory?(dir) : intent_directory?(dir)
74
+ end
75
+
76
+ def opt(args, name)
77
+ (i = args.index(name)) && args[i + 1]
78
+ end
79
+
80
+ def unrecognized_flag(verb, args)
81
+ allowed = KNOWN_FLAGS.fetch(verb, [])
82
+ args.find { |a| a.is_a?(String) && a.start_with?("--") && !allowed.include?(a) }
83
+ end
84
+
85
+ def main(argv)
86
+ args = argv.dup
87
+ verb = args.shift
88
+
89
+ unless verb
90
+ usage
91
+ return 2
92
+ end
93
+
94
+ unless VERBS.include?(verb)
95
+ warn "graph-measure: unknown subcommand #{verb.inspect}"
96
+ usage
97
+ return 2
98
+ end
99
+
100
+ dir_arg = args.shift
101
+ unless valid_dir?(verb, dir_arg && File.expand_path(dir_arg))
102
+ label = DIR_KIND.fetch(verb, :intent) == :store ? "not a store directory" : "not an intent directory"
103
+ warn "graph-measure: #{dir_arg.inspect} is #{label}"
104
+ usage
105
+ return 2
106
+ end
107
+ dir = File.expand_path(dir_arg)
108
+
109
+ bad_flag = unrecognized_flag(verb, args)
110
+ if bad_flag
111
+ warn "graph-measure: unknown flag #{bad_flag.inspect} for #{verb}"
112
+ usage
113
+ return 2
114
+ end
115
+
116
+ format = opt(args, "--format") || "text"
117
+ unless %w[text json].include?(format)
118
+ warn "graph-measure: unknown --format #{format.inspect} (use text or json)"
119
+ usage
120
+ return 2
121
+ end
122
+
123
+ run_lazy(verb, dir, format)
124
+ rescue StandardError => e
125
+ # Row 2.22: a malformed graph.md, a torn ledger, or invalid UTF-8 never
126
+ # raises out of GraphMeasure or GraphMeasureReport (both are built to
127
+ # resolve broken data to an anomaly, never an exception) - this is
128
+ # defence in depth so a defect in either never reaches the owner as a
129
+ # bare Ruby backtrace.
130
+ warn "graph-measure: internal error: #{e.message}"
131
+ 1
132
+ end
133
+
134
+ def run_lazy(verb, dir, format)
135
+ LAZY_MODULES.fetch(verb, []).each { |(_name, file)| require_relative(File.join("lib", file)) }
136
+ rescue LoadError
137
+ warn "graph-measure: #{verb} is not yet delivered (its module has not landed)"
138
+ 3
139
+ else
140
+ dispatch_lazy(verb, dir, format)
141
+ end
142
+
143
+ def dispatch_lazy(verb, dir, format)
144
+ case verb
145
+ when "intent"
146
+ run_intent(dir, format)
147
+ when "budget"
148
+ run_budget(dir, format)
149
+ when "cohorts"
150
+ run_cohorts(dir, format)
151
+ else
152
+ warn "graph-measure: #{verb}'s module loaded but no dispatcher is wired up yet"
153
+ 3
154
+ end
155
+ end
156
+
157
+ # M1: the boundary's own config resolution, never GraphMeasureModels' or
158
+ # GraphMeasureCohorts' (spec D2, both modules' own DI convention). Mirrors
159
+ # scripts/doctor.rb's own `PLASTIC_HOME` fallback and `load_yaml_safe`
160
+ # shape exactly, so a missing or malformed config.yml/project.yml resolves
161
+ # to `{}` (the shipped-default chain), never an exception.
162
+ def plastic_home
163
+ ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
164
+ end
165
+
166
+ def load_yaml_safe(path)
167
+ return {} unless path && File.exist?(path)
168
+
169
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time]) || {}
170
+ rescue StandardError
171
+ {}
172
+ end
173
+
174
+ def missing_savepoint(dir)
175
+ savepoint_path = File.join(dir, SAVEPOINT_FILE)
176
+ return nil if File.exist?(savepoint_path)
177
+
178
+ warn "graph-measure: #{savepoint_path} does not exist, nothing to measure"
179
+ savepoint_path
180
+ end
181
+
182
+ def run_intent(dir, format)
183
+ # Row 2.5: checked directly, ahead of GraphMeasure.read, which resolves a
184
+ # missing file to an empty (not absent) record by design (D3) - the CLI
185
+ # boundary is the only place that can still tell "nothing to measure"
186
+ # apart from "measured, and it is empty".
187
+ return 1 if missing_savepoint(dir)
188
+
189
+ record = GraphMeasure.read(dir)
190
+ puts format == "json" ? GraphMeasureReport.render_json(record) : GraphMeasureReport.render_text(record)
191
+ 0
192
+ end
193
+
194
+ def run_budget(dir, format)
195
+ return 1 if missing_savepoint(dir)
196
+
197
+ record = GraphMeasureBudget.read(dir)
198
+ puts format == "json" ? GraphMeasureBudget.render_json(record) : GraphMeasureBudget.render_text(record)
199
+ 0
200
+ end
201
+
202
+ # Row 2.5's "missing savepoint.md" check does not apply here: `dir` is a
203
+ # STORE directory (spec D1's "the directory holding id--slug/ intent
204
+ # folders"), which never carries a savepoint.md of its own.
205
+ #
206
+ # n6 adds the rates, the latency and the bar to this same verb, sourced
207
+ # from `GraphMeasureCohorts`. Its bar section reuses the one
208
+ # `GraphMeasureModels` walk's `qualified` counts (passed in here) rather
209
+ # than triggering a second store walk for the same numbers.
210
+ #
211
+ # M1 (v1 review): this call used to pass neither `project_config:` nor
212
+ # `global_config:`, so the model comparison always resolved against
213
+ # RunnerPolicy's shipped defaults only, never the real project-then-
214
+ # global-then-default chain the CHANGELOG describes and
215
+ # `scripts/doctor.rb`'s own `model_requalification_checks` actually reads.
216
+ # Reproduced: with a `plastic-executor:` override present in global
217
+ # config, `GraphMeasureModels.read` reports real drift rows while this
218
+ # verb (unpatched) always reported zero. `GraphMeasure`'s own modules stay
219
+ # config-free by design (D2's DI convention: they never read disk or ENV
220
+ # themselves) - this executable is the boundary that is allowed to, the
221
+ # same role `scripts/doctor.rb` already plays for the same two config
222
+ # files.
223
+ #
224
+ # NEW-4 (v2 review, D22): the project-scope half named `<store>/../project.yml`,
225
+ # a file whose template carries no `agents:` key at all, so a real
226
+ # operator override at the file that DOES exist in the wild - the store's
227
+ # sibling `config.yml` - was silently ignored. The path now comes from
228
+ # `GraphMeasureModels.project_config_path`, the one function
229
+ # `scripts/doctor.rb`'s own rule calls too, so the two commands cannot
230
+ # resolve a different file for the same store.
231
+ def run_cohorts(dir, format)
232
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml"))
233
+ project_config = load_yaml_safe(GraphMeasureModels.project_config_path(dir))
234
+
235
+ models_record = GraphMeasureModels.read(dir, project_config: project_config, global_config: global_config)
236
+ cohorts_record = GraphMeasureCohorts.read(dir, qualified: models_record[:qualified],
237
+ project_config: project_config, global_config: global_config)
238
+ if format == "json"
239
+ combined = GraphMeasureModels.model(models_record).merge(GraphMeasureCohorts.model(cohorts_record))
240
+ puts JSON.generate(combined)
241
+ else
242
+ puts GraphMeasureModels.render_text(models_record)
243
+ puts GraphMeasureCohorts.render_text(cohorts_record)
244
+ end
245
+ 0
246
+ end
247
+ end
248
+
249
+ exit GraphMeasureCLI.main(ARGV) if $PROGRAM_NAME == __FILE__