feat(kyberforge): primitive-author and factory-audit support for apm hooks, instructions and prompts #144
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "holocron",
|
||||
"description": "AI development skills for Claude Code, and for GitHub Copilot through apm — factory, design, implement, review, and cross-cutting workflows.",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"owner": {
|
||||
"name": "Defame1297",
|
||||
"email": "defame1297@rkdr.net",
|
||||
@@ -11,7 +11,7 @@
|
||||
{
|
||||
"name": "kyberforge",
|
||||
"description": "Skills and agents for creating, maintaining, and managing an apm plugin marketplace for Claude Code and GitHub Copilot.",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"category": "Developer Tools",
|
||||
"source": "./plugins/kyberforge"
|
||||
},
|
||||
|
||||
40
CONTEXT.md
40
CONTEXT.md
@@ -48,7 +48,32 @@ _Avoid_: agent hygiene
|
||||
A reusable slash command defined as a `SKILL.md` file following the
|
||||
[Agent Skills open standard](https://agentskills.io), authored at
|
||||
`plugins/<plugin>/.apm/skills/<skill>/SKILL.md`.
|
||||
_Avoid_: command, prompt, macro
|
||||
_Avoid_: command, macro; and "prompt" for a skill — a **Prompt** is a different artifact
|
||||
|
||||
**Prompt**:
|
||||
A single-intent, user-triggered message with parameters, authored as
|
||||
`plugins/<plugin>/.apm/prompts/<name>.prompt.md` — the text a user would otherwise type repeatedly.
|
||||
It carries no procedure beyond steering existing skills or agents by name; once it holds reusable
|
||||
know-how, bundled files, or anything the model should find on its own, it is a **Skill** in the
|
||||
wrong container. This is a house rule, stricter than apm, which frames a prompt as a full workflow.
|
||||
_Avoid_: command (the Claude-side deployed form), workflow, macro
|
||||
|
||||
**Instruction**:
|
||||
A scoped rule authored as `plugins/<plugin>/.apm/instructions/<name>.instructions.md`, applied when
|
||||
the agent touches files matching its `applyTo` glob. Omitting `applyTo` makes it always-on in every
|
||||
session of every repo that installs the package — a legitimate way for a package to ship guidance to
|
||||
consumers, but a deliberate choice, never a default. A rule for this repo alone belongs in
|
||||
**AGENTS.md**, not in an instruction.
|
||||
_Avoid_: rule (the Claude-side deployed form under `.claude/rules/`), guideline, standard
|
||||
|
||||
**Hook**:
|
||||
A runtime callback a harness fires inside its own tool loop, authored as JSON under
|
||||
`plugins/<plugin>/.apm/hooks/` — one file or several; kyberforge ships a single `hooks.json` — in
|
||||
apm's canonical shape — nested entries, PascalCase events, `${PLUGIN_ROOT}` script paths — which
|
||||
apm renders per target. Reach is narrowed in the package's
|
||||
`apm.yml` `targets:`, never by filename. The last resort among apm primitives: procedure belongs in
|
||||
a **Skill**, and a hook is only for "this must always happen at this event".
|
||||
_Avoid_: trigger, callback script (the script is the hook's payload, not the hook)
|
||||
|
||||
**apm package**:
|
||||
The deployable unit apm builds and installs — one or more skills, agents, hooks, commands, and MCP
|
||||
@@ -58,6 +83,13 @@ _Avoid_: bundle, module, source tree; and bare "plugin" for the *installable art
|
||||
ADR-0024 is an apm package and not a Claude Code plugin. "Plugin" stays correct as a modifier in the
|
||||
repo's settled compounds — **Plugin marketplace**, "plugin units", `plugins/`.
|
||||
|
||||
**apm primitive**:
|
||||
Any content type authored under `plugins/<name>/.apm/` — skills, agents, hooks, instructions, and
|
||||
prompts. Skills and agents each have their own author skill; `primitive-author` covers the other
|
||||
three, so its name is narrower in practice than the term. `factory-audit` audits all five.
|
||||
_Avoid_: component, asset, artifact (unqualified); bare "primitive" when only the three
|
||||
non-skill, non-agent types are meant — say "hook, instruction, or prompt"
|
||||
|
||||
**Output profile**:
|
||||
A named ecosystem format `apm pack` can compile the marketplace manifest into, declared per profile
|
||||
under root `apm.yml`'s `marketplace.outputs:`. apm defines `claude` and `codex`; each writes to its
|
||||
@@ -193,3 +225,9 @@ _Avoid_: namespace, category
|
||||
an audit running in the same context as the work it checks shares that work's blind spots. The
|
||||
recheck belongs to `factory-audit`, not to `forge`, which routes only to the author
|
||||
skills and never to an audit.
|
||||
- "prompt" meant both apm's `.prompt.md` primitive and, loosely, any slash command or a skill — resolved:
|
||||
a **Prompt** is the `.prompt.md` primitive under the house rule above. apm frames a prompt as a
|
||||
program ("a prompt is a program for an LLM", its "What is APM?" page), but on Claude it deploys
|
||||
as a model-invocable command with fewer frontmatter keys than a skill, and Codex receives
|
||||
nothing. A fat prompt is a worse skill on every harness, so the procedure goes in the skill and
|
||||
the prompt only steers it.
|
||||
|
||||
6
apm.yml
6
apm.yml
@@ -1,5 +1,5 @@
|
||||
name: holocron
|
||||
version: 0.5.1
|
||||
version: 0.5.2
|
||||
description: AI development skills for Claude Code, and for GitHub Copilot through apm — factory, design, implement, review, and cross-cutting workflows.
|
||||
license: MIT
|
||||
|
||||
@@ -61,7 +61,7 @@ dependencies:
|
||||
# an apm mechanic.
|
||||
executables:
|
||||
allow:
|
||||
kyberforge#2.0.1:
|
||||
kyberforge#2.1.0:
|
||||
hooks: true
|
||||
bin: true
|
||||
|
||||
@@ -71,7 +71,7 @@ marketplace:
|
||||
# top-level apm.yml description:/version: above are NOT inherited into the
|
||||
# compiled output despite being used elsewhere (e.g. by `apm audit`).
|
||||
description: AI development skills for Claude Code, and for GitHub Copilot through apm — factory, design, implement, review, and cross-cutting workflows.
|
||||
version: 0.5.1
|
||||
version: 0.5.2
|
||||
owner:
|
||||
name: Defame1297
|
||||
email: defame1297@rkdr.net
|
||||
|
||||
@@ -34,7 +34,9 @@ behind and refreshes it in place.
|
||||
*session* loads, and that is the moment the staleness does damage. It also enables two things a git
|
||||
hook structurally cannot do: `additionalContext` puts the notice into the agent's context rather
|
||||
than terminal scrollback nobody reads, and `reloadSkills: true` makes the host re-scan the skill
|
||||
directories after the hook returns, so a refresh lands in the running session without a restart.
|
||||
directories after the hook returns, so a refresh lands in the running session without a restart
|
||||
(`reloadSkills` is a documented `SessionStart` `hookSpecificOutput` field:
|
||||
code.claude.com/docs/en/hooks, checked 2026-09-29).
|
||||
|
||||
apm's own lifecycle events (`pre-/post-install`, `pre-/post-update`, `pre-/post-uninstall`) were
|
||||
rejected: they fire around apm operations already chosen, so they can announce a refresh but never
|
||||
@@ -51,8 +53,9 @@ Three sub-decisions:
|
||||
drift. A hook shipped in a package is written into that file by apm itself, so it is apm's output
|
||||
and does not drift. `.claude/settings.local.json` also works but is gitignored and machine-local,
|
||||
which fails the requirement that this travel with the repo.
|
||||
- **`startup` matcher only.** `resume`, `clear`, `compact` and `fork` would re-run the check on every
|
||||
compaction, and a compaction is not an event after which the remote can have moved.
|
||||
- **`startup` matcher only.** `resume`, `clear`, `compact` and `fork` — the other documented
|
||||
`SessionStart` matchers (code.claude.com/docs/en/hooks, checked 2026-09-29) — would re-run the check
|
||||
on every compaction, and a compaction is not an event after which the remote can have moved.
|
||||
|
||||
The executable-trust gate is switched on at the same time. Root `apm.yml` gains an `executables:`
|
||||
block allowing kyberforge's hooks and bin.
|
||||
@@ -124,6 +127,9 @@ A test pins the reference.
|
||||
> `plugins/kyberforge/hooks/` no longer exists at all — so `${CLAUDE_PLUGIN_ROOT}/hooks/...` still
|
||||
> names a path with nothing at it, now because the directory is gone rather than because a sync
|
||||
> emptied it. `tests/test-apm-current-hook.sh` still pins the literal string.
|
||||
>
|
||||
> *Superseded in part by the 2026-09-28 amendment below: the token is now `${PLUGIN_ROOT}`. The
|
||||
> `.apm/`-path conclusion is unchanged.*
|
||||
|
||||
**Session startup gets slower when the install is stale.** Measured: ~0.7 s for the `apm outdated`
|
||||
check when everything is current, ~10.4 s when six packages are behind and the refresh runs. The
|
||||
@@ -139,6 +145,22 @@ value to be larger — so changing either side without the other fails the suite
|
||||
> Re-measured: ~24–26 s for the same six-behind refresh, warm, on a LAN remote — well inside the
|
||||
> 380 s above.
|
||||
|
||||
> **Amendment (2026-09-29) — the time limits are harder to escape, and portable.** Four changes to
|
||||
> `check-apm-current.sh` (PR #144):
|
||||
>
|
||||
> - Both limits are now `timeout -k 5`: a child that ignores SIGTERM is SIGKILLed 5 s later. The
|
||||
> worst case is 60 + 5 + 300 + 5 = 370 s, still below the host's 380, and the test now sums each
|
||||
> limit plus its grace.
|
||||
> - `timeout` falls back to `gtimeout` (Homebrew coreutils on macOS). With neither on `PATH` the
|
||||
> hook emits a notice and exits without calling `apm`. Before, the missing binary exited 127, the
|
||||
> `|| exit 0` swallowed it, and the hook silently never ran.
|
||||
> - `GIT_TERMINAL_PROMPT=0` is exported, so a remote that wants credentials fails at once instead of
|
||||
> blocking on an invisible prompt until the timeout fires.
|
||||
> - `apm update` runs under `flock -n` on `apm_modules/.kyberforge-apm-update.lock` when `flock` exists
|
||||
> and `apm_modules/` does. A second session starting at the same moment skips its refresh and says
|
||||
> so, instead of running a second `apm update` over the same tree. Without `flock` (stock macOS) the
|
||||
> refresh runs unserialised, as before.
|
||||
|
||||
**Reading a human-readable CLI for a control decision cost a silent failure, again.** `apm outdated`
|
||||
has no `--json` or other machine-readable flag (confirmed against 0.28.0), so the hook must match
|
||||
its prose. The first attempt matched `outdated dependencies found` — plural only. apm emits
|
||||
@@ -201,9 +223,20 @@ Until then the repo has the mechanism in source and not in effect.
|
||||
> and it does not last. At the next session start the hook finds the restored lock behind `main`
|
||||
> and refreshes again.
|
||||
|
||||
> **Amendment (2026-09-19) — the advice is neutral when the default branch is unknown.** The hook
|
||||
> picks its lock advice by comparing the current branch with `refs/remotes/origin/HEAD`. That ref is
|
||||
> often unset: git writes it on clone, and `git remote add` never does. The fallback used to be
|
||||
> `main`, which told a checkout whose default branch is `master` to discard a real lock update while
|
||||
> it stood on its default branch. Now, when `origin/HEAD` is unset, the notice gives neither the
|
||||
> default-branch nor the feature-branch advice. It says only "commit it or discard it
|
||||
> deliberately", the same text used outside a git checkout or on a detached HEAD. Learning the
|
||||
> remote's default would need the network, so the hook asserts nothing and the reader decides
|
||||
> (`ea119d8`).
|
||||
|
||||
**`.claude/settings.json` stops being `{"hooks": {}}`.** apm merges the hook into it and tracks
|
||||
ownership in a `.claude/apm-hooks.json` sidecar, with the script copied to
|
||||
`.claude/hooks/<pkg>/`. The sidecar and the script directory are gitignored install output; the
|
||||
`.claude/hooks/<pkg>/`. The sidecar and the script directory are gitignored install output
|
||||
(*superseded for the sidecar: it is committed, see correction 2026-09-16 below*); the
|
||||
settings file remains committed, now with apm-generated content in it. ADR-0018's statement that the
|
||||
committed content is exactly `{"hooks": {}}` is superseded on that point only — the rule it was
|
||||
protecting, that nothing repo-authored goes in that file, is unchanged.
|
||||
@@ -220,7 +253,9 @@ protecting, that nothing repo-authored goes in that file, is unchanged.
|
||||
|
||||
**Native consumers are protected by a guard, not by the gate.** A host installing holocron through
|
||||
`claude plugin install` auto-discovers `hooks/hooks.json` and does not consult apm's trust gate at
|
||||
all. The script therefore exits silently when there is no `apm.lock.yaml` in the working directory,
|
||||
all. The script therefore exits silently when there is no `apm.lock.yaml` in the working directory
|
||||
(*superseded: it now checks `${CLAUDE_PROJECT_DIR}/apm.lock.yaml` and has no cwd fallback, see
|
||||
correction 2026-09-28 below*),
|
||||
which is what makes it inert in a repo that does not consume packages through apm. Copilot CLI sees
|
||||
no hook at all, for the reasons already documented in `plugins/kyberforge/docs/hooks.md`.
|
||||
|
||||
@@ -234,6 +269,46 @@ no hook at all, for the reasons already documented in `plugins/kyberforge/docs/h
|
||||
> lockfile there is nothing for `apm update` to refresh, so exiting silently is the correct
|
||||
> behaviour rather than a defensive measure aimed at a second installer. The Copilot CLI sentence is
|
||||
> unaffected.
|
||||
>
|
||||
> *The Copilot CLI sentence is superseded by the 2026-09-28 amendment below.*
|
||||
|
||||
> **Amendment (2026-09-28) — target-neutral token; the hook reaches Copilot and Codex, accepted.**
|
||||
> Two corrections from the apm 0.28.0 research pass behind `primitive-author` (issue #94;
|
||||
> `plugins/kyberforge/docs/research/docs/microsoft-apm/hooks-primitive-schema.md`).
|
||||
>
|
||||
> *The token.* `hooks.json` now references `${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh`. apm
|
||||
> documents `${PLUGIN_ROOT}` as its target-neutral token and rewrites it exactly as it rewrites
|
||||
> `${CLAUDE_PLUGIN_ROOT}`: two scratch packages differing only in the token deploy byte-identical
|
||||
> `SessionStart` entries, matching the one committed in `.claude/settings.json`. The `.apm/`-path
|
||||
> rule above is unchanged, and `tests/test-apm-current-hook.sh` pins the new literal.
|
||||
>
|
||||
> *The reach.* "Copilot CLI sees no hook at all" was wrong for apm installs. `targets:` is
|
||||
> package-wide, and kyberforge declares `claude`, `copilot` and `codex`, so apm also writes the hook
|
||||
> to `.github/hooks/kyberforge-hooks.json` — event renamed to `sessionStart`, path rewritten,
|
||||
> `version: 1` added, the nested Claude shape otherwise passed through unreshaped — and merges it
|
||||
> into `.codex/hooks.json` whenever `.codex/` exists. Whether Copilot CLI executes a nested entry or
|
||||
> honours `matcher` is unverified. This is **accepted**: the hook's behaviour is Claude-specific (the
|
||||
> `startup` matcher, `CLAUDE_PROJECT_DIR`, the `reloadSkills` output) and the `apm.lock.yaml` guard
|
||||
> keeps it inert where there is nothing to refresh. Keeping it Claude-only the apm-native way would
|
||||
> need a separate package declaring `target: claude` — the seventh-plugin alternative below, still
|
||||
> rejected as disproportionate — because per-file routing (`claude-hooks.json`) is deprecated and
|
||||
> narrowing kyberforge's own `targets:` would drop its skills from Copilot and Codex.
|
||||
>
|
||||
> *The "inert" rationale for the reach is superseded by the correction below.*
|
||||
|
||||
> **Correction (2026-09-28) — the lockfile guard never made the hook inert under Copilot or Codex;
|
||||
> a `CLAUDE_PROJECT_DIR` guard now does.** The hook only reaches a project through `apm install`,
|
||||
> which writes `apm.lock.yaml`, so in every project that receives it the lockfile guard passes. The
|
||||
> script then fell back to `$PWD` for its project directory and ran `apm outdated`, and
|
||||
> `apm update --yes` when anything was stale — a lock rewrite and full redeploy on a Copilot or Codex
|
||||
> session start, with no `reloadSkills` or advice that harness understands. The hook is now Claude
|
||||
> Code only: `check-apm-current.sh` opens with `[[ -n "${CLAUDE_PROJECT_DIR:-}" ]] || exit 0`, the
|
||||
> cwd fallback is gone, and `tests/test-apm-current-hook.sh` pins that an unset or empty
|
||||
> `CLAUDE_PROJECT_DIR` exits 0 silently without invoking `apm`. apm still deploys the hook to
|
||||
> Copilot and Codex; it exits immediately there. The acceptance stands on that guard, not on the
|
||||
> lockfile. The seventh-plugin alternative below remains rejected as disproportionate, but no
|
||||
> longer because either guard keeps the hook off external kyberforge consumers: on Claude Code
|
||||
> neither guard stops it for an apm consumer, and that is intended.
|
||||
|
||||
**`scripts/git-hooks/` is now empty.** `post-push` and `test-post-push.sh` are deleted.
|
||||
`install.sh`'s copy block is generic and is kept; `test-git-hooks-install.sh` now synthesizes its
|
||||
@@ -251,3 +326,7 @@ be used again if a hook git actually invokes is ever wanted.
|
||||
consumers. Rejected as disproportionate: the `apm.lock.yaml` guard already makes the hook inert
|
||||
for anyone not consuming through apm, and a package exists to be maintained, versioned, and
|
||||
registered in the marketplace.
|
||||
|
||||
*Rationale superseded by the 2026-09-28 correction above: the `CLAUDE_PROJECT_DIR` guard keeps
|
||||
the hook off non-Claude hosts, and on Claude Code it runs for every apm consumer, including
|
||||
external kyberforge consumers, by design. The alternative stays rejected as disproportionate.*
|
||||
|
||||
@@ -191,6 +191,17 @@ comments (`:9-93`) have described all three correctly since it shipped.
|
||||
was wrong for the tree-identical case: the `same_subtree` skip makes a push **pass** that this ADR as
|
||||
written requires to **fail**, which is documented behaviour changing, not an implementation detail.
|
||||
|
||||
## Amendment (2026-09-29): a new skill stays at `0.1.0` until its first improve
|
||||
|
||||
The Decision says `skill-author`'s create/improve bump convention is "unchanged". It has changed
|
||||
since. Before, `skill-author` bumped the **minor** version on create, so a skill left its first
|
||||
session above the `0.1.0` scaffold. Now create keeps the scaffold's `0.1.0` and does not bump it;
|
||||
the first improve is the first bump, a **patch** (`skill-author` `SKILL.md`, `references/create.md`
|
||||
and `references/improve.md`; `965208b`, PR #144). This makes `0.1.0` mean what this ADR says it
|
||||
means, "created and never yet revised", instead of a value no created skill ever kept. The rules
|
||||
above are otherwise unaffected: `metadata.version` is still required, and the push gate still
|
||||
compares whatever value is there.
|
||||
|
||||
## Consequences
|
||||
|
||||
27 SKILL.md files gain `metadata.version: "1.0.0"`, and a 28th — `bin/write-docs` — reaches the same
|
||||
|
||||
@@ -278,6 +278,31 @@ both entry points, and that is what most of the table is. Every change below is
|
||||
A single `validate.sh` copied or symlinked out of its `scripts/` directory still does not work,
|
||||
because its libraries are not beside it. It now fails at exit 2 and says so.
|
||||
|
||||
> **Amendment (2026-09-28) — three more modes: hook, instruction and prompt.** The "two accepted
|
||||
> shapes" in point 2 above are now five. The `primitive-author` change (issue #94, PR #144) added
|
||||
> three rows to the Step 0 table, each with its own self-contained flow file and a shared
|
||||
> `scripts/lib-checks-primitive.sh` that `validate.sh` sources for all three:
|
||||
>
|
||||
> - A file named `*.instructions.md` takes the instruction flow (`references/instruction-flow.md`).
|
||||
> - A file named `*.prompt.md` takes the prompt flow (`references/prompt-flow.md`); the prompt rules
|
||||
> themselves are ADR-0029's.
|
||||
> - A `.json` file whose *immediate* parent directory is `hooks/` (`.apm/hooks/`, or a package's
|
||||
> root `hooks/`) takes the hook flow (`references/hook-flow.md`).
|
||||
>
|
||||
> The suffix rows are checked before the `agents/`-parent rule, so a `*.prompt.md` or
|
||||
> `*.instructions.md` under `agents/` is not audited as an agent. The fallback row is unchanged in
|
||||
> kind: anything else stops, runs no validator, exits 2, and names every accepted shape rather than
|
||||
> two. The exit-code table above applies to the new modes as written; its "matches neither shape"
|
||||
> row now means "matches none of the five".
|
||||
>
|
||||
> This is ADR-0020's merge-siblings rule applied forward rather than a new decision. Auditing a hook,
|
||||
> an instruction or a prompt is the same job as auditing a skill or agent — deterministic checks,
|
||||
> a read, a qualitative pass, one shared report — over a different input type, which is exactly the
|
||||
> case the rule says belongs in one skill behind a dispatch table, not in a new sibling audit skill.
|
||||
> The authoring side follows the rule's other half: the author skills stay split because they emit
|
||||
> genuinely different artifacts, so hooks, instructions and prompts got their own
|
||||
> `primitive-author` rather than rows in `skill-author` or `agent-author`.
|
||||
|
||||
## Considered options
|
||||
|
||||
**Keep two skills and rely on the byte-identity contract test alone (rejected).** This is the status
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Prompts are thin, user-triggered steering messages; procedure belongs in a skill
|
||||
|
||||
**Status: accepted (2026-09-28).** Refs #94. Sets the house rule that `primitive-author` enforces
|
||||
when it writes a `.prompt.md`, and that `factory-audit` checks in its prompt flow.
|
||||
|
||||
A **Prompt** (the term is in `CONTEXT.md`) is a single-intent, user-triggered message with
|
||||
parameters. It is the text a user would otherwise type again and again. It carries no procedure
|
||||
beyond steering existing skills or agents by name. Once it holds reusable know-how, bundled files,
|
||||
or anything the model should find on its own, it is a **Skill** in the wrong container.
|
||||
|
||||
This is stricter than apm. apm frames a prompt as a program: its docs state that "a prompt is a
|
||||
program for an LLM" (microsoft.github.io/apm/concepts/what-is-apm/, "Secure by default"; the same
|
||||
sentence is in the apm-cli 0.28.0 package README), and 0.28.0 scaffolds one as a numbered-steps
|
||||
workflow (`apm_cli/workflow/discovery.py`). On every harness this repo targets, a prompt with a full
|
||||
workflow in it is a worse skill:
|
||||
|
||||
- **Claude Code.** Custom commands have been merged into skills. "Both create `/deploy` and work
|
||||
the same way", and both are model-invocable by default (code.claude.com/docs/en/skills.md).
|
||||
apm 0.28.0 keeps only `description`, `allowed-tools`, `model`, `argument-hint` and `input` for
|
||||
Claude (`_PRESERVED_COMMAND_KEYS`) and drops `disable-model-invocation`. A deployed prompt is
|
||||
therefore a model-visible skill with fewer frontmatter keys, and it cannot be made user-only.
|
||||
- **Copilot / VS Code.** "Prompt files are deprecated for Agent Host sessions and aren't loaded by
|
||||
Agent Host", and VS Code offers a migration that converts existing prompt files to agent skills
|
||||
(code.visualstudio.com/docs/agent-customization/prompt-files). They still load in the Local agent,
|
||||
which that page says will be removed in a future release.
|
||||
- **Codex.** Codex receives no prompts at all.
|
||||
|
||||
The one job a prompt does better than a skill is a short, parameterised "do this now, using X and Y"
|
||||
message, where `input:` → `$arguments` is the whole point.
|
||||
|
||||
## Description contract
|
||||
|
||||
A prompt's `description` is one plain, human-facing sentence that names the skills or agents it
|
||||
steers. For example: "Review the current PR with `gitea-prs` and `factory-audit`, then summarise."
|
||||
It has no "Use when…" trigger clause and no `Not X -> Y` boundary clause. This is the same shape
|
||||
`factory-audit` already applies to `disable-model-invocation: true` skills. Without a trigger clause,
|
||||
the router has little reason to pick the prompt over the skills it wraps. So when the model routes,
|
||||
it tends to reach the real capability.
|
||||
|
||||
**Unverified:** how strongly Claude avoids routing to a description with no trigger clause. The
|
||||
contract lowers the chance of the model invoking the prompt, but does not prevent it. If it does
|
||||
happen, the cost is bounded: the prompt is a thin wrapper that calls the right skills anyway.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- **`primitive-author`.** The prompt reference opens with a boundary gate. A request that carries
|
||||
procedure is redirected to `skill-author`.
|
||||
- **`factory-audit`, script checks.**
|
||||
- `description` is present and non-empty (FAIL).
|
||||
- It is 250 characters or fewer (SUGGESTION).
|
||||
- It has no "Use when" trigger clause (SUGGESTION).
|
||||
- It has no `Not X -> Y` boundary clause (SUGGESTION).
|
||||
- **`factory-audit`, judgment step.** A body that clearly carries reusable procedure is a FAIL. A
|
||||
borderline body is a SUGGESTION. There is deliberately no line-count or heading heuristic: the
|
||||
call is made by reading the content, because any threshold misfires.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **`factory-audit` gains a prompt mode.** `*.prompt.md` is a Step 0 dispatch row with its own
|
||||
`references/prompt-flow.md`, and `scripts/lib-checks-primitive.sh` carries the three description
|
||||
checks above plus the judgment step (ADR-0025, amendment 2026-09-28). A prompt that was fine under
|
||||
apm's framing — a trigger clause, a boundary clause, a numbered workflow — now draws findings.
|
||||
- **`primitive-author` refuses procedure-bearing prompts.** Its prompt reference opens with the
|
||||
boundary gate, so a "make me a command" request that carries reusable know-how is redirected to
|
||||
`skill-author` before any file is written. That makes skill-vs-prompt a checked boundary rather
|
||||
than an authoring preference.
|
||||
- **Codex gets no prompts from this repo.** apm deploys none to Codex, so anything a prompt steers
|
||||
must also be reachable there through the skill it names. Keeping procedure in the skill is what
|
||||
makes that true; a fat prompt would be content Codex users silently never see.
|
||||
- **Reversing this is cheap in files, not in routing.** No prompt exists in the repo yet, so the rule
|
||||
constrains new work only. Loosening it later means re-deciding the description contract, and every
|
||||
prompt written under it would need a trigger clause added.
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Fat workflow prompts as peers of skills (rejected).** This follows apm's framing. But every
|
||||
"make me a command" request becomes a coin flip between two near-identical containers. The prompt
|
||||
also carries worse metadata on Claude and does not arrive on Codex.
|
||||
- **The full ADR-0020 description contract for prompts (rejected).** A trigger clause and a boundary
|
||||
clause would make prompts route well. That actively invites the model to invoke the prompt, which
|
||||
contradicts "user-triggered".
|
||||
- **Skill-by-default with prompts as a grudging exception (superseded during the grill).** This
|
||||
framed a prompt as a weaker skill competing for the same job. Giving it a distinct role, a thin
|
||||
caller over skills, is a boundary that can be checked, which "prefer skills" is not.
|
||||
@@ -787,8 +787,10 @@ Wiring Vale as a deterministic prefilter for `factory-audit`'s Description dimen
|
||||
issue #84) is repo-specific, not part of the generic `lint` plugin, so it does not live in
|
||||
`plugins/lint/` — and per ADR-0014 it no longer lives at the repo root either. It lives **once**,
|
||||
under `plugins/kyberforge/.apm/skills/factory-audit/assets/vale/`, carrying both the `Kyberforge`
|
||||
and `KyberforgeCopilot` styles and a single `.vale.ini` with all three glob sections:
|
||||
`[**/SKILL.md]`, `[**/agents/*.md]`, `[**/*.agent.md]`.
|
||||
and `KyberforgeCopilot` styles and a single `.vale.ini` with five glob sections:
|
||||
`[**/SKILL.md]`, `[**/agents/*.md]`, `[**/*.instructions.md]`, `[**/*.prompt.md]` and
|
||||
`[**/*.agent.md]`. The instructions and prompt sections arrived with `factory-audit`'s primitive
|
||||
modes (ADR-0025, amendment 2026-09-28).
|
||||
|
||||
ADR-0014 split this into two skill-scoped copies because a plugin's cache-install copies only each
|
||||
skill's own files and `skill-audit` could not reach across the skill boundary into `agent-audit`'s
|
||||
@@ -816,7 +818,8 @@ Its `StylesPath` and `BasedOnStyles` checks were **not** diffs. They were per-fi
|
||||
invoked `vale --config` on one representative path per file shape. It was the only assertion
|
||||
anywhere that catches a `.vale.ini` glob typo (`[**/SKILL.md]` → `[**/SKILLS.md]`), the failure mode
|
||||
where every other check stays clean while Vale lints zero files. One config does not make that
|
||||
impossible: a typo in any one of the three sections still 0-file-skips that shape.
|
||||
impossible: a typo in any one section still 0-file-skips that shape. The table has since grown
|
||||
to eight rows: one each for the instructions and prompt sections added later.
|
||||
|
||||
**Case 0** runs before any Vale-dependent case and needs no Vale binary. It asserts that the shipped
|
||||
`.vale.ini` exists and is readable, sets a `StylesPath` that resolves to a directory, and names only
|
||||
@@ -826,8 +829,9 @@ cases back.
|
||||
|
||||
The probes now live in `tests/test-vale-wrap.sh` (cases 28–30), rehomed against the merged config:
|
||||
one representative path per file shape, each asserted to produce a Vale scan of more than zero files
|
||||
*and* a Kyberforge alert (case 28). Case 28 also checks that each probe path is in scope of a
|
||||
published vale hook, and that every `.vale.ini` section has a probe row. Its Part B drops
|
||||
*and* a Kyberforge alert (case 28). Case 28 also checks that every `.vale.ini` section has an
|
||||
isolating probe row. It no longer holds each probe path to a hook's `files:` scope: that half read
|
||||
the retired `.pre-commit-hooks.yaml`, and case 32 owns the local hooks' scope. Its Part B drops
|
||||
`Kyberforge` from each section's `BasedOnStyles` in a copy and requires that section's probes to
|
||||
fail as "style not loaded". Case 29 is a mutation case: it typos each section in a copy of the
|
||||
assets and requires that section's isolating probes to drop to zero. Case 30 asserts that
|
||||
@@ -931,17 +935,17 @@ authors. Without the binary the hooks fail with a bare "command not found" and n
|
||||
**Two hooks, not one combined hook — for a different reason than ADR-0014 gave.** The original
|
||||
reason was mechanical: with a config per skill, a single hook could point at only one copy and would
|
||||
silently 0-file-skip the other file shape (see
|
||||
[A 0-file Vale run is NOT RUN](#a-0-file-vale-run-is-not-run)). One `.vale.ini` carrying all three
|
||||
sections removes that constraint. The split stays anyway because the `files:` regexes still have to
|
||||
[A 0-file Vale run is NOT RUN](#a-0-file-vale-run-is-not-run)). One `.vale.ini` carrying every
|
||||
section removes that constraint. The split stays anyway because the `files:` regexes still have to
|
||||
differ — each hook hands Vale only the file shape it is scoped to. Both hooks name the same
|
||||
plugin-bundled `factory-audit/scripts/vale-wrap.sh` through `repo: local`; there is no second root
|
||||
copy.
|
||||
|
||||
### The `.vale.ini` globs do no scoping
|
||||
|
||||
The `.vale.ini`'s section globs are **path-agnostic** — `[**/SKILL.md]`, `[**/agents/*.md]` and
|
||||
`[**/*.agent.md]` — and constrain filename *shape*, not
|
||||
location: Vale's `*` crosses `/`. A `SKILL.md` outside `plugins/` (a project-scope
|
||||
The `.vale.ini`'s section globs are **path-agnostic** — `[**/SKILL.md]`, `[**/agents/*.md]`,
|
||||
`[**/*.instructions.md]`, `[**/*.prompt.md]` and `[**/*.agent.md]` — and constrain filename *shape*,
|
||||
not location: Vale's `*` crosses `/`. A `SKILL.md` outside `plugins/` (a project-scope
|
||||
`.claude/skills/foo/SKILL.md`, say) still matches `[**/SKILL.md]` and gets linted normally.
|
||||
|
||||
All scoping therefore comes from the pre-commit hooks' own `files:` regexes, which pin this repo's
|
||||
@@ -951,8 +955,16 @@ invocation — in this repo or in any repo that installs it, whatever that repo'
|
||||
Narrowing a `.vale.ini` glob to a `plugins/`-shaped path to "tighten" it breaks the consumer case:
|
||||
`factory-audit` run against a project-scope `.claude/skills/` tree would lint nothing.
|
||||
`check-vale-style-sync`'s probe set was built to catch exactly that; it moved to
|
||||
`tests/test-vale-wrap.sh` with the hook's deletion, and two of the six probes exist specifically to
|
||||
pin this location independence — see [One copy, one config](#one-copy-one-config).
|
||||
`tests/test-vale-wrap.sh` with the hook's deletion. Its table (`PROBE_TABLE28`) now has eight rows,
|
||||
one or more per section, and the two `.claude/`-prefixed rows exist specifically to pin this
|
||||
location independence — see [One copy, one config](#one-copy-one-config).
|
||||
|
||||
**No pre-commit Vale hook covers `*.instructions.md` or `*.prompt.md` yet.** The `.vale.ini`
|
||||
sections exist so that `factory-audit`'s own Vale call lints those files when it is handed one, in
|
||||
any repo. This repo's two prefilter hooks select only `SKILL.md` and `.agent.md` files, and the repo
|
||||
has no instruction or prompt file for a third hook to select. Case 32 requires every Vale hook's
|
||||
`files:` regex to match at least one tracked file, so a hook added now would fail it. Add the hook
|
||||
in the change that lands the first `.apm/instructions/` or `.apm/prompts/` file.
|
||||
|
||||
### The blind spot: `references/` is unlinted, for two independent reasons
|
||||
|
||||
@@ -1051,9 +1063,9 @@ clean.
|
||||
### Pre-push
|
||||
|
||||
`vale` is still a **pre-push** dependency, but no longer through a hook of its own.
|
||||
`check-vale-style-sync` — the hook that ran the six glob probes, and whose
|
||||
`check-vale-style-sync` — the hook that ran the original six glob probes, and whose
|
||||
`CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1` opt-out downgraded them audibly rather than skipping the
|
||||
hook — is deleted with the second Vale copy (ADR-0025). The six glob probes survive it inside
|
||||
hook — is deleted with the second Vale copy (ADR-0025). The glob probes survive it inside
|
||||
`test-vale-wrap.sh`, so `run-tests --strict` is now the gate that runs them. That is also what keeps
|
||||
`vale` a pre-push requirement: `test-vale-wrap.sh` exits 77 without the binary once its static cases
|
||||
pass, and a skip fails the push.
|
||||
|
||||
@@ -298,14 +298,14 @@ EOF
|
||||
|
||||
@test "a doubled UTF-8 BOM does not hide the @import line" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
python3 -c "import sys; open(sys.argv[1], 'wb').write(('@AGENTS.md\n').encode('utf-8'))" "$ADAPTER"
|
||||
python3 -c "import sys; open(sys.argv[1], 'wb').write(('\ufeff\ufeff@AGENTS.md\n').encode('utf-8'))" "$ADAPTER"
|
||||
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "a BOM in front of a mid-file @import line does not hide it" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
python3 -c "import sys; open(sys.argv[1], 'wb').write(('# Claude notes\n\n@AGENTS.md\n').encode('utf-8'))" "$ADAPTER"
|
||||
python3 -c "import sys; open(sys.argv[1], 'wb').write(('# Claude notes\n\n\ufeff@AGENTS.md\n').encode('utf-8'))" "$ADAPTER"
|
||||
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@@ -10,19 +10,27 @@
|
||||
# Refreshes in place and asks the host to re-scan, so the running session picks
|
||||
# the new content up without a restart.
|
||||
#
|
||||
# Inert in any project that does not consume packages through apm.
|
||||
# Inert under any host but Claude Code, and in any project that does not
|
||||
# consume packages through apm.
|
||||
set -uo pipefail
|
||||
|
||||
# Anchor on the project root, not the session's cwd. Claude Code exports
|
||||
# CLAUDE_PROJECT_DIR for SessionStart hooks; a session opened in a subdirectory
|
||||
# would otherwise miss the lockfile, no-op silently, and — worse — run the apm
|
||||
# calls below against that wrong directory. Fall back to the cwd when the
|
||||
# variable is absent, which keeps the hook inert-but-harmless under a host that
|
||||
# does not set it.
|
||||
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
# Claude Code only. apm deploys this hook to Copilot and Codex too, and there
|
||||
# the lockfile guard below would pass — apm wrote the lock — so without this
|
||||
# guard a non-Claude session start would run `apm update --yes` and rewrite the
|
||||
# working tree with nothing to re-scan it. Claude Code exports
|
||||
# CLAUDE_PROJECT_DIR for SessionStart hooks and the other targets do not
|
||||
# document setting it, so its absence is the exit (ADR-0019, correction
|
||||
# 2026-09-28). A heuristic: if the variable is inherited from the user's
|
||||
# environment, a non-Claude session start gets past this guard.
|
||||
[[ -n "${CLAUDE_PROJECT_DIR:-}" ]] || exit 0
|
||||
|
||||
# No lockfile means nothing was installed through apm here — e.g. a host that
|
||||
# installed this plugin natively. Say nothing and cost nothing.
|
||||
# Anchor on the project root, not the session's cwd: a session opened in a
|
||||
# subdirectory would otherwise miss the lockfile, no-op silently, and — worse —
|
||||
# run the apm calls below against that wrong directory.
|
||||
project_dir="$CLAUDE_PROJECT_DIR"
|
||||
|
||||
# No lockfile means this project consumes nothing through apm, so there is
|
||||
# nothing for apm update to refresh. Say nothing and cost nothing.
|
||||
[[ -f "$project_dir/apm.lock.yaml" ]] || exit 0
|
||||
command -v apm > /dev/null 2>&1 || exit 0
|
||||
|
||||
@@ -30,27 +38,47 @@ command -v apm > /dev/null 2>&1 || exit 0
|
||||
# `apm outdated` and `apm update` both resolve the lockfile from the cwd.
|
||||
cd "$project_dir" || exit 0
|
||||
|
||||
# Only ever emit fixed text plus a digit-checked count — never interpolate
|
||||
# command output into the JSON, which would need escaping this cannot do safely.
|
||||
emit() {
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":%s,"additionalContext":"%s"}}\n' "$1" "$2"
|
||||
}
|
||||
|
||||
# Every apm call is time-boxed, so timeout(1) is a hard requirement. It is GNU
|
||||
# coreutils: stock macOS has none, and Homebrew's coreutils installs it as
|
||||
# `gtimeout`. Calling a missing binary would exit 127, which the `|| exit 0`
|
||||
# below swallows — the hook would silently never work. Say so once instead, and
|
||||
# do not run apm unbounded.
|
||||
if command -v timeout > /dev/null 2>&1; then
|
||||
timeout_bin="timeout"
|
||||
elif command -v gtimeout > /dev/null 2>&1; then
|
||||
timeout_bin="gtimeout"
|
||||
else
|
||||
emit false "The apm install currency check did not run: neither timeout nor gtimeout (GNU coreutils) is on PATH, and this hook will not run apm without a time limit. Install coreutils (macOS: brew install coreutils) or run apm outdated by hand."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# apm drives git for every remote ref. A remote that wants credentials must fail
|
||||
# fast, not block on a terminal prompt nobody can see until the timeout fires.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
# `apm outdated` exits 0 whether or not anything is stale, so the answer has to
|
||||
# come from its output. ~0.7s against six remote refs; a hung remote must not
|
||||
# hold the session open.
|
||||
# hold the session open. -k sends SIGKILL a grace period after the SIGTERM, so a
|
||||
# child that ignores TERM cannot outlive its budget; tests/test-apm-current-hook.sh
|
||||
# sums every limit plus its grace against the hooks.json timeout.
|
||||
#
|
||||
# There is no --json/machine-readable flag on `apm outdated` (verified against
|
||||
# apm 0.28.0), so the phrase match is forced rather than chosen. Note the
|
||||
# singular: apm prints "1 outdated dependency found" when exactly one package is
|
||||
# behind, so matching only "dependencies" would silently miss a one-package
|
||||
# drift. tests/test-apm-current-hook.sh pins both spellings against the real apm.
|
||||
outdated_output="$(timeout 60 apm outdated 2>&1)" || exit 0
|
||||
outdated_output="$("$timeout_bin" -k 5 60 apm outdated 2>&1)" || exit 0
|
||||
grep -qE 'outdated dependenc(y|ies) found' <<< "$outdated_output" || exit 0
|
||||
|
||||
stale_count="$(grep -oE '[0-9]+ outdated dependenc(y|ies) found' <<< "$outdated_output" | grep -oE '^[0-9]+' || true)"
|
||||
[[ "$stale_count" =~ ^[0-9]+$ ]] || stale_count="some"
|
||||
|
||||
# Only ever emit fixed text plus a digit-checked count — never interpolate
|
||||
# command output into the JSON, which would need escaping this cannot do safely.
|
||||
emit() {
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":%s,"additionalContext":"%s"}}\n' "$1" "$2"
|
||||
}
|
||||
|
||||
# What to do with the rewritten lock depends on the branch (ADR-0019): on the
|
||||
# default branch it is a real update to commit or discard; on a feature branch it
|
||||
# is churn unrelated to the branch and should be discarded. The branch name only
|
||||
@@ -77,7 +105,23 @@ if [[ -n "$current_branch" && -n "$default_branch" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if timeout 300 apm update --yes > /dev/null 2>&1; then
|
||||
# Two sessions started together would both run `apm update --yes` over the same
|
||||
# tree. Serialise on a lock under apm_modules/: `apm install` itself adds that
|
||||
# directory to .gitignore, so the lock never shows up as a working-tree change,
|
||||
# and apm only ever removes package directories inside it, never the directory
|
||||
# (or this file) itself. The loser does not wait — the winner's refresh is the
|
||||
# one it wanted — and says so. flock(1) is util-linux, absent on stock macOS,
|
||||
# and there the refresh runs unserialised, as it did before the lock existed; so
|
||||
# does a checkout with no apm_modules/ yet, rather than creating it.
|
||||
if command -v flock > /dev/null 2>&1 && [[ -d apm_modules ]] \
|
||||
&& { exec 9> apm_modules/.kyberforge-apm-update.lock; } 2> /dev/null; then
|
||||
if ! flock -n 9; then
|
||||
emit false "apm install is ${stale_count} package(s) behind the remote default branch, and another session is refreshing it right now, so this session skipped its own refresh. Skills and agents loaded in this session may be stale; if they are, restart the session once that refresh has finished."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if "$timeout_bin" -k 5 300 apm update --yes > /dev/null 2>&1; then
|
||||
emit true "apm install was ${stale_count} package(s) behind the remote default branch and has been refreshed automatically; skills and agents were redeployed and re-scanned. apm.lock.yaml has been rewritten and is now a modified file in the working tree - ${lock_advice}"
|
||||
else
|
||||
emit false "apm install is ${stale_count} package(s) behind the remote default branch and the automatic refresh failed. Deployed skills and agents may be stale. Run: apm update --yes"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"command": "${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"timeout": 380,
|
||||
"type": "command"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: agent-author
|
||||
description: >
|
||||
Use when the user wants to create a new agent definition file from scratch, or
|
||||
apply grill findings, audit findings, or inline feedback to an existing one.
|
||||
Use when the user wants a new agent definition created, or grill, audit
|
||||
or inline feedback applied to an existing one.
|
||||
Not read-only review -> `factory-audit`. Not skills -> `skill-author`.
|
||||
Not hooks, instructions or prompts -> `primitive-author`.
|
||||
allowed-tools: Bash Read Write Edit
|
||||
metadata:
|
||||
version: "1.0.3"
|
||||
version: "1.0.4"
|
||||
category: factory
|
||||
source_keys:
|
||||
- context7-websites-code-claude
|
||||
@@ -60,6 +61,6 @@ At every scope, five tools reach no subagent whatever `tools` says — `AskUserQ
|
||||
|
||||
Invoke `factory-audit` on each file written and resolve every FAIL before reporting done. It checks the field allowlist, name-to-stem match, leftover placeholders and template comments, the description budget and the Copilot body limit — do not hand-check those.
|
||||
|
||||
At plugin/APM scope bump the resolved package's `apm.yml` `version` — **minor** on create, **patch** on improve — because consumers compare it to detect updates. Project and user scope have no manifest.
|
||||
At plugin/APM scope bump the resolved package's `apm.yml` `version` — **minor** on create, **patch** on improve — because consumers compare it to detect updates. Skip, and say so, if this branch already bumped it: `git diff $(git merge-base HEAD <remote-default-branch>) -- <package>/apm.yml` shows a changed `version:` line, and one bump covers a branch. Project and user scope have no manifest.
|
||||
|
||||
**Commit verification.** Once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is lost if the tree is cleaned up. Report done only once the hash has changed.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: apm-workflow
|
||||
description: >
|
||||
Use when authoring, installing, or publishing an apm package, its apm.yml and
|
||||
the dependencies it declares, or an apm marketplace — even when the user does
|
||||
not say "apm". Not the apm binary or an agent runtime -> `apm-install`.
|
||||
Use when authoring, installing or publishing an apm package, its apm.yml and
|
||||
dependencies, or a marketplace, even if "apm" goes unsaid.
|
||||
Not the apm binary or an agent runtime -> apm-install.
|
||||
Not a hook, instruction or prompt file -> primitive-author.
|
||||
metadata:
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
category: apm
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
@@ -13,15 +14,14 @@ metadata:
|
||||
|
||||
## Gotchas
|
||||
|
||||
- MCP server secrets in `apm.yml` (headers, env vars) must use `${VAR}` indirection, never literal values, so they resolve at install or runtime and are never committed.
|
||||
- `apm experimental enable registries` must run before a `registries:` block or `registry.*` config takes effect anywhere — configure, install or publish. Without it, declaring one silently does nothing: no error, no warning.
|
||||
- `apm.yml`'s `type:` selects which primitives are processed and is never checked against what `.apm/` holds, so `apm install` and `apm compile` can exit 0 having shipped none of the ones you expected. Set it to cover every primitive the package ships, and confirm the deployed output, not the exit code. Mechanics: `references/configure.md`.
|
||||
- `apm experimental enable registries` must run before a `registries:` block or `registry.*` config takes effect in any flow; without it they silently do nothing.
|
||||
- `apm.yml`'s `type:` is never checked against `.apm/`, so `apm install` and `apm compile` can exit 0 shipping none of the expected primitives. Set `type:` to cover every primitive shipped; confirm the deployed output, not the exit code (`references/configure.md`).
|
||||
|
||||
## Step 1 — Dispatch
|
||||
|
||||
| Condition | Flow | Reference |
|
||||
|---|---|---|
|
||||
| Author or edit `apm.yml`, or scaffold a new package (`apm plugin init`) | configure | `references/configure.md` |
|
||||
| Author or edit `apm.yml`, or scaffold a new package (`apm plugin init`); skill, agent, hook, instruction or prompt content goes to the matching author skill | configure | `references/configure.md` |
|
||||
| Resolve or fetch the dependencies `apm.yml` declares (`apm install`, `apm install [PACKAGE_REF]`) | install | `references/install.md` |
|
||||
| Build a marketplace, register a package into it (local: hand-edit `apm.yml`; remote: `apm marketplace package add`), or register someone else's as a consumer (`apm marketplace init/check/package add/add`) | marketplace | `references/marketplace.md` |
|
||||
| Generate per-target output, bundle, or publish (`apm compile`, `apm pack`, `apm publish`) | compile | `references/compile.md` |
|
||||
|
||||
@@ -34,6 +34,8 @@ Bundles a producer package into a distributable artifact. Default to `--dry-run
|
||||
- A populated one gets its `mcpServers` content merged directly into the compiled `plugin.json`, but only for the `claude` target.
|
||||
- The `copilot` target's compiled `plugin.json` OMITS `mcpServers` entirely — it isn't part of Copilot's plugin manifest schema.
|
||||
|
||||
`mcpServers` headers and env must use `${VAR}` indirection — the content is merged verbatim into the published `plugin.json`, so a literal secret ships with the package.
|
||||
|
||||
`dependencies.mcp` in `apm.yml` is for a different purpose — declaring a remote MCP-server package as an APM dependency — not local `.mcp.json` passthrough.
|
||||
|
||||
### `includes: auto` and the packed bundle
|
||||
|
||||
@@ -10,7 +10,7 @@ source_keys:
|
||||
apm plugin init --yes --target claude,copilot
|
||||
```
|
||||
|
||||
Run from inside the target package directory, with no positional name argument (see Gotchas). Creates `apm.yml` + `plugin.json` in the current directory — it does NOT scaffold a `.apm/` skeleton. Primitive subdirectories (`.apm/skills/`, `.apm/agents/`, `.apm/hooks/`) must be created manually as content is migrated into them. Run this once per package (e.g. once per `plugins/<name>/` directory in a monorepo-hybrid layout), not once for the whole repo.
|
||||
Run from inside the target package directory, with no positional name argument (see this file's Gotchas). Creates `apm.yml` + `plugin.json` in the current directory — it does NOT scaffold a `.apm/` skeleton. Create each primitive subdirectory (`.apm/skills/`, `.apm/agents/`, `.apm/hooks/`, `.apm/instructions/`, `.apm/prompts/`) through its author skill as content lands in it: skills via `skill-author`, agents via `agent-author`, hooks, instructions and prompts via `primitive-author`. Run this once per package (e.g. once per `plugins/<name>/` directory in a monorepo-hybrid layout), not once for the whole repo.
|
||||
|
||||
## `apm.yml` — required fields
|
||||
|
||||
@@ -25,7 +25,7 @@ version: 1.0.0
|
||||
|
||||
- `name`, `version` — required (see above)
|
||||
- `description`, `author`, `license`, `homepage`, `repository`, `keywords` — standard package metadata
|
||||
- `type` — `instructions | skill | hybrid | prompts`; selects how the package is processed at install/compile time. It is a routing selector, not a constraint on what `.apm/` may contain (see Gotchas)
|
||||
- `type` — `instructions | skill | hybrid | prompts`; selects how the package is processed at install/compile time. It is a routing selector, not a constraint on what `.apm/` may contain (see this file's Gotchas)
|
||||
- `targets` — which harnesses this package compiles to (plural list form preferred; legacy singular `target: copilot,claude` CSV form still accepted)
|
||||
- `includes` — `auto` publishes the authoritative local layout as-is; it is not scoped down to what's relevant, so anything narrower needs an explicit repo-path list. Note: `auto` also does not sweep generic root-level passthrough files (README.md, docs/, sources.md, config files) into the `apm pack` distribution bundle — see `references/compile.md`
|
||||
- `dependencies`/`devDependencies` — `apm`/`mcp`/`lsp` entries; `devDependencies` share the same shape but are excluded from the shipped artifact
|
||||
@@ -73,16 +73,16 @@ version follows a separate rule — see `references/marketplace.md`.
|
||||
|
||||
## MCP server secrets
|
||||
|
||||
`${VAR}` indirection is required for MCP server secrets (headers, env vars) in `apm.yml`, never literal values — see SKILL.md Gotchas.
|
||||
MCP server secrets (headers, env vars) in `apm.yml` must use `${VAR}` indirection, never literal values, so they resolve at install or runtime and are never committed.
|
||||
|
||||
## Registries (config-level, not `apm.yml`)
|
||||
|
||||
Any git repo is a valid package source by default — no registry required. To declare named registries for shorthand dependency resolution:
|
||||
|
||||
```bash
|
||||
apm experimental enable registries # required first — see Gotchas
|
||||
apm experimental enable registries # required first — see this file's Gotchas
|
||||
apm config set registry.corp-main.url https://artifactory.corp.example.com/apm
|
||||
apm config set registry.corp-main.token eyJ...
|
||||
apm config set registry.corp-main.token "$CORP_APM_TOKEN"
|
||||
apm config set registry.corp-main.default true
|
||||
```
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: factory-audit
|
||||
description: >
|
||||
Use when the user wants a skill directory or agent definition audited,
|
||||
including "is this ready to ship", or after hand-editing one outside its
|
||||
author skill. Not applying skill fixes -> skill-author. Not applying agent
|
||||
fixes -> agent-author.
|
||||
Use when a skill, agent, apm hook, instruction or prompt needs auditing
|
||||
("ready to ship?"), even after a hand edit. Not fixing a skill -> skill-author.
|
||||
Not fixing an agent -> agent-author.
|
||||
Not fixing a hook, instruction or prompt -> primitive-author.
|
||||
allowed-tools: Bash Read
|
||||
metadata:
|
||||
version: "1.0.5"
|
||||
version: "1.1.1"
|
||||
category: factory
|
||||
source_keys:
|
||||
- agentskills-home
|
||||
@@ -20,6 +20,8 @@ metadata:
|
||||
- claude-code-subagents-docs
|
||||
- context7-github-en-copilot
|
||||
- github-custom-agents-configuration
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
@@ -30,21 +32,24 @@ metadata:
|
||||
|
||||
## Step 0 — Dispatch
|
||||
|
||||
Resolve the flow from the target path **before running anything**. The two flows run different validators over different dimension vocabularies, so dispatching after Step 1 means the wrong validator has already produced the wrong findings. The rows mirror the shapes `scripts/validate.sh` accepts; take the first that matches.
|
||||
Resolve the flow from the target path **before running anything**. The flows run different validators over different dimension vocabularies, so dispatching after Step 1 means the wrong validator has already produced the wrong findings. The rows mirror the shapes `scripts/validate.sh` accepts; take the first that matches.
|
||||
|
||||
| Target | Flow | Read |
|
||||
|---|---|---|
|
||||
| A directory containing `SKILL.md` | skill | `references/skill-flow.md` |
|
||||
| A file named `SKILL.md` — audit its parent directory | skill | `references/skill-flow.md` |
|
||||
| A file named `*.agent.md` | agent | `references/agent-flow.md` |
|
||||
| A file named `*.instructions.md` | instruction | `references/instruction-flow.md` |
|
||||
| A file named `*.prompt.md` | prompt | `references/prompt-flow.md` |
|
||||
| A `.md` file whose immediate parent directory is `agents/` (`.apm/agents`, `.claude/agents`, `.github/agents`, `.copilot/agents`) | agent | `references/agent-flow.md` |
|
||||
| A `.json` file whose immediate parent directory is `hooks/` (`.apm/hooks`, or a package-root `hooks/`; the hook flow FAILs any other `hooks/`) | hook | `references/hook-flow.md` |
|
||||
| Anything else — a missing path, a directory without `SKILL.md`, any other file | none | — |
|
||||
|
||||
Read only the file its row matched. Each carries Steps 1 to 3 — the deterministic checks, the read, and the qualitative audit — and is self-contained. Return here for Step 4.
|
||||
|
||||
On the last row, stop: run no validator and tell the user the two accepted shapes — a skill directory (or its `SKILL.md`), or an agent file (`*.agent.md`, or a `.md` directly under an `agents/` directory). Guessing a flow audits the path against the wrong spec.
|
||||
On the last row, stop: run no validator and tell the user the shapes the other rows accept. Guessing a flow audits the path against the wrong spec.
|
||||
|
||||
The scripts re-detect the flow from the path. If `validate.sh` reports on the other artifact type than your row, discard what you have and restart here — the flow file, not the script, picked your rubrics, coverage line and remediation line.
|
||||
The scripts re-detect the flow from the path. If `validate.sh` reports on a different artifact type than your row, discard what you have and restart here — the flow file, not the script, picked your rubrics, coverage line and remediation line.
|
||||
|
||||
## Step 4 — Report
|
||||
|
||||
@@ -64,7 +69,9 @@ Checked: structure · provider-safety · description · body · delegation · co
|
||||
|
||||
On the agent flow at plugin/APM scope, drop `pair-consistency` — there is no pair to check.
|
||||
|
||||
Then output only the dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each. Omit clean dimensions — their absence is what confirms they passed.
|
||||
Hook, instruction and prompt flows: the line their flow file ends with.
|
||||
|
||||
Then output only the dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each. Omit clean dimensions.
|
||||
|
||||
Each finding:
|
||||
|
||||
@@ -74,4 +81,4 @@ FAIL/SUGGESTION <finding> — file:line
|
||||
Fix: <exact change — quote before/after where applicable>
|
||||
```
|
||||
|
||||
Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. INFO findings are observational and never change PASS/FAIL; omit `· P info` when there are none. Add a second line whenever there is at least one finding — `Run skill-author to address findings.` on the skill flow, `Run agent-author to address findings.` on the agent flow. Do not apply fixes — report and propose only.
|
||||
Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. INFO findings are observational and never change PASS/FAIL; omit `· P info` when there are none. Add a second line whenever there is at least one finding — `Run skill-author to address findings.` on the skill flow, `Run agent-author to address findings.` on the agent flow, `Run primitive-author to address findings.` on the other three. Do not apply fixes — report and propose only.
|
||||
|
||||
@@ -6,5 +6,13 @@ BasedOnStyles = Kyberforge
|
||||
[**/agents/*.md]
|
||||
BasedOnStyles = Kyberforge
|
||||
|
||||
[**/*.instructions.md]
|
||||
BasedOnStyles = Kyberforge
|
||||
|
||||
[**/*.prompt.md]
|
||||
BasedOnStyles = Kyberforge
|
||||
|
||||
# Stays the last section: the repo's `tests/test-vale-wrap.sh` case 31 appends a rule
|
||||
# override to the end of this file and relies on it landing here.
|
||||
[**/*.agent.md]
|
||||
BasedOnStyles = Kyberforge, KyberforgeCopilot
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
extends: existence
|
||||
message: "Description opens with '%s' — use an imperative 'Use when...' opener instead"
|
||||
message: "Description opens with '%s' — lead with the action or trigger, not 'This'"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
extends: existence
|
||||
message: "Generic reference pointer: '%s' — use the specific 'If X, read `references/file.md`' form instead"
|
||||
message: "Generic reference pointer: '%s' — name the exact file and when to read it ('If X, read `<path>`') instead"
|
||||
level: error
|
||||
scope: text
|
||||
ignorecase: true
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
source_keys:
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- claude-code-hooks-reference
|
||||
- github-copilot-hooks-configuration
|
||||
---
|
||||
|
||||
# Hook Flow
|
||||
|
||||
Steps 1 to 3 for an apm hook — the target Step 0 matched as a `.json` file directly under a
|
||||
`hooks/` directory. Work them in order, then return to `SKILL.md` Step 4 to report.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- apm checks almost nothing here. Invalid JSON is skipped without a word, an event name its rename map does not cover deploys verbatim with no warning (a lowercase `stop` or a typo such as `PreToolUSe` never fires on Claude or Copilot), and a missing script only warns — so `apm install` exiting 0 says nothing about whether the hook works. Never cite a clean install as evidence against a finding.
|
||||
- Copilot receiving a Claude-shaped file is not a finding. apm renders one source for every target and documents that it owns the per-target shape; whether Copilot CLI honours a nested entry or `matcher` is unverified upstream, not a defect in the file.
|
||||
- Quoting is not the fix for a script path with a space. apm rewrites a whole-token-quoted `"${PLUGIN_ROOT}/scripts/x.sh"`, but still stops reading the path at the space; the only fix is a path without one.
|
||||
|
||||
## Step 1 — Deterministic checks
|
||||
|
||||
Resolve the path against this skill's own directory. Run exactly:
|
||||
|
||||
```bash
|
||||
bash scripts/validate.sh <hook-file>
|
||||
```
|
||||
|
||||
Its findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned.
|
||||
|
||||
Checks:
|
||||
|
||||
- JSON validity and UTF-8 encoding; a top level that is not a JSON object; the wrapped-or-naked shape; event lists and nested handler lists (the checks whose failure makes the Copilot install fail).
|
||||
- A file contributing no entries (no events, only empty event lists, or an entry with no handler), an empty event name, and event names that never fire.
|
||||
- Unfilled `FILL IN` or `FILL_IN_` template placeholders.
|
||||
- A symlinked file, or one under apm's deployed output or outside any package rather than package source (exit 1, a finding, not exit 2).
|
||||
- Referenced scripts that are missing, outside the package, not executable when run directly, or referenced by an absolute, bare relative, `../`, split-quoted, space-containing, or `$`/backtick-containing path apm will not bundle correctly.
|
||||
- A plugin-root token apm never rewrites: unbraced (`$PLUGIN_ROOT/x.sh`, `$CLAUDE_PLUGIN_ROOT`), or braced but not directly followed by `/` or `\` (`cd ${PLUGIN_ROOT} && …`).
|
||||
- Deprecated filename routing, and `${CLAUDE_PLUGIN_ROOT}` where `${PLUGIN_ROOT}` would do.
|
||||
- INFO: no `apm.yml` at an inferred `.apm/` package root, or a `targets:` naming no hook target apm recognises (`claude-code`), which leaves event names checked against no harness.
|
||||
|
||||
Script reference rules:
|
||||
|
||||
- Script references are read with apm 0.28.0's own patterns: `${PLUGIN_ROOT}/…` only when the path follows the token directly, up to the first whitespace or quote, and `./…` anywhere in the command.
|
||||
- A `./` or `../` match is held to the script rules — a FAIL when missing — only in command position, when it ends in a script extension, or when it names a package entry that is not a file; any other match (`npx prettier --check ./src`, `printf '.\n'`) is at most a SUGGESTION, because apm only warns and it runs against the consumer's working directory as meant.
|
||||
- Command position is the first token past any `NAME=value` assignments and `env` with its options, or the first operand after `bash`, `sh`, `zsh`, `python`, `python3`, `node`, `pwsh`, `ruby` or `perl`, past its options such as `-e` or `-u`; after a `sh`-family `-c`, the first token of the command string; inline code such as `python3 -c` has none. Absolute and bare relative script paths are checked in the same positions.
|
||||
- The exec bit is required of a script that runs directly: the first token, or the first token of a `-c` command string (`bash -c "${PLUGIN_ROOT}/x.sh"`). Through an interpreter it is not.
|
||||
- Each event is judged per target the package root's `apm.yml` deploys to — no `target:`/`targets:`, `all`, or no `apm.yml` means every hook target — after apm's rename map for that target: it FAILs when a target with a published event list (Claude, Copilot) does not fire the renamed name, whatever its casing.
|
||||
|
||||
Exit codes: **0** with no FAIL, **1** on real findings, **2** when it never ran, including a crash inside the checks — report that as `### Structure` unverified, quoting the stderr reason. An INFO line is observational: report it under `### Structure` and count it in `· P info`.
|
||||
|
||||
The command parser is a heuristic, not a shell. Known blind spots: a script after `&&`, `;` or a pipe inside one command, `env -S`, command substitution, and a quoted `-c` string that ends before the script are not in command position, so a bad reference there is at most a SUGGESTION or unseen. Read every command in Step 2 rather than taking a clean script run as proof.
|
||||
|
||||
There is no provenance and no Vale step: a hook carries no `source_keys` and no prose.
|
||||
|
||||
Six tiers deliberately differ from `primitive-author`'s checklist or the research's. Do not re-tier them by judgment:
|
||||
|
||||
- A hook file directly under a package-root `hooks/` — beside the package's `apm.yml`, or beside a `plugin.json` at any location apm's `find_plugin_json` reads (root, `.github/plugin/`, `.claude-plugin/`, `.cursor-plugin/`) — passes. apm discovers both `.apm/hooks/` and `hooks/`, installs a Claude plugin with no `apm.yml`, and this audit may target a third-party package; `primitive-author` authors only in `.apm/hooks/`. Any other `hooks/` directory (`.github/hooks/`, `.cursor/hooks/`, …) is apm's deployed output, or no package source at all, and FAILs at exit 1.
|
||||
- An event that every listed target fires, but that reaches a target with no published event list (Cursor, Kiro, Gemini, Codex, Antigravity, Windsurf) in a non-PascalCase form after apm's rename, is a SUGGESTION, not the FAIL Must 4 implies: the script cannot tell a harness's native spelling (Cursor's `stop`, Windsurf's `pre_run_command`) from a typo. A Cursor-only `stop` therefore exits 0.
|
||||
- Copilot counts every name apm's own Copilot map emits as fired (`userPromptSubmit`, although Copilot documents `userPromptSubmitted`): the author cannot route around apm's rename, so that is not a finding in the file.
|
||||
- Deprecated filename routing is a SUGGESTION, matching the author's Should: the research allows it when deprecated routing is intended.
|
||||
- A non-executable script run directly (the first token, or first in a `-c` string) is a FAIL, stricter than the research's Should, because it fails every time it fires.
|
||||
- `primitive-author` hook Must 5 bans an absolute or bare relative path "in any position"; this audit checks command positions only, because a later argument is data the script cannot tell from a path. Judge the rest by reading in Step 3.
|
||||
|
||||
## Step 2 — Read the hook and its scripts
|
||||
|
||||
Read the hook file, every script it references, and the package's `apm.yml` `targets:` — reach is narrowed there, never in the hook file.
|
||||
|
||||
## Step 3 — Qualitative audit
|
||||
|
||||
Cite file and line for every finding.
|
||||
|
||||
**purpose** — apm's own rule is to reach for a skill, instruction or prompt first; a hook is for "this must always happen at this event".
|
||||
|
||||
- FAIL: the script carries procedure the agent should follow — instructions printed to the model, a multi-step workflow — rather than a runtime callback. That is a skill.
|
||||
- SUGGESTION: the behaviour is harness-specific (a Claude-only event reaching a harness the script has no event list for, a Claude-only matcher value) in a package whose `targets:` includes other harnesses, and nothing records that the other targets receiving it was accepted. The apm-native fix is a separate package with its own `targets:`, not a routing filename.
|
||||
|
||||
**handlers** — the research checklist's Should and audit-only items, which apm never checks:
|
||||
|
||||
- SUGGESTION: a handler without `"type": "command"` or an explicit numeric `timeout` in seconds.
|
||||
- SUGGESTION: a tool event (`PreToolUse`, `PostToolUse`) or `SessionStart` with no `matcher` — Claude receives `"*"`. A `matcher` on an event Claude ignores it for (`Stop`, `UserPromptSubmit`) is inert, not wrong.
|
||||
- SUGGESTION: a PascalCase event, in a package reaching only harnesses with no published event list, that is not one of that harness's events — the script FAILs a misspelling only where it has the list (Claude, Copilot).
|
||||
- SUGGESTION: `bash`/`powershell`/`timeoutSec` keys in a Claude-shaped file — they render, but leave stray keys in `settings.json`.
|
||||
- SUGGESTION: a helper `.json` file in the hook directory without a `hooks` key — Copilot's loader scans the bundled scripts directory and rejects it. Keep helper configuration non-JSON.
|
||||
|
||||
Then return to `SKILL.md` Step 4, opening the report with this coverage line:
|
||||
|
||||
```text
|
||||
Checked: structure · purpose · handlers
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
source_keys:
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
---
|
||||
|
||||
# Instruction Flow
|
||||
|
||||
Steps 1 to 3 for an apm instruction — the target Step 0 matched as a `*.instructions.md` file.
|
||||
Work them in order, then return to `SKILL.md` Step 4 to report.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `apm compile --validate` is not a gate. Every message `Instruction.validate()` produces is a warning, and it reports success on a file with no description and an empty body — never cite it as evidence against a finding.
|
||||
- `description` never reaches Claude, and it is index text elsewhere, never a routing description. Do not hold it to the skill description contract: no trigger clause, no boundary clause. A Vale `Kyberforge.DescriptionOpener` alert here means rewrite it as a plain statement of what the rule covers.
|
||||
|
||||
## Step 1 — Deterministic checks
|
||||
|
||||
Resolve both paths against this skill's own directory. Run exactly:
|
||||
|
||||
```bash
|
||||
bash scripts/validate.sh <instruction-file>
|
||||
bash scripts/vale-wrap.sh <instruction-file>
|
||||
```
|
||||
|
||||
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path, a file that is not valid UTF-8, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description`, body, an `applyTo` that is neither a string nor a list, present but empty, or has unbalanced braces or brackets (a closer before its opener counts), a missing or list-form `applyTo`, extra keys, and a stem duplicated at the package root. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran, including a crash inside the checks — report that as `### Structure` unverified, quoting the stderr reason.
|
||||
|
||||
A missing `applyTo` is a SUGGESTION, not the FAIL `primitive-author`'s instruction Must 4 implies: absence is legal after the author Gate's explicit yes, which the audit cannot see. The **scope** dimension's always-on FAILs below cover the misuse. Do not re-tier it by judgment.
|
||||
|
||||
`vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. That includes the rules scoped to the `description` (`Kyberforge.DescriptionOpener`, `VagueWording`, `CompositionNote`), a deliberate deviation from `primitive-author`, which holds description wording to at most a Should: the house prose rules apply to every model- or user-visible description, and a deterministic rule does not change tier by file kind. Do not re-tier them. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
|
||||
|
||||
There is no provenance step: an instruction carries no `source_keys`.
|
||||
|
||||
## Step 2 — Read the instruction and its context
|
||||
|
||||
Read the file, the package's `apm.yml`, and the repo's root `AGENTS.md`. For a scoped file, list the tracked files its `applyTo` matches (`git ls-files` filtered by the glob). List the instruction stems the installed dependencies ship (`apm_modules/**/.apm/instructions/*.instructions.md`) — the script checks only the package root for a duplicate.
|
||||
|
||||
## Step 3 — Qualitative audit
|
||||
|
||||
Cite file and line for every finding.
|
||||
|
||||
**scope** — an instruction applies when files matching `applyTo` are touched; with no `applyTo` it loads into every session of every repo that installs the package.
|
||||
|
||||
- FAIL: the content is a rule for this repo alone, scoped or always-on — it belongs in `AGENTS.md` (a nested `AGENTS.md` for a subtree), which is the repo's own instruction source, not in a package that ships it to every consumer. This matches `primitive-author`'s Gate, which routes every repo-only rule there.
|
||||
- FAIL: the stem matches an instruction an installed dependency ships — both deploy to `.claude/rules/<stem>.md`, and one silently overwrites the other.
|
||||
- SUGGESTION: an `applyTo` glob that matches no tracked file here. It is legitimate for files the package's consumers have and this repo does not, so name the mismatch rather than failing it.
|
||||
- SUGGESTION: an always-on file whose content is really file-type specific — narrow it with `applyTo`.
|
||||
- SUGGESTION: a glob much broader than the content (`**` for a rule about Python).
|
||||
|
||||
**description**
|
||||
|
||||
- SUGGESTION: the description does not say what the rule covers, or contradicts the body. Any rationale Claude readers need belongs in the body, because Claude drops the description.
|
||||
- SUGGESTION: a relative markdown link that does not resolve from the source file — apm rewrites links on deploy, and a broken one stays broken on every target.
|
||||
|
||||
Then return to `SKILL.md` Step 4, opening the report with this coverage line:
|
||||
|
||||
```text
|
||||
Checked: structure · prose · scope · description
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
source_keys:
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- adr-0029-prompt-house-rule
|
||||
---
|
||||
|
||||
# Prompt Flow
|
||||
|
||||
Steps 1 to 3 for an apm prompt — the target Step 0 matched as a `*.prompt.md` file. Work them in
|
||||
order, then return to `SKILL.md` Step 4 to report.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- A prompt is judged against ADR-0029, not against apm's framing. apm's docs call a prompt "a program for an LLM" (`apm-docs-llms-full`, "What is APM?" › "Secure by default"); this repo holds it to a single-intent, user-triggered message that steers existing skills or agents by name and carries no procedure of its own.
|
||||
- A prompt's description is not a skill description. It is one plain user-facing sentence with no "Use when" trigger clause and no boundary clause — so never raise a missing trigger or boundary as a finding. A Vale `Kyberforge.DescriptionOpener` alert here means rewrite it as an imperative action ("Review the current PR with …"), not add a trigger.
|
||||
|
||||
## Step 1 — Deterministic checks
|
||||
|
||||
Resolve both paths against this skill's own directory. Run exactly:
|
||||
|
||||
```bash
|
||||
bash scripts/validate.sh <prompt-file>
|
||||
bash scripts/vale-wrap.sh <prompt-file>
|
||||
```
|
||||
|
||||
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, a file that is not valid UTF-8, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description` presence, length, and trigger or `Not X -> Y` boundary clause, keys Claude drops, the camelCase spelling of `allowed-tools` or `argument-hint` and an `argument-hint` alongside `input:` (both SUGGESTION), `input:` names and the object form `- <name>: "<desc>"` (`primitive-author` prompt Must 3 — a bare name, a string list or a plain map is a FAIL even though apm reads them), and `${input:x}` references against `input:`. Keys Claude drops are a SUGGESTION, per `primitive-author` prompt Should 5: a Copilot-only key is legitimate when its Claude drop is intended, and only the author can say which. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran, including a crash inside the checks — report that as `### Structure` unverified, quoting the stderr reason.
|
||||
|
||||
`vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. That includes the rules scoped to the `description` (`Kyberforge.DescriptionOpener`, `VagueWording`, `CompositionNote`), a deliberate deviation from `primitive-author`, which holds description wording to at most a Should: the house prose rules apply to every model- or user-visible description, and a deterministic rule does not change tier by file kind. Do not re-tier them. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
|
||||
|
||||
There is no provenance step: a prompt carries no `source_keys`.
|
||||
|
||||
## Step 2 — Read the prompt and what it steers
|
||||
|
||||
Read the file end to end, then the description of every skill or agent its body names, and confirm each resolves in this repo or in a package the prompt's package declares.
|
||||
|
||||
## Step 3 — Qualitative audit
|
||||
|
||||
Cite file and line for every finding.
|
||||
|
||||
**role** — whether this is a prompt at all. Decide it by reading the body, not by its length or headings; there is no threshold.
|
||||
|
||||
- FAIL: the body clearly carries reusable procedure — steps, gotchas, domain know-how the agent could not act without — rather than steering skills or agents that hold it. Fix: move the procedure into a skill (new, or the one it belongs to) and reduce the prompt to the message that invokes it.
|
||||
- FAIL: the body names a skill or agent that does not resolve, or one carrying `disable-model-invocation: true`, which the model cannot invoke.
|
||||
- SUGGESTION: borderline — some how-to detail beyond steering, but not a full procedure.
|
||||
- SUGGESTION: more than one intent in one prompt.
|
||||
- SUGGESTION: the body is not written as second-person instructions to the agent.
|
||||
- SUGGESTION: a `model` value that is not a model slug the package's Claude target accepts. Copilot ignores `model` and `allowed-tools`, so neither constrains a Copilot run.
|
||||
|
||||
**description**
|
||||
|
||||
- SUGGESTION: the description does not read as one user-facing action, carries a `Not X -> Y` boundary clause (`primitive-author` prompt Should 6), or does not name the skills or agents the prompt steers. On Claude the description is model-visible and apm drops `disable-model-invocation`, so naming what it steers keeps the router pointed at the capability rather than the wrapper.
|
||||
|
||||
Then return to `SKILL.md` Step 4, opening the report with this coverage line:
|
||||
|
||||
```text
|
||||
Checked: structure · prose · role · description
|
||||
```
|
||||
@@ -10,6 +10,11 @@ source_keys:
|
||||
- claude-code-subagents-docs
|
||||
- context7-github-en-copilot
|
||||
- github-custom-agents-configuration
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- adr-0029-prompt-house-rule
|
||||
- claude-code-hooks-reference
|
||||
- github-copilot-hooks-configuration
|
||||
---
|
||||
|
||||
# Sources
|
||||
@@ -151,3 +156,47 @@ source_keys:
|
||||
- **Description:** SDK custom agent API — CustomAgentConfig fields in all five languages, session config, sub-agent lifecycle events, tool scoping, permission handling
|
||||
- **Contributing files:** (none)
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-cli-installed-source
|
||||
|
||||
- **URL:** https://github.com/microsoft/apm/tree/v0.28.0/src/apm_cli/
|
||||
- **Note:** read locally from the pipx install at `~/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/` (apm-cli 0.28.0, tag `v0.28.0`)
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Description:** Installed apm-cli 0.28.0 source — ground truth for what apm deploys from a hook, instruction or prompt file and what it silently skips, warns on, or fails the install for; every deterministic check in `scripts/lib-checks-primitive.sh` traces to it via the research docs' Authoring checklists
|
||||
- **Contributing files:** SKILL.md, references/hook-flow.md, references/instruction-flow.md, references/prompt-flow.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-docs-llms-full
|
||||
|
||||
- **URL:** https://microsoft.github.io/apm/llms-full.txt
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Description:** Published apm docs bundle — the "Hooks and commands", "Instructions and agents" and "Author a prompt" guides: canonical hook shape and `${PLUGIN_ROOT}`, reach narrowed by `targets:` rather than filename routing, and "reach for a skill, instruction, or prompt first"
|
||||
- **Contributing files:** SKILL.md, references/hook-flow.md, references/instruction-flow.md, references/prompt-flow.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## adr-0029-prompt-house-rule
|
||||
|
||||
- **URL:** docs/adr/0029-prompts-are-thin-user-triggered-steering-messages.md
|
||||
- **Research doc:** none
|
||||
- **Basis:** docs/adr/0029-prompts-are-thin-user-triggered-steering-messages.md
|
||||
- **Description:** The repo's prompt house rule — a prompt is a single-intent, user-triggered steering message with no procedure — and its description contract: one user-facing sentence naming the steered skills, no trigger or boundary clause
|
||||
- **Contributing files:** references/prompt-flow.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## claude-code-hooks-reference
|
||||
|
||||
- **URL:** https://code.claude.com/docs/en/hooks
|
||||
- **Research doc:** none
|
||||
- **Basis:** plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh (KNOWN_EVENTS, transcribed from the URL on 2026-09-28; the vendored corpus lists Claude's hook events only partially)
|
||||
- **Description:** Claude Code's hook reference — the full list of hook event names Claude fires, which `scripts/lib-checks-primitive.sh` carries as `KNOWN_EVENTS['claude']` to FAIL an event Claude never fires after apm's rename
|
||||
- **Contributing files:** references/hook-flow.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## github-copilot-hooks-configuration
|
||||
|
||||
- **URL:** https://docs.github.com/en/copilot/reference/hooks-configuration
|
||||
- **Research doc:** none
|
||||
- **Basis:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/configuration.md (the camelCase list; the PascalCase alternative is from the URL, 2026-09-28)
|
||||
- **Description:** GitHub Copilot's hook configuration reference — the camelCase event names Copilot fires and its PascalCase "VS Code compatible" alternative, carried as `KNOWN_EVENTS['copilot']`
|
||||
- **Contributing files:** references/hook-flow.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
@@ -1016,7 +1016,7 @@ FRONTMATTER_RE = re.compile(
|
||||
|
||||
|
||||
def strip_bom(text):
|
||||
return text[1:] if text.startswith(u'') else text
|
||||
return text[1:] if text.startswith(u'\ufeff') else text
|
||||
|
||||
|
||||
class FrontmatterError(Exception):
|
||||
|
||||
951
plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh
Executable file
951
plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh
Executable file
@@ -0,0 +1,951 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib-checks-primitive.sh — SOURCED, never executed.
|
||||
#
|
||||
# The structural check suite for the three apm primitives with no
|
||||
# SKILL.md-shaped container, all authored by primitive-author: hooks
|
||||
# (.apm/hooks/*.json), instructions (*.instructions.md) and prompts
|
||||
# (*.prompt.md). validate.sh detects which one it was handed from the path and
|
||||
# feeds $KYBERFORGE_PRIMITIVE_PY to python3 with the target as argv[1] and the
|
||||
# primitive kind (hook | instruction | prompt) as argv[2].
|
||||
#
|
||||
# Every check here exists because apm itself does not make it. apm 0.28.0
|
||||
# silently skips invalid hook JSON, only warns on an instruction with no
|
||||
# description or body, and never validates a prompt's input: names against its
|
||||
# ${input:x} references — so `apm install` and `apm compile --validate` both exit
|
||||
# 0 on files that deploy nothing, or deploy something that never fires. The
|
||||
# checks follow primitive-author's hook, instruction and prompt reference
|
||||
# checklists (research provenance: source key apm-cli-installed-source in
|
||||
# references/sources.md), except where references/{hook,instruction,prompt}-flow.md
|
||||
# documents a deliberate deviation (a tier moved, or a check the author leaves
|
||||
# audit-only). A Must in primitive-author is a FAIL here, a Should a SUGGESTION.
|
||||
#
|
||||
# No boundary resolver and no word budgets: none of these files is routed on a
|
||||
# description the way a skill is. A prompt's description IS model-visible on
|
||||
# Claude, which is why it gets the three ADR-0029 description SUGGESTIONs below
|
||||
# (length, trigger clause, boundary clause) — but whether
|
||||
# a prompt body carries procedure that belongs in a skill is a judgment call the
|
||||
# prompt flow makes by reading it, and deliberately has no heuristic here.
|
||||
#
|
||||
# Output follows lib-checks-agent.sh: FAIL lines on stderr, SUGGESTION and INFO
|
||||
# on stdout, exit 1 on any FAIL, 0 otherwise.
|
||||
#
|
||||
# Consumed by: validate.sh, hook / instruction / prompt modes.
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034
|
||||
|
||||
kyberforge_primitive_preflight() {
|
||||
# Interpreter and library are checked separately so the message names the
|
||||
# thing to install; see lib-checks-agent.sh for the history. PyYAML is needed
|
||||
# for the two markdown kinds, and is required for hooks too so that one
|
||||
# dependency set covers the whole suite rather than a hook audit passing on a
|
||||
# machine where the next instruction audit cannot run.
|
||||
if ! command -v python3 > /dev/null 2>&1; then
|
||||
echo "Error: python3 is required but was not found on PATH." >&2
|
||||
echo " Why: every primitive check parses the file; without python3 no check runs, and reporting that as a pass would be vacuous." >&2
|
||||
echo " Fix: install python3." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! python3 -c 'import yaml' > /dev/null 2>&1; then
|
||||
echo "Error: PyYAML is required but is not importable by python3." >&2
|
||||
echo " Why: instruction and prompt frontmatter has to be parsed the way apm parses it; a hand-rolled reader would disagree with it on exactly the edge cases these checks exist for." >&2
|
||||
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
IFS='' read -r -d '' KYBERFORGE_PRIMITIVE_PY <<'KYBERFORGE_PRIMITIVE' || true
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import shlex
|
||||
|
||||
import yaml
|
||||
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding='utf-8')
|
||||
except AttributeError: # pragma: no cover — Python < 3.7
|
||||
pass
|
||||
|
||||
target = os.path.abspath(sys.argv[1])
|
||||
kind = sys.argv[2]
|
||||
fname = os.path.basename(target)
|
||||
parent_dir = os.path.dirname(target)
|
||||
|
||||
failed = False
|
||||
suggestions = []
|
||||
|
||||
|
||||
def fail(msg):
|
||||
global failed
|
||||
failed = True
|
||||
print(f"FAIL {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def suggest(msg):
|
||||
suggestions.append(msg)
|
||||
|
||||
|
||||
def info(msg):
|
||||
print(f"INFO {msg}")
|
||||
|
||||
|
||||
def read_text(path):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except UnicodeDecodeError as exc:
|
||||
fail(f"not valid UTF-8 ({exc.reason} at byte {exc.start}) — apm reads primitives as UTF-8 — {fname}")
|
||||
except OSError as exc:
|
||||
fail(f"cannot be read ({exc.strerror}) — {fname}")
|
||||
return None
|
||||
|
||||
|
||||
def check_not_linked(hardlinks=True):
|
||||
# apm's find_files_by_glob (instructions, prompts) rejects symlinks and
|
||||
# hardlinks (link count > 1); find_hook_files skips symlinks only, so hooks
|
||||
# pass hardlinks=False. A rejected file is silently never deployed.
|
||||
if os.path.islink(target):
|
||||
fail(f"is a symlink — apm's discovery skips symlinks, so it is never deployed — {fname}")
|
||||
return
|
||||
if not hardlinks:
|
||||
return
|
||||
try:
|
||||
if os.stat(target).st_nlink > 1:
|
||||
fail(f"is a hardlink (link count > 1) — apm's discovery rejects hardlinks, so it is never deployed — {fname}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def package_root_for(subdir):
|
||||
# <pkg>/.apm/<subdir>/<file> -> <pkg>. Returns None for any other layout.
|
||||
if os.path.basename(parent_dir) != subdir:
|
||||
return None
|
||||
apm_dir = os.path.dirname(parent_dir)
|
||||
if os.path.basename(apm_dir) != '.apm':
|
||||
return None
|
||||
return os.path.dirname(apm_dir)
|
||||
|
||||
|
||||
# The inner group is lazy-optional so an empty block (`---` directly followed
|
||||
# by `---`) matches as empty rather than as no block at all.
|
||||
FRONTMATTER_RE = re.compile(r'\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
||||
|
||||
|
||||
def split_frontmatter(content):
|
||||
"""Return (frontmatter dict | None, body, ok). ok is False on a parse FAIL."""
|
||||
if content.startswith('\ufeff'):
|
||||
content = content[1:]
|
||||
m = FRONTMATTER_RE.match(content)
|
||||
if not m:
|
||||
fail(f"has no YAML frontmatter block (--- ... ---) — description and every other key live there — {fname}")
|
||||
return None, content, False
|
||||
try:
|
||||
fm = yaml.safe_load(m.group(1) or '')
|
||||
except yaml.YAMLError as exc:
|
||||
mark = getattr(exc, 'problem_mark', None)
|
||||
where = f" at line {mark.line + 2}" if mark is not None else ''
|
||||
fail(f"frontmatter is not valid YAML{where} — apm cannot read any key from it — {fname}")
|
||||
return None, content[m.end():], False
|
||||
if fm is None:
|
||||
fm = {}
|
||||
if not isinstance(fm, dict):
|
||||
fail(f"frontmatter is not a YAML mapping — {fname}")
|
||||
return None, content[m.end():], False
|
||||
return fm, content[m.end():], True
|
||||
|
||||
|
||||
def check_description(fm):
|
||||
desc = fm.get('description')
|
||||
if not isinstance(desc, str) or not desc.strip():
|
||||
fail(f"'description' is missing or empty — apm does not require it, so nothing else will catch this — {fname}")
|
||||
return None
|
||||
return desc.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ROUTING_TOKENS = ('copilot', 'vscode', 'cursor', 'claude', 'codex', 'gemini',
|
||||
'antigravity', 'windsurf', 'kiro')
|
||||
_TOK = '|'.join(ROUTING_TOKENS)
|
||||
ROUTING_STEM_RE = re.compile(rf'^hooks-(?:{_TOK})$|(?:^|-)(?:{_TOK})-hooks$')
|
||||
|
||||
# apm 0.28.0 _HOOK_EVENT_MAP (apm_cli/integration/hook_integrator.py): the
|
||||
# rename each target applies before deploying. A name absent from a target's
|
||||
# map deploys to it verbatim, with no warning for an all-lowercase name.
|
||||
_STOP_ALIASES = ('Stop', 'AgentStop', 'agentStop')
|
||||
HOOK_EVENT_MAP = {
|
||||
'copilot': {
|
||||
'PreToolUse': 'preToolUse', 'preToolUse': 'preToolUse',
|
||||
'PostToolUse': 'postToolUse', 'postToolUse': 'postToolUse',
|
||||
'UserPromptSubmit': 'userPromptSubmit', 'userPromptSubmit': 'userPromptSubmit',
|
||||
'SessionStart': 'sessionStart', 'sessionStart': 'sessionStart',
|
||||
**dict.fromkeys(_STOP_ALIASES, 'agentStop'),
|
||||
'PreTaskExecution': 'preTaskExecution', 'preTaskExecution': 'preTaskExecution',
|
||||
'PostTaskExecution': 'postTaskExecution', 'postTaskExecution': 'postTaskExecution',
|
||||
},
|
||||
'claude': {
|
||||
'preToolUse': 'PreToolUse', 'postToolUse': 'PostToolUse',
|
||||
'SessionStart': 'SessionStart', 'sessionStart': 'SessionStart',
|
||||
**dict.fromkeys(_STOP_ALIASES, 'Stop'),
|
||||
},
|
||||
'gemini': {
|
||||
'PreToolUse': 'BeforeTool', 'preToolUse': 'BeforeTool',
|
||||
'PostToolUse': 'AfterTool', 'postToolUse': 'AfterTool',
|
||||
'Stop': 'SessionEnd',
|
||||
},
|
||||
'kiro': {
|
||||
'PreToolUse': 'PreToolUse', 'preToolUse': 'PreToolUse',
|
||||
'PostToolUse': 'PostToolUse', 'postToolUse': 'PostToolUse',
|
||||
'UserPromptSubmit': 'UserPromptSubmit', 'userPromptSubmit': 'UserPromptSubmit',
|
||||
'promptSubmit': 'UserPromptSubmit',
|
||||
'Stop': 'Stop', 'stop': 'Stop', 'AgentStop': 'Stop', 'agentStop': 'Stop',
|
||||
'SessionStart': 'SessionStart', 'sessionStart': 'SessionStart',
|
||||
'PreTaskExecution': 'PreTaskExec', 'preTaskExecution': 'PreTaskExec',
|
||||
'PreTaskExec': 'PreTaskExec',
|
||||
'PostTaskExecution': 'PostTaskExec', 'postTaskExecution': 'PostTaskExec',
|
||||
'PostTaskExec': 'PostTaskExec',
|
||||
'PostFileCreate': 'PostFileCreate', 'PostFileSave': 'PostFileSave',
|
||||
'PostFileDelete': 'PostFileDelete',
|
||||
},
|
||||
}
|
||||
|
||||
# apm's target aliases (core/target_catalog.py): vscode and agents are copilot.
|
||||
TARGET_ALIASES = {'vscode': 'copilot', 'agents': 'copilot'}
|
||||
# The targets apm 0.28.0 deploys hooks to (KNOWN_TARGETS with a hooks primitive).
|
||||
HOOK_TARGETS = {'copilot', 'claude', 'cursor', 'kiro', 'gemini', 'antigravity',
|
||||
'codex', 'windsurf'}
|
||||
|
||||
# The events each harness fires, for the harnesses with a published list.
|
||||
# Claude: code.claude.com/docs/en/hooks. Copilot: docs.github.com hooks
|
||||
# configuration reference, which also accepts each event in PascalCase (its
|
||||
# "VS Code compatible" format), plus the camelCase names apm's own Copilot map
|
||||
# emits — a rename the author cannot route around is not a finding here.
|
||||
# No list is published in apm's source or this repo's research for cursor,
|
||||
# kiro, gemini, antigravity, codex or windsurf, so those are judged by
|
||||
# convention only (hook-flow.md). Checked 2026-09.
|
||||
_COPILOT_CAMEL = {'sessionStart', 'sessionEnd', 'userPromptSubmitted', 'preToolUse',
|
||||
'postToolUse', 'postToolUseFailure', 'preCompact', 'agentStop',
|
||||
'subagentStart', 'subagentStop', 'errorOccurred',
|
||||
'permissionRequest', 'notification'}
|
||||
KNOWN_EVENTS = {
|
||||
'claude': {'SessionStart', 'Setup', 'UserPromptSubmit', 'UserPromptExpansion',
|
||||
'PreToolUse', 'PermissionRequest', 'PermissionDenied', 'PostToolUse',
|
||||
'PostToolUseFailure', 'PostToolBatch', 'Notification', 'MessageDisplay',
|
||||
'SubagentStart', 'SubagentStop', 'TaskCreated', 'TaskCompleted', 'Stop',
|
||||
'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange',
|
||||
'CwdChanged', 'DirectoryAdded', 'FileChanged', 'WorktreeCreate',
|
||||
'WorktreeRemove', 'PreCompact', 'PostCompact', 'PreModelSwitch',
|
||||
'PostModelSwitch', 'Elicitation', 'ElicitationResult', 'SessionEnd'},
|
||||
'copilot': (_COPILOT_CAMEL | {e[0].upper() + e[1:] for e in _COPILOT_CAMEL}
|
||||
| {'Stop', 'UserPromptSubmit'} | set(HOOK_EVENT_MAP['copilot'].values())),
|
||||
}
|
||||
|
||||
HOOK_COMMAND_KEYS = ('command', 'bash', 'powershell', 'windows', 'linux', 'osx')
|
||||
ROOT_TOKENS = ('PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT', 'KIRO_PLUGIN_ROOT')
|
||||
ROOT_TOKEN_RE = re.compile(r'\$\{(' + '|'.join(ROOT_TOKENS) + r')\}')
|
||||
# apm 0.28.0's own patterns, hook_integrator.py _rewrite_command_for_target:
|
||||
# the path must follow the token directly and ends at whitespace or a quote.
|
||||
# The ./ pattern is applied with finditer over the whole command, so it
|
||||
# matches after an interpreter (`bash ./x.sh`) too.
|
||||
APM_ROOT_REF_RE = re.compile(r'\$\{(?:' + '|'.join(ROOT_TOKENS) + r')\}([\\/][^\s"\']+)')
|
||||
APM_REL_REF_RE = re.compile(r'(\.[\\/][^\s"\']+)')
|
||||
# A plugin-root token apm never rewrites: unbraced, so its pattern cannot see it.
|
||||
UNBRACED_ROOT_RE = re.compile(r'\$(?:CLAUDE_|CURSOR_|KIRO_)?PLUGIN_ROOT\b')
|
||||
# A NAME=value shell assignment before the command, bare or after `env`.
|
||||
ASSIGN_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
|
||||
# env options that consume the next token as their value.
|
||||
ENV_VALUE_OPTS = {'-u', '--unset', '-C', '--chdir'}
|
||||
|
||||
|
||||
# An interpreter whose first operand is the script it runs. A reference in
|
||||
# that operand slot is in command position just as a first token is.
|
||||
INTERPRETERS = {'bash', 'sh', 'zsh', 'python', 'python3', 'node', 'pwsh', 'ruby', 'perl'}
|
||||
SH_FAMILY = {'bash', 'sh', 'zsh'}
|
||||
# Options that consume the next token as their value, per interpreter.
|
||||
VALUE_OPTS = {
|
||||
'bash': {'-o', '+o', '-O', '+O'}, 'sh': {'-o', '+o'}, 'zsh': {'-o', '+o'},
|
||||
'python': {'-W', '-X'}, 'python3': {'-W', '-X'},
|
||||
'node': {'-r', '--require', '--import'}, 'ruby': {'-I', '-r'}, 'perl': {'-I', '-M'},
|
||||
}
|
||||
# Options after which the rest is inline code or a module, never a script path.
|
||||
CODE_OPTS = {
|
||||
'python': {'-c', '-m'}, 'python3': {'-c', '-m'},
|
||||
'node': {'-e', '-p', '--eval', '--print'}, 'ruby': {'-e'}, 'perl': {'-e', '-E'},
|
||||
'pwsh': {'-c', '-command', '-encodedcommand'},
|
||||
}
|
||||
|
||||
|
||||
def _prefix_tokens(prefix):
|
||||
return [t.strip('"\'') for t in prefix.split()]
|
||||
|
||||
|
||||
def _command_start(tokens):
|
||||
"""Index of the token that actually runs: past leading NAME=value
|
||||
assignments and an `env` with its options and assignments."""
|
||||
i = 0
|
||||
while i < len(tokens) and ASSIGN_RE.match(tokens[i]):
|
||||
i += 1
|
||||
if i < len(tokens) and os.path.basename(tokens[i]) == 'env':
|
||||
i += 1
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
if tok in ENV_VALUE_OPTS:
|
||||
i += 2
|
||||
elif tok.startswith('-') or ASSIGN_RE.match(tok):
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
return i
|
||||
|
||||
|
||||
def _interp_arg_index(tokens):
|
||||
"""(index, is_command_string) of the script operand after a known
|
||||
interpreter (optionally behind assignments or `env`), or None when the command does not
|
||||
open with one. Option flags are skipped (`bash -e x.sh`, `python3 -u x.py`);
|
||||
for a sh-family `-c` the operand is the command string, whose own first
|
||||
token is the script (`sh -c 'scripts/x.sh'`). Inline code (`python3 -c`,
|
||||
`node -e`) has no script operand."""
|
||||
i = _command_start(tokens)
|
||||
if not (len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS):
|
||||
return None
|
||||
interp = os.path.basename(tokens[i])
|
||||
j = i + 1
|
||||
while j < len(tokens):
|
||||
tok = tokens[j]
|
||||
low = tok.lower()
|
||||
if tok == '--':
|
||||
return j + 1, False
|
||||
if not tok.startswith(('-', '+')) or tok in ('-', '+'):
|
||||
return j, False
|
||||
if interp in SH_FAMILY and not tok.startswith('--') and 'c' in tok[1:]:
|
||||
return j + 1, True
|
||||
if interp == 'pwsh' and low in ('-file', '-f'):
|
||||
return j + 1, False
|
||||
if low in CODE_OPTS.get(interp, ()):
|
||||
return None
|
||||
j += 2 if tok in VALUE_OPTS.get(interp, ()) else 1
|
||||
return j, False
|
||||
|
||||
|
||||
def _position(prefix):
|
||||
"""(is_direct, is_interpreter_arg) for a reference preceded by prefix.
|
||||
Direct means the reference is what runs: the command's first token, or the
|
||||
first token of a sh-family `-c` command string (`bash -c "./x.sh"`)."""
|
||||
toks = [t for t in _prefix_tokens(prefix) if t]
|
||||
while True:
|
||||
if _command_start(toks) == len(toks):
|
||||
return True, False
|
||||
slot = _interp_arg_index(toks)
|
||||
if slot is None:
|
||||
return False, False
|
||||
idx, is_command_string = slot
|
||||
if is_command_string and idx <= len(toks):
|
||||
toks = toks[idx:]
|
||||
continue
|
||||
return False, idx == len(toks)
|
||||
|
||||
|
||||
def is_handler(h):
|
||||
# A handler runs something: a command key, or a non-command handler type
|
||||
# (Claude's prompt/agent/http hooks) whose payload is not a script.
|
||||
if not isinstance(h, dict):
|
||||
return False
|
||||
if any(isinstance(h.get(k), str) and h.get(k).strip() for k in HOOK_COMMAND_KEYS):
|
||||
return True
|
||||
return h.get('type') not in (None, 'command')
|
||||
|
||||
|
||||
def extract_script_refs(cmd, pkg_root, where):
|
||||
"""Return (kind, relpath, is_direct, is_interpreter_arg) for each
|
||||
package-relative reference apm would rewrite, reading the command exactly
|
||||
as apm does. kind is 'root' for a ${*_PLUGIN_ROOT} token, 'rel' for a
|
||||
./path, 'up' for a ../path. A token apm reads wrongly — split-quoted, or a
|
||||
path with a space — is a FAIL here, because apm leaves it unrewritten or
|
||||
cuts it short."""
|
||||
refs = []
|
||||
masked = cmd
|
||||
for m in ROOT_TOKEN_RE.finditer(cmd):
|
||||
start, end = m.start(), m.end()
|
||||
if end < len(cmd) and cmd[end] in '"\'' and cmd[end + 1:end + 2] in ('/', '\\'):
|
||||
fail(f"script path '{cmd[start:]}' splits the quote after ${{{m.group(1)}}} — apm rewrites only a path that follows the token directly, so this one deploys unrewritten and unbundled; quote the whole token: \"${{PLUGIN_ROOT}}/<path>\" — {where}")
|
||||
elif cmd[end:end + 1] not in ('/', '\\'):
|
||||
fail(f"${{{m.group(1)}}} is not followed directly by / or \\ — apm rewrites the token only as the head of a path (${{PLUGIN_ROOT}}/<path>), so here it deploys unrewritten and expands to nothing on most targets — {where}")
|
||||
for m in UNBRACED_ROOT_RE.finditer(cmd):
|
||||
fail(f"unbraced {m.group(0)} — apm rewrites only the braced ${{PLUGIN_ROOT}}/<path> form, so this deploys unrewritten and the script is not bundled; write ${{{m.group(0)[1:]}}}/<path> — {where}")
|
||||
for m in APM_ROOT_REF_RE.finditer(cmd):
|
||||
start, end = m.start(), m.end()
|
||||
opener = cmd[start - 1] if start > 0 and cmd[start - 1] in '"\'' else None
|
||||
path = m.group(1)
|
||||
nxt = cmd[end:end + 1]
|
||||
# A backslash-escaped space, or a quoted token whose script name only
|
||||
# completes past the whitespace apm stopped at ("…/my hook.sh").
|
||||
# A path apm read that exists as a file is exactly what apm bundles, so
|
||||
# a later argument inside the same quotes (`bash -c "…/tool --x a.sh"`)
|
||||
# is an argument, not the rest of a spaced name.
|
||||
spaced = nxt.isspace() and path.endswith('\\')
|
||||
if not spaced and opener is not None and nxt.isspace():
|
||||
quoted = cmd[start:].split(opener, 1)[0]
|
||||
exists = os.path.isfile(os.path.join(pkg_root, path.replace('\\', '/').lstrip('/')))
|
||||
spaced = (not exists and bool(SCRIPT_EXT_RE.search(quoted))
|
||||
and not SCRIPT_EXT_RE.search(path))
|
||||
if spaced:
|
||||
fail(f"script path '{cmd[start:]}' contains a space — apm reads a ${{PLUGIN_ROOT}} path only up to the first whitespace or quote, so it bundles the wrong file and the hook fails; rename the script without spaces — {where}")
|
||||
else:
|
||||
prefix = cmd[:start - 1] if opener else cmd[:start]
|
||||
refs.append(('root', path.replace('\\', '/').lstrip('/')) + _position(prefix))
|
||||
masked = masked[:start] + ' ' * (end - start) + masked[end:]
|
||||
for m in APM_REL_REF_RE.finditer(masked):
|
||||
start = m.start()
|
||||
ref = m.group(1)
|
||||
kind_ = 'rel'
|
||||
if start > 0 and masked[start - 1] == '.':
|
||||
kind_, start = 'up', start - 1
|
||||
opener = masked[start - 1] if start > 0 and masked[start - 1] in '"\'' else None
|
||||
prefix = masked[:start - 1] if opener else masked[:start]
|
||||
refs.append((kind_, ref[2:].replace('\\', '/')) + _position(prefix))
|
||||
return refs
|
||||
|
||||
|
||||
SCRIPT_EXT_RE = re.compile(r'\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|ps1|rb|pl)$', re.IGNORECASE)
|
||||
|
||||
|
||||
def command_tokens(cmd):
|
||||
"""The command's leading whitespace-delimited tokens, quotes removed."""
|
||||
try:
|
||||
return shlex.split(cmd)
|
||||
except ValueError:
|
||||
return _prefix_tokens(cmd)
|
||||
|
||||
|
||||
def check_unanchored_script(cmd, pkg_root, where):
|
||||
# apm rewrites and bundles only ${*_PLUGIN_ROOT}/... and ./... references;
|
||||
# a bare command (`npx foo`, `echo hi`) passes through untouched, which is
|
||||
# fine. An absolute script path, or a bare relative path to a file in the
|
||||
# package, also passes through untouched — so the script is not bundled
|
||||
# and the deployed hook points at a path that does not exist on the
|
||||
# consumer's machine. Checked in command position only: the first token,
|
||||
# and the first operand after a known interpreter, past its options
|
||||
# (`bash -e scripts/x.sh`); a sh-family `-c` string is checked as a command
|
||||
# of its own. A later argument is data, not a script apm is asked to run.
|
||||
toks = command_tokens(cmd)
|
||||
if not toks:
|
||||
return
|
||||
start = _command_start(toks)
|
||||
if start >= len(toks):
|
||||
return
|
||||
slots = [start]
|
||||
arg = _interp_arg_index(toks)
|
||||
if arg is not None and arg[0] < len(toks):
|
||||
if arg[1]:
|
||||
check_unanchored_script(toks[arg[0]], pkg_root, where)
|
||||
else:
|
||||
slots.append(arg[0])
|
||||
for idx in slots:
|
||||
tok = toks[idx]
|
||||
if not tok or tok.startswith(('./', '../', '~', '-')) or '$' in tok:
|
||||
continue
|
||||
if tok.startswith('/'):
|
||||
real_root = os.path.realpath(pkg_root)
|
||||
inside = os.path.realpath(tok).startswith(real_root + os.sep)
|
||||
# In the first slot an extension-less absolute path outside the
|
||||
# package (`/usr/bin/env`, `/bin/bash`) is the host's interpreter;
|
||||
# in the interpreter-argument slot it is the script being run.
|
||||
if inside or SCRIPT_EXT_RE.search(tok) or idx > start:
|
||||
fail(f"script '{tok}' is an absolute path — apm neither bundles nor rewrites it, so it breaks on every other machine; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
|
||||
continue
|
||||
if '/' in tok:
|
||||
for base in (parent_dir, pkg_root):
|
||||
if os.path.isfile(os.path.join(base, tok)):
|
||||
fail(f"script '{tok}' is a bare relative path — apm bundles and rewrites only ${{PLUGIN_ROOT}}/... and ./... references, so this one deploys unbundled; prefix it with ${{PLUGIN_ROOT}}/ or ./ — {where}")
|
||||
break
|
||||
|
||||
|
||||
def check_script(kind_, rel, direct, interp_arg, pkg_root, where):
|
||||
if not rel:
|
||||
return
|
||||
# apm's ./ pattern also matches plain arguments — a cwd directory
|
||||
# (`npx prettier --check ./src`), a printf escape (`'.\\n'`), a sibling path.
|
||||
# apm only warns on those and they run against the consumer's cwd as
|
||||
# meant, so a ./ or ../ match is held to the script rules only in command
|
||||
# position or when it names a script by extension (or a package entry that
|
||||
# is not a file).
|
||||
strong = kind_ == 'root' or direct or interp_arg or bool(SCRIPT_EXT_RE.search(rel))
|
||||
if kind_ == 'up':
|
||||
in_pkg = os.path.exists(os.path.join(pkg_root, rel)) and not os.path.isfile(os.path.join(pkg_root, rel))
|
||||
if strong or in_pkg:
|
||||
fail(f"script path '../{rel}' starts with ../ — apm reads it as ./{rel} from the hook directory, not the parent, so the wrong file (or none) is bundled; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
|
||||
else:
|
||||
suggest(f"argument '../{rel}' matches apm's ./ script pattern — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}")
|
||||
return
|
||||
if '$' in rel or '`' in rel:
|
||||
fail(f"script path '{rel}' contains '$' or a backtick — apm refuses to rewrite it for Claude — {where}")
|
||||
return
|
||||
candidates = []
|
||||
if kind_ == 'root':
|
||||
candidates.append(os.path.join(pkg_root, rel))
|
||||
else:
|
||||
candidates.append(os.path.join(parent_dir, rel))
|
||||
candidates.append(os.path.join(pkg_root, rel))
|
||||
real_root = os.path.realpath(pkg_root)
|
||||
found = None
|
||||
for c in candidates:
|
||||
real = os.path.realpath(c)
|
||||
if real != real_root and not real.startswith(real_root + os.sep):
|
||||
fail(f"script '{rel}' resolves outside the package — apm confines hook scripts to the package root — {where}")
|
||||
return
|
||||
if os.path.isfile(c):
|
||||
found = c
|
||||
break
|
||||
if found is None:
|
||||
not_a_file = any(os.path.exists(c) for c in candidates)
|
||||
if strong or not_a_file:
|
||||
what = "exists in the package but is not a regular file" if not_a_file else "does not exist in the package"
|
||||
fail(f"script '{rel}' {what} — apm only warns, then deploys a hook that fails every time it fires — {where}")
|
||||
else:
|
||||
suggest(f"argument './{rel}' matches apm's ./ script pattern but names no package file — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}")
|
||||
return
|
||||
if direct and not os.access(found, os.X_OK):
|
||||
fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}")
|
||||
|
||||
|
||||
# apm's package manifests (apm.yml, and utils/helpers.py find_plugin_json): a
|
||||
# directory holding any of these is a package root, and its hooks/*.json is
|
||||
# hook source. A Claude plugin needs no apm.yml.
|
||||
PACKAGE_MANIFESTS = ('apm.yml', 'plugin.json', os.path.join('.github', 'plugin', 'plugin.json'),
|
||||
os.path.join('.claude-plugin', 'plugin.json'),
|
||||
os.path.join('.cursor-plugin', 'plugin.json'))
|
||||
|
||||
|
||||
def is_package_root(d):
|
||||
return any(os.path.isfile(os.path.join(d, m)) for m in PACKAGE_MANIFESTS)
|
||||
|
||||
|
||||
def package_targets(pkg_root):
|
||||
"""The hook targets apm renders this package to, aliases folded. No
|
||||
target:/targets: (or no apm.yml, as in a plain Claude plugin) means every
|
||||
target, and 'all' folds to every target. An unreadable apm.yml is treated
|
||||
as every target, the reading that keeps the stricter checks on."""
|
||||
every = set(HOOK_TARGETS)
|
||||
path = os.path.join(pkg_root, 'apm.yml')
|
||||
if not os.path.isfile(path):
|
||||
return every
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
||||
return every
|
||||
if not isinstance(data, dict):
|
||||
return every
|
||||
raw = data.get('targets', data.get('target'))
|
||||
if raw is None:
|
||||
return every
|
||||
if isinstance(raw, list):
|
||||
tokens = [str(t).strip().lower() for t in raw]
|
||||
else:
|
||||
tokens = [t.strip().lower() for t in str(raw).split(',')]
|
||||
tokens = {TARGET_ALIASES.get(t, t) for t in tokens if t}
|
||||
if not tokens or 'all' in tokens:
|
||||
return every
|
||||
known = tokens & HOOK_TARGETS
|
||||
if not known:
|
||||
info(f"targets: in apm.yml names no hook target apm 0.28.0 recognises ({', '.join(sorted(tokens))}) — event names were checked against no harness; apm's hook targets are {', '.join(sorted(HOOK_TARGETS))} — {fname}")
|
||||
return known
|
||||
|
||||
|
||||
def check_event(event, deploys_to):
|
||||
"""hook.md Must 4: the event fires on every target the package deploys
|
||||
to, after apm's rename for that target. A target with a published event
|
||||
list (KNOWN_EVENTS) that does not fire the rendered name is a FAIL. A
|
||||
target without one is judged by apm's own expectation (PascalCase), and at
|
||||
most a SUGGESTION: a harness's native spelling (Cursor's `stop`, Windsurf's
|
||||
snake_case) may be exactly right there."""
|
||||
broken, unverified = [], []
|
||||
for t in sorted(deploys_to):
|
||||
name = HOOK_EVENT_MAP.get(t, {}).get(event, event)
|
||||
if t in KNOWN_EVENTS:
|
||||
if name not in KNOWN_EVENTS[t]:
|
||||
broken.append(f"{t} (as '{name}')" if name != event else t)
|
||||
elif not name[:1].isupper():
|
||||
unverified.append(t)
|
||||
if broken:
|
||||
fail(f"event '{event}' never fires on {', '.join(broken)} — after apm's rename it is not an event that harness fires, and apm never warns; write the harness's PascalCase name (PreToolUse, UserPromptSubmit, Stop, …), or narrow targets: in apm.yml to the harnesses that fire it — {fname}")
|
||||
elif unverified:
|
||||
suggest(f"event '{event}' reaches {', '.join(unverified)} verbatim and is not PascalCase — this audit has no published event list for that harness; confirm it is the harness's own spelling — {fname}")
|
||||
|
||||
|
||||
# The directories apm deploys hooks into for each harness. A hook file under
|
||||
# one of them is install output, not package source.
|
||||
DEPLOY_ROOTS = ('.github', '.claude', '.cursor', '.codex', '.kiro', '.windsurf',
|
||||
'.gemini', '.vscode', '.antigravity', '.copilot')
|
||||
PLACEHOLDER_RE = re.compile(r'FILL IN|FILL_IN_')
|
||||
|
||||
|
||||
def check_placeholders(content):
|
||||
m = PLACEHOLDER_RE.search(content)
|
||||
if m:
|
||||
line = content.count('\n', 0, m.start()) + 1
|
||||
fail(f"unfilled template placeholder '{m.group(0)}' at line {line} — primitive-author Step 3 fills every FILL IN and FILL_IN_ placeholder before the file ships — {fname}")
|
||||
|
||||
|
||||
def audit_hook():
|
||||
check_not_linked(hardlinks=False)
|
||||
stem = fname[:-len('.json')]
|
||||
# validate.sh dispatches only a .json directly under a hooks/ directory.
|
||||
# apm discovers package source at .apm/hooks/*.json and at a package-root
|
||||
# hooks/*.json; anything else under a hooks/ directory is apm's deployed
|
||||
# output (.github/hooks/, .cursor/hooks/, ...) or not a package at all.
|
||||
above = os.path.dirname(parent_dir)
|
||||
if os.path.basename(above) == '.apm':
|
||||
pkg_root = os.path.dirname(above)
|
||||
if not os.path.isfile(os.path.join(pkg_root, 'apm.yml')):
|
||||
info(f"no apm.yml at the inferred package root {pkg_root} — script paths are resolved against it anyway — {fname}")
|
||||
elif os.path.basename(above) not in DEPLOY_ROOTS and is_package_root(above):
|
||||
pkg_root = above
|
||||
else:
|
||||
kind_of = (f"apm's deployed output ({os.path.basename(above)}/hooks/)"
|
||||
if os.path.basename(above) in DEPLOY_ROOTS else 'no package source')
|
||||
fail(f"is {kind_of} — apm reads hook source only from <package>/.apm/hooks/*.json or a package-root hooks/*.json beside apm.yml or a plugin.json manifest; audit the source file in the package's .apm/hooks/ instead — {fname}")
|
||||
return
|
||||
|
||||
# apm lowercases the stem before routing (hook_file_routing.py).
|
||||
if ROUTING_STEM_RE.search(stem.lower()):
|
||||
suggest(f"filename stem '{stem}' uses deprecated hook filename routing — name it plainly and narrow reach with target:/targets: in the package's apm.yml — {fname}")
|
||||
|
||||
content = read_text(target)
|
||||
if content is None:
|
||||
return
|
||||
check_placeholders(content)
|
||||
try:
|
||||
doc = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
fail(f"is not valid JSON (line {exc.lineno}, column {exc.colno}) — apm skips an unparseable hook file silently — {fname}")
|
||||
return
|
||||
if not isinstance(doc, dict):
|
||||
fail(f"top level is not a JSON object — {fname}")
|
||||
return
|
||||
|
||||
if 'hooks' in doc:
|
||||
events = doc['hooks']
|
||||
if not isinstance(events, dict):
|
||||
fail(f"'hooks' is not an object — apm skips the file, and the Copilot install fails outright — {fname}")
|
||||
return
|
||||
else:
|
||||
stray = [k for k, v in doc.items() if not isinstance(v, list)]
|
||||
if stray:
|
||||
fail(f"naked settings-slice shape with non-list top-level key(s) {', '.join(sorted(stray))} — apm does not promote it, Claude gets nothing and Copilot gets a junk file; wrap events in {{\"hooks\": {{...}}}} — {fname}")
|
||||
return
|
||||
events = doc
|
||||
|
||||
if not events:
|
||||
fail(f"contributes no hook entries — apm warns and deploys nothing — {fname}")
|
||||
return
|
||||
|
||||
shape_ok = True
|
||||
for event, entries in events.items():
|
||||
if not isinstance(entries, list):
|
||||
fail(f"event '{event}' is not a list — the Copilot install fails on this payload — {fname}")
|
||||
shape_ok = False
|
||||
continue
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
fail(f"event '{event}' entry {i} is not an object — the Copilot install fails on this payload — {fname}")
|
||||
shape_ok = False
|
||||
continue
|
||||
if 'hooks' in entry:
|
||||
nested = entry['hooks']
|
||||
if not isinstance(nested, list) or not all(isinstance(h, dict) for h in nested):
|
||||
fail(f"event '{event}' entry {i}: nested 'hooks' is not a list of objects — the Copilot install fails on this payload — {fname}")
|
||||
shape_ok = False
|
||||
|
||||
if shape_ok:
|
||||
# hook.md Must 3: the file contributes at least one entry. An empty
|
||||
# event list, or an entry with no handler, deploys nothing runnable.
|
||||
total = 0
|
||||
for event, entries in events.items():
|
||||
for i, entry in enumerate(entries):
|
||||
total += 1
|
||||
handlers = entry['hooks'] if 'hooks' in entry else [entry]
|
||||
if not any(is_handler(h) for h in handlers):
|
||||
fail(f"event '{event}' entry {i} has no handler — no command (or other handler type) to run, so it deploys nothing — {fname}")
|
||||
if total == 0:
|
||||
fail(f"contributes no hook entries — every event list is empty, so apm deploys nothing — {fname}")
|
||||
|
||||
deploys_to = package_targets(pkg_root)
|
||||
for event in events:
|
||||
if not event.strip():
|
||||
fail(f"empty event name — {fname}")
|
||||
else:
|
||||
check_event(event, deploys_to)
|
||||
|
||||
if not shape_ok:
|
||||
return
|
||||
|
||||
uses_claude_token = False
|
||||
for event, entries in events.items():
|
||||
for i, entry in enumerate(entries):
|
||||
handlers = entry['hooks'] if 'hooks' in entry else [entry]
|
||||
for j, handler in enumerate(handlers):
|
||||
where = f"{fname} {event}[{i}]" + (f".hooks[{j}]" if 'hooks' in entry else '')
|
||||
for key in HOOK_COMMAND_KEYS:
|
||||
cmd = handler.get(key)
|
||||
if not isinstance(cmd, str):
|
||||
continue
|
||||
if '${CLAUDE_PLUGIN_ROOT}' in cmd:
|
||||
uses_claude_token = True
|
||||
for kind_, rel, direct, interp_arg in extract_script_refs(cmd, pkg_root, where):
|
||||
check_script(kind_, rel, direct, interp_arg, pkg_root, where)
|
||||
check_unanchored_script(cmd, pkg_root, where)
|
||||
|
||||
if uses_claude_token:
|
||||
suggest(f"uses ${{CLAUDE_PLUGIN_ROOT}} — apm documents the target-neutral ${{PLUGIN_ROOT}}, which it rewrites identically for every target — {fname}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instructions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INSTRUCTION_KEYS = {'description', 'applyTo', 'author', 'version'}
|
||||
|
||||
|
||||
def split_top_level(value):
|
||||
# apm's parse_apply_to: split on commas outside {} (and not escaped \,),
|
||||
# strip each segment, drop empty ones.
|
||||
segs, cur, depth, i = [], '', 0, 0
|
||||
while i < len(value):
|
||||
c = value[i]
|
||||
if c == '\\' and i + 1 < len(value):
|
||||
cur += value[i:i + 2]
|
||||
i += 2
|
||||
continue
|
||||
if c == '{':
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if c == ',' and depth == 0:
|
||||
segs.append(cur)
|
||||
cur = ''
|
||||
else:
|
||||
cur += c
|
||||
i += 1
|
||||
segs.append(cur)
|
||||
return [s.strip() for s in segs if s.strip()]
|
||||
|
||||
|
||||
def _balanced(glob):
|
||||
# A depth walk per bracket kind: a closer before its opener (`}{`) is as
|
||||
# unbalanced as a missing one, which equal counts would not catch.
|
||||
for opener, closer in ('{}', '[]'):
|
||||
depth, i = 0, 0
|
||||
while i < len(glob):
|
||||
c = glob[i]
|
||||
if c == '\\':
|
||||
i += 2
|
||||
continue
|
||||
if c == opener:
|
||||
depth += 1
|
||||
elif c == closer:
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
return False
|
||||
i += 1
|
||||
if depth:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_apply_to(apply_to):
|
||||
if isinstance(apply_to, list):
|
||||
entries = [e for e in apply_to if e is not None and str(e).strip()]
|
||||
globs = [str(e).strip() for e in entries]
|
||||
elif isinstance(apply_to, str):
|
||||
globs = split_top_level(apply_to)
|
||||
else:
|
||||
fail(f"applyTo is neither a string nor a list — apm cannot read a glob from it — {fname}")
|
||||
return False
|
||||
if not globs:
|
||||
fail(f"applyTo is present but empty — remove the key for an intentionally always-on rule, or give it a glob — {fname}")
|
||||
return False
|
||||
ok = True
|
||||
for g in globs:
|
||||
if not _balanced(g):
|
||||
fail(f"applyTo glob '{g}' has unbalanced braces or brackets — it matches nothing, so the rule never fires — {fname}")
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def audit_instruction():
|
||||
check_not_linked()
|
||||
stem = fname[:-len('.instructions.md')]
|
||||
pkg_root = package_root_for('instructions')
|
||||
if pkg_root is None:
|
||||
fail(f"is not directly in a .apm/instructions/ directory — that is the authoring source; anything else is either deployed output or a legacy root file — {fname}")
|
||||
else:
|
||||
dup = os.path.join(pkg_root, fname)
|
||||
if os.path.isfile(dup):
|
||||
fail(f"stem '{stem}' also exists at the package root ({dup}) — both deploy to the same .claude/rules/{stem}.md, and one overwrites the other — {fname}")
|
||||
|
||||
content = read_text(target)
|
||||
if content is None:
|
||||
return
|
||||
check_placeholders(content)
|
||||
fm, body, ok = split_frontmatter(content)
|
||||
if not ok:
|
||||
return
|
||||
check_description(fm)
|
||||
if not body.strip():
|
||||
fail(f"body is empty — apm deploys an empty rule without complaint — {fname}")
|
||||
|
||||
apply_to = fm.get('applyTo')
|
||||
apply_to_ok = apply_to is not None and check_apply_to(apply_to)
|
||||
if apply_to is None:
|
||||
suggest(f"no applyTo — this loads into every session of every repo that installs the package; confirm always-on is intended, and that a rule for this repo alone is not really an AGENTS.md rule — {fname}")
|
||||
elif apply_to_ok and isinstance(apply_to, list):
|
||||
suggest(f"applyTo is a YAML list — Copilot receives the file verbatim and its handling of a list is unverified; use one comma-separated string — {fname}")
|
||||
|
||||
extra = sorted(str(k) for k in fm if k not in INSTRUCTION_KEYS)
|
||||
if extra:
|
||||
suggest(f"frontmatter key(s) {', '.join(extra)} are read by no target and dropped on Claude — keep to description and applyTo (author, version optional) — {fname}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROMPT_KEYS = {'description', 'allowed-tools', 'model', 'argument-hint', 'input'}
|
||||
PROMPT_CAMEL_ALIASES = {'allowedTools': 'allowed-tools', 'argumentHint': 'argument-hint'}
|
||||
INPUT_NAME_RE = re.compile(r'^[A-Za-z][\w-]{0,63}$')
|
||||
# apm's own rewrite pattern for ${input:x}, command_integrator.py.
|
||||
INPUT_REF_RE = re.compile(r'\$\{\{?\s*input\s*:\s*([\w-]+)\s*\}?\}')
|
||||
TRIGGER_RE = re.compile(r'\buse\s+(?:this\s+)?when\b', re.IGNORECASE)
|
||||
# The skill boundary form `Not <thing> -> <target>` (ASCII or Unicode arrow).
|
||||
BOUNDARY_RE = re.compile(r'\bnot\b[^.;]*?(?:->|\u2192)', re.IGNORECASE)
|
||||
PROMPT_DESC_SUGGEST_CHARS = 250
|
||||
|
||||
|
||||
def prompt_input_names(spec):
|
||||
"""Mirror apm's _extract_input_names, but FAIL on what it rejects or
|
||||
misreads instead of warning. Returns the declared names."""
|
||||
names = []
|
||||
|
||||
def accept(candidate):
|
||||
if not isinstance(candidate, str):
|
||||
fail(f"input entry {candidate!r} is not a string name — apm rejects it — {fname}")
|
||||
return
|
||||
s = candidate.strip()
|
||||
if not s:
|
||||
return
|
||||
if not INPUT_NAME_RE.match(s):
|
||||
fail(f"input name '{s}' does not match ^[A-Za-z][\\w-]{{0,63}}$ — apm rejects it, so the argument never exists — {fname}")
|
||||
return
|
||||
names.append(s)
|
||||
|
||||
form = "write each input as `- <name>: \"<description>\"` (primitive-author prompt Must 3)"
|
||||
if spec is None:
|
||||
return names
|
||||
if isinstance(spec, str):
|
||||
fail(f"input: is a bare name, not the object form — {form}, so every argument carries its description — {fname}")
|
||||
accept(spec)
|
||||
elif isinstance(spec, dict):
|
||||
fail(f"input: is a map, not the object form — {form} — {fname}")
|
||||
for k in spec:
|
||||
accept(k)
|
||||
elif isinstance(spec, list):
|
||||
for item in spec:
|
||||
if not isinstance(item, dict):
|
||||
fail(f"input entry {item!r} is a bare name, not the object form — {form} — {fname}")
|
||||
if isinstance(item, dict):
|
||||
if len(item) > 1:
|
||||
keys = ', '.join(str(k) for k in item)
|
||||
hint = (" — this is the upstream docs example's `- name: x` / `description:` form, which yields arguments [name, description]"
|
||||
if 'name' in item else '')
|
||||
fail(f"input entry {{{keys}}} is one map with several keys — apm reads every key as an argument name{hint}; write `- <name>: \"<desc>\"` — {fname}")
|
||||
for k in item:
|
||||
accept(k)
|
||||
else:
|
||||
accept(item)
|
||||
else:
|
||||
fail(f"input is neither a name, a list nor a map — apm extracts no arguments from it — {fname}")
|
||||
return names
|
||||
|
||||
|
||||
def audit_prompt():
|
||||
check_not_linked()
|
||||
stem = fname[:-len('.prompt.md')]
|
||||
segs = stem.replace('\\', '/').split('/')
|
||||
if not stem.strip() or any(s in ('.', '..', '') for s in segs) or '/' in stem.replace('\\', '/'):
|
||||
fail(f"name '{stem}' is not a safe path segment — apm's validate_path_segments rejects it — {fname}")
|
||||
pkg_root = package_root_for('prompts')
|
||||
if pkg_root is None:
|
||||
fail(f"is not directly in a .apm/prompts/ directory — that is the authoring source; anything else is either deployed output or a legacy root file — {fname}")
|
||||
else:
|
||||
dup = os.path.join(pkg_root, fname)
|
||||
if os.path.isfile(dup):
|
||||
fail(f"name '{stem}' also exists at the package root ({dup}) — both deploy as /{stem}, and they collide — {fname}")
|
||||
|
||||
content = read_text(target)
|
||||
if content is None:
|
||||
return
|
||||
check_placeholders(content)
|
||||
fm, body, ok = split_frontmatter(content)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
desc = check_description(fm)
|
||||
if desc is not None:
|
||||
if len(desc) > PROMPT_DESC_SUGGEST_CHARS:
|
||||
suggest(f"description is {len(desc)} characters (> {PROMPT_DESC_SUGGEST_CHARS}) — it is one user-facing sentence (ADR-0029) — {fname}")
|
||||
if TRIGGER_RE.search(desc):
|
||||
suggest(f"description carries a 'Use when' trigger clause — a prompt is user-triggered (ADR-0029); a trigger clause invites the model to route to it on Claude — {fname}")
|
||||
if BOUNDARY_RE.search(desc):
|
||||
suggest(f"description carries a 'Not X -> Y' boundary clause — a prompt is user-triggered (ADR-0029, primitive-author prompt Should 6); name the skills it steers instead — {fname}")
|
||||
|
||||
for camel, kebab in PROMPT_CAMEL_ALIASES.items():
|
||||
if camel in fm:
|
||||
suggest(f"'{camel}' — use the kebab-case spelling '{kebab}' apm documents — {fname}")
|
||||
extra = sorted(str(k) for k in fm if k not in PROMPT_KEYS and k not in PROMPT_CAMEL_ALIASES)
|
||||
if extra:
|
||||
suggest(f"frontmatter key(s) {', '.join(extra)} are dropped on Claude (it keeps only {', '.join(sorted(PROMPT_KEYS))}) — keep them only if the Copilot-only behaviour is intended — {fname}")
|
||||
|
||||
declared = prompt_input_names(fm.get('input'))
|
||||
used = []
|
||||
for m in INPUT_REF_RE.finditer(body):
|
||||
if m.group(1) not in used:
|
||||
used.append(m.group(1))
|
||||
if used and fm.get('input') is None:
|
||||
fail(f"body uses {', '.join('${input:' + u + '}' for u in used)} but no input: is declared — apm rewrites references only when input: names them, so Claude receives the literal text — {fname}")
|
||||
else:
|
||||
for u in used:
|
||||
if u not in declared:
|
||||
fail(f"body uses ${{input:{u}}} but input: does not declare '{u}' — {fname}")
|
||||
for d in declared:
|
||||
if d not in used:
|
||||
fail(f"input '{d}' is declared but the body never uses ${{input:{d}}} — the user is asked for an argument that goes nowhere — {fname}")
|
||||
|
||||
if declared and ('argument-hint' in fm or 'argumentHint' in fm):
|
||||
suggest(f"argument-hint is set alongside input: — apm synthesises the hint from input: names; drop it unless that form is inadequate — {fname}")
|
||||
|
||||
|
||||
AUDITS = {'hook': lambda: audit_hook(), 'instruction': lambda: audit_instruction(), 'prompt': lambda: audit_prompt()}
|
||||
if kind not in AUDITS:
|
||||
print(f"Error: unknown primitive kind '{kind}'", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
AUDITS[kind]()
|
||||
except Exception as exc: # an input shape no check anticipated
|
||||
# Exit 1 means findings; a crash means the checks never completed, which
|
||||
# is the never-ran tier, not a verdict on the file.
|
||||
print(f"Error: the {kind} checks crashed ({type(exc).__name__}: {exc}) and did not complete — {fname}", file=sys.stderr)
|
||||
print(" Why: a partial run reported as findings (exit 1) or as clean (exit 0) would be a verdict the checks never reached.", file=sys.stderr)
|
||||
print(" Fix: report ### Structure as unverified, and file the input shape against factory-audit's lib-checks-primitive.sh.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
for s in suggestions:
|
||||
print(f"SUGGESTION {s}")
|
||||
sys.exit(1 if failed else 0)
|
||||
KYBERFORGE_PRIMITIVE
|
||||
KYBERFORGE_PRIMITIVE_PY="${KYBERFORGE_PRIMITIVE_PY%$'\n'}"
|
||||
@@ -2,13 +2,16 @@
|
||||
set -euo pipefail
|
||||
|
||||
# The ONE entry point for structural validation. It auto-detects whether the
|
||||
# target is a skill directory or an agent definition file and runs the matching
|
||||
# check suite; the two suites live in lib-checks-skill.sh and lib-checks-agent.sh
|
||||
# and are unchanged from the skill-audit / agent-audit scripts they came from.
|
||||
# target is a skill directory, an agent definition file, or one of the three apm
|
||||
# primitives with no container of their own (a hook, an instruction or a prompt)
|
||||
# and runs the matching check suite. The skill and agent suites live in
|
||||
# lib-checks-skill.sh and lib-checks-agent.sh and are unchanged from the
|
||||
# skill-audit / agent-audit scripts they came from; the primitive suite lives in
|
||||
# lib-checks-primitive.sh.
|
||||
# The ADR-0020 boundary resolver both of them need is sourced once, from
|
||||
# lib-boundary-resolver.sh, instead of being embedded twice.
|
||||
#
|
||||
# Detection never guesses. A target that matches neither shape is a hard exit 2
|
||||
# Detection never guesses. A target that matches no shape is a hard exit 2
|
||||
# naming the mismatch, because the alternative — picking a mode and letting the
|
||||
# suite fail on its own terms — reports a skill-shaped finding about an agent
|
||||
# file, or the reverse, and sends the reader after the wrong problem.
|
||||
@@ -115,17 +118,25 @@ _kf_require_lib() {
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate.sh <skill-dir | agent-file>
|
||||
Usage: validate.sh <skill-dir | agent-file | hook-file | instruction-file | prompt-file>
|
||||
|
||||
Validate a skill directory against the agentskills.io specification, or an agent
|
||||
definition file against the agent definition spec. The mode is detected from the
|
||||
target:
|
||||
Validate a skill directory against the agentskills.io specification, an agent
|
||||
definition file against the agent definition spec, or an apm hook, instruction or
|
||||
prompt file against what apm 0.28.0 actually deploys. The mode is detected from
|
||||
the target:
|
||||
|
||||
skill mode the target is a directory (a skill directory contains SKILL.md),
|
||||
or the target IS a SKILL.md file.
|
||||
agent mode the target is a *.agent.md file, or a *.md file whose parent
|
||||
directory is named 'agents' (.apm/agents, .claude/agents,
|
||||
.github/agents, .copilot/agents).
|
||||
hook mode the target is a *.json file directly under a hooks/
|
||||
directory (.apm/hooks, or a hooks/ at a package root: beside
|
||||
apm.yml or a plugin.json manifest; any other hooks/
|
||||
directory is deployed output or no package at all, and
|
||||
FAILs at exit 1).
|
||||
instruction mode the target is a *.instructions.md file.
|
||||
prompt mode the target is a *.prompt.md file.
|
||||
|
||||
Skill mode audits the directory named by <skill-dir>.
|
||||
|
||||
@@ -137,16 +148,21 @@ At project or user scope, <agent-file> is either half of a Claude Code .md /
|
||||
Copilot .agent.md pair.
|
||||
|
||||
Arguments:
|
||||
skill-dir Path to the skill directory containing SKILL.md.
|
||||
agent-file Path to the agent file (or either half of a project/user-scope pair).
|
||||
skill-dir Path to the skill directory containing SKILL.md.
|
||||
agent-file Path to the agent file (or either half of a project/user-scope pair).
|
||||
hook-file Path to a hook JSON file directly under .apm/hooks/ or hooks/.
|
||||
instruction-file Path to a *.instructions.md file.
|
||||
prompt-file Path to a *.prompt.md file.
|
||||
|
||||
Exit codes:
|
||||
0 All checks passed (may include SUGGESTIONs)
|
||||
1 One or more checks failed
|
||||
2 Nothing was audited (no argument, the target matches neither shape, the
|
||||
2 Nothing was audited (no argument, the target matches no shape, the
|
||||
target does not exist, an unrecognized file extension, a missing
|
||||
references/agent-field-inventory.md, or a missing or unreadable lib-*.sh
|
||||
beside this script)
|
||||
references/agent-field-inventory.md, a missing or unreadable lib-*.sh
|
||||
beside this script, or, for a hook, instruction or prompt, a missing
|
||||
python3 or PyYAML, or a crash inside the hook, instruction or prompt
|
||||
checks)
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -156,7 +172,7 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
fi
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Error: a skill directory or an agent file is required." >&2
|
||||
echo "Error: a skill directory, an agent file, or a hook, instruction or prompt file is required." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
@@ -184,7 +200,7 @@ TARGET="$1"
|
||||
if [[ ! -e "$TARGET" && ! -L "$TARGET" ]]; then
|
||||
echo "Error: '$TARGET' does not exist." >&2
|
||||
echo " Why: the path shape says what would be audited, but there is nothing at this path to audit — and auditing a target that is not there would report the absence as findings about it, sending the reader after a spec violation instead of a typo." >&2
|
||||
echo " Fix: check the path, and pass an existing skill directory (or its SKILL.md) or an existing agent file." >&2
|
||||
echo " Fix: check the path, and pass an existing skill directory (or its SKILL.md), agent file, hook file (.json under a hooks/ directory), *.instructions.md or *.prompt.md." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -200,8 +216,8 @@ if [[ -d "$TARGET" ]]; then
|
||||
MODE=skill
|
||||
else
|
||||
echo "Error: '$TARGET' is a directory with no SKILL.md in it." >&2
|
||||
echo " Why: a skill directory is identified by its SKILL.md, and an agent target is a file, never a directory — so this path matches neither mode and guessing one would report findings of the wrong kind." >&2
|
||||
echo " Fix: pass the skill directory that holds SKILL.md, or an agent file (<name>.agent.md, or a .md file under an agents/ directory)." >&2
|
||||
echo " Why: a skill directory is identified by its SKILL.md, and every other target (agent, hook, instruction, prompt) is a file, never a directory — so this path matches no mode and guessing one would report findings of the wrong kind." >&2
|
||||
echo " Fix: pass the skill directory that holds SKILL.md, an agent file (<name>.agent.md, or a .md file under an agents/ directory), a hook file (.json under a hooks/ directory), a *.instructions.md or a *.prompt.md." >&2
|
||||
exit 2
|
||||
fi
|
||||
elif [[ "$TARGET_BASE" == "SKILL.md" ]]; then
|
||||
@@ -209,12 +225,21 @@ elif [[ "$TARGET_BASE" == "SKILL.md" ]]; then
|
||||
TARGET="$(_kf_dirname "$TARGET")"
|
||||
elif [[ "$TARGET_BASE" == *.agent.md ]]; then
|
||||
MODE=agent
|
||||
# The two primitive suffixes are tested before the agents/-parent rule: a
|
||||
# *.prompt.md or *.instructions.md file is that primitive wherever it sits, and
|
||||
# the parent-name rule would otherwise claim one that happened to sit in agents/.
|
||||
elif [[ "$TARGET_BASE" == *.instructions.md ]]; then
|
||||
MODE=instruction
|
||||
elif [[ "$TARGET_BASE" == *.prompt.md ]]; then
|
||||
MODE=prompt
|
||||
elif [[ "$TARGET_BASE" == *.json && "$TARGET_PARENT" == "hooks" && ! -d "$TARGET" ]]; then
|
||||
MODE=hook
|
||||
elif [[ "$TARGET_BASE" == *.md && "$TARGET_PARENT" == "agents" ]]; then
|
||||
MODE=agent
|
||||
else
|
||||
echo "Error: '$TARGET' matches neither a skill directory nor an agent file." >&2
|
||||
echo " Why: skill mode needs a directory containing SKILL.md (or the SKILL.md itself); agent mode needs a <name>.agent.md file, or a .md file directly under an agents/ directory (.apm/agents, .claude/agents, .github/agents, .copilot/agents). Picking a mode anyway would audit this path against the wrong spec." >&2
|
||||
echo " Fix: pass one of those two shapes." >&2
|
||||
echo "Error: '$TARGET' matches no auditable shape." >&2
|
||||
echo " Why: skill mode needs a directory containing SKILL.md (or the SKILL.md itself); agent mode needs a <name>.agent.md file, or a .md file directly under an agents/ directory (.apm/agents, .claude/agents, .github/agents, .copilot/agents); hook mode needs a .json file directly under a hooks/ directory; instruction and prompt modes need a *.instructions.md or *.prompt.md file. Picking a mode anyway would audit this path against the wrong spec." >&2
|
||||
echo " Fix: pass one of those shapes." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -250,6 +275,13 @@ $KYBERFORGE_RESOLVER_PY
|
||||
$KYBERFORGE_AGENT_BODY_PY"
|
||||
python3 -u - "$TARGET" "$SCRIPT_DIR" <<< "$PROG" || RC=$?
|
||||
;;
|
||||
hook | instruction | prompt)
|
||||
_kf_require_lib lib-checks-primitive.sh
|
||||
# shellcheck source=lib-checks-primitive.sh
|
||||
. "$SCRIPT_DIR/lib-checks-primitive.sh"
|
||||
kyberforge_primitive_preflight
|
||||
python3 -u - "$TARGET" "$MODE" <<< "$KYBERFORGE_PRIMITIVE_PY" || RC=$?
|
||||
;;
|
||||
esac
|
||||
|
||||
exit "$RC"
|
||||
|
||||
@@ -36,13 +36,16 @@ bats plugins/kyberforge/.apm/skills/factory-audit/tests/
|
||||
| `validate-agent.bats` | `scripts/validate.sh` against agent files |
|
||||
| `validate-provenance-skill.bats` | `scripts/validate-provenance.sh` against skill directories |
|
||||
| `validate-provenance-agent.bats` | `scripts/validate-provenance.sh` against agent files |
|
||||
| `validate-primitive.bats` | `scripts/validate.sh` against apm hook, instruction and prompt files |
|
||||
|
||||
## Two scripts, four suites
|
||||
## Two scripts, five suites
|
||||
|
||||
`factory-audit` merges what were two skills — `skill-audit` and `agent-audit` —
|
||||
each of which shipped its own `validate.sh` and `validate-provenance.sh`. The
|
||||
merged skill has **one** of each. Every suite here invokes one of those two
|
||||
scripts; the four files are two scripts × two artifact types, not four scripts.
|
||||
scripts; the four skill and agent files are two scripts × two artifact types, not four scripts.
|
||||
`validate-primitive.bats` is a fifth suite over the same `scripts/validate.sh`, for hooks,
|
||||
instructions and prompts, which have no provenance mode and so no provenance suite.
|
||||
|
||||
`validate-skill.bats` and `validate-agent.bats` run the same
|
||||
`scripts/validate.sh` and differ only in the fixtures they point it at. The two
|
||||
@@ -50,22 +53,30 @@ provenance suites stand in the same relation to `scripts/validate-provenance.sh`
|
||||
Do not add a third script path here on the assumption that a differently named
|
||||
suite must mean a differently named script.
|
||||
|
||||
### Auto-detection is pinned across the pair
|
||||
### Auto-detection is pinned across the suites
|
||||
|
||||
Each entry point decides for itself what it was handed. ADR-0025 states the
|
||||
rule: a directory containing `SKILL.md` takes the skill flow; an `.agent.md`
|
||||
file, or a file under a directory named `agents/`, takes the agent flow.
|
||||
Anything else is rejected rather than guessed at. That behaviour is new with the
|
||||
merge — before it, each script was hard-wired to one artifact type and nothing
|
||||
about classification could be wrong — so it is asserted from both sides rather
|
||||
than in one place:
|
||||
skill and agent rule: a directory containing `SKILL.md` takes the skill flow; an
|
||||
`.agent.md` file, or a file under a directory named `agents/`, takes the agent
|
||||
flow. `scripts/validate.sh` adds the hook, instruction and prompt shapes: a `*.instructions.md`
|
||||
or `*.prompt.md` file takes the instruction or prompt flow wherever it sits, and
|
||||
a `.json` file directly under a `hooks/` directory takes the hook flow. Any
|
||||
shape outside the five is rejected rather than guessed at. Classification could
|
||||
not be wrong before the merge — each script was hard-wired to one artifact type
|
||||
— so it is asserted from every side rather than in one place:
|
||||
|
||||
- the skill-side suites pin the skill-directory classification and the
|
||||
neither-shape rejection,
|
||||
no-shape rejection,
|
||||
- the agent-side suites pin the two agent rules *separately* — `.agent.md` in a
|
||||
directory that is not `agents/`, and a plain `.md` under `.apm/agents/` — so
|
||||
that a detector implementing only one of them cannot pass both. Plus a control
|
||||
asserting an agent file never picks up a skill-only gate.
|
||||
- `validate-primitive.bats` pins the hook, instruction and prompt shapes,
|
||||
including the precedence that makes a `*.instructions.md` or `*.prompt.md`
|
||||
under `agents/` take the instruction or prompt flow rather than the agent flow, a hook
|
||||
file under a package-root `hooks/`, and a `.json` outside `hooks/` matching
|
||||
no shape. It also pins their exit tiers: every negative case asserts
|
||||
exit 1 (`assert_failure 1`), and a missing python3 or PyYAML, or a crash inside the checks, asserts exit 2.
|
||||
|
||||
Both skill-side suites additionally pin the `SKILL.md` **file** path, not just
|
||||
the directory: a pre-commit `files:` hook matches files, so every hook-driven
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,12 @@
|
||||
---
|
||||
name: forge
|
||||
description: >
|
||||
Use when the user wants to build or improve something but has not yet named
|
||||
the artifact type — skill, agent, plugin, or marketplace entry; "not sure if
|
||||
this should be a skill or a plugin", "I have an idea but don't know where it
|
||||
belongs". Routes to the matching author skill. Do not use when the type is
|
||||
already named — invoke `skill-author`, `agent-author` or `apm-workflow`
|
||||
directly.
|
||||
Use when the user wants to build or improve something of unnamed type ("where
|
||||
does this idea go?"). If named, use instead: skill -> `skill-author`, agent ->
|
||||
`agent-author`, apm hook/instruction/prompt -> `primitive-author`, plugin ->
|
||||
`apm-workflow`.
|
||||
metadata:
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
category: factory
|
||||
source_keys:
|
||||
- claude-code-subagents-docs
|
||||
@@ -18,16 +16,15 @@ metadata:
|
||||
|
||||
## Gotchas
|
||||
|
||||
- forge is an optional guided entry point, not a gate — `skill-author`, `agent-author`, `factory-audit` and `apm-workflow` all stay directly invokable, and forge never intercepts a direct call to one.
|
||||
- Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite the shared word: `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. The route reference each classification loads spends that distinction: `references/author-routes.md` chooses between the two, `references/apm-routes.md` rules the fork out.
|
||||
- Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite the shared word: `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. The route reference each classification loads spends that distinction: `references/author-routes.md` chooses between a `/fork` subagent and an inline run, and `references/apm-routes.md` rules the fork out.
|
||||
|
||||
## Step 1 — Grill the intent
|
||||
|
||||
Call `grill-with-docs` unless a grill session has already run and is available in the context.
|
||||
|
||||
`grill-with-docs` ships in a sibling plugin that kyberforge does not declare as an apm dependency, so it resolves in the authoring monorepo but can be absent where kyberforge is installed alone. If it does not resolve, grill inline yourself rather than skipping the step: what problem the artifact solves, who invokes it and how, what it must refuse, and which existing skill or plugin already owns part of the job. Say which path you took.
|
||||
If `grill-with-docs` does not resolve, read `references/grill-fallback.md`.
|
||||
|
||||
Grilling regularly overturns the artifact type assumed at the start, or splits one idea into several artifacts, so it runs before classification rather than confirming it. Run it inline in the current conversation — grilling is interactive and a subagent cannot hold the back-and-forth.
|
||||
Grilling often overturns or splits the assumed type, so it runs before classification, inline — a subagent cannot hold the back-and-forth.
|
||||
|
||||
## Step 2 — Classify and dispatch
|
||||
|
||||
@@ -37,12 +34,13 @@ Match the grilled intent against exactly one row — or more than one, if the in
|
||||
|---|---|---|---|
|
||||
| A reusable capability the agent loads inline in the main conversation, triggered by description-matching, free to bundle its own `references/`, `scripts/` or `assets/` | Skill | `skill-author` | `references/author-routes.md` |
|
||||
| A recurring task needs its own reusable definition — dedicated system prompt, tools and description, invokable by name across sessions | Agent / subagent | `agent-author` | `references/author-routes.md` |
|
||||
| A runtime callback at a harness event, a rule scoped to a file pattern, or a reusable user-typed message steering existing skills or agents | Hook / instruction / prompt | `primitive-author` | `references/author-routes.md` |
|
||||
| A new distributable unit — no existing plugin is the right home for the skill, agent, hook or MCP server being built, or the bundle needs its own manifest, versioning and install lifecycle | Plugin | `apm-workflow` (`apm plugin init`) | `references/apm-routes.md` |
|
||||
| The plugin already exists and only its marketplace-facing metadata changes — a first listing, or a version/description update, never the plugin's contents | Marketplace entry | `apm-workflow` (`apm marketplace package add`) | `references/apm-routes.md` |
|
||||
|
||||
The table classifies what to build, not how to run it: a one-off task that merely needs an isolated or context-inheriting run is not an artifact and has no row here. If the intent stays genuinely ambiguous between rows after grilling, ask the user rather than guessing.
|
||||
|
||||
A real artifact that matches no row — a hook, an MCP server, an AGENTS.md, a research doc — has no route here. Say so, hand the user the skill that does own it, and never bend it into a row to make the table fit.
|
||||
A real artifact that matches no row — an MCP server, an AGENTS.md, a research doc — has no route here. Say so, hand the user the skill that does own it, and never bend it into a row to make the table fit.
|
||||
|
||||
When the intent spans several rows, chain the routes in dependency order — an artifact that must exist on disk before another skill can target it goes first, so `apm-workflow` scaffolds the plugin directory before `skill-author` scaffolds a skill inside it.
|
||||
|
||||
@@ -51,4 +49,4 @@ When the intent spans several rows, chain the routes in dependency order — an
|
||||
## Step 3 — Closing gates, common to every route
|
||||
|
||||
- **Resolve before closing.** A route is finished only when its verification reports nothing unresolved. An actionable finding reopens the route; it is never reported onward as a caveat.
|
||||
- **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one. `agent-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output does not say they did.
|
||||
- **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one; it skips the bump when the branch already has one. `agent-author`, `primitive-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output says neither that they bumped nor that the branch already had.
|
||||
|
||||
@@ -15,15 +15,17 @@ removed per ADR-0015 once issue #90 landed, and `apm-workflow` is their sole suc
|
||||
## Always inline, never forked
|
||||
|
||||
Run these routes inline, in the current conversation. Their flows are short, prompt-heavy or
|
||||
gated — `apm-workflow`'s publish and release steps take a HITL gate, and removing a marketplace
|
||||
entry takes a conversational confirmation — and a backgrounded fork cannot surface those
|
||||
checkpoints to the user in real time.
|
||||
gated — editing a package's manifest metadata republishes its public `plugin.json` description,
|
||||
and removing a marketplace entry takes a conversational confirmation before `apm pack` changes the
|
||||
consumed catalog — and a backgrounded fork cannot surface those checkpoints to the user in real
|
||||
time.
|
||||
|
||||
## No clean-context recheck, and no automatic audit
|
||||
|
||||
Skill and agent routes close with a clean-context audit rerun; these two do not, and the omission
|
||||
is deliberate rather than an oversight. Neither artifact type has an audit skill counterpart to
|
||||
re-run, so detaching the route to earn a recheck it would never get buys nothing.
|
||||
Skill, agent, and hook, instruction or prompt routes close with a clean-context audit rerun; these
|
||||
two do not, and the omission is deliberate rather than an oversight. Neither artifact type has an
|
||||
audit skill counterpart to re-run, so detaching the route to earn a recheck it would never get buys
|
||||
nothing.
|
||||
|
||||
These routes get no automated terminal check either. `apm audit` is a separate action on
|
||||
`apm-workflow`'s own dispatch table, not a closing step of the configure or marketplace flow a
|
||||
|
||||
@@ -3,12 +3,13 @@ source_keys:
|
||||
- claude-code-subagents-docs
|
||||
---
|
||||
|
||||
# Routing a skill or agent to its author skill
|
||||
# Routing a skill, agent, hook, instruction or prompt to its author skill
|
||||
|
||||
Reached from `SKILL.md` Step 2 when the classified artifact is a skill or an agent/subagent
|
||||
definition. Route a skill to `skill-author` and an agent to `agent-author`. The two branches
|
||||
differ on the author skill only — both verify the result with `factory-audit`, which detects the
|
||||
artifact type itself — and everything below applies to both.
|
||||
Reached from `SKILL.md` Step 2 when the classified artifact is a skill, an agent/subagent
|
||||
definition, or a hook, instruction or prompt. Route a skill to `skill-author`, an agent to
|
||||
`agent-author`, and a hook, instruction or prompt to `primitive-author`. The branches differ on
|
||||
the author skill only — all verify the result with `factory-audit`, which detects the artifact
|
||||
type itself — and everything below applies to all of them.
|
||||
|
||||
## Choose fork or inline
|
||||
|
||||
@@ -25,13 +26,13 @@ Fall back to an **inline invocation** — same conversation, no subagent — whe
|
||||
|
||||
## Two-tier verification
|
||||
|
||||
Both author skills already close out with their own inline audit, in the same context as the
|
||||
authoring work: `skill-author` and `agent-author` each invoke `factory-audit` on what they wrote.
|
||||
That is tier one, and forge does not change it.
|
||||
Every author skill already closes out with its own inline audit, in the same context as the
|
||||
authoring work: `skill-author`, `agent-author` and `primitive-author` each invoke `factory-audit`
|
||||
on what they wrote. That is tier one, and forge does not change it.
|
||||
|
||||
Tier two belongs to forge. Once the author skill's run has finished, spin up a separate
|
||||
**clean-context subagent** — fresh, not forked, no inherited context — to independently re-run the
|
||||
same audit skill against the finished artifact. This is a distinct verification layer, not a
|
||||
**clean-context subagent** — fresh, not forked, no inherited context — to independently re-run
|
||||
the same audit skill against the finished artifact. This is a distinct verification layer, not a
|
||||
duplicate: the inline audit shares context with the work it is checking and can share its blind
|
||||
spots, while the clean rerun has no stake in the result.
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source_keys: []
|
||||
---
|
||||
|
||||
# Grilling without grill-with-docs
|
||||
|
||||
Reached from `SKILL.md` Step 1 when `grill-with-docs` does not resolve.
|
||||
|
||||
`grill-with-docs` ships in a sibling plugin that kyberforge does not declare as an apm dependency,
|
||||
so it resolves in the authoring monorepo but can be absent where kyberforge is installed alone.
|
||||
|
||||
When it is absent, grill inline yourself rather than skipping the step. Cover four questions:
|
||||
|
||||
- What problem does the artifact solve?
|
||||
- Who invokes it, and how?
|
||||
- What must it refuse?
|
||||
- Which existing skill or plugin already owns part of the job?
|
||||
|
||||
Say which path you took — `grill-with-docs` or the inline fallback — then return to `SKILL.md`
|
||||
Step 2.
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
- **URL:** https://agentskills.io/specification.md
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md
|
||||
- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category, and forge's per-route procedures are that kind of supporting content — so the spec permits the `references/` split here but does not require it. The warrant is a house decision: dispatch is mandatory at two or more mutually exclusive flows, which forge's four-row table is. `references/sources.md` likewise exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it.
|
||||
- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category, and forge's per-route procedures are that kind of supporting content — so the spec permits the `references/` split here but does not require it. The warrant is a house decision: dispatch is mandatory at two or more mutually exclusive flows, which forge's five-row table is. `references/sources.md` likewise exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it.
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ source_keys:
|
||||
|
||||
Reached from `SKILL.md` Step 3 after a route has finished. A skill route always lands here:
|
||||
`skill-author` moves only a skill's own `metadata.version`, which is not the package manifest's
|
||||
number, so the package version is still behind when it reports done. `agent-author` bumps the
|
||||
resolved package's `apm.yml` itself at plugin/APM scope, and `apm-workflow`'s configure flow
|
||||
number, so the package version is still behind when it reports done. `agent-author` and
|
||||
`primitive-author` bump the resolved package's `apm.yml` themselves at plugin/APM scope, and
|
||||
`apm-workflow`'s configure flow
|
||||
carries the same policy — read those routes' output before acting here, because a second bump for
|
||||
one change is wrong.
|
||||
|
||||
@@ -23,6 +24,13 @@ than declaring one, so it does not count as a match. Skip it and keep walking up
|
||||
Skip this step entirely if no ancestor `apm.yml` carries a `type:` field: the artifact is then
|
||||
standalone or scoped to a user agent directory, and there is no package to version.
|
||||
|
||||
Skip the bump, and report that you skipped it, if the branch already moved this package's `version`
|
||||
for unreleased work: `git diff $(git merge-base HEAD origin/main) -- <package>/apm.yml` shows a
|
||||
changed `version:` line. Diff against the remote default branch, not a local `main` that may be
|
||||
stale; if it is not `main`, resolve it with `git symbolic-ref refs/remotes/origin/HEAD`. One bump
|
||||
covers all unreleased work on a branch — `agent-author` and `primitive-author` skip on the same
|
||||
condition — so bumping again here double-counts it.
|
||||
|
||||
## Delegate the bump
|
||||
|
||||
Invoke `apm-workflow` as a **clean-context subagent** — fresh, not forked — with this
|
||||
|
||||
53
plugins/kyberforge/.apm/skills/primitive-author/SKILL.md
Normal file
53
plugins/kyberforge/.apm/skills/primitive-author/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: primitive-author
|
||||
description: >
|
||||
Use when the user wants an apm hook, instruction or prompt created, or
|
||||
findings applied to one. Not read-only review -> factory-audit. Not skills
|
||||
-> skill-author. Not agents -> agent-author. Not apm.yml, targets or package
|
||||
config -> apm-workflow.
|
||||
allowed-tools: Bash Read Write Edit
|
||||
metadata:
|
||||
version: "0.1.0"
|
||||
category: factory
|
||||
source_keys:
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `apm compile --validate` is not a gate: it only warns on instructions, exits 0, and never reads prompts. `apm install` exits 1 only on a hook payload Copilot would reject or on critical hidden Unicode; bad prompt input names and dropped keys merely warn — `/factory-audit` is the only check that fails on the rest.
|
||||
- Never draft with the real suffix outside `.apm/<type>/`. apm's local discovery globs `**/*.instructions.md` across the whole tree, so a draft named that way anywhere in the repo becomes a real instruction. The templates carry a trailing `.template` for this reason; drop it only on the final path.
|
||||
- Never hand-write `.claude/settings.json`, even to test a hook. apm owns that file (ADR-0019), overwrites it outright when it is malformed, and `apm audit --ci` fails on anything it would not have written.
|
||||
|
||||
## Step 1 — Dispatch
|
||||
|
||||
| Target or intent | Type | Reference |
|
||||
|---|---|---|
|
||||
| A hook — `.apm/hooks/<name>.json`, or "run X whenever Y happens" | hook | `references/hook.md` |
|
||||
| An instruction — `.apm/instructions/<name>.instructions.md`, or a rule for files matching a pattern | instruction | `references/instruction.md` |
|
||||
| A prompt — `.apm/prompts/<name>.prompt.md`, or a slash command steering existing skills | prompt | `references/prompt.md` |
|
||||
| Anything else (agent file, context/memory file) | none | stop; agents -> `agent-author`, otherwise name the unsupported type |
|
||||
|
||||
Read only the matching reference. If the target sits inside a git worktree, capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
||||
|
||||
## Step 2 — Boundary gate
|
||||
|
||||
Run the reference's **Gate** section before writing anything. A failed gate stops this skill: hand over to the owner the Gate names. Never bend the artifact to pass the gate.
|
||||
|
||||
## Step 3 — Create or improve
|
||||
|
||||
| Condition | Action |
|
||||
|---|---|
|
||||
| No file at the target path | Create: copy the reference's template from `assets/templates/`, drop `.template`, fill every `FILL IN` and `FILL_IN_` placeholder, and apply the reference's checklist |
|
||||
| File exists, at least one signal | Improve: read the whole file, then apply each signal against the reference's checklist |
|
||||
| File exists, no signal | Stop and ask whether the user meant a new file or has feedback to apply |
|
||||
|
||||
Signals: grill output, `/factory-audit` findings, inline feedback, session context. Group findings by root cause and fix the cause once.
|
||||
|
||||
## Step 4 — Validate and close
|
||||
|
||||
1. Run `/factory-audit` on the file, inline in this context; resolve every FAIL before reporting done, including Vale's `### Prose` FAILs on an instruction or prompt.
|
||||
2. Render it: in a fresh `mktemp -d` directory, run `rtk apm install <absolute path to the owning package> --target <its targets: joined with commas, or all when it declares none>` (a repeated `--target` keeps only the last; Codex receives no prompts), then read what each target received — `.claude/settings.json` and `.github/hooks/`, `.claude/rules/` and `.github/instructions/`, or `.claude/commands/` and `.github/prompts/`. A local path deploys the working tree; `--dry-run` renders nothing and a repo-root install resolves the remote's `main`.
|
||||
3. Bump the owning package's `apm.yml` `version:` (minor for a new hook, instruction or prompt, patch for a fix; even if an instruction carries its own `version:` key) unless this branch already bumped it for unreleased work.
|
||||
4. **Commit verification.** Inside a git worktree, once the audit is clean, run `rtk git add` and `rtk git commit`, then confirm `rtk git log --oneline -1` changed from Step 1's hash: staged-but-uncommitted work is lost if the tree is cleaned up. Outside a worktree, report done on a clean audit and name that as the reason.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"FILL_IN_PascalCaseEvent": [
|
||||
{
|
||||
"matcher": "FILL_IN_matcher",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${PLUGIN_ROOT}/.apm/hooks/FILL_IN_script.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: "FILL IN: one sentence on what this rule governs"
|
||||
applyTo: "FILL IN: glob, e.g. **/*.{ts,tsx}"
|
||||
---
|
||||
|
||||
FILL IN: the rule, as direct second-person guidance. Put any rationale Claude needs here — the
|
||||
description above never reaches Claude.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description: "FILL IN: one user-facing action naming the skills or agents it steers"
|
||||
input:
|
||||
- FILL_IN_name: "FILL IN: what the user supplies"
|
||||
---
|
||||
|
||||
FILL IN: the message the user would otherwise type, steering existing skills or agents by name.
|
||||
Use ${input:FILL_IN_name} where the value belongs.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
source_keys:
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
---
|
||||
|
||||
# Authoring an apm hook
|
||||
|
||||
Reached from `SKILL.md` Step 1 for a hook. `SKILL.md` Step 2 runs the Gate below; Step 3 writes
|
||||
against the shape and the checklist.
|
||||
|
||||
## Gate
|
||||
|
||||
A hook is a runtime callback the harness fires inside its own tool loop — "this must always
|
||||
happen at this event", enforced deterministically rather than left to the model. apm's own
|
||||
guidance is to reach for a skill, instruction or prompt first, and to treat hooks as opt-in
|
||||
surface: they ship to a strict subset of harnesses and are silently skipped everywhere else.
|
||||
|
||||
- **Procedure, know-how, or anything the model should decide to do** → a skill. Stop and hand to
|
||||
`skill-author`.
|
||||
- **The hook must reach only some harnesses** → reach is set by the package `apm.yml` `targets:`,
|
||||
never by the hook file. Stop and hand to `apm-workflow`. `targets:` is package-wide, so a
|
||||
harness-specific hook in a multi-target package means either a separate package or accepting that
|
||||
the other targets receive it too.
|
||||
- **A runtime callback** → continue.
|
||||
|
||||
## Shape
|
||||
|
||||
One JSON file per concern at `.apm/hooks/<name>.json`, with a plain name. Copy
|
||||
`assets/templates/hook.json.template`. Write the canonical shape apm documents and renders per
|
||||
target:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{"type": "command", "command": "${PLUGIN_ROOT}/.apm/hooks/check.sh", "timeout": 10}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`${PLUGIN_ROOT}`** is the target-neutral token; apm rewrites it per target
|
||||
(`"${CLAUDE_PROJECT_DIR}/.claude/hooks/<pkg>/…"` on Claude, repo-relative elsewhere).
|
||||
`${CLAUDE_PLUGIN_ROOT}` is rewritten identically, so it is valid, but it ties the source to one
|
||||
harness's name (Should 11).
|
||||
- **Claude is the verified target.** apm 0.28.0 passes this nested shape to Copilot without
|
||||
reshaping it, and whether Copilot CLI runs nested entries or honours `matcher` is unverified. That
|
||||
gap is apm's to close. Per-file target routing is deprecated, so a Copilot-native flat hook
|
||||
(`bash` / `powershell` / `timeoutSec`) can only live in a separate Copilot-targeted package — hand
|
||||
that to `apm-workflow` rather than adding a second file here.
|
||||
|
||||
## Checklist
|
||||
|
||||
Must:
|
||||
|
||||
1. The file sits directly in `.apm/hooks/`, is not a symlink, and parses as a JSON object. apm
|
||||
skips invalid JSON silently. (apm also discovers a package-root `hooks/`, and `factory-audit`
|
||||
accepts it for third-party packages; author in `.apm/hooks/`.)
|
||||
2. Use the wrapped shape `{"hooks": {Event: [...]}}`. If a naked settings slice is used instead,
|
||||
every top-level value must be a list, with no stray scalar keys anywhere.
|
||||
3. Every event value is a list of objects, and every nested `hooks` is a list of objects. Anything
|
||||
else fails the Copilot install outright. The file contributes at least one entry, and every
|
||||
entry carries at least one handler: an empty list or a handler-less entry deploys nothing, with
|
||||
only a warning.
|
||||
4. Every event is one that each target the package deploys to fires, after apm's rename for that target
|
||||
(`_HOOK_EVENT_MAP`; no `targets:` means every target). Write Claude's PascalCase names
|
||||
(`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`, …), which apm renames
|
||||
for each target its map covers. A name the map does not cover deploys verbatim with no warning,
|
||||
so a lowercase `stop`, a camelCase `userPromptSubmit` or a typo such as `PreToolUSe` never fires
|
||||
on Claude or Copilot. A harness's own spelling (Cursor's `stop`, Windsurf's `pre_run_command`)
|
||||
belongs only in a package whose `targets:` reach no harness that would break it.
|
||||
5. The script is referenced as `${PLUGIN_ROOT}/…` (or `${CLAUDE_PLUGIN_ROOT}/…`, see Shape) for
|
||||
the package root, or `./…` for the hook directory, and exists inside the package. The script is
|
||||
the command's first token or the first argument after an interpreter (`bash`, `sh`, `zsh`,
|
||||
`python`, `python3`, `node`, `pwsh`, `ruby`, `perl`), skipping its options (`-e`, `-u`, …) —
|
||||
after `-c`, the first token of the command string. In any position, no absolute path and
|
||||
no bare relative path (`scripts/x.sh`): apm bundles and rewrites neither. No `$` or backtick in
|
||||
the path itself, and no space. When quoting, quote the whole token —
|
||||
`"${PLUGIN_ROOT}/scripts/my-hook.sh"`, never `"${PLUGIN_ROOT}"/scripts/x.sh`: apm rewrites
|
||||
`${PLUGIN_ROOT}` only when a path separator follows it directly, and only up to the next space
|
||||
or quote, so a split quote is left unrewritten and a spaced path is cut short. The quoting and
|
||||
space rules are stricter than the research's Should, as with Must 6: either defect fails every
|
||||
time the hook fires. A missing script is only a warning at install time.
|
||||
6. A script run directly as the command's first token, or as the first token of an interpreter's
|
||||
`-c` command string (`bash -c "${PLUGIN_ROOT}/x.sh"`), is executable. This is stricter than the
|
||||
research's Should: without it the hook fails every time it fires. A script passed to an
|
||||
interpreter (`bash ${PLUGIN_ROOT}/x.sh`) needs no executable bit.
|
||||
|
||||
Should:
|
||||
|
||||
7. Every handler sets `"type": "command"` and an explicit `timeout` in seconds — apm passes `type`
|
||||
through but never supplies it.
|
||||
8. Set `matcher` explicitly on tool events and on `SessionStart` (`startup`, `resume`, …). Omitted,
|
||||
Claude receives `"*"`.
|
||||
9. Do not author Copilot's flat `bash` / `powershell` / `timeoutSec` keys in a Claude-shaped file;
|
||||
they render onto Claude as stray keys.
|
||||
10. Keep helper files in the hook directory non-JSON. Copilot's loader rejects any bundled `.json`
|
||||
without a `hooks` key.
|
||||
11. Prefer `${PLUGIN_ROOT}` over `${CLAUDE_PLUGIN_ROOT}`.
|
||||
12. No filename that routes by target. Case-insensitively, apm routes a stem of exactly
|
||||
`hooks-<target>` and any stem ending `<target>-hooks` — bare (`claude-hooks`), prefixed
|
||||
(`x-claude-hooks`) or combined (`claude-codex-hooks`, the union). That routing is deprecated and
|
||||
reach belongs to `targets:` (see Gate). The research files this as a Must; it is a Should here
|
||||
because only the author can say deprecated routing is intended.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
source_keys:
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
---
|
||||
|
||||
# Authoring an apm instruction
|
||||
|
||||
Reached from `SKILL.md` Step 1 for an instruction. `SKILL.md` Step 2 runs the Gate below; Step 3
|
||||
writes against the checklist.
|
||||
|
||||
## Gate
|
||||
|
||||
An instruction is a scoped rule: it applies when the agent touches files matching its `applyTo`
|
||||
glob. On Claude it deploys to `.claude/rules/<stem>.md` with `applyTo` renamed to `paths:`.
|
||||
|
||||
- **A rule for this repo alone** → it belongs in the repo's AGENTS.md, which is the single
|
||||
always-on source. Stop and hand to `agentsmd-author`; if it is not installed, edit the repo's
|
||||
AGENTS.md directly.
|
||||
- **No file pattern fits** → an instruction without `applyTo` is always-on in every session of
|
||||
every repo that installs this package, and `apm compile` can fold it into the global sections of
|
||||
`AGENTS.md` and `CLAUDE.md` (CLAUDE.md is skipped when `.claude/rules/` is populated, AGENTS.md
|
||||
when `.github/instructions/` is, unless `--force-instructions`). Say exactly that to the user and continue only on an explicit yes.
|
||||
Legitimate when a package deliberately ships guidance to its consumers; never a default.
|
||||
- **Procedure the agent follows step by step** → a skill. Stop and hand to `skill-author`.
|
||||
- **Which harnesses receive it, or other package config** → set by the package `apm.yml`
|
||||
`targets:`, never by the instruction file. Stop and hand to `apm-workflow`.
|
||||
- **A rule scoped to a file pattern** → continue.
|
||||
|
||||
## Checklist
|
||||
|
||||
Copy `assets/templates/name.instructions.md.template` and drop `.template` only on the final path.
|
||||
|
||||
Must:
|
||||
|
||||
1. The path is `.apm/instructions/<stem>.instructions.md`, directly in that directory, not a
|
||||
symlink or hardlink.
|
||||
2. `description` is a non-empty string. Only `apm compile` warns when it is missing; `apm install`
|
||||
deploys it silently.
|
||||
3. The body is non-empty after trimming whitespace. apm deploys an empty rule silently.
|
||||
4. `applyTo` is a non-empty glob or comma-separated list — top-level commas only as separators,
|
||||
alternation inside `{}` (`"**/*.{ts,tsx}"`), braces and brackets balanced — or absent after the
|
||||
Gate's explicit yes. An empty `applyTo: ""` is neither.
|
||||
5. The stem is unique across the package and its dependencies: a `.claude/rules/<stem>.md`
|
||||
collision is silently overwritten.
|
||||
|
||||
Should:
|
||||
|
||||
6. Write `applyTo` as a scalar string, not a YAML list. Copilot receives the source verbatim, and
|
||||
its handling of a list is unverified.
|
||||
7. Keep frontmatter to `description` and `applyTo`, plus optional `author` and `version`. No target
|
||||
consumes other keys, and Claude drops them.
|
||||
8. Put any rationale Claude needs in the body. `description` never reaches Claude — it survives for
|
||||
Copilot and as index text in Cursor rules and compiled AGENTS.md/CLAUDE.md.
|
||||
9. Keep relative markdown links resolvable from the source file.
|
||||
10. Check the glob against the tree: one that matches nothing here fires only in consumer repos
|
||||
that have such files, and one broader than the rule's real scope spends context on every file
|
||||
it touches.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
source_keys:
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- adr-0029-prompt-house-rule
|
||||
---
|
||||
|
||||
# Authoring an apm prompt
|
||||
|
||||
Reached from `SKILL.md` Step 1 for a prompt. `SKILL.md` Step 2 runs the Gate below; Step 3 writes
|
||||
against the description contract and the checklist.
|
||||
|
||||
## Gate
|
||||
|
||||
This repo holds a prompt to ADR-0029, which is stricter than apm: apm calls a prompt "a callable
|
||||
program", but on Claude it deploys as a command that is a skill in every respect except that it
|
||||
keeps fewer frontmatter keys, apm drops `disable-model-invocation` so it can never be made
|
||||
user-only, and Codex receives no prompts at all. A prompt that carries procedure is therefore a
|
||||
worse skill on every harness.
|
||||
|
||||
- **Reusable know-how, steps, gotchas, bundled files, or anything the model should find on its
|
||||
own** → a skill. Stop and hand to `skill-author`; if a short steering message is still wanted
|
||||
afterwards, come back and write it against the new skill.
|
||||
- **A single-intent message the user would otherwise type repeatedly, steering existing skills or
|
||||
agents by name** → continue. Confirm each skill or agent it names exists and is not
|
||||
`disable-model-invocation: true`: the prompt's body reaches the model, and the model cannot
|
||||
invoke a skill that sets it, so the steering would dead-end (the same check `factory-audit`'s
|
||||
prompt flow applies).
|
||||
- **Which harnesses receive it, or other package config** → set by the package `apm.yml`
|
||||
`targets:`, never by the prompt file. Stop and hand to `apm-workflow`.
|
||||
|
||||
## Description contract
|
||||
|
||||
One plain, user-facing sentence stating the action and naming the skills or agents it steers — "Review the
|
||||
current PR with `gitea-prs` and `factory-audit`, then summarise the findings." No "Use when"
|
||||
trigger clause and no `Not X -> Y` boundary: on Claude the description is model-visible, and a
|
||||
trigger clause invites the router to pick the wrapper over the skills it wraps.
|
||||
|
||||
## Checklist
|
||||
|
||||
Copy `assets/templates/name.prompt.md.template` and drop `.template` only on the final path. No
|
||||
parameters: delete `input:` and the `${input:…}` line.
|
||||
|
||||
Must:
|
||||
|
||||
1. The path is `.apm/prompts/<name>.prompt.md`, directly in that directory, not a symlink or
|
||||
hardlink. `<name>` is a safe path segment and unique across `.apm/prompts/` and the package
|
||||
root; it becomes the Copilot filename and the Claude `/command` name.
|
||||
2. `description` is present and non-empty.
|
||||
3. Every `input:` name matches `^[A-Za-z][\w-]{0,63}$`, written in the object form
|
||||
`- pr_number: "The PR to review"`. Never copy apm's published `- name: pr_number` /
|
||||
`description: …` example: apm reads the map's keys, so it produces the arguments `name` and
|
||||
`description`.
|
||||
4. Every `${input:x}` in the body is declared in `input:`, and every declared name is used. Without
|
||||
`input:`, no `${input:…}` may appear — it would reach Claude unrewritten.
|
||||
|
||||
Should:
|
||||
|
||||
5. Frontmatter keys stay within `description`, `allowed-tools`, `model`, `argument-hint` and
|
||||
`input`. Claude drops everything else with only a warning. The exception: a Copilot-only key
|
||||
(`agent`, `tools`, …) that is intended, with its Claude drop accepted and said so.
|
||||
The research files this as a Must; it is a Should here because only the author can say a
|
||||
Copilot-only key is intended.
|
||||
6. The description follows the contract above: one plain sentence, no trigger or boundary clause,
|
||||
naming the skills or agents it steers.
|
||||
7. Spell keys in kebab-case — `allowed-tools`, `argument-hint` — not the camelCase aliases.
|
||||
8. Omit `argument-hint` when `input:` is set, unless the `<a> <b>` form apm synthesises from the
|
||||
input names is inadequate; an explicit `argument-hint` wins.
|
||||
9. Keep one intent per prompt, and write the body as second-person instructions.
|
||||
10. Keep `description` to 250 characters or fewer.
|
||||
11. Give `model` a slug the target accepts. Copilot ignores `allowed-tools` and `model`, so neither
|
||||
constrains a Copilot run.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Sources
|
||||
|
||||
## apm-cli-installed-source
|
||||
|
||||
- **URL:** https://github.com/microsoft/apm/tree/v0.28.0/src/apm_cli/
|
||||
- **Note:** read locally from the pipx install at `~/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/`
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Description:** Installed apm-cli 0.28.0 source — ground truth for what apm deploys from a hook, instruction or prompt file and what it silently skips or only warns on; each Must/Should traces to the research docs' Authoring checklists or audit-only lists, or to ADR-0029, with tier moves annotated inline, and the `**/*.instructions.md` local-discovery glob behind the template-suffix Gotcha is `primitives/discovery.py` `LOCAL_PRIMITIVE_PATTERNS`; the per-target event rename maps are `integration/hook_integrator.py` `_HOOK_EVENT_MAP`; and Step 4.2's render relies on `apm install <local path>` deploying the working tree to every `--target`, verified against 0.28.0
|
||||
- **Contributing files:** SKILL.md, references/hook.md, references/instruction.md, references/prompt.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-docs-llms-full
|
||||
|
||||
- **URL:** https://microsoft.github.io/apm/llms-full.txt
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Description:** Published apm docs bundle — the "Hooks and commands" guide's canonical hook shape, `${PLUGIN_ROOT}`, reach via `targets:` rather than deprecated filename routing, and "reach for a skill, instruction, or prompt first"; the "Author a prompt" guide's one-intent rule
|
||||
- **Contributing files:** SKILL.md, references/hook.md, references/instruction.md, references/prompt.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## adr-0029-prompt-house-rule
|
||||
|
||||
- **URL:** docs/adr/0029-prompts-are-thin-user-triggered-steering-messages.md
|
||||
- **Research doc:** none
|
||||
- **Basis:** docs/adr/0029-prompts-are-thin-user-triggered-steering-messages.md
|
||||
- **Description:** The repo's prompt house rule — a prompt is a single-intent, user-triggered steering message with no procedure — and its description contract: one user-facing sentence naming the steered skills, no trigger or boundary clause
|
||||
- **Contributing files:** references/prompt.md
|
||||
- **Status:** `extracted`
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: skill-author
|
||||
description: >
|
||||
Use when the user wants to create a new skill from scratch, or apply audit
|
||||
findings, grill output, eval results, or inline feedback to an existing one.
|
||||
Not read-only review -> `factory-audit`. Not agent files -> `agent-author`.
|
||||
Use when creating a new skill, or applying audit findings, grill output, eval
|
||||
results or feedback to an existing one. Not read-only review ->
|
||||
`factory-audit`. Not agents -> `agent-author`. Not hooks, instructions or
|
||||
prompts -> `primitive-author`.
|
||||
allowed-tools: Bash Read Write Edit
|
||||
metadata:
|
||||
version: "1.0.5"
|
||||
version: "1.0.6"
|
||||
category: factory
|
||||
source_keys:
|
||||
- agentskills-home
|
||||
@@ -20,9 +21,8 @@ metadata:
|
||||
|
||||
## Gotchas
|
||||
|
||||
- The word gates are two measurements, not two tiers of one rule: the 2,770-word / 500-line spec backstop counts the whole file, Step 3's gate the body alone. Never unify them.
|
||||
- The 2,770-word / 500-line spec backstop counts the whole file, frontmatter included — a separate measurement from Step 3's body-only gate. Never unify them.
|
||||
- Never spawn a subagent to audit or recheck your own work — run `/factory-audit` inline, in the same context as the edits. Clean-context recheck belongs to `/forge`'s outer loop, and a self-spawned subagent's worktree can be torn down by concurrent cleanup, destroying an uncommitted draft.
|
||||
- Do not create new scripts unless a signal explicitly calls for it. Writing one from scratch requires out-of-scope transcript analysis — flag the opportunity as a suggestion instead.
|
||||
|
||||
## Step 1 — Dispatch
|
||||
|
||||
@@ -57,6 +57,6 @@ Gates `/factory-audit` enforces in both flows:
|
||||
|
||||
Run `/factory-audit` on the resolved skill directory; resolve every FAIL before reporting done. It checks name-to-directory match, placeholders, both size budgets, boundary-target resolution and script hygiene — do not hand-check those. Hand-check the one thing it misses: an empty body reports `PASS SKILL.md body word count 0 (ADR-0020 target: 600)`, so confirm at least one non-empty section exists.
|
||||
|
||||
Bump `metadata.version`: the **minor** version on create (new skills start at `0.1.0`) and the **patch** version on improve.
|
||||
Versioning: on create, keep the scaffold's `0.1.0` — do not bump it (ADR-0022, Decision: `0.1.0` means "created and never yet revised"); on improve, bump the **patch** version.
|
||||
|
||||
**Commit verification.** Inside a git worktree: once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is silently lost if the tree is cleaned up. Report done only once the hash has changed. Outside a worktree (a skill under `~/.claude/skills/`, say) nothing is committable — report done on a clean audit, naming that as the reason.
|
||||
|
||||
@@ -16,14 +16,12 @@ description: >
|
||||
Not FILL IN: near-miss case -> FILL IN: real sibling skill.
|
||||
# Required. Preloaded into EVERY session whether or not the skill is invoked.
|
||||
# Exactly three parts, in this order: trigger clause, at most one capability
|
||||
# clause, boundary clause. Drop the boundary line if no near-miss skill exists.
|
||||
# clause, boundary clause. Write one boundary clause per genuine near-miss; at least one.
|
||||
# Trigger clause: when should an agent activate this skill? Describe the user's
|
||||
# intent, not the skill's internal mechanics.
|
||||
# Budget: 250 characters target, 400 hard ceiling (counting this value only,
|
||||
# with YAML folding resolved). This scaffold sits at 214 — keep the fill-in
|
||||
# under the target rather than growing past it.
|
||||
# Boundary clauses may be plural: write one per genuine near-miss, and none
|
||||
# where no sibling could steal activations.
|
||||
# with YAML folding resolved). Keep the fill-in under the target rather
|
||||
# than growing past it.
|
||||
# Never let a hyphenated skill name wrap across two lines of this folded block
|
||||
# — folding turns the break into a space and the routing target stops resolving.
|
||||
# Banned here: capability lists, output-format detail, composition notes,
|
||||
@@ -89,7 +87,7 @@ metadata:
|
||||
after the mistake is worthless.
|
||||
|
||||
Each entry states a fact that CONTRADICTS a reasonable default:
|
||||
something the agent gets wrong by acting sensibly. Maximum 5 entries.
|
||||
something the agent gets wrong by acting sensibly. Aim for at most five; more is a SUGGESTION.
|
||||
An entry that paraphrases a step below it is a failure, not a gotcha.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sources
|
||||
|
||||
<!-- Populated at Step 5 of skill authoring, after all skill files are written.
|
||||
<!-- Populated at Step 6 of skill authoring (`references/create.md`), after all skill files are written.
|
||||
For each research source with status `extracted`, record which skill files
|
||||
it contributed to under Contributing files.
|
||||
Delete this file if no research sources were provided as input. -->
|
||||
|
||||
@@ -126,16 +126,16 @@ blocks, rationale prose, and any content only one branch reaches. Each reference
|
||||
self-contained for its concern, and every one is wired from the body with the literal conditional
|
||||
form:
|
||||
|
||||
````markdown
|
||||
If <condition>, read `references/<file>.md`.
|
||||
````
|
||||
|
||||
**The one exception, stated once so it is not re-litigated:** an output schema stays in the body
|
||||
only when it applies to *every* flow and is short — roughly 50 words or less, which is the "Output
|
||||
format template" pattern below. An output schema that is longer than that, or that only one flow
|
||||
produces, moves to `references/` like any other schema. No third option exists, and the two rules
|
||||
do not disagree.
|
||||
|
||||
````markdown
|
||||
If <condition>, read `references/<file>.md`.
|
||||
````
|
||||
|
||||
A generic pointer ("see references/ for details") is a Vale error — the agent cannot act on it.
|
||||
|
||||
**A dispatch table is the wiring.** Where the body dispatches, a row already pairs a condition with
|
||||
@@ -153,9 +153,10 @@ an edit to either belongs in both.
|
||||
|
||||
**Dispatch is mandatory at two or more mutually exclusive flows.** The body carries the dispatch
|
||||
table and the gates common to every branch; each flow gets its own self-contained `references/`
|
||||
file. Exemplar: the `apm-workflow` skill — a **294-word body** dispatching to 3,154 words of
|
||||
references across five flow files. Calibrate against 294: that file's whole-file count is 348
|
||||
words, and aiming at that number instead overshoots the body budget by ~18%. The 3,154 excludes
|
||||
file. Exemplar: the `apm-workflow` skill — a body of roughly 240 words dispatching to over
|
||||
3,000 words of references across five flow files. Calibrate against the body-only count
|
||||
`factory-audit` reports, not the whole-file count: there the whole file runs about a fifth larger,
|
||||
so aiming at it overshoots the body budget by that much. The reference total excludes
|
||||
`references/sources.md`, which is a provenance record and is never loaded at runtime.
|
||||
|
||||
**Length.** 600 words SUGGESTION, 900 words FAIL, counting the **body only** — everything after
|
||||
|
||||
@@ -100,8 +100,9 @@ plain sentence and `disable-model-invocation: true` instead.
|
||||
|
||||
**`metadata.version`** — required on every skill (ADR-0022), not a per-skill or per-plugin choice,
|
||||
and enforced by the `skill-size-check` pre-commit hook. The scaffold seeds a new skill at
|
||||
`"0.1.0"`; leave that value alone here and let `SKILL.md` Step 4 bump it. (`"1.0.0"` is the seed
|
||||
for a pre-existing skill retrofitted into the rule, and never applies to a skill created here.)
|
||||
`"0.1.0"`; leave that value alone — `SKILL.md` Step 4 leaves it at `"0.1.0"` too, which ADR-0022
|
||||
reserves for "created and never yet revised". (`"1.0.0"` is the seed for a pre-existing skill
|
||||
retrofitted into the rule, and never applies to a skill created here.)
|
||||
|
||||
**Optional frontmatter** — uncomment and fill in, or remove entirely:
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
source_keys:
|
||||
- agentskills-spec
|
||||
- apm-docs-llms-full
|
||||
---
|
||||
|
||||
# Deployment Modes
|
||||
@@ -11,7 +12,7 @@ Skills deploy standalone, or as part of an APM package (an `apm.yml`-governed `.
|
||||
|
||||
When a host installs a plugin, it copies the plugin directory to a cache. Only the plugin's own files are copied. **Any path that leaves the skill directory breaks post-install:**
|
||||
|
||||
```
|
||||
```text
|
||||
../other-skill/validate.sh # breaks
|
||||
plugins/<plugin>/.apm/skills/other/ # breaks
|
||||
../../shared/utils.sh # breaks
|
||||
@@ -23,7 +24,7 @@ Fix: duplicate the file into the skill's own `scripts/` or `assets/`. There is n
|
||||
|
||||
For a package (an `apm.yml`-governed `.apm/` source tree), the deployable artifact is generated by `apm compile` per target harness — not produced by copying the raw `.apm/` directory wholesale the way a plugin cache install copies a plugin directory. The same self-containment rule still applies at the skill level: **file references inside `.apm/skills/<name>/` must not reach outside that skill's own directory.**
|
||||
|
||||
```
|
||||
```text
|
||||
../other-skill/validate.sh # breaks
|
||||
.apm/skills/other-skill/ # breaks
|
||||
../../shared/utils.sh # breaks
|
||||
@@ -31,16 +32,16 @@ For a package (an `apm.yml`-governed `.apm/` source tree), the deployable artifa
|
||||
|
||||
Fix: duplicate the file into the skill's own `scripts/` or `assets/`, same as plugin mode. `apm.yml`'s `includes:` list (when explicit, not `auto`) controls what gets published from the package, but it is not a cross-skill sharing mechanism — each skill directory must still stand alone.
|
||||
|
||||
## Env vars (plugin mode only)
|
||||
## Env vars (Claude Code plugin install only)
|
||||
|
||||
These variables are injected when the plugin is loaded from an install cache. They are **not available in standalone mode.**
|
||||
Claude Code injects these when it loads the plugin from its install cache. Other harnesses do not, and neither does standalone mode — so they are Claude Code-specific, unlike the target-neutral `${PLUGIN_ROOT}` hook token that apm rewrites per target.
|
||||
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `${CLAUDE_PLUGIN_ROOT}` | Absolute path to the plugin's install directory. Changes on update. |
|
||||
| `${CLAUDE_PLUGIN_DATA}` | Persistent directory that survives updates. Use for `node_modules`, generated state, caches. |
|
||||
|
||||
Use `${CLAUDE_PLUGIN_ROOT}` only in hook commands — not in SKILL.md body text, since standalone deployments won't have it.
|
||||
Neither belongs in SKILL.md body text, since standalone deployments won't have them. Hook commands are not authored here: hook authoring, including which script-path token to use (the target-neutral `${PLUGIN_ROOT}`), belongs to `primitive-author`.
|
||||
|
||||
## Standalone mode
|
||||
|
||||
@@ -48,7 +49,7 @@ Deployed directly to `~/.agents/skills/<name>/`. No plugin context, no env vars
|
||||
|
||||
## Cross-tool portability
|
||||
|
||||
`SKILL.md` is portable — the same file works in Claude Code and Copilot CLI, whether deployed standalone or compiled from an APM package. `apm.yml` is the source manifest: it is itself tool-agnostic (one file describes the package regardless of target), but `apm compile` produces per-target compiled output — a Claude Code plugin tree, a Copilot CLI tree, etc. — from it. Legacy hand-authored manifest files (`plugin.json`, `hooks.json`) are tool-specific and authored separately per tool; they sit outside the `apm.yml`-based flow.
|
||||
`SKILL.md` is portable — the same file works in Claude Code and Copilot CLI, whether deployed standalone or compiled from an APM package. `apm.yml` is the source manifest: it is itself tool-agnostic (one file describes the package regardless of target), but `apm compile` produces per-target compiled output — a Claude Code plugin tree, a Copilot CLI tree, etc. — from it. A legacy hand-authored `plugin.json` is tool-specific and sits outside the `apm.yml`-based flow. Hooks are apm primitives under `.apm/hooks/`, authored by `primitive-author`.
|
||||
|
||||
## Shared assets between skills
|
||||
|
||||
|
||||
@@ -10,13 +10,9 @@ source_keys:
|
||||
Return to `SKILL.md` Step 4 once Step 4 below is done — validation, versioning and commit
|
||||
verification are shared with the create flow and are not repeated here.
|
||||
|
||||
## Step 1 — Verify inputs
|
||||
## Step 1 — Signal sources
|
||||
|
||||
Confirm the skill directory path exists and that at least one improvement signal is present in the
|
||||
conversation or a referenced file.
|
||||
|
||||
If the skill directory is missing, ask for it. If no signals are present, stop: "This skill applies
|
||||
existing signals to a skill. For a blind review without signals, use `/factory-audit` instead."
|
||||
The dispatch in `SKILL.md` Step 1 has already confirmed the directory and at least one signal.
|
||||
|
||||
Signals can come from anywhere in the conversation or referenced files:
|
||||
|
||||
@@ -25,8 +21,6 @@ Signals can come from anywhere in the conversation or referenced files:
|
||||
- Human feedback (feedback.json, inline in conversation, PR or issue comments)
|
||||
- Session context describing what went wrong
|
||||
|
||||
Also verify the `name` field in frontmatter matches the skill's directory name exactly.
|
||||
|
||||
## Step 2 — Gather and group signals
|
||||
|
||||
Read the current skill files (SKILL.md and any files in `scripts/`, `references/`, `assets/`,
|
||||
@@ -91,8 +85,9 @@ Still over after all four means the skill does two jobs: split it rather than co
|
||||
|
||||
**Re-cite what moved.** After content moves between files, update `references/sources.md`'s
|
||||
`Contributing files` for every slug whose content moved, and drop any file the edit deleted.
|
||||
`factory-audit`'s `scripts/validate-provenance.sh` exits 0 on exactly that drift, so a stale
|
||||
provenance claim ships unless you fix it here.
|
||||
`factory-audit`'s `scripts/validate-provenance.sh` fails a listed file that no longer exists, but
|
||||
exits 0 when content moved out of a file that still exists and still lists the slug, so that stale
|
||||
claim ships unless you fix it here.
|
||||
|
||||
**Re-check every relocated gate's reachability.** A Gotcha or gate moved out of the body into one
|
||||
flow's `references/` file is invisible to every other branch, and the word counts improve either
|
||||
@@ -102,6 +97,9 @@ exactly one flow reaches it, otherwise in the body's common-gates section.
|
||||
If a signal points to a script or reference file, edit that file directly rather than adding a
|
||||
workaround in SKILL.md.
|
||||
|
||||
**Do not create a new script unless a signal explicitly calls for it.** Writing one from scratch
|
||||
requires out-of-scope transcript analysis — flag the opportunity as a suggestion instead.
|
||||
|
||||
**A skill carrying no `metadata.version` is seeded at `"1.0.0"`, not bumped** — `SKILL.md` Step 4's
|
||||
patch bump presumes a version to bump, and ADR-0022 reserves `"0.1.0"` for a newly created skill.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ source_keys:
|
||||
- agentskills-evaluating-skills
|
||||
- agentskills-using-scripts
|
||||
- agentskills-quickstart
|
||||
- apm-docs-llms-full
|
||||
---
|
||||
|
||||
# Sources
|
||||
@@ -68,3 +69,11 @@ source_keys:
|
||||
- **Description:** Step-by-step guide to creating a first skill (roll-dice example), how discovery/activation/execution work in practice
|
||||
- **Contributing files:** SKILL.md, references/create.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-docs-llms-full
|
||||
|
||||
- **URL:** https://microsoft.github.io/apm/llms-full.txt
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Description:** Published apm docs bundle — `apm compile` producing per-target output from an `apm.yml` package, the target-neutral `${PLUGIN_ROOT}` hook token apm rewrites per target, and hooks as primitives under `.apm/hooks/`
|
||||
- **Contributing files:** references/deployment-modes.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
@@ -40,8 +40,10 @@ Output:
|
||||
Standalone mode: <path>/<skill-name>/
|
||||
|
||||
Exit codes:
|
||||
0 Scaffold created successfully, or destination already exists (no-op)
|
||||
1 Invalid arguments, missing path, or templates not found
|
||||
0 Scaffold created, destination already complete (no-op), or a partial
|
||||
scaffold from an earlier failed run repaired
|
||||
1 Invalid arguments, missing path, templates not found, name
|
||||
substitution failed, or the destination appeared mid-build
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -152,20 +154,98 @@ else
|
||||
TARGET="$TARGET_INPUT/$SKILL_NAME"
|
||||
fi
|
||||
|
||||
# Destination already exists — treat as a no-op so retries are safe
|
||||
# Files carrying the SKILL_NAME placeholder token, each paired with the exact
|
||||
# template line that marks it as still unsubstituted. Only that whole line
|
||||
# counts: a finished skill may legitimately mention SKILL_NAME in its prose.
|
||||
SUBST_FILES=("SKILL.md" "tests/README.md")
|
||||
SUBST_MARKERS=("name: SKILL_NAME" "bats <destination-dir>/SKILL_NAME/tests/")
|
||||
|
||||
# Replace SKILL_NAME in one file. `sed -i` is not portable — GNU takes an
|
||||
# optional attached suffix, BSD/macOS requires a separate suffix argument and
|
||||
# reads the expression as one — so write to a temp file and move it over.
|
||||
# The move runs only if sed succeeded: a failed sed leaves an empty or partial
|
||||
# temp file, and moving that over the original would destroy it.
|
||||
substitute_file() {
|
||||
local f="$1"
|
||||
if sed "s/SKILL_NAME/$SKILL_NAME/g" "$f" > "$f.tmp" && mv "$f.tmp" "$f"; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$f.tmp"
|
||||
echo "Error: could not substitute the skill name in '$f'." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Substitute every placeholder file under dir $1 (a fresh template copy).
|
||||
substitute_name() {
|
||||
local dir="$1" rel
|
||||
for rel in "${SUBST_FILES[@]}"; do
|
||||
if [[ -f "$dir/$rel" ]]; then
|
||||
substitute_file "$dir/$rel" || return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Substitute only the placeholder files under dir $1 that still carry their
|
||||
# template marker line. Sets REPAIRED to how many were repaired; returns 1 on
|
||||
# the first failure. Called directly, never inside $(...): a command
|
||||
# substitution would swallow the failure and let the caller report success.
|
||||
REPAIRED=0
|
||||
repair_placeholders() {
|
||||
local dir="$1" i f
|
||||
REPAIRED=0
|
||||
for i in "${!SUBST_FILES[@]}"; do
|
||||
f="$dir/${SUBST_FILES[$i]}"
|
||||
if [[ -f "$f" ]] && grep -qxF "${SUBST_MARKERS[$i]}" "$f"; then
|
||||
substitute_file "$f" || return 1
|
||||
REPAIRED=$((REPAIRED + 1))
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ -d "$TARGET" ]]; then
|
||||
# A scaffold left half-built by an earlier failed run still carries a
|
||||
# template marker line; finish it instead of reporting a silent no-op.
|
||||
# Anything else — including a complete skill — is left untouched.
|
||||
if ! repair_placeholders "$TARGET"; then
|
||||
echo "Error: repair of '$TARGET' failed; no file was left half-written." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$REPAIRED" -gt 0 ]]; then
|
||||
# The marker line proves only that the name was never substituted, not
|
||||
# that the earlier copy finished — a file may still be missing.
|
||||
echo "Repaired partial scaffold at '$TARGET' — only the name placeholder (SKILL_NAME) was substituted." >&2
|
||||
echo "The earlier run may also have left files missing: run /factory-audit on it, or delete it and re-run this script." >&2
|
||||
exit 0
|
||||
fi
|
||||
echo "Scaffold already exists at '$TARGET' — nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$TARGET")"
|
||||
|
||||
# Copy templates to destination
|
||||
cp -r "$TEMPLATES_DIR" "$TARGET"
|
||||
# Build in a sibling staging directory and rename it into place only once
|
||||
# complete, so a failure mid-build never leaves a half-built $TARGET behind.
|
||||
# The dot prefix matters: a SIGKILL skips the trap, and a leftover must not
|
||||
# look like a skill to anything scanning .apm/skills/.
|
||||
STAGING="$(mktemp -d "$(dirname "$TARGET")/.new-skill.XXXXXX")"
|
||||
trap 'rm -rf "$STAGING"' EXIT
|
||||
# mktemp creates the directory 0700; give the skill the umask default instead.
|
||||
chmod "$(umask -S)" "$STAGING"
|
||||
cp -R "$TEMPLATES_DIR/." "$STAGING"
|
||||
substitute_name "$STAGING"
|
||||
|
||||
# Set skill name in templates
|
||||
sed -i "s/SKILL_NAME/$SKILL_NAME/g" "$TARGET/SKILL.md"
|
||||
sed -i "s/SKILL_NAME/$SKILL_NAME/g" "$TARGET/tests/README.md"
|
||||
# $TARGET may have appeared since the check above (a concurrent run). `mv`
|
||||
# onto an existing directory nests the source inside it instead of failing,
|
||||
# and GNU `mv -T` is not portable, so re-check immediately before the rename.
|
||||
# This narrows the window to the gap between two syscalls; it does not close it.
|
||||
if [[ -e "$TARGET" ]]; then
|
||||
echo "Error: '$TARGET' appeared while the scaffold was being built; left it untouched." >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$STAGING" "$TARGET"
|
||||
trap - EXIT
|
||||
|
||||
if [[ "$MODE" == "package" ]]; then
|
||||
echo "Mode: package — type-bearing apm.yml found at '$PKG_ROOT'" >&2
|
||||
|
||||
@@ -107,6 +107,132 @@ teardown() {
|
||||
assert_output --partial "nothing to do"
|
||||
}
|
||||
|
||||
@test "no SKILL_NAME placeholder remains anywhere in a fresh scaffold" {
|
||||
bash "$SCRIPT" my-tool "$DEST"
|
||||
run grep -r "SKILL_NAME" "$DEST/my-tool"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
# Lists every entry in $1 other than my-tool and the fixture copies, so a
|
||||
# staging directory of any name (dotted or not) left behind is caught.
|
||||
leftovers() {
|
||||
local entry name
|
||||
for entry in "$1"/* "$1"/.[!.]* "$1"/..?*; do
|
||||
[[ -e "$entry" ]] || continue
|
||||
name=${entry##*/}
|
||||
case "$name" in my-tool|bin|skill.orig) ;; *) printf '%s\n' "$name" ;; esac
|
||||
done
|
||||
}
|
||||
|
||||
# Puts a `sed` on PATH that fails without writing output, so a test can
|
||||
# inject a failure into the substitution step.
|
||||
stub_failing_sed() {
|
||||
mkdir -p "$DEST/bin"
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' > "$DEST/bin/sed"
|
||||
chmod +x "$DEST/bin/sed"
|
||||
}
|
||||
|
||||
@test "leaves no staging directory behind after a successful run" {
|
||||
bash "$SCRIPT" my-tool "$DEST"
|
||||
run leftovers "$DEST"
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "stages the build in a dot-prefixed directory that cannot pass for a skill" {
|
||||
# A SIGKILL skips the cleanup trap, so the staging directory's name is what
|
||||
# keeps a leftover from looking like a skill under .apm/skills/.
|
||||
mkdir -p "$DEST/bin"
|
||||
real_sed="$(command -v sed)"
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "${@: -1}" >> "%s/sed.log"\nexec "%s" "$@"\n' \
|
||||
"$DEST/bin" "$real_sed" > "$DEST/bin/sed"
|
||||
chmod +x "$DEST/bin/sed"
|
||||
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
||||
assert_success
|
||||
run grep -c . "$DEST/bin/sed.log"
|
||||
refute_output "0"
|
||||
run grep -vE "^$DEST/\.[^/]+/" "$DEST/bin/sed.log"
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "scaffold directory gets umask-default permissions, not mktemp's 0700" {
|
||||
bash "$SCRIPT" my-tool "$DEST"
|
||||
mkdir "$DEST/reference-dir"
|
||||
assert_equal "$(ls -ld "$DEST/my-tool" | cut -c1-10)" \
|
||||
"$(ls -ld "$DEST/reference-dir" | cut -c1-10)"
|
||||
rmdir "$DEST/reference-dir"
|
||||
}
|
||||
|
||||
@test "fresh scaffold: a failing sed aborts non-zero with no target and no staging left" {
|
||||
stub_failing_sed
|
||||
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
||||
assert_failure
|
||||
assert [ ! -e "$DEST/my-tool" ]
|
||||
run leftovers "$DEST"
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "repair: a failing sed aborts non-zero and leaves the original file unchanged" {
|
||||
cp -r "$BATS_TEST_DIRNAME/../assets/templates" "$DEST/my-tool"
|
||||
cp "$DEST/my-tool/SKILL.md" "$DEST/skill.orig"
|
||||
stub_failing_sed
|
||||
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
||||
assert_failure
|
||||
refute_output --partial "Repaired"
|
||||
run cmp "$DEST/skill.orig" "$DEST/my-tool/SKILL.md"
|
||||
assert_success
|
||||
run find "$DEST/my-tool" -name '*.tmp'
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "a target that appears after the existence check is not nested into" {
|
||||
# A sed wrapper creates the target mid-build, standing in for a concurrent
|
||||
# run winning the race between the check and the final rename.
|
||||
mkdir -p "$DEST/bin"
|
||||
real_sed="$(command -v sed)"
|
||||
printf '#!/usr/bin/env bash\nmkdir -p "%s/my-tool"\nexec "%s" "$@"\n' \
|
||||
"$DEST" "$real_sed" > "$DEST/bin/sed"
|
||||
chmod +x "$DEST/bin/sed"
|
||||
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
||||
assert_failure
|
||||
run ls -A "$DEST/my-tool"
|
||||
assert_output ""
|
||||
run leftovers "$DEST"
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "retry repairs a half-built scaffold that still carries SKILL_NAME" {
|
||||
# Simulate an earlier run that copied the templates but died before the
|
||||
# name substitution: the retry must finish the job, not no-op.
|
||||
cp -r "$BATS_TEST_DIRNAME/../assets/templates" "$DEST/my-tool"
|
||||
run bash "$SCRIPT" my-tool "$DEST"
|
||||
assert_success
|
||||
assert_output --partial "Repaired partial scaffold"
|
||||
assert_output --partial "only the name placeholder"
|
||||
run grep -r "SKILL_NAME" "$DEST/my-tool"
|
||||
assert_failure
|
||||
run grep -E '^name: my-tool$' "$DEST/my-tool/SKILL.md"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "a complete skill whose text mentions SKILL_NAME stays a true no-op" {
|
||||
# Only the template's exact marker lines mark a half-built scaffold. A
|
||||
# finished skill that merely mentions the token must not be rewritten.
|
||||
mkdir -p "$DEST/my-tool/tests"
|
||||
printf -- '---\nname: my-tool\n---\n\nUse `__SKILL_NAME_PLACEHOLDER__` here.\n' \
|
||||
> "$DEST/my-tool/SKILL.md"
|
||||
printf 'Set SKILL_NAME before running.\n' > "$DEST/my-tool/tests/README.md"
|
||||
cp "$DEST/my-tool/SKILL.md" "$DEST/skill.orig"
|
||||
cp "$DEST/my-tool/tests/README.md" "$DEST/readme.orig"
|
||||
run bash "$SCRIPT" my-tool "$DEST"
|
||||
assert_success
|
||||
assert_output --partial "nothing to do"
|
||||
refute_output --partial "Repaired"
|
||||
run cmp "$DEST/skill.orig" "$DEST/my-tool/SKILL.md"
|
||||
assert_success
|
||||
run cmp "$DEST/readme.orig" "$DEST/my-tool/tests/README.md"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mode detection: package vs standalone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -31,18 +31,19 @@ Authoring source lives in `.apm/`; it is the only content source and the only th
|
||||
|---|---|---|
|
||||
| Skills | `.apm/skills/` | Slash commands available after install |
|
||||
| Agents | `.apm/agents/*.agent.md` | Role-based agents; one vendor-neutral `.agent.md` per agent (ADR-0016) |
|
||||
| Hooks | `.apm/hooks/` | Event-triggered automation — Claude Code only, see below |
|
||||
| Hooks | `.apm/hooks/` | Event-triggered automation — authored for Claude Code, see below |
|
||||
|
||||
**Hooks are Claude Code-only.** apm merges `.apm/hooks/*.json` into the consuming project's `.claude/settings.json` at install. Copilot CLI has no default hooks path — `agents` and `skills` default to `agents/` and `skills/`, but `hooks` defaults to nothing (`docs/research/docs/github-copilot-plugins/configuration.md:47`), so Copilot reads hooks only via an explicit pointer in a plugin manifest — and since ADR-0024 there is no per-plugin manifest to carry one. Copilot therefore loads no hooks from this plugin. Details, including why a pointer was the wrong fix even when a manifest existed, are in `docs/hooks.md`.
|
||||
**Hooks are authored for Claude Code, but apm writes them for every package target.** apm merges `.apm/hooks/*.json` into the consuming project's `.claude/settings.json` at install. Because kyberforge also targets Copilot and Codex, apm writes the same hook to `.github/hooks/kyberforge-hooks.json` (nested shape passed through, not reshaped) and into `.codex/hooks.json` when `.codex/` exists. Whether those harnesses execute it is unverified; if they do, it exits immediately, because the script exits 0 unless `CLAUDE_PROJECT_DIR` is set, and of the three only Claude Code documents exporting it for SessionStart hooks (unless the variable is inherited from the user's environment). Details are in `docs/hooks.md` and ADR-0019's 2026-09-28 amendment and correction.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Description |
|
||||
|---|---|
|
||||
| `forge` | Grill an unclassified "I want to add something" request, decide whether it's a skill, agent, plugin, or marketplace entry, then route to the matching author skill |
|
||||
| `forge` | Grill an unclassified "I want to add something" request, decide whether it's a skill, agent, hook, instruction, prompt, plugin, or marketplace entry, then route to the matching author skill |
|
||||
| `skill-author` | Create or improve a skill from scratch, audit findings, or inline feedback |
|
||||
| `agent-author` | Author an agent definition file |
|
||||
| `factory-audit` | Audit a skill directory or an agent definition — structure, provider safety, description and body quality, and provenance; produces a findings report. Auto-detects which of the two it was handed (ADR-0025) |
|
||||
| `primitive-author` | Create or improve an apm hook, instruction or prompt, gated on whether it should be one at all (ADR-0029 for prompts) |
|
||||
| `factory-audit` | Audit a skill directory, an agent definition, or an apm hook, instruction or prompt — structure, provider safety, description and body quality, and provenance; produces a findings report. Auto-detects which it was handed (ADR-0025) |
|
||||
| `apm-install` | Install or upgrade the apm CLI and set up the agent runtimes it drives (Copilot CLI, Codex, Gemini, generic llm) |
|
||||
| `apm-workflow` | Author apm.yml, scaffold an apm package/marketplace, install dependencies, and compile/pack/publish/audit apm content |
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: kyberforge
|
||||
version: 2.0.1
|
||||
version: 2.1.0
|
||||
description: Skills and agents for creating, maintaining, and managing an apm plugin marketplace for Claude Code and GitHub Copilot.
|
||||
author:
|
||||
name: Defame1297
|
||||
|
||||
@@ -9,9 +9,10 @@ Author hooks in `plugins/kyberforge/.apm/hooks/*.json`. `.apm/` is the only cont
|
||||
`apm install` is the only supported install path (ADR-0024) — there is no generated mirror at the
|
||||
plugin root and no per-plugin `plugin.json`, so `.apm/hooks/` is both where you edit and what ships.
|
||||
|
||||
apm merges every `*.json` in that directory into a single hook definition and writes the event
|
||||
bindings into the consuming project's `.claude/settings.json`; see "Deployed shape" below. Scripts a
|
||||
hook invokes live in the same directory, alongside the JSON that references them.
|
||||
For Claude, apm merges the event bindings from every `*.json` in that directory into the consuming
|
||||
project's `.claude/settings.json`; see "Deployed shape" below. That merge is Claude's rendering, not
|
||||
a general rule: Copilot gets one file per source file (see "GitHub Copilot CLI and Codex"). Scripts
|
||||
a hook invokes live in the same directory, alongside the JSON that references them.
|
||||
|
||||
## Hook file structure
|
||||
|
||||
@@ -24,7 +25,7 @@ The shape Claude Code reads, and therefore the shape to author under `.apm/hooks
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'tool used'" }
|
||||
{ "type": "command", "command": "echo 'tool used'", "timeout": 10 }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -32,26 +33,26 @@ The shape Claude Code reads, and therefore the shape to author under `.apm/hooks
|
||||
}
|
||||
```
|
||||
|
||||
Events (**partial list**): `PreToolUse`, `PostToolUse`, `Notification`, `Stop`, and `SessionStart`
|
||||
(verified end-to-end by the hook below). Claude Code's plugin hook set is larger — `SessionEnd`,
|
||||
`UserPromptSubmit`, `PreCompact` and `SubagentStop` also exist — and this repo's vendored corpus does
|
||||
not enumerate it anywhere: `docs/research/docs/claude-code-plugins/configuration.md:100` describes
|
||||
the file as "Event handlers (PreToolUse, PostToolUse, etc.)", and `agent-definition.md:53` covers
|
||||
only the per-agent `hooks` field, not the plugin-level set. Treat the five names above as the ones
|
||||
this repo has verified, not as the schema. Check Claude Code's own hooks documentation before wiring
|
||||
an event not listed here.
|
||||
Events: see `plugins/kyberforge/docs/research/docs/microsoft-apm/hooks-primitive-schema.md`,
|
||||
section "Events: `_HOOK_EVENT_MAP`", for which names apm renames per target and which it passes
|
||||
through unchanged. For Claude, author every event in PascalCase (`SessionStart`, `UserPromptSubmit`,
|
||||
`PreCompact`, and so on). A camelCase name apm does not map, such as `userPromptSubmit`, draws only
|
||||
a non-fatal warning and never fires; an all-lowercase one draws no warning at all and never fires
|
||||
either.
|
||||
|
||||
## Referencing a script — use the `.apm/` path
|
||||
|
||||
Use `${CLAUDE_PLUGIN_ROOT}` to reference scripts inside this plugin; the plugin runs from a cache or
|
||||
`apm_modules/` path after install, not its original repo location. **Address the script at its
|
||||
`.apm/` path:**
|
||||
Use `${PLUGIN_ROOT}` to reference scripts inside this plugin; the plugin runs from a cache or
|
||||
`apm_modules/` path after install, not its original repo location. `${PLUGIN_ROOT}` is apm's
|
||||
target-neutral token; apm rewrites it exactly as it rewrites `${CLAUDE_PLUGIN_ROOT}` — verified
|
||||
byte-identical in the deployed `.claude/settings.json` with apm 0.28.0 — so prefer it, and
|
||||
`factory-audit` suggests it. **Address the script at its `.apm/` path:**
|
||||
|
||||
```json
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
||||
"command": "${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
||||
```
|
||||
|
||||
The obvious-looking `${CLAUDE_PLUGIN_ROOT}/hooks/check-apm-current.sh` does not work, and fails
|
||||
The obvious-looking `${PLUGIN_ROOT}/hooks/check-apm-current.sh` does not work, and fails
|
||||
quietly enough to be worth spelling out. apm resolves the placeholder against the installed package
|
||||
root, and there is no `hooks/` directory there at all — the package's content is `.apm/`. apm prints
|
||||
`Hook script not found: .../hooks/check-apm-current.sh` and then deploys the hook anyway, pointing at
|
||||
@@ -66,36 +67,61 @@ At install, apm merges the event bindings into `.claude/settings.json`, copies t
|
||||
to `.claude/hooks/<pkg>/` (preserving its executable bit, preserving the `.apm/hooks/` subpath), and
|
||||
rewrites `command` to a `${CLAUDE_PROJECT_DIR}`-relative path. Ownership of its own entries is
|
||||
tracked in a `.claude/apm-hooks.json` sidecar, so an uninstall removes them without touching
|
||||
hand-authored hooks. Both `.claude/hooks/` and the sidecar are gitignored install output.
|
||||
hand-authored hooks. `.claude/hooks/` is gitignored install output; the sidecar is committed
|
||||
alongside `.claude/settings.json`, because without it a fresh clone's `apm install` treats the
|
||||
committed entry as user-owned and adds a duplicate, and `apm audit --ci` reports drift (ADR-0019,
|
||||
correction 2026-09-16).
|
||||
|
||||
Note that apm's **executable-trust gate is off** unless the consuming project's `apm.yml` has an
|
||||
`executables:` block — without one, package hooks deploy with no prompt. The allow key is
|
||||
version-pinned (`kyberforge#<version>`), so a version bump on one side alone stops the hook
|
||||
deploying; `check-executables-allow-sync` is the pre-push gate that catches it. See ADR-0019.
|
||||
`executables:` block — without one, package hooks deploy with no prompt. The allow key carries a
|
||||
version (`kyberforge#<version>`), but apm 0.28.0 matches grants version-blind, so a version bump on
|
||||
one side does not stop the hook deploying. `check-executables-allow-sync` is a pre-push gate for this
|
||||
repo's own convention that the key tracks `plugins/kyberforge/apm.yml`'s `version:`, not for an apm
|
||||
mechanic. See ADR-0019, correction 2026-09-19, and the comment above `executables:` in the root
|
||||
`apm.yml`.
|
||||
|
||||
## The SessionStart hook
|
||||
|
||||
`check-apm-current.sh` keeps an apm-consumed install level with its remote: it runs `apm outdated`,
|
||||
and if anything is behind, runs `apm update --yes` and returns `reloadSkills: true` so the running
|
||||
session picks up the redeployed content. Rationale, measurements, and the failure modes are in
|
||||
session picks up the redeployed content. `reloadSkills` is a documented `SessionStart`
|
||||
`hookSpecificOutput` field that makes Claude Code re-scan skill directories once the hooks finish
|
||||
(code.claude.com/docs/en/hooks, checked 2026-09-29). Rationale, measurements, and the failure modes are in
|
||||
ADR-0019.
|
||||
|
||||
**Where it looks for the lockfile.** The hook resolves a project directory as `${CLAUDE_PROJECT_DIR}`
|
||||
when the host exports it (Claude Code does, for SessionStart hooks) and the current directory
|
||||
otherwise, then exits silently unless that directory holds an `apm.lock.yaml` — which is what makes
|
||||
it inert in any project that does not consume packages through apm. Both `apm` invocations run
|
||||
against the same resolved directory. The earlier spelling checked a bare `apm.lock.yaml` against the
|
||||
session's cwd, so a session opened in a subdirectory of an apm-consuming repo no-opped silently.
|
||||
Keep the cwd fallback: a host that sets no `CLAUDE_PROJECT_DIR` must still get inert-but-harmless
|
||||
behaviour, not an unset-variable error.
|
||||
**Claude Code only, and where it looks for the lockfile.** The hook exits 0 at once, silently and
|
||||
without calling `apm`, unless `CLAUDE_PROJECT_DIR` is set and non-empty — Claude Code exports it for
|
||||
SessionStart hooks, and Copilot and Codex do not document setting it, so the guard keeps the hook
|
||||
inert there unless the variable is inherited from the user's environment (see below).
|
||||
It then takes `${CLAUDE_PROJECT_DIR}` as the project directory and exits silently unless that
|
||||
directory holds an `apm.lock.yaml` — which is what makes it inert in any project that does not
|
||||
consume packages through apm. Both `apm` invocations run against the same directory. The earlier
|
||||
spelling checked a bare `apm.lock.yaml` against the session's cwd, so a session opened in a
|
||||
subdirectory of an apm-consuming repo no-opped silently. Do not reintroduce a cwd fallback: under a
|
||||
host that sets no `CLAUDE_PROJECT_DIR` the lockfile guard passes in every apm consumer, and the
|
||||
fallback ran `apm update --yes` there (ADR-0019, correction 2026-09-28).
|
||||
|
||||
**The `timeout` in `hooks.json` must exceed the script's own budget.** The script spends at most
|
||||
`timeout 60 apm outdated` plus `timeout 300 apm update`; the hook entry declares `timeout: 380`, the
|
||||
sum plus a buffer. Set it lower and a slow remote gets the hook SIGKILLed mid-`apm update`, leaving a
|
||||
`timeout -k 5 60 apm outdated` plus `timeout -k 5 300 apm update`, 370 s counting each 5 s SIGKILL
|
||||
grace; the hook entry declares `timeout: 380`, the sum plus a buffer. Set it lower and a slow remote gets the hook SIGKILLed mid-`apm update`, leaving a
|
||||
partially redeployed `.claude/skills/` and emitting no notice — precisely the silent failure the hook
|
||||
exists to prevent. `tests/test-apm-current-hook.sh` pins the relationship (host timeout > sum of the
|
||||
script's internal timeouts) rather than the literal, so raising either side alone fails the suite.
|
||||
|
||||
**The time limits are portable and hard to escape (ADR-0019, amendment 2026-09-29).** The script
|
||||
uses `timeout`, or `gtimeout` where only Homebrew coreutils provides it (stock macOS). With neither,
|
||||
it emits a notice and exits without running `apm`, instead of dying silently on exit 127. It exports
|
||||
`GIT_TERMINAL_PROMPT=0`, so a remote that wants credentials fails at once instead of waiting out the
|
||||
timeout on a prompt nobody can see. Where `flock` exists and `apm_modules/` does too, `apm update`
|
||||
runs under a non-blocking lock on `apm_modules/.kyberforge-apm-update.lock`. A second session that
|
||||
starts during a refresh skips its own and says so. Without `flock` the refresh runs unserialised.
|
||||
|
||||
**The lock advice is neutral when the default branch is unknown.** The notice tells you to discard
|
||||
the rewritten `apm.lock.yaml` on a feature branch and to decide deliberately on the default branch.
|
||||
It learns the default from `refs/remotes/origin/HEAD`, which `git remote add` never writes. When
|
||||
that ref is unset, on a detached HEAD, or outside a git checkout, it says only "commit it or
|
||||
discard it deliberately" rather than guessing `main` (ADR-0019, amendment 2026-09-19).
|
||||
|
||||
**Staleness is detected by matching apm's summary line, and both spellings count.** `apm outdated`
|
||||
has no `--json` or otherwise machine-readable output (verified against apm 0.28.0), so the hook
|
||||
greps its text. apm prints `1 outdated dependency found` in the singular when exactly one package is
|
||||
@@ -105,32 +131,34 @@ stages a genuinely outdated dependency against the **real** `apm` — a local gi
|
||||
`url.<path>.insteadOf` rewrites, so it needs no network — and replays that genuine output through the
|
||||
hook.
|
||||
|
||||
## GitHub Copilot CLI
|
||||
## GitHub Copilot CLI and Codex
|
||||
|
||||
**Copilot loads no hooks from this plugin.** Two independent reasons, either one sufficient:
|
||||
**apm writes this plugin's hook for Copilot and Codex too; whether they run it is unverified.**
|
||||
kyberforge's `apm.yml` declares `targets: [claude, copilot, codex]`, and `targets:` is package-wide,
|
||||
so the hook reaches every target the package does
|
||||
(`plugins/kyberforge/docs/research/docs/microsoft-apm/hooks-primitive-schema.md`, verified against
|
||||
apm 0.28.0):
|
||||
|
||||
- **Nothing can point Copilot at a hooks file.** Copilot types `hooks` as a `plugin.json` field of
|
||||
type "string or object" with **no default**
|
||||
(`docs/research/docs/github-copilot-plugins/configuration.md:47`), so there is no convention path
|
||||
for it to scan — it reads hooks only via an explicit pointer. Since ADR-0024 there is no
|
||||
per-plugin Copilot manifest at all, so there is nothing to carry that pointer.
|
||||
- **The two ecosystems do not share a hooks format.** Copilot reads a differently-shaped
|
||||
`hooks.json`: `version: 1` is required, each entry is `type: "command"` with separate `bash` and
|
||||
`powershell` scripts, and the lifecycle points are lowercase and differently named (`sessionStart`,
|
||||
`sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `errorOccurred`, `agentStop`).
|
||||
See `docs/research/docs/github-copilot-plugins/configuration.md`. apm merges `.apm/hooks/*.json`
|
||||
into one definition with no per-target shaping, and that definition is Claude-shaped.
|
||||
- **Copilot** gets `.github/hooks/kyberforge-hooks.json`, one file per source file. apm renames the
|
||||
event (`SessionStart` → `sessionStart`), rewrites the script path, adds `version: 1`, and otherwise
|
||||
passes the nested Claude shape through — it does **not** reshape it into Copilot's flat
|
||||
`bash`/`powershell`/`timeoutSec` form. Whether Copilot CLI executes a nested entry, or honours
|
||||
`matcher`, has not been verified.
|
||||
- **Codex** gets the entry merged into `.codex/hooks.json`, but only when `.codex/` already exists;
|
||||
otherwise nothing is written.
|
||||
|
||||
The second reason is why "just add a pointer" was rejected even while a Copilot manifest existed: a
|
||||
pointer would tell Copilot that a Claude-shaped file is Copilot-shaped, trading an incomplete
|
||||
manifest for a wrong one. ADR-0024 consequence 7 records that the question is now moot — the
|
||||
manifest it argued about is gone — but the schema mismatch it turned on is not, and it is what any
|
||||
future Copilot hooks support has to solve.
|
||||
This is accepted rather than fixed (ADR-0019, amendment and correction 2026-09-28). The hook's
|
||||
behaviour is Claude-specific anyway — the `startup` matcher, `CLAUDE_PROJECT_DIR`, and the
|
||||
`reloadSkills` output — and a harness that does run it exits immediately, because the script's
|
||||
first guard exits 0 when `CLAUDE_PROJECT_DIR` is unset. The `apm.lock.yaml` guard cannot do that
|
||||
job: `apm install` wrote the lock, so it passes in every project the hook reaches. The only
|
||||
apm-native way to keep it Claude-only is a separate package whose `apm.yml`
|
||||
declares `target: claude`; per-file target routing (`claude-hooks.json`) is deprecated, and
|
||||
kyberforge cannot narrow its own `targets:` without dropping its skills from Copilot and Codex.
|
||||
|
||||
**What this costs you:** a hook authored under `.apm/hooks/` reaches Claude Code and not Copilot.
|
||||
That is a real limitation, and it is the accepted one until apm emits a per-target hooks file or the
|
||||
two schemas converge. If you need a Copilot hook today, raise it — it needs an upstream change or a
|
||||
second authoring path, not a pointer.
|
||||
An earlier version of this section said Copilot loads no hooks from this plugin, because nothing
|
||||
could point Copilot at a hooks file and apm did no per-target shaping. Both halves are superseded:
|
||||
apm deploys the file into Copilot's hooks directory itself, and does rename events per target.
|
||||
|
||||
## Symlinks under `.apm/` do not survive, and nothing reports it
|
||||
|
||||
|
||||
@@ -1,59 +1,139 @@
|
||||
---
|
||||
topic: hooks-primitive-schema
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- apm-github-repo
|
||||
- context7-microsoft-apm # partial: earlier pass only; the 2026-09-28 re-verification could not reach Context7 (see sources.md)
|
||||
---
|
||||
|
||||
## File location, naming, and format — confirmed `.json`, not assumed
|
||||
Ground truth for this file is the installed apm-cli **0.28.0** source (`apm_cli/integration/hook_integrator.py`, `hook_native_formats.py`, `hook_ir.py`, `hook_file_routing.py`, `_hook_dropped_targets.py`, `targets.py`, `security/executables.py`) plus a live `apm install` of a scratch package targeting `claude` and `copilot` (2026-09-28). Where the published docs (`llms-full.txt`) disagree with 0.28.0, the disagreement is called out; the published docs track upstream `main` and may describe a newer release.
|
||||
|
||||
`.apm/hooks/*.json` (legacy fallback: bare `hooks/*.json` at package root, still discovered — `_has_hook_json()` checks both `hooks/` and `.apm/hooks/`). This is genuinely JSON, not YAML or Markdown-with-frontmatter like every other primitive — confirmed directly from source (`apm_cli/integration/hook_integrator.py` module docstring: "Integrates hook JSON files...") and from `apm_cli/models/validation.py`, which states a hook-only package's files define "hook handlers per the Claude Code hooks specification" — i.e. the canonical authoring shape APM expects is Claude Code's own native hook JSON shape, not an APM-invented one. This is consistent with APM's general P1 principle (no invented primitive frontmatter/format) extending even to hooks: author in whichever native harness shape you like, and APM normalizes.
|
||||
## File location, naming, discovery
|
||||
|
||||
**Accepted input shapes** (APM normalizes both into an internal vendor-neutral IR before rendering per target):
|
||||
- `HookIntegrator.find_hook_files()` globs `<pkg>/.apm/hooks/*.json` first, then `<pkg>/hooks/*.json` (Claude-native layout). Non-recursive; symlinks skipped; stems deduplicated case-insensitively, so `.apm/hooks/x.json` shadows `hooks/x.json`. `security/executables.scan_package_executables` uses the same two directories.
|
||||
- Genuinely JSON, not Markdown-with-frontmatter. There is no `Hook` dataclass in `primitives/models.py`; hooks never enter `discover_primitives()`, so `apm compile` (and `apm compile --validate`) never see them. Hooks are deployed by `apm install` only.
|
||||
- **Filename routing (deprecated, still active).** `hook_file_routing._hook_file_allowed_targets` lowercases the stem first (`hook_file.stem.lower()`), so matching is case-insensitive: `Claude-Hooks.json` routes like `claude-hooks.json`. It routes a file whose stem is `hooks-<token>`, is a bare `<token>-hooks`, or ends `-<token>-hooks` (tokens: `copilot`, `vscode`, `cursor`, `claude`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`) to that target only, with a deprecation warning. A combined stem whose trailing segments are all tokens, such as `claude-codex-hooks`, routes to the union of those targets (`_target_suffix_segments`, `_union_target_sets`). `copilot` and `vscode` are one set: either token selects both. If any file for a target is target-specific, universal files are ignored for that target (`specific if specific else universal`). A stem like `claude-hooks.json` is therefore Claude-only. The replacement is `target:`/`targets:` in the package's own `apm.yml`, or object-form per-dependency `targets:` on the consumer side.
|
||||
|
||||
## Accepted source shapes
|
||||
|
||||
`_parse_hook_json()` accepts:
|
||||
|
||||
```json
|
||||
// "Nested" wrapper (what the docs' canonical example shows)
|
||||
{ "hooks": { "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.sh", "timeout": 10} ] } ] } }
|
||||
// Wrapped (canonical)
|
||||
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ {"type": "command", "command": "./scripts/check.sh", "timeout": 10} ] } ] } }
|
||||
|
||||
// "Naked" top-level settings-slice (Claude Code settings.json shape, unwrapped)
|
||||
{ "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.sh", "timeout": 10} ] } ] }
|
||||
// Naked settings slice: promoted to wrapped only if EVERY top-level value is a list
|
||||
{ "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/check.sh"} ] } ] }
|
||||
|
||||
// Flat Copilot-style entry (no inner "hooks" array)
|
||||
{ "hooks": { "preToolUse": [ {"type": "command", "bash": "./scripts/check.sh", "powershell": "pwsh ./scripts/check.ps1", "timeoutSec": 5} ] } }
|
||||
```
|
||||
|
||||
Both are accepted; APM's discovery/parsing layer detects and unwraps either. There is no separate `Hook`/`HookPrimitive` dataclass in `primitives/models.py` (unlike `Instruction`) — hooks are represented instead by a dedicated vendor-neutral IR (`apm_cli/integration/hook_ir.py`): `HookHandler(command, platform="all", timeout_seconds, provenance, metadata)` grouped into `HookBinding(event, handlers, matcher, provenance, metadata)` grouped into `HookDocument(bindings)`. This IR is populated during install-time integration, not during the generic primitive-discovery pass used for instructions/contexts/agents.
|
||||
Nested and flat entries can be mixed in one event array. Parse failure modes (all verified live):
|
||||
|
||||
**Event names are case-convention-sensitive by target and get remapped, not just passed through.** Author in either PascalCase (Claude convention: `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`) or camelCase (Copilot convention: `preToolUse`, `postToolUse`, etc.) — `_HOOK_EVENT_MAP` per-target dictionaries translate between them during merge/deploy. An event name whose casing doesn't match the target's expected convention *and* has no explicit mapping entry triggers a non-fatal warning at install time (`_emit_hook_event_diagnostics`) — not a hard failure, but a real signal that the event likely won't fire.
|
||||
| Input | Behaviour |
|
||||
|---|---|
|
||||
| Invalid JSON | File silently skipped. No warning. |
|
||||
| `"hooks"` present but not an object | Skipped; `_log.warning` "Skipping malformed hook file ...: 'hooks' must be a dict". |
|
||||
| Naked shape plus one stray scalar key (such as `"description"`) | Not promoted. Merge targets warn "Hook file X contributed no entries to claude settings; skipped." **Copilot still writes** a junk `.github/hooks/<pkg>-X.json` containing the original keys plus `"hooks": {}`. |
|
||||
| Event value not a list (such as `{"PreToolUse": {...}}`) | Claude: "contributed no entries" warning. Copilot: `_validate_copilot_payload` error "Invalid Copilot hook payload", and **the install fails**. |
|
||||
|
||||
**Script path placeholders** are rewritten per target during deploy: `${CLAUDE_PLUGIN_ROOT}/path`, `${CURSOR_PLUGIN_ROOT}/path`, `${PLUGIN_ROOT}/path`, and bare `./path` all get resolved relative to the package root and rewritten to whatever the target expects; bare system commands (no path separators) pass through unchanged.
|
||||
## Vendor-neutral IR (`hook_ir.py`, `hook_native_formats.py`)
|
||||
|
||||
## Compile-time mapping per target — both are real reconstruction, differently shaped
|
||||
`HookHandler(command, platform="all", timeout_seconds, provenance, metadata)` inside `HookBinding(event, handlers, matcher, provenance, metadata)` inside `HookDocument`. The IR is used **only when rendering merge targets** (Claude, Gemini, Antigravity). Copilot never goes through it (see below).
|
||||
|
||||
Neither Claude nor Copilot receives a byte-verbatim copy of the source hook JSON — this is a genuine, structural transform on both sides, driven by `apm_cli/integration/hook_native_formats.py` and `hook_integrator.py`.
|
||||
`_handler_to_ir` rules:
|
||||
- `command` wins. If it is absent, the **first** present key of `bash` (platform `posix`), `powershell` (`windows`) or `windows` (`windows`) becomes `command`. All other keys, including a second platform key, stay in `metadata`.
|
||||
- `timeoutSec` wins over `timeout`. Both are treated as seconds.
|
||||
- Every other handler key (`type`, `async`, `statusMessage`, `shell`, `env`, `cwd`, arbitrary keys) passes through in `metadata`. **APM has no handler-field allowlist.**
|
||||
- `_entries_to_ir`: `matcher` is popped from the entry and kept. A flat entry (no `hooks` list) becomes a one-handler binding. Non-dict entries pass through raw.
|
||||
|
||||
**Claude Code — merged into `.claude/settings.json`, not a standalone file.** `claude` is registered in `_MERGE_HOOK_TARGETS` with `config_filename="settings.json"`, `schema_strict=True`. Behavior (per the integrator's own class docstring: "Claude: Merged into .claude/settings.json hooks key + .claude/hooks/<pkg>/"):
|
||||
- Event bindings are merged into the `"hooks"` key of `.claude/settings.json`, using Claude's native nested-matcher-group shape (`{"hooks": {"PreToolUse": [{"hooks": [{"type": "command", "command": "...", "timeout": N}]}]}}`), with PascalCase event names.
|
||||
- Any referenced script files are physically copied to `.claude/hooks/<package-name>/`, and the `command` field is rewritten to point at the copied location.
|
||||
- An ownership sidecar (`apm-hooks.json`) tracks which entries in the shared `settings.json` were APM-installed, so `apm install`/uninstall can cleanly add/remove only its own entries without clobbering hand-authored hooks a user already had in that file.
|
||||
## Events: `_HOOK_EVENT_MAP` (0.28.0, verbatim content)
|
||||
|
||||
**Copilot CLI — dedicated per-file deployment, flat/camelCase, field-renamed.** `copilot` is deliberately **not** in `_MERGE_HOOK_TARGETS` (confirmed in `_hook_dropped_targets.py`: "Names not registered in `_MERGE_HOOK_TARGETS` (e.g. `copilot`, which uses per-file, not merged, hook deployment...)"). Instead `PrimitiveMapping("hooks", ".json", "github_hooks")` deploys a dedicated file per source hook file. The native Copilot shape differs structurally from Claude's, per the module docstring:
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": { "preToolUse": [ {"type": "command", "bash": "./scripts/validate.sh", "timeoutSec": 10} ] }
|
||||
}
|
||||
```
|
||||
Differences from the Claude/source shape: flat arrays (no nested matcher-group wrapper), camelCase event keys, a required top-level `"version": 1`, and handler commands split by platform (`bash` / `powershell` keys) instead of a single `command` key, with `timeoutSec` replacing `timeout`.
|
||||
| Target | Source name → native name |
|
||||
|---|---|
|
||||
| `copilot` | `PreToolUse`/`preToolUse`→`preToolUse`; `PostToolUse`/`postToolUse`→`postToolUse`; `UserPromptSubmit`/`userPromptSubmit`→`userPromptSubmit`; `SessionStart`/`sessionStart`→`sessionStart`; `Stop`/`AgentStop`/`agentStop`→`agentStop`; `PreTaskExecution`/`preTaskExecution`→`preTaskExecution`; `PostTaskExecution`/`postTaskExecution`→`postTaskExecution` |
|
||||
| `claude` | `preToolUse`→`PreToolUse`; `postToolUse`→`PostToolUse`; `SessionStart`/`sessionStart`→`SessionStart`; `Stop`/`AgentStop`/`agentStop`→`Stop` |
|
||||
| `gemini` | `PreToolUse`/`preToolUse`→`BeforeTool`; `PostToolUse`/`postToolUse`→`AfterTool`; `Stop`→`SessionEnd` |
|
||||
| `kiro` | PascalCase triggers, including `PreTaskExec`, `PostTaskExec`, `PostFileCreate`, `PostFileSave`, `PostFileDelete`, `promptSubmit`→`UserPromptSubmit` |
|
||||
|
||||
## Compile-time file placement
|
||||
- **No event is dropped.** Any name absent from the map passes through unchanged (`event_map.get(raw, raw)`). For Claude, that means `PreCompact`, `Notification`, `SubagentStop`, `SessionEnd`, `UserPromptSubmit` and others all work as long as they are authored in PascalCase.
|
||||
- `_emit_hook_event_diagnostics` warns (non-fatal) only when the name is unmapped **and** `_detect_event_casing` yields the wrong convention. `_HOOK_EVENT_EXPECTED_CASING`: `copilot` expects camelCase; every other target expects PascalCase. All-lowercase names (`notification`, `stop`) return casing `None`, so they **never warn** and silently never fire. Verified live: `userPromptSubmit` warned on Claude, `PreCompact` warned on Copilot, `notification` warned on neither.
|
||||
- The Claude map has no `userPromptSubmit`→`UserPromptSubmit` entry. The camelCase spelling is deployed verbatim into `settings.json` and will not fire, so author `UserPromptSubmit`.
|
||||
- **Docs vs 0.28.0:** the published "Session lifecycle event aliases" table says `UserPromptSubmit`/`userPromptSubmitted` → Copilot `userPromptSubmitted`. 0.28.0 maps to `userPromptSubmit` and has no `userPromptSubmitted` alias. Which key Copilot CLI actually fires on has not been verified here.
|
||||
- Two source events that rename to the same native key are merged (Copilot: list extend; Claude: appended under one key).
|
||||
|
||||
| Target | Output location | Mechanism |
|
||||
|---|---|---|
|
||||
| Claude Code | `.claude/settings.json` (`"hooks"` key, merged) + scripts copied to `.claude/hooks/<pkg>/` | Merge into existing shared config file, ownership tracked via `apm-hooks.json` sidecar |
|
||||
| Copilot CLI | `.github/hooks/<name>.json` | Dedicated per-file deploy, reshaped to Copilot's flat/camelCase/`version:1` schema |
|
||||
## Per-target rendering
|
||||
|
||||
### Claude Code: merged into `.claude/settings.json`
|
||||
|
||||
`_MERGE_HOOK_TARGETS["claude"]` = `_MergeHookConfig("settings.json", "claude", require_dir=False, schema_strict=True)`. `_integrate_merged_hooks` then:
|
||||
1. Renames events via the Claude map.
|
||||
2. Runs `_to_claude_hook_entries`, which is `_render_nested_document(timeout_milliseconds=False, default_matcher="*")`. Every entry becomes `{matcher, hooks:[...]}`. **The source `matcher` is preserved verbatim.** An entry without a matcher gets `"matcher": "*"`, including events such as `Stop` or `UserPromptSubmit` where Claude ignores matchers.
|
||||
3. Rewrites script paths (see below) and copies the hook bundle to `.claude/hooks/<pkg>/…`, keeping the path relative to the package root.
|
||||
4. Tags entries with `_apm_source`, then strips the tags into the sidecar `.claude/apm-hooks.json` (schema-strict). `settings.json` holds only native fields.
|
||||
5. Upsert is idempotent per source marker (`_should_remove_prior_merged_entry`) and deduplicates by content.
|
||||
|
||||
**Flat Copilot entry → Claude** (live): `bash` becomes `command`, `timeoutSec` becomes `timeout`, and the **unused `powershell` key and any other extras are left in the Claude handler** (for example `"powershell": "pwsh $env:CLAUDE_PROJECT_DIR/…"`). Whether Claude Code tolerates unknown handler keys has not been verified here.
|
||||
|
||||
**Confirmed for this repo:** `plugins/kyberforge/.apm/hooks/hooks.json` (`SessionStart`, `"matcher": "startup"`, `command: ${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh`, `timeout: 380`) compiles in `/root/ai-development/.claude/settings.json` to `{"matcher": "startup", "hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh\"", "timeout": 380}]}`. Matcher and timeout are kept, and the path is re-anchored and quoted.
|
||||
|
||||
### Copilot: one file per source file, *not* reshaped
|
||||
|
||||
`integrate_package_hooks()` writes `<root>/hooks/<pkg>-<stem>.json` (project `.github/hooks/`, user `~/.copilot/hooks/`) and copies scripts to `.github/hooks/scripts/<pkg>/…`. **Correction to the earlier version of this doc:** 0.28.0 does *not* flatten, rename `command`→`bash`, or rename `timeout`→`timeoutSec`. The only transforms are:
|
||||
- event renaming via the Copilot map
|
||||
- script-path rewrite (repo-relative such as `.github/hooks/scripts/<pkg>/scripts/check.sh`; absolute at user scope)
|
||||
- `version: 1` injected with `setdefault`
|
||||
- `_validate_copilot_payload`: `version == 1`, `hooks` is an object, each event is a list, each entry is an object, and any nested `hooks` is a list of objects. A failure is a diagnostics **error**, the file is not written, and the install reports failure.
|
||||
|
||||
So a Claude-shaped source reaches Copilot as nested `{matcher, hooks:[{type, command, timeout}]}` with the matcher preserved (live: `sessionStart` with `"matcher": "startup"`). A flat `bash`/`powershell`/`timeoutSec` entry reaches Copilot unchanged. The hook-integrator docstring describes the Copilot-native shape as flat `{"type": "command", "bash": …, "timeoutSec": …}`; `HOOK_COMMAND_KEYS` comments name `bash`/`powershell` (Copilot agent/CLI) and `command`/`windows`/`linux`/`osx` (VS Code). **Unverified:** whether Copilot CLI executes a nested entry or a `command` key, and whether it honours `matcher`. APM does not guarantee it.
|
||||
|
||||
### Platform-specific commands: how to author
|
||||
|
||||
- **Claude-only package:** use `command` (POSIX). For Windows, prefix the command with `pwsh`/`powershell`, or set handler `"shell": "powershell"`. `_project_scoped_command_path` then renders `$env:CLAUDE_PROJECT_DIR/...` instead of `"${CLAUDE_PROJECT_DIR}/..."`. Whether Claude Code itself honours a `shell` handler field is not verified here.
|
||||
- **Copilot-correct package:** author flat entries with `bash` + `powershell` + `timeoutSec`, which pass to Copilot verbatim. The Claude render takes `bash` as `command` and carries `powershell` along as a stray key.
|
||||
- **Both targets, cleanly:** split into per-target packages or files (`target: claude` / `target: copilot` in each package `apm.yml`), because no single source shape renders natively for both in 0.28.0.
|
||||
|
||||
### Other targets (brief)
|
||||
|
||||
Merge targets: `cursor` (`.cursor/hooks.json`, `version: 1` default), `codex` (`.codex/hooks.json`), `gemini` (`.gemini/settings.json`, timeouts ×1000 ms, nested), `antigravity` (`.agents/hooks.json` under container key `apm`), `windsurf` (`.windsurf/hooks.json`). Everything except Claude has `require_dir=True`: nothing is written unless the target dir exists. `kiro` writes one file per action. `opencode` has no hooks (`unsupported_user_primitives=("hooks",)`), and other hook-less targets are silently skipped.
|
||||
|
||||
## Script path rewriting (`_rewrite_command_for_target`)
|
||||
|
||||
- Tokens `${CLAUDE_PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, `${KIRO_PLUGIN_ROOT}` and `${PLUGIN_ROOT}` followed by a path resolve against the **package root**. `./path` resolves against the hook file's directory first, then the package root (`_resolve_relative_hook_script`). Both are confined with `ensure_path_within`.
|
||||
- The rewrite runs on every key in `HOOK_COMMAND_KEYS` = `command`, `bash`, `powershell`, `windows`, `linux`, `osx`, at entry level and at nested-handler level.
|
||||
- Claude project scope: `"${CLAUDE_PROJECT_DIR}/.claude/hooks/<pkg>/<rel>"`, double-quoted unless the source already quoted it. PowerShell uses `$env:CLAUDE_PROJECT_DIR/...`. A target path containing `$` or a backtick raises `ValueError`. Other targets stay repo-relative. User scope (`-g`) uses absolute paths (`_deploy_root_for_hook_rewrite`).
|
||||
- Missing script: `_rich_warning("Hook script not found: …")`. The install continues. At project scope the token is left unexpanded; at user scope it is rewritten to the absolute source path.
|
||||
- Bare commands with no `./` and no token (`echo hi`, `npx foo`) pass through untouched and are not bundled.
|
||||
- `<pkg>` is the dependency install dir name, or for the project's own `.apm/` the `apm.yml` `name` (fallback `_local`).
|
||||
|
||||
## Security / trust gate
|
||||
|
||||
Hooks are an executable primitive (`security/executables.EXEC_TYPE_HOOKS`). If the consuming project's `apm.yml` has an `executables:` block (`executables: {allow: …, deny: …}`, the form this repo's root `apm.yml` uses), dependency hooks are deny-by-default until approved (`apm approve`). `allowExecutables` is the deprecated spelling, still read as an alias for one minor cycle and migrated into `executables.allow` on write (`security/executables.parse_project_executables`, `write_project_executables`). Non-interactive runs hard-error. Local project content (`_local`) is always trusted (`install/exec_gate.check_executable_approval`). With neither block, everything deploys. The pre-deploy hidden-Unicode scan (`install/helpers/security_scan`, `BLOCK_POLICY`) also covers hook files.
|
||||
|
||||
## Validation constraints and gotchas
|
||||
|
||||
- **Copilot's native payload has an enforced shape** (`_validate_copilot_payload`): top-level `"version"` must equal `1`; `"hooks"` must be an object; each event's value must be a list; each entry must be an object; if an entry has a `"hooks"` key, its value must be a list of objects. These errors are collected and surfaced before any file is written (fail before mutation, not after).
|
||||
- **Malformed existing config fails closed, not silently.** If `.claude/settings.json` (or another merge target's config) is unreadable/malformed JSON, APM leaves it **byte-identical** and logs an actionable warning rather than overwriting or corrupting it — the same fail-closed posture applies to orphaned `apm-hooks.json` sidecars when their native JSON counterpart is already gone.
|
||||
- **Dropping a target from `apm.yml`'s `targets:` list does not auto-clean its merged hook entries** unless `apm install`/reconcile logic explicitly walks the complement set (`reconcile_dropped_targets`) — a real, documented gap the code works around rather than a design guarantee; relying on "just remove the target and hooks disappear" is not safe without a fresh `apm install`.
|
||||
- **Event-casing mismatches are warnings, not errors** — a hook authored with the wrong casing for a target and no applicable rename mapping will silently not fire at runtime; APM only logs a warning at install time, it does not block the install or refuse to deploy the file.
|
||||
- **No dedicated `Hook`/`HookPrimitive` validation dataclass** exists comparable to `Instruction.validate()` — validation is distributed across `_validate_copilot_payload` (Copilot-shape-specific) and general JSON-parseability checks, not a single primitive-level contract. This mirrors the same "no independent validation model" gap already documented for the agent primitive.
|
||||
- **Malformed `.claude/settings.json` is overwritten during install.** This corrects the earlier version of this doc. In `_integrate_merged_hooks`, a `JSONDecodeError` on the existing config sets `json_config = {}`, and the rebuilt file is written. Verified live: a malformed file containing `permissions` was replaced and the `permissions` content lost. The "left byte-identical" fail-closed behaviour applies **only** to `reconcile_dropped_targets` (`_hook_dropped_targets.py`) when it cleans a target dropped from `targets:`.
|
||||
- **Dropped targets are cleaned.** `manifest_reconcile.reconcile_dropped_merge_hook_targets` runs `reconcile_dropped_targets` on the complement of active and declared targets on the next install/compile/update (published docs agree). Copilot's per-file hooks are cleaned through `deployed_files` instead.
|
||||
- APM's only hook-shape validation is `_validate_copilot_payload` (Copilot only) plus the parse checks above. Nothing validates handler fields, `type`, timeout type or range, matcher syntax, or event-name existence. Casing mismatches only warn.
|
||||
- `apm audit --ci` covers deployed-file presence, drift and hidden Unicode for hook outputs. It does not check hook semantics.
|
||||
|
||||
## Authoring checklist
|
||||
|
||||
Must (an author skill enforces these; an audit skill checks them):
|
||||
1. Hook files live at `.apm/hooks/<name>.json` (not a subdir, not a symlink) and parse as a JSON object. *Source: `find_hook_files`; invalid JSON is silently skipped by `_parse_hook_json`.*
|
||||
2. Use the wrapped shape `{"hooks": {Event: [...]}}`. In the naked shape, every top-level value must be a list, and no stray scalar keys are allowed anywhere. *Source: `_parse_hook_json`; the Copilot junk-file behaviour above.*
|
||||
3. Every event value is a list of objects, and every nested `hooks` is a list of objects. Otherwise the Copilot install fails. *Source: `_validate_copilot_payload`.*
|
||||
4. Event names use PascalCase for Claude (`PreToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`, …). Never use all-lowercase names, and never use camelCase for events outside the Claude map. *Source: `_HOOK_EVENT_MAP["claude"]`, `_detect_event_casing`.*
|
||||
5. Script references use `${CLAUDE_PLUGIN_ROOT}/…` / `${PLUGIN_ROOT}/…` (package-root relative) or `./…` (hook-dir relative), and the referenced file exists inside the package. No absolute paths, and no `$` or backtick in the script path. *Source: `_rewrite_command_for_target`, `_project_scoped_command_path`.*
|
||||
6. Avoid a stem matching `hooks-<target>`, `<target>-hooks`, `*-<target>-hooks` or a combined `<a>-<b>-hooks`, in any letter case, unless you intend deprecated routing. Use `target:` in the package `apm.yml` instead. *Source: `hook_file_routing`.*
|
||||
|
||||
Should:
|
||||
7. Every handler sets `"type": "command"` and an explicit `timeout` in seconds. APM passes `type` through but never supplies it. *Source: `_handler_to_ir`, `_handler_from_ir`.*
|
||||
8. Set `matcher` explicitly on tool events (`PreToolUse`/`PostToolUse`) and on `SessionStart` (`startup` / `resume` / …). If you omit it, Claude receives `"*"`. *Source: `_to_claude_hook_entries` `default_matcher="*"`.*
|
||||
9. For a Claude-targeted package, do not author `bash`/`powershell`/`timeoutSec`. They render but leave stray keys. For Copilot-correct output, author the flat Copilot shape in a Copilot-targeted file. *Source: live render; `_handler_to_ir`.*
|
||||
10. Script paths must not contain spaces. apm's token pattern is `\$\{…PLUGIN_ROOT\}([\\/][^\s"']+)`, so the path must follow `}` directly and ends at the first whitespace or quote. Quoting the whole token, `"${PLUGIN_ROOT}/x.sh"`, is rewritten (and keeps its quotes); the split form `"${PLUGIN_ROOT}"/x.sh` is never matched and deploys unrewritten and unbundled; `"${PLUGIN_ROOT}/my hook.sh"` resolves only `my` and warns "Hook script not found". Quoting does not make a space safe. *Source: `plugin_root_pattern` and `rel_pattern` in `_rewrite_command_for_target` (`hook_integrator.py` ~L652, L694); the adjacent-quote check there only decides whether apm adds its own quotes.*
|
||||
11. Hook scripts must be executable and self-contained within the hook directory bundle. For Copilot, do not ship `.json` helper files in the bundle, because Copilot's loader rejects JSON without a `hooks` key. *Source: published hooks guide; `copy_deployed_hook_bundle(exclude_json_files=True)`.*
|
||||
|
||||
Audit-only (apm does not check these): unknown or misspelled event names; missing `type`; non-numeric timeout; matcher on non-tool events; extra handler keys that the target ignores.
|
||||
|
||||
@@ -1,66 +1,120 @@
|
||||
---
|
||||
topic: instructions-primitive-schema
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- apm-github-repo
|
||||
- context7-microsoft-apm # partial: earlier pass only; the 2026-09-28 re-verification could not reach Context7 (see sources.md)
|
||||
---
|
||||
|
||||
## File location, naming, and frontmatter
|
||||
This file is checked against the installed apm-cli **0.28.0** source (`primitives/models.py`, `primitives/parser.py`, `primitives/discovery.py`, `utils/patterns.py`, `integration/instruction_integrator.py`, `integration/base_integrator.py`, `integration/targets.py`, `compilation/agents_compiler.py`, `commands/compile/cli.py`) and against a live `apm install` / `apm compile` of a scratch package (2026-09-28).
|
||||
|
||||
`.apm/instructions/*.instructions.md`. Confirmed as the genuine required extension (not assumed) via APM's own discovery glob in `apm_cli/primitives/discovery.py`: `**/.apm/instructions/*.instructions.md` (and the `.github/instructions/` mirror, plus a bare `**/*.instructions.md` fallback).
|
||||
## File location, naming, discovery
|
||||
|
||||
Unlike prompts and hooks, instructions **do** have a small, concretely modeled dataclass — `apm_cli.primitives.models.Instruction` — because instructions feed APM's own compile pipeline (they get folded into root context files), not just pass-through deployment:
|
||||
APM finds instruction files in two different ways, depending on the command.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Instruction:
|
||||
name: str
|
||||
file_path: Path
|
||||
description: str
|
||||
apply_to: str # from frontmatter key "applyTo"; empty means global/unconditional
|
||||
content: str
|
||||
author: str | None = None
|
||||
version: str | None = None
|
||||
source: str | None = None
|
||||
- **`apm install`** uses `InstructionIntegrator.find_instruction_files()`, which calls `find_files_by_glob(pkg, "*.instructions.md", subdirs=[".apm/instructions"])`. It searches the **package root and `.apm/instructions/`**. The search is non-recursive, so files in subdirectories of `.apm/instructions/` are never deployed. Symlinks and hardlinks (link count > 1) are rejected.
|
||||
- **`apm compile`** uses `discover_primitives()`, which has broader globs (`primitives/discovery.py`): `**/.apm/instructions/*.instructions.md`, `**/.github/instructions/*.instructions.md`, and a bare `**/*.instructions.md`. Dependencies are searched under `instructions/*.instructions.md` in both `.apm/` and `.github/`. In a live compile, the deployed `.github/instructions/` copies were not double-counted: 4 sources gave "Validated 4 primitives". The exact dedupe mechanism was not traced.
|
||||
- The deployed stem is the filename minus `.instructions.md` (`_extract_primitive_name`; rename loop in `integrate_instructions_for_target`).
|
||||
|
||||
## Frontmatter and the `Instruction` model
|
||||
|
||||
`primitives/parser._parse_instruction` builds `Instruction` with these fields:
|
||||
|
||||
| Field | Source | Notes |
|
||||
|---|---|---|
|
||||
| `description` | `description:` | Defaults to `""` |
|
||||
| `apply_to` | `applyTo:` | Normalised by `normalize_apply_to` |
|
||||
| `author` | `author:` | Optional |
|
||||
| `version` | `version:` | Optional |
|
||||
| `content` | the body | |
|
||||
|
||||
Any other frontmatter key is ignored by the model. On Copilot, such keys still survive the verbatim copy.
|
||||
|
||||
**`applyTo` grammar** (`utils/patterns.py`):
|
||||
- **Scalar string.** Either a single glob or a comma-separated list. `parse_apply_to` splits only on **top-level** commas, so brace groups stay intact: `"**/*.py, **/*.{pyi,pyx}"` gives `["**/*.py", "**/*.{pyi,pyx}"]`. Each segment is stripped of whitespace. Empty segments are dropped, so leading, trailing and doubled commas are tolerated.
|
||||
- **YAML sequence.** `normalize_apply_to` joins non-null, non-empty entries with `,`. A literal top-level comma inside an entry is escaped as `\,`, so `"a,b/**"` survives as a single glob. You can also write `\,` by hand in a scalar value.
|
||||
- Missing, `null`, an empty string or an empty list all produce `""`, which means **unconditional**.
|
||||
- A non-string scalar (for example a number) is passed through `str()`. There is no glob syntax validation anywhere.
|
||||
|
||||
**`Instruction.validate()` messages (exact text).** All three are appended to one list:
|
||||
- `"Missing 'description' in frontmatter"`
|
||||
- `"No 'applyTo' pattern specified -- instruction will apply globally"`
|
||||
- `"Empty content"` (a whitespace-only body counts as empty)
|
||||
|
||||
**Correction to the earlier version of this doc:** none of these is a hard error. `AgentsCompiler.validate_primitives()` turns every message into a **warning** (`self.warnings.append(f"{file_path}: {error}")`) and always returns `[]`. Consequences, all verified live:
|
||||
- `apm compile` prints the warnings, still compiles, and exits 0.
|
||||
- **`apm compile --validate` can never fail on primitive errors.** `_run_validation_mode` only exits 1 when `validate_primitives` returns errors, which never happens. It printed "All primitives validated successfully!" for a file with no `description` and an empty body. The published docs describe `--validate` as a "frontmatter + structure check", which overstates it.
|
||||
- `apm install` never calls `validate()`. An instruction with no description and an empty body deployed silently to both targets.
|
||||
- `validate_primitives` also emits broken-markdown-link warnings (`validate_link_targets`) during compile.
|
||||
|
||||
## Per-target mapping (`apm install`)
|
||||
|
||||
**Copilot: verbatim.** `PrimitiveMapping("instructions", ".instructions.md", "github_instructions")`, where `copy_instruction` does link resolution plus LF normalisation. In a live run, `diff` against the source was empty. Frontmatter is kept byte-for-byte, including a YAML-list `applyTo`. The published docs say Copilot splits comma-lists natively. **Unverified:** whether Copilot honours a YAML-list `applyTo`. Prefer the scalar comma form for Copilot.
|
||||
|
||||
**Copilot user scope** (`~/.copilot/`) uses `user_primitive_overrides` → `copilot_user_instructions`, handled by `_integrate_copilot_user_instructions`:
|
||||
- Every instruction's body is **frontmatter-stripped**, so `applyTo` scoping is lost.
|
||||
- The bodies are concatenated into `~/.copilot/copilot-instructions.md`, inside `<!-- apm:source:<pkg> -->` sections under an APM header.
|
||||
- A pre-existing user-authored file without the header is a collision. APM skips it with a warning unless you pass `--force`.
|
||||
|
||||
**Claude: `.claude/rules/<stem>.md` via `_convert_to_claude_rules`.** Mapping: `PrimitiveMapping("rules", ".md", "claude_rules", output_compare=True)`.
|
||||
- `applyTo` becomes a `paths:` list, with each glob run through `yaml_double_quote`.
|
||||
- `description` and all other keys are **dropped**.
|
||||
- With no `applyTo`, the output has no frontmatter and the body is left-stripped.
|
||||
- An escaped-comma glob comes out unescaped (`a\,b/**` → `"a,b/**"`).
|
||||
|
||||
Live output:
|
||||
|
||||
```markdown
|
||||
---
|
||||
paths:
|
||||
- "**/*.py"
|
||||
- "**/*.{pyi,pyx}"
|
||||
---
|
||||
|
||||
Use type hints.
|
||||
```
|
||||
|
||||
Frontmatter fields: `description` (required by convention — its absence is a validation error) and `applyTo` (a glob or comma-separated glob list, or a YAML sequence — APM normalizes all three input shapes into one canonical comma-separated form internally via `normalize_apply_to`/`parse_apply_to`). No `applyTo` means the rule is treated as **unconditional** — folded into root context files as always-on guidance rather than scoped to specific paths.
|
||||
|
||||
`Instruction.validate()` produces these built-in errors/warnings:
|
||||
- Missing `description` → error: `"Missing 'description' in frontmatter"`.
|
||||
- Missing `applyTo` → warning-level: `"No 'applyTo' pattern specified -- instruction will apply globally"` (not fatal — it's accepted, just broad).
|
||||
- Empty body → error: `"Empty content"`.
|
||||
|
||||
## Compile-time mapping: two entirely different mechanisms per target
|
||||
|
||||
This is the biggest divergence from the agent/skill/prompt primitives, and the one most likely to surprise: **Claude Code does not get a verbatim copy of the `.instructions.md` file at all.**
|
||||
|
||||
**Copilot CLI — verbatim, native primitive.** `PrimitiveMapping("instructions", ".instructions.md", "github_instructions")` on the `copilot` target has no `output_compare` flag, so `InstructionIntegrator` copies content through unchanged, preserving the original `applyTo:` frontmatter byte-for-byte (per the integrator's own docstring: "Copilot: `.github/instructions/` (verbatim, preserving applyTo:)"). This is deployed by `apm install`, not `apm compile`.
|
||||
|
||||
At **Copilot user scope only** (`~/.copilot/`), individual files are not deployed — Copilot CLI at user scope reads a single `copilot-instructions.md`, so APM concatenates all instructions into that one file instead (`user_primitive_overrides: {"instructions": PrimitiveMapping("", ".md", "copilot_user_instructions")}`). Project-scope behavior (per-file, `.github/instructions/`) is unaffected.
|
||||
|
||||
**Claude Code — real reconstruction into `.claude/rules/`, with field-dropping.** `PrimitiveMapping("rules", ".md", "claude_rules", output_compare=True)` marks this as one of APM's four "rule formats" (`RULE_FORMATS = {cursor_rules, claude_rules, windsurf_rules, kiro_steering}`) that transform their source rather than copy it. `InstructionIntegrator._convert_to_claude_rules()`:
|
||||
|
||||
- Parses the source frontmatter and pulls only `applyTo` — **`description` is dropped entirely**, not carried into the output in any form.
|
||||
- Converts `applyTo` into a `paths:` YAML list (one `parse_apply_to()`-split glob per line), e.g. `applyTo: "**/*.py"` → `paths:\n - "**/*.py"`.
|
||||
- If there was no `applyTo` (unconditional instruction), the output has **no frontmatter at all** — just the raw body, matching Claude's convention that files without `paths:` in `.claude/rules/` apply unconditionally.
|
||||
- Filename is renamed: `<x>.instructions.md` → `<x>.md` (the primitive's `extension` field, `.md`, replaces the source suffix — this is the general rule for every `output_compare=True` "rule format").
|
||||
|
||||
This is architecturally the same category of lossy, real transformation the prior agent-primitive research found for Codex/Kiro agents — except here it's the default behavior for Claude specifically (not an opt-out edge case), and it applies even though Claude and Copilot are both first-class, actively-supported targets.
|
||||
|
||||
## Compile-time file placement
|
||||
- **Ownership.** Rule-dir files are APM-owned per file. An existing file at the target path is compared against the *transformed* output: it is adopted if identical and rewritten otherwise. `managed_files` is not consulted (apm#1662), so a hand-written `.claude/rules/<same-stem>.md` is overwritten.
|
||||
- **Claude user scope.** `~/.claude/rules/`, or `$CLAUDE_CONFIG_DIR/rules/` if that variable is set. All Claude primitives are supported at user scope.
|
||||
- **`auto_create=False` for Claude.** `integrate_instructions_for_target` returns early when `<project>/.claude/` is not a directory. In a live run with `targets: [claude, copilot]` declared and no `.claude/`, the directory was created and the rules were deployed anyway. Which step creates it was not traced; the command integrator is a likely candidate.
|
||||
|
||||
| Target | Output path | Transform |
|
||||
|---|---|---|
|
||||
| Copilot CLI (project scope) | `.github/instructions/<name>.instructions.md` | Verbatim byte copy, `applyTo:` preserved as-is |
|
||||
| Copilot CLI (user scope, `~/.copilot/`) | `~/.copilot/copilot-instructions.md` | Concatenated — all instructions merged into one file, because Copilot CLI at user scope reads only that single file |
|
||||
| Claude Code | `.claude/rules/<name>.md` | Reconstructed: `applyTo` → `paths:` YAML list; `description` dropped; no frontmatter at all if unconditional |
|
||||
| Copilot (project) | `.github/instructions/<stem>.instructions.md` | Verbatim |
|
||||
| Copilot (user) | `~/.copilot/copilot-instructions.md` | Bodies concatenated; frontmatter stripped |
|
||||
| Claude (project/user) | `.claude/rules/<stem>.md` | `applyTo` → `paths:`; everything else dropped; no frontmatter if unconditional |
|
||||
|
||||
Additionally, **`apm compile`** (distinct from `apm install`) can also fold instruction content directly into root context files — `AGENTS.md` (single-file or per-directory "distributed" mode) and the Claude-specific parallel format `CLAUDE.md`/per-directory `CLAUDE.md` — grouped by directory using `applyTo` pattern analysis (`context_optimizer.optimize_instruction_placement`). To avoid duplicating content between the native `.claude/rules/`+`.github/instructions/` deployment (from `apm install`) and this root-context fold-in (from `apm compile`), a `skip_instructions` config flag (and `compilation.placement.min_instructions_per_file` in `apm.yml`) actively suppresses the redundant copy in AGENTS.md/CLAUDE.md once native per-target files exist — `apm compile --target claude --force-instructions` overrides this dedup when an author explicitly wants both.
|
||||
Other rule formats (`RULE_FORMATS`) work the same way: `cursor_rules` produces `.mdc` with `globs:` and derives `description` from the body when it is missing, `windsurf_rules`, `kiro_steering`, and `antigravity_rules`.
|
||||
|
||||
## Validation constraints and gotchas
|
||||
## `apm compile`: fold-in to AGENTS.md / CLAUDE.md
|
||||
|
||||
- **The `description` field is real for Copilot but silently discarded for Claude.** An author who relies on `description` to explain *why* a rule exists (common practice, since Copilot's `.instructions.md` UI can surface it) gets that context deleted on every Claude compile — there's no config to keep it as a comment or otherwise.
|
||||
- **No content-level validation for the `paths:` conversion** — if `applyTo` contains a pattern `parse_apply_to` can't split sensibly, the resulting `paths:` list is whatever falls out; no dedicated schema check catches a malformed glob before deploy.
|
||||
- **Directory-distribution logic for AGENTS.md/CLAUDE.md is heuristic, not declarative** — `context_optimizer.optimize_instruction_placement` picks placement directories from `applyTo` patterns algorithmically; `compilation.placement.min_instructions_per_file` in `apm.yml` (default effectively 1) is the only tuning knob, and setting it above 1 causes under-populated directories to have their instructions bubbled up to the parent directory rather than dropped.
|
||||
- **Same "no dedicated primitive validation function" gap noted for agents** — `Instruction.validate()` in `primitives/models.py` is the only validation, and it is invoked as part of the generic primitive-discovery/compile pipeline, not as a standalone `apm audit` check comparable to what exists for `apm.yml` itself.
|
||||
`compilation/distributed_compiler.py` and `context_optimizer.optimize_instruction_placement` group instruction bodies into `AGENTS.md` and `CLAUDE.md` files, placed per directory according to the `applyTo` patterns.
|
||||
|
||||
- **Dedup.** Instructions are omitted from `CLAUDE.md` when `.claude/rules/` is populated, and from `AGENTS.md` when `.github/instructions/` is populated. `--force-instructions` (alias `--no-dedup`) overrides this. In a live `apm compile -t claude,copilot` after install, no `CLAUDE.md` was produced. With `-t claude --force-instructions`, `CLAUDE.md` contained a "Global Instructions" section and one `### Files matching \`<applyTo>\`` section per pattern.
|
||||
- **Headings show the normalised `applyTo` string verbatim**, including `\,` escapes (for example ``Files matching `src/**,a\,b/**` ``).
|
||||
- `compilation.placement.min_instructions_per_file` in `apm.yml` controls when under-populated directories bubble their instructions up to the parent.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`description` never reaches Claude.** It is kept for Copilot and is used as the index text in Cursor rules and compiled context. Keep the rationale for a rule in the body if Claude readers need it.
|
||||
- **Copilot user scope loses `applyTo`.** Every instruction becomes global there.
|
||||
- **A glob is never validated.** A typo gives a rule that silently never binds.
|
||||
- `apm audit` checks deployed rules for hidden Unicode and drift. It does not validate frontmatter.
|
||||
|
||||
## Authoring checklist
|
||||
|
||||
**Must** (an author skill enforces these; an audit skill checks them):
|
||||
1. The path is `.apm/instructions/<stem>.instructions.md`, directly in that directory with no subdirectories, and is not a symlink. *Source: `find_instruction_files`, `find_files_by_glob`.*
|
||||
2. `description` is a non-empty string. *Source: `Instruction.validate()`; apm only warns during `apm compile`.*
|
||||
3. The body is non-empty after trimming whitespace. *Source: `Instruction.validate()`; install deploys empty rules silently.*
|
||||
4. `applyTo` is either absent (intentionally global) or a non-empty glob / comma-list. Use top-level commas only as separators, and put alternation inside `{}`. *Source: `parse_apply_to`, `has_top_level_comma`.*
|
||||
5. The stem is unique across the package and its dependencies, because a `.claude/rules/<stem>.md` collision is overwritten. *Source: `integrate_instructions_for_target` rule-dir ownership.*
|
||||
|
||||
**Should:**
|
||||
6. Use the scalar string form of `applyTo` rather than a YAML list, because Copilot receives the source verbatim. *Source: `copy_instruction`. Copilot's handling of list values is unverified.*
|
||||
7. Omitting `applyTo` should be a deliberate choice. Without it, the file is always-on in Claude rules and is folded into `AGENTS.md`/`CLAUDE.md` global sections. *Source: `_convert_to_claude_rules`; `Instruction.validate()` warning text.*
|
||||
8. Keep frontmatter to `description` and `applyTo`, plus the optional `author` and `version`. No target consumes other keys, and Claude drops them. *Source: `_parse_instruction`, `_convert_to_claude_rules`.*
|
||||
9. Keep relative markdown links resolvable from the source file. *Source: `validate_link_targets`, `resolve_links`.*
|
||||
|
||||
**Audit-only** (apm does not check these): glob syntax validity; globs that match nothing in the repo; duplicate stems; description quality; YAML-list `applyTo` on Copilot-targeted packages. `apm compile --validate` cannot be relied on as a gate.
|
||||
|
||||
@@ -1,53 +1,123 @@
|
||||
---
|
||||
topic: prompt-primitive-schema
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- apm-github-repo
|
||||
- context7-microsoft-apm # partial: earlier pass only; the 2026-09-28 re-verification could not reach Context7 (see sources.md)
|
||||
---
|
||||
|
||||
## File location, naming, and frontmatter
|
||||
Checked against the installed apm-cli **0.28.0** source (`integration/prompt_integrator.py`, `integration/command_integrator.py`, `integration/base_integrator.py`, `integration/targets.py`, `security/gate.py`) and a live `apm install` of a scratch package targeting `claude` and `copilot` (2026-09-28).
|
||||
|
||||
`.apm/prompts/*.prompt.md` (also discovered at the package root). Filename minus the `.prompt.md` suffix becomes the prompt's identity — used verbatim as the Copilot filename and, after transformation, as the Claude command name. No required-extension ambiguity: it is genuinely `.prompt.md`, confirmed both in docs and in APM's own `PromptIntegrator.find_prompt_files` (`*.prompt.md`) and `CommandIntegrator.find_prompt_files` (same glob).
|
||||
## File location, naming, discovery
|
||||
|
||||
There is no single closed frontmatter schema — APM's P1 "no invented primitive frontmatter" principle applies here too, so a prompt author writes whatever keys their primary target needs and APM passes or drops per-target. Keys seen in APM's own docs/examples:
|
||||
- `PromptIntegrator.find_prompt_files()` and `CommandIntegrator.find_prompt_files()` both run `find_files_by_glob(pkg, "*.prompt.md", subdirs=[".apm/prompts"])`.
|
||||
- The search covers the package root and `.apm/prompts/`, and is non-recursive.
|
||||
- Symlinks and hardlinks are rejected.
|
||||
- `.apm/prompts/` is canonical. Root files are discovered for backward compatibility (published docs).
|
||||
- Identity comes from the filename minus `.prompt.md`. That name is the Copilot filename unchanged, and the Claude `/command` name.
|
||||
- `integrate_commands_for_target` runs `validate_path_segments(base_name, context="command filename")` against traversal names.
|
||||
- A duplicate name in the root and in `.apm/prompts/` collides. The published docs say "later writer wins on copilot and the transform fails on Claude/Cursor". This was not verified here.
|
||||
- Prompts are **not** in `discover_primitives()` and have no model class. `apm compile` and `apm compile --validate` never look at them.
|
||||
- Prompts are deployed only by `apm install`.
|
||||
- There is no `.apm/commands/` primitive. A Claude command is the compiled form of a prompt.
|
||||
|
||||
| Field | Purpose |
|
||||
## Frontmatter
|
||||
|
||||
There is no closed schema. What survives is decided per target.
|
||||
|
||||
**`_PRESERVED_COMMAND_KEYS` (exact, 0.28.0):**
|
||||
- `description`
|
||||
- `allowed-tools`
|
||||
- `allowedTools`
|
||||
- `model`
|
||||
- `argument-hint`
|
||||
- `argumentHint`
|
||||
- `input`
|
||||
|
||||
The user-facing list (`_PRESERVED_COMMAND_KEYS_DISPLAY`) omits the camelCase aliases.
|
||||
|
||||
**`input:` shapes accepted by `_extract_input_names`:**
|
||||
|
||||
| Shape | Names extracted |
|
||||
|---|---|
|
||||
| `description` | Shown in Copilot's prompt picker / used for discovery |
|
||||
| `input` | List of parameter names (simple list, or list of `{name: description}` objects) referenced in body as `${input:name}` |
|
||||
| `allowed-tools` (or `allowedTools`) | Tool allowlist for the prompt's execution |
|
||||
| `argument-hint` (or `argumentHint`) | Human-readable hint for expected arguments |
|
||||
| `model` | Model override when the prompt runs |
|
||||
| `author`, `mcp`, `parameters` | Cursor/other-target-specific metadata — **not preserved** by the shared Claude/Cursor command transformer (see below) |
|
||||
| `input: [file, focus]` (list of strings) | each string |
|
||||
| `input:` list of one-key maps (`- file: "desc"`) | each map's **keys** |
|
||||
| `input: file` (single string) | that string |
|
||||
| `input: {file: desc, focus: desc}` (map) | its keys |
|
||||
|
||||
**Workflow-prompt-only keys** (Copilot App / Copilot Workflows, not Copilot CLI): `name`, `interval` (`manual`/`hourly`/`daily`/`weekly`), `schedule_hour` (0–23 UTC), `schedule_day` (0–6, weekly only), `mode` (`interactive`/`plan`), `reasoning_effort`. These are flat top-level keys on the same `.prompt.md` file, consumed only by the Copilot App scheduler integration — irrelevant to Claude Code / Copilot CLI compilation and should not be treated as universal prompt schema.
|
||||
- Names must match `_INPUT_NAME_RE = ^[A-Za-z][\w-]{0,63}$`.
|
||||
- Invalid names and non-string entries are rejected, with the warning `input: rejected N invalid name(s) (must match [A-Za-z][\w-]{0,63}): <first 5>`.
|
||||
- Whitespace-only entries are dropped silently.
|
||||
- **Upstream docs bug.** The published "Commands" example writes `- name: pr_number` / `description: …` inside a single map. `_extract_input_names` reads the map's *keys*, so the arguments come out as `name` and `description` rather than `pr_number`. This was verified live: `arguments: [name, description]`, `argument-hint: <name> <description>`. Use `- pr_number: "desc"` instead.
|
||||
|
||||
## Compile-time mapping: verbatim for Copilot, real reconstruction for Claude
|
||||
**Other keys seen in docs:**
|
||||
- Copilot-only picker metadata: `name`, `agent`, `mode`, `tools`.
|
||||
- Cursor and other targets: `author`, `mcp`, `parameters`.
|
||||
- Copilot App workflow keys: `interval`, `schedule_hour`, `schedule_day`, `reasoning_effort`, per the published docs. The source has a `copilot_app_workflow_integrator` module, but it was not traced here.
|
||||
|
||||
**Copilot CLI target — verbatim copy.** `PrimitiveMapping("prompts", ".prompt.md", "github_prompt")` on the `copilot` target profile carries no `output_compare` flag, and `PromptIntegrator.copy_prompt()` reads the source file and writes it out unchanged (only markdown link targets get rewritten) via `copy_prompt: "Copy prompt file verbatim with link resolution."`. Every frontmatter key — including `author`, `mcp`, `parameters` — survives. Filename is untouched (`get_target_filename` returns `source_file.name`, "no -apm suffix").
|
||||
None of these other keys survive the Claude transform.
|
||||
|
||||
**Claude Code target — real reconstruction into a slash command, with field-dropping.** There is no `prompts:` key at all in Claude's `TargetProfile.primitives` dict; instead prompts route through the shared `CommandIntegrator`, which transforms `.prompt.md` → Claude custom slash command markdown. `CommandIntegrator._transform_prompt_to_command()`:
|
||||
## Per-target mapping
|
||||
|
||||
- Strips the `.prompt.md` suffix from the filename to derive `command_name`.
|
||||
- Builds an entirely new frontmatter object containing **only** these preserved keys: `description`, `allowed-tools` (accepts `allowedTools` alias), `model`, `argument-hint` (accepts `argumentHint` alias).
|
||||
- Maps APM's `input:` list to Claude's `arguments:` list, and synthesizes `argument-hint` from it if not already set.
|
||||
- Rewrites body placeholders `${input:name}` / `${{input:name}}` to Claude's native `$name` syntax via regex substitution.
|
||||
- Computes `dropped_keys = source_frontmatter_keys - preserved_keys` and surfaces it as an install-time diagnostic warning — so `author`, `mcp`, `parameters`, and any other non-listed key are silently dropped from the compiled output but *not* silently dropped from the user's awareness (a warning fires).
|
||||
- Cursor reuses this exact same transformer (`claude_command` format_id) — same preserved-key set, same drops.
|
||||
**Copilot, verbatim.** Mapping: `PrimitiveMapping("prompts", ".prompt.md", "github_prompt")`. `PromptIntegrator.copy_prompt` resolves links and normalises line endings to LF. In the live run a `diff` against the source was empty, and every key survived, including dropped-for-Claude keys. `${input:x}` stays as written. At user scope the prompt goes to `~/.copilot/prompts/`.
|
||||
|
||||
## Compile-time file placement
|
||||
**Claude, reconstructed.** Mapping: `PrimitiveMapping("commands", ".md", "claude_command")`, which goes through `CommandIntegrator._transform_prompt_to_command`. The transform:
|
||||
- Builds new frontmatter from `description`, `allowed-tools` (the `allowedTools` alias is accepted), `model`, and `argument-hint` (the `argumentHint` alias is accepted).
|
||||
- Adds `arguments: [names]` from `input`. When there is no explicit `argument-hint`, it synthesises `argument-hint: "<a> <b>"`.
|
||||
- Emits keys in alphabetical order, because `frontmatter.dumps` sorts them.
|
||||
- Rewrites the body with the regex `\$\{\{?\s*input\s*:\s*([\w-]+)\s*\}?\}` → `$name`. This runs **only when at least one valid input name exists**, and then it rewrites **every** `${input:…}`, including names not declared in `input:`. Live: `${input:undeclared}` became `$undeclared`, while a prompt with no `input:` kept `${input:x}` literally.
|
||||
- Reports dropped keys, `sorted(source_keys - _PRESERVED_COMMAND_KEYS)`, as the exact warning `Claude command <name>: frontmatter keys not supported for claude commands and were dropped: <keys>. Supported keys: allowed-tools, argument-hint, description, input, model.`
|
||||
- Emits the info message `Mapped input -> command arguments in <file>: [...]`.
|
||||
- Scans the compiled text with `SecurityGate.scan_text(BLOCK_POLICY)`. A critical hidden-character finding skips the write.
|
||||
- Deploys at user scope to `~/.claude/commands/`, or to `$CLAUDE_CONFIG_DIR/commands/` if that variable is set.
|
||||
|
||||
Cursor, OpenCode and Grok Build reuse the same `claude_command` transformer. Gemini writes TOML. Windsurf writes workflows. Codex gets nothing.
|
||||
|
||||
Live output of `review.prompt.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
argument-hint: <file> <focus>
|
||||
arguments:
|
||||
- file
|
||||
- focus
|
||||
description: Review a file
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
Review $file focusing on $focus and $undeclared.
|
||||
```
|
||||
|
||||
| Target | Output path | Transform |
|
||||
|---|---|---|
|
||||
| Copilot CLI | `.github/prompts/<name>.prompt.md` | Verbatim byte copy (links resolved) |
|
||||
| Claude Code | `.claude/commands/<name>.md` | Reconstructed: only `description`/`allowed-tools`/`model`/`argument-hint`/`arguments` survive; `input:` → `arguments:`; `${input:x}` → `$x` |
|
||||
|
||||
Invocation surface differs correspondingly: Copilot exposes it via the prompts picker UI (select by name); Claude exposes it as `/<name> <args>` (same pattern Cursor, OpenCode, Gemini CLI, and Windsurf's workflows menu use for their own compiled copies).
|
||||
| Copilot | `.github/prompts/<name>.prompt.md` | Verbatim (links resolved) |
|
||||
| Claude | `.claude/commands/<name>.md` | Preserved-key subset; `input` becomes `arguments`; `${input:x}` becomes `$x` |
|
||||
|
||||
## Validation constraints and gotchas
|
||||
|
||||
- **Input-name validation is real, not just documentation.** `_extract_input_names()` enforces `[A-Za-z][\w-]{0,63}` on every name pulled from `input:`; anything that fails is dropped from `arguments:` and reported as a warning listing up to 5 rejected names (`input: rejected N invalid name(s) ... `). A malformed `input:` entry does not fail the install — it silently loses that one argument.
|
||||
- **Filename-derived identity is security-checked.** `integrate_commands_for_target` calls `validate_path_segments(base_name, context="command filename")` specifically to reject a package shipping a `.prompt.md` file with a manipulated relative name (e.g. `../../evil.prompt.md`) that would otherwise escape the target commands directory.
|
||||
- **The dropped-key warning is the only signal a Claude-only author gets** that Cursor-specific frontmatter (`author`, `mcp`, `parameters`) never reached the deployed file — there is no error, no hard failure, and no config flag to preserve those keys for Claude; the shared transformer's preserved-key list is fixed in code (`_PRESERVED_COMMAND_KEYS`), not configurable per package.
|
||||
- **No dedicated `Prompt`/`PromptPrimitive` validation class exists** in `apm_cli/models/validation.py` or `apm_cli/primitives/models.py` — same gap pattern documented for the agent primitive. `apm.yml`'s `type: prompts` package-content-type ("Commands/prompts only, no instructions or skills") is validated at the package-type-detection level, not the individual-prompt level.
|
||||
- Slash commands and prompts share one source directory and one glob (`.apm/prompts/*.prompt.md`) — there is no separate `.apm/commands/` primitive; "command" is purely a per-target compiled *name* for the same source file, not a distinct authoring primitive.
|
||||
- APM never validates `description`: the transform only copies it if present. Deploying a prompt with no frontmatter at all was not tested.
|
||||
- `$ARGUMENTS` and other native Claude syntax pass through untouched. They appear literally in the Copilot copy.
|
||||
- A prompt that relies on Copilot-only keys (`agent`, `tools`, `mode`) loses them on Claude. The only signal is the install-time warning.
|
||||
- A pre-install hidden-Unicode scan (`install/helpers/security_scan`, `BLOCK_POLICY`) runs on source files. `apm compile` does not re-scan, so run `apm audit` before publishing (published docs).
|
||||
- `apm run <script> --param k=v` compiles a prompt with parameters bound. See `cli-reference.md`.
|
||||
|
||||
## Authoring checklist
|
||||
|
||||
**Must** (an author skill enforces these; an audit skill checks them):
|
||||
1. The path is `.apm/prompts/<name>.prompt.md`, directly in that directory and not a symlink. `<name>` is unique across `.apm/prompts/` and the package root, and is a safe path segment. *Source: `find_prompt_files`, `validate_path_segments`.*
|
||||
2. `description` is present and non-empty. It is the picker and command description on both targets, and apm does not check it. *Source: `_transform_prompt_to_command`.*
|
||||
3. Every `input:` name matches `^[A-Za-z][\w-]{0,63}$`. The object form is `- <name>: "<desc>"`, never `- name: <name>`. *Source: `_INPUT_NAME_RE`, `_extract_input_names`.*
|
||||
4. Every `${input:x}` in the body refers to a name declared in `input:`, and every declared name is used. If `input:` is empty or absent, no `${input:…}` may appear, because it would reach Claude unrewritten. *Source: the rewrite regex and its condition.*
|
||||
5. Frontmatter keys are limited to the preserved set (`description`, `allowed-tools`, `model`, `argument-hint`, `input`) unless a Copilot-only key is intended and its Claude drop is accepted. *Source: `_PRESERVED_COMMAND_KEYS`.*
|
||||
|
||||
**Should:**
|
||||
6. Use the kebab-case spellings `allowed-tools` and `argument-hint`, not the camelCase aliases. *Source: `_PRESERVED_COMMAND_KEYS_DISPLAY`.*
|
||||
7. Omit `argument-hint` when `input:` is set, unless the synthesised `<a> <b>` form is inadequate. *Source: `_transform_prompt_to_command`.*
|
||||
8. Give `model` a slug the target accepts. Copilot ignores `allowed-tools` and `model` (published docs).
|
||||
9. Keep one intent per prompt, and write the body as second-person instructions (published "Author a prompt" guide).
|
||||
|
||||
**Audit-only** (apm does not check these): missing `description`; undeclared or unused inputs; `${input:…}` without `input:`; Copilot-only keys in a Claude-targeted package; name collisions between the root and `.apm/prompts/`.
|
||||
|
||||
@@ -14,4 +14,21 @@
|
||||
- **Contributing files:** agent-primitive-schema.md, prompt-primitive-schema.md, instructions-primitive-schema.md, hooks-primitive-schema.md, releasing.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-cli-installed-source
|
||||
|
||||
- **URL:** https://github.com/microsoft/apm/tree/v0.28.0/src/apm_cli/
|
||||
- **Local copy read:** the installed package at `~/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/` (pipx, apm-cli 0.28.0), which is what was actually read; the URL above is the matching upstream tag, for readers without that install.
|
||||
- **Description:** Installed apm-cli 0.28.0 package source, which is the version this repo runs. Read as ground truth for the hooks, instructions and prompts docs, including `integration/hook_integrator.py`, `hook_native_formats.py`, `hook_ir.py`, `hook_file_routing.py`, `instruction_integrator.py`, `command_integrator.py`, `prompt_integrator.py`, `targets.py`, `primitives/`, `utils/patterns.py`, `compilation/agents_compiler.py`, `commands/compile/cli.py` and `security/executables.py`. Cross-checked by live `apm install` / `apm compile` runs of a throwaway package (claude and copilot targets) in a scratch dir on 2026-09-28.
|
||||
- **Contributing files:** hooks-primitive-schema.md, instructions-primitive-schema.md, prompt-primitive-schema.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-docs-llms-full
|
||||
|
||||
- **URL:** https://microsoft.github.io/apm/llms-full.txt
|
||||
- **Description:** The full published APM docs bundle. It tracks upstream main and may be newer than 0.28.0. Used the "Hooks and commands", "Instructions and agents" and "Author a prompt" guides for public-facing claims and to flag where the docs diverge from 0.28.0.
|
||||
- **Contributing files:** hooks-primitive-schema.md, instructions-primitive-schema.md, prompt-primitive-schema.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
Note (2026-09-28): during the verification pass for the hooks, instructions and prompts docs, the Context7 `/microsoft/apm` endpoint returned an invalid-API-key error. Those three files were re-verified against `apm-cli-installed-source` and `apm-docs-llms-full` only. Their `context7-microsoft-apm` key reflects the earlier pass, and each of the three carries a note saying so next to the key.
|
||||
|
||||
Note: `releasing.md`'s `--check-clean`/`--check-versions` scope, `apm pack` exit-code semantics, and the `.apm/`-vs-root-flat-dir mutual exclusivity referenced there were additionally cross-checked directly against `apm_cli/bundle/plugin_exporter.py`, `apm_cli/commands/pack.py`, and `apm_cli/marketplace/drift_check.py` in the installed `apm-cli` 0.28.0 package (`/root/.local/pipx/venvs/apm-cli/`), not just Context7 doc snippets — confirmed by a live `apm pack --format plugin` run inside `plugins/bin` that reproduced the documented `[!] Skipping root-level skills/ because .apm/ is present` warning.
|
||||
|
||||
@@ -36,6 +36,7 @@ make_apm() {
|
||||
cat > "$FAKE_BIN/apm" << EOF
|
||||
#!/usr/bin/env bash
|
||||
pwd > "$WORK/apm-cwd"
|
||||
echo "\${GIT_TERMINAL_PROMPT-unset}" > "$WORK/gtp-\$1"
|
||||
case "\$1" in
|
||||
outdated) echo "$outdated_line"; exit 0 ;;
|
||||
update) touch "$WORK/update-was-called"; exit $update_exit ;;
|
||||
@@ -45,11 +46,12 @@ EOF
|
||||
chmod +x "$FAKE_BIN/apm"
|
||||
}
|
||||
|
||||
# CLAUDE_PROJECT_DIR is cleared rather than merely left alone: a session in this
|
||||
# repo exports it, and an inherited value would point every case at the real
|
||||
# repo root (which has a real apm.lock.yaml) instead of the fixture. The
|
||||
# project-directory cases below set it deliberately.
|
||||
run_hook() { (cd "$WORK" && env -u CLAUDE_PROJECT_DIR PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
|
||||
# CLAUDE_PROJECT_DIR is set to the fixture rather than inherited: a session in
|
||||
# this repo exports it, and an inherited value would point every case at the
|
||||
# real repo root (which has a real apm.lock.yaml) instead of the fixture. It
|
||||
# must be set, not cleared — the hook is Claude Code only and exits at once
|
||||
# without it. The project-directory cases below vary it deliberately.
|
||||
run_hook() { (cd "$WORK" && env CLAUDE_PROJECT_DIR="$WORK" PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
|
||||
|
||||
# Same, with an explicit cwd and CLAUDE_PROJECT_DIR. $1 is the cwd; $2 the value
|
||||
# for CLAUDE_PROJECT_DIR, or the literal `-` to leave it unset.
|
||||
@@ -86,7 +88,7 @@ echo "--- inert when apm is absent ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
rm -f "$WORK/update-was-called"
|
||||
out="$( (cd "$WORK" && PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$?
|
||||
out="$( (cd "$WORK" && env CLAUDE_PROJECT_DIR="$WORK" PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when apm is not on PATH" || fail "should exit 0 when apm is missing"
|
||||
[[ -z "$out" ]] && pass "emits nothing when apm is not on PATH" || fail "should stay silent when apm is missing"
|
||||
|
||||
@@ -252,7 +254,7 @@ echo "--- anchors on the project root, not the session cwd ---"
|
||||
# subdirectory of an apm-consuming repo therefore no-opped silently — and would
|
||||
# have run `apm outdated`/`apm update` against that wrong directory had the
|
||||
# guard passed. Claude Code exports CLAUDE_PROJECT_DIR for SessionStart hooks,
|
||||
# so that is the anchor; the cwd is only the fallback.
|
||||
# so that is the anchor, with no cwd fallback.
|
||||
ELSEWHERE="$WORK/elsewhere"
|
||||
mkdir -p "$ELSEWHERE"
|
||||
rm -f "$ELSEWHERE/apm.lock.yaml"
|
||||
@@ -269,40 +271,188 @@ out="$(run_hook_in "$ELSEWHERE" "$WORK")"
|
||||
grep -q "6 package" <<< "$(json_field additionalContext <<< "$out")" \
|
||||
&& pass "reports the count found via CLAUDE_PROJECT_DIR" || fail "should report the count"
|
||||
|
||||
# The fallback is not cosmetic: a host that installed this plugin natively sets
|
||||
# no CLAUDE_PROJECT_DIR, and the hook must stay inert-but-harmless there rather
|
||||
# than erroring on an unset variable (the script runs under `set -u`).
|
||||
# Claude Code only (ADR-0019, correction 2026-09-28). apm deploys this hook to
|
||||
# Copilot and Codex too, and in a consumer the lockfile guard passes there —
|
||||
# apm wrote the lock. A host that sets no CLAUDE_PROJECT_DIR must therefore
|
||||
# exit before any apm call, even with a lockfile in the cwd and a stale install
|
||||
# on offer; the old cwd fallback ran `apm update --yes` under such a host.
|
||||
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
|
||||
out="$(run_hook_in "$WORK" "-")"
|
||||
[[ -f "$WORK/update-was-called" ]] \
|
||||
&& pass "falls back to the cwd when CLAUDE_PROJECT_DIR is unset" \
|
||||
|| fail "must still work with no CLAUDE_PROJECT_DIR in the environment"
|
||||
[[ "$(cat "$WORK/apm-cwd" 2>/dev/null)" == "$WORK" ]] \
|
||||
&& pass "runs apm in the cwd under the fallback" \
|
||||
|| fail "apm ran in '$(cat "$WORK/apm-cwd" 2>/dev/null)' — should be the cwd"
|
||||
out="$(run_hook_in "$WORK" "-")"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when CLAUDE_PROJECT_DIR is unset" \
|
||||
|| fail "exited $rc with no CLAUDE_PROJECT_DIR — must exit 0"
|
||||
[[ -z "$out" ]] && pass "stays silent when CLAUDE_PROJECT_DIR is unset" \
|
||||
|| fail "emitted output with no CLAUDE_PROJECT_DIR — a non-Claude host must see nothing"
|
||||
[[ ! -f "$WORK/apm-cwd" ]] \
|
||||
&& pass "runs no apm command when CLAUDE_PROJECT_DIR is unset" \
|
||||
|| fail "ran apm with no CLAUDE_PROJECT_DIR — a non-Claude host must never reach apm"
|
||||
[[ ! -f "$WORK/update-was-called" ]] \
|
||||
&& pass "does not run apm update when CLAUDE_PROJECT_DIR is unset" \
|
||||
|| fail "ran apm update under a host that is not Claude Code"
|
||||
|
||||
# Set but empty is the same as unset: there is no project root to anchor on.
|
||||
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
|
||||
out="$(run_hook_in "$WORK" "")"; rc=$?
|
||||
[[ $rc -eq 0 && -z "$out" && ! -f "$WORK/apm-cwd" ]] \
|
||||
&& pass "treats an empty CLAUDE_PROJECT_DIR as unset" \
|
||||
|| fail "an empty CLAUDE_PROJECT_DIR must exit 0 silently without running apm"
|
||||
|
||||
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
|
||||
out="$(run_hook_in "$ELSEWHERE" "$ELSEWHERE")"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when neither the project dir nor the cwd has a lockfile" \
|
||||
|| fail "should exit 0 when there is no lockfile anywhere"
|
||||
[[ -z "$out" ]] && pass "stays silent when neither the project dir nor the cwd has a lockfile" \
|
||||
|| fail "should stay silent when there is no lockfile anywhere"
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when the project dir has no lockfile" \
|
||||
|| fail "should exit 0 when the project dir has no lockfile"
|
||||
[[ -z "$out" ]] && pass "stays silent when the project dir has no lockfile" \
|
||||
|| fail "should stay silent when the project dir has no lockfile"
|
||||
[[ ! -f "$WORK/update-was-called" ]] \
|
||||
&& pass "does not run apm update when there is no lockfile anywhere" \
|
||||
&& pass "does not run apm update when the project dir has no lockfile" \
|
||||
|| fail "must not touch a project that does not use apm"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- git never prompts ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A remote that wants credentials must fail fast inside apm's git calls, not
|
||||
# block on a terminal prompt until the timeout fires. Cleared on the way in so
|
||||
# the assertion cannot pass on an inherited value.
|
||||
make_apm "[!] 6 outdated dependencies found" 0
|
||||
rm -f "$WORK/gtp-outdated" "$WORK/gtp-update"
|
||||
(cd "$WORK" && env -u GIT_TERMINAL_PROMPT CLAUDE_PROJECT_DIR="$WORK" PATH="$FAKE_BIN:$PATH" bash "$HOOK" > /dev/null 2>&1)
|
||||
[[ "$(cat "$WORK/gtp-outdated" 2>/dev/null)" == "0" ]] \
|
||||
&& pass "apm outdated runs with GIT_TERMINAL_PROMPT=0" \
|
||||
|| fail "apm outdated saw GIT_TERMINAL_PROMPT='$(cat "$WORK/gtp-outdated" 2>/dev/null)' — must be 0"
|
||||
[[ "$(cat "$WORK/gtp-update" 2>/dev/null)" == "0" ]] \
|
||||
&& pass "apm update runs with GIT_TERMINAL_PROMPT=0" \
|
||||
|| fail "apm update saw GIT_TERMINAL_PROMPT='$(cat "$WORK/gtp-update" 2>/dev/null)' — must be 0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- timeout(1) resolution ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Stock macOS has no timeout(1); Homebrew coreutils ships it as gtimeout. A PATH
|
||||
# that holds only what the hook and the mock need — and no timeout — stands in
|
||||
# for that host. bash is invoked by absolute path; `env` and `bash` are still
|
||||
# linked in because the mock's shebang resolves them through PATH.
|
||||
REAL_TIMEOUT="$(command -v timeout || true)"
|
||||
BASH_BIN="$(command -v bash)"
|
||||
SANDBOX_BIN="$(mktemp -d)"
|
||||
GT_BIN="$(mktemp -d)"
|
||||
trap 'rm -rf "$FAKE_BIN" "$WORK" "$SANDBOX_BIN" "$GT_BIN" "${PROBE:-}"' EXIT
|
||||
for tool in bash env grep touch git; do
|
||||
src="$(command -v "$tool" || true)"
|
||||
[[ -n "$src" ]] && ln -s "$src" "$SANDBOX_BIN/$tool"
|
||||
done
|
||||
|
||||
make_apm "[!] 6 outdated dependencies found" 0
|
||||
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
|
||||
out="$( (cd "$WORK" && env CLAUDE_PROJECT_DIR="$WORK" PATH="$FAKE_BIN:$SANDBOX_BIN" "$BASH_BIN" "$HOOK" 2>/dev/null) )"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when neither timeout nor gtimeout is on PATH" \
|
||||
|| fail "exited $rc with no timeout binary — must exit 0"
|
||||
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
|
||||
pass "emits valid JSON when timeout(1) is missing"
|
||||
[[ "$(echo "$out" | json_field reloadSkills)" == "False" ]] \
|
||||
&& pass "does not ask for a skill reload when timeout(1) is missing" || fail "reloadSkills should be false"
|
||||
grep -q "neither timeout nor gtimeout" <<< "$(json_field additionalContext <<< "$out")" \
|
||||
&& pass "says timeout(1) is missing instead of failing silently" \
|
||||
|| fail "the notice should name the missing timeout binary"
|
||||
else
|
||||
fail "emits valid JSON when timeout(1) is missing: $out"
|
||||
fi
|
||||
[[ ! -f "$WORK/apm-cwd" ]] && pass "runs no apm command without a time limit" \
|
||||
|| fail "ran apm unbounded with no timeout binary"
|
||||
|
||||
if [[ -n "$REAL_TIMEOUT" ]]; then
|
||||
cat > "$GT_BIN/gtimeout" << EOF
|
||||
#!/usr/bin/env bash
|
||||
touch "$WORK/gtimeout-was-called"
|
||||
exec "$REAL_TIMEOUT" "\$@"
|
||||
EOF
|
||||
chmod +x "$GT_BIN/gtimeout"
|
||||
|
||||
rm -f "$WORK/update-was-called" "$WORK/gtimeout-was-called"
|
||||
out="$( (cd "$WORK" && env CLAUDE_PROJECT_DIR="$WORK" PATH="$FAKE_BIN:$GT_BIN:$SANDBOX_BIN" "$BASH_BIN" "$HOOK" 2>/dev/null) )"
|
||||
[[ -f "$WORK/gtimeout-was-called" ]] && pass "falls back to gtimeout when timeout is absent" \
|
||||
|| fail "should use gtimeout when timeout is not on PATH"
|
||||
[[ -f "$WORK/update-was-called" && "$(echo "$out" | json_field reloadSkills 2>/dev/null)" == "True" ]] \
|
||||
&& pass "refreshes normally through gtimeout" || fail "the gtimeout path should refresh and reload: $out"
|
||||
|
||||
rm -f "$WORK/gtimeout-was-called"
|
||||
(cd "$WORK" && env CLAUDE_PROJECT_DIR="$WORK" PATH="$FAKE_BIN:$GT_BIN:$(dirname "$REAL_TIMEOUT"):$SANDBOX_BIN" "$BASH_BIN" "$HOOK" > /dev/null 2>&1)
|
||||
[[ ! -f "$WORK/gtimeout-was-called" ]] && pass "prefers timeout over gtimeout when both exist" \
|
||||
|| fail "used gtimeout although timeout is on PATH"
|
||||
else
|
||||
echo " (timeout not on PATH — gtimeout fallback cases not run)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- concurrent sessions do not both update ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if command -v flock > /dev/null 2>&1; then
|
||||
LOCK_PROJECT="$WORK/lock-project"
|
||||
mkdir -p "$LOCK_PROJECT/apm_modules"
|
||||
touch "$LOCK_PROJECT/apm.lock.yaml"
|
||||
LOCKFILE="$LOCK_PROJECT/apm_modules/.kyberforge-apm-update.lock"
|
||||
make_apm "[!] 6 outdated dependencies found" 0
|
||||
|
||||
# Lock free: the refresh runs and the lock lands under the gitignored
|
||||
# apm_modules/, never in the project root where git would see it.
|
||||
rm -f "$WORK/update-was-called"
|
||||
out="$(run_hook_in "$LOCK_PROJECT" "$LOCK_PROJECT")"
|
||||
[[ -f "$WORK/update-was-called" ]] && pass "updates when the lock is free" \
|
||||
|| fail "should update when no other session holds the lock"
|
||||
[[ -f "$LOCKFILE" ]] && pass "takes its lock under apm_modules/" \
|
||||
|| fail "expected the lock at apm_modules/.kyberforge-apm-update.lock"
|
||||
|
||||
# Lock held by another session: this one must not update, must still exit 0
|
||||
# with valid JSON, and must say why nothing was refreshed.
|
||||
exec 8> "$LOCKFILE"
|
||||
flock 8
|
||||
rm -f "$WORK/update-was-called"
|
||||
out="$(run_hook_in "$LOCK_PROJECT" "$LOCK_PROJECT" 8>&-)"; rc=$?
|
||||
exec 8>&-
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when another session holds the update lock" \
|
||||
|| fail "exited $rc with the lock held — must exit 0"
|
||||
[[ ! -f "$WORK/update-was-called" ]] && pass "skips apm update when another session holds the lock" \
|
||||
|| fail "ran apm update while another session held the lock"
|
||||
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
|
||||
[[ "$(echo "$out" | json_field reloadSkills)" == "False" ]] \
|
||||
&& pass "does not ask for a skill reload when it skipped the refresh" || fail "reloadSkills should be false"
|
||||
grep -q "another session is refreshing it" <<< "$(json_field additionalContext <<< "$out")" \
|
||||
&& pass "says another session is refreshing" || fail "the notice should say another session holds the refresh"
|
||||
else
|
||||
fail "emits valid JSON when the lock is held: $out"
|
||||
fi
|
||||
|
||||
# No apm_modules/ yet (fresh clone before install): proceed unserialised
|
||||
# rather than create the directory.
|
||||
NO_MODULES="$WORK/no-modules"
|
||||
mkdir -p "$NO_MODULES"
|
||||
touch "$NO_MODULES/apm.lock.yaml"
|
||||
rm -f "$WORK/update-was-called"
|
||||
run_hook_in "$NO_MODULES" "$NO_MODULES" > /dev/null
|
||||
[[ -f "$WORK/update-was-called" && ! -e "$NO_MODULES/apm_modules" ]] \
|
||||
&& pass "without apm_modules/, updates unserialised and creates nothing" \
|
||||
|| fail "without apm_modules/ the hook should update and not create the directory"
|
||||
else
|
||||
echo " (flock not on PATH — lock cases not run)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- hooks.json wiring ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# apm resolves script paths relative to the package root, and `apm pack` keeps
|
||||
# only *.json from .apm/hooks/ — so a ${CLAUDE_PLUGIN_ROOT}/hooks/... reference
|
||||
# points at a directory the script never reaches. It must be .apm/-relative.
|
||||
# apm resolves script paths relative to the package root, and the script lives
|
||||
# under .apm/hooks/ — so a ${PLUGIN_ROOT}/hooks/... reference points at a
|
||||
# directory that does not exist. It must be .apm/-relative, and
|
||||
# it uses apm's target-neutral token, which apm rewrites identically to
|
||||
# ${CLAUDE_PLUGIN_ROOT} for every target (ADR-0019, amendment 2026-09-28).
|
||||
referenced="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["command"])' "$HOOKS_JSON")"
|
||||
[[ "$referenced" == '${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh' ]] \
|
||||
[[ "$referenced" == '${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh' ]] \
|
||||
&& pass "hooks.json references the script at its .apm/ path" \
|
||||
|| fail "hooks.json references '$referenced' — must be \${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
||||
|| fail "hooks.json references '$referenced' — must be \${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
||||
|
||||
[[ -x "$HOOK" ]] && pass "hook script is executable" || fail "hook script must be executable"
|
||||
|
||||
@@ -317,15 +467,27 @@ matcher="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d[
|
||||
# literal, so raising either internal `timeout` without raising the host budget
|
||||
# fails here instead of reintroducing the gap quietly.
|
||||
#
|
||||
# Every `timeout N` in the script counts, comments included: a stray "timeout
|
||||
# 300" in prose only makes this stricter, which is the safe direction.
|
||||
# Both spellings count, comments included: `-k K N` (the binary is resolved at
|
||||
# run time, so the call reads `"$timeout_bin" -k 5 60 …`) contributes the limit N
|
||||
# plus the kill-after grace K, since SIGKILL lands only K seconds after N; a
|
||||
# bare `timeout N` contributes N. A stray match in prose only makes this
|
||||
# stricter, which is the safe direction.
|
||||
script_budget=0
|
||||
timeout_count=0
|
||||
while read -r n; do
|
||||
[[ -n "$n" ]] || continue
|
||||
script_budget=$((script_budget + n))
|
||||
while read -r k n; do
|
||||
[[ -n "$k" ]] || continue
|
||||
script_budget=$((script_budget + k + ${n:-0}))
|
||||
timeout_count=$((timeout_count + 1))
|
||||
done < <(grep -oE '\btimeout [0-9]+\b' "$HOOK" | grep -oE '[0-9]+')
|
||||
done < <(
|
||||
grep -oE -- '-k [0-9]+ [0-9]+\b' "$HOOK" | grep -oE '[0-9]+ [0-9]+' || true
|
||||
grep -oE '\btimeout [0-9]+\b' "$HOOK" | grep -oE '[0-9]+' || true
|
||||
)
|
||||
|
||||
# Without -k a child that ignores SIGTERM outlives its limit and the budget
|
||||
# above is fiction. Every time-boxed call must carry the grace.
|
||||
unkilled="$(grep -E '"\$timeout_bin" ' "$HOOK" | grep -vE -- '-k [0-9]+ [0-9]+' || true)"
|
||||
[[ -z "$unkilled" ]] && pass "every time-boxed apm call carries a -k kill-after grace" \
|
||||
|| fail "a time-boxed call has no -k grace: $unkilled"
|
||||
|
||||
hook_timeout="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["timeout"])' "$HOOKS_JSON")"
|
||||
|
||||
@@ -365,7 +527,7 @@ if ! command -v apm > /dev/null 2>&1 || ! command -v git > /dev/null 2>&1; then
|
||||
echo " $SKIP_REASON"
|
||||
else
|
||||
PROBE="$(mktemp -d)"
|
||||
trap 'rm -rf "$FAKE_BIN" "$WORK" "$PROBE"' EXIT
|
||||
trap 'rm -rf "$FAKE_BIN" "$WORK" "$SANDBOX_BIN" "$GT_BIN" "$PROBE"' EXIT
|
||||
|
||||
UPSTREAM="$PROBE/upstream.git"
|
||||
git init -q "$UPSTREAM"
|
||||
|
||||
@@ -1382,6 +1382,8 @@ plugins/demo/.apm/agents/demo.md|[**/agents/*.md]|isolating
|
||||
.claude/agents/demo.md|[**/agents/*.md]|isolating
|
||||
plugins/demo/.apm/agents/demo.agent.md|[**/*.agent.md]|overlapping
|
||||
copilot/demo.agent.md|[**/*.agent.md]|isolating
|
||||
plugins/demo/.apm/instructions/demo.instructions.md|[**/*.instructions.md]|isolating
|
||||
plugins/demo/.apm/prompts/demo.prompt.md|[**/*.prompt.md]|isolating
|
||||
EOF_PROBE28
|
||||
)"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user