@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/PLASTIC.md +163 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +37 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +37 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
  8. package/agents/plastic-planner.md +37 -0
  9. package/agents/plastic-spec-specialist.md +37 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/package.json +2 -2
  22. package/scripts/dashboard.rb +687 -0
  23. package/scripts/doctor.rb +1054 -628
  24. package/scripts/hook-auto-arm +51 -0
  25. package/scripts/hook-bash-gate +41 -0
  26. package/scripts/hook-code-gate +27 -0
  27. package/scripts/hook-continue +15 -114
  28. package/scripts/hook-create-gate +59 -0
  29. package/scripts/hook-gate-check +47 -32
  30. package/scripts/hook-qmd-search +44 -0
  31. package/scripts/hook-session-start +106 -38
  32. package/scripts/install.rb +91 -529
  33. package/scripts/lib/boot_banner.rb +28 -0
  34. package/scripts/lib/bridge.rb +404 -19
  35. package/scripts/lib/installer_core.rb +807 -0
  36. package/scripts/lib/intent_validator.rb +235 -0
  37. package/scripts/lib/qmd_hook.rb +44 -0
  38. package/scripts/lib/qmd_sync.rb +209 -0
  39. package/scripts/lib/store_provisioning.rb +100 -0
  40. package/scripts/migrate-to-global +1 -1
  41. package/scripts/new-intent +226 -0
  42. package/scripts/provision-project-store +53 -0
  43. package/scripts/qmd-sync +92 -0
  44. package/scripts/select-update-target +93 -0
  45. package/scripts/spawn-preamble +121 -0
  46. package/scripts/uninstall.rb +53 -0
  47. package/scripts/update.rb +164 -0
  48. package/scripts/validate-intent +54 -0
  49. package/scripts/versions.rb +141 -0
  50. package/skills/_active-intent-gate.md +1 -1
  51. package/skills/add-project-store/SKILL.md +54 -0
  52. package/skills/auto/SKILL.md +77 -7
  53. package/skills/auto/evals/evals.json +207 -0
  54. package/skills/auto/references/agent-architecture.md +135 -0
  55. package/skills/brainstorming/SKILL.md +9 -9
  56. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  57. package/skills/continuing/SKILL.md +92 -82
  58. package/skills/continuing/evals/evals.json +136 -0
  59. package/skills/continuing/references/context-management.md +32 -0
  60. package/skills/creating-intent/SKILL.md +74 -36
  61. package/skills/creating-intent/evals/evals.json +56 -0
  62. package/skills/creating-intent/references/lifecycle.md +76 -0
  63. package/skills/creating-intent/references/wikilinks.md +8 -0
  64. package/skills/creating-project/SKILL.md +40 -8
  65. package/skills/creating-project/references/hubs-projects.md +55 -0
  66. package/skills/dashboard/SKILL.md +121 -0
  67. package/skills/dashboard/templates/dashboard-global.md +31 -0
  68. package/skills/dashboard/templates/dashboard-project.md +40 -0
  69. package/skills/doctor/SKILL.md +51 -4
  70. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  71. package/skills/doctor/report.md +4 -0
  72. package/skills/evaluating-skills/SKILL.md +140 -0
  73. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  74. package/skills/evaluating-skills/evals/evals.json +75 -0
  75. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  76. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  77. package/skills/executing-plan/SKILL.md +5 -3
  78. package/skills/install/SKILL.md +69 -8
  79. package/skills/intent-curator/SKILL.md +3 -3
  80. package/skills/linking-intents/SKILL.md +16 -7
  81. package/skills/linking-intents/references/zettelkasten.md +38 -0
  82. package/skills/managing-index/SKILL.md +5 -1
  83. package/skills/managing-index/references/zettelkasten-linking.md +1 -1
  84. package/skills/releasing/SKILL.md +80 -23
  85. package/skills/releasing/references/deprecations.md +60 -0
  86. package/skills/research/SKILL.md +2 -2
  87. package/skills/savepoint/SKILL.md +46 -37
  88. package/skills/savepoint/references/context-management.md +32 -0
  89. package/skills/uninstall/SKILL.md +39 -28
  90. package/skills/update/SKILL.md +41 -44
  91. package/skills/versions/SKILL.md +65 -0
  92. package/skills/writing-instructions/SKILL.md +159 -0
  93. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  94. package/skills/writing-plans/SKILL.md +5 -5
  95. package/templates/agents.md +7 -7
  96. package/templates/outcome.md +13 -0
  97. package/templates/savepoint.md +14 -13
  98. package/templates/spec.md +25 -0
  99. package/bin/install.js +0 -29
@@ -0,0 +1,28 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Renders the SessionStart boot banner from a core-health result (intent 36a).
5
+ #
6
+ # Pure and dependency-injected: it takes the health hash (the return value of
7
+ # Doctor#run_core_checks) and the version string, and returns a single banner
8
+ # line. No I/O, no globals, no ENV — so it is unit-testable in isolation while
9
+ # the hook feeds it real data.
10
+ module BootBanner
11
+ module_function
12
+
13
+ # health: the Hash returned by Doctor#run_core_checks, or nil if the check
14
+ # itself raised (degraded to an error banner).
15
+ # version: the installed Plastic version string, or nil.
16
+ #
17
+ # Returns one of two binary lines:
18
+ # "Plastic Core loaded — v{VER} | doctor --core run: success"
19
+ # "Plastic Core loaded — v{VER} | doctor --core run: error — run /plastic-doctor"
20
+ def render(health:, version:)
21
+ ver = version || "unknown"
22
+ if !health.nil? && health[:status] == "pass"
23
+ "Plastic Core loaded — v#{ver} | doctor --core run: success"
24
+ else
25
+ "Plastic Core loaded — v#{ver} | doctor --core run: error — run /plastic-doctor"
26
+ end
27
+ end
28
+ end
@@ -5,46 +5,199 @@ require "json"
5
5
  require "yaml"
6
6
  require "fileutils"
7
7
  require "tempfile"
8
+ require "digest"
8
9
 
9
10
  module Bridge
10
11
  STAGES = %w[what why how exec done].freeze
11
12
 
13
+ # Placeholder sentinel (intent 60b). A scaffolded lifecycle file
14
+ # (spec.md/plan.md/checklist.md/outcome.md) carries this exact string as its
15
+ # first line until an agent fills the file and deletes the sentinel. The
16
+ # sentinel is the "stage not reached yet" marker, so stage detection treats a
17
+ # sentinel-marked file as absent (see stage_file_present?). The intent file
18
+ # (<id>--<slug>.md) is never sentineled; it is born complete.
19
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
20
+
21
+ # Stale-bridge purge window (intent 67). The bridge file is ephemeral
22
+ # live-session gate state, NOT a continuation source: an intent is resumed from
23
+ # its savepoint.md ledger, never from a /tmp bridge. So any bridge older than
24
+ # this window is dead weight and safe to purge, regardless of arm state. No
25
+ # real session stays live for two days, so a 48h cutoff never removes a bridge
26
+ # an active run depends on.
27
+ PURGE_AGE_SECONDS = 48 * 3600 # 48 hours
28
+
12
29
  def self.intent_file(intent_dir)
13
30
  dir_name = File.basename(intent_dir)
14
31
  "#{intent_dir}/#{dir_name}.md"
15
32
  end
16
33
 
17
- def self.path(session)
18
- "/tmp/plastic-#{session}.json"
34
+ # Single source for the OS temp location holding bridge files. Lets every
35
+ # process (arm_auto, the gate hooks) agree, and lets tests fully isolate by
36
+ # pointing PLASTIC_TMP at a Dir.mktmpdir. This is OS-temp-location resolution,
37
+ # not a logic-config injection seam.
38
+ def self.tmp_dir
39
+ t = ENV["PLASTIC_TMP"]
40
+ (t.nil? || t.strip.empty?) ? "/tmp" : t
41
+ end
42
+
43
+ def self.path(session, tmp: tmp_dir)
44
+ "#{tmp}/plastic-#{session}.json"
45
+ end
46
+
47
+ # --- Session resolution (intent 52) ----------------------------------------
48
+
49
+ def self.blank?(value)
50
+ value.nil? || value.to_s.strip.empty?
51
+ end
52
+
53
+ # Deterministic, session-id-less bridge key derived from store + intent id.
54
+ # Stable across processes so a session-less arm and a later session-less
55
+ # gate-check resolve to the same bridge file.
56
+ def self.derive_key(store, intent_id)
57
+ "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
58
+ end
59
+
60
+ # Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
61
+ # then a derived key. Never returns nil/empty. Whitespace-only counts as empty.
62
+ def self.resolve_session(explicit, intent_id:, store:)
63
+ return explicit.to_s.strip unless blank?(explicit)
64
+ env = ENV["CLAUDE_SESSION_ID"]
65
+ return env.to_s.strip unless blank?(env)
66
+ derive_key(store, intent_id)
67
+ end
68
+
69
+ # Walk up from file_path; return the first ancestor that looks like an intent
70
+ # directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
71
+ # target without needing a bridge. The input is always a file inside the intent
72
+ # dir (never the dir itself), so the walk-up starts at its parent.
73
+ def self.intent_dir_for(file_path)
74
+ dir = File.expand_path(file_path)
75
+ loop do
76
+ parent = File.dirname(dir)
77
+ break if parent == dir # reached filesystem root
78
+ dir = parent
79
+ return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
80
+ end
81
+ nil
82
+ end
83
+
84
+ # A bridge hash is usable iff it has a non-empty session and an intent Hash.
85
+ def self.bridge_valid?(data)
86
+ data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
87
+ end
88
+
89
+ # Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
90
+ # for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
91
+ # the one whose intent.store matches cwd, tie-break by newest mtime.
92
+ def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
93
+ if !blank?(session) && File.exist?(path(session, tmp: tmp))
94
+ exact = read(session, tmp: tmp)
95
+ return exact if bridge_valid?(exact)
96
+ end
97
+
98
+ candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
99
+ parsed = candidates.filter_map do |f|
100
+ data = (JSON.parse(File.read(f)) rescue nil)
101
+ next unless data && bridge_valid?(data)
102
+ { file: f, data: data, mtime: File.mtime(f) }
103
+ end
104
+ return nil if parsed.empty?
105
+
106
+ auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
107
+ pool = auto.empty? ? parsed : auto
108
+
109
+ unless blank?(cwd)
110
+ cwd_abs = File.expand_path(cwd)
111
+ matching = pool.select do |c|
112
+ store = c[:data].dig("intent", "store").to_s
113
+ next false if store.empty?
114
+ store_abs = File.expand_path(store)
115
+ cwd_abs == store_abs ||
116
+ cwd_abs.start_with?("#{store_abs}/") ||
117
+ store_abs.start_with?("#{cwd_abs}/")
118
+ end
119
+ pool = matching unless matching.empty?
120
+ end
121
+
122
+ pool.max_by { |c| c[:mtime] }&.fetch(:data)
123
+ end
124
+
125
+ # --- Stale-bridge purge (intent 67) ---------------------------------------
126
+ #
127
+ # Remove stale tmp/plastic-*.json bridge files so discover_bridge's per-fire
128
+ # scan stays bounded. Best-effort and non-raising: returns the array of removed
129
+ # paths. Continuation does not depend on these files (an intent resumes from its
130
+ # savepoint.md ledger), so the only safety rule is age: a bridge older than
131
+ # max_age_seconds is purged regardless of arm state, while anything newer is kept
132
+ # (it may be a live run). The current session's own bridge is never purged
133
+ # (preserves the disarm_auto contract that it stays readable). Wired into
134
+ # arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
135
+ # clean.
136
+ def self.purge_stale_bridges(session:, now: Time.now, max_age_seconds: PURGE_AGE_SECONDS,
137
+ tmp: tmp_dir)
138
+ current = path(session, tmp: tmp)
139
+ removed = []
140
+ Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
141
+ next if f == current
142
+ begin
143
+ next if (now - File.mtime(f)) < max_age_seconds
144
+ File.delete(f)
145
+ removed << f
146
+ rescue Errno::ENOENT
147
+ # Raced with another job that already removed it; count as purged.
148
+ removed << f
149
+ rescue => e
150
+ $stderr.puts "plastic: purge skipped #{f}: #{e.message}"
151
+ end
152
+ end
153
+ removed
154
+ rescue => e
155
+ $stderr.puts "plastic: purge_stale_bridges failed: #{e.message}"
156
+ removed || []
19
157
  end
20
158
 
21
- def self.read(session)
22
- p = path(session)
159
+ def self.read(session, tmp: tmp_dir)
160
+ p = path(session, tmp: tmp)
23
161
  return nil unless File.exist?(p)
24
162
  JSON.parse(File.read(p))
25
163
  rescue JSON::ParserError
26
164
  nil
27
165
  end
28
166
 
29
- def self.write(session, data)
30
- p = path(session)
167
+ def self.write(session, data, tmp: tmp_dir)
168
+ raise ArgumentError, "bridge session must be present" if blank?(session)
169
+ p = path(session, tmp: tmp)
31
170
  # Atomic write: tmp file + rename to prevent partial reads
32
- tmp = "#{p}.tmp.#{Process.pid}"
33
- File.write(tmp, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
34
- File.rename(tmp, p)
171
+ tmp_file = "#{p}.tmp.#{Process.pid}"
172
+ File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
173
+ File.rename(tmp_file, p)
35
174
  rescue => e
36
- File.delete(tmp) if tmp && File.exist?(tmp)
175
+ File.delete(tmp_file) if tmp_file && File.exist?(tmp_file)
37
176
  raise e
38
177
  end
39
178
 
179
+ # True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
180
+ # not the placeholder sentinel. Reads only the file head (never the whole file)
181
+ # so the dashboard stays fast across many intents. Exact first-line match only,
182
+ # so a real file that merely contains an HTML comment later is unaffected, and a
183
+ # partially-edited sentinel reads as real rather than sticking as a placeholder.
184
+ def self.stage_file_present?(path)
185
+ return false unless File.exist?(path)
186
+ first = File.open(path, &:gets)
187
+ return true if first.nil? # empty file: present, not a sentinel
188
+ first.chomp != PLACEHOLDER_SENTINEL
189
+ rescue StandardError
190
+ File.exist?(path)
191
+ end
192
+
40
193
  def self.derive_stage(intent_dir)
41
- return "done" if File.exist?("#{intent_dir}/outcome.md")
42
- if File.exist?("#{intent_dir}/plan.md") &&
194
+ return "done" if stage_file_present?("#{intent_dir}/outcome.md")
195
+ if stage_file_present?("#{intent_dir}/plan.md") &&
43
196
  File.directory?("#{intent_dir}/actions") &&
44
- File.exist?("#{intent_dir}/checklist.md")
197
+ stage_file_present?("#{intent_dir}/checklist.md")
45
198
  return "exec"
46
199
  end
47
- return "how" if File.exist?("#{intent_dir}/spec.md")
200
+ return "how" if stage_file_present?("#{intent_dir}/spec.md")
48
201
  return "why" if File.exist?(intent_file(intent_dir))
49
202
  "what"
50
203
  end
@@ -52,8 +205,9 @@ module Bridge
52
205
  def self.has_files(intent_dir)
53
206
  files = []
54
207
  ifile = File.basename(intent_file(intent_dir))
55
- [ifile, "spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
56
- files << f if File.exist?("#{intent_dir}/#{f}")
208
+ files << ifile if File.exist?("#{intent_dir}/#{ifile}")
209
+ ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
210
+ files << f if stage_file_present?("#{intent_dir}/#{f}")
57
211
  end
58
212
  files << "actions/" if File.directory?("#{intent_dir}/actions")
59
213
  files
@@ -70,6 +224,72 @@ module Bridge
70
224
  end
71
225
  end
72
226
 
227
+ # --- Cycle-step savepoint ledger (intent 34) ------------------------------
228
+ #
229
+ # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
230
+ # (newest at the bottom). It is sugar on top of the conventions: derived from
231
+ # files-on-disk, rebuildable, never a source of truth. Milestones are
232
+ # file-event boundaries only; action/resource files record nothing.
233
+
234
+ SAVEPOINT_FILE = "savepoint.md"
235
+
236
+ # Map a written filename to [stage_label, milestone_text], or nil if the file
237
+ # is not a lifecycle milestone.
238
+ def self.savepoint_milestone(intent_dir, basename)
239
+ return ["What", basename] if basename == File.basename(intent_file(intent_dir))
240
+
241
+ case basename
242
+ when "spec.md" then ["Why", "spec.md created"]
243
+ when "plan.md" then ["How", "plan.md created"]
244
+ when "checklist.md" then ["How", "checklist.md created"]
245
+ when "outcome.md" then ["Exec", "outcome.md created"]
246
+ end
247
+ end
248
+
249
+ # Milestones already recorded in the ledger (field 3 of each line).
250
+ def self.savepoint_recorded_milestones(intent_dir)
251
+ f = File.join(intent_dir, SAVEPOINT_FILE)
252
+ return [] unless File.exist?(f)
253
+ File.read(f).each_line.map do |line|
254
+ parts = line.strip.split(/\s{2,}/)
255
+ parts.length >= 3 ? parts[2] : nil
256
+ end.compact
257
+ end
258
+
259
+ # Append a milestone line for file_path if (and only if) it is a milestone
260
+ # not already recorded. Returns true when a line was written, false otherwise.
261
+ def self.append_savepoint(intent_dir, file_path, now: Time.now)
262
+ basename = File.basename(file_path)
263
+ stage, milestone = savepoint_milestone(intent_dir, basename)
264
+ return false unless milestone
265
+ # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
266
+ # yet). The intent file is never sentineled, so it still logs its What line.
267
+ return false unless stage_file_present?(File.join(intent_dir, basename))
268
+ return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
269
+
270
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
271
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
272
+ true
273
+ end
274
+
275
+ # Reconstruct the ledger from files on disk (timestamps from mtimes), in
276
+ # stage order, overwriting savepoint.md. Returns the number of lines written.
277
+ def self.rebuild_savepoint(intent_dir)
278
+ ordered = [
279
+ File.basename(intent_file(intent_dir)),
280
+ "spec.md", "plan.md", "checklist.md", "outcome.md",
281
+ ]
282
+ lines = ordered.filter_map do |basename|
283
+ path = File.join(intent_dir, basename)
284
+ next unless stage_file_present?(path)
285
+ stage, milestone = savepoint_milestone(intent_dir, basename)
286
+ next unless milestone
287
+ "#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
288
+ end
289
+ File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
290
+ lines.length
291
+ end
292
+
73
293
  def self.derive(session, intent_id:, intent_dir:, store:, name:)
74
294
  stage = derive_stage(intent_dir)
75
295
  has = has_files(intent_dir)
@@ -88,6 +308,7 @@ module Bridge
88
308
  "has" => has,
89
309
  "missing" => missing,
90
310
  "gate_failures" => 0,
311
+ "auto" => false,
91
312
  "last_activity" => Time.now.utc.iso8601
92
313
  },
93
314
  "observe" => {
@@ -117,16 +338,16 @@ module Bridge
117
338
  return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
118
339
  end
119
340
  when "plan.md"
120
- unless File.exist?("#{intent_dir}/spec.md")
341
+ unless stage_file_present?("#{intent_dir}/spec.md")
121
342
  return "Cannot start How — Why is incomplete (spec.md missing)"
122
343
  end
123
344
  when "checklist.md"
124
- unless File.exist?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
345
+ unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
125
346
  return "Cannot complete How — plan.md or actions/ missing"
126
347
  end
127
348
  when "outcome.md"
128
349
  checklist = "#{intent_dir}/checklist.md"
129
- if File.exist?(checklist)
350
+ if stage_file_present?(checklist)
130
351
  content = File.read(checklist)
131
352
  unchecked = content.scan(/^- \[ \]/).length
132
353
  if unchecked > 0
@@ -159,6 +380,170 @@ module Bridge
159
380
  PROJECT_CONFIG_DEFAULTS.dup
160
381
  end
161
382
 
383
+ # --- Auto mode (intent 27) ---
384
+
385
+ # Arm auto mode for a session+intent. Works even when no bridge exists yet
386
+ # (mid-session intent creation). Re-derives intent state, then sets build.auto.
387
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
388
+ key = resolve_session(session, intent_id: intent_id, store: store)
389
+ if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
390
+ $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
391
+ end
392
+ data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
393
+ data["build"]["auto"] = true
394
+ write(key, data)
395
+ purge_stale_bridges(session: key)
396
+ data
397
+ end
398
+
399
+ # Disarm auto mode. No-op if no bridge exists for the session.
400
+ def self.disarm_auto(session)
401
+ data = read(session)
402
+ return nil unless data
403
+ data["build"] ||= {}
404
+ data["build"]["auto"] = false
405
+ write(session, data)
406
+ purge_stale_bridges(session: session)
407
+ data
408
+ end
409
+
410
+ # Decide whether a code edit should be blocked while auto mode is armed.
411
+ # Returns a reason string to BLOCK, or nil to ALLOW.
412
+ #
413
+ # Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
414
+ # target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
415
+ def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
416
+ return nil unless bridge_data.is_a?(Hash)
417
+ build = bridge_data["build"] || {}
418
+ return nil unless build["auto"] == true
419
+
420
+ intent_info = bridge_data["intent"] || {}
421
+ store = intent_info["store"]
422
+ dir = intent_info["dir"]
423
+ return nil unless store && dir
424
+ intent_dir_abs = File.expand_path("#{store}/#{dir}")
425
+
426
+ # "How reached" = the plan triplet exists. Gate by artifact presence, not the
427
+ # stage label (derive_stage returns "how" as soon as spec.md exists, before any
428
+ # plan). Code edits stay blocked until plan.md + checklist.md are both present.
429
+ reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
430
+ stage_file_present?("#{intent_dir_abs}/checklist.md")
431
+ return nil if reached_how
432
+
433
+ file_abs = File.expand_path(file_path.to_s)
434
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
435
+ return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
436
+ return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
437
+
438
+ id = intent_info["id"]
439
+ "intent #{id} has not reached How — write plan.md + checklist.md before " \
440
+ "editing project code. Run plastic-auto or plastic-writing-plans first. " \
441
+ "(blocked edit: #{file_abs})"
442
+ end
443
+
444
+ # --- Bash-edit gate (intent 27a) ---
445
+
446
+ # Extract the set of file paths a Bash command writes to. Conservative by
447
+ # design: it is acceptable to miss exotic forms, but it must NOT flag reads
448
+ # or /dev/null. Returns an Array of path strings (possibly relative).
449
+ #
450
+ # Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
451
+ # tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
452
+ def self.bash_write_targets(command)
453
+ return [] unless command.is_a?(String)
454
+
455
+ targets = []
456
+ targets.concat(bash_redirect_targets(command))
457
+ # Split on command separators for per-segment utility parsing.
458
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
459
+ targets.concat(bash_utility_targets(segment))
460
+ end
461
+ targets.uniq
462
+ end
463
+
464
+ # Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
465
+ # A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
466
+ def self.bash_redirect_targets(command)
467
+ targets = []
468
+ # Match optional leading fd digits, then > or >>, not followed by & , then path.
469
+ command.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
470
+ path = m[0]
471
+ next if path.nil? || path.empty?
472
+ next if dev_null?(path)
473
+ targets << path
474
+ end
475
+ targets
476
+ end
477
+
478
+ def self.bash_utility_targets(segment)
479
+ tokens = segment.strip.split(/\s+/)
480
+ return [] if tokens.empty?
481
+
482
+ # Find the utility name, skipping env-style assignments.
483
+ idx = 0
484
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
485
+ util = File.basename(tokens[idx].to_s)
486
+ args = tokens[(idx + 1)..] || []
487
+
488
+ case util
489
+ when "tee"
490
+ tee_targets(args)
491
+ when "sed"
492
+ sed_targets(args)
493
+ when "cp", "mv"
494
+ copy_move_targets(args)
495
+ when "dd"
496
+ dd_targets(tokens)
497
+ else
498
+ []
499
+ end
500
+ end
501
+
502
+ def self.tee_targets(args)
503
+ args.reject { |a| a.start_with?("-") || dev_null?(a) }
504
+ end
505
+
506
+ def self.sed_targets(args)
507
+ # In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
508
+ inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
509
+ return [] unless inplace
510
+ files = args.reject { |a| a.start_with?("-") }
511
+ # sed args: script then file(s). First non-flag is the script expression
512
+ # unless an -e/-f was used; conservatively treat the LAST non-flag as file.
513
+ files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
514
+ end
515
+
516
+ def self.copy_move_targets(args)
517
+ files = args.reject { |a| a.start_with?("-") }
518
+ return [] if files.length < 2
519
+ dest = files.last
520
+ dev_null?(dest) ? [] : [dest]
521
+ end
522
+
523
+ def self.dd_targets(tokens)
524
+ tokens.each_with_object([]) do |t, acc|
525
+ next unless t.start_with?("of=")
526
+ path = t.sub("of=", "")
527
+ acc << path unless path.empty? || dev_null?(path)
528
+ end
529
+ end
530
+
531
+ def self.dev_null?(path)
532
+ path == "/dev/null" || path.start_with?("/dev/")
533
+ end
534
+
535
+ # Decide whether a Bash command should be blocked under the auto-mode code
536
+ # gate. Resolves each write target against cwd and applies the SAME policy as
537
+ # code_gate_decision. Returns the first block reason, or nil to allow.
538
+ def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
539
+ bash_write_targets(command).each do |target|
540
+ abs = File.absolute_path?(target) ? target : File.join(cwd, target)
541
+ reason = code_gate_decision(bridge_data, abs, home: home)
542
+ return reason if reason
543
+ end
544
+ nil
545
+ end
546
+
162
547
  def self.deep_merge(base, overlay)
163
548
  result = base.dup
164
549
  overlay.each do |key, value|