@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
@@ -0,0 +1,132 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ # DataBoundary (intent 338, G5, n1): the trust boundary a node input is
7
+ # built over. A data block carries text other agents and the owner wrote,
8
+ # and is opened by `<<<PLASTIC-DATA:<token> label="..." source="...">>>` and
9
+ # closed by `<<<END-PLASTIC-DATA:<token>>>`. The token is content-derived
10
+ # (spec D4), so the same payloads always produce the same token and the
11
+ # node input stays deterministic; the escaping rule (spec D5) runs on every
12
+ # payload independently of the token, so a payload carrying this node input's
13
+ # own closing marker still cannot close its block. Attribute values
14
+ # (`label`, `source`) are sanitized separately, by a whitelist (spec D5a),
15
+ # because they are not payload text and the payload escaping rule does not
16
+ # cover them.
17
+ #
18
+ # Pure and side-effect-free: no I/O, no clock, nothing raised across the
19
+ # boundary. `estimate_tokens` is the one arithmetic every budget in this
20
+ # delivery is spent in (spec D6): bytes over four, rounded.
21
+ module DataBoundary
22
+ module_function
23
+
24
+ MARKER_OPEN = "<<<PLASTIC-DATA:"
25
+ MARKER_CLOSE = "<<<END-PLASTIC-DATA:"
26
+ ATTR_MAX = 200
27
+
28
+ # The attribute whitelist (spec D5a): anything outside this class becomes
29
+ # `_`. A quote, an angle bracket, a backslash, a newline and a carriage
30
+ # return are therefore all impossible in a marker line.
31
+ ATTR_SAFE_CHARS_RE = %r{[^A-Za-z0-9 _.,:#/@+=-]}.freeze
32
+
33
+ OPEN_LINE_RE = /\A#{Regexp.escape(MARKER_OPEN)}([0-9a-f]+) label="([^"]*)" source="([^"]*)">>>\z/.freeze
34
+ CLOSE_LINE_RE = /\A#{Regexp.escape(MARKER_CLOSE)}([0-9a-f]+)>>>\z/.freeze
35
+
36
+ # The first twelve hex characters of the SHA-256 over the payloads joined
37
+ # by a newline (spec D4): fixed per node input, unguessable from any single
38
+ # record file, deterministic across two builds of the same payloads.
39
+ def boundary_token(payloads)
40
+ Digest::SHA256.hexdigest(Array(payloads).map(&:to_s).join("\n"))[0, 12]
41
+ end
42
+
43
+ # One-way marker escaping (spec D5), plus a UTF-8 scrub (matrix 1.10) so
44
+ # one invalid byte anywhere in a record never raises the whole node input
45
+ # build. Runs regardless of what token trails the marker text, because a
46
+ # payload copied from an earlier node input may carry ANY token, not only this
47
+ # one (matrix 1.2's concern applied to escaping rather than to the token
48
+ # itself).
49
+ def escape(payload)
50
+ text = payload.to_s.scrub
51
+ text = text.gsub(MARKER_OPEN) { "<<<\\PLASTIC-DATA:" }
52
+ text.gsub(MARKER_CLOSE) { "<<<\\END-PLASTIC-DATA:" }
53
+ end
54
+
55
+ # Whitelist-sanitize and truncate a marker attribute value (spec D5a).
56
+ # Every character outside the whitelist becomes `_`; the result is
57
+ # truncated to ATTR_MAX characters, so neither a hostile value nor a
58
+ # multi-kilobyte one can escape or bloat a marker line.
59
+ def attr_safe(value)
60
+ value.to_s.scrub.gsub(ATTR_SAFE_CHARS_RE, "_")[0, ATTR_MAX]
61
+ end
62
+
63
+ # One complete, closed data block ending in a newline. The payload is
64
+ # escaped and scrubbed first; both markers sit alone on their own line
65
+ # (matrix 1.4), even when the payload is empty (matrix 1.9).
66
+ def wrap(payload, label:, source:, token:)
67
+ body = escape(payload)
68
+ body += "\n" unless body.empty? || body.end_with?("\n")
69
+ open_line = "#{MARKER_OPEN}#{token} label=\"#{attr_safe(label)}\" source=\"#{attr_safe(source)}\">>>\n"
70
+ close_line = "#{MARKER_CLOSE}#{token}>>>\n"
71
+ "#{open_line}#{body}#{close_line}"
72
+ end
73
+
74
+ # [{label:, source:, payload:}], in order. Matches markers case-sensitively
75
+ # (matrix 1.16: no `/i`, ever) and closes a block only on a close marker
76
+ # whose token matches the block's own opening token, so a stray marker for
77
+ # a different token found mid-payload (which the escaping rule already
78
+ # makes unreachable from real payload text) is never mistaken for this
79
+ # block's close.
80
+ def unwrap(text)
81
+ lines = text.to_s.each_line.to_a
82
+ blocks = []
83
+ i = 0
84
+ while i < lines.length
85
+ m = lines[i].chomp("\n").match(OPEN_LINE_RE)
86
+ unless m
87
+ i += 1
88
+ next
89
+ end
90
+
91
+ token, label, source = m[1], m[2], m[3]
92
+ payload_lines = []
93
+ i += 1
94
+ while i < lines.length
95
+ cm = lines[i].chomp("\n").match(CLOSE_LINE_RE)
96
+ break if cm && cm[1] == token
97
+
98
+ payload_lines << lines[i]
99
+ i += 1
100
+ end
101
+ blocks << { label: label, source: source, payload: payload_lines.join }
102
+ i += 1
103
+ end
104
+ blocks
105
+ end
106
+
107
+ # Disarm any line that is, on its own, a complete open or close marker
108
+ # (intent 338 post-execution review, finding A2). Blocks 1 and 5 are
109
+ # instruction, raw-interpolated rather than wrapped, on the grounds that
110
+ # they are orchestrator- and project-record-authored; that trust does not
111
+ # extend to `release.verify` in project.yml, which the owner and other
112
+ # agents write. `escape` already disarms every occurrence of the marker
113
+ # substring wherever it falls, but a marker sitting mid-line (matrix 1.5's
114
+ # convention of quoting a matrix row verbatim inside a node file) is
115
+ # already harmless, because `unwrap` only recognizes a marker that is the
116
+ # WHOLE line. Rewriting a harmless mid-line quote would be needless churn
117
+ # on instruction text an owner reads, so only a line that fully matches
118
+ # `OPEN_LINE_RE` or `CLOSE_LINE_RE` gets `escape`'s backslash treatment.
119
+ def neutralize_marker_lines(text)
120
+ text.to_s.each_line.map do |line|
121
+ chomped = line.chomp("\n")
122
+ chomped.match?(OPEN_LINE_RE) || chomped.match?(CLOSE_LINE_RE) ? escape(line) : line
123
+ end.join
124
+ end
125
+
126
+ # (bytes / 4.0).round (spec D6): the estimate G10 measures against. Taken
127
+ # over bytesize, never characters, so multi-byte text is never
128
+ # under-counted.
129
+ def estimate_tokens(text)
130
+ (text.to_s.bytesize / 4.0).round
131
+ end
132
+ end
@@ -8,10 +8,12 @@
8
8
  # heartbeat. Never the raw ledger (296 D36). No environment reads; every
9
9
  # path is injected.
10
10
 
11
+ require_relative "store_layout"
11
12
  require "time"
12
13
  require_relative "session_ledger"
13
14
  require_relative "handoff"
14
15
  require_relative "lock"
16
+ require_relative "active_delivery"
15
17
  require_relative "report_screen"
16
18
 
17
19
  module DaySummary
@@ -43,7 +45,7 @@ module DaySummary
43
45
  open: open_items(store, day),
44
46
  done: last_done(store, day),
45
47
  live: live_intents(home, now: now),
46
- others: active_sessions(store, session, now: now, ttl: heartbeat_ttl),
48
+ others: active_sessions(store, session, now: now, ttl: heartbeat_ttl, home: home),
47
49
  }
48
50
  hidden = Hash.new(0)
49
51
  cap!(lists, hidden, :open, OPEN_CAP, keep: :newest)
@@ -81,10 +83,10 @@ module DaySummary
81
83
  # The global store plus every projects/<slug>/store, each with its INDEX
82
84
  # one level up, the way doctor enumerates them.
83
85
  def stores(home)
84
- list = [[File.join(home, "INDEX.md"), File.join(home, "store")]]
85
- projects_root = File.join(home, "projects")
86
+ list = [[File.join(Plastic::StoreLayout.global_root(home), "INDEX.md"), Plastic::StoreLayout.global_store(home)]]
87
+ projects_root = Plastic::StoreLayout.projects_root(home)
86
88
  if File.directory?(projects_root)
87
- Dir.children(projects_root).sort.each do |slug|
89
+ Plastic::StoreLayout.project_slugs(home).each do |slug|
88
90
  list << [File.join(projects_root, slug, "INDEX.md"), File.join(projects_root, slug, "store")]
89
91
  end
90
92
  end
@@ -142,7 +144,7 @@ module DaySummary
142
144
  "(no savepoint yet)"
143
145
  end
144
146
 
145
- def active_sessions(store, session, now:, ttl:)
147
+ def active_sessions(store, session, now:, ttl:, home: nil)
146
148
  tmp_root = SessionLedger.tmp_root(store)
147
149
  return [] unless File.directory?(tmp_root)
148
150
 
@@ -155,7 +157,7 @@ module DaySummary
155
157
  age = heartbeat_age(dir, now)
156
158
  next if age.nil? || age > ttl
157
159
 
158
- "- #{sid} (#{(age / 60).floor}m ago, on #{pointer_of(dir)})"
160
+ "- #{sid} (#{(age / 60).floor}m ago, on #{delivering_label(store, home, sid, now)})"
159
161
  end
160
162
  rescue SystemCallError
161
163
  []
@@ -178,14 +180,21 @@ module DaySummary
178
180
  nil
179
181
  end
180
182
 
181
- def pointer_of(dir)
182
- path = File.join(dir, "current")
183
- return "?" unless File.file?(path)
184
-
185
- value = File.read(path).strip
186
- value.empty? ? "?" : value[0, 80]
187
- rescue SystemCallError
188
- "?"
183
+ # The delivering intent's id, or "day ledger" (344 n2, D4-D7): the
184
+ # basename before "--" of ActiveDelivery.resolve_by_short_session's
185
+ # match, else the session records into today's day ledger.
186
+ def delivering_label(store, home, sid, now)
187
+ return "day ledger" if home.to_s.empty?
188
+
189
+ dir = ActiveDelivery.resolve_by_short_session(
190
+ global_store: store,
191
+ project_roots: ActiveDelivery.project_roots(home),
192
+ short_session: sid,
193
+ now: now
194
+ )
195
+ dir ? File.basename(dir).split("--", 2).first : "day ledger"
196
+ rescue StandardError
197
+ "day ledger"
189
198
  end
190
199
 
191
200
  # --- rendering, pure -------------------------------------------------------------
@@ -8,12 +8,13 @@
8
8
  # the store, conventions, intent and CLI halves, so Doctor stays one class with
9
9
  # one public surface and one definition of which checks are core (intent 36a).
10
10
 
11
+ require_relative "store_layout"
11
12
  require "json"
12
13
  require "yaml"
13
14
  require "time"
14
15
  require "digest"
15
- require "rubygems"
16
16
 
17
+ require_relative "version_number"
17
18
  require_relative "hook_registry"
18
19
  require_relative "compact_instructions"
19
20
 
@@ -27,10 +28,16 @@ class Doctor
27
28
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
28
29
  }.freeze
29
30
 
30
- # The Claude events hooks_registered expects in settings.json: the six-event map of
31
+ # The Claude events hooks_registered expects in settings.json: the eight-event map of
31
32
  # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
32
- # intent 316a added MessageDisplay, registered for message-display, Claude only).
33
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd MessageDisplay].freeze
33
+ # intent 316a added MessageDisplay, registered for message-display, Claude only; intent
34
+ # 355 added PreToolUse, registered for call-budget, Claude only; intent 340b added Stop,
35
+ # registered for stop, Claude only). Stop's registration is static (D5/D7) even though
36
+ # its runtime arm defaults off (D9, runner.stop_hook), so an install missing it is
37
+ # exactly as broken as one missing PreCompact, and this list keeps hooks_registered and
38
+ # hooks_match_registry agreeing on that (row 4.32).
39
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreToolUse PreCompact PostToolUse UserPromptSubmit SessionEnd
40
+ MessageDisplay Stop].freeze
34
41
 
35
42
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
36
43
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -91,8 +98,19 @@ class Doctor
91
98
 
92
99
  JSON.parse(File.read(path))
93
100
  rescue JSON::ParserError
94
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
95
- JSON.parse(content)
101
+ # The comment/trailing-comma-stripped retry below can itself raise
102
+ # JSON::ParserError on genuinely malformed content (a truncated file, or
103
+ # plain garbage). A nested begin/rescue is required here because a
104
+ # method-level `rescue` clause never catches an exception raised from
105
+ # INSIDE a sibling rescue clause's own body (only from the main body).
106
+ # Without this nesting a malformed settings.json crashes doctor instead
107
+ # of reporting a clean fail (intent 331e, F5).
108
+ begin
109
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
110
+ JSON.parse(content)
111
+ rescue
112
+ nil
113
+ end
96
114
  rescue
97
115
  nil
98
116
  end
@@ -284,7 +302,7 @@ class Doctor
284
302
  return checks
285
303
  end
286
304
 
287
- index_path = File.join(plastic_home, "INDEX.md")
305
+ index_path = File.join(Plastic::StoreLayout.global_root(plastic_home), "INDEX.md")
288
306
  if File.exist?(index_path)
289
307
  checks << check(
290
308
  category: "global_store", name: "global_index_reachable", status: "pass",
@@ -395,7 +413,7 @@ class Doctor
395
413
  fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes " \
396
414
  "stale launchers). The plastic- prefix is reserved for Plastic's own hooks: " \
397
415
  "if one of these is yours, rename it (for example to ~/.claude/hooks/" \
398
- "writing-style) and re-register it in settings.json before re-running."
416
+ "plain-writing) and re-register it in settings.json before re-running."
399
417
  )
400
418
  end
401
419
 
@@ -500,9 +518,6 @@ class Doctor
500
518
  checks << hooks_entries_owned_check(settings)
501
519
  end
502
520
 
503
- # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
504
- checks << flat_skills_check(agent_dir, "--claude")
505
-
506
521
  # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
507
522
  # e.g. an old-name copy after a rename; intent 158a AC15)
508
523
  checks << stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
@@ -613,24 +628,6 @@ class Doctor
613
628
  )
614
629
  end
615
630
 
616
- def flat_skills_check(agent_dir, installer_flag)
617
- skills_root = File.join(agent_dir, "skills")
618
- found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
619
-
620
- if !found.empty?
621
- check(
622
- category: "agent_registration", name: "skills_exist", status: "pass",
623
- message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
624
- )
625
- else
626
- check(
627
- category: "agent_registration", name: "skills_exist", status: "fail",
628
- message: "No plastic-* skills found in #{tilde(skills_root)}",
629
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
630
- )
631
- end
632
- end
633
-
634
631
  # Manifest-diff stray-skill check (intent 158a), extended (intent 276) to
635
632
  # never vanish on a missing manifest and to name the reserved-prefix rule.
636
633
  def stray_skills_check(agent_dir, installer_flag, manifest_path)
@@ -690,9 +687,6 @@ class Doctor
690
687
  def check_flat_skills_and_stray(agent_key, agent_dir)
691
688
  checks = []
692
689
 
693
- # For codex/hermes: just check skills exist (no settings.json hooks)
694
- checks << flat_skills_check(agent_dir, "--#{agent_key}")
695
-
696
690
  # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
697
691
  # e.g. an old-name copy after a rename; intent 158a AC15)
698
692
  checks << stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
@@ -1044,9 +1038,7 @@ class Doctor
1044
1038
  end
1045
1039
 
1046
1040
  def safe_version(str)
1047
- Gem::Version.new(str.to_s)
1048
- rescue ArgumentError
1049
- nil
1041
+ VersionNumber.parse(str)
1050
1042
  end
1051
1043
 
1052
1044
  def check_core_files(agent_key, include_drift: true)
@@ -1150,7 +1142,7 @@ class Doctor
1150
1142
  "#{tilde(agent_version_path)}: #{agent_version}",
1151
1143
  ],
1152
1144
  fixable: true,
1153
- fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
1145
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or `plastic rollback` to a prior version"
1154
1146
  )
1155
1147
  end
1156
1148
  else
@@ -1158,7 +1150,7 @@ class Doctor
1158
1150
  category: "core_files", name: "version_match", status: "warn",
1159
1151
  message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
1160
1152
  fixable: true,
1161
- fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
1153
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or `plastic rollback` to a prior version"
1162
1154
  )
1163
1155
  end
1164
1156
  end
@@ -1240,15 +1232,99 @@ class Doctor
1240
1232
  # agent_model_drift, no store-content scanning).
1241
1233
  def run_core_checks(agent_key)
1242
1234
  all_checks = []
1243
- all_checks += check_agent_registration(agent_key)
1235
+ all_checks += check_agent_registration(agent_key).reject { |c| c[:name] == "codex_hooks_trust" }
1244
1236
  all_checks += check_core_files(agent_key, include_drift: false)
1245
1237
  all_checks += check_manifest_sync(agent_key)
1246
1238
  all_checks += check_registered_project_paths
1247
1239
  all_checks += check_global_store_available
1240
+ all_checks += check_display_registration(agent_key)
1248
1241
 
1249
1242
  summarize(all_checks, agent_key, binary: true)
1250
1243
  end
1251
1244
 
1245
+ # The single `hooks/<name>` launcher basename for the MessageDisplay event,
1246
+ # derived from HookRegistry rather than hand-kept (intent 331e), so a
1247
+ # future rename of the hook stays in one place. Shared by
1248
+ # check_display_registration (below, boot path) and scripts/doctor.rb's
1249
+ # check_display_paints (full run), which resolves the SAME name under the
1250
+ # agent_dir it was given.
1251
+ def display_hook_launcher_name
1252
+ group = HookRegistry.events["MessageDisplay"].first
1253
+ "plastic-#{group['hooks'].first['name']}"
1254
+ end
1255
+
1256
+ DISPLAY_HOOK_FIX_HINT = "Re-run the Plastic installer to repair the hook registration: " \
1257
+ "npx @zalom/plastic@<channel> install --reinstall --claude " \
1258
+ "(plastic install --reinstall)".freeze
1259
+
1260
+ # display_hook_registered (intent 331e, D1, category "display"): the Claude
1261
+ # settings carry the plastic-message-display command, on-disk, executable.
1262
+ # Boot-path safe: resolves everything from the injected `agents` hash and
1263
+ # `plastic_home`, never Dir.home or a real ~/.claude (E18). This is the same
1264
+ # discipline check_claude_registration already follows.
1265
+ #
1266
+ # D3: a harness Doctor knows carries no display hook (Codex, Hermes) is a
1267
+ # pass, not a fail, worded "plain by contract" like the paint check's own
1268
+ # skip (scripts/doctor.rb's check_display_paints).
1269
+ def check_display_registration(agent_key)
1270
+ config = agents[agent_key]
1271
+ unless agent_key == "claude"
1272
+ return [check(
1273
+ category: "display", name: "display_hook_registered", status: "pass",
1274
+ message: "#{config[:name]} is plain by contract; no MessageDisplay hook to register"
1275
+ )]
1276
+ end
1277
+
1278
+ agent_dir = config[:dir]
1279
+ settings_path = File.join(agent_dir, "settings.json")
1280
+ settings = read_json_safe(settings_path)
1281
+
1282
+ if settings.nil?
1283
+ return [check(
1284
+ category: "display", name: "display_hook_registered", status: "fail",
1285
+ message: "Cannot read #{tilde(settings_path)}: file missing or invalid",
1286
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1287
+ )]
1288
+ end
1289
+
1290
+ hooks = settings["hooks"].is_a?(Hash) ? settings["hooks"] : {}
1291
+ commands = event_commands(hooks["MessageDisplay"])
1292
+ launcher_name = display_hook_launcher_name
1293
+
1294
+ registered = commands.any? { |cmd| HookRegistry.command_basenames(cmd).include?(launcher_name) }
1295
+
1296
+ unless registered
1297
+ return [check(
1298
+ category: "display", name: "display_hook_registered", status: "fail",
1299
+ message: "No MessageDisplay hook registered in #{tilde(settings_path)}",
1300
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1301
+ )]
1302
+ end
1303
+
1304
+ launcher_path = File.join(agent_dir, "hooks", launcher_name)
1305
+
1306
+ unless File.exist?(launcher_path)
1307
+ return [check(
1308
+ category: "display", name: "display_hook_registered", status: "fail",
1309
+ message: "MessageDisplay is registered but #{tilde(launcher_path)} does not exist",
1310
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1311
+ )]
1312
+ end
1313
+
1314
+ unless File.executable?(launcher_path)
1315
+ return [check(
1316
+ category: "display", name: "display_hook_registered", status: "fail",
1317
+ message: "#{tilde(launcher_path)} exists but is not executable",
1318
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1319
+ )]
1320
+ end
1321
+
1322
+ [check(
1323
+ category: "display", name: "display_hook_registered", status: "pass",
1324
+ message: "MessageDisplay hook registered and #{tilde(launcher_path)} is executable"
1325
+ )]
1326
+ end
1327
+
1252
1328
  def check_registered_project_paths
1253
1329
  checks = []
1254
1330
 
@@ -1,6 +1,7 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "store_layout"
4
5
  require "time"
5
6
  require_relative "session_ledger"
6
7
 
@@ -15,21 +16,12 @@ require_relative "session_ledger"
15
16
  # heartbeat is only refreshed on prompts and edits, so an old heartbeat is evidence, not
16
17
  # proof, that the session is gone. Fail-open: an unreadable directory is skipped, never
17
18
  # raised. Depends on the including class for `plastic_home` and `check`.
19
+ #
20
+ # Intent 344 (G11, D10): the third check this category used to carry,
21
+ # `no_pointer_session_tmp`, is retired along with the per-session state it read.
18
22
  module DoctorSessionLedger
19
23
  ORPHAN_TTL_SECONDS = 24 * 60 * 60
20
24
 
21
- # Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
22
- # DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
23
- # session start never ran at all (a `-p` print session, a resumed
24
- # background job, an unregistered SessionStart hook), not a session that
25
- # ran and then never closed. No choice of session id ever creates a
26
- # pointer for that class, so it would otherwise stay invisible for the
27
- # full 24 hours (or forever, once hook-capture/hook-record stop creating
28
- # it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
29
- # between its own start and its first pointer write is normal and must not
30
- # be flagged, but that window is seconds, not hours.
31
- NO_POINTER_TTL_SECONDS = 5 * 60
32
-
33
25
  # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
34
26
  # else that file's mtime, else the directory's mtime.
35
27
  def heartbeat_age(dir, now)
@@ -47,9 +39,8 @@ module DoctorSessionLedger
47
39
  def check_session_ledger(scopes: nil, now: Time.now)
48
40
  return [] unless scopes.nil? || scopes.include?("global")
49
41
 
50
- store_dir = File.join(plastic_home, "store")
42
+ store_dir = Plastic::StoreLayout.global_store(plastic_home)
51
43
  orphans = orphaned_session_dirs(store_dir, now)
52
- no_pointer = no_pointer_session_dirs(store_dir, now)
53
44
  shape = day_ledger_shape_problems(store_dir)
54
45
 
55
46
  checks = []
@@ -67,20 +58,6 @@ module DoctorSessionLedger
67
58
  "session is gone: a live session rewrites its heartbeat on every " \
68
59
  "prompt and edit, so only a listed directory may be removed, by hand.")
69
60
  end
70
- checks << if no_pointer.empty?
71
- check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
72
- message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
73
- "longer than #{NO_POINTER_TTL_SECONDS} seconds")
74
- else
75
- check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
76
- message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
77
- "with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
78
- "(session start never ran for this session)",
79
- details: no_pointer, fixable: true,
80
- fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
81
- "session never started: no `current` pointer means session start never " \
82
- "ran for it, so this is not a live session missing a checklist entry.")
83
- end
84
61
  checks << if shape.empty?
85
62
  check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
86
63
  message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
@@ -113,31 +90,6 @@ module DoctorSessionLedger
113
90
  [] # unreadable .tmp/: nothing to report, never a crash
114
91
  end
115
92
 
116
- # Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
117
- # NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
118
- # that one is age-only and blind to whether a pointer exists at all, so a
119
- # young no-pointer dir (a session between its start and its first pointer
120
- # write) must not appear here even though it may well appear there once it
121
- # ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
122
- # and a dir can legitimately show up in neither, either, or both.
123
- def no_pointer_session_dirs(store_dir, now)
124
- tmp_root = SessionLedger.tmp_root(store_dir)
125
- return [] unless File.directory?(tmp_root)
126
-
127
- Dir.children(tmp_root).sort.filter_map do |name|
128
- dir = File.join(tmp_root, name)
129
- next unless File.directory?(dir)
130
- next if File.exist?(File.join(dir, "current"))
131
-
132
- age = heartbeat_age(dir, now)
133
- next if age <= NO_POINTER_TTL_SECONDS
134
-
135
- "global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
136
- end
137
- rescue SystemCallError
138
- [] # unreadable .tmp/: nothing to report, never a crash
139
- end
140
-
141
93
  def day_ledger_shape_problems(store_dir)
142
94
  root = SessionLedger.sessions_root(store_dir)
143
95
  return [] unless File.directory?(root)
@@ -0,0 +1,88 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # EnginePermissions: the engine deny rule (intent 340b, G7c, n3). A permissions.deny
5
+ # block merged into settings.json at install so the engine directories are not
6
+ # editable by a dispatched agent (C25's self-preservation half).
7
+ #
8
+ # Claude Code accepts a Write(...) path rule and never consults it: Edit(...) is the
9
+ # rule that covers Write, MultiEdit and NotebookEdit. Every entry below names Edit,
10
+ # never Write, and every entry ends in /** because a bare directory path matches the
11
+ # directory itself, not the files under it.
12
+ #
13
+ # This is a second ownership mechanism, deliberately not a reuse of HookRegistry's
14
+ # merge, which knows its own entries by a plastic- launcher basename inside a command
15
+ # string. A deny entry is a bare string with no marker in it, so ownership here is
16
+ # exact-string membership in ENTRIES. The consequence is deliberate: an entry the
17
+ # owner has edited no longer matches the frozen string, so it is the owner's from
18
+ # that point on - the merge appends Plastic's own alongside it, and removal leaves
19
+ # the edited one in place untouched.
20
+ #
21
+ # Pure functions only: no file I/O here. InstallerCore#merge_engine_permissions and
22
+ # #remove_engine_permissions own the read-modify-write against settings.json,
23
+ # mirroring the split HookRegistry keeps from merge_claude_hooks.
24
+ module EnginePermissions
25
+ module_function
26
+
27
+ ENTRIES = [
28
+ "Edit(~/.plastic/scripts/**)",
29
+ "Edit(~/.plastic/skills/**)",
30
+ "Edit(~/.plastic/hooks/**)",
31
+ "Edit(~/.plastic/templates/**)",
32
+ ].freeze
33
+
34
+ # Returns a new settings hash with ENTRIES merged into permissions.deny, appended
35
+ # (never duplicated) alongside whatever is already there. Survives a permissions
36
+ # or deny value that is not the shape expected (row 3.12): a non-Hash permissions
37
+ # value or a non-Array deny value is replaced rather than raised on. Never
38
+ # mutates the argument.
39
+ def merge_into(settings)
40
+ settings = settings.is_a?(Hash) ? settings.dup : {}
41
+
42
+ permissions = settings["permissions"]
43
+ permissions = permissions.is_a?(Hash) ? permissions.dup : {}
44
+
45
+ deny = permissions["deny"]
46
+ deny = deny.is_a?(Array) ? deny.dup : []
47
+
48
+ ENTRIES.each { |entry| deny << entry unless deny.include?(entry) }
49
+
50
+ permissions["deny"] = deny
51
+ settings["permissions"] = permissions
52
+ settings
53
+ end
54
+
55
+ # Returns a new settings hash with exactly the ENTRIES strings removed from
56
+ # permissions.deny, leaving every other entry (the owner's own deny rules, and
57
+ # any Plastic entry the owner has since edited) untouched. Prunes the deny array
58
+ # once it is empty, and the whole permissions block once nothing else remains
59
+ # under it (row 3.10). A settings/permissions/deny shape that is not what is
60
+ # expected is left alone rather than raised on (row 3.12's uninstall side).
61
+ def remove_from(settings)
62
+ return settings unless settings.is_a?(Hash)
63
+
64
+ permissions = settings["permissions"]
65
+ return settings unless permissions.is_a?(Hash)
66
+
67
+ deny = permissions["deny"]
68
+ return settings unless deny.is_a?(Array)
69
+
70
+ settings = settings.dup
71
+ permissions = permissions.dup
72
+ deny = deny.reject { |entry| ENTRIES.include?(entry) }
73
+
74
+ if deny.empty?
75
+ permissions.delete("deny")
76
+ else
77
+ permissions["deny"] = deny
78
+ end
79
+
80
+ if permissions.empty?
81
+ settings.delete("permissions")
82
+ else
83
+ settings["permissions"] = permissions
84
+ end
85
+
86
+ settings
87
+ end
88
+ end