@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
@@ -0,0 +1,235 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "open3"
6
+
7
+ # HookReplay (intent 331a, T1; promoted to a production lib in 331e) - streams
8
+ # text through a MessageDisplay launcher the way Claude Code streams an
9
+ # assistant reply, chunk by chunk, and returns every chunk's raw stdout.
10
+ #
11
+ # 331a's test/support/hook_replay.rb held this as test-only code; 331e's
12
+ # doctor `display_hook_paints` check (scripts/doctor.rb) needs the exact same
13
+ # mechanics to replay the INSTALLED launcher for real, so the logic lives
14
+ # here and test/support/hook_replay.rb now delegates to it (a require, not a
15
+ # duplicate). Never require this from scripts/lib/doctor_core.rb: that file
16
+ # is the SessionStart boot path (test/doctor_core_split_test.rb T2 pins its
17
+ # exact require set), and the paint check that needs this lib runs only from
18
+ # the full scripts/doctor.rb.
19
+ module HookReplay
20
+ module_function
21
+
22
+ # Streams `text` through `hook_path` in fixed-size chunks. `session_id`/
23
+ # `message_id` default to fixed values since nothing about a replay depends
24
+ # on the ambient session at all.
25
+ #
26
+ # `env` (intent 331e): extra child-process environment, merged over the
27
+ # PLASTIC_TMP entry every call already sets (a caller's own key wins). A
28
+ # `nil` value unsets that variable in the child (Process.spawn's own
29
+ # convention) is how a caller forces NO_COLOR off regardless of the ambient
30
+ # environment. Default `{}` keeps every existing caller's behavior
31
+ # unchanged: this is an extension, not a fork.
32
+ #
33
+ # `timeout` (intent 331e): when given, bounds each chunk's whole exchange to
34
+ # that many seconds (see run_bounded). Default `nil` keeps every existing
35
+ # caller on the original unbounded Open3.capture3 path.
36
+ def replay(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay", message_id: "replay",
37
+ env: {}, timeout: nil)
38
+ chunks = text.scan(/.{1,#{chunk}}/m)
39
+ chunks = [""] if chunks.empty?
40
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
41
+
42
+ chunks.each_with_index.map do |delta, i|
43
+ payload = {
44
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
45
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
46
+ "hook_event_name" => "MessageDisplay",
47
+ }
48
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, timeout)
49
+ { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
50
+ end
51
+ end
52
+
53
+ # replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
54
+ # way `replay` does, but fires every chunk in its OWN thread, staggered by
55
+ # `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
56
+ # than run sequentially. This is what reproduces the decision-race defect
57
+ # 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
58
+ # in production, and `replay`'s strictly-sequential default never puts two
59
+ # chunks in flight at once, so it could never have reproduced the race in
60
+ # the first place.
61
+ #
62
+ # `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
63
+ # at once": firing all 335 chunks of the live session capture with no
64
+ # stagger at all takes about 8 s of wall clock on this 8-core machine,
65
+ # because EACH chunk boots its own real Ruby process and the completions
66
+ # cluster at the tail once every core is saturated - a load real streaming
67
+ # never produces (a real stream delivers a chunk every few tens of
68
+ # milliseconds, one at a time). One Ruby process per streamed chunk is the
69
+ # actual throughput ceiling here, not something this method works around.
70
+ #
71
+ # `replay`'s own signature and sequential default are UNCHANGED by this
72
+ # method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
73
+ # must keep working exactly as it does today) - this is a sibling method
74
+ # in the same module, never a replacement.
75
+ #
76
+ # Returns the SAME result shape `replay` returns (one Hash per chunk, keys
77
+ # index/exitstatus/stdout/stderr/final), ordered by index regardless of
78
+ # the order the threads actually finish in.
79
+ def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
80
+ message_id: "replay", env: {}, gap_ms: 5, jitter: true, lead_until_engaged: false)
81
+ chunks = text.scan(/.{1,#{chunk}}/m)
82
+ chunks = [""] if chunks.empty?
83
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
84
+ gap = gap_ms / 1000.0
85
+
86
+ payloads = chunks.each_with_index.map do |delta, i|
87
+ {
88
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
89
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
90
+ "hook_event_name" => "MessageDisplay",
91
+ }
92
+ end
93
+ results = Array.new(chunks.length)
94
+
95
+ # `lead_until_engaged` runs chunks one at a time until one engages, so
96
+ # the decision marker is on disk before the rest fire concurrently. A
97
+ # caller proving "no late passthrough once the decision is in place"
98
+ # then tests exactly that, instead of whether chunk 0's own process
99
+ # booted inside a later chunk's poll budget on a loaded machine.
100
+ lead = 0
101
+ if lead_until_engaged
102
+ payloads.each_with_index do |payload, i|
103
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
104
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
105
+ lead = i + 1
106
+ break unless out.to_s.empty?
107
+ end
108
+ end
109
+
110
+ threads = payloads.each_with_index.drop(lead).map do |payload, i|
111
+ Thread.new do
112
+ begin
113
+ delay = (i - lead) * gap
114
+ delay += (rand * gap / 2.0) if jitter
115
+ sleep(delay)
116
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
117
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
118
+ rescue StandardError => e
119
+ # A raise inside a thread body is invisible until join, and an
120
+ # unrescued one aborts `threads.each(&:join)` at the first dead
121
+ # thread: every later thread is then never joined and outlives the
122
+ # call, racing whatever the caller does next (typically removing
123
+ # the very tmp root those threads are still writing under). Report
124
+ # the failure as this chunk's own result instead, so the array is
125
+ # always complete, every thread is always joined, and a replay
126
+ # tells its caller what went wrong rather than throwing at it.
127
+ results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
128
+ final: payload["final"] }
129
+ end
130
+ end
131
+ end
132
+ threads.each { |thread| thread.join }
133
+ results
134
+ end
135
+
136
+ # Indices of the chunks that reached the terminal as raw Markdown: a
137
+ # non-final chunk that emitted nothing at all, after the engaging chunk.
138
+ # A chunk "passed through" when its stdout is empty; a chunk was
139
+ # "blanked" (correctly buffered, not shown raw) when its stdout contains
140
+ # `"displayContent":""`. Only chunks with an index greater than the
141
+ # engaging chunk's own index count - the engaging chunk is the first one
142
+ # (at or after `start_index`) whose stdout is non-empty, and chunks
143
+ # before it already reached the terminal live, verbatim, through the
144
+ # ordinary passthrough path (they were never candidates for buffering at
145
+ # all, so an empty stdout from one of them is not this defect).
146
+ def passthrough_indices(outs, start_index: 0)
147
+ engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
148
+ return [] unless engaging
149
+
150
+ outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
151
+ .map { |o| o[:index] }
152
+ end
153
+
154
+ def run_one(hook_path, payload, full_env, tmp_root, timeout)
155
+ return capture(hook_path, payload, full_env) unless timeout
156
+
157
+ run_bounded(hook_path, payload, full_env, tmp_root, timeout)
158
+ end
159
+
160
+ def capture(hook_path, payload, full_env)
161
+ out, err, status = Open3.capture3(full_env, hook_path, stdin_data: JSON.generate(payload))
162
+ [out, err, status.exitstatus]
163
+ end
164
+
165
+ # Bounded run over pipes (acceptance N9). Scratch files for the child's
166
+ # stdin and stdout broke on Snap Ruby: the launcher's `ruby` could neither
167
+ # read nor write them, so every chunk came back empty with exit 1. Pipes
168
+ # work there, as the unbounded path already shows.
169
+ #
170
+ # One deadline covers the whole exchange: the launcher's exit, feeding
171
+ # stdin, and draining stdout and stderr. A launcher can exit while a child
172
+ # it started keeps the pipes open, so waiting for the launcher alone is not
173
+ # enough. A writer thread feeds stdin and reader threads drain both outputs
174
+ # at the same time, so large input or output cannot block.
175
+ #
176
+ # The launcher leads its own process group. When the deadline passes, the
177
+ # whole group is killed, the pipes are closed, and the result carries the
178
+ # output read so far with a nil exitstatus: the caller's signal that this
179
+ # chunk timed out. Nothing is written under tmp_root.
180
+ def run_bounded(hook_path, payload, full_env, _tmp_root, timeout)
181
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
182
+ stdin, stdout, stderr, wait = Open3.popen3(full_env, hook_path, pgroup: true)
183
+ buffers = [String.new, String.new]
184
+ threads = [Thread.new { feed(stdin, JSON.generate(payload)) }] +
185
+ [stdout, stderr].zip(buffers).map { |io, buffer| Thread.new { drain(io, buffer) } }
186
+ finished = [wait, *threads].all? { |thread| thread.join(remaining(deadline)) }
187
+ kill_group(wait.pid) unless finished
188
+ buffers.map { |buffer| buffer.force_encoding(Encoding.default_external) } +
189
+ [finished ? wait.value.exitstatus : nil]
190
+ ensure
191
+ [stdin, stdout, stderr].compact.each { |io| io.close unless io.closed? }
192
+ threads&.each { |thread| thread.join(1) }
193
+ end
194
+
195
+ def remaining(deadline)
196
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
197
+ end
198
+
199
+ def feed(io, data)
200
+ io.write(data)
201
+ rescue IOError, SystemCallError
202
+ # the hook exited without reading its input, or the deadline closed the pipe
203
+ ensure
204
+ io.close unless io.closed?
205
+ end
206
+
207
+ def drain(io, buffer)
208
+ loop { buffer << io.readpartial(65_536) }
209
+ rescue IOError, SystemCallError
210
+ # end of output, or the deadline closed the pipe
211
+ end
212
+
213
+ def kill_group(pid)
214
+ Process.kill("KILL", -pid)
215
+ rescue SystemCallError
216
+ nil # the whole group has already exited
217
+ end
218
+
219
+ # The final chunk's parsed displayContent, or nil when it emitted nothing
220
+ # (no envelope at all: the message never engaged).
221
+ def final_display_content(outs)
222
+ final = outs.last
223
+ return nil if final[:stdout].to_s.empty?
224
+
225
+ JSON.parse(final[:stdout]).dig("hookSpecificOutput", "displayContent")
226
+ rescue JSON::ParserError
227
+ nil
228
+ end
229
+
230
+ # True when any chunk's spawn hit its timeout (run_bounded's nil-exitstatus
231
+ # signal). A replay made with no `timeout:` never reports true.
232
+ def timed_out?(outs)
233
+ outs.any? { |o| o[:exitstatus].nil? }
234
+ end
235
+ end
@@ -0,0 +1,62 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # IndexEntry - the shared INDEX.md entry matcher and Active check (intent 188,
5
+ # D12/D13; moved off the retired shared-helpers module by intent 344, D2).
6
+ #
7
+ # ONE definition site for the "- [ID <sep> Title](link)" shape both `active?`
8
+ # (below) and `scripts/end-intent`'s own INDEX-move parser depend on, so the
9
+ # two regexes can never drift apart. Accepts a real em dash (U+2014) OR a
10
+ # plain hyphen as the id/title separator on READ; every WRITE still emits the
11
+ # real em dash (D10). The separator is built from the codepoint, not a
12
+ # literal byte, so this file stays em-dash free.
13
+ require_relative "atomic_write"
14
+
15
+ module IndexEntry
16
+ module_function
17
+
18
+ EM_DASH = "\u2014".freeze
19
+ ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
20
+
21
+ # Match `line` (already chomped) against the shared INDEX entry shape.
22
+ # Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
23
+ def match(line)
24
+ line.to_s.match(ENTRY_RE)
25
+ end
26
+
27
+ def add_active(index_path, dir_name:, title:)
28
+ id = dir_name.split("--").first
29
+ line = "- [#{id} #{EM_DASH} #{title}](store/#{dir_name}/#{dir_name}.md)\n"
30
+ text = File.read(index_path, encoding: "UTF-8")
31
+ AtomicWrite.write(index_path, text.sub(/^## Active\n/) { |heading| heading + line })
32
+ end
33
+
34
+ # True iff the intent is Active in its store's INDEX.md, which lives at the
35
+ # PARENT of the store/ dir. Non-raising: any failure (missing or unreadable
36
+ # INDEX, bad arg) returns false. `index_active_ids` is a pure-data test
37
+ # seam: when an Array of id strings is supplied, membership is checked
38
+ # against it directly with no file read.
39
+ def active?(intent_id, store:, index_active_ids: nil)
40
+ target = intent_id.to_s
41
+ return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
42
+
43
+ index = File.join(File.dirname(store.to_s), "INDEX.md")
44
+ return false unless File.exist?(index)
45
+
46
+ in_active = false
47
+ File.foreach(index) do |line|
48
+ stripped = line.chomp
49
+ if stripped == "## Active"
50
+ in_active = true
51
+ next
52
+ end
53
+ next unless in_active
54
+ break if stripped.start_with?("## ") # next section ends the Active block
55
+ m = match(stripped)
56
+ return true if m && m[1] == target
57
+ end
58
+ false
59
+ rescue StandardError
60
+ false
61
+ end
62
+ end
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "doctor_exclusions"
5
+ require_relative "graph_file"
6
+ require_relative "atomic_write"
7
+
8
+ # IndexProjection (intent 337, n5): computes every intent's status from its
9
+ # own savepoint.md ledger, reads the status INDEX.md currently claims, and
10
+ # reports the drift between them. The ledger wins WHERE THE LEDGER SPEAKS
11
+ # (row 5.1/5.2): a REAL terminal line (Done delivered/abandoned, or a
12
+ # classifiable Done detail) beats a stale INDEX section. An intent whose
13
+ # ledger is silent (no terminal line) or absent (no savepoint.md at all)
14
+ # keeps the status INDEX already carries (row 5.13, merged at the
15
+ # 2026-09-10 plan review): 63 of 451 intents in the plastic store have no
16
+ # savepoint.md and 59 more never reach a Done line, and a literal reading
17
+ # would demote all of them. This module computes and compares only; it
18
+ # writes nothing (row 5.11) and reads no clock or environment variable.
19
+ module IndexProjection
20
+ module_function
21
+
22
+ INDEX_SECTIONS = %w[Active Future Completed Abandoned].freeze
23
+ TERMINAL_STATUSES = %w[Completed Abandoned].freeze
24
+ EXCLUSION_RULES = %w[savepoint_operational backfilled_complete].freeze
25
+
26
+ # `store_path` holds the intent directories (a project's own `store/`, or
27
+ # the global `~/.plastic/store`). `index_path` defaults to a sibling
28
+ # `INDEX.md` inside `store_path` (matching every fixture in this test
29
+ # file), but the real Plastic layout keeps INDEX.md one level ABOVE
30
+ # `store/` - callers there (doctor.rb, the CLI) pass it explicitly.
31
+ def analyze(store_path, index_path: nil)
32
+ index_path ||= File.join(store_path, "INDEX.md")
33
+ index_map = read_index(index_path)
34
+ dir_ids = store_intent_ids(store_path)
35
+
36
+ excluded = excluded_ids(index_path)
37
+
38
+ drift = index_map.filter_map do |id, index_status|
39
+ next if index_status == "Future" # 5.5: Future has no ledger counterpart
40
+ next if excluded.include?(id) # 5.12
41
+
42
+ ledger_status = ledger_status_for(store_path, id)
43
+ next unless TERMINAL_STATUSES.include?(ledger_status) # 5.3/5.13
44
+ next if ledger_status == index_status
45
+
46
+ { id: id, index_status: index_status, ledger_status: ledger_status }
47
+ end
48
+
49
+ index_only = (index_map.keys - dir_ids).reject { |id| excluded.include?(id) }
50
+ .map { |id| { id: id, index_status: index_map[id] } }
51
+ directory_only = (dir_ids - index_map.keys).reject { |id| excluded.include?(id) }
52
+ .map { |id| { id: id } }
53
+
54
+ { ok: true, drift: drift, index_only: index_only, directory_only: directory_only, errors: [] }
55
+ end
56
+
57
+ # Render the four status sections (## Active, ## Future, ## Completed,
58
+ # ## Abandoned) from the projection and write through AtomicWrite. Only
59
+ # entries `analyze` actually reported as `drift` ever move - an entry
60
+ # whose ledger is silent or absent never appears in `drift` (row 5.13),
61
+ # so --write can never demote it (row 6.14). Every other line, including
62
+ # ## Clusters and ## Relocated (or any other section), is untouched
63
+ # (row 6.13): only the four named headings are ever replaced.
64
+ def write(store_path, index_path:, renamer: File.method(:rename))
65
+ return { ok: false, written: false, error: "no INDEX.md at #{index_path}" } unless File.exist?(index_path)
66
+
67
+ analysis = analyze(store_path, index_path: index_path)
68
+ text = read_utf8(index_path)
69
+
70
+ original_lines = {}
71
+ INDEX_SECTIONS.each do |heading|
72
+ section_body(text, heading).each_line do |line|
73
+ next unless line.strip.start_with?("- [")
74
+
75
+ m = line.strip.match(/\A-\s*\[(\S+)\s/)
76
+ original_lines[m[1]] = line if m
77
+ end
78
+ end
79
+
80
+ moves = analysis[:drift].each_with_object({}) { |r, h| h[r[:id]] = r[:ledger_status] }
81
+
82
+ new_bodies = INDEX_SECTIONS.each_with_object({}) do |heading, h|
83
+ kept = section_body(text, heading).each_line.reject do |line|
84
+ stripped = line.strip
85
+ next false unless stripped.start_with?("- [")
86
+
87
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
88
+ m && moves.key?(m[1])
89
+ end
90
+ arriving = moves.select { |_id, target| target == heading }.keys
91
+ h[heading] = (kept + arriving.filter_map { |id| original_lines[id] }).join
92
+ end
93
+
94
+ content = text
95
+ INDEX_SECTIONS.each do |heading|
96
+ content = GraphFile.replace_or_append_section(content, "## #{heading}", new_bodies[heading].rstrip + "\n")
97
+ end
98
+
99
+ AtomicWrite.write(index_path, content, renamer: renamer)
100
+ { ok: true, written: true, moved: analysis[:drift], error: nil }
101
+ end
102
+
103
+ # --- INDEX -------------------------------------------------------------------
104
+
105
+ def read_index(index_path)
106
+ map = {}
107
+ return map unless File.exist?(index_path)
108
+
109
+ text = read_utf8(index_path)
110
+ INDEX_SECTIONS.each do |heading|
111
+ section_body(text, heading).each_line do |line|
112
+ stripped = line.strip
113
+ next unless stripped.start_with?("- [")
114
+
115
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
116
+ map[m[1]] = heading if m
117
+ end
118
+ end
119
+ map
120
+ end
121
+
122
+ def section_body(text, heading)
123
+ m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
124
+ m ? m[1] : ""
125
+ end
126
+
127
+ # --- store directories ---------------------------------------------------------
128
+
129
+ def store_intent_ids(store_path)
130
+ return [] unless Dir.exist?(store_path)
131
+
132
+ Dir.entries(store_path).select { |e| e.include?("--") && File.directory?(File.join(store_path, e)) }
133
+ .map { |e| e.split("--", 2).first }
134
+ end
135
+
136
+ def intent_dir_for(store_path, id)
137
+ Dir.glob(File.join(store_path, "#{id}--*")).find { |p| File.directory?(p) }
138
+ end
139
+
140
+ # --- the ledger (savepoint.md) --------------------------------------------------
141
+
142
+ # "Completed", "Abandoned", "Active" (no terminal line yet), "unknown" (no
143
+ # directory or no savepoint.md), or "indeterminate" (a Done line whose
144
+ # detail is neither delivered/merged nor abandoned, row 5.14).
145
+ def ledger_status_for(store_path, id)
146
+ dir = intent_dir_for(store_path, id)
147
+ return "unknown" unless dir
148
+
149
+ sp_path = File.join(dir, "savepoint.md")
150
+ return "unknown" unless File.exist?(sp_path)
151
+
152
+ parsed = complete_lines(read_utf8(sp_path)).filter_map { |l| parse_line(l) }
153
+ last_done = parsed.reverse.find { |p| p[:kind] == "Done" }
154
+ return "Active" unless last_done
155
+
156
+ classify_done_detail(last_done[:detail]) || "indeterminate"
157
+ end
158
+
159
+ # Drop the trailing torn fragment: a savepoint line that never got its
160
+ # closing newline because the process died mid-append (row 5.6). Works
161
+ # whether the file ends in a newline (the dropped element is the empty
162
+ # string split(-1) always yields after a final "\n") or not (the dropped
163
+ # element is the torn fragment itself).
164
+ def complete_lines(content)
165
+ return [] if content.to_s.empty?
166
+
167
+ lines = content.split("\n", -1)
168
+ lines.pop
169
+ lines.reject { |l| l.strip.empty? }
170
+ end
171
+
172
+ def parse_line(line)
173
+ parts = line.strip.split(/\s{2,}/, 3)
174
+ return nil unless parts.length == 3
175
+
176
+ { time: parts[0], kind: parts[1], detail: parts[2] }
177
+ end
178
+
179
+ def classify_done_detail(detail)
180
+ d = detail.to_s.strip
181
+ return "Abandoned" if d.start_with?("abandoned")
182
+ return "Completed" if d.start_with?("delivered") || d.start_with?("merged")
183
+
184
+ nil
185
+ end
186
+
187
+ # --- doctor exclusions (row 5.12) -----------------------------------------------
188
+
189
+ def excluded_ids(index_path)
190
+ loaded = DoctorExclusions.load(index_path)
191
+ EXCLUSION_RULES.flat_map { |rule| loaded[:rules][rule] || [] }.uniq
192
+ end
193
+
194
+ # --- utf-8 -----------------------------------------------------------------------
195
+
196
+ def read_utf8(path)
197
+ text = File.read(path)
198
+ text.force_encoding(Encoding::UTF_8)
199
+ text.valid_encoding? ? text : text.scrub("")
200
+ end
201
+ end
@@ -2,7 +2,7 @@
2
2
  # encoding: UTF-8
3
3
 
4
4
  # insights.rb - the blessed write path for an intent's `## Insights` section
5
- # (intent 82). A sibling library to bridge.rb so the insight write path stays
5
+ # (intent 82). A standalone library so the insight write path stays
6
6
  # independent of the savepoint ledger (the spec forbids touching the ledger).
7
7
  #
8
8
  # Every entry leads with a fixed, machine-parseable prefix