@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
@@ -1,15 +1,19 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "store_layout"
4
5
  require "json"
5
6
  require "yaml"
6
7
  require "fileutils"
7
8
  require "digest"
8
9
  require "time"
10
+ require "tmpdir"
9
11
  require_relative "hook_registry"
10
12
  require_relative "agent_models"
11
13
  require_relative "harness_text"
12
14
  require_relative "compact_instructions"
15
+ require_relative "engine_permissions"
16
+ require_relative "qmd_sync"
13
17
 
14
18
  # Shared installer machinery, instantiable with injected package root / store / agent
15
19
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
@@ -43,23 +47,21 @@ class InstallerCore
43
47
  CLAUDE_SECTION_END = "<!-- END PLASTIC COMPACT -->"
44
48
  CLAUDE_SECTION_RE = /^<!-- BEGIN PLASTIC COMPACT.*?-->\n.*?\n<!-- END PLASTIC COMPACT -->\n?/m
45
49
 
46
- # Curated essentials plus a pointer to ~/.plastic/PLASTIC.md and the plastic-conventions
47
- # skill, injected into ~/.codex/AGENTS.md. Not a slice of PLASTIC.md itself: AGENTS.md is
48
- # a shared file Codex merges from multiple sources, so this block stays a small,
49
- # hand-curated pointer rather than embedding the core wholesale, and it never drifts
50
- # because it only ever points, never duplicates.
50
+ # Curated essentials plus a pointer to ~/.plastic/PLASTIC.md and the help topics
51
+ # `plastic help TOPIC` prints, injected into ~/.codex/AGENTS.md. Not a slice of
52
+ # PLASTIC.md itself: AGENTS.md is a shared file Codex merges from multiple sources,
53
+ # so this block stays a small, hand-curated pointer rather than embedding the core
54
+ # wholesale, and it never drifts because it only ever points, never duplicates.
51
55
  CODEX_AGENTS_MD_BODY = <<~MD.freeze
52
- Plastic is installed for this agent. Plastic is intent-driven state management: all
53
- work flows through an intent, moved through What, Why, How, then Exec. Do not jump
54
- straight to code.
56
+ Plastic is installed for this agent. Plastic is intent-driven state management: work runs
57
+ in one of three modes, direct, thinking, or auto (a team drives the runner loop:
58
+ `runner step`, `status`, `answer`). Do not jump straight to code.
55
59
 
56
60
  Standing rules:
57
- - Core conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly. For
58
- depth, read a chapter from ~/.agents/skills/plastic-conventions/references/ on demand.
59
- Both are generated and overwritten on Plastic updates, so never edit them.
60
- - Operational procedures are installed as skills under ~/.agents/skills/ (each
61
- plastic-<name>/SKILL.md). Invoke one explicitly as $plastic-<name> (for example
62
- $plastic-doctor), or let Codex pick one implicitly by matching its description.
61
+ - The command line is ~/.plastic/PLASTIC.md. Read it and follow it exactly. The
62
+ conventions are chapters `plastic help TOPIC` prints on demand. Both are
63
+ generated and overwritten on Plastic updates, so never edit them.
64
+ - Operational procedures are the `plastic` command line itself, run directly.
63
65
  - Intents, specs, plans, checklists, and outcomes live under ~/.plastic/, never in
64
66
  the project tree.
65
67
 
@@ -253,7 +255,10 @@ class InstallerCore
253
255
 
254
256
  # --- Distribution phase ---
255
257
 
256
- def distribute(mode)
258
+ # `tmp_dirs:` defaults to the real system tmp directory plus `/tmp` (where a
259
+ # retired session-state leftover would sit); a test overrides it (often to
260
+ # `[]`) so the removal never scans the real filesystem's tmp directories.
261
+ def distribute(mode, tmp_dirs: [Dir.tmpdir, "/tmp"].uniq)
257
262
  puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
258
263
 
259
264
  manifest_path = File.join(plastic_home, "manifest.json")
@@ -295,6 +300,8 @@ class InstallerCore
295
300
  write_manifest(global_files, manifest_path)
296
301
 
297
302
  pruned = prune_removed_files(old_files - global_files, root: plastic_home)
303
+ removed = remove_retired_session_state(plastic_home: plastic_home, tmp_dirs: tmp_dirs)
304
+ puts " \u{1f9f9} #{removed} retired session state file(s) removed" if removed.positive?
298
305
 
299
306
  puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
300
307
  end
@@ -331,12 +338,50 @@ class InstallerCore
331
338
  end
332
339
  end
333
340
 
341
+ # Intent 331a (D6/R7): a screen kind file (scripts/lib/screens/<kind>.rb)
342
+ # must reach an installed ~/.plastic the same way a new template or hook
343
+ # does - glob-derived, so "add a file, not a diff" is actually true for an
344
+ # installed Plastic, not just an in-repo one. This repo ships none yet;
345
+ # the glob answers {} until one exists.
346
+ def screen_files
347
+ Dir.glob(File.join(package_root, "scripts", "lib", "screens", "*.rb")).each_with_object({}) do |path, acc|
348
+ next unless File.file?(path)
349
+
350
+ rel = File.join("scripts", "lib", "screens", File.basename(path))
351
+ acc[rel] = rel
352
+ end
353
+ end
354
+
355
+ # The command line (intent 363). Glob-derived for the reason screen_files is:
356
+ # a command is one row in scripts/lib/cli/table.rb plus one file, and a
357
+ # hand-written manifest would make it two files and a diff. The launcher
358
+ # bin/plastic requires scripts/lib/cli.rb by a relative path, so the whole
359
+ # subtree travels together or an installed copy LoadErrors on the first call.
360
+ def cli_files
361
+ Dir.glob(File.join(package_root, "scripts", "lib", "{cli,rlm}", "**", "*.rb")).each_with_object({}) do |path, acc|
362
+ rel = path.sub("#{package_root}/", "")
363
+ acc[rel] = rel
364
+ end
365
+ end
366
+
367
+ # `plastic help TOPIC` (intent 372, family 4) reads docs/help/TOPIC.md from
368
+ # package_root the same way it reads a command's file, so the chapter has to
369
+ # ship the same way a template does: glob-derived, one file added and it
370
+ # installs with no diff here.
371
+ def help_files
372
+ Dir.glob(File.join(package_root, "docs", "help", "*.md")).each_with_object({}) do |path, acc|
373
+ rel = File.join("docs", "help", File.basename(path))
374
+ acc[rel] = rel
375
+ end
376
+ end
377
+
334
378
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
335
379
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
336
- # by install_sync_test). The templates half is glob-derived (template_files above); the
337
- # rest stays a hand-written literal.
380
+ # by install_sync_test). The templates and screen-kind halves are glob-derived
381
+ # (template_files, screen_files above); the rest stays a hand-written literal.
338
382
  def core_files
339
- hand_registered_files.merge(template_files).merge(hook_files)
383
+ hand_registered_files.merge(template_files).merge(hook_files).merge(screen_files).merge(cli_files)
384
+ .merge(help_files)
340
385
  end
341
386
 
342
387
  def hand_registered_files
@@ -357,13 +402,22 @@ class InstallerCore
357
402
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
358
403
  "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
359
404
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
360
- "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
405
+ "scripts/lib/index_entry.rb" => "scripts/lib/index_entry.rb",
406
+ "scripts/lib/project_config.rb" => "scripts/lib/project_config.rb",
361
407
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
408
+ "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
409
+ "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
410
+ "scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
411
+ "scripts/lib/node_progress.rb" => "scripts/lib/node_progress.rb",
412
+ "scripts/node-transition" => "scripts/node-transition",
362
413
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
363
414
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
364
415
  "scripts/plastic-lock" => "scripts/plastic-lock",
365
416
  "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
366
417
  "scripts/lib/compact_instructions.rb" => "scripts/lib/compact_instructions.rb",
418
+ "scripts/lib/version_number.rb" => "scripts/lib/version_number.rb",
419
+ "scripts/lib/cli.rb" => "scripts/lib/cli.rb",
420
+ "bin/plastic" => "bin/plastic",
367
421
  "scripts/agent-report" => "scripts/agent-report",
368
422
  "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
369
423
  "scripts/insight-append" => "scripts/insight-append",
@@ -379,6 +433,14 @@ class InstallerCore
379
433
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
380
434
  "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
381
435
  "scripts/lib/store_discovery.rb" => "scripts/lib/store_discovery.rb",
436
+ "scripts/lib/store_layout.rb" => "scripts/lib/store_layout.rb",
437
+ "scripts/lib/stores_move.rb" => "scripts/lib/stores_move.rb",
438
+ "scripts/lib/sqlite.rb" => "scripts/lib/sqlite.rb",
439
+ "scripts/lib/search_index.rb" => "scripts/lib/search_index.rb",
440
+ "scripts/lib/store_sync.rb" => "scripts/lib/store_sync.rb",
441
+ "scripts/lib/work_graph.rb" => "scripts/lib/work_graph.rb",
442
+ "scripts/lib/reference_archive.rb" => "scripts/lib/reference_archive.rb",
443
+ "scripts/lib/backup.rb" => "scripts/lib/backup.rb",
382
444
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
383
445
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
384
446
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
@@ -421,8 +483,11 @@ class InstallerCore
421
483
  "scripts/verify-intent" => "scripts/verify-intent",
422
484
  "scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
423
485
  "scripts/exec-worktree" => "scripts/exec-worktree",
486
+ "scripts/lib/session_usage.rb" => "scripts/lib/session_usage.rb",
487
+ "scripts/session-usage" => "scripts/session-usage",
424
488
  "scripts/doctor.rb" => "scripts/doctor.rb",
425
489
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
490
+ "scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
426
491
  "scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
427
492
  "scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
428
493
  "scripts/lib/doctor_session_ledger.rb" => "scripts/lib/doctor_session_ledger.rb",
@@ -452,23 +517,159 @@ class InstallerCore
452
517
  # entries are their only protection (test/install_sync_test.rb:23-29
453
518
  # greps installer_core.rb's own source text for "scripts/<name>").
454
519
  "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
520
+ "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
455
521
  "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
522
+ # Intent 331d (A1): scripts/dashboard.rb require_relatives this lib
523
+ # directly; templates/dashboard-screen.md and scripts/lib/screens/
524
+ # dashboard.rb are glob-derived (template_files, screen_files above)
525
+ # and need no entry here.
526
+ "scripts/lib/dashboard_screen.rb" => "scripts/lib/dashboard_screen.rb",
456
527
  "scripts/hook-message-display" => "scripts/hook-message-display",
528
+ # Intent 334 (G1): the node file and graph.md library, plus its
529
+ # validator and CLI (327 D40/D41).
530
+ "scripts/lib/graph_edges.rb" => "scripts/lib/graph_edges.rb",
531
+ "scripts/lib/node_file.rb" => "scripts/lib/node_file.rb",
532
+ "scripts/lib/atomic_write.rb" => "scripts/lib/atomic_write.rb",
533
+ "scripts/lib/graph_file.rb" => "scripts/lib/graph_file.rb",
534
+ "scripts/lib/work_graph_validator.rb" => "scripts/lib/work_graph_validator.rb",
535
+ "scripts/validate-work-graph" => "scripts/validate-work-graph",
536
+ # Intent 335a: every id an intent has ever seen, so a deleted node's id
537
+ # is never reissued to a new node with the dead one's ledger history.
538
+ "scripts/lib/node_ids.rb" => "scripts/lib/node_ids.rb",
539
+ # Intent 339 (G6): the generated outcome.md library and its CLI.
540
+ "scripts/lib/outcome_report.rb" => "scripts/lib/outcome_report.rb",
541
+ "scripts/outcome-report" => "scripts/outcome-report",
542
+ # Intent 336 (G3): ReadySet, the one function that says what may run
543
+ # next, and its CLI. node-transition requires the lib as of n5;
544
+ # registered here (rather than waiting for n8's own CLI/doc unit) so
545
+ # test/install_sync_test.rb stays green across every intermediate unit.
546
+ "scripts/lib/ready_set.rb" => "scripts/lib/ready_set.rb",
547
+ "scripts/ready-set" => "scripts/ready-set",
548
+ # Intent 337 (G4): the roadmap graph model and its tree renderer,
549
+ # registered as they land (test/installer_core_test.rb requires every
550
+ # scripts/lib file to be manifest-covered as soon as it exists, not
551
+ # only once the CLI that ships it arrives at n4).
552
+ "scripts/lib/roadmap_graph.rb" => "scripts/lib/roadmap_graph.rb",
553
+ "scripts/lib/graph_tree.rb" => "scripts/lib/graph_tree.rb",
554
+ "scripts/lib/index_projection.rb" => "scripts/lib/index_projection.rb",
555
+ "scripts/lib/roadmap_render.rb" => "scripts/lib/roadmap_render.rb",
556
+ "scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
557
+ "scripts/roadmap-graph" => "scripts/roadmap-graph",
558
+ "scripts/index-projection" => "scripts/index-projection",
559
+ # Intent 338 (G5): the node input command - the trust-boundary wrapper,
560
+ # the five-block gatherer/assembler, and the CLI 340's runner calls.
561
+ "scripts/lib/data_boundary.rb" => "scripts/lib/data_boundary.rb",
562
+ "scripts/lib/node_input.rb" => "scripts/lib/node_input.rb",
563
+ "scripts/node-input" => "scripts/node-input",
564
+ # Intent 342 (G9): the backward shim that presents actions/*.md as a
565
+ # node graph for any legacy intent, so WorkGraphValidator can require
566
+ # it without going red on contact with install_sync_test.
567
+ "scripts/lib/action_graph_shim.rb" => "scripts/lib/action_graph_shim.rb",
568
+ # Intent 340 (G7, n1): the runner command - the subcommand table over
569
+ # the declared node graph, its shared context (RunnerCore), and the
570
+ # installed-core integrity check (CoreIntegrity) the runner's trust
571
+ # boundary and doctor will both call.
572
+ "scripts/runner" => "scripts/runner",
573
+ "scripts/lib/runner_core.rb" => "scripts/lib/runner_core.rb",
574
+ "scripts/lib/core_integrity.rb" => "scripts/lib/core_integrity.rb",
575
+ # Intent 340 (G7, n2): the merge abort, the reclaim, and the extension -
576
+ # the first thing every `step` does, routed from scripts/runner's `sweep`
577
+ # verb.
578
+ "scripts/lib/runner_sweep.rb" => "scripts/lib/runner_sweep.rb",
579
+ # Intent 340 (G7, n3): a work node's own git worktree, cut from the
580
+ # intent branch tip, merged back into the intent branch, and swept once
581
+ # its node is terminal.
582
+ "scripts/lib/node_worktree.rb" => "scripts/lib/node_worktree.rb",
583
+ # Intent 340 (G7, n4): the return schema (NodeReturn) and the six-check
584
+ # gate (RunnerAbsorb) that turns one executor return into exactly one
585
+ # node ledger transition, required lazily by scripts/runner's `step`.
586
+ "scripts/lib/node_return.rb" => "scripts/lib/node_return.rb",
587
+ "scripts/lib/runner_absorb.rb" => "scripts/lib/runner_absorb.rb",
588
+ # Intent 340 (G7, n5): validation, policy, leases and the dispatch plan
589
+ # - RunnerPolicy (the kind table) and RunnerDispatch, required lazily
590
+ # by scripts/runner's `step`.
591
+ "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
592
+ "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
593
+ # Intent 340b (G7c, n1): the harness seam - HarnessAdapter, required
594
+ # by scripts/lib/runner_dispatch.rb and by scripts/runner's `step`
595
+ # directly.
596
+ "scripts/lib/harness_adapter.rb" => "scripts/lib/harness_adapter.rb",
597
+ # Intent 340b (G7c, n6): the Codex leg - CodexAdapter (the `codex exec`
598
+ # argv, the sandbox per kind, and the bounded subprocess) and
599
+ # scripts/node-run, the CLI that runs one node's whole attempt over
600
+ # it and writes only a return file, never a ledger transition.
601
+ "scripts/lib/codex_adapter.rb" => "scripts/lib/codex_adapter.rb",
602
+ "scripts/node-run" => "scripts/node-run",
603
+ # Intent 340b (G7c, n3): the engine deny rule - the frozen permissions.deny
604
+ # entry list, merged into settings.json at install and removed surgically
605
+ # at uninstall.
606
+ "scripts/lib/engine_permissions.rb" => "scripts/lib/engine_permissions.rb",
607
+ # Intent 340b (G7c, n4): the Stop gate, the shared ActiveDelivery walk
608
+ # it and the PreCompact hand-off both call, and hook-stop, the Stop
609
+ # hook body scripts/hook-stop's launcher (hooks/stop) relays into.
610
+ "scripts/lib/stop_gate.rb" => "scripts/lib/stop_gate.rb",
611
+ "scripts/lib/active_delivery.rb" => "scripts/lib/active_delivery.rb",
612
+ "scripts/hook-stop" => "scripts/hook-stop",
613
+ # Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
614
+ # is its cap table, above); its launcher (hooks/call-budget) ships via
615
+ # hook_files' own glob, so only the hook script itself needs an entry.
616
+ "scripts/hook-call-budget" => "scripts/hook-call-budget",
617
+ "scripts/meter-watch" => "scripts/meter-watch",
618
+ "scripts/lib/meter_watch.rb" => "scripts/lib/meter_watch.rb",
619
+ # Intent 340 (G7, n6): answer (closes a decision node or unparks a
620
+ # work node parked at needs_decision), proposals (mints ids for what
621
+ # an executor proposed), and rewind (resets the intent branch to a
622
+ # node's own commit and respins it) - routed from scripts/runner's
623
+ # `answer` and `rewind` verbs.
624
+ "scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
625
+ "scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
626
+ "scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
627
+ # Intent 343 (G10, n1): the ledger reader - GraphMeasure.read turns one
628
+ # intent directory into one frozen measurement record. No CLI yet
629
+ # (scripts/graph-measure ships at n2); registered here so it is never
630
+ # a lib that exists on disk but never reaches an install.
631
+ "scripts/lib/graph_measure.rb" => "scripts/lib/graph_measure.rb",
632
+ # Intent 343 (G10, n2): the graph-measure command - the subcommand
633
+ # table over the ledger reader, and the two renderers (text, JSON)
634
+ # over the one record it returns.
635
+ "scripts/graph-measure" => "scripts/graph-measure",
636
+ "scripts/lib/graph_measure_report.rb" => "scripts/lib/graph_measure_report.rb",
637
+ # Intent 343 (G10, n4): the `budget` verb - whether the `budget:` a
638
+ # node's envelope declares (327 C19) is a ceiling that ever actually
639
+ # held, read from the ledger and the real input files.
640
+ "scripts/lib/graph_measure_budget.rb" => "scripts/lib/graph_measure_budget.rb",
641
+ # Intent 343 (G10, n5): the `cohorts` verb's model section - the store
642
+ # walk and whether a recorded model= still matches what config
643
+ # resolves today through RunnerPolicy.
644
+ "scripts/lib/graph_measure_models.rb" => "scripts/lib/graph_measure_models.rb",
645
+ # Intent 343 (G10, n6): the rest of the `cohorts` verb - approve-then-
646
+ # fix per verify model, hop on versus off, delivery latency, the
647
+ # evidence bar, and the two concurrency ceilings.
648
+ "scripts/lib/graph_measure_cohorts.rb" => "scripts/lib/graph_measure_cohorts.rb",
649
+ # Intent 340b (G7c, n7): the Codex loop - composes `step` and
650
+ # `node-run` itself (concurrency two, serial absorb, iteration-capped),
651
+ # routed from scripts/runner's internal `until-empty` verb.
652
+ "scripts/lib/runner_until_empty.rb" => "scripts/lib/runner_until_empty.rb",
653
+ # Intent 340a (G7b, n1): the delivery watch - one tick over disk truth,
654
+ # stalled and done-unreported classification, no CLI and no dispatch.
655
+ "scripts/lib/runner_watch.rb" => "scripts/lib/runner_watch.rb",
457
656
  }
458
657
  end
459
658
 
460
659
  def bootstrap
461
660
  puts " \u{1f331} First install \u{2014} bootstrapping store..."
462
661
 
463
- FileUtils.mkdir_p(File.join(plastic_home, "store"))
464
- FileUtils.mkdir_p(File.join(plastic_home, "projects"))
662
+ legacy = %w[store projects INDEX.md roadmaps].any? { |entry| File.exist?(File.join(plastic_home, entry)) }
663
+ FileUtils.mkdir_p(File.join(plastic_home, "stores", "global")) unless legacy
664
+ FileUtils.mkdir_p(Plastic::StoreLayout.global_store(plastic_home))
665
+ FileUtils.mkdir_p(Plastic::StoreLayout.projects_root(plastic_home))
465
666
 
466
667
  write_if_missing(File.join(plastic_home, "config.yml"), <<~YAML)
467
668
  version: 3
468
669
  execution_mode: subagent-driven
469
670
  stale_threshold_days: 3
470
- context_offer_tokens: 350000
471
- context_insist_tokens: 500000
671
+ context_offer_tokens: 150000
672
+ context_insist_tokens: 250000
472
673
  hash_length: 6
473
674
  hash_algorithm: sha256-base36
474
675
  max_slug_words: 5
@@ -479,7 +680,7 @@ class InstallerCore
479
680
 
480
681
  write_if_missing(File.join(plastic_home, "projects.yml"), "---\nprojects: {}\n")
481
682
 
482
- write_if_missing(File.join(plastic_home, "INDEX.md"), <<~MD)
683
+ write_if_missing(File.join(Plastic::StoreLayout.global_root(plastic_home), "INDEX.md"), <<~MD)
483
684
  # Index
484
685
 
485
686
  ## Active
@@ -497,7 +698,7 @@ class InstallerCore
497
698
  # Plastic: Agent Instructions
498
699
 
499
700
  Read `PLASTIC.md` in this directory for the core conventions; deeper doctrine lives
500
- in the `plastic-conventions` skill's chapters. Follow it exactly. Never modify it:
701
+ in the chapters `plastic help TOPIC` prints. Follow it exactly. Never modify it:
501
702
  it is overwritten on plugin updates.
502
703
 
503
704
  This file (`AGENTS.md`) is where project-specific rules live.
@@ -508,6 +709,25 @@ class InstallerCore
508
709
  puts " \u{2705} Store bootstrapped"
509
710
  end
510
711
 
712
+ # Intent 372 (former install skill, lines 119-127): `~/.plastic` becomes its own
713
+ # git repository the first time it exists, so `update` can later commit the core
714
+ # files it re-syncs. Leaves an existing repository alone.
715
+ def git_init_if_absent(runner: ->(cmd) { system(*cmd) })
716
+ FileUtils.mkdir_p(plastic_home)
717
+ return if File.directory?(File.join(plastic_home, ".git"))
718
+ runner.call(["git", "-C", plastic_home, "init", "-q"])
719
+ end
720
+
721
+ # Intent 372 (former install skill, lines 167-179): register every Plastic store as
722
+ # a QMD collection. QmdSync.register already no-ops per store when QMD is absent;
723
+ # the detector is injected too so a run stays hermetic on a machine that happens to
724
+ # have the real `qmd` binary on PATH.
725
+ def register_with_qmd(runner: QmdSync.default_runner, detector: QmdSync.method(:detect))
726
+ QmdSync.enumerate_stores(plastic_home: plastic_home).each do |store|
727
+ QmdSync.register(collection: store[:collection], dir: store[:dir], runner: runner, detector: detector)
728
+ end
729
+ end
730
+
511
731
  # --- Agent adapters ---
512
732
 
513
733
  # Uniform per-agent install record dir: <agent-dir>/plastic/ holds VERSION and
@@ -729,6 +949,111 @@ class InstallerCore
729
949
  removed
730
950
  end
731
951
 
952
+ # Intent 344 (G11, D10): leftovers from the retired per-session day pointer
953
+ # and the retired inter-hook coordination key. A `current` candidate is
954
+ # real only when reached with no symlink between plastic_home and the
955
+ # session directory: a store root's own real path must equal the plain
956
+ # join of plastic_home's real path with the store's lexical relative path
957
+ # (a symlinked store, or a symlinked projects/<slug> above it, breaks
958
+ # that equality), and neither the store's `.tmp` directory nor the
959
+ # session directory beneath it may itself be a symlink. A tmp-file
960
+ # candidate is judged by its containing directory's real path instead,
961
+ # so a symlinked tmp root such as macOS's real /tmp still works; its
962
+ # name is matched on raw bytes so an undecodable name never raises.
963
+ # Never opens or parses a candidate: a torn or malformed leftover is
964
+ # deleted by name alone, exactly like a well-formed one. Only a regular
965
+ # file that is not a symlink is ever a candidate for deletion. Returns
966
+ # the count removed; a delete failure warns once per path and the sync
967
+ # continues, since a stuck leftover must never fail an install.
968
+ def remove_retired_session_state(plastic_home:, tmp_dirs:)
969
+ candidates = []
970
+
971
+ real_store_roots(plastic_home).each do |store_root|
972
+ candidates.concat(current_candidates(store_root))
973
+ end
974
+
975
+ Array(tmp_dirs).compact.each do |dir|
976
+ candidates.concat(tmp_json_candidates(dir))
977
+ end
978
+
979
+ removed = 0
980
+ candidates.uniq.each do |candidate|
981
+ begin
982
+ next unless File.lstat(candidate).file?
983
+ rescue SystemCallError
984
+ next
985
+ end
986
+
987
+ begin
988
+ File.delete(candidate)
989
+ removed += 1
990
+ rescue SystemCallError => e
991
+ warn " \u{26a0}\u{fe0f} Could not remove retired session state #{candidate}: #{e.message}"
992
+ end
993
+ end
994
+ removed
995
+ rescue StandardError
996
+ removed || 0
997
+ end
998
+
999
+ # Every store root (the global store, plus each project store) whose real
1000
+ # path is reached with no symlink between it and plastic_home.
1001
+ def real_store_roots(plastic_home)
1002
+ roots = [Plastic::StoreLayout.global_store(plastic_home)] +
1003
+ Dir.glob(File.join(Plastic::StoreLayout.projects_root(plastic_home), "*", "store"))
1004
+ roots.uniq.select { |root| real_store_root?(plastic_home, root) }
1005
+ end
1006
+
1007
+ def real_store_root?(plastic_home, store_root)
1008
+ return false unless File.directory?(store_root)
1009
+
1010
+ real_home = File.realpath(plastic_home)
1011
+ relative = store_root.sub(/\A#{Regexp.escape(plastic_home)}\/?/, "")
1012
+ File.realpath(store_root) == File.join(real_home, relative)
1013
+ rescue SystemCallError
1014
+ false
1015
+ end
1016
+
1017
+ # `current` candidates under one validated store root: `.tmp` and every
1018
+ # session directory beneath it must be real directories, never symlinks.
1019
+ def current_candidates(store_root)
1020
+ tmp_root = File.join(store_root, ".tmp")
1021
+ return [] unless File.lstat(tmp_root).directory?
1022
+
1023
+ Dir.children(tmp_root).filter_map do |child|
1024
+ begin
1025
+ session_dir = File.join(tmp_root, child)
1026
+ next unless File.lstat(session_dir).directory?
1027
+
1028
+ File.join(session_dir, "current")
1029
+ rescue SystemCallError
1030
+ nil
1031
+ end
1032
+ end
1033
+ rescue SystemCallError
1034
+ []
1035
+ end
1036
+
1037
+ # Retired session-state leftover files sitting directly in `dir`, resolved through
1038
+ # `dir`'s own real path so a symlinked tmp root (macOS's real /tmp) still
1039
+ # works; the name match runs on raw bytes so an undecodable name is
1040
+ # simply not a match, never a raised error.
1041
+ def tmp_json_candidates(dir)
1042
+ real = File.realpath(dir)
1043
+ Dir.children(real).select { |name| retired_tmp_name?(name) }.map { |name| File.join(real, name) }
1044
+ rescue SystemCallError
1045
+ []
1046
+ end
1047
+
1048
+ # True when `name`'s raw bytes match the retired session-state leftover shape
1049
+ # (`plastic-<session>--<id>.json`). Runs on `name.b` so a name that is
1050
+ # not valid UTF-8 is judged on bytes instead of raising.
1051
+ def retired_tmp_name?(name)
1052
+ name.b.match?(/\Aplastic-[^\/]+--[^\/]+\.json\z/n)
1053
+ rescue StandardError
1054
+ false
1055
+ end
1056
+
732
1057
  # True when `path`, once expanded, is one of `roots` itself or lives underneath
733
1058
  # one of them. String-prefix containment guarded by a trailing separator so a
734
1059
  # sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
@@ -751,7 +1076,7 @@ class InstallerCore
751
1076
  # fails milder than the bug it prevents, independent of what any caller's `root:` is or
752
1077
  # what a hand-edited manifest claims.
753
1078
  def store_roots
754
- [File.join(plastic_home, "store"), File.join(plastic_home, "projects")].map { |p| File.expand_path(p) }
1079
+ %w[store projects stores].map { |name| File.expand_path(File.join(plastic_home, name)) }
755
1080
  end
756
1081
 
757
1082
  # `install_skills_flat` relocates any top-level underscore-prefixed markdown
@@ -812,7 +1137,7 @@ class InstallerCore
812
1137
  installed += install_skills_flat(skills_source, skills_root, exclude: skill_exclude) if File.directory?(skills_source)
813
1138
 
814
1139
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
815
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
1140
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, efforts: agent_effort_overrides, advisor_enabled: advisor_enabled?)
816
1141
 
817
1142
  # Write VERSION
818
1143
  version_file = File.join(plastic_dir, "VERSION")
@@ -827,6 +1152,11 @@ class InstallerCore
827
1152
  choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
828
1153
  merge_claude_hooks(settings_path, choice: choice)
829
1154
 
1155
+ # The engine deny rule (intent 340b, G7c, n3, D4): a second ownership
1156
+ # mechanism from the hook merge above, so it is its own call rather than a
1157
+ # branch inside merge_claude_hooks.
1158
+ merge_engine_permissions(settings_path)
1159
+
830
1160
  # Instruction injection (intent 312): the compact-instructions block into
831
1161
  # ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
832
1162
  # (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
@@ -858,7 +1188,12 @@ class InstallerCore
858
1188
  # Codex-scoped overrides only (agents.models.codex.*): a literal Claude
859
1189
  # model id set under agents.models.claude.* (or the legacy flat form,
860
1190
  # which resolves as claude) must never reach a Codex TOML.
861
- installed += generate_codex_agents(File.join(config[:home_dir], "agents"), models: agent_model_overrides(harness: "codex"))
1191
+ installed += generate_codex_agents(
1192
+ File.join(config[:home_dir], "agents"),
1193
+ models: agent_model_overrides(harness: "codex"),
1194
+ efforts: agent_effort_overrides(harness: "codex"),
1195
+ advisor_enabled: advisor_enabled?
1196
+ )
862
1197
 
863
1198
  # Instruction injection (L1): Plastic standing conventions into ~/.codex/AGENTS.md.
864
1199
  # Partial-ownership file, so it is NOT manifest-tracked (stripped surgically on uninstall).
@@ -888,24 +1223,17 @@ class InstallerCore
888
1223
  # a whole-file, Plastic-owned .toml per repo agents/*.md instead of copying markdown.
889
1224
  # The returned paths append to `installed`, so they are manifest-tracked and pruned on
890
1225
  # uninstall by the manifest whole-file-delete path, exactly like ~/.claude/agents/*.md.
891
- def generate_codex_agents(agents_root, models: {})
1226
+ def generate_codex_agents(agents_root, models: {}, efforts: {}, advisor_enabled: true)
892
1227
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
893
1228
  return [] if sources.empty?
894
1229
 
895
1230
  FileUtils.mkdir_p(agents_root)
896
1231
  sources.filter_map do |src|
897
1232
  basename = File.basename(src, ".md")
898
- # Codex advisor support is out of scope for this release (intent 185): the
899
- # owner has not evaluated the Codex reasoning-model ecosystem long enough to
900
- # judge it. Skip every AgentModels::CONSULTATION_AGENTS file (both
901
- # plastic-advisor and plastic-faux-advisor) by name, a deliberate and
902
- # mechanical scope cut tracked at intent 186 (Codex advisor evaluation), not
903
- # a permanent exclusion and not conditioned on any frontmatter or override
904
- # value.
905
- next if AgentModels::CONSULTATION_AGENTS.include?(basename)
1233
+ next if !advisor_enabled && AgentModels::CONSULTATION_AGENTS.include?(basename)
906
1234
 
907
1235
  dest = File.join(agents_root, "#{basename}.toml")
908
- write_text_atomic(dest, render_codex_agent_toml(src, models[basename]))
1236
+ write_text_atomic(dest, render_codex_agent_toml(src, models[basename], efforts[basename]))
909
1237
  dest
910
1238
  end
911
1239
  end
@@ -913,16 +1241,25 @@ class InstallerCore
913
1241
  # Render one repo agents/*.md into a deterministic Codex agent TOML document. Fixed field
914
1242
  # order (name, description, the model field(s) from codex_model_fields, developer_instructions)
915
1243
  # so regenerate is byte-identical (idempotency).
916
- def render_codex_agent_toml(source_path, override)
1244
+ def render_codex_agent_toml(source_path, override, effort_override = nil)
917
1245
  front, body = split_frontmatter(File.read(source_path))
918
1246
  name = (front["name"] || File.basename(source_path, ".md")).to_s
919
1247
  description = (front["description"] || "").to_s
920
- effective = (override && !override.to_s.empty? ? override : front["model"]).to_s
1248
+ effective = if override && !override.to_s.empty?
1249
+ override
1250
+ else
1251
+ AgentModels.shipped_model_for(name, harness: "codex") || front["model"]
1252
+ end
1253
+ effort = if effort_override && !effort_override.to_s.empty?
1254
+ effort_override
1255
+ else
1256
+ front["effort"] || AgentModels.shipped_effort_for(name)
1257
+ end
921
1258
 
922
1259
  parts = []
923
1260
  parts << %(name = "#{toml_inline_escape(name)}")
924
1261
  parts << %(description = "#{toml_inline_escape(description)}")
925
- parts << codex_model_fields(effective)
1262
+ parts << codex_model_fields(effective, effort: effort)
926
1263
  parts << "developer_instructions = \"\"\"\n#{toml_ml_escape(body.strip)}\n\"\"\""
927
1264
  parts.reject(&:empty?).join("\n") + "\n"
928
1265
  end
@@ -942,20 +1279,20 @@ class InstallerCore
942
1279
  # The model-selection line(s). A known tier alias (opus/sonnet/haiku) emits BOTH a `model` line
943
1280
  # (from AgentModels.codex_model_for, the intent-186 per-role Codex identity) and a
944
1281
  # model_reasoning_effort line, model first for deterministic byte-identical regenerate. Any other
945
- # non-empty value is a literal Codex model id emitted verbatim as `model` only. Empty -> no line
946
- # (the agent inherits the session default). If an alias somehow lacks a mapped model, the effort
947
- # line still emits alone (backward-safe).
948
- def codex_model_fields(effective)
1282
+ # non-empty value is a literal Codex model id. Every non-empty model also emits the resolved effort,
1283
+ # medium by default or the harness-scoped override. Empty emits no model fields.
1284
+ def codex_model_fields(effective, effort: AgentModels::DEFAULT_EFFORT)
949
1285
  return "" if effective.nil? || effective.to_s.empty?
950
- effort = AgentModels.effort_for(effective)
951
- if effort
1286
+ if AgentModels.effort_for(effective)
952
1287
  lines = []
953
1288
  model = AgentModels.codex_model_for(effective)
954
1289
  lines << %(model = "#{toml_inline_escape(model)}") if model && !model.to_s.empty?
955
- lines << %(model_reasoning_effort = "#{effort}")
1290
+ lines << %(model_reasoning_effort = "#{toml_inline_escape(effort)}") unless effort.to_s.empty?
956
1291
  lines.join("\n")
957
1292
  else
958
- %(model = "#{toml_inline_escape(effective.to_s)}")
1293
+ lines = [%(model = "#{toml_inline_escape(effective.to_s)}")]
1294
+ lines << %(model_reasoning_effort = "#{toml_inline_escape(effort)}") unless effort.to_s.empty?
1295
+ lines.join("\n")
959
1296
  end
960
1297
  end
961
1298
 
@@ -1040,7 +1377,7 @@ class InstallerCore
1040
1377
  skills_source = File.join(package_root, "skills")
1041
1378
  skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
1042
1379
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
1043
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
1380
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, efforts: agent_effort_overrides, advisor_enabled: advisor_enabled?)
1044
1381
 
1045
1382
  # Uniform per-agent record (intent 210, D2): write VERSION alongside the manifest,
1046
1383
  # the same shape install_claude already writes.
@@ -1088,10 +1425,9 @@ class InstallerCore
1088
1425
  # them to `installed` before write_manifest (manifest + prune are then automatic).
1089
1426
  # No-op safe: returns [] when the package has no agents dir or it is empty.
1090
1427
  # advisor_enabled: false (advisor.enabled config key) skips every
1091
- # AgentModels::CONSULTATION_AGENTS file entirely (both plastic-advisor and
1092
- # plastic-faux-advisor), so a user who declined the advisor never gets either
1428
+ # AgentModels::CONSULTATION_AGENTS file entirely, so a user who declined the advisor never gets either
1093
1429
  # agent installed.
1094
- def install_agents(agents_root, models: {}, advisor_enabled: true)
1430
+ def install_agents(agents_root, models: {}, efforts: {}, advisor_enabled: true)
1095
1431
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
1096
1432
  return [] if sources.empty?
1097
1433
 
@@ -1103,9 +1439,13 @@ class InstallerCore
1103
1439
  sources.map do |src|
1104
1440
  dest = File.join(agents_root, File.basename(src))
1105
1441
  basename = File.basename(src, ".md")
1106
- override = models[basename]
1107
- if override
1108
- File.write(dest, rewrite_model_line(File.read(src), override))
1442
+ model = models[basename]
1443
+ effort = efforts[basename]
1444
+ if model || effort
1445
+ content = File.read(src)
1446
+ content = rewrite_model_line(content, model) if model
1447
+ content = rewrite_effort_line(content, effort) if effort
1448
+ File.write(dest, content)
1109
1449
  else
1110
1450
  FileUtils.cp(src, dest)
1111
1451
  end
@@ -1119,29 +1459,48 @@ class InstallerCore
1119
1459
  content.sub(/^model:[^\n]*$/, "model: #{model}")
1120
1460
  end
1121
1461
 
1462
+ def rewrite_effort_line(content, effort)
1463
+ return content.sub(/^effort:[^\n]*$/, "effort: #{effort}") if content.match?(/^effort:/)
1464
+
1465
+ content.sub(/^(model:[^\n]*)$/) { "#{Regexp.last_match(1)}\neffort: #{effort}" }
1466
+ end
1467
+
1122
1468
  # Resolve per-agent model overrides for this install: project config (when a
1123
1469
  # project dir is known) overlaid on global config, scoped to `harness`
1124
1470
  # ("claude" or "codex"). Defaults are NOT included, so unconfigured agents
1125
1471
  # keep their shipped frontmatter.
1126
1472
  #
1127
- # Both advisor agents (plastic-advisor, plastic-faux-advisor) resolve through
1473
+ # Both advisor agents resolve through
1128
1474
  # this SAME generic map, like any other agent: a config author sets
1129
- # agents.models.claude.plastic-advisor (or the legacy flat
1130
- # agents.models.plastic-advisor, read as claude) to point either agent at a
1475
+ # agents.models.claude.<agent> (or the legacy flat form, read as Claude) to point either agent at a
1131
1476
  # different literal model. There is no separate advisor-specific model key;
1132
1477
  # which agent the advisor SKILL routes to by default is a routing decision
1133
1478
  # (advisor.claude.default), never a model-selection one.
1134
1479
  def agent_model_overrides(project_dir = nil, harness: "claude")
1135
- global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
1480
+ global_path = File.join(plastic_home, "config.yml")
1481
+ migrate_advisor_config_file(global_path)
1482
+ global_config = load_config_yaml(global_path)
1136
1483
  project_config =
1137
1484
  if project_dir
1138
- load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml"))
1485
+ project_path = File.join(project_dir, ".plastic_store", "config.yml")
1486
+ migrate_advisor_config_file(project_path)
1487
+ load_config_yaml(project_path)
1139
1488
  else
1140
1489
  {}
1141
1490
  end
1142
1491
  AgentModels.override_map(project_config: project_config, global_config: global_config, harness: harness)
1143
1492
  end
1144
1493
 
1494
+ def agent_effort_overrides(project_dir = nil, harness: "claude")
1495
+ global_path = File.join(plastic_home, "config.yml")
1496
+ migrate_advisor_config_file(global_path)
1497
+ global_config = load_config_yaml(global_path)
1498
+ project_path = project_dir && File.join(project_dir, ".plastic_store", "config.yml")
1499
+ migrate_advisor_config_file(project_path) if project_path
1500
+ project_config = project_path ? load_config_yaml(project_path) : {}
1501
+ AgentModels.effort_override_map(project_config: project_config, global_config: global_config, harness: harness)
1502
+ end
1503
+
1145
1504
  # advisor.enabled: project overlays global, missing or malformed counts as
1146
1505
  # enabled (fail-open). Harness-blind: false skips both advisor agents and the
1147
1506
  # agent-advisor skill on every installed harness.
@@ -1153,10 +1512,14 @@ class InstallerCore
1153
1512
  value != false
1154
1513
  end
1155
1514
 
1156
- # Agent-name shorthands for the --advisor flag: the two shipped choices,
1157
- # named for the role (real advisor vs. the cheaper imitation), never a model
1158
- # name.
1159
- ADVISOR_SHORTHANDS = { "real" => "plastic-advisor", "faux" => "plastic-faux-advisor" }.freeze
1515
+ ADVISOR_NAME_MIGRATIONS = {
1516
+ "plastic-advisor" => "plastic-primary-advisor",
1517
+ "plastic-faux-advisor" => "plastic-secondary-advisor"
1518
+ }.freeze
1519
+ ADVISOR_SHORTHANDS = {
1520
+ "primary" => "plastic-primary-advisor", "secondary" => "plastic-secondary-advisor",
1521
+ "real" => "plastic-primary-advisor", "faux" => "plastic-secondary-advisor"
1522
+ }.freeze
1160
1523
 
1161
1524
  # Write advisor.enabled / advisor.claude.default into the global config.yml
1162
1525
  # from install-time flags. Absent flags change nothing: advisor.enabled
@@ -1164,7 +1527,7 @@ class InstallerCore
1164
1527
  # (the skill's own fallback chain applies) when missing.
1165
1528
  # --no-advisor -> advisor.enabled: false
1166
1529
  # --advisor VALUE -> advisor.claude.default: VALUE (an agent name, or the
1167
- # shorthand "real"/"faux")
1530
+ # shorthand "primary"/"secondary")
1168
1531
  def apply_config_flags(argv)
1169
1532
  no_advisor = argv.include?("--no-advisor")
1170
1533
  advisor_idx = argv.index("--advisor")
@@ -1172,7 +1535,13 @@ class InstallerCore
1172
1535
  return unless no_advisor || advisor_value
1173
1536
 
1174
1537
  config_path = File.join(plastic_home, "config.yml")
1175
- config = load_config_yaml(config_path)
1538
+ config = if File.exist?(config_path)
1539
+ parsed = YAML.safe_load(File.read(config_path))
1540
+ return false unless parsed.nil? || parsed.is_a?(Hash)
1541
+ migrate_advisor_config(parsed || {})
1542
+ else
1543
+ {}
1544
+ end
1176
1545
 
1177
1546
  if no_advisor
1178
1547
  config["advisor"] ||= {}
@@ -1187,6 +1556,9 @@ class InstallerCore
1187
1556
 
1188
1557
  FileUtils.mkdir_p(plastic_home)
1189
1558
  File.write(config_path, YAML.dump(config))
1559
+ true
1560
+ rescue StandardError
1561
+ false
1190
1562
  end
1191
1563
 
1192
1564
  def load_config_yaml(path)
@@ -1196,6 +1568,45 @@ class InstallerCore
1196
1568
  {}
1197
1569
  end
1198
1570
 
1571
+ # Renames retired advisor keys without discarding a current key. It accepts
1572
+ # malformed config sections and leaves unrelated values untouched.
1573
+ def migrate_advisor_config(config)
1574
+ return {} unless config.is_a?(Hash)
1575
+ config = Marshal.load(Marshal.dump(config))
1576
+ advisor = config["advisor"]
1577
+ if advisor.is_a?(Hash) && advisor["claude"].is_a?(Hash)
1578
+ default = advisor["claude"]["default"]
1579
+ advisor["claude"]["default"] = ADVISOR_NAME_MIGRATIONS.fetch(default, default)
1580
+ end
1581
+ agents = config["agents"]
1582
+ %w[models efforts].each do |section_name|
1583
+ section = agents.is_a?(Hash) ? agents[section_name] : nil
1584
+ next unless section.is_a?(Hash)
1585
+ migrate_advisor_keys!(section)
1586
+ %w[claude codex].each { |harness| migrate_advisor_keys!(section[harness]) if section[harness].is_a?(Hash) }
1587
+ end
1588
+ config
1589
+ end
1590
+
1591
+ def migrate_advisor_config_file(path)
1592
+ return false unless path && File.file?(path)
1593
+ parsed = YAML.safe_load(File.read(path))
1594
+ return false unless parsed.is_a?(Hash)
1595
+ migrated = migrate_advisor_config(parsed)
1596
+ return false if migrated == parsed
1597
+ File.write(path, YAML.dump(migrated))
1598
+ true
1599
+ rescue StandardError
1600
+ false
1601
+ end
1602
+
1603
+ def migrate_advisor_keys!(section)
1604
+ ADVISOR_NAME_MIGRATIONS.each do |legacy, current|
1605
+ section[current] = section[legacy] if !section.key?(current) && section.key?(legacy)
1606
+ section.delete(legacy)
1607
+ end
1608
+ end
1609
+
1199
1610
  # --- Legacy plugin migration ---
1200
1611
 
1201
1612
  # Earlier versions registered Plastic as a local marketplace plugin
@@ -1292,7 +1703,7 @@ class InstallerCore
1292
1703
  puts " \u{2139}\u{fe0f} Kept #{kept.size} hook(s) Plastic does not own, named with the reserved plastic- prefix:"
1293
1704
  kept.each { |cmd| puts " - #{tilde(cmd.to_s)}" }
1294
1705
  puts " The plastic- prefix is reserved for Plastic's own hooks. Rename yours (for"
1295
- puts " example ~/.claude/hooks/writing-style) so a future update never mistakes it."
1706
+ puts " example ~/.claude/hooks/plain-writing) so a future update never mistakes it."
1296
1707
  end
1297
1708
 
1298
1709
  # --- settings.json merge (read-modify-write, never clobber) ---
@@ -1380,6 +1791,44 @@ class InstallerCore
1380
1791
  removed
1381
1792
  end
1382
1793
 
1794
+ # --- The engine deny rule (intent 340b, G7c, n3) ---
1795
+ #
1796
+ # A second ownership mechanism from merge_claude_hooks above (D4, node n3): a
1797
+ # permissions.deny entry is a bare string with no marker in it, so EnginePermissions
1798
+ # owns its four entries by exact-string membership, not by a plastic- launcher
1799
+ # basename. This pair only does the read-modify-write; EnginePermissions.merge_into
1800
+ # and .remove_from are the pure transforms.
1801
+
1802
+ # Merges EnginePermissions::ENTRIES into settings.json. Unlike merge_claude_hooks,
1803
+ # this refuses rather than starting from {} when the existing file cannot be
1804
+ # parsed (row 3.11): a hand-edited settings file is never silently replaced.
1805
+ # Returns true when it wrote, false when it refused.
1806
+ def merge_engine_permissions(settings_path)
1807
+ if File.exist?(settings_path)
1808
+ settings = read_json_safe(settings_path)
1809
+ return false if settings.nil?
1810
+ else
1811
+ settings = {}
1812
+ end
1813
+
1814
+ write_json_atomic(settings_path, EnginePermissions.merge_into(settings))
1815
+ true
1816
+ end
1817
+
1818
+ # Removes exactly EnginePermissions::ENTRIES from settings.json, leaving every
1819
+ # other deny entry (the owner's own, and any Plastic entry the owner has since
1820
+ # edited) in place. Returns true when it wrote, false on a missing or
1821
+ # unparseable file, or a permissions/deny shape it does not recognize.
1822
+ def remove_engine_permissions(settings_path)
1823
+ return false unless File.exist?(settings_path)
1824
+
1825
+ settings = read_json_safe(settings_path)
1826
+ return false unless settings.is_a?(Hash)
1827
+
1828
+ write_json_atomic(settings_path, EnginePermissions.remove_from(settings))
1829
+ true
1830
+ end
1831
+
1383
1832
  # --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
1384
1833
  # New primitive: markdown marked-section merge, the analog of merge_claude_hooks'
1385
1834
  # JSON read-modify-write for a partial-ownership text file. Three states
@@ -1569,6 +2018,7 @@ class InstallerCore
1569
2018
  if key == "claude"
1570
2019
  settings_path = File.join(config[:dir], "settings.json")
1571
2020
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
2021
+ remove_engine_permissions(settings_path) if File.exist?(settings_path)
1572
2022
  removed.concat(migrate_legacy_plugin(config[:dir]))
1573
2023
 
1574
2024
  # The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
@@ -1717,11 +2167,22 @@ class InstallerCore
1717
2167
 
1718
2168
  def read_json_safe(path)
1719
2169
  return nil unless File.exist?(path)
2170
+
1720
2171
  JSON.parse(File.read(path))
1721
2172
  rescue JSON::ParserError
1722
- # Try JSONC stripping (remove // comments and trailing commas)
1723
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
1724
- JSON.parse(content)
2173
+ # The comment/trailing-comma-stripped retry below can itself raise
2174
+ # JSON::ParserError on genuinely malformed content (a truncated file, or
2175
+ # plain garbage). A nested begin/rescue is required here because a
2176
+ # method-level `rescue` clause never catches an exception raised from
2177
+ # INSIDE a sibling rescue clause's own body (only from the main body);
2178
+ # doctor_core.rb#read_json_safe carries the same fix for the same reason
2179
+ # (intent 331e, F5).
2180
+ begin
2181
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
2182
+ JSON.parse(content)
2183
+ rescue
2184
+ nil
2185
+ end
1725
2186
  rescue
1726
2187
  nil
1727
2188
  end