mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
7b8cdecd5e
* fix(loader): a surface that exists on disk was never read, and the report named it anyway
The loader read the repo-root `skills/` **or** the project-level
`.claude/skills/` — never both — and materialized whichever it picked under the
SAME canonical `<materializeRoot>/<surface>/…` key. Two real files, one key: the
loser was never opened, and the winner's bytes sat under the loser's name.
The vendor settles which one the harness loads: BOTH, in two namespaces.
> Plugin skills use a `plugin-name:skill-name` namespace, so they can't
> conflict with other levels. For example, `my-plugin/skills/deploy/SKILL.md`
> becomes `/my-plugin:deploy` and loads alongside a `deploy` skill in your
> project's `.claude/skills/`.
> — https://code.claude.com/docs/en/skills § "Where skills live"
So "pick one" was never a tie-break to get right; it was a question with no
answer, asked because the key shape forced one. The fix removes the question:
every scope present is read, and a scope's key prefix is DERIVED FROM THE SCOPE,
so two files can no longer claim one key — there is no ordering, no
"if already taken", and no last-write-wins left to get wrong.
Measured (36 SHA-pinned public repos, 2026-08-18):
* 573 → 493 surface files that exist on disk and are never opened.
The 80 recovered are the shadowed ones: nyldn/claude-octopus 71 → 0,
hyhmrright/brooks-lint 8 → 0, thedotmack/claude-mem 1 → 0.
* The 493 that remain are a DIFFERENT, pre-existing gap, unchanged by this
commit: directory symlinks under `skills/`, which `entryOf` deliberately
does not descend (the ELOOP containment) but Claude Code does follow.
* nyldn/claude-octopus ships each skill twice — 61 under `skills/`, 57 under
`.claude/skills/`, 50 names in both, and ALL 50 pairs differ. One pair is
vendored as a true-positive fixture.
* vigiles' own repo: 6 skills → 19, lethal-trifecta units 4 → 12. Its own
audit was blind to two thirds of its own machine.
* Grades moved on 1 of 36: brooks-lint C(70) → B(82). NO finding was deleted
(6 before, 6 after) and the COUNT of dangerous units rose (6 → 7); the
Safety ring is a ratio, and this moves its denominator from 6 to the real
12. See the report for the full justification.
Also fixed, because reading both scopes caused it: a name no longer identifies a
surface, so `description-overlap` would have printed `skills "dup" and "dup"` —
50 times on claude-octopus. Overlap labels now carry the real path.
SUBTRACTS: the `root` | `user` | `none` variants of `SurfaceSource` (the either/or
is gone, so "we picked the wrong one" is unrepresentable), the duplicated
precedence rules in `scan-files.ts` (the decision now lives once, IO-free), and
one net lint warning in the two touched files (6 → 4).
Gates: npx vitest run · npm run lint · npx prettier --check . · npm run api:check
· npm run build — all clean, in that order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd
* chore: escape the raw NUL bytes on this branch too
* chore: regenerate the browser-parity fixture for the two-scope disambiguation
The overlap finding now names each skill by PATH, not by bare name — which is
the point of the two-scope work: with root skills/ and .claude/skills both
present, a bare name does not identify a surface. Intentional engine change,
regenerated per the parity test's own instruction.
* chore: regenerate generated.d.ts for the merged tree
Единственный конфликт мёрджа был одной строкой в СГЕНЕРИРОВАННОМ файле — счётчик файлов
проекта, 388 против 387. Разрешено не выбором стороны, а регенерацией: у слитого дерева
счёт 389, то есть ОБЕ стороны были неверны для него. Выбор любой из них дал бы зелёный
мёрдж с неправдой в артефакте.
* fix: four defects found by dogfooding vigiles on a real consumer repo
Все четыре найдены за один день догфуда на `zernie/mine` (43 скилла, 48 харнесс-файлов),
каждый — с замером, а не с рассуждением.
1. `exclude` не доходил до прохода по markdown-рефам
`findDocRefs({ basePath })` вызывался без `ignore`, хотя поле `exclude` документировано
ровно под этот случай («vendored or benchmark fixtures the repo's own lint shouldn't
police»). Замер: 9 битых рефов, 8 из них внутри каталога, явно перечисленного в
`exclude`, и все 9 репортились. Следствие дороже причины: выйти в 0 стало нечем, поэтому
шаг пометили `continue-on-error: true` — а lint, чей код возврата выбрасывают, не гейтит
ничего, и рядом выросли самодельные CI-шаги, чтобы гейтить вместо него. Один непереданный
аргумент — вся эта цепочка. После починки: 9 → 0.
2. Рефы искались ТЕКСТОМ, а не разбором
`/\b(enforce|file|cmd|ref)\(\s*["']…/` — четыре из шести входов классифицировались
неверно (замер 19.08): `ctx.file("OUT")` (метод чужого объекта — живой ложный
срабатыв в реальной репе), `obj.cmd(...)`, `// cmd(...)` в комментарии,
`'cmd("x")'` внутри строки. `\b` спокойно стоит после точки, а у регулярки вообще нет
понятия комментария и строкового литерала. Разбор делает все четыре НЕВЫРАЗИМЫМИ: AST
отдаёт только вызов, у которого callee — голый идентификатор. `typescript` и так
runtime-зависимость пакета — парсер лежал в коробке всё это время.
3. Раннер скриптов был строгой очередью
`files.forEach(... spawnSync ...)` со `stdio: "inherit"`. Замер на 48 харнессах:
270s → 77s (3.5×), результат ПОБАЙТОВО тот же (84 passed, 1 with 0 checks, 7 failed).
Пул по умолчанию выводится из `entry`, а не из нового флага: `test` не передаёт entry и
разъезжается по ядрам (файлы бесплатны и детерминированы по построению — мок-модель, без
API-ключа), `eval` передаёт и остаётся на 1, потому что параллель там — это одновременные
оплачиваемые вызовы модели. Вывод буферизуется на ребёнка: с `inherit` 48 отчётов
шинкуются друг о друга, и это настоящая цена параллели, а не сам цикл.
4. Путь агента в находке брался по ИМЕНИ
`new Map(agents.map(a => [a.name, a.path]))` оставляет последнюю запись, поэтому при
`agents/foo.md` и `.claude/agents/foo.md` (разные неймспейсы у Claude) находка о первом
репортилась против файла второго. Находка lethal-trifecta, указывающая не на тот файл,
хуже отсутствующей: она отправляет читателя проверять невиновный код. До полной
scope-qualified идентичности (правка крупнее, `finding.name` несёт только голое имя)
неоднозначное имя даёт ПУСТОЙ путь вместо произвольного.
Плюс: все 10 джобов в 6 воркфлоу этого репозитория были без `timeout-minutes` — то есть
зависший джоб шёл до шестичасового потолка GitHub. Бонды ~3-4× ЗАМЕРЕННОЙ длительности
собственных прогонов репы, замер процитирован над каждой строкой.
Тесты: два новых на пул, обе половины. ⚠️ Первая версия фикстуры считала маркеры, которые
только СОЗДАВАЛИСЬ, — то есть «сколько стартовало всего», а это достигает N и при строго
последовательном прогоне; тест проходил по причине, не связанной с параллелью. Исправлено
освобождением слота на выходе. Обе мутации (форсировать serial · распараллелить eval)
валят каждая свой ассерт, каждая с доказательством, что патч лёг.
Гейты: `npm run lint` 0 errors · `npx prettier --check .` чисто · `npm run api:check` без
дрейфа · `npm run build` · `npx vitest run` — падает только пре-существующий
`scripts/release-preset.test.ts` (не находит `@semantic-release/commit-analyzer`, объявленный
в package.json, но не установленный в этом окружении; на CI со свежей установкой зелено).
* docs: record what each of the four defects was, in the pages that own them
comparison.md — что СЧИТАЕТСЯ рефом теперь (вызов, у которого callee — голый идентификатор,
а первый аргумент строковый литерал) и почему три формы больше не рефы: метод чужого объекта,
упоминание в комментарии, строка с текстом вызова. Плюс область: проход читает `**/*.md` и
теперь уважает `exclude`, чего не делал — из-за чего репозиторий с вендоренной чужой
разметкой не мог получить чистый lint вообще.
docs/rules/delegation-trifecta.md — новая секция про два агента с одним именем: находка
теперь показывается БЕЗ пути, а не с произвольно выбранным из двух. Явно сказано, почему
это лучше: неверный путь отправляет читателя проверять невиновный файл.
docs/cli.md — почему `test` гоняет несколько скриптов разом, а `eval` по одному, и что
это следует из яруса, а не из флага. С замером 270s → 77s при побайтово одинаковом
результате, и с ценой: вывод буферизуется, живого стрима больше нет.
Все три — то, что было НАЙДЕНО, а не только что изменилось: через месяц полезно именно это.
* fix(docs): mark two illustrative enforce() blocks the old regex could not see
Разбор нашёл на два рефа больше, чем регулярка, и оба оказались СЛОМАННЫМИ — годами,
незаметно: `enforce("rubocop/Custom/NoDirectDbQuery")` и `enforce("pylint/no-direct-db-query")`
в справочных страницах скилла. Это примеры того, как объявить СВОЁ правило, то есть имена
заведомо несуществующие — им место под `<!-- vigiles:ignore -->`.
🔴 Почему регулярка их не видела, и это отдельный класс: она применялась ПОСТРОЧНО
(`for (const {lineNo, text} of blockLines) text.matchAll(CALL_RE)`), поэтому вызов,
разнесённый на несколько строк — а оба именно такие — не совпадал никогда. То есть у прежнего
детектора были не только ложные срабатывания (метод чужого объекта, комментарий, строка), но и
ложные ПРОПУСКИ на самой обычной форматировке. Разбор снимает оба класса разом: у AST нет
понятия «строка файла».
Итог на этом репозитории: 21 реф → 25 (после двух маркеров), все валидны.
* fix(compile): quote frontmatter scalars YAML would otherwise mis-read
`description: ${spec.description}` подставлялся сырым, поэтому описание с двоеточием и
пробелом — «…ловит ошибки, где замер врёт: неаналоги в выборке» — давало frontmatter,
который НЕ является валидным YAML. Замер 19.08 на двух реальных скиллах: сразу после
`vigiles compile` метод `skillContract()` отдаёт `malformed: true` и `declared: []`. Файл
ВЫГЛЯДИТ объявляющим `allowed-tools`, строгий парсер не читает ничего, скилл молча
наследует все инструменты сессии.
🔴 Это ровно тот дефект, который правило `frontmatter-valid` существует чтобы репортить —
то есть благословлённый путь производил то, что продукт ищет. Хуже: он НАКАЗЫВАЛ за
починку. Человек, закавычивший значение руками, получал на следующем линте расхождение
хеша («manually edited after compilation»), а перекомпиляция молча откатывала правку.
Проверка — КРУГОВОЙ ПРОГОН, а не список опасных символов: эмитим, читаем обратно тем же
загрузчиком, которым пользуется линтер, и квотим, только если вернулось не то, что ушло.
Два следствия этого выбора:
· уже безопасное значение эмитится побайтово как раньше — ни один скомпилированный файл не
меняется, ни один интегрити-хеш не двигается (подтверждено: все 1303 core-теста зелены);
· множество «опасных» входов не надо перечислять и поддерживать — решает сам YAML, поэтому
`#`, `[`, `&`, `*`, ведущий пробел, `yes`, `null` покрыты, не будучи названными.
Применено и к субагентам: описание там пишется так же свободно и ломает frontmatter так же.
Тесты — обе половины, и обе несущие (мутации с доказательством, что патч лёг):
· вернуть сырую подстановку → падает «compile quotes a description YAML would mis-read»
· квотить всегда → падает «leaves an already-safe description bare — no hash churn»
Вторая не менее важна первой: избыточное квотирование переписало бы каждый файл в каждой
репе и сдвинуло каждый хеш, что читается как «vigiles переписал мне весь репозиторий».
* feat!: doc-refs is opt-in, because it measured 0 true positives
The markdown code-block ref pass ran unconditionally and could not be turned
off. Measured across two real repositories on 2026-08-19 — 2 582 markdown
files, 52 builder refs — it produced ZERO true positives, and every error it
had ever raised was false: design prose sketching an API that does not exist
yet (`cmd("npm test")` for a package with no such script), and a third-party
CLAUDE.md captured verbatim as benchmark data.
The cause is structural rather than a threshold miss. A fenced block in prose
is a DRAWING of config, and this pass read it as config. No calibration fixes
that, because the block is not wrong — it is not config.
The consumer repo could only reach a passing lint by listing whole directories
in `exclude`, silencing every other check over a third of the tree; measured
afterwards the pass walked 604 files and found 0 refs — inert, still paying for
the walk. Off by default replaces that blunt instrument with an honest default.
BREAKING CHANGE: the markdown code-block ref pass no longer runs by default.
Enable it with {"rules": {"doc-refs": "error"}} — appropriate where markdown
genuinely is the source (a docs site whose fences are copied into live specs).
At "warn" findings print marked ℹ and the exit code is untouched; at "error"
they gate with exit 2 (they previously contributed exit 1, the soft tier,
because the pass fired unasked).
Known gap recorded for whoever raises the default again: the walker globs
without `dot`, so .claude/** — where deployed skills and agents live — has
never been scanned. The rule has never once judged a real instruction file.
* test(doc-refs): the GitHub annotation follows the configured severity
The inline-annotation test drove the pass with no config, which no longer runs
it. Extended to all three tiers rather than just re-enabled: "error" annotates
::error, "warn" annotates ::warning, and absent annotates nothing at all. The
middle case is the one worth asserting — a ::error rendered next to an exit code
of 0 is how an advisory rule gets read as a gate.
* chore: regenerate generated.d.ts, and stop two docs lines claiming doc-refs is on
CI caught what my five local gates could not: `npm run check` runs 18 stages
and I ran 5 of them. The new test file was missing from the generated project-
file union — 17/18, failing on `generated-types:committed`.
Also two sentences in docs/comparison.md still described the pass as
unconditional: the section's opening claim ("vigiles validates … calls inside
fenced TS/JS code blocks") and the Scope paragraph. Both now say opt-in; the
reader hits the opening line four paragraphs before the new note explaining the
default.
* ci(site): stop the browser install from eating the job
The site job was cancelled at its 15-minute bond twice in a row (runs
32300064395 and 32303343213), both times inside `playwright install --with-deps
chromium`, and neither failure was in the diff under review. The log shows the
cause plainly: `--with-deps` shells out to apt, apt hung on
azure.archive.ubuntu.com (`Ign:` on every line, then twelve minutes of silence),
and the orphan reaped at the end is `npm exec playwright install --with-deps`.
Three changes, one per cause:
- Drop `--with-deps`. ubuntu-latest already ships the libraries Chromium needs;
the flag exists for bare containers. Removing it removes the only part of this
step that touches a distro mirror. If a library really is missing, the browser
fails to LAUNCH in the next step within seconds and names the missing .so —
loud, and far better than a silent 15-minute hang.
- Cache ~/.cache/ms-playwright, keyed on the lockfiles. The step's floor was a
full ~150 MB download on every run, cache or no cache, good day or bad.
- Bound the step at 5 minutes. The job bond cannot tell "this step wedged" from
"the suite is slow", so a wedge cost the whole job; now it costs five minutes
and names the step that did it.
Measured baseline for the job: 3m35s.
* ci: run a job only when the commit can reach its inputs
This workflow had no path filter at all, so every PR paid for all five jobs
whatever it touched. Measured on this very PR (16 files across src/ and docs/,
none under site/): the site job built the engine, downloaded a browser and ran
the demo suite against code the diff could not reach — twice, at ~15 minutes
each, because the browser install wedged.
A workflow-level `paths:` cannot express this. It decides whether to run the
workflow AT ALL and says nothing about individual jobs. So the decision moves
into a `changes` job: one API call, no checkout, ~10s, feeding an `if:` on the
jobs whose inputs are disjoint from what changed.
Two flags, each conservative in the direction that costs minutes rather than
correctness:
root false ONLY when every changed file is under site/ — nothing outside
site/ imports anything inside it. Gates test, harness, e2e.
site false ONLY when every changed file is prose or agent config
(docs/, .claude/, root *.md). Gates site.
`check` stays unconditional: it runs types:site and cli-lint, so both halves of
the repo are among its inputs.
The site flag is a measurement, not an assumption. The demo imports the built
engine through three aliases, so any src/ change can reach it and must run it.
What cannot: grepping the three bundles it actually loads for the lint rule table
(DEFAULT_RULES, frontmatter-valid, doc-refs) returns zero in all three, and its
check pages are generated from site/src/checks/checks.ts rather than from docs/.
Tested in src/ci-path-filter.test.ts, which EXTRACTS the patterns from the
workflow rather than restating them — a test that agrees with its own copy of a
rule proves nothing about the rule that runs. A skipped job and a passed job look
identical in the checks list, so both directions are asserted. Three mutations,
each failing on its own assertion with the patch verified in place:
prose arm widened to any *.md → 'a markdown file NESTED in src'
prose arm widened to include src/ → 'runs everything for a normal src change'
empty-list bail flipped to false → 'an empty diff is not a licence to skip'
* test(ci): a job added later cannot silently escape the path gate
The filter itself was tested; the SHAPE of the workflow was not. In the checks
list a skipped job and a passed job are the same tick, so a job added tomorrow
with no `if:` looks exactly like a correctly-gated one that happened to run —
the filter quietly stops meaning anything and nothing goes red.
Exemptions are named with their reason rather than inferred, so granting one is
a visible edit: `changes` is the classifier itself (gating it on itself is a
cycle), `check` runs types:site and cli-lint so both halves of the repo are its
inputs. Plus a vacuity guard — if every job ends up exempt the assertion can no
longer fail, and an empty list would read as health.
Mutation, patch verified in place: stripping the gate off `site` fails with
"job(s) run on every commit with no path gate: site".
Mirrors the same guard added to mine's paper-gates.harness.mjs, where an ungated
19m42s job was what prompted it.
Gates, in order: npx vitest run (8 in this file) · npm run check 18/18.
* Revert "test(ci): a job added later cannot silently escape the path gate"
This reverts commit be267c7133.
* test(compile): state the frontmatter guarantee as a property, not one example
The colon-space test proved the colon-space case and nothing else. YAML has a
dozen ways to read a bare scalar as something other than the string written, and
a description is arbitrary human prose, so the guarantee belongs as a property
over that space: 29 adversarial scalars in, identical strings back; 4 safe ones
left bare so no consumer's integrity hash moves.
This is what lets a downstream repo delete its own check. Before, a consumer
proved the reader failed closed by hand-editing a compiled SKILL.md — text
surgery resting on how that corpus happened to be quoted, an assumption that
stops holding now that quoting is conditional. The emission side is provable
here; the hand-edit side is what frontmatter-valid and the integrity hash cover.
The 'trailing space' row went red on first run and the finding was about the
TEST: frontmatterScalar trims by contract, so it cannot witness a byte-exact
round trip. The compiler had emitted the value correctly. Oracle moved to the raw
parsed value, with the reason recorded inline so nobody 'fixes' it back.
Mutations, patch verified in place both times:
yamlScalar never quotes → 20 rows red, incl. every YAML indicator character
yamlScalar always quotes → all 5 bare-scalar rows red (the hash-churn side)
Gates in order: npx vitest run (44 in this file) · npm run check 18/18.
* fix(coverage): a script that could not LOAD did not run, so it must not retract
The retraction rule is right and stays: a script that RAN and proved nothing must
not keep yesterday's green record alive. It just does not describe a file the
runtime never evaluated. A module that fails to resolve executed no assertion — it
is the same 'did not run' as a missing claude CLI, arriving through another door.
Measured twice in one session on 2026-08-20: a container restore left an old
node_modules behind, every harness in a consumer repo died on `Named export
'recordCheck' not found`, and that repo's ledger fell from 48 records to 34 and
from 47 to 33. Nothing about those surfaces had changed — the machine had.
statusFor now also sees the child's output and classifies a loader error as skip.
Deliberately literal and only the loader's own vocabulary (ERR_MODULE_NOT_FOUND,
Cannot find package/module, the CommonJS named-export SyntaxError,
ERR_UNSUPPORTED_DIR_IMPORT, ERR_PACKAGE_PATH_NOT_EXPORTED) — those strings come
from Node's resolver, not from user code.
⚠️ It does not make a broken environment quiet. A skip still prints ⊘ SKIPPED and
--no-skip, which this repo's own CI passes, still fails the run. What changes is
only whether a machine problem may delete a measurement taken on a machine that
worked.
Mutation with the patch verified in place: reverting to a bare 'fail' fails on
'a module-resolution failure is a skip, not a fail'. The opposite direction is
asserted too — an AssertionError, a zero-check run and a passing run that merely
mentions a loader string all keep their old classification.
Gates: npx vitest run (32 in this file) · npm run check 18/18.
* feat(hook)!: compiled hooks are marked experimental, structurally
docs/experimental.md already states the principle — 'The prefix is the promise,
wherever the symbol lives' — and npm run experimental:check already fails CI when
a declaration tagged @experimental is not named for it. Compiled hooks were never
enrolled: grep found zero @experimental in hook.ts and hook-program.ts, so the
checker had nothing to compare and stayed silent. That is the checker's honest
limit — it catches a mismatch, not an absent marking, which is a human call.
Only the SIX ENTRY POINTS carry the prefix, and the placement is the argument:
every other name in the vocabulary — allow, deny, tool, pathView, commandView,
state, record, notice, run — is reachable only from inside a define* call, so
prefixing the chokepoint makes the marking structural for the whole family. Same
reasoning experimental_skill.input() is already documented with.
BREAKING CHANGE: defineHook, defineFileGate, definePromptGate, defineStopGate,
defineInject and defineReact are exported from vigiles/hook as
experimental_defineHook, experimental_defineFileGate, experimental_definePromptGate,
experimental_defineStopGate, experimental_defineInject and experimental_defineReact.
Alias at the import site so the word crosses the package boundary once and the body
stays readable:
import { experimental_defineInject as defineInject, state } from "vigiles/hook";
Docs, all three places asked for: a banner on docs/compiled-hooks.md (the feature
already has its own guide), a full section in docs/experimental.md, and the private
architecture note in CLAUDE.md.spec.ts. The experimental section states GAPS rather
than a disclaimer — named state landed 2026-08-12 and is untested by anyone but its
author; testing a stateful hook means reconstructing a runtime-private store path,
which the dogfood repo hard-codes and which broke when facts were renamed; compile
is not idempotent; two consumers, both the author's — plus what would have to be
true to drop the prefix.
⚠️ Three of my own defects on the way, all caught by gates rather than by review:
docs-imports flagged that the guide's examples still used the old names; a regex
that ran over one file twice produced 'experimental_defineHook as experimental_
defineHook as defineHook'; and six fixture STRINGS plus one dynamic-import
destructure held the old names in places a rename does not reach.
Gates: npx vitest run 3231 passed · npm run check 18/18.
* fix(examples): the rename missed examples/, and only the harness tier saw it
CI failed on examples/harness/compiled-hook-inprocess.harness.mjs. npm run check
cannot catch this — it type-checks src/ and never runs the example harnesses; the
harness job does, which is why the whole 18/18 suite stayed green over a broken
example.
Fourth instance of the same class in one change, and the pattern is worth naming:
a rename reaches declarations and imports, and does NOT reach a symbol living in
a fixture STRING, in a dynamic-import destructure, in a doc code block, or in a
directory the type-checker does not compile. Each was found by a different gate —
docs-imports, vitest, and now the harness tier — and none by reading the diff.
⚠️ refs-nudge.harness.mjs fails locally and passes in CI, and that is the
environment rather than this branch: CI pins the claude CLI to the
dialect-validated 2.1.187 (read out of src/dialect-drift.ts), this container has
2.1.237, and the file is byte-identical to main's.
* fix(skill-resources): a file the skill WRITES is not a file it ships
A consumer skill's line was reported as a missing bundled resource:
API responses are cached to `scripts/.cite-cache.json` (gitignored) so
re-runs are cheap.
Isolated to one word with a three-line fixture differing only in its tail. The
same sentence ending "so subsequent invocations are cheap" stayed silent, and
so did the same sentence with no tail. Only `re-runs` fired: JavaScript's `\b`
counts a hyphen as a word boundary, so USE_DIRECTIVE's `runs` matches INSIDE
it, and a plural noun about repetition was read as an instruction to run the
file.
Fixing the hyphen was rejected by that same measurement — "re-run
`scripts/x.sh` before submitting" is a genuine directive, so a rule about
hyphens trades this false positive for a false negative. The honest
discriminator is that the line says the file is MADE, not read, and
`gitignored` is its strongest form: a file the author states is outside the
repository cannot be shipped inside it.
GENERATED_FILE_CUE is deliberately separate from ILLUSTRATIVE_CUE. That one
vetoes a HYPOTHETICAL mention; this one vetoes a mention that is entirely
concrete and still cannot name a bundled resource. It is the read the module
already takes: USE_DIRECTIVE excludes authoring verbs (store/create/add/move/
write) for this exact reason, and simply never covered the writing side.
Both candidate shapes consult it — the measured line is an inline path, but
the same sentence as a markdown link would slip past a one-sided fix.
Mutation-proven, each with evidence the patch landed:
| mutation | fails on |
| --- | --- |
| veto removed from the inline shape | the hyphenated-word case |
| veto matches everything (`/./`) | "still flags a REAL directive" (+4 true positives) |
| veto removed from the link shape | the written-at-runtime link case |
End-to-end on the consumer repo: the finding is gone, and the one remaining
`✗` there is unrelated.
Gates, in order: `npm run check` 18/18 · `npx vitest run` 3236 passed. One
suite fails to LOAD in this checkout — `scripts/release-preset.test.ts` cannot
resolve `@semantic-release/commit-analyzer`, which is in package.json but not
in this node_modules. A partial install, not a result: it did not run, so it
proves nothing either way.
---------
Co-authored-by: Claude <noreply@anthropic.com>
614 lines
26 KiB
TypeScript
614 lines
26 KiB
TypeScript
/**
|
|
* Pure decision logic for `vigiles init`.
|
|
*
|
|
* Turns CLI args + whether a human's at a TTY into a concrete setup PLAN, so the
|
|
* IO-heavy `setup()` in cli.ts stays a thin shell and every choice is unit-tested.
|
|
* Best-practice onboarding: **interactive** when a human runs it in a terminal,
|
|
* **non-interactive** (sensible defaults) for agents / CI / piped input — so
|
|
* "set up vigiles" from a Claude Code or Codex prompt Just Works without hanging
|
|
* on a prompt. See docs/agent-setup.md.
|
|
*/
|
|
|
|
/** What `vigiles init` will set up. */
|
|
export interface SetupPlan {
|
|
/** Lint pillar — verify instruction-file references (specs, types, compile, lint, hooks). */
|
|
lint: boolean;
|
|
/** Test pillar — test the harness (scaffold a starter harness test + CI job). */
|
|
test: boolean;
|
|
/** Wire CI (the `zernie/vigiles@v1` Action; creates a workflow if none). */
|
|
gha: boolean;
|
|
/** Install the Claude Code plugin (hooks + skills). */
|
|
plugin: boolean;
|
|
/**
|
|
* Adopt/scaffold the instruction file(s) into typed `.spec.ts` (the compiled
|
|
* `CLAUDE.md` + integrity hash). GATE-FIRST (see `gate-first-adoption`): the
|
|
* INTEGRITY GATE (structural rules on your raw files + CI + devDep) is the
|
|
* universal floor and needs NO spec — the `structural` rule group reads
|
|
* skills/agents/hooks as-is, and `require-instructions-spec` is itself opt-in. So
|
|
* scaffolding a spec (extra maintenance + a compiled artifact) is the INVITED
|
|
* layer, gated on this flag (`setupPillar1` runs iff `lint && scaffoldSpecs`).
|
|
* Default: TRACKS the lint pillar (`resolvePlan` sets it to `plan.lint`), so bare
|
|
* `init` / `--lint` scaffold as before — non-interactive behaviour is unchanged.
|
|
* The ONE thing that turns it off with lint on is the wizard's "gate" choice: a
|
|
* pure lint gate on your raw files, nothing installed, no spec. No new CLI flag —
|
|
* a non-interactive gate is a documented follow-up (research/adoption-design.md §1).
|
|
*/
|
|
scaffoldSpecs: boolean;
|
|
/** Strict rule severities in `.vigilesrc.json`. */
|
|
strict: boolean;
|
|
/** Rewrite an existing STALE CI workflow in place (instead of only warning). */
|
|
force: boolean;
|
|
}
|
|
|
|
/** The explicit choices a user pinned via flags (undefined = "not specified"). */
|
|
export interface ParsedSetupArgs {
|
|
target?: string;
|
|
strict: boolean;
|
|
/** `--report-only` — write the gating rules at "warn" (nothing fails CI). The
|
|
* orthogonal severity dial; composes with `--strict` (which rules) by setting
|
|
* their severity. */
|
|
reportOnly: boolean;
|
|
yes: boolean;
|
|
/** `--force` — rewrite a stale CI workflow in place. */
|
|
force: boolean;
|
|
/** Lint pillar — `--lint` → true, `--no-lint` → false, absent → undefined. */
|
|
lint?: boolean;
|
|
/** Test pillar — `--test` → true, `--no-test` → false, absent → undefined. */
|
|
test?: boolean;
|
|
/** `--harness=claude,codex` override (empty = auto-detect). */
|
|
harness?: string;
|
|
gha?: boolean;
|
|
plugin?: boolean;
|
|
/**
|
|
* `--ci-only` — the explicit CI-gate-only opt-in: the non-interactive equal of
|
|
* the wizard's "gate" choice (the lint gate in CI + devDep, no plugin, no spec,
|
|
* no test). Lets an agent/CI reach it without a TTY. Full stays the DEFAULT (bare
|
|
* `init` is unchanged), so this is opt-IN — it never buries the richer layers
|
|
* behind a default flip. (Named for what it sets up — the CI check — not the
|
|
* internal "integrity gate" concept.)
|
|
*/
|
|
ciOnly: boolean;
|
|
}
|
|
|
|
function flagValue(
|
|
args: readonly string[],
|
|
prefix: string,
|
|
): string | undefined {
|
|
return args.find((a) => a.startsWith(prefix))?.slice(prefix.length);
|
|
}
|
|
|
|
/** `--name` → true, `--no-name` → false, neither present → undefined. */
|
|
function boolFlag(args: readonly string[], name: string): boolean | undefined {
|
|
if (args.includes(`--${name}`)) return true;
|
|
if (args.includes(`--no-${name}`)) return false;
|
|
return undefined;
|
|
}
|
|
|
|
/** Parse `init` args into the choices the user pinned. The two pillars are
|
|
* selected with `--lint` / `--test`. */
|
|
export function parseSetupArgs(args: readonly string[]): ParsedSetupArgs {
|
|
return {
|
|
target: flagValue(args, "--target="),
|
|
strict: args.includes("--strict"),
|
|
reportOnly: args.includes("--report-only"),
|
|
yes: args.includes("--yes") || args.includes("-y"),
|
|
force: args.includes("--force"),
|
|
lint: boolFlag(args, "lint"),
|
|
test: boolFlag(args, "test"),
|
|
harness: flagValue(args, "--harness="),
|
|
gha: args.includes("--no-gha") ? false : undefined,
|
|
plugin: args.includes("--no-plugin") ? false : undefined,
|
|
ciOnly: args.includes("--ci-only"),
|
|
};
|
|
}
|
|
|
|
/** The non-interactive defaults: both pillars, CI, and the plugin. `scaffoldSpecs`
|
|
* tracks the lint pillar (specs are created when the lint layer is set up) — the
|
|
* ONE exception is the wizard's "gate" choice, which sets up the lint GATE on your
|
|
* raw files WITHOUT scaffolding a spec (gate-first-adoption). Non-interactive
|
|
* behaviour is unchanged: bare `init` (lint on) scaffolds as before. */
|
|
export function defaultPlan(strict = false): SetupPlan {
|
|
return {
|
|
lint: true,
|
|
test: true,
|
|
gha: true,
|
|
plugin: true,
|
|
scaffoldSpecs: true,
|
|
strict,
|
|
force: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pure config-merge for what `vigiles init` writes to `.vigilesrc.json`: record
|
|
* the `harness` if absent, add strict rule severities if `--strict`, NEVER
|
|
* clobber an existing key. Returns the merged config, or `null` when nothing
|
|
* changed (so the IO layer skips the write). The IO (read/parse/write + the
|
|
* malformed-file guard) stays in cli.ts.
|
|
*/
|
|
/**
|
|
* The structural rules `init` gates BY DEFAULT (severity `error`, so a broken
|
|
* surface fails `vigiles lint`). Every one is HIGH-PRECISION / FP-safe — it fires
|
|
* only on a genuine defect (a never-available/typo'd tool, a subagent missing
|
|
* `name`/`description`, a typo'd hook event, a dead hook script, a broken MCP
|
|
* ref, two skills that collide in the selector) — so a well-formed plugin stays
|
|
* green and catching real breakage out of the box never cries wolf.
|
|
*
|
|
* Deliberately EXCLUDES `require-instructions-spec` and the workflow-forcing rules:
|
|
* those make a CLEAN repo fail (you simply haven't written the spec/test yet), so
|
|
* they stay opt-in under `--strict` (progressive adoption — see
|
|
* `STRICT_EXTRA_RULES`).
|
|
*
|
|
* This is the **`structural`** rule group (see research/install-enforcement-dx.md).
|
|
*/
|
|
export const STRUCTURAL_RULES = [
|
|
"subagent-tool-contract",
|
|
"subagent-frontmatter",
|
|
"hook-events",
|
|
"hook-script-exists",
|
|
"mcp-config",
|
|
"mcp-tool-resolves",
|
|
"mcp-hook-target-resolves",
|
|
"disallowed-tools-contract",
|
|
"description-overlap",
|
|
] as const;
|
|
|
|
/**
|
|
* The **`workflow`** group — the WORKFLOW-FORCING / opinionated tier `--strict`
|
|
* gates, which a clean repo can still fail because you haven't done the work yet:
|
|
* a spec per instruction file (`require-instructions-spec`), a test/eval per
|
|
* surface (`untested-*`). Opt-in by design (the smooth-adoption on-ramp). The
|
|
* Clippy-`pedantic` / TS-`strict` analog — ONE opinionated opt-in.
|
|
*
|
|
* NB `frontmatter-valid` / `skill-frontmatter` live in the `nudge` group, not
|
|
* here: they're acknowledged-noisy recommendations we never gate on (see
|
|
* research/install-enforcement-dx.md).
|
|
*/
|
|
export const WORKFLOW_RULES = [
|
|
"require-instructions-spec",
|
|
"untested-skill",
|
|
"untested-subagent",
|
|
"untested-hook",
|
|
] as const;
|
|
|
|
/**
|
|
* The **`nudge`** group — recommendations / acknowledged-noisy checks that NEVER
|
|
* gate (not even under `--strict`): `frontmatter-valid` (js-yaml is stricter than
|
|
* CC's loader), `skill-frontmatter` (skills load without it) and `unmarked-refs`
|
|
* (the undecidable-plaintext nudge) sit at `warn`; `prefer-compiled-hooks` defaults
|
|
* OFF (a recommendation that shouldn't fire unasked — the shell lane stays
|
|
* first-class). `lethal-trifecta` + `skill-resource-resolves` are here for now on a
|
|
* don't-cry-wolf rollout (default `warn`; a team raises either to `error` by hand
|
|
* once it's confirmed quiet on their corpus). `init` does not write these — they
|
|
* keep their own default severities. Named for the group taxonomy
|
|
* (research/install-enforcement-dx.md).
|
|
*/
|
|
export const NUDGE_RULES = [
|
|
"skill-description-budget",
|
|
"frontmatter-valid",
|
|
"skill-frontmatter",
|
|
"prefer-compiled-hooks",
|
|
"unmarked-refs",
|
|
"lethal-trifecta",
|
|
"skill-resource-resolves",
|
|
"skill-missing-fence",
|
|
"plugin-dir-layout",
|
|
"delegation-trifecta",
|
|
"hook-block-ineffective",
|
|
"hook-matcher",
|
|
// Default OFF for a measured reason, not a rollout one: 0 true positives over
|
|
// 2 582 markdown files, because a fence in prose is a drawing of config rather
|
|
// than config. See docs/rules/doc-refs.md.
|
|
"doc-refs",
|
|
] as const;
|
|
|
|
export function mergeProjectConfig(
|
|
existing: Record<string, unknown>,
|
|
opts: {
|
|
harness: string | string[];
|
|
strict: boolean;
|
|
reportOnly?: boolean;
|
|
/** Whether the LINT pillar is on (default true). The rule gate is a lint-layer
|
|
* concern, so a test-only setup (`init --test` / `--no-lint`) records the
|
|
* harness but writes NO lint rules. */
|
|
lint?: boolean;
|
|
},
|
|
): Record<string, unknown> | null {
|
|
const config = { ...existing };
|
|
let changed = false;
|
|
if (config.harness === undefined) {
|
|
config.harness = opts.harness;
|
|
changed = true;
|
|
}
|
|
// The rule gate belongs to the LINT layer — a test-only setup records the
|
|
// harness but writes no rules (honoring the positive-flag contract that
|
|
// `--test` selects only the test pillar).
|
|
if (opts.lint !== false) {
|
|
// Gate the FP-safe `structural` group by default; `--strict` adds the
|
|
// `workflow` group on top. `--report-only` is the orthogonal severity dial —
|
|
// it writes the SAME rule set at "warn" (nothing fails CI; the
|
|
// migration/observe mode). Never clobber a severity the user already set —
|
|
// only fill the undefined ones.
|
|
const severity = opts.reportOnly ? "warn" : "error";
|
|
const gate = opts.strict
|
|
? [...STRUCTURAL_RULES, ...WORKFLOW_RULES]
|
|
: [...STRUCTURAL_RULES];
|
|
const rules = { ...(config.rules as Record<string, unknown> | undefined) };
|
|
for (const r of gate) {
|
|
if (rules[r] === undefined) {
|
|
rules[r] = severity;
|
|
changed = true;
|
|
}
|
|
}
|
|
config.rules = rules;
|
|
}
|
|
return changed ? config : null;
|
|
}
|
|
|
|
/**
|
|
* Whether to drop into interactive prompts: a human at a TTY who passed neither
|
|
* `--yes` nor an explicit `--target`, and who hasn't already pinned every choice
|
|
* via flags. Agents / CI / piped input (no TTY) never prompt.
|
|
*/
|
|
export function shouldPrompt(parsed: ParsedSetupArgs, isTTY: boolean): boolean {
|
|
// `--ci-only` is an explicit setup-shape choice (the CLI equal of the wizard's
|
|
// "gate" answer), so it settles the fork — never prompt over it.
|
|
if (!isTTY || parsed.yes || parsed.target || parsed.ciOnly) return false;
|
|
const pillarsPinned = parsed.lint !== undefined || parsed.test !== undefined;
|
|
const allPinned =
|
|
pillarsPinned && parsed.gha !== undefined && parsed.plugin !== undefined;
|
|
return !allPinned;
|
|
}
|
|
|
|
/** Interactive answers (only the fields the prompts cover). */
|
|
export type SetupAnswers = Partial<
|
|
Pick<
|
|
SetupPlan,
|
|
"lint" | "test" | "gha" | "plugin" | "strict" | "scaffoldSpecs"
|
|
>
|
|
>;
|
|
|
|
/** Ask one question with a default — injected so the interactive Q&A is pure +
|
|
* unit-testable (a fake `ask` scripts answers; no TTY, no readline). */
|
|
export type AskFn = (question: string, def: string) => Promise<string>;
|
|
|
|
const isYesAnswer = (s: string): boolean => /^y(es)?$/i.test(s);
|
|
|
|
/**
|
|
* The interactive setup Q&A as PURE logic over an injected `ask` — the prompts,
|
|
* their defaults, and the answer→`SetupAnswers` mapping. The IO shell (readline)
|
|
* lives in `cli.ts`'s `promptSetup`, which just supplies a real `ask`. Keeping
|
|
* this here means the fragile interactive path is unit-tested deterministically
|
|
* (the questions can't silently break) without a terminal.
|
|
*/
|
|
export async function collectSetupAnswers(ask: AskFn): Promise<SetupAnswers> {
|
|
// GATE-FIRST fork (gate-first-adoption): the FIRST question is the shape of the
|
|
// setup, not a pillar list — because the integrity GATE is the universal floor and
|
|
// everything richer is invited. "gate" = lint your files in CI, nothing installed,
|
|
// zero conflict (the existing-harness / non-JS path). "full" = also scaffold specs
|
|
// + install the skills/hooks. Default "full" keeps the newcomer experience; an
|
|
// existing-harness user picks "gate" (and its tradeoff is stated inline).
|
|
const mode = (
|
|
await ask(
|
|
"Setup mode — [gate] lint your files in CI, nothing installed (best if you already have a harness) · [full] also scaffold specs + install skills/hooks? [gate/full] (full): ",
|
|
"full",
|
|
)
|
|
).toLowerCase();
|
|
if (mode === "gate" || mode === "g") {
|
|
// Pure gate: the structural rules on your raw files + CI + devDep. No spec, no
|
|
// plugin, no test scaffold — nothing installed, nothing to maintain.
|
|
return {
|
|
lint: true,
|
|
test: false,
|
|
plugin: false,
|
|
scaffoldSpecs: false,
|
|
strict: false,
|
|
};
|
|
}
|
|
const pillars = (
|
|
await ask("Set up which pillars? [both/lint/test] (both): ", "both")
|
|
).toLowerCase();
|
|
const gha = isYesAnswer(await ask("Wire CI (GitHub Action)? [Y/n]: ", "y"));
|
|
const plugin = isYesAnswer(
|
|
await ask("Install the Claude Code plugin (hooks + skills)? [Y/n]: ", "y"),
|
|
);
|
|
// Structural gating (broken tools/hooks/MCP/collisions) is always on. This asks
|
|
// about the WORKFLOW tier — a spec per file + a test per surface — which a clean
|
|
// repo can fail just for not having done the work yet, so it's the recommended
|
|
// default a human opts OUT of (never forced on a silent run). "yes" also turns on
|
|
// the spec scaffold (the workflow tier's `require-instructions-spec`).
|
|
const strict = isYesAnswer(
|
|
await ask(
|
|
"Also enforce specs + a test per surface (recommended)? [Y/n]: ",
|
|
"y",
|
|
),
|
|
);
|
|
return {
|
|
lint: pillars !== "test",
|
|
test: pillars !== "lint" && pillars !== "verify",
|
|
gha,
|
|
plugin,
|
|
strict,
|
|
// "full" scaffolds specs whenever the lint pillar is on (specs are how the full
|
|
// setup works); `strict` is a separate axis (it gates the workflow RULES, not
|
|
// whether a spec exists).
|
|
scaffoldSpecs: pillars !== "test",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply the pillar flags. A positive flag (`--lint` and/or `--test`) is an
|
|
* explicit SELECTION — enable exactly the named pillars. Otherwise default to
|
|
* both and let a `--no-*` flag drop one.
|
|
*/
|
|
function applyPillarFlags(plan: SetupPlan, parsed: ParsedSetupArgs): void {
|
|
if (parsed.lint === true || parsed.test === true) {
|
|
plan.lint = parsed.lint === true;
|
|
plan.test = parsed.test === true;
|
|
return;
|
|
}
|
|
if (parsed.lint === false) plan.lint = false;
|
|
if (parsed.test === false) plan.test = false;
|
|
}
|
|
|
|
function applyAnswers(plan: SetupPlan, answers: SetupAnswers): void {
|
|
if (answers.lint !== undefined) plan.lint = answers.lint;
|
|
if (answers.test !== undefined) plan.test = answers.test;
|
|
if (answers.gha !== undefined) plan.gha = answers.gha;
|
|
if (answers.plugin !== undefined) plan.plugin = answers.plugin;
|
|
if (answers.strict !== undefined) plan.strict = answers.strict;
|
|
if (answers.scaffoldSpecs !== undefined)
|
|
plan.scaffoldSpecs = answers.scaffoldSpecs;
|
|
}
|
|
|
|
/**
|
|
* The NON-EVIL invitation to graduate a gate-only setup to the full layer (skills
|
|
* + typed specs) — `gate-first-adoption`'s "invite the rest". Pure: returns the
|
|
* one-line invitation when the setup landed as a pure gate (no plugin, no specs),
|
|
* else null. It's INFORMATIONAL — a printed line, never a second prompt (the TTY
|
|
* wizard already asked; a headless run must never hang), and declining costs
|
|
* nothing (the gate is fully functional). The IO (printing it, and the optional
|
|
* later reminder on `audit`/`lint`) lives in cli.ts.
|
|
*/
|
|
export function gateOnlyInvitation(plan: SetupPlan): string | null {
|
|
const gateOnly = !plan.plugin && !plan.scaffoldSpecs;
|
|
if (!gateOnly) return null;
|
|
return "→ Want your agent to maintain this + measure whether your skills fire? Run `npx vigiles init` and choose 'full' (installs the skills). Optional — the gate above already works.";
|
|
}
|
|
|
|
/**
|
|
* How to install vigiles's skills/hooks for ONE harness — the deterministic
|
|
* decision behind the IO in cli.ts, so a CI test asserts WHICH commands an
|
|
* install runs without a network call or a real `claude`/`codex` binary.
|
|
*
|
|
* The method is genuinely harness-specific: Claude Code has a GLOBAL plugin
|
|
* marketplace (installs to ~/.claude/plugins/, nothing in the repo); Codex has
|
|
* no global store — its config is repo-committed (`.codex/`, AGENTS.md), so its
|
|
* instructions are read directly and skills are an opt-in, repo-local concern.
|
|
*/
|
|
export interface InstallPlan {
|
|
harness: string;
|
|
/** Shell commands to auto-run (empty = nothing runnable here). */
|
|
commands: string[];
|
|
/** One-line success message printed after the commands run. */
|
|
successMessage: string;
|
|
/** The equivalent commands a user runs by hand (printed on failure / no-CLI). */
|
|
manualSteps: string[];
|
|
/** Always-printed informational lines (where it installs, caveats). */
|
|
notes: string[];
|
|
/** Whether this method writes files into the consumer's repo (vendoring). */
|
|
vendors: boolean;
|
|
}
|
|
|
|
/** Per-harness install plan. `hasClaude` gates the auto-run `claude plugin` CLI
|
|
* (else the same two steps are printed as `/plugin` slash commands).
|
|
*
|
|
* Both methods install GLOBALLY, never into the repo: Claude through its plugin
|
|
* marketplace (~/.claude/plugins/), Codex through the cross-agent `skills` CLI
|
|
* with `-g -y` (the global store ~/.agents/skills/, which Codex reads). Codex
|
|
* gets the skills but NOT hooks — Codex hook wiring (.codex/config.toml [hooks])
|
|
* is not automated yet. */
|
|
/**
|
|
* The SHIPPED consumer skills (the `skills/` dir, published via `plugin.json`) —
|
|
* the ONLY ones a user's install should get. The cross-agent `skills` CLI would
|
|
* otherwise clone the whole repo and install EVERY skill dir it finds, including
|
|
* this repo's CONTRIBUTOR-only `.claude/skills/` (generate-logo, landing-site,
|
|
* pr-to-lint-rule, …) — internal tooling users never asked for. So the Codex
|
|
* install is scoped with `-s`. Kept in lockstep with `skills/` on disk by the
|
|
* dogfood test in `src/adapters/claude-code/skills-dogfood.test.ts`.
|
|
*/
|
|
export const SHIPPED_SKILLS = [
|
|
"adopt-spec",
|
|
"debug-my-harness",
|
|
"edit-spec",
|
|
"linter-docs",
|
|
"strengthen",
|
|
"test-harness",
|
|
] as const;
|
|
|
|
export function planPluginInstall(
|
|
harnesses: readonly string[],
|
|
opts: { hasClaude: boolean },
|
|
): InstallPlan[] {
|
|
return harnesses.map((harness) => {
|
|
if (harness === "claude") {
|
|
return {
|
|
harness,
|
|
commands: opts.hasClaude
|
|
? [
|
|
"claude plugin marketplace add zernie/vigiles",
|
|
"claude plugin install vigiles@vigiles",
|
|
]
|
|
: [],
|
|
successMessage:
|
|
"✓ Installed the vigiles plugin (hooks + skills) into ~/.claude/plugins/",
|
|
manualSteps: [
|
|
"/plugin marketplace add zernie/vigiles",
|
|
"/plugin install vigiles@vigiles",
|
|
],
|
|
notes: [
|
|
"Installs globally to ~/.claude/plugins/ — nothing is added to your repo.",
|
|
],
|
|
vendors: false,
|
|
};
|
|
}
|
|
if (harness === "codex") {
|
|
// The cross-agent `skills` CLI with `-g -y` installs to the global store
|
|
// ~/.agents/skills/ (NOT the repo, and NOT ~/.codex/ — verified against
|
|
// the real CLI). Skills install globally; the proactive NUDGE hooks
|
|
// (eval-lock + refs) are wired into the repo's .codex/config.toml by
|
|
// `init` (see codexPluginHooks / wireCodexHooks) — Codex config is
|
|
// repo-committed, so that's the idiomatic place.
|
|
// Scope to the SHIPPED skills with `-s` — without it the `skills` CLI
|
|
// installs EVERY SKILL.md in the repo, leaking the contributor-only
|
|
// .claude/skills/ into the user's global store (#dogfood-I2; confirmed —
|
|
// the CLI's `-l` list shows 17 skills for this repo, incl. .claude/skills).
|
|
// The `-s` parser is SPACE-separated (it consumes consecutive non-dash
|
|
// args), NOT comma-separated — a comma list is read as one literal skill
|
|
// name, matches nothing, and the install exits 1 (verified against the
|
|
// real `skills@1.5.20` arg parser).
|
|
const skillScope = `-s ${SHIPPED_SKILLS.join(" ")}`;
|
|
return {
|
|
harness,
|
|
commands: [
|
|
`npx --yes skills add zernie/vigiles -a codex ${skillScope} -g -y`,
|
|
],
|
|
successMessage:
|
|
"✓ Installed the vigiles skills into ~/.agents/skills/ (global, not vendored)",
|
|
manualSteps: [
|
|
`npx skills add zernie/vigiles -a codex ${skillScope} -g -y`,
|
|
],
|
|
notes: [
|
|
"Codex reads AGENTS.md directly; the skills install globally to ~/.agents/skills/ (not the repo).",
|
|
"The eval-lock + refs NUDGE hooks are wired into .codex/config.toml (repo-committed, the Codex norm).",
|
|
"Still manual on Codex: the SessionStart lint summary + compile-on-edit/pre-edit guards (no harness-neutral entrypoint yet).",
|
|
],
|
|
vendors: true,
|
|
};
|
|
}
|
|
return {
|
|
harness,
|
|
commands: [],
|
|
successMessage: "",
|
|
manualSteps: [],
|
|
notes: [`No plugin install path for harness '${harness}'.`],
|
|
vendors: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** One vigiles-managed Codex hook: a `[[hooks.<event>]]` entry. */
|
|
export interface CodexPluginHook {
|
|
readonly event: string;
|
|
/** Codex matcher (anchored regex, the dialect convention). */
|
|
readonly matcher: string;
|
|
/** The shell command Codex runs (a direct `npx vigiles …`, no plugin root). */
|
|
readonly command: string;
|
|
/** A unique command substring → idempotent re-merge (replaces in place). */
|
|
readonly key: string;
|
|
}
|
|
|
|
/**
|
|
* vigiles's proactive nudges, wired into a Codex repo's `.codex/config.toml`.
|
|
*
|
|
* Codex has no global plugin store (unlike Claude Code's marketplace), so its
|
|
* config is repo-committed — the idiomatic place for these. They run as DIRECT
|
|
* `npx vigiles hook-runtime …` commands (NOT vendored bash scripts): the runtime
|
|
* entrypoints read the event JSON on stdin and emit the `hookSpecificOutput.
|
|
* additionalContext` shape Codex honors on `PostToolUse` (confirmed against the
|
|
* official hooks docs + encoded in `HookProtocol.injectableEvents`). Safety: only
|
|
* an INTENTIONAL `exit 2` blocks an edit (the refs nudge, when `unmarked-refs` is
|
|
* `error`); an npx-resolution failure exits non-2, so a missing dep never blocks.
|
|
*
|
|
* Deliberately NOT here (a loud, documented deferral — no-silent-skips): the
|
|
* SessionStart lint summary (CC delivers it as plain stdout, whose SessionStart
|
|
* prepend is unconfirmed on Codex — vs the JSON `additionalContext` these use) and
|
|
* the compile-on-edit / pre-edit-block guards (filename-gated bash with no
|
|
* harness-neutral `hook-runtime` entrypoint yet). Those stay manual on Codex.
|
|
*/
|
|
export function codexPluginHooks(): CodexPluginHook[] {
|
|
// Codex's file-edit tool is `apply_patch` (its dialect vocabulary —
|
|
// src/adapters/codex/dialect.ts), NOT Claude's `Edit`/`Write`. A PostToolUse
|
|
// matcher keyed on CC tool names would never fire on Codex, so the nudges must
|
|
// match the Codex tool name. (Both nudge entrypoints also self-gate on the
|
|
// edited file, so a non-edit event no-ops regardless.)
|
|
return [
|
|
{
|
|
event: "PostToolUse",
|
|
matcher: "^apply_patch$",
|
|
command: "npx --no-install vigiles hook-runtime eval-lock-nudge",
|
|
key: "hook-runtime eval-lock-nudge",
|
|
},
|
|
{
|
|
event: "PostToolUse",
|
|
matcher: "^apply_patch$",
|
|
command: "npx --no-install vigiles hook-runtime refs",
|
|
key: "hook-runtime refs",
|
|
},
|
|
];
|
|
}
|
|
|
|
/** A Codex `config.toml` shape, narrowed to the `[hooks]` table we manage. */
|
|
interface CodexConfig {
|
|
hooks?: Record<string, { matcher?: string; command: string }[]>;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* Idempotently merge {@link codexPluginHooks} into a parsed `.codex/config.toml`
|
|
* object. Pure — the IO (read/parse/serialize/write) stays in cli.ts's
|
|
* `wireCodexHooks`. Each vigiles hook is keyed by a unique command substring, so
|
|
* a re-run REPLACES its own entry in place and leaves the user's own Codex hooks
|
|
* (and every other config key) untouched. Returns a new object.
|
|
*/
|
|
export function applyCodexPluginHooks(
|
|
existing: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const config = existing as CodexConfig;
|
|
const hooks: Record<string, { matcher?: string; command: string }[]> = {
|
|
...(config.hooks ?? {}),
|
|
};
|
|
for (const h of codexPluginHooks()) {
|
|
const kept = (hooks[h.event] ?? []).filter(
|
|
(e) => !e.command.includes(h.key),
|
|
);
|
|
hooks[h.event] = [...kept, { matcher: h.matcher, command: h.command }];
|
|
}
|
|
return { ...existing, hooks };
|
|
}
|
|
|
|
/**
|
|
* Resolve the final plan: defaults, then flags, then interactive answers (each
|
|
* layer overrides the previous only where it has an opinion). `--target` pins a
|
|
* bare lint-pillar spec (no harness scaffold).
|
|
*/
|
|
export function resolvePlan(
|
|
parsed: ParsedSetupArgs,
|
|
answers?: SetupAnswers,
|
|
): SetupPlan {
|
|
const plan = defaultPlan(parsed.strict);
|
|
applyPillarFlags(plan, parsed);
|
|
if (parsed.gha === false) plan.gha = false;
|
|
if (parsed.plugin === false) plan.plugin = false;
|
|
if (parsed.force) plan.force = true;
|
|
if (parsed.target) plan.test = false;
|
|
// Specs track the lint pillar (created when the lint layer is set up); the wizard's
|
|
// "gate" answer is the one thing that overrides this to false (a lint gate with no
|
|
// spec). So `--no-lint` also stops the scaffold, and everything else is unchanged.
|
|
plan.scaffoldSpecs = plan.lint;
|
|
// `--ci-only`: the explicit CI-gate-only opt-in — the lint gate in CI + devDep,
|
|
// but no plugin, no spec scaffold, no test (the same shape as the wizard's "gate"
|
|
// choice). Full stays the DEFAULT, so this never flips bare `init`'s behaviour;
|
|
// it just lets a headless agent/CI request the non-invasive gate. Applied after
|
|
// the pillar flags so it wins over `plan.scaffoldSpecs = plan.lint`.
|
|
if (parsed.ciOnly) {
|
|
plan.lint = true;
|
|
plan.test = false;
|
|
plan.plugin = false;
|
|
plan.scaffoldSpecs = false;
|
|
plan.strict = false;
|
|
}
|
|
if (answers) applyAnswers(plan, answers);
|
|
return plan;
|
|
}
|