asciidoc-pubkit 1.0.3 → 1.2.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +50 -5
- data/data/heading-rules.ja.yml +4 -0
- data/data/review-rules.ja.yml +14 -4
- data/data/writing/ja/criteria.md +98 -1
- data/data/writing/ja/headings.md +11 -0
- data/data/writing/ja/outline.md +23 -0
- data/docs/agent-review-tutorial.md +312 -0
- data/docs/configuration.md +6 -0
- data/docs/document-commands.md +6 -1
- data/docs/installation.md +19 -2
- data/docs/outline-review.md +110 -0
- data/docs/prose-evaluation.md +25 -3
- data/docs/review-workflow.md +49 -1
- data/docs/rules-and-coverage.md +13 -2
- data/docs/skill-commands.md +136 -0
- data/docs/skill-integration.md +190 -0
- data/docs/writing.md +18 -1
- data/examples/skills/review-asciidoc-manuscript/SKILL.md +56 -0
- data/lib/asciidoc_pubkit/cli.rb +15 -2
- data/lib/asciidoc_pubkit/document.rb +11 -2
- data/lib/asciidoc_pubkit/document_commands.rb +32 -2
- data/lib/asciidoc_pubkit/heading_rules.rb +1 -0
- data/lib/asciidoc_pubkit/rules.rb +14 -0
- data/lib/asciidoc_pubkit/session.rb +45 -3
- data/lib/asciidoc_pubkit/settings.rb +12 -1
- data/lib/asciidoc_pubkit/skill_commands.rb +246 -0
- data/lib/asciidoc_pubkit/writing.rb +5 -0
- data/lib/asciidoc_pubkit.rb +2 -1
- metadata +8 -1
|
@@ -51,6 +51,7 @@ module AsciidocPubkit
|
|
|
51
51
|
'protected' => protected_content(document),
|
|
52
52
|
'numeric_tokens' => numeric_tokens(document)
|
|
53
53
|
}
|
|
54
|
+
manifest['outline_criteria'] = Writing.outline_criteria(settings.data['language']) if settings.data['scope'] == 'headings'
|
|
54
55
|
write_json(File.join(staging, 'document.json'), document.to_h)
|
|
55
56
|
write_json(File.join(staging, 'findings.json'), findings)
|
|
56
57
|
manifest['artifacts'] = %w[document.json findings.json].to_h do |name|
|
|
@@ -157,14 +158,21 @@ module AsciidocPubkit
|
|
|
157
158
|
@manifest.fetch('settings').fetch('language')
|
|
158
159
|
end
|
|
159
160
|
|
|
160
|
-
def prompt(mode)
|
|
161
|
+
def prompt(mode, view: 'full')
|
|
162
|
+
raise Error, 'view must be full or outline.' unless %w[full outline].include?(view)
|
|
163
|
+
if view == 'outline' && @manifest.fetch('settings').fetch('scope', 'prose') != 'headings'
|
|
164
|
+
raise Error, 'Outline view requires a heading session.'
|
|
165
|
+
end
|
|
166
|
+
if view == 'outline' && !@manifest['outline_criteria'].is_a?(String)
|
|
167
|
+
raise Error, 'Outline view requires saved outline criteria. Create a new heading session without replacing the existing baseline.'
|
|
168
|
+
end
|
|
161
169
|
raise Error, 'mode must be revise or diagnose.' unless %w[revise diagnose].include?(mode)
|
|
162
170
|
stale = @manifest['sources'].select do |source|
|
|
163
171
|
!File.file?(source['path']) || Digest::SHA256.file(source['path']).hexdigest != source['sha256']
|
|
164
172
|
end
|
|
165
173
|
raise Error, 'Sources have changed since scanning. Create a new scan before generating a prompt.' unless stale.empty?
|
|
166
174
|
language = Language.validate!(@manifest.fetch('settings').fetch('language'), operation: 'review')
|
|
167
|
-
return heading_prompt(mode, language) if @manifest.fetch('settings').fetch('scope', 'prose') == 'headings'
|
|
175
|
+
return heading_prompt(mode, language, view: view) if @manifest.fetch('settings').fetch('scope', 'prose') == 'headings'
|
|
168
176
|
list_scope = @manifest['settings']['scope'] == 'lists'
|
|
169
177
|
criteria = @manifest.fetch('writing_criteria')
|
|
170
178
|
instructions = <<~TEXT
|
|
@@ -332,7 +340,7 @@ module AsciidocPubkit
|
|
|
332
340
|
end
|
|
333
341
|
end
|
|
334
342
|
|
|
335
|
-
def heading_prompt(mode, language)
|
|
343
|
+
def heading_prompt(mode, language, view: 'full')
|
|
336
344
|
text = <<~TEXT
|
|
337
345
|
# Japanese heading review
|
|
338
346
|
|
|
@@ -399,6 +407,14 @@ module AsciidocPubkit
|
|
|
399
407
|
## Selected headings
|
|
400
408
|
|
|
401
409
|
TEXT
|
|
410
|
+
if view == 'outline'
|
|
411
|
+
text.sub!('# Japanese heading review', '# Japanese outline review')
|
|
412
|
+
text.sub!(prompt_data(JSON.generate(@document.fetch('outline')), 'json'), outline_view_data)
|
|
413
|
+
text.sub!('## Outline (reference data)', outline_review_guidance + "\n## Outline (reference data)")
|
|
414
|
+
end
|
|
415
|
+
if (depth = @manifest['settings']['heading_depth'])
|
|
416
|
+
text.sub!('## Selected headings', "Selected heading depth: #{depth} (maximum Asciidoctor section level).\nDeeper titles are reference data only and remain protected.\n\n## Selected headings")
|
|
417
|
+
end
|
|
402
418
|
grouped = @findings.group_by { |finding| finding['heading_id'] }
|
|
403
419
|
@document.fetch('headings').each do |heading|
|
|
404
420
|
text << "### Heading #{heading['id']}\n\n"
|
|
@@ -415,6 +431,28 @@ module AsciidocPubkit
|
|
|
415
431
|
text
|
|
416
432
|
end
|
|
417
433
|
|
|
434
|
+
def outline_review_guidance
|
|
435
|
+
@manifest.fetch('outline_criteria')
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def outline_view_data
|
|
439
|
+
selected = @document.fetch('headings').to_h { |h| [h['index'], h] }
|
|
440
|
+
numbers = {}
|
|
441
|
+
siblings = Hash.new(0)
|
|
442
|
+
rows = @document.fetch('outline').map do |heading|
|
|
443
|
+
parent = heading['parent_index']
|
|
444
|
+
siblings[parent] += 1
|
|
445
|
+
number = (numbers[parent] || []) + [siblings[parent]]
|
|
446
|
+
numbers[heading['index']] = number
|
|
447
|
+
source = selected[heading['index']] || heading
|
|
448
|
+
location = source['file'] ? "#{source['file']}:#{source['line'] || '?'}" : 'source position unavailable'
|
|
449
|
+
status = selected.key?(heading['index']) ? 'editable' : 'reference-only'
|
|
450
|
+
"#{' ' * (number.length - 1)}#{number.join('-')}. #{heading['text']} " \
|
|
451
|
+
"[#{status}; level #{heading['level']}; section_id #{heading['section_id']}; #{location}]"
|
|
452
|
+
end
|
|
453
|
+
prompt_data(rows.join("\n"), 'text')
|
|
454
|
+
end
|
|
455
|
+
|
|
418
456
|
def review_process(unit, mode)
|
|
419
457
|
<<~TEXT
|
|
420
458
|
## Review decisions and completion
|
|
@@ -423,10 +461,14 @@ module AsciidocPubkit
|
|
|
423
461
|
Check mechanical preservation and baseline integrity with this session before editing and at completion. If a baseline or artifact integrity check fails, stop editing, preserve the evidence, and report the failure. Do not repair or overwrite snapshots, update their hashes, or replace the session to turn a failed check into a successful report; a fresh scan cannot prove preservation against the original baseline.
|
|
424
462
|
Record each reviewed #{unit} ID and each candidate ID with revise, keep, or needs-evidence and a reason grounded in that passage. For keep, identify the concrete referent, operation, condition, necessary distinction, or established terminology that justifies retaining it. A shared generic reason does not establish individual review. Appending a source quotation to a stock reason is still insufficient.
|
|
425
463
|
For each candidate decision, identify its concrete role or claim, the evidence and source location, the direct wording considered, and why that alternative is better or why the original wording is necessary. For needs-evidence, name the missing support instead of inventing a role or revision. A quotation supports evidence but does not substitute for that explanation. For role metaphors such as 入口, apply the shared criteria even when a nearby identifier makes the referent clear.
|
|
464
|
+
For broad or everyday wording, record a concrete alternative and a passage-specific comparison before choosing keep or revise. Apply the saved shared terminology criteria even when the original is understandable. A passage summary is not a wording comparison.
|
|
465
|
+
Reporting helpers may validate and count explicit assessments but must not manufacture keep decisions or inherit a paragraph summary as a candidate assessment. Missing or insufficient comparisons remain pending; they are not needs-evidence unless factual support is missing. Automatic decisions and zero pending counts cannot establish completion.
|
|
426
466
|
Never infer revise or keep from whether the matched string survives, disappears, or changes. Do not bulk-assign keep to unchanged #{unit}s or remaining candidates. An unreviewed item stays pending; use needs-evidence only after review identifies missing evidence.
|
|
427
467
|
Review #{unit}s without candidates against the shared criteria as well. For concerns outside the editable scope, report the location and a separate out-of-scope proposal; do not count protection as an editorial keep decision or edit protected content.
|
|
428
468
|
#{mode == 'revise' ? 'Compare the original and edited passages, check both information loss and unsupported additions, and reconcile each recorded decision with the actual diff. After verification, individually inspect newly detected or remaining candidates; retain the original candidate IDs in the review record.' : 'Compare each proposed revision with the original passage for information loss and unsupported additions. A revise decision means a proposed revision, not an applied edit.'}
|
|
429
469
|
Report mechanical preservation, candidate decisions, and #{unit}-level quality review separately. State selected and reviewed #{unit} counts, candidate decision counts, pending items, needs-evidence items, out-of-scope concerns, and evidence used. Recheck retained role metaphors by their actual roles and cite the individual decisions; listing remaining words is not enough. Out-of-scope concerns cover only evidence actually inspected; the supplied prose/body context omits lists and other excluded blocks, so do not claim those were reviewed. If any selected item or candidate remains pending, report the review as incomplete.
|
|
470
|
+
For an explicitly task-limited review (such as human-role terminology), state the objective and relevant entry IDs separately from the total scanned entries. Report task-specific decisions and full-quality review counts separately. Scanning the whole book or completing a terminology pass does not establish full editorial review; unrelated unreviewed entries and candidates remain pending, not automatic keep decisions. This reporting distinction does not change saved edit permissions or add a selection filter.
|
|
471
|
+
Check related tables, figures and alternative text when terminology changes affect them. Report updates outside this session's edit scope separately; this prompt does not authorize those edits. Preserve the original baseline and phase-end verification when separately authorized synchronization follows.
|
|
430
472
|
Mechanical verification does not read or validate these decision records. Its passed result and candidate counts do not establish review completion or writing quality; meaning_verified remains false. Do not claim semantic correctness from those checks.
|
|
431
473
|
TEXT
|
|
432
474
|
end
|
|
@@ -23,7 +23,7 @@ module AsciidocPubkit
|
|
|
23
23
|
reject_keys(config, ['review'], 'configuration')
|
|
24
24
|
review = config.fetch('review', {})
|
|
25
25
|
raise Error, 'review must be a mapping.' unless review.is_a?(Hash)
|
|
26
|
-
reject_keys(review, %w[language style glossary exclude allows attributes base_dir tokenizer mecab_command mecab_dictionary rules heading_rules], 'review')
|
|
26
|
+
reject_keys(review, %w[language style glossary exclude allows attributes base_dir tokenizer mecab_command mecab_dictionary rules heading_rules heading_depth], 'review')
|
|
27
27
|
raise Error, 'attributes must be a mapping.' unless review.fetch('attributes', {}).is_a?(Hash)
|
|
28
28
|
%w[base_dir glossary mecab_command mecab_dictionary rules heading_rules].each do |key|
|
|
29
29
|
raise Error, "#{key} must be a nonempty path string." if review.key?(key) && (!review[key].is_a?(String) || review[key].empty?)
|
|
@@ -38,6 +38,16 @@ module AsciidocPubkit
|
|
|
38
38
|
if scope != 'headings' && options[:heading_rules]
|
|
39
39
|
raise Error, '--heading-rules requires --scope headings.'
|
|
40
40
|
end
|
|
41
|
+
if options.key?(:depth) && scope != 'headings'
|
|
42
|
+
raise Error, '--depth requires --scope headings.'
|
|
43
|
+
end
|
|
44
|
+
if review.key?('heading_depth') && (!review['heading_depth'].is_a?(Integer) || review['heading_depth'] < 1)
|
|
45
|
+
raise Error, 'heading_depth must be a positive integer.'
|
|
46
|
+
end
|
|
47
|
+
depth = options.fetch(:depth, review['heading_depth']) if scope == 'headings'
|
|
48
|
+
if depth && (!depth.is_a?(Integer) || depth < 1)
|
|
49
|
+
raise Error, 'Depth must be a positive integer.'
|
|
50
|
+
end
|
|
41
51
|
heading_rules_path = options[:heading_rules] ? File.expand_path(options[:heading_rules]) : (review['heading_rules'] ? File.expand_path(review['heading_rules'], base) : HeadingRuleSet.default_path(language))
|
|
42
52
|
rules_path = options[:replacement] ? RuleSet.default_path(language) : options[:rules] ? File.expand_path(options[:rules]) : (review['rules'] ? File.expand_path(review['rules'], base) : RuleSet.default_path(language))
|
|
43
53
|
@data = {
|
|
@@ -58,6 +68,7 @@ module AsciidocPubkit
|
|
|
58
68
|
'base_dir' => File.expand_path(options[:base_dir] || review.fetch('base_dir', base), options[:base_dir] ? Dir.pwd : base),
|
|
59
69
|
'glossary' => {}
|
|
60
70
|
}
|
|
71
|
+
@data['heading_depth'] = depth if depth
|
|
61
72
|
raise Error, 'tokenizer must be mecab or literal.' unless %w[mecab literal].include?(@data['tokenizer'])
|
|
62
73
|
raise Error, 'style must be preserve, desu-masu, or dearu.' unless %w[preserve desu-masu dearu].include?(@data['style'])
|
|
63
74
|
%w[exclude allows].each do |key|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'rubygems/version'
|
|
6
|
+
|
|
7
|
+
module AsciidocPubkit
|
|
8
|
+
# Manages bundled skills at an explicit path or agent-specific project path.
|
|
9
|
+
class SkillCommands
|
|
10
|
+
MANIFEST = '.asciidoc-pubkit-skill.json'
|
|
11
|
+
LOCK = '.asciidoc-pubkit-skills.lock'
|
|
12
|
+
AGENT_DIRECTORIES = { 'codex' => '.agents/skills', 'claude' => '.claude/skills' }.freeze
|
|
13
|
+
|
|
14
|
+
def self.bundled_root
|
|
15
|
+
File.expand_path('../../examples/skills', __dir__)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.run(command, args, out:, err:)
|
|
19
|
+
raise Error, 'Unknown skill command. Use skill --help.' unless %w[list install status upgrade].include?(command)
|
|
20
|
+
options = {}
|
|
21
|
+
parser = OptionParser.new do |opts|
|
|
22
|
+
opts.banner = "Usage: asciidoc-pubkit skill #{command}#{command == 'list' ? '' : ' NAME (--agent AGENT | --dir PATH)'} [options]"
|
|
23
|
+
unless command == 'list'
|
|
24
|
+
opts.on('--dir PATH', 'Explicit parent directory for installed skills') { |v| options[:dir] = v }
|
|
25
|
+
opts.on('--agent AGENT', AGENT_DIRECTORIES.keys, 'Project destination: codex or claude (does not invoke an agent)') { |v| options[:agent] = v }
|
|
26
|
+
end
|
|
27
|
+
opts.on('--json', 'Output structured skill information') { options[:json] = true } if %w[list status].include?(command)
|
|
28
|
+
opts.on('-h', '--help', 'Show command help') { options[:help] = true }
|
|
29
|
+
end
|
|
30
|
+
parser.parse!(args)
|
|
31
|
+
if options[:help]
|
|
32
|
+
out.puts parser
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
if command == 'list'
|
|
36
|
+
raise Error, 'skill list does not accept a name or destination.' unless args.empty?
|
|
37
|
+
skills = Dir.children(bundled_root).sort.select { |name| File.directory?(File.join(bundled_root, name)) }.map { |name| new(name).info }
|
|
38
|
+
out.puts(options[:json] ? JSON.pretty_generate({ 'tool_version' => VERSION, 'skills' => skills }) : skills.map { |s| "#{s['name']}: #{s['description']}" }.join("\n"))
|
|
39
|
+
return 0
|
|
40
|
+
end
|
|
41
|
+
if options[:dir] && options[:agent]
|
|
42
|
+
raise Error, 'Choose either --agent AGENT or --dir PATH, not both.'
|
|
43
|
+
end
|
|
44
|
+
directory = options[:dir] || AGENT_DIRECTORIES[options[:agent]]
|
|
45
|
+
unless args.length == 1 && directory && !directory.empty?
|
|
46
|
+
raise Error, 'Provide one bundled skill name and either --agent AGENT or --dir PATH.'
|
|
47
|
+
end
|
|
48
|
+
skill = new(args.first)
|
|
49
|
+
root = File.expand_path(directory)
|
|
50
|
+
check_path(root)
|
|
51
|
+
destination = File.join(root, skill.name)
|
|
52
|
+
if command == 'status'
|
|
53
|
+
report = skill.status(destination)
|
|
54
|
+
out.puts(options[:json] ? JSON.pretty_generate(report) : format_status(report))
|
|
55
|
+
return report['state'] == 'current' ? 0 : 1
|
|
56
|
+
end
|
|
57
|
+
if command == 'upgrade' && !%w[current update-available].include?(skill.status(destination)['state'])
|
|
58
|
+
raise Error, "Cannot upgrade this skill. Use skill status to inspect it; existing content was kept."
|
|
59
|
+
end
|
|
60
|
+
FileUtils.mkdir_p(root)
|
|
61
|
+
check_path(root)
|
|
62
|
+
lock = File.join(root, LOCK)
|
|
63
|
+
begin
|
|
64
|
+
Dir.mkdir(lock, 0o700)
|
|
65
|
+
rescue Errno::EEXIST
|
|
66
|
+
raise Error, "Skill destination is locked: #{lock}. Another install or upgrade may be running."
|
|
67
|
+
end
|
|
68
|
+
begin
|
|
69
|
+
result = skill.publish(destination, command)
|
|
70
|
+
ensure
|
|
71
|
+
Dir.rmdir(lock)
|
|
72
|
+
end
|
|
73
|
+
if result == :unchanged
|
|
74
|
+
out.puts "Already current: #{destination}"
|
|
75
|
+
return 0
|
|
76
|
+
end
|
|
77
|
+
out.puts "#{command == 'install' ? 'Installed' : 'Upgraded'} #{skill.name} from asciidoc-pubkit #{VERSION}: #{destination}"
|
|
78
|
+
0
|
|
79
|
+
rescue Error, OptionParser::ParseError, SystemCallError, JSON::ParserError, Psych::Exception, ArgumentError => e
|
|
80
|
+
err.puts "Error: #{e.message}"
|
|
81
|
+
2
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.check_path(path)
|
|
85
|
+
Pathname.new(path).ascend do |part|
|
|
86
|
+
raise Error, "Symlink paths are not supported: #{part}" if File.symlink?(part)
|
|
87
|
+
raise Error, "Expected a directory: #{part}" if File.exist?(part) && !File.directory?(part)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.format_status(report)
|
|
92
|
+
lines = ["Skill: #{report['name']}", "Destination: #{report['destination']}", "State: #{report['state']}",
|
|
93
|
+
"Installed pubkit: #{report['installed_version'] || 'unknown'}", "Available pubkit: #{VERSION}"]
|
|
94
|
+
%w[modified missing extra updated].each do |kind|
|
|
95
|
+
report.fetch(kind, []).each { |file| lines << "#{kind.capitalize}: #{file}" }
|
|
96
|
+
end
|
|
97
|
+
lines.join("\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
attr_reader :name
|
|
101
|
+
|
|
102
|
+
def initialize(name)
|
|
103
|
+
raise Error, 'Invalid skill name.' unless name.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) && name.length <= 64
|
|
104
|
+
@name = name
|
|
105
|
+
@source = File.join(self.class.bundled_root, name)
|
|
106
|
+
raise Error, "Unknown bundled skill: #{name}. Use skill list." unless File.directory?(@source)
|
|
107
|
+
self.class.check_path(@source)
|
|
108
|
+
@files = snapshot(@source)
|
|
109
|
+
raise Error, 'Bundled skill contains reserved registration metadata.' if @files.key?(MANIFEST)
|
|
110
|
+
text = AsciidocPubkit.read_text(File.join(@source, 'SKILL.md'))
|
|
111
|
+
frontmatter = text.match(/\A---\n(.*?)\n---(?:\n|\z)/m)
|
|
112
|
+
metadata = frontmatter && YAML.safe_load(frontmatter[1], permitted_classes: [], aliases: false)
|
|
113
|
+
unless metadata.is_a?(Hash) && metadata['name'] == name && metadata['description'].is_a?(String) && !metadata['description'].strip.empty?
|
|
114
|
+
raise Error, "Invalid bundled SKILL.md: #{name}"
|
|
115
|
+
end
|
|
116
|
+
@description = metadata['description']
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def info
|
|
120
|
+
{ 'name' => name, 'description' => @description, 'tool_version' => VERSION, 'files' => @files.keys.sort }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def snapshot(root)
|
|
124
|
+
files = {}
|
|
125
|
+
Find.find(root) do |path|
|
|
126
|
+
raise Error, "Symlink skill content is not supported: #{path}" if File.symlink?(path)
|
|
127
|
+
next if File.directory?(path)
|
|
128
|
+
raise Error, "Unsupported skill file: #{path}" unless File.file?(path)
|
|
129
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
130
|
+
files[relative] = Digest::SHA256.file(path).hexdigest
|
|
131
|
+
end
|
|
132
|
+
files
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def registration(destination)
|
|
136
|
+
path = File.join(destination, MANIFEST)
|
|
137
|
+
raise Error, "Symlink registration is not supported: #{path}" if File.symlink?(path)
|
|
138
|
+
return nil unless File.exist?(path)
|
|
139
|
+
raise Error, "Expected a registration file: #{path}" unless File.file?(path)
|
|
140
|
+
data = JSON.parse(AsciidocPubkit.read_text(path))
|
|
141
|
+
valid = data.is_a?(Hash) && data.keys.sort == %w[files name schema_version tool tool_version] &&
|
|
142
|
+
data['schema_version'] == 1 && data['tool'] == 'asciidoc-pubkit' && data['name'] == name &&
|
|
143
|
+
data['tool_version'].is_a?(String) && !data['tool_version'].empty? && Gem::Version.correct?(data['tool_version']) &&
|
|
144
|
+
data['files'].is_a?(Hash) && data['files'].key?('SKILL.md') && !data['files'].key?(MANIFEST)
|
|
145
|
+
valid &&= data['files'].all? do |file, sha|
|
|
146
|
+
file.is_a?(String) && !file.empty? && !file.start_with?('/') && !file.include?('\\') &&
|
|
147
|
+
file.split('/', -1).none? { |part| ['', '.', '..'].include?(part) || part.include?("\0") } &&
|
|
148
|
+
sha.is_a?(String) && sha.match?(/\A[0-9a-f]{64}\z/)
|
|
149
|
+
end
|
|
150
|
+
raise Error, "Invalid skill registration: #{path}" unless valid
|
|
151
|
+
data
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def status(destination)
|
|
155
|
+
self.class.check_path(destination)
|
|
156
|
+
report = { 'name' => name, 'destination' => destination, 'available_version' => VERSION,
|
|
157
|
+
'installed_version' => nil, 'state' => 'not-installed', 'modified' => [], 'missing' => [], 'extra' => [], 'updated' => [] }
|
|
158
|
+
return report unless File.directory?(destination)
|
|
159
|
+
data = registration(destination)
|
|
160
|
+
return report.merge('state' => 'unmanaged') unless data
|
|
161
|
+
actual = snapshot(destination).reject { |file, _| file == MANIFEST }
|
|
162
|
+
expected = data['files']
|
|
163
|
+
report['installed_version'] = data['tool_version']
|
|
164
|
+
report['modified'] = (expected.keys & actual.keys).select { |file| expected[file] != actual[file] }.sort
|
|
165
|
+
report['missing'] = (expected.keys - actual.keys).sort
|
|
166
|
+
report['extra'] = (actual.keys - expected.keys).sort
|
|
167
|
+
# Even empty additional directories are user-owned content.
|
|
168
|
+
permitted_dirs = expected.keys.flat_map do |file|
|
|
169
|
+
parts = file.split('/')[0...-1]
|
|
170
|
+
(1..parts.length).map { |length| parts.first(length).join('/') }
|
|
171
|
+
end
|
|
172
|
+
Find.find(destination) do |path|
|
|
173
|
+
next unless File.directory?(path) && path != destination
|
|
174
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(destination)).to_s
|
|
175
|
+
report['extra'] << relative + '/' unless permitted_dirs.include?(relative)
|
|
176
|
+
end
|
|
177
|
+
report['extra'].sort!
|
|
178
|
+
report['updated'] = (expected.keys | @files.keys).select { |file| expected[file] != @files[file] }.sort
|
|
179
|
+
report['state'] = if %w[modified missing extra].any? { |kind| !report[kind].empty? }
|
|
180
|
+
'modified'
|
|
181
|
+
elsif Gem::Version.new(data['tool_version']) > Gem::Version.new(VERSION)
|
|
182
|
+
'newer-installed'
|
|
183
|
+
elsif data['tool_version'] != VERSION || !report['updated'].empty?
|
|
184
|
+
'update-available'
|
|
185
|
+
else
|
|
186
|
+
'current'
|
|
187
|
+
end
|
|
188
|
+
report
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def publish(destination, command)
|
|
192
|
+
self.class.check_path(destination)
|
|
193
|
+
if command == 'install'
|
|
194
|
+
raise Error, "Skill destination already exists: #{destination}. Use skill status or choose a different directory." if File.exist?(destination)
|
|
195
|
+
else
|
|
196
|
+
report = status(destination)
|
|
197
|
+
unless %w[current update-available].include?(report['state'])
|
|
198
|
+
raise Error, "Cannot upgrade #{report['state']} skill: #{destination}. Use skill status to inspect it; existing content was kept."
|
|
199
|
+
end
|
|
200
|
+
return :unchanged if report['state'] == 'current'
|
|
201
|
+
end
|
|
202
|
+
workspace = Dir.mktmpdir('.asciidoc-pubkit-skill-', File.dirname(destination))
|
|
203
|
+
staged = File.join(workspace, 'new')
|
|
204
|
+
backup = File.join(workspace, 'old')
|
|
205
|
+
preserve_backup = false
|
|
206
|
+
begin
|
|
207
|
+
Dir.mkdir(staged)
|
|
208
|
+
@files.each do |file, sha|
|
|
209
|
+
source = File.join(@source, file)
|
|
210
|
+
raise Error, 'Bundled skill changed while staging.' unless Digest::SHA256.file(source).hexdigest == sha
|
|
211
|
+
target = File.join(staged, file)
|
|
212
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
213
|
+
FileUtils.copy_file(source, target)
|
|
214
|
+
File.chmod(File.stat(source).mode & 0o777, target)
|
|
215
|
+
raise Error, 'Bundled skill changed while copying.' unless Digest::SHA256.file(target).hexdigest == sha
|
|
216
|
+
end
|
|
217
|
+
File.write(File.join(staged, MANIFEST), JSON.pretty_generate({ 'schema_version' => 1, 'tool' => 'asciidoc-pubkit',
|
|
218
|
+
'name' => name, 'tool_version' => VERSION, 'files' => @files }) + "\n")
|
|
219
|
+
self.class.check_path(destination)
|
|
220
|
+
if command == 'upgrade'
|
|
221
|
+
raise Error, 'Installed skill changed while staging.' unless status(destination) == report
|
|
222
|
+
File.rename(destination, backup)
|
|
223
|
+
elsif File.exist?(destination)
|
|
224
|
+
raise Error, "Skill destination already exists: #{destination}"
|
|
225
|
+
end
|
|
226
|
+
begin
|
|
227
|
+
File.rename(staged, destination)
|
|
228
|
+
rescue SystemCallError
|
|
229
|
+
if File.directory?(backup)
|
|
230
|
+
preserve_backup = true
|
|
231
|
+
begin
|
|
232
|
+
File.rename(backup, destination)
|
|
233
|
+
preserve_backup = false
|
|
234
|
+
rescue SystemCallError => restore_error
|
|
235
|
+
raise Error, "Could not restore the skill. Original content is preserved at #{backup}: #{restore_error.message}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
raise
|
|
239
|
+
end
|
|
240
|
+
ensure
|
|
241
|
+
FileUtils.remove_entry_secure(workspace) unless preserve_backup
|
|
242
|
+
end
|
|
243
|
+
:updated
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -18,6 +18,11 @@ module AsciidocPubkit
|
|
|
18
18
|
AsciidocPubkit.read_text(File.join(DATA_ROOT, language, 'headings.md'))
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def self.outline_criteria(language = Language::DEFAULT)
|
|
22
|
+
Language.validate!(language, operation: 'review')
|
|
23
|
+
AsciidocPubkit.read_text(File.join(DATA_ROOT, language, 'outline.md'))
|
|
24
|
+
end
|
|
25
|
+
|
|
21
26
|
def self.prompt(language = Language::DEFAULT)
|
|
22
27
|
guide = prompt_criteria(language)
|
|
23
28
|
<<~TEXT
|
data/lib/asciidoc_pubkit.rb
CHANGED
|
@@ -9,7 +9,7 @@ require 'pathname'
|
|
|
9
9
|
require 'optparse'
|
|
10
10
|
|
|
11
11
|
module AsciidocPubkit
|
|
12
|
-
VERSION = '1.0
|
|
12
|
+
VERSION = '1.2.0'
|
|
13
13
|
class Error < StandardError; end
|
|
14
14
|
|
|
15
15
|
def self.hash_text(text)
|
|
@@ -36,4 +36,5 @@ require_relative 'asciidoc_pubkit/local_evaluator'
|
|
|
36
36
|
require_relative 'asciidoc_pubkit/score'
|
|
37
37
|
require_relative 'asciidoc_pubkit/replacement'
|
|
38
38
|
require_relative 'asciidoc_pubkit/document_commands'
|
|
39
|
+
require_relative 'asciidoc_pubkit/skill_commands'
|
|
39
40
|
require_relative 'asciidoc_pubkit/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asciidoc-pubkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CyberGarage
|
|
@@ -53,6 +53,8 @@ files:
|
|
|
53
53
|
- data/review-rules.ja.yml
|
|
54
54
|
- data/writing/ja/criteria.md
|
|
55
55
|
- data/writing/ja/headings.md
|
|
56
|
+
- data/writing/ja/outline.md
|
|
57
|
+
- docs/agent-review-tutorial.md
|
|
56
58
|
- docs/architecture.md
|
|
57
59
|
- docs/configuration.md
|
|
58
60
|
- docs/custom-rules.md
|
|
@@ -61,15 +63,19 @@ files:
|
|
|
61
63
|
- docs/english-review-research.md
|
|
62
64
|
- docs/installation.md
|
|
63
65
|
- docs/list-review.md
|
|
66
|
+
- docs/outline-review.md
|
|
64
67
|
- docs/prose-evaluation.md
|
|
65
68
|
- docs/references.md
|
|
66
69
|
- docs/replacements.md
|
|
67
70
|
- docs/review-workflow.md
|
|
68
71
|
- docs/rules-and-coverage.md
|
|
69
72
|
- docs/scoring.md
|
|
73
|
+
- docs/skill-commands.md
|
|
74
|
+
- docs/skill-integration.md
|
|
70
75
|
- docs/writing.md
|
|
71
76
|
- examples/book.adoc
|
|
72
77
|
- examples/replacements.yml
|
|
78
|
+
- examples/skills/review-asciidoc-manuscript/SKILL.md
|
|
73
79
|
- exe/asciidoc-pubkit
|
|
74
80
|
- lib/asciidoc_pubkit.rb
|
|
75
81
|
- lib/asciidoc_pubkit/cli.rb
|
|
@@ -85,6 +91,7 @@ files:
|
|
|
85
91
|
- lib/asciidoc_pubkit/score.rb
|
|
86
92
|
- lib/asciidoc_pubkit/session.rb
|
|
87
93
|
- lib/asciidoc_pubkit/settings.rb
|
|
94
|
+
- lib/asciidoc_pubkit/skill_commands.rb
|
|
88
95
|
- lib/asciidoc_pubkit/writing.rb
|
|
89
96
|
homepage: https://github.com/cybergarage/asciidoc-pubkit
|
|
90
97
|
licenses:
|