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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/PLASTIC.md +170 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +45 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +47 -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} +8 -6
  8. package/agents/plastic-planner.md +47 -0
  9. package/agents/plastic-spec-specialist.md +45 -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/hooks/statusline +150 -41
  22. package/package.json +2 -2
  23. package/scripts/agent-report +142 -0
  24. package/scripts/dashboard.rb +687 -0
  25. package/scripts/doctor.rb +1219 -621
  26. package/scripts/hook-auto-arm +51 -0
  27. package/scripts/hook-bash-gate +41 -0
  28. package/scripts/hook-code-gate +27 -0
  29. package/scripts/hook-continue +15 -114
  30. package/scripts/hook-create-gate +59 -0
  31. package/scripts/hook-gate-check +47 -32
  32. package/scripts/hook-qmd-search +44 -0
  33. package/scripts/hook-session-start +106 -38
  34. package/scripts/install.rb +91 -529
  35. package/scripts/lib/boot_banner.rb +28 -0
  36. package/scripts/lib/bridge.rb +452 -19
  37. package/scripts/lib/frontmatter_writer.rb +130 -0
  38. package/scripts/lib/graph_rebuild.rb +328 -0
  39. package/scripts/lib/installer_core.rb +812 -0
  40. package/scripts/lib/intent_validator.rb +235 -0
  41. package/scripts/lib/links_projection.rb +160 -0
  42. package/scripts/lib/links_section.rb +207 -0
  43. package/scripts/lib/power_tools.rb +76 -0
  44. package/scripts/lib/qmd_hook.rb +57 -0
  45. package/scripts/lib/qmd_sync.rb +230 -0
  46. package/scripts/lib/store_provisioning.rb +100 -0
  47. package/scripts/migrate-to-global +1 -1
  48. package/scripts/new-intent +327 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/provision-project-store +53 -0
  51. package/scripts/qmd-sync +139 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/select-update-target +93 -0
  54. package/scripts/spawn-preamble +138 -0
  55. package/scripts/uninstall.rb +53 -0
  56. package/scripts/update.rb +164 -0
  57. package/scripts/validate-intent +54 -0
  58. package/scripts/versions.rb +141 -0
  59. package/skills/_active-intent-gate.md +1 -1
  60. package/skills/add-project-store/SKILL.md +54 -0
  61. package/skills/auto/SKILL.md +87 -7
  62. package/skills/auto/evals/evals.json +255 -0
  63. package/skills/auto/references/agent-architecture.md +155 -0
  64. package/skills/auto/references/agent-report-contract.md +86 -0
  65. package/skills/brainstorming/SKILL.md +10 -9
  66. package/skills/brainstorming/evals/evals.json +22 -0
  67. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  68. package/skills/continuing/SKILL.md +99 -82
  69. package/skills/continuing/evals/evals.json +145 -0
  70. package/skills/continuing/references/context-management.md +32 -0
  71. package/skills/creating-intent/SKILL.md +82 -36
  72. package/skills/creating-intent/evals/evals.json +72 -0
  73. package/skills/creating-intent/references/lifecycle.md +81 -0
  74. package/skills/creating-intent/references/wikilinks.md +8 -0
  75. package/skills/creating-project/SKILL.md +40 -8
  76. package/skills/creating-project/references/hubs-projects.md +55 -0
  77. package/skills/dashboard/SKILL.md +126 -0
  78. package/skills/dashboard/evals/evals.json +22 -0
  79. package/skills/dashboard/templates/dashboard-global.md +31 -0
  80. package/skills/dashboard/templates/dashboard-project.md +40 -0
  81. package/skills/doctor/SKILL.md +51 -4
  82. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  83. package/skills/doctor/report.md +4 -0
  84. package/skills/evaluating-skills/SKILL.md +140 -0
  85. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  86. package/skills/evaluating-skills/evals/evals.json +75 -0
  87. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  88. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  89. package/skills/executing-plan/SKILL.md +5 -3
  90. package/skills/install/SKILL.md +69 -8
  91. package/skills/intent-curator/SKILL.md +6 -4
  92. package/skills/intent-curator/evals/evals.json +22 -0
  93. package/skills/linking-intents/SKILL.md +22 -7
  94. package/skills/linking-intents/evals/evals.json +22 -0
  95. package/skills/linking-intents/references/zettelkasten.md +45 -0
  96. package/skills/managing-index/SKILL.md +11 -1
  97. package/skills/managing-index/evals/evals.json +22 -0
  98. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  99. package/skills/releasing/SKILL.md +80 -23
  100. package/skills/releasing/references/deprecations.md +60 -0
  101. package/skills/research/SKILL.md +10 -2
  102. package/skills/research/evals/evals.json +22 -0
  103. package/skills/savepoint/SKILL.md +46 -37
  104. package/skills/savepoint/references/context-management.md +32 -0
  105. package/skills/uninstall/SKILL.md +39 -28
  106. package/skills/update/SKILL.md +41 -44
  107. package/skills/versions/SKILL.md +65 -0
  108. package/skills/writing-instructions/SKILL.md +159 -0
  109. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  110. package/skills/writing-plans/SKILL.md +5 -5
  111. package/templates/agents.md +7 -7
  112. package/templates/outcome.md +13 -0
  113. package/templates/savepoint.md +14 -13
  114. package/templates/spec.md +25 -0
  115. 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,247 @@ 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
+ # Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
22
+ # weight ONLY once its intent is terminal (no longer in its store's INDEX.md
23
+ # `## Active` block); such bridges are purged. An Active intent's bridge is kept
24
+ # unconditionally, because while the intent is live the bridge is still load-
25
+ # bearing: it is the continuation signal (a parked or interrupted run resumes
26
+ # from it) and the anti-collision lock (it keys the per-session statusline so
27
+ # parallel sessions do not overwrite each other). An age window was the wrong
28
+ # axis: it left dead bridges resident for ~2 days AND could reap bridges of
29
+ # interrupted-but-still-active intents, which are exactly the ones to preserve.
30
+
12
31
  def self.intent_file(intent_dir)
13
32
  dir_name = File.basename(intent_dir)
14
33
  "#{intent_dir}/#{dir_name}.md"
15
34
  end
16
35
 
17
- def self.path(session)
18
- "/tmp/plastic-#{session}.json"
36
+ # Single source for the OS temp location holding bridge files. Lets every
37
+ # process (arm_auto, the gate hooks) agree, and lets tests fully isolate by
38
+ # pointing PLASTIC_TMP at a Dir.mktmpdir. This is OS-temp-location resolution,
39
+ # not a logic-config injection seam.
40
+ def self.tmp_dir
41
+ t = ENV["PLASTIC_TMP"]
42
+ (t.nil? || t.strip.empty?) ? "/tmp" : t
43
+ end
44
+
45
+ def self.path(session, tmp: tmp_dir)
46
+ "#{tmp}/plastic-#{session}.json"
47
+ end
48
+
49
+ # --- Session resolution (intent 52) ----------------------------------------
50
+
51
+ def self.blank?(value)
52
+ value.nil? || value.to_s.strip.empty?
53
+ end
54
+
55
+ # Deterministic, session-id-less bridge key derived from store + intent id.
56
+ # Stable across processes so a session-less arm and a later session-less
57
+ # gate-check resolve to the same bridge file.
58
+ def self.derive_key(store, intent_id)
59
+ "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
60
+ end
61
+
62
+ # Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
63
+ # CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
64
+ # Whitespace-only counts as empty.
65
+ #
66
+ # The CLAUDE_CODE_SESSION_ID fallback (intent 79) is additive: it only changes
67
+ # behavior when CLAUDE_SESSION_ID is blank but CLAUDE_CODE_SESSION_ID is set —
68
+ # the bg/headless case where the real session id lives in CLAUDE_CODE_SESSION_ID.
69
+ # Keying by the real id (instead of a derived hash) lets the statusline, which
70
+ # receives that same id on stdin, find the bridge by direct filename lookup.
71
+ def self.resolve_session(explicit, intent_id:, store:)
72
+ return explicit.to_s.strip unless blank?(explicit)
73
+ env = ENV["CLAUDE_SESSION_ID"]
74
+ return env.to_s.strip unless blank?(env)
75
+ code_env = ENV["CLAUDE_CODE_SESSION_ID"]
76
+ return code_env.to_s.strip unless blank?(code_env)
77
+ derive_key(store, intent_id)
78
+ end
79
+
80
+ # Walk up from file_path; return the first ancestor that looks like an intent
81
+ # directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
82
+ # target without needing a bridge. The input is always a file inside the intent
83
+ # dir (never the dir itself), so the walk-up starts at its parent.
84
+ def self.intent_dir_for(file_path)
85
+ dir = File.expand_path(file_path)
86
+ loop do
87
+ parent = File.dirname(dir)
88
+ break if parent == dir # reached filesystem root
89
+ dir = parent
90
+ return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
91
+ end
92
+ nil
93
+ end
94
+
95
+ # A bridge hash is usable iff it has a non-empty session and an intent Hash.
96
+ def self.bridge_valid?(data)
97
+ data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
98
+ end
99
+
100
+ # Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
101
+ # for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
102
+ # the one whose intent.store matches cwd, tie-break by newest mtime.
103
+ def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
104
+ if !blank?(session) && File.exist?(path(session, tmp: tmp))
105
+ exact = read(session, tmp: tmp)
106
+ return exact if bridge_valid?(exact)
107
+ end
108
+
109
+ candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
110
+ parsed = candidates.filter_map do |f|
111
+ data = (JSON.parse(File.read(f)) rescue nil)
112
+ next unless data && bridge_valid?(data)
113
+ { file: f, data: data, mtime: File.mtime(f) }
114
+ end
115
+ return nil if parsed.empty?
116
+
117
+ auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
118
+ pool = auto.empty? ? parsed : auto
119
+
120
+ unless blank?(cwd)
121
+ cwd_abs = File.expand_path(cwd)
122
+ matching = pool.select do |c|
123
+ store = c[:data].dig("intent", "store").to_s
124
+ next false if store.empty?
125
+ store_abs = File.expand_path(store)
126
+ cwd_abs == store_abs ||
127
+ cwd_abs.start_with?("#{store_abs}/") ||
128
+ store_abs.start_with?("#{cwd_abs}/")
129
+ end
130
+ pool = matching unless matching.empty?
131
+ end
132
+
133
+ pool.max_by { |c| c[:mtime] }&.fetch(:data)
134
+ end
135
+
136
+ # --- Terminal-state bridge purge (intent 80) -------------------------------
137
+
138
+ # True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
139
+ # the PARENT of the store/ dir the bridge records, so we resolve it from the
140
+ # bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
141
+ # bad arg) returns false, which means "not active" so the caller treats the
142
+ # bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
143
+ # Array of id strings is supplied, membership is checked against it directly
144
+ # with no file read.
145
+ def self.intent_active?(intent_id, store:, index_active_ids: nil)
146
+ target = intent_id.to_s
147
+ return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
148
+
149
+ index = File.join(File.dirname(store.to_s), "INDEX.md")
150
+ return false unless File.exist?(index)
151
+
152
+ in_active = false
153
+ File.foreach(index) do |line|
154
+ stripped = line.chomp
155
+ if stripped == "## Active"
156
+ in_active = true
157
+ next
158
+ end
159
+ next unless in_active
160
+ break if stripped.start_with?("## ") # next section ends the Active block
161
+ m = stripped.match(/^- \[(\S+) +—/)
162
+ return true if m && m[1] == target
163
+ end
164
+ false
165
+ rescue StandardError
166
+ false
19
167
  end
20
168
 
21
- def self.read(session)
22
- p = path(session)
169
+ # Remove tmp/plastic-*.json bridge files whose intent is terminal, so
170
+ # discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
171
+ # returns the array of removed paths. A bridge is purged when it cannot be
172
+ # parsed, has no intent.id, has no intent.store, or its intent is not Active in
173
+ # its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
174
+ # anti-collision lock), and the current session's own bridge is never purged
175
+ # (preserves the disarm_auto contract that it stays readable). Wired into
176
+ # arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
177
+ # clean at deterministic work boundaries.
178
+ def self.purge_done_bridges(session:, tmp: tmp_dir)
179
+ current = path(session, tmp: tmp)
180
+ removed = []
181
+ Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
182
+ next if f == current
183
+ begin
184
+ data = JSON.parse(File.read(f)) rescue nil
185
+ keep = false
186
+ if data
187
+ id = data.dig("intent", "id")
188
+ store = data.dig("intent", "store")
189
+ keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
190
+ end
191
+ next if keep
192
+ File.delete(f)
193
+ removed << f
194
+ rescue Errno::ENOENT
195
+ # Raced with another job that already removed it; count as purged.
196
+ removed << f
197
+ rescue => e
198
+ $stderr.puts "plastic: purge skipped #{f}: #{e.message}"
199
+ end
200
+ end
201
+ removed
202
+ rescue => e
203
+ $stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
204
+ removed || []
205
+ end
206
+
207
+ def self.read(session, tmp: tmp_dir)
208
+ p = path(session, tmp: tmp)
23
209
  return nil unless File.exist?(p)
24
210
  JSON.parse(File.read(p))
25
211
  rescue JSON::ParserError
26
212
  nil
27
213
  end
28
214
 
29
- def self.write(session, data)
30
- p = path(session)
215
+ def self.write(session, data, tmp: tmp_dir)
216
+ raise ArgumentError, "bridge session must be present" if blank?(session)
217
+ p = path(session, tmp: tmp)
31
218
  # 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)
219
+ tmp_file = "#{p}.tmp.#{Process.pid}"
220
+ File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
221
+ File.rename(tmp_file, p)
35
222
  rescue => e
36
- File.delete(tmp) if tmp && File.exist?(tmp)
223
+ File.delete(tmp_file) if tmp_file && File.exist?(tmp_file)
37
224
  raise e
38
225
  end
39
226
 
227
+ # True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
228
+ # not the placeholder sentinel. Reads only the file head (never the whole file)
229
+ # so the dashboard stays fast across many intents. Exact first-line match only,
230
+ # so a real file that merely contains an HTML comment later is unaffected, and a
231
+ # partially-edited sentinel reads as real rather than sticking as a placeholder.
232
+ def self.stage_file_present?(path)
233
+ return false unless File.exist?(path)
234
+ first = File.open(path, &:gets)
235
+ return true if first.nil? # empty file: present, not a sentinel
236
+ first.chomp != PLACEHOLDER_SENTINEL
237
+ rescue StandardError
238
+ File.exist?(path)
239
+ end
240
+
40
241
  def self.derive_stage(intent_dir)
41
- return "done" if File.exist?("#{intent_dir}/outcome.md")
42
- if File.exist?("#{intent_dir}/plan.md") &&
242
+ return "done" if stage_file_present?("#{intent_dir}/outcome.md")
243
+ if stage_file_present?("#{intent_dir}/plan.md") &&
43
244
  File.directory?("#{intent_dir}/actions") &&
44
- File.exist?("#{intent_dir}/checklist.md")
245
+ stage_file_present?("#{intent_dir}/checklist.md")
45
246
  return "exec"
46
247
  end
47
- return "how" if File.exist?("#{intent_dir}/spec.md")
248
+ return "how" if stage_file_present?("#{intent_dir}/spec.md")
48
249
  return "why" if File.exist?(intent_file(intent_dir))
49
250
  "what"
50
251
  end
@@ -52,8 +253,9 @@ module Bridge
52
253
  def self.has_files(intent_dir)
53
254
  files = []
54
255
  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}")
256
+ files << ifile if File.exist?("#{intent_dir}/#{ifile}")
257
+ ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
258
+ files << f if stage_file_present?("#{intent_dir}/#{f}")
57
259
  end
58
260
  files << "actions/" if File.directory?("#{intent_dir}/actions")
59
261
  files
@@ -70,6 +272,72 @@ module Bridge
70
272
  end
71
273
  end
72
274
 
275
+ # --- Cycle-step savepoint ledger (intent 34) ------------------------------
276
+ #
277
+ # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
278
+ # (newest at the bottom). It is sugar on top of the conventions: derived from
279
+ # files-on-disk, rebuildable, never a source of truth. Milestones are
280
+ # file-event boundaries only; action/resource files record nothing.
281
+
282
+ SAVEPOINT_FILE = "savepoint.md"
283
+
284
+ # Map a written filename to [stage_label, milestone_text], or nil if the file
285
+ # is not a lifecycle milestone.
286
+ def self.savepoint_milestone(intent_dir, basename)
287
+ return ["What", basename] if basename == File.basename(intent_file(intent_dir))
288
+
289
+ case basename
290
+ when "spec.md" then ["Why", "spec.md created"]
291
+ when "plan.md" then ["How", "plan.md created"]
292
+ when "checklist.md" then ["How", "checklist.md created"]
293
+ when "outcome.md" then ["Exec", "outcome.md created"]
294
+ end
295
+ end
296
+
297
+ # Milestones already recorded in the ledger (field 3 of each line).
298
+ def self.savepoint_recorded_milestones(intent_dir)
299
+ f = File.join(intent_dir, SAVEPOINT_FILE)
300
+ return [] unless File.exist?(f)
301
+ File.read(f).each_line.map do |line|
302
+ parts = line.strip.split(/\s{2,}/)
303
+ parts.length >= 3 ? parts[2] : nil
304
+ end.compact
305
+ end
306
+
307
+ # Append a milestone line for file_path if (and only if) it is a milestone
308
+ # not already recorded. Returns true when a line was written, false otherwise.
309
+ def self.append_savepoint(intent_dir, file_path, now: Time.now)
310
+ basename = File.basename(file_path)
311
+ stage, milestone = savepoint_milestone(intent_dir, basename)
312
+ return false unless milestone
313
+ # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
314
+ # yet). The intent file is never sentineled, so it still logs its What line.
315
+ return false unless stage_file_present?(File.join(intent_dir, basename))
316
+ return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
317
+
318
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
319
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
320
+ true
321
+ end
322
+
323
+ # Reconstruct the ledger from files on disk (timestamps from mtimes), in
324
+ # stage order, overwriting savepoint.md. Returns the number of lines written.
325
+ def self.rebuild_savepoint(intent_dir)
326
+ ordered = [
327
+ File.basename(intent_file(intent_dir)),
328
+ "spec.md", "plan.md", "checklist.md", "outcome.md",
329
+ ]
330
+ lines = ordered.filter_map do |basename|
331
+ path = File.join(intent_dir, basename)
332
+ next unless stage_file_present?(path)
333
+ stage, milestone = savepoint_milestone(intent_dir, basename)
334
+ next unless milestone
335
+ "#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
336
+ end
337
+ File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
338
+ lines.length
339
+ end
340
+
73
341
  def self.derive(session, intent_id:, intent_dir:, store:, name:)
74
342
  stage = derive_stage(intent_dir)
75
343
  has = has_files(intent_dir)
@@ -88,6 +356,7 @@ module Bridge
88
356
  "has" => has,
89
357
  "missing" => missing,
90
358
  "gate_failures" => 0,
359
+ "auto" => false,
91
360
  "last_activity" => Time.now.utc.iso8601
92
361
  },
93
362
  "observe" => {
@@ -117,16 +386,16 @@ module Bridge
117
386
  return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
118
387
  end
119
388
  when "plan.md"
120
- unless File.exist?("#{intent_dir}/spec.md")
389
+ unless stage_file_present?("#{intent_dir}/spec.md")
121
390
  return "Cannot start How — Why is incomplete (spec.md missing)"
122
391
  end
123
392
  when "checklist.md"
124
- unless File.exist?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
393
+ unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
125
394
  return "Cannot complete How — plan.md or actions/ missing"
126
395
  end
127
396
  when "outcome.md"
128
397
  checklist = "#{intent_dir}/checklist.md"
129
- if File.exist?(checklist)
398
+ if stage_file_present?(checklist)
130
399
  content = File.read(checklist)
131
400
  unchecked = content.scan(/^- \[ \]/).length
132
401
  if unchecked > 0
@@ -159,6 +428,170 @@ module Bridge
159
428
  PROJECT_CONFIG_DEFAULTS.dup
160
429
  end
161
430
 
431
+ # --- Auto mode (intent 27) ---
432
+
433
+ # Arm auto mode for a session+intent. Works even when no bridge exists yet
434
+ # (mid-session intent creation). Re-derives intent state, then sets build.auto.
435
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
436
+ key = resolve_session(session, intent_id: intent_id, store: store)
437
+ if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"]) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
438
+ $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
439
+ end
440
+ data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
441
+ data["build"]["auto"] = true
442
+ write(key, data)
443
+ purge_done_bridges(session: key)
444
+ data
445
+ end
446
+
447
+ # Disarm auto mode. No-op if no bridge exists for the session.
448
+ def self.disarm_auto(session)
449
+ data = read(session)
450
+ return nil unless data
451
+ data["build"] ||= {}
452
+ data["build"]["auto"] = false
453
+ write(session, data)
454
+ purge_done_bridges(session: session)
455
+ data
456
+ end
457
+
458
+ # Decide whether a code edit should be blocked while auto mode is armed.
459
+ # Returns a reason string to BLOCK, or nil to ALLOW.
460
+ #
461
+ # Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
462
+ # target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
463
+ def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
464
+ return nil unless bridge_data.is_a?(Hash)
465
+ build = bridge_data["build"] || {}
466
+ return nil unless build["auto"] == true
467
+
468
+ intent_info = bridge_data["intent"] || {}
469
+ store = intent_info["store"]
470
+ dir = intent_info["dir"]
471
+ return nil unless store && dir
472
+ intent_dir_abs = File.expand_path("#{store}/#{dir}")
473
+
474
+ # "How reached" = the plan triplet exists. Gate by artifact presence, not the
475
+ # stage label (derive_stage returns "how" as soon as spec.md exists, before any
476
+ # plan). Code edits stay blocked until plan.md + checklist.md are both present.
477
+ reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
478
+ stage_file_present?("#{intent_dir_abs}/checklist.md")
479
+ return nil if reached_how
480
+
481
+ file_abs = File.expand_path(file_path.to_s)
482
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
483
+ return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
484
+ return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
485
+
486
+ id = intent_info["id"]
487
+ "intent #{id} has not reached How — write plan.md + checklist.md before " \
488
+ "editing project code. Run plastic-auto or plastic-writing-plans first. " \
489
+ "(blocked edit: #{file_abs})"
490
+ end
491
+
492
+ # --- Bash-edit gate (intent 27a) ---
493
+
494
+ # Extract the set of file paths a Bash command writes to. Conservative by
495
+ # design: it is acceptable to miss exotic forms, but it must NOT flag reads
496
+ # or /dev/null. Returns an Array of path strings (possibly relative).
497
+ #
498
+ # Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
499
+ # tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
500
+ def self.bash_write_targets(command)
501
+ return [] unless command.is_a?(String)
502
+
503
+ targets = []
504
+ targets.concat(bash_redirect_targets(command))
505
+ # Split on command separators for per-segment utility parsing.
506
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
507
+ targets.concat(bash_utility_targets(segment))
508
+ end
509
+ targets.uniq
510
+ end
511
+
512
+ # Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
513
+ # A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
514
+ def self.bash_redirect_targets(command)
515
+ targets = []
516
+ # Match optional leading fd digits, then > or >>, not followed by & , then path.
517
+ command.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
518
+ path = m[0]
519
+ next if path.nil? || path.empty?
520
+ next if dev_null?(path)
521
+ targets << path
522
+ end
523
+ targets
524
+ end
525
+
526
+ def self.bash_utility_targets(segment)
527
+ tokens = segment.strip.split(/\s+/)
528
+ return [] if tokens.empty?
529
+
530
+ # Find the utility name, skipping env-style assignments.
531
+ idx = 0
532
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
533
+ util = File.basename(tokens[idx].to_s)
534
+ args = tokens[(idx + 1)..] || []
535
+
536
+ case util
537
+ when "tee"
538
+ tee_targets(args)
539
+ when "sed"
540
+ sed_targets(args)
541
+ when "cp", "mv"
542
+ copy_move_targets(args)
543
+ when "dd"
544
+ dd_targets(tokens)
545
+ else
546
+ []
547
+ end
548
+ end
549
+
550
+ def self.tee_targets(args)
551
+ args.reject { |a| a.start_with?("-") || dev_null?(a) }
552
+ end
553
+
554
+ def self.sed_targets(args)
555
+ # In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
556
+ inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
557
+ return [] unless inplace
558
+ files = args.reject { |a| a.start_with?("-") }
559
+ # sed args: script then file(s). First non-flag is the script expression
560
+ # unless an -e/-f was used; conservatively treat the LAST non-flag as file.
561
+ files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
562
+ end
563
+
564
+ def self.copy_move_targets(args)
565
+ files = args.reject { |a| a.start_with?("-") }
566
+ return [] if files.length < 2
567
+ dest = files.last
568
+ dev_null?(dest) ? [] : [dest]
569
+ end
570
+
571
+ def self.dd_targets(tokens)
572
+ tokens.each_with_object([]) do |t, acc|
573
+ next unless t.start_with?("of=")
574
+ path = t.sub("of=", "")
575
+ acc << path unless path.empty? || dev_null?(path)
576
+ end
577
+ end
578
+
579
+ def self.dev_null?(path)
580
+ path == "/dev/null" || path.start_with?("/dev/")
581
+ end
582
+
583
+ # Decide whether a Bash command should be blocked under the auto-mode code
584
+ # gate. Resolves each write target against cwd and applies the SAME policy as
585
+ # code_gate_decision. Returns the first block reason, or nil to allow.
586
+ def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
587
+ bash_write_targets(command).each do |target|
588
+ abs = File.absolute_path?(target) ? target : File.join(cwd, target)
589
+ reason = code_gate_decision(bridge_data, abs, home: home)
590
+ return reason if reason
591
+ end
592
+ nil
593
+ end
594
+
162
595
  def self.deep_merge(base, overlay)
163
596
  result = base.dup
164
597
  overlay.each do |key, value|