@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.
- package/PLASTIC.md +20 -18
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +79 -4
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-session-start +307 -319
- package/scripts/insight-append +18 -4
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/doctor_core.rb +2 -1
- package/scripts/lib/graph_edges.rb +16 -0
- package/scripts/lib/hook_registry.rb +14 -2
- package/scripts/lib/installer_core.rb +13 -5
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_packet.rb +27 -5
- package/scripts/lib/runner_dispatch.rb +29 -5
- package/scripts/lib/runner_policy.rb +31 -0
- package/scripts/lib/runner_proposals.rb +21 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/meter-watch +57 -0
- package/scripts/read-config +3 -3
- package/scripts/runner +5 -0
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/skills/auto/SKILL.md +61 -63
- package/skills/auto/references/agent-architecture.md +10 -8
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +7 -7
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/maintenance-and-revisions.md +1 -1
- package/skills/doctor/report.md +1 -1
- package/skills/intent-continuing/references/boarding-matrix.md +2 -2
- package/skills/intent-creating/SKILL.md +58 -133
- package/skills/intent-ending/SKILL.md +48 -56
- package/skills/intent-ending/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +39 -134
- package/skills/intent-speccing/SKILL.md +3 -0
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/SKILL.md +2 -1
- package/skills/tutorial/references/track-1-guided.md +21 -40
- package/skills/tutorial/references/track-2-auto.md +2 -2
- package/templates/agents.md +2 -2
- 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)
|
package/scripts/read-config
CHANGED
|
@@ -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,
|
|
18
|
-
"context_offer_tokens" =>
|
|
19
|
-
"context_insist_tokens" =>
|
|
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
|
package/scripts/skill-lint
CHANGED
|
@@ -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
|
|
8
|
-
# bare-pointer, orphan-files, references-depth)
|
|
9
|
-
#
|
|
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
|
|
150
|
+
if violations.empty?
|
|
42
151
|
puts "OK: #{dir}"
|
|
43
152
|
exit 0
|
|
44
153
|
end
|
|
45
154
|
|
|
46
155
|
warn "VIOLATIONS: #{dir}"
|
|
47
|
-
|
|
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
|