@venizia/vulcan 0.2.0-0 → 0.2.0-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/base/claude/settings.json +2 -2
- package/base/skills/docs-update/SKILL.md +100 -0
- package/base/skills/docs-update/evals/evals.json +23 -0
- package/package.json +3 -3
- package/src/commands/sync.command.ts +13 -4
- package/src/doctor/checks.ts +11 -5
- package/src/router.ts +5 -0
- package/src/sync/common/constants.ts +2 -0
- package/src/sync/planner.ts +13 -0
- package/src/sync/prune.ts +34 -28
- package/src/sync/settings.ts +74 -1
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ flag `bun x` fetches it when `@venizia/vulcan` is not installed yet.
|
|
|
17
17
|
| Command | What it does |
|
|
18
18
|
|---|---|
|
|
19
19
|
| `init [--config ts\|mts\|mjs\|js]` | Scaffold the project layer: `AGENTS.md`, `.configs/vulcan.config.*`, the three lint configs, `.agents/rules.md`, `.agents/knowledge/index.md`, a Makefile `-include`. Never overwrites; prints what to add to existing files, and the atlas `.mcp.json` entry for projects with a wiki |
|
|
20
|
-
| `sync [--dry-run] [--prune]` | Write the gitignored agent adapters: `CLAUDE.md` (`@AGENTS.md`), `.claude/settings.json` (shipped + slot + project hooks + `claude.settings` over your personal keys; what it applied is recorded in `.claude/vulcan-managed.json`, so a removed choice is removed), `.claude/skills/` links, and the committed `.agents/vulcan.manifest.json`. Prints each base rule added, changed or removed since the manifest last recorded it, with its new text, then records the new hashes. `--prune` also removes what sync proves stale and did not write: an old VULCAN or plugin-era hook command (a shipped hook
|
|
20
|
+
| `sync [--dry-run] [--prune]` | Write the gitignored agent adapters: `CLAUDE.md` (`@AGENTS.md`), `.claude/settings.json` (shipped + slot + project hooks + `claude.settings` over your personal keys; what it applied is recorded in `.claude/vulcan-managed.json`, so a removed choice is removed, and a hook it wrote is taken back by its command even after you edited its entry), `.claude/skills/` links, and the committed `.agents/vulcan.manifest.json`. Prints each base rule added, changed or removed since the manifest last recorded it, with its new text, then records the new hashes. `--prune` also removes what sync proves stale and did not write: an old VULCAN or plugin-era hook command (a shipped hook exactly as an older VULCAN released it, or a `.agents/` hook whose file is gone) and a dead `.claude/skills` link - never a developer's own hook, a customised shipped command, a real directory or a symlinked `.claude/skills`. `--dry-run` writes nothing and labels each line `would create`, `would replace` or `would remove`. Idempotent |
|
|
21
21
|
| `doctor` | Check the project follows VULCAN; exit 1 on any violation |
|
|
22
22
|
| `upgrade [<version\|tag>]` | Install the target `@venizia/vulcan` (default: the `next` dist-tag), then run `sync` from the newly installed CLI |
|
|
23
|
-
| `x <tool> [args...]` | Run a shipped tool at the pinned version: `okf`, `atlas` |
|
|
23
|
+
| `x <tool> [args...]` | Run a shipped tool at the pinned version: `okf`, `atlas`. `x --help` prints the help; `x <tool> --help` goes to the tool |
|
|
24
24
|
| `help`, `version` | |
|
|
25
25
|
|
|
26
26
|
Diagnostics go to stderr; `vulcan` writes nothing to stdout of its own, so `vulcan x atlas mcp` is a
|
|
@@ -49,7 +49,7 @@ An unknown key is an error; every error names the file and the field.
|
|
|
49
49
|
|
|
50
50
|
- `rules.md` - the base rules: write boundaries, security, process, build, and the IGNIS coding style V-C-01..V-C-18 (37 rules; it lists V-W-05, V-C-03 and V-C-05 as retired).
|
|
51
51
|
- `slots.json` - rules a project picks a wording for (V-W-01 today).
|
|
52
|
-
- `skills/` - `knowledge-sync`, `docs-writing` (project-neutral; a project-specific skill stays in its project).
|
|
52
|
+
- `skills/` - `knowledge-sync`, `docs-writing`, `docs-update` (project-neutral; a project-specific skill stays in its project).
|
|
53
53
|
- `hooks/` - `session-start.ts` (prints the rules digest) and `guard-writes.ts` (asks before a database write, a migration, a deploy or a mutating HTTP call).
|
|
54
54
|
- `claude/settings.json`, `make/vulcan.mk`, `templates/`.
|
|
55
55
|
|
|
@@ -63,7 +63,7 @@ or shadows a shipped one.
|
|
|
63
63
|
shadowed) · `make` (includes `vulcan.mk`, no shipped target redefined) · `hooks` (shipped hooks are
|
|
64
64
|
not re-registered) · `adapters` (sync is current, no hand-written `CLAUDE.md`, valid settings) ·
|
|
65
65
|
`prune` (no stale hook command or dead skill link - `sync --prune` removes them) ·
|
|
66
|
-
`style` (`@venizia/dev-configs` declared by the package holding the lint config or the root, every lint config builds on it).
|
|
66
|
+
`style` (`@venizia/dev-configs` declared by the package holding the lint config or the root, every lint config builds on it; the packages are `packages/*` and every configured package `path`).
|
|
67
67
|
|
|
68
68
|
The session-start digest prints the base write boundaries, the slot choices, the project's own rules (bold `| **W-00** |` rows and `| V-W-02, V-W-03 |` rows that elaborate base rules), the project's `<!-- minimap-shape:start -->` ... `<!-- minimap-shape:end -->` block from `.agents/rules.md` verbatim, and the `digest.oftenDropped` rules.
|
|
69
69
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/session-start.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; echo \"vulcan: the session-start hook cannot run
|
|
10
|
+
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/session-start.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; [ -f \"$f\" ] || echo \"vulcan: the session-start hook cannot run: node_modules/@venizia/vulcan is missing - run bun install\" >&2; command -v bun >/dev/null 2>&1 || echo \"vulcan: the session-start hook cannot run: bun is not on PATH - put bun on PATH (Claude Code may have started without your shell's PATH)\" >&2; exit 2"
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"hooks": [
|
|
19
19
|
{
|
|
20
20
|
"type": "command",
|
|
21
|
-
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; if grep -Eq '\"command\" *: *\"bun (install|i)(
|
|
21
|
+
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; if grep -Eq '\"command\" *: *\"bun (install|i)( (--frozen-lockfile|--production|-p|--no-save|--ignore-scripts|--force|--verbose|--silent))*\"'; then exit 0; fi; [ -f \"$f\" ] || echo \"vulcan: the guard-writes hook cannot run: node_modules/@venizia/vulcan is missing - run bun install (the one command that passes until then)\" >&2; command -v bun >/dev/null 2>&1 || echo \"vulcan: the guard-writes hook cannot run: bun is not on PATH - put bun on PATH (Claude Code may have started without your shell's PATH)\" >&2; exit 2"
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: docs-update
|
|
3
|
+
description: Updates the project's human-facing docs after a code change - refreshes the page that owns a topic, adds a page for a new feature, writes the changelog entry, then runs the docs gates until they pass. Use when the user asks to update the wiki or the docs, document a change or a feature, refresh a page, add a changelog entry, or keep the docs in step with the code.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
allowed-tools: Read, Write, Edit, Grep, Glob, Bash, Agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Docs update
|
|
9
|
+
|
|
10
|
+
The docs under `knowledge.wiki` are what people read; the knowledge bundle under `knowledge.bundle`
|
|
11
|
+
is what agents read. Never copy a page from one into the other. **The code is ground truth**: when a
|
|
12
|
+
page and the code disagree, the page is the bug. Nothing is committed: the user reviews the diff.
|
|
13
|
+
|
|
14
|
+
`$ARGUMENTS` says what to document - `add a changelog entry for the retry option`, `refresh the
|
|
15
|
+
storage reference`. With no arguments, ask what to document.
|
|
16
|
+
|
|
17
|
+
## Checklist
|
|
18
|
+
|
|
19
|
+
Copy it into your reply and tick it off, in order:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
- [ ] 1. Paths read from the manifest
|
|
23
|
+
- [ ] 2. Code read - the changed source, not a diff summary, not dist/, not another doc
|
|
24
|
+
- [ ] 3. Page picked - the page that owns the topic; a new one only when none does, wired in
|
|
25
|
+
- [ ] 4. Page written and self-reviewed with the docs-writing skill
|
|
26
|
+
- [ ] 5. Changelog entry written (release-worthy changes only)
|
|
27
|
+
- [ ] 6. Gates green (loop until they are)
|
|
28
|
+
- [ ] 7. Reported; nothing committed
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 1. Paths
|
|
32
|
+
|
|
33
|
+
1. `bun x --no-install vulcan doctor` passes, so the manifest is fresh. Fix what it names first.
|
|
34
|
+
2. Read `.agents/vulcan.manifest.json`. Every path comes from it, never from a layout you expect:
|
|
35
|
+
|
|
36
|
+
| Key | What it gives you |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `knowledge.wiki` | the docs root - every page lives under it |
|
|
39
|
+
| `knowledge.changelogs` | the changelog directory |
|
|
40
|
+
| `packages[].path` | where each package's source lives |
|
|
41
|
+
| `packages[].name` | the npm names a changelog entry declares |
|
|
42
|
+
| `name` | the brand, upper case in prose |
|
|
43
|
+
|
|
44
|
+
A key the manifest lacks is a part the project does not have. No `knowledge.wiki`: the human-facing
|
|
45
|
+
docs are the package READMEs (`<packages[].path>/README.md`) and the changelog - ask before creating
|
|
46
|
+
a docs root. No `knowledge.changelogs`: skip step 5 and say so.
|
|
47
|
+
|
|
48
|
+
## 2. Code
|
|
49
|
+
|
|
50
|
+
Read the source the change touched, under `packages[].path`: `git diff HEAD --stat -- <paths>` for
|
|
51
|
+
uncommitted work, `git log -p -- <paths>` for committed work. Open the files themselves. Write down
|
|
52
|
+
every name, signature, option and default you will mention - each one is verified here, not later.
|
|
53
|
+
|
|
54
|
+
## 3. Pick the page
|
|
55
|
+
|
|
56
|
+
1. Find the page that owns the topic: `grep -rln '<symbol or feature>' <knowledge.wiki>`, plus the
|
|
57
|
+
package READMEs. Edit that page. A second page on the same subject is how docs drift.
|
|
58
|
+
2. No page owns it: create one in the section whose pages are the same kind, copying the frontmatter
|
|
59
|
+
and layout of 2-3 siblings. List it where a sibling is listed - the sidebar config, a nav file,
|
|
60
|
+
the section `index.md` (`grep -rn '<sibling file name>' --exclude-dir=node_modules .`).
|
|
61
|
+
|
|
62
|
+
## 4. Write
|
|
63
|
+
|
|
64
|
+
Write and self-review with the [docs-writing](../docs-writing/SKILL.md) skill, its mechanical gates
|
|
65
|
+
included. Then apply the docs rules in `.agents/rules.md` (the generator's limits, the link style).
|
|
66
|
+
Every code sample uses the names and signatures from step 2.
|
|
67
|
+
|
|
68
|
+
## 5. Changelog
|
|
69
|
+
|
|
70
|
+
Only for a release-worthy change: a feature, a fix a user notices, a breaking change.
|
|
71
|
+
|
|
72
|
+
1. **The file.** `<knowledge.changelogs>/YYYY-MM-DD-<slug>.md`, from the directory's `template.md`.
|
|
73
|
+
No template: copy the shape of the newest entry. Keep only the sections that apply. When an
|
|
74
|
+
entry for the coming release already exists, add a bullet to it instead of opening a second.
|
|
75
|
+
2. **The `packages:` list** (Project Contract v1, C3). The frontmatter lists the npm names, from
|
|
76
|
+
`packages[].name`, of every package the entry covers: `packages: ['@scope/core']`. An entry about
|
|
77
|
+
no package, such as repository tooling, carries `packages: []`. Never leave the list out -
|
|
78
|
+
`okf check --strict` fails the entry.
|
|
79
|
+
3. **The content.** What changed, who is affected, how to migrate. It is not a commit log.
|
|
80
|
+
4. **The index.** When the directory's `index.md` lists entries, add a row in its order.
|
|
81
|
+
|
|
82
|
+
## 6. Gates - validate, fix, repeat
|
|
83
|
+
|
|
84
|
+
1. `bun x --no-install vulcan doctor`
|
|
85
|
+
2. `bun x --no-install vulcan x okf check --strict` - the bundle, the house rules, every changelog
|
|
86
|
+
entry's `packages:` list.
|
|
87
|
+
3. The project's own docs build, when it has one. Discover it, never assume a target name: the
|
|
88
|
+
targets `.agents/rules.md` names, `make help`, the Makefile and its `*.mk` files (`grep -n '<the
|
|
89
|
+
wiki directory>'`), the `scripts` of the `package.json` nearest `knowledge.wiki`. Run every docs
|
|
90
|
+
target you find - build, link check, anchor check, docs lint. None found: say so in the report.
|
|
91
|
+
|
|
92
|
+
A gate that fails: fix what it names, go back to the step that caused it, then run every gate again.
|
|
93
|
+
Stop only when all of them pass. Never weaken a gate to make it pass.
|
|
94
|
+
|
|
95
|
+
## 7. Report
|
|
96
|
+
|
|
97
|
+
A fact an agent reads changed too? Update its concept in the knowledge bundle in the same change; a
|
|
98
|
+
full re-audit is the `knowledge-sync` skill's job. Report in the user's language: the pages changed
|
|
99
|
+
and created, the changelog entry and its `packages:` list, each gate with its result, anything
|
|
100
|
+
skipped and why. **Do not commit.** Leave the diff for the user.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "docs-update",
|
|
3
|
+
"evals": [
|
|
4
|
+
{
|
|
5
|
+
"id": 0,
|
|
6
|
+
"prompt": "I changed how one of our packages behaves - see the uncommitted diff, or the last commit if the tree is clean. Update the docs to match and add the changelog entry. Do not commit.",
|
|
7
|
+
"expected_output": "Reads knowledge.wiki, knowledge.changelogs and packages[].path from .agents/vulcan.manifest.json; reads the changed source itself; edits the existing page that owns the topic instead of opening a duplicate; writes a YYYY-MM-DD-<slug>.md entry from the changelog template whose packages: list carries the npm names it covers; runs vulcan doctor, okf check --strict and the docs build it discovered, looping until all pass; nothing committed",
|
|
8
|
+
"files": [".agents/vulcan.manifest.json"]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": 1,
|
|
12
|
+
"prompt": "Add a changelog entry for last week's change to the repository's CI and release tooling. It touches no package. Do not commit.",
|
|
13
|
+
"expected_output": "One dated entry under the manifest's knowledge.changelogs, shaped by the directory's template, carrying packages: [] in its frontmatter, added to the changelog index when one lists entries; okf check --strict passes; nothing committed",
|
|
14
|
+
"files": [".agents/vulcan.manifest.json"]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": 2,
|
|
18
|
+
"prompt": "The docs for the first package in .agents/vulcan.manifest.json are out of date. Refresh them against the current source. No changelog entry - nothing shipped. Do not commit.",
|
|
19
|
+
"expected_output": "Finds the page or README that owns that package (under knowledge.wiki, or <packages[].path>/README.md when the manifest has no wiki); verifies every name, signature and default against the source under packages[].path and fixes each drift; creates no new page and no changelog entry; runs the gates; reports each claim that changed with its source file; nothing committed",
|
|
20
|
+
"files": [".agents/vulcan.manifest.json"]
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venizia/vulcan",
|
|
3
|
-
"version": "0.2.0-
|
|
3
|
+
"version": "0.2.0-2",
|
|
4
4
|
"description": "VULCAN - one setup, one workflow for every project: init, sync, doctor, upgrade, and `vulcan x` for the shipped tools. Bun-only.",
|
|
5
5
|
"homepage": "https://github.com/VENIZIA-AI/vulcan/tree/main/packages/cli",
|
|
6
6
|
"repository": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"test": "NODE_ENV=test bun test --env-file=.env.test"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@venizia/vulcan-atlas": "0.2.0-
|
|
48
|
-
"@venizia/vulcan-okf": "0.2.0-
|
|
47
|
+
"@venizia/vulcan-atlas": "0.2.0-2",
|
|
48
|
+
"@venizia/vulcan-okf": "0.2.0-2",
|
|
49
49
|
"zod": "^4.5.4"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
@@ -6,6 +6,15 @@ import { diffBaseRules, planPrune, planSync, SyncConstants, SyncStatuses } from
|
|
|
6
6
|
|
|
7
7
|
const DRY_RUN_FLAG = '--dry-run';
|
|
8
8
|
const PRUNE_FLAG = '--prune';
|
|
9
|
+
const LABEL_WIDTH = 9;
|
|
10
|
+
|
|
11
|
+
/** Under --dry-run nothing is written, so a write is labelled as what it would do. */
|
|
12
|
+
const DRY_RUN_LABELS: Record<string, string> = {
|
|
13
|
+
[SyncStatuses.CREATED]: 'would create',
|
|
14
|
+
[SyncStatuses.REPLACED]: 'would replace',
|
|
15
|
+
[SyncStatuses.REMOVED]: 'would remove',
|
|
16
|
+
};
|
|
17
|
+
const DRY_RUN_LABEL_WIDTH = Math.max(...Object.values(DRY_RUN_LABELS).map(label => label.length));
|
|
9
18
|
|
|
10
19
|
export const runSyncCommand = async (opts: ICommandContext): Promise<number> => {
|
|
11
20
|
const loaded = await loadConfig({ root: opts.cwd });
|
|
@@ -22,6 +31,7 @@ export const runSyncCommand = async (opts: ICommandContext): Promise<number> =>
|
|
|
22
31
|
const actions = [...planSync({ root: opts.cwd, config: loaded.config }), ...pruned];
|
|
23
32
|
// Read before the manifest action rewrites the hashes it compares against.
|
|
24
33
|
const changes = diffBaseRules({ root: opts.cwd });
|
|
34
|
+
const width = dryRun ? DRY_RUN_LABEL_WIDTH : LABEL_WIDTH;
|
|
25
35
|
|
|
26
36
|
for (const action of actions) {
|
|
27
37
|
if (!dryRun) {
|
|
@@ -29,14 +39,13 @@ export const runSyncCommand = async (opts: ICommandContext): Promise<number> =>
|
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
const note = action.note === undefined ? '' : ` (${action.note})`;
|
|
42
|
+
const label = (dryRun ? DRY_RUN_LABELS[action.status] : undefined) ?? action.status;
|
|
32
43
|
|
|
33
|
-
process.stderr.write(
|
|
34
|
-
` ${action.status.padEnd(9)} ${relative(opts.cwd, action.path)}${note}\n`,
|
|
35
|
-
);
|
|
44
|
+
process.stderr.write(` ${label.padEnd(width)} ${relative(opts.cwd, action.path)}${note}\n`);
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
for (const { id, change, text } of changes) {
|
|
39
|
-
process.stderr.write(` ${change.padEnd(
|
|
48
|
+
process.stderr.write(` ${change.padEnd(width)} ${[id, text].filter(Boolean).join(' ')}\n`);
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
const settingsBroken = actions.some(
|
package/src/doctor/checks.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Glob } from 'bun';
|
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { BASE_DIRECTORY, ProjectLayout } from '../common/index.ts';
|
|
5
|
+
import { resolvePackages } from '../config/index.ts';
|
|
5
6
|
import type { TResolvedConfig } from '../config/index.ts';
|
|
6
7
|
import {
|
|
7
8
|
parseBaseReferences,
|
|
@@ -271,15 +272,20 @@ const hasStylePackage = (opts: { directory: string }): boolean => {
|
|
|
271
272
|
/**
|
|
272
273
|
* D9: the shared coding style - every lint config builds on it, and the package whose `.configs/`
|
|
273
274
|
* holds the config declares it, or the root does. A project with no lint config declares it at the
|
|
274
|
-
* root.
|
|
275
|
+
* root. Owners are `packages/*` and every configured package's `path`, such as `umbras/kit`.
|
|
275
276
|
*/
|
|
276
277
|
export const checkStyle = (opts: ICheckContext): IViolation[] => {
|
|
277
278
|
const violations: IViolation[] = [];
|
|
278
279
|
const owners = [
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
join('packages'
|
|
282
|
-
|
|
280
|
+
...new Set([
|
|
281
|
+
'.',
|
|
282
|
+
...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
|
|
283
|
+
join('packages', name),
|
|
284
|
+
),
|
|
285
|
+
...resolvePackages({ config: opts.config }).map(
|
|
286
|
+
entry => relative(opts.root, join(opts.root, entry.path)) || '.',
|
|
287
|
+
),
|
|
288
|
+
]),
|
|
283
289
|
];
|
|
284
290
|
const isRootDeclared = hasStylePackage({ directory: opts.root });
|
|
285
291
|
let lintConfigs = 0;
|
package/src/router.ts
CHANGED
|
@@ -66,6 +66,11 @@ export const runVulcan = async (opts: { argv: string[]; cwd: string }): Promise<
|
|
|
66
66
|
return ExitCodes.FAILURE;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// `x` hands its arguments to the tool, so only a help flag in the tool's place is vulcan's.
|
|
70
|
+
if (name === VulcanCommands.X && HELP_ALIASES.has(args[0] ?? '')) {
|
|
71
|
+
return runHelpCommand();
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
const accepted = ACCEPTED_ARGS[name];
|
|
70
75
|
|
|
71
76
|
if (accepted !== undefined) {
|
|
@@ -26,6 +26,8 @@ export class SyncConstants {
|
|
|
26
26
|
static readonly MANAGED_FILE = 'vulcan-managed.json';
|
|
27
27
|
static readonly SKILLS_DIRECTORY = 'skills';
|
|
28
28
|
static readonly SKILL_FILE = 'SKILL.md';
|
|
29
|
+
/** The settings key of hook entries: sync takes a managed hook back by its command, not its whole entry. */
|
|
30
|
+
static readonly HOOKS_KEY = 'hooks';
|
|
29
31
|
/** Objects of independent entries: merged one level deep, so personal entries survive shared ones. */
|
|
30
32
|
static readonly MERGE_ONE_LEVEL = new Set(['permissions', 'hooks', 'enabledPlugins', 'env']);
|
|
31
33
|
}
|
package/src/sync/planner.ts
CHANGED
|
@@ -162,6 +162,19 @@ const findSkillDirectories = (opts: { directory: string }): string[] =>
|
|
|
162
162
|
/** Shipped and project skills, linked flat into `.claude/skills/<name>`; a real directory there is left alone. */
|
|
163
163
|
const planSkillLinks = (opts: { root: string }): ISyncAction[] => {
|
|
164
164
|
const target = join(opts.root, SyncConstants.CLAUDE_DIRECTORY, SyncConstants.SKILLS_DIRECTORY);
|
|
165
|
+
|
|
166
|
+
// A symlinked skills directory points outside the project: writing links through it would land them there.
|
|
167
|
+
if (isSymlink({ path: target })) {
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
path: target,
|
|
171
|
+
status: SyncStatuses.SKIPPED,
|
|
172
|
+
note: 'a symlink - left untouched',
|
|
173
|
+
apply: NO_CHANGE,
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
const stableSkills = join(stableBaseDirectoryOf(opts), SyncConstants.SKILLS_DIRECTORY);
|
|
166
179
|
const shipped = findSkillDirectories({
|
|
167
180
|
directory: join(BASE_DIRECTORY, SyncConstants.SKILLS_DIRECTORY),
|
package/src/sync/prune.ts
CHANGED
|
@@ -3,9 +3,18 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { BASE_DIRECTORY } from '../common/index.ts';
|
|
4
4
|
import { SyncConstants, SyncStatuses } from './common/index.ts';
|
|
5
5
|
import type { ISyncAction, TSettings } from './common/index.ts';
|
|
6
|
+
import { extractHookCommands, isSettingsObject, removeHookCommands } from './settings.ts';
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The exact hook commands released VULCAN versions shipped (0.1.0-x, then 0.2.0-0). Only these are
|
|
10
|
+
* an old VULCAN form: a shipped hook a developer customised is theirs, and never pruned.
|
|
11
|
+
*/
|
|
12
|
+
const RETIRED_SHIPPED_COMMANDS = new Set([
|
|
13
|
+
`bun "\${CLAUDE_PROJECT_DIR}"/node_modules/@venizia/vulcan/base/hooks/session-start.ts`,
|
|
14
|
+
`bun "\${CLAUDE_PROJECT_DIR}"/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts`,
|
|
15
|
+
`f="\${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/session-start.ts"; if [ -f "$f" ] && command -v bun >/dev/null 2>&1; then bun "$f"; s=$?; [ "$s" -eq 0 ] && exit 0; exit 2; fi; echo "vulcan: the session-start hook cannot run (node_modules/@venizia/vulcan or bun is missing) - run bun install" >&2; exit 2`,
|
|
16
|
+
`f="\${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts"; if [ -f "$f" ] && command -v bun >/dev/null 2>&1; then bun "$f"; s=$?; [ "$s" -eq 0 ] && exit 0; exit 2; fi; if grep -Eq '"command" *: *"bun (install|i)( --?[a-z][a-z0-9-]*(=[A-Za-z0-9._-]+)?)*"'; then exit 0; fi; echo "vulcan: the guard-writes hook cannot run (node_modules/@venizia/vulcan or bun is missing) - run bun install (the one command that passes until then)" >&2; exit 2`,
|
|
17
|
+
]);
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* A hook a project kept under its own `.agents/` - where plugin-era setups put theirs: the whole
|
|
@@ -15,15 +24,15 @@ const SHIPPED_HOOKS_PATH = '@venizia/vulcan/base/hooks/';
|
|
|
15
24
|
const AGENTS_HOOK =
|
|
16
25
|
/^(?:bun|node)\s+(?:"?\$\{?CLAUDE_PROJECT_DIR\}?"?\/)?(\.agents\/[\w./-]+\.ts)\s*$/;
|
|
17
26
|
|
|
18
|
-
type THookEntries = Record<string, { hooks?: { command?: string }[] }[]>;
|
|
19
|
-
|
|
20
27
|
const readJson = (opts: { path: string }): TSettings | undefined => {
|
|
21
28
|
if (!existsSync(opts.path)) {
|
|
22
29
|
return undefined;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
try {
|
|
26
|
-
|
|
33
|
+
const parsed = { value: JSON.parse(readFileSync(opts.path, 'utf8')) };
|
|
34
|
+
|
|
35
|
+
return isSettingsObject(parsed) ? parsed.value : undefined;
|
|
27
36
|
} catch (error) {
|
|
28
37
|
console.error(
|
|
29
38
|
`[readJson] ${opts.path} | ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -33,17 +42,12 @@ const readJson = (opts: { path: string }): TSettings | undefined => {
|
|
|
33
42
|
}
|
|
34
43
|
};
|
|
35
44
|
|
|
36
|
-
const commandsOf = (opts: { settings: TSettings | undefined }): string[] =>
|
|
37
|
-
Object.values((opts.settings?.hooks ?? {}) as THookEntries).flatMap(entries =>
|
|
38
|
-
entries.flatMap(entry => (entry.hooks ?? []).flatMap(hook => hook.command ?? [])),
|
|
39
|
-
);
|
|
40
|
-
|
|
41
45
|
/**
|
|
42
|
-
* A command sync did not write that is known to be stale: a shipped hook
|
|
43
|
-
*
|
|
46
|
+
* A command sync did not write that is known to be stale: a shipped hook exactly as an older VULCAN
|
|
47
|
+
* released it, or a `.agents/` hook whose file is gone. Any other command is a developer's own.
|
|
44
48
|
*/
|
|
45
49
|
const isStaleCommand = (opts: { root: string; command: string; shipped: Set<string> }): boolean => {
|
|
46
|
-
if (opts.command
|
|
50
|
+
if (RETIRED_SHIPPED_COMMANDS.has(opts.command)) {
|
|
47
51
|
return !opts.shipped.has(opts.command);
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -55,24 +59,23 @@ const isStaleCommand = (opts: { root: string; command: string; shipped: Set<stri
|
|
|
55
59
|
/** Takes one command out of the settings file; an entry or an event left empty goes with it. */
|
|
56
60
|
const removeHookCommand = (opts: { path: string; command: string }): void => {
|
|
57
61
|
const settings = readJson({ path: opts.path });
|
|
58
|
-
const hooks =
|
|
62
|
+
const hooks = { value: settings?.[SyncConstants.HOOKS_KEY] };
|
|
59
63
|
|
|
60
|
-
if (settings === undefined) {
|
|
64
|
+
if (settings === undefined || !isSettingsObject(hooks)) {
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
for (const [event, entries] of Object.entries(hooks)) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.filter(entry => entry.hooks.length > 0);
|
|
68
|
+
for (const [event, entries] of Object.entries(hooks.value)) {
|
|
69
|
+
if (!Array.isArray(entries)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kept = removeHookCommands({ entries, commands: new Set([opts.command]) });
|
|
71
74
|
|
|
72
75
|
if (kept.length > 0) {
|
|
73
|
-
hooks[event] = kept;
|
|
76
|
+
hooks.value[event] = kept;
|
|
74
77
|
} else {
|
|
75
|
-
delete hooks[event];
|
|
78
|
+
delete hooks.value[event];
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
@@ -96,20 +99,23 @@ export const planPrune = (opts: { root: string }): ISyncAction[] => {
|
|
|
96
99
|
const directory = join(opts.root, SyncConstants.CLAUDE_DIRECTORY);
|
|
97
100
|
const settingsPath = join(directory, SyncConstants.SETTINGS_FILE);
|
|
98
101
|
const managed = new Set(
|
|
99
|
-
|
|
102
|
+
extractHookCommands({
|
|
103
|
+
settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }),
|
|
104
|
+
}),
|
|
100
105
|
);
|
|
101
106
|
const shipped = new Set(
|
|
102
|
-
|
|
107
|
+
extractHookCommands({
|
|
103
108
|
settings: readJson({ path: join(BASE_DIRECTORY, 'claude', SyncConstants.SETTINGS_FILE) }),
|
|
104
109
|
}),
|
|
105
110
|
);
|
|
106
111
|
const staleCommands = [
|
|
107
|
-
...new Set(
|
|
112
|
+
...new Set(extractHookCommands({ settings: readJson({ path: settingsPath }) })),
|
|
108
113
|
].filter(
|
|
109
114
|
command => !managed.has(command) && isStaleCommand({ root: opts.root, command, shipped }),
|
|
110
115
|
);
|
|
111
116
|
const skills = join(directory, SyncConstants.SKILLS_DIRECTORY);
|
|
112
|
-
|
|
117
|
+
// lstat, not stat: a symlinked skills directory leads out of the project and is never walked.
|
|
118
|
+
const deadLinks = lstatSync(skills, { throwIfNoEntry: false })?.isDirectory()
|
|
113
119
|
? readdirSync(skills)
|
|
114
120
|
.map(name => join(skills, name))
|
|
115
121
|
.filter(path => isDeadLink({ path }))
|
package/src/sync/settings.ts
CHANGED
|
@@ -48,6 +48,71 @@ export const mergeSettings = (opts: { base: TSettings; overlay: TSettings }): TS
|
|
|
48
48
|
|
|
49
49
|
const toKey = (opts: { value: unknown }): string => JSON.stringify(opts.value);
|
|
50
50
|
|
|
51
|
+
/** The command of a hook; `undefined` when `hook` is not an object with a command string. */
|
|
52
|
+
const toHookCommand = (opts: { hook: unknown }): string | undefined => {
|
|
53
|
+
const hook = { value: opts.hook };
|
|
54
|
+
|
|
55
|
+
return isSettingsObject(hook) && typeof hook.value.command === 'string'
|
|
56
|
+
? hook.value.command
|
|
57
|
+
: undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A hook entry and its hooks; `undefined` when `entry` is not an object with a `hooks` array. */
|
|
61
|
+
const toHookEntry = (opts: {
|
|
62
|
+
entry: unknown;
|
|
63
|
+
}): { entry: TSettings; hooks: unknown[] } | undefined => {
|
|
64
|
+
const entry = { value: opts.entry };
|
|
65
|
+
|
|
66
|
+
return isSettingsObject(entry) && Array.isArray(entry.value.hooks)
|
|
67
|
+
? { entry: entry.value, hooks: entry.value.hooks }
|
|
68
|
+
: undefined;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Every hook command in one event's entries; anything that is not an entry or a command is skipped. */
|
|
72
|
+
export const extractEntryCommands = (opts: { entries: unknown }): string[] =>
|
|
73
|
+
Array.isArray(opts.entries)
|
|
74
|
+
? opts.entries.flatMap(entry =>
|
|
75
|
+
(toHookEntry({ entry })?.hooks ?? []).flatMap(hook => toHookCommand({ hook }) ?? []),
|
|
76
|
+
)
|
|
77
|
+
: [];
|
|
78
|
+
|
|
79
|
+
/** Every hook command of a settings file, across its events. */
|
|
80
|
+
export const extractHookCommands = (opts: { settings: TSettings | undefined }): string[] => {
|
|
81
|
+
const hooks = { value: opts.settings?.[SyncConstants.HOOKS_KEY] };
|
|
82
|
+
|
|
83
|
+
return isSettingsObject(hooks)
|
|
84
|
+
? Object.values(hooks.value).flatMap(entries => extractEntryCommands({ entries }))
|
|
85
|
+
: [];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `entries` without the hooks whose command is in `commands`: an entry's other hooks and keys stay,
|
|
90
|
+
* an entry left with no hooks goes, and anything that is not an entry is kept as it is.
|
|
91
|
+
*/
|
|
92
|
+
export const removeHookCommands = (opts: {
|
|
93
|
+
entries: unknown[];
|
|
94
|
+
commands: Set<string>;
|
|
95
|
+
}): unknown[] =>
|
|
96
|
+
opts.entries.flatMap(entry => {
|
|
97
|
+
const hookEntry = toHookEntry({ entry });
|
|
98
|
+
|
|
99
|
+
if (hookEntry === undefined) {
|
|
100
|
+
return [entry];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const kept = hookEntry.hooks.filter(hook => {
|
|
104
|
+
const command = toHookCommand({ hook });
|
|
105
|
+
|
|
106
|
+
return command === undefined || !opts.commands.has(command);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (kept.length === hookEntry.hooks.length) {
|
|
110
|
+
return [entry];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return kept.length > 0 ? [{ ...hookEntry.entry, hooks: kept }] : [];
|
|
114
|
+
});
|
|
115
|
+
|
|
51
116
|
/** `settings` with its keys in `reference`'s order: keys `reference` has keep their place, new keys follow. */
|
|
52
117
|
const orderKeysLike = (opts: { settings: TSettings; reference: TSettings }): TSettings => {
|
|
53
118
|
const keys = [
|
|
@@ -109,7 +174,15 @@ export const removeManagedSettings = (opts: {
|
|
|
109
174
|
|
|
110
175
|
if (Array.isArray(innerCurrent) && Array.isArray(innerManaged)) {
|
|
111
176
|
const managedKeys = new Set(innerManaged.map(entry => toKey({ value: entry })));
|
|
112
|
-
const
|
|
177
|
+
const unmatched = innerCurrent.filter(entry => !managedKeys.has(toKey({ value: entry })));
|
|
178
|
+
// A hand-edited entry no longer matches whole: its managed hooks still go, by command.
|
|
179
|
+
const kept =
|
|
180
|
+
key === SyncConstants.HOOKS_KEY
|
|
181
|
+
? removeHookCommands({
|
|
182
|
+
entries: unmatched,
|
|
183
|
+
commands: new Set(extractEntryCommands({ entries: innerManaged })),
|
|
184
|
+
})
|
|
185
|
+
: unmatched;
|
|
113
186
|
|
|
114
187
|
if (kept.length > 0) {
|
|
115
188
|
inner[innerKey] = kept;
|