@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.
- package/README.md +10 -6
- 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 +23 -5
- 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 +107 -31
- 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 +65 -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 +16 -0
- package/src/sync/common/types.ts +10 -1
- package/src/sync/index.ts +2 -0
- package/src/sync/planner.ts +42 -7
- package/src/sync/prune.ts +138 -0
- package/src/sync/settings.ts +74 -1
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// Reusable Workflow script for /knowledge-sync.
|
|
2
|
-
// See .
|
|
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/
|
|
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
|
|
14
|
-
// concepts: ['packages/core
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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:
|
|
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
|
-
|
|
213
|
-
|
|
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('
|
|
219
|
-
path = '
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
47
|
-
"@venizia/vulcan-okf": "0.
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
package/src/common/constants.ts
CHANGED
|
@@ -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';
|
package/src/common/paths.ts
CHANGED
|
@@ -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
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/config/schema.ts
CHANGED
|
@@ -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(
|
|
26
|
-
|
|
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
|
-
.
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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>;
|