@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.
- package/PLASTIC.md +128 -473
- package/README.md +90 -58
- package/agents/future-intent-researcher.md +1 -1
- package/agents/intent-curator.md +1 -1
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/deprecations.yml +7 -6
- package/hooks/auto-arm +5 -0
- package/hooks/bash-gate +3 -0
- package/hooks/check-update +12 -8
- package/hooks/code-gate +10 -0
- package/hooks/hooks.json +25 -4
- package/hooks/statusline +50 -10
- package/package.json +2 -2
- package/scripts/dashboard.rb +480 -0
- package/scripts/doctor.rb +973 -0
- package/scripts/hook-auto-arm +52 -0
- package/scripts/hook-bash-gate +53 -0
- package/scripts/hook-code-gate +39 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-gate-check +19 -4
- package/scripts/hook-session-start +76 -31
- package/scripts/install.rb +91 -480
- package/scripts/lib/bridge.rb +255 -0
- package/scripts/lib/installer_core.rb +760 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/select-update-target +93 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +142 -0
- package/scripts/versions.rb +141 -0
- package/skills/_active-intent-gate.md +26 -0
- package/skills/auto/SKILL.md +62 -9
- package/skills/auto/evals/evals.json +92 -0
- package/skills/auto/references/agent-architecture.md +60 -0
- package/skills/brainstorming/SKILL.md +143 -0
- package/skills/brainstorming-grill-me/SKILL.md +5 -5
- package/skills/continuing/SKILL.md +102 -77
- package/skills/continuing/evals/evals.json +136 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +16 -1
- package/skills/creating-intent/references/lifecycle.md +74 -0
- package/skills/creating-intent/references/wikilinks.md +8 -0
- package/skills/creating-project/SKILL.md +8 -4
- package/skills/creating-project/references/hubs-projects.md +55 -0
- package/skills/dashboard/SKILL.md +92 -0
- package/skills/doctor/SKILL.md +116 -0
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +96 -0
- package/skills/evaluating-skills/SKILL.md +140 -0
- package/skills/evaluating-skills/assets/eval-template.json +12 -0
- package/skills/evaluating-skills/evals/evals.json +75 -0
- package/skills/evaluating-skills/references/convention-checks.md +76 -0
- package/skills/evaluating-skills/references/eval-methodology.md +154 -0
- package/skills/executing-plan/SKILL.md +3 -3
- package/skills/install/SKILL.md +56 -8
- package/skills/intent-curator/SKILL.md +3 -3
- package/skills/linking-intents/SKILL.md +5 -1
- package/skills/linking-intents/references/zettelkasten.md +33 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/skills/releasing/SKILL.md +119 -18
- package/skills/releasing/references/deprecations.md +44 -0
- package/skills/research/SKILL.md +114 -0
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -36
- package/skills/versions/SKILL.md +65 -0
- package/skills/writing-instructions/SKILL.md +159 -0
- package/skills/writing-instructions/references/agentskills-spec.md +135 -0
- package/skills/writing-plans/SKILL.md +183 -0
- package/templates/agents.md +16 -0
- package/templates/outcome.md +13 -0
- package/templates/project.yml +5 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|