mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
Update gen-lifecycle-matrix.rb and validate-lifecycle-matrix.rb to drop all
hardcoded bundles/ path references after the directory flatten:
- CANONICAL_BUNDLES now selects canonical bundles via */manifest.yaml (the
8 bundle dirs) instead of */SKILL.md (which would match every top-level
skill); keeps the 8-bundle invariant required by the validation contract.
- source strings, manifest paths, fallback frontmatter, and base dirs use
<name>/... at the repo root; the top-level JSON key stays "bundles".
- validate-lifecycle-matrix.rb re-anchors NESTED_HELPER_PATTERN to
%r{(?:^|/)skills/} so nested helpers (tailscale/skills/*,
workflow-architect/skills/*) are still detected in the catalogs, and
canonical_bundles matches the generator's */manifest.yaml glob.
- align the two matrix tests in test-validate-bundles.rb: canonical bundles
are manifest-having dirs (a manifest-less top-level skill is not a row),
and the missing-row rejection targets the demo manifest bundle.
- regenerate docs/lifecycle-capability-matrix.{md,json}.
gen check mode: "is current (8 bundles)"; validate:
"Validated lifecycle capability matrix (8 bundle(s)): complete, traceable, and current."
test-validate-bundles.rb: 14 runs, 307 assertions, 0 failures.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
307 lines
11 KiB
Ruby
307 lines
11 KiB
Ruby
#!/usr/bin/env ruby
|
|
# frozen_string_literal: true
|
|
|
|
# Generates and validates the lifecycle capability matrix artifacts:
|
|
#
|
|
# docs/lifecycle-capability-matrix.md # human-readable, one row per canonical bundle
|
|
# docs/lifecycle-capability-matrix.json # machine-readable, per-cell source provenance
|
|
#
|
|
# Every matrix entry derives from a declared bundle manifest field
|
|
# (<name>/manifest.yaml) or a documented derivation (SKILL.md
|
|
# frontmatter description for manifest-less bundles; a documented deferral
|
|
# marker for the remaining cells). See docs/bundle-manifest-design.md.
|
|
#
|
|
# Conventions follow gen-claude-marketplace.rb / gen-codex-plugin.rb /
|
|
# gen-llms-txt.rb: ROOT constant, the same PUBLIC_SKILLS glob semantics,
|
|
# YAML.safe_load frontmatter parsing, deterministic ordering, --write mode,
|
|
# and a check mode that exits non-zero with a "Run: ... --write" hint when
|
|
# output is stale.
|
|
#
|
|
# Usage:
|
|
# ruby scripts/gen-lifecycle-matrix.rb # validate committed artifacts are current
|
|
# ruby scripts/gen-lifecycle-matrix.rb --write # regenerate both artifacts
|
|
|
|
require "fileutils"
|
|
require "json"
|
|
require "yaml"
|
|
|
|
ROOT = File.expand_path("..", __dir__)
|
|
SCHEMA_VERSION = 1
|
|
GENERATOR_REL = "scripts/gen-lifecycle-matrix.rb"
|
|
MD_PATH = File.join(ROOT, "docs", "lifecycle-capability-matrix.md")
|
|
JSON_PATH = File.join(ROOT, "docs", "lifecycle-capability-matrix.json")
|
|
DEFERRED_MARKER = "migration deferred — see docs/bundle-manifest-design.md §Migration path"
|
|
DESIGN_NOTE = "docs/bundle-manifest-design.md#migration-path"
|
|
|
|
# Public install candidates: top-level skills and bundle entrypoints.
|
|
# Same glob as the other generators and validate-skills.rb; excludes
|
|
# bundle-internal helper skills (<x>/skills/<sub>/SKILL.md).
|
|
PUBLIC_SKILLS = Dir.glob("#{ROOT}/*/SKILL.md").sort
|
|
|
|
CANONICAL_BUNDLES = Dir.glob("#{ROOT}/*/manifest.yaml").map do |path|
|
|
File.basename(File.dirname(path))
|
|
end.sort
|
|
|
|
def normalize_description(text)
|
|
text.to_s.gsub(/\s+/, " ").strip
|
|
end
|
|
|
|
def frontmatter(skill_path)
|
|
text = File.read(skill_path)
|
|
match = text.match(/\A---\n(.*?)\n---\n/m)
|
|
raise "#{skill_path}: missing YAML frontmatter" unless match
|
|
|
|
data = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
|
|
raise "#{skill_path}: frontmatter must be a mapping" unless data.is_a?(Hash)
|
|
|
|
data
|
|
end
|
|
|
|
def skill_display_name(entry, base_dir = ROOT)
|
|
expanded = File.expand_path(entry, base_dir)
|
|
return entry unless File.file?(expanded)
|
|
|
|
File.basename(File.dirname(expanded))
|
|
rescue ArgumentError
|
|
entry
|
|
end
|
|
|
|
def field_cell(relative_manifest, field, value)
|
|
{ "value" => value, "source" => "#{relative_manifest}#/#{field}" }
|
|
end
|
|
|
|
def deferred_cell
|
|
{ "value" => DEFERRED_MARKER, "source" => DESIGN_NOTE, "derivation" => "migration-deferred" }
|
|
end
|
|
|
|
def manifest_row(name, data, relative_manifest)
|
|
{
|
|
"name" => name,
|
|
"derivation" => "manifest",
|
|
"source" => relative_manifest,
|
|
"fields" => {
|
|
"purpose" => field_cell(relative_manifest, "purpose", data["purpose"]),
|
|
"audience" => field_cell(relative_manifest, "audience", data["audience"]),
|
|
"stages" => field_cell(relative_manifest, "stages", data["stages"]),
|
|
"included_skills" => field_cell(relative_manifest, "included_skills", data["included_skills"]),
|
|
"prerequisites" => field_cell(relative_manifest, "prerequisites", data["prerequisites"]),
|
|
"outputs" => field_cell(relative_manifest, "outputs", data["outputs"]),
|
|
"handoffs" => field_cell(relative_manifest, "handoffs", data["handoffs"]),
|
|
"conflicts" => field_cell(relative_manifest, "conflicts", data["conflicts"]),
|
|
"eval_suite" => field_cell(relative_manifest, "eval_suite", data["eval_suite"])
|
|
}
|
|
}
|
|
end
|
|
|
|
def deferred_row(name, description)
|
|
{
|
|
"name" => name,
|
|
"derivation" => "frontmatter-description-and-deferred-migration",
|
|
"source" => "#{name}/SKILL.md",
|
|
"fields" => {
|
|
"purpose" => { "value" => description, "source" => "#{name}/SKILL.md#/description", "derivation" => "frontmatter-description" },
|
|
"audience" => deferred_cell,
|
|
"stages" => deferred_cell,
|
|
"included_skills" => deferred_cell,
|
|
"prerequisites" => deferred_cell,
|
|
"outputs" => deferred_cell,
|
|
"handoffs" => deferred_cell,
|
|
"conflicts" => deferred_cell,
|
|
"eval_suite" => deferred_cell
|
|
}
|
|
}
|
|
end
|
|
|
|
def build_matrix
|
|
CANONICAL_BUNDLES.map do |name|
|
|
manifest = File.join(ROOT, name, "manifest.yaml")
|
|
relative_manifest = "#{name}/manifest.yaml"
|
|
if File.file?(manifest)
|
|
data = YAML.safe_load(File.read(manifest), permitted_classes: [], aliases: false)
|
|
raise "#{relative_manifest}: manifest must be a mapping" unless data.is_a?(Hash)
|
|
|
|
manifest_row(name, data, relative_manifest)
|
|
else
|
|
description = normalize_description(frontmatter(File.join(ROOT, name, "SKILL.md"))["description"])
|
|
deferred_row(name, description)
|
|
end
|
|
end
|
|
end
|
|
|
|
def cell_value(cell)
|
|
cell["value"]
|
|
end
|
|
|
|
def join_cells(values)
|
|
values.compact.reject { |value| value.to_s.strip.empty? }.join(", ")
|
|
end
|
|
|
|
def summary_cell(row, field)
|
|
base = File.join(ROOT, row["name"])
|
|
value = cell_value(row["fields"][field])
|
|
result =
|
|
case field
|
|
when "purpose"
|
|
value.to_s
|
|
when "audience"
|
|
value.to_s
|
|
when "stages"
|
|
value.is_a?(Array) ? value.map { |stage| stage["name"] }.join(" → ") : value.to_s
|
|
when "included_skills"
|
|
value.is_a?(Array) ? value.map { |entry| skill_display_name(entry, base) }.join(", ") : value.to_s
|
|
when "prerequisites"
|
|
if value.is_a?(Array)
|
|
value.map do |entry|
|
|
skill = entry["skill"] ? "(#{skill_display_name(entry['skill'], base)})" : ""
|
|
"#{entry['artifact']}#{skill}"
|
|
end.join("; ")
|
|
else
|
|
value.to_s
|
|
end
|
|
when "outputs"
|
|
value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
when "handoffs"
|
|
if value.is_a?(Array)
|
|
value.map { |entry| "#{entry['artifact']} → #{entry['to']}" }.join("; ")
|
|
else
|
|
value.to_s
|
|
end
|
|
when "conflicts"
|
|
if value.is_a?(Array)
|
|
value.map { |entry| "#{skill_display_name(entry['skill'], base)} (with #{entry['with']})" }.join("; ")
|
|
else
|
|
value.to_s
|
|
end
|
|
when "eval_suite"
|
|
value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
else
|
|
value.to_s
|
|
end
|
|
result.strip.empty? ? "none declared" : result
|
|
end
|
|
|
|
def render_markdown(matrix)
|
|
lines = []
|
|
lines << "# Lifecycle Capability Matrix"
|
|
lines << ""
|
|
lines << "Deterministically generated by `#{GENERATOR_REL}` (schema version #{SCHEMA_VERSION}). "
|
|
lines << "Every matrix entry derives from a declared bundle manifest field or a documented "
|
|
lines << "derivation; see [bundle-manifest-design.md](bundle-manifest-design.md) for the schema, "
|
|
lines << "the migration path, and the field definitions. Regenerate with "
|
|
lines << "`ruby scripts/gen-lifecycle-matrix.rb --write`."
|
|
lines << ""
|
|
lines << "## Summary"
|
|
lines << ""
|
|
lines << "| Bundle | Purpose | Audience | Lifecycle stages | Included skills | Prerequisites | Outputs | Handoffs | Conflicts | Eval suite |"
|
|
lines << "|---|---|---|---|---|---|---|---|---|---|"
|
|
matrix.each do |row|
|
|
cells = ["#{row['name']}"] + %w[purpose audience stages included_skills prerequisites outputs handoffs conflicts eval_suite].map do |field|
|
|
summary_cell(row, field)
|
|
end
|
|
lines << "| #{cells.join(' | ')} |"
|
|
end
|
|
lines << ""
|
|
lines << "## Details"
|
|
lines << ""
|
|
matrix.each do |row|
|
|
lines << "### #{row['name']}"
|
|
lines << ""
|
|
lines << "- Derivation: `#{row['derivation']}` — source: `#{row['source']}`"
|
|
base = File.join(ROOT, row["name"])
|
|
fields = row["fields"]
|
|
lines << "- **Purpose:** #{cell_value(fields['purpose'])}"
|
|
lines << "- **Audience:** #{cell_value(fields['audience'])}"
|
|
stages = cell_value(fields["stages"])
|
|
if stages.is_a?(Array)
|
|
lines << "- **Stages (ordered):**"
|
|
stages.each_with_index do |stage, index|
|
|
skills = stage["skills"].map { |entry| "#{skill_display_name(entry, base)} (`#{entry}`)" }.join(", ")
|
|
lines << " #{index + 1}. #{stage['name']} — #{skills}"
|
|
end
|
|
else
|
|
lines << "- **Stages:** #{stages}"
|
|
end
|
|
included = cell_value(fields["included_skills"])
|
|
if included.is_a?(Array)
|
|
lines << "- **Included skills:**"
|
|
included.each { |entry| lines << " - #{skill_display_name(entry, base)} (`#{entry}`)" }
|
|
else
|
|
lines << "- **Included skills:** #{included}"
|
|
end
|
|
prereqs = cell_value(fields["prerequisites"])
|
|
if prereqs.is_a?(Array)
|
|
lines << "- **Prerequisites:**"
|
|
prereqs.each do |entry|
|
|
skill = entry["skill"] ? " — via #{skill_display_name(entry['skill'], base)} (`#{entry['skill']}`)" : ""
|
|
lines << " - #{entry['artifact']}#{skill}"
|
|
end
|
|
else
|
|
lines << "- **Prerequisites:** #{prereqs}"
|
|
end
|
|
outputs = cell_value(fields["outputs"])
|
|
lines << "- **Outputs:** #{outputs.is_a?(Array) ? outputs.join(', ') : outputs}"
|
|
handoffs = cell_value(fields["handoffs"])
|
|
if handoffs.is_a?(Array)
|
|
lines << "- **Handoffs:**"
|
|
handoffs.each do |entry|
|
|
note = entry["note"] ? " — #{entry['note']}" : ""
|
|
lines << " - #{entry['artifact']} → #{entry['to']}#{note}"
|
|
end
|
|
else
|
|
lines << "- **Handoffs:** #{handoffs}"
|
|
end
|
|
conflicts = cell_value(fields["conflicts"])
|
|
if conflicts.is_a?(Array)
|
|
if conflicts.empty?
|
|
lines << "- **Conflicts:** none declared"
|
|
else
|
|
lines << "- **Conflicts:**"
|
|
conflicts.each do |entry|
|
|
lines << " - #{skill_display_name(entry['skill'], base)} (`#{entry['skill']}`) with #{entry['with']} — #{entry['guidance']}"
|
|
end
|
|
end
|
|
else
|
|
lines << "- **Conflicts:** #{conflicts}"
|
|
end
|
|
eval_suite = cell_value(fields["eval_suite"])
|
|
lines << "- **Eval suite:** #{eval_suite.is_a?(Array) ? eval_suite.join(', ') : eval_suite}"
|
|
lines << ""
|
|
end
|
|
lines.join("\n")
|
|
end
|
|
|
|
matrix = build_matrix
|
|
|
|
json_document = {
|
|
"schema_version" => SCHEMA_VERSION,
|
|
"generated_by" => GENERATOR_REL,
|
|
"bundles" => matrix
|
|
}
|
|
|
|
expected_md = render_markdown(matrix)
|
|
expected_json = JSON.pretty_generate(json_document) + "\n"
|
|
|
|
def write_artifacts(expected_md, expected_json)
|
|
FileUtils.mkdir_p(File.dirname(MD_PATH))
|
|
FileUtils.mkdir_p(File.dirname(JSON_PATH))
|
|
File.write(MD_PATH, expected_md)
|
|
File.write(JSON_PATH, expected_json)
|
|
puts "Wrote #{MD_PATH.delete_prefix("#{ROOT}/")} and #{JSON_PATH.delete_prefix("#{ROOT}/")} (#{CANONICAL_BUNDLES.length} bundles)."
|
|
end
|
|
|
|
def current?(path, expected)
|
|
File.file?(path) && File.read(path) == expected
|
|
end
|
|
|
|
if ARGV.include?("--write")
|
|
write_artifacts(expected_md, expected_json)
|
|
exit 0
|
|
end
|
|
|
|
unless current?(MD_PATH, expected_md) && current?(JSON_PATH, expected_json)
|
|
warn "lifecycle capability matrix is out of date. Run: ruby scripts/gen-lifecycle-matrix.rb --write"
|
|
exit 1
|
|
end
|
|
|
|
puts "lifecycle capability matrix is current (#{CANONICAL_BUNDLES.length} bundles)."
|