@zalom/plastic 2.0.0-alpha.8 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +23 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +62 -1
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +238 -22
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +19 -5
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +30 -16
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +532 -71
  152. package/scripts/lib/intent_screen.rb +9 -6
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +377 -173
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1460 -74
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +555 -0
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +192 -30
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +15 -2
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -295
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -173
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,229 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "open3"
6
+ require "timeout"
7
+
8
+ # HookReplay (intent 331a, T1; promoted to a production lib in 331e) - streams
9
+ # text through a MessageDisplay launcher the way Claude Code streams an
10
+ # assistant reply, chunk by chunk, and returns every chunk's raw stdout.
11
+ #
12
+ # 331a's test/support/hook_replay.rb held this as test-only code; 331e's
13
+ # doctor `display_hook_paints` check (scripts/doctor.rb) needs the exact same
14
+ # mechanics to replay the INSTALLED launcher for real, so the logic lives
15
+ # here and test/support/hook_replay.rb now delegates to it (a require, not a
16
+ # duplicate). Never require this from scripts/lib/doctor_core.rb: that file
17
+ # is the SessionStart boot path (test/doctor_core_split_test.rb T2 pins its
18
+ # exact require set), and the paint check that needs this lib runs only from
19
+ # the full scripts/doctor.rb.
20
+ module HookReplay
21
+ module_function
22
+
23
+ # Streams `text` through `hook_path` in fixed-size chunks. `session_id`/
24
+ # `message_id` default to fixed values since nothing about a replay depends
25
+ # on the ambient session at all.
26
+ #
27
+ # `env` (intent 331e): extra child-process environment, merged over the
28
+ # PLASTIC_TMP entry every call already sets (a caller's own key wins). A
29
+ # `nil` value unsets that variable in the child (Process.spawn's own
30
+ # convention) is how a caller forces NO_COLOR off regardless of the ambient
31
+ # environment. Default `{}` keeps every existing caller's behavior
32
+ # unchanged: this is an extension, not a fork.
33
+ #
34
+ # `timeout` (intent 331e): when given, bounds EACH chunk's spawn to that
35
+ # many seconds. A bare `Timeout.timeout` around `Open3.capture3` does not
36
+ # reliably bound a genuinely hanging child: capture3's own wait still
37
+ # blocks on Process.waitpid for the child regardless of the raised
38
+ # Timeout::Error (the same gotcha scripts/hook-record works around), so a
39
+ # timeout here spawns directly and kills the child on expiry instead.
40
+ # Default `nil` keeps every existing caller on the original unbounded
41
+ # Open3.capture3 path.
42
+ def replay(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay", message_id: "replay",
43
+ env: {}, timeout: nil)
44
+ chunks = text.scan(/.{1,#{chunk}}/m)
45
+ chunks = [""] if chunks.empty?
46
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
47
+
48
+ chunks.each_with_index.map do |delta, i|
49
+ payload = {
50
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
51
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
52
+ "hook_event_name" => "MessageDisplay",
53
+ }
54
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, timeout)
55
+ { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
56
+ end
57
+ end
58
+
59
+ # replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
60
+ # way `replay` does, but fires every chunk in its OWN thread, staggered by
61
+ # `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
62
+ # than run sequentially. This is what reproduces the decision-race defect
63
+ # 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
64
+ # in production, and `replay`'s strictly-sequential default never puts two
65
+ # chunks in flight at once, so it could never have reproduced the race in
66
+ # the first place.
67
+ #
68
+ # `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
69
+ # at once": firing all 335 chunks of the live session capture with no
70
+ # stagger at all takes about 8 s of wall clock on this 8-core machine,
71
+ # because EACH chunk boots its own real Ruby process and the completions
72
+ # cluster at the tail once every core is saturated - a load real streaming
73
+ # never produces (a real stream delivers a chunk every few tens of
74
+ # milliseconds, one at a time). One Ruby process per streamed chunk is the
75
+ # actual throughput ceiling here, not something this method works around.
76
+ #
77
+ # `replay`'s own signature and sequential default are UNCHANGED by this
78
+ # method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
79
+ # must keep working exactly as it does today) - this is a sibling method
80
+ # in the same module, never a replacement.
81
+ #
82
+ # Returns the SAME result shape `replay` returns (one Hash per chunk, keys
83
+ # index/exitstatus/stdout/stderr/final), ordered by index regardless of
84
+ # the order the threads actually finish in.
85
+ def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
86
+ message_id: "replay", env: {}, gap_ms: 5, jitter: true, lead_until_engaged: false)
87
+ chunks = text.scan(/.{1,#{chunk}}/m)
88
+ chunks = [""] if chunks.empty?
89
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
90
+ gap = gap_ms / 1000.0
91
+
92
+ payloads = chunks.each_with_index.map do |delta, i|
93
+ {
94
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
95
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
96
+ "hook_event_name" => "MessageDisplay",
97
+ }
98
+ end
99
+ results = Array.new(chunks.length)
100
+
101
+ # `lead_until_engaged` runs chunks one at a time until one engages, so
102
+ # the decision marker is on disk before the rest fire concurrently. A
103
+ # caller proving "no late passthrough once the decision is in place"
104
+ # then tests exactly that, instead of whether chunk 0's own process
105
+ # booted inside a later chunk's poll budget on a loaded machine.
106
+ lead = 0
107
+ if lead_until_engaged
108
+ payloads.each_with_index do |payload, i|
109
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
110
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
111
+ lead = i + 1
112
+ break unless out.to_s.empty?
113
+ end
114
+ end
115
+
116
+ threads = payloads.each_with_index.drop(lead).map do |payload, i|
117
+ Thread.new do
118
+ begin
119
+ delay = (i - lead) * gap
120
+ delay += (rand * gap / 2.0) if jitter
121
+ sleep(delay)
122
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
123
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
124
+ rescue StandardError => e
125
+ # A raise inside a thread body is invisible until join, and an
126
+ # unrescued one aborts `threads.each(&:join)` at the first dead
127
+ # thread: every later thread is then never joined and outlives the
128
+ # call, racing whatever the caller does next (typically removing
129
+ # the very tmp root those threads are still writing under). Report
130
+ # the failure as this chunk's own result instead, so the array is
131
+ # always complete, every thread is always joined, and a replay
132
+ # tells its caller what went wrong rather than throwing at it.
133
+ results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
134
+ final: payload["final"] }
135
+ end
136
+ end
137
+ end
138
+ threads.each { |thread| thread.join }
139
+ results
140
+ end
141
+
142
+ # Indices of the chunks that reached the terminal as raw Markdown: a
143
+ # non-final chunk that emitted nothing at all, after the engaging chunk.
144
+ # A chunk "passed through" when its stdout is empty; a chunk was
145
+ # "blanked" (correctly buffered, not shown raw) when its stdout contains
146
+ # `"displayContent":""`. Only chunks with an index greater than the
147
+ # engaging chunk's own index count - the engaging chunk is the first one
148
+ # (at or after `start_index`) whose stdout is non-empty, and chunks
149
+ # before it already reached the terminal live, verbatim, through the
150
+ # ordinary passthrough path (they were never candidates for buffering at
151
+ # all, so an empty stdout from one of them is not this defect).
152
+ def passthrough_indices(outs, start_index: 0)
153
+ engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
154
+ return [] unless engaging
155
+
156
+ outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
157
+ .map { |o| o[:index] }
158
+ end
159
+
160
+ def run_one(hook_path, payload, full_env, tmp_root, timeout)
161
+ return capture(hook_path, payload, full_env) unless timeout
162
+
163
+ run_bounded(hook_path, payload, full_env, tmp_root, timeout)
164
+ end
165
+
166
+ def capture(hook_path, payload, full_env)
167
+ out, err, status = Open3.capture3(full_env, hook_path, stdin_data: JSON.generate(payload))
168
+ [out, err, status.exitstatus]
169
+ end
170
+
171
+ # Spawn directly (never Open3.capture3) so a timeout can actually kill the
172
+ # child, with stdin/stdout/stderr routed through scratch files under the
173
+ # caller's own tmp_root, and never pipes, so a stalled or oversized write can
174
+ # never deadlock the read side, and never anywhere outside tmp_root, so a
175
+ # bounded replay carries the same "writes only under the injected tmp
176
+ # root" guarantee as the unbounded path.
177
+ def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
178
+ token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
179
+ in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
180
+ out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
181
+ err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
182
+ File.write(in_path, JSON.generate(payload))
183
+
184
+ pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
185
+ exitstatus =
186
+ begin
187
+ Timeout.timeout(timeout) { Process.wait(pid) }
188
+ $?.exitstatus
189
+ rescue Timeout::Error
190
+ kill_and_reap(pid)
191
+ nil # nil exitstatus is the caller's signal that this chunk timed out
192
+ end
193
+
194
+ out = File.exist?(out_path) ? File.read(out_path) : ""
195
+ err = File.exist?(err_path) ? File.read(err_path) : ""
196
+ [out, err, exitstatus]
197
+ ensure
198
+ [in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
199
+ end
200
+
201
+ def kill_and_reap(pid)
202
+ Process.kill("KILL", pid)
203
+ rescue StandardError
204
+ nil
205
+ ensure
206
+ begin
207
+ Process.wait(pid)
208
+ rescue StandardError
209
+ nil
210
+ end
211
+ end
212
+
213
+ # The final chunk's parsed displayContent, or nil when it emitted nothing
214
+ # (no envelope at all: the message never engaged).
215
+ def final_display_content(outs)
216
+ final = outs.last
217
+ return nil if final[:stdout].to_s.empty?
218
+
219
+ JSON.parse(final[:stdout]).dig("hookSpecificOutput", "displayContent")
220
+ rescue JSON::ParserError
221
+ nil
222
+ end
223
+
224
+ # True when any chunk's spawn hit its timeout (run_bounded's nil-exitstatus
225
+ # signal). A replay made with no `timeout:` never reports true.
226
+ def timed_out?(outs)
227
+ outs.any? { |o| o[:exitstatus].nil? }
228
+ end
229
+ 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