Docs Update

SkillWeb & browsing

Lets your agent update your documentation website after you finish an SDK or CLI change, proposing minimal fixes to stale pages.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Docs Update skill

About this capability

Updates the docs website after a change to the SDK or CLI. Finds the pages your change made out of date and proposes the smallest edit that fixes them. Use when invoking /qv-docs-update.

What this skill tells your AI

The instructions your AI receives, as published by tetherto/qvac in .agents/skills/qv-docs-update/SKILL.md and read by ahel’s review.

Whenever a developer finishes implementing a feature in the SDK or the CLI, they invoke this skill to update the docs website, adding or updating the content. That way, every new feature PR opens with its documentation already in place.

Route a source change to the documentation pages it invalidated. Propose the smallest patch that makes them correct again.

Everything you write is read by a developer building a local AI application on QVAC, an open-source ecosystem. docs/website is their developer portal: it teaches how to use the SDK and the CLI, never how the codebase works internally. Write for that reader in Phase 3 and Phase 5.

Run this skill only when the developer asks for it. The source API must be stable. Do not run it mid-implementation: developers iterate on an API several times before it settles, and prose written against a moving surface is the waste this skill exists to avoid.

What this skill reads and writes

There are three observed source packages: packages/sdk, packages/sdk-python, packages/cli. They are read-only.

Never edit anything under packages/**. If a source file is wrong, report it and stop. Fixing source is the developer's job.

The writable surface is defined in references/docs-scope.md. Read that file before writing anything. A write outside the allowlist aborts the run and reverts every patch already applied.

Pipeline objects

The skill builds four objects, in order. Each one appears as a block in the final report.

ObjectQuestion it answersBuilt in
SOURCE_CHANGE_SETWhat changed in the code?Phase 2
DOCS_IMPACTWhat does that mean for a user?Phase 3
DOCS_TARGETSWhich pages and sections became wrong, and why?Phase 4
DOCS_PATCHWhat is the smallest change that fixes them?Phase 5

Cardinality:

1 SOURCE_CHANGE_SET  ->  1 DOCS_IMPACT   (always one, never split)
1 DOCS_IMPACT        ->  N pages         (routing is a union of hits)
1 page               ->  N targets       (distinct sections of that page)
1 target             ->  1 reason + 1 patch

DOCS_IMPACT is always a single report. Never split it. Never estimate how many pages it will touch. The router produces the page grouping, deterministically.

You have exactly two jobs in this pipeline, and both are verifiable: describe the change (Phase 3), and judge a concrete page you have in front of you (Phase 4). Nothing here asks you how many independent impacts a change contains. That question has no ground truth, and nothing downstream needs the answer.

States

A state classifies the documentary impact of a change, and decides what the skill does next: proceed to routing, stop and report, or ask the developer a question.

There are eight. Six classify one source file. Two describe the whole run.

StateScopeMeaning
NO_SOURCE_CHANGErunNothing changed in the three packages against the merge base.
NO_DOCS_IMPACTfileCode changed. Nothing user-facing went stale.
GENERATED_DOCS_ONLYfileUser-facing impact, fully covered by a generated surface.
DOCS_UPDATE_REQUIREDfileEditable prose must change. Proceed to routing.
NEW_CAPABILITY_PAGEfileNew AI capability with no page. The skill creates it.
NEW_MODELS_PAGEfileNew model-lifecycle topic with no page. The skill creates it.
HUMAN_INPUT_REQUIREDfileAn ambiguity the repo does not resolve. Ask the developer.
DONErunEvery patch applied and validated.

The two page-creation states are the same operation on different subtrees, and they are mutually exclusive. Separate them by what the symbol is about, not by where its source file sits. A symbol that performs inference — it takes a prompt, audio, or an image and returns a generated result — is an AI capability. A symbol that acquires, inspects, or prepares a model without performing inference is a model-lifecycle topic. completion and transcribe are the first. downloadAsset and assessModelFit are the second.

Assign the file-scoped states one source file at a time. If one file is unresolved, keep the patches already proposed for the files that routed cleanly.

Both scripts report a single run-level state field, and it is advisory. You own the state machine, not the scripts.

The collector decides only the two states it can prove alone, NO_SOURCE_CHANGE and NO_DOCS_IMPACT, and reports CONTINUE for everything else. CONTINUE is not one of the seven states: it means the script reached no verdict and Phase 3 must judge. The router reports its best guess from the routing signals it can see. Treat either field as a starting point, then refine per file as you work.

The final report carries one state in its header. Choose it this way. If any file is HUMAN_INPUT_REQUIRED, then report HUMAN_INPUT_REQUIRED, and list the resolved patches alongside the pending question. Else if every patch was applied and validated, then report DONE. Else report the state that every file shares.

Decision flow

This is every path through the skill, and every point where it stops. The phases below implement this flow.

Developer finishes the feature
        │
        ▼
  /qv-docs-update
        │
        ▼
Phase 1 — resolve the merge base; collect committed, staged,
          unstaged and untracked changes
        │
        ├── nothing changed ──────────────► NO_SOURCE_CHANGE      STOP
        ├── every changed file `internal` ─► NO_DOCS_IMPACT       STOP
        ▼
Phase 1 — export diff, TSDoc diff, auxiliary context
        │
        ▼
Phase 2 — build SOURCE_CHANGE_SET
        │
        ▼
Phase 3 — classify against the impact policy
        │
        ├── nothing user-facing ──────────► NO_DOCS_IMPACT        STOP
        ├── a generated surface covers it ─► GENERATED_DOCS_ONLY  STOP
        ├── is the behaviour supported? ──► HUMAN_INPUT_REQUIRED  STOP
        │   the repo does not say
        ▼
      DOCS_UPDATE_REQUIRED
        │
        ▼
Phase 4 — run the router
        │
        ├── new_capability_symbols ───────► NEW_CAPABILITY_PAGE
        │   symbol performs inference       four append-only edits,
        │                                   then Phase 6
        │
        ├── new_capability_symbols ───────► NEW_MODELS_PAGE
        │   symbol is model lifecycle       three append-only edits,
        │                                   then Phase 6
        │
        ├── unrouted and user-facing ─────► HUMAN_INPUT_REQUIRED
        │                                   for that source only;
        │                                   routed pages continue
        ▼
Phase 4 — filter each candidate; write one reason for each
        │
        ▼
Phase 5 — write the patch; present the diff
        │
        ├── developer rejects ────────────► revise, present again
        ▼
Apply to disk
        │
        ▼
Phase 6 — five gates
        │
        ├── any gate fails ───────────────► report the error, leave
        │                                   the patches on disk,
        │                                   do not declare DONE    STOP
        ▼
      DONE

Phase 1 — Collect the source change

  • Inputs: the current git working tree.
  • Outputs: /tmp/qv-docs-scs.json (written by the script), plus three records you gather by hand: export diff, TSDoc diff, auxiliary context.
  • Expected result: every changed path in the three packages is accounted for and carries a bucket, across all four git states. A file missed here is invisible to every later phase.

Start with the script. It resolves the merge base, collects changes across all four git states (committed on the branch, staged, unstaged, untracked), and classifies every path into a bucket.

  1. Run the collector.
bash .agents/skills/qv-docs-update/scripts/collect-source-changes.sh > /tmp/qv-docs-scs.json

The output has this shape:

{
  "state": "CONTINUE",
  "base": { "ref": "tether/main", "sha": "3f2a91c…", "short": "3f2a91c", "via": "url" },
  "strong_evidence": true,
  "buckets": ["api", "examples"],
  "file_count": 2,
  "files": [{ "path": "packages/sdk/src/client/api/completion-stream.ts", "bucket": "api", "status": "M" }]
}

Each file carries a bucket. The bucket decides which router runs in Phase 4.

BucketPaths
examplespackages/{sdk,sdk-python}/examples/**
apipackages/sdk/src/client/api/**
client-otherpackages/sdk/src/client/** outside api/
surfacebarrels, src/types/**, src/schemas/**
cli-commandpackages/cli/src/{bundle-sdk,serve,configure,openai,doctor,verify}/**
cli-infrapackages/cli/src/cli/**, src/{config,errors,logger,index}.ts
python-surfacepackages/sdk-python/** outside examples/
areapackages/sdk/src/{logging,models,server,worker}/**
internaleverything else

The script decides two states on its own. It reports NO_SOURCE_CHANGE when nothing changed, and NO_DOCS_IMPACT when every changed file is internal. Every other run reports CONTINUE: changes exist, and their documentary impact is Phase 3's to judge.

  1. If state is NO_SOURCE_CHANGE or NO_DOCS_IMPACT, then stop and emit the no-update report. Else continue.

strong_evidence is true when any of examples, api, or cli-command was touched. Treat it as weight in Phase 3, not as a verdict. Do not put it in the report.

base.via records how the base was chosen: url when a remote points at tetherto/qvac, explicit when the developer passed --base, single-remote when neither applied and the clone has exactly one remote. Report the base with its via. A single-remote base plus an implausible file_count means the base is wrong, so stop and say so rather than routing hundreds of files.

The script cannot read the public export surface. Get it from the barrel, comparing the base against the working tree.

  1. If no changed file is in the api or surface bucket, then skip to step 5. Else read the barrel at the base and in the working tree, and record added, removed, and renamed exports.
git show <base-sha>:packages/sdk/src/client/api/index.ts

Compare it against the current packages/sdk/src/client/api/index.ts. The barrel is the authority on what is public.

  1. Read the diff of every file in the api and surface buckets, then record each changed function signature and each changed TSDoc block.
git diff <base-sha> -- <file-path>

For an untracked file, read the file directly. There is no diff to read.

  1. Gather auxiliary context.

Collect three things: the branch commit messages (git log <base-sha>..HEAD --format=%s), the PR title and body (gh pr view --json title,body, only when a PR exists), and any changed test that exercises an affected symbol. A new test often states new behaviour more plainly than the diff does.

Phase 2 — Build SOURCE_CHANGE_SET

  • Inputs: everything collected in Phase 1.
  • Outputs: one SOURCE_CHANGE_SET text block.
  • Expected result: the block carries every fact the later phases need, so nothing downstream has to reopen the raw diff.

This object is what you read from here on. Do not go back to the raw diff after this phase.

  1. Write the SOURCE_CHANGE_SET block in this exact shape.
SOURCE_CHANGE_SET

Base: tether/main @ 3f2a91c (via url)
Packages: packages/sdk

Buckets:
- api:       packages/sdk/src/client/api/completion-stream.ts
- examples:  packages/sdk/examples/completion-events.ts
- surface:   packages/sdk/src/types/generation.ts

Exports:
- changed:   completion() — new optional parameter `maxTokens?: number`
- unchanged: all others

TSDoc:
- completion(): @param maxTokens added

Tests:
- packages/sdk/test/completion-max-tokens.test.ts (new)

Phase 3 — Build DOCS_IMPACT

  • Inputs: the SOURCE_CHANGE_SET block, and references/docs-impact-policy.md.
  • Outputs: one DOCS_IMPACT text block, and a state.
  • Expected result: the block states the change in the user's terms, not the code's, and every later claim in a patch traces back to something written here.

The question here is not "did the code change?". Phase 1 already answered that. Ask two questions instead, and answer both.

First: did a claim the docs make stop being true, or become incomplete?

Second: with the docs exactly as they stand today, can a user use the feature that changed?

The two catch different failures. The first catches a page that went stale. The second catches a page that is still entirely correct and yet leaves the user unable to reach the new capability. A new optional parameter usually makes no existing sentence false, and still leaves the user with no way to learn that the parameter exists. If the answer to either question is bad, the change has documentary impact.

  1. Read references/docs-impact-policy.md.

  2. Write the DOCS_IMPACT block in this exact shape.

DOCS_IMPACT

User-facing change:
completion() accepts an optional maxTokens parameter.

Public surface affected:
- completion()
- GenerateTextOptions.maxTokens

Behaviour:
- maxTokens caps the total tokens generated in the response.
- Omitted, current behaviour is unchanged.

Generated coverage:
- The API summary will list completion() with the new signature.
- The API summary will NOT describe what maxTokens means. It omits parameter descriptions by design.

Documentary implication:
maxTokens is essential to controlling output, so it belongs on the capability page, per the policy on essential parameters.
  1. If the repo does not settle whether the changed behaviour is public and supported, then emit HUMAN_INPUT_REQUIRED, put that exact question to the developer, and stop.

This is the impact ambiguity, not the routing one. It appears when an observable behaviour changed and no barrel export, no TSDoc, no test and no page says whether that behaviour is part of the contract or an accident of the implementation. Documenting an accident is worse than documenting nothing, because the next change silently breaks a promise the docs made. Ask instead of inferring. Phase 4 raises the same state for a different reason: there the behaviour is known and the page is not.

  1. If the state is NO_DOCS_IMPACT or GENERATED_DOCS_ONLY, then stop and emit the no-update report. Else continue.

When you claim GENERATED_DOCS_ONLY, name the covering surface in the report. An unnamed claim is not checkable.

Phase 4 — Route to DOCS_TARGETS

  • Inputs: /tmp/qv-docs-scs.json, and the DOCS_IMPACT block.
  • Outputs: one DOCS_TARGETS block, grouped by page, with a written reason per candidate.
  • Expected result: every candidate the router produced is either kept with a reason, or dismissed with a reason. None is silently dropped.

The router reads the JSON from Phase 1, not the SOURCE_CHANGE_SET.

  1. Run the router.
bun run .agents/skills/qv-docs-update/scripts/route-docs-targets.ts --input /tmp/qv-docs-scs.json

The output has this shape:

{
  "state": "DOCS_UPDATE_REQUIRED",
  "base": { "ref": "tether/main", "sha": "3f2a91c…", "short": "3f2a91c", "via": "url" },
  "r3_used": false,
  "high_page_count": false,
  "new_capability_symbols": [],
  "pages": [
    {
      "page": "ai-capabilities/text-generation.mdx",
      "targets": [
        {
          "page": "ai-capabilities/text-generation.mdx",
          "section": "Examples › Usage",
          "sectionLevel": 3,
          "via": "R1",
          "source": "packages/sdk/examples/completion-events.ts",
          "evidence": "file=<rootDir>/packages/sdk/examples/completion-events.ts",
          "line": 185
        }
      ]
    }
  ],
  "unrouted": [{ "source": "…", "bucket": "…", "status": "M", "reason": "…" }],
  "discarded": [{ "page": "…", "source": "…", "via": "R4", "reason": "…" }]
}

Read the fields as follows:

  • pages[].targets[] are the candidates. Each one is a page section to judge in step 3.
  • evidence is the authored binding the router matched. It is a fact about the repo, not a guess.
  • line is where that binding sits in the page. Use it to find the section fast.
  • section is null on a page-level hit: every R3 hit, and the cli/http-server/** subtree of R4. The router bound the page, not a section, because the map and the subtree rule name pages only.
  • unrouted[] are source files no router could place. Handle them in step 5.
  • discarded[] are hits that fell outside the allowlist or hit a path declared undocumented. Copy them into the report. Never re-add them.
  • new_capability_symbols[] are new exported symbols in client/api/ with no page. Each one means NEW_CAPABILITY_PAGE.
  • r3_used is true only when every hit came from the area map. It is run-level, so a run with one R1 hit and one R3 hit reports false. To weigh a single candidate, read its via field instead: R3 is the declared fallback, so treat an R3 candidate with more suspicion than an R1, R2 or R4 hit.
  • high_page_count is true when the router produced more than four pages. It is counted before your filtering, so recount after step 3.

There are four routers, and their hits are unioned. R1, R2 and R4 are exact: each resolves a binding that already exists in the content. R3 is the declared fallback and labels itself as such.

RouterBinding it resolvesBuckets it covers
R1the literal file=<rootDir>/… directive in a fenceexamples
R2the /reference/api#<symbol> anchorapi, export diff
R4the ### `qvac <command>` headingcli-command
R3references/routing-map.yamleverything else

Four router behaviours affect how you read the output:

  • R1 has no false-positive mode. No page inlines a full example. A TS example also routes the page that shows its transpiled dist/**.js counterpart.
  • R2 takes symbols from the barrel, not the filename. completion-stream.ts exports completion, so the anchor is #completion and never #completionstream. rag.ts exports nine functions and transcribe.ts exports two. Use the same rule when you write a link in a patch.
  • R2 also has a secondary pass for a symbol linked somewhere other than its anchor, reported as the weaker binding. text-generation.mdx links the text `batchCompletion()` to the batch-processing page instead of to its API anchor, and carries a paragraph on how that function shares parallel slots. That page is a real target even though the anchor is absent. A symbol mentioned in prose with no link at all is routed by nothing.
  • R4 also routes the narrative sections that describe a command outside ## Reference, and for serve/ it adds the whole cli/http-server/** subtree.
  • R3 runs per file, not per run. It picks up only the files the exact routers could not resolve. A commit that touches an example and a config module gets an R1 hit for the example, and R3 still runs for the config module. One file's exact hit never suppresses the fallback for another file.

A pages: [] entry in the routing map is a positive declaration that a path is intentionally not documented. It is why a file can be unrouted without becoming a question.

  1. Read each candidate section in the page it belongs to.

Judge the page itself, not the diff. Read the section with DOCS_IMPACT in hand.

If section is null, then read the whole page and choose the section yourself, then state that choice and its justification in the candidate's Reason. This is the one place where you pick a target the router did not name, so make the choice auditable rather than silent. If no section on the page fits, dismiss the candidate — do not invent a section for it.

  1. For each candidate, answer this question in writing: did this section become incorrect, incomplete, misleading, or materially insufficient after the change described in DOCS_IMPACT?

Keep the candidate if the answer is yes. Dismiss it if the answer is no. Either way, write the reason. A page that routed by accident has no answer to "which claim here went stale?", and that is what removes it.

The Reason field is the contract for the patch. Phase 5 is bound to it, so write it precisely.

  1. Write the DOCS_TARGETS block, grouped by page, in this exact shape.
DOCS_TARGETS — 2 pages, 3 targets

ai-capabilities/text-generation.mdx
  1. Section: "Features"
     Via:     R2 (completion -> /reference/api#completion)
     Reason:  the list of generation controls is complete today and would
              become incomplete by omitting maxTokens.
     Action:  list maxTokens among the controls.

  2. Section: "Examples › Usage"
     Via:     R1 (packages/sdk/examples/completion-events.ts)
     Reason:  the introductory sentence describes what the script does, and the
              script now demonstrates the new parameter.
     Action:  update the introductory sentence.

configuration/index.mdx
  3. Section: "Reference"
     Via:     R3 (packages/sdk/src/client/config-loader/**)
     Reason:  R3 bound the page and named no section; "Reference" is the only
              section that enumerates config keys, and its key table lacks the
              new one.
     Action:  add the key, its accepted values, and its default.

Dismissed:
- ai-capabilities/batch-processing.mdx
  Reason: links completion() only by comparison; no claim went stale.

There is no page limit, and multi-page is a normal result.

The team rule "1 change == 1 scope" describes the scope of the change in the source. It says nothing about how many pages document that change, and the two are different quantities. A new CLI command that reads a new config key is perfectly scoped, and it needs both cli/index.mdx and the configuration page. Do not drop a target to keep the page count down. The gate against wide routing is the written reason, not a count.

  1. If more than four pages survived filtering, then emit this note and continue. It never blocks.
Routing produced 6 pages after filtering. That is allowed but uncommon. Worth
checking whether routing went wide (candidates that should have been dismissed)
or the source change mixes scopes. The patches stand.
  1. For each entry in new_capability_symbols[], classify the symbol by the rule in States, then run the matching subprocedure, below. If the symbol performs inference, run NEW_CAPABILITY_PAGE. Else run NEW_MODELS_PAGE.

Both subprocedures also apply to a symbol the router could not place because no page links its anchor yet. R2 binds a page by the /reference/api#<symbol> link it already contains, so a public symbol that no prose page mentions routes to nothing and reaches unrouted[] whether or not it is new in this run. A symbol in that position, exported from the barrel and listed in the API summary, needs the page the routers found missing rather than a question to the developer. Confirm the absence before creating the page: search docs/website/content/docs/ for the symbol name and for its anchor, and treat a hit in reference/** or release-notes/** as no coverage, since both are generated.

  1. For each entry in unrouted[] that is user-facing and carries no newSymbol, emit HUMAN_INPUT_REQUIRED for that source and ask which page covers the topic.

new_capability_symbols[] is derived from unrouted[], so a new symbol appears in both lists. Step 6 already handled it. Asking about it here would create the page and then ask which page covers the topic.

The answer becomes a new routing-map.yaml entry. That is how the map grows. A source file that is not user-facing needs no question: ignore it.

Phase 5 — Write and apply DOCS_PATCH

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
612
Forks
112
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
qv-docs-update
Source
github.com/tetherto/qvac