@zalom/plastic 1.14.1 → 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 (189) 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/close +5 -0
  10. package/hooks/hooks.json +9 -49
  11. package/hooks/record +4 -0
  12. package/package.json +3 -2
  13. package/scripts/agent-report +8 -9
  14. package/scripts/append-ledger +195 -0
  15. package/scripts/codex-hook +78 -90
  16. package/scripts/dashboard.rb +4 -4
  17. package/scripts/doctor.rb +106 -43
  18. package/scripts/end-intent +77 -76
  19. package/scripts/exec-worktree +11 -20
  20. package/scripts/file-session-intent +61 -0
  21. package/scripts/hook-capture +242 -0
  22. package/scripts/hook-close +34 -0
  23. package/scripts/hook-record +149 -0
  24. package/scripts/hook-session-start +95 -12
  25. package/scripts/install.rb +8 -6
  26. package/scripts/lib/agent_models.rb +1 -7
  27. package/scripts/lib/arm.rb +283 -0
  28. package/scripts/lib/backfill_intent.rb +316 -0
  29. package/scripts/lib/bridge.rb +24 -1597
  30. package/scripts/lib/doctor_core.rb +17 -88
  31. package/scripts/lib/doctor_session_ledger.rb +106 -0
  32. package/scripts/lib/exec_worktree.rb +14 -60
  33. package/scripts/lib/harness_text.rb +5 -4
  34. package/scripts/lib/hook_registry.rb +44 -107
  35. package/scripts/lib/insights.rb +3 -4
  36. package/scripts/lib/installer_core.rb +19 -30
  37. package/scripts/lib/lock.rb +6 -26
  38. package/scripts/lib/outcome_guard.rb +7 -5
  39. package/scripts/lib/project_validator.rb +30 -1
  40. package/scripts/lib/roadmap_queue.rb +3 -3
  41. package/scripts/lib/roadmap_savepoint.rb +1 -1
  42. package/scripts/lib/rule_catalog.rb +6 -3
  43. package/scripts/lib/savepoint.rb +320 -0
  44. package/scripts/lib/scaffold_intent.rb +13 -179
  45. package/scripts/lib/session_backfill.rb +184 -0
  46. package/scripts/lib/session_close.rb +84 -0
  47. package/scripts/lib/session_git.rb +469 -0
  48. package/scripts/lib/session_ledger.rb +497 -0
  49. package/scripts/lib/store_discovery.rb +1 -1
  50. package/scripts/lib/store_provisioning.rb +1 -1
  51. package/scripts/maintenance-run +7 -7
  52. package/scripts/new-intent +63 -4
  53. package/scripts/plastic-lock +94 -41
  54. package/scripts/promote-session-item +150 -0
  55. package/scripts/rebuild-graph +1 -1
  56. package/scripts/roadmap-next +2 -2
  57. package/scripts/rollback.rb +42 -3
  58. package/scripts/scaffold-intent +37 -63
  59. package/scripts/session-commit +116 -0
  60. package/scripts/spawn-preamble +5 -5
  61. package/scripts/update.rb +7 -3
  62. package/skills/agent-advisor/SKILL.md +3 -3
  63. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  64. package/skills/auto/SKILL.md +218 -317
  65. package/skills/auto/evals/evals.json +10 -10
  66. package/skills/auto/references/agent-architecture.md +102 -90
  67. package/skills/auto/references/agent-report-contract.md +4 -21
  68. package/skills/auto/references/end-tail.md +47 -53
  69. package/skills/auto/references/human-report-contract.md +8 -8
  70. package/skills/conventions/SKILL.md +1 -3
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  72. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  73. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  74. package/skills/dashboard/SKILL.md +1 -1
  75. package/skills/direct/SKILL.md +66 -0
  76. package/skills/direct/references/request-signals.md +59 -0
  77. package/skills/doctor/SKILL.md +58 -11
  78. package/skills/doctor/report.md +3 -1
  79. package/skills/install/SKILL.md +11 -9
  80. package/skills/intent-continuing/SKILL.md +131 -89
  81. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  82. package/skills/intent-continuing/references/context-management.md +2 -2
  83. package/skills/intent-creating/SKILL.md +2 -2
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-ending/SKILL.md +28 -37
  86. package/skills/intent-ending/evals/evals.json +4 -4
  87. package/skills/intent-executing/SKILL.md +12 -49
  88. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  89. package/skills/intent-speccing/SKILL.md +108 -51
  90. package/skills/intent-speccing/evals/evals.json +5 -5
  91. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  92. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  93. package/skills/project-creating/SKILL.md +1 -1
  94. package/skills/releasing/SKILL.md +7 -8
  95. package/skills/tutorial/SKILL.md +2 -2
  96. package/skills/tutorial/references/track-1-guided.md +16 -18
  97. package/skills/tutorial/references/track-2-auto.md +12 -13
  98. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  99. package/templates/agents.md +4 -7
  100. package/templates/project.yml +7 -0
  101. package/templates/roadmap.md +1 -1
  102. package/templates/savepoint.md +2 -2
  103. package/templates/session-intent.md +29 -0
  104. package/agents/plastic-brainstorming.md +0 -48
  105. package/agents/plastic-future-intent-researcher.md +0 -35
  106. package/agents/plastic-intent-curator.md +0 -47
  107. package/agents/plastic-intent-discovery.md +0 -43
  108. package/agents/plastic-planner.md +0 -56
  109. package/agents/plastic-spec-specialist.md +0 -53
  110. package/hooks/auto-arm +0 -5
  111. package/hooks/bash-gate +0 -3
  112. package/hooks/continue +0 -31
  113. package/hooks/edit-gates +0 -3
  114. package/hooks/future-intent-check +0 -25
  115. package/hooks/gate-check +0 -12
  116. package/hooks/power-tools +0 -8
  117. package/scripts/hook-auto-arm +0 -51
  118. package/scripts/hook-bash-gate +0 -78
  119. package/scripts/hook-code-gate +0 -38
  120. package/scripts/hook-continue +0 -48
  121. package/scripts/hook-create-gate +0 -51
  122. package/scripts/hook-edit-gates +0 -58
  123. package/scripts/hook-future-intent-check +0 -90
  124. package/scripts/hook-gate-check +0 -169
  125. package/scripts/hook-links-gate +0 -54
  126. package/scripts/hook-lock-gate +0 -47
  127. package/scripts/hook-power-tools +0 -38
  128. package/scripts/hook-savepoint-pre +0 -32
  129. package/scripts/lib/codex_edit_gates.rb +0 -138
  130. package/scripts/lib/edit_gates.rb +0 -398
  131. package/scripts/lib/links_gate.rb +0 -140
  132. package/scripts/lib/qmd_hook.rb +0 -41
  133. package/scripts/lib/spec_header.rb +0 -83
  134. package/scripts/lib/start_intent.rb +0 -296
  135. package/scripts/start-intent +0 -89
  136. package/skills/_active-intent-gate.md +0 -26
  137. package/skills/auto/references/tiers.md +0 -100
  138. package/skills/continuing/SKILL.md +0 -34
  139. package/skills/continuing/evals/evals.json +0 -91
  140. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  141. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  142. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  143. package/skills/intent-brainstorming/SKILL.md +0 -118
  144. package/skills/intent-brainstorming/evals/evals.json +0 -67
  145. package/skills/intent-continuing/evals/evals.json +0 -145
  146. package/skills/intent-discovering/SKILL.md +0 -56
  147. package/skills/intent-grilling/SKILL.md +0 -108
  148. package/skills/intent-linking/SKILL.md +0 -128
  149. package/skills/intent-linking/evals/evals.json +0 -22
  150. package/skills/intent-linking/references/zettelkasten.md +0 -45
  151. package/skills/intent-locking/SKILL.md +0 -64
  152. package/skills/intent-planning/SKILL.md +0 -145
  153. package/skills/intent-planning/evals/evals.json +0 -81
  154. package/skills/intent-planning/references/plan-format.md +0 -130
  155. package/skills/intent-researching/SKILL.md +0 -123
  156. package/skills/intent-researching/evals/evals.json +0 -22
  157. package/skills/intent-savepoint/SKILL.md +0 -83
  158. package/skills/intent-savepoint/references/context-management.md +0 -32
  159. package/skills/intent-starting/SKILL.md +0 -151
  160. package/skills/intent-starting/evals/evals.json +0 -117
  161. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  162. package/skills/project-continuing/SKILL.md +0 -119
  163. package/skills/project-continuing/evals/evals.json +0 -100
  164. package/skills/roadmap-continuing/SKILL.md +0 -89
  165. package/skills/roadmap-continuing/evals/evals.json +0 -82
  166. package/skills/skill-creating/SKILL.md +0 -75
  167. package/skills/skill-creating/evals/evals.json +0 -108
  168. package/skills/skill-creating/references/agents.md +0 -168
  169. package/skills/skill-creating/references/defaults-first.md +0 -23
  170. package/skills/skill-creating/references/evals.md +0 -41
  171. package/skills/skill-creating/references/hooks.md +0 -251
  172. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  173. package/skills/skill-creating/references/scripts.md +0 -166
  174. package/skills/skill-creating/references/skills.md +0 -169
  175. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  176. package/skills/skill-evaluating/SKILL.md +0 -141
  177. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  178. package/skills/skill-evaluating/evals/evals.json +0 -75
  179. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  180. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  181. package/skills/store-curating/SKILL.md +0 -64
  182. package/skills/store-curating/evals/evals.json +0 -38
  183. package/skills/store-indexing/SKILL.md +0 -93
  184. package/skills/store-indexing/evals/evals.json +0 -22
  185. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  186. package/skills/store-provisioning/SKILL.md +0 -55
  187. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  188. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  189. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -2,19 +2,29 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
  #
5
- # plastic-lock: inspect and repair the durable delivery lock (intent 108, D5),
6
- # and take/free per-artifact claim tokens (intent 111 D1/D5).
5
+ # plastic-lock: take, inspect, repair, and give back the durable delivery lock
6
+ # (intent 108, D5; intent 307), and take/free per-artifact claim tokens
7
+ # (intent 111 D1/D5).
7
8
  #
8
- # Usage: plastic-lock <status|who|fix|release|reclaim|delegate|claim|release-claim>
9
+ # Usage: plastic-lock <arm|status|who|fix|release|reclaim|delegate|claim|release-claim>
9
10
  # [--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]
11
+ # [--harness claude|codex] [--agent NAME] [--model MODEL] [--thread ID]
12
+ # [--mode auto|guided] [--status finished|failed]
10
13
  #
11
14
  # Verbs:
15
+ # arm take the intent for this session (intent 307): acquire
16
+ # delivery.lock stamped with --mode (default auto) and the
17
+ # provenance flags, provision the code worktree, and point the
18
+ # session at the intent; exit 1 on a held, stale, excluded, or
19
+ # corrupt lock with the resolving doctor hint
12
20
  # who compact, read-only durable owner/activity view
13
- # status report the lock file, the bridge cache, their agreement,
14
- # and any live per-artifact claims
15
- # fix idempotent repair: rebuild lock + bridge from disk truth for
16
- # the current session; never touches a fresh foreign lock
17
- # release owner clears the lock (End tail / abandoning a boarding)
21
+ # status report the lock file, its freshness, the derived worktree,
22
+ # whether this session's pointer names the intent, and any
23
+ # live per-artifact claims
24
+ # fix idempotent repair of the lock for the current session; never
25
+ # touches a fresh foreign lock
26
+ # release owner clears the lock and points the session back at the day
27
+ # ledger (End tail / abandoning a boarding)
18
28
  # reclaim explicit takeover of a stale lock, audited in savepoint.md
19
29
  # delegate owner registers a subagent session under the lock (D4)
20
30
  # claim acquire the named --artifact's claim token (intent 111);
@@ -22,15 +32,23 @@
22
32
  # including this same session; takes over a stale claim
23
33
  # release-claim free the named --artifact's claim token
24
34
  #
25
- # Without --intent-dir the intent is resolved from this session's bridge.
26
- # Exit 0 on success/report; exit 1 when the verb is blocked (held elsewhere).
35
+ # Without --intent-dir the intent is resolved from this session's pointer
36
+ # (~/.plastic/store/.tmp/<session>/current) when it names an intent id, looked
37
+ # up in the working directory's project store and then the global store. The
38
+ # /tmp bridge JSON this CLI used to resolve through was removed in 2.0
39
+ # (intent 307). Exit 0 on success/report; exit 1 when the verb is blocked.
27
40
 
28
41
  require "json"
42
+ require "yaml"
29
43
  require_relative "lib/bridge"
30
44
  require_relative "lib/lock"
45
+ require_relative "lib/arm"
46
+ require_relative "lib/session_ledger"
47
+
48
+ VERBS = %w[arm status who fix release reclaim delegate claim release-claim].freeze
31
49
 
32
50
  def usage!
33
- warn "usage: plastic-lock <status|who|fix|release|reclaim|delegate|claim|release-claim> " \
51
+ warn "usage: plastic-lock <#{VERBS.join('|')}> " \
34
52
  "[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME] " \
35
53
  "[--harness claude|codex] [--agent NAME] [--model MODEL] [--thread ID] " \
36
54
  "[--mode auto|guided] [--status finished|failed]"
@@ -38,7 +56,7 @@ def usage!
38
56
  end
39
57
 
40
58
  verb = ARGV.shift
41
- usage! unless %w[status who fix release reclaim delegate claim release-claim].include?(verb)
59
+ usage! unless VERBS.include?(verb)
42
60
 
43
61
  opts = {}
44
62
  until ARGV.empty?
@@ -64,15 +82,29 @@ session = opts[:session]
64
82
  session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
65
83
  harness = opts[:harness]
66
84
  hint_harness = harness || "claude"
85
+ # The plastic home every hook uses (PLASTIC_HOME, else ~/.plastic); Arm takes the
86
+ # OS home above it, the same convention Worktree resolves projects.yml from.
87
+ plastic_home_env = ENV["PLASTIC_HOME"].to_s.strip
88
+ home = plastic_home_env.empty? ? Dir.home : File.dirname(File.expand_path(plastic_home_env))
89
+
90
+ # The stores this session's pointer is resolved against: the project store of
91
+ # the working directory (projects.yml, longest path match), then the global one.
92
+ def candidate_stores(home, cwd)
93
+ plastic_home = File.join(home, ".plastic")
94
+ stores = []
95
+ slug = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
96
+ stores << File.join(plastic_home, "projects", slug, "store") if slug && slug != "global"
97
+ stores << File.join(plastic_home, "store")
98
+ stores
99
+ end
67
100
 
68
101
  dir = opts[:dir]
69
102
  if dir.nil? || dir.strip.empty?
70
- if verb == "who"
71
- warn "plastic-lock: who is strictly durable-state only; pass --intent-dir <intent dir>"
103
+ if %w[who arm].include?(verb)
104
+ warn "plastic-lock: #{verb} needs --intent-dir <intent dir>"
72
105
  exit 1
73
106
  end
74
- bridge = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
75
- dir = Bridge.bridge_intent_dir(bridge)
107
+ dir = Arm.intent_dir_from_pointer(session, home: home, stores: candidate_stores(home, Dir.pwd))
76
108
  end
77
109
  if dir.nil?
78
110
  warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
@@ -85,7 +117,7 @@ if verb == "who"
85
117
  basename = File.basename(dir)
86
118
  intent_id, slug = basename.split("--", 2)
87
119
  display = ->(value) { value.nil? || value.to_s.strip.empty? || value == "unknown" ? "Unknown" : value.to_s }
88
- harness = ->(value) {
120
+ harness_name = ->(value) {
89
121
  shown = display.call(value)
90
122
  shown == "Unknown" ? shown : shown.sub(/\A./) { |char| char.upcase }
91
123
  }
@@ -99,12 +131,12 @@ if verb == "who"
99
131
 
100
132
  puts "#{intent_id} · #{slug || basename}"
101
133
  puts "State: #{view['state']}"
102
- puts "Controller: #{display.call(owner['agent'])} via #{harness.call(owner['harness'])}"
134
+ puts "Controller: #{display.call(owner['agent'])} via #{harness_name.call(owner['harness'])}"
103
135
  puts "Session: #{display.call(view['owner_session'])}"
104
136
  puts "Heartbeat: #{view['heartbeat_at'] || 'none'}"
105
137
  puts "Claims: #{claim_text.empty? ? 'none' : claim_text.join(', ')}"
106
138
  if delegate
107
- puts "Delegate: #{display.call(delegate['agent'])} via #{harness.call(delegate['harness'])}, #{display.call(delegate['status'])}"
139
+ puts "Delegate: #{display.call(delegate['agent'])} via #{harness_name.call(delegate['harness'])}, #{display.call(delegate['status'])}"
108
140
  else
109
141
  puts "Delegate: none"
110
142
  end
@@ -113,31 +145,59 @@ end
113
145
 
114
146
  intent_id = Bridge.intent_id_from_dir(dir)
115
147
  store = File.dirname(dir)
116
- name = File.basename(dir)
117
- key = Bridge.resolve_session(session, intent_id: intent_id, store: store)
148
+ key = Arm.resolve_session(session, store: store, intent_id: intent_id)
149
+
150
+ def doctor_hint(verb_text, harness)
151
+ "run #{Lock.skill_ref('plastic-doctor', harness: harness)} #{verb_text}"
152
+ end
118
153
 
119
154
  case verb
155
+ when "arm"
156
+ result = Arm.arm(intent_dir: dir, session: key, mode: opts[:mode] || "auto", home: home,
157
+ harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread])
158
+ case result[:status]
159
+ when :acquired, :owned
160
+ wt = result[:worktree] || {}
161
+ puts JSON.pretty_generate(
162
+ "intent_dir" => dir, "session" => result[:session], "status" => result[:status].to_s,
163
+ "run_mode" => result[:lock] && result[:lock]["run_mode"],
164
+ "worktree" => wt, "pointer" => result[:pointer]
165
+ )
166
+ when :held
167
+ warn "plastic-lock: delivery lock for intent #{intent_id} is held by session " \
168
+ "#{result[:lock] && result[:lock]['owner_session']}; #{doctor_hint('check the lock status', hint_harness)}"
169
+ exit 1
170
+ when :stale
171
+ warn "plastic-lock: delivery lock for intent #{intent_id} is stale (owner " \
172
+ "#{result[:lock] && result[:lock]['owner_session']}); #{doctor_hint('reclaim the lock to take it over with an audit', hint_harness)}"
173
+ exit 1
174
+ when :excluded
175
+ warn "plastic-lock: a #{result[:lock] && result[:lock]['type']} lock is active on intent " \
176
+ "#{intent_id}; #{doctor_hint('check the lock status', hint_harness)}"
177
+ exit 1
178
+ when :corrupt
179
+ warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; #{doctor_hint('fix the lock', hint_harness)}"
180
+ exit 1
181
+ end
120
182
  when "status"
121
183
  lock = Lock.read(dir)
122
- bridge = Bridge.read(key, intent_id: intent_id)
184
+ pointer = Arm.read_pointer(key, home: home)
123
185
  report = {
124
186
  "intent_dir" => dir,
125
187
  "session" => key,
126
188
  "lock" => lock,
127
189
  "lock_fresh" => Lock.fresh?(dir),
128
190
  "lock_corrupt" => Lock.corrupt?(dir),
129
- "bridge_present" => !bridge.nil?,
130
- "agreement" => (lock && bridge) ?
131
- (lock["owner_session"] == bridge.dig("lock", "owner_session")) : nil,
191
+ "worktree" => Arm.worktree_block(intent_dir: dir, home: home),
192
+ "pointer" => pointer,
193
+ "pointer_names_intent" => pointer == intent_id,
132
194
  "claims" => Claim.claims_status(dir),
133
195
  }
134
196
  puts JSON.pretty_generate(report)
135
197
  when "fix"
136
- report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
137
- store: store, name: name, harness: harness,
138
- agent: opts[:agent], model: opts[:model], thread: opts[:thread],
139
- run_mode: opts[:mode],
140
- hint_harness: hint_harness)
198
+ report = Arm.repair(intent_dir: dir, session: key, home: home, harness: harness,
199
+ agent: opts[:agent], model: opts[:model], thread: opts[:thread],
200
+ run_mode: opts[:mode], hint_harness: hint_harness)
141
201
  puts JSON.pretty_generate(report)
142
202
  unless report["status"] == "repaired"
143
203
  warn "plastic-lock: #{report['status']} by #{report['owner']}" \
@@ -150,12 +210,7 @@ when "release"
150
210
  warn "plastic-lock: not the owner; run plastic-lock status"
151
211
  exit 1
152
212
  end
153
- data = Bridge.read(key, intent_id: intent_id)
154
- if data
155
- data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
156
- "host" => nil, "type" => nil, "delegates" => [] }
157
- Bridge.write(key, data)
158
- end
213
+ Arm.reset_pointer(key, intent_id, home: home)
159
214
  puts "released (#{result})"
160
215
  when "reclaim"
161
216
  status, lock_data = Lock.takeover(dir, session: key, harness: harness,
@@ -166,11 +221,9 @@ when "reclaim"
166
221
  "back off (no silent reclaim)"
167
222
  exit 1
168
223
  end
169
- report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
170
- store: store, name: name, harness: harness,
171
- agent: opts[:agent], model: opts[:model], thread: opts[:thread],
172
- run_mode: opts[:mode],
173
- hint_harness: hint_harness)
224
+ report = Arm.repair(intent_dir: dir, session: key, home: home, harness: harness,
225
+ agent: opts[:agent], model: opts[:model], thread: opts[:thread],
226
+ run_mode: opts[:mode], hint_harness: hint_harness)
174
227
  puts JSON.pretty_generate(report)
175
228
  when "delegate"
176
229
  usage! if opts[:delegate].nil?
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # promote-session-item (intent 301): turn one session day-ledger line into a
6
+ # registered intent through scripts/new-intent (never hand-authored), record
7
+ # the day of origin in the new intent, and mark the line promoted.
8
+ #
9
+ # Usage:
10
+ # promote-session-item --day <YYYYMMDD> --match <substring> [--session <id>]
11
+ # [--store <dir>] [--project <slug>] [--templates <dir>]
12
+ # [--plastic-home <dir>] [--no-reindex]
13
+ #
14
+ # Target: the newest open line, else the newest pending line, whose summary
15
+ # contains --match, tagged with --session when given, any session otherwise.
16
+ # The intent lands in the project's store when --project names a registered
17
+ # project, else in --store ($PLASTIC_HOME/store by default). No graph edge to
18
+ # the day id is written (the day ledger is not a store node); the origin is
19
+ # the `session_day:` frontmatter key plus a line under ## Context.
20
+ #
21
+ # Exit codes: 0 promoted (prints the intent dir last), 1 new-intent failed,
22
+ # 2 usage error or nothing to promote.
23
+
24
+ require "open3"
25
+ require "yaml"
26
+ require_relative "lib/session_ledger"
27
+ require_relative "lib/session_backfill"
28
+ require_relative "lib/store_provisioning"
29
+
30
+ def usage_abort(message)
31
+ warn "promote-session-item: #{message}"
32
+ exit 2
33
+ end
34
+
35
+ def next_value!(argv, i, flag)
36
+ value = argv[i + 1]
37
+ usage_abort("#{flag} needs a value") if value.nil? || value.start_with?("--")
38
+ value
39
+ end
40
+
41
+ opts = { day: nil, match: nil, session: nil, store: nil, project: nil, templates: nil,
42
+ plastic_home: nil, reindex: true }
43
+ i = 0
44
+ while i < ARGV.length
45
+ case ARGV[i]
46
+ when "--day" then opts[:day] = next_value!(ARGV, i, "--day"); i += 1
47
+ when "--match" then opts[:match] = next_value!(ARGV, i, "--match"); i += 1
48
+ when "--session" then opts[:session] = next_value!(ARGV, i, "--session"); i += 1
49
+ when "--store" then opts[:store] = next_value!(ARGV, i, "--store"); i += 1
50
+ when "--project" then opts[:project] = next_value!(ARGV, i, "--project"); i += 1
51
+ when "--templates" then opts[:templates] = next_value!(ARGV, i, "--templates"); i += 1
52
+ when "--plastic-home" then opts[:plastic_home] = next_value!(ARGV, i, "--plastic-home"); i += 1
53
+ when "--no-reindex" then opts[:reindex] = false
54
+ else usage_abort("unknown argument #{ARGV[i].inspect}")
55
+ end
56
+ i += 1
57
+ end
58
+
59
+ usage_abort("--day is required") if opts[:day].nil?
60
+ usage_abort("--day must be a real calendar date, YYYYMMDD") unless SessionLedger.valid_day_id?(opts[:day])
61
+ usage_abort("--match is required") if opts[:match].nil? || opts[:match].empty?
62
+
63
+ plastic_home = File.expand_path(opts[:plastic_home] || ENV.fetch("PLASTIC_HOME", "~/.plastic"))
64
+ store = opts[:store] || File.join(plastic_home, "store")
65
+ templates = opts[:templates] || File.expand_path("../templates", __dir__)
66
+ session = opts[:session] ? SessionLedger.short_session_id(opts[:session]) : nil
67
+
68
+ target_store = store
69
+ if opts[:project]
70
+ projects = StoreProvisioning.load_projects(plastic_home)
71
+ usage_abort("--project #{opts[:project]} is not a registered project") unless projects.key?(opts[:project])
72
+ target_store = File.join(plastic_home, "projects", opts[:project], "store")
73
+ end
74
+
75
+ # --- pick the target line ---------------------------------------------------
76
+
77
+ day_dir = SessionLedger.day_dir(store, opts[:day])
78
+ checklist = SessionLedger.checklist_path(store, opts[:day])
79
+ items = SessionBackfill.read_items(day_dir)
80
+ candidates = items.select do |item|
81
+ (session.nil? || item[:session] == session) && item[:summary].include?(opts[:match])
82
+ end
83
+ target = candidates.reverse.find { |item| item[:state] == :open } ||
84
+ candidates.reverse.find { |item| item[:state] == :pending }
85
+ usage_abort("nothing to promote in #{opts[:day]} matching #{opts[:match].inspect}") if target.nil?
86
+
87
+ # --- scaffold through new-intent ---------------------------------------------
88
+
89
+ def slug_for(summary)
90
+ words = summary.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "").split("-").reject(&:empty?)
91
+ slug = words.first(5).join("-")
92
+ slug.empty? ? "item" : slug
93
+ end
94
+
95
+ new_intent = File.expand_path("new-intent", __dir__)
96
+ out, err, status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, new_intent,
97
+ "--store", target_store, "--intent", target[:summary],
98
+ "--slug", slug_for(target[:summary]), "--templates", templates)
99
+ unless status.success?
100
+ warn "promote-session-item: new-intent failed:"
101
+ warn err
102
+ warn out
103
+ exit 1
104
+ end
105
+ intent_dir = out.lines.last.to_s.strip
106
+ usage_abort("new-intent printed no intent dir") if intent_dir.empty? || !Dir.exist?(intent_dir)
107
+ intent_id = File.basename(intent_dir).split("--", 2).first
108
+
109
+ # --- record the origin in the new intent (one atomic rewrite) -----------------
110
+
111
+ intent_file = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
112
+ content = File.read(intent_file)
113
+ parts = content.split(/^(---\s*\n)/, 3)
114
+ if parts.length >= 4
115
+ parts[2] += "session_day: \"#{opts[:day]}\"\n"
116
+ content = parts[0] + parts[1] + parts[2] + parts[3] + parts[4].to_s
117
+ end
118
+ origin = "Promoted from the session ledger of #{opts[:day]}: #{target[:summary]}\n"
119
+ content = content.sub(/^## Context\n/) { |heading| heading + origin }
120
+ SessionBackfill.write_atomic(intent_dir, File.basename(intent_file), content)
121
+
122
+ # --- register the intent under ## Future in the store's INDEX.md ----------------
123
+
124
+ def register_in_index(index_path, intent_id, intent_dir, summary)
125
+ return unless File.exist?(index_path)
126
+
127
+ relative = "store/#{File.basename(intent_dir)}/#{File.basename(intent_dir)}.md"
128
+ entry = "- [#{intent_id} - #{summary}](#{relative}) - promoted from a session ledger\n"
129
+ content = File.read(index_path)
130
+ return if content.include?("(#{relative})")
131
+
132
+ updated = content.sub(/^## Future\n(?:\(no future intents\)\n)?/) { |heading| heading.sub(/\(no future intents\)\n/, "") + entry }
133
+ SessionBackfill.write_atomic(File.dirname(index_path), File.basename(index_path), updated)
134
+ end
135
+
136
+ register_in_index(File.join(File.dirname(target_store), "INDEX.md"), intent_id, intent_dir, target[:summary])
137
+
138
+ # --- mark the line, note it, reindex ---------------------------------------------
139
+
140
+ SessionLedger.set_state(checklist, from: target[:state], to: :promoted, session: target[:session],
141
+ match: target[:summary])
142
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, opts[:day]),
143
+ SessionLedger.savepoint_line("Note", target[:session], target[:project],
144
+ "promoted \"#{target[:summary]}\" to intent #{intent_id}",
145
+ now: Time.now))
146
+ if opts[:reindex]
147
+ qmd_sync = File.expand_path("qmd-sync", __dir__)
148
+ system({ "RUBYOPT" => nil }, RbConfig.ruby, qmd_sync, "reindex", out: File::NULL, err: File::NULL) if File.exist?(qmd_sync)
149
+ end
150
+ puts intent_dir
@@ -82,7 +82,7 @@ class RebuildGraph
82
82
  end
83
83
 
84
84
  # Registered projects.yml slugs with no store directory on disk (legal;
85
- # plastic-store-provisioning exists for exactly this state). Reported in the audit
85
+ # the plastic-doctor provisioning section exists for exactly this state). Reported in the audit
86
86
  # (intent 189 D5) so this never looks like a silent empty store.
87
87
  def missing_stores
88
88
  discovery[:missing]
@@ -4,10 +4,10 @@
4
4
 
5
5
  # roadmap-next - thin CLI over RoadmapQueue (intent 148).
6
6
  #
7
- # The auto loop and plastic-roadmap-continuing both call this instead of ranking roadmap
7
+ # The auto loop and plastic-intent-continuing both call this instead of ranking roadmap
8
8
  # liveness in prose. Queue mode (default) is for the auto loop: it breaks a tie
9
9
  # deterministically and reports the winner's frontier state. Which mode (--which) is for
10
- # plastic-roadmap-continuing: it returns tie_candidates so the skill's single ask can resolve
10
+ # plastic-intent-continuing: it returns tie_candidates so the skill's roadmap route can resolve
11
11
  # a genuine tie.
12
12
  #
13
13
  # Usage:
@@ -82,8 +82,6 @@ class Rollback < InstallerCore
82
82
  File.exist?(path) ? File.read(path).strip : nil
83
83
  end
84
84
 
85
- private
86
-
87
85
  def flag_value(argv, name)
88
86
  i = argv.index(name)
89
87
  return nil unless i && argv[i + 1]
@@ -93,11 +91,52 @@ class Rollback < InstallerCore
93
91
  def switch_to(target, current)
94
92
  action = action_for(target, current)
95
93
  puts "#{action == "downgrade" ? "\u{23ea}" : "\u{23e9}"} #{current} \u{2192} #{target} (#{action})"
96
- cmd = ["npx", "#{PKG}@#{target}", "install", "--reinstall", "--ledger-action", action, "--claude"]
94
+ prepare_switch(target, current)
95
+ cmd = ["npx", "#{PKG}@#{target}", "install", "--reinstall", "--ledger-action", action, *harness_flags]
97
96
  puts " $ #{cmd.join(" ")}"
98
97
  system(*cmd) ? 0 : 1
99
98
  end
100
99
 
100
+ # Before handing off to an older package (a downgrade), strip Plastic's own current hook
101
+ # registrations from every installed harness (intent 310, cut-inventory R2). The older
102
+ # installer purges only the names its own registry knows, so an entry this version
103
+ # registered would otherwise survive while the older manifest diff deletes its launcher,
104
+ # and the older doctor would then report an unowned plastic- command. The older installer
105
+ # registers its own set fresh on the cleaned file. Returns the paths it stripped.
106
+ def prepare_switch(target, current)
107
+ return [] unless action_for(target, current) == "downgrade"
108
+
109
+ stripped = []
110
+ installed_agents.each do |key|
111
+ agent = agents.find { |a| a[:key] == key }
112
+ next unless agent
113
+
114
+ case key
115
+ when "claude"
116
+ path = File.join(agent[:dir], "settings.json")
117
+ if File.exist?(path)
118
+ remove_claude_hooks(path)
119
+ stripped << path
120
+ end
121
+ when "codex"
122
+ path = agent[:home_dir] && File.join(agent[:home_dir], "hooks.json")
123
+ if path && File.exist?(path)
124
+ remove_codex_hooks(path)
125
+ stripped << path
126
+ end
127
+ end
128
+ end
129
+ stripped
130
+ end
131
+
132
+ # The install flags for every harness with a per-agent record (intent 310); Claude alone
133
+ # when nothing is recorded, matching the first-install default.
134
+ def harness_flags
135
+ keys = installed_agents
136
+ keys = ["claude"] if keys.empty?
137
+ keys.map { |k| "--#{k}" }
138
+ end
139
+
101
140
  def print_table(ledger, current)
102
141
  puts "\nPlastic version history (versions.json — append-only):\n\n"
103
142
  puts " %-22s %-10s %s" % ["version", "action", "at"]
@@ -2,46 +2,38 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # scaffold-intent (intent 213) - one CLI, three subcommands, each writes one lifecycle
6
- # artifact by copying or mechanically deriving it from an already-committed source. Every
7
- # field it writes is a verbatim copy or a mechanical derivation (a `git diff --stat`, a
8
- # supplied file's contents) of an already-committed artifact; no subcommand interprets or
9
- # invents prose. A field it cannot derive mechanically is left as the template's own stub
10
- # text instead of being guessed at.
5
+ # scaffold-intent (intent 213; one verb since intent 308) - write an intent's judgment
6
+ # documents from its record. `backfill` fills spec.md, plan.md, actions/ACTION_1.md, and
7
+ # outcome.md wherever the file is missing or still the scaffold placeholder, from the
8
+ # intent file, checklist.md, and the diff (BackfillIntent). Every section it writes is a
9
+ # verbatim copy or a mechanical rendering of an already-committed artifact; a section with
10
+ # no source keeps the template's own stub text. A hand-written file is never touched, and
11
+ # there is no force flag: real content is edited by hand or not at all.
11
12
  #
12
- # Usage:
13
- # scaffold-intent spec|checklist|outcome --store <path> --id <intent_id> [--force]
14
- # [--test-summary <path>] (outcome subcommand only)
13
+ # `scripts/end-intent` runs the same writer at every close, so this CLI exists for the
14
+ # doctor fix hint (backfilled_complete) and for filling an intent by hand before its close.
15
15
  #
16
- # Subcommand is the first positional argument. --store and --id are required for every
17
- # subcommand. --test-summary is valid only on the outcome subcommand; on spec or
18
- # checklist, or naming a path that does not exist, it is a usage error (exit 1). No other
19
- # flags exist: --store, --id, --force, and --test-summary (outcome only) are the whole
20
- # shipped surface (D5, a one-way door).
16
+ # Usage:
17
+ # scaffold-intent backfill --store <path> --id <intent_id> --disposition delivered|abandoned
18
+ # [--summary <text>]
21
19
  #
22
20
  # Exit codes:
23
- # 0 scaffolded
21
+ # 0 written, or nothing left to write
24
22
  # 1 usage or path-resolution failure
25
- # 2 refused to overwrite real content without --force
26
- # 3 a prerequisite committed artifact is missing or still sentinel
27
- #
28
- # The actions/ rule (intent 133a, D11): scaffold-intent does not scaffold actions/ in any
29
- # form. Actions require judgment and stay entirely with the planner. No subcommand
30
- # creates the directory, writes a .gitkeep, or writes a sentinel-only ACTION_*.md.
23
+ # 3 a source is unusable (the intent file is missing)
31
24
  #
32
- # outcome is an end-of-Exec step. Run it once the diff and the test summary exist, right
33
- # before plastic-intent-ending writes the narrative. Writing outcome.md makes the intent
34
- # read as Done to any stage-derived display (Bridge.derive_stage keys on outcome.md's
35
- # presence). This does NOT purge a bridge:
36
- # Bridge.purge_done_bridges keys on INDEX Active status plus lock presence, never on the
37
- # derived stage.
25
+ # The spec, checklist, and outcome subcommands, --force, and --test-summary were removed
26
+ # in 2.0 (intent 308): the backfill writer covers their two derivable targets and the
27
+ # outcome diffstat, and nothing else called them.
38
28
 
39
29
  require_relative "lib/scaffold_intent"
30
+ require_relative "lib/backfill_intent"
40
31
 
41
- SUBCOMMANDS = %w[spec checklist outcome].freeze
32
+ SUBCOMMANDS = %w[backfill].freeze
33
+ DISPOSITIONS = %w[delivered abandoned].freeze
42
34
 
43
35
  def parse_args(argv)
44
- opts = { subcommand: nil, store: nil, id: nil, force: false, test_summary: nil }
36
+ opts = { subcommand: nil, store: nil, id: nil, disposition: nil, summary: nil }
45
37
  args = argv.dup
46
38
  opts[:subcommand] = args.shift
47
39
 
@@ -49,10 +41,10 @@ def parse_args(argv)
49
41
  while i < args.length
50
42
  arg = args[i]
51
43
  case arg
52
- when "--store" then opts[:store] = args[i += 1]
53
- when "--id" then opts[:id] = args[i += 1]
54
- when "--force" then opts[:force] = true
55
- when "--test-summary" then opts[:test_summary] = args[i += 1]
44
+ when "--store" then opts[:store] = args[i += 1]
45
+ when "--id" then opts[:id] = args[i += 1]
46
+ when "--disposition" then opts[:disposition] = args[i += 1]
47
+ when "--summary" then opts[:summary] = args[i += 1]
56
48
  else
57
49
  usage_abort("unknown argument #{arg.inspect}")
58
50
  end
@@ -62,8 +54,8 @@ def parse_args(argv)
62
54
  end
63
55
 
64
56
  def usage
65
- "usage: scaffold-intent spec|checklist|outcome --store <store_path> --id <intent_id> " \
66
- "[--force] [--test-summary <path>]"
57
+ "usage: scaffold-intent backfill --store <store_path> --id <intent_id> " \
58
+ "--disposition delivered|abandoned [--summary <text>]"
67
59
  end
68
60
 
69
61
  def usage_abort(message)
@@ -75,11 +67,11 @@ end
75
67
  def main(argv)
76
68
  opts = parse_args(argv)
77
69
 
78
- usage_abort("subcommand must be one of #{SUBCOMMANDS.join('|')}") unless SUBCOMMANDS.include?(opts[:subcommand])
70
+ usage_abort("subcommand must be #{SUBCOMMANDS.join('|')}") unless SUBCOMMANDS.include?(opts[:subcommand])
79
71
  usage_abort("--store is required") if opts[:store].nil? || opts[:store].empty?
80
72
  usage_abort("--id is required") if opts[:id].nil? || opts[:id].empty?
81
- if opts[:test_summary] && opts[:subcommand] != "outcome"
82
- usage_abort("--test-summary is valid only on the outcome subcommand")
73
+ unless DISPOSITIONS.include?(opts[:disposition])
74
+ usage_abort("--disposition must be one of #{DISPOSITIONS.join('|')}")
83
75
  end
84
76
 
85
77
  store = ScaffoldIntent.expand(opts[:store])
@@ -88,33 +80,15 @@ def main(argv)
88
80
  intent_dir, err = ScaffoldIntent.resolve_intent_dir(store, opts[:id])
89
81
  usage_abort(err) if intent_dir.nil?
90
82
 
91
- test_summary_path = nil
92
- if opts[:test_summary]
93
- test_summary_path = ScaffoldIntent.expand(opts[:test_summary])
94
- usage_abort("--test-summary path does not exist: #{test_summary_path}") unless File.exist?(test_summary_path)
95
- end
83
+ result = BackfillIntent.run(intent_dir: intent_dir, store: store, id: opts[:id],
84
+ disposition: opts[:disposition], summary: opts[:summary])
96
85
 
97
- result =
98
- case opts[:subcommand]
99
- when "spec"
100
- ScaffoldIntent.scaffold_spec(intent_dir: intent_dir, force: opts[:force])
101
- when "checklist"
102
- ScaffoldIntent.scaffold_checklist(intent_dir: intent_dir, force: opts[:force])
103
- when "outcome"
104
- ScaffoldIntent.scaffold_outcome(intent_dir: intent_dir, force: opts[:force], store: store,
105
- id: opts[:id], test_summary: test_summary_path)
106
- end
107
-
108
- if result[:message]
109
- if result[:code] == 0
110
- puts result[:message]
111
- else
112
- warn "scaffold-intent: #{result[:message]}"
113
- end
114
- end
115
- puts result[:path] if result[:path]
86
+ result[:written].each { |rel| puts "scaffold-intent: backfilled #{File.join(intent_dir, rel)}" }
87
+ result[:skipped].each { |rel| puts "scaffold-intent: kept #{rel} (real content)" }
88
+ result[:notes].each { |note| warn "scaffold-intent: #{note}" }
89
+ puts "scaffold-intent: nothing to backfill" if result[:written].empty? && result[:notes].empty?
116
90
 
117
- exit result[:code]
91
+ exit(result[:notes].any? { |n| n.include?("intent file is missing") } ? 3 : 0)
118
92
  end
119
93
 
120
94
  main(ARGV) if $PROGRAM_NAME == __FILE__