@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/doctor/checks.ts
CHANGED
|
@@ -3,8 +3,15 @@ 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
5
|
import type { TResolvedConfig } from '../config/index.ts';
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
parseBaseReferences,
|
|
8
|
+
readBaseRules,
|
|
9
|
+
readRetiredRuleIds,
|
|
10
|
+
readSlots,
|
|
11
|
+
RuleConstants,
|
|
12
|
+
} from '../rules/index.ts';
|
|
13
|
+
import { ManifestConstants, projectNameOf } from '../manifest/index.ts';
|
|
14
|
+
import { planManifest, planPrune, planSync, SyncStatuses } from '../sync/index.ts';
|
|
8
15
|
import { DoctorChecks, DoctorConstants } from './common/index.ts';
|
|
9
16
|
import type { IViolation, TDoctorCheck } from './common/index.ts';
|
|
10
17
|
import { parseMakeIncludes, parseMakeTargets } from './makefile.ts';
|
|
@@ -46,17 +53,28 @@ export const checkLayout = (opts: ICheckContext): IViolation[] =>
|
|
|
46
53
|
}),
|
|
47
54
|
);
|
|
48
55
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
/**
|
|
57
|
+
* The base owns the `V-` namespace: a project defines its rules with bare IDs, and a `V-` ID in its
|
|
58
|
+
* rules points at a base rule - one the base does not carry, or has retired, dangles.
|
|
59
|
+
*/
|
|
60
|
+
export const checkRuleIds = (opts: ICheckContext): IViolation[] => {
|
|
61
|
+
const base = readBaseRules();
|
|
62
|
+
const retired = new Set(readRetiredRuleIds());
|
|
63
|
+
|
|
64
|
+
return parseBaseReferences({
|
|
65
|
+
markdown: readText({ file: join(opts.root, '.agents', 'rules.md') }),
|
|
66
|
+
})
|
|
67
|
+
.filter(id => base[id] === undefined)
|
|
53
68
|
.map(id =>
|
|
54
69
|
createViolation({
|
|
55
70
|
check: DoctorChecks.RULE_IDS,
|
|
56
71
|
file: '.agents/rules.md',
|
|
57
|
-
message:
|
|
72
|
+
message: retired.has(id)
|
|
73
|
+
? `${id} is retired in the base - drop the reference`
|
|
74
|
+
: `${id} is not a base rule - give the extension a bare ID`,
|
|
58
75
|
}),
|
|
59
76
|
);
|
|
77
|
+
};
|
|
60
78
|
|
|
61
79
|
export const checkRuleSlots = (opts: ICheckContext): IViolation[] => {
|
|
62
80
|
const slots = readSlots();
|
|
@@ -115,7 +133,7 @@ export const checkSkills = (opts: ICheckContext): IViolation[] => {
|
|
|
115
133
|
createViolation({
|
|
116
134
|
check: DoctorChecks.SKILLS,
|
|
117
135
|
file: `.agents/skills/${name}`,
|
|
118
|
-
message: `'${name}' is a shipped skill - rename the project skill`,
|
|
136
|
+
message: `'${name}' is a shipped skill - rename the project skill, for example to '${projectNameOf({ root: opts.root, config: opts.config })}-${name}'`,
|
|
119
137
|
}),
|
|
120
138
|
);
|
|
121
139
|
};
|
|
@@ -198,6 +216,7 @@ export const checkHooks = (opts: ICheckContext): IViolation[] =>
|
|
|
198
216
|
export const checkAdapters = (opts: ICheckContext): IViolation[] =>
|
|
199
217
|
planSync({ root: opts.root, config: opts.config })
|
|
200
218
|
.filter(action => action.status !== SyncStatuses.UNCHANGED)
|
|
219
|
+
.filter(action => action.path !== join(opts.root, ManifestConstants.FILE))
|
|
201
220
|
.map(action =>
|
|
202
221
|
createViolation({
|
|
203
222
|
check: DoctorChecks.ADAPTERS,
|
|
@@ -209,8 +228,33 @@ export const checkAdapters = (opts: ICheckContext): IViolation[] =>
|
|
|
209
228
|
}),
|
|
210
229
|
);
|
|
211
230
|
|
|
212
|
-
|
|
213
|
-
|
|
231
|
+
/** What `sync --prune` would remove: an old VULCAN or plugin-era hook command, a dead skill link. */
|
|
232
|
+
export const checkPrune = (opts: ICheckContext): IViolation[] =>
|
|
233
|
+
planPrune({ root: opts.root }).map(action =>
|
|
234
|
+
createViolation({
|
|
235
|
+
check: DoctorChecks.PRUNE,
|
|
236
|
+
file: relative(opts.root, action.path),
|
|
237
|
+
message: `${action.note ?? 'stale'} - run '${ProjectLayout.VULCAN_COMMAND} sync --prune'`,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
/** C1: the committed manifest is what the config renders - atlas reads the file, never the config. */
|
|
242
|
+
export const checkManifest = (opts: ICheckContext): IViolation[] => {
|
|
243
|
+
const action = planManifest({ root: opts.root, config: opts.config });
|
|
244
|
+
|
|
245
|
+
return action.status === SyncStatuses.UNCHANGED
|
|
246
|
+
? []
|
|
247
|
+
: [
|
|
248
|
+
createViolation({
|
|
249
|
+
check: DoctorChecks.MANIFEST,
|
|
250
|
+
file: ManifestConstants.FILE,
|
|
251
|
+
message: `${action.status === SyncStatuses.CREATED ? 'missing' : 'stale against the config'} - run '${ProjectLayout.VULCAN_COMMAND} sync' and commit it`,
|
|
252
|
+
}),
|
|
253
|
+
];
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const hasStylePackage = (opts: { directory: string }): boolean => {
|
|
257
|
+
const file = join(opts.directory, 'package.json');
|
|
214
258
|
|
|
215
259
|
if (!existsSync(file)) {
|
|
216
260
|
return false;
|
|
@@ -224,31 +268,47 @@ const hasStylePackage = (opts: { root: string }): boolean => {
|
|
|
224
268
|
return ProjectLayout.STYLE_PACKAGE in { ...manifest.dependencies, ...manifest.devDependencies };
|
|
225
269
|
};
|
|
226
270
|
|
|
227
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* D9: the shared coding style - every lint config builds on it, and the package whose `.configs/`
|
|
273
|
+
* holds the config declares it, or the root does. A project with no lint config declares it at the
|
|
274
|
+
* root.
|
|
275
|
+
*/
|
|
228
276
|
export const checkStyle = (opts: ICheckContext): IViolation[] => {
|
|
229
277
|
const violations: IViolation[] = [];
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
violations.push(
|
|
233
|
-
createViolation({
|
|
234
|
-
check: DoctorChecks.STYLE,
|
|
235
|
-
file: 'package.json',
|
|
236
|
-
message: `add ${ProjectLayout.STYLE_PACKAGE} as a devDependency`,
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const configDirectories = [
|
|
242
|
-
'.configs',
|
|
278
|
+
const owners = [
|
|
279
|
+
'.',
|
|
243
280
|
...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
|
|
244
|
-
join('packages', name
|
|
281
|
+
join('packages', name),
|
|
245
282
|
),
|
|
246
283
|
];
|
|
284
|
+
const isRootDeclared = hasStylePackage({ directory: opts.root });
|
|
285
|
+
let lintConfigs = 0;
|
|
286
|
+
|
|
287
|
+
for (const owner of owners) {
|
|
288
|
+
const directory = join(owner, '.configs');
|
|
289
|
+
const names = existsSync(join(opts.root, directory))
|
|
290
|
+
? readdirSync(join(opts.root, directory)).filter(file =>
|
|
291
|
+
DoctorConstants.LINT_CONFIG_FILE.test(file),
|
|
292
|
+
)
|
|
293
|
+
: [];
|
|
294
|
+
|
|
295
|
+
lintConfigs += names.length;
|
|
296
|
+
|
|
297
|
+
if (
|
|
298
|
+
names.length > 0 &&
|
|
299
|
+
!isRootDeclared &&
|
|
300
|
+
!hasStylePackage({ directory: join(opts.root, owner) })
|
|
301
|
+
) {
|
|
302
|
+
violations.push(
|
|
303
|
+
createViolation({
|
|
304
|
+
check: DoctorChecks.STYLE,
|
|
305
|
+
file: join(owner, 'package.json'),
|
|
306
|
+
message: `add ${ProjectLayout.STYLE_PACKAGE} as a devDependency - here, or at the root`,
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
247
310
|
|
|
248
|
-
|
|
249
|
-
for (const name of readdirSync(join(opts.root, directory)).filter(file =>
|
|
250
|
-
DoctorConstants.LINT_CONFIG_FILE.test(file),
|
|
251
|
-
)) {
|
|
311
|
+
for (const name of names) {
|
|
252
312
|
const file = join(directory, name);
|
|
253
313
|
|
|
254
314
|
if (!readText({ file: join(opts.root, file) }).includes(ProjectLayout.STYLE_PACKAGE)) {
|
|
@@ -263,5 +323,15 @@ export const checkStyle = (opts: ICheckContext): IViolation[] => {
|
|
|
263
323
|
}
|
|
264
324
|
}
|
|
265
325
|
|
|
326
|
+
if (lintConfigs === 0 && !isRootDeclared) {
|
|
327
|
+
violations.push(
|
|
328
|
+
createViolation({
|
|
329
|
+
check: DoctorChecks.STYLE,
|
|
330
|
+
file: 'package.json',
|
|
331
|
+
message: `add ${ProjectLayout.STYLE_PACKAGE} as a devDependency`,
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
266
336
|
return violations;
|
|
267
337
|
};
|
|
@@ -8,6 +8,9 @@ export class DoctorChecks {
|
|
|
8
8
|
static readonly HOOKS = 'hooks';
|
|
9
9
|
static readonly ADAPTERS = 'adapters';
|
|
10
10
|
static readonly STYLE = 'style';
|
|
11
|
+
static readonly MANIFEST = 'manifest';
|
|
12
|
+
static readonly TAGS = 'tags';
|
|
13
|
+
static readonly PRUNE = 'prune';
|
|
11
14
|
static readonly SCHEME_SET = new Set<string>([
|
|
12
15
|
this.CONFIG,
|
|
13
16
|
this.LAYOUT,
|
|
@@ -18,6 +21,9 @@ export class DoctorChecks {
|
|
|
18
21
|
this.HOOKS,
|
|
19
22
|
this.ADAPTERS,
|
|
20
23
|
this.STYLE,
|
|
24
|
+
this.MANIFEST,
|
|
25
|
+
this.TAGS,
|
|
26
|
+
this.PRUNE,
|
|
21
27
|
]);
|
|
22
28
|
static isValid(value: string): boolean {
|
|
23
29
|
return this.SCHEME_SET.has(value);
|
package/src/doctor/index.ts
CHANGED
package/src/doctor/runner.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { relative } from 'node:path';
|
|
|
2
2
|
import { loadConfig } from '../config/index.ts';
|
|
3
3
|
import {
|
|
4
4
|
checkAdapters,
|
|
5
|
+
checkManifest,
|
|
6
|
+
checkPrune,
|
|
5
7
|
checkHooks,
|
|
6
8
|
checkLayout,
|
|
7
9
|
checkMake,
|
|
@@ -10,6 +12,8 @@ import {
|
|
|
10
12
|
checkSkills,
|
|
11
13
|
checkStyle,
|
|
12
14
|
} from './checks.ts';
|
|
15
|
+
import { checkSkillLint } from './skills.ts';
|
|
16
|
+
import { checkTags } from './tags.ts';
|
|
13
17
|
import { DoctorChecks } from './common/index.ts';
|
|
14
18
|
import type { IViolation } from './common/index.ts';
|
|
15
19
|
|
|
@@ -41,6 +45,10 @@ export const runDoctorChecks = async (opts: { root: string }): Promise<IViolatio
|
|
|
41
45
|
...checkMake(context),
|
|
42
46
|
...checkHooks(context),
|
|
43
47
|
...checkAdapters(context),
|
|
48
|
+
...checkPrune(context),
|
|
49
|
+
...checkManifest(context),
|
|
50
|
+
...checkTags(context),
|
|
51
|
+
...checkSkillLint(context),
|
|
44
52
|
...checkStyle(context),
|
|
45
53
|
];
|
|
46
54
|
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import type { ICheckContext } from './checks.ts';
|
|
4
|
+
import { DoctorChecks } from './common/index.ts';
|
|
5
|
+
import type { IViolation } from './common/index.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Anthropic's skill-authoring rules (platform.claude.com, Agent Skills best practices), as checks:
|
|
9
|
+
* the frontmatter limits, SKILL.md under 500 lines, a contents list in any file past 100 lines (an
|
|
10
|
+
* agent may preview only the first lines), references one level deep from SKILL.md, and named
|
|
11
|
+
* requirements for a skill that runs scripts.
|
|
12
|
+
*/
|
|
13
|
+
const NAME_PATTERN = /^[a-z0-9-]{1,64}$/;
|
|
14
|
+
const RESERVED_NAME = /claude|anthropic/;
|
|
15
|
+
const XML_TAG = /<\/?[a-zA-Z][^>]*>/;
|
|
16
|
+
const MAX_DESCRIPTION = 1024;
|
|
17
|
+
const MAX_BODY_LINES = 500;
|
|
18
|
+
const CONTENTS_AFTER_LINES = 100;
|
|
19
|
+
const CONTENTS_WITHIN_LINES = 30;
|
|
20
|
+
const CONTENTS_MARK = /^(\*\*Contents\*\*|#{2,3} Contents|<!-- okf:generated:contents start -->)/;
|
|
21
|
+
const MARKDOWN_LINK = /\]\(([^)#\s]+\.md)(?:#[^)]*)?\)/g;
|
|
22
|
+
const SCRIPT = /\.(js|mjs|ts|py|sh)$/;
|
|
23
|
+
const REQUIREMENTS = /^## (Requirements|Prerequisites)\b/m;
|
|
24
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
25
|
+
|
|
26
|
+
const filesOf = (opts: { directory: string }): string[] =>
|
|
27
|
+
readdirSync(opts.directory).flatMap(name => {
|
|
28
|
+
const path = join(opts.directory, name);
|
|
29
|
+
|
|
30
|
+
return statSync(path).isDirectory() ? filesOf({ directory: path }) : [path];
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const lineCountOf = (opts: { text: string }): number =>
|
|
34
|
+
opts.text.replace(/\n$/, '').split('\n').length;
|
|
35
|
+
|
|
36
|
+
const frontmatterOf = (opts: { text: string }): Record<string, unknown> => {
|
|
37
|
+
const block = FRONTMATTER.exec(opts.text)?.[1];
|
|
38
|
+
const parsed: unknown = block === undefined ? undefined : Bun.YAML.parse(block);
|
|
39
|
+
|
|
40
|
+
return typeof parsed === 'object' && parsed !== null
|
|
41
|
+
? Object.fromEntries(Object.entries(parsed))
|
|
42
|
+
: {};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Every rule one skill directory breaks, each a message naming the file. */
|
|
46
|
+
export const lintSkill = (opts: { directory: string }): string[] => {
|
|
47
|
+
const skillFile = join(opts.directory, 'SKILL.md');
|
|
48
|
+
|
|
49
|
+
if (!existsSync(skillFile)) {
|
|
50
|
+
return ['SKILL.md is missing'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const problems: string[] = [];
|
|
54
|
+
const text = readFileSync(skillFile, 'utf8');
|
|
55
|
+
const frontmatter = frontmatterOf({ text });
|
|
56
|
+
const { name, description } = frontmatter;
|
|
57
|
+
|
|
58
|
+
if (typeof name !== 'string' || !NAME_PATTERN.test(name) || RESERVED_NAME.test(name)) {
|
|
59
|
+
problems.push(
|
|
60
|
+
`SKILL.md: name '${String(name)}' - at most 64 characters of a-z, 0-9 and hyphens, never 'claude' or 'anthropic'`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
typeof description !== 'string' ||
|
|
66
|
+
description.trim().length === 0 ||
|
|
67
|
+
description.length > MAX_DESCRIPTION ||
|
|
68
|
+
XML_TAG.test(description)
|
|
69
|
+
) {
|
|
70
|
+
problems.push('SKILL.md: the description must be 1-1024 characters with no XML tags');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const body = text.slice(FRONTMATTER.exec(text)?.[0].length ?? 0);
|
|
74
|
+
const bodyLines = lineCountOf({ text: body });
|
|
75
|
+
|
|
76
|
+
if (bodyLines >= MAX_BODY_LINES) {
|
|
77
|
+
problems.push(
|
|
78
|
+
`SKILL.md: the body is ${bodyLines} lines - keep it under ${MAX_BODY_LINES}, move detail into reference files`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const files = filesOf({ directory: opts.directory }).sort();
|
|
83
|
+
const shown = (path: string): string => relative(opts.directory, path);
|
|
84
|
+
|
|
85
|
+
for (const file of files.filter(path => path.endsWith('.md'))) {
|
|
86
|
+
const content = readFileSync(file, 'utf8');
|
|
87
|
+
const count = lineCountOf({ text: content });
|
|
88
|
+
const opening = content.split('\n').slice(0, CONTENTS_WITHIN_LINES);
|
|
89
|
+
|
|
90
|
+
if (count > CONTENTS_AFTER_LINES && !opening.some(line => CONTENTS_MARK.test(line))) {
|
|
91
|
+
problems.push(
|
|
92
|
+
`${shown(file)}: ${count} lines and no contents list in its first ${CONTENTS_WITHIN_LINES} lines - an agent may read only the first lines`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (file === skillFile) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const match of content.matchAll(MARKDOWN_LINK)) {
|
|
101
|
+
const target = resolve(dirname(file), match[1]);
|
|
102
|
+
|
|
103
|
+
if (target !== skillFile && target.startsWith(`${opts.directory}/`) && existsSync(target)) {
|
|
104
|
+
problems.push(
|
|
105
|
+
`${shown(file)} links ${shown(target)} - link every reference from SKILL.md, one level deep`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (files.some(path => SCRIPT.test(path)) && !REQUIREMENTS.test(text)) {
|
|
112
|
+
problems.push(
|
|
113
|
+
'SKILL.md: the skill runs scripts and has no ## Requirements section - name what they need',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return problems;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The project's own skills, linted; a nested one is reported - sync links flat skills only. */
|
|
121
|
+
export const checkSkillLint = (opts: ICheckContext): IViolation[] => {
|
|
122
|
+
const skills = join(opts.root, '.agents', 'skills');
|
|
123
|
+
|
|
124
|
+
if (!existsSync(skills)) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return readdirSync(skills)
|
|
129
|
+
.filter(name => statSync(join(skills, name)).isDirectory())
|
|
130
|
+
.sort()
|
|
131
|
+
.flatMap(name => {
|
|
132
|
+
const directory = join(skills, name);
|
|
133
|
+
const file = `.agents/skills/${name}`;
|
|
134
|
+
|
|
135
|
+
if (!existsSync(join(directory, 'SKILL.md'))) {
|
|
136
|
+
const inner = readdirSync(directory).find(child =>
|
|
137
|
+
existsSync(join(directory, child, 'SKILL.md')),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return inner === undefined
|
|
141
|
+
? []
|
|
142
|
+
: [
|
|
143
|
+
{
|
|
144
|
+
check: DoctorChecks.SKILLS,
|
|
145
|
+
file,
|
|
146
|
+
message: `skills are flat - sync links .agents/skills/<name> only; move ${name}/${inner} up to .agents/skills/${inner}`,
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return lintSkill({ directory }).map(message => ({
|
|
152
|
+
check: DoctorChecks.SKILLS,
|
|
153
|
+
file,
|
|
154
|
+
message,
|
|
155
|
+
}));
|
|
156
|
+
});
|
|
157
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { resolvePackages } from '../config/index.ts';
|
|
3
|
+
import type { ICheckContext } from './checks.ts';
|
|
4
|
+
import { DoctorChecks } from './common/index.ts';
|
|
5
|
+
import type { IViolation } from './common/index.ts';
|
|
6
|
+
|
|
7
|
+
const escapeLiteral = (opts: { text: string }): string =>
|
|
8
|
+
opts.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
|
|
10
|
+
/** The tag pattern as a regular expression that captures `<dir>` (or `<package>`). */
|
|
11
|
+
const tagPatternOf = (opts: { template: string }): RegExp =>
|
|
12
|
+
new RegExp(
|
|
13
|
+
`^${opts.template
|
|
14
|
+
.split(/(<(?:dir|package|version|date)>)/)
|
|
15
|
+
.map(part => {
|
|
16
|
+
switch (part) {
|
|
17
|
+
case '<dir>':
|
|
18
|
+
case '<package>': {
|
|
19
|
+
return '(?<dir>[a-z0-9][a-z0-9.-]*)';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
case '<version>': {
|
|
23
|
+
return '\\S+';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
case '<date>': {
|
|
27
|
+
return '\\d{4}-\\d{2}-\\d{2}';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
default: {
|
|
31
|
+
return escapeLiteral({ text: part });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
.join('')}$`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Project Contract v1, C4: every tag `atlas.tagPattern` reads names a declared package - by
|
|
40
|
+
* directory or alias, retired ones included - because atlas computes releases from the tags. A tag
|
|
41
|
+
* of an old layout is ignored through `atlas.ignoreTags`. A repo-level pattern (no `<dir>`) has
|
|
42
|
+
* nothing to check; a directory that is not a git repository has no tags.
|
|
43
|
+
*/
|
|
44
|
+
export const checkTags = (opts: ICheckContext): IViolation[] => {
|
|
45
|
+
const template = opts.config.atlas.tagPattern;
|
|
46
|
+
|
|
47
|
+
if (!/<(?:dir|package)>/.test(template)) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const listed = spawnSync('git', ['tag', '--list'], { cwd: opts.root, encoding: 'utf8' });
|
|
52
|
+
|
|
53
|
+
if (listed.status !== 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pattern = tagPatternOf({ template });
|
|
58
|
+
const ignored = (opts.config.atlas.ignoreTags ?? []).map(glob => new Bun.Glob(glob));
|
|
59
|
+
const names = new Set(
|
|
60
|
+
resolvePackages({ config: opts.config }).flatMap(entry => [entry.dir, ...entry.aliases]),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return listed.stdout
|
|
64
|
+
.split('\n')
|
|
65
|
+
.filter(tag => tag.length > 0 && !ignored.some(glob => glob.match(tag)))
|
|
66
|
+
.flatMap(tag => {
|
|
67
|
+
const dir = pattern.exec(tag)?.groups?.dir;
|
|
68
|
+
|
|
69
|
+
return dir === undefined || names.has(dir)
|
|
70
|
+
? []
|
|
71
|
+
: [
|
|
72
|
+
{
|
|
73
|
+
check: DoctorChecks.TAGS,
|
|
74
|
+
file: 'git tags',
|
|
75
|
+
message: `tag ${tag} reads as a release of '${dir}', which no package declares - declare it (retired: true if it is gone), add it as an alias, or ignore the tag (atlas.ignoreTags)`,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
});
|
|
79
|
+
};
|
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { BASE_DIRECTORY, ProjectLayout } from '../common/index.ts';
|
|
4
|
-
import { loadConfig } from '../config/index.ts';
|
|
5
|
-
import { extractRuleRows, readSlots, RuleConstants } from '../rules/index.ts';
|
|
4
|
+
import { ConfigConstants, loadConfig } from '../config/index.ts';
|
|
5
|
+
import { extractRuleRows, parseRuleIds, readSlots, RuleConstants } from '../rules/index.ts';
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
7
|
+
/** A project row whose first cell lists IDs - `| V-W-02, V-W-03 |` - elaborates those base rules. */
|
|
8
|
+
const ID_LIST_ROW =
|
|
9
|
+
/^\|\s*(?:\*\*)?(?:[A-Z]+-)?[A-Z]-\d{2}(?:\*\*)?(?:\s*,\s*(?:\*\*)?(?:[A-Z]+-)?[A-Z]-\d{2}(?:\*\*)?)+\s*\|/;
|
|
10
|
+
|
|
11
|
+
/** The project's own picture of itself, printed verbatim at session start. */
|
|
12
|
+
const MINIMAP = /<!-- minimap-shape:start -->\n([\s\S]*?)\n?<!-- minimap-shape:end -->/;
|
|
9
13
|
|
|
10
14
|
const TABLE_HEADER = ['| ID | Rule | Enforced by |', '|----|------|-------------|'];
|
|
11
15
|
|
|
16
|
+
/** Each listed ID's row, plain or bold (`| **W-00** |`), quoted from its rule file so it cannot drift. */
|
|
12
17
|
const selectRows = (opts: { rows: string[]; ids: string[] }): string[] =>
|
|
13
18
|
opts.ids
|
|
14
|
-
.map(id => opts.rows.find(row =>
|
|
19
|
+
.map(id => opts.rows.find(row => parseRuleIds({ markdown: row })[0] === id))
|
|
15
20
|
.filter((row): row is string => row !== undefined);
|
|
16
21
|
|
|
17
|
-
const
|
|
18
|
-
existsSync(opts.file) ?
|
|
22
|
+
const readText = (opts: { file: string }): string =>
|
|
23
|
+
existsSync(opts.file) ? readFileSync(opts.file, 'utf8') : '';
|
|
19
24
|
|
|
20
25
|
/** The SessionStart context: base write boundaries, this project's slot choices and own rules, the often-dropped rules. */
|
|
21
26
|
export const buildSessionDigest = async (opts: { projectRoot: string }): Promise<string> => {
|
|
22
|
-
const baseRows =
|
|
23
|
-
|
|
27
|
+
const baseRows = extractRuleRows({
|
|
28
|
+
markdown: readText({ file: join(BASE_DIRECTORY, 'rules.md') }),
|
|
29
|
+
});
|
|
30
|
+
const projectText = readText({ file: join(opts.projectRoot, '.agents', 'rules.md') });
|
|
31
|
+
const projectRows = projectText
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter(line => extractRuleRows({ markdown: line }).length > 0 || ID_LIST_ROW.test(line));
|
|
34
|
+
const minimap = MINIMAP.exec(projectText)?.[1].trim();
|
|
24
35
|
const loaded = await loadConfig({ root: opts.projectRoot });
|
|
25
36
|
const choices = loaded.ok ? loaded.config.rules : {};
|
|
37
|
+
const oftenDropped = loaded.ok
|
|
38
|
+
? loaded.config.digest.oftenDropped
|
|
39
|
+
: ConfigConstants.DEFAULT_OFTEN_DROPPED;
|
|
26
40
|
|
|
27
41
|
const slotRows = Object.entries(readSlots()).map(([id, slot]) => {
|
|
28
42
|
const choice = choices[id];
|
|
@@ -53,12 +67,16 @@ export const buildSessionDigest = async (opts: { projectRoot: string }): Promise
|
|
|
53
67
|
lines.push('', "## This project's own rules", '', ...TABLE_HEADER, ...projectRows);
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
if (minimap !== undefined && minimap.length > 0) {
|
|
71
|
+
lines.push('', "## This project's minimap", '', minimap);
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
lines.push(
|
|
57
75
|
'',
|
|
58
76
|
'## The rules sessions drop most',
|
|
59
77
|
'',
|
|
60
78
|
...TABLE_HEADER,
|
|
61
|
-
...selectRows({ rows: baseRows, ids:
|
|
79
|
+
...selectRows({ rows: [...baseRows, ...projectRows], ids: oftenDropped }),
|
|
62
80
|
);
|
|
63
81
|
|
|
64
82
|
return `${lines.join('\n')}\n`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './render.ts';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { resolvePackages } from '../config/index.ts';
|
|
4
|
+
import type { TResolvedConfig } from '../config/index.ts';
|
|
5
|
+
import { readBaseRules } from '../rules/index.ts';
|
|
6
|
+
|
|
7
|
+
export class ManifestConstants {
|
|
8
|
+
/** Committed; what atlas reads from a project - local or fetched - and nothing else. */
|
|
9
|
+
static readonly FILE = '.agents/vulcan.manifest.json';
|
|
10
|
+
static readonly SCHEMA_VERSION = 1;
|
|
11
|
+
static readonly PROJECT_RULES = '.agents/rules.md';
|
|
12
|
+
static readonly ENV = 'VULCAN_MANIFEST';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HASH_LENGTH = 8;
|
|
16
|
+
|
|
17
|
+
/** `@acme/shop-workspace` gives `shop`; a project with no package name takes its directory's. */
|
|
18
|
+
export const projectNameOf = (opts: { root: string; config: TResolvedConfig }): string => {
|
|
19
|
+
if (opts.config.name !== undefined) {
|
|
20
|
+
return opts.config.name;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const file = join(opts.root, 'package.json');
|
|
24
|
+
const manifest: { name?: unknown } = existsSync(file)
|
|
25
|
+
? JSON.parse(readFileSync(file, 'utf8'))
|
|
26
|
+
: {};
|
|
27
|
+
const name = typeof manifest.name === 'string' ? manifest.name : basename(opts.root);
|
|
28
|
+
|
|
29
|
+
return name
|
|
30
|
+
.replace(/^@[^/]+\//, '')
|
|
31
|
+
.replace(/-workspace$/, '')
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Each base rule's ID with a hash of its row: a sync after an upgrade can say which rules changed. */
|
|
36
|
+
export const buildBaseRuleHashes = (): Record<string, string> =>
|
|
37
|
+
Object.fromEntries(
|
|
38
|
+
Object.entries(readBaseRules()).map(([id, row]) => {
|
|
39
|
+
const hash = new Bun.CryptoHasher('sha256').update(row.trim()).digest('hex');
|
|
40
|
+
|
|
41
|
+
return [id, hash.slice(0, HASH_LENGTH)];
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The project manifest (Project Contract v1, C1): data only, rendered from the config with a fixed
|
|
47
|
+
* key order, so the committed file is byte-stable and a stale one shows as a diff.
|
|
48
|
+
*/
|
|
49
|
+
export const renderManifest = (opts: { root: string; config: TResolvedConfig }): string => {
|
|
50
|
+
const { config } = opts;
|
|
51
|
+
const { wiki, changelogs, syncAreas } = config.knowledge;
|
|
52
|
+
|
|
53
|
+
const manifest = {
|
|
54
|
+
schemaVersion: ManifestConstants.SCHEMA_VERSION,
|
|
55
|
+
name: projectNameOf(opts),
|
|
56
|
+
packages: resolvePackages({ config }).map(entry => ({
|
|
57
|
+
name: entry.name,
|
|
58
|
+
dir: entry.dir,
|
|
59
|
+
path: entry.path,
|
|
60
|
+
aliases: entry.aliases,
|
|
61
|
+
retired: entry.retired,
|
|
62
|
+
})),
|
|
63
|
+
knowledge: {
|
|
64
|
+
bundle: config.knowledge.bundle,
|
|
65
|
+
...(wiki === undefined ? {} : { wiki }),
|
|
66
|
+
...(changelogs === undefined ? {} : { changelogs }),
|
|
67
|
+
...(syncAreas === undefined ? {} : { syncAreas }),
|
|
68
|
+
},
|
|
69
|
+
release: {
|
|
70
|
+
tagPattern: config.atlas.tagPattern,
|
|
71
|
+
legacyTagPrefixes: config.atlas.legacyTagPrefixes ?? [],
|
|
72
|
+
ignoreTags: config.atlas.ignoreTags ?? [],
|
|
73
|
+
},
|
|
74
|
+
rules: { project: ManifestConstants.PROJECT_RULES, base: buildBaseRuleHashes() },
|
|
75
|
+
// The sources this project reads knowledge from (atlas, git mode); a project without any carries none.
|
|
76
|
+
...(config.atlas.sources.length === 0 ? {} : { sources: config.atlas.sources }),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
80
|
+
};
|
package/src/okf.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@venizia/vulcan/okf` - the okf renderer API for a project module. A project installs
|
|
3
|
+
* `@venizia/vulcan` alone; its okf is a dependency, not resolvable from the project, so the API is
|
|
4
|
+
* re-exported here.
|
|
5
|
+
*/
|
|
6
|
+
export { defineRenderer } from '@venizia/vulcan-okf';
|
|
7
|
+
export type {
|
|
8
|
+
IRenderContext,
|
|
9
|
+
IRenderedFile,
|
|
10
|
+
IRenderer,
|
|
11
|
+
IRenderPage,
|
|
12
|
+
IRenderSource,
|
|
13
|
+
} from '@venizia/vulcan-okf';
|