@zalom/plastic 1.14.1 → 2.0.0-alpha.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 (200) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +9 -49
  13. package/hooks/record +4 -0
  14. package/hooks/savepoint +5 -5
  15. package/package.json +3 -2
  16. package/scripts/agent-report +8 -9
  17. package/scripts/append-ledger +208 -0
  18. package/scripts/codex-hook +78 -90
  19. package/scripts/dashboard.rb +4 -4
  20. package/scripts/day-summary +53 -0
  21. package/scripts/doctor.rb +106 -43
  22. package/scripts/end-intent +77 -76
  23. package/scripts/exec-worktree +11 -20
  24. package/scripts/file-session-intent +61 -0
  25. package/scripts/hook-capture +242 -0
  26. package/scripts/hook-close +36 -0
  27. package/scripts/hook-record +149 -0
  28. package/scripts/hook-savepoint +45 -0
  29. package/scripts/hook-session-start +106 -12
  30. package/scripts/install.rb +8 -6
  31. package/scripts/lib/agent_models.rb +1 -7
  32. package/scripts/lib/arm.rb +283 -0
  33. package/scripts/lib/backfill_intent.rb +316 -0
  34. package/scripts/lib/bridge.rb +24 -1597
  35. package/scripts/lib/compact_instructions.rb +56 -0
  36. package/scripts/lib/day_summary.rb +206 -0
  37. package/scripts/lib/doctor_core.rb +65 -88
  38. package/scripts/lib/doctor_session_ledger.rb +106 -0
  39. package/scripts/lib/exec_worktree.rb +14 -60
  40. package/scripts/lib/handoff.rb +184 -0
  41. package/scripts/lib/harness_text.rb +5 -4
  42. package/scripts/lib/hook_registry.rb +44 -107
  43. package/scripts/lib/insights.rb +3 -4
  44. package/scripts/lib/installer_core.rb +104 -40
  45. package/scripts/lib/lock.rb +6 -26
  46. package/scripts/lib/outcome_guard.rb +7 -5
  47. package/scripts/lib/project_validator.rb +30 -1
  48. package/scripts/lib/roadmap_queue.rb +3 -3
  49. package/scripts/lib/roadmap_savepoint.rb +1 -1
  50. package/scripts/lib/rule_catalog.rb +6 -3
  51. package/scripts/lib/savepoint.rb +320 -0
  52. package/scripts/lib/scaffold_intent.rb +13 -179
  53. package/scripts/lib/session_backfill.rb +184 -0
  54. package/scripts/lib/session_close.rb +104 -0
  55. package/scripts/lib/session_git.rb +469 -0
  56. package/scripts/lib/session_ledger.rb +497 -0
  57. package/scripts/lib/store_discovery.rb +1 -1
  58. package/scripts/lib/store_provisioning.rb +1 -1
  59. package/scripts/maintenance-run +7 -7
  60. package/scripts/new-intent +63 -4
  61. package/scripts/plastic-lock +94 -41
  62. package/scripts/promote-session-item +150 -0
  63. package/scripts/read-config +3 -0
  64. package/scripts/rebuild-graph +1 -1
  65. package/scripts/roadmap-next +2 -2
  66. package/scripts/rollback.rb +48 -3
  67. package/scripts/scaffold-intent +37 -63
  68. package/scripts/session-commit +116 -0
  69. package/scripts/spawn-preamble +5 -5
  70. package/scripts/update.rb +7 -3
  71. package/scripts/write-handoff +60 -0
  72. package/skills/agent-advisor/SKILL.md +3 -3
  73. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  74. package/skills/auto/SKILL.md +218 -317
  75. package/skills/auto/evals/evals.json +10 -10
  76. package/skills/auto/references/agent-architecture.md +102 -90
  77. package/skills/auto/references/agent-report-contract.md +4 -21
  78. package/skills/auto/references/end-tail.md +47 -53
  79. package/skills/auto/references/human-report-contract.md +8 -8
  80. package/skills/conventions/SKILL.md +1 -3
  81. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  82. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  83. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  84. package/skills/dashboard/SKILL.md +1 -1
  85. package/skills/direct/SKILL.md +66 -0
  86. package/skills/direct/references/request-signals.md +59 -0
  87. package/skills/doctor/SKILL.md +58 -11
  88. package/skills/doctor/report.md +3 -1
  89. package/skills/install/SKILL.md +11 -9
  90. package/skills/intent-continuing/SKILL.md +132 -89
  91. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  92. package/skills/intent-continuing/references/context-management.md +2 -2
  93. package/skills/intent-creating/SKILL.md +2 -2
  94. package/skills/intent-creating/references/lifecycle.md +1 -1
  95. package/skills/intent-ending/SKILL.md +28 -37
  96. package/skills/intent-ending/evals/evals.json +4 -4
  97. package/skills/intent-executing/SKILL.md +12 -49
  98. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  99. package/skills/intent-speccing/SKILL.md +108 -51
  100. package/skills/intent-speccing/evals/evals.json +5 -5
  101. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  102. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  103. package/skills/project-creating/SKILL.md +1 -1
  104. package/skills/releasing/SKILL.md +7 -8
  105. package/skills/tutorial/SKILL.md +2 -2
  106. package/skills/tutorial/references/track-1-guided.md +16 -18
  107. package/skills/tutorial/references/track-2-auto.md +12 -13
  108. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  109. package/templates/agents.md +4 -7
  110. package/templates/config.yml +5 -0
  111. package/templates/project.yml +7 -0
  112. package/templates/roadmap.md +1 -1
  113. package/templates/savepoint.md +2 -2
  114. package/templates/session-intent.md +29 -0
  115. package/agents/plastic-brainstorming.md +0 -48
  116. package/agents/plastic-future-intent-researcher.md +0 -35
  117. package/agents/plastic-intent-curator.md +0 -47
  118. package/agents/plastic-intent-discovery.md +0 -43
  119. package/agents/plastic-planner.md +0 -56
  120. package/agents/plastic-spec-specialist.md +0 -53
  121. package/hooks/auto-arm +0 -5
  122. package/hooks/bash-gate +0 -3
  123. package/hooks/continue +0 -31
  124. package/hooks/edit-gates +0 -3
  125. package/hooks/future-intent-check +0 -25
  126. package/hooks/gate-check +0 -12
  127. package/hooks/power-tools +0 -8
  128. package/scripts/hook-auto-arm +0 -51
  129. package/scripts/hook-bash-gate +0 -78
  130. package/scripts/hook-code-gate +0 -38
  131. package/scripts/hook-continue +0 -48
  132. package/scripts/hook-create-gate +0 -51
  133. package/scripts/hook-edit-gates +0 -58
  134. package/scripts/hook-future-intent-check +0 -90
  135. package/scripts/hook-gate-check +0 -169
  136. package/scripts/hook-links-gate +0 -54
  137. package/scripts/hook-lock-gate +0 -47
  138. package/scripts/hook-power-tools +0 -38
  139. package/scripts/hook-savepoint-pre +0 -32
  140. package/scripts/lib/codex_edit_gates.rb +0 -138
  141. package/scripts/lib/edit_gates.rb +0 -398
  142. package/scripts/lib/links_gate.rb +0 -140
  143. package/scripts/lib/qmd_hook.rb +0 -41
  144. package/scripts/lib/spec_header.rb +0 -83
  145. package/scripts/lib/start_intent.rb +0 -296
  146. package/scripts/start-intent +0 -89
  147. package/skills/_active-intent-gate.md +0 -26
  148. package/skills/auto/references/tiers.md +0 -100
  149. package/skills/continuing/SKILL.md +0 -34
  150. package/skills/continuing/evals/evals.json +0 -91
  151. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  152. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  153. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  154. package/skills/intent-brainstorming/SKILL.md +0 -118
  155. package/skills/intent-brainstorming/evals/evals.json +0 -67
  156. package/skills/intent-continuing/evals/evals.json +0 -145
  157. package/skills/intent-discovering/SKILL.md +0 -56
  158. package/skills/intent-grilling/SKILL.md +0 -108
  159. package/skills/intent-linking/SKILL.md +0 -128
  160. package/skills/intent-linking/evals/evals.json +0 -22
  161. package/skills/intent-linking/references/zettelkasten.md +0 -45
  162. package/skills/intent-locking/SKILL.md +0 -64
  163. package/skills/intent-planning/SKILL.md +0 -145
  164. package/skills/intent-planning/evals/evals.json +0 -81
  165. package/skills/intent-planning/references/plan-format.md +0 -130
  166. package/skills/intent-researching/SKILL.md +0 -123
  167. package/skills/intent-researching/evals/evals.json +0 -22
  168. package/skills/intent-savepoint/SKILL.md +0 -83
  169. package/skills/intent-savepoint/references/context-management.md +0 -32
  170. package/skills/intent-starting/SKILL.md +0 -151
  171. package/skills/intent-starting/evals/evals.json +0 -117
  172. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  173. package/skills/project-continuing/SKILL.md +0 -119
  174. package/skills/project-continuing/evals/evals.json +0 -100
  175. package/skills/roadmap-continuing/SKILL.md +0 -89
  176. package/skills/roadmap-continuing/evals/evals.json +0 -82
  177. package/skills/skill-creating/SKILL.md +0 -75
  178. package/skills/skill-creating/evals/evals.json +0 -108
  179. package/skills/skill-creating/references/agents.md +0 -168
  180. package/skills/skill-creating/references/defaults-first.md +0 -23
  181. package/skills/skill-creating/references/evals.md +0 -41
  182. package/skills/skill-creating/references/hooks.md +0 -251
  183. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  184. package/skills/skill-creating/references/scripts.md +0 -166
  185. package/skills/skill-creating/references/skills.md +0 -169
  186. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  187. package/skills/skill-evaluating/SKILL.md +0 -141
  188. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  189. package/skills/skill-evaluating/evals/evals.json +0 -75
  190. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  191. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  192. package/skills/store-curating/SKILL.md +0 -64
  193. package/skills/store-curating/evals/evals.json +0 -38
  194. package/skills/store-indexing/SKILL.md +0 -93
  195. package/skills/store-indexing/evals/evals.json +0 -22
  196. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  197. package/skills/store-provisioning/SKILL.md +0 -55
  198. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  199. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  200. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -6,10 +6,14 @@
6
6
  require "json"
7
7
  require "date"
8
8
  require "yaml"
9
+ require "fileutils"
9
10
  require_relative "lib/bridge"
11
+ require_relative "lib/savepoint"
10
12
  require_relative "lib/boot_banner"
11
13
  require_relative "lib/qmd_sync"
12
14
  require_relative "lib/doctor_core"
15
+ require_relative "lib/session_ledger"
16
+ require_relative "lib/day_summary"
13
17
 
14
18
  index_path, plastic_home, mode, plugin_root = ARGV
15
19
  exit 0 unless index_path && plastic_home && mode
@@ -48,16 +52,20 @@ lines.each do |line|
48
52
  end
49
53
  end
50
54
 
51
- # --- Derive bridge for active intent ---
55
+ # --- Stage of the single active intent (the /tmp bridge this once derived was removed in 2.0, intent 307) ---
52
56
 
53
- bridge_data = nil
57
+ stage_line = nil
54
58
  if active.length == 1 && active.first =~ /store\/([\w-]+)\//
55
59
  dir_name = $1
56
60
  intent_dir = "#{store_dir}/#{dir_name}"
57
- session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
58
- intent_id = dir_name.split("--").first
59
- intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
60
- bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_dir, name: intent_name)
61
+ begin
62
+ stage = Savepoint.derive_stage(intent_dir)
63
+ missing = Savepoint.missing_for_stage(stage, intent_dir)
64
+ missing_str = missing.empty? ? "none" : missing.join(", ")
65
+ stage_line = "Stage: #{stage} | Next: #{missing_str}"
66
+ rescue StandardError
67
+ stage_line = nil
68
+ end
61
69
  end
62
70
 
63
71
  # --- Detect stale future intents ---
@@ -251,12 +259,7 @@ if plastic_md
251
259
  if all_active.any?
252
260
  parts << "Active intents:\n"
253
261
  all_active.each { |a| parts << a }
254
- if bridge_data
255
- stage = bridge_data["build"]["stage"]
256
- missing = bridge_data["build"]["missing"]
257
- missing_str = missing.empty? ? "none" : missing.join(", ")
258
- parts << "Stage: #{stage} | Next: #{missing_str}"
259
- end
262
+ parts << stage_line if stage_line
260
263
  parts << ""
261
264
  else
262
265
  parts << "No active intents. #{future.length} future intents available.\n"
@@ -302,6 +305,97 @@ if update_notice
302
305
  parts.unshift("! #{update_notice}\n")
303
306
  end
304
307
 
308
+ # --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
309
+ # ledger into today, oldest first, at most 3 per boot within a 5-second
310
+ # budget. Each day runs the sibling file-session-intent in its own rescue;
311
+ # the boot never blocks on it.
312
+ begin
313
+ require "open3"
314
+ require "timeout"
315
+ require_relative "lib/session_backfill"
316
+
317
+ sweep_today = SessionLedger.day_id
318
+ sweep_root = SessionLedger.sessions_root(store_dir)
319
+ sweep_filer = File.expand_path("file-session-intent", __dir__)
320
+ sweep_templates = File.expand_path("../templates", __dir__)
321
+ candidates = []
322
+ if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
323
+ candidates = Dir.children(sweep_root).select do |name|
324
+ File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
325
+ name < sweep_today && !SessionBackfill.closed?(store_dir, name)
326
+ end.sort
327
+ end
328
+ filed = 0
329
+ budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
330
+ candidates.first(3).each do |day|
331
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
332
+
333
+ begin
334
+ Timeout.timeout(5) do
335
+ _out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
336
+ "--day", day, "--carry-to", sweep_today,
337
+ "--store", store_dir, "--templates", sweep_templates)
338
+ filed += 1 if sweep_status.success? && SessionBackfill.closed?(store_dir, day)
339
+ end
340
+ rescue StandardError
341
+ nil
342
+ end
343
+ end
344
+ if filed.positive?
345
+ line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
346
+ remaining = candidates.size - filed
347
+ line += ", #{remaining} more wait for the next boot" if remaining.positive?
348
+ parts << line
349
+ end
350
+ rescue StandardError
351
+ nil
352
+ end
353
+
354
+ # --- Session day ledger: open or join today, write the per-session pointer
355
+ # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
356
+ # to no ledger context line, never blocks the session.
357
+ begin
358
+ templates = File.expand_path("../templates", __dir__)
359
+ day = SessionLedger.day_id
360
+ author = `"#{read_config}" author`.strip
361
+ author = "session" if author.empty?
362
+ SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
363
+
364
+ session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
365
+ sid = SessionLedger.short_session_id(nil, session)
366
+ SessionLedger.ensure_tmp_root(store_dir)
367
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
368
+ File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
369
+ pointer = SessionLedger.pointer_path(store_dir, sid)
370
+ File.write(pointer, "#{day}\n") unless File.exist?(pointer)
371
+
372
+ checklist = SessionLedger.checklist_path(store_dir, day)
373
+ open_count = 0
374
+ pending_count = 0
375
+ if File.exist?(checklist)
376
+ File.readlines(checklist).each do |line|
377
+ parsed = SessionLedger.parse_checklist_line(line)
378
+ next unless parsed
379
+
380
+ open_count += 1 if parsed[:state] == :open
381
+ pending_count += 1 if parsed[:state] == :pending
382
+ end
383
+ end
384
+ parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
385
+
386
+ # The day summary (intent 311, spec D8): open items, the last five done,
387
+ # live auto intents, other active sessions. Never the raw ledger. A
388
+ # failure here leaves the joined line alone.
389
+ begin
390
+ summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
391
+ parts << summary unless summary.empty?
392
+ rescue StandardError
393
+ nil
394
+ end
395
+ rescue StandardError
396
+ nil
397
+ end
398
+
305
399
  # Emit nothing when there is genuinely nothing to surface (no conventions,
306
400
  # no deprecations, no update notice).
307
401
  exit 0 if parts.join.strip.empty?
@@ -3,7 +3,11 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  # Plastic — `install` verb. Runs via `npx @zalom/plastic install` (bin/plastic.js) or directly.
6
- # Usage: ruby scripts/install.rb [--claude|--codex|--hermes|--all] [--alpha|--beta|--latest] [--reinstall] [--force] [--help]
6
+ # Usage: ruby scripts/install.rb [--claude|--codex|--hermes|--all] [--reinstall] [--force] [--help]
7
+ #
8
+ # The package version selects the channel: `npx @zalom/plastic@alpha install --claude` (or
9
+ # a pinned `@2.0.0-alpha.1`). The channel flags this verb once listed never selected a
10
+ # package and were removed in 2.0 (intent 310).
7
11
  #
8
12
  # One-shot by design:
9
13
  # - no install present -> fresh install + bootstrap store
@@ -206,7 +210,7 @@ class Install < InstallerCore
206
210
  return if codex_like.none? { |a| installed.any? { |r| r[:agent] == a[:name] } }
207
211
 
208
212
  puts " Codex: open Codex, run /hooks, and trust the Plastic hook definitions."
209
- puts " Plastic's gates will not fire until you do.\n\n"
213
+ puts " Plastic's hooks will not fire until you do.\n\n"
210
214
  end
211
215
 
212
216
  def show_help
@@ -223,10 +227,8 @@ class Install < InstallerCore
223
227
  --hermes Install for Hermes
224
228
  --all Install for all supported agents
225
229
 
226
- Channel options:
227
- --latest Stable channel (default)
228
- --beta Beta channel
229
- --alpha Alpha channel
230
+ Channel: pin the package, e.g. `npx @zalom/plastic@alpha install --claude`
231
+ (@latest is stable and the default; @beta and @alpha are the other channels)
230
232
 
231
233
  Other options:
232
234
  --reinstall Re-sync core files for the installed version (repair). Store untouched.
@@ -12,13 +12,7 @@ module AgentModels
12
12
  # file basenames without the `.md` extension.
13
13
  TIER_DEFAULTS = {
14
14
  "plastic-enforcer" => "opus",
15
- "plastic-brainstorming" => "opus",
16
- "plastic-planner" => "opus",
17
- "plastic-spec-specialist" => "sonnet",
18
- "plastic-executor" => "sonnet",
19
- "plastic-intent-curator" => "sonnet",
20
- "plastic-future-intent-researcher" => "sonnet",
21
- "plastic-intent-discovery" => "sonnet"
15
+ "plastic-executor" => "sonnet"
22
16
  }.freeze
23
17
 
24
18
  # The two advisor agents (intent 185 final design): plastic-advisor (the real
@@ -0,0 +1,283 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "fileutils"
6
+ require_relative "lock"
7
+ require_relative "worktree"
8
+ require_relative "session_ledger"
9
+ require_relative "savepoint"
10
+
11
+ # Arm - how an auto team takes an intent and gives it back (intent 307).
12
+ #
13
+ # Three durable facts make a delivery: the delivery lock in the intent
14
+ # directory (who owns it), the code worktree under the project repo (where
15
+ # the code lands), and the per-session pointer in the global store's `.tmp/`
16
+ # (which intent this session records into; a day id means the day ledger).
17
+ # Before 2.0 a fourth thing, a `/tmp` bridge JSON, cached all three plus a
18
+ # stage snapshot; ruling 6 of intent 296 retired it, and this module is what
19
+ # replaced the bridge's arm, disarm, and repair methods (removed in 2.0, intent 307).
20
+ #
21
+ # Pure and dependency-injected: every path and clock is an argument, every git
22
+ # call goes through an injected runner, and the only environment read is the
23
+ # `env:` value the caller passes to `resolve_session`. Nothing here raises for
24
+ # a lock outcome; callers get a status and decide.
25
+ module Arm
26
+ module_function
27
+
28
+ STATUSES = %i[acquired owned held stale excluded corrupt].freeze
29
+
30
+ def blank?(value)
31
+ value.nil? || value.to_s.strip.empty?
32
+ end
33
+
34
+ # Deterministic, session-less key derived from the store and the intent id,
35
+ # for headless runs that carry no session id at all (moved from Bridge).
36
+ def derive_key(store, intent_id)
37
+ "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
38
+ end
39
+
40
+ # The session that keys a lock: the explicit id, else the env id the caller
41
+ # read (CLAUDE_CODE_SESSION_ID), else the derived key. Never nil.
42
+ def resolve_session(explicit, env: nil, store:, intent_id:)
43
+ return explicit.to_s.strip unless blank?(explicit)
44
+ return env.to_s.strip unless blank?(env)
45
+ derive_key(store, intent_id)
46
+ end
47
+
48
+ # --- derivations (no I/O beyond Dir.exist?) ---------------------------------
49
+
50
+ def intent_id_for(intent_dir)
51
+ File.basename(intent_dir.to_s).split("--", 2).first
52
+ end
53
+
54
+ def store_for(intent_dir)
55
+ File.dirname(File.expand_path(intent_dir))
56
+ end
57
+
58
+ # The home (parent of `.plastic`) an intent dir belongs to, else the given
59
+ # fallback. A sandboxed store never resolves to the real Dir.home.
60
+ def home_for(intent_dir, home: Dir.home)
61
+ Worktree.home_from_store(store_for(intent_dir)) || home
62
+ end
63
+
64
+ def global_store(home)
65
+ File.join(File.expand_path(home), ".plastic", "store")
66
+ end
67
+
68
+ # The minimal hash Worktree.provision, release, and finish consume: the
69
+ # intent block plus, when asked, the derived worktree block.
70
+ def bridge_hash(intent_dir:, home: Dir.home, with_worktree: true)
71
+ dir = File.expand_path(intent_dir)
72
+ data = {
73
+ "intent" => { "id" => intent_id_for(dir), "dir" => File.basename(dir), "store" => store_for(dir) },
74
+ }
75
+ data["worktree"] = worktree_block(intent_dir: dir, home: home) if with_worktree
76
+ data
77
+ end
78
+
79
+ # `{code, code_branch, provisioned}` derived from projects.yml and the
80
+ # intent id: the same path provision creates, `provisioned` iff it exists.
81
+ def worktree_block(intent_dir:, home: Dir.home)
82
+ dir = File.expand_path(intent_dir)
83
+ store = store_for(dir)
84
+ h = home_for(dir, home: home)
85
+ slug = Worktree.slug_for_store(store, home: h)
86
+ p = Worktree.paths(slug: slug, intent_id: intent_id_for(dir),
87
+ intent_slug: Worktree.slug_from_dir(dir), home: h)
88
+ code = p["code"]
89
+ provisioned = !blank?(code) && Dir.exist?(code)
90
+ {
91
+ "code" => provisioned ? code : nil,
92
+ "code_branch" => provisioned ? p["code_branch"] : nil,
93
+ "provisioned" => provisioned,
94
+ }
95
+ end
96
+
97
+ # --- the pointer -------------------------------------------------------------
98
+
99
+ def pointer_path(session, home:)
100
+ store = global_store(home)
101
+ SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
102
+ end
103
+
104
+ def read_pointer(session, home:)
105
+ path = pointer_path(session, home: home)
106
+ File.exist?(path) ? File.read(path).strip : nil
107
+ end
108
+
109
+ def write_pointer(session, value, home:)
110
+ store = global_store(home)
111
+ SessionLedger.ensure_tmp_root(store)
112
+ path = pointer_path(session, home: home)
113
+ FileUtils.mkdir_p(File.dirname(path))
114
+ File.write(path, "#{value}\n")
115
+ path
116
+ end
117
+
118
+ # --- arm ---------------------------------------------------------------------
119
+
120
+ # Take an intent for `session`: acquire delivery.lock (stamped with the run
121
+ # mode and the provenance the caller knows), provision the code worktree
122
+ # (fail-open for a global-only or non-git intent), and point the session at
123
+ # the intent. Returns `{status:, lock:, worktree:, session:, pointer:}`.
124
+ # A held, stale, excluded, or corrupt lock returns that status with the
125
+ # lock data read and touches nothing.
126
+ def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
127
+ agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
128
+ host: Socket.gethostname)
129
+ raise ArgumentError, "mode must be auto or guided" unless %w[auto guided].include?(mode.to_s)
130
+ dir = File.expand_path(intent_dir)
131
+ key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
132
+ h = home_for(dir, home: home)
133
+
134
+ status, lock = Lock.acquire(dir, session: key, host: host, now: now,
135
+ harness: harness, agent: agent, model: model,
136
+ thread: thread, run_mode: mode.to_s)
137
+ unless %i[acquired owned].include?(status)
138
+ return { status: status, lock: lock, worktree: nil, session: key, pointer: nil }
139
+ end
140
+
141
+ data = bridge_hash(intent_dir: dir, home: h, with_worktree: false)
142
+ begin
143
+ Worktree.provision(data, home: h, runner: runner)
144
+ rescue StandardError => e
145
+ warn "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
146
+ end
147
+
148
+ # The pointer is what the capture and record hooks read; they key on the
149
+ # harness's real session id, so a derived key gets no pointer (nothing would
150
+ # ever read it). A pointer failure warns and never undoes an acquired lock.
151
+ pointer = nil
152
+ unless key == derive_key(store_for(dir), intent_id_for(dir))
153
+ begin
154
+ pointer = write_pointer(key, intent_id_for(dir), home: h)
155
+ rescue StandardError => e
156
+ warn "plastic: session pointer not written (#{e.message}); the lock is held regardless"
157
+ end
158
+ end
159
+ { status: status, lock: lock, worktree: worktree_block(intent_dir: dir, home: h),
160
+ session: key, pointer: pointer }
161
+ end
162
+
163
+ # --- disarm ------------------------------------------------------------------
164
+
165
+ # Give the intent back: remove the worktree (when `remove`), release the
166
+ # lock as its recorded owner (falling back to `session`), and reset the
167
+ # session pointer to today's day id when it named this intent. Returns
168
+ # the lock release status (:released, :none, :not_owner, or :raised).
169
+ def disarm(intent_dir:, session:, home: Dir.home, runner: Worktree::ShellRunner.new,
170
+ remove: true, now: Time.now)
171
+ dir = File.expand_path(intent_dir)
172
+ h = home_for(dir, home: home)
173
+ key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
174
+
175
+ begin
176
+ Worktree.release(bridge_hash(intent_dir: dir, home: h), home: h, runner: runner, remove: remove)
177
+ rescue StandardError => e
178
+ warn "plastic: worktree release raised, continuing: #{e.message}"
179
+ end
180
+
181
+ lock = Lock.read(dir)
182
+ owner = lock && !blank?(lock["owner_session"]) ? lock["owner_session"] : key
183
+ release_status = begin
184
+ Lock.release(dir, session: owner)
185
+ rescue StandardError => e
186
+ warn "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
187
+ :raised
188
+ end
189
+
190
+ begin
191
+ reset_pointer(key, intent_id_for(dir), home: h, now: now)
192
+ rescue StandardError => e
193
+ warn "plastic: session pointer not reset (#{e.message})"
194
+ end
195
+ release_status
196
+ end
197
+
198
+ # Point the session back at the day ledger, but only when it names `intent_id`.
199
+ def reset_pointer(session, intent_id, home:, now: Time.now)
200
+ current = read_pointer(session, home: home)
201
+ return false unless current == intent_id.to_s
202
+ write_pointer(session, SessionLedger.day_id(now), home: home)
203
+ true
204
+ end
205
+
206
+ # --- repair ------------------------------------------------------------------
207
+
208
+ # One idempotent repair (the lock half of the bridge's repair, removed in 2.0):
209
+ # remove a corrupt lock, back off from a fresh foreign lock (`held`), report
210
+ # a stale foreign lock (`stale`) for the explicit reclaim verb, keep and
211
+ # enrich an own lock, heartbeat a delegated one, acquire when none, and
212
+ # provision the worktree so the repaired intent has its checkout.
213
+ def repair(intent_dir:, session:, home: Dir.home, now: Time.now, harness: nil,
214
+ agent: nil, model: nil, thread: nil, run_mode: nil, hint_harness: nil,
215
+ runner: Worktree::ShellRunner.new)
216
+ dir = File.expand_path(intent_dir)
217
+ h = home_for(dir, home: home)
218
+ key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
219
+ actions = []
220
+
221
+ if Lock.corrupt?(dir)
222
+ File.delete(Lock.path(dir))
223
+ actions << "removed corrupt delivery.lock"
224
+ end
225
+
226
+ lock = Lock.read(dir)
227
+ if lock && !Lock.authorized?(lock, key)
228
+ if Lock.fresh?(dir, now: now)
229
+ return { "status" => "held", "owner" => lock["owner_session"],
230
+ "actions" => actions, "session" => key }
231
+ end
232
+ return { "status" => "stale", "owner" => lock["owner_session"],
233
+ "actions" => actions, "session" => key,
234
+ "hint" => "run #{Lock.skill_ref('plastic-doctor', harness: hint_harness || harness)} " \
235
+ "reclaim the lock to take over with an audit" }
236
+ end
237
+
238
+ mode = blank?(run_mode) ? (lock && lock["run_mode"]) : run_mode.to_s
239
+ if lock
240
+ if lock["owner_session"].to_s == key.to_s
241
+ lock_data = lock.dup
242
+ { "owner_harness" => harness, "owner_agent" => agent, "owner_model" => model,
243
+ "owner_thread" => thread, "run_mode" => mode }.each do |field, value|
244
+ lock_data[field] = value.to_s unless blank?(value)
245
+ end
246
+ Lock.write(dir, lock_data)
247
+ Lock.heartbeat(dir, session: key, now: now)
248
+ else
249
+ Lock.heartbeat(dir, session: key, now: now)
250
+ lock_data = Lock.read(dir)
251
+ end
252
+ role = lock_data["owner_session"].to_s == key.to_s ? "owner" : "delegate"
253
+ actions << "lock kept (#{role})"
254
+ else
255
+ status, lock_data = Lock.acquire(dir, session: key, now: now, harness: harness,
256
+ agent: agent, model: model, thread: thread,
257
+ run_mode: mode)
258
+ actions << "lock #{status}"
259
+ end
260
+
261
+ begin
262
+ Worktree.provision(bridge_hash(intent_dir: dir, home: h, with_worktree: false), home: h, runner: runner)
263
+ rescue StandardError => e
264
+ warn "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
265
+ end
266
+ actions << "worktree #{worktree_block(intent_dir: dir, home: h)['provisioned'] ? 'present' : 'absent'}"
267
+ actions << "stage #{Savepoint.derive_stage(dir)}"
268
+
269
+ { "status" => "repaired", "actions" => actions, "lock" => lock_data, "session" => key }
270
+ end
271
+
272
+ # The intent directory this session's pointer names, searched in the given
273
+ # stores in order; nil when the pointer is absent, empty, or a day id.
274
+ def intent_dir_from_pointer(session, home:, stores:)
275
+ current = read_pointer(session, home: home)
276
+ return nil if blank?(current) || SessionLedger.valid_day_id?(current)
277
+ stores.each do |store|
278
+ match = Dir.glob(File.join(store, "#{current}--*")).find { |p| File.directory?(p) }
279
+ return match if match
280
+ end
281
+ nil
282
+ end
283
+ end