@zalom/plastic 2.0.0-alpha.9 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +21 -16
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +8 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/docs/help/completion-and-done.md +53 -0
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +5 -4
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +55 -2
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +378 -56
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +7 -0
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +74 -91
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +111 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +68 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +58 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +75 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +79 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +46 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +95 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +35 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +77 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +65 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +55 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +23 -14
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +235 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +543 -71
  152. package/scripts/lib/intent_screen.rb +4 -4
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +350 -54
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +921 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1371 -47
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +170 -12
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +526 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +365 -28
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +67 -19
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/untouched_scaffold.rb +51 -0
  212. package/scripts/lib/verify_intent.rb +36 -8
  213. package/scripts/lib/version_number.rb +48 -0
  214. package/scripts/lib/work_graph.rb +59 -0
  215. package/scripts/lib/work_graph_validator.rb +201 -0
  216. package/scripts/lib/worktree.rb +27 -32
  217. package/scripts/lib/worktree_sweep.rb +6 -5
  218. package/scripts/link-suggest +2 -1
  219. package/scripts/meter-watch +57 -0
  220. package/scripts/migrate-to-global +1 -1
  221. package/scripts/new-intent +4 -13
  222. package/scripts/node-input +92 -0
  223. package/scripts/node-run +225 -0
  224. package/scripts/node-transition +291 -0
  225. package/scripts/outcome-report +74 -0
  226. package/scripts/plastic-lock +65 -60
  227. package/scripts/project-links +6 -21
  228. package/scripts/promote-session-item +3 -2
  229. package/scripts/read-config +53 -9
  230. package/scripts/ready-set +126 -0
  231. package/scripts/release-check +123 -0
  232. package/scripts/report-screen +177 -16
  233. package/scripts/roadmap-graph +125 -0
  234. package/scripts/roadmap-savepoint +7 -0
  235. package/scripts/rollback.rb +5 -1
  236. package/scripts/runner +581 -0
  237. package/scripts/savepoint-note +11 -9
  238. package/scripts/session-commit +2 -1
  239. package/scripts/session-usage +56 -0
  240. package/scripts/skill-lint +115 -6
  241. package/scripts/spawn-preamble +2 -2
  242. package/scripts/update.rb +31 -4
  243. package/scripts/validate-work-graph +39 -0
  244. package/scripts/verify-intent +3 -2
  245. package/scripts/write-handoff +2 -1
  246. package/templates/agents.md +7 -7
  247. package/templates/config.yml +16 -9
  248. package/templates/dashboard-screen.md +22 -0
  249. package/templates/display-fixture.md +21 -0
  250. package/templates/graph.md +16 -0
  251. package/templates/index.md +1 -1
  252. package/templates/intent-screen.md +1 -1
  253. package/templates/node-decision.md +11 -0
  254. package/templates/node-research.md +13 -0
  255. package/templates/node-verify.md +13 -0
  256. package/templates/node-work.md +22 -0
  257. package/templates/outcome.md +8 -3
  258. package/templates/project.yml +1 -1
  259. package/templates/render.css +10 -0
  260. package/templates/report-plan.md +15 -0
  261. package/templates/report-roadmap-delivered.md +10 -0
  262. package/templates/report-roadmap-plan.md +9 -0
  263. package/templates/report-roadmap-state.md +9 -0
  264. package/templates/report-state.md +1 -1
  265. package/templates/roadmap.md +13 -0
  266. package/bin/plastic.js +0 -70
  267. package/scripts/lib/bridge.rb +0 -116
  268. package/skills/agent-advisor/SKILL.md +0 -92
  269. package/skills/auto/SKILL.md +0 -296
  270. package/skills/auto/evals/evals.json +0 -255
  271. package/skills/auto/references/end-tail.md +0 -66
  272. package/skills/auto/references/human-report-contract.md +0 -78
  273. package/skills/conventions/SKILL.md +0 -29
  274. package/skills/conventions/references/completion-and-done.md +0 -43
  275. package/skills/dashboard/SKILL.md +0 -169
  276. package/skills/dashboard/evals/evals.json +0 -38
  277. package/skills/dashboard/references/classification.md +0 -22
  278. package/skills/dashboard/templates/dashboard-global.md +0 -20
  279. package/skills/dashboard/templates/dashboard-project.md +0 -19
  280. package/skills/direct/SKILL.md +0 -66
  281. package/skills/direct/references/request-signals.md +0 -59
  282. package/skills/doctor/SKILL.md +0 -299
  283. package/skills/doctor/report.md +0 -102
  284. package/skills/feedback/SKILL.md +0 -98
  285. package/skills/feedback/references/transport-and-privacy.md +0 -65
  286. package/skills/feedback/report.md +0 -36
  287. package/skills/install/SKILL.md +0 -217
  288. package/skills/intent-continuing/SKILL.md +0 -154
  289. package/skills/intent-continuing/references/board-fill.md +0 -43
  290. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  291. package/skills/intent-continuing/references/context-management.md +0 -28
  292. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  293. package/skills/intent-creating/SKILL.md +0 -164
  294. package/skills/intent-creating/evals/evals.json +0 -72
  295. package/skills/intent-creating/references/lifecycle.md +0 -81
  296. package/skills/intent-creating/references/wikilinks.md +0 -8
  297. package/skills/intent-ending/SKILL.md +0 -176
  298. package/skills/intent-ending/evals/evals.json +0 -74
  299. package/skills/intent-executing/SKILL.md +0 -170
  300. package/skills/intent-executing/evals/evals.json +0 -66
  301. package/skills/intent-executing/implementer-prompt.md +0 -42
  302. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  303. package/skills/intent-speccing/SKILL.md +0 -130
  304. package/skills/intent-speccing/evals/evals.json +0 -126
  305. package/skills/intent-speccing/references/design-principles.md +0 -44
  306. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  307. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  308. package/skills/project-creating/SKILL.md +0 -162
  309. package/skills/project-creating/references/hubs-projects.md +0 -55
  310. package/skills/project-creating/references/project-scaffolding.md +0 -97
  311. package/skills/releasing/SKILL.md +0 -337
  312. package/skills/releasing/references/deprecations.md +0 -60
  313. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  314. package/skills/releasing/references/release-lines.md +0 -105
  315. package/skills/roadmap/SKILL.md +0 -64
  316. package/skills/roadmap/references/file-format.md +0 -124
  317. package/skills/roadmap/references/operations.md +0 -112
  318. package/skills/rollback/SKILL.md +0 -91
  319. package/skills/tutorial/SKILL.md +0 -65
  320. package/skills/tutorial/evals/evals.json +0 -186
  321. package/skills/uninstall/SKILL.md +0 -75
  322. package/skills/update/SKILL.md +0 -126
  323. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  324. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  325. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  326. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -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
 
@@ -69,6 +71,17 @@ class InstallerCore
69
71
 
70
72
  attr_reader :package_root, :plastic_home, :version, :agents
71
73
 
74
+ def store_layout_compatible?(target)
75
+ !Plastic::StoreLayout.moved?(plastic_home) || semver_compare(target, "2.0.0-alpha.28") >= 0
76
+ end
77
+
78
+ def refuse_incompatible_store_layout(target)
79
+ return if store_layout_compatible?(target)
80
+
81
+ warn "Plastic #{target} cannot read stores/ in #{plastic_home}. Keep a version that supports this layout."
82
+ true
83
+ end
84
+
72
85
  def initialize(package_root:, plastic_home: DEFAULT_PLASTIC_HOME, version: nil, agents: DEFAULT_AGENTS)
73
86
  @package_root = package_root
74
87
  @plastic_home = plastic_home
@@ -253,7 +266,10 @@ class InstallerCore
253
266
 
254
267
  # --- Distribution phase ---
255
268
 
256
- def distribute(mode)
269
+ # `tmp_dirs:` defaults to the real system tmp directory plus `/tmp` (where a
270
+ # retired session-state leftover would sit); a test overrides it (often to
271
+ # `[]`) so the removal never scans the real filesystem's tmp directories.
272
+ def distribute(mode, tmp_dirs: [Dir.tmpdir, "/tmp"].uniq)
257
273
  puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
258
274
 
259
275
  manifest_path = File.join(plastic_home, "manifest.json")
@@ -295,6 +311,8 @@ class InstallerCore
295
311
  write_manifest(global_files, manifest_path)
296
312
 
297
313
  pruned = prune_removed_files(old_files - global_files, root: plastic_home)
314
+ removed = remove_retired_session_state(plastic_home: plastic_home, tmp_dirs: tmp_dirs)
315
+ puts " \u{1f9f9} #{removed} retired session state file(s) removed" if removed.positive?
298
316
 
299
317
  puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
300
318
  end
@@ -331,12 +349,50 @@ class InstallerCore
331
349
  end
332
350
  end
333
351
 
352
+ # Intent 331a (D6/R7): a screen kind file (scripts/lib/screens/<kind>.rb)
353
+ # must reach an installed ~/.plastic the same way a new template or hook
354
+ # does - glob-derived, so "add a file, not a diff" is actually true for an
355
+ # installed Plastic, not just an in-repo one. This repo ships none yet;
356
+ # the glob answers {} until one exists.
357
+ def screen_files
358
+ Dir.glob(File.join(package_root, "scripts", "lib", "screens", "*.rb")).each_with_object({}) do |path, acc|
359
+ next unless File.file?(path)
360
+
361
+ rel = File.join("scripts", "lib", "screens", File.basename(path))
362
+ acc[rel] = rel
363
+ end
364
+ end
365
+
366
+ # The command line (intent 363). Glob-derived for the reason screen_files is:
367
+ # a command is one row in scripts/lib/cli/table.rb plus one file, and a
368
+ # hand-written manifest would make it two files and a diff. The launcher
369
+ # bin/plastic requires scripts/lib/cli.rb by a relative path, so the whole
370
+ # subtree travels together or an installed copy LoadErrors on the first call.
371
+ def cli_files
372
+ Dir.glob(File.join(package_root, "scripts", "lib", "{cli,rlm}", "**", "*.rb")).each_with_object({}) do |path, acc|
373
+ rel = path.sub("#{package_root}/", "")
374
+ acc[rel] = rel
375
+ end
376
+ end
377
+
378
+ # `plastic help TOPIC` (intent 372, family 4) reads docs/help/TOPIC.md from
379
+ # package_root the same way it reads a command's file, so the chapter has to
380
+ # ship the same way a template does: glob-derived, one file added and it
381
+ # installs with no diff here.
382
+ def help_files
383
+ Dir.glob(File.join(package_root, "docs", "help", "*.md")).each_with_object({}) do |path, acc|
384
+ rel = File.join("docs", "help", File.basename(path))
385
+ acc[rel] = rel
386
+ end
387
+ end
388
+
334
389
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
335
390
  # 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.
391
+ # by install_sync_test). The templates and screen-kind halves are glob-derived
392
+ # (template_files, screen_files above); the rest stays a hand-written literal.
338
393
  def core_files
339
- hand_registered_files.merge(template_files).merge(hook_files)
394
+ hand_registered_files.merge(template_files).merge(hook_files).merge(screen_files).merge(cli_files)
395
+ .merge(help_files)
340
396
  end
341
397
 
342
398
  def hand_registered_files
@@ -357,13 +413,23 @@ class InstallerCore
357
413
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
358
414
  "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
359
415
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
360
- "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
416
+ "scripts/lib/index_entry.rb" => "scripts/lib/index_entry.rb",
417
+ "scripts/lib/project_config.rb" => "scripts/lib/project_config.rb",
361
418
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
419
+ "scripts/lib/untouched_scaffold.rb" => "scripts/lib/untouched_scaffold.rb",
420
+ "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
421
+ "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
422
+ "scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
423
+ "scripts/lib/node_progress.rb" => "scripts/lib/node_progress.rb",
424
+ "scripts/node-transition" => "scripts/node-transition",
362
425
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
363
426
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
364
427
  "scripts/plastic-lock" => "scripts/plastic-lock",
365
428
  "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
366
429
  "scripts/lib/compact_instructions.rb" => "scripts/lib/compact_instructions.rb",
430
+ "scripts/lib/version_number.rb" => "scripts/lib/version_number.rb",
431
+ "scripts/lib/cli.rb" => "scripts/lib/cli.rb",
432
+ "bin/plastic" => "bin/plastic",
367
433
  "scripts/agent-report" => "scripts/agent-report",
368
434
  "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
369
435
  "scripts/insight-append" => "scripts/insight-append",
@@ -379,6 +445,14 @@ class InstallerCore
379
445
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
380
446
  "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
381
447
  "scripts/lib/store_discovery.rb" => "scripts/lib/store_discovery.rb",
448
+ "scripts/lib/store_layout.rb" => "scripts/lib/store_layout.rb",
449
+ "scripts/lib/stores_move.rb" => "scripts/lib/stores_move.rb",
450
+ "scripts/lib/sqlite.rb" => "scripts/lib/sqlite.rb",
451
+ "scripts/lib/search_index.rb" => "scripts/lib/search_index.rb",
452
+ "scripts/lib/store_sync.rb" => "scripts/lib/store_sync.rb",
453
+ "scripts/lib/work_graph.rb" => "scripts/lib/work_graph.rb",
454
+ "scripts/lib/reference_archive.rb" => "scripts/lib/reference_archive.rb",
455
+ "scripts/lib/backup.rb" => "scripts/lib/backup.rb",
382
456
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
383
457
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
384
458
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
@@ -421,8 +495,11 @@ class InstallerCore
421
495
  "scripts/verify-intent" => "scripts/verify-intent",
422
496
  "scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
423
497
  "scripts/exec-worktree" => "scripts/exec-worktree",
498
+ "scripts/lib/session_usage.rb" => "scripts/lib/session_usage.rb",
499
+ "scripts/session-usage" => "scripts/session-usage",
424
500
  "scripts/doctor.rb" => "scripts/doctor.rb",
425
501
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
502
+ "scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
426
503
  "scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
427
504
  "scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
428
505
  "scripts/lib/doctor_session_ledger.rb" => "scripts/lib/doctor_session_ledger.rb",
@@ -454,22 +531,157 @@ class InstallerCore
454
531
  "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
455
532
  "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
456
533
  "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
534
+ # Intent 331d (A1): scripts/dashboard.rb require_relatives this lib
535
+ # directly; templates/dashboard-screen.md and scripts/lib/screens/
536
+ # dashboard.rb are glob-derived (template_files, screen_files above)
537
+ # and need no entry here.
538
+ "scripts/lib/dashboard_screen.rb" => "scripts/lib/dashboard_screen.rb",
457
539
  "scripts/hook-message-display" => "scripts/hook-message-display",
540
+ # Intent 334 (G1): the node file and graph.md library, plus its
541
+ # validator and CLI (327 D40/D41).
542
+ "scripts/lib/graph_edges.rb" => "scripts/lib/graph_edges.rb",
543
+ "scripts/lib/node_file.rb" => "scripts/lib/node_file.rb",
544
+ "scripts/lib/atomic_write.rb" => "scripts/lib/atomic_write.rb",
545
+ "scripts/lib/graph_file.rb" => "scripts/lib/graph_file.rb",
546
+ "scripts/lib/work_graph_validator.rb" => "scripts/lib/work_graph_validator.rb",
547
+ "scripts/validate-work-graph" => "scripts/validate-work-graph",
548
+ # Intent 335a: every id an intent has ever seen, so a deleted node's id
549
+ # is never reissued to a new node with the dead one's ledger history.
550
+ "scripts/lib/node_ids.rb" => "scripts/lib/node_ids.rb",
551
+ # Intent 339 (G6): the generated outcome.md library and its CLI.
552
+ "scripts/lib/outcome_report.rb" => "scripts/lib/outcome_report.rb",
553
+ "scripts/outcome-report" => "scripts/outcome-report",
554
+ # Intent 336 (G3): ReadySet, the one function that says what may run
555
+ # next, and its CLI. node-transition requires the lib as of n5;
556
+ # registered here (rather than waiting for n8's own CLI/doc unit) so
557
+ # test/install_sync_test.rb stays green across every intermediate unit.
558
+ "scripts/lib/ready_set.rb" => "scripts/lib/ready_set.rb",
559
+ "scripts/ready-set" => "scripts/ready-set",
560
+ # Intent 337 (G4): the roadmap graph model and its tree renderer,
561
+ # registered as they land (test/installer_core_test.rb requires every
562
+ # scripts/lib file to be manifest-covered as soon as it exists, not
563
+ # only once the CLI that ships it arrives at n4).
564
+ "scripts/lib/roadmap_graph.rb" => "scripts/lib/roadmap_graph.rb",
565
+ "scripts/lib/graph_tree.rb" => "scripts/lib/graph_tree.rb",
566
+ "scripts/lib/index_projection.rb" => "scripts/lib/index_projection.rb",
567
+ "scripts/lib/roadmap_render.rb" => "scripts/lib/roadmap_render.rb",
568
+ "scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
569
+ "scripts/roadmap-graph" => "scripts/roadmap-graph",
570
+ "scripts/index-projection" => "scripts/index-projection",
571
+ # Intent 338 (G5): the node input command - the trust-boundary wrapper,
572
+ # the five-block gatherer/assembler, and the CLI 340's runner calls.
573
+ "scripts/lib/data_boundary.rb" => "scripts/lib/data_boundary.rb",
574
+ "scripts/lib/node_input.rb" => "scripts/lib/node_input.rb",
575
+ "scripts/node-input" => "scripts/node-input",
576
+ # Intent 342 (G9): the backward shim that presents actions/*.md as a
577
+ # node graph for any legacy intent, so WorkGraphValidator can require
578
+ # it without going red on contact with install_sync_test.
579
+ "scripts/lib/action_graph_shim.rb" => "scripts/lib/action_graph_shim.rb",
580
+ # Intent 340 (G7, n1): the runner command - the subcommand table over
581
+ # the declared node graph, its shared context (RunnerCore), and the
582
+ # installed-core integrity check (CoreIntegrity) the runner's trust
583
+ # boundary and doctor will both call.
584
+ "scripts/runner" => "scripts/runner",
585
+ "scripts/lib/runner_core.rb" => "scripts/lib/runner_core.rb",
586
+ "scripts/lib/core_integrity.rb" => "scripts/lib/core_integrity.rb",
587
+ # Intent 340 (G7, n2): the merge abort, the reclaim, and the extension -
588
+ # the first thing every `step` does, routed from scripts/runner's `sweep`
589
+ # verb.
590
+ "scripts/lib/runner_sweep.rb" => "scripts/lib/runner_sweep.rb",
591
+ # Intent 340 (G7, n3): a work node's own git worktree, cut from the
592
+ # intent branch tip, merged back into the intent branch, and swept once
593
+ # its node is terminal.
594
+ "scripts/lib/node_worktree.rb" => "scripts/lib/node_worktree.rb",
595
+ # Intent 340 (G7, n4): the return schema (NodeReturn) and the six-check
596
+ # gate (RunnerAbsorb) that turns one executor return into exactly one
597
+ # node ledger transition, required lazily by scripts/runner's `step`.
598
+ "scripts/lib/node_return.rb" => "scripts/lib/node_return.rb",
599
+ "scripts/lib/runner_absorb.rb" => "scripts/lib/runner_absorb.rb",
600
+ # Intent 340 (G7, n5): validation, policy, leases and the dispatch plan
601
+ # - RunnerPolicy (the kind table) and RunnerDispatch, required lazily
602
+ # by scripts/runner's `step`.
603
+ "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
604
+ "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
605
+ # Intent 340b (G7c, n1): the harness seam - HarnessAdapter, required
606
+ # by scripts/lib/runner_dispatch.rb and by scripts/runner's `step`
607
+ # directly.
608
+ "scripts/lib/harness_adapter.rb" => "scripts/lib/harness_adapter.rb",
609
+ # Intent 340b (G7c, n6): the Codex leg - CodexAdapter (the `codex exec`
610
+ # argv, the sandbox per kind, and the bounded subprocess) and
611
+ # scripts/node-run, the CLI that runs one node's whole attempt over
612
+ # it and writes only a return file, never a ledger transition.
613
+ "scripts/lib/codex_adapter.rb" => "scripts/lib/codex_adapter.rb",
614
+ "scripts/node-run" => "scripts/node-run",
615
+ # Intent 340b (G7c, n3): the engine deny rule - the frozen permissions.deny
616
+ # entry list, merged into settings.json at install and removed surgically
617
+ # at uninstall.
618
+ "scripts/lib/engine_permissions.rb" => "scripts/lib/engine_permissions.rb",
619
+ # Intent 340b (G7c, n4): the Stop gate, the shared ActiveDelivery walk
620
+ # it and the PreCompact hand-off both call, and hook-stop, the Stop
621
+ # hook body scripts/hook-stop's launcher (hooks/stop) relays into.
622
+ "scripts/lib/stop_gate.rb" => "scripts/lib/stop_gate.rb",
623
+ "scripts/lib/active_delivery.rb" => "scripts/lib/active_delivery.rb",
624
+ "scripts/hook-stop" => "scripts/hook-stop",
625
+ # Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
626
+ # is its cap table, above); its launcher (hooks/call-budget) ships via
627
+ # hook_files' own glob, so only the hook script itself needs an entry.
628
+ "scripts/hook-call-budget" => "scripts/hook-call-budget",
629
+ "scripts/meter-watch" => "scripts/meter-watch",
630
+ "scripts/lib/meter_watch.rb" => "scripts/lib/meter_watch.rb",
631
+ # Intent 340 (G7, n6): answer (closes a decision node or unparks a
632
+ # work node parked at needs_decision), proposals (mints ids for what
633
+ # an executor proposed), and rewind (resets the intent branch to a
634
+ # node's own commit and respins it) - routed from scripts/runner's
635
+ # `answer` and `rewind` verbs.
636
+ "scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
637
+ "scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
638
+ "scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
639
+ # Intent 343 (G10, n1): the ledger reader - GraphMeasure.read turns one
640
+ # intent directory into one frozen measurement record. No CLI yet
641
+ # (scripts/graph-measure ships at n2); registered here so it is never
642
+ # a lib that exists on disk but never reaches an install.
643
+ "scripts/lib/graph_measure.rb" => "scripts/lib/graph_measure.rb",
644
+ # Intent 343 (G10, n2): the graph-measure command - the subcommand
645
+ # table over the ledger reader, and the two renderers (text, JSON)
646
+ # over the one record it returns.
647
+ "scripts/graph-measure" => "scripts/graph-measure",
648
+ "scripts/lib/graph_measure_report.rb" => "scripts/lib/graph_measure_report.rb",
649
+ # Intent 343 (G10, n4): the `budget` verb - whether the `budget:` a
650
+ # node's envelope declares (327 C19) is a ceiling that ever actually
651
+ # held, read from the ledger and the real input files.
652
+ "scripts/lib/graph_measure_budget.rb" => "scripts/lib/graph_measure_budget.rb",
653
+ # Intent 343 (G10, n5): the `cohorts` verb's model section - the store
654
+ # walk and whether a recorded model= still matches what config
655
+ # resolves today through RunnerPolicy.
656
+ "scripts/lib/graph_measure_models.rb" => "scripts/lib/graph_measure_models.rb",
657
+ # Intent 343 (G10, n6): the rest of the `cohorts` verb - approve-then-
658
+ # fix per verify model, hop on versus off, delivery latency, the
659
+ # evidence bar, and the two concurrency ceilings.
660
+ "scripts/lib/graph_measure_cohorts.rb" => "scripts/lib/graph_measure_cohorts.rb",
661
+ # Intent 340b (G7c, n7): the Codex loop - composes `step` and
662
+ # `node-run` itself (concurrency two, serial absorb, iteration-capped),
663
+ # routed from scripts/runner's internal `until-empty` verb.
664
+ "scripts/lib/runner_until_empty.rb" => "scripts/lib/runner_until_empty.rb",
665
+ # Intent 340a (G7b, n1): the delivery watch - one tick over disk truth,
666
+ # stalled and done-unreported classification, no CLI and no dispatch.
667
+ "scripts/lib/runner_watch.rb" => "scripts/lib/runner_watch.rb",
458
668
  }
459
669
  end
460
670
 
461
671
  def bootstrap
462
672
  puts " \u{1f331} First install \u{2014} bootstrapping store..."
463
673
 
464
- FileUtils.mkdir_p(File.join(plastic_home, "store"))
465
- FileUtils.mkdir_p(File.join(plastic_home, "projects"))
674
+ legacy = %w[store projects INDEX.md roadmaps].any? { |entry| File.exist?(File.join(plastic_home, entry)) }
675
+ FileUtils.mkdir_p(File.join(plastic_home, "stores", "global")) unless legacy
676
+ FileUtils.mkdir_p(Plastic::StoreLayout.global_store(plastic_home))
677
+ FileUtils.mkdir_p(Plastic::StoreLayout.projects_root(plastic_home))
466
678
 
467
679
  write_if_missing(File.join(plastic_home, "config.yml"), <<~YAML)
468
680
  version: 3
469
681
  execution_mode: subagent-driven
470
682
  stale_threshold_days: 3
471
- context_offer_tokens: 350000
472
- context_insist_tokens: 500000
683
+ context_offer_tokens: 150000
684
+ context_insist_tokens: 250000
473
685
  hash_length: 6
474
686
  hash_algorithm: sha256-base36
475
687
  max_slug_words: 5
@@ -480,7 +692,7 @@ class InstallerCore
480
692
 
481
693
  write_if_missing(File.join(plastic_home, "projects.yml"), "---\nprojects: {}\n")
482
694
 
483
- write_if_missing(File.join(plastic_home, "INDEX.md"), <<~MD)
695
+ write_if_missing(File.join(Plastic::StoreLayout.global_root(plastic_home), "INDEX.md"), <<~MD)
484
696
  # Index
485
697
 
486
698
  ## Active
@@ -498,7 +710,7 @@ class InstallerCore
498
710
  # Plastic: Agent Instructions
499
711
 
500
712
  Read `PLASTIC.md` in this directory for the core conventions; deeper doctrine lives
501
- in the `plastic-conventions` skill's chapters. Follow it exactly. Never modify it:
713
+ in the chapters `plastic help TOPIC` prints. Follow it exactly. Never modify it:
502
714
  it is overwritten on plugin updates.
503
715
 
504
716
  This file (`AGENTS.md`) is where project-specific rules live.
@@ -509,6 +721,25 @@ class InstallerCore
509
721
  puts " \u{2705} Store bootstrapped"
510
722
  end
511
723
 
724
+ # Intent 372 (former install skill, lines 119-127): `~/.plastic` becomes its own
725
+ # git repository the first time it exists, so `update` can later commit the core
726
+ # files it re-syncs. Leaves an existing repository alone.
727
+ def git_init_if_absent(runner: ->(cmd) { system(*cmd) })
728
+ FileUtils.mkdir_p(plastic_home)
729
+ return if File.directory?(File.join(plastic_home, ".git"))
730
+ runner.call(["git", "-C", plastic_home, "init", "-q"])
731
+ end
732
+
733
+ # Intent 372 (former install skill, lines 167-179): register every Plastic store as
734
+ # a QMD collection. QmdSync.register already no-ops per store when QMD is absent;
735
+ # the detector is injected too so a run stays hermetic on a machine that happens to
736
+ # have the real `qmd` binary on PATH.
737
+ def register_with_qmd(runner: QmdSync.default_runner, detector: QmdSync.method(:detect))
738
+ QmdSync.enumerate_stores(plastic_home: plastic_home).each do |store|
739
+ QmdSync.register(collection: store[:collection], dir: store[:dir], runner: runner, detector: detector)
740
+ end
741
+ end
742
+
512
743
  # --- Agent adapters ---
513
744
 
514
745
  # Uniform per-agent install record dir: <agent-dir>/plastic/ holds VERSION and
@@ -730,6 +961,111 @@ class InstallerCore
730
961
  removed
731
962
  end
732
963
 
964
+ # Intent 344 (G11, D10): leftovers from the retired per-session day pointer
965
+ # and the retired inter-hook coordination key. A `current` candidate is
966
+ # real only when reached with no symlink between plastic_home and the
967
+ # session directory: a store root's own real path must equal the plain
968
+ # join of plastic_home's real path with the store's lexical relative path
969
+ # (a symlinked store, or a symlinked projects/<slug> above it, breaks
970
+ # that equality), and neither the store's `.tmp` directory nor the
971
+ # session directory beneath it may itself be a symlink. A tmp-file
972
+ # candidate is judged by its containing directory's real path instead,
973
+ # so a symlinked tmp root such as macOS's real /tmp still works; its
974
+ # name is matched on raw bytes so an undecodable name never raises.
975
+ # Never opens or parses a candidate: a torn or malformed leftover is
976
+ # deleted by name alone, exactly like a well-formed one. Only a regular
977
+ # file that is not a symlink is ever a candidate for deletion. Returns
978
+ # the count removed; a delete failure warns once per path and the sync
979
+ # continues, since a stuck leftover must never fail an install.
980
+ def remove_retired_session_state(plastic_home:, tmp_dirs:)
981
+ candidates = []
982
+
983
+ real_store_roots(plastic_home).each do |store_root|
984
+ candidates.concat(current_candidates(store_root))
985
+ end
986
+
987
+ Array(tmp_dirs).compact.each do |dir|
988
+ candidates.concat(tmp_json_candidates(dir))
989
+ end
990
+
991
+ removed = 0
992
+ candidates.uniq.each do |candidate|
993
+ begin
994
+ next unless File.lstat(candidate).file?
995
+ rescue SystemCallError
996
+ next
997
+ end
998
+
999
+ begin
1000
+ File.delete(candidate)
1001
+ removed += 1
1002
+ rescue SystemCallError => e
1003
+ warn " \u{26a0}\u{fe0f} Could not remove retired session state #{candidate}: #{e.message}"
1004
+ end
1005
+ end
1006
+ removed
1007
+ rescue StandardError
1008
+ removed || 0
1009
+ end
1010
+
1011
+ # Every store root (the global store, plus each project store) whose real
1012
+ # path is reached with no symlink between it and plastic_home.
1013
+ def real_store_roots(plastic_home)
1014
+ roots = [Plastic::StoreLayout.global_store(plastic_home)] +
1015
+ Dir.glob(File.join(Plastic::StoreLayout.projects_root(plastic_home), "*", "store"))
1016
+ roots.uniq.select { |root| real_store_root?(plastic_home, root) }
1017
+ end
1018
+
1019
+ def real_store_root?(plastic_home, store_root)
1020
+ return false unless File.directory?(store_root)
1021
+
1022
+ real_home = File.realpath(plastic_home)
1023
+ relative = store_root.sub(/\A#{Regexp.escape(plastic_home)}\/?/, "")
1024
+ File.realpath(store_root) == File.join(real_home, relative)
1025
+ rescue SystemCallError
1026
+ false
1027
+ end
1028
+
1029
+ # `current` candidates under one validated store root: `.tmp` and every
1030
+ # session directory beneath it must be real directories, never symlinks.
1031
+ def current_candidates(store_root)
1032
+ tmp_root = File.join(store_root, ".tmp")
1033
+ return [] unless File.lstat(tmp_root).directory?
1034
+
1035
+ Dir.children(tmp_root).filter_map do |child|
1036
+ begin
1037
+ session_dir = File.join(tmp_root, child)
1038
+ next unless File.lstat(session_dir).directory?
1039
+
1040
+ File.join(session_dir, "current")
1041
+ rescue SystemCallError
1042
+ nil
1043
+ end
1044
+ end
1045
+ rescue SystemCallError
1046
+ []
1047
+ end
1048
+
1049
+ # Retired session-state leftover files sitting directly in `dir`, resolved through
1050
+ # `dir`'s own real path so a symlinked tmp root (macOS's real /tmp) still
1051
+ # works; the name match runs on raw bytes so an undecodable name is
1052
+ # simply not a match, never a raised error.
1053
+ def tmp_json_candidates(dir)
1054
+ real = File.realpath(dir)
1055
+ Dir.children(real).select { |name| retired_tmp_name?(name) }.map { |name| File.join(real, name) }
1056
+ rescue SystemCallError
1057
+ []
1058
+ end
1059
+
1060
+ # True when `name`'s raw bytes match the retired session-state leftover shape
1061
+ # (`plastic-<session>--<id>.json`). Runs on `name.b` so a name that is
1062
+ # not valid UTF-8 is judged on bytes instead of raising.
1063
+ def retired_tmp_name?(name)
1064
+ name.b.match?(/\Aplastic-[^\/]+--[^\/]+\.json\z/n)
1065
+ rescue StandardError
1066
+ false
1067
+ end
1068
+
733
1069
  # True when `path`, once expanded, is one of `roots` itself or lives underneath
734
1070
  # one of them. String-prefix containment guarded by a trailing separator so a
735
1071
  # sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
@@ -752,7 +1088,7 @@ class InstallerCore
752
1088
  # fails milder than the bug it prevents, independent of what any caller's `root:` is or
753
1089
  # what a hand-edited manifest claims.
754
1090
  def store_roots
755
- [File.join(plastic_home, "store"), File.join(plastic_home, "projects")].map { |p| File.expand_path(p) }
1091
+ %w[store projects stores].map { |name| File.expand_path(File.join(plastic_home, name)) }
756
1092
  end
757
1093
 
758
1094
  # `install_skills_flat` relocates any top-level underscore-prefixed markdown
@@ -813,7 +1149,7 @@ class InstallerCore
813
1149
  installed += install_skills_flat(skills_source, skills_root, exclude: skill_exclude) if File.directory?(skills_source)
814
1150
 
815
1151
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
816
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
1152
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, efforts: agent_effort_overrides, advisor_enabled: advisor_enabled?)
817
1153
 
818
1154
  # Write VERSION
819
1155
  version_file = File.join(plastic_dir, "VERSION")
@@ -828,6 +1164,11 @@ class InstallerCore
828
1164
  choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
829
1165
  merge_claude_hooks(settings_path, choice: choice)
830
1166
 
1167
+ # The engine deny rule (intent 340b, G7c, n3, D4): a second ownership
1168
+ # mechanism from the hook merge above, so it is its own call rather than a
1169
+ # branch inside merge_claude_hooks.
1170
+ merge_engine_permissions(settings_path)
1171
+
831
1172
  # Instruction injection (intent 312): the compact-instructions block into
832
1173
  # ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
833
1174
  # (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
@@ -859,7 +1200,12 @@ class InstallerCore
859
1200
  # Codex-scoped overrides only (agents.models.codex.*): a literal Claude
860
1201
  # model id set under agents.models.claude.* (or the legacy flat form,
861
1202
  # which resolves as claude) must never reach a Codex TOML.
862
- installed += generate_codex_agents(File.join(config[:home_dir], "agents"), models: agent_model_overrides(harness: "codex"))
1203
+ installed += generate_codex_agents(
1204
+ File.join(config[:home_dir], "agents"),
1205
+ models: agent_model_overrides(harness: "codex"),
1206
+ efforts: agent_effort_overrides(harness: "codex"),
1207
+ advisor_enabled: advisor_enabled?
1208
+ )
863
1209
 
864
1210
  # Instruction injection (L1): Plastic standing conventions into ~/.codex/AGENTS.md.
865
1211
  # Partial-ownership file, so it is NOT manifest-tracked (stripped surgically on uninstall).
@@ -889,24 +1235,17 @@ class InstallerCore
889
1235
  # a whole-file, Plastic-owned .toml per repo agents/*.md instead of copying markdown.
890
1236
  # The returned paths append to `installed`, so they are manifest-tracked and pruned on
891
1237
  # uninstall by the manifest whole-file-delete path, exactly like ~/.claude/agents/*.md.
892
- def generate_codex_agents(agents_root, models: {})
1238
+ def generate_codex_agents(agents_root, models: {}, efforts: {}, advisor_enabled: true)
893
1239
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
894
1240
  return [] if sources.empty?
895
1241
 
896
1242
  FileUtils.mkdir_p(agents_root)
897
1243
  sources.filter_map do |src|
898
1244
  basename = File.basename(src, ".md")
899
- # Codex advisor support is out of scope for this release (intent 185): the
900
- # owner has not evaluated the Codex reasoning-model ecosystem long enough to
901
- # judge it. Skip every AgentModels::CONSULTATION_AGENTS file (both
902
- # plastic-advisor and plastic-faux-advisor) by name, a deliberate and
903
- # mechanical scope cut tracked at intent 186 (Codex advisor evaluation), not
904
- # a permanent exclusion and not conditioned on any frontmatter or override
905
- # value.
906
- next if AgentModels::CONSULTATION_AGENTS.include?(basename)
1245
+ next if !advisor_enabled && AgentModels::CONSULTATION_AGENTS.include?(basename)
907
1246
 
908
1247
  dest = File.join(agents_root, "#{basename}.toml")
909
- write_text_atomic(dest, render_codex_agent_toml(src, models[basename]))
1248
+ write_text_atomic(dest, render_codex_agent_toml(src, models[basename], efforts[basename]))
910
1249
  dest
911
1250
  end
912
1251
  end
@@ -914,16 +1253,25 @@ class InstallerCore
914
1253
  # Render one repo agents/*.md into a deterministic Codex agent TOML document. Fixed field
915
1254
  # order (name, description, the model field(s) from codex_model_fields, developer_instructions)
916
1255
  # so regenerate is byte-identical (idempotency).
917
- def render_codex_agent_toml(source_path, override)
1256
+ def render_codex_agent_toml(source_path, override, effort_override = nil)
918
1257
  front, body = split_frontmatter(File.read(source_path))
919
1258
  name = (front["name"] || File.basename(source_path, ".md")).to_s
920
1259
  description = (front["description"] || "").to_s
921
- effective = (override && !override.to_s.empty? ? override : front["model"]).to_s
1260
+ effective = if override && !override.to_s.empty?
1261
+ override
1262
+ else
1263
+ AgentModels.shipped_model_for(name, harness: "codex") || front["model"]
1264
+ end
1265
+ effort = if effort_override && !effort_override.to_s.empty?
1266
+ effort_override
1267
+ else
1268
+ front["effort"] || AgentModels.shipped_effort_for(name)
1269
+ end
922
1270
 
923
1271
  parts = []
924
1272
  parts << %(name = "#{toml_inline_escape(name)}")
925
1273
  parts << %(description = "#{toml_inline_escape(description)}")
926
- parts << codex_model_fields(effective)
1274
+ parts << codex_model_fields(effective, effort: effort)
927
1275
  parts << "developer_instructions = \"\"\"\n#{toml_ml_escape(body.strip)}\n\"\"\""
928
1276
  parts.reject(&:empty?).join("\n") + "\n"
929
1277
  end
@@ -943,20 +1291,20 @@ class InstallerCore
943
1291
  # The model-selection line(s). A known tier alias (opus/sonnet/haiku) emits BOTH a `model` line
944
1292
  # (from AgentModels.codex_model_for, the intent-186 per-role Codex identity) and a
945
1293
  # model_reasoning_effort line, model first for deterministic byte-identical regenerate. Any other
946
- # non-empty value is a literal Codex model id emitted verbatim as `model` only. Empty -> no line
947
- # (the agent inherits the session default). If an alias somehow lacks a mapped model, the effort
948
- # line still emits alone (backward-safe).
949
- def codex_model_fields(effective)
1294
+ # non-empty value is a literal Codex model id. Every non-empty model also emits the resolved effort,
1295
+ # medium by default or the harness-scoped override. Empty emits no model fields.
1296
+ def codex_model_fields(effective, effort: AgentModels::DEFAULT_EFFORT)
950
1297
  return "" if effective.nil? || effective.to_s.empty?
951
- effort = AgentModels.effort_for(effective)
952
- if effort
1298
+ if AgentModels.effort_for(effective)
953
1299
  lines = []
954
1300
  model = AgentModels.codex_model_for(effective)
955
1301
  lines << %(model = "#{toml_inline_escape(model)}") if model && !model.to_s.empty?
956
- lines << %(model_reasoning_effort = "#{effort}")
1302
+ lines << %(model_reasoning_effort = "#{toml_inline_escape(effort)}") unless effort.to_s.empty?
957
1303
  lines.join("\n")
958
1304
  else
959
- %(model = "#{toml_inline_escape(effective.to_s)}")
1305
+ lines = [%(model = "#{toml_inline_escape(effective.to_s)}")]
1306
+ lines << %(model_reasoning_effort = "#{toml_inline_escape(effort)}") unless effort.to_s.empty?
1307
+ lines.join("\n")
960
1308
  end
961
1309
  end
962
1310
 
@@ -1041,7 +1389,7 @@ class InstallerCore
1041
1389
  skills_source = File.join(package_root, "skills")
1042
1390
  skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
1043
1391
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
1044
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
1392
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, efforts: agent_effort_overrides, advisor_enabled: advisor_enabled?)
1045
1393
 
1046
1394
  # Uniform per-agent record (intent 210, D2): write VERSION alongside the manifest,
1047
1395
  # the same shape install_claude already writes.
@@ -1089,10 +1437,9 @@ class InstallerCore
1089
1437
  # them to `installed` before write_manifest (manifest + prune are then automatic).
1090
1438
  # No-op safe: returns [] when the package has no agents dir or it is empty.
1091
1439
  # advisor_enabled: false (advisor.enabled config key) skips every
1092
- # AgentModels::CONSULTATION_AGENTS file entirely (both plastic-advisor and
1093
- # plastic-faux-advisor), so a user who declined the advisor never gets either
1440
+ # AgentModels::CONSULTATION_AGENTS file entirely, so a user who declined the advisor never gets either
1094
1441
  # agent installed.
1095
- def install_agents(agents_root, models: {}, advisor_enabled: true)
1442
+ def install_agents(agents_root, models: {}, efforts: {}, advisor_enabled: true)
1096
1443
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
1097
1444
  return [] if sources.empty?
1098
1445
 
@@ -1104,9 +1451,13 @@ class InstallerCore
1104
1451
  sources.map do |src|
1105
1452
  dest = File.join(agents_root, File.basename(src))
1106
1453
  basename = File.basename(src, ".md")
1107
- override = models[basename]
1108
- if override
1109
- File.write(dest, rewrite_model_line(File.read(src), override))
1454
+ model = models[basename]
1455
+ effort = efforts[basename]
1456
+ if model || effort
1457
+ content = File.read(src)
1458
+ content = rewrite_model_line(content, model) if model
1459
+ content = rewrite_effort_line(content, effort) if effort
1460
+ File.write(dest, content)
1110
1461
  else
1111
1462
  FileUtils.cp(src, dest)
1112
1463
  end
@@ -1120,29 +1471,48 @@ class InstallerCore
1120
1471
  content.sub(/^model:[^\n]*$/, "model: #{model}")
1121
1472
  end
1122
1473
 
1474
+ def rewrite_effort_line(content, effort)
1475
+ return content.sub(/^effort:[^\n]*$/, "effort: #{effort}") if content.match?(/^effort:/)
1476
+
1477
+ content.sub(/^(model:[^\n]*)$/) { "#{Regexp.last_match(1)}\neffort: #{effort}" }
1478
+ end
1479
+
1123
1480
  # Resolve per-agent model overrides for this install: project config (when a
1124
1481
  # project dir is known) overlaid on global config, scoped to `harness`
1125
1482
  # ("claude" or "codex"). Defaults are NOT included, so unconfigured agents
1126
1483
  # keep their shipped frontmatter.
1127
1484
  #
1128
- # Both advisor agents (plastic-advisor, plastic-faux-advisor) resolve through
1485
+ # Both advisor agents resolve through
1129
1486
  # this SAME generic map, like any other agent: a config author sets
1130
- # agents.models.claude.plastic-advisor (or the legacy flat
1131
- # agents.models.plastic-advisor, read as claude) to point either agent at a
1487
+ # agents.models.claude.<agent> (or the legacy flat form, read as Claude) to point either agent at a
1132
1488
  # different literal model. There is no separate advisor-specific model key;
1133
1489
  # which agent the advisor SKILL routes to by default is a routing decision
1134
1490
  # (advisor.claude.default), never a model-selection one.
1135
1491
  def agent_model_overrides(project_dir = nil, harness: "claude")
1136
- global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
1492
+ global_path = File.join(plastic_home, "config.yml")
1493
+ migrate_advisor_config_file(global_path)
1494
+ global_config = load_config_yaml(global_path)
1137
1495
  project_config =
1138
1496
  if project_dir
1139
- load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml"))
1497
+ project_path = File.join(project_dir, ".plastic_store", "config.yml")
1498
+ migrate_advisor_config_file(project_path)
1499
+ load_config_yaml(project_path)
1140
1500
  else
1141
1501
  {}
1142
1502
  end
1143
1503
  AgentModels.override_map(project_config: project_config, global_config: global_config, harness: harness)
1144
1504
  end
1145
1505
 
1506
+ def agent_effort_overrides(project_dir = nil, harness: "claude")
1507
+ global_path = File.join(plastic_home, "config.yml")
1508
+ migrate_advisor_config_file(global_path)
1509
+ global_config = load_config_yaml(global_path)
1510
+ project_path = project_dir && File.join(project_dir, ".plastic_store", "config.yml")
1511
+ migrate_advisor_config_file(project_path) if project_path
1512
+ project_config = project_path ? load_config_yaml(project_path) : {}
1513
+ AgentModels.effort_override_map(project_config: project_config, global_config: global_config, harness: harness)
1514
+ end
1515
+
1146
1516
  # advisor.enabled: project overlays global, missing or malformed counts as
1147
1517
  # enabled (fail-open). Harness-blind: false skips both advisor agents and the
1148
1518
  # agent-advisor skill on every installed harness.
@@ -1154,10 +1524,14 @@ class InstallerCore
1154
1524
  value != false
1155
1525
  end
1156
1526
 
1157
- # Agent-name shorthands for the --advisor flag: the two shipped choices,
1158
- # named for the role (real advisor vs. the cheaper imitation), never a model
1159
- # name.
1160
- ADVISOR_SHORTHANDS = { "real" => "plastic-advisor", "faux" => "plastic-faux-advisor" }.freeze
1527
+ ADVISOR_NAME_MIGRATIONS = {
1528
+ "plastic-advisor" => "plastic-primary-advisor",
1529
+ "plastic-faux-advisor" => "plastic-secondary-advisor"
1530
+ }.freeze
1531
+ ADVISOR_SHORTHANDS = {
1532
+ "primary" => "plastic-primary-advisor", "secondary" => "plastic-secondary-advisor",
1533
+ "real" => "plastic-primary-advisor", "faux" => "plastic-secondary-advisor"
1534
+ }.freeze
1161
1535
 
1162
1536
  # Write advisor.enabled / advisor.claude.default into the global config.yml
1163
1537
  # from install-time flags. Absent flags change nothing: advisor.enabled
@@ -1165,7 +1539,7 @@ class InstallerCore
1165
1539
  # (the skill's own fallback chain applies) when missing.
1166
1540
  # --no-advisor -> advisor.enabled: false
1167
1541
  # --advisor VALUE -> advisor.claude.default: VALUE (an agent name, or the
1168
- # shorthand "real"/"faux")
1542
+ # shorthand "primary"/"secondary")
1169
1543
  def apply_config_flags(argv)
1170
1544
  no_advisor = argv.include?("--no-advisor")
1171
1545
  advisor_idx = argv.index("--advisor")
@@ -1173,7 +1547,13 @@ class InstallerCore
1173
1547
  return unless no_advisor || advisor_value
1174
1548
 
1175
1549
  config_path = File.join(plastic_home, "config.yml")
1176
- config = load_config_yaml(config_path)
1550
+ config = if File.exist?(config_path)
1551
+ parsed = YAML.safe_load(File.read(config_path))
1552
+ return false unless parsed.nil? || parsed.is_a?(Hash)
1553
+ migrate_advisor_config(parsed || {})
1554
+ else
1555
+ {}
1556
+ end
1177
1557
 
1178
1558
  if no_advisor
1179
1559
  config["advisor"] ||= {}
@@ -1188,6 +1568,9 @@ class InstallerCore
1188
1568
 
1189
1569
  FileUtils.mkdir_p(plastic_home)
1190
1570
  File.write(config_path, YAML.dump(config))
1571
+ true
1572
+ rescue StandardError
1573
+ false
1191
1574
  end
1192
1575
 
1193
1576
  def load_config_yaml(path)
@@ -1197,6 +1580,45 @@ class InstallerCore
1197
1580
  {}
1198
1581
  end
1199
1582
 
1583
+ # Renames retired advisor keys without discarding a current key. It accepts
1584
+ # malformed config sections and leaves unrelated values untouched.
1585
+ def migrate_advisor_config(config)
1586
+ return {} unless config.is_a?(Hash)
1587
+ config = Marshal.load(Marshal.dump(config))
1588
+ advisor = config["advisor"]
1589
+ if advisor.is_a?(Hash) && advisor["claude"].is_a?(Hash)
1590
+ default = advisor["claude"]["default"]
1591
+ advisor["claude"]["default"] = ADVISOR_NAME_MIGRATIONS.fetch(default, default)
1592
+ end
1593
+ agents = config["agents"]
1594
+ %w[models efforts].each do |section_name|
1595
+ section = agents.is_a?(Hash) ? agents[section_name] : nil
1596
+ next unless section.is_a?(Hash)
1597
+ migrate_advisor_keys!(section)
1598
+ %w[claude codex].each { |harness| migrate_advisor_keys!(section[harness]) if section[harness].is_a?(Hash) }
1599
+ end
1600
+ config
1601
+ end
1602
+
1603
+ def migrate_advisor_config_file(path)
1604
+ return false unless path && File.file?(path)
1605
+ parsed = YAML.safe_load(File.read(path))
1606
+ return false unless parsed.is_a?(Hash)
1607
+ migrated = migrate_advisor_config(parsed)
1608
+ return false if migrated == parsed
1609
+ File.write(path, YAML.dump(migrated))
1610
+ true
1611
+ rescue StandardError
1612
+ false
1613
+ end
1614
+
1615
+ def migrate_advisor_keys!(section)
1616
+ ADVISOR_NAME_MIGRATIONS.each do |legacy, current|
1617
+ section[current] = section[legacy] if !section.key?(current) && section.key?(legacy)
1618
+ section.delete(legacy)
1619
+ end
1620
+ end
1621
+
1200
1622
  # --- Legacy plugin migration ---
1201
1623
 
1202
1624
  # Earlier versions registered Plastic as a local marketplace plugin
@@ -1293,7 +1715,7 @@ class InstallerCore
1293
1715
  puts " \u{2139}\u{fe0f} Kept #{kept.size} hook(s) Plastic does not own, named with the reserved plastic- prefix:"
1294
1716
  kept.each { |cmd| puts " - #{tilde(cmd.to_s)}" }
1295
1717
  puts " The plastic- prefix is reserved for Plastic's own hooks. Rename yours (for"
1296
- puts " example ~/.claude/hooks/writing-style) so a future update never mistakes it."
1718
+ puts " example ~/.claude/hooks/plain-writing) so a future update never mistakes it."
1297
1719
  end
1298
1720
 
1299
1721
  # --- settings.json merge (read-modify-write, never clobber) ---
@@ -1381,6 +1803,44 @@ class InstallerCore
1381
1803
  removed
1382
1804
  end
1383
1805
 
1806
+ # --- The engine deny rule (intent 340b, G7c, n3) ---
1807
+ #
1808
+ # A second ownership mechanism from merge_claude_hooks above (D4, node n3): a
1809
+ # permissions.deny entry is a bare string with no marker in it, so EnginePermissions
1810
+ # owns its four entries by exact-string membership, not by a plastic- launcher
1811
+ # basename. This pair only does the read-modify-write; EnginePermissions.merge_into
1812
+ # and .remove_from are the pure transforms.
1813
+
1814
+ # Merges EnginePermissions::ENTRIES into settings.json. Unlike merge_claude_hooks,
1815
+ # this refuses rather than starting from {} when the existing file cannot be
1816
+ # parsed (row 3.11): a hand-edited settings file is never silently replaced.
1817
+ # Returns true when it wrote, false when it refused.
1818
+ def merge_engine_permissions(settings_path)
1819
+ if File.exist?(settings_path)
1820
+ settings = read_json_safe(settings_path)
1821
+ return false if settings.nil?
1822
+ else
1823
+ settings = {}
1824
+ end
1825
+
1826
+ write_json_atomic(settings_path, EnginePermissions.merge_into(settings))
1827
+ true
1828
+ end
1829
+
1830
+ # Removes exactly EnginePermissions::ENTRIES from settings.json, leaving every
1831
+ # other deny entry (the owner's own, and any Plastic entry the owner has since
1832
+ # edited) in place. Returns true when it wrote, false on a missing or
1833
+ # unparseable file, or a permissions/deny shape it does not recognize.
1834
+ def remove_engine_permissions(settings_path)
1835
+ return false unless File.exist?(settings_path)
1836
+
1837
+ settings = read_json_safe(settings_path)
1838
+ return false unless settings.is_a?(Hash)
1839
+
1840
+ write_json_atomic(settings_path, EnginePermissions.remove_from(settings))
1841
+ true
1842
+ end
1843
+
1384
1844
  # --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
1385
1845
  # New primitive: markdown marked-section merge, the analog of merge_claude_hooks'
1386
1846
  # JSON read-modify-write for a partial-ownership text file. Three states
@@ -1570,6 +2030,7 @@ class InstallerCore
1570
2030
  if key == "claude"
1571
2031
  settings_path = File.join(config[:dir], "settings.json")
1572
2032
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
2033
+ remove_engine_permissions(settings_path) if File.exist?(settings_path)
1573
2034
  removed.concat(migrate_legacy_plugin(config[:dir]))
1574
2035
 
1575
2036
  # The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
@@ -1718,11 +2179,22 @@ class InstallerCore
1718
2179
 
1719
2180
  def read_json_safe(path)
1720
2181
  return nil unless File.exist?(path)
2182
+
1721
2183
  JSON.parse(File.read(path))
1722
2184
  rescue JSON::ParserError
1723
- # Try JSONC stripping (remove // comments and trailing commas)
1724
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
1725
- JSON.parse(content)
2185
+ # The comment/trailing-comma-stripped retry below can itself raise
2186
+ # JSON::ParserError on genuinely malformed content (a truncated file, or
2187
+ # plain garbage). A nested begin/rescue is required here because a
2188
+ # method-level `rescue` clause never catches an exception raised from
2189
+ # INSIDE a sibling rescue clause's own body (only from the main body);
2190
+ # doctor_core.rb#read_json_safe carries the same fix for the same reason
2191
+ # (intent 331e, F5).
2192
+ begin
2193
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
2194
+ JSON.parse(content)
2195
+ rescue
2196
+ nil
2197
+ end
1726
2198
  rescue
1727
2199
  nil
1728
2200
  end