@venizia/vulcan 0.1.0-2 → 0.2.0-1
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 +10 -6
- package/base/claude/settings.json +2 -2
- package/base/hooks/guard-writes.ts +22 -7
- package/base/make/vulcan.mk +30 -21
- package/base/rules.md +12 -8
- package/base/skills/docs-writing/references/before-after.md +1 -1
- package/base/skills/docs-writing/references/page-anatomy.md +2 -2
- package/base/skills/knowledge-sync/SKILL.md +70 -136
- package/base/skills/knowledge-sync/knowledge-sync.workflow.js +43 -43
- package/base/skills/knowledge-sync/references/workflow.md +44 -0
- package/package.json +4 -3
- package/src/commands/help.command.ts +2 -1
- package/src/commands/sync.command.ts +23 -5
- package/src/commands/upgrade.command.ts +1 -2
- package/src/commands/x.command.ts +42 -1
- package/src/common/constants.ts +4 -0
- package/src/common/paths.ts +17 -1
- package/src/config/common/constants.ts +10 -0
- package/src/config/index.ts +1 -0
- package/src/config/packages.ts +33 -0
- package/src/config/schema.ts +101 -8
- package/src/doctor/checks.ts +107 -31
- package/src/doctor/common/constants.ts +6 -0
- package/src/doctor/index.ts +1 -0
- package/src/doctor/runner.ts +8 -0
- package/src/doctor/skills.ts +157 -0
- package/src/doctor/tags.ts +79 -0
- package/src/hooks/session-digest.ts +28 -10
- package/src/manifest/index.ts +1 -0
- package/src/manifest/render.ts +80 -0
- package/src/okf.ts +13 -0
- package/src/router.ts +65 -0
- package/src/rules/base.ts +19 -0
- package/src/rules/common/constants.ts +3 -0
- package/src/rules/index.ts +1 -0
- package/src/rules/parser.ts +35 -0
- package/src/sync/base-rules.ts +72 -0
- package/src/sync/common/constants.ts +16 -0
- package/src/sync/common/types.ts +10 -1
- package/src/sync/index.ts +2 -0
- package/src/sync/planner.ts +42 -7
- package/src/sync/prune.ts +138 -0
- package/src/sync/settings.ts +74 -1
package/src/router.ts
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import { COMMAND_HANDLERS } from './commands/index.ts';
|
|
2
|
+
import { runHelpCommand } from './commands/help.command.ts';
|
|
2
3
|
import { ExitCodes, VulcanCommands } from './common/index.ts';
|
|
3
4
|
|
|
4
5
|
const HELP_ALIASES = new Set(['--help', '-h']);
|
|
5
6
|
const VERSION_ALIASES = new Set(['--version', '-v']);
|
|
6
7
|
|
|
8
|
+
interface IAcceptedArgs {
|
|
9
|
+
flags: string[];
|
|
10
|
+
/** Flags that take the next argument as their value. */
|
|
11
|
+
values: string[];
|
|
12
|
+
positionals: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What each command takes; `x` hands everything to its tool, `help` and `version` take nothing. */
|
|
16
|
+
const ACCEPTED_ARGS: Record<string, IAcceptedArgs> = {
|
|
17
|
+
[VulcanCommands.INIT]: { flags: [], values: ['--config'], positionals: 0 },
|
|
18
|
+
[VulcanCommands.SYNC]: { flags: ['--dry-run', '--prune'], values: [], positionals: 0 },
|
|
19
|
+
[VulcanCommands.DOCTOR]: { flags: [], values: [], positionals: 0 },
|
|
20
|
+
[VulcanCommands.UPGRADE]: { flags: [], values: [], positionals: 1 },
|
|
21
|
+
};
|
|
22
|
+
|
|
7
23
|
/** The command name an argv asks for: no argument or a help flag means help. */
|
|
8
24
|
const resolveCommandName = (opts: { first: string | undefined }): string => {
|
|
9
25
|
if (opts.first === undefined || HELP_ALIASES.has(opts.first)) {
|
|
@@ -13,6 +29,32 @@ const resolveCommandName = (opts: { first: string | undefined }): string => {
|
|
|
13
29
|
return VERSION_ALIASES.has(opts.first) ? VulcanCommands.VERSION : opts.first;
|
|
14
30
|
};
|
|
15
31
|
|
|
32
|
+
/** The first argument `accepted` does not cover - a typo must never run the command as if unsaid. */
|
|
33
|
+
const findUnknownArg = (opts: { args: string[]; accepted: IAcceptedArgs }): string | undefined => {
|
|
34
|
+
let positionals = 0;
|
|
35
|
+
|
|
36
|
+
for (let index = 0; index < opts.args.length; index++) {
|
|
37
|
+
const arg = opts.args[index];
|
|
38
|
+
|
|
39
|
+
if (opts.accepted.values.includes(arg)) {
|
|
40
|
+
index++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (opts.accepted.flags.includes(arg)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (arg.startsWith('-') || positionals === opts.accepted.positionals) {
|
|
49
|
+
return arg;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
positionals++;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
16
58
|
export const runVulcan = async (opts: { argv: string[]; cwd: string }): Promise<number> => {
|
|
17
59
|
const [first, ...args] = opts.argv;
|
|
18
60
|
const name = resolveCommandName({ first });
|
|
@@ -24,5 +66,28 @@ export const runVulcan = async (opts: { argv: string[]; cwd: string }): Promise<
|
|
|
24
66
|
return ExitCodes.FAILURE;
|
|
25
67
|
}
|
|
26
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
|
+
|
|
74
|
+
const accepted = ACCEPTED_ARGS[name];
|
|
75
|
+
|
|
76
|
+
if (accepted !== undefined) {
|
|
77
|
+
if (args.some(arg => HELP_ALIASES.has(arg))) {
|
|
78
|
+
return runHelpCommand();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const unknown = findUnknownArg({ args, accepted });
|
|
82
|
+
|
|
83
|
+
if (unknown !== undefined) {
|
|
84
|
+
process.stderr.write(
|
|
85
|
+
`vulcan ${name}: unknown argument '${unknown}' - run 'bun x vulcan help'\n`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return ExitCodes.FAILURE;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
27
92
|
return handler({ args, cwd: opts.cwd });
|
|
28
93
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { BASE_DIRECTORY } from '../common/index.ts';
|
|
4
|
+
import { extractRuleRows, parseRetiredRuleIds, parseRuleIds } from './parser.ts';
|
|
5
|
+
|
|
6
|
+
const readBaseMarkdown = (): string => readFileSync(join(BASE_DIRECTORY, 'rules.md'), 'utf8');
|
|
7
|
+
|
|
8
|
+
/** Each base rule's ID with its table row, verbatim. */
|
|
9
|
+
export const readBaseRules = (): Record<string, string> =>
|
|
10
|
+
Object.fromEntries(
|
|
11
|
+
extractRuleRows({ markdown: readBaseMarkdown() }).map(row => {
|
|
12
|
+
const [id] = parseRuleIds({ markdown: row });
|
|
13
|
+
|
|
14
|
+
return [id, row];
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export const readRetiredRuleIds = (): string[] =>
|
|
19
|
+
parseRetiredRuleIds({ markdown: readBaseMarkdown() });
|
|
@@ -4,4 +4,7 @@ export class RuleConstants {
|
|
|
4
4
|
|
|
5
5
|
/** `W-01` (a project's own) or `V-W-01` (namespaced). */
|
|
6
6
|
static readonly ID_SOURCE = '(?:[A-Z]+-)?[A-Z]-\\d{2}';
|
|
7
|
+
|
|
8
|
+
/** `V-W-01`: an ID in the base namespace, defined in the base or pointing at it. */
|
|
9
|
+
static readonly BASE_ID_SOURCE = `${this.BASE_NAMESPACE}[A-Z]-\\d{2}`;
|
|
7
10
|
}
|
package/src/rules/index.ts
CHANGED
package/src/rules/parser.ts
CHANGED
|
@@ -12,3 +12,38 @@ export const parseRuleIds = (opts: { markdown: string }): string[] =>
|
|
|
12
12
|
/** The table rows of every rule, verbatim, in order. */
|
|
13
13
|
export const extractRuleRows = (opts: { markdown: string }): string[] =>
|
|
14
14
|
opts.markdown.split('\n').filter(line => RULE_ROW.test(line));
|
|
15
|
+
|
|
16
|
+
const BASE_ID = new RegExp(`(?<![A-Z-])${RuleConstants.BASE_ID_SOURCE}\\b`, 'g');
|
|
17
|
+
const EXTENDS_LIST = new RegExp(
|
|
18
|
+
`\\bExtends\\s+${RuleConstants.BASE_ID_SOURCE}(?:(?:\\s*,\\s*|\\s+and\\s+)${RuleConstants.BASE_ID_SOURCE})*`,
|
|
19
|
+
'g',
|
|
20
|
+
);
|
|
21
|
+
const RETIRED_ITEM = new RegExp(`^- \`(${RuleConstants.BASE_ID_SOURCE})\` - `);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The base IDs a project's rules point at, once each, in order: an `Extends V-X, V-Y and V-Z` list
|
|
25
|
+
* in a table row, and every `V-` ID in a row's first cell. A `V-` ID in prose or a later cell is not one.
|
|
26
|
+
*/
|
|
27
|
+
export const parseBaseReferences = (opts: { markdown: string }): string[] => {
|
|
28
|
+
const references = new Set<string>();
|
|
29
|
+
|
|
30
|
+
for (const line of opts.markdown.split('\n').filter(row => row.startsWith('|'))) {
|
|
31
|
+
const firstCell = line.split('|')[1] ?? '';
|
|
32
|
+
const lists = [...line.matchAll(EXTENDS_LIST)].map(match => match[0]);
|
|
33
|
+
|
|
34
|
+
for (const text of [...lists, firstCell]) {
|
|
35
|
+
for (const match of text.matchAll(BASE_ID)) {
|
|
36
|
+
references.add(match[0]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [...references];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The IDs the base retired, one `` - `V-X-NN` - why `` bullet each; they are never reused. */
|
|
45
|
+
export const parseRetiredRuleIds = (opts: { markdown: string }): string[] =>
|
|
46
|
+
opts.markdown
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map(line => RETIRED_ITEM.exec(line)?.[1])
|
|
49
|
+
.filter((id): id is string => id !== undefined);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildBaseRuleHashes, ManifestConstants } from '../manifest/index.ts';
|
|
4
|
+
import { readBaseRules, readRetiredRuleIds } from '../rules/index.ts';
|
|
5
|
+
import { BaseRuleChanges } from './common/index.ts';
|
|
6
|
+
import type { IBaseRuleChange } from './common/index.ts';
|
|
7
|
+
|
|
8
|
+
/** `| V-W-02 | Never write ... | HARD |` gives `Never write ... | HARD`. */
|
|
9
|
+
const toRuleText = (opts: { row: string }): string =>
|
|
10
|
+
opts.row
|
|
11
|
+
.trim()
|
|
12
|
+
.replace(/^\|[^|]*\|\s*/, '')
|
|
13
|
+
.replace(/\s*\|$/, '');
|
|
14
|
+
|
|
15
|
+
/** The hashes the committed manifest recorded; none before a first sync or when it is unreadable. */
|
|
16
|
+
const readSyncedHashes = (opts: { root: string }): Record<string, unknown> => {
|
|
17
|
+
const file = join(opts.root, ManifestConstants.FILE);
|
|
18
|
+
|
|
19
|
+
if (!existsSync(file)) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const manifest: { rules?: { base?: Record<string, unknown> } } = JSON.parse(
|
|
25
|
+
readFileSync(file, 'utf8'),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return manifest.rules?.base ?? {};
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error(
|
|
31
|
+
`[readSyncedHashes] ${file} | ${error instanceof Error ? error.message : String(error)}`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** V3: each base rule added, changed or removed since the committed manifest's last sync. */
|
|
39
|
+
export const diffBaseRules = (opts: { root: string }): IBaseRuleChange[] => {
|
|
40
|
+
const synced = readSyncedHashes(opts);
|
|
41
|
+
|
|
42
|
+
if (Object.keys(synced).length === 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const installed = buildBaseRuleHashes();
|
|
47
|
+
const rows = readBaseRules();
|
|
48
|
+
const retired = new Set(readRetiredRuleIds());
|
|
49
|
+
const changes: IBaseRuleChange[] = [];
|
|
50
|
+
|
|
51
|
+
for (const [id, hash] of Object.entries(installed)) {
|
|
52
|
+
if (synced[id] === hash) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
changes.push({
|
|
57
|
+
id,
|
|
58
|
+
change: synced[id] === undefined ? BaseRuleChanges.ADDED : BaseRuleChanges.CHANGED,
|
|
59
|
+
text: toRuleText({ row: rows[id] }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const id of Object.keys(synced).filter(syncedId => installed[syncedId] === undefined)) {
|
|
64
|
+
changes.push({
|
|
65
|
+
id,
|
|
66
|
+
change: BaseRuleChanges.REMOVED,
|
|
67
|
+
text: retired.has(id) ? '(retired)' : '',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return changes;
|
|
72
|
+
};
|
|
@@ -3,11 +3,14 @@ export class SyncStatuses {
|
|
|
3
3
|
static readonly REPLACED = 'replaced';
|
|
4
4
|
static readonly UNCHANGED = 'unchanged';
|
|
5
5
|
static readonly SKIPPED = 'skipped';
|
|
6
|
+
/** Taken out by `sync --prune`. */
|
|
7
|
+
static readonly REMOVED = 'removed';
|
|
6
8
|
static readonly SCHEME_SET = new Set<string>([
|
|
7
9
|
this.CREATED,
|
|
8
10
|
this.REPLACED,
|
|
9
11
|
this.UNCHANGED,
|
|
10
12
|
this.SKIPPED,
|
|
13
|
+
this.REMOVED,
|
|
11
14
|
]);
|
|
12
15
|
static isValid(value: string): boolean {
|
|
13
16
|
return this.SCHEME_SET.has(value);
|
|
@@ -23,6 +26,19 @@ export class SyncConstants {
|
|
|
23
26
|
static readonly MANAGED_FILE = 'vulcan-managed.json';
|
|
24
27
|
static readonly SKILLS_DIRECTORY = 'skills';
|
|
25
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';
|
|
26
31
|
/** Objects of independent entries: merged one level deep, so personal entries survive shared ones. */
|
|
27
32
|
static readonly MERGE_ONE_LEVEL = new Set(['permissions', 'hooks', 'enabledPlugins', 'env']);
|
|
28
33
|
}
|
|
34
|
+
|
|
35
|
+
/** How a base rule moved between the manifest's last sync and the installed base. */
|
|
36
|
+
export class BaseRuleChanges {
|
|
37
|
+
static readonly ADDED = 'added';
|
|
38
|
+
static readonly CHANGED = 'changed';
|
|
39
|
+
static readonly REMOVED = 'removed';
|
|
40
|
+
static readonly SCHEME_SET = new Set<string>([this.ADDED, this.CHANGED, this.REMOVED]);
|
|
41
|
+
static isValid(value: string): boolean {
|
|
42
|
+
return this.SCHEME_SET.has(value);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/sync/common/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TConstValue } from '../../common/index.ts';
|
|
2
|
-
import type { SyncStatuses } from './constants.ts';
|
|
2
|
+
import type { BaseRuleChanges, SyncStatuses } from './constants.ts';
|
|
3
3
|
|
|
4
4
|
export type TSyncStatus = TConstValue<typeof SyncStatuses>;
|
|
5
5
|
|
|
@@ -12,3 +12,12 @@ export interface ISyncAction {
|
|
|
12
12
|
note?: string;
|
|
13
13
|
apply: () => void;
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
export type TBaseRuleChange = TConstValue<typeof BaseRuleChanges>;
|
|
17
|
+
|
|
18
|
+
/** One base rule that moved since the last sync; `text` is its new text, or `(retired)` when the base retired it. */
|
|
19
|
+
export interface IBaseRuleChange {
|
|
20
|
+
id: string;
|
|
21
|
+
change: TBaseRuleChange;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
package/src/sync/index.ts
CHANGED
package/src/sync/planner.ts
CHANGED
|
@@ -9,10 +9,11 @@ import {
|
|
|
9
9
|
symlinkSync,
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from 'node:fs';
|
|
12
|
-
import { basename, join, resolve } from 'node:path';
|
|
13
|
-
import { BASE_DIRECTORY } from '../common/index.ts';
|
|
12
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { BASE_DIRECTORY, stableBaseDirectoryOf } from '../common/index.ts';
|
|
14
14
|
import { CodingAgents } from '../config/index.ts';
|
|
15
15
|
import type { TResolvedConfig } from '../config/index.ts';
|
|
16
|
+
import { ManifestConstants, renderManifest } from '../manifest/index.ts';
|
|
16
17
|
import { readSlots } from '../rules/index.ts';
|
|
17
18
|
import { SyncConstants, SyncStatuses } from './common/index.ts';
|
|
18
19
|
import type { ISyncAction, TSettings } from './common/index.ts';
|
|
@@ -161,9 +162,23 @@ const findSkillDirectories = (opts: { directory: string }): string[] =>
|
|
|
161
162
|
/** Shipped and project skills, linked flat into `.claude/skills/<name>`; a real directory there is left alone. */
|
|
162
163
|
const planSkillLinks = (opts: { root: string }): ISyncAction[] => {
|
|
163
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
|
+
|
|
178
|
+
const stableSkills = join(stableBaseDirectoryOf(opts), SyncConstants.SKILLS_DIRECTORY);
|
|
164
179
|
const shipped = findSkillDirectories({
|
|
165
180
|
directory: join(BASE_DIRECTORY, SyncConstants.SKILLS_DIRECTORY),
|
|
166
|
-
});
|
|
181
|
+
}).map(source => join(stableSkills, basename(source)));
|
|
167
182
|
const shippedNames = new Set(shipped.map(source => basename(source)));
|
|
168
183
|
const project = findSkillDirectories({
|
|
169
184
|
directory: join(opts.root, '.agents', SyncConstants.SKILLS_DIRECTORY),
|
|
@@ -213,12 +228,32 @@ const planSkillLinks = (opts: { root: string }): ISyncAction[] => {
|
|
|
213
228
|
return [...links, ...shadowing];
|
|
214
229
|
};
|
|
215
230
|
|
|
216
|
-
/**
|
|
217
|
-
export const
|
|
218
|
-
opts.
|
|
231
|
+
/** The committed project manifest (Project Contract v1, C1), rendered from the config. */
|
|
232
|
+
export const planManifest = (opts: { root: string; config: TResolvedConfig }): ISyncAction => {
|
|
233
|
+
const path = join(opts.root, ManifestConstants.FILE);
|
|
234
|
+
const text = renderManifest(opts);
|
|
235
|
+
const write = (): void => {
|
|
236
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
237
|
+
writeFileSync(path, text);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (!existsSync(path)) {
|
|
241
|
+
return { path, status: SyncStatuses.CREATED, apply: write };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return readFileSync(path, 'utf8') === text
|
|
245
|
+
? { path, status: SyncStatuses.UNCHANGED, apply: NO_CHANGE }
|
|
246
|
+
: { path, status: SyncStatuses.REPLACED, apply: write };
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Every adapter the project's agents need, and the manifest every project carries - planned, not written. */
|
|
250
|
+
export const planSync = (opts: { root: string; config: TResolvedConfig }): ISyncAction[] => [
|
|
251
|
+
...(opts.config.agents.includes(CodingAgents.CLAUDE)
|
|
219
252
|
? [
|
|
220
253
|
planClaudeImport({ root: opts.root }),
|
|
221
254
|
planSettings(opts),
|
|
222
255
|
...planSkillLinks({ root: opts.root }),
|
|
223
256
|
]
|
|
224
|
-
: []
|
|
257
|
+
: []),
|
|
258
|
+
planManifest(opts),
|
|
259
|
+
];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { BASE_DIRECTORY } from '../common/index.ts';
|
|
4
|
+
import { SyncConstants, SyncStatuses } from './common/index.ts';
|
|
5
|
+
import type { ISyncAction, TSettings } from './common/index.ts';
|
|
6
|
+
import { extractHookCommands, isSettingsObject, removeHookCommands } from './settings.ts';
|
|
7
|
+
|
|
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
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A hook a project kept under its own `.agents/` - where plugin-era setups put theirs: the whole
|
|
21
|
+
* command is `bun` or `node` on that file, from the project root. Any other shape - another
|
|
22
|
+
* directory's `.agents/`, `$HOME`, a `cd` first - is a developer's own and never pruned.
|
|
23
|
+
*/
|
|
24
|
+
const AGENTS_HOOK =
|
|
25
|
+
/^(?:bun|node)\s+(?:"?\$\{?CLAUDE_PROJECT_DIR\}?"?\/)?(\.agents\/[\w./-]+\.ts)\s*$/;
|
|
26
|
+
|
|
27
|
+
const readJson = (opts: { path: string }): TSettings | undefined => {
|
|
28
|
+
if (!existsSync(opts.path)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const parsed = { value: JSON.parse(readFileSync(opts.path, 'utf8')) };
|
|
34
|
+
|
|
35
|
+
return isSettingsObject(parsed) ? parsed.value : undefined;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(
|
|
38
|
+
`[readJson] ${opts.path} | ${error instanceof Error ? error.message : String(error)}`,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
const isStaleCommand = (opts: { root: string; command: string; shipped: Set<string> }): boolean => {
|
|
50
|
+
if (RETIRED_SHIPPED_COMMANDS.has(opts.command)) {
|
|
51
|
+
return !opts.shipped.has(opts.command);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const agentsHook = AGENTS_HOOK.exec(opts.command)?.[1];
|
|
55
|
+
|
|
56
|
+
return agentsHook !== undefined && !existsSync(join(opts.root, agentsHook));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Takes one command out of the settings file; an entry or an event left empty goes with it. */
|
|
60
|
+
const removeHookCommand = (opts: { path: string; command: string }): void => {
|
|
61
|
+
const settings = readJson({ path: opts.path });
|
|
62
|
+
const hooks = { value: settings?.[SyncConstants.HOOKS_KEY] };
|
|
63
|
+
|
|
64
|
+
if (settings === undefined || !isSettingsObject(hooks)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
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]) });
|
|
74
|
+
|
|
75
|
+
if (kept.length > 0) {
|
|
76
|
+
hooks.value[event] = kept;
|
|
77
|
+
} else {
|
|
78
|
+
delete hooks.value[event];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
writeFileSync(opts.path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isDeadLink = (opts: { path: string }): boolean => {
|
|
86
|
+
try {
|
|
87
|
+
return lstatSync(opts.path).isSymbolicLink() && !existsSync(opts.path);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `sync --prune`: what sync can prove is stale and did not write itself - old VULCAN and plugin-era
|
|
95
|
+
* hook commands, and skill links whose target is gone. What sync wrote last time it takes back on
|
|
96
|
+
* every run; a developer's own hooks and any real directory are never touched.
|
|
97
|
+
*/
|
|
98
|
+
export const planPrune = (opts: { root: string }): ISyncAction[] => {
|
|
99
|
+
const directory = join(opts.root, SyncConstants.CLAUDE_DIRECTORY);
|
|
100
|
+
const settingsPath = join(directory, SyncConstants.SETTINGS_FILE);
|
|
101
|
+
const managed = new Set(
|
|
102
|
+
extractHookCommands({
|
|
103
|
+
settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const shipped = new Set(
|
|
107
|
+
extractHookCommands({
|
|
108
|
+
settings: readJson({ path: join(BASE_DIRECTORY, 'claude', SyncConstants.SETTINGS_FILE) }),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const staleCommands = [
|
|
112
|
+
...new Set(extractHookCommands({ settings: readJson({ path: settingsPath }) })),
|
|
113
|
+
].filter(
|
|
114
|
+
command => !managed.has(command) && isStaleCommand({ root: opts.root, command, shipped }),
|
|
115
|
+
);
|
|
116
|
+
const skills = join(directory, SyncConstants.SKILLS_DIRECTORY);
|
|
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()
|
|
119
|
+
? readdirSync(skills)
|
|
120
|
+
.map(name => join(skills, name))
|
|
121
|
+
.filter(path => isDeadLink({ path }))
|
|
122
|
+
: [];
|
|
123
|
+
|
|
124
|
+
return [
|
|
125
|
+
...staleCommands.map((command): ISyncAction => ({
|
|
126
|
+
path: settingsPath,
|
|
127
|
+
status: SyncStatuses.REMOVED,
|
|
128
|
+
note: `stale hook: ${command}`,
|
|
129
|
+
apply: () => removeHookCommand({ path: settingsPath, command }),
|
|
130
|
+
})),
|
|
131
|
+
...deadLinks.map((path): ISyncAction => ({
|
|
132
|
+
path,
|
|
133
|
+
status: SyncStatuses.REMOVED,
|
|
134
|
+
note: 'dead link',
|
|
135
|
+
apply: () => rmSync(path),
|
|
136
|
+
})),
|
|
137
|
+
];
|
|
138
|
+
};
|
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;
|