@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
@@ -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 { parseRuleIds, readSlots, RuleConstants } from '../rules/index.ts';
7
- import { planSync, SyncStatuses } from '../sync/index.ts';
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
- /** 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))
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: `${id} is in the base namespace ${RuleConstants.BASE_NAMESPACE} - give the extension a bare ID`,
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
- const hasStylePackage = (opts: { root: string }): boolean => {
213
- const file = join(opts.root, 'package.json');
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
- /** D9: the shared coding style - the package is a dependency, and every lint config builds on it. */
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
- 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',
278
+ const owners = [
279
+ '.',
243
280
  ...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
244
- join('packages', name, '.configs'),
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
- 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
- )) {
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);
@@ -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';