@zalom/plastic 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (76) hide show
  1. package/PLASTIC.md +128 -473
  2. package/README.md +90 -58
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/bin/plastic.js +57 -0
  6. package/bin/test +28 -0
  7. package/deprecations.yml +7 -6
  8. package/hooks/auto-arm +5 -0
  9. package/hooks/bash-gate +3 -0
  10. package/hooks/check-update +12 -8
  11. package/hooks/code-gate +10 -0
  12. package/hooks/hooks.json +25 -4
  13. package/hooks/statusline +50 -10
  14. package/package.json +2 -2
  15. package/scripts/dashboard.rb +480 -0
  16. package/scripts/doctor.rb +973 -0
  17. package/scripts/hook-auto-arm +52 -0
  18. package/scripts/hook-bash-gate +53 -0
  19. package/scripts/hook-code-gate +39 -0
  20. package/scripts/hook-continue +15 -114
  21. package/scripts/hook-gate-check +19 -4
  22. package/scripts/hook-session-start +76 -31
  23. package/scripts/install.rb +91 -480
  24. package/scripts/lib/bridge.rb +255 -0
  25. package/scripts/lib/installer_core.rb +760 -0
  26. package/scripts/migrate-to-global +1 -1
  27. package/scripts/select-update-target +93 -0
  28. package/scripts/uninstall.rb +53 -0
  29. package/scripts/update.rb +142 -0
  30. package/scripts/versions.rb +141 -0
  31. package/skills/_active-intent-gate.md +26 -0
  32. package/skills/auto/SKILL.md +62 -9
  33. package/skills/auto/evals/evals.json +92 -0
  34. package/skills/auto/references/agent-architecture.md +60 -0
  35. package/skills/brainstorming/SKILL.md +143 -0
  36. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  37. package/skills/continuing/SKILL.md +102 -77
  38. package/skills/continuing/evals/evals.json +136 -0
  39. package/skills/continuing/references/context-management.md +32 -0
  40. package/skills/creating-intent/SKILL.md +16 -1
  41. package/skills/creating-intent/references/lifecycle.md +74 -0
  42. package/skills/creating-intent/references/wikilinks.md +8 -0
  43. package/skills/creating-project/SKILL.md +8 -4
  44. package/skills/creating-project/references/hubs-projects.md +55 -0
  45. package/skills/dashboard/SKILL.md +92 -0
  46. package/skills/doctor/SKILL.md +116 -0
  47. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  48. package/skills/doctor/report.md +96 -0
  49. package/skills/evaluating-skills/SKILL.md +140 -0
  50. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  51. package/skills/evaluating-skills/evals/evals.json +75 -0
  52. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  53. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  54. package/skills/executing-plan/SKILL.md +3 -3
  55. package/skills/install/SKILL.md +56 -8
  56. package/skills/intent-curator/SKILL.md +3 -3
  57. package/skills/linking-intents/SKILL.md +5 -1
  58. package/skills/linking-intents/references/zettelkasten.md +33 -0
  59. package/skills/managing-index/SKILL.md +5 -1
  60. package/skills/releasing/SKILL.md +119 -18
  61. package/skills/releasing/references/deprecations.md +44 -0
  62. package/skills/research/SKILL.md +114 -0
  63. package/skills/savepoint/SKILL.md +46 -37
  64. package/skills/savepoint/references/context-management.md +32 -0
  65. package/skills/uninstall/SKILL.md +39 -28
  66. package/skills/update/SKILL.md +41 -36
  67. package/skills/versions/SKILL.md +65 -0
  68. package/skills/writing-instructions/SKILL.md +159 -0
  69. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  70. package/skills/writing-plans/SKILL.md +183 -0
  71. package/templates/agents.md +16 -0
  72. package/templates/outcome.md +13 -0
  73. package/templates/project.yml +5 -0
  74. package/templates/savepoint.md +14 -13
  75. package/templates/spec.md +25 -0
  76. package/bin/install.js +0 -29
@@ -2,6 +2,7 @@
2
2
  # encoding: UTF-8
3
3
 
4
4
  require "json"
5
+ require "yaml"
5
6
  require "fileutils"
6
7
  require "tempfile"
7
8
 
@@ -69,6 +70,68 @@ module Bridge
69
70
  end
70
71
  end
71
72
 
73
+ # --- Cycle-step savepoint ledger (intent 34) ------------------------------
74
+ #
75
+ # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
76
+ # (newest at the bottom). It is sugar on top of the conventions: derived from
77
+ # files-on-disk, rebuildable, never a source of truth. Milestones are
78
+ # file-event boundaries only; action/resource files record nothing.
79
+
80
+ SAVEPOINT_FILE = "savepoint.md"
81
+
82
+ # Map a written filename to [stage_label, milestone_text], or nil if the file
83
+ # is not a lifecycle milestone.
84
+ def self.savepoint_milestone(intent_dir, basename)
85
+ return ["What", basename] if basename == File.basename(intent_file(intent_dir))
86
+
87
+ case basename
88
+ when "spec.md" then ["Why", "spec.md created"]
89
+ when "plan.md" then ["How", "plan.md created"]
90
+ when "checklist.md" then ["How", "checklist.md created"]
91
+ when "outcome.md" then ["Exec", "outcome.md created"]
92
+ end
93
+ end
94
+
95
+ # Milestones already recorded in the ledger (field 3 of each line).
96
+ def self.savepoint_recorded_milestones(intent_dir)
97
+ f = File.join(intent_dir, SAVEPOINT_FILE)
98
+ return [] unless File.exist?(f)
99
+ File.read(f).each_line.map do |line|
100
+ parts = line.strip.split(/\s{2,}/)
101
+ parts.length >= 3 ? parts[2] : nil
102
+ end.compact
103
+ end
104
+
105
+ # Append a milestone line for file_path if (and only if) it is a milestone
106
+ # not already recorded. Returns true when a line was written, false otherwise.
107
+ def self.append_savepoint(intent_dir, file_path, now: Time.now)
108
+ stage, milestone = savepoint_milestone(intent_dir, File.basename(file_path))
109
+ return false unless milestone
110
+ return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
111
+
112
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
113
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
114
+ true
115
+ end
116
+
117
+ # Reconstruct the ledger from files on disk (timestamps from mtimes), in
118
+ # stage order, overwriting savepoint.md. Returns the number of lines written.
119
+ def self.rebuild_savepoint(intent_dir)
120
+ ordered = [
121
+ File.basename(intent_file(intent_dir)),
122
+ "spec.md", "plan.md", "checklist.md", "outcome.md",
123
+ ]
124
+ lines = ordered.filter_map do |basename|
125
+ path = File.join(intent_dir, basename)
126
+ next unless File.exist?(path)
127
+ stage, milestone = savepoint_milestone(intent_dir, basename)
128
+ next unless milestone
129
+ "#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
130
+ end
131
+ File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
132
+ lines.length
133
+ end
134
+
72
135
  def self.derive(session, intent_id:, intent_dir:, store:, name:)
73
136
  stage = derive_stage(intent_dir)
74
137
  has = has_files(intent_dir)
@@ -87,6 +150,7 @@ module Bridge
87
150
  "has" => has,
88
151
  "missing" => missing,
89
152
  "gate_failures" => 0,
153
+ "auto" => false,
90
154
  "last_activity" => Time.now.utc.iso8601
91
155
  },
92
156
  "observe" => {
@@ -136,4 +200,195 @@ module Bridge
136
200
 
137
201
  nil # no gate violation
138
202
  end
203
+
204
+ PROJECT_CONFIG_DEFAULTS = {
205
+ "governing_docs" => ["AGENTS.md"],
206
+ "release" => {
207
+ "on_complete" => "commit",
208
+ },
209
+ }.freeze
210
+
211
+ def self.read_project_config(slug)
212
+ path = File.join(Dir.home, ".plastic", "projects", slug, "project.yml")
213
+ config = if File.exist?(path)
214
+ YAML.safe_load(File.read(path)) || {}
215
+ else
216
+ {}
217
+ end
218
+
219
+ deep_merge(PROJECT_CONFIG_DEFAULTS, config)
220
+ rescue => e
221
+ $stderr.puts "Warning: failed to read project config for #{slug}: #{e.message}"
222
+ PROJECT_CONFIG_DEFAULTS.dup
223
+ end
224
+
225
+ # --- Auto mode (intent 27) ---
226
+
227
+ # Arm auto mode for a session+intent. Works even when no bridge exists yet
228
+ # (mid-session intent creation). Re-derives intent state, then sets build.auto.
229
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
230
+ data = derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
231
+ data["build"]["auto"] = true
232
+ write(session, data)
233
+ data
234
+ end
235
+
236
+ # Disarm auto mode. No-op if no bridge exists for the session.
237
+ def self.disarm_auto(session)
238
+ data = read(session)
239
+ return nil unless data
240
+ data["build"] ||= {}
241
+ data["build"]["auto"] = false
242
+ write(session, data)
243
+ data
244
+ end
245
+
246
+ # Decide whether a code edit should be blocked while auto mode is armed.
247
+ # Returns a reason string to BLOCK, or nil to ALLOW.
248
+ #
249
+ # Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
250
+ # target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
251
+ def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
252
+ return nil unless bridge_data.is_a?(Hash)
253
+ build = bridge_data["build"] || {}
254
+ return nil unless build["auto"] == true
255
+
256
+ intent_info = bridge_data["intent"] || {}
257
+ store = intent_info["store"]
258
+ dir = intent_info["dir"]
259
+ return nil unless store && dir
260
+ intent_dir_abs = File.expand_path("#{store}/#{dir}")
261
+
262
+ # "How reached" = the plan triplet exists. Gate by artifact presence, not the
263
+ # stage label (derive_stage returns "how" as soon as spec.md exists, before any
264
+ # plan). Code edits stay blocked until plan.md + checklist.md are both present.
265
+ reached_how = File.exist?("#{intent_dir_abs}/plan.md") &&
266
+ File.exist?("#{intent_dir_abs}/checklist.md")
267
+ return nil if reached_how
268
+
269
+ file_abs = File.expand_path(file_path.to_s)
270
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
271
+ return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
272
+ return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
273
+
274
+ id = intent_info["id"]
275
+ "intent #{id} has not reached How — write plan.md + checklist.md before " \
276
+ "editing project code. Run plastic-auto or plastic-writing-plans first. " \
277
+ "(blocked edit: #{file_abs})"
278
+ end
279
+
280
+ # --- Bash-edit gate (intent 27a) ---
281
+
282
+ # Extract the set of file paths a Bash command writes to. Conservative by
283
+ # design: it is acceptable to miss exotic forms, but it must NOT flag reads
284
+ # or /dev/null. Returns an Array of path strings (possibly relative).
285
+ #
286
+ # Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
287
+ # tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
288
+ def self.bash_write_targets(command)
289
+ return [] unless command.is_a?(String)
290
+
291
+ targets = []
292
+ targets.concat(bash_redirect_targets(command))
293
+ # Split on command separators for per-segment utility parsing.
294
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
295
+ targets.concat(bash_utility_targets(segment))
296
+ end
297
+ targets.uniq
298
+ end
299
+
300
+ # Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
301
+ # A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
302
+ def self.bash_redirect_targets(command)
303
+ targets = []
304
+ # Match optional leading fd digits, then > or >>, not followed by & , then path.
305
+ command.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
306
+ path = m[0]
307
+ next if path.nil? || path.empty?
308
+ next if dev_null?(path)
309
+ targets << path
310
+ end
311
+ targets
312
+ end
313
+
314
+ def self.bash_utility_targets(segment)
315
+ tokens = segment.strip.split(/\s+/)
316
+ return [] if tokens.empty?
317
+
318
+ # Find the utility name, skipping env-style assignments.
319
+ idx = 0
320
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
321
+ util = File.basename(tokens[idx].to_s)
322
+ args = tokens[(idx + 1)..] || []
323
+
324
+ case util
325
+ when "tee"
326
+ tee_targets(args)
327
+ when "sed"
328
+ sed_targets(args)
329
+ when "cp", "mv"
330
+ copy_move_targets(args)
331
+ when "dd"
332
+ dd_targets(tokens)
333
+ else
334
+ []
335
+ end
336
+ end
337
+
338
+ def self.tee_targets(args)
339
+ args.reject { |a| a.start_with?("-") || dev_null?(a) }
340
+ end
341
+
342
+ def self.sed_targets(args)
343
+ # In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
344
+ inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
345
+ return [] unless inplace
346
+ files = args.reject { |a| a.start_with?("-") }
347
+ # sed args: script then file(s). First non-flag is the script expression
348
+ # unless an -e/-f was used; conservatively treat the LAST non-flag as file.
349
+ files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
350
+ end
351
+
352
+ def self.copy_move_targets(args)
353
+ files = args.reject { |a| a.start_with?("-") }
354
+ return [] if files.length < 2
355
+ dest = files.last
356
+ dev_null?(dest) ? [] : [dest]
357
+ end
358
+
359
+ def self.dd_targets(tokens)
360
+ tokens.each_with_object([]) do |t, acc|
361
+ next unless t.start_with?("of=")
362
+ path = t.sub("of=", "")
363
+ acc << path unless path.empty? || dev_null?(path)
364
+ end
365
+ end
366
+
367
+ def self.dev_null?(path)
368
+ path == "/dev/null" || path.start_with?("/dev/")
369
+ end
370
+
371
+ # Decide whether a Bash command should be blocked under the auto-mode code
372
+ # gate. Resolves each write target against cwd and applies the SAME policy as
373
+ # code_gate_decision. Returns the first block reason, or nil to allow.
374
+ def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
375
+ bash_write_targets(command).each do |target|
376
+ abs = File.absolute_path?(target) ? target : File.join(cwd, target)
377
+ reason = code_gate_decision(bridge_data, abs, home: home)
378
+ return reason if reason
379
+ end
380
+ nil
381
+ end
382
+
383
+ def self.deep_merge(base, overlay)
384
+ result = base.dup
385
+ overlay.each do |key, value|
386
+ if value.is_a?(Hash) && result[key].is_a?(Hash)
387
+ result[key] = deep_merge(result[key], value)
388
+ else
389
+ result[key] = value
390
+ end
391
+ end
392
+ result
393
+ end
139
394
  end