@venizia/vulcan 0.1.0-2 → 0.2.0-0
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 +9 -5
- 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 +11 -2
- 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 +99 -29
- 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 +60 -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 +14 -0
- package/src/sync/common/types.ts +10 -1
- package/src/sync/index.ts +2 -0
- package/src/sync/planner.ts +29 -7
- package/src/sync/prune.ts +132 -0
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,23 @@ export const runVulcan = async (opts: { argv: string[]; cwd: string }): Promise<
|
|
|
24
66
|
return ExitCodes.FAILURE;
|
|
25
67
|
}
|
|
26
68
|
|
|
69
|
+
const accepted = ACCEPTED_ARGS[name];
|
|
70
|
+
|
|
71
|
+
if (accepted !== undefined) {
|
|
72
|
+
if (args.some(arg => HELP_ALIASES.has(arg))) {
|
|
73
|
+
return runHelpCommand();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const unknown = findUnknownArg({ args, accepted });
|
|
77
|
+
|
|
78
|
+
if (unknown !== undefined) {
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
`vulcan ${name}: unknown argument '${unknown}' - run 'bun x vulcan help'\n`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return ExitCodes.FAILURE;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
27
87
|
return handler({ args, cwd: opts.cwd });
|
|
28
88
|
};
|
|
@@ -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);
|
|
@@ -26,3 +29,14 @@ export class SyncConstants {
|
|
|
26
29
|
/** Objects of independent entries: merged one level deep, so personal entries survive shared ones. */
|
|
27
30
|
static readonly MERGE_ONE_LEVEL = new Set(['permissions', 'hooks', 'enabledPlugins', 'env']);
|
|
28
31
|
}
|
|
32
|
+
|
|
33
|
+
/** How a base rule moved between the manifest's last sync and the installed base. */
|
|
34
|
+
export class BaseRuleChanges {
|
|
35
|
+
static readonly ADDED = 'added';
|
|
36
|
+
static readonly CHANGED = 'changed';
|
|
37
|
+
static readonly REMOVED = 'removed';
|
|
38
|
+
static readonly SCHEME_SET = new Set<string>([this.ADDED, this.CHANGED, this.REMOVED]);
|
|
39
|
+
static isValid(value: string): boolean {
|
|
40
|
+
return this.SCHEME_SET.has(value);
|
|
41
|
+
}
|
|
42
|
+
}
|
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,10 @@ 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
|
+
const stableSkills = join(stableBaseDirectoryOf(opts), SyncConstants.SKILLS_DIRECTORY);
|
|
164
166
|
const shipped = findSkillDirectories({
|
|
165
167
|
directory: join(BASE_DIRECTORY, SyncConstants.SKILLS_DIRECTORY),
|
|
166
|
-
});
|
|
168
|
+
}).map(source => join(stableSkills, basename(source)));
|
|
167
169
|
const shippedNames = new Set(shipped.map(source => basename(source)));
|
|
168
170
|
const project = findSkillDirectories({
|
|
169
171
|
directory: join(opts.root, '.agents', SyncConstants.SKILLS_DIRECTORY),
|
|
@@ -213,12 +215,32 @@ const planSkillLinks = (opts: { root: string }): ISyncAction[] => {
|
|
|
213
215
|
return [...links, ...shadowing];
|
|
214
216
|
};
|
|
215
217
|
|
|
216
|
-
/**
|
|
217
|
-
export const
|
|
218
|
-
opts.
|
|
218
|
+
/** The committed project manifest (Project Contract v1, C1), rendered from the config. */
|
|
219
|
+
export const planManifest = (opts: { root: string; config: TResolvedConfig }): ISyncAction => {
|
|
220
|
+
const path = join(opts.root, ManifestConstants.FILE);
|
|
221
|
+
const text = renderManifest(opts);
|
|
222
|
+
const write = (): void => {
|
|
223
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
224
|
+
writeFileSync(path, text);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (!existsSync(path)) {
|
|
228
|
+
return { path, status: SyncStatuses.CREATED, apply: write };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return readFileSync(path, 'utf8') === text
|
|
232
|
+
? { path, status: SyncStatuses.UNCHANGED, apply: NO_CHANGE }
|
|
233
|
+
: { path, status: SyncStatuses.REPLACED, apply: write };
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Every adapter the project's agents need, and the manifest every project carries - planned, not written. */
|
|
237
|
+
export const planSync = (opts: { root: string; config: TResolvedConfig }): ISyncAction[] => [
|
|
238
|
+
...(opts.config.agents.includes(CodingAgents.CLAUDE)
|
|
219
239
|
? [
|
|
220
240
|
planClaudeImport({ root: opts.root }),
|
|
221
241
|
planSettings(opts),
|
|
222
242
|
...planSkillLinks({ root: opts.root }),
|
|
223
243
|
]
|
|
224
|
-
: []
|
|
244
|
+
: []),
|
|
245
|
+
planManifest(opts),
|
|
246
|
+
];
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
|
|
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/';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A hook a project kept under its own `.agents/` - where plugin-era setups put theirs: the whole
|
|
12
|
+
* command is `bun` or `node` on that file, from the project root. Any other shape - another
|
|
13
|
+
* directory's `.agents/`, `$HOME`, a `cd` first - is a developer's own and never pruned.
|
|
14
|
+
*/
|
|
15
|
+
const AGENTS_HOOK =
|
|
16
|
+
/^(?:bun|node)\s+(?:"?\$\{?CLAUDE_PROJECT_DIR\}?"?\/)?(\.agents\/[\w./-]+\.ts)\s*$/;
|
|
17
|
+
|
|
18
|
+
type THookEntries = Record<string, { hooks?: { command?: string }[] }[]>;
|
|
19
|
+
|
|
20
|
+
const readJson = (opts: { path: string }): TSettings | undefined => {
|
|
21
|
+
if (!existsSync(opts.path)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(opts.path, 'utf8'));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(
|
|
29
|
+
`[readJson] ${opts.path} | ${error instanceof Error ? error.message : String(error)}`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
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
|
+
/**
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
const isStaleCommand = (opts: { root: string; command: string; shipped: Set<string> }): boolean => {
|
|
46
|
+
if (opts.command.includes(SHIPPED_HOOKS_PATH)) {
|
|
47
|
+
return !opts.shipped.has(opts.command);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const agentsHook = AGENTS_HOOK.exec(opts.command)?.[1];
|
|
51
|
+
|
|
52
|
+
return agentsHook !== undefined && !existsSync(join(opts.root, agentsHook));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Takes one command out of the settings file; an entry or an event left empty goes with it. */
|
|
56
|
+
const removeHookCommand = (opts: { path: string; command: string }): void => {
|
|
57
|
+
const settings = readJson({ path: opts.path });
|
|
58
|
+
const hooks = (settings?.hooks ?? {}) as THookEntries;
|
|
59
|
+
|
|
60
|
+
if (settings === undefined) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
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);
|
|
71
|
+
|
|
72
|
+
if (kept.length > 0) {
|
|
73
|
+
hooks[event] = kept;
|
|
74
|
+
} else {
|
|
75
|
+
delete hooks[event];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
writeFileSync(opts.path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const isDeadLink = (opts: { path: string }): boolean => {
|
|
83
|
+
try {
|
|
84
|
+
return lstatSync(opts.path).isSymbolicLink() && !existsSync(opts.path);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `sync --prune`: what sync can prove is stale and did not write itself - old VULCAN and plugin-era
|
|
92
|
+
* hook commands, and skill links whose target is gone. What sync wrote last time it takes back on
|
|
93
|
+
* every run; a developer's own hooks and any real directory are never touched.
|
|
94
|
+
*/
|
|
95
|
+
export const planPrune = (opts: { root: string }): ISyncAction[] => {
|
|
96
|
+
const directory = join(opts.root, SyncConstants.CLAUDE_DIRECTORY);
|
|
97
|
+
const settingsPath = join(directory, SyncConstants.SETTINGS_FILE);
|
|
98
|
+
const managed = new Set(
|
|
99
|
+
commandsOf({ settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }) }),
|
|
100
|
+
);
|
|
101
|
+
const shipped = new Set(
|
|
102
|
+
commandsOf({
|
|
103
|
+
settings: readJson({ path: join(BASE_DIRECTORY, 'claude', SyncConstants.SETTINGS_FILE) }),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const staleCommands = [
|
|
107
|
+
...new Set(commandsOf({ settings: readJson({ path: settingsPath }) })),
|
|
108
|
+
].filter(
|
|
109
|
+
command => !managed.has(command) && isStaleCommand({ root: opts.root, command, shipped }),
|
|
110
|
+
);
|
|
111
|
+
const skills = join(directory, SyncConstants.SKILLS_DIRECTORY);
|
|
112
|
+
const deadLinks = existsSync(skills)
|
|
113
|
+
? readdirSync(skills)
|
|
114
|
+
.map(name => join(skills, name))
|
|
115
|
+
.filter(path => isDeadLink({ path }))
|
|
116
|
+
: [];
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
...staleCommands.map((command): ISyncAction => ({
|
|
120
|
+
path: settingsPath,
|
|
121
|
+
status: SyncStatuses.REMOVED,
|
|
122
|
+
note: `stale hook: ${command}`,
|
|
123
|
+
apply: () => removeHookCommand({ path: settingsPath, command }),
|
|
124
|
+
})),
|
|
125
|
+
...deadLinks.map((path): ISyncAction => ({
|
|
126
|
+
path,
|
|
127
|
+
status: SyncStatuses.REMOVED,
|
|
128
|
+
note: 'dead link',
|
|
129
|
+
apply: () => rmSync(path),
|
|
130
|
+
})),
|
|
131
|
+
];
|
|
132
|
+
};
|