@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.
Files changed (42) hide show
  1. package/README.md +9 -5
  2. package/base/claude/settings.json +2 -2
  3. package/base/hooks/guard-writes.ts +22 -7
  4. package/base/make/vulcan.mk +30 -21
  5. package/base/rules.md +12 -8
  6. package/base/skills/docs-writing/references/before-after.md +1 -1
  7. package/base/skills/docs-writing/references/page-anatomy.md +2 -2
  8. package/base/skills/knowledge-sync/SKILL.md +70 -136
  9. package/base/skills/knowledge-sync/knowledge-sync.workflow.js +43 -43
  10. package/base/skills/knowledge-sync/references/workflow.md +44 -0
  11. package/package.json +4 -3
  12. package/src/commands/help.command.ts +2 -1
  13. package/src/commands/sync.command.ts +11 -2
  14. package/src/commands/upgrade.command.ts +1 -2
  15. package/src/commands/x.command.ts +42 -1
  16. package/src/common/constants.ts +4 -0
  17. package/src/common/paths.ts +17 -1
  18. package/src/config/common/constants.ts +10 -0
  19. package/src/config/index.ts +1 -0
  20. package/src/config/packages.ts +33 -0
  21. package/src/config/schema.ts +101 -8
  22. package/src/doctor/checks.ts +99 -29
  23. package/src/doctor/common/constants.ts +6 -0
  24. package/src/doctor/index.ts +1 -0
  25. package/src/doctor/runner.ts +8 -0
  26. package/src/doctor/skills.ts +157 -0
  27. package/src/doctor/tags.ts +79 -0
  28. package/src/hooks/session-digest.ts +28 -10
  29. package/src/manifest/index.ts +1 -0
  30. package/src/manifest/render.ts +80 -0
  31. package/src/okf.ts +13 -0
  32. package/src/router.ts +60 -0
  33. package/src/rules/base.ts +19 -0
  34. package/src/rules/common/constants.ts +3 -0
  35. package/src/rules/index.ts +1 -0
  36. package/src/rules/parser.ts +35 -0
  37. package/src/sync/base-rules.ts +72 -0
  38. package/src/sync/common/constants.ts +14 -0
  39. package/src/sync/common/types.ts +10 -1
  40. package/src/sync/index.ts +2 -0
  41. package/src/sync/planner.ts +29 -7
  42. 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
  }
@@ -1,3 +1,4 @@
1
1
  export * from './common/index.ts';
2
+ export * from './base.ts';
2
3
  export * from './parser.ts';
3
4
  export * from './slots.ts';
@@ -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
+ }
@@ -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
@@ -1,3 +1,5 @@
1
1
  export * from './common/index.ts';
2
+ export * from './base-rules.ts';
2
3
  export * from './planner.ts';
4
+ export * from './prune.ts';
3
5
  export * from './settings.ts';
@@ -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
- /** Every adapter the project's agents need, planned, not written. */
217
- export const planSync = (opts: { root: string; config: TResolvedConfig }): ISyncAction[] =>
218
- opts.config.agents.includes(CodingAgents.CLAUDE)
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
+ };