Compare commits
4 Commits
97cd22edda
...
docs/onede
| Author | SHA1 | Date | |
|---|---|---|---|
| a3453c5d6f | |||
| d654dca056 | |||
| c5f754d3ad | |||
| 3ea057794c |
@@ -31,7 +31,7 @@ Fall back to raw shell only when no skill covers it.
|
|||||||
- **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook keeps the install current on launch and rewrites the lock in the process (ADR-0019). On `main`, commit or discard it deliberately. On a feature branch, discard it (`git checkout -- apm.lock.yaml`, then `apm install`). This keeps unrelated lock churn out of the branch diff and keeps `apm pack --check-clean` consistent with the committed lock. The session then runs the older `main` that the lock records, which is accepted on a branch, and the next session start refreshes again.
|
- **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook keeps the install current on launch and rewrites the lock in the process (ADR-0019). On `main`, commit or discard it deliberately. On a feature branch, discard it (`git checkout -- apm.lock.yaml`, then `apm install`). This keeps unrelated lock churn out of the branch diff and keeps `apm pack --check-clean` consistent with the committed lock. The session then runs the older `main` that the lock records, which is accepted on a branch, and the next session start refreshes again.
|
||||||
- **A `.apm/` edit is not live until it is on the remote's `main`.** The six dependencies resolve from the holocron remote, unpinned against the default branch, so pushing a feature branch does not deploy it (ADR-0019). `apm install` deploys from the lock; `apm update` is what re-resolves refs.
|
- **A `.apm/` edit is not live until it is on the remote's `main`.** The six dependencies resolve from the holocron remote, unpinned against the default branch, so pushing a feature branch does not deploy it (ADR-0019). `apm install` deploys from the lock; `apm update` is what re-resolves refs.
|
||||||
- **No pre-push hook needs the network — once `apm install` has run.** Root `apm.yml`'s marketplace has no remote package entries, so every hook resolves locally. The guarantee is a property of a populated `apm_modules/`, not of the hook set: on a fresh clone `apm-audit-ci`'s `deployed-files-present` fails outright, and its `drift` and `config-consistency` install-replays have no cache to replay from and clone from the remote. Run `apm install` once on a new checkout and the offline guarantee holds from then on (`docs/spec/gates.md`, "Pushing without a network").
|
- **No pre-push hook needs the network — once `apm install` has run.** Root `apm.yml`'s marketplace has no remote package entries, so every hook resolves locally. The guarantee is a property of a populated `apm_modules/`, not of the hook set: on a fresh clone `apm-audit-ci`'s `deployed-files-present` fails outright, and its `drift` and `config-consistency` install-replays have no cache to replay from and clone from the remote. Run `apm install` once on a new checkout and the offline guarantee holds from then on (`docs/spec/gates.md`, "Pushing without a network").
|
||||||
- **This repo and Gitea are the only source of truth.** All project state, decisions, and working conventions live here. Do not use an external memory system for this project — cached state diverges from the repo and you get a split brain. Before answering any design or architecture question, check `docs/adr/` for an existing decision.
|
- **This repo and OneDev are the only source of truth.** All project state, decisions, and working conventions live here. Do not use an external memory system for this project — cached state diverges from the repo and you get a split brain. Before answering any design or architecture question, check `docs/adr/` for an existing decision.
|
||||||
|
|
||||||
## Key documents
|
## Key documents
|
||||||
|
|
||||||
|
|||||||
@@ -133,8 +133,9 @@ than to enumerate siblings. Detail: `factory-audit/references/skill-description-
|
|||||||
_Avoid_: overlap, similar skill
|
_Avoid_: overlap, similar skill
|
||||||
|
|
||||||
**Issue**:
|
**Issue**:
|
||||||
The cross-provider term for a tracked unit of work. Gitea is this repo's canonical tracker
|
The cross-provider term for a tracked unit of work. OneDev is this repo's canonical tracker
|
||||||
(ADR-0007), but skills say "linked issue" generically rather than naming a provider.
|
(ADR-0007, superseded by ADR-0029), but skills say "linked issue" generically rather than naming
|
||||||
|
a provider.
|
||||||
_Avoid_: ticket, card, task
|
_Avoid_: ticket, card, task
|
||||||
|
|
||||||
**Family prefix**:
|
**Family prefix**:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Gitea is the exclusive issue tracker — file-based fallback removed
|
# Gitea is the exclusive issue tracker — file-based fallback removed
|
||||||
|
|
||||||
|
**Superseded by:** ADR-0029 (OneDev supersedes Gitea as this repo's canonical forge — this repo's own hosting, issue tracking, and PRs move to OneDev; `gitea/` continues to ship as a marketplace product regardless)
|
||||||
|
|
||||||
**Supersedes:** ADR-0011 (provider-agnostic issue tracker with file-based default — archived during refactoring)
|
**Supersedes:** ADR-0011 (provider-agnostic issue tracker with file-based default — archived during refactoring)
|
||||||
|
|
||||||
> **Note on the ADR-0011 number.** Every "ADR-0011" on this page means the *archived* provider-agnostic issue tracker ADR, which no longer exists in `docs/adr/` — it was removed when it was superseded, and the number 0011 was later reused for an unrelated decision, `docs/adr/0011-gitea-skill-deep-modules.md` (the gitea skill's split into deep modules). That file is not the ADR referenced below. The number is not renumbered here: these ADRs are a published record and renumbering would break every citation that already points at either one. The archived text is recoverable from git history.
|
> **Note on the ADR-0011 number.** Every "ADR-0011" on this page means the *archived* provider-agnostic issue tracker ADR, which no longer exists in `docs/adr/` — it was removed when it was superseded, and the number 0011 was later reused for an unrelated decision, `docs/adr/0011-gitea-skill-deep-modules.md` (the gitea skill's split into deep modules). That file is not the ADR referenced below. The number is not renumbered here: these ADRs are a published record and renumbering would break every citation that already points at either one. The archived text is recoverable from git history.
|
||||||
|
|||||||
@@ -414,6 +414,56 @@ and rises to a blocking ERROR the moment a resolving sibling joins it. The reaso
|
|||||||
the point of enforcement in `_add()`'s docstring in `scripts/skill-size-check.sh` and its two
|
the point of enforcement in `_add()`'s docstring in `scripts/skill-size-check.sh` and its two
|
||||||
mirrored copies, and the verdict table in `docs/spec/gates.md` states the corrected shape.
|
mirrored copies, and the verdict table in `docs/spec/gates.md` states the corrected shape.
|
||||||
|
|
||||||
|
## Amendment (2026-09-22): body-level routing targets are resolved too
|
||||||
|
|
||||||
|
The Decision section's routing-target resolver (`boundary_targets()` / `unresolved_targets()`) reads
|
||||||
|
the **description** only. A target named in the **body** — a dispatch table row, a "run X" step, both
|
||||||
|
routine in a 900-word procedure — was checked by nothing. Two real instances shipped before either
|
||||||
|
was caught: `bin/write-docs` routed twice to a deleted `to-prd` skill, and `bin/triage` told an agent
|
||||||
|
to run a nonexistent `/setup-matt-pocock-skills`. Both were found by reading, not by a gate, during
|
||||||
|
the #99 retrofit and its follow-up audit; both were fixed in `03abcff`. **The fix this amendment
|
||||||
|
records is the gate, not those two edits** (issue #124).
|
||||||
|
|
||||||
|
The body gate is a **separate, narrower** extractor (`body_targets()` /
|
||||||
|
`unresolved_body_targets()`), not the description resolver reused at wider scope. The description
|
||||||
|
resolver's sentence-level heuristics — `BOUNDARY_MARKER`, the follower test, in-sentence
|
||||||
|
corroboration — are tuned for a one-to-three-sentence routing clause and misfire on dispatch-table
|
||||||
|
and procedure prose in both directions: under-firing on a table row that carries no "do not" /
|
||||||
|
"instead", over-firing on a procedure step naming a file, a CLI verb or a config key exactly the way
|
||||||
|
a route names a skill. Retuning those heuristics for the body genre was considered and rejected as
|
||||||
|
the harder half of the problem, with a materially worse cost of getting it wrong (a body is loaded
|
||||||
|
on every invocation, so a false-positive-prone body gate is felt far more often than a
|
||||||
|
false-positive-prone description gate).
|
||||||
|
|
||||||
|
So the body gate reads **only** explicit route notation — `/name` and backticked-or-slash-prefixed
|
||||||
|
`-> name` / `→ name` — already the description gate's own unconditionally-blocking tier, and nothing
|
||||||
|
softer: no SUGGESTION tier, no bare-word forms, no corroboration. Two further restrictions, both
|
||||||
|
earned by a real corpus false positive rather than assumed up front:
|
||||||
|
|
||||||
|
- **the target must be hyphenated**, even in notation. `` `/fork` `` (`forge/SKILL.md`, citing
|
||||||
|
Claude Code's own `/fork` subagent command) and `` `/name` `` (`skill-author/SKILL.md`, a
|
||||||
|
placeholder for the skill's own name) are real corpus citations of a tool or a placeholder, not
|
||||||
|
routes, and both hard-FAILed with no escape hatch before this restriction. This is the same
|
||||||
|
"single-word targets are ordinary English" trade the Decision section already makes for the bare
|
||||||
|
form, extended to notation because the body genre has no boundary-sentence signal to fall back on;
|
||||||
|
- **a bare hyphenated word after any arrow is not notation.** The description gate's own bare-arrow
|
||||||
|
sweep (`NOTATION_ARROW`) reads ordinary process-chain prose as a route: `caveman`'s "Inline obj
|
||||||
|
prop -> new ref -> re-render." dangled to `re-render` under it. The body gate uses `ARROW_MARKED`
|
||||||
|
instead, which requires the target to be backticked or slash-prefixed — true of the one real
|
||||||
|
historical target (`` -> `to-prd` ``, confirmed against `03abcff`'s diff), so this costs no real
|
||||||
|
coverage;
|
||||||
|
- a target immediately preceded by `<` is a closing tag (`</what-to-do>`, `<supporting-info>` — this
|
||||||
|
repo's own `grill-with-docs/SKILL.md` uses these as prompt section delimiters), not `/name`
|
||||||
|
notation, and is discarded on that basis alone.
|
||||||
|
|
||||||
|
Both consumers — `scripts/skill-size-check.sh` and `factory-audit/scripts/lib-checks-skill.sh` —
|
||||||
|
call the shared functions independently over the same `known_targets()` universe the description
|
||||||
|
check already computed, so a body target folds into the existing "DID NOT RUN" INFO tier rather than
|
||||||
|
adding a second one. `tests/test-adr0020-targets.sh` pins the two live true positives, all three
|
||||||
|
guards above, and the fenced-code-block mask; the corpus-wide dangling assertion now covers body
|
||||||
|
targets the same way it already covered description ones. `docs/spec/gates.md`'s "Body-level routing
|
||||||
|
targets" section states the enforced shape in full.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39
|
**Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39
|
||||||
|
|||||||
43
docs/adr/0029-onedev-supersedes-gitea-canonical-forge.md
Normal file
43
docs/adr/0029-onedev-supersedes-gitea-canonical-forge.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# OneDev supersedes Gitea as this repo's canonical forge
|
||||||
|
|
||||||
|
**Supersedes:** ADR-0007 (Gitea as the exclusive issue tracker)
|
||||||
|
|
||||||
|
This repo's own hosting, issue tracking, and pull requests move from Gitea (`git.dev.rkdr.net`) to
|
||||||
|
OneDev (`onedev.dev.rkdr.net/Holocron`). Gitea is frozen and kept reachable read-only as a historical
|
||||||
|
archive rather than deleted, so commit messages, branch names, and ADRs that cite Gitea issue/PR
|
||||||
|
numbers (e.g. `#124`, `#140`) stay resolvable. `plugins/gitea/` is unaffected — it continues to ship
|
||||||
|
as a marketplace product for consumers with Gitea repos of their own; this decision is about what
|
||||||
|
*this* repo uses on itself, not what this repo authors and distributes.
|
||||||
|
|
||||||
|
## Considered and rejected
|
||||||
|
|
||||||
|
- **Preserving Gitea's issue/PR numbers in OneDev.** Rejected: OneDev issues and pull requests use
|
||||||
|
independent per-type counters, unlike Gitea's single shared sequence — there is no API path to
|
||||||
|
reproduce both simultaneously without a fragile create/delete padding hack. Migrated issues carry a
|
||||||
|
back-link to their original Gitea URL instead; new work uses OneDev's own numbers from the cutover
|
||||||
|
point forward.
|
||||||
|
- **Recreating historical PR objects (title/description/reviews) in OneDev.** Rejected for the
|
||||||
|
existing ~140 closed/merged PRs: `tod pr create` requires a live source branch, and Gitea already
|
||||||
|
deletes head branches on merge, so recreating them means resurrecting deleted branches from
|
||||||
|
merge-commit parent SHAs, opening throwaway PRs, and discarding them without merging (to avoid a
|
||||||
|
second, divergent merge commit alongside the mirrored git history). The git mirror already carries
|
||||||
|
every commit, message, author, and merge losslessly; the archived Gitea instance still holds the
|
||||||
|
original PR/review UI for anyone who needs it. Any PRs genuinely open and in flight at cutover time
|
||||||
|
are migrated for real, not archived.
|
||||||
|
- **Recreating Gitea's `Reviewed/*`, `Status/*`, and `Compat/Breaking` labels as new OneDev labels.**
|
||||||
|
Rejected in favor of OneDev's own out-of-the-box shape: structured `Type`/`Priority` fields (which
|
||||||
|
`Kind/*` and `Priority/*` map onto directly) plus the native three-state workflow (`Open` /
|
||||||
|
`In Progress` / `Closed`, no built-in disposition states). Disposition information that has no
|
||||||
|
native home becomes a one-line note in the migrated issue body instead of a second, parallel,
|
||||||
|
unstructured label taxonomy next to the real fields.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Gitea issue/PR numbers cited in existing commit messages and docs remain valid only as long as the
|
||||||
|
archived Gitea instance stays reachable; they are not remapped to OneDev numbers anywhere.
|
||||||
|
- The six first-party `apm.yml` plugin dependencies (`git`, `gitea`, `kyberforge`, `lint`, `core`,
|
||||||
|
`bin`), previously resolved unpinned against `git@git.dev.rkdr.net:Defame1297/holocron.git`, are
|
||||||
|
repointed to the OneDev remote as part of this migration — apm's own dependency resolution must
|
||||||
|
track the now-canonical remote, not a frozen archive.
|
||||||
|
- `AGENTS.md`'s "this repo and Gitea are the only source of truth" language is updated to name
|
||||||
|
OneDev.
|
||||||
130
docs/notes/onedev-migration-plan.md
Normal file
130
docs/notes/onedev-migration-plan.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Gitea → OneDev migration plan
|
||||||
|
|
||||||
|
Executes ADR-0029 (supersedes ADR-0007). Scope: this repo's own self-hosting only — git history,
|
||||||
|
issues, milestones, wiki (already mirrored), and this repo's own tooling config. `plugins/gitea/`
|
||||||
|
ships unchanged as a marketplace product. Historical PR objects and Gitea's exact issue/PR numbering
|
||||||
|
are explicitly not migrated (see ADR-0029's "Considered and rejected").
|
||||||
|
|
||||||
|
Source: `Defame1297/holocron` on `git.dev.rkdr.net`. Target: `Holocron` (project id 1, currently
|
||||||
|
empty) on `onedev.dev.rkdr.net`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [x] `tod` installed and configured — `~/.config/tod/config` already has `server-url` and
|
||||||
|
`access-token`; `~/.bashrc` sources `~/.config/tod/env` automatically (`set -a; . env; set +a`),
|
||||||
|
but non-interactive shells (scripts, CI, this tool) must source it explicitly per invocation.
|
||||||
|
- [x] OneDev project `Holocron` exists (`tod project get Holocron`), `codeManagement` /
|
||||||
|
`issueManagement` enabled, currently no `defaultBranch` (empty repo).
|
||||||
|
- [ ] **Gotcha to build scripts around:** `tod issue`/`tod pr` subcommands resolve their target
|
||||||
|
project from the working directory's git remote, not from `--project` (verified — `--project`
|
||||||
|
is accepted by the flag parser but ignored; commands fail outside a repo with a OneDev remote).
|
||||||
|
Every migration script step below must run from inside a local clone with a remote pointing at
|
||||||
|
`onedev.dev.rkdr.net/Holocron`.
|
||||||
|
- [ ] Create the 7 OneDev **Iterations** (milestone equivalent) manually via the OneDev web UI —
|
||||||
|
`tod` has no iteration-create command. Names, verbatim, to match Gitea milestones for clean
|
||||||
|
`--iteration` references on migrated issues:
|
||||||
|
`Governance: enforcement`, `Kyberforge basics`, `Legacy / Triage`, `Road to homelab - prep`,
|
||||||
|
`Skills & Agents`, `The great refactoring`, `Tooling`.
|
||||||
|
- [ ] Freeze Gitea: stop merging PRs there once Phase 1 starts. Solo-maintainer repo, so this is just
|
||||||
|
"don't push to `git.dev.rkdr.net` after the mirror point."
|
||||||
|
|
||||||
|
## Phase 1 — Mirror git history (lossless, zero risk)
|
||||||
|
|
||||||
|
1. `git remote add onedev https://onedev.dev.rkdr.net/Holocron` in the local clone.
|
||||||
|
2. `git push onedev refs/heads/*:refs/heads/* refs/tags/*:refs/tags/*` (explicit branch+tag push,
|
||||||
|
not `--mirror` — avoids touching any Gitea-internal refs that aren't real branches).
|
||||||
|
3. Verify: `tod project get Holocron` shows `defaultBranch` populated; HEAD of `main` on OneDev
|
||||||
|
matches Gitea `main` HEAD (`d654dca...` as of this plan).
|
||||||
|
4. This alone carries every commit, author, message, and merge losslessly — nothing else in this
|
||||||
|
plan is required for code-level fidelity.
|
||||||
|
|
||||||
|
## Phase 2 — Issues (~95 total: 81 closed + 14 open across 7 milestones, per Gitea milestone counts)
|
||||||
|
|
||||||
|
Field mapping (OneDev's out-of-the-box `Type`/`Priority` fields, no new labels created):
|
||||||
|
|
||||||
|
| Gitea label | OneDev field |
|
||||||
|
|---|---|
|
||||||
|
| `Kind/Bug` | `Type: Bug` |
|
||||||
|
| `Kind/Feature` | `Type: New Feature` |
|
||||||
|
| `Kind/Enhancement` | `Type: Improvement` |
|
||||||
|
| `Kind/Documentation`, `Kind/Testing` | `Type: Task` |
|
||||||
|
| `Kind/Security` | `Type: Bug` |
|
||||||
|
| `Priority/Critical` | `Priority: Critical` |
|
||||||
|
| `Priority/High` | `Priority: Major` |
|
||||||
|
| `Priority/Medium` | `Priority: Normal` |
|
||||||
|
| `Priority/Low` | `Priority: Minor` |
|
||||||
|
| `Reviewed/*`, `Status/*`, `Compat/Breaking` | no field/state equivalent — fold into a one-line note in the migrated body |
|
||||||
|
|
||||||
|
State mapping: Gitea `open` → OneDev `Open` (default, no action); Gitea `closed` →
|
||||||
|
`tod issue change-state <ref> Closed`. OneDev's out-of-the-box workflow only has
|
||||||
|
Open/In Progress/Closed — no disposition states, confirmed by probing the live server.
|
||||||
|
|
||||||
|
Per issue:
|
||||||
|
5. `tod issue create "<title>" --field Type=<mapped> --field Priority=<mapped> --iteration "<milestone>" --description "<body>\n\n---\nMigrated from git.dev.rkdr.net/Defame1297/holocron/issues/<N>.[\nGitea disposition: <label>.]"`
|
||||||
|
6. Replay comments via `tod issue add-comment` (low effort, worth doing for continuity).
|
||||||
|
7. `tod issue change-state <new-ref> Closed` for originally-closed issues.
|
||||||
|
8. Spot-check a sample (e.g. 5 issues across different milestones) against the Gitea source.
|
||||||
|
|
||||||
|
Numbers will not match Gitea's (accepted — ADR-0029). Author/submitter on migrated issues will be
|
||||||
|
the migration token's own OneDev account, not the original Gitea author — no CLI-exposed way to
|
||||||
|
override this (the `onBehalfOf` field exists in OneDev's issue schema but isn't exposed through `tod`;
|
||||||
|
using it would mean raw, unverified REST calls, not worth it for this scope).
|
||||||
|
|
||||||
|
## Phase 3 — Pull requests
|
||||||
|
|
||||||
|
- **Currently-open PRs only** (check at execution time: `list_pull_requests state=open`): migrate for
|
||||||
|
real via `tod pr create`, since the source branch still exists. Add description, reviewers.
|
||||||
|
- **Closed/merged historical PRs (~140 of them): explicitly skipped.** Per ADR-0029, their content
|
||||||
|
survives losslessly in the Phase 1 git mirror; the archived Gitea instance remains the record for
|
||||||
|
anyone who wants the original review thread.
|
||||||
|
|
||||||
|
## Phase 4 — Releases
|
||||||
|
|
||||||
|
Git tags (`v1.0.0`, `v2.0.0`, `v2.0.1`) carry over automatically in Phase 1. OneDev has no confirmed
|
||||||
|
first-class "Release" object with a rendered markdown body the way Gitea does — this needs a quick
|
||||||
|
check against the live server before deciding further (not yet verified in this session). Fallback if
|
||||||
|
none exists: leave the 3 release-note bodies in the archived Gitea (read-only) and optionally fold
|
||||||
|
them into a `CHANGELOG.md` in the repo for local discoverability. **Flag this to the user before
|
||||||
|
executing Phase 4** — not fully resolved.
|
||||||
|
|
||||||
|
## Phase 5 — Wiki
|
||||||
|
|
||||||
|
Nothing to do. `docs/wiki/HUMANS.md` and `docs/wiki/Home.md` already mirror the two Gitea wiki pages
|
||||||
|
in-repo (confirmed identical), and OneDev has no separate wiki feature to migrate into (confirmed: no
|
||||||
|
wiki flag on the project object, no wiki REST resource). They travel with Phase 1 automatically.
|
||||||
|
|
||||||
|
## Phase 6 — Repo self-reference updates (code changes)
|
||||||
|
|
||||||
|
9. Repoint the six first-party `apm.yml` plugin dependencies (`git`, `gitea`, `kyberforge`, `lint`,
|
||||||
|
`core`, `bin`) from `git@git.dev.rkdr.net:Defame1297/holocron.git` to the OneDev remote.
|
||||||
|
10. Update `AGENTS.md`'s routing table (currently: *"Issues, PRs, labels, milestones →
|
||||||
|
`gitea-issues`, `gitea-prs`, `gitea-labels-milestones`..."*) to route this repo's own
|
||||||
|
issue/PR operations to the OneDev/tod skills instead (`using-tod` as the catch-all, plus
|
||||||
|
`work-on-issue`, `work-on-pull-request`, `submit-issue-work`, `submit-pull-request-work`). This
|
||||||
|
needs a deliberate mapping pass, not a mechanical find-replace — the tod skillset is
|
||||||
|
workflow-shaped, not CRUD-shaped like the gitea skills it replaces.
|
||||||
|
11. Update local `origin` remote to point at OneDev; rename the old one (e.g. `git remote rename
|
||||||
|
origin gitea-archive`) rather than deleting it.
|
||||||
|
12. Run `apm install` against the repointed remote and verify it resolves cleanly.
|
||||||
|
13. Sweep `README.md` and any other doc prose that names Gitea as *this repo's own* host (separate
|
||||||
|
from `plugins/gitea/`'s own product documentation, which is unaffected).
|
||||||
|
|
||||||
|
## Phase 7 — Freeze and archive Gitea
|
||||||
|
|
||||||
|
14. Set the Gitea repository to read-only/archived via the Gitea web UI (no MCP tool exposes this —
|
||||||
|
manual step).
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
|
||||||
|
- [ ] `tod project get Holocron` → `defaultBranch: main`, HEAD SHA matches Gitea's `main`.
|
||||||
|
- [ ] Issue count on OneDev matches Gitea's ~95 (open + closed).
|
||||||
|
- [ ] `apm install` succeeds from a fresh clone against the new remote.
|
||||||
|
- [ ] Pre-commit hooks (`pre-commit run --all-files`) pass in a fresh OneDev clone.
|
||||||
|
- [ ] Gitea repo is read-only; a test push to it fails as expected.
|
||||||
|
|
||||||
|
## Explicitly out of scope (deferred, per earlier decisions)
|
||||||
|
|
||||||
|
- `.onedev-buildspec.yml` / CI setup — no Gitea Actions exist today to migrate; separate follow-up
|
||||||
|
task via the `edit-build-spec` skill.
|
||||||
|
- Sunsetting `plugins/gitea/` as a marketplace product — agreed as a *later* phase, not part of this
|
||||||
|
migration.
|
||||||
@@ -360,6 +360,68 @@ at a real sentence end. **Read the second bullet forward as well as back:** a ba
|
|||||||
after a dotted filename is now extracted, resolved, and a blocking ERROR when it dangles, where the
|
after a dotted filename is now extracted, resolved, and a blocking ERROR when it dangles, where the
|
||||||
same clause used to pass unchecked in silence.
|
same clause used to pass unchecked in silence.
|
||||||
|
|
||||||
|
### Body-level routing targets (issue #124)
|
||||||
|
|
||||||
|
Everything above resolves targets named in the **description** — the one field `boundary_targets()`
|
||||||
|
and `unresolved_targets()` read. Until issue #124, a target named in the **body** — a dispatch table
|
||||||
|
or a "run X" step, both routine in a 900-word procedure — was checked by nothing: `bin/write-docs`
|
||||||
|
routed twice to a deleted `to-prd` skill and `bin/triage` told an agent to run a nonexistent
|
||||||
|
`/setup-matt-pocock-skills`, and both were found by reading, not by any gate (fixed in `03abcff`;
|
||||||
|
the gate itself is the ask this section documents).
|
||||||
|
|
||||||
|
`body_targets()` / `unresolved_body_targets()` (`lib-boundary-resolver.sh`) are a **separate,
|
||||||
|
narrower** extractor, not a reuse of the description one at wider scope. A body is dispatch-table
|
||||||
|
and procedure prose, not a one-to-three-sentence routing clause, so `BOUNDARY_MARKER`, the follower
|
||||||
|
test and in-sentence corroboration all misfire on it in both directions — under-firing on a table
|
||||||
|
row that carries no "do not"/"instead", over-firing on a procedure step that names a file, a CLI verb
|
||||||
|
or a config key exactly the way a route names a skill. So the body gate reads only **notation**,
|
||||||
|
already the description gate's own "always blocks" tier, and nothing softer:
|
||||||
|
|
||||||
|
| Form | Pattern | Requires |
|
||||||
|
|---|---|---|
|
||||||
|
| `/name` | `NOTATION_SLASH` | a hyphen in `name`; not preceded by `<` |
|
||||||
|
| `-> name` / `→ name` | `ARROW_MARKED` | the name **backticked or slash-prefixed** — `NOTATION_ARROW`'s bare form is not used here |
|
||||||
|
|
||||||
|
Both constraints exist because the corpus, not intuition, said so — each is a real false positive
|
||||||
|
this gate produced once and was narrowed to remove:
|
||||||
|
|
||||||
|
- **No SUGGESTION tier, no continuation, one arrow per target.** Both forms are notation, and
|
||||||
|
notation is unconditionally blocking — there is no ambiguous prose reading left to soften, so
|
||||||
|
there is nothing to report at a softer tier. `CONT_MARKED`/`CONT_ANY` are not run either, so
|
||||||
|
`-> \`a\` or \`b\`` resolves only `a`, same as the one-arrow-one-target convention **#107** already
|
||||||
|
states for descriptions — enforced here by construction instead of by a second SUGGESTION.
|
||||||
|
- **A bare hyphenated word after any arrow is not notation here.** `NOTATION_ARROW` (used for the
|
||||||
|
description gate's own `Not X -> name` sweep) matches a bare `-> name` unconditionally, and a body
|
||||||
|
is full of ordinary arrow prose that is not a route: `caveman`'s own `Inline obj prop -> new ref ->
|
||||||
|
re-render.` read as a dangling route to `re-render` under that pattern. `ARROW_MARKED` requires the
|
||||||
|
target to be backticked or slash-prefixed, which the one real historical target (`` -> `to-prd` ``,
|
||||||
|
per `03abcff`'s diff) already was, so the narrowing costs no real coverage.
|
||||||
|
- **A single-word target is discarded, even in notation.** `` `/fork` `` (`forge/SKILL.md`,
|
||||||
|
contrasting `context: fork` with Claude Code's own `/fork` subagent command) and `` `/name` ``
|
||||||
|
(`skill-author/SKILL.md`, "the user types `/name`" — a placeholder for the skill's *own* name, not
|
||||||
|
a route) are both real corpus citations of a tool or a placeholder, not routes, and both hard-FAILed
|
||||||
|
with no escape hatch before the hyphen requirement was added. This is a real, accepted recall loss:
|
||||||
|
a body dispatch entry to a genuinely single-word skill (`forge`, `research`, `triage`, `tdd`,
|
||||||
|
`prototype`) cannot be checked through this extractor. Same trade the description gate already
|
||||||
|
makes for the *bare* form (the known gap above), extended here to notation as well because the body
|
||||||
|
genre has no boundary-sentence signal to lean on instead.
|
||||||
|
- **A name immediately preceded by `<` is a closing tag, not a route.** `grill-with-docs/SKILL.md`
|
||||||
|
uses XML-style prompt delimiters (`<what-to-do>...</what-to-do>`, `<supporting-info>...`), and
|
||||||
|
`</what-to-do>` is indistinguishable from `/what-to-do` notation by every other rule above. No route
|
||||||
|
is ever written directly after `<` in this corpus, so the guard costs nothing else.
|
||||||
|
|
||||||
|
Fenced code blocks are masked first (`mask_fenced()`, the same masking `gotcha_stats()` and the
|
||||||
|
references/-pointer check already use): an illustrative ` ```/some-skill``` ` in `skill-author` or
|
||||||
|
`factory-audit` — which document this very notation — is not a live dispatch entry.
|
||||||
|
|
||||||
|
Both consumers agree by construction: `scripts/skill-size-check.sh` and
|
||||||
|
`factory-audit/scripts/lib-checks-skill.sh` each call `body_targets()`/`unresolved_body_targets()`
|
||||||
|
independently, over the same `known_targets()` universe the description check already computed, so
|
||||||
|
the "DID NOT RUN" INFO tier covers both description and body targets in one message rather than
|
||||||
|
firing twice. `tests/test-adr0020-targets.sh`'s "body-level routing targets (issue #124)" section
|
||||||
|
pins both the two live true positives and every guard above; the corpus-wide dangling assertion
|
||||||
|
(`EXPECTED_DANGLING`) covers body targets the same way it already covered description ones.
|
||||||
|
|
||||||
### SUGGESTION-only checks
|
### SUGGESTION-only checks
|
||||||
|
|
||||||
Deterministic to measure, judgment to act on:
|
Deterministic to measure, judgment to act on:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: >
|
|||||||
fixes -> agent-author.
|
fixes -> agent-author.
|
||||||
allowed-tools: Bash Read
|
allowed-tools: Bash Read
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.4"
|
version: "1.0.5"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- agentskills-home
|
- agentskills-home
|
||||||
|
|||||||
@@ -898,6 +898,103 @@ def unresolved_targets(description, known):
|
|||||||
reported.add(name)
|
reported.add(name)
|
||||||
return sorted(blocking), sorted(reported - blocking)
|
return sorted(blocking), sorted(reported - blocking)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Body-level routing targets (issue #124) -------------------------------
|
||||||
|
# boundary_targets()/unresolved_targets() above are tuned for a description:
|
||||||
|
# one to three sentences, where BOUNDARY_MARKER, the follower test and
|
||||||
|
# in-sentence corroboration all exist to tell a routing sentence apart from
|
||||||
|
# ordinary prose about a hyphenated tool. A SKILL.md body is a different
|
||||||
|
# genre — up to 900 words of procedure and dispatch tables — where those same
|
||||||
|
# heuristics would misfire in both directions: a dispatch table rarely reads
|
||||||
|
# as a "boundary sentence" (under-fire), and a procedure step naming a file, a
|
||||||
|
# CLI verb or a config key looks exactly like a route (over-fire). Retuning
|
||||||
|
# the sentence-level heuristics for that genre is the hard half of this gate
|
||||||
|
# and is deliberately NOT attempted here — see the issue for why.
|
||||||
|
#
|
||||||
|
# So the body extractor takes the narrow route instead: only two EXPLICIT
|
||||||
|
# ROUTE NOTATION forms count, and each is measured against the real corpus
|
||||||
|
# (39 SKILL.md bodies) rather than assumed correct from the description gate's
|
||||||
|
# behaviour — a body is dense with prose that LOOKS like this notation and
|
||||||
|
# genuinely is not, in ways a one-to-three-sentence description never is:
|
||||||
|
#
|
||||||
|
# * ARROW_MARKED — `-> name` / `→ name` where the target is BACKTICKED or
|
||||||
|
# slash-prefixed (MARKED_TARGET). NOT NOTATION_ARROW, which matches a bare
|
||||||
|
# hyphenated word after any arrow: the corpus's own process-chain prose
|
||||||
|
# ("Inline obj prop -> new ref -> re-render.", caveman/SKILL.md) reads as
|
||||||
|
# a route under that pattern and does not under this one, because a
|
||||||
|
# process chain is never itself backticked or slash-prefixed. The one
|
||||||
|
# live true positive this was filed over, write-docs' "-> `to-prd`", IS
|
||||||
|
# backticked (03abcff's diff shows the original), so ARROW_MARKED still
|
||||||
|
# catches it losslessly.
|
||||||
|
# * NOTATION_SLASH — free-standing `/name`, unconditionally, the same
|
||||||
|
# pattern the description gate sweeps with. Two guards narrow it for body
|
||||||
|
# text specifically, each one measured against a real corpus false
|
||||||
|
# positive rather than hypothesised:
|
||||||
|
# - a name with NO hyphen is discarded. A real dispatch entry in this
|
||||||
|
# corpus always names a multi-word skill (`to-prd`,
|
||||||
|
# `setup-matt-pocock-skills`); a single bare or backticked word after
|
||||||
|
# a `/` is prose citing a CLI command, a Claude Code built-in or a
|
||||||
|
# placeholder — `` `/fork` `` (forge/SKILL.md, contrasting
|
||||||
|
# `context: fork` with Claude Code's own /fork subagent command) and
|
||||||
|
# `` `/name` `` (skill-author/SKILL.md, "the user types `/name`" —
|
||||||
|
# `name` is a placeholder for the skill's OWN name, not a route) are
|
||||||
|
# both real corpus hits this guard removes. This is a real recall
|
||||||
|
# loss — `/forge`, `/triage` and other single-word skill names are
|
||||||
|
# unreachable through this extractor — accepted deliberately, the
|
||||||
|
# same "start narrow" trade the issue itself recommends.
|
||||||
|
# - a name immediately preceded by `<` is discarded. An XML/HTML-style
|
||||||
|
# closing tag used as a prompt section delimiter — `</what-to-do>`,
|
||||||
|
# `</supporting-info>` (grill-with-docs/SKILL.md) — is indistinguishable
|
||||||
|
# from `/what-to-do` notation by every other rule in this pattern; no
|
||||||
|
# route is ever written directly after `<` in this corpus, so the
|
||||||
|
# guard costs nothing else.
|
||||||
|
#
|
||||||
|
# Every surviving hit is unconditionally blocking: both forms are explicit
|
||||||
|
# notation with the ambiguous single-word and closing-tag readings already
|
||||||
|
# removed, so there is no SUGGESTION tier here — that tier exists to soften
|
||||||
|
# an ambiguous prose form, and none is admitted at this point.
|
||||||
|
#
|
||||||
|
# No conjunction continuation (CONT_*) either: `-> \`to-prd\` or \`grill-me\``
|
||||||
|
# resolves only `to-prd`, the same one-arrow-one-target convention
|
||||||
|
# multi_target_arrow_clauses() already enforces on descriptions (issue #107),
|
||||||
|
# applied here by construction instead of by a second SUGGESTION.
|
||||||
|
def body_targets(body):
|
||||||
|
"""Every /name or -> `name` routing target named in a SKILL.md body.
|
||||||
|
|
||||||
|
Fenced code blocks are masked first, the same way gotcha_stats() and
|
||||||
|
missing_reference_pointers() mask them: a ```-fenced example quoting
|
||||||
|
`/some-skill` or `-> \`some-skill\`` as illustration is not a live
|
||||||
|
dispatch entry, and skill-author/factory-audit — which document this
|
||||||
|
very notation — are exactly the skills most likely to carry one.
|
||||||
|
"""
|
||||||
|
masked = mask_fenced(body)
|
||||||
|
names = set()
|
||||||
|
for match in NOTATION_SLASH.finditer(masked):
|
||||||
|
if match.start() > 0 and masked[match.start() - 1] == '<':
|
||||||
|
continue # </closing-tag>, not /route-notation
|
||||||
|
name = match.group(1)
|
||||||
|
if '-' in name:
|
||||||
|
names.add(name)
|
||||||
|
for match in ARROW_MARKED.finditer(masked):
|
||||||
|
name, _, _ = _first(match)
|
||||||
|
if name and '-' in name:
|
||||||
|
names.add(name)
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def unresolved_body_targets(body, known):
|
||||||
|
"""Body routing targets (notation only) that resolve to nothing.
|
||||||
|
|
||||||
|
Unlike unresolved_targets(), this has one outcome, not two: every name
|
||||||
|
body_targets() finds is already route notation, and notation always
|
||||||
|
blocks. `known` is the resolved universe from known_targets(); passing an
|
||||||
|
empty set is not meaningful — callers check for that first and decline
|
||||||
|
out loud instead, exactly as they do for the description gate.
|
||||||
|
"""
|
||||||
|
return sorted(name for name in body_targets(body)
|
||||||
|
if normalize_target(name) not in known)
|
||||||
|
|
||||||
|
|
||||||
# --- Frontmatter ----------------------------------------------------------
|
# --- Frontmatter ----------------------------------------------------------
|
||||||
# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading
|
# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading
|
||||||
# blank line, trailing whitespace after either `---`, or CRLF line endings all
|
# blank line, trailing whitespace after either `---`, or CRLF line endings all
|
||||||
|
|||||||
@@ -443,15 +443,23 @@ elif desc:
|
|||||||
# derived from this script's own path, and — when an authoring root exists — it
|
# derived from this script's own path, and — when an authoring root exists — it
|
||||||
# never reads a deployed .claude/ tree, so a fresh clone and a machine that has
|
# never reads a deployed .claude/ tree, so a fresh clone and a machine that has
|
||||||
# run `apm install` return the same verdict. See the shared resolver's header.
|
# run `apm install` return the same verdict. See the shared resolver's header.
|
||||||
if desc:
|
routing_targets = boundary_targets(desc) if desc else []
|
||||||
routing_targets = boundary_targets(desc)
|
# Body-level targets (issue #124): notation only (`/name`, `-> name`), so
|
||||||
known = known_targets(skill_dir) if routing_targets else set()
|
# every hit is unconditionally blocking — see the shared resolver's
|
||||||
if routing_targets and not known:
|
# body_targets() header for why the description gate's SUGGESTION tier has
|
||||||
|
# no counterpart here. Read regardless of `desc`: a body dispatch table can
|
||||||
|
# carry a broken route even when the description carries none.
|
||||||
|
body_routing_targets = body_targets(body)
|
||||||
|
if routing_targets or body_routing_targets:
|
||||||
|
known = known_targets(skill_dir)
|
||||||
|
if not known:
|
||||||
|
unchecked = sorted(set(routing_targets) | set(body_routing_targets))
|
||||||
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
|
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
|
||||||
f"determined for this path (no authoring root above it, no apm package "
|
f"determined for this path (no authoring root above it, no apm package "
|
||||||
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
|
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
|
||||||
f"tree). Unchecked target(s): {', '.join(routing_targets)}")
|
f"tree). Unchecked target(s): {', '.join(unchecked)}")
|
||||||
elif routing_targets:
|
else:
|
||||||
|
if routing_targets:
|
||||||
# blocking vs reported: a target only earns a FAIL when it is written in
|
# blocking vs reported: a target only earns a FAIL when it is written in
|
||||||
# route notation or its own sentence corroborates it by naming another
|
# route notation or its own sentence corroborates it by naming another
|
||||||
# target that resolves. See the shared resolver's CORROBORATION note.
|
# target that resolves. See the shared resolver's CORROBORATION note.
|
||||||
@@ -476,6 +484,15 @@ if desc:
|
|||||||
resolved = [t for t in routing_targets if normalize_target(t) in known]
|
resolved = [t for t in routing_targets if normalize_target(t) in known]
|
||||||
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
|
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
|
||||||
f"{', '.join(resolved) if resolved else '(none)'}")
|
f"{', '.join(resolved) if resolved else '(none)'}")
|
||||||
|
unresolved_body = unresolved_body_targets(body, known)
|
||||||
|
for target in unresolved_body:
|
||||||
|
fail(f"body routes to '{target}' (`/{target}` or `-> {target}` notation), which "
|
||||||
|
f"resolves to no skill or agent in this monorepo, in this package, or in a "
|
||||||
|
f"package it declares in apm.yml dependencies.apm — a dispatch table or "
|
||||||
|
f"\"run X\" step naming a non-existent target sends the agent nowhere")
|
||||||
|
if body_routing_targets and not unresolved_body:
|
||||||
|
ok(f"{len(body_routing_targets)} of {len(body_routing_targets)} body routing "
|
||||||
|
f"target(s) resolve: {', '.join(body_routing_targets)}")
|
||||||
|
|
||||||
# Body unfilled placeholders
|
# Body unfilled placeholders
|
||||||
fill_matches = PLACEHOLDER_RE.findall(body)
|
fill_matches = PLACEHOLDER_RE.findall(body)
|
||||||
|
|||||||
@@ -444,9 +444,14 @@ for path in files:
|
|||||||
"\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second))
|
"\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second))
|
||||||
|
|
||||||
targets = boundary_targets(desc)
|
targets = boundary_targets(desc)
|
||||||
if targets:
|
# Body-level targets (issue #124): notation only (`/name`, `-> name`), so
|
||||||
|
# every hit is unconditionally blocking — see body_targets()'s header for
|
||||||
|
# why the description gate's SUGGESTION tier has no counterpart here.
|
||||||
|
body_route_names = body_targets(body)
|
||||||
|
if targets or body_route_names:
|
||||||
known = known_targets(skill_dir)
|
known = known_targets(skill_dir)
|
||||||
if known:
|
if known:
|
||||||
|
if targets:
|
||||||
blocking, reported = unresolved_targets(desc, known)
|
blocking, reported = unresolved_targets(desc, known)
|
||||||
for target in blocking:
|
for target in blocking:
|
||||||
error("%s: description routes to '%s', which does not resolve to a skill "
|
error("%s: description routes to '%s', which does not resolve to a skill "
|
||||||
@@ -462,12 +467,19 @@ for path in files:
|
|||||||
"so this is equally likely to be a tool, a file format or an English "
|
"so this is equally likely to be a tool, a file format or an English "
|
||||||
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
|
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
|
||||||
"be checked properly." % (path, target, target, target))
|
"be checked properly." % (path, target, target, target))
|
||||||
|
for target in unresolved_body_targets(body, known):
|
||||||
|
error("%s: body routes to '%s' (`/%s` or `-> %s` notation), which does not "
|
||||||
|
"resolve to a skill or agent in this monorepo, in this package, or in a "
|
||||||
|
"package it declares in apm.yml dependencies.apm (ADR-0020). A dispatch "
|
||||||
|
"table or \"run X\" step naming a non-existent target sends the agent "
|
||||||
|
"nowhere." % (path, target, target, target))
|
||||||
else:
|
else:
|
||||||
|
unchecked = sorted(set(targets) | set(body_route_names))
|
||||||
info("%s: boundary-target resolution DID NOT RUN — no skill universe "
|
info("%s: boundary-target resolution DID NOT RUN — no skill universe "
|
||||||
"could be determined for this path (no authoring root above it, no "
|
"could be determined for this path (no authoring root above it, no "
|
||||||
"apm package root, no declared apm dependencies, no deployed "
|
"apm package root, no declared apm dependencies, no deployed "
|
||||||
".claude/ or .agents/ tree). Unchecked target(s): %s"
|
".claude/ or .agents/ tree). Unchecked target(s): %s"
|
||||||
% (path, ", ".join(targets)))
|
% (path, ", ".join(unchecked)))
|
||||||
|
|
||||||
sys.exit(1 if failed else 0)
|
sys.exit(1 if failed else 0)
|
||||||
SSC_CHECKS_PY
|
SSC_CHECKS_PY
|
||||||
|
|||||||
@@ -961,6 +961,117 @@ else
|
|||||||
fail "an attributive target naming a REAL skill produced output (exit $ATTR_RC): $ATTR_OUT"
|
fail "an attributive target naming a REAL skill produced output (exit $ATTR_RC): $ATTR_OUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Body-level routing targets (issue #124)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# boundary_targets()/unresolved_targets() are the DESCRIPTION gate, exercised
|
||||||
|
# above. body_targets()/unresolved_body_targets() are the separate, narrower
|
||||||
|
# extractor added for issue #124: a SKILL.md body is dispatch-table and
|
||||||
|
# procedure prose, not a one-to-three-sentence routing clause, so the body
|
||||||
|
# extractor takes only /name and -> `name` NOTATION (never the bare-prose
|
||||||
|
# forms the description gate also reads), and even within notation, a target
|
||||||
|
# must be hyphenated and must not be a `<tag` immediately before the `/`.
|
||||||
|
# Every fixture is built inside a real plugin tree (BODY_ROOT), same as
|
||||||
|
# section 2 above, so the resolver actually runs instead of declining.
|
||||||
|
echo ""
|
||||||
|
echo "--- body-level routing targets (issue #124) ---"
|
||||||
|
BODY_ROOT="$TMPDIR_T/body"
|
||||||
|
write_skill "$BODY_ROOT/plugins/p/.apm/skills/sibling-skill" sibling-skill \
|
||||||
|
"Use when doing the other thing. Do not use for anything else."
|
||||||
|
|
||||||
|
# write_skill_body <skill-dir> <name> <body>
|
||||||
|
write_skill_body() {
|
||||||
|
mkdir -p "$1"
|
||||||
|
{
|
||||||
|
echo "---"
|
||||||
|
echo "name: $2"
|
||||||
|
echo "description: Use when doing the thing. Do not use for anything else."
|
||||||
|
echo "metadata:"
|
||||||
|
echo " version: \"1.0.0\""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
printf '%s\n' "$3"
|
||||||
|
} > "$1/SKILL.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# body_case <slug> <expect: silent|errors> <needle> <body>
|
||||||
|
body_case() {
|
||||||
|
local slug="$1" mode="$2" needle="$3" body="$4" out status=0
|
||||||
|
write_skill_body "$BODY_ROOT/plugins/p/.apm/skills/$slug" "$slug" "$body"
|
||||||
|
set +e
|
||||||
|
out="$(bash "$HOOK" "$BODY_ROOT/plugins/p/.apm/skills/$slug/SKILL.md" 2>&1)"
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$out" == *"DID NOT RUN"* ]]; then
|
||||||
|
fail "body \"$body\" — the resolver declined, so this case asserts nothing about extraction: $out"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
case "$mode" in
|
||||||
|
silent)
|
||||||
|
if [[ $status -eq 0 && -z "$out" ]]; then
|
||||||
|
pass "not a dangling body target: \"$body\""
|
||||||
|
else
|
||||||
|
fail "body \"$body\" (exit $status, output: ${out:-<empty>})"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
errors)
|
||||||
|
if [[ $status -ne 0 && "$out" == *"$needle"* ]]; then
|
||||||
|
pass "dangling body target caught: \"$body\""
|
||||||
|
else
|
||||||
|
fail "body \"$body\" should have ERRORed with $needle (exit $status, output: ${out:-<empty>})"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The two live true positives the issue was filed over, at fixture scale:
|
||||||
|
# a bare/backticked `/name` and a backticked `-> \`name\``.
|
||||||
|
body_case body-slash-dangling errors "body routes to 'no-such-body-skill'" \
|
||||||
|
"Run \`/no-such-body-skill\` if the config is missing."
|
||||||
|
body_case body-slash-resolves silent "" \
|
||||||
|
"Run \`/sibling-skill\` if the config is missing."
|
||||||
|
body_case body-arrow-dangling errors "body routes to 'no-such-arrow-body'" \
|
||||||
|
"- User wants X -> \`no-such-arrow-body\`"
|
||||||
|
body_case body-arrow-resolves silent "" \
|
||||||
|
"- User wants X -> \`sibling-skill\`"
|
||||||
|
|
||||||
|
# No conjunction continuation: only the FIRST target after an arrow is ever
|
||||||
|
# read, so a dangling SECOND name is silently uncounted rather than reported
|
||||||
|
# — the same one-arrow-one-target convention issue #107 enforces on
|
||||||
|
# descriptions (there, at SUGGESTION tier; here, by construction, since the
|
||||||
|
# body gate has no SUGGESTION tier at all).
|
||||||
|
body_case body-arrow-no-continuation silent "" \
|
||||||
|
"- User wants X -> \`sibling-skill\` or \`no-such-uncounted-target\`"
|
||||||
|
|
||||||
|
# The single-word guard: a real corpus false positive removed by requiring a
|
||||||
|
# hyphen. `` `/fork` `` (forge/SKILL.md) and `` `/name` `` (skill-author/SKILL.md)
|
||||||
|
# are both single-word citations of a tool or a placeholder, not routes, and
|
||||||
|
# both would otherwise have hard-FAILed with no escape hatch.
|
||||||
|
body_case body-slash-single-word-guard silent "" \
|
||||||
|
"See \`/fork\` for how the two differ."
|
||||||
|
|
||||||
|
# The closing-tag guard: an XML/HTML-style section delimiter used as a prompt
|
||||||
|
# marker (grill-with-docs/SKILL.md's <what-to-do>...</what-to-do>) is
|
||||||
|
# indistinguishable from /route notation by every other rule in the pattern —
|
||||||
|
# a `<` immediately before the `/` is the one signal that tells them apart.
|
||||||
|
body_case body-closing-tag-guard silent "" \
|
||||||
|
$'<what-to-do>\nDo the thing.\n</what-to-do>'
|
||||||
|
|
||||||
|
# The bare-arrow guard: NOTATION_ARROW (bare hyphenated word after any arrow)
|
||||||
|
# is deliberately NOT used here, only ARROW_MARKED (backticked or
|
||||||
|
# slash-prefixed). caveman/SKILL.md's own process chain, "Inline obj prop ->
|
||||||
|
# new ref -> re-render.", is real corpus prose this guard exists for — an
|
||||||
|
# unbacked, unresolvable name after an arrow must stay silent, not become a
|
||||||
|
# hard-blocking dangling-target FAIL with no suppression mechanism.
|
||||||
|
body_case body-arrow-bare-not-notation silent "" \
|
||||||
|
"Reproduce -> minimise -> no-such-bare-chain-target."
|
||||||
|
|
||||||
|
# Fenced code blocks are masked, same as gotcha_stats() and
|
||||||
|
# missing_reference_pointers() mask them: an illustrative example is not a
|
||||||
|
# live dispatch entry.
|
||||||
|
body_case body-fenced-example silent "" \
|
||||||
|
$'```\nRun /no-such-fenced-skill instead.\n```'
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
[[ $FAIL -eq 0 ]]
|
[[ $FAIL -eq 0 ]]
|
||||||
|
|||||||
Reference in New Issue
Block a user