@zalom/plastic 2.0.0-alpha.9 → 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 +11 -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 +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 +153 -30
  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 +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 +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 +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 +531 -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 +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 +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 +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 +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 +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 +177 -16
  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 +8 -3
  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 -296
  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 -176
  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,222 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-call-budget (reads the PreToolUse stdin JSON payload)
6
+ #
7
+ # Intent 355, n2 (review fix n8). A node gets a call budget by kind
8
+ # (RunnerPolicy.call_cap): a cap on tool calls per attempt. RunnerDispatch
9
+ # writes the cap onto the running line as `calls=` and states it as one
10
+ # sentence in the node's own input, so the executor learns the number before
11
+ # it ever gets denied.
12
+ #
13
+ # Inside a subagent the hook input's session_id and transcript_path are the
14
+ # MAIN session's, never the subagent's own (v1 finding). So this hook caps
15
+ # ONLY a subagent call: agent_id present in the payload. A call with no
16
+ # agent_id (the main thread, a plain conversation, a live --agent session's
17
+ # own turns) is never capped and the decision returns before any file is
18
+ # touched.
19
+ #
20
+ # Which node a subagent is working is read from the subagent's OWN
21
+ # transcript, at <dirname(transcript_path)>/<session_id>/subagents/
22
+ # agent-<agent_id>.jsonl (the main transcript_path plus the main session_id
23
+ # plus the subagent's own agent_id): its first user record carries the
24
+ # dispatch prompt, and the lead/spawn block always pastes the node's input
25
+ # path into that prompt (INPUT_PATH_RE). The node input path names the intent
26
+ # directory directly (its grandparent), so this hook never scans the store to
27
+ # find it. No node input path in that first prompt means allow.
28
+ #
29
+ # The cap applies only when that node's LATEST transition line in
30
+ # <intent>/savepoint.md is `running` and carries `calls=`; a later `done` or
31
+ # `failed_verification` line (or a sibling node's own running line) means no
32
+ # cap. Past the cap, a Bash call that runs only a safe git verb (add, commit,
33
+ # status, rev-parse, log) is still allowed, so the denial the hook itself
34
+ # orders (commit what is green) is never the thing the hook blocks.
35
+ #
36
+ # Pure and dependency-injected down to the paths it reads; the trailing block
37
+ # guarded by `$PROGRAM_NAME == __FILE__` is this file's only I/O (stdin,
38
+ # stdout), so a test can `load` this file and call HookCallBudget's module
39
+ # functions directly without spawning a process or touching stdin.
40
+
41
+ require "json"
42
+ require_relative "lib/node_ledger"
43
+ require_relative "lib/savepoint"
44
+
45
+ module HookCallBudget
46
+ module_function
47
+
48
+ RETURN_INSTRUCTION = "commit what is green and return failed_verification reason=call_budget"
49
+
50
+ # A node id, then "--a", then an attempt number, inside a /attempts/
51
+ # directory: the exact shape RunnerDispatch's spawn block pastes into a
52
+ # subagent's own first prompt. Captures [full_path, node].
53
+ INPUT_PATH_RE = %r{(\S+/attempts/([a-z]{1,2}\d+)--a\d+\.input)}.freeze
54
+
55
+ FIRST_RECORD_SCAN_LIMIT = 20
56
+
57
+ GIT_ALLOWED_VERBS = %w[add commit status rev-parse log].freeze
58
+ GIT_UNSAFE_RE = /[;&|\n]|\$\(|`/.freeze
59
+
60
+ # The one decision: nil (allow, no output) or a deny payload Hash. Never
61
+ # raises across its own boundary.
62
+ def decide(payload)
63
+ return nil unless payload.is_a?(Hash)
64
+
65
+ agent_id = payload["agent_id"]
66
+ return nil if agent_id.to_s.strip.empty?
67
+
68
+ session_id = payload["session_id"].to_s
69
+ transcript_path = payload["transcript_path"].to_s
70
+ return nil if session_id.empty? || transcript_path.empty?
71
+
72
+ sub_path = subagent_transcript_path(transcript_path, session_id, agent_id)
73
+
74
+ prompt = begin
75
+ first_user_prompt_text(sub_path)
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ return nil unless prompt
80
+
81
+ match = INPUT_PATH_RE.match(prompt)
82
+ return nil unless match
83
+
84
+ node = match[2]
85
+ intent_dir = File.dirname(File.dirname(match[1]))
86
+
87
+ cap = node_cap(intent_dir, node)
88
+ return nil unless cap
89
+
90
+ count = begin
91
+ count_tool_use_blocks(sub_path)
92
+ rescue StandardError => e
93
+ note_unreadable(intent_dir, session_id, e.message)
94
+ return nil
95
+ end
96
+
97
+ return nil if count <= cap
98
+ return nil if git_only_bash_call?(payload)
99
+
100
+ deny_payload("#{node} is past its call budget (#{count}/#{cap} tool calls); #{RETURN_INSTRUCTION}")
101
+ end
102
+
103
+ def subagent_transcript_path(main_transcript_path, session_id, agent_id)
104
+ File.join(File.dirname(main_transcript_path), session_id, "subagents", "agent-#{agent_id}.jsonl")
105
+ end
106
+
107
+ # The first `"type":"user"` record in `path`'s own transcript, rendered to
108
+ # plain text (message.content as a string, or the joined text blocks of a
109
+ # content array). Scans at most FIRST_RECORD_SCAN_LIMIT lines: the dispatch
110
+ # prompt is always the transcript's opening record, so this never touches
111
+ # the bulk of a long-running subagent's history. Raises on a genuinely
112
+ # unreadable path (a directory, a permission error); the caller decides
113
+ # what that means.
114
+ def first_user_prompt_text(path)
115
+ File.open(path) do |f|
116
+ f.each_line.first(FIRST_RECORD_SCAN_LIMIT).each do |raw|
117
+ record = begin
118
+ JSON.parse(raw)
119
+ rescue JSON::ParserError
120
+ next
121
+ end
122
+ next unless record.is_a?(Hash) && record["type"] == "user"
123
+
124
+ return message_text(record)
125
+ end
126
+ end
127
+ nil
128
+ end
129
+
130
+ def message_text(record)
131
+ content = record.dig("message", "content")
132
+ case content
133
+ when String
134
+ content
135
+ when Array
136
+ content.select { |b| b.is_a?(Hash) && b["type"] == "text" }.map { |b| b["text"].to_s }.join("\n")
137
+ else
138
+ ""
139
+ end
140
+ end
141
+
142
+ # The cap named on `node`'s LATEST (file-order) non-torn transition line in
143
+ # `intent_dir`/savepoint.md, or nil when that line is not `running`, carries
144
+ # no `calls=`, or does not exist at all (matrix 2.6/2.6b: a later done/
145
+ # failed_verification line, or a sibling node's own running line, never
146
+ # caps this node).
147
+ def node_cap(intent_dir, node)
148
+ path = File.join(intent_dir, "savepoint.md")
149
+ return nil unless File.exist?(path)
150
+
151
+ latest = NodeLedger.entries(path).select { |e| !e[:torn] && e[:subject] == node }.last
152
+ return nil unless latest && latest[:state] == "running"
153
+
154
+ cap = (latest[:fields] || {})["calls"]
155
+ cap ? cap.to_i : nil
156
+ end
157
+
158
+ # Streams `path` a line at a time, counting `"type":"tool_use"` content
159
+ # blocks with a plain regex rather than a full JSON parse per line (matrix
160
+ # 2.5/2.8): a 3 MB transcript counts in well under 50 ms this way.
161
+ def count_tool_use_blocks(path)
162
+ count = 0
163
+ File.foreach(path) { |ln| count += ln.scan(/"type"\s*:\s*"tool_use"/).length }
164
+ count
165
+ end
166
+
167
+ # Past the cap, a Bash call is still allowed when its command runs only one
168
+ # safe git verb (matrix 2.7/D3): the git add/commit the denial itself
169
+ # orders must never be the call the hook blocks. Any chaining token
170
+ # (`;`, `&`, `|`, a newline, a subshell or a backtick) refuses the whole
171
+ # command, so a compound command can never smuggle an unrelated call past
172
+ # the cap riding on a leading `git`.
173
+ def git_only_bash_call?(payload)
174
+ return false unless payload["tool_name"].to_s == "Bash"
175
+
176
+ command = payload.dig("tool_input", "command").to_s.strip
177
+ return false if command.empty? || GIT_UNSAFE_RE.match?(command)
178
+
179
+ tokens = command.split(/\s+/)
180
+ return false unless tokens.shift == "git"
181
+
182
+ tokens.shift(2) if tokens.first == "-C"
183
+ GIT_ALLOWED_VERBS.include?(tokens.first)
184
+ end
185
+
186
+ def deny_payload(reason)
187
+ { "hookSpecificOutput" => { "hookEventName" => "PreToolUse", "permissionDecision" => "deny",
188
+ "permissionDecisionReason" => reason } }
189
+ end
190
+
191
+ # Best-effort note that the transcript could not be counted (matrix 2.9): a
192
+ # plain milestone line, the same shared primitive Commit/Review/Report
193
+ # lines already use, dedup'd on (stage, milestone) so a repeat failure in
194
+ # the same session with the same message is not written twice.
195
+ def note_unreadable(intent_dir, session_id, message, now: Time.now)
196
+ Savepoint.append_savepoint_line(intent_dir, "CallBudget", "transcript unreadable for #{session_id}: #{message}",
197
+ now)
198
+ rescue StandardError
199
+ nil
200
+ end
201
+ end
202
+
203
+ if $PROGRAM_NAME == __FILE__
204
+ raw = begin
205
+ $stdin.read
206
+ rescue StandardError
207
+ nil
208
+ end
209
+
210
+ if raw && !raw.strip.empty?
211
+ payload = begin
212
+ JSON.parse(raw)
213
+ rescue StandardError
214
+ nil
215
+ end
216
+
217
+ result = HookCallBudget.decide(payload)
218
+ puts JSON.generate(result) if result
219
+ end
220
+
221
+ exit 0
222
+ end
@@ -5,16 +5,18 @@
5
5
  # Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
6
6
  # Intent 298. One UserPromptSubmit process replacing hook-continue,
7
7
  # hook-future-intent-check, and hook-auto-arm: it appends a pending line to
8
- # the session day ledger, detects "continue" and "auto" prompts, and hints at
9
- # matching Future intents. Every job runs in its own rescue and the hook
10
- # always exits 0 (spec D2).
8
+ # the session day ledger and detects "continue" and "auto" prompts. Every job
9
+ # runs in its own rescue and the hook always exits 0 (spec D2). Intent 345
10
+ # (D7, 323) removed the per-prompt Future-intent hint step entirely.
11
11
 
12
+ require_relative "lib/store_layout"
12
13
  require "json"
13
- require "yaml"
14
14
  require "open3"
15
+ require "time"
15
16
  require "fileutils"
16
17
  require_relative "lib/session_ledger"
17
18
  require_relative "lib/dashboard_banner"
19
+ require_relative "lib/active_delivery"
18
20
 
19
21
  raw = begin
20
22
  $stdin.read
@@ -37,124 +39,42 @@ cwd = payload["cwd"].to_s
37
39
  cwd = Dir.pwd if cwd.empty?
38
40
 
39
41
  plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
40
- store = File.join(plastic_home, "store")
42
+ store = Plastic::StoreLayout.global_store(plastic_home)
41
43
  templates = File.expand_path("../templates", __dir__)
42
44
  sid = SessionLedger.short_session_id(nil, session_id)
43
45
  today = SessionLedger.day_id
44
46
 
45
- # --- Extracted matching logic, shared by both the global and project store
46
- # passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
47
- def future_intent_matches(store_root, message)
48
- index_path = File.join(store_root, "INDEX.md")
49
- return [] unless File.exist?(index_path)
50
-
51
- lines = File.readlines(index_path)
52
- future_dirs = []
53
- section = nil
54
-
55
- lines.each do |line|
56
- section = :future if line.start_with?("## Future")
57
- section = nil if line.start_with?("## ") && !line.start_with?("## Future")
58
- next unless section == :future && line.strip.start_with?("- [")
59
-
60
- future_dirs << $1 if line =~ /store\/([\w-]+)\//
61
- end
62
- return [] if future_dirs.empty?
63
-
64
- matches = []
65
- message_words = message.split(/\W+/).reject { |w| w.length < 4 }
66
-
67
- future_dirs.each do |dir|
68
- intent_path = File.join(store_root, "store", dir, "#{dir}.md")
69
- next unless File.exist?(intent_path)
70
-
71
- content = File.read(intent_path)
72
-
73
- tags = []
74
- tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
75
-
76
- intent_name = ""
77
- intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
78
-
79
- keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
80
-
81
- matched_keywords = keywords.select { |kw| message.include?(kw) }
82
- name_matches = message_words.select { |w| intent_name.include?(w) }
83
- matched_keywords = (matched_keywords + name_matches).uniq
84
-
85
- next if matched_keywords.empty?
86
-
87
- index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
88
- matches << { "index_line" => index_line, "keywords" => matched_keywords }
89
- end
90
- matches
91
- end
92
-
93
- # The store_root that carries the current project's own store, resolved from
94
- # cwd through projects.yml, mirroring SessionLedger.project_slug's matching
95
- # but returning nil (not "global") when nothing matches, since there is then
96
- # no distinct second store to hint against.
97
- def resolve_project_store_root(cwd, plastic_home)
98
- projects_path = File.join(plastic_home, "projects.yml")
99
- return nil unless File.exist?(projects_path)
100
-
101
- data = begin
102
- YAML.safe_load(File.read(projects_path))
103
- rescue StandardError
104
- nil
105
- end
106
- data = {} unless data.is_a?(Hash)
107
- projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
108
- expanded_cwd = File.expand_path(cwd)
109
-
110
- matches = projects.filter_map do |slug, info|
111
- next unless info.is_a?(Hash) && slug.is_a?(String)
112
-
113
- path = info["path"]
114
- next unless path
115
-
116
- root = File.expand_path(path)
117
- next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
118
-
119
- [root.length, slug]
120
- end
121
-
122
- best = matches.max_by { |(length, _slug)| length }
123
- best ? File.join(plastic_home, "projects", best[1]) : nil
124
- end
125
-
126
47
  def truncate(text, max)
127
48
  return text if text.length <= max
128
49
 
129
50
  "#{text[0, max - 3]}..."
130
51
  end
131
52
 
132
- # --- (a) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
133
- # Session start writes the `current` pointer; a capture with no pointer yet
53
+ # --- (a) heartbeat, guarded on the session tmp dir already existing --------
54
+ # Session start creates `.tmp/<sid>/`; a capture with no such directory yet
134
55
  # means session start never ran for this session at all (a `-p` print
135
56
  # session, a resumed background job, an unregistered SessionStart hook), so
136
- # neither the per-session dir nor its heartbeat is created. UserPromptSubmit
137
- # fires before PostToolUse, so capture is the FIRST creator of every orphan
138
- # `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
139
- # its own (later) heartbeat write.
57
+ # neither the directory nor its heartbeat is created HERE either.
58
+ # UserPromptSubmit fires before PostToolUse, so capture is the FIRST place
59
+ # this guard matters; hook-record carries the same guard for its own
60
+ # (later) heartbeat write. (344 n2, D4-D5)
140
61
  begin
141
- if File.exist?(SessionLedger.pointer_path(store, sid))
142
- SessionLedger.ensure_tmp_root(store)
143
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
62
+ if Dir.exist?(SessionLedger.session_tmp_dir(store, sid))
144
63
  File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
145
64
  end
146
65
  rescue StandardError
147
66
  nil
148
67
  end
149
68
 
150
- # --- (b) read the pointer -----------------------------------------------
69
+ # --- (b) a live delivery lock names this session (344 n2, D5, D12) ----------
151
70
  skip_day_ledger = false
152
71
  begin
153
- pointer = SessionLedger.pointer_path(store, sid)
154
- if File.exist?(pointer)
155
- current = File.read(pointer).strip
156
- skip_day_ledger = !current.empty? && current != today
157
- end
72
+ full_session = session_id.empty? ? ENV["CLAUDE_CODE_SESSION_ID"].to_s : session_id
73
+ skip_day_ledger = !ActiveDelivery.resolve(
74
+ global_store: store,
75
+ project_roots: ActiveDelivery.project_roots(plastic_home),
76
+ session: full_session
77
+ ).nil?
158
78
  rescue StandardError
159
79
  nil
160
80
  end
@@ -183,13 +103,13 @@ system_message = nil
183
103
 
184
104
  # --- (d) "continue" cockpit -------------------------------------------------
185
105
  begin
186
- if prompt.match?(/\bcontinue\b/i)
106
+ if prompt.to_s.strip.downcase == "continue"
187
107
  dashboard = File.expand_path("dashboard.rb", __dir__)
188
108
  if File.exist?(dashboard)
189
109
  cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
190
110
  if status.success? && !cockpit.strip.empty?
191
111
  context_parts << (cockpit.rstrip +
192
- "\n\nFollow the plastic-intent-continuing skill workflow to resume: " \
112
+ "\n\nRun `plastic continue` to resume: " \
193
113
  "if active intents exist, read their state and resume; otherwise surface the " \
194
114
  "most-valuable next work and any stale or triage intents from the board above.")
195
115
 
@@ -216,7 +136,7 @@ begin
216
136
  auto_triggers = ["take it from here", "deliver this", "/plastic-auto", "auto mode"].freeze
217
137
  triggered = auto_triggers.any? { |p| message_lower.include?(p) } || message_lower.match?(/\bauto\b/)
218
138
  if triggered
219
- context_parts << "Auto-mode request detected. Invoke the plastic-auto skill to deliver the " \
139
+ context_parts << "Auto-mode request detected. Run `plastic auto take ID` to deliver the " \
220
140
  "active intent autonomously - it takes the intent under a lock and a worktree " \
221
141
  "and points this session at it."
222
142
  end
@@ -224,25 +144,6 @@ rescue StandardError
224
144
  nil
225
145
  end
226
146
 
227
- # --- (f) Future-intent hint, global store then the project's own store -----
228
- begin
229
- message = prompt.to_s.downcase
230
- if message.strip.length >= 10 && message.strip != "continue"
231
- matches = future_intent_matches(plastic_home, message)
232
- project_root = resolve_project_store_root(cwd, plastic_home)
233
- matches += future_intent_matches(project_root, message) if project_root
234
-
235
- if matches.any?
236
- parts = ["PLASTIC - Future intents related to this message:\n"]
237
- matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
238
- parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
239
- context_parts << parts.join("\n")
240
- end
241
- end
242
- rescue StandardError
243
- nil
244
- end
245
-
246
147
  exit 0 if context_parts.empty?
247
148
 
248
149
  payload_out = {
@@ -8,6 +8,7 @@
8
8
  #
9
9
  # Usage: hook-close <plastic_home> (stdin: the hook JSON)
10
10
 
11
+ require_relative "lib/store_layout"
11
12
  require "json"
12
13
  require_relative "lib/session_ledger"
13
14
  require_relative "lib/session_close"
@@ -23,7 +24,7 @@ payload =
23
24
  {}
24
25
  end
25
26
 
26
- store = File.join(File.expand_path(plastic_home), "store")
27
+ store = Plastic::StoreLayout.global_store(File.expand_path(plastic_home))
27
28
  filer = File.expand_path("file-session-intent", __dir__)
28
29
  templates = File.expand_path("../templates", __dir__)
29
30
  begin
@@ -51,11 +51,18 @@ begin
51
51
  tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
52
52
  plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
53
53
 
54
+ # PLASTIC_HOOK_TRACE=<file> (331a1): opt-in, off by default, one JSON
55
+ # object appended per chunk. This script is the only place allowed to
56
+ # read the environment; MessageDisplay takes the sink as an argument.
57
+ trace_path = ENV["PLASTIC_HOOK_TRACE"].to_s
58
+ trace = trace_path.empty? ? nil : MessageDisplay.file_trace(trace_path)
59
+
54
60
  handler = MessageDisplay.new(
55
61
  tmp_root: tmp_root,
56
62
  plastic_home: plastic_home,
57
63
  color: color_enabled?(plastic_home),
58
64
  now: Time.now,
65
+ trace: trace,
59
66
  )
60
67
  result = handler.handle(payload)
61
68
 
@@ -10,12 +10,14 @@
10
10
  # lands, and calls the session-commit seam when that script exists. Always
11
11
  # exits 0 (spec D3).
12
12
 
13
+ require_relative "lib/store_layout"
13
14
  require "json"
14
15
  require "timeout"
15
16
  require "fileutils"
16
17
  require_relative "lib/savepoint"
17
18
  require_relative "lib/lock"
18
19
  require_relative "lib/session_ledger"
20
+ require_relative "lib/active_delivery"
19
21
 
20
22
  raw = begin
21
23
  $stdin.read
@@ -76,15 +78,24 @@ skip_day_ledger = inside_plastic || !intent_dir_abs.nil?
76
78
  # --- (c) promote the newest pending line, Item savepoint, seam -------------
77
79
  unless skip_day_ledger
78
80
  begin
79
- store = File.join(plastic_home, "store")
81
+ store = Plastic::StoreLayout.global_store(plastic_home)
80
82
  sid = SessionLedger.short_session_id(nil, session_id)
81
83
  today = SessionLedger.day_id
82
84
 
85
+ # A live delivery lock naming this session's full id skips promotion
86
+ # (344 n2, D5, D12): the lock, not the retired pointer, decides.
83
87
  proceed = true
84
- pointer = SessionLedger.pointer_path(store, sid)
85
- if File.exist?(pointer)
86
- current = File.read(pointer).strip
87
- proceed = current.empty? || current == today
88
+ unless session_id.empty?
89
+ begin
90
+ held = ActiveDelivery.resolve(
91
+ global_store: store,
92
+ project_roots: ActiveDelivery.project_roots(plastic_home),
93
+ session: session_id
94
+ )
95
+ proceed = held.nil?
96
+ rescue StandardError
97
+ proceed = true
98
+ end
88
99
  end
89
100
 
90
101
  if proceed
@@ -135,19 +146,16 @@ unless skip_day_ledger
135
146
  end
136
147
  end
137
148
 
138
- # --- (d) heartbeat, guarded on the pointer (spec D9) -----------------------
139
- # Session start writes the `current` pointer; with no pointer at all, session
140
- # start never ran for this session (a `-p` print session, a resumed
141
- # background job, an unregistered SessionStart hook), so neither the
142
- # per-session dir nor its heartbeat is created here -- guarding only the
143
- # write would still leave an empty directory that heartbeat_age's mtime
144
- # fallback reports as an orphan just the same.
149
+ # --- (d) heartbeat, guarded on the session tmp dir (344 n2, D4) ------------
150
+ # Session start creates the per-session tmp directory; with no such
151
+ # directory at all, session start never ran for this session (a `-p` print
152
+ # session, a resumed background job, an unregistered SessionStart hook), so
153
+ # the heartbeat is not written and no directory is created here either --
154
+ # the session tmp directory, created by session start, is the guard.
145
155
  begin
146
- store ||= File.join(plastic_home, "store")
156
+ store ||= Plastic::StoreLayout.global_store(plastic_home)
147
157
  sid ||= SessionLedger.short_session_id(nil, session_id)
148
- if File.exist?(SessionLedger.pointer_path(store, sid))
149
- SessionLedger.ensure_tmp_root(store)
150
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
158
+ if Dir.exist?(SessionLedger.session_tmp_dir(store, sid))
151
159
  File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
152
160
  end
153
161
  rescue StandardError
@@ -4,7 +4,8 @@
4
4
 
5
5
  # hook-savepoint (intent 311): the PreCompact hook body. Reads the harness
6
6
  # payload from stdin (session_id, cwd), takes the Plastic home from argv,
7
- # writes this session's hand-off for the pointer's day, and prints one
7
+ # writes this session's hand-off for the session's day from the day ledger,
8
+ # and prints one
8
9
  # message: the fixed text, plus the written file's path when a session
9
10
  # resolved (both harnesses relay the same launcher and the Codex pin feeds
10
11
  # both sides the same payload, so the two stay equal). Always exits 0,
@@ -12,15 +13,35 @@
12
13
  #
13
14
  # Usage: hook-savepoint <plastic_home> (stdin: the hook JSON)
14
15
 
16
+ require_relative "lib/store_layout"
15
17
  require "json"
16
18
  require_relative "lib/session_ledger"
17
19
  require_relative "lib/handoff"
20
+ require_relative "lib/active_delivery"
18
21
 
19
22
  MESSAGE = "PLASTIC SAVEPOINT - context is being compacted. The hand-off for this " \
20
23
  "session is written in today's day ledger " \
21
24
  "(store/.sessions/<day>/handoff--<session>.md). " \
22
25
  "After compaction say continue to resume from it."
23
26
 
27
+ # The intent this session is delivering, resolved through the same walk the
28
+ # Stop gate uses (row 4.38): read-config's project_roots, plus the global
29
+ # store, glob for a live delivery.lock this session holds. Never raises
30
+ # (ActiveDelivery.resolve rescues its own reads); a lookup failure just
31
+ # means the Runner section is omitted, never that the hand-off is lost.
32
+ def resolve_intent(plastic_home, session_id, read_config_path)
33
+ return nil if session_id.strip.empty?
34
+
35
+ global_store = Plastic::StoreLayout.global_store(plastic_home)
36
+ roots_raw = IO.popen({ "PLASTIC_HOME" => plastic_home }, [read_config_path, "project_roots"],
37
+ err: File::NULL, &:read).to_s.strip
38
+ project_roots = roots_raw.empty? ? [] : Array(JSON.parse(roots_raw))
39
+ project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
40
+ ActiveDelivery.resolve(global_store: global_store, project_roots: project_roots, session: session_id)
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
24
45
  written = nil
25
46
  begin
26
47
  plastic_home = ARGV[0].to_s
@@ -30,11 +51,14 @@ begin
30
51
  session_id = payload["session_id"].to_s
31
52
 
32
53
  unless plastic_home.empty? || session_id.strip.empty?
33
- store = File.join(File.expand_path(plastic_home), "store")
54
+ home = File.expand_path(plastic_home)
55
+ store = Plastic::StoreLayout.global_store(home)
34
56
  session = SessionLedger.short_session_id(session_id, nil)
35
57
  day = Handoff.day_for(store, session, today: SessionLedger.day_id)
58
+ read_config_path = File.expand_path("read-config", __dir__)
59
+ intent = resolve_intent(home, session_id, read_config_path)
36
60
  written = Handoff.write(store: store, day: day, session: session, trigger: "precompact",
37
- templates: File.expand_path("../templates", __dir__))
61
+ templates: File.expand_path("../templates", __dir__), intent: intent)
38
62
  end
39
63
  rescue StandardError, JSON::ParserError
40
64
  nil