@zalom/plastic 2.0.0-alpha.8 → 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 +23 -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 +62 -1
  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 +238 -22
  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 +19 -5
  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 +30 -16
  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 +532 -71
  152. package/scripts/lib/intent_screen.rb +9 -6
  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 +377 -173
  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 +1460 -74
  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 +555 -0
  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 +192 -30
  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 +15 -2
  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 -295
  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 -173
  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
@@ -0,0 +1,185 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "yaml"
6
+ require "time"
7
+ require "fileutils"
8
+ require "rbconfig"
9
+ require_relative "atomic_write"
10
+
11
+ # MeterWatch (intent 355, n5, D6): reads the owner's rate-limit cache on a
12
+ # timer and writes one state file a session watches, instead of every session
13
+ # parsing the cache and re-deriving the thresholds for itself. No model call
14
+ # is spent on a tick that leaves the state unchanged.
15
+ #
16
+ # Everything is injected: home (holds config.yml and .cache/), the clock, the
17
+ # cache path, and the renamer AtomicWrite uses. Nothing reads ENV, nothing
18
+ # calls launchctl, and --install-timer (scripts/meter-watch) only ever writes
19
+ # under the injected home.
20
+ class MeterWatch
21
+ DEFAULT_REDUCE_AT = 55
22
+ DEFAULT_STOP_AT = 85
23
+ DEFAULT_WEEKLY_STOP_AT = 97
24
+ TICK_SECONDS = 20 * 60
25
+ STALE_AFTER_SECONDS = TICK_SECONDS * 2
26
+
27
+ def initialize(home:, cache_path: nil, config_path: nil, now: Time.now, renamer: File.method(:rename))
28
+ @home = home
29
+ @cache_path = cache_path || File.join(home, ".cache", "rate-limits.json")
30
+ @config_path = config_path || File.join(home, "config.yml")
31
+ @state_path = File.join(home, ".cache", "meter-state.json")
32
+ @now = now
33
+ @renamer = renamer
34
+ @reduce_at, @stop_at, @weekly_stop_at = load_thresholds
35
+ end
36
+
37
+ attr_reader :state_path
38
+
39
+ def tick
40
+ previous = read_state
41
+ state = compute_state(previous)
42
+ write_if_changed(previous, state)
43
+ end
44
+
45
+ private
46
+
47
+ def compute_state(previous)
48
+ return base_state("unavailable") unless File.file?(@cache_path)
49
+ return stopped?(previous) ? previous : base_state("stale") if stale?
50
+
51
+ cache = JSON.parse(File.read(@cache_path))
52
+ five_hour = cache["five_hour"]
53
+ seven_day = cache["seven_day"]
54
+ resets_at = cache["resets_at"]
55
+
56
+ # Resume compares `now` against the STOP's OWN resets_at (carried
57
+ # forward on `previous`, from the tick that first wrote "stop"), never
58
+ # the cache's current resets_at: the cache moves resets_at on to the
59
+ # NEXT window before five_hour/seven_day themselves drop, so comparing
60
+ # against the live value would never report resume (B4).
61
+ label = if stopped?(previous) && reset_passed?(previous["resets_at"])
62
+ "resume"
63
+ else
64
+ classify(five_hour, seven_day)
65
+ end
66
+
67
+ base_state(label, five_hour: five_hour, seven_day: seven_day, resets_at: resets_at)
68
+ rescue JSON::ParserError
69
+ base_state("unavailable")
70
+ end
71
+
72
+ def stopped?(previous)
73
+ previous && previous["state"] == "stop"
74
+ end
75
+
76
+ def classify(five_hour, seven_day)
77
+ return "stop" if five_hour.to_f >= @stop_at
78
+ return "stop" if seven_day.to_f >= @weekly_stop_at
79
+ return "reduce" if five_hour.to_f >= @reduce_at
80
+
81
+ "ok"
82
+ end
83
+
84
+ def stale?
85
+ File.mtime(@cache_path) < (@now - STALE_AFTER_SECONDS)
86
+ rescue Errno::ENOENT
87
+ true
88
+ end
89
+
90
+ def reset_passed?(resets_at)
91
+ at = parse_time(resets_at)
92
+ at && @now >= at
93
+ end
94
+
95
+ def parse_time(value)
96
+ return nil if value.nil? || value.to_s.empty?
97
+
98
+ text = value.to_s
99
+ text.match?(/\A\d+\z/) ? Time.at(text.to_i).utc : Time.iso8601(text)
100
+ rescue ArgumentError, TypeError
101
+ nil
102
+ end
103
+
104
+ def base_state(label, five_hour: nil, seven_day: nil, resets_at: nil)
105
+ {
106
+ "state" => label,
107
+ "five_hour" => five_hour,
108
+ "seven_day" => seven_day,
109
+ "resets_at" => resets_at,
110
+ "checked_at" => @now.getutc.iso8601,
111
+ }
112
+ end
113
+
114
+ def write_if_changed(previous, state)
115
+ return state if previous && previous["state"] == state["state"]
116
+
117
+ FileUtils.mkdir_p(File.dirname(@state_path))
118
+ AtomicWrite.write(@state_path, JSON.generate(state), renamer: @renamer)
119
+ state
120
+ end
121
+
122
+ def read_state
123
+ return nil unless File.file?(@state_path)
124
+
125
+ JSON.parse(File.read(@state_path))
126
+ rescue JSON::ParserError
127
+ nil
128
+ end
129
+
130
+ def load_thresholds
131
+ config = File.file?(@config_path) ? (YAML.safe_load(File.read(@config_path)) || {}) : {}
132
+ meter = config["meter"].is_a?(Hash) ? config["meter"] : {}
133
+ [
134
+ meter.fetch("reduce_at", DEFAULT_REDUCE_AT),
135
+ meter.fetch("stop_at", DEFAULT_STOP_AT),
136
+ meter.fetch("weekly_stop_at", DEFAULT_WEEKLY_STOP_AT),
137
+ ]
138
+ end
139
+
140
+ class << self
141
+ # Writes the LaunchAgent plist under an injectable home; never loads it
142
+ # with launchctl (scripts/meter-watch --install-timer calls this and
143
+ # nothing else). RunAtLoad primes the first tick; StartInterval repeats
144
+ # it every 20 minutes.
145
+ #
146
+ # `label:` and `arguments:` (intent 340a, G7b, n3, graph.md D9) let the
147
+ # delivery watch's own `runner watch --install-timer` reuse this one
148
+ # writer for its `com.plastic.delivery-watch.<id>` job instead of
149
+ # rendering plist XML a second time; their defaults reproduce today's
150
+ # meter-watch plist byte for byte (row 3.1).
151
+ def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS,
152
+ label: "com.plastic.meter-watch", arguments: nil)
153
+ agents_dir = File.join(home, "Library", "LaunchAgents")
154
+ FileUtils.mkdir_p(agents_dir)
155
+ plist_path = File.join(agents_dir, "#{label}.plist")
156
+ args = arguments || [ruby, script_path, "--home", home]
157
+ File.write(plist_path, plist(label, args, interval))
158
+ plist_path
159
+ end
160
+
161
+ private
162
+
163
+ def plist(label, arguments, interval)
164
+ args_xml = arguments.map { |a| " <string>#{a}</string>" }.join("\n")
165
+ <<~XML
166
+ <?xml version="1.0" encoding="UTF-8"?>
167
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
168
+ <plist version="1.0">
169
+ <dict>
170
+ <key>Label</key>
171
+ <string>#{label}</string>
172
+ <key>ProgramArguments</key>
173
+ <array>
174
+ #{args_xml}
175
+ </array>
176
+ <key>StartInterval</key>
177
+ <integer>#{interval}</integer>
178
+ <key>RunAtLoad</key>
179
+ <true/>
180
+ </dict>
181
+ </plist>
182
+ XML
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,234 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "date"
6
+
7
+ # NodeFile (intent 334, n3): one node file's YAML envelope (`node`, `kind`,
8
+ # `files`, `budget`, and the optional research `report`) over a Markdown body,
9
+ # plus the deterministic id minter
10
+ # (327 D1r, D5r, D9r-D12r, D16r). The kind-prefix rule lives here, not in
11
+ # GraphEdges, which stays loose about id grammar so a numeric roadmap id
12
+ # parses the same way (D12r).
13
+ #
14
+ # Pure and side-effect-free: parse reads one file and returns a Result hash,
15
+ # never raising across the boundary; mint_id takes the ids already present
16
+ # and returns one past the highest of them. Reserving an id against a
17
+ # concurrent minter is G7's problem, not a claim this module makes (D16r);
18
+ # gathering the ids an intent has ever seen is NodeIds' job (335a), which is
19
+ # why this module still requires nothing but yaml and date.
20
+ module NodeFile
21
+ module_function
22
+
23
+ KIND_PREFIX = { "work" => "n", "verify" => "v", "decision" => "d", "research" => "r" }.freeze
24
+ VALID_KINDS = KIND_PREFIX.keys.freeze
25
+ FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
26
+
27
+ # {ok:, node:, kind:, files:, budget:, body:, errors:}. `body` is the raw
28
+ # text after the frontmatter block, present even when `ok` is false and the
29
+ # body itself parsed fine, so a caller can still inspect sections on an
30
+ # envelope-invalid file. Every failure mode returns errors rather than
31
+ # raising: a missing file, absent or non-mapping frontmatter, or YAML that
32
+ # will not parse.
33
+ def parse(path)
34
+ return failure(["node file not found: #{path}"]) unless File.exist?(path)
35
+
36
+ content = File.read(path)
37
+ return failure(["missing YAML frontmatter"]) unless content.start_with?("---")
38
+
39
+ parts = content.split("---", 3)
40
+ return failure(["missing YAML frontmatter"]) if parts.length < 3
41
+
42
+ fm = begin
43
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time])
44
+ rescue StandardError => e
45
+ return failure(["frontmatter is not valid YAML: #{e.message}"])
46
+ end
47
+ fm = {} if fm.nil?
48
+ return failure(["frontmatter must be a mapping, got #{fm.class}"]) unless fm.is_a?(Hash)
49
+
50
+ body = parts[2].to_s
51
+ errors = []
52
+
53
+ kind = fm["kind"].to_s
54
+ unless VALID_KINDS.include?(kind)
55
+ errors << "unknown kind: #{fm["kind"].inspect} (must be one of #{VALID_KINDS.join(', ')})"
56
+ end
57
+
58
+ id = fm["node"].to_s
59
+ errors << "missing node id (node:)" if id.empty?
60
+
61
+ if !id.empty?
62
+ basename = File.basename(path, ".md")
63
+ unless filename_matches_id?(basename, id)
64
+ errors << "node id #{id.inspect} does not match filename #{File.basename(path).inspect}"
65
+ end
66
+ end
67
+
68
+ if !id.empty? && VALID_KINDS.include?(kind)
69
+ expected_prefix = KIND_PREFIX[kind]
70
+ unless id.match?(/\A#{Regexp.escape(expected_prefix)}[1-9][0-9]*\z/)
71
+ errors << "node id #{id.inspect} does not match kind #{kind.inspect}'s prefix #{expected_prefix.inspect}"
72
+ end
73
+ end
74
+
75
+ errors << "depends_on: is not a valid envelope field (removed by 327 D41; edges belong in ## Graph)" if fm.key?("depends_on")
76
+ errors << "turns: is not a valid envelope field (removed by 327 D47)" if fm.key?("turns")
77
+
78
+ files = fm["files"]
79
+ if files.nil?
80
+ errors << "missing files:"
81
+ elsif !files.is_a?(Array)
82
+ errors << "files: must be a list, got #{files.class}"
83
+ end
84
+
85
+ budget, budget_errors = normalize_budget(fm["budget"])
86
+ errors.concat(budget_errors)
87
+ report, report_errors = normalize_report(fm["report"], kind)
88
+ errors.concat(report_errors)
89
+
90
+ {
91
+ ok: errors.empty?,
92
+ node: id.empty? ? nil : id,
93
+ kind: kind.empty? ? nil : kind,
94
+ files: files.is_a?(Array) ? files : nil,
95
+ budget: budget,
96
+ report: report,
97
+ body: body,
98
+ errors: errors,
99
+ }
100
+ end
101
+
102
+ def failure(errors)
103
+ { ok: false, node: nil, kind: nil, files: nil, budget: nil, report: nil, body: nil, errors: errors }
104
+ end
105
+
106
+ def normalize_report(raw, kind)
107
+ return [nil, []] if raw.nil?
108
+ return [nil, ["report: is allowed only on research nodes"]] unless kind == "research"
109
+ return [nil, ["report: must be a non-empty relative Markdown path under resources/"]] unless raw.is_a?(String)
110
+
111
+ path = raw
112
+ parts = path.split("/", -1)
113
+ valid = path == path.strip && parts.length >= 2 && parts.first == "resources" &&
114
+ parts.none? { |part| part.empty? || %w[. ..].include?(part) } &&
115
+ !path.include?("\\") && path.end_with?(".md")
116
+ return [nil, ["report: must be a relative .md path under resources/ with no traversal"]] unless valid
117
+
118
+ [path, []]
119
+ end
120
+ private_class_method :normalize_report
121
+
122
+ # <id>.md or <id>--<slug>.md, exactly - a longer id's file (n11--x.md) must
123
+ # never satisfy a shorter id (n1), so the id is matched as the whole prefix
124
+ # up to end-of-string or the literal "--" separator, never as a substring
125
+ # (review A9).
126
+ def filename_matches_id?(basename, id)
127
+ basename.match?(/\A#{Regexp.escape(id)}(--[A-Za-z0-9][A-Za-z0-9-]*)?\z/)
128
+ end
129
+
130
+ # budget: normalizes to a plain integer token ceiling. A bare integer and
131
+ # {tokens: N} both normalize to N; {turns: N} is the field 327 D47 removed
132
+ # and is a named error, never silently accepted (from report's example).
133
+ def normalize_budget(raw)
134
+ return [nil, []] if raw.nil?
135
+ return [raw, []] if raw.is_a?(Integer)
136
+
137
+ if raw.is_a?(Hash)
138
+ return [nil, ["budget: {turns:} was removed by 327 D47; use budget: <int> or {tokens: <int>}"]] if raw.key?("turns") || raw.key?(:turns)
139
+
140
+ tokens = raw["tokens"] || raw[:tokens]
141
+ return [tokens, []] if tokens.is_a?(Integer)
142
+
143
+ return [nil, ["budget: must normalize to an integer token count, got #{raw.inspect}"]]
144
+ end
145
+
146
+ [nil, ["budget: must be an integer or {tokens: <int>}, got #{raw.class}"]]
147
+ end
148
+
149
+ # Every [heading_line, body] pair in the body text, split on any heading
150
+ # line, fence-aware: a "#" line inside a fenced block never starts a new
151
+ # section (see: "a node passes on a section it does not have").
152
+ def split_by_headings(text)
153
+ sections = []
154
+ heading = nil
155
+ body = +""
156
+ each_fence_line(text) do |line, fenced|
157
+ if !fenced && line.start_with?("#")
158
+ sections << [heading, body] if heading
159
+ heading = line.strip
160
+ body = +""
161
+ else
162
+ body << line
163
+ end
164
+ end
165
+ sections << [heading, body] if heading
166
+ sections
167
+ end
168
+
169
+ # Markdown table data rows (header + separator stripped) in text, fence-aware.
170
+ def table_rows(text)
171
+ lines = []
172
+ each_fence_line(text) do |line, fenced|
173
+ next if fenced
174
+
175
+ stripped = line.strip
176
+ lines << stripped if stripped.start_with?("|")
177
+ end
178
+ sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
179
+ return [] unless sep_idx
180
+
181
+ lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
182
+ end
183
+
184
+ def each_fence_line(text)
185
+ return enum_for(:each_fence_line, text) unless block_given?
186
+
187
+ marker = nil
188
+ text.to_s.each_line do |line|
189
+ if marker
190
+ yield line, true
191
+ m = line.match(FENCE_LINE_RE)
192
+ next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
193
+ next unless line.sub(FENCE_LINE_RE, "").strip.empty?
194
+
195
+ marker = nil
196
+ else
197
+ m = line.match(FENCE_LINE_RE)
198
+ if m
199
+ marker = [m[1][0], m[1].length]
200
+ yield line, true
201
+ else
202
+ yield line, false
203
+ end
204
+ end
205
+ end
206
+ end
207
+
208
+ # One past the highest id ever seen for the kind: purely numeric, so ids
209
+ # never sort as strings ("n10" ranking above "n9"), and a gap in the
210
+ # sequence STAYS a gap.
211
+ #
212
+ # Node ids never recycle (335a, owner ruling 2026-09-09). Intent 335 keyed
213
+ # the work ledger in savepoint.md on node id, and deletion is not a
214
+ # transition, so nothing in the ledger says a node is gone. Reissuing a
215
+ # deleted node's id therefore hands the new node the dead one's whole
216
+ # history: its `done` line, its evidence, its holder, and the successors
217
+ # that line released. The failure is silent, which is the worst shape it
218
+ # could take, so the gap is a headstone rather than free space.
219
+ #
220
+ # `taken` may carry ids this kind's grammar rejects, because NodeIds
221
+ # over-reserves on purpose (335a D11); they are ignored without shifting
222
+ # the sequence.
223
+ def mint_id(kind, taken)
224
+ prefix = KIND_PREFIX[kind.to_s]
225
+ return nil unless prefix
226
+
227
+ used = taken.to_a.filter_map do |id|
228
+ m = id.to_s.match(/\A#{Regexp.escape(prefix)}([1-9][0-9]*)\z/)
229
+ m && m[1].to_i
230
+ end
231
+
232
+ "#{prefix}#{(used.max || 0) + 1}"
233
+ end
234
+ end
@@ -0,0 +1,99 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "node_file"
5
+ require_relative "graph_file"
6
+ require_relative "graph_edges"
7
+ require_relative "node_ledger"
8
+ # Savepoint::INTENT_SUBJECT is named directly below, so the require is direct
9
+ # too: test/savepoint_split_test.rb:157 refuses a transitive one.
10
+ require_relative "savepoint"
11
+
12
+ # NodeIds (intent 335a): every node id one intent has EVER seen, gathered from
13
+ # the three places an id can appear, so NodeFile.mint_id can mint one past the
14
+ # highest and never reissue a deleted node's id.
15
+ #
16
+ # Why this exists as its own module rather than inside NodeFile: NodeFile is
17
+ # 334's deliberately dependency-free envelope reader (yaml and date only), and
18
+ # gathering needs the graph parsers and the ledger. It is not inside
19
+ # WorkGraphValidator either, because that module reports errors and minting is
20
+ # not validation.
21
+ #
22
+ # The rule this serves (335a D1, owner ruling 2026-09-09): a removed node still
23
+ # reserves its id. Deletion is not a ledger transition, so a reissued id would
24
+ # silently inherit the dead node's transition history, evidence included.
25
+ module NodeIds
26
+ module_function
27
+
28
+ # Every id seen in `intent_dir`, sorted and deduplicated. Each of the three
29
+ # sources is guarded on its own (D5): an intent that has not started yet is
30
+ # the ordinary case, not an error.
31
+ #
32
+ # The result deliberately OVER-reserves (D11). An id that no kind's grammar
33
+ # matches costs nothing, because mint_id filters by prefix; a missed id costs
34
+ # a false history.
35
+ def taken(intent_dir)
36
+ return [] unless intent_dir && File.directory?(intent_dir)
37
+
38
+ (from_node_files(intent_dir) + from_graph(intent_dir) + from_ledger(intent_dir))
39
+ .reject { |id| id.nil? || id.empty? }
40
+ .uniq
41
+ .sort
42
+ end
43
+
44
+ # Both the filename's id AND the envelope's `node:` (D7). A file can disagree
45
+ # with its own name, and a file whose frontmatter will not parse yields no
46
+ # envelope id at all, which is exactly when the filename is the only thing
47
+ # standing between a corrupt node file and a reissued id.
48
+ #
49
+ # Plain glob (D8): a zero-byte or sentinel-marked node file still names a node
50
+ # whose id is taken. Savepoint.has_real_files_in?'s filter answers a different
51
+ # question (has this intent started) and is not reused here.
52
+ def from_node_files(intent_dir)
53
+ Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort.flat_map do |path|
54
+ [id_from_filename(path), NodeFile.parse(path)[:node]]
55
+ end
56
+ end
57
+
58
+ # The id a node filename declares: the basename up to the "--" separator, so
59
+ # both shapes 334 D11r ratifies reserve - the bare `n2.md` and the slugged
60
+ # `n1--graph-edges.md` (D6). Taking the whole basename would reserve nothing
61
+ # for every slugged file in the store, which is the dominant shape.
62
+ def id_from_filename(path)
63
+ File.basename(path, ".md").split("--", 2).first.to_s
64
+ end
65
+
66
+ # Declared plus targeted, from the `## Graph` section only (D9). The gatherer
67
+ # inherits GraphEdges' looseness: it is not fence-aware, so an id inside a
68
+ # fenced example is gathered. That over-reserves, which is the safe direction.
69
+ #
70
+ # `## Status` is NOT read (D10): it is a projection rendered from the ledger
71
+ # and rewritten wholesale, so every id it can carry the ledger already carries.
72
+ def from_graph(intent_dir)
73
+ path = File.join(intent_dir, "graph.md")
74
+ return [] unless File.exist?(path)
75
+
76
+ section = GraphFile.section_body(File.read(path).scrub, "## Graph")
77
+ return [] unless section
78
+
79
+ GraphEdges.parse(section)[:nodes].to_a
80
+ end
81
+
82
+ # Subjects only, read through NodeLedger.entries, NEVER a token scan (D4).
83
+ # A node id can sit inside an ordinary milestone's free text and does today:
84
+ # intent 335's own ledger carries "ACTION_1 v2" inside a Review line, and 23
85
+ # such tokens exist across the live store. NodeLedger.entries already owns the
86
+ # two-space split and already keeps a torn line's subject, so this reuses that
87
+ # seam rather than making a third copy of it.
88
+ #
89
+ # The literal `Intent` subject is dropped: it is an intent-scope line, not a
90
+ # node, and no kind's grammar would match it anyway.
91
+ def from_ledger(intent_dir)
92
+ path = File.join(intent_dir, "savepoint.md")
93
+ return [] unless File.exist?(path)
94
+
95
+ NodeLedger.entries(path)
96
+ .map { |entry| entry[:subject] }
97
+ .reject { |subject| subject == Savepoint::INTENT_SUBJECT }
98
+ end
99
+ end