@zalom/plastic 1.14.0 → 2.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) 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/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/check-update +12 -3
  10. package/hooks/close +5 -0
  11. package/hooks/hooks.json +9 -49
  12. package/hooks/record +4 -0
  13. package/package.json +3 -2
  14. package/scripts/agent-report +8 -9
  15. package/scripts/append-ledger +195 -0
  16. package/scripts/codex-hook +101 -95
  17. package/scripts/dashboard.rb +4 -4
  18. package/scripts/doctor.rb +106 -43
  19. package/scripts/end-intent +77 -76
  20. package/scripts/exec-worktree +11 -20
  21. package/scripts/file-session-intent +61 -0
  22. package/scripts/hook-capture +242 -0
  23. package/scripts/hook-close +34 -0
  24. package/scripts/hook-record +149 -0
  25. package/scripts/hook-session-start +95 -12
  26. package/scripts/install.rb +8 -6
  27. package/scripts/lib/agent_models.rb +1 -7
  28. package/scripts/lib/arm.rb +283 -0
  29. package/scripts/lib/backfill_intent.rb +316 -0
  30. package/scripts/lib/bridge.rb +24 -1597
  31. package/scripts/lib/doctor_core.rb +17 -88
  32. package/scripts/lib/doctor_session_ledger.rb +106 -0
  33. package/scripts/lib/exec_worktree.rb +14 -60
  34. package/scripts/lib/harness_text.rb +5 -4
  35. package/scripts/lib/hook_registry.rb +44 -107
  36. package/scripts/lib/insights.rb +3 -4
  37. package/scripts/lib/installer_core.rb +19 -30
  38. package/scripts/lib/lock.rb +6 -26
  39. package/scripts/lib/outcome_guard.rb +7 -5
  40. package/scripts/lib/project_validator.rb +30 -1
  41. package/scripts/lib/roadmap_queue.rb +3 -3
  42. package/scripts/lib/roadmap_savepoint.rb +1 -1
  43. package/scripts/lib/rule_catalog.rb +6 -3
  44. package/scripts/lib/savepoint.rb +320 -0
  45. package/scripts/lib/scaffold_intent.rb +13 -179
  46. package/scripts/lib/session_backfill.rb +184 -0
  47. package/scripts/lib/session_close.rb +84 -0
  48. package/scripts/lib/session_git.rb +469 -0
  49. package/scripts/lib/session_ledger.rb +497 -0
  50. package/scripts/lib/store_discovery.rb +1 -1
  51. package/scripts/lib/store_provisioning.rb +1 -1
  52. package/scripts/maintenance-run +7 -7
  53. package/scripts/new-intent +63 -4
  54. package/scripts/plastic-lock +94 -41
  55. package/scripts/promote-session-item +150 -0
  56. package/scripts/rebuild-graph +1 -1
  57. package/scripts/roadmap-next +2 -2
  58. package/scripts/rollback.rb +42 -3
  59. package/scripts/scaffold-intent +37 -63
  60. package/scripts/session-commit +116 -0
  61. package/scripts/spawn-preamble +5 -5
  62. package/scripts/update.rb +7 -3
  63. package/skills/agent-advisor/SKILL.md +3 -3
  64. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  65. package/skills/auto/SKILL.md +218 -317
  66. package/skills/auto/evals/evals.json +10 -10
  67. package/skills/auto/references/agent-architecture.md +102 -90
  68. package/skills/auto/references/agent-report-contract.md +4 -21
  69. package/skills/auto/references/end-tail.md +47 -53
  70. package/skills/auto/references/human-report-contract.md +8 -8
  71. package/skills/conventions/SKILL.md +1 -3
  72. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  73. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  74. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  75. package/skills/dashboard/SKILL.md +1 -1
  76. package/skills/direct/SKILL.md +66 -0
  77. package/skills/direct/references/request-signals.md +59 -0
  78. package/skills/doctor/SKILL.md +58 -11
  79. package/skills/doctor/report.md +3 -1
  80. package/skills/install/SKILL.md +11 -9
  81. package/skills/intent-continuing/SKILL.md +131 -89
  82. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  83. package/skills/intent-continuing/references/context-management.md +2 -2
  84. package/skills/intent-creating/SKILL.md +2 -2
  85. package/skills/intent-creating/references/lifecycle.md +1 -1
  86. package/skills/intent-ending/SKILL.md +28 -37
  87. package/skills/intent-ending/evals/evals.json +4 -4
  88. package/skills/intent-executing/SKILL.md +12 -49
  89. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  90. package/skills/intent-speccing/SKILL.md +108 -51
  91. package/skills/intent-speccing/evals/evals.json +5 -5
  92. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  93. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  94. package/skills/project-creating/SKILL.md +1 -1
  95. package/skills/releasing/SKILL.md +7 -8
  96. package/skills/tutorial/SKILL.md +2 -2
  97. package/skills/tutorial/references/track-1-guided.md +16 -18
  98. package/skills/tutorial/references/track-2-auto.md +12 -13
  99. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  100. package/templates/agents.md +4 -7
  101. package/templates/project.yml +7 -0
  102. package/templates/roadmap.md +1 -1
  103. package/templates/savepoint.md +2 -2
  104. package/templates/session-intent.md +29 -0
  105. package/agents/plastic-brainstorming.md +0 -48
  106. package/agents/plastic-future-intent-researcher.md +0 -35
  107. package/agents/plastic-intent-curator.md +0 -47
  108. package/agents/plastic-intent-discovery.md +0 -43
  109. package/agents/plastic-planner.md +0 -56
  110. package/agents/plastic-spec-specialist.md +0 -53
  111. package/hooks/auto-arm +0 -5
  112. package/hooks/bash-gate +0 -3
  113. package/hooks/continue +0 -31
  114. package/hooks/edit-gates +0 -3
  115. package/hooks/future-intent-check +0 -25
  116. package/hooks/gate-check +0 -12
  117. package/hooks/power-tools +0 -8
  118. package/scripts/hook-auto-arm +0 -51
  119. package/scripts/hook-bash-gate +0 -78
  120. package/scripts/hook-code-gate +0 -38
  121. package/scripts/hook-continue +0 -48
  122. package/scripts/hook-create-gate +0 -51
  123. package/scripts/hook-edit-gates +0 -58
  124. package/scripts/hook-future-intent-check +0 -90
  125. package/scripts/hook-gate-check +0 -169
  126. package/scripts/hook-links-gate +0 -54
  127. package/scripts/hook-lock-gate +0 -47
  128. package/scripts/hook-power-tools +0 -38
  129. package/scripts/hook-savepoint-pre +0 -32
  130. package/scripts/lib/codex_edit_gates.rb +0 -138
  131. package/scripts/lib/edit_gates.rb +0 -398
  132. package/scripts/lib/links_gate.rb +0 -140
  133. package/scripts/lib/qmd_hook.rb +0 -41
  134. package/scripts/lib/spec_header.rb +0 -83
  135. package/scripts/lib/start_intent.rb +0 -296
  136. package/scripts/start-intent +0 -89
  137. package/skills/_active-intent-gate.md +0 -26
  138. package/skills/auto/references/tiers.md +0 -100
  139. package/skills/continuing/SKILL.md +0 -34
  140. package/skills/continuing/evals/evals.json +0 -91
  141. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  142. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  143. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  144. package/skills/intent-brainstorming/SKILL.md +0 -118
  145. package/skills/intent-brainstorming/evals/evals.json +0 -67
  146. package/skills/intent-continuing/evals/evals.json +0 -145
  147. package/skills/intent-discovering/SKILL.md +0 -56
  148. package/skills/intent-grilling/SKILL.md +0 -108
  149. package/skills/intent-linking/SKILL.md +0 -128
  150. package/skills/intent-linking/evals/evals.json +0 -22
  151. package/skills/intent-linking/references/zettelkasten.md +0 -45
  152. package/skills/intent-locking/SKILL.md +0 -64
  153. package/skills/intent-planning/SKILL.md +0 -145
  154. package/skills/intent-planning/evals/evals.json +0 -81
  155. package/skills/intent-planning/references/plan-format.md +0 -130
  156. package/skills/intent-researching/SKILL.md +0 -123
  157. package/skills/intent-researching/evals/evals.json +0 -22
  158. package/skills/intent-savepoint/SKILL.md +0 -83
  159. package/skills/intent-savepoint/references/context-management.md +0 -32
  160. package/skills/intent-starting/SKILL.md +0 -151
  161. package/skills/intent-starting/evals/evals.json +0 -117
  162. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  163. package/skills/project-continuing/SKILL.md +0 -119
  164. package/skills/project-continuing/evals/evals.json +0 -100
  165. package/skills/roadmap-continuing/SKILL.md +0 -89
  166. package/skills/roadmap-continuing/evals/evals.json +0 -82
  167. package/skills/skill-creating/SKILL.md +0 -75
  168. package/skills/skill-creating/evals/evals.json +0 -108
  169. package/skills/skill-creating/references/agents.md +0 -168
  170. package/skills/skill-creating/references/defaults-first.md +0 -23
  171. package/skills/skill-creating/references/evals.md +0 -41
  172. package/skills/skill-creating/references/hooks.md +0 -251
  173. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  174. package/skills/skill-creating/references/scripts.md +0 -166
  175. package/skills/skill-creating/references/skills.md +0 -169
  176. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  177. package/skills/skill-evaluating/SKILL.md +0 -141
  178. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  179. package/skills/skill-evaluating/evals/evals.json +0 -75
  180. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  181. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  182. package/skills/store-curating/SKILL.md +0 -64
  183. package/skills/store-curating/evals/evals.json +0 -38
  184. package/skills/store-indexing/SKILL.md +0 -93
  185. package/skills/store-indexing/evals/evals.json +0 -22
  186. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  187. package/skills/store-provisioning/SKILL.md +0 -55
  188. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  189. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  190. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -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
@@ -0,0 +1,316 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "scaffold_intent"
7
+ require_relative "savepoint"
8
+ require_relative "intent_validator"
9
+ require_relative "worktree"
10
+
11
+ # BackfillIntent (intent 308) - write the four judgment documents of an intent from its
12
+ # record at intent end. The record is what an intent already carries by the time it
13
+ # closes: the intent file (`## Intent`, `### Decisions`, `## Insights`), checklist.md,
14
+ # and the diff on its own code worktree. Every section written here is a verbatim copy
15
+ # or a mechanical rendering of one of those; a section with no source keeps the
16
+ # template's own stub text, so nothing here invents prose. The one value that comes from
17
+ # the caller is outcome.md's `disposition:`, the closer's explicit assertion (spec D11).
18
+ #
19
+ # A target is backfilled only when it is missing or still the scaffold placeholder: the
20
+ # first line is the sentinel AND the body under it is nothing but the template
21
+ # (`edited_sentinel?`). A sentinel over hand-written lines counts as real. actions/ is
22
+ # backfilled as ACTION_1.md only when `Savepoint.has_real_action?` is false. A real file
23
+ # is never touched and there is no force flag. Every written file carries a marker
24
+ # comment on the line after its title, never the sentinel, so every reader treats it as
25
+ # real. A zero-byte spec.md or plan.md reads as real here exactly as it does in
26
+ # `Savepoint.stage_file_present?` (spec D1), so the doctor surfaces never disagree.
27
+ #
28
+ # Pure and dependency-injected like ScaffoldIntent: never exits, never reads ARGV or ENV
29
+ # (only the ambient Dir.home default), and takes the git seam as `runner:` so tests drive
30
+ # it in process with a fake runner.
31
+ module BackfillIntent
32
+ module_function
33
+
34
+ TARGETS = ["spec.md", "plan.md", "actions/ACTION_1.md", "outcome.md"].freeze
35
+ TEMPLATED = ["spec.md", "plan.md", "outcome.md"].freeze
36
+ MARKER_PREFIX = "<!-- backfilled from the record by end-intent on "
37
+ ITEM_RE = /\A\s*- \[( |x|X)\] /.freeze
38
+ TITLE_RE = /\A# /.freeze
39
+ STUB_SECTIONS = {
40
+ "## Goals" => "- ...\n", "## Non-Goals" => "- ...\n",
41
+ "## Approach" => "(the chosen approach, in prose)\n",
42
+ "## Alternatives Considered" => "- <alternative>: not chosen because ...\n",
43
+ }.freeze
44
+
45
+ def marker(now)
46
+ "#{MARKER_PREFIX}#{now.utc.iso8601} -->"
47
+ end
48
+
49
+ # --- templates ---------------------------------------------------------------------
50
+
51
+ # { "spec.md" => text, ... } for the templates that exist, else {}.
52
+ def load_templates(templates_dir: nil, home: Dir.home)
53
+ dir = templates_dir || ScaffoldIntent.resolve_templates_dir(home: home)
54
+ return {} if dir.nil?
55
+
56
+ TEMPLATED.each_with_object({}) do |name, acc|
57
+ path = File.join(dir, name)
58
+ acc[name] = File.read(path) if File.exist?(path)
59
+ end
60
+ end
61
+
62
+ # --- target selection --------------------------------------------------------------
63
+
64
+ # True iff `path` starts with the sentinel and its body carries a non-blank, non-title
65
+ # line that the template for this file does not: someone wrote under the sentinel.
66
+ # With no template known, any body line at all means edited.
67
+ def edited_sentinel?(path, template_text, known_lines: [])
68
+ lines = File.read(path).lines
69
+ return false unless lines.first.to_s.chomp == Savepoint::PLACEHOLDER_SENTINEL
70
+
71
+ body = lines.drop(1).map(&:strip).reject { |l| l.empty? || l.match?(TITLE_RE) }
72
+ return body.any? if template_text.nil?
73
+
74
+ known = template_text.lines.map(&:strip).reject(&:empty?) + known_lines.map(&:strip)
75
+ (body - known).any?
76
+ rescue StandardError
77
+ true
78
+ end
79
+
80
+ # { targets: [rel], edited: [rel] }: the targets that need writing, and the sentinel
81
+ # files that were left alone because their body was edited.
82
+ def classify(intent_dir, templates: {}, known_lines: [])
83
+ targets = []
84
+ edited = []
85
+ TEMPLATED.each do |rel|
86
+ path = File.join(intent_dir, rel)
87
+ next if Savepoint.stage_file_present?(path)
88
+
89
+ if File.exist?(path) && edited_sentinel?(path, templates[rel], known_lines: known_lines)
90
+ edited << rel
91
+ else
92
+ targets << rel
93
+ end
94
+ end
95
+ unless Savepoint.has_real_action?(intent_dir)
96
+ action = File.join(intent_dir, "actions", "ACTION_1.md")
97
+ if File.exist?(action) && !Savepoint.stage_file_present?(action) && edited_sentinel?(action, nil)
98
+ edited << "actions/ACTION_1.md"
99
+ elsif !File.exist?(action) || !Savepoint.stage_file_present?(action)
100
+ targets << "actions/ACTION_1.md"
101
+ end
102
+ end
103
+ { targets: TARGETS & targets, edited: TARGETS & edited }
104
+ end
105
+
106
+ def targets_for(intent_dir, templates_dir: nil, home: Dir.home)
107
+ classify(intent_dir, templates: load_templates(templates_dir: templates_dir, home: home))[:targets]
108
+ end
109
+
110
+ # --- reading the record (pure over file contents) ---------------------------------
111
+
112
+ def strip_edges(lines)
113
+ ScaffoldIntent.strip_blank_edges(lines)
114
+ end
115
+
116
+ def section_lines(text, heading)
117
+ body = ScaffoldIntent.sections_from(text)[heading]
118
+ return [] if body.nil?
119
+
120
+ strip_edges(body.lines.map(&:rstrip)).reject { |l| l.start_with?("<!--") && l.end_with?("-->") }
121
+ end
122
+
123
+ # Every GFM checkbox item of checklist.md with its section and done flag. A continuation
124
+ # line (indented, not an item, not a heading, not a table row) stays attached to the item
125
+ # above it so a wrapped item round-trips verbatim.
126
+ def checklist_items(text)
127
+ items = []
128
+ section = nil
129
+ text.each_line do |raw|
130
+ line = raw.rstrip
131
+ if line.start_with?("## ")
132
+ section = line
133
+ elsif (m = ITEM_RE.match(line))
134
+ items << { line: line, done: m[1] != " ", section: section }
135
+ elsif items.any? && line.start_with?(" ") && !line.start_with?(" |") && section == items.last[:section]
136
+ items.last[:line] = "#{items.last[:line]}\n#{line}"
137
+ end
138
+ end
139
+ items
140
+ end
141
+
142
+ def session_log_lines(text)
143
+ section_lines(text, "## Session Log").select { |l| l.start_with?("|") }
144
+ end
145
+
146
+ # [record, nil] or [nil, message] when the intent file is missing. `record[:notes]`
147
+ # names every source that was absent; an absent source never stops the write.
148
+ def read_record(intent_dir)
149
+ intent_file = Savepoint.intent_file(intent_dir)
150
+ return [nil, "the intent file is missing at #{intent_file}"] unless File.exist?(intent_file)
151
+
152
+ content = File.read(intent_file)
153
+ fm = IntentValidator.parse_frontmatter_text(content)
154
+ name = fm.is_a?(Hash) ? fm["intent"].to_s.strip : ""
155
+ notes = []
156
+ if name.empty?
157
+ name = File.basename(intent_dir)
158
+ notes << "the intent file has no frontmatter intent name; the directory name stands in"
159
+ end
160
+ decisions, = ScaffoldIntent.extract_decisions(content)
161
+ notes << "the intent file has no ### Decisions list; spec.md says so" if decisions.nil?
162
+
163
+ checklist = File.join(intent_dir, "checklist.md")
164
+ checklist_text = ""
165
+ if Savepoint.stage_file_present?(checklist)
166
+ checklist_text = File.read(checklist)
167
+ else
168
+ notes << "checklist.md is missing or still a placeholder; the item sections say so"
169
+ end
170
+
171
+ [{
172
+ name: name,
173
+ intent_body: section_lines(content, "## Intent"),
174
+ decisions: decisions,
175
+ insights: section_lines(content, "## Insights"),
176
+ items: checklist_items(checklist_text),
177
+ session_log: session_log_lines(checklist_text),
178
+ notes: notes,
179
+ }, nil]
180
+ end
181
+
182
+ # --- rendering (pure) ---------------------------------------------------------------
183
+
184
+ def block(lines, empty)
185
+ lines.empty? ? "#{empty}\n" : "#{lines.join("\n")}\n"
186
+ end
187
+
188
+ def template_stub(template_text, heading)
189
+ body = template_text && ScaffoldIntent.sections_from(template_text)[heading]
190
+ body = STUB_SECTIONS.fetch(heading, "") if body.nil? || body.strip.empty?
191
+ "#{body.rstrip}\n"
192
+ end
193
+
194
+ def render(record, disposition:, summary:, verification:, now:, templates: {})
195
+ mark = marker(now)
196
+ items = record[:items].map { |i| i[:line] }
197
+ done = record[:items].select { |i| i[:done] }.map { |i| i[:line] }
198
+ open = record[:items].reject { |i| i[:done] }.map { |i| i[:line] }
199
+ decisions = record[:decisions].nil? ? "(none recorded in the intent file)\n" : record[:decisions]
200
+ decisions += "\n" unless decisions.end_with?("\n")
201
+
202
+ spec = +"# Spec: #{record[:name]}\n#{mark}\n\n"
203
+ spec << "## Problem\n#{block(record[:intent_body], '(not recorded in the intent file)')}\n"
204
+ STUB_SECTIONS.each_key { |h| spec << "#{h}\n#{template_stub(templates['spec.md'], h)}\n" }
205
+ spec << "## Decisions\n#{decisions}\n"
206
+ spec << "## Acceptance Criteria\n#{block(items, '(no checklist items recorded)')}\n"
207
+ spec << "## Open Questions\nNone\n"
208
+
209
+ plan = +"# Plan: #{record[:name]}\n#{mark}\n\n"
210
+ plan << "## Goal\n#{record[:name]}\n\n"
211
+ plan << "## Steps\n#{block(items, '(no checklist items recorded)')}\n"
212
+ plan << "## Notes\n#{block(record[:insights], '(no insights recorded)')}"
213
+
214
+ action = +"# ACTION_1: #{record[:name]}\n#{mark}\n\n"
215
+ action << "## Items\n#{block(items, '(no checklist items recorded)')}\n"
216
+ action << "## Session Log\n#{block(record[:session_log], '(none)')}"
217
+
218
+ summary_text = summary.to_s.strip
219
+ summary_text = "(no summary given at close)" if summary_text.empty?
220
+ outcome = +"---\ndisposition: #{disposition}\n---\n# Outcome: #{record[:name]}\n#{mark}\n\n"
221
+ outcome << "## Summary\n#{summary_text}\n\n"
222
+ outcome << "## Delivered\n#{block(done, '(no completed checklist items recorded)')}\n"
223
+ outcome << "## Verification\n#{verification.to_s.strip.empty? ? "(no diff available)\n" : verification}\n"
224
+ outcome << "## Follow-ups\n#{block(open, 'None')}"
225
+
226
+ { "spec.md" => spec, "plan.md" => plan, "actions/ACTION_1.md" => action, "outcome.md" => outcome }
227
+ end
228
+
229
+ # --- the diff, from this intent's own code worktree only -----------------------------
230
+
231
+ # The provisioned code worktree for this intent when it exists on disk, else nil. Never
232
+ # the current directory's repo: a store-only intent closed from an unrelated checkout
233
+ # must not report that checkout's diff.
234
+ def worktree_dir(store:, id:, intent_dir:, home: Dir.home)
235
+ wt_home = Worktree.home_from_store(store) || home
236
+ slug = Worktree.slug_for_store(store, home: wt_home)
237
+ return nil if slug.nil?
238
+
239
+ intent_slug = File.basename(intent_dir).split("--", 2).last
240
+ code = Worktree.paths(slug: slug, intent_id: id, intent_slug: intent_slug, home: wt_home)["code"]
241
+ code && Dir.exist?(code) ? code : nil
242
+ end
243
+
244
+ def verification_body(store:, id:, intent_dir:, home:, runner:)
245
+ repo = worktree_dir(store: store, id: id, intent_dir: intent_dir, home: home)
246
+ return "Diffstat unavailable: this intent provisioned no code worktree\n" if repo.nil?
247
+
248
+ base = ScaffoldIntent.detect_base_branch(repo, runner: runner)
249
+ return "Diffstat unavailable: no base branch could be detected (no origin/HEAD, main, or master)\n" if base.nil?
250
+
251
+ stat, err = ScaffoldIntent.diffstat(repo, base, runner: runner)
252
+ return "Diffstat unavailable: #{err}\n" if stat.nil?
253
+
254
+ "Diffstat against #{base}:\n```\n#{stat}#{stat.end_with?("\n") ? "" : "\n"}```\n"
255
+ rescue StandardError => e
256
+ "Diffstat unavailable: #{e.message}\n"
257
+ end
258
+
259
+ # --- writing, atomic inside the intent dir -------------------------------------------
260
+
261
+ def write_atomic(intent_dir, relative, content)
262
+ target = File.join(intent_dir, relative)
263
+ FileUtils.mkdir_p(File.dirname(target))
264
+ tmp = File.join(File.dirname(target), ".filing-#{File.basename(target)}")
265
+ File.write(tmp, content)
266
+ File.rename(tmp, target)
267
+ target
268
+ end
269
+
270
+ # A `.filing-*` left by an earlier crash would otherwise ride into the store commit.
271
+ def remove_stale_filing(intent_dir)
272
+ Dir.glob([File.join(intent_dir, ".filing-*"), File.join(intent_dir, "actions", ".filing-*")]).each do |f|
273
+ File.delete(f) if File.file?(f)
274
+ end
275
+ end
276
+
277
+ # Returns { written: [rel], skipped: [rel], notes: [String] }. `skipped` lists the
278
+ # targets that already carry real content (an edited sentinel among them, named in
279
+ # `notes`). `notes` names every absent source; an absent source never stops the write.
280
+ # One savepoint line, `Exec backfilled <list>`, records the fact (idempotent per list).
281
+ def run(intent_dir:, store:, id:, disposition:, summary: nil, home: Dir.home,
282
+ runner: Worktree::ShellRunner.new, templates_dir: nil, now: Time.now)
283
+ result = { written: [], skipped: [], notes: [] }
284
+ remove_stale_filing(intent_dir)
285
+
286
+ templates = load_templates(templates_dir: templates_dir, home: home)
287
+ record, err = read_record(intent_dir)
288
+ if record.nil?
289
+ result[:notes] << err
290
+ result[:skipped] = TARGETS - classify(intent_dir, templates: templates)[:targets]
291
+ return result
292
+ end
293
+
294
+ classified = classify(intent_dir, templates: templates, known_lines: [record[:name]])
295
+ targets = classified[:targets]
296
+ result[:skipped] = TARGETS - targets
297
+ classified[:edited].each do |rel|
298
+ result[:notes] << "#{rel} carries a sentinel over hand-written content; left untouched"
299
+ end
300
+ result[:notes].concat(record[:notes])
301
+ return result if targets.empty?
302
+
303
+ verification = nil
304
+ if targets.include?("outcome.md")
305
+ verification = verification_body(store: store, id: id, intent_dir: intent_dir, home: home, runner: runner)
306
+ end
307
+ docs = render(record, disposition: disposition, summary: summary, verification: verification,
308
+ now: now, templates: templates)
309
+ targets.each do |rel|
310
+ write_atomic(intent_dir, rel, docs.fetch(rel))
311
+ result[:written] << rel
312
+ end
313
+ Savepoint.append_savepoint_line(intent_dir, "Exec", "backfilled #{result[:written].join(', ')}", now)
314
+ result
315
+ end
316
+ end