contextdevkit 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/install.mjs +14 -1
- package/package.json +3 -3
- package/templates/CLAUDE.md.tpl +2 -1
- package/templates/claude/commands/README.md +6 -2
- package/templates/claude/commands/audit/validate-doc.md +37 -0
- package/templates/claude/commands/bug-hunt.md +10 -2
- package/templates/claude/commands/forge/forge-new.md +6 -0
- package/templates/claude/commands/pipeline/dev-start.md +21 -6
- package/templates/claude/commands/pipeline/pipeline.md +5 -1
- package/templates/claude/commands/pipeline/ship.md +6 -1
- package/templates/claude/commands/roadmap.md +10 -1
- package/templates/claude/commands/vcs/changelog-social.md +30 -0
- package/templates/claude/commands/vcs/draft-changelog.md +28 -0
- package/templates/claude/commands/vcs/gh-triage.md +41 -0
- package/templates/contextkit/policy/complexity-rubric.json +52 -0
- package/templates/contextkit/runtime/config/paths.mjs +2 -0
- package/templates/contextkit/tools/scripts/complexity-rubric.mjs +164 -0
- package/templates/contextkit/tools/scripts/draft-changelog.mjs +101 -0
- package/templates/contextkit/tools/scripts/validate-doc.mjs +135 -0
- package/tools/install/cli.mjs +8 -1
- package/tools/install/migrate.mjs +162 -0
- package/tools/integration-test-migrate.mjs +151 -0
- package/tools/integration-test-tooling.mjs +25 -1
- package/tools/selfcheck-source.mjs +72 -0
- package/docs/CHANGELOG.md +0 -559
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `/draft-changelog` — build a `[Unreleased]` skeleton from Conventional Commits
|
|
4
|
+
* since the last tag (ADR-0030, OSS repo-ops).
|
|
5
|
+
*
|
|
6
|
+
* Reads `git log <lastTag>..HEAD`, parses Conventional Commit subjects, and groups
|
|
7
|
+
* them into Keep-a-Changelog sections (Added / Changed / Fixed / …). It DRAFTS —
|
|
8
|
+
* it never writes `docs/CHANGELOG.md`; the human reviews and pastes. Local git only
|
|
9
|
+
* (no network), every git call is timed out + defensive (rule 2): a non-repo or a
|
|
10
|
+
* git error prints a clean message, never a stack trace.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node contextkit/tools/scripts/draft-changelog.mjs # since last tag
|
|
14
|
+
* node contextkit/tools/scripts/draft-changelog.mjs --since v1.7.0
|
|
15
|
+
* node contextkit/tools/scripts/draft-changelog.mjs --json
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
/** Conventional-commit type → Keep-a-Changelog section. */
|
|
20
|
+
const SECTION = {
|
|
21
|
+
feat: 'Added',
|
|
22
|
+
fix: 'Fixed',
|
|
23
|
+
perf: 'Changed',
|
|
24
|
+
refactor: 'Changed',
|
|
25
|
+
revert: 'Removed',
|
|
26
|
+
security: 'Security',
|
|
27
|
+
docs: 'Documentation',
|
|
28
|
+
chore: 'Chores',
|
|
29
|
+
build: 'Chores',
|
|
30
|
+
ci: 'Chores',
|
|
31
|
+
test: 'Chores',
|
|
32
|
+
style: 'Chores',
|
|
33
|
+
};
|
|
34
|
+
const ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security', 'Documentation', 'Chores', 'Other'];
|
|
35
|
+
|
|
36
|
+
/** Runs git with a hard timeout; returns stdout or null on any failure. */
|
|
37
|
+
function git(args) {
|
|
38
|
+
const res = spawnSync('git', args, { encoding: 'utf-8', timeout: 5000 });
|
|
39
|
+
return res.status === 0 ? (res.stdout || '').trim() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function lastTag() {
|
|
43
|
+
return git(['describe', '--tags', '--abbrev=0']);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parses a `feat(scope)!: subject` line into { section, scope, breaking, text }. */
|
|
47
|
+
function parse(subject) {
|
|
48
|
+
const m = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
|
|
49
|
+
if (!m) return { section: 'Other', scope: null, breaking: false, text: subject };
|
|
50
|
+
const [, type, scope, bang, text] = m;
|
|
51
|
+
return { section: SECTION[type.toLowerCase()] || 'Other', scope: scope || null, breaking: Boolean(bang), text };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collect(since) {
|
|
55
|
+
if (git(['rev-parse', '--is-inside-work-tree']) !== 'true') return { error: 'not a git repository' };
|
|
56
|
+
const range = since ? `${since}..HEAD` : null;
|
|
57
|
+
const args = ['log', '--no-merges', '--pretty=%s'];
|
|
58
|
+
if (range) args.splice(1, 0, range);
|
|
59
|
+
const out = git(args);
|
|
60
|
+
if (out === null) return { error: since ? `git log failed for range ${range}` : 'git log failed' };
|
|
61
|
+
const subjects = out.split('\n').filter(Boolean);
|
|
62
|
+
const groups = {};
|
|
63
|
+
for (const s of subjects) {
|
|
64
|
+
const p = parse(s);
|
|
65
|
+
(groups[p.section] ||= []).push(p);
|
|
66
|
+
}
|
|
67
|
+
return { since, count: subjects.length, groups };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function render(result) {
|
|
71
|
+
if (result.error) return `ℹ️ ${result.error} — nothing to draft.`;
|
|
72
|
+
const lines = ['## [Unreleased]', ''];
|
|
73
|
+
if (result.count === 0) {
|
|
74
|
+
lines.push(`_No commits since ${result.since || 'the start'}._`);
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
for (const section of ORDER) {
|
|
78
|
+
const items = result.groups[section];
|
|
79
|
+
if (!items || items.length === 0) continue;
|
|
80
|
+
lines.push(`### ${section}`);
|
|
81
|
+
for (const it of items) {
|
|
82
|
+
const scope = it.scope ? `**${it.scope}:** ` : '';
|
|
83
|
+
const breaking = it.breaking ? '⚠️ BREAKING — ' : '';
|
|
84
|
+
lines.push(`- ${breaking}${scope}${it.text}`);
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
}
|
|
88
|
+
lines.push(`_Drafted from ${result.count} commit(s) since ${result.since || 'the first commit'}. Review before pasting into docs/CHANGELOG.md — this command never writes it._`);
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function main() {
|
|
93
|
+
const argv = process.argv.slice(2);
|
|
94
|
+
const wantJson = argv.includes('--json');
|
|
95
|
+
const sinceIdx = argv.indexOf('--since');
|
|
96
|
+
const since = sinceIdx !== -1 && argv[sinceIdx + 1] ? argv[sinceIdx + 1] : lastTag();
|
|
97
|
+
const result = collect(since);
|
|
98
|
+
console.log(wantJson ? JSON.stringify(result, null, 2) : render(result));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main();
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `/validate-doc` — quality gate for OUR OWN planning artifacts (ADR-0030).
|
|
4
|
+
*
|
|
5
|
+
* Adapts EVO-METHOD/BMAD's `steps-v` document-validation chain (MIT) to
|
|
6
|
+
* ContextDevKit's artifacts: an ADR must pose its problem, decide plainly, and own
|
|
7
|
+
* its trade-offs; a roadmap must be measurable, not aspirational. The kit already
|
|
8
|
+
* validates *code wiring* (selfcheck) — this validates the *prose* of the decisions
|
|
9
|
+
* those tests are built on.
|
|
10
|
+
*
|
|
11
|
+
* Report-only by design (constitution §8 — never a false "pass", but also never a
|
|
12
|
+
* push-blocker): it prints findings and exits non-zero only so CI *could* gate on
|
|
13
|
+
* it; the slash command is advisory. Zero runtime deps (rule 1).
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node contextkit/tools/scripts/validate-doc.mjs <file.md>
|
|
17
|
+
* node contextkit/tools/scripts/validate-doc.mjs <file.md> --json
|
|
18
|
+
* node contextkit/tools/scripts/validate-doc.mjs --adr <file.md> # force ADR rubric
|
|
19
|
+
* node contextkit/tools/scripts/validate-doc.mjs --roadmap <file.md> # force roadmap rubric
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { basename } from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** Leftover template tokens that mean the artifact was never filled in. */
|
|
25
|
+
const PLACEHOLDERS = [/<short decision title>/i, /\bNNNN\b/, /YYYY-MM-DD/, /<who>/i, /<what we will do/i, /ADR-XXXX/];
|
|
26
|
+
/** Words that signal a real trade-off was considered (Consequences quality). */
|
|
27
|
+
const TRADEOFF_HINTS = /(trade-?off|negative|risk|harder|downside|we give up|cost)/i;
|
|
28
|
+
/** Tokens that make a roadmap line measurable rather than aspirational. */
|
|
29
|
+
const MEASURABLE_HINTS = /(\d|%|by\s+\w+|target|metric|KPI|reduce|increase|within|ship|release)/i;
|
|
30
|
+
|
|
31
|
+
/** Splits a markdown doc into `## Heading` → body. Zero-dep, defensive. */
|
|
32
|
+
function sections(text) {
|
|
33
|
+
const out = {};
|
|
34
|
+
const re = /^#{2,3}\s+(.+?)\s*$/gm;
|
|
35
|
+
const heads = [];
|
|
36
|
+
let m;
|
|
37
|
+
while ((m = re.exec(text))) heads.push({ title: m[1].trim(), start: m.index + m[0].length });
|
|
38
|
+
heads.forEach((h, i) => {
|
|
39
|
+
const end = i + 1 < heads.length ? heads[i + 1].start : text.length;
|
|
40
|
+
out[h.title.toLowerCase()] = text.slice(h.start, end).trim();
|
|
41
|
+
});
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Detects the rubric to apply from the path + content. */
|
|
46
|
+
function detectType(file, text) {
|
|
47
|
+
const name = basename(file).toLowerCase();
|
|
48
|
+
if (/\/decisions\//.test(file.replace(/\\/g, '/')) || /^\d{4}-/.test(name) || /\*\*Status\*\*/i.test(text)) return 'adr';
|
|
49
|
+
if (/roadmap/.test(name)) return 'roadmap';
|
|
50
|
+
return 'generic';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function err(code, message) {
|
|
54
|
+
return { level: 'error', code, message };
|
|
55
|
+
}
|
|
56
|
+
function warn(code, message) {
|
|
57
|
+
return { level: 'warn', code, message };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** ADR rubric — sections present, status valid, no placeholders, trade-offs owned. */
|
|
61
|
+
function validateAdr(text) {
|
|
62
|
+
const findings = [];
|
|
63
|
+
const sec = sections(text);
|
|
64
|
+
const has = (name) => Object.keys(sec).some((k) => k === name || k.startsWith(name));
|
|
65
|
+
for (const required of ['context', 'decision', 'consequences']) {
|
|
66
|
+
if (!has(required)) findings.push(err('MISSING_SECTION', `ADR is missing a "## ${required[0].toUpperCase() + required.slice(1)}" section.`));
|
|
67
|
+
}
|
|
68
|
+
if (!/\*\*status\*\*\s*:/i.test(text)) findings.push(err('NO_STATUS', 'ADR has no "**Status**:" line.'));
|
|
69
|
+
else if (!/\*\*status\*\*\s*:\s*(proposed|accepted|superseded)/i.test(text)) findings.push(warn('STATUS_VALUE', 'Status is not one of Proposed / Accepted / Superseded.'));
|
|
70
|
+
for (const re of PLACEHOLDERS) {
|
|
71
|
+
if (re.test(text)) findings.push(err('PLACEHOLDER', `Unfilled template placeholder still present: ${re}`));
|
|
72
|
+
}
|
|
73
|
+
const context = sec['context'] || '';
|
|
74
|
+
if (context && context.length < 200) findings.push(warn('THIN_CONTEXT', `Context is thin (${context.length} chars) — it should state the forces WITHOUT already giving the answer.`));
|
|
75
|
+
const consequences = Object.entries(sec).find(([k]) => k.startsWith('consequences'))?.[1] || '';
|
|
76
|
+
if (consequences && !TRADEOFF_HINTS.test(consequences)) findings.push(warn('NO_TRADEOFFS', 'Consequences states no trade-off / negative / risk — a decision with only upsides is usually under-examined.'));
|
|
77
|
+
if (!/follow-?up/i.test(text)) findings.push(warn('NO_FOLLOWUPS', 'No follow-ups noted — what does this decision obligate next?'));
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Roadmap rubric — items exist and read as measurable, not aspirational. */
|
|
82
|
+
function validateRoadmap(text) {
|
|
83
|
+
const findings = [];
|
|
84
|
+
const items = text.split('\n').filter((l) => /^\s*[-*]\s+/.test(l) || /^\s*\d+\.\s+/.test(l));
|
|
85
|
+
if (items.length === 0) findings.push(warn('NO_ITEMS', 'No list items detected — is the roadmap populated?'));
|
|
86
|
+
const vague = items.filter((l) => l.replace(/^\s*[-*\d.]+\s*/, '').length > 12 && !MEASURABLE_HINTS.test(l));
|
|
87
|
+
if (items.length && vague.length / items.length > 0.5) {
|
|
88
|
+
findings.push(warn('NOT_MEASURABLE', `${vague.length}/${items.length} items read as aspirational (no number / date / target). Make outcomes measurable.`));
|
|
89
|
+
}
|
|
90
|
+
for (const re of PLACEHOLDERS) {
|
|
91
|
+
if (re.test(text)) findings.push(warn('PLACEHOLDER', `Template placeholder still present: ${re}`));
|
|
92
|
+
}
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validate(file, forced) {
|
|
97
|
+
const text = readFileSync(file, 'utf-8').replace(/^/, '');
|
|
98
|
+
const type = forced || detectType(file, text);
|
|
99
|
+
const findings = type === 'adr' ? validateAdr(text) : type === 'roadmap' ? validateRoadmap(text) : validateRoadmap(text);
|
|
100
|
+
return { file, type, findings };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function main() {
|
|
104
|
+
const argv = process.argv.slice(2);
|
|
105
|
+
const wantJson = argv.includes('--json');
|
|
106
|
+
let forced = null;
|
|
107
|
+
if (argv.includes('--adr')) forced = 'adr';
|
|
108
|
+
if (argv.includes('--roadmap')) forced = 'roadmap';
|
|
109
|
+
const file = argv.find((a) => !a.startsWith('--'));
|
|
110
|
+
if (!file) {
|
|
111
|
+
console.error('Usage: validate-doc.mjs <file.md> [--adr|--roadmap] [--json]');
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
if (!existsSync(file)) {
|
|
115
|
+
console.error(`File not found: ${file}`);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
const report = validate(file, forced);
|
|
119
|
+
const errors = report.findings.filter((f) => f.level === 'error');
|
|
120
|
+
const warns = report.findings.filter((f) => f.level === 'warn');
|
|
121
|
+
|
|
122
|
+
if (wantJson) {
|
|
123
|
+
console.log(JSON.stringify({ ...report, errorCount: errors.length, warnCount: warns.length }, null, 2));
|
|
124
|
+
} else {
|
|
125
|
+
console.log(`\n📋 validate-doc — ${basename(file)} (${report.type} rubric)`);
|
|
126
|
+
console.log('─'.repeat(56));
|
|
127
|
+
if (report.findings.length === 0) console.log(' ✅ No issues — the artifact passes the rubric.');
|
|
128
|
+
for (const f of errors) console.log(` ❌ [${f.code}] ${f.message}`);
|
|
129
|
+
for (const f of warns) console.log(` ⚠️ [${f.code}] ${f.message}`);
|
|
130
|
+
console.log(`\n ${errors.length} error(s), ${warns.length} warning(s). (advisory — never blocks a push)`);
|
|
131
|
+
}
|
|
132
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
main();
|
package/tools/install/cli.mjs
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
export { LEVEL_LABELS } from '../../templates/contextkit/runtime/config/levels.mjs';
|
|
6
6
|
|
|
7
7
|
export function parseArgs(argv) {
|
|
8
|
-
const args = { yes: false, rewire: false, force: false, uninstall: false, help: false, version: false, purge: false, update: false };
|
|
8
|
+
const args = { yes: false, rewire: false, force: false, uninstall: false, help: false, version: false, purge: false, update: false, migrate: false, dryRun: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const a = argv[i];
|
|
11
11
|
if (a === '--yes' || a === '-y') args.yes = true;
|
|
12
12
|
else if (a === '--update') { args.update = true; args.yes = true; }
|
|
13
|
+
else if (a === '--migrate') args.migrate = true;
|
|
14
|
+
else if (a === '--dry-run') args.dryRun = true;
|
|
13
15
|
else if (a === '--rewire') args.rewire = true;
|
|
14
16
|
else if (a === '--force') args.force = true;
|
|
15
17
|
else if (a === '--uninstall') args.uninstall = true;
|
|
@@ -33,6 +35,8 @@ Usage:
|
|
|
33
35
|
[--mode greenfield|existing] [--yes] [--force]
|
|
34
36
|
node install.mjs --update safe update: refresh engine + commands,
|
|
35
37
|
keep your level/config/memory/CLAUDE.md
|
|
38
|
+
node install.mjs --migrate [--dry-run] carry a legacy vibekit/ install forward
|
|
39
|
+
to contextkit/ (preview with --dry-run)
|
|
36
40
|
node install.mjs --rewire --level <1-7> only recompose .claude/settings.json
|
|
37
41
|
node install.mjs --uninstall [--purge] unwire hooks (--purge also removes engine)
|
|
38
42
|
node install.mjs --help | --version
|
|
@@ -47,6 +51,9 @@ Flags:
|
|
|
47
51
|
--force overwrite CLAUDE.md / memory seeds if they exist
|
|
48
52
|
--update safe update: refresh engine/commands/agents + re-wire hooks for
|
|
49
53
|
the CURRENT level; never touches CLAUDE.md, config, or memory
|
|
54
|
+
--migrate carry a legacy vibekit/ install forward to contextkit/ (moves the
|
|
55
|
+
folder, preserves memory/config, rewires settings/CLAUDE.md/hooks)
|
|
56
|
+
--dry-run with --migrate: report what would change without writing
|
|
50
57
|
--rewire only recompose settings.json for the given --level
|
|
51
58
|
--uninstall remove ContextDevKit hook wiring + git hooks (keeps memory)
|
|
52
59
|
--purge with --uninstall, also delete contextkit/ engine + commands/agents
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy-install migration: VibeDevKit (`vibekit/`) → ContextDevKit (`contextkit/`).
|
|
3
|
+
*
|
|
4
|
+
* Existing users installed the old `vibedevkit` package, which laid down a
|
|
5
|
+
* `vibekit/` platform folder, `/vibe-*` slash commands, `vibekit/...` hook
|
|
6
|
+
* wiring and `VibeDevKit` / `VIBE_*` references. After the rename, running
|
|
7
|
+
* `npx contextdevkit --update` must carry that install FORWARD — without data
|
|
8
|
+
* loss and without leaving two installs side by side.
|
|
9
|
+
*
|
|
10
|
+
* Strategy (idempotent, refuse-on-ambiguity — constitution rule 8):
|
|
11
|
+
* 1. detect a legacy `vibekit/` with no `contextkit/`;
|
|
12
|
+
* 2. MOVE the folder (atomic rename → preserves memory / config / pipeline / .env);
|
|
13
|
+
* 3. rewrite the rename tokens in the control files (settings.json, .gitignore,
|
|
14
|
+
* .gitattributes, git hooks, contextkit/.env, CLAUDE.md — the last two backed
|
|
15
|
+
* up to `*.bak` first, as they hold user content);
|
|
16
|
+
* 4. delete the stale `/vibe-*` + `setupvibedevkit` command files.
|
|
17
|
+
* The normal installer flow then refreshes the engine into `contextkit/`.
|
|
18
|
+
*
|
|
19
|
+
* Zero third-party deps (runs via `npx` on a bare machine). Rule 2: it never
|
|
20
|
+
* throws into the installer — all I/O is defensive; a failure degrades to a
|
|
21
|
+
* warning and leaves the project untouched.
|
|
22
|
+
*/
|
|
23
|
+
import { rename, cp, rm, readFile, writeFile } from 'node:fs/promises';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const LEGACY_DIR = 'vibekit';
|
|
28
|
+
const NEW_DIR = 'contextkit';
|
|
29
|
+
|
|
30
|
+
// Order matters: longest / most-specific tokens first so no rule eats another.
|
|
31
|
+
const TOKENS = [
|
|
32
|
+
['VibeDevKit', 'ContextDevKit'],
|
|
33
|
+
['VIBEDEVKIT', 'CONTEXTDEVKIT'],
|
|
34
|
+
['vibedevkit', 'contextdevkit'],
|
|
35
|
+
['vibekit', 'contextkit'],
|
|
36
|
+
['VIBE_', 'CONTEXT_'],
|
|
37
|
+
['vibe-', 'context-'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Control files whose kit-managed content carries old tokens. `backup` files hold user content. */
|
|
41
|
+
const CONTROL = [
|
|
42
|
+
{ rel: '.claude/settings.json', backup: false },
|
|
43
|
+
{ rel: '.gitignore', backup: false },
|
|
44
|
+
{ rel: '.gitattributes', backup: false },
|
|
45
|
+
{ rel: 'CLAUDE.md', backup: true },
|
|
46
|
+
{ rel: join(NEW_DIR, '.env'), backup: true }, // after the move
|
|
47
|
+
];
|
|
48
|
+
const GIT_HOOKS = ['pre-commit', 'commit-msg', 'pre-push'];
|
|
49
|
+
const STALE_COMMANDS = [
|
|
50
|
+
'.claude/commands/vibe-stats.md',
|
|
51
|
+
'.claude/commands/setup/vibe-config.md',
|
|
52
|
+
'.claude/commands/setup/vibe-doctor.md',
|
|
53
|
+
'.claude/commands/setup/vibe-level.md',
|
|
54
|
+
'.claude/commands/setup/setupvibedevkit.md',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function rewriteTokens(text) {
|
|
58
|
+
let out = text;
|
|
59
|
+
for (const [from, to] of TOKENS) out = out.split(from).join(to);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detects whether `target` holds a legacy install and whether the new folder
|
|
65
|
+
* already exists. A legacy install is a `vibekit/` with a config or runtime.
|
|
66
|
+
*/
|
|
67
|
+
export function detectLegacy(target) {
|
|
68
|
+
const legacy = join(target, LEGACY_DIR);
|
|
69
|
+
const isLegacy = existsSync(legacy) && (existsSync(join(legacy, 'config.json')) || existsSync(join(legacy, 'runtime')));
|
|
70
|
+
return { isLegacy, hasNew: existsSync(join(target, NEW_DIR)) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function rewriteFile(path, { backup, dryRun }) {
|
|
74
|
+
if (!existsSync(path)) return false;
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = await readFile(path, 'utf-8');
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const next = rewriteTokens(text);
|
|
82
|
+
if (next === text) return false;
|
|
83
|
+
if (dryRun) return true;
|
|
84
|
+
if (backup && !existsSync(`${path}.bak`)) await writeFile(`${path}.bak`, text, 'utf-8').catch(() => {});
|
|
85
|
+
await writeFile(path, next, 'utf-8').catch(() => {});
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function moveFolder(from, to) {
|
|
90
|
+
try {
|
|
91
|
+
await rename(from, to);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err && err.code === 'EXDEV') {
|
|
94
|
+
// Cross-device (e.g. target on another volume): copy then remove.
|
|
95
|
+
await cp(from, to, { recursive: true, force: true });
|
|
96
|
+
await rm(from, { recursive: true, force: true });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Runs the legacy → new migration on `target`. No-ops cleanly when there is
|
|
105
|
+
* nothing to migrate. NEVER throws — returns `{ migrated, report }`.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} target project root
|
|
108
|
+
* @param {{ dryRun?: boolean }} [opts] `dryRun` reports without writing
|
|
109
|
+
* @returns {Promise<{ migrated: boolean, report: string[] }>}
|
|
110
|
+
*/
|
|
111
|
+
export async function migrateLegacy(target, opts = {}) {
|
|
112
|
+
const dryRun = !!opts.dryRun;
|
|
113
|
+
const report = [];
|
|
114
|
+
let det;
|
|
115
|
+
try {
|
|
116
|
+
det = detectLegacy(target);
|
|
117
|
+
} catch {
|
|
118
|
+
return { migrated: false, report };
|
|
119
|
+
}
|
|
120
|
+
if (!det.isLegacy) return { migrated: false, report };
|
|
121
|
+
|
|
122
|
+
if (det.hasNew) {
|
|
123
|
+
report.push('⚠️ found BOTH vibekit/ (legacy) and contextkit/ — not merging automatically.');
|
|
124
|
+
report.push(' Your old data is in vibekit/. Move what you need into contextkit/, then delete vibekit/.');
|
|
125
|
+
return { migrated: false, report };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tag = dryRun ? '[dry-run] would' : '✓';
|
|
129
|
+
report.push(dryRun ? '🔎 legacy VibeDevKit install detected (dry-run — no changes):' : '🔄 migrating legacy VibeDevKit install → ContextDevKit…');
|
|
130
|
+
|
|
131
|
+
// 1) move the folder — carries ALL user data (memory, config, pipeline, .env) forward.
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
try {
|
|
134
|
+
await moveFolder(join(target, LEGACY_DIR), join(target, NEW_DIR));
|
|
135
|
+
} catch (err) {
|
|
136
|
+
report.push(`⚠️ could not move vibekit/ → contextkit/ (${err?.code || err}); migration aborted, nothing changed.`);
|
|
137
|
+
return { migrated: false, report };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
report.push(` ${tag} vibekit/ → contextkit/ (memory, config, pipeline, .env preserved)`);
|
|
141
|
+
|
|
142
|
+
// 2) rewrite the control files (CLAUDE.md + .env are backed up to *.bak first).
|
|
143
|
+
for (const { rel, backup } of CONTROL) {
|
|
144
|
+
if (await rewriteFile(join(target, rel), { backup, dryRun })) report.push(` ${tag} updated ${rel}${backup ? ' (backup *.bak)' : ''}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3) git-hook wrappers (best-effort; the installer re-installs them properly afterwards).
|
|
148
|
+
for (const hook of GIT_HOOKS) {
|
|
149
|
+
if (await rewriteFile(join(target, '.git', 'hooks', hook), { backup: false, dryRun })) report.push(` ${tag} rewired .git/hooks/${hook}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4) delete the stale /vibe-* + setupvibedevkit command files (new ones reinstalled by the flow).
|
|
153
|
+
for (const rel of STALE_COMMANDS) {
|
|
154
|
+
const path = join(target, rel);
|
|
155
|
+
if (!existsSync(path)) continue;
|
|
156
|
+
if (!dryRun) await rm(path, { force: true }).catch(() => {});
|
|
157
|
+
report.push(` ${tag} removed stale ${rel}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
report.push(dryRun ? '🔎 dry-run complete — re-run without --dry-run to apply.' : '✅ migration complete — continuing with the engine refresh…');
|
|
161
|
+
return { migrated: !dryRun, report };
|
|
162
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — legacy `vibekit/` → `contextkit/` migration (the rename).
|
|
3
|
+
*
|
|
4
|
+
* Scaffolds a throwaway project that looks like an OLD `vibedevkit` install,
|
|
5
|
+
* then drives the real installer to prove the migration:
|
|
6
|
+
* - `--migrate --dry-run` changes nothing;
|
|
7
|
+
* - `--migrate` moves the folder (preserving user memory/config/.env), rewrites
|
|
8
|
+
* settings.json / CLAUDE.md / .gitignore / git hooks, backs up user files,
|
|
9
|
+
* and deletes the stale /vibe-* commands;
|
|
10
|
+
* - `--update` auto-migrates end-to-end and refreshes the engine (no dup hooks);
|
|
11
|
+
* - BOTH folders present → refuse (no changes);
|
|
12
|
+
* - re-running on a migrated project is a no-op.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { KIT, node, run, git, reporter } from './it-helpers.mjs';
|
|
18
|
+
|
|
19
|
+
const rep = reporter();
|
|
20
|
+
const read = (p) => readFileSync(p, 'utf-8');
|
|
21
|
+
const tmp = () => mkdtempSync(join(tmpdir(), 'contextkit-mig-'));
|
|
22
|
+
|
|
23
|
+
/** Scaffolds a project that looks like a legacy VibeDevKit install. */
|
|
24
|
+
function makeLegacy(proj, { withGit = false } = {}) {
|
|
25
|
+
if (withGit) {
|
|
26
|
+
git(['init', '-b', 'main'], proj);
|
|
27
|
+
git(['config', 'user.email', 'it@example.com'], proj);
|
|
28
|
+
git(['config', 'user.name', 'IT'], proj);
|
|
29
|
+
}
|
|
30
|
+
const w = (rel, body) => {
|
|
31
|
+
const p = join(proj, rel);
|
|
32
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
33
|
+
writeFileSync(p, body, 'utf-8');
|
|
34
|
+
};
|
|
35
|
+
w('vibekit/config.json', JSON.stringify({ level: 5, setup: { completed: true }, ledger: {} }, null, 2));
|
|
36
|
+
w('vibekit/runtime/hooks/session-start.mjs', '// legacy dummy engine\n');
|
|
37
|
+
w('vibekit/memory/decisions/0001-user-decision.md', '# 0001 — a precious user ADR\nkeep me\n');
|
|
38
|
+
w('vibekit/.env', 'GOOGLE_AI_API_KEY=secret\nVIBE_GIT_TIMEOUT_MS=5000\n');
|
|
39
|
+
w('.claude/settings.json', JSON.stringify({
|
|
40
|
+
hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node vibekit/runtime/hooks/session-start.mjs' }] }] } },
|
|
41
|
+
null, 2));
|
|
42
|
+
w('.claude/commands/setup/vibe-level.md', 'old vibe-level command\n');
|
|
43
|
+
w('.claude/commands/vibe-stats.md', 'old vibe-stats command\n');
|
|
44
|
+
w('CLAUDE.md', 'This project uses VibeDevKit. Engine in vibekit/runtime. Run /vibe-level to change level.\n');
|
|
45
|
+
w('.gitignore', '# VibeDevKit — local runtime state (do not commit)\nvibekit/memory/tech-debt-findings.json\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Scenario A: dry-run changes nothing ──────────────────────────────────────
|
|
49
|
+
(() => {
|
|
50
|
+
const proj = tmp();
|
|
51
|
+
try {
|
|
52
|
+
makeLegacy(proj);
|
|
53
|
+
const out = run([join(KIT, 'install.mjs'), '--target', proj, '--migrate', '--dry-run']);
|
|
54
|
+
out.status === 0 ? rep.ok('--migrate --dry-run exits 0') : rep.bad(`dry-run status ${out.status}: ${out.stderr}`);
|
|
55
|
+
existsSync(join(proj, 'vibekit')) && !existsSync(join(proj, 'contextkit'))
|
|
56
|
+
? rep.ok('dry-run left vibekit/ in place and created no contextkit/')
|
|
57
|
+
: rep.bad('dry-run mutated the filesystem');
|
|
58
|
+
read(join(proj, 'CLAUDE.md')).includes('vibekit/') ? rep.ok('dry-run left CLAUDE.md untouched') : rep.bad('dry-run rewrote CLAUDE.md');
|
|
59
|
+
/dry-run/i.test(out.stdout) ? rep.ok('dry-run output flags itself') : rep.bad('dry-run output missing the marker');
|
|
60
|
+
} finally { rmSync(proj, { recursive: true, force: true }); }
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
// ── Scenario B: real migration preserves data + rewrites references ──────────
|
|
64
|
+
(() => {
|
|
65
|
+
const proj = tmp();
|
|
66
|
+
try {
|
|
67
|
+
makeLegacy(proj);
|
|
68
|
+
const out = run([join(KIT, 'install.mjs'), '--target', proj, '--migrate']);
|
|
69
|
+
out.status === 0 ? rep.ok('--migrate exits 0') : rep.bad(`--migrate status ${out.status}: ${out.stderr}`);
|
|
70
|
+
|
|
71
|
+
!existsSync(join(proj, 'vibekit')) ? rep.ok('vibekit/ is gone') : rep.bad('vibekit/ still present');
|
|
72
|
+
existsSync(join(proj, 'contextkit')) ? rep.ok('contextkit/ exists') : rep.bad('contextkit/ missing');
|
|
73
|
+
|
|
74
|
+
// user data preserved
|
|
75
|
+
const adr = join(proj, 'contextkit', 'memory', 'decisions', '0001-user-decision.md');
|
|
76
|
+
existsSync(adr) && read(adr).includes('precious user ADR') ? rep.ok('user ADR preserved through the move') : rep.bad('user ADR lost');
|
|
77
|
+
try {
|
|
78
|
+
JSON.parse(read(join(proj, 'contextkit', 'config.json'))).level === 5 ? rep.ok('config level 5 preserved') : rep.bad('config level changed');
|
|
79
|
+
} catch { rep.bad('config.json unreadable after migration'); }
|
|
80
|
+
|
|
81
|
+
// .env migrated + backed up
|
|
82
|
+
const env = read(join(proj, 'contextkit', '.env'));
|
|
83
|
+
env.includes('CONTEXT_GIT_TIMEOUT_MS') && !env.includes('VIBE_') ? rep.ok('.env VIBE_* → CONTEXT_*') : rep.bad('.env env-var not migrated');
|
|
84
|
+
existsSync(join(proj, 'contextkit', '.env.bak')) ? rep.ok('.env backed up to .env.bak') : rep.bad('.env.bak missing');
|
|
85
|
+
|
|
86
|
+
// settings.json rewired (no legacy paths, no duplicate)
|
|
87
|
+
const settings = read(join(proj, '.claude', 'settings.json'));
|
|
88
|
+
!settings.includes('vibekit/') && settings.includes('contextkit/runtime/hooks') ? rep.ok('settings.json rewired to contextkit/') : rep.bad('settings.json still references vibekit/');
|
|
89
|
+
|
|
90
|
+
// CLAUDE.md rewritten + backed up
|
|
91
|
+
const claude = read(join(proj, 'CLAUDE.md'));
|
|
92
|
+
!claude.includes('vibekit/') && claude.includes('contextkit/') && claude.includes('/context-level') && claude.includes('ContextDevKit')
|
|
93
|
+
? rep.ok('CLAUDE.md references rewritten') : rep.bad('CLAUDE.md not fully rewritten');
|
|
94
|
+
existsSync(join(proj, 'CLAUDE.md.bak')) && read(join(proj, 'CLAUDE.md.bak')).includes('VibeDevKit')
|
|
95
|
+
? rep.ok('CLAUDE.md backed up to .bak') : rep.bad('CLAUDE.md.bak missing/wrong');
|
|
96
|
+
|
|
97
|
+
// stale commands removed
|
|
98
|
+
!existsSync(join(proj, '.claude', 'commands', 'setup', 'vibe-level.md')) && !existsSync(join(proj, '.claude', 'commands', 'vibe-stats.md'))
|
|
99
|
+
? rep.ok('stale /vibe-* command files removed') : rep.bad('stale command files left behind');
|
|
100
|
+
|
|
101
|
+
// .gitignore de-duplicated (now ContextDevKit)
|
|
102
|
+
read(join(proj, '.gitignore')).includes('ContextDevKit') ? rep.ok('.gitignore block rewritten') : rep.bad('.gitignore not migrated');
|
|
103
|
+
|
|
104
|
+
// idempotent: second run is a clean no-op
|
|
105
|
+
const again = run([join(KIT, 'install.mjs'), '--target', proj, '--migrate']);
|
|
106
|
+
again.status === 0 && /nothing to migrate/i.test(again.stdout) ? rep.ok('re-running --migrate is a no-op') : rep.bad('second --migrate was not a clean no-op');
|
|
107
|
+
} finally { rmSync(proj, { recursive: true, force: true }); }
|
|
108
|
+
})();
|
|
109
|
+
|
|
110
|
+
// ── Scenario C: BOTH folders present → refuse, change nothing ────────────────
|
|
111
|
+
(() => {
|
|
112
|
+
const proj = tmp();
|
|
113
|
+
try {
|
|
114
|
+
makeLegacy(proj);
|
|
115
|
+
mkdirSync(join(proj, 'contextkit'), { recursive: true });
|
|
116
|
+
writeFileSync(join(proj, 'contextkit', 'config.json'), JSON.stringify({ level: 3 }), 'utf-8');
|
|
117
|
+
const out = run([join(KIT, 'install.mjs'), '--target', proj, '--migrate']);
|
|
118
|
+
out.status === 0 ? rep.ok('refuse-both exits 0') : rep.bad(`refuse-both status ${out.status}`);
|
|
119
|
+
/BOTH/i.test(out.stdout) ? rep.ok('warns about BOTH installs') : rep.bad('no BOTH warning');
|
|
120
|
+
existsSync(join(proj, 'vibekit')) ? rep.ok('refuse-both left vibekit/ untouched') : rep.bad('refuse-both deleted vibekit/');
|
|
121
|
+
} finally { rmSync(proj, { recursive: true, force: true }); }
|
|
122
|
+
})();
|
|
123
|
+
|
|
124
|
+
// ── Scenario D: --update auto-migrates AND refreshes the real engine ─────────
|
|
125
|
+
(() => {
|
|
126
|
+
const proj = tmp();
|
|
127
|
+
try {
|
|
128
|
+
makeLegacy(proj, { withGit: true });
|
|
129
|
+
const out = run([join(KIT, 'install.mjs'), '--target', proj, '--update']);
|
|
130
|
+
out.status === 0 ? rep.ok('--update on a legacy install exits 0') : rep.bad(`--update status ${out.status}: ${out.stderr}`);
|
|
131
|
+
!existsSync(join(proj, 'vibekit')) ? rep.ok('--update migrated away vibekit/') : rep.bad('--update left vibekit/');
|
|
132
|
+
// real engine refreshed (the dummy 1-line hook is replaced by the actual script)
|
|
133
|
+
const hook = join(proj, 'contextkit', 'runtime', 'hooks', 'session-start.mjs');
|
|
134
|
+
existsSync(hook) && read(hook).length > 200 ? rep.ok('--update refreshed the real engine') : rep.bad('engine not refreshed');
|
|
135
|
+
// no duplicate hooks: exactly one SessionStart group, referencing contextkit/
|
|
136
|
+
const s = JSON.parse(read(join(proj, '.claude', 'settings.json')));
|
|
137
|
+
const ss = s.hooks?.SessionStart || [];
|
|
138
|
+
ss.length === 1 && !JSON.stringify(s).includes('vibekit/') ? rep.ok('no duplicate hooks after --update') : rep.bad(`duplicate/legacy hooks remain (SessionStart groups: ${ss.length})`);
|
|
139
|
+
} finally { rmSync(proj, { recursive: true, force: true }); }
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
// ── Scenario E: fresh install is unaffected (no legacy → no-op) ──────────────
|
|
143
|
+
(() => {
|
|
144
|
+
const proj = tmp();
|
|
145
|
+
try {
|
|
146
|
+
const out = run([join(KIT, 'install.mjs'), '--target', proj, '--migrate']);
|
|
147
|
+
out.status === 0 && /nothing to migrate/i.test(out.stdout) ? rep.ok('no-legacy --migrate is a clean no-op') : rep.bad('no-legacy --migrate misbehaved');
|
|
148
|
+
} finally { rmSync(proj, { recursive: true, force: true }); }
|
|
149
|
+
})();
|
|
150
|
+
|
|
151
|
+
rep.finish('Integration (migration)');
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
|
-
import { KIT, run, readJson, reporter, installFixture } from './it-helpers.mjs';
|
|
25
|
+
import { KIT, run, git, readJson, reporter, installFixture } from './it-helpers.mjs';
|
|
26
26
|
|
|
27
27
|
const rep = reporter();
|
|
28
28
|
const { ok, bad } = rep;
|
|
@@ -48,6 +48,30 @@ try {
|
|
|
48
48
|
(() => { try { const g = JSON.parse(gitStatus.stdout); return g.isRepo === true && g.remoteUrl === null; } catch { return false; } })()
|
|
49
49
|
? ok('git.mjs reports repo + missing remote') : bad(`git.mjs failed: ${gitStatus.stdout || gitStatus.stderr}`);
|
|
50
50
|
|
|
51
|
+
// ADR-0030 — complexity rubric: regulated domain auto-routes + forces architectural tier.
|
|
52
|
+
const clsLgpd = script('complexity-rubric.mjs', 'classify', 'store user CPF and consent', '--json');
|
|
53
|
+
(() => { try { const j = JSON.parse(clsLgpd.stdout); return j.domain === 'lgpd' && j.requiredAgents.includes('privacy-lgpd') && j.tier === 'architectural' && j.needsAdr === true; } catch { return false; } })()
|
|
54
|
+
? ok('complexity-rubric routes a regulated (LGPD) task to privacy-lgpd + architectural tier')
|
|
55
|
+
: bad(`complexity-rubric LGPD classify failed: ${clsLgpd.stdout || clsLgpd.stderr}`);
|
|
56
|
+
const clsTrivial = script('complexity-rubric.mjs', 'classify', 'fix typo in readme', '--json');
|
|
57
|
+
(() => { try { const j = JSON.parse(clsTrivial.stdout); return j.tier === 'trivial' && j.needsAdr === false && j.domain === 'general'; } catch { return false; } })()
|
|
58
|
+
? ok('complexity-rubric classifies a trivial task with no ceremony')
|
|
59
|
+
: bad(`complexity-rubric trivial classify failed: ${clsTrivial.stdout || clsTrivial.stderr}`);
|
|
60
|
+
|
|
61
|
+
// ADR-0030 — validate-doc flags an unfilled ADR template (placeholders), runs the adr rubric.
|
|
62
|
+
const vdTpl = script('validate-doc.mjs', 'contextkit/memory/decisions/_TEMPLATE.md', '--json');
|
|
63
|
+
(() => { try { const j = JSON.parse(vdTpl.stdout); return j.type === 'adr' && j.errorCount > 0 && j.findings.some((f) => f.code === 'PLACEHOLDER'); } catch { return false; } })()
|
|
64
|
+
? ok('validate-doc flags an unfilled ADR template (placeholders)')
|
|
65
|
+
: bad(`validate-doc template check failed: ${vdTpl.stdout || vdTpl.stderr}`);
|
|
66
|
+
|
|
67
|
+
// ADR-0030 — draft-changelog groups Conventional Commits since the last tag.
|
|
68
|
+
git(['add', '-A'], proj);
|
|
69
|
+
git(['commit', '-m', 'feat(x): add a thing', '--no-verify'], proj);
|
|
70
|
+
const dc = script('draft-changelog.mjs', '--json');
|
|
71
|
+
(() => { try { const j = JSON.parse(dc.stdout); return Array.isArray(j.groups?.Added) && j.groups.Added.some((i) => i.text.includes('add a thing')); } catch { return false; } })()
|
|
72
|
+
? ok('draft-changelog groups Conventional Commits into Keep-a-Changelog sections')
|
|
73
|
+
: bad(`draft-changelog failed: ${dc.stdout || dc.stderr}`);
|
|
74
|
+
|
|
51
75
|
// DevPipeline tests live in `integration-test-tooling-pipeline.mjs` (sibling).
|
|
52
76
|
|
|
53
77
|
// Deep analysis: aggregates the deterministic scanners into one report.
|