@zalom/plastic 1.14.1 → 2.0.0-alpha.10

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 (213) 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 +14 -42
  13. package/hooks/message-display +81 -0
  14. package/hooks/record +4 -0
  15. package/hooks/savepoint +5 -5
  16. package/package.json +3 -2
  17. package/scripts/agent-report +15 -10
  18. package/scripts/append-ledger +208 -0
  19. package/scripts/codex-hook +78 -90
  20. package/scripts/dashboard.rb +43 -14
  21. package/scripts/day-summary +53 -0
  22. package/scripts/doctor.rb +269 -43
  23. package/scripts/end-intent +170 -76
  24. package/scripts/exec-worktree +11 -20
  25. package/scripts/file-session-intent +61 -0
  26. package/scripts/hook-capture +255 -0
  27. package/scripts/hook-close +36 -0
  28. package/scripts/hook-message-display +74 -0
  29. package/scripts/hook-record +157 -0
  30. package/scripts/hook-savepoint +45 -0
  31. package/scripts/hook-session-start +128 -12
  32. package/scripts/install.rb +8 -6
  33. package/scripts/intent-screen +77 -0
  34. package/scripts/lib/agent_models.rb +1 -7
  35. package/scripts/lib/arm.rb +308 -0
  36. package/scripts/lib/backfill_intent.rb +316 -0
  37. package/scripts/lib/bridge.rb +24 -1597
  38. package/scripts/lib/compact_instructions.rb +56 -0
  39. package/scripts/lib/day_summary.rb +211 -0
  40. package/scripts/lib/doctor_core.rb +66 -88
  41. package/scripts/lib/doctor_session_ledger.rb +158 -0
  42. package/scripts/lib/exec_worktree.rb +14 -60
  43. package/scripts/lib/handoff.rb +184 -0
  44. package/scripts/lib/harness_text.rb +5 -4
  45. package/scripts/lib/hook_registry.rb +57 -106
  46. package/scripts/lib/insights.rb +3 -4
  47. package/scripts/lib/installer_core.rb +136 -41
  48. package/scripts/lib/intent_screen.rb +309 -0
  49. package/scripts/lib/intent_screen_ansi.rb +262 -0
  50. package/scripts/lib/lock.rb +6 -26
  51. package/scripts/lib/message_display.rb +290 -0
  52. package/scripts/lib/outcome_guard.rb +7 -5
  53. package/scripts/lib/project_validator.rb +30 -1
  54. package/scripts/lib/report_screen.rb +648 -0
  55. package/scripts/lib/roadmap_queue.rb +3 -3
  56. package/scripts/lib/roadmap_savepoint.rb +1 -1
  57. package/scripts/lib/rule_catalog.rb +6 -3
  58. package/scripts/lib/savepoint.rb +334 -0
  59. package/scripts/lib/scaffold_intent.rb +13 -179
  60. package/scripts/lib/screen_paint.rb +276 -0
  61. package/scripts/lib/session_backfill.rb +184 -0
  62. package/scripts/lib/session_close.rb +104 -0
  63. package/scripts/lib/session_git.rb +500 -0
  64. package/scripts/lib/session_ledger.rb +621 -0
  65. package/scripts/lib/store_discovery.rb +1 -1
  66. package/scripts/lib/store_provisioning.rb +1 -1
  67. package/scripts/maintenance-run +7 -7
  68. package/scripts/new-intent +63 -4
  69. package/scripts/plastic-lock +101 -41
  70. package/scripts/promote-session-item +150 -0
  71. package/scripts/read-config +3 -0
  72. package/scripts/rebuild-graph +1 -1
  73. package/scripts/report-screen +120 -0
  74. package/scripts/roadmap-next +2 -2
  75. package/scripts/rollback.rb +48 -3
  76. package/scripts/savepoint-note +67 -0
  77. package/scripts/scaffold-intent +37 -63
  78. package/scripts/session-commit +116 -0
  79. package/scripts/spawn-preamble +14 -7
  80. package/scripts/update.rb +7 -3
  81. package/scripts/write-handoff +60 -0
  82. package/skills/agent-advisor/SKILL.md +3 -3
  83. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  84. package/skills/auto/SKILL.md +223 -317
  85. package/skills/auto/evals/evals.json +10 -10
  86. package/skills/auto/references/agent-architecture.md +102 -90
  87. package/skills/auto/references/agent-report-contract.md +4 -21
  88. package/skills/auto/references/end-tail.md +47 -53
  89. package/skills/auto/references/human-report-contract.md +62 -56
  90. package/skills/conventions/SKILL.md +1 -3
  91. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  92. package/skills/conventions/references/locks-and-worktrees.md +62 -58
  93. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  94. package/skills/dashboard/SKILL.md +1 -1
  95. package/skills/direct/SKILL.md +66 -0
  96. package/skills/direct/references/request-signals.md +59 -0
  97. package/skills/doctor/SKILL.md +58 -11
  98. package/skills/doctor/report.md +3 -1
  99. package/skills/install/SKILL.md +11 -9
  100. package/skills/intent-continuing/SKILL.md +142 -91
  101. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  102. package/skills/intent-continuing/references/context-management.md +3 -3
  103. package/skills/intent-creating/SKILL.md +2 -2
  104. package/skills/intent-creating/references/lifecycle.md +1 -1
  105. package/skills/intent-ending/SKILL.md +34 -37
  106. package/skills/intent-ending/evals/evals.json +4 -4
  107. package/skills/intent-executing/SKILL.md +15 -46
  108. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  109. package/skills/intent-speccing/SKILL.md +108 -51
  110. package/skills/intent-speccing/evals/evals.json +5 -5
  111. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  112. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  113. package/skills/project-creating/SKILL.md +1 -1
  114. package/skills/releasing/SKILL.md +7 -8
  115. package/skills/tutorial/SKILL.md +2 -2
  116. package/skills/tutorial/references/track-1-guided.md +16 -18
  117. package/skills/tutorial/references/track-2-auto.md +12 -13
  118. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  119. package/templates/agents.md +4 -7
  120. package/templates/config.yml +5 -0
  121. package/templates/intent-screen.md +17 -0
  122. package/templates/outcome.md +14 -1
  123. package/templates/project.yml +7 -0
  124. package/templates/report-state.md +11 -0
  125. package/templates/roadmap.md +1 -1
  126. package/templates/savepoint.md +2 -2
  127. package/templates/session-intent.md +29 -0
  128. package/agents/plastic-brainstorming.md +0 -48
  129. package/agents/plastic-future-intent-researcher.md +0 -35
  130. package/agents/plastic-intent-curator.md +0 -47
  131. package/agents/plastic-intent-discovery.md +0 -43
  132. package/agents/plastic-planner.md +0 -56
  133. package/agents/plastic-spec-specialist.md +0 -53
  134. package/hooks/auto-arm +0 -5
  135. package/hooks/bash-gate +0 -3
  136. package/hooks/continue +0 -31
  137. package/hooks/edit-gates +0 -3
  138. package/hooks/future-intent-check +0 -25
  139. package/hooks/gate-check +0 -12
  140. package/hooks/power-tools +0 -8
  141. package/scripts/hook-auto-arm +0 -51
  142. package/scripts/hook-bash-gate +0 -78
  143. package/scripts/hook-code-gate +0 -38
  144. package/scripts/hook-continue +0 -48
  145. package/scripts/hook-create-gate +0 -51
  146. package/scripts/hook-edit-gates +0 -58
  147. package/scripts/hook-future-intent-check +0 -90
  148. package/scripts/hook-gate-check +0 -169
  149. package/scripts/hook-links-gate +0 -54
  150. package/scripts/hook-lock-gate +0 -47
  151. package/scripts/hook-power-tools +0 -38
  152. package/scripts/hook-savepoint-pre +0 -32
  153. package/scripts/lib/codex_edit_gates.rb +0 -138
  154. package/scripts/lib/edit_gates.rb +0 -398
  155. package/scripts/lib/links_gate.rb +0 -140
  156. package/scripts/lib/qmd_hook.rb +0 -41
  157. package/scripts/lib/spec_header.rb +0 -83
  158. package/scripts/lib/start_intent.rb +0 -296
  159. package/scripts/start-intent +0 -89
  160. package/skills/_active-intent-gate.md +0 -26
  161. package/skills/auto/references/tiers.md +0 -100
  162. package/skills/continuing/SKILL.md +0 -34
  163. package/skills/continuing/evals/evals.json +0 -91
  164. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  165. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  166. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  167. package/skills/intent-brainstorming/SKILL.md +0 -118
  168. package/skills/intent-brainstorming/evals/evals.json +0 -67
  169. package/skills/intent-continuing/evals/evals.json +0 -145
  170. package/skills/intent-discovering/SKILL.md +0 -56
  171. package/skills/intent-grilling/SKILL.md +0 -108
  172. package/skills/intent-linking/SKILL.md +0 -128
  173. package/skills/intent-linking/evals/evals.json +0 -22
  174. package/skills/intent-linking/references/zettelkasten.md +0 -45
  175. package/skills/intent-locking/SKILL.md +0 -64
  176. package/skills/intent-planning/SKILL.md +0 -145
  177. package/skills/intent-planning/evals/evals.json +0 -81
  178. package/skills/intent-planning/references/plan-format.md +0 -130
  179. package/skills/intent-researching/SKILL.md +0 -123
  180. package/skills/intent-researching/evals/evals.json +0 -22
  181. package/skills/intent-savepoint/SKILL.md +0 -83
  182. package/skills/intent-savepoint/references/context-management.md +0 -32
  183. package/skills/intent-starting/SKILL.md +0 -151
  184. package/skills/intent-starting/evals/evals.json +0 -117
  185. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  186. package/skills/project-continuing/SKILL.md +0 -119
  187. package/skills/project-continuing/evals/evals.json +0 -100
  188. package/skills/roadmap-continuing/SKILL.md +0 -89
  189. package/skills/roadmap-continuing/evals/evals.json +0 -82
  190. package/skills/skill-creating/SKILL.md +0 -75
  191. package/skills/skill-creating/evals/evals.json +0 -108
  192. package/skills/skill-creating/references/agents.md +0 -168
  193. package/skills/skill-creating/references/defaults-first.md +0 -23
  194. package/skills/skill-creating/references/evals.md +0 -41
  195. package/skills/skill-creating/references/hooks.md +0 -251
  196. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  197. package/skills/skill-creating/references/scripts.md +0 -166
  198. package/skills/skill-creating/references/skills.md +0 -169
  199. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  200. package/skills/skill-evaluating/SKILL.md +0 -141
  201. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  202. package/skills/skill-evaluating/evals/evals.json +0 -75
  203. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  204. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  205. package/skills/store-curating/SKILL.md +0 -64
  206. package/skills/store-curating/evals/evals.json +0 -38
  207. package/skills/store-indexing/SKILL.md +0 -93
  208. package/skills/store-indexing/evals/evals.json +0 -22
  209. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  210. package/skills/store-provisioning/SKILL.md +0 -55
  211. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  212. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  213. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -6,14 +6,36 @@
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
16
20
 
21
+ # --- session id: the stdin payload's session_id, then the env var, then the
22
+ # pid (spec 298 D1, row G, spec D4). Guarded on $stdin.tty? so a human running
23
+ # this hook by hand at a real terminal never blocks on a read that never gets
24
+ # an EOF; every real harness invocation pipes the SessionStart JSON payload,
25
+ # never attaches a tty. Malformed or empty stdin (or no payload id) falls
26
+ # through to the same env-var-then-pid chain this always had.
27
+ stdin_payload = begin
28
+ if $stdin.tty?
29
+ nil
30
+ else
31
+ raw = $stdin.read
32
+ raw && !raw.strip.empty? ? JSON.parse(raw) : nil
33
+ end
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
38
+
17
39
  # Plastic home and the store are two different paths (intent 231). The shim passes
18
40
  # home (~/.plastic) as argument 2; the store lives one level below it. Compose the
19
41
  # store exactly once here, so no later line re-derives it and no path can gain a
@@ -48,16 +70,20 @@ lines.each do |line|
48
70
  end
49
71
  end
50
72
 
51
- # --- Derive bridge for active intent ---
73
+ # --- Stage of the single active intent (the /tmp bridge this once derived was removed in 2.0, intent 307) ---
52
74
 
53
- bridge_data = nil
75
+ stage_line = nil
54
76
  if active.length == 1 && active.first =~ /store\/([\w-]+)\//
55
77
  dir_name = $1
56
78
  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)
79
+ begin
80
+ stage = Savepoint.derive_stage(intent_dir)
81
+ missing = Savepoint.missing_for_stage(stage, intent_dir)
82
+ missing_str = missing.empty? ? "none" : missing.join(", ")
83
+ stage_line = "Stage: #{stage} | Next: #{missing_str}"
84
+ rescue StandardError
85
+ stage_line = nil
86
+ end
61
87
  end
62
88
 
63
89
  # --- Detect stale future intents ---
@@ -251,12 +277,7 @@ if plastic_md
251
277
  if all_active.any?
252
278
  parts << "Active intents:\n"
253
279
  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
280
+ parts << stage_line if stage_line
260
281
  parts << ""
261
282
  else
262
283
  parts << "No active intents. #{future.length} future intents available.\n"
@@ -302,6 +323,101 @@ if update_notice
302
323
  parts.unshift("! #{update_notice}\n")
303
324
  end
304
325
 
326
+ # --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
327
+ # ledger into today, oldest first, at most 3 per boot within a 5-second
328
+ # budget. Each day runs the sibling file-session-intent in its own rescue;
329
+ # the boot never blocks on it.
330
+ begin
331
+ require "open3"
332
+ require "timeout"
333
+ require_relative "lib/session_backfill"
334
+
335
+ sweep_today = SessionLedger.day_id
336
+ sweep_root = SessionLedger.sessions_root(store_dir)
337
+ sweep_filer = File.expand_path("file-session-intent", __dir__)
338
+ sweep_templates = File.expand_path("../templates", __dir__)
339
+ candidates = []
340
+ if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
341
+ candidates = Dir.children(sweep_root).select do |name|
342
+ File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
343
+ name < sweep_today && !SessionBackfill.closed?(store_dir, name)
344
+ end.sort
345
+ end
346
+ filed = 0
347
+ budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
348
+ candidates.first(3).each do |day|
349
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
350
+
351
+ begin
352
+ Timeout.timeout(5) do
353
+ _out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
354
+ "--day", day, "--carry-to", sweep_today,
355
+ "--store", store_dir, "--templates", sweep_templates)
356
+ filed += 1 if sweep_status.success? && SessionBackfill.closed?(store_dir, day)
357
+ end
358
+ rescue StandardError
359
+ nil
360
+ end
361
+ end
362
+ if filed.positive?
363
+ line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
364
+ remaining = candidates.size - filed
365
+ line += ", #{remaining} more wait for the next boot" if remaining.positive?
366
+ parts << line
367
+ end
368
+ rescue StandardError
369
+ nil
370
+ end
371
+
372
+ # --- Session day ledger: open or join today, write the per-session pointer
373
+ # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
374
+ # to no ledger context line, never blocks the session.
375
+ begin
376
+ templates = File.expand_path("../templates", __dir__)
377
+ day = SessionLedger.day_id
378
+ author = `"#{read_config}" author`.strip
379
+ author = "session" if author.empty?
380
+ SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
381
+
382
+ session = if !payload_session_id.empty?
383
+ payload_session_id
384
+ else
385
+ ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
386
+ end
387
+ sid = SessionLedger.short_session_id(nil, session)
388
+ SessionLedger.ensure_tmp_root(store_dir)
389
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
390
+ File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
391
+ pointer = SessionLedger.pointer_path(store_dir, sid)
392
+ File.write(pointer, "#{day}\n") unless File.exist?(pointer)
393
+
394
+ checklist = SessionLedger.checklist_path(store_dir, day)
395
+ open_count = 0
396
+ pending_count = 0
397
+ if File.exist?(checklist)
398
+ File.readlines(checklist).each do |line|
399
+ parsed = SessionLedger.parse_checklist_line(line)
400
+ next unless parsed
401
+
402
+ open_count += 1 if parsed[:state] == :open
403
+ pending_count += 1 if parsed[:state] == :pending
404
+ end
405
+ end
406
+ parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
407
+
408
+ # The day summary (intent 311, spec D8): open items, the last five done,
409
+ # live auto intents, other active sessions. Never the raw ledger. A
410
+ # failure here leaves the joined line alone.
411
+ begin
412
+ summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
413
+ parts << summary unless summary.empty?
414
+ rescue StandardError
415
+ nil
416
+ end
417
+ rescue StandardError
418
+ nil
419
+ end
420
+
305
421
  # Emit nothing when there is genuinely nothing to surface (no conventions,
306
422
  # no deprecations, no update notice).
307
423
  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.
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ # intent-screen (intent 316) - prints the intent screen for one intent directory:
5
+ # the title, the field table (Store, Status, Stage, Savepoint, Progress, Next,
6
+ # Insight, each with a note), and the Steps table, filled from the record by
7
+ # scripts/lib/intent_screen.rb. The session adds the What-this-means bullets and
8
+ # the close; it never edits the numbers.
9
+ #
10
+ # Usage:
11
+ # intent-screen <intent_dir> [--template <path>] [--ansi]
12
+ #
13
+ # The store root is the directory two levels above the intent (<root>/store/<id--slug>);
14
+ # the template defaults to templates/intent-screen.md next to this script's dir,
15
+ # in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
16
+ # ~/.plastic/templates) alike.
17
+ #
18
+ # --ansi (intent 316a, O3): emits scripts/lib/intent_screen_ansi.rb's styled
19
+ # truecolor block instead of the plain Markdown screen. Plain stays the
20
+ # default with no flag. Two things force plain even WITH --ansi (D18): NO_COLOR
21
+ # present in the environment (any value counts), or a non-TTY stdout — the
22
+ # true default form of D2, not IntentScreenAnsi's own uncoloured layout. The
23
+ # library (scripts/lib/intent_screen_ansi.rb) is pure and never reads either;
24
+ # this script is the one place allowed to.
25
+ #
26
+ # Exit codes:
27
+ # 0 - the screen is on stdout
28
+ # 2 - usage error, or the path is not an intent directory (one line on stderr)
29
+ #
30
+ # Harness-agnostic core: no harness assumption lives here. This script holds
31
+ # the plain/ANSI selection (intent 316a1, D3) and belongs to the same
32
+ # harness-agnostic core as lib/intent_screen.rb and lib/intent_screen_ansi.rb.
33
+
34
+ require_relative "lib/intent_screen"
35
+ require_relative "lib/intent_screen_ansi"
36
+
37
+ def usage_abort(message)
38
+ warn "intent-screen: #{message}"
39
+ exit 2
40
+ end
41
+
42
+ args = ARGV.dup
43
+ template_path = nil
44
+ ansi = false
45
+ positional = []
46
+ while (arg = args.shift)
47
+ case arg
48
+ when "--template"
49
+ template_path = args.shift or usage_abort("--template needs a path")
50
+ when "--ansi"
51
+ ansi = true
52
+ else
53
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
54
+ positional << arg
55
+ end
56
+ end
57
+
58
+ usage_abort("usage: intent-screen <intent_dir> [--template <path>] [--ansi]") unless positional.length == 1
59
+ intent_dir = File.expand_path(positional.first)
60
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
61
+
62
+ store_root = File.expand_path("../..", intent_dir)
63
+ template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
64
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
65
+
66
+ plain = -> { IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path)) }
67
+
68
+ degrade_to_plain = ENV.key?("NO_COLOR") || !$stdout.tty?
69
+
70
+ $stdout.write(
71
+ if ansi && !degrade_to_plain
72
+ IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
73
+ else
74
+ plain.call
75
+ end
76
+ )
77
+ exit 0
@@ -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,308 @@
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
+ # Owner rule 2026-08-31: does this session already have a live top-level
98
+ # pointer pointing SOMEWHERE ELSE (the day ledger or another intent)? A
99
+ # pointer already on this intent is the owner re-arming mid-delivery and
100
+ # stays idempotent. True means a
101
+ # conversation session. Reads only; rescues to false (fail open).
102
+ def preexisting_pointer?(session, home:)
103
+ path = pointer_path(session, home: home)
104
+ File.exist?(path) && !File.read(path).to_s.strip.empty?
105
+ rescue StandardError
106
+ false
107
+ end
108
+
109
+ # --- the pointer -------------------------------------------------------------
110
+
111
+
112
+ def pointer_path(session, home:)
113
+ store = global_store(home)
114
+ SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
115
+ end
116
+
117
+ def read_pointer(session, home:)
118
+ path = pointer_path(session, home: home)
119
+ File.exist?(path) ? File.read(path).strip : nil
120
+ end
121
+
122
+ def write_pointer(session, value, home:)
123
+ store = global_store(home)
124
+ SessionLedger.ensure_tmp_root(store)
125
+ path = pointer_path(session, home: home)
126
+ FileUtils.mkdir_p(File.dirname(path))
127
+ File.write(path, "#{value}\n")
128
+ path
129
+ end
130
+
131
+ # --- arm ---------------------------------------------------------------------
132
+
133
+ # Take an intent for `session`: acquire delivery.lock (stamped with the run
134
+ # mode and the provenance the caller knows), provision the code worktree
135
+ # (fail-open for a global-only or non-git intent), and point the session at
136
+ # the intent. Returns `{status:, lock:, worktree:, session:, pointer:}`.
137
+ # A held, stale, excluded, or corrupt lock returns that status with the
138
+ # lock data read and touches nothing.
139
+ def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
140
+ agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
141
+ host: Socket.gethostname, allow_inline: false)
142
+ raise ArgumentError, "mode must be auto or guided" unless %w[auto guided].include?(mode.to_s)
143
+ dir = File.expand_path(intent_dir)
144
+ key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
145
+ h = home_for(dir, home: home)
146
+
147
+ # Owner rule 2026-08-31: the main session never delivers an intent inline.
148
+ # A session that already carries a top-level session pointer is a
149
+ # conversation session (SessionStart wrote it at boot); arming there is
150
+ # inline delivery and is refused BEFORE any lock is taken. A dispatched or
151
+ # headless session has no pre-existing pointer and arms freely.
152
+ # --allow-inline is the explicit owner override. Fail open on read errors:
153
+ # a broken pointer file must never block a legitimate delivery.
154
+ if !allow_inline && preexisting_pointer?(key, home: h) &&
155
+ read_pointer(key, home: h).to_s.strip != intent_id_for(dir)
156
+ return { status: :inline_refused, lock: nil, worktree: nil, session: key, pointer: nil }
157
+ end
158
+
159
+ status, lock = Lock.acquire(dir, session: key, host: host, now: now,
160
+ harness: harness, agent: agent, model: model,
161
+ thread: thread, run_mode: mode.to_s)
162
+ unless %i[acquired owned].include?(status)
163
+ return { status: status, lock: lock, worktree: nil, session: key, pointer: nil }
164
+ end
165
+
166
+ data = bridge_hash(intent_dir: dir, home: h, with_worktree: false)
167
+ begin
168
+ Worktree.provision(data, home: h, runner: runner)
169
+ rescue StandardError => e
170
+ warn "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
171
+ end
172
+
173
+ # The pointer is what the capture and record hooks read; they key on the
174
+ # harness's real session id, so a derived key gets no pointer (nothing would
175
+ # ever read it). A pointer failure warns and never undoes an acquired lock.
176
+ pointer = nil
177
+ unless key == derive_key(store_for(dir), intent_id_for(dir))
178
+ begin
179
+ pointer = write_pointer(key, intent_id_for(dir), home: h)
180
+ rescue StandardError => e
181
+ warn "plastic: session pointer not written (#{e.message}); the lock is held regardless"
182
+ end
183
+ end
184
+ { status: status, lock: lock, worktree: worktree_block(intent_dir: dir, home: h),
185
+ session: key, pointer: pointer }
186
+ end
187
+
188
+ # --- disarm ------------------------------------------------------------------
189
+
190
+ # Give the intent back: remove the worktree (when `remove`), release the
191
+ # lock as its recorded owner (falling back to `session`), and reset the
192
+ # session pointer to today's day id when it named this intent. Returns
193
+ # the lock release status (:released, :none, :not_owner, or :raised).
194
+ def disarm(intent_dir:, session:, home: Dir.home, runner: Worktree::ShellRunner.new,
195
+ remove: true, now: Time.now)
196
+ dir = File.expand_path(intent_dir)
197
+ h = home_for(dir, home: home)
198
+ key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
199
+
200
+ begin
201
+ Worktree.release(bridge_hash(intent_dir: dir, home: h), home: h, runner: runner, remove: remove)
202
+ rescue StandardError => e
203
+ warn "plastic: worktree release raised, continuing: #{e.message}"
204
+ end
205
+
206
+ lock = Lock.read(dir)
207
+ owner = lock && !blank?(lock["owner_session"]) ? lock["owner_session"] : key
208
+ release_status = begin
209
+ Lock.release(dir, session: owner)
210
+ rescue StandardError => e
211
+ warn "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
212
+ :raised
213
+ end
214
+
215
+ begin
216
+ reset_pointer(key, intent_id_for(dir), home: h, now: now)
217
+ rescue StandardError => e
218
+ warn "plastic: session pointer not reset (#{e.message})"
219
+ end
220
+ release_status
221
+ end
222
+
223
+ # Point the session back at the day ledger, but only when it names `intent_id`.
224
+ def reset_pointer(session, intent_id, home:, now: Time.now)
225
+ current = read_pointer(session, home: home)
226
+ return false unless current == intent_id.to_s
227
+ write_pointer(session, SessionLedger.day_id(now), home: home)
228
+ true
229
+ end
230
+
231
+ # --- repair ------------------------------------------------------------------
232
+
233
+ # One idempotent repair (the lock half of the bridge's repair, removed in 2.0):
234
+ # remove a corrupt lock, back off from a fresh foreign lock (`held`), report
235
+ # a stale foreign lock (`stale`) for the explicit reclaim verb, keep and
236
+ # enrich an own lock, heartbeat a delegated one, acquire when none, and
237
+ # provision the worktree so the repaired intent has its checkout.
238
+ def repair(intent_dir:, session:, home: Dir.home, now: Time.now, harness: nil,
239
+ agent: nil, model: nil, thread: nil, run_mode: nil, hint_harness: nil,
240
+ runner: Worktree::ShellRunner.new)
241
+ dir = File.expand_path(intent_dir)
242
+ h = home_for(dir, home: home)
243
+ key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
244
+ actions = []
245
+
246
+ if Lock.corrupt?(dir)
247
+ File.delete(Lock.path(dir))
248
+ actions << "removed corrupt delivery.lock"
249
+ end
250
+
251
+ lock = Lock.read(dir)
252
+ if lock && !Lock.authorized?(lock, key)
253
+ if Lock.fresh?(dir, now: now)
254
+ return { "status" => "held", "owner" => lock["owner_session"],
255
+ "actions" => actions, "session" => key }
256
+ end
257
+ return { "status" => "stale", "owner" => lock["owner_session"],
258
+ "actions" => actions, "session" => key,
259
+ "hint" => "run #{Lock.skill_ref('plastic-doctor', harness: hint_harness || harness)} " \
260
+ "reclaim the lock to take over with an audit" }
261
+ end
262
+
263
+ mode = blank?(run_mode) ? (lock && lock["run_mode"]) : run_mode.to_s
264
+ if lock
265
+ if lock["owner_session"].to_s == key.to_s
266
+ lock_data = lock.dup
267
+ { "owner_harness" => harness, "owner_agent" => agent, "owner_model" => model,
268
+ "owner_thread" => thread, "run_mode" => mode }.each do |field, value|
269
+ lock_data[field] = value.to_s unless blank?(value)
270
+ end
271
+ Lock.write(dir, lock_data)
272
+ Lock.heartbeat(dir, session: key, now: now)
273
+ else
274
+ Lock.heartbeat(dir, session: key, now: now)
275
+ lock_data = Lock.read(dir)
276
+ end
277
+ role = lock_data["owner_session"].to_s == key.to_s ? "owner" : "delegate"
278
+ actions << "lock kept (#{role})"
279
+ else
280
+ status, lock_data = Lock.acquire(dir, session: key, now: now, harness: harness,
281
+ agent: agent, model: model, thread: thread,
282
+ run_mode: mode)
283
+ actions << "lock #{status}"
284
+ end
285
+
286
+ begin
287
+ Worktree.provision(bridge_hash(intent_dir: dir, home: h, with_worktree: false), home: h, runner: runner)
288
+ rescue StandardError => e
289
+ warn "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
290
+ end
291
+ actions << "worktree #{worktree_block(intent_dir: dir, home: h)['provisioned'] ? 'present' : 'absent'}"
292
+ actions << "stage #{Savepoint.derive_stage(dir)}"
293
+
294
+ { "status" => "repaired", "actions" => actions, "lock" => lock_data, "session" => key }
295
+ end
296
+
297
+ # The intent directory this session's pointer names, searched in the given
298
+ # stores in order; nil when the pointer is absent, empty, or a day id.
299
+ def intent_dir_from_pointer(session, home:, stores:)
300
+ current = read_pointer(session, home: home)
301
+ return nil if blank?(current) || SessionLedger.valid_day_id?(current)
302
+ stores.each do |store|
303
+ match = Dir.glob(File.join(store, "#{current}--*")).find { |p| File.directory?(p) }
304
+ return match if match
305
+ end
306
+ nil
307
+ end
308
+ end