@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,155 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # GuardedAppend - the shared fail-closed write guard behind NodeLedger's transition
5
+ # lines and RoadmapSavepoint's roadmap ledger (intent 335, spec "Approach").
6
+ #
7
+ # One module function, #call: opens `path` RDWR|APPEND|CREAT, takes a non-blocking
8
+ # exclusive lock with a bounded number of retries, and under that ONE hold reads the
9
+ # current content, yields it to the caller's block, and appends whatever the block
10
+ # returns. A block returning nil is a refusal: nothing is written. Read, decide, and
11
+ # append happen inside one lock hold on purpose (spec "Approach"): that is what makes
12
+ # "is this subject already running" and "append running" atomic against a second
13
+ # writer, which a check followed by a separate append could never be.
14
+ #
15
+ # Pure and dependency-injected: `flock:` and `sleeper:` are constructor-style test
16
+ # seams (never an environment variable, never a global); this module reads no
17
+ # environment variable and shells out to nothing.
18
+ module GuardedAppend
19
+ # Raised when the lock could not be taken within `retries` attempts (real
20
+ # contention), or when flock itself is unsupported on this filesystem and
21
+ # `strict: true` (spec D12a). Nothing is written either way; the caller is told
22
+ # plainly that nothing landed and must retry.
23
+ class Unavailable < StandardError; end
24
+
25
+ module_function
26
+
27
+ # Five attempts, 20 ms apart: about 100 ms of wall time total (spec D12).
28
+ DEFAULT_RETRIES = 5
29
+ DEFAULT_BACKOFF = 0.02
30
+
31
+ # Default lock and sleep seams: a real flock call, a real sleep. Tests inject
32
+ # replacements to simulate contention, recovery, and a flock-less filesystem
33
+ # hermetically, with no need for a real flock-less mount or a slow test run.
34
+ DEFAULT_FLOCK = ->(handle, mode) { handle.flock(mode) }
35
+ private_constant :DEFAULT_FLOCK
36
+
37
+ DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
38
+ private_constant :DEFAULT_SLEEPER
39
+
40
+ # Open `path` (creating it if absent, never truncating it), take an exclusive
41
+ # non-blocking lock with up to `retries` attempts (`backoff` seconds apart), and
42
+ # under that one hold read the file's current content, yield it to the block, and
43
+ # append what the block returns.
44
+ #
45
+ # Returns :written when a line was appended, :refused when the block returned nil
46
+ # (nothing written, spec: a refusal). Raises Unavailable, writing nothing, when the
47
+ # lock could not be taken within `retries` attempts.
48
+ #
49
+ # `strict:` decides what happens when flock itself raises a SystemCallError OTHER
50
+ # than contention (EWOULDBLOCK/EAGAIN) - a filesystem without flock support,
51
+ # distinct from real contention (spec D12a): strict (the default) raises
52
+ # Unavailable; non-strict proceeds unguarded, since a single O_APPEND write still
53
+ # lands whole there. The SystemCallError rescue wraps the flock call only (spec
54
+ # D12b); an Errno::ENOENT from File.open (a missing parent directory) propagates
55
+ # as itself, never read as Unavailable.
56
+ def call(path, retries: DEFAULT_RETRIES, backoff: DEFAULT_BACKOFF, strict: true,
57
+ flock: DEFAULT_FLOCK, sleeper: DEFAULT_SLEEPER, &block)
58
+ created = !File.exist?(path)
59
+ handle = File.open(path, File::RDWR | File::APPEND | File::CREAT, 0o644)
60
+ begin
61
+ status = take_lock(handle, retries: retries, backoff: backoff, flock: flock, sleeper: sleeper)
62
+
63
+ case status
64
+ when :contended
65
+ raise Unavailable, "could not take an exclusive lock on #{path} after #{retries} attempts"
66
+ when :unsupported
67
+ if strict
68
+ raise Unavailable, "flock is unsupported on #{path} and strict: true refuses to proceed unguarded"
69
+ end
70
+
71
+ write_line(handle, &block)
72
+ when :locked
73
+ begin
74
+ write_line(handle, &block)
75
+ ensure
76
+ unlock(handle, flock: flock)
77
+ end
78
+ end
79
+ ensure
80
+ handle.close
81
+ end
82
+ rescue Unavailable
83
+ remove_freshly_created_empty_file(path) if created
84
+ raise
85
+ end
86
+
87
+ # Attempt the lock up to `retries` times. Returns :locked, :unsupported (a
88
+ # non-contention SystemCallError from flock, decided once, never retried), or
89
+ # :contended (every attempt failed with EWOULDBLOCK/EAGAIN or a false return).
90
+ # Sleeps `backoff` seconds after EVERY contended attempt, including the last, so
91
+ # the total backoff budget is exactly `retries` sleeps (spec D12: "about 100 ms of
92
+ # wall time in total" = 5 attempts * 20 ms, not 4).
93
+ def take_lock(handle, retries:, backoff:, flock:, sleeper:)
94
+ status = :contended
95
+ retries.times do
96
+ status = try_flock(handle, flock)
97
+ return status unless status == :contended
98
+
99
+ sleeper.call(backoff)
100
+ end
101
+ status
102
+ end
103
+ private_class_method :take_lock
104
+
105
+ # One attempt at the non-blocking exclusive lock. File#flock RAISES (does not
106
+ # return false) for every errno except EWOULDBLOCK/EAGAIN on most platforms, so
107
+ # both the "returns false" and the "raises EWOULDBLOCK" shapes read as contention;
108
+ # any other SystemCallError means flock is not supported on this filesystem.
109
+ def try_flock(handle, flock)
110
+ result = flock.call(handle, File::LOCK_EX | File::LOCK_NB)
111
+ result == false ? :contended : :locked
112
+ rescue Errno::EWOULDBLOCK, Errno::EAGAIN
113
+ :contended
114
+ rescue SystemCallError
115
+ :unsupported
116
+ end
117
+ private_class_method :try_flock
118
+
119
+ # Read the current content, yield it to the block, and write what it returns. A
120
+ # block returning nil writes nothing and reports :refused. Before a real write, if
121
+ # the content is non-empty and does not end in a newline, a newline is written
122
+ # first (spec D9a): a crash that truncated the previous write must not glue the
123
+ # next transition onto its tail. The handle is opened O_APPEND, so every write
124
+ # lands at the current end of file regardless of the read's cursor position.
125
+ def write_line(handle, &block)
126
+ content = handle.read
127
+ line = block.call(content)
128
+ return :refused if line.nil?
129
+
130
+ prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
131
+ handle.write("#{prefix}#{line}")
132
+ handle.flush
133
+ :written
134
+ end
135
+ private_class_method :write_line
136
+
137
+ def unlock(handle, flock:)
138
+ flock.call(handle, File::LOCK_UN)
139
+ rescue SystemCallError
140
+ nil
141
+ end
142
+ private_class_method :unlock
143
+
144
+ # Row 7.9 (post-execution review): a give-up (Unavailable) must not leave a zero-byte
145
+ # file behind that File::CREAT created for a target that did not exist before this
146
+ # call. Only ever removes a file this same call created (never a pre-existing file,
147
+ # spec matrix 1.11) and only when it is still empty (no write ever reached it on the
148
+ # give-up paths this rescues).
149
+ def remove_freshly_created_empty_file(path)
150
+ File.unlink(path) if File.exist?(path) && File.zero?(path)
151
+ rescue SystemCallError
152
+ nil
153
+ end
154
+ private_class_method :remove_freshly_created_empty_file
155
+ end
@@ -19,6 +19,10 @@ module Handoff
19
19
  DONE_CAP = 10
20
20
  RECENT_CAP = 10
21
21
  OTHERS_CAP = 10
22
+ # Intent 340b (G7c, n4, D11/D12): runner-step.last is raw stdout+stderr,
23
+ # not a bounded list of items like every other section, so it gets a byte
24
+ # cap of its own rather than an item count.
25
+ RUNNER_CAP = 2048
22
26
  # A ledger summary may run to 200 characters; a hand-off line shows the
23
27
  # first 80, so the caps above are reachable inside the byte budget.
24
28
  SUMMARY_MAX = 80
@@ -34,15 +38,11 @@ module Handoff
34
38
  File.join(SessionLedger.day_dir(store, day), "handoff--#{session}.md")
35
39
  end
36
40
 
37
- # The day this session's hand-off belongs to: the pointer's day id when
38
- # the pointer holds one, else today (no pointer, or a pointer naming an
39
- # intent), the same fallback SessionClose uses for the drop at close.
41
+ # The day this session's hand-off belongs to: the session's day from the
42
+ # day ledger (SessionLedger.session_day, graph.md D7), the same lookup
43
+ # SessionClose uses for the drop at close.
40
44
  def day_for(store, session, today:)
41
- path = SessionLedger.pointer_path(store, session)
42
- return today unless File.exist?(path)
43
-
44
- value = File.read(path).strip
45
- SessionLedger.valid_day_id?(value) ? value : today
45
+ SessionLedger.session_day(store, session, today: today)
46
46
  end
47
47
 
48
48
  def clip(summary)
@@ -74,9 +74,34 @@ module Handoff
74
74
  end
75
75
  end
76
76
 
77
+ # Raw text of the delivering intent's runner-step.last, or nil when there
78
+ # is no intent, the file is absent, or it cannot be read (row 4.41: a torn
79
+ # file must never raise a PreCompact hand-off out of existence).
80
+ def read_runner_last(intent)
81
+ return nil if intent.to_s.strip.empty?
82
+
83
+ path = File.join(intent, "runner-step.last")
84
+ return nil unless File.file?(path)
85
+
86
+ text = File.read(path)
87
+ text.strip.empty? ? nil : text
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ # Omitted entirely when no intent resolves or the file is absent/empty
93
+ # (row 4.39); capped like every other section otherwise (row 4.40).
94
+ def runner_section(intent)
95
+ text = read_runner_last(intent)
96
+ return nil unless text
97
+
98
+ clipped = text.bytesize > RUNNER_CAP ? "#{text.byteslice(0, RUNNER_CAP)}\n(truncated)" : text
99
+ "## Runner\n#{clipped}\n"
100
+ end
101
+
77
102
  # --- rendering, pure -----------------------------------------------------------
78
103
 
79
- def render(store:, day:, session:, trigger:, now: Time.now)
104
+ def render(store:, day:, session:, trigger:, now: Time.now, intent: nil)
80
105
  raise ArgumentError, "unknown trigger: #{trigger.inspect}" unless TRIGGERS.include?(trigger)
81
106
 
82
107
  items = read_items(store, day)
@@ -102,8 +127,9 @@ module Handoff
102
127
  "Written #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')} at #{trigger}",
103
128
  "",
104
129
  ]
130
+ runner = runner_section(intent)
105
131
  loop do
106
- text = compose(header, lists, hidden)
132
+ text = compose(header, lists, hidden, runner)
107
133
  return text if text.bytesize <= BUDGET
108
134
 
109
135
  key = TRIM_ORDER.find { |k| !lists[k].empty? }
@@ -123,12 +149,13 @@ module Handoff
123
149
  lists[key] = lists[key].last(cap)
124
150
  end
125
151
 
126
- def compose(header, lists, hidden)
152
+ def compose(header, lists, hidden, runner = nil)
127
153
  sections = [
128
154
  section("Open", lists[:open], hidden[:open]),
129
155
  section("Done", lists[:done], hidden[:done]),
130
156
  section("Recent", lists[:recent], hidden[:recent]),
131
157
  section("Others today", lists[:others], hidden[:others]),
158
+ runner,
132
159
  "## Resume\n#{RESUME}\n",
133
160
  ]
134
161
  (header + sections.compact).join("\n")
@@ -168,13 +195,13 @@ module Handoff
168
195
  # partial hand-off and two writers for one session (a tick overlapping a
169
196
  # PreCompact) never share a temp name. Returns the path. With
170
197
  # `templates: nil` the day is not scaffolded, only its directory ensured.
171
- def write(store:, day:, session:, trigger:, templates:, now: Time.now)
198
+ def write(store:, day:, session:, trigger:, templates:, now: Time.now, intent: nil)
172
199
  if templates
173
200
  SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
174
201
  else
175
202
  FileUtils.mkdir_p(SessionLedger.day_dir(store, day))
176
203
  end
177
- text = render(store: store, day: day, session: session, trigger: trigger, now: now)
204
+ text = render(store: store, day: day, session: session, trigger: trigger, now: now, intent: intent)
178
205
  target = path_for(store, day, session)
179
206
  tmp = File.join(File.dirname(target), ".handoff-#{session}-#{Process.pid}-#{Thread.current.object_id}.tmp")
180
207
  File.write(tmp, text)
@@ -0,0 +1,184 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # HarnessAdapter (intent 340b, G7c, n1): the one module in Plastic that knows
5
+ # a harness by name. It resolves the current key from config `agent.type`,
6
+ # renders a dispatch plan into that harness's instructions, and answers what
7
+ # the ledger should record. `runner step` calls it; nothing else in the
8
+ # runner does (spec D1).
9
+ #
10
+ # Pure: every input is an argument, nothing here reads ENV or a real
11
+ # config.yml itself - the caller (scripts/runner) owns loading real config,
12
+ # this module only resolves values out of what it is handed, the same
13
+ # discipline RunnerPolicy already holds for model resolution.
14
+ module HarnessAdapter
15
+ module_function
16
+
17
+ # The only two harnesses this module knows (spec D2). An unknown key -
18
+ # config-authored or from `--harness` - always falls back to this rather
19
+ # than to no adapter at all (matrix row 1.3): a plan with no rendering
20
+ # under it is worse than a plan rendered for the wrong harness, because the
21
+ # first is silent.
22
+ DEFAULT_KEY = "claude-code"
23
+ KNOWN_KEYS = %w[claude-code codex].freeze
24
+
25
+ # The exact sentence 340a's watch prints to stderr and exits 1 with,
26
+ # before any write, when `--dispatch` is asked of a harness
27
+ # #unattended_start? refuses (graph.md D7). Quoted verbatim in
28
+ # docs/reference/harness-adapters.md (D12); change the wording in one
29
+ # place only.
30
+ UNATTENDED_START_SENTENCE =
31
+ "Unattended start is delivered only where a Ruby loop owns dispatch (Codex, through runner " \
32
+ "watch --dispatch); on Claude Code the runner is the harness session (327 Q6), so arm /loop " \
33
+ "over runner watch in a live session instead."
34
+
35
+ # unattended_start?(key) -> true only for "codex" (graph.md D7): the one
36
+ # harness where a Ruby loop (RunnerUntilEmpty, through `runner watch
37
+ # --dispatch`) owns dispatch end to end with nobody on the other end
38
+ # making subagent calls. Claude Code IS the harness session (327 Q6), and
39
+ # an unknown key is never granted unattended start either - this module
40
+ # stays the only place that names a harness, so RunnerWatch asks this
41
+ # predicate and never compares a key against "codex" itself.
42
+ def unattended_start?(key)
43
+ key.to_s == "codex"
44
+ end
45
+
46
+ # kind -> the Claude Code agent type dispatched for it (spec Approach).
47
+ # Verify and research carry no `Bash` in their own frontmatter (n2); this
48
+ # table only names WHICH agent a kind maps to. An unknown kind gets
49
+ # `work`'s row, the widest of the three, never no agent type at all
50
+ # (matrix row 1.10), mirroring RunnerPolicy's own kind-table fallback.
51
+ AGENT_TYPE_BY_KIND = {
52
+ "work" => "plastic-node-work",
53
+ "verify" => "plastic-node-verify",
54
+ "research" => "plastic-node-research",
55
+ }.freeze
56
+
57
+ def agent_type_for_kind(kind)
58
+ AGENT_TYPE_BY_KIND.fetch(kind.to_s, AGENT_TYPE_BY_KIND["work"])
59
+ end
60
+
61
+ # resolve_key(config:, override:) -> the harness key for one call (matrix
62
+ # rows 1.1-1.3, 1.18). `override` (the CLI's `--harness KEY`, spec D2) wins
63
+ # over `config`'s own `agent.type` for that one call; either source is
64
+ # squashed (whitespace collapsed, matrix row 1.18) before it is even
65
+ # compared, so a config-authored value carrying a tab or a newline can
66
+ # never reach `NodeLedger` unsquashed and raise `ArgumentError` mid-dispatch,
67
+ # after the node input is already built. A value that still is not one of
68
+ # KNOWN_KEYS after squashing falls back to DEFAULT_KEY with a warning
69
+ # (never to no adapter), so this method's return is always one of the two
70
+ # known, clean strings - safe to hand straight to a ledger field.
71
+ def resolve_key(config: {}, override: nil)
72
+ raw = blank?(override) ? agent_type_from_config(config) : override.to_s
73
+ squashed = squash(raw)
74
+ return squashed if KNOWN_KEYS.include?(squashed)
75
+
76
+ warn "harness_adapter: unknown harness #{raw.inspect}, falling back to #{DEFAULT_KEY.inspect}"
77
+ DEFAULT_KEY
78
+ end
79
+
80
+ def agent_type_from_config(config)
81
+ return DEFAULT_KEY unless config.is_a?(Hash)
82
+
83
+ value = config.dig("agent", "type")
84
+ value = config.dig(:agent, :type) if blank?(value)
85
+ blank?(value) ? DEFAULT_KEY : value.to_s
86
+ end
87
+ private_class_method :agent_type_from_config
88
+
89
+ def squash(text)
90
+ text.to_s.strip.gsub(/\s+/, " ")
91
+ end
92
+ private_class_method :squash
93
+
94
+ def blank?(value)
95
+ value.nil? || value.to_s.strip.empty?
96
+ end
97
+ private_class_method :blank?
98
+
99
+ # render(dispatched, harness:, return_contract:) -> the rendered
100
+ # instruction block for `harness` (a String), or nil for an empty dispatch
101
+ # list (matrix row 1.14: a step that dispatched nothing prints no
102
+ # instruction block for a session to act on). `dispatched` is the runner's
103
+ # own plan entries (RunnerDispatch's `:dispatched` shape): [{node:, kind:,
104
+ # role:, model:, worktree:, input:}, ...]. The model and the node input path
105
+ # ride straight from each entry - never a fresh lookup (matrix row 1.11)
106
+ # and never a summary (matrix row 1.12) - and `return_contract` (the exact
107
+ # text RunnerDispatch::RETURN_CONTRACT already carries in the YAML plan)
108
+ # renders exactly once, above every node, never per node (matrix row 1.13).
109
+ def render(dispatched, return_contract:, harness: DEFAULT_KEY)
110
+ entries = Array(dispatched)
111
+ return nil if entries.empty?
112
+
113
+ key = KNOWN_KEYS.include?(harness.to_s) ? harness.to_s : DEFAULT_KEY
114
+ key == "codex" ? render_codex(entries, return_contract) : render_claude_code(entries, return_contract)
115
+ end
116
+
117
+ # The Claude Code rendering (spec Approach): per dispatched node, the agent
118
+ # type from the kind, the model the plan already resolved, and the node input
119
+ # path as the whole prompt.
120
+ def render_claude_code(entries, return_contract)
121
+ blocks = entries.map do |d|
122
+ "Dispatch #{agent_type_for_kind(d[:kind])} for #{d[:node]} (model: #{d[:model]}):\n prompt: #{d[:input]}"
123
+ end
124
+ "#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
125
+ end
126
+ private_class_method :render_claude_code
127
+
128
+ # The Codex rendering: a Codex node runs end to end through `scripts/
129
+ # node-run` (n6), which reads the ledger's own `running` line directly and
130
+ # never through this printed block - so this names, per node, the same
131
+ # facts the block gives Claude Code (kind, model, node input path) framed as
132
+ # `node-run` calls, proving the seam renders SOMETHING for the second
133
+ # harness rather than nothing (matrix row 1.27), without inventing detail
134
+ # that belongs to n6's own adapter.
135
+ def render_codex(entries, return_contract)
136
+ blocks = entries.map do |d|
137
+ "node-run #{d[:node]} (#{d[:kind]}, model: #{d[:model]}):\n input: #{d[:input]}"
138
+ end
139
+ "#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
140
+ end
141
+ private_class_method :render_codex
142
+
143
+ # cross_harness_resume(entries) -> [{node:, harnesses: [...], commits: {...}}, ...]
144
+ # (spec D12, 340b n8). `entries` is a node ledger's own parsed lines (the
145
+ # shape `NodeLedger.entries`/`entries_from_content` return): every declared
146
+ # node's own `harness=` values, read across every non-torn line for that
147
+ # subject in file order, deduplicated by first appearance (matrix row 8.6:
148
+ # the starting harness is whichever key showed up first). A `reclaimed`
149
+ # line carries no `harness=` of its own and is never a boundary here (row
150
+ # 8.7) - it is simply a line with nothing to contribute, scanned like any
151
+ # other. A node whose lines carry one key, or none at all, reports nothing
152
+ # (rows 8.2, 8.3): an old ledger with no `harness=` field anywhere must
153
+ # stay silent rather than raise.
154
+ #
155
+ # Every node named by ANY entry is scanned (row 8.8), not only the first;
156
+ # `RunnerAbsorb` is what put `harness=` on both the running line and the
157
+ # terminal line, so the commit that lands with the finishing harness's own
158
+ # terminal line is exactly the evidence a resumed node actually left
159
+ # behind (row 8.5).
160
+ def cross_harness_resume(entries)
161
+ live = Array(entries).reject { |e| e[:torn] }
162
+ nodes = live.map { |e| e[:subject] }.uniq
163
+
164
+ nodes.filter_map do |node|
165
+ node_lines = live.select { |e| e[:subject] == node }
166
+ keys = []
167
+ commits = Hash.new { |h, k| h[k] = [] }
168
+
169
+ node_lines.each do |e|
170
+ fields = e[:fields] || {}
171
+ key = fields["harness"]
172
+ next if blank?(key)
173
+
174
+ keys << key unless keys.include?(key)
175
+ commit = fields["commit"]
176
+ commits[key] << commit unless blank?(commit) || commits[key].include?(commit)
177
+ end
178
+
179
+ next if keys.size < 2
180
+
181
+ { node: node, harnesses: keys, commits: keys.to_h { |k| [k, commits[k]] } }
182
+ end
183
+ end
184
+ end
@@ -34,6 +34,17 @@ module HookRegistry
34
34
  { "name" => "check-update", "status" => "" },
35
35
  ] },
36
36
  ],
37
+ # Intent 355, n2: the call budget guard. Not one of the edit-path
38
+ # gates intent 302 removed (those denied a write on content; this
39
+ # denies a call on a per-attempt COUNT, read from the session's own
40
+ # transcript, never from the tool's arguments) - see scripts/hook-
41
+ # call-budget. Claude only: CODEX_LIVE_STATE_EVENTS below does not
42
+ # carry PreToolUse, so codex_hooks_json never projects it.
43
+ "PreToolUse" => [
44
+ { "matcher" => "", "hooks" => [
45
+ { "name" => "call-budget", "status" => "" },
46
+ ] },
47
+ ],
37
48
  "PreCompact" => [
38
49
  { "matcher" => "", "hooks" => [
39
50
  { "name" => "savepoint", "status" => "Saving Plastic intent state..." },
@@ -68,6 +79,18 @@ module HookRegistry
68
79
  { "name" => "message-display", "status" => "" },
69
80
  ] },
70
81
  ],
82
+ # Continuation on Claude Code (intent 340b, G7c, n4, D5/D7): registered
83
+ # statically here, like every other hook, and decided at runtime by
84
+ # StopGate, which reads runner.stop_hook itself (default false, D9).
85
+ # Not one of CODEX_LIVE_STATE_EVENTS, CODEX_POST_HOOKS or
86
+ # CODEX_SESSION_END_HOOKS, so codex_hooks_json never picks it up and
87
+ # codex_hook_names stays at its pinned six (test/hook_registry_test.rb,
88
+ # test/codex_hooks_test.rb).
89
+ "Stop" => [
90
+ { "matcher" => "", "hooks" => [
91
+ { "name" => "stop", "status" => "" },
92
+ ] },
93
+ ],
71
94
  }
72
95
  end
73
96
 
@@ -75,8 +98,9 @@ module HookRegistry
75
98
  # the PostToolUse record hook collapses from Claude's multi-tool matcher onto
76
99
  # Codex's single apply_patch tool (181 F4: apply_patch is Codex's sole
77
100
  # file-mutation tool; tool_name always reports apply_patch), and the live-state
78
- # events project through whole. Since intent 302 there is no PreToolUse group at
79
- # all: the edit-path gates are gone on both harnesses. Command invokes the
101
+ # events project through whole. PreToolUse (intent 355, n2's call-budget guard,
102
+ # Claude only) is not one of CODEX_LIVE_STATE_EVENTS below, so Codex still
103
+ # carries no PreToolUse group of its own. Command invokes the
80
104
  # codex-hook dispatcher with the hook name. Guide-settled shape [guide Part 3]:
81
105
  # top-level {"hooks":{<Event>: [{"matcher","hooks":[{"type":"command","command",
82
106
  # "statusMessage"}]}]}}, identical to Claude's shape, string command. Single
@@ -149,7 +173,7 @@ module HookRegistry
149
173
  # name here in the SAME change. Skip it and every existing install keeps a dead
150
174
  # registration no update will ever clean up.
151
175
  #
152
- # Never fold these into claude_launcher_names: that method is what doctor's
176
+ # Never merge these into claude_launcher_names: that method is what doctor's
153
177
  # hooks_exist demands be present on disk, so a retired name there makes a correct
154
178
  # install report missing launchers.
155
179
  RETIRED_HOOK_NAMES = %w[