@zalom/plastic 1.14.1 → 2.0.0-alpha.10

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 (213) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +14 -42
  13. package/hooks/message-display +81 -0
  14. package/hooks/record +4 -0
  15. package/hooks/savepoint +5 -5
  16. package/package.json +3 -2
  17. package/scripts/agent-report +15 -10
  18. package/scripts/append-ledger +208 -0
  19. package/scripts/codex-hook +78 -90
  20. package/scripts/dashboard.rb +43 -14
  21. package/scripts/day-summary +53 -0
  22. package/scripts/doctor.rb +269 -43
  23. package/scripts/end-intent +170 -76
  24. package/scripts/exec-worktree +11 -20
  25. package/scripts/file-session-intent +61 -0
  26. package/scripts/hook-capture +255 -0
  27. package/scripts/hook-close +36 -0
  28. package/scripts/hook-message-display +74 -0
  29. package/scripts/hook-record +157 -0
  30. package/scripts/hook-savepoint +45 -0
  31. package/scripts/hook-session-start +128 -12
  32. package/scripts/install.rb +8 -6
  33. package/scripts/intent-screen +77 -0
  34. package/scripts/lib/agent_models.rb +1 -7
  35. package/scripts/lib/arm.rb +308 -0
  36. package/scripts/lib/backfill_intent.rb +316 -0
  37. package/scripts/lib/bridge.rb +24 -1597
  38. package/scripts/lib/compact_instructions.rb +56 -0
  39. package/scripts/lib/day_summary.rb +211 -0
  40. package/scripts/lib/doctor_core.rb +66 -88
  41. package/scripts/lib/doctor_session_ledger.rb +158 -0
  42. package/scripts/lib/exec_worktree.rb +14 -60
  43. package/scripts/lib/handoff.rb +184 -0
  44. package/scripts/lib/harness_text.rb +5 -4
  45. package/scripts/lib/hook_registry.rb +57 -106
  46. package/scripts/lib/insights.rb +3 -4
  47. package/scripts/lib/installer_core.rb +136 -41
  48. package/scripts/lib/intent_screen.rb +309 -0
  49. package/scripts/lib/intent_screen_ansi.rb +262 -0
  50. package/scripts/lib/lock.rb +6 -26
  51. package/scripts/lib/message_display.rb +290 -0
  52. package/scripts/lib/outcome_guard.rb +7 -5
  53. package/scripts/lib/project_validator.rb +30 -1
  54. package/scripts/lib/report_screen.rb +648 -0
  55. package/scripts/lib/roadmap_queue.rb +3 -3
  56. package/scripts/lib/roadmap_savepoint.rb +1 -1
  57. package/scripts/lib/rule_catalog.rb +6 -3
  58. package/scripts/lib/savepoint.rb +334 -0
  59. package/scripts/lib/scaffold_intent.rb +13 -179
  60. package/scripts/lib/screen_paint.rb +276 -0
  61. package/scripts/lib/session_backfill.rb +184 -0
  62. package/scripts/lib/session_close.rb +104 -0
  63. package/scripts/lib/session_git.rb +500 -0
  64. package/scripts/lib/session_ledger.rb +621 -0
  65. package/scripts/lib/store_discovery.rb +1 -1
  66. package/scripts/lib/store_provisioning.rb +1 -1
  67. package/scripts/maintenance-run +7 -7
  68. package/scripts/new-intent +63 -4
  69. package/scripts/plastic-lock +101 -41
  70. package/scripts/promote-session-item +150 -0
  71. package/scripts/read-config +3 -0
  72. package/scripts/rebuild-graph +1 -1
  73. package/scripts/report-screen +120 -0
  74. package/scripts/roadmap-next +2 -2
  75. package/scripts/rollback.rb +48 -3
  76. package/scripts/savepoint-note +67 -0
  77. package/scripts/scaffold-intent +37 -63
  78. package/scripts/session-commit +116 -0
  79. package/scripts/spawn-preamble +14 -7
  80. package/scripts/update.rb +7 -3
  81. package/scripts/write-handoff +60 -0
  82. package/skills/agent-advisor/SKILL.md +3 -3
  83. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  84. package/skills/auto/SKILL.md +223 -317
  85. package/skills/auto/evals/evals.json +10 -10
  86. package/skills/auto/references/agent-architecture.md +102 -90
  87. package/skills/auto/references/agent-report-contract.md +4 -21
  88. package/skills/auto/references/end-tail.md +47 -53
  89. package/skills/auto/references/human-report-contract.md +62 -56
  90. package/skills/conventions/SKILL.md +1 -3
  91. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  92. package/skills/conventions/references/locks-and-worktrees.md +62 -58
  93. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  94. package/skills/dashboard/SKILL.md +1 -1
  95. package/skills/direct/SKILL.md +66 -0
  96. package/skills/direct/references/request-signals.md +59 -0
  97. package/skills/doctor/SKILL.md +58 -11
  98. package/skills/doctor/report.md +3 -1
  99. package/skills/install/SKILL.md +11 -9
  100. package/skills/intent-continuing/SKILL.md +142 -91
  101. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  102. package/skills/intent-continuing/references/context-management.md +3 -3
  103. package/skills/intent-creating/SKILL.md +2 -2
  104. package/skills/intent-creating/references/lifecycle.md +1 -1
  105. package/skills/intent-ending/SKILL.md +34 -37
  106. package/skills/intent-ending/evals/evals.json +4 -4
  107. package/skills/intent-executing/SKILL.md +15 -46
  108. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  109. package/skills/intent-speccing/SKILL.md +108 -51
  110. package/skills/intent-speccing/evals/evals.json +5 -5
  111. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  112. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  113. package/skills/project-creating/SKILL.md +1 -1
  114. package/skills/releasing/SKILL.md +7 -8
  115. package/skills/tutorial/SKILL.md +2 -2
  116. package/skills/tutorial/references/track-1-guided.md +16 -18
  117. package/skills/tutorial/references/track-2-auto.md +12 -13
  118. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  119. package/templates/agents.md +4 -7
  120. package/templates/config.yml +5 -0
  121. package/templates/intent-screen.md +17 -0
  122. package/templates/outcome.md +14 -1
  123. package/templates/project.yml +7 -0
  124. package/templates/report-state.md +11 -0
  125. package/templates/roadmap.md +1 -1
  126. package/templates/savepoint.md +2 -2
  127. package/templates/session-intent.md +29 -0
  128. package/agents/plastic-brainstorming.md +0 -48
  129. package/agents/plastic-future-intent-researcher.md +0 -35
  130. package/agents/plastic-intent-curator.md +0 -47
  131. package/agents/plastic-intent-discovery.md +0 -43
  132. package/agents/plastic-planner.md +0 -56
  133. package/agents/plastic-spec-specialist.md +0 -53
  134. package/hooks/auto-arm +0 -5
  135. package/hooks/bash-gate +0 -3
  136. package/hooks/continue +0 -31
  137. package/hooks/edit-gates +0 -3
  138. package/hooks/future-intent-check +0 -25
  139. package/hooks/gate-check +0 -12
  140. package/hooks/power-tools +0 -8
  141. package/scripts/hook-auto-arm +0 -51
  142. package/scripts/hook-bash-gate +0 -78
  143. package/scripts/hook-code-gate +0 -38
  144. package/scripts/hook-continue +0 -48
  145. package/scripts/hook-create-gate +0 -51
  146. package/scripts/hook-edit-gates +0 -58
  147. package/scripts/hook-future-intent-check +0 -90
  148. package/scripts/hook-gate-check +0 -169
  149. package/scripts/hook-links-gate +0 -54
  150. package/scripts/hook-lock-gate +0 -47
  151. package/scripts/hook-power-tools +0 -38
  152. package/scripts/hook-savepoint-pre +0 -32
  153. package/scripts/lib/codex_edit_gates.rb +0 -138
  154. package/scripts/lib/edit_gates.rb +0 -398
  155. package/scripts/lib/links_gate.rb +0 -140
  156. package/scripts/lib/qmd_hook.rb +0 -41
  157. package/scripts/lib/spec_header.rb +0 -83
  158. package/scripts/lib/start_intent.rb +0 -296
  159. package/scripts/start-intent +0 -89
  160. package/skills/_active-intent-gate.md +0 -26
  161. package/skills/auto/references/tiers.md +0 -100
  162. package/skills/continuing/SKILL.md +0 -34
  163. package/skills/continuing/evals/evals.json +0 -91
  164. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  165. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  166. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  167. package/skills/intent-brainstorming/SKILL.md +0 -118
  168. package/skills/intent-brainstorming/evals/evals.json +0 -67
  169. package/skills/intent-continuing/evals/evals.json +0 -145
  170. package/skills/intent-discovering/SKILL.md +0 -56
  171. package/skills/intent-grilling/SKILL.md +0 -108
  172. package/skills/intent-linking/SKILL.md +0 -128
  173. package/skills/intent-linking/evals/evals.json +0 -22
  174. package/skills/intent-linking/references/zettelkasten.md +0 -45
  175. package/skills/intent-locking/SKILL.md +0 -64
  176. package/skills/intent-planning/SKILL.md +0 -145
  177. package/skills/intent-planning/evals/evals.json +0 -81
  178. package/skills/intent-planning/references/plan-format.md +0 -130
  179. package/skills/intent-researching/SKILL.md +0 -123
  180. package/skills/intent-researching/evals/evals.json +0 -22
  181. package/skills/intent-savepoint/SKILL.md +0 -83
  182. package/skills/intent-savepoint/references/context-management.md +0 -32
  183. package/skills/intent-starting/SKILL.md +0 -151
  184. package/skills/intent-starting/evals/evals.json +0 -117
  185. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  186. package/skills/project-continuing/SKILL.md +0 -119
  187. package/skills/project-continuing/evals/evals.json +0 -100
  188. package/skills/roadmap-continuing/SKILL.md +0 -89
  189. package/skills/roadmap-continuing/evals/evals.json +0 -82
  190. package/skills/skill-creating/SKILL.md +0 -75
  191. package/skills/skill-creating/evals/evals.json +0 -108
  192. package/skills/skill-creating/references/agents.md +0 -168
  193. package/skills/skill-creating/references/defaults-first.md +0 -23
  194. package/skills/skill-creating/references/evals.md +0 -41
  195. package/skills/skill-creating/references/hooks.md +0 -251
  196. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  197. package/skills/skill-creating/references/scripts.md +0 -166
  198. package/skills/skill-creating/references/skills.md +0 -169
  199. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  200. package/skills/skill-evaluating/SKILL.md +0 -141
  201. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  202. package/skills/skill-evaluating/evals/evals.json +0 -75
  203. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  204. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  205. package/skills/store-curating/SKILL.md +0 -64
  206. package/skills/store-curating/evals/evals.json +0 -38
  207. package/skills/store-indexing/SKILL.md +0 -93
  208. package/skills/store-indexing/evals/evals.json +0 -22
  209. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  210. package/skills/store-provisioning/SKILL.md +0 -55
  211. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  212. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  213. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -9,6 +9,7 @@ require "time"
9
9
  require_relative "hook_registry"
10
10
  require_relative "agent_models"
11
11
  require_relative "harness_text"
12
+ require_relative "compact_instructions"
12
13
 
13
14
  # Shared installer machinery, instantiable with injected package root / store / agent
14
15
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
@@ -33,6 +34,15 @@ class InstallerCore
33
34
  # Regex matching exactly one managed section (BEGIN line .. END line), non-greedy.
34
35
  CODEX_SECTION_RE = /^<!-- BEGIN PLASTIC INTEGRATION.*?-->\n.*?\n<!-- END PLASTIC INTEGRATION -->\n?/m
35
36
 
37
+ # Claude CLAUDE.md marked-section markers (intent 312). A pair of its own, not the
38
+ # Codex literals: the two managed files can be one file (a user who symlinks
39
+ # ~/.claude/CLAUDE.md at ~/.codex/AGENTS.md, or the reverse), and a shared literal
40
+ # would let one body silently replace the other and an uninstall of one strip both.
41
+ # doctor_core.rb matches these literals structurally, so keep the two in sync by hand.
42
+ CLAUDE_SECTION_BEGIN_PREFIX = "<!-- BEGIN PLASTIC COMPACT"
43
+ CLAUDE_SECTION_END = "<!-- END PLASTIC COMPACT -->"
44
+ CLAUDE_SECTION_RE = /^<!-- BEGIN PLASTIC COMPACT.*?-->\n.*?\n<!-- END PLASTIC COMPACT -->\n?/m
45
+
36
46
  # Curated essentials plus a pointer to ~/.plastic/PLASTIC.md and the plastic-conventions
37
47
  # skill, injected into ~/.codex/AGENTS.md. Not a slice of PLASTIC.md itself: AGENTS.md is
38
48
  # a shared file Codex merges from multiple sources, so this block stays a small,
@@ -66,8 +76,25 @@ class InstallerCore
66
76
  @agents = agents
67
77
  end
68
78
 
79
+ # Reads the package version from `package.json` when present (the source
80
+ # tree, and any dev checkout), falling back to the bare `VERSION` file the
81
+ # installer copies to `~/.plastic` (row B, spec 315b): an installed system
82
+ # carries VERSION but never package.json, so `rollback.rb` and `update.rb`
83
+ # constructing an InstallerCore with `package_root: ~/.plastic` crashed with
84
+ # a raw Errno::ENOENT before this fallback existed. Raises a named error,
85
+ # not a bare ENOENT, when neither file exists, so the caller is not
86
+ # misdirected toward the wrong missing file.
69
87
  def read_package_version(root)
70
- File.read(File.join(root, "package.json")).then { |s| JSON.parse(s)["version"] }
88
+ package_json = File.join(root, "package.json")
89
+ version_file = File.join(root, "VERSION")
90
+
91
+ if File.exist?(package_json)
92
+ JSON.parse(File.read(package_json))["version"]
93
+ elsif File.exist?(version_file)
94
+ File.read(version_file).strip
95
+ else
96
+ raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
97
+ end
71
98
  end
72
99
 
73
100
  # --- Channel derivation (the channel is encoded in the version string) ---
@@ -174,13 +201,6 @@ class InstallerCore
174
201
  keys.uniq
175
202
  end
176
203
 
177
- def channel_from(argv, default: "latest")
178
- return "alpha" if argv.include?("--alpha")
179
- return "beta" if argv.include?("--beta")
180
- return "latest" if argv.include?("--latest")
181
- default
182
- end
183
-
184
204
  def prompt_agents(input: $stdin)
185
205
  return ["claude"] unless input.tty?
186
206
 
@@ -329,25 +349,21 @@ class InstallerCore
329
349
  "scripts/write-config" => "scripts/write-config",
330
350
  "scripts/select-update-target" => "scripts/select-update-target",
331
351
  "scripts/hook-session-start" => "scripts/hook-session-start",
332
- "scripts/hook-continue" => "scripts/hook-continue",
333
- "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
334
- "scripts/hook-gate-check" => "scripts/hook-gate-check",
335
- "scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
336
- "scripts/hook-power-tools" => "scripts/hook-power-tools",
337
- "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
352
+ "scripts/hook-capture" => "scripts/hook-capture",
353
+ "scripts/hook-record" => "scripts/hook-record",
354
+ "scripts/hook-close" => "scripts/hook-close",
338
355
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
339
356
  "scripts/lib/ruby_probe.rb" => "scripts/lib/ruby_probe.rb",
340
357
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
341
358
  "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
342
359
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
343
- "scripts/hook-code-gate" => "scripts/hook-code-gate",
344
- "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
345
- "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
346
- "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
347
360
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
361
+ "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
362
+ "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
348
363
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
349
364
  "scripts/plastic-lock" => "scripts/plastic-lock",
350
365
  "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
366
+ "scripts/lib/compact_instructions.rb" => "scripts/lib/compact_instructions.rb",
351
367
  "scripts/agent-report" => "scripts/agent-report",
352
368
  "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
353
369
  "scripts/insight-append" => "scripts/insight-append",
@@ -380,16 +396,13 @@ class InstallerCore
380
396
  "scripts/validate-intent" => "scripts/validate-intent",
381
397
  "scripts/new-intent" => "scripts/new-intent",
382
398
  "scripts/end-intent" => "scripts/end-intent",
383
- "scripts/hook-create-gate" => "scripts/hook-create-gate",
384
- "scripts/hook-links-gate" => "scripts/hook-links-gate",
385
- "scripts/lib/links_gate.rb" => "scripts/lib/links_gate.rb",
386
- "scripts/lib/edit_gates.rb" => "scripts/lib/edit_gates.rb",
387
- "scripts/hook-edit-gates" => "scripts/hook-edit-gates",
388
399
  "scripts/lib/apply_patch_envelope.rb" => "scripts/lib/apply_patch_envelope.rb",
389
- "scripts/lib/codex_edit_gates.rb" => "scripts/lib/codex_edit_gates.rb",
390
400
  "scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
391
401
  "scripts/codex-hook" => "scripts/codex-hook",
392
402
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
403
+ "scripts/lib/report_screen.rb" => "scripts/lib/report_screen.rb",
404
+ "scripts/report-screen" => "scripts/report-screen",
405
+ "scripts/savepoint-note" => "scripts/savepoint-note",
393
406
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
394
407
  "scripts/provision-project-store" => "scripts/provision-project-store",
395
408
  "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
@@ -401,24 +414,47 @@ class InstallerCore
401
414
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
402
415
  "scripts/rollback.rb" => "scripts/rollback.rb",
403
416
  "scripts/lib/outcome_guard.rb" => "scripts/lib/outcome_guard.rb",
404
- "scripts/lib/spec_header.rb" => "scripts/lib/spec_header.rb",
405
417
  "scripts/lib/scaffold_intent.rb" => "scripts/lib/scaffold_intent.rb",
418
+ "scripts/lib/backfill_intent.rb" => "scripts/lib/backfill_intent.rb",
406
419
  "scripts/scaffold-intent" => "scripts/scaffold-intent",
407
420
  "scripts/lib/verify_intent.rb" => "scripts/lib/verify_intent.rb",
408
421
  "scripts/verify-intent" => "scripts/verify-intent",
409
- "scripts/lib/start_intent.rb" => "scripts/lib/start_intent.rb",
410
- "scripts/start-intent" => "scripts/start-intent",
411
422
  "scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
412
423
  "scripts/exec-worktree" => "scripts/exec-worktree",
413
424
  "scripts/doctor.rb" => "scripts/doctor.rb",
414
425
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
415
426
  "scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
416
427
  "scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
428
+ "scripts/lib/doctor_session_ledger.rb" => "scripts/lib/doctor_session_ledger.rb",
417
429
  "scripts/dashboard.rb" => "scripts/dashboard.rb",
418
430
  "scripts/skill-lint" => "scripts/skill-lint",
419
431
  "scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
420
432
  "scripts/feedback-report" => "scripts/feedback-report",
421
433
  "scripts/lib/feedback_report.rb" => "scripts/lib/feedback_report.rb",
434
+ "scripts/append-ledger" => "scripts/append-ledger",
435
+ "scripts/lib/session_ledger.rb" => "scripts/lib/session_ledger.rb",
436
+ "scripts/session-commit" => "scripts/session-commit",
437
+ "scripts/file-session-intent" => "scripts/file-session-intent",
438
+ "scripts/promote-session-item" => "scripts/promote-session-item",
439
+ "scripts/lib/session_backfill.rb" => "scripts/lib/session_backfill.rb",
440
+ "scripts/lib/session_close.rb" => "scripts/lib/session_close.rb",
441
+ "scripts/lib/session_git.rb" => "scripts/lib/session_git.rb",
442
+ "scripts/lib/handoff.rb" => "scripts/lib/handoff.rb",
443
+ "scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
444
+ "scripts/write-handoff" => "scripts/write-handoff",
445
+ "scripts/day-summary" => "scripts/day-summary",
446
+ "scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
447
+ "scripts/intent-screen" => "scripts/intent-screen",
448
+ "scripts/hook-savepoint" => "scripts/hook-savepoint",
449
+ # Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
450
+ # (see hook_files above), so the two lib files a require_relative
451
+ # between themselves are unguarded there — these three literal
452
+ # entries are their only protection (test/install_sync_test.rb:23-29
453
+ # greps installer_core.rb's own source text for "scripts/<name>").
454
+ "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
455
+ "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
456
+ "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
457
+ "scripts/hook-message-display" => "scripts/hook-message-display",
422
458
  }
423
459
  end
424
460
 
@@ -432,6 +468,8 @@ class InstallerCore
432
468
  version: 3
433
469
  execution_mode: subagent-driven
434
470
  stale_threshold_days: 3
471
+ context_offer_tokens: 350000
472
+ context_insist_tokens: 500000
435
473
  hash_length: 6
436
474
  hash_algorithm: sha256-base36
437
475
  max_slug_words: 5
@@ -718,7 +756,7 @@ class InstallerCore
718
756
  end
719
757
 
720
758
  # `install_skills_flat` relocates any top-level underscore-prefixed markdown
721
- # fragment (today: `_active-intent-gate.md`, `_decision-tables.md`) out of the
759
+ # fragment (today: `_decision-tables.md`) out of the
722
760
  # per-agent skills tree and into `plastic_home` directly, then lists them in
723
761
  # `installed`, so they are manifest-tracked and eligible for prune. But
724
762
  # `install_for_agent`'s prune roots are `[config[:dir], config[:home_dir]]`,
@@ -790,6 +828,11 @@ class InstallerCore
790
828
  choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
791
829
  merge_claude_hooks(settings_path, choice: choice)
792
830
 
831
+ # Instruction injection (intent 312): the compact-instructions block into
832
+ # ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
833
+ # (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
834
+ inject_claude_compact_md(File.join(config[:dir], "CLAUDE.md"))
835
+
793
836
  # Write manifest
794
837
  manifest_path = File.join(plastic_dir, "manifest.json")
795
838
  write_manifest(installed, manifest_path)
@@ -1012,7 +1055,7 @@ class InstallerCore
1012
1055
 
1013
1056
  # Copy each skills/<name>/ to <skills_root>/plastic-<name>/ (flat, namespaced by
1014
1057
  # directory name -- the only personal-skill namespacing Claude Code supports).
1015
- # Any top-level underscore-prefixed markdown fragment (e.g. `_active-intent-gate.md`,
1058
+ # Any top-level underscore-prefixed markdown fragment (e.g. `_decision-tables.md`,
1016
1059
  # `_decision-tables.md`) is a shared non-skill fragment and relocates to ~/.plastic/
1017
1060
  # instead, so every skill can read it from one shared location. `exclude` skips
1018
1061
  # named top-level skill directories entirely (intent 185: the agent-advisor skill
@@ -1273,8 +1316,8 @@ class InstallerCore
1273
1316
  plastic_hooks.each do |event, group|
1274
1317
  hooks[event] ||= []
1275
1318
 
1276
- # An event may map to a LIST of plastic groups (PreToolUse carries the
1277
- # code-gate AND the create-gate). The purge pass above already removed all
1319
+ # An event may map to a LIST of plastic groups (none does since the edit-path
1320
+ # gates left in 2.0, intent 302; the shape stays). The purge pass above already removed all
1278
1321
  # prior plastic groups, so appending each desired group fresh is idempotent
1279
1322
  # across re-runs and never collapses two matchers into one group.
1280
1323
  groups = group.is_a?(Array) ? group : [group]
@@ -1355,14 +1398,28 @@ class InstallerCore
1355
1398
  raise e
1356
1399
  end
1357
1400
 
1358
- def codex_section(body: CODEX_AGENTS_MD_BODY)
1401
+ # A managed instruction file may be a symlink into a dotfiles repo. write_text_atomic
1402
+ # renames a temp file over its argument, which would replace the link with a regular
1403
+ # file and silently detach it, so every read and write resolves the link first and the
1404
+ # change lands on its target (intent 312).
1405
+ def resolve_managed_path(path)
1406
+ File.symlink?(path) ? File.realpath(path) : path
1407
+ rescue Errno::ENOENT
1408
+ path
1409
+ end
1410
+
1411
+ def marked_section(body: CODEX_AGENTS_MD_BODY, begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1412
+ end_marker: CODEX_SECTION_END)
1359
1413
  hash = Digest::SHA256.hexdigest(body)[0, 12]
1360
- "#{CODEX_SECTION_BEGIN_PREFIX} hash:#{hash} -->\n#{body.strip}\n#{CODEX_SECTION_END}\n"
1414
+ "#{begin_prefix} hash:#{hash} -->\n#{body.strip}\n#{end_marker}\n"
1361
1415
  end
1362
1416
 
1363
1417
  # Returns :created / :appended / :replaced / :refused. Never raises on a normal user file.
1364
- def inject_codex_agents_md(path, body: CODEX_AGENTS_MD_BODY)
1365
- section = codex_section(body: body)
1418
+ def inject_marked_section(path, body: CODEX_AGENTS_MD_BODY,
1419
+ begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1420
+ end_marker: CODEX_SECTION_END, section_re: CODEX_SECTION_RE)
1421
+ section = marked_section(body: body, begin_prefix: begin_prefix, end_marker: end_marker)
1422
+ path = resolve_managed_path(path)
1366
1423
 
1367
1424
  unless File.exist?(path)
1368
1425
  FileUtils.mkdir_p(File.dirname(path))
@@ -1371,14 +1428,14 @@ class InstallerCore
1371
1428
  end
1372
1429
 
1373
1430
  content = File.read(path)
1374
- has_begin = content.include?(CODEX_SECTION_BEGIN_PREFIX)
1375
- has_end = content.include?(CODEX_SECTION_END)
1431
+ has_begin = content.include?(begin_prefix)
1432
+ has_end = content.include?(end_marker)
1376
1433
 
1377
1434
  # 22a safety rule: never write if the existing section cannot be parsed.
1378
1435
  return :refused if has_begin && !has_end
1379
1436
 
1380
1437
  if has_begin
1381
- write_text_atomic(path, content.sub(CODEX_SECTION_RE, section))
1438
+ write_text_atomic(path, content.sub(section_re, section))
1382
1439
  :replaced
1383
1440
  else
1384
1441
  base = content.end_with?("\n") ? content : content + "\n"
@@ -1387,18 +1444,41 @@ class InstallerCore
1387
1444
  end
1388
1445
  end
1389
1446
 
1447
+ # --- The two blocks Plastic ships, each with its own marker pair ---
1448
+
1449
+ def codex_section(body: CODEX_AGENTS_MD_BODY)
1450
+ marked_section(body: body)
1451
+ end
1452
+
1453
+ def inject_codex_agents_md(path, body: CODEX_AGENTS_MD_BODY)
1454
+ inject_marked_section(path, body: body)
1455
+ end
1456
+
1457
+ # The compact-instructions block for ~/.claude/CLAUDE.md (intent 312).
1458
+ def claude_compact_section(body: CompactInstructions::BODY)
1459
+ marked_section(body: body, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1460
+ end_marker: CLAUDE_SECTION_END)
1461
+ end
1462
+
1463
+ def inject_claude_compact_md(path, body: CompactInstructions::BODY)
1464
+ inject_marked_section(path, body: body, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1465
+ end_marker: CLAUDE_SECTION_END, section_re: CLAUDE_SECTION_RE)
1466
+ end
1467
+
1390
1468
  # Remove exactly Plastic's managed section from a user-owned AGENTS.md. Preserve all other
1391
1469
  # content. Delete the file only if Plastic created it and nothing else remains. Returns the
1392
1470
  # path when it acted, nil on no-op. Mirrors remove_claude_hooks: dedicated surgical strip,
1393
1471
  # never the manifest whole-file-delete path.
1394
- def strip_codex_section(path)
1472
+ def strip_marked_section(path, begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1473
+ section_re: CODEX_SECTION_RE)
1474
+ path = resolve_managed_path(path)
1395
1475
  return nil unless File.exist?(path)
1396
1476
  content = File.read(path)
1397
- return nil unless content.include?(CODEX_SECTION_BEGIN_PREFIX)
1477
+ return nil unless content.include?(begin_prefix)
1398
1478
 
1399
1479
  # Remove the section plus the single separator newline the append introduced, so a
1400
1480
  # standard user file round-trips byte-identical.
1401
- stripped = content.sub(/\n?#{CODEX_SECTION_RE}/, "")
1481
+ stripped = content.sub(/\n?#{section_re}/, "")
1402
1482
 
1403
1483
  if stripped.strip.empty?
1404
1484
  File.delete(path) # Plastic-created file: nothing else left
@@ -1409,6 +1489,15 @@ class InstallerCore
1409
1489
  path
1410
1490
  end
1411
1491
 
1492
+ def strip_codex_section(path)
1493
+ strip_marked_section(path)
1494
+ end
1495
+
1496
+ def strip_claude_compact_section(path)
1497
+ strip_marked_section(path, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1498
+ section_re: CLAUDE_SECTION_RE)
1499
+ end
1500
+
1412
1501
  # --- Uninstall ---
1413
1502
 
1414
1503
  def handle_uninstall(uninstall_agents)
@@ -1435,6 +1524,7 @@ class InstallerCore
1435
1524
  puts " ls ~/.claude/skills | grep '^plastic-' # → no output"
1436
1525
  puts " ls ~/.claude/hooks | grep '^plastic-' # → no output"
1437
1526
  puts " grep -c plastic ~/.claude/settings.json # → only hook refs gone"
1527
+ puts " grep 'PLASTIC COMPACT' ~/.claude/CLAUDE.md # → no output"
1438
1528
  puts "\n To also delete your intent store: rm -rf #{tilde(plastic_home)}\n\n"
1439
1529
  end
1440
1530
 
@@ -1481,6 +1571,11 @@ class InstallerCore
1481
1571
  settings_path = File.join(config[:dir], "settings.json")
1482
1572
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
1483
1573
  removed.concat(migrate_legacy_plugin(config[:dir]))
1574
+
1575
+ # The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
1576
+ # surgical strip, never the manifest whole-file-delete path above.
1577
+ stripped = strip_claude_compact_section(File.join(config[:dir], "CLAUDE.md"))
1578
+ removed << stripped if stripped
1484
1579
  end
1485
1580
 
1486
1581
  # Codex: surgically strip Plastic's marked section from the user-owned AGENTS.md
@@ -0,0 +1,309 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+ # IntentScreen (intent 316) - fills templates/intent-screen.md from one intent's
4
+ # record: the intent file, the tier's INDEX.md, savepoint.md and checklist.md.
5
+ # Every number on the screen comes from here so the session never writes one by
6
+ # eye. Pure: explicit paths in, a Markdown string out; no ENV, no Dir.pwd.
7
+ #
8
+ # Intent 316a fixed three defects the field code inherited into both the plain
9
+ # renderer and the ANSI renderer (scripts/lib/intent_screen_ansi.rb): the
10
+ # Insight row dumping a multi-clause remainder into the note column, an empty
11
+ # "What this means" heading rendering bold with nothing under it, and step
12
+ # text cut mid-sentence. `step_text`, `insight_fields` and `next_fields` are
13
+ # public so the ANSI renderer reuses the exact same trims (D3) rather than
14
+ # re-deriving them and drifting.
15
+ #
16
+ # Harness-agnostic core: no harness assumption lives here.
17
+ module IntentScreen
18
+ BAR_WIDTH = 20
19
+ ON = "█"
20
+ OFF = "░"
21
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
22
+ SECTIONS = %w[Active Future Completed Abandoned].freeze
23
+ ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
24
+ # Em dash and en dash added (intent 316a O1e): a checklist item written
25
+ # "S1 — text" (the em dash every checklist this intent writes, and the one a
26
+ # reviewer reads, uses) kept its prefix under the old character class and
27
+ # rendered "S1 [ open ] S1 — text" on screen. Intent 317a (A1) made the
28
+ # separator optional with required whitespace: "S1 text" (the shape real
29
+ # checklists use) kept its label and doubled in next_fields; a bare "S1"
30
+ # has no following text and stays prose.
31
+ STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*(?:[-:·—–]\s*|\s+)(?=\S)/i
32
+ INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
33
+ SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
34
+ # Intent 317, D6: field-2 tokens that are genuine lifecycle stages. A ledger
35
+ # can also carry non-lifecycle lines (`Lock takeover: ...`, and 317's own
36
+ # `Review`/`Commit`); those must never be mistaken for the current stage.
37
+ # Placed here, clear of STEP_PREFIX_RE above (316a edits that one).
38
+ LIFECYCLE_STAGES = %w[What Why How Exec Done].freeze
39
+
40
+ # True iff a raw savepoint ledger line's field 2 is a genuine lifecycle
41
+ # stage (post-execution-review finding 5: centralizes what spawn-preamble
42
+ # and agent-report used to each copy-paste beside the constant above).
43
+ def self.lifecycle_line?(line)
44
+ parts = line.to_s.split(/\s{2,}/)
45
+ parts.length >= 2 && LIFECYCLE_STAGES.include?(parts[1])
46
+ end
47
+
48
+ # Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
49
+ # trim: a clause trim on step text destroys a pinned `OPEN:` row
50
+ # (test/intent_screen_test.rb:171-178) that a mid-sentence cut would eat.
51
+ INSIGHT_VALUE_MAX = 72
52
+ INSIGHT_NOTE_MAX = 96
53
+ NEXT_VALUE_MAX = 72
54
+ STEP_TEXT_MAX = 110
55
+
56
+ # Where a resume lands, from the ledger's last line (the boarding matrix).
57
+ def self.landing_stage(stage, milestone)
58
+ case stage
59
+ when "Done" then "Done"
60
+ when "What" then "Why"
61
+ when "Why" then milestone.to_s.include?("spec.md") ? "How" : "Why"
62
+ when "How" then milestone.to_s.include?("checklist.md") ? "Exec" : "How"
63
+ when "Exec" then milestone.to_s.include?("outcome.md") ? "ready to complete" : "Exec"
64
+ else "Why"
65
+ end
66
+ end
67
+
68
+ def self.intent_dir?(dir)
69
+ return false unless dir && File.directory?(dir)
70
+
71
+ base = File.basename(dir)
72
+ return false unless base.match?(/\A[0-9][0-9a-z]*--[\w-]+\z/)
73
+
74
+ File.exist?(File.join(dir, "#{base}.md"))
75
+ end
76
+
77
+ def self.render(intent_dir:, store_root:, template:)
78
+ base = File.basename(intent_dir)
79
+ id = base.split("--", 2).first
80
+ intent_text = File.read(File.join(intent_dir, "#{base}.md"))
81
+
82
+ fields = {}
83
+ fields.merge!(store_fields(store_root))
84
+ status, title = index_fields(store_root, id)
85
+ fields["status"] = status
86
+ fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
87
+ fields["id"] = id
88
+ fields["name"] = title || fallback_name(intent_text)
89
+ fields.merge!(savepoint_fields(intent_dir, intent_text))
90
+ items = checklist_items(intent_dir)
91
+ fields.merge!(progress_fields(items))
92
+ fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
93
+ fields.merge!(insight_fields(intent_text))
94
+ fields["steps.rows"] = steps_rows(items)
95
+
96
+ out = template.dup
97
+ fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
98
+ out.gsub(/\n{3,}/, "\n\n")
99
+ end
100
+
101
+ # --- store ---------------------------------------------------------------------
102
+
103
+ def self.store_fields(store_root)
104
+ parent = File.basename(File.dirname(store_root))
105
+ if parent == "projects"
106
+ slug = File.basename(store_root)
107
+ { "store" => "project:#{slug}", "store.note" => "the #{slug} project store" }
108
+ else
109
+ { "store" => "global", "store.note" => "the global store" }
110
+ end
111
+ end
112
+
113
+ # --- INDEX.md -----------------------------------------------------------------
114
+
115
+ def self.index_fields(store_root, id)
116
+ path = File.join(store_root, "INDEX.md")
117
+ return ["unlisted", nil] unless File.exist?(path)
118
+
119
+ section = nil
120
+ File.foreach(path) do |line|
121
+ if line.start_with?("## ")
122
+ name = line[3..].strip
123
+ section = SECTIONS.include?(name) ? name : nil
124
+ next
125
+ end
126
+ next unless section && line.strip.start_with?("- [")
127
+
128
+ m = line.match(/\A\s*- \[#{Regexp.escape(id)}\s+[-—]\s+(.+?)\]\(/)
129
+ return [section, m[1].strip] if m
130
+ end
131
+ ["unlisted", nil]
132
+ end
133
+
134
+ def self.fallback_name(intent_text)
135
+ m = intent_text.match(/^intent:\s*["']?(.+?)["']?\s*$/)
136
+ text = m ? m[1] : ""
137
+ text.length > 60 ? "#{text[0, 57]}..." : text
138
+ end
139
+
140
+ # --- savepoint.md ---------------------------------------------------------------
141
+
142
+ def self.savepoint_fields(intent_dir, intent_text)
143
+ path = File.join(intent_dir, "savepoint.md")
144
+ lines = File.exist?(path) ? File.readlines(path).map(&:strip).reject(&:empty?) : []
145
+ matched = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact
146
+ last = matched.first
147
+ unless last
148
+ return { "stage" => "Why", "stage.note" => "no savepoint line yet",
149
+ "savepoint" => "none", "savepoint.note" => "" }
150
+ end
151
+
152
+ # D6: the STAGE PICK is guarded to the last LIFECYCLE line (What/Why/How/
153
+ # Exec/Done), so a trailing Lock/Review/Commit line cannot be mistaken for
154
+ # the stage. The Savepoint field below still shows the TRUE last line,
155
+ # whatever its kind - that is what a savepoint is.
156
+ lifecycle_last = matched.find { |m| LIFECYCLE_STAGES.include?(m[2]) }
157
+ stage_source = lifecycle_last || last
158
+
159
+ ts, stage, milestone = last[1], last[2], last[3]
160
+ landing = landing_stage(stage_source[2], stage_source[3])
161
+ delivered = lines.map { |l| l.match(SAVEPOINT_RE) }.compact.map { |m| m[2] }.uniq
162
+ delivered &= %w[What Why How Exec]
163
+ note = if landing == "Done"
164
+ "delivered; the record is immutable"
165
+ elsif landing == "ready to complete"
166
+ "outcome.md is real; run the ending procedure"
167
+ else
168
+ "#{delivered.join(', ')} delivered; the work is open"
169
+ end
170
+ { "stage" => landing, "stage.note" => note,
171
+ "savepoint" => "#{stage} · #{milestone}", "savepoint.note" => human_time(ts) }
172
+ end
173
+
174
+ def self.human_time(ts)
175
+ m = ts.match(/\A(\d{4}-\d\d-\d\d)T(\d\d:\d\d)/)
176
+ m ? "#{m[1]} #{m[2]} UTC" : ts
177
+ end
178
+
179
+ # --- checklist.md --------------------------------------------------------------
180
+
181
+ def self.items_present?(intent_dir)
182
+ path = File.join(intent_dir, "checklist.md")
183
+ return false unless File.exist?(path)
184
+
185
+ !File.read(path).lstrip.start_with?(PLACEHOLDER_SENTINEL)
186
+ end
187
+
188
+ def self.checklist_items(intent_dir)
189
+ return [] unless items_present?(intent_dir)
190
+
191
+ File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
192
+ m = line.match(ITEM_RE)
193
+ next unless m
194
+
195
+ text = m[2].strip
196
+ next if text == "..."
197
+
198
+ { done: m[1] != " ", text: text.sub(STEP_PREFIX_RE, "") }
199
+ end
200
+ end
201
+
202
+ def self.progress_fields(items)
203
+ total = items.length
204
+ done = items.count { |i| i[:done] }
205
+ on = total.zero? ? 0 : (done * BAR_WIDTH) / total
206
+ bar = (ON * on) + (OFF * (BAR_WIDTH - on))
207
+ note = if total.zero?
208
+ "no checklist yet"
209
+ elsif done == total
210
+ "all steps done"
211
+ else
212
+ "#{total - done} steps open"
213
+ end
214
+ { "progress.bar" => bar, "progress.done" => done.to_s, "progress.total" => total.to_s,
215
+ "progress.note" => note }
216
+ end
217
+
218
+ # `escape_pipes:` (intent 316a O1d, default true) keeps the plain Markdown
219
+ # table's pipe-escaping; the ANSI renderer, which never emits a table,
220
+ # passes `escape_pipes: false` to get the raw value instead of a literal
221
+ # `\|`. Named to not shadow the module's own `escape` method.
222
+ def self.next_fields(items, status, checklist_present:, escape_pipes: true)
223
+ return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
224
+ return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
225
+
226
+ idx = items.index { |i| !i[:done] }
227
+ return { "next" => "", "next.note" => "all steps done" } unless idx
228
+
229
+ head, = split_first_clause(items[idx][:text])
230
+ head = truncate_words(head, NEXT_VALUE_MAX)
231
+ head = escape(head) if escape_pipes
232
+ { "next" => "S#{idx + 1} · #{head}", "next.note" => "first open step" }
233
+ end
234
+
235
+ def self.steps_rows(items)
236
+ return "| | | no steps yet |" if items.empty?
237
+
238
+ items.each_with_index.map do |item, i|
239
+ "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(step_text(item[:text]))} |"
240
+ end.join("\n")
241
+ end
242
+
243
+ # Public (intent 316a O1c) so the ANSI renderer trims step text identically:
244
+ # word-boundary truncation only, never a clause trim, at STEP_TEXT_MAX.
245
+ def self.step_text(text)
246
+ truncate_words(text, STEP_TEXT_MAX)
247
+ end
248
+
249
+ def self.escape(text)
250
+ text.gsub("|", "\\|")
251
+ end
252
+
253
+ # --- ## Insights ----------------------------------------------------------------
254
+
255
+ def self.insight_fields(intent_text, escape_pipes: true)
256
+ section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
257
+ entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
258
+ return { "insight" => "none yet", "insight.note" => "" } unless entry
259
+
260
+ ts, text = entry[1], entry[2].strip
261
+ head, tail = split_first_clause(text)
262
+ value = truncate_words(head, INSIGHT_VALUE_MAX)
263
+ tail = tail.empty? ? "" : truncate_words(tail, INSIGHT_NOTE_MAX)
264
+ note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
265
+ if escape_pipes
266
+ { "insight" => escape(value), "insight.note" => escape(note) }
267
+ else
268
+ { "insight" => value, "insight.note" => note }
269
+ end
270
+ end
271
+
272
+ # First clause of `text`, and at most one following clause as the tail.
273
+ # Anything past the second clause is discarded (intent 316a O1a): the old
274
+ # behavior dumped the ENTIRE remainder into the note (an 800-character
275
+ # real-world tail starting mid-list). Boundary is a `.` or `;` immediately
276
+ # followed by whitespace-then-more or end of string, so "alpha.2" and "2.0"
277
+ # are never mistaken for clause ends.
278
+ def self.split_first_clause(text)
279
+ head, rest = clause_and_rest(text)
280
+ return [head, ""] unless rest
281
+
282
+ second, more = clause_and_rest(rest)
283
+ tail = more ? second : rest
284
+ [head, tail]
285
+ end
286
+
287
+ # Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
288
+ # the remainder means either no boundary exists at all, or the boundary
289
+ # sits at the absolute end of `text` (a single trailing clause with nothing
290
+ # after it) — both cases where there is no SECOND clause to fold in.
291
+ def self.clause_and_rest(text)
292
+ m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
293
+ return [text, nil] unless m
294
+
295
+ remainder = m[2].to_s.strip
296
+ remainder.empty? ? [m[1], nil] : [m[1], remainder]
297
+ end
298
+
299
+ # Word-boundary truncation with a trailing "…" when cut, never mid-word and
300
+ # never a clause trim (intent 316a D3).
301
+ def self.truncate_words(text, max)
302
+ return text if text.length <= max
303
+ return "…" if max <= 1
304
+
305
+ cut = text[0, max - 1].rindex(" ")
306
+ cut = max - 1 if cut.nil? || cut.zero?
307
+ "#{text[0, cut].rstrip}…"
308
+ end
309
+ end