dev-flow-support

Internal runtime support for the universal dev-flow v2 stage skills — policy resolution, result-schema validation, record rendering, and stage-exit computation. Do not invoke directly.

SKILL.md

GitHub

Dev flow support

Agent-posture REST reads: For each REST read shown as gh api below, agent-posture sessions use /usr/local/bin/gh-api-read instead, keeping the endpoint, pagination and output flags. When a read names --hostname "$host", remove that flag and set GH_HOST="$host" on the wrapper invocation. In dev and bot postures the shown command remains valid. This substitution is for GET reads only; do not route writes or GraphQL through the GET-only wrapper.

This package gives review, integrate, orchestrate, and retro one implementation each of the mechanical steps the dev-flow v2 lifecycle depends on. It is a SKILL.md-bearing package only so current and legacy category-sync engines vendor it with the universal category; it has no user-facing workflow.

It exists because a stage skill that invokes a repository-root scripts/ path installs into a consumer repository that cannot run it (harmon-devkit#974). The skills sync is the only distribution channel for this runtime: a script one skill uses lives in that skill's own assets/, and a script several skills share lives here. Nothing is shipped through the harmon-init template.

Assets

Asset Used by What it does
assets/devflow-policy.mjs review, integrate, orchestrate, implement Resolve rigor, strategy, rounds, breadth, and role tiers from .devflow.toml and agent-registry.json — including the issue's tier inputs (the derived Tier from [tier.matrix], the pinned Tier, tier:<role>:* labels), ported from harmon-init 81bbe787 (harmon-devkit#1248).
assets/tier-inputs.mjs orchestrate, implement The consumer half of tier resolution: translate an issue's labels (and, on an organization repository, its Risk/Complexity fields in place of rating labels) into devflow-policy.mjs resolve flags, reconciling label conflicts and refusing an ambiguous pin; disclose renders the PR-body tier disclosure from the reader's output.
assets/.devflow-conformance-v2.json scripts/test-devflow-conformance.sh (source tree only) harmon-init's portable v2 policy corpus, byte-identical and blob-pinned, so the vendored reader is held to harmon-init's answers.
assets/validate-result-schemas.mjs review, integrate, orchestrate Schema-check one brief, result, adjudication, run, or plan document, plus the receipt checks a raw schema cannot express.
assets/run-record.mjs orchestrate The run-record writer: stage transitions, interventions (with a stable ruling snapshot and SHA-256), PR bindings, the promotion record (written by whichever session owns integration and decides promotion), and close (outcome plus archiving the run's orchestrator files), each through candidate → validate-result-schemas.mjs run → rename → readback; also the slate lock both writers take, with audited operator recovery through lock release --force --reason.
assets/plan-revise.mjs orchestrate The mechanical half of a plan recompute for a base move (bump base_sha, retire done issues and their lanes, extend the digest chain), refusing with the paths named whenever the move needs orchestrator judgment.
assets/render-dev-flow.sh → assets/render-dev-flow.mjs review, integrate, retro Render a run record into its PR-body and comment projections.
assets/dev-flow-exit.sh → assets/dev-flow-exit.mjs review, retro Compute a confidence stage's exit verdict from the run record.
assets/lib/toml-lite.mjs the readers above Restricted TOML parser.
assets/lib/merge-base-policy.sh step 0 below, integrate Decide the governing merge-base argument for a reader run directly: --merge-base-policy <file> when the merge base has .devflow.toml, --merge-base-policy-absent when it has none but the branch adds one (and only for a reader that supports the flag), neither flag when no side has a policy; indeterminate otherwise. A governing file itself.
assets/lib/json-schema-subset.mjs the validators above Hand-rolled JSON Schema subset validator.
assets/lib/cap-authorizations.mjs dev-flow-exit.mjs, render-dev-flow.mjs The one predicate for a maintainer-authorized round above the resolved cap: derives the in-memory ceiling from run.json's cap_authorizations[] (contiguous rounds only, never lifting a cap of 0) without touching policy.json.
assets/lib/run-exit-fixtures.mjs assets/test-dev-flow-exit.sh Fixture driver for the stage-exit corpus.
assets/schemas/ validate-result-schemas.mjs, render-dev-flow.mjs The package's own copy of the shared JSON Schemas — the default schemas directory, so a vendored consumer validates without a separate schema sync.

The tests for these assets live beside them (assets/test-*.sh) and are wired into harmon-devkit's task verify through root Taskfile targets that call the asset paths.

Resolving an issue's Tier

/orchestrate and /implement resolve the implementer tier from the issue, not only from .devflow.toml, and both follow this one procedure. The reader owns the order (operator tier instruction > pinned Tier > rigor:* and tier:<role>:* > derived Tier > default_rigor, ADR 2026-09-30 D5); the skill owns reading the issue and reconciling its labels, which assets/tier-inputs.mjs does so both skills do it identically. An unqualified tier:<value> label is not a role override: it is the issue's stored Tier, a cache of the derived Tier, or the pinned Tier when tier:pinned is also present. The Tier is a label on every owner type.

  1. Hold the self-modification boundary first (AGENTS.md: a branch may not choose the values or code that govern its own review). Invariant: every tier resolution runs this whole procedure, step 0 included, whenever it happens: at loop entry, at dispatch, at the PR profile line, or on any re-resolution. Which step is resolving never decides whether step 0 applies; the working tree does.

    • When it applies: the working tree differs from the merge base in a governing file: .devflow.toml, agent-registry.json, assets/devflow-policy.mjs, assets/lib/toml-lite.mjs, assets/lib/merge-base-policy.sh or assets/tier-inputs.mjs. Commits alone are not enough; an uncommitted, staged or untracked edit governs just as much:

      # step0_probe — $remote is the target remote the calling skill already
      # validated against $repo (owner/name). Pass its name as the argument.
      # Returns 0 and prints the governing files when step 0 applies, 1 when
      # the working tree changes none of them, and 2 when the answer is
      # INDETERMINATE: stop, never read "no output" as "no file".
      step0_probe() {
          local remote="${1:-}" default mb changed untracked files
          [ -n "$remote" ] || { echo "step 0 indeterminate: no validated target remote bound" >&2; return 2; }
          default="$(git symbolic-ref --short "refs/remotes/$remote/HEAD" 2>/dev/null)" && [ -n "$default" ] ||
              { echo "step 0 indeterminate: $remote has no default branch (git remote set-head $remote --auto)" >&2; return 2; }
          mb="$(git merge-base HEAD "$default")" && [ -n "$mb" ] ||
              { echo "step 0 indeterminate: no merge base between HEAD and $default" >&2; return 2; }
          changed="$(git diff --name-only "$mb")" || { echo "step 0 indeterminate: git diff failed" >&2; return 2; }
          untracked="$(git ls-files --others --exclude-standard)" || { echo "step 0 indeterminate: git ls-files failed" >&2; return 2; }
          files="$(printf '%s\n%s\n' "$changed" "$untracked" |
              grep -E '(^|/)(\.devflow\.toml|agent-registry\.json|dev-flow-support/assets/(devflow-policy\.mjs|lib/toml-lite\.mjs|lib/merge-base-policy\.sh|tier-inputs\.mjs))$')" || true
          [ -n "$files" ] || return 1
          printf '%s\n' "$files"
      }
      rc=0; governing="$(step0_probe "${remote:-}")" || rc=$?
      

      The caller passes its validated $remote binding: /implement binds it in step 1; /orchestrate validates it for the lane's target checkout before tier resolution. The probe never re-discovers it by URL suffix, which could select a same-path mirror on another host. The default branch is that remote's refs/remotes/<remote>/HEAD. Nothing is hard-coded to one remote name. git diff --name-only "$mb" (no ...HEAD) compares the merge base with the working tree, so committed, staged and unstaged edits all count, and git ls-files --others --exclude-standard adds untracked files. rc 0 means step 0 applies; 1 means it does not; 2 means stop as indeterminate. Every lookup's status is checked, so a remote, default branch or merge base that cannot be resolved is never mistaken for "no governing file".

    • What to do: before running any branch copy, materialize the merge-base copy of all six outside the worktree (git show <merge-base>:<path> into a scratch closure that keeps tier-inputs.mjs and devflow-policy.mjs beside their shared lib/, with lib/merge-base-policy.sh in it). Materialize the merge-base Taskfile.yml and any taskfiles/ it includes into the same closure. Run that tier-inputs.mjs with the closure's .devflow.toml as --policy, and that devflow-policy.mjs with the governing merge-base argument below, the merge-base registry as --merge-base-registry, and --taskfile-dir <closure>, so the gate-target list also comes from the merge base, never the branch's.

    • The merge base may have no .devflow.toml — a branch that adds it. That is not "no merge-base rule": the built-in fallback governs, never the branch's own new file. Ask the shared helper rather than inferring absence from a failed git show; it looks the path up with a checked tree read, materializes the file when present, and answers one word, mapping it to the one argument both recipes pass. Both this step and /integrate's merge-base recipe call it, so the whole stage chain resolves the same governing policy:

      # $mb is step0_probe's merge base; $closure the materialized closure,
      # $mb_reader the merge-base devflow-policy.mjs inside it; tier_args are
      # step 3's translated flags, from the closure's tier-inputs.mjs.
      # The helper and the reader govern too: trusted_copy (the First adoption
      # rule below, implemented once, here) prints the merge base's own copy
      # when it declares the capability marker, and nothing otherwise.
      trusted_copy() {
          [ -f "$1" ] && grep -Fq 'dev-flow-capability: merge-base-absent-v1' "$1" && printf '%s' "$1"
          return 0
      }
      mb_helper="$(trusted_copy "$(dirname "$mb_reader")/lib/merge-base-policy.sh")"
      [ -n "$mb_helper" ] ||
          { echo "step 0 indeterminate: the merge base's lib/merge-base-policy.sh does not declare the capability (first adoption)" >&2; exit 2; }
      mb_reader="$(trusted_copy "$mb_reader")"
      [ -n "$mb_reader" ] ||
          { echo "step 0 indeterminate: the merge base's devflow-policy.mjs does not declare the capability (first adoption)" >&2; exit 2; }
      mb_state="$(bash "$mb_helper" "$mb" "$mb_reader" "$closure/.devflow.toml" .devflow.toml)" ||
          { echo "step 0 stopped: merge-base policy lookup failed or the reader is too old" >&2; exit 2; }
      case "$mb_state" in
      present) mb_policy=(--merge-base-policy "$closure/.devflow.toml") ;;
      absent) mb_policy=(--merge-base-policy-absent) ;;
      none) mb_policy=() ;;
      *) echo "step 0 stopped: unexpected answer '$mb_state'" >&2; exit 2 ;;
      esac
      node "$mb_reader" resolve --policy .devflow.toml ${mb_policy[@]+"${mb_policy[@]}"} \
          --merge-base-registry "$closure/agent-registry.json" \
          --registry agent-registry.json --taskfile-dir "$closure" \
          --json ${tier_args[@]+"${tier_args[@]}"}
      

      present passes --merge-base-policy <file>; absent (the merge base has no policy, the branch adds one) passes --merge-base-policy-absent; none (neither side has a policy, so step 0 fired on another governing file) passes neither flag, because the reader refuses --merge-base-policy-absent without an existing --policy file and resolves the built-in fallback on its own when the file is missing. The helper's fourth argument is the branch policy path the reader gets as --policy, which is how it tells absent from none. On absent and none, tier-inputs.mjs reads the closure's missing .devflow.toml, which is the built-in fallback's catalog too. Any non-zero status stops step 0 as indeterminate — never read it, or empty output, as "absent". On absent the helper has already confirmed the merge-base reader's source names merge-base-policy-absent (the same check the reader's own --closure probe makes): a reader written before the flag accepts any option, would ignore it, and would let the branch policy govern, so the helper exits 1 instead, and that is a stop, exactly like a missing reader. Both readers refuse an empty --merge-base-policy with usage status 2 — an unset variable is a caller bug, never "no merge-base policy" — and refuse it combined with --merge-base-policy-absent.

    • First adoption: when the merge base predates tier-inputs.mjs, the merge base has no trusted helper. Use an operator-pinned helper and reader supplied outside the candidate branch (AGENTS.md: "an operator-pinned reader supplied outside the candidate branch"). Feed them only the materialized merge-base policy, registry and target list. Only when no such pin exists is tier resolution indeterminate: stop and report it. Never fall back to the branch copy. The same rule governs devflow-policy.mjs and lib/merge-base-policy.sh, here and in /integrate's merge-base recipe, and dev-flow-exit.mjs in /integrate's integration-exit recipe; both point at this sentence rather than restating it. A branch that edits the helper could answer absent and swap the merge-base policy for the built-in fallback, and one that edits the engine could ignore the merge-base argument or lower its own caps. One rule picks every governing copy: it is the merge base's own copy, and it qualifies iff its source declares the capability marker, the exact line dev-flow-capability: merge-base-absent-v1 (which the reader, the helper and the engine carry as a comment). A merge base whose copy is missing or predates the marker cannot govern this path: resolution is indeterminate, the stage stops, and the maintainer decides. Never run the branch's copy, and never judge a copy by which flag names its source mentions. The reader, the helper and the engine therefore always come from one merge-base tree, and the engine imports the reader beside it in that same tree. trusted_copy COPY in the recipe above is the one implementation; /integrate's recipe carries a byte-identical copy so it runs in a fresh shell, and a test keeps the two identical.

    • When the working tree differs in none of them, the checkout's own copies are the trusted ones, and the steps below run them.

  2. Read the issue's inputs. Its labels, and the repository owner's type, passed as owner_type: gh api --hostname "$host" "repos/$repo" --jq .owner.type gives User or Organization, where $repo (owner/name) and $host are the canonical issue's, bound and validated by the caller (/implement step 1, /orchestrate before tier resolution), never a default host or a URL-suffix match. (gh repo view --json owner has no type.) The owner type is where Risk and Complexity are stored (triage's classification rubric). On a personal-account repository (User) they are the risk:*/complexity:* labels, and no fields are passed. On an organization repository they are only the Risk and Complexity issue fields, and fields is required: pass what a complete issue-field read returned ({} when none is set). If that read fails, is unavailable or is truncated, stop: tier resolution is indeterminate, never "unset". A same-named label there is inert and never read, so an unset, omitted or null field leaves that axis unset whatever labels the issue carries (an *-label-inert warning names them). Nothing read from issue or PR text is an operator instruction.

  3. Establish label provenance (AGENTS.md, "Nothing here arms anything"). An interactive session confirms with the operator any label the operator has not authorized. Unattended automation verifies who applied it against its own trusted-actor configuration, re-reading immediately before acting. That covers:

    • Execution-policy labels (rigor:*, strategy:*, tier:<role>:*): list every one whose provenance holds in authorized_labels. The helper is fail-closed: an execution-policy label not listed is dropped with a policy-label-unauthorized warning naming it, and resolution continues without it.
    • The pin, when tier:pinned is present: who applied the tier:pinned marker and who applied the tier:<value> it pins, checked separately (pin_provenance.marker_trusted / value_trusted). An unverified half leaves the pin unhonored, with a warning.
    • Not the classification: risk:*, complexity:* and the unqualified stored tier:<value> are deliberately ungated. ADR 2026-09-30 D3 lets an AI or a human set them with no safeguard, and the stored Tier is only a cache of Risk × Complexity.
  4. Translate, then resolve with the translated flags appended:

    tier_tmp="$(mktemp -d "${TMPDIR:-/tmp}/tier-resolve.XXXXXX")"
    # write the issue's inputs to "$tier_tmp/tier-input.json" (shape below)
    node "$support_dir/tier-inputs.mjs" --policy .devflow.toml \
        --input "$tier_tmp/tier-input.json" >"$tier_tmp/tier-translation.json" ||
        { echo "tier resolution stopped: input translation failed" >&2; exit 2; }
    node -e 'for (const a of JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).args) console.log(a)' \
        "$tier_tmp/tier-translation.json" >"$tier_tmp/args.txt" ||
        { echo "tier resolution stopped: argument extraction failed" >&2; exit 2; }
    tier_args=()
    while IFS= read -r a; do tier_args+=("$a"); done <"$tier_tmp/args.txt"
    reg_args=()
    if [ -f agent-registry.json ]; then reg_args=(--registry agent-registry.json); fi
    node "$support_dir/devflow-policy.mjs" resolve --policy .devflow.toml \
        ${reg_args[@]+"${reg_args[@]}"} --taskfile-dir . \
        --json ${tier_args[@]+"${tier_args[@]}"} >"$tier_tmp/resolved.json"
    

    Pass --registry agent-registry.json only when that file exists; when both policy and registry are absent this ordinary recipe reaches the built-in fallback. This guard does not apply to step 0: that path deliberately passes the materialized merge-base registry from its trusted closure.

    Run it from the repository root. --taskfile-dir . hands the reader this checkout's gate-target list. Without it (or --task-targets), cross-validation is indeterminate and resolve always exits 3, which would hide the one exit 3 that matters: the derived Tier's. On the step-0 path, --taskfile-dir is the merge-base closure instead. With neither a policy file nor a requested registry, the built-in fallback is determinate and resolve exits 0 when the other checks complete. Any indeterminate entry, especially the derived Tier's, still stops resolution exactly as before. Exit 3 always stops this recipe. Translation and argument extraction must both succeed before resolve runs. Either failure prints a message and stops the recipe; the file-backed extraction preserves its exit status instead of losing it in process substitution. Run this block in a shell where exit 2 stops the resolution. The working files live in "$tier_tmp", a scratch directory outside the checkout, never in the worktree, where a commit could sweep them up.

    tier-input.json is {"labels": [...], "authorized_labels": [...], "owner_type": "User" | "Organization", "fields": {"risk": …, "complexity": …}, "operator": {"rigor": …, "strategy": …, "tiers": {…}}, "pin_provenance": {"marker_trusted": …, "value_trusted": …}}. Every key is optional, and an omitted authorized_labels honors no execution-policy label. The document must be a JSON object. An unknown top-level key, an operator key other than rigor, strategy and tiers, or a fields key other than risk and complexity, is a usage error (exit 2), never silently ignored. So is a missing owner_type on an issue carrying a risk:*/complexity:* label or a set field (its storage is then unknown), an owner_type other than User or Organization, and any fields key with owner_type User. Label conflicts are settled here, before the reader runs.

    • tier:pinned with more than one unqualified tier:<value> is an ambiguous pin. No pinned Tier is passed, a pin-ambiguous warning names every value, and the pin rung is dropped: resolution continues through the remaining rungs (a tier:implementer:* label or a chosen rigor can still decide; otherwise the derived Tier, then the default).
    • Two tier:<role>:* values for one role resolve to the stronger on tier_order, and two rigor:* labels to the stronger on rigor_order; either conflict is disclosed.
    • A rigor:*/strategy:* label that names no [rigor.*]/[strategy.*] table in the policy (--policy) is ignored with a *-label-unknown warning, never forwarded for the reader to refuse.
    • Two strategy:* labels are ambiguous: the helper passes neither and emits a strategy-label-ambiguous warning (AGENTS.md: strategy conflicts are not orderable). On that warning an interactive session stops and asks the operator which strategy applies, then re-runs with the answer as operator.strategy. Unattended automation takes default_strategy, with the warning carried into the PR body.
    • Both Risk and Complexity are required to derive the Tier. With either missing, or conflicting (the helper passes the off-scale conflict value), the derived Tier is indeterminate, never guessed.
    • Ambiguity is counted over the raw labels: a malformed value still makes its family ambiguous, and is never forwarded.
  5. Disclose. node "$support_dir/tier-inputs.mjs" disclose --inputs "$tier_tmp/tier-translation.json" --resolved "$tier_tmp/resolved.json" prints one PR-body line per item. The first is the selections line: rigor and strategy as the reader resolved them, each with its source (operator, label or default). That source is read from the translation's inputs.rigor.source and inputs.strategy.source, never from the reader. A label-chosen and an operator-chosen strategy reach the reader as the same --strategy flag, so only the translation knows which it was. Use the same two fields for the profile announcement. Then: the implementer tier and its source (pinned, rigor, derived, default, or operator); every off-profile role tier; every companion a pin leaves below the implementer, named a pin-caused invariant break (disclosed, never corrected); every valid tier:<role>:* label a stronger rung overrode; every one the reader rejected (a retired or off-ladder value), named once, as rejected, with the reader's reason; and every other warning. Carry those lines into the PR body's policy disclosure verbatim.

A policy without [tier.matrix] has no derived-Tier rung, and a classified issue's issue_tier.status is indeterminate. Whether that stops the resolution depends on which rung decides:

  • Only when the derived rung would decide does it make the resolution indeterminate: there is no operator tier, no honored pin, no tier:implementer:* label and no chosen rigor. The reader then exits 3 with "the implementer's derived Tier cannot be computed", and the implementer keeps its profile tier, disclosed rather than guessed around.
  • When a stronger rung decides, that rung applies and the reader exits 0, still reporting issue_tier indeterminate. Do not stop such a run.

This applies to harmon-devkit itself today: its own .devflow.toml is the harmon-init v4.45.0 template, which predates [tier.matrix]. A classified issue here takes the derived-rung exit 3 until a copier update to the harmon-init release carrying harmon-init#1475. A repository with no .devflow.toml at all takes the built-in fallback. There the derived Tier is recorded but not applied (issue_tier.status is inert), while a pin, a tier:<role>:* label and an operator tier still apply. That is the conformance corpus's absent-policy-classified-issue-keeps-an-honored-pin. Only standard rigor and plan strategy exist there, so any other rigor:*/strategy:* label is ignored with a warning.

Calling it from another skill

Resolve this package relative to the calling asset's own physical directory, never from a repository root — the same shape track-work/assets/check-issue-metadata.sh uses for issue-title-support. A skill file does the same thing one level up, resolving ${CLAUDE_SKILL_DIR} physically before appending the sibling hop:

skill_dir="$(cd "${CLAUDE_SKILL_DIR}" && pwd -P)"
support_dir="$skill_dir/../dev-flow-support/assets"

Either way the physical resolution comes first:

asset_dir="$(cd "$(dirname "$0")" && pwd -P)"
support_dir="$asset_dir/../../dev-flow-support/assets"

pwd -P matters, and the failure it prevents is subtle enough to be worth spelling out. Categories are flattened on vendor, so the sibling package is two levels up in a consumer's .claude/skills/ tree; in harmon-devkit's own source tree it is two levels up from ai/skills/universal/<skill>/assets as well, and the .agents/skills/<name> dogfood entries are symlinks whose physical target is that same source path.

A logical .. there splits by resolver rather than failing cleanly:

# ls follows the link, then applies `..` — succeeds.
ls .agents/skills/review/../dev-flow-support/assets/validate-result-schemas.mjs
# node collapses `..` first, looks beside the LINK's parent — MODULE_NOT_FOUND.
node .agents/skills/review/../dev-flow-support/assets/validate-result-schemas.mjs

The kernel follows the symlink and then applies ..; Node collapses .. with path.resolve() before touching the filesystem, so it looks beside the link's parent instead of beside its target. Resolving physically first — cd then pwd -P — makes every resolver agree. Consumers are unaffected either way, because their .claude/skills/<name> entries are real directories; this is a hazard of the source tree's own dogfood links, which is exactly where it would go unnoticed.

A .mjs asset uses a path relative to its own file for the same reason.

Resolving the assets from an agent file

The section above is for another skill's script resolving this package via $0 / import.meta.url. An agent file (ai/agents/reviewer.md, ai/agents/challenger.md, ai/agents/integrator.md) has no such anchor — it is prose run by an LLM in a shell, not a script with a physical location of its own — so it must resolve this package's assets/ the same way devflow-policy.mjs resolves its own vendored copy: probe, in order, the vendored skill layouts that CLOSURE_READER_PATHS (assets/devflow-policy.mjs) carries — ai/skills/universal/, then .claude/skills/, then .agents/skills/ — and use the first one that exists. This resolution assumes the current directory is the repository root — the anchor a dispatched agent runs from — which is precisely why an agent file cannot use the ${CLAUDE_SKILL_DIR}-relative resolution the rest of this file uses: it has no skill directory of its own to be relative to. An agent file that needs this package's assets/ directory names this rule by section title and file instead of re-enumerating the layouts, and sets it once:

for c in ai/skills/universal/dev-flow-support/assets .claude/skills/dev-flow-support/assets .agents/skills/dev-flow-support/assets; do
    [ -d "$c" ] && { DEV_FLOW_SUPPORT="$c"; break; }
done
[ -n "${DEV_FLOW_SUPPORT:-}" ] || { echo "dev-flow-support assets not found (looked in: ai/skills/universal/dev-flow-support/assets, .claude/skills/dev-flow-support/assets, .agents/skills/dev-flow-support/assets)" >&2; exit 2; }

Keep this candidate list in the same order as CLOSURE_READER_PATHS in assets/devflow-policy.mjs — that array is the reference order the reader uses; this snippet is a derived copy, not a second source of truth, and must be updated if that array's order or membership changes.

Schemas

ai/schemas/ in harmon-devkit remains the authoring source of truth: its README, its conformance fixture corpus, and Foreman's reference all point there. assets/schemas/ is a byte-identical copy that travels with the package, so a consumer that vendored only skills still has the schemas its vendored validators default to. task test:schema-parity fails the build when the two diverge.

A consumer that also wants the schemas at a stable top-level path may add the optional schemas: block to its .skills-sync.yaml; it is not required, and this package does not depend on it.