@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
@@ -1,17 +1,21 @@
1
1
  // Reusable Workflow script for /knowledge-sync.
2
- // See .agents/plugin/skills/knowledge-sync/SKILL.md for the procedure that drives it.
2
+ // See SKILL.md and references/workflow.md beside it for the procedure that drives it.
3
3
  //
4
4
  // Invoke via: Workflow({ scriptPath: '.claude/skills/knowledge-sync/knowledge-sync.workflow.js', args: {...} })
5
5
  //
6
6
  // args = {
7
- // repo: '/abs/path/to/ignis', // repo root - `git rev-parse --show-toplevel`
7
+ // repo: '/abs/path/to/project', // repo root - `git rev-parse --show-toplevel`
8
8
  // mode: 'delta' | 'full',
9
9
  // since: 'YYYY-MM-DD', // last sync date (display label only), read from log.md
10
10
  // gitRange: '<hash>..HEAD', // commit range since the last sync - what verifiers actually diff
11
+ // bundle: '.agents/knowledge', // the manifest's knowledge.bundle
12
+ // brand: 'ACME', // the manifest's name, upper case - the spelling prose keeps
13
+ // packagePaths: ['packages/core'], // the manifest's packages[].path
14
+ // sections: ['overview', 'packages'], // the bundle's top-level directories, for full mode
11
15
  // deltaTargets: [{
12
16
  // key: 'core', // short label
13
- // area: 'packages/core-server', // git pathspec(s), space-separated
14
- // concepts: ['packages/core-server.md'], // bundle-relative concept files to verify
17
+ // area: 'packages/core', // git pathspec(s), space-separated
18
+ // concepts: ['packages/core.md'], // bundle-relative concept files to verify
15
19
  // hint: 'what changed (from commit subjects) - verify, do not trust',
16
20
  // model: 'opus' | 'sonnet', // opus for heavy/critical areas, sonnet for small ones
17
21
  // }],
@@ -19,7 +23,7 @@
19
23
  export const meta = {
20
24
  name: 'knowledge-sync',
21
25
  description:
22
- 'Re-sync .agents/knowledge curated concepts against source: delta-verify changed areas (and in full mode spot-audit everything + critics), then apply fixes per concept file',
26
+ "Re-sync the knowledge bundle's curated concepts against source: delta-verify changed areas (and in full mode spot-audit everything + critics), then apply fixes per concept file",
23
27
  phases: [
24
28
  {
25
29
  title: 'Verify',
@@ -51,7 +55,11 @@ if (!gitRange) {
51
55
  );
52
56
  }
53
57
 
54
- const KB = REPO + '/.agents/knowledge';
58
+ const BUNDLE = (input.bundle ?? '.agents/knowledge').replace(/\/+$/, '');
59
+ const KB = REPO + '/' + BUNDLE;
60
+ const BRAND = input.brand ?? 'the project';
61
+ const PACKAGE_PATHS = input.packagePaths ?? [];
62
+ const SECTIONS = input.sections ?? [];
55
63
 
56
64
  const FINDINGS_SCHEMA = {
57
65
  type: 'object',
@@ -66,7 +74,7 @@ const FINDINGS_SCHEMA = {
66
74
  concept: {
67
75
  type: 'string',
68
76
  description:
69
- 'repo-relative path of the knowledge file, e.g. .agents/knowledge/packages/core-server.md',
77
+ 'repo-relative path of the knowledge file, e.g. .agents/knowledge/packages/core.md',
70
78
  },
71
79
  kind: { type: 'string', enum: ['wrong', 'stale', 'missing'] },
72
80
  claim: {
@@ -120,46 +128,37 @@ const APPLY_SCHEMA = {
120
128
  },
121
129
  };
122
130
 
123
- const COMMON = `You are a knowledge-bundle verifier for the IGNIS framework monorepo at ${REPO} (branch develop).
124
- IGNIS is a TypeScript server framework: LoopBack 4's architecture (decorator DI, repository pattern, boot system, components) on Hono's speed.
131
+ const COMMON = `You are a knowledge-bundle verifier for the ${BRAND} repository at ${REPO}. Read ${KB}/index.md first: it says what the project is.
125
132
  The knowledge bundle lives at ${KB} - markdown concepts with YAML frontmatter (OKF format).
126
133
  Your job: verify the ASSIGNED concept files against the ACTUAL CURRENT SOURCE CODE (ground truth is code, never other docs).
127
134
  Method: read each assigned concept file fully; for every load-bearing claim (class names, file paths, lifecycle order, binding keys, routes, hierarchies, enum values, flows, seams), open the real source and confirm it. Use Grep/Read/Bash freely.
128
135
  Rules:
129
136
  - Ground truth = source code on the current working tree. docs/wiki, AGENTS.md and any CLAUDE.md may themselves be stale - this bundle was created precisely because they were.
130
137
  - NEVER run a git command that changes state (no commit/stash/checkout/reset/add). Read-only git (log, diff, show) is fine.
131
- - Generated files are OUT OF SCOPE - \`okf gen\` and \`vulcan x atlas surface|symbols|releases gen\` own them. Do not report findings on: ${KB}/reference/source-map.md, reference/components.md, reference/helpers.md, reference/binding-keys.md, reference/makefile-targets.md, reference/public-surface.md, any reference/*.json, and the \`<!-- okf:generated:packages-table -->\` region inside overview/monorepo-layout.md. (Exception: note it if you spot an extractor BUG.)
138
+ - Generated content is OUT OF SCOPE - \`vulcan x okf gen\` and \`vulcan x atlas\` own it. Do not report findings on: a file whose frontmatter has a \`generated:\` key, any \`<!-- okf:generated:\` region, and any ${KB}/reference/*.json. (Exception: note it if you spot an extractor BUG.)
132
139
  - Report only ACTIONABLE findings: wrong (contradicts code), stale (was true, code moved on), missing (a durable, high-value fact an engineer/agent needs that the concept omits). Skip volatile implementation detail (dependency lists, exhaustive config keys, version numbers).
133
140
  - Each finding MUST cite evidence (source path:line or a command you ran + output).
134
- - Style the bundle follows: hyphen not em-dash, the brand is always written IGNIS, English prose, no version numbers.
141
+ - Style the bundle follows: hyphen not em-dash, the brand is always written ${BRAND}, English prose, no version numbers.
135
142
  - Do NOT edit any files. Return findings via structured output only.`;
136
143
 
137
144
  const DELTA = `\nFocus mode DELTA: first run \`git log ${gitRange} --no-merges --oneline -- <area>\` and \`git diff\` for the interesting commits to learn exactly what changed since the bundle's last sync (${since}). Uncommitted working-tree changes are code too: also run \`git diff HEAD --stat -- <area>\` and read the diff of everything it lists. Then check whether the assigned concepts reflect the NEW reality. Also verify a sample of older claims while you are in the code.`;
138
145
 
139
- // Full-mode spot groups are directory-based so they stay valid as concepts are added.
140
- const spotGroups = [
141
- {
142
- key: 'overview-ref',
143
- dirs: 'overview/ + the CURATED files under reference/ (glossary.md, key-source-files.md, external-links.md) - the other reference/ files are generated, skip them',
144
- },
145
- { key: 'architecture', dirs: 'architecture/' },
146
- {
147
- key: 'packages',
148
- dirs: 'packages/ - verify each package concept against its real src/ and package.json exports map',
149
- },
150
- {
151
- key: 'conventions',
152
- dirs: 'conventions/ - every rule must still hold in the code; gotchas.md especially, it decays fastest',
153
- },
154
- {
155
- key: 'process',
156
- dirs: 'process/ - verify every command, path, Makefile target and step actually exists and works as written (read Makefile, package.json scripts, scripts/, .githooks/, .github/workflows/)',
157
- },
158
- {
159
- key: 'examples',
160
- dirs: 'examples/ - verify each concept against the real examples/<dir>: what it demos, how it runs, what it exercises',
161
- },
162
- ];
146
+ // Full-mode spot groups: one per bundle section, so they stay valid as sections are added. A few
147
+ // sections that decay in known ways carry a sharper instruction.
148
+ const SECTION_HINTS = {
149
+ packages: 'verify each package concept against its real src/ and package.json exports map',
150
+ conventions: 'every rule must still hold in the code; gotchas decay fastest',
151
+ process:
152
+ 'verify every command, path, make target and step actually exists and works as written (read the Makefile, package.json scripts, scripts/, .github/workflows/)',
153
+ examples:
154
+ 'verify each concept against the real example: what it demos, how it runs, what it exercises',
155
+ reference:
156
+ 'audit only the CURATED files - a file with a generated: frontmatter key is generated, skip it',
157
+ };
158
+ const spotGroups = SECTIONS.map(section => ({
159
+ key: section,
160
+ dirs: SECTION_HINTS[section] ? `${section}/ - ${SECTION_HINTS[section]}` : `${section}/`,
161
+ }));
163
162
 
164
163
  phase('Verify');
165
164
  const deltaConceptList = deltaTargets.flatMap(t => t.concepts).join(', ');
@@ -189,7 +188,7 @@ if (mode === 'full') {
189
188
  ),
190
189
  () =>
191
190
  agent(
192
- `${COMMON}\nRole: COMPLETENESS CRITIC. Do not verify individual claims. Instead ask: what does this repo contain that the bundle does NOT cover? Compare ${KB}/index.md + the file tree under ${KB}/ against reality: ls packages/ examples/, scan major changes since the last sync (\`git log ${gitRange} --oneline\` and uncommitted \`git diff HEAD --stat\`), new components under packages/core-server/src/components/, new helper modules under packages/helpers/src/modules/, new connectors under packages/core-server/src/connectors/. Report (a) findings = missing durable facts in EXISTING concepts, (b) gaps = whole missing concepts worth creating (title + rationale + suggestedPath under ${KB}). A gap must be durable, load-bearing knowledge - not volatile detail. Note: every packages/* and examples/* dir MUST have a concept or \`okf coverage\` fails at under 100% structural.`,
191
+ `${COMMON}\nRole: COMPLETENESS CRITIC. Do not verify individual claims. Instead ask: what does this repo contain that the bundle does NOT cover? Compare ${KB}/index.md + the file tree under ${KB}/ against reality: the package paths (${PACKAGE_PATHS.length > 0 ? PACKAGE_PATHS.join(', ') : 'see the manifest'}) and their src/ trees, and the major changes since the last sync (\`git log ${gitRange} --oneline\` and uncommitted \`git diff HEAD --stat\`). Report (a) findings = missing durable facts in EXISTING concepts, (b) gaps = whole missing concepts worth creating (title + rationale + suggestedPath under ${KB}). A gap must be durable, load-bearing knowledge - not volatile detail. Note: every directory a coverage axis covers MUST have a concept or \`okf coverage\` fails at under 100% structural.`,
193
192
  { label: 'critic:completeness', phase: 'Verify', model: 'opus', schema: GAPS_SCHEMA },
194
193
  ),
195
194
  () =>
@@ -209,14 +208,15 @@ log(
209
208
  `Verify done (${mode}): ${allFindings.length} findings, ${gaps.length} gap proposals, ${confirmedTotal} claims confirmed`,
210
209
  );
211
210
 
212
- const GENERATED =
213
- /reference\/(source-map|components|helpers|binding-keys|makefile-targets|public-surface)\.md$|reference\/[a-z-]+\.json$/;
211
+ // Generated files never reach an applier: `okf gen` owns them, and a verifier is told so. The JSON
212
+ // tables are the only ones a path alone identifies; the rest are refused by the applier's rules.
213
+ const GENERATED = /reference\/[a-z-]+\.json$/;
214
214
 
215
215
  const byFile = {};
216
216
  for (const finding of allFindings) {
217
217
  let path = finding.concept.trim().replace(/^\/+/, '');
218
- if (!path.startsWith('.agents/knowledge/')) {
219
- path = '.agents/knowledge/' + path.replace(/^\.agents\/knowledge\//, '');
218
+ if (!path.startsWith(BUNDLE + '/')) {
219
+ path = BUNDLE + '/' + path;
220
220
  }
221
221
  path = path.replace(/ \(.*\)$/, '');
222
222
  if (GENERATED.test(path)) {
@@ -236,13 +236,13 @@ const applyResults = await parallel(
236
236
  files.map(
237
237
  path => () =>
238
238
  agent(
239
- `You are a careful knowledge curator for the IGNIS monorepo at ${REPO}.
239
+ `You are a careful knowledge curator for the ${BRAND} repository at ${REPO}.
240
240
  Target file: ${REPO}/${path}
241
241
  Apply the verified findings below to this ONE file. Curation rules:
242
242
  - Fix 'wrong' and 'stale' faithfully to the cited evidence - re-check the evidence in source first if it looks off; if a finding does not hold up against the code, SKIP it and say why.
243
243
  - For 'missing', fold in only durable, high-value facts; keep the concept TIGHT - this is a curated concept, not a dump. Skip volatile detail.
244
- - Preserve the file's existing voice, structure, frontmatter (update the description only if now inaccurate), and links. Do not touch any '<!-- okf:generated:' regions.
245
- - Style: hyphen not em-dash, brand always written IGNIS, English, no version numbers. Never abbreviate identifiers (ProductRepository not ProductRepo).
244
+ - Preserve the file's existing voice, structure, frontmatter (update the description only if now inaccurate), and links. Do not touch any '<!-- okf:generated:' regions. If the file's frontmatter has a generated: key, change nothing and say so.
245
+ - Style: hyphen not em-dash, brand always written ${BRAND}, English, no version numbers. Never abbreviate identifiers (ProductRepository not ProductRepo).
246
246
  - Do not edit any other file. NEVER run a state-changing git command.
247
247
  Findings (JSON):
248
248
  ${JSON.stringify(byFile[path], null, 2)}
@@ -0,0 +1,44 @@
1
+ # The knowledge-sync workflow - targets and arguments
2
+
3
+ ## Delta targets
4
+
5
+ One target per changed area:
6
+
7
+ - **With an area map** (`knowledge.syncAreas` in the manifest): the file holds a table
8
+ `| Changed area | Concepts to verify |`. Each changed path that falls under a row's area becomes a
9
+ target with that row's concepts.
10
+ - **Without one:** each changed package path becomes a target; its concepts are the bundle files
11
+ whose frontmatter `resource` points into that path. An area no concept names: verify every concept
12
+ under `packages/` and `architecture/` that mentions it (`grep -l`).
13
+
14
+ Per target: `key` (short label), `area` (git pathspecs), `concepts` (bundle-relative files), `hint`
15
+ (the commit subjects, summarised - verifiers check it, never trust it), `model` (`opus` for large or
16
+ central areas, `sonnet` for small ones). Merge areas of 3 files or fewer into one target; skip
17
+ formatting-only or lockfile-only changes.
18
+
19
+ ## Running it
20
+
21
+ Invoke the saved script - never re-write the orchestration inline:
22
+
23
+ ```
24
+ Workflow({
25
+ scriptPath: '.claude/skills/knowledge-sync/knowledge-sync.workflow.js',
26
+ args: {
27
+ repo: '<git rev-parse --show-toplevel>',
28
+ mode: '<delta|full>',
29
+ since: '<YYYY-MM-DD of the last sync>', // a label in prompts and the log
30
+ gitRange: '<LAST_SYNC hash>..HEAD', // what the verifiers diff
31
+ bundle: '<manifest knowledge.bundle>',
32
+ brand: '<manifest name, upper case>',
33
+ packagePaths: ['<manifest packages[].path>', ...],
34
+ sections: ['<each top-level directory of the bundle>', ...],
35
+ deltaTargets: [...],
36
+ },
37
+ })
38
+ ```
39
+
40
+ It fans out verifiers (plus one spot-auditor per bundle section and two critics in full mode), groups
41
+ the findings per concept file, and runs one applier per file. It returns
42
+ `{ stats, findingsByFile, applySummaries, gaps, notes }`. Files whose frontmatter carries
43
+ `generated:`, every `<!-- okf:generated:` region, and `reference/*.json` are out of scope: `gen` owns
44
+ them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venizia/vulcan",
3
- "version": "0.1.0-2",
3
+ "version": "0.2.0-1",
4
4
  "description": "VULCAN - one setup, one workflow for every project: init, sync, doctor, upgrade, and `vulcan x` for the shipped tools. Bun-only.",
5
5
  "homepage": "https://github.com/VENIZIA-AI/vulcan/tree/main/packages/cli",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "exports": {
28
28
  ".": "./src/index.ts",
29
+ "./okf": "./src/okf.ts",
29
30
  "./package.json": "./package.json"
30
31
  },
31
32
  "files": [
@@ -43,8 +44,8 @@
43
44
  "test": "NODE_ENV=test bun test --env-file=.env.test"
44
45
  },
45
46
  "dependencies": {
46
- "@venizia/vulcan-atlas": "0.1.0-2",
47
- "@venizia/vulcan-okf": "0.1.0-2",
47
+ "@venizia/vulcan-atlas": "0.2.0-1",
48
+ "@venizia/vulcan-okf": "0.2.0-1",
48
49
  "zod": "^4.5.4"
49
50
  },
50
51
  "devDependencies": {
@@ -5,7 +5,8 @@ const HELP = `vulcan ${VULCAN_VERSION} - one setup, one workflow for every proje
5
5
  Usage: bun x vulcan <command>
6
6
 
7
7
  init [--config ts|mts|mjs|js] Scaffold the project layer (never overwrites)
8
- sync [--dry-run] Write the gitignored agent adapters (CLAUDE.md, .claude/)
8
+ sync [--dry-run] [--prune] Write the gitignored agent adapters (CLAUDE.md, .claude/);
9
+ --prune also removes stale hooks and dead skill links
9
10
  doctor Check the project follows VULCAN; exit 1 on any violation
10
11
  upgrade [<version|tag>] Bump the pinned @venizia/vulcan, then sync
11
12
  x <tool> [args...] Run a shipped tool at the pinned version: okf, atlas
@@ -2,9 +2,19 @@ import { relative } from 'node:path';
2
2
  import { ExitCodes } from '../common/index.ts';
3
3
  import type { ICommandContext } from '../common/index.ts';
4
4
  import { loadConfig } from '../config/index.ts';
5
- import { planSync, SyncConstants, SyncStatuses } from '../sync/index.ts';
5
+ import { diffBaseRules, planPrune, planSync, SyncConstants, SyncStatuses } from '../sync/index.ts';
6
6
 
7
7
  const DRY_RUN_FLAG = '--dry-run';
8
+ const PRUNE_FLAG = '--prune';
9
+ const LABEL_WIDTH = 9;
10
+
11
+ /** Under --dry-run nothing is written, so a write is labelled as what it would do. */
12
+ const DRY_RUN_LABELS: Record<string, string> = {
13
+ [SyncStatuses.CREATED]: 'would create',
14
+ [SyncStatuses.REPLACED]: 'would replace',
15
+ [SyncStatuses.REMOVED]: 'would remove',
16
+ };
17
+ const DRY_RUN_LABEL_WIDTH = Math.max(...Object.values(DRY_RUN_LABELS).map(label => label.length));
8
18
 
9
19
  export const runSyncCommand = async (opts: ICommandContext): Promise<number> => {
10
20
  const loaded = await loadConfig({ root: opts.cwd });
@@ -16,7 +26,12 @@ export const runSyncCommand = async (opts: ICommandContext): Promise<number> =>
16
26
  }
17
27
 
18
28
  const dryRun = opts.args.includes(DRY_RUN_FLAG);
19
- const actions = planSync({ root: opts.cwd, config: loaded.config });
29
+ // Planned before anything is written: prune reads the managed record of the previous sync.
30
+ const pruned = opts.args.includes(PRUNE_FLAG) ? planPrune({ root: opts.cwd }) : [];
31
+ const actions = [...planSync({ root: opts.cwd, config: loaded.config }), ...pruned];
32
+ // Read before the manifest action rewrites the hashes it compares against.
33
+ const changes = diffBaseRules({ root: opts.cwd });
34
+ const width = dryRun ? DRY_RUN_LABEL_WIDTH : LABEL_WIDTH;
20
35
 
21
36
  for (const action of actions) {
22
37
  if (!dryRun) {
@@ -24,10 +39,13 @@ export const runSyncCommand = async (opts: ICommandContext): Promise<number> =>
24
39
  }
25
40
 
26
41
  const note = action.note === undefined ? '' : ` (${action.note})`;
42
+ const label = (dryRun ? DRY_RUN_LABELS[action.status] : undefined) ?? action.status;
43
+
44
+ process.stderr.write(` ${label.padEnd(width)} ${relative(opts.cwd, action.path)}${note}\n`);
45
+ }
27
46
 
28
- process.stderr.write(
29
- ` ${action.status.padEnd(9)} ${relative(opts.cwd, action.path)}${note}\n`,
30
- );
47
+ for (const { id, change, text } of changes) {
48
+ process.stderr.write(` ${change.padEnd(width)} ${[id, text].filter(Boolean).join(' ')}\n`);
31
49
  }
32
50
 
33
51
  const settingsBroken = actions.some(
@@ -1,11 +1,10 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { ExitCodes } from '../common/index.ts';
3
+ import { ExitCodes, PACKAGE_NAME } from '../common/index.ts';
4
4
  import type { ICommandContext } from '../common/index.ts';
5
5
 
6
6
  /** Every VULCAN release is a prerelease on the `next` dist-tag until a stable channel exists. */
7
7
  const DEFAULT_TARGET = 'next';
8
- const PACKAGE_NAME = '@venizia/vulcan';
9
8
 
10
9
  export const buildUpgradeArgs = (opts: { target?: string }): string[] => [
11
10
  'add',
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { ExitCodes, PACKAGE_ROOT, VulcanTools } from '../common/index.ts';
3
+ import { ExitCodes, PACKAGE_ROOT, ProjectLayout, VulcanTools } from '../common/index.ts';
4
+ import { findConfigFiles, loadConfig, resolvePackages } from '../config/index.ts';
5
+ import { ManifestConstants, renderManifest } from '../manifest/index.ts';
4
6
  import type { ICommandContext, IToolPackage } from '../common/index.ts';
5
7
 
6
8
  const TOOL_PACKAGES: Record<string, IToolPackage> = {
@@ -40,6 +42,36 @@ export const resolveToolBin = (opts: { name: string }): string | undefined => {
40
42
  return bin === undefined ? undefined : join(dirname(manifestPath), bin);
41
43
  };
42
44
 
45
+ /**
46
+ * The environment a tool runs with: the project's resolved config, validated once here, when the
47
+ * project has one. No config is fine (atlas serves sources without one); an invalid one is an error.
48
+ */
49
+ export const buildToolEnvironment = async (opts: {
50
+ root: string;
51
+ }): Promise<{ ok: true; env: Record<string, string> } | { ok: false; error: string }> => {
52
+ if (findConfigFiles({ root: opts.root }).length === 0) {
53
+ return { ok: true, env: {} };
54
+ }
55
+
56
+ const loaded = await loadConfig({ root: opts.root });
57
+
58
+ if (!loaded.ok) {
59
+ return { ok: false, error: loaded.error };
60
+ }
61
+
62
+ const config = { ...loaded.config, packages: resolvePackages({ config: loaded.config }) };
63
+
64
+ return {
65
+ ok: true,
66
+ env: {
67
+ [ProjectLayout.CONFIG_ENV]: JSON.stringify(config),
68
+ [ProjectLayout.ROOT_ENV]: opts.root,
69
+ // Atlas reads the manifest only; rendered fresh, so a run never sees a stale committed one.
70
+ [ManifestConstants.ENV]: renderManifest({ root: opts.root, config: loaded.config }),
71
+ },
72
+ };
73
+ };
74
+
43
75
  /** Runs the tool as a child on the inherited stdio: vulcan writes nothing to stdout, so MCP stays clean. */
44
76
  export const runXCommand = async (opts: ICommandContext): Promise<number> => {
45
77
  const [name, ...toolArgs] = opts.args;
@@ -62,8 +94,17 @@ export const runXCommand = async (opts: ICommandContext): Promise<number> => {
62
94
  return ExitCodes.FAILURE;
63
95
  }
64
96
 
97
+ const environment = await buildToolEnvironment({ root: opts.cwd });
98
+
99
+ if (!environment.ok) {
100
+ process.stderr.write(`vulcan x: ${environment.error}\n`);
101
+
102
+ return ExitCodes.FAILURE;
103
+ }
104
+
65
105
  const child = Bun.spawn([process.execPath, bin, ...toolArgs], {
66
106
  cwd: opts.cwd,
107
+ env: { ...process.env, ...environment.env },
67
108
  stdio: ['inherit', 'inherit', 'inherit'],
68
109
  });
69
110
 
@@ -39,6 +39,10 @@ export class VulcanTools {
39
39
  export class ProjectLayout {
40
40
  /** How everything unattended runs vulcan: `--no-install` never fetches the unrelated npm package named `vulcan`. */
41
41
  static readonly VULCAN_COMMAND = 'bun x --no-install vulcan';
42
+
43
+ /** `vulcan x` hands the resolved project config to the tool in these variables; the tools read nothing else. */
44
+ static readonly CONFIG_ENV = 'VULCAN_CONFIG';
45
+ static readonly ROOT_ENV = 'VULCAN_PROJECT_ROOT';
42
46
  /** `-include`: a fresh clone runs `make install` before node_modules exists. */
43
47
  static readonly MAKE_INCLUDE_LINE = '-include node_modules/@venizia/vulcan/base/make/vulcan.mk';
44
48
  static readonly STYLE_PACKAGE = '@venizia/dev-configs';
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
 
4
4
  /** The installed `@venizia/vulcan` directory, in a workspace or a project's node_modules. */
@@ -7,6 +7,22 @@ export const PACKAGE_ROOT = resolve(import.meta.dir, '..', '..');
7
7
  /** The shipped base: rules, slots, skills, settings, hooks, make module, templates. */
8
8
  export const BASE_DIRECTORY = join(PACKAGE_ROOT, 'base');
9
9
 
10
+ export const PACKAGE_NAME = '@venizia/vulcan';
11
+
12
+ /**
13
+ * The base as a project reaches it: through `node_modules/@venizia/vulcan` when that resolves to this
14
+ * package. bun's isolated install keeps the files in a hashed store directory that every upgrade
15
+ * replaces, so a link to the real path dies with the next upgrade; the stable path does not.
16
+ * Anywhere else - no install, or another copy installed - the real directory.
17
+ */
18
+ export const stableBaseDirectoryOf = (opts: { root: string }): string => {
19
+ const installed = join(opts.root, 'node_modules', PACKAGE_NAME, 'base');
20
+
21
+ return existsSync(installed) && realpathSync(installed) === realpathSync(BASE_DIRECTORY)
22
+ ? installed
23
+ : BASE_DIRECTORY;
24
+ };
25
+
10
26
  export const VULCAN_VERSION: string = JSON.parse(
11
27
  readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'),
12
28
  ).version;
@@ -51,4 +51,14 @@ export class ConfigConstants {
51
51
  static readonly BASENAME = 'vulcan.config';
52
52
  static readonly DEFAULT_KNOWLEDGE_BUNDLE = '.agents/knowledge';
53
53
  static readonly RULE_ID_PATTERN = /^(?:[A-Z]+-)?[A-Z]-\d{2}$/;
54
+ /** The rules a session drops most, printed at every session start unless the project lists its own. */
55
+ static readonly DEFAULT_OFTEN_DROPPED = [
56
+ 'V-B-03',
57
+ 'V-P-05',
58
+ 'V-P-06',
59
+ 'V-B-04',
60
+ 'V-W-04',
61
+ 'V-C-02',
62
+ 'V-C-10',
63
+ ];
54
64
  }
@@ -1,3 +1,4 @@
1
1
  export * from './common/index.ts';
2
2
  export * from './loader.ts';
3
3
  export * from './schema.ts';
4
+ export * from './packages.ts';
@@ -0,0 +1,33 @@
1
+ import type { TResolvedConfig } from './schema.ts';
2
+
3
+ const DEFAULT_PACKAGES_DIRECTORY = 'packages';
4
+
5
+ export interface IProjectPackage {
6
+ dir: string;
7
+ name: string;
8
+ /** From the root, resolved: `packages/<dir>` unless the config says otherwise. */
9
+ path: string;
10
+ aliases: string[];
11
+ retired: boolean;
12
+ }
13
+
14
+ /** The project's packages in release order: `packages`, or the `atlas.packages` alias for one release. */
15
+ export const resolvePackages = (opts: { config: TResolvedConfig }): IProjectPackage[] => {
16
+ if (opts.config.packages.length > 0) {
17
+ return opts.config.packages.map(entry => ({
18
+ dir: entry.dir,
19
+ name: entry.name,
20
+ path: entry.path ?? `${DEFAULT_PACKAGES_DIRECTORY}/${entry.dir}`,
21
+ aliases: entry.aliases,
22
+ retired: entry.retired,
23
+ }));
24
+ }
25
+
26
+ return Object.entries(opts.config.atlas.packages ?? {}).map(([dir, name]) => ({
27
+ dir,
28
+ name,
29
+ path: `${DEFAULT_PACKAGES_DIRECTORY}/${dir}`,
30
+ aliases: [],
31
+ retired: false,
32
+ }));
33
+ };
@@ -8,33 +8,118 @@ const HOOK_EVENT = z.string().refine(value => HookEvents.isValid(value), {
8
8
  message: `one of: ${[...HookEvents.SCHEME_SET].join(', ')}`,
9
9
  });
10
10
 
11
+ const PROJECT_PACKAGE = z.strictObject({
12
+ dir: z.string().regex(/^[a-z0-9][a-z0-9.-]*$/),
13
+ name: z.string(),
14
+ /** Where the package lives, from the root; default `packages/<dir>`. */
15
+ path: z.string().optional(),
16
+ aliases: z.array(z.string()).default([]),
17
+ retired: z.boolean().default(false),
18
+ });
19
+
20
+ /**
21
+ * A release template: `<dir>` (synonym `<package>`) is the package DIRECTORY - the `packages` key,
22
+ * never the npm name (`packages/core-server` published as `@acme/server` tags `core-server-v1.2.0`); `<version>` is the
23
+ * version; `<issue>` an issue number. Every other character is literal. When matching a commit, a
24
+ * leading `[#<issue>] ` is optional and a trailing ` [<mode>]` is tolerated. A release train that
25
+ * tags once leaves `<dir>` out (`v<version>`).
26
+ */
27
+ const RELEASE_TEMPLATE = z.string().refine(value => value.includes('<version>'), {
28
+ message: 'a template with <version> (and <dir> or <package> for per-package releases)',
29
+ });
30
+
31
+ /** A renderer by id: a built-in, or a project module (`module`); a bare string is the id alone. */
32
+ const OKF_RENDERER = z.union([
33
+ z.string().transform(id => ({ id, options: {} })),
34
+ z.strictObject({
35
+ id: z.string(),
36
+ module: z.string().optional(),
37
+ options: z.record(z.string(), z.unknown()).default({}),
38
+ }),
39
+ ]);
40
+
41
+ const VIZ_OUTPUT = '.agents/knowledge/viz.html';
42
+
11
43
  /** `.configs/vulcan.config.*` - strict: an unknown key is a typo, and a typo is an error. */
12
44
  export const VULCAN_CONFIG_SCHEMA = z.strictObject({
45
+ /** The project's name in the manifest and in every atlas result; default: the root package name, unscoped. */
46
+ name: z
47
+ .string()
48
+ .regex(/^[a-z0-9][a-z0-9-]*$/)
49
+ .optional(),
13
50
  agents: z.array(CODING_AGENT).default([CodingAgents.CLAUDE]),
14
51
  rules: z.record(z.string().regex(ConfigConstants.RULE_ID_PATTERN), z.string()).default({}),
52
+ /** The project's packages in release order; okf, atlas, make and release read this one list. */
53
+ packages: z.array(PROJECT_PACKAGE).default([]),
15
54
  knowledge: z
16
55
  .strictObject({
17
56
  bundle: z.string().default(ConfigConstants.DEFAULT_KNOWLEDGE_BUNDLE),
18
57
  wiki: z.string().optional(),
19
58
  changelogs: z.string().optional(),
59
+ /** The knowledge-sync skill's map of code areas to concepts. */
60
+ syncAreas: z.string().optional(),
20
61
  })
21
62
  .default({ bundle: ConfigConstants.DEFAULT_KNOWLEDGE_BUNDLE }),
22
- /** Validated now; consumed once okf reads project config (plan: known gaps). */
23
63
  okf: z
24
64
  .strictObject({
25
- renderers: z.array(z.string()).optional(),
26
- coverage: z.array(z.strictObject({ name: z.string(), path: z.string() })).optional(),
65
+ renderers: z.array(OKF_RENDERER).default([]),
66
+ regions: z
67
+ .array(z.strictObject({ id: z.string(), file: z.string(), renderer: z.string() }))
68
+ .default([]),
69
+ sections: z
70
+ .strictObject({
71
+ order: z.array(z.string()).default([]),
72
+ labels: z.record(z.string(), z.string()).default({}),
73
+ })
74
+ .default({ order: [], labels: {} }),
75
+ coverage: z.array(z.strictObject({ name: z.string(), path: z.string() })).default([]),
76
+ denylist: z.array(z.string()).default([]),
77
+ /** Agent-read files outside the bundle that get a generated contents list when they pass 100 lines. */
78
+ contents: z
79
+ .strictObject({ files: z.array(z.string()).default(['AGENTS.md', '.agents/rules.md']) })
80
+ .default({ files: ['AGENTS.md', '.agents/rules.md'] }),
81
+ viz: z
82
+ .strictObject({
83
+ outputs: z.array(z.string()).default([VIZ_OUTPUT]),
84
+ brand: z.string().optional(),
85
+ copy: z.record(z.string(), z.unknown()).optional(),
86
+ })
87
+ .default({ outputs: [VIZ_OUTPUT] }),
27
88
  })
28
- .optional(),
29
- /** Validated now; consumed by atlas v1. */
89
+ .default({
90
+ renderers: [],
91
+ regions: [],
92
+ sections: { order: [], labels: {} },
93
+ coverage: [],
94
+ denylist: [],
95
+ contents: { files: ['AGENTS.md', '.agents/rules.md'] },
96
+ viz: { outputs: [VIZ_OUTPUT] },
97
+ }),
30
98
  atlas: z
31
99
  .strictObject({
100
+ /** The `packages` alias for one release: directory to npm name, in release order. */
32
101
  packages: z.record(z.string(), z.string()).optional(),
33
- tagPattern: z.string().optional(),
102
+ releaseCommit: RELEASE_TEMPLATE.default('[#<issue>] chore(<dir>): release v<version>'),
103
+ tagPattern: RELEASE_TEMPLATE.default('<dir>-v<version>'),
104
+ sources: z
105
+ .array(
106
+ z.strictObject({
107
+ name: z.string(),
108
+ repo: z.string(),
109
+ ref: z.string().default('develop'),
110
+ }),
111
+ )
112
+ .default([]),
34
113
  legacyTagPrefixes: z.array(z.string()).optional(),
35
- releaseCommit: z.string().optional(),
114
+ /** Tags neither read nor checked (globs) - an old layout's tags. */
115
+ ignoreTags: z.array(z.string()).optional(),
116
+ typescriptFrom: z.string().optional(),
36
117
  })
37
- .optional(),
118
+ .default({
119
+ releaseCommit: '[#<issue>] chore(<dir>): release v<version>',
120
+ tagPattern: '<dir>-v<version>',
121
+ sources: [],
122
+ }),
38
123
  /** Team-shared Claude settings (permissions, env, enabledPlugins...), merged by sync over personal keys. */
39
124
  claude: z
40
125
  .strictObject({ settings: z.record(z.string(), z.unknown()).default({}) })
@@ -44,6 +129,14 @@ export const VULCAN_CONFIG_SCHEMA = z.strictObject({
44
129
  z.strictObject({ event: HOOK_EVENT, matcher: z.string().optional(), command: z.string() }),
45
130
  )
46
131
  .default([]),
132
+ /** The session-start digest: the rule IDs it repeats as "the rules sessions drop most" - base or the project's own. */
133
+ digest: z
134
+ .strictObject({
135
+ oftenDropped: z
136
+ .array(z.string().regex(ConfigConstants.RULE_ID_PATTERN))
137
+ .default(ConfigConstants.DEFAULT_OFTEN_DROPPED),
138
+ })
139
+ .default({ oftenDropped: ConfigConstants.DEFAULT_OFTEN_DROPPED }),
47
140
  });
48
141
 
49
142
  export type VulcanConfig = z.input<typeof VULCAN_CONFIG_SCHEMA>;