@zalom/plastic 2.0.0-alpha.20 → 2.0.0-alpha.22

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 (45) hide show
  1. package/PLASTIC.md +20 -18
  2. package/bin/test +24 -4
  3. package/hooks/call-budget +4 -0
  4. package/hooks/hooks.json +12 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +79 -4
  7. package/scripts/hook-call-budget +222 -0
  8. package/scripts/hook-session-start +307 -319
  9. package/scripts/insight-append +18 -4
  10. package/scripts/lib/compact_instructions.rb +5 -5
  11. package/scripts/lib/doctor_core.rb +2 -1
  12. package/scripts/lib/graph_edges.rb +16 -0
  13. package/scripts/lib/hook_registry.rb +14 -2
  14. package/scripts/lib/installer_core.rb +13 -5
  15. package/scripts/lib/meter_watch.rb +179 -0
  16. package/scripts/lib/node_packet.rb +27 -5
  17. package/scripts/lib/runner_dispatch.rb +29 -5
  18. package/scripts/lib/runner_policy.rb +31 -0
  19. package/scripts/lib/runner_proposals.rb +21 -0
  20. package/scripts/lib/session_usage.rb +190 -0
  21. package/scripts/meter-watch +57 -0
  22. package/scripts/read-config +3 -3
  23. package/scripts/runner +5 -0
  24. package/scripts/session-usage +56 -0
  25. package/scripts/skill-lint +115 -6
  26. package/skills/auto/SKILL.md +61 -63
  27. package/skills/auto/references/agent-architecture.md +10 -8
  28. package/skills/auto/references/human-report-contract.md +1 -1
  29. package/skills/conventions/references/completion-and-done.md +7 -7
  30. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  31. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  32. package/skills/doctor/report.md +1 -1
  33. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  34. package/skills/intent-creating/SKILL.md +58 -133
  35. package/skills/intent-ending/SKILL.md +48 -56
  36. package/skills/intent-ending/evals/evals.json +1 -1
  37. package/skills/intent-executing/SKILL.md +39 -134
  38. package/skills/intent-speccing/SKILL.md +3 -0
  39. package/skills/releasing/SKILL.md +1 -1
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/skills/tutorial/SKILL.md +2 -1
  42. package/skills/tutorial/references/track-1-guided.md +21 -40
  43. package/skills/tutorial/references/track-2-auto.md +2 -2
  44. package/templates/agents.md +2 -2
  45. package/templates/config.yml +3 -3
@@ -0,0 +1,190 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+
7
+ # SessionUsage (intent 355, D10): reads the harness transcripts modified since
8
+ # a cutoff, dedupes assistant records by message id (the harness logs one
9
+ # record per content block), and reports per session the model, the calls in
10
+ # the window, the boot and last context, the median step, the steps over 5k,
11
+ # and the cache read. Context is input plus cache read plus cache write.
12
+ # Broken records are counted and named, never averaged over. The transcripts
13
+ # root and the rate-limit cache path are injected; nothing reads ENV.
14
+ class SessionUsage
15
+ WINDOW = 5 * 3600
16
+ BIG_STEP = 5_000
17
+ LABEL_WIDTH = 70
18
+ NO_PROMPT = "(no prompt)"
19
+ SYNTHETIC_MODEL = "<synthetic>"
20
+ CONTEXT_FIELDS = %w[input_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
21
+ TRANSCRIPT_GLOBS = [File.join("*", "*.jsonl"), File.join("*", "*", "subagents", "*.jsonl")].freeze
22
+ HEADERS = ["session", "model", "calls", "boot", "last", "median step", "big steps", "cache read", "broken", "prompt"].freeze
23
+
24
+ def initialize(transcripts_root:, rate_limits_path:, now: Time.now)
25
+ @transcripts_root = transcripts_root
26
+ @rate_limits_path = rate_limits_path
27
+ @now = now
28
+ end
29
+
30
+ def cutoff(since: nil)
31
+ return [since, "since"] if since
32
+
33
+ reset = reset_time
34
+ return [reset - WINDOW, "rate-limit reset"] if reset && reset > @now
35
+
36
+ [@now - WINDOW, "last 5 hours"]
37
+ end
38
+
39
+ def report(since: nil)
40
+ at, source = cutoff(since: since)
41
+ header = { "status" => "ok", "root" => @transcripts_root, "cutoff" => at.getutc.iso8601, "cutoff_source" => source }
42
+ return header.merge("status" => "unavailable") unless File.directory?(@transcripts_root)
43
+
44
+ broken = []
45
+ sessions = transcripts(at).filter_map { |path| summarize(path, at, broken) }
46
+ header.merge("sessions" => sessions.sort_by { |s| -s["cache_read"] }, "broken" => broken)
47
+ end
48
+
49
+ def self.render_text(report)
50
+ return "Session usage: unavailable (no transcripts directory at #{report['root']})\n" if report["status"] == "unavailable"
51
+
52
+ out = +"Session usage since #{report['cutoff']} (#{report['cutoff_source']})\n\n"
53
+ out << (report["sessions"].empty? ? "(no sessions)\n" : table(report["sessions"]))
54
+ return out if report["broken"].empty?
55
+
56
+ out << "\nBroken records (#{report['broken'].size}):\n"
57
+ report["broken"].each { |b| out << " #{b['file']}:#{b['line']} #{b['error']}\n" }
58
+ out
59
+ end
60
+
61
+ def self.table(sessions)
62
+ rows = sessions.map do |s|
63
+ boot = tokens(s["boot"]) + (s["started_before_cutoff"] ? "*" : "")
64
+ [s["id"], s["model"].to_s, s["calls"].to_s, boot, tokens(s["last"]), tokens(s["median_step"]),
65
+ s["big_steps"].to_s, tokens(s["cache_read"]), s["broken"].to_s, s["label"]]
66
+ end
67
+ widths = HEADERS.each_index.map { |i| ([HEADERS] + rows).map { |r| r[i].length }.max }
68
+ lines = ([HEADERS] + rows).map { |r| r.each_with_index.map { |cell, i| cell.ljust(widths[i]) }.join(" ").rstrip }
69
+ note = sessions.any? { |s| s["started_before_cutoff"] } ? "\n* boot predates the cutoff\n" : ""
70
+ lines.join("\n") + "\n" + note
71
+ end
72
+
73
+ def self.tokens(count)
74
+ return "-" if count.nil?
75
+
76
+ count.abs >= 1_000 ? format("%.1fk", count / 1_000.0) : count.to_s
77
+ end
78
+
79
+ private_class_method :table, :tokens
80
+
81
+ private
82
+
83
+ def reset_time
84
+ return unless File.file?(@rate_limits_path)
85
+
86
+ value = JSON.parse(File.read(@rate_limits_path))["resets_at"].to_s
87
+ return if value.empty?
88
+
89
+ value.match?(/\A\d+\z/) ? Time.at(value.to_i).utc : Time.iso8601(value)
90
+ rescue JSON::ParserError, ArgumentError, TypeError
91
+ nil
92
+ end
93
+
94
+ def transcripts(cutoff)
95
+ TRANSCRIPT_GLOBS.flat_map { |glob| Dir.glob(File.join(@transcripts_root, glob)) }
96
+ .select { |path| File.mtime(path) >= cutoff }
97
+ .sort
98
+ end
99
+
100
+ def summarize(path, cutoff, broken)
101
+ calls = {}
102
+ label = nil
103
+ torn = 0
104
+ File.foreach(path).with_index(1) do |line, number|
105
+ next if line.strip.empty?
106
+
107
+ record = JSON.parse(line)
108
+ raise TypeError, "record is not a JSON object" unless record.is_a?(Hash)
109
+
110
+ label ||= prompt_line(record)
111
+ call = call_from(record)
112
+ next unless call
113
+
114
+ calls[call[:id]] = calls.key?(call[:id]) ? call.merge(at: calls[call[:id]][:at]) : call
115
+ rescue JSON::ParserError
116
+ torn += 1
117
+ broken << { "file" => path, "line" => number, "error" => "unparsable JSON" }
118
+ rescue ArgumentError, TypeError => e
119
+ torn += 1
120
+ broken << { "file" => path, "line" => number, "error" => e.message }
121
+ end
122
+ row(path, calls.values, cutoff, label, torn)
123
+ end
124
+
125
+ def row(path, calls, cutoff, label, torn)
126
+ window = calls.each_index.select { |i| calls[i][:at] >= cutoff }
127
+ return if window.empty? && torn.zero?
128
+
129
+ steps = window.filter_map { |i| calls[i][:context] - calls[i - 1][:context] if i.positive? }
130
+ {
131
+ "id" => File.basename(path, ".jsonl"),
132
+ "file" => path,
133
+ "label" => label || NO_PROMPT,
134
+ "model" => calls.last && calls.last[:model],
135
+ "calls" => window.size,
136
+ "boot" => calls.first && calls.first[:context],
137
+ "last" => calls.last && calls.last[:context],
138
+ "median_step" => median(steps),
139
+ "big_steps" => steps.count { |step| step > BIG_STEP },
140
+ "cache_read" => window.sum { |i| calls[i][:cache_read] },
141
+ "broken" => torn,
142
+ "started_before_cutoff" => !calls.empty? && calls.first[:at] < cutoff,
143
+ }
144
+ end
145
+
146
+ def call_from(record)
147
+ return unless record["type"] == "assistant"
148
+
149
+ message = record["message"]
150
+ raise TypeError, "assistant record without a message" unless message.is_a?(Hash)
151
+ return if message["model"] == SYNTHETIC_MODEL
152
+
153
+ usage = message["usage"]
154
+ raise ArgumentError, "assistant record without message id or usage" unless message["id"] && usage.is_a?(Hash)
155
+
156
+ {
157
+ id: message["id"],
158
+ model: message["model"],
159
+ at: Time.iso8601(record["timestamp"].to_s),
160
+ context: CONTEXT_FIELDS.sum { |field| usage[field].to_i },
161
+ cache_read: usage["cache_read_input_tokens"].to_i,
162
+ }
163
+ end
164
+
165
+ def prompt_line(record)
166
+ return unless record["type"] == "user" && !record["isMeta"]
167
+
168
+ text = prompt_text(record["message"].is_a?(Hash) ? record["message"]["content"] : nil)
169
+ return if text.nil?
170
+
171
+ first = text.lines.map(&:strip).find { |line| !line.empty? && !line.start_with?("<") }
172
+ first && first[0, LABEL_WIDTH]
173
+ end
174
+
175
+ def prompt_text(content)
176
+ return content if content.is_a?(String)
177
+ return unless content.is_a?(Array)
178
+
179
+ block = content.find { |b| b.is_a?(Hash) && b["type"] == "text" }
180
+ block && block["text"]
181
+ end
182
+
183
+ def median(values)
184
+ return if values.empty?
185
+
186
+ sorted = values.sort
187
+ middle = sorted.size / 2
188
+ sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
189
+ end
190
+ end
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # meter-watch (intent 355, n5, D6): reads the rate-limit cache under --home
6
+ # and writes ~/.plastic/.cache/meter-state.json with the state (ok, reduce,
7
+ # stop, resume, stale, unavailable), the two raw percentages, the reset
8
+ # time, and when it was checked. A session watches that one file instead of
9
+ # every session parsing the cache and re-deriving the thresholds.
10
+ #
11
+ # Usage:
12
+ # meter-watch [--home DIR]
13
+ # meter-watch --install-timer [--home DIR]
14
+ #
15
+ # --install-timer writes a LaunchAgent plist under --home (default
16
+ # ~/.plastic when --home is not given) that runs this tick every 20
17
+ # minutes. It never calls launchctl; load it yourself with the command it
18
+ # prints. The Plastic installer never calls --install-timer on its own.
19
+ #
20
+ # Exit codes: 0 reported (unavailable included); 2 usage error.
21
+
22
+ require "json"
23
+ require_relative "lib/meter_watch"
24
+
25
+ def usage_abort(message)
26
+ warn "meter-watch: #{message}"
27
+ warn "Usage: meter-watch [--home DIR] [--install-timer]"
28
+ exit 2
29
+ end
30
+
31
+ home = File.join(Dir.home, ".plastic")
32
+ install_timer = false
33
+
34
+ argv = ARGV.dup
35
+ until argv.empty?
36
+ case (token = argv.shift)
37
+ when "--home"
38
+ usage_abort("--home requires a value") if argv.empty?
39
+ home = argv.shift
40
+ when "--install-timer"
41
+ install_timer = true
42
+ else
43
+ usage_abort("unknown argument #{token.inspect}")
44
+ end
45
+ end
46
+
47
+ home = File.expand_path(home)
48
+
49
+ if install_timer
50
+ plist_path = MeterWatch.install_timer(home: home, script_path: File.expand_path(__FILE__))
51
+ puts "Installed LaunchAgent at #{plist_path}"
52
+ puts "Run `launchctl load #{plist_path}` to activate it."
53
+ exit 0
54
+ end
55
+
56
+ state = MeterWatch.new(home: home).tick
57
+ puts JSON.generate(state)
@@ -14,9 +14,9 @@ require_relative "lib/agent_models"
14
14
  DEFAULTS = {
15
15
  "version" => 3,
16
16
  "stale_threshold_days" => 3,
17
- # Absolute token counts for a 1M window, 35 and 50 percent (intent 312, 296 D38).
18
- "context_offer_tokens" => 350_000,
19
- "context_insist_tokens" => 500_000,
17
+ # Absolute token counts for a 1M window, 15 and 25 percent (intent 355, n5, D7).
18
+ "context_offer_tokens" => 150_000,
19
+ "context_insist_tokens" => 250_000,
20
20
  "execution_mode" => "subagent-driven",
21
21
  "hash_length" => 6,
22
22
  "hash_algorithm" => "sha256-base36",
package/scripts/runner CHANGED
@@ -5,6 +5,7 @@
5
5
  require_relative "lib/savepoint"
6
6
  require_relative "lib/ready_set"
7
7
  require_relative "lib/runner_core"
8
+ require_relative "lib/runner_proposals"
8
9
 
9
10
  # runner - the one executable over the graph-ready loop's declared node graph
10
11
  # (intent 340, G7, n1). A subcommand table: the public verbs (step, status,
@@ -138,6 +139,10 @@ module Runner
138
139
  end
139
140
  end
140
141
 
142
+ graph = context.graph || {}
143
+ fixes = RunnerProposals.review_fix_count(graph[:edges] || {}, graph[:nodes] || {})
144
+ puts "review fixes: #{fixes} of #{RunnerProposals::REVIEW_FIX_CAP}"
145
+
141
146
  complete = RunnerCore.complete?(context)
142
147
  puts
143
148
  puts complete ? "complete" : "stalled"
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # session-usage (intent 355, D10): read the harness transcripts since a cutoff
6
+ # and report per session the model, calls, boot context, last context, median
7
+ # step, steps over 5k, and cache read. Broken records are counted and named.
8
+ #
9
+ # Usage:
10
+ # session-usage [--since <iso8601>] [--format text|json] [--root <dir>] [--rate-limits <file>]
11
+ #
12
+ # Defaults: the root is ~/.claude/projects, the rate-limit cache is
13
+ # ~/.plastic/.cache/rate-limits.json, and the cutoff is that cache's reset
14
+ # time minus five hours, else the last five hours.
15
+ #
16
+ # Exit codes: 0 reported (unavailable included); 2 usage error.
17
+
18
+ require "json"
19
+ require "time"
20
+ require_relative "lib/session_usage"
21
+
22
+ FLAGS = %w[--since --format --root --rate-limits].freeze
23
+ FORMATS = %w[text json].freeze
24
+
25
+ def usage_abort(message)
26
+ warn "session-usage: #{message}"
27
+ warn "Usage: session-usage [--since <iso8601>] [--format text|json] [--root <dir>] [--rate-limits <file>]"
28
+ exit 2
29
+ end
30
+
31
+ def parse_args(argv)
32
+ usage_abort("every flag takes a value") if argv.length.odd?
33
+ opts = argv.each_slice(2).to_h
34
+ unknown = opts.keys - FLAGS
35
+ usage_abort("unknown flag #{unknown.first.inspect}") unless unknown.empty?
36
+ usage_abort("unknown format #{opts['--format'].inspect}") if opts["--format"] && !FORMATS.include?(opts["--format"])
37
+ opts
38
+ end
39
+
40
+ def parse_since(value)
41
+ value && Time.iso8601(value)
42
+ rescue ArgumentError
43
+ usage_abort("--since #{value.inspect} is not an ISO 8601 time")
44
+ end
45
+
46
+ opts = parse_args(ARGV)
47
+ usage = SessionUsage.new(
48
+ transcripts_root: File.expand_path(opts.fetch("--root", File.join(Dir.home, ".claude", "projects"))),
49
+ rate_limits_path: File.expand_path(opts.fetch("--rate-limits", File.join(Dir.home, ".plastic", ".cache", "rate-limits.json")))
50
+ )
51
+ report = usage.report(since: parse_since(opts["--since"]))
52
+ if opts["--format"] == "json"
53
+ puts JSON.pretty_generate(report)
54
+ else
55
+ print SessionUsage.render_text(report)
56
+ end
@@ -2,11 +2,13 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # skill-lint: deterministic CLI over SkillLint (intent 85b).
5
+ # skill-lint: deterministic CLI over SkillLint (intent 85b), plus one CLI-local
6
+ # check (intent 341, G8, n1, C35).
6
7
  #
7
- # Runs the five structural skill checks (body-budget, frontmatter-validity,
8
- # bare-pointer, orphan-files, references-depth) over a directory of Agent
9
- # Skills and reports every violation. Mirrors `scripts/validate-intent`'s
8
+ # Runs the five structural checks (body-budget, frontmatter-validity,
9
+ # bare-pointer, orphan-files, references-depth) via SkillLint, then this
10
+ # script's own refusal-restatement check, over a directory of Agent Skills,
11
+ # and reports every violation. Mirrors `scripts/validate-intent`'s
10
12
  # CLI-over-lib shape and exit-code contract.
11
13
  #
12
14
  # Usage:
@@ -17,6 +19,112 @@
17
19
 
18
20
  require_relative "lib/skill_lint"
19
21
 
22
+ # RefusalRestatementCheck (C35): a skill body must not restate, past a short
23
+ # run of words, a refusal rule the conventions chapter already carries; it
24
+ # must link that chapter instead. No-op when <skills_dir>/conventions/references
25
+ # does not exist, so an older tree or a fixture dir with no conventions chapter
26
+ # is unaffected.
27
+ #
28
+ # Deliberately lives in this CLI, not in scripts/lib/skill_lint.rb: C35 is a
29
+ # doctrine-duplication check, one level removed from SkillLint's five
30
+ # structural checks, and keeping it here means adding it touches only the
31
+ # files this change is scoped to.
32
+ class RefusalRestatementCheck
33
+ REFUSAL_KEYWORD_RE = /\brefus(e|es|ed|ing|al)\b/i
34
+ CONVENTIONS_LINK_RE = %r{conventions/references/|plastic-conventions}i
35
+
36
+ # How many consecutive normalized words must match, verbatim, between a
37
+ # skill's refusal paragraph and the doctrine text before it counts as a
38
+ # restatement rather than a coincidental shared phrase.
39
+ NGRAM = 6
40
+
41
+ def initialize(skills_dir)
42
+ @skills_dir = skills_dir
43
+ end
44
+
45
+ def run
46
+ doctrine = doctrine_blob
47
+ return [] unless doctrine
48
+
49
+ skill_md_paths.flat_map do |skill_md|
50
+ skill_dir = File.dirname(skill_md)
51
+ next [] if File.basename(skill_dir) == "conventions" # the source never restates itself
52
+
53
+ check_skill(skill_dir, skill_md, File.read(skill_md), doctrine)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def skill_md_paths
60
+ Dir.glob(File.join(@skills_dir, "*", "SKILL.md")).sort
61
+ end
62
+
63
+ def doctrine_blob
64
+ chapters = Dir.glob(File.join(@skills_dir, "conventions", "references", "*.md")).sort
65
+ return nil if chapters.empty?
66
+
67
+ text = chapters.map { |f| File.read(f) }.join(" ")
68
+ " #{normalize_words(text).join(" ")} "
69
+ end
70
+
71
+ def normalize_words(text)
72
+ text.downcase.gsub("`", "").gsub(/[^a-z0-9\s-]/, " ").split(/\s+/).reject(&:empty?)
73
+ end
74
+
75
+ # Same split as SkillLint's own frontmatter/body divide: `content.split("---", 3)`.
76
+ def body_and_offset(content)
77
+ parts = content.split("---", 3)
78
+ return [content, 0] if parts.length < 3
79
+
80
+ prefix_len = parts[0].length + 3 + parts[1].length + 3
81
+ [parts[2], content[0...prefix_len].count("\n")]
82
+ end
83
+
84
+ # Blank-line-delimited paragraph blocks, so a refusal keyword and its
85
+ # doctrine echo are compared across the whole paragraph, not one line.
86
+ def paragraph_blocks(lines)
87
+ blocks = []
88
+ start = nil
89
+ lines.each_with_index do |line, i|
90
+ if line.strip.empty?
91
+ blocks << { start: start, text: lines[start..(i - 1)].join } if start
92
+ start = nil
93
+ else
94
+ start ||= i
95
+ end
96
+ end
97
+ blocks << { start: start, text: lines[start..].join } if start
98
+ blocks
99
+ end
100
+
101
+ def restated?(block_text, doctrine)
102
+ words = normalize_words(block_text)
103
+ return false if words.length < NGRAM
104
+
105
+ (0..(words.length - NGRAM)).any? do |i|
106
+ doctrine.include?(" #{words[i, NGRAM].join(" ")} ")
107
+ end
108
+ end
109
+
110
+ def check_skill(skill_dir, skill_md, content, doctrine)
111
+ name = File.basename(skill_dir)
112
+ body, offset = body_and_offset(content)
113
+
114
+ paragraph_blocks(body.lines).filter_map do |block|
115
+ next unless block[:text].match?(REFUSAL_KEYWORD_RE)
116
+ next if block[:text].match?(CONVENTIONS_LINK_RE) # links the chapter instead of restating
117
+ next unless restated?(block[:text], doctrine)
118
+
119
+ {
120
+ check: "refusal-restatement", skill: name, file: skill_md, line: offset + block[:start] + 1,
121
+ message: "this paragraph restates a refusal rule word-for-word from " \
122
+ "skills/conventions/references/; link the chapter instead of repeating its text",
123
+ }
124
+ end
125
+ end
126
+ end
127
+
20
128
  def resolve_skills_dir(args)
21
129
  if (i = args.index("--skills-dir"))
22
130
  args[i + 1]
@@ -37,14 +145,15 @@ end
37
145
 
38
146
  dir = File.expand_path(resolve_skills_dir(ARGV))
39
147
  result = SkillLint.new(skills_dir: dir).run
148
+ violations = result.violations + RefusalRestatementCheck.new(dir).run
40
149
 
41
- if result.ok?
150
+ if violations.empty?
42
151
  puts "OK: #{dir}"
43
152
  exit 0
44
153
  end
45
154
 
46
155
  warn "VIOLATIONS: #{dir}"
47
- result.violations.each do |v|
156
+ violations.each do |v|
48
157
  warn "#{v[:check]} #{v[:skill]} #{v[:file]}:#{v[:line].nil? ? "-" : v[:line]} #{v[:message]}"
49
158
  end
50
159
  exit 1