@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.
Files changed (43) hide show
  1. package/README.md +10 -6
  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 +23 -5
  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 +107 -31
  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 +65 -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 +16 -0
  39. package/src/sync/common/types.ts +10 -1
  40. package/src/sync/index.ts +2 -0
  41. package/src/sync/planner.ts +42 -7
  42. package/src/sync/prune.ts +138 -0
  43. package/src/sync/settings.ts +74 -1
@@ -2,9 +2,17 @@ import { Glob } from 'bun';
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
4
  import { BASE_DIRECTORY, ProjectLayout } from '../common/index.ts';
5
+ import { resolvePackages } from '../config/index.ts';
5
6
  import type { TResolvedConfig } from '../config/index.ts';
6
- import { parseRuleIds, readSlots, RuleConstants } from '../rules/index.ts';
7
- import { planSync, SyncStatuses } from '../sync/index.ts';
7
+ import {
8
+ parseBaseReferences,
9
+ readBaseRules,
10
+ readRetiredRuleIds,
11
+ readSlots,
12
+ RuleConstants,
13
+ } from '../rules/index.ts';
14
+ import { ManifestConstants, projectNameOf } from '../manifest/index.ts';
15
+ import { planManifest, planPrune, planSync, SyncStatuses } from '../sync/index.ts';
8
16
  import { DoctorChecks, DoctorConstants } from './common/index.ts';
9
17
  import type { IViolation, TDoctorCheck } from './common/index.ts';
10
18
  import { parseMakeIncludes, parseMakeTargets } from './makefile.ts';
@@ -46,17 +54,28 @@ export const checkLayout = (opts: ICheckContext): IViolation[] =>
46
54
  }),
47
55
  );
48
56
 
49
- /** The base owns the `V-` namespace; a project's own IDs stay bare and can reuse any letter and number. */
50
- export const checkRuleIds = (opts: ICheckContext): IViolation[] =>
51
- parseRuleIds({ markdown: readText({ file: join(opts.root, '.agents', 'rules.md') }) })
52
- .filter(id => id.startsWith(RuleConstants.BASE_NAMESPACE))
57
+ /**
58
+ * The base owns the `V-` namespace: a project defines its rules with bare IDs, and a `V-` ID in its
59
+ * rules points at a base rule - one the base does not carry, or has retired, dangles.
60
+ */
61
+ export const checkRuleIds = (opts: ICheckContext): IViolation[] => {
62
+ const base = readBaseRules();
63
+ const retired = new Set(readRetiredRuleIds());
64
+
65
+ return parseBaseReferences({
66
+ markdown: readText({ file: join(opts.root, '.agents', 'rules.md') }),
67
+ })
68
+ .filter(id => base[id] === undefined)
53
69
  .map(id =>
54
70
  createViolation({
55
71
  check: DoctorChecks.RULE_IDS,
56
72
  file: '.agents/rules.md',
57
- message: `${id} is in the base namespace ${RuleConstants.BASE_NAMESPACE} - give the extension a bare ID`,
73
+ message: retired.has(id)
74
+ ? `${id} is retired in the base - drop the reference`
75
+ : `${id} is not a base rule - give the extension a bare ID`,
58
76
  }),
59
77
  );
78
+ };
60
79
 
61
80
  export const checkRuleSlots = (opts: ICheckContext): IViolation[] => {
62
81
  const slots = readSlots();
@@ -115,7 +134,7 @@ export const checkSkills = (opts: ICheckContext): IViolation[] => {
115
134
  createViolation({
116
135
  check: DoctorChecks.SKILLS,
117
136
  file: `.agents/skills/${name}`,
118
- message: `'${name}' is a shipped skill - rename the project skill`,
137
+ message: `'${name}' is a shipped skill - rename the project skill, for example to '${projectNameOf({ root: opts.root, config: opts.config })}-${name}'`,
119
138
  }),
120
139
  );
121
140
  };
@@ -198,6 +217,7 @@ export const checkHooks = (opts: ICheckContext): IViolation[] =>
198
217
  export const checkAdapters = (opts: ICheckContext): IViolation[] =>
199
218
  planSync({ root: opts.root, config: opts.config })
200
219
  .filter(action => action.status !== SyncStatuses.UNCHANGED)
220
+ .filter(action => action.path !== join(opts.root, ManifestConstants.FILE))
201
221
  .map(action =>
202
222
  createViolation({
203
223
  check: DoctorChecks.ADAPTERS,
@@ -209,8 +229,33 @@ export const checkAdapters = (opts: ICheckContext): IViolation[] =>
209
229
  }),
210
230
  );
211
231
 
212
- const hasStylePackage = (opts: { root: string }): boolean => {
213
- const file = join(opts.root, 'package.json');
232
+ /** What `sync --prune` would remove: an old VULCAN or plugin-era hook command, a dead skill link. */
233
+ export const checkPrune = (opts: ICheckContext): IViolation[] =>
234
+ planPrune({ root: opts.root }).map(action =>
235
+ createViolation({
236
+ check: DoctorChecks.PRUNE,
237
+ file: relative(opts.root, action.path),
238
+ message: `${action.note ?? 'stale'} - run '${ProjectLayout.VULCAN_COMMAND} sync --prune'`,
239
+ }),
240
+ );
241
+
242
+ /** C1: the committed manifest is what the config renders - atlas reads the file, never the config. */
243
+ export const checkManifest = (opts: ICheckContext): IViolation[] => {
244
+ const action = planManifest({ root: opts.root, config: opts.config });
245
+
246
+ return action.status === SyncStatuses.UNCHANGED
247
+ ? []
248
+ : [
249
+ createViolation({
250
+ check: DoctorChecks.MANIFEST,
251
+ file: ManifestConstants.FILE,
252
+ message: `${action.status === SyncStatuses.CREATED ? 'missing' : 'stale against the config'} - run '${ProjectLayout.VULCAN_COMMAND} sync' and commit it`,
253
+ }),
254
+ ];
255
+ };
256
+
257
+ const hasStylePackage = (opts: { directory: string }): boolean => {
258
+ const file = join(opts.directory, 'package.json');
214
259
 
215
260
  if (!existsSync(file)) {
216
261
  return false;
@@ -224,31 +269,52 @@ const hasStylePackage = (opts: { root: string }): boolean => {
224
269
  return ProjectLayout.STYLE_PACKAGE in { ...manifest.dependencies, ...manifest.devDependencies };
225
270
  };
226
271
 
227
- /** D9: the shared coding style - the package is a dependency, and every lint config builds on it. */
272
+ /**
273
+ * D9: the shared coding style - every lint config builds on it, and the package whose `.configs/`
274
+ * holds the config declares it, or the root does. A project with no lint config declares it at the
275
+ * root. Owners are `packages/*` and every configured package's `path`, such as `umbras/kit`.
276
+ */
228
277
  export const checkStyle = (opts: ICheckContext): IViolation[] => {
229
278
  const violations: IViolation[] = [];
230
-
231
- if (!hasStylePackage({ root: opts.root })) {
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',
243
- ...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
244
- join('packages', name, '.configs'),
245
- ),
279
+ const owners = [
280
+ ...new Set([
281
+ '.',
282
+ ...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
283
+ join('packages', name),
284
+ ),
285
+ ...resolvePackages({ config: opts.config }).map(
286
+ entry => relative(opts.root, join(opts.root, entry.path)) || '.',
287
+ ),
288
+ ]),
246
289
  ];
290
+ const isRootDeclared = hasStylePackage({ directory: opts.root });
291
+ let lintConfigs = 0;
292
+
293
+ for (const owner of owners) {
294
+ const directory = join(owner, '.configs');
295
+ const names = existsSync(join(opts.root, directory))
296
+ ? readdirSync(join(opts.root, directory)).filter(file =>
297
+ DoctorConstants.LINT_CONFIG_FILE.test(file),
298
+ )
299
+ : [];
300
+
301
+ lintConfigs += names.length;
302
+
303
+ if (
304
+ names.length > 0 &&
305
+ !isRootDeclared &&
306
+ !hasStylePackage({ directory: join(opts.root, owner) })
307
+ ) {
308
+ violations.push(
309
+ createViolation({
310
+ check: DoctorChecks.STYLE,
311
+ file: join(owner, 'package.json'),
312
+ message: `add ${ProjectLayout.STYLE_PACKAGE} as a devDependency - here, or at the root`,
313
+ }),
314
+ );
315
+ }
247
316
 
248
- for (const directory of configDirectories.filter(path => existsSync(join(opts.root, path)))) {
249
- for (const name of readdirSync(join(opts.root, directory)).filter(file =>
250
- DoctorConstants.LINT_CONFIG_FILE.test(file),
251
- )) {
317
+ for (const name of names) {
252
318
  const file = join(directory, name);
253
319
 
254
320
  if (!readText({ file: join(opts.root, file) }).includes(ProjectLayout.STYLE_PACKAGE)) {
@@ -263,5 +329,15 @@ export const checkStyle = (opts: ICheckContext): IViolation[] => {
263
329
  }
264
330
  }
265
331
 
332
+ if (lintConfigs === 0 && !isRootDeclared) {
333
+ violations.push(
334
+ createViolation({
335
+ check: DoctorChecks.STYLE,
336
+ file: 'package.json',
337
+ message: `add ${ProjectLayout.STYLE_PACKAGE} as a devDependency`,
338
+ }),
339
+ );
340
+ }
341
+
266
342
  return violations;
267
343
  };
@@ -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);
@@ -2,3 +2,4 @@ export * from './checks.ts';
2
2
  export * from './common/index.ts';
3
3
  export * from './makefile.ts';
4
4
  export * from './runner.ts';
5
+ export * from './skills.ts';
@@ -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
- /** The base rules a session drops most; quoted from the rule file, so the digest cannot drift. */
8
- const OFTEN_DROPPED = ['V-B-03', 'V-P-05', 'V-P-06', 'V-B-04', 'V-W-04', 'V-C-02', 'V-C-10'];
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 => row.startsWith(`| ${id} |`)))
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 readRuleRows = (opts: { file: string }): string[] =>
18
- existsSync(opts.file) ? extractRuleRows({ markdown: readFileSync(opts.file, 'utf8') }) : [];
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 = readRuleRows({ file: join(BASE_DIRECTORY, 'rules.md') });
23
- const projectRows = readRuleRows({ file: join(opts.projectRoot, '.agents', 'rules.md') });
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: OFTEN_DROPPED }),
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';