@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 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 in an old form, or a `.agents/` hook whose file is gone) and a dead `.claude/skills` link - never a developer's own hook or a real directory. Idempotent |
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 (node_modules/@venizia/vulcan or bun is missing) - run bun install\" >&2; exit 2"
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)( --?[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"
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-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-0",
48
- "@venizia/vulcan-okf": "0.2.0-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(9)} ${[id, text].filter(Boolean).join(' ')}\n`);
48
+ process.stderr.write(` ${change.padEnd(width)} ${[id, text].filter(Boolean).join(' ')}\n`);
40
49
  }
41
50
 
42
51
  const settingsBroken = actions.some(
@@ -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
- ...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
281
- join('packages', name),
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
  }
@@ -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
- /** The directory every shipped hook lives in; a command that runs one is VULCAN's. */
8
- const SHIPPED_HOOKS_PATH = '@venizia/vulcan/base/hooks/';
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
- return JSON.parse(readFileSync(opts.path, 'utf8'));
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 in a form this VULCAN no
43
- * longer writes, or a `.agents/` hook whose file is gone. Any other command is a developer's own.
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.includes(SHIPPED_HOOKS_PATH)) {
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 = (settings?.hooks ?? {}) as THookEntries;
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
- const kept = entries
66
- .map(entry => ({
67
- ...entry,
68
- hooks: (entry.hooks ?? []).filter(hook => hook.command !== opts.command),
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
- commandsOf({ settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }) }),
102
+ extractHookCommands({
103
+ settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }),
104
+ }),
100
105
  );
101
106
  const shipped = new Set(
102
- commandsOf({
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(commandsOf({ settings: readJson({ path: settingsPath }) })),
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
- const deadLinks = existsSync(skills)
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 }))
@@ -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 kept = innerCurrent.filter(entry => !managedKeys.has(toKey({ value: entry })));
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;