@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (87) hide show
  1. package/PLASTIC.md +134 -469
  2. package/README.md +95 -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 +1 -10
  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 +12 -0
  12. package/hooks/gate-check +3 -1
  13. package/hooks/hooks.json +25 -4
  14. package/hooks/statusline +50 -10
  15. package/package.json +2 -2
  16. package/scripts/dashboard.rb +680 -0
  17. package/scripts/doctor.rb +1227 -0
  18. package/scripts/hook-auto-arm +51 -0
  19. package/scripts/hook-bash-gate +41 -0
  20. package/scripts/hook-code-gate +27 -0
  21. package/scripts/hook-continue +15 -114
  22. package/scripts/hook-gate-check +30 -34
  23. package/scripts/hook-session-start +121 -31
  24. package/scripts/install.rb +91 -487
  25. package/scripts/lib/boot_banner.rb +28 -0
  26. package/scripts/lib/bridge.rb +358 -10
  27. package/scripts/lib/installer_core.rb +747 -0
  28. package/scripts/lib/intent_validator.rb +90 -0
  29. package/scripts/lib/qmd_sync.rb +159 -0
  30. package/scripts/lib/store_provisioning.rb +100 -0
  31. package/scripts/migrate-to-global +1 -1
  32. package/scripts/provision-project-store +53 -0
  33. package/scripts/qmd-sync +92 -0
  34. package/scripts/select-update-target +93 -0
  35. package/scripts/uninstall.rb +53 -0
  36. package/scripts/update.rb +164 -0
  37. package/scripts/validate-intent +54 -0
  38. package/scripts/versions.rb +141 -0
  39. package/skills/_active-intent-gate.md +26 -0
  40. package/skills/add-project-store/SKILL.md +54 -0
  41. package/skills/auto/SKILL.md +69 -9
  42. package/skills/auto/evals/evals.json +92 -0
  43. package/skills/auto/references/agent-architecture.md +60 -0
  44. package/skills/brainstorming/SKILL.md +143 -0
  45. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  46. package/skills/continuing/SKILL.md +92 -82
  47. package/skills/continuing/evals/evals.json +136 -0
  48. package/skills/continuing/references/context-management.md +32 -0
  49. package/skills/creating-intent/SKILL.md +38 -9
  50. package/skills/creating-intent/references/lifecycle.md +74 -0
  51. package/skills/creating-intent/references/wikilinks.md +8 -0
  52. package/skills/creating-project/SKILL.md +40 -8
  53. package/skills/creating-project/references/hubs-projects.md +55 -0
  54. package/skills/dashboard/SKILL.md +121 -0
  55. package/skills/dashboard/templates/dashboard-global.md +31 -0
  56. package/skills/dashboard/templates/dashboard-project.md +40 -0
  57. package/skills/doctor/SKILL.md +159 -0
  58. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  59. package/skills/doctor/report.md +100 -0
  60. package/skills/evaluating-skills/SKILL.md +140 -0
  61. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  62. package/skills/evaluating-skills/evals/evals.json +75 -0
  63. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  64. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  65. package/skills/executing-plan/SKILL.md +5 -3
  66. package/skills/install/SKILL.md +69 -8
  67. package/skills/intent-curator/SKILL.md +3 -3
  68. package/skills/linking-intents/SKILL.md +5 -1
  69. package/skills/linking-intents/references/zettelkasten.md +33 -0
  70. package/skills/managing-index/SKILL.md +5 -1
  71. package/skills/releasing/SKILL.md +119 -18
  72. package/skills/releasing/references/deprecations.md +60 -0
  73. package/skills/research/SKILL.md +114 -0
  74. package/skills/savepoint/SKILL.md +46 -37
  75. package/skills/savepoint/references/context-management.md +32 -0
  76. package/skills/uninstall/SKILL.md +39 -28
  77. package/skills/update/SKILL.md +41 -36
  78. package/skills/versions/SKILL.md +65 -0
  79. package/skills/writing-instructions/SKILL.md +159 -0
  80. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  81. package/skills/writing-plans/SKILL.md +183 -0
  82. package/templates/agents.md +16 -0
  83. package/templates/outcome.md +13 -0
  84. package/templates/project.yml +5 -0
  85. package/templates/savepoint.md +14 -13
  86. package/templates/spec.md +25 -0
  87. package/bin/install.js +0 -29
@@ -2,8 +2,10 @@
2
2
  # encoding: UTF-8
3
3
 
4
4
  require "json"
5
+ require "yaml"
5
6
  require "fileutils"
6
7
  require "tempfile"
8
+ require "digest"
7
9
 
8
10
  module Bridge
9
11
  STAGES = %w[what why how exec done].freeze
@@ -13,26 +15,114 @@ module Bridge
13
15
  "#{intent_dir}/#{dir_name}.md"
14
16
  end
15
17
 
16
- def self.path(session)
17
- "/tmp/plastic-#{session}.json"
18
+ # Single source for the OS temp location holding bridge files. Lets every
19
+ # process (arm_auto, the gate hooks) agree, and lets tests fully isolate by
20
+ # pointing PLASTIC_TMP at a Dir.mktmpdir. This is OS-temp-location resolution,
21
+ # not a logic-config injection seam.
22
+ def self.tmp_dir
23
+ t = ENV["PLASTIC_TMP"]
24
+ (t.nil? || t.strip.empty?) ? "/tmp" : t
18
25
  end
19
26
 
20
- def self.read(session)
21
- p = path(session)
27
+ def self.path(session, tmp: tmp_dir)
28
+ "#{tmp}/plastic-#{session}.json"
29
+ end
30
+
31
+ # --- Session resolution (intent 52) ----------------------------------------
32
+
33
+ def self.blank?(value)
34
+ value.nil? || value.to_s.strip.empty?
35
+ end
36
+
37
+ # Deterministic, session-id-less bridge key derived from store + intent id.
38
+ # Stable across processes so a session-less arm and a later session-less
39
+ # gate-check resolve to the same bridge file.
40
+ def self.derive_key(store, intent_id)
41
+ "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
42
+ end
43
+
44
+ # Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
45
+ # then a derived key. Never returns nil/empty. Whitespace-only counts as empty.
46
+ def self.resolve_session(explicit, intent_id:, store:)
47
+ return explicit.to_s.strip unless blank?(explicit)
48
+ env = ENV["CLAUDE_SESSION_ID"]
49
+ return env.to_s.strip unless blank?(env)
50
+ derive_key(store, intent_id)
51
+ end
52
+
53
+ # Walk up from file_path; return the first ancestor that looks like an intent
54
+ # directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
55
+ # target without needing a bridge. The input is always a file inside the intent
56
+ # dir (never the dir itself), so the walk-up starts at its parent.
57
+ def self.intent_dir_for(file_path)
58
+ dir = File.expand_path(file_path)
59
+ loop do
60
+ parent = File.dirname(dir)
61
+ break if parent == dir # reached filesystem root
62
+ dir = parent
63
+ return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
64
+ end
65
+ nil
66
+ end
67
+
68
+ # A bridge hash is usable iff it has a non-empty session and an intent Hash.
69
+ def self.bridge_valid?(data)
70
+ data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
71
+ end
72
+
73
+ # Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
74
+ # for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
75
+ # the one whose intent.store matches cwd, tie-break by newest mtime.
76
+ def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
77
+ if !blank?(session) && File.exist?(path(session, tmp: tmp))
78
+ exact = read(session, tmp: tmp)
79
+ return exact if bridge_valid?(exact)
80
+ end
81
+
82
+ candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
83
+ parsed = candidates.filter_map do |f|
84
+ data = (JSON.parse(File.read(f)) rescue nil)
85
+ next unless data && bridge_valid?(data)
86
+ { file: f, data: data, mtime: File.mtime(f) }
87
+ end
88
+ return nil if parsed.empty?
89
+
90
+ auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
91
+ pool = auto.empty? ? parsed : auto
92
+
93
+ unless blank?(cwd)
94
+ cwd_abs = File.expand_path(cwd)
95
+ matching = pool.select do |c|
96
+ store = c[:data].dig("intent", "store").to_s
97
+ next false if store.empty?
98
+ store_abs = File.expand_path(store)
99
+ cwd_abs == store_abs ||
100
+ cwd_abs.start_with?("#{store_abs}/") ||
101
+ store_abs.start_with?("#{cwd_abs}/")
102
+ end
103
+ pool = matching unless matching.empty?
104
+ end
105
+
106
+ pool.max_by { |c| c[:mtime] }&.fetch(:data)
107
+ end
108
+
109
+ def self.read(session, tmp: tmp_dir)
110
+ p = path(session, tmp: tmp)
22
111
  return nil unless File.exist?(p)
23
112
  JSON.parse(File.read(p))
24
113
  rescue JSON::ParserError
25
114
  nil
26
115
  end
27
116
 
28
- def self.write(session, data)
29
- p = path(session)
117
+ def self.write(session, data, tmp: tmp_dir)
118
+ raise ArgumentError, "bridge session must be present" if blank?(session)
119
+ p = path(session, tmp: tmp)
30
120
  # Atomic write: tmp file + rename to prevent partial reads
31
- tmp = "#{p}.tmp.#{Process.pid}"
32
- File.write(tmp, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
33
- File.rename(tmp, p)
121
+ tmp_file = "#{p}.tmp.#{Process.pid}"
122
+ File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
123
+ File.rename(tmp_file, p)
34
124
  rescue => e
35
- File.delete(tmp) if tmp && File.exist?(tmp)
125
+ File.delete(tmp_file) if tmp_file && File.exist?(tmp_file)
36
126
  raise e
37
127
  end
38
128
 
@@ -69,6 +159,68 @@ module Bridge
69
159
  end
70
160
  end
71
161
 
162
+ # --- Cycle-step savepoint ledger (intent 34) ------------------------------
163
+ #
164
+ # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
165
+ # (newest at the bottom). It is sugar on top of the conventions: derived from
166
+ # files-on-disk, rebuildable, never a source of truth. Milestones are
167
+ # file-event boundaries only; action/resource files record nothing.
168
+
169
+ SAVEPOINT_FILE = "savepoint.md"
170
+
171
+ # Map a written filename to [stage_label, milestone_text], or nil if the file
172
+ # is not a lifecycle milestone.
173
+ def self.savepoint_milestone(intent_dir, basename)
174
+ return ["What", basename] if basename == File.basename(intent_file(intent_dir))
175
+
176
+ case basename
177
+ when "spec.md" then ["Why", "spec.md created"]
178
+ when "plan.md" then ["How", "plan.md created"]
179
+ when "checklist.md" then ["How", "checklist.md created"]
180
+ when "outcome.md" then ["Exec", "outcome.md created"]
181
+ end
182
+ end
183
+
184
+ # Milestones already recorded in the ledger (field 3 of each line).
185
+ def self.savepoint_recorded_milestones(intent_dir)
186
+ f = File.join(intent_dir, SAVEPOINT_FILE)
187
+ return [] unless File.exist?(f)
188
+ File.read(f).each_line.map do |line|
189
+ parts = line.strip.split(/\s{2,}/)
190
+ parts.length >= 3 ? parts[2] : nil
191
+ end.compact
192
+ end
193
+
194
+ # Append a milestone line for file_path if (and only if) it is a milestone
195
+ # not already recorded. Returns true when a line was written, false otherwise.
196
+ def self.append_savepoint(intent_dir, file_path, now: Time.now)
197
+ stage, milestone = savepoint_milestone(intent_dir, File.basename(file_path))
198
+ return false unless milestone
199
+ return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
200
+
201
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
202
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
203
+ true
204
+ end
205
+
206
+ # Reconstruct the ledger from files on disk (timestamps from mtimes), in
207
+ # stage order, overwriting savepoint.md. Returns the number of lines written.
208
+ def self.rebuild_savepoint(intent_dir)
209
+ ordered = [
210
+ File.basename(intent_file(intent_dir)),
211
+ "spec.md", "plan.md", "checklist.md", "outcome.md",
212
+ ]
213
+ lines = ordered.filter_map do |basename|
214
+ path = File.join(intent_dir, basename)
215
+ next unless File.exist?(path)
216
+ stage, milestone = savepoint_milestone(intent_dir, basename)
217
+ next unless milestone
218
+ "#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
219
+ end
220
+ File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
221
+ lines.length
222
+ end
223
+
72
224
  def self.derive(session, intent_id:, intent_dir:, store:, name:)
73
225
  stage = derive_stage(intent_dir)
74
226
  has = has_files(intent_dir)
@@ -87,6 +239,7 @@ module Bridge
87
239
  "has" => has,
88
240
  "missing" => missing,
89
241
  "gate_failures" => 0,
242
+ "auto" => false,
90
243
  "last_activity" => Time.now.utc.iso8601
91
244
  },
92
245
  "observe" => {
@@ -136,4 +289,199 @@ module Bridge
136
289
 
137
290
  nil # no gate violation
138
291
  end
292
+
293
+ PROJECT_CONFIG_DEFAULTS = {
294
+ "governing_docs" => ["AGENTS.md"],
295
+ "release" => {
296
+ "on_complete" => "commit",
297
+ },
298
+ }.freeze
299
+
300
+ def self.read_project_config(slug)
301
+ path = File.join(Dir.home, ".plastic", "projects", slug, "project.yml")
302
+ config = if File.exist?(path)
303
+ YAML.safe_load(File.read(path)) || {}
304
+ else
305
+ {}
306
+ end
307
+
308
+ deep_merge(PROJECT_CONFIG_DEFAULTS, config)
309
+ rescue => e
310
+ $stderr.puts "Warning: failed to read project config for #{slug}: #{e.message}"
311
+ PROJECT_CONFIG_DEFAULTS.dup
312
+ end
313
+
314
+ # --- Auto mode (intent 27) ---
315
+
316
+ # Arm auto mode for a session+intent. Works even when no bridge exists yet
317
+ # (mid-session intent creation). Re-derives intent state, then sets build.auto.
318
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
319
+ key = resolve_session(session, intent_id: intent_id, store: store)
320
+ if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
321
+ $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
322
+ end
323
+ data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
324
+ data["build"]["auto"] = true
325
+ write(key, data)
326
+ data
327
+ end
328
+
329
+ # Disarm auto mode. No-op if no bridge exists for the session.
330
+ def self.disarm_auto(session)
331
+ data = read(session)
332
+ return nil unless data
333
+ data["build"] ||= {}
334
+ data["build"]["auto"] = false
335
+ write(session, data)
336
+ data
337
+ end
338
+
339
+ # Decide whether a code edit should be blocked while auto mode is armed.
340
+ # Returns a reason string to BLOCK, or nil to ALLOW.
341
+ #
342
+ # Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
343
+ # target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
344
+ def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
345
+ return nil unless bridge_data.is_a?(Hash)
346
+ build = bridge_data["build"] || {}
347
+ return nil unless build["auto"] == true
348
+
349
+ intent_info = bridge_data["intent"] || {}
350
+ store = intent_info["store"]
351
+ dir = intent_info["dir"]
352
+ return nil unless store && dir
353
+ intent_dir_abs = File.expand_path("#{store}/#{dir}")
354
+
355
+ # "How reached" = the plan triplet exists. Gate by artifact presence, not the
356
+ # stage label (derive_stage returns "how" as soon as spec.md exists, before any
357
+ # plan). Code edits stay blocked until plan.md + checklist.md are both present.
358
+ reached_how = File.exist?("#{intent_dir_abs}/plan.md") &&
359
+ File.exist?("#{intent_dir_abs}/checklist.md")
360
+ return nil if reached_how
361
+
362
+ file_abs = File.expand_path(file_path.to_s)
363
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
364
+ return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
365
+ return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
366
+
367
+ id = intent_info["id"]
368
+ "intent #{id} has not reached How — write plan.md + checklist.md before " \
369
+ "editing project code. Run plastic-auto or plastic-writing-plans first. " \
370
+ "(blocked edit: #{file_abs})"
371
+ end
372
+
373
+ # --- Bash-edit gate (intent 27a) ---
374
+
375
+ # Extract the set of file paths a Bash command writes to. Conservative by
376
+ # design: it is acceptable to miss exotic forms, but it must NOT flag reads
377
+ # or /dev/null. Returns an Array of path strings (possibly relative).
378
+ #
379
+ # Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
380
+ # tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
381
+ def self.bash_write_targets(command)
382
+ return [] unless command.is_a?(String)
383
+
384
+ targets = []
385
+ targets.concat(bash_redirect_targets(command))
386
+ # Split on command separators for per-segment utility parsing.
387
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
388
+ targets.concat(bash_utility_targets(segment))
389
+ end
390
+ targets.uniq
391
+ end
392
+
393
+ # Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
394
+ # A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
395
+ def self.bash_redirect_targets(command)
396
+ targets = []
397
+ # Match optional leading fd digits, then > or >>, not followed by & , then path.
398
+ command.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
399
+ path = m[0]
400
+ next if path.nil? || path.empty?
401
+ next if dev_null?(path)
402
+ targets << path
403
+ end
404
+ targets
405
+ end
406
+
407
+ def self.bash_utility_targets(segment)
408
+ tokens = segment.strip.split(/\s+/)
409
+ return [] if tokens.empty?
410
+
411
+ # Find the utility name, skipping env-style assignments.
412
+ idx = 0
413
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
414
+ util = File.basename(tokens[idx].to_s)
415
+ args = tokens[(idx + 1)..] || []
416
+
417
+ case util
418
+ when "tee"
419
+ tee_targets(args)
420
+ when "sed"
421
+ sed_targets(args)
422
+ when "cp", "mv"
423
+ copy_move_targets(args)
424
+ when "dd"
425
+ dd_targets(tokens)
426
+ else
427
+ []
428
+ end
429
+ end
430
+
431
+ def self.tee_targets(args)
432
+ args.reject { |a| a.start_with?("-") || dev_null?(a) }
433
+ end
434
+
435
+ def self.sed_targets(args)
436
+ # In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
437
+ inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
438
+ return [] unless inplace
439
+ files = args.reject { |a| a.start_with?("-") }
440
+ # sed args: script then file(s). First non-flag is the script expression
441
+ # unless an -e/-f was used; conservatively treat the LAST non-flag as file.
442
+ files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
443
+ end
444
+
445
+ def self.copy_move_targets(args)
446
+ files = args.reject { |a| a.start_with?("-") }
447
+ return [] if files.length < 2
448
+ dest = files.last
449
+ dev_null?(dest) ? [] : [dest]
450
+ end
451
+
452
+ def self.dd_targets(tokens)
453
+ tokens.each_with_object([]) do |t, acc|
454
+ next unless t.start_with?("of=")
455
+ path = t.sub("of=", "")
456
+ acc << path unless path.empty? || dev_null?(path)
457
+ end
458
+ end
459
+
460
+ def self.dev_null?(path)
461
+ path == "/dev/null" || path.start_with?("/dev/")
462
+ end
463
+
464
+ # Decide whether a Bash command should be blocked under the auto-mode code
465
+ # gate. Resolves each write target against cwd and applies the SAME policy as
466
+ # code_gate_decision. Returns the first block reason, or nil to allow.
467
+ def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
468
+ bash_write_targets(command).each do |target|
469
+ abs = File.absolute_path?(target) ? target : File.join(cwd, target)
470
+ reason = code_gate_decision(bridge_data, abs, home: home)
471
+ return reason if reason
472
+ end
473
+ nil
474
+ end
475
+
476
+ def self.deep_merge(base, overlay)
477
+ result = base.dup
478
+ overlay.each do |key, value|
479
+ if value.is_a?(Hash) && result[key].is_a?(Hash)
480
+ result[key] = deep_merge(result[key], value)
481
+ else
482
+ result[key] = value
483
+ end
484
+ end
485
+ result
486
+ end
139
487
  end