@ionivetech/mugiwara 0.4.0 → 0.5.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 +231 -371
- package/content/agents/brook-healing.md +2 -2
- package/content/agents/chopper-checkpoint.md +2 -1
- package/content/agents/eval-runner.md +4 -4
- package/content/agents/franky-gates.md +1 -1
- package/content/agents/jinbe-security.md +2 -1
- package/content/agents/luffy-orchestrator.md +3 -3
- package/content/agents/memory-keeper.md +1 -1
- package/content/agents/nami-planner.md +2 -2
- package/content/agents/resume-coordinator.md +8 -9
- package/content/agents/robin-reviewer.md +3 -2
- package/content/agents/sanji-quality.md +1 -1
- package/content/agents/skeptic-verifier.md +4 -3
- package/content/agents/using-mugiwara.md +3 -3
- package/content/agents/usopp-brainstorm.md +3 -3
- package/content/agents/zoro-execution.md +4 -3
- package/content/skills/mugiwara-agent-security/SKILL.md +14 -2
- package/content/skills/mugiwara-backend/SKILL.md +2 -8
- package/content/skills/mugiwara-backend/references/source-backed-example.md +77 -0
- package/content/skills/mugiwara-brainstorm/SKILL.md +2 -2
- package/content/skills/mugiwara-checkpoint/SKILL.md +2 -2
- package/content/skills/mugiwara-checkpoint/references/ledger-format.md +34 -0
- package/content/skills/{mugiwara-doubt-driven-development → mugiwara-claim-audit}/SKILL.md +2 -2
- package/content/skills/{mugiwara-context-engineering → mugiwara-context-budget}/SKILL.md +2 -2
- package/content/skills/mugiwara-context-budget/references/context-budget.md +37 -0
- package/content/skills/{mugiwara-api-and-interface-design → mugiwara-contract-first}/SKILL.md +4 -2
- package/content/skills/mugiwara-contract-first/references/versioning-playbook.md +36 -0
- package/content/skills/mugiwara-execution/SKILL.md +4 -2
- package/content/skills/mugiwara-execution/references/worked-example.md +88 -0
- package/content/skills/mugiwara-frontend/SKILL.md +13 -14
- package/content/skills/mugiwara-frontend/references/design-tokens.md +25 -0
- package/content/skills/mugiwara-frontend/references/slop-catalog.md +19 -0
- package/content/skills/mugiwara-gates/SKILL.md +4 -4
- package/content/skills/mugiwara-git/SKILL.md +1 -1
- package/content/skills/mugiwara-healing/SKILL.md +34 -7
- package/content/skills/mugiwara-healing/references/failure-taxonomy.md +23 -0
- package/content/skills/mugiwara-lessons/SKILL.md +1 -1
- package/content/skills/mugiwara-orchestration/SKILL.md +5 -5
- package/content/skills/mugiwara-planning/SKILL.md +2 -2
- package/content/skills/mugiwara-planning/references/anti-patterns.md +17 -0
- package/content/skills/mugiwara-planning/references/plan-template.md +46 -0
- package/content/skills/mugiwara-pr/SKILL.md +1 -1
- package/content/skills/{mugiwara-test-driven-development → mugiwara-proof-order}/SKILL.md +2 -2
- package/content/skills/mugiwara-proof-order/references/proof-order-examples.md +62 -0
- package/content/skills/mugiwara-quality/SKILL.md +3 -3
- package/content/skills/mugiwara-resume/SKILL.md +44 -33
- package/content/skills/mugiwara-review/SKILL.md +1 -1
- package/content/skills/mugiwara-review/references/five-axis-worksheet.md +36 -0
- package/content/skills/mugiwara-review/references/severity-rubric.md +20 -0
- package/content/skills/{mugiwara-systematic-debugging → mugiwara-root-cause}/SKILL.md +4 -2
- package/content/skills/mugiwara-root-cause/references/four-phase-worked.md +71 -0
- package/content/skills/mugiwara-security/SKILL.md +1 -1
- package/content/skills/mugiwara-security/references/owasp-mapping.md +30 -0
- package/content/skills/mugiwara-security/references/stride-worksheet.md +37 -0
- package/content/skills/mugiwara-ship/SKILL.md +1 -1
- package/content/skills/{mugiwara-deprecation → mugiwara-sunset}/SKILL.md +2 -2
- package/content/skills/mugiwara-testcases/SKILL.md +1 -1
- package/content/skills/mugiwara-testcases/references/intake-formats.md +42 -0
- package/content/skills/mugiwara-workflow/SKILL.md +20 -23
- package/dist/mugiwara.js +37 -8
- package/docs/agents.md +2 -2
- package/docs/audit-trail.md +65 -0
- package/docs/comparison.md +43 -170
- package/docs/compliance-matrix.md +81 -0
- package/docs/config.md +4 -2
- package/docs/cost.md +45 -0
- package/docs/enforcement.md +32 -35
- package/docs/getting-started.md +103 -41
- package/docs/harness-matrix.md +41 -0
- package/docs/lanes.md +65 -23
- package/docs/skill-anatomy.md +42 -54
- package/docs/skills.md +36 -54
- package/evals/cases/_no-skill.json +16 -0
- package/evals/cases/adversarial-pressure-fake-pass.json +21 -8
- package/evals/cases/adversarial-pressure-skip-review.json +19 -7
- package/evals/cases/lane-exploratory-vague.json +18 -6
- package/evals/cases/lane-sensitivity-payment.json +18 -6
- package/evals/cases/positive-refactor-existing-tests.json +22 -8
- package/evals/cases/positive-resume-mid-mission.json +20 -7
- package/evals/cases/routing-agent-security.json +25 -0
- package/evals/cases/routing-auth-feature.json +20 -7
- package/evals/cases/routing-backend.json +25 -0
- package/evals/cases/routing-bug-one-file.json +21 -8
- package/evals/cases/routing-claim-audit.json +25 -0
- package/evals/cases/routing-context-budget.json +25 -0
- package/evals/cases/routing-contract-first.json +25 -0
- package/evals/cases/routing-execution.json +25 -0
- package/evals/cases/routing-frontend.json +26 -0
- package/evals/cases/routing-gates.json +25 -0
- package/evals/cases/routing-git.json +25 -0
- package/evals/cases/routing-healing.json +25 -0
- package/evals/cases/routing-lessons.json +25 -0
- package/evals/cases/routing-orchestration.json +25 -0
- package/evals/cases/routing-planning.json +26 -0
- package/evals/cases/routing-pr.json +25 -0
- package/evals/cases/routing-proof-order.json +25 -0
- package/evals/cases/routing-quality.json +25 -0
- package/evals/cases/routing-ship.json +26 -0
- package/evals/cases/routing-sunset.json +25 -0
- package/evals/cases/routing-workflow.json +25 -0
- package/evals/floor.json +6 -0
- package/package.json +6 -4
- package/references/definition-of-done.md +42 -0
- package/references/multi-actor.md +44 -0
- package/references/skill-versioning.md +44 -0
- package/references/source-grounding.md +23 -0
- package/references/token-budget.md +34 -0
- package/scripts/evidence.sh +37 -0
- package/scripts/lane.sh +74 -0
- package/scripts/mission-report.sh +88 -0
- package/scripts/probe.ts +40 -0
- package/scripts/release-notes.ts +65 -0
- package/scripts/retrieval-eval.ts +261 -0
- package/scripts/run-evals.ts +149 -0
- package/scripts/savepoint.sh +166 -0
- package/scripts/sync-version.ts +30 -0
- package/scripts/validate-content.ts +226 -0
- package/src/cli.ts +8 -3
- package/src/mission.ts +26 -3
- package/content/skills/mugiwara-dynamic-workflow/SKILL.md +0 -90
- package/content/skills/mugiwara-eval/SKILL.md +0 -87
- package/content/skills/mugiwara-git-worktrees/SKILL.md +0 -67
- package/content/skills/mugiwara-mode/SKILL.md +0 -77
- package/content/skills/mugiwara-observability/SKILL.md +0 -59
- package/content/skills/mugiwara-writing-skills/SKILL.md +0 -65
- package/evals/cases/negative-secrets-typo.json +0 -12
- package/evals/cases/negative-security-docs-change.json +0 -12
- package/evals/cases/routing-typo.json +0 -13
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// scripts/retrieval-eval.ts — offline retrieval ranking eval
|
|
3
|
+
// Builds TF-IDF index over skill descriptions, scores prompts, reports rank-1 + top_k.
|
|
4
|
+
// No model needed — pure string matching. Outputs JSON.
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { parseFrontmatter } from '../src/frontmatter.ts';
|
|
9
|
+
|
|
10
|
+
const root = join(import.meta.dirname, '..');
|
|
11
|
+
const skillsDir = join(root, 'content', 'skills');
|
|
12
|
+
const evalsDir = join(root, 'evals', 'cases');
|
|
13
|
+
|
|
14
|
+
// --- TF-IDF ---
|
|
15
|
+
type Index = { terms: Map<string, number>; docs: Map<string, Map<string, number>>; docCount: number };
|
|
16
|
+
|
|
17
|
+
function tokenize(text: string): string[] {
|
|
18
|
+
return text.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.filter(t => t.length > 1 && !['the', 'and', 'for', 'use', 'when', 'that', 'with', 'this', 'from', 'its', 'not', 'are', 'has'].includes(t));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildIndex(): Index {
|
|
25
|
+
const index: Index = { terms: new Map(), docs: new Map(), docCount: 0 };
|
|
26
|
+
const dirs = readdirSync(skillsDir).filter(d => statSync(join(skillsDir, d)).isDirectory());
|
|
27
|
+
|
|
28
|
+
for (const dir of dirs) {
|
|
29
|
+
const file = join(skillsDir, dir, 'SKILL.md');
|
|
30
|
+
if (!existsSync(file)) continue;
|
|
31
|
+
|
|
32
|
+
const { data } = parseFrontmatter(readFileSync(file, 'utf8'));
|
|
33
|
+
const desc = data.description ?? '';
|
|
34
|
+
const tokens = tokenize(desc);
|
|
35
|
+
const tf = new Map<string, number>();
|
|
36
|
+
|
|
37
|
+
for (const t of tokens) {
|
|
38
|
+
tf.set(t, (tf.get(t) || 0) + 1);
|
|
39
|
+
index.terms.set(t, (index.terms.get(t) || 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
index.docs.set(dir, tf);
|
|
43
|
+
index.docCount++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return index;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tfidf(index: Index, doc: string, term: string): number {
|
|
50
|
+
const tf = index.docs.get(doc)?.get(term) || 0;
|
|
51
|
+
if (tf === 0) return 0;
|
|
52
|
+
const df = index.terms.get(term) || 1;
|
|
53
|
+
return tf * Math.log(index.docCount / df);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function score(index: Index, prompt: string): { skill: string; score: number }[] {
|
|
57
|
+
const promptTerms = tokenize(prompt);
|
|
58
|
+
const results: { skill: string; score: number }[] = [];
|
|
59
|
+
|
|
60
|
+
for (const [doc] of index.docs) {
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (const t of promptTerms) {
|
|
63
|
+
total += tfidf(index, doc, t);
|
|
64
|
+
}
|
|
65
|
+
results.push({ skill: doc, score: total });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return results.sort((a, b) => b.score - a.score || a.skill.localeCompare(b.skill));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- case schema ---
|
|
72
|
+
interface CaseFile {
|
|
73
|
+
name: string;
|
|
74
|
+
skill: string;
|
|
75
|
+
type?: string;
|
|
76
|
+
task: string;
|
|
77
|
+
rubric: string[];
|
|
78
|
+
expect_lane?: string;
|
|
79
|
+
trigger?: {
|
|
80
|
+
positive?: { prompt: string; top_k?: number }[];
|
|
81
|
+
negative?: { prompt: string }[];
|
|
82
|
+
};
|
|
83
|
+
behavioral?: { task: string; rubric: string[] }[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Probe {
|
|
87
|
+
kind: 'positive' | 'negative';
|
|
88
|
+
skill: string;
|
|
89
|
+
prompt: string;
|
|
90
|
+
topK: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- build index ---
|
|
94
|
+
const index = buildIndex();
|
|
95
|
+
|
|
96
|
+
// --- load cases ---
|
|
97
|
+
const skills = readdirSync(skillsDir).filter(d => statSync(join(skillsDir, d)).isDirectory());
|
|
98
|
+
const files = readdirSync(evalsDir).filter(f => f.endsWith('.json'));
|
|
99
|
+
const probes: Probe[] = [];
|
|
100
|
+
const noSkillProbes: Probe[] = [];
|
|
101
|
+
const covered = new Set<string>();
|
|
102
|
+
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
let c: CaseFile;
|
|
105
|
+
try {
|
|
106
|
+
c = JSON.parse(readFileSync(join(evalsDir, f), 'utf8'));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(`eval case ${f} is not valid JSON: ${e}`);
|
|
109
|
+
}
|
|
110
|
+
if (!c.skill) throw new Error(`eval case ${f} has no "skill"`);
|
|
111
|
+
covered.add(c.skill);
|
|
112
|
+
|
|
113
|
+
const target = c.skill === '_no-skill' ? noSkillProbes : probes;
|
|
114
|
+
for (const p of c.trigger?.positive ?? [])
|
|
115
|
+
target.push({ kind: 'positive', skill: c.skill, prompt: p.prompt, topK: p.top_k ?? 3 });
|
|
116
|
+
for (const p of c.trigger?.negative ?? [])
|
|
117
|
+
target.push({ kind: 'negative', skill: c.skill, prompt: p.prompt, topK: 3 });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- coverage gate ---
|
|
121
|
+
const missing = skills.filter(s => !covered.has(s));
|
|
122
|
+
if (missing.length) {
|
|
123
|
+
console.error(`missing eval cases for ${missing.length} skills:`);
|
|
124
|
+
for (const s of missing) console.error(` ${s}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- run probes ---
|
|
129
|
+
let rank1 = 0, inTopK = 0, negPass = 0, noSkillPass = 0;
|
|
130
|
+
const positives = probes.filter(p => p.kind === 'positive');
|
|
131
|
+
const negatives = probes.filter(p => p.kind === 'negative');
|
|
132
|
+
const nsPositives = noSkillProbes.filter(p => p.kind === 'positive');
|
|
133
|
+
const nsNegatives = noSkillProbes.filter(p => p.kind === 'negative');
|
|
134
|
+
const failures: string[] = [];
|
|
135
|
+
const results: Record<string, { rank: number; score: number; top_k: number; passed: boolean }> = {};
|
|
136
|
+
|
|
137
|
+
for (const p of positives) {
|
|
138
|
+
const ranked = score(index, p.prompt);
|
|
139
|
+
const rank = ranked.findIndex(r => r.skill === p.skill) + 1;
|
|
140
|
+
const key = `${p.skill}: ${p.prompt}`;
|
|
141
|
+
const entryScore = rank > 0 ? ranked[rank - 1].score : 0;
|
|
142
|
+
const topScore = ranked[0]?.score ?? 0;
|
|
143
|
+
const passed = entryScore > 0 && rank > 0 && rank <= p.topK;
|
|
144
|
+
results[key] = { rank, score: entryScore, top_k: p.topK, passed };
|
|
145
|
+
if (passed && rank === 1) rank1++;
|
|
146
|
+
if (passed) inTopK++;
|
|
147
|
+
else failures.push(`positive "${p.prompt}" → ${p.skill} ranked ${rank || 'unranked'} (want <=${p.topK}), got ${ranked[0]?.skill ?? 'none'}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const p of negatives) {
|
|
151
|
+
const ranked = score(index, p.prompt);
|
|
152
|
+
const top = ranked[0];
|
|
153
|
+
const pass = (top?.score ?? 0) === 0 || top?.skill !== p.skill;
|
|
154
|
+
const key = `!${p.skill}: ${p.prompt}`;
|
|
155
|
+
results[key] = { rank: 0, score: top?.score ?? 0, top_k: 3, passed: pass };
|
|
156
|
+
if (pass) negPass++;
|
|
157
|
+
else failures.push(`negative "${p.prompt}" wrongly ranked ${p.skill} first`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const p of nsPositives) {
|
|
161
|
+
const ranked = score(index, p.prompt);
|
|
162
|
+
const top = ranked[0];
|
|
163
|
+
const pass = !top || top.score < 3.5;
|
|
164
|
+
const key = `_no-skill+: ${p.prompt}`;
|
|
165
|
+
results[key] = { rank: 0, score: top?.score ?? 0, top_k: 3, passed: pass };
|
|
166
|
+
if (pass) noSkillPass++;
|
|
167
|
+
else failures.push(`no-skill positive "${p.prompt}" → ${top.skill} score ${top.score.toFixed(2)} (want <3.5)`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const p of nsNegatives) {
|
|
171
|
+
const ranked = score(index, p.prompt);
|
|
172
|
+
const top = ranked[0];
|
|
173
|
+
const pass = !!top && top.skill.startsWith('mugiwara-') && top.score >= 3.5;
|
|
174
|
+
const key = `_no-skill-: ${p.prompt}`;
|
|
175
|
+
results[key] = { rank: 0, score: top?.score ?? 0, top_k: 3, passed: pass };
|
|
176
|
+
if (pass) noSkillPass++;
|
|
177
|
+
else failures.push(`no-skill negative "${p.prompt}" → ${top?.skill ?? 'none'} score ${top?.score?.toFixed(2) ?? '0'} (want mugiwara skill >=3.5)`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- compute rates ---
|
|
181
|
+
const rank1Rate = positives.length ? (rank1 / positives.length) * 100 : 0;
|
|
182
|
+
const topKRate = positives.length ? (inTopK / positives.length) * 100 : 0;
|
|
183
|
+
const negRate = negatives.length ? (negPass / negatives.length) * 100 : 0;
|
|
184
|
+
const nsTotal = nsPositives.length + nsNegatives.length;
|
|
185
|
+
const nsRate = nsTotal ? (noSkillPass / nsTotal) * 100 : 100;
|
|
186
|
+
|
|
187
|
+
const allPassed = Object.values(results).filter(r => r.passed).length;
|
|
188
|
+
const allFailed = Object.values(results).filter(r => !r.passed).length;
|
|
189
|
+
const totalProbes = Object.keys(results).length;
|
|
190
|
+
|
|
191
|
+
// --- floor / ratchet ---
|
|
192
|
+
const floorPath = join(root, 'evals', 'floor.json');
|
|
193
|
+
const updateFloor = process.argv.includes('--update-floor');
|
|
194
|
+
|
|
195
|
+
if (!existsSync(floorPath)) {
|
|
196
|
+
if (updateFloor) {
|
|
197
|
+
const initial = { rank1: Math.round(rank1Rate * 10) / 10, topk: Math.round(topKRate * 10) / 10, negatives: Math.round(negRate * 10) / 10, updated: new Date().toISOString().split('T')[0] };
|
|
198
|
+
writeJson(floorPath, initial);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
const floor = JSON.parse(readFileSync(floorPath, 'utf8'));
|
|
202
|
+
const TOL = 0.5;
|
|
203
|
+
|
|
204
|
+
if (updateFloor) {
|
|
205
|
+
floor.rank1 = Math.round(rank1Rate * 10) / 10;
|
|
206
|
+
floor.topk = Math.round(topKRate * 10) / 10;
|
|
207
|
+
floor.negatives = Math.round(negRate * 10) / 10;
|
|
208
|
+
floor.updated = new Date().toISOString().split('T')[0];
|
|
209
|
+
writeJson(floorPath, floor);
|
|
210
|
+
} else {
|
|
211
|
+
const regressions: string[] = [];
|
|
212
|
+
if (rank1Rate < floor.rank1 - TOL) regressions.push(`rank-1 ${rank1Rate.toFixed(1)}% < floor ${floor.rank1}%`);
|
|
213
|
+
if (topKRate < floor.topk - TOL) regressions.push(`top-k ${topKRate.toFixed(1)}% < floor ${floor.topk}%`);
|
|
214
|
+
if (negRate < floor.negatives - TOL) regressions.push(`negatives ${negRate.toFixed(1)}% < floor ${floor.negatives}%`);
|
|
215
|
+
|
|
216
|
+
console.log(`rank-1 ${rank1Rate.toFixed(1)}% top-3 ${topKRate.toFixed(1)}% negatives ${negRate.toFixed(1)}% no-skill ${nsRate.toFixed(1)}% (${positives.length}p / ${negatives.length}n / ${nsTotal}ns over ${skills.length} skills)`);
|
|
217
|
+
for (const f of failures) console.error(` FAIL ${f}`);
|
|
218
|
+
if (regressions.length) {
|
|
219
|
+
regressions.forEach(r => console.error(`REGRESSION: ${r}`));
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- report ---
|
|
226
|
+
const ciArg = process.argv.indexOf('--json');
|
|
227
|
+
if (ciArg !== -1) {
|
|
228
|
+
const report = {
|
|
229
|
+
index_size: index.docCount,
|
|
230
|
+
index_terms: index.terms.size,
|
|
231
|
+
probes: totalProbes,
|
|
232
|
+
positives: positives.length,
|
|
233
|
+
negatives: negatives.length,
|
|
234
|
+
no_skill: nsTotal,
|
|
235
|
+
rank1_count: rank1,
|
|
236
|
+
rank1_rate: `${rank1Rate.toFixed(1)}%`,
|
|
237
|
+
topk_count: inTopK,
|
|
238
|
+
topk_rate: `${topKRate.toFixed(1)}%`,
|
|
239
|
+
negative_pass: negPass,
|
|
240
|
+
negative_rate: `${negRate.toFixed(1)}%`,
|
|
241
|
+
no_skill_pass: noSkillPass,
|
|
242
|
+
no_skill_rate: `${nsRate.toFixed(1)}%`,
|
|
243
|
+
passed: allPassed,
|
|
244
|
+
failed: allFailed,
|
|
245
|
+
failures: failures.length > 0 ? failures : undefined,
|
|
246
|
+
results,
|
|
247
|
+
};
|
|
248
|
+
console.log(JSON.stringify(report, null, 2));
|
|
249
|
+
} else {
|
|
250
|
+
console.log(`\nRetrieval eval: ${allPassed}/${totalProbes} passed, rank-1 ${rank1Rate.toFixed(1)}%, top-3 ${topKRate.toFixed(1)}%, neg ${negRate.toFixed(1)}%, ns ${nsRate.toFixed(1)}%`);
|
|
251
|
+
if (failures.length > 0) {
|
|
252
|
+
console.error(`\n${failures.length} failures:`);
|
|
253
|
+
for (const f of failures) console.error(` ${f}`);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function writeJson(path: string, obj: object) {
|
|
259
|
+
const { writeFileSync } = require('node:fs');
|
|
260
|
+
writeFileSync(path, JSON.stringify(obj, null, 2) + '\n');
|
|
261
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// scripts/run-evals.ts — the mugiwara eval harness.
|
|
3
|
+
// Two modes:
|
|
4
|
+
// bun scripts/run-evals.ts validate the suite (structure + coverage). CI-safe, no model.
|
|
5
|
+
// bun scripts/run-evals.ts --run [cmd] execute each case against a model CLI and score the rubric.
|
|
6
|
+
//
|
|
7
|
+
// --run uses the command to invoke a model that prints only its answer to the
|
|
8
|
+
// task; the harness scores the answer against the rubric with a heuristic
|
|
9
|
+
// keyword match, then reports pass/fail per case and a rank-1 style score.
|
|
10
|
+
// The command defaults to "claude -p" — override with MUGIWARA_EVAL_CMD.
|
|
11
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const root = join(import.meta.dirname, '..');
|
|
15
|
+
const casesDir = join(root, 'evals', 'cases');
|
|
16
|
+
const skillsDir = join(root, 'content', 'skills');
|
|
17
|
+
const errors: string[] = [];
|
|
18
|
+
|
|
19
|
+
type BehavioralTest = {
|
|
20
|
+
task: string;
|
|
21
|
+
rubric: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Case = {
|
|
25
|
+
name: string;
|
|
26
|
+
skill: string;
|
|
27
|
+
type?: 'positive' | 'negative' | 'adversarial' | 'lane';
|
|
28
|
+
behavioral?: BehavioralTest[];
|
|
29
|
+
expect_lane?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function listCases(dir: string, prefix = ''): string[] {
|
|
33
|
+
if (!existsSync(dir)) return [];
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
const p = join(dir, ent.name);
|
|
37
|
+
if (ent.isDirectory()) out.push(...listCases(p, join(prefix, ent.name)));
|
|
38
|
+
else if (ent.name.endsWith('.json')) out.push(join(prefix, ent.name));
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateSuite(): Case[] {
|
|
44
|
+
const cases: Case[] = [];
|
|
45
|
+
const files = listCases(casesDir).sort();
|
|
46
|
+
for (const rel of files) {
|
|
47
|
+
let raw: any;
|
|
48
|
+
try { raw = JSON.parse(readFileSync(join(casesDir, rel), 'utf8')); }
|
|
49
|
+
catch (e) { errors.push(`${rel}: invalid JSON (${(e as Error).message})`); continue; }
|
|
50
|
+
if (!raw.name || !raw.skill) { errors.push(`${rel}: missing name/skill`); continue; }
|
|
51
|
+
|
|
52
|
+
// skip files with no behavioral section (e.g. _no-skill.json)
|
|
53
|
+
if (!raw.behavioral || !Array.isArray(raw.behavioral) || raw.behavioral.length === 0) continue;
|
|
54
|
+
if (raw.skill === '_no-skill') continue;
|
|
55
|
+
|
|
56
|
+
// infer type from filename if not declared
|
|
57
|
+
const type = raw.type ?? (
|
|
58
|
+
rel.includes('positive') ? 'positive' :
|
|
59
|
+
rel.includes('negative') ? 'negative' :
|
|
60
|
+
rel.includes('adversarial') ? 'adversarial' :
|
|
61
|
+
rel.includes('lane') ? 'lane' :
|
|
62
|
+
undefined
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// validate skill exists
|
|
66
|
+
if (!existsSync(join(skillsDir, raw.skill)))
|
|
67
|
+
errors.push(`${rel}: unknown skill "${raw.skill}"`);
|
|
68
|
+
|
|
69
|
+
if (type && !['positive', 'negative', 'adversarial', 'lane'].includes(type))
|
|
70
|
+
errors.push(`${rel}: bad type "${type}"`);
|
|
71
|
+
|
|
72
|
+
// register one case per behavioral test
|
|
73
|
+
for (const b of raw.behavioral) {
|
|
74
|
+
if (!b.task || !Array.isArray(b.rubric) || !b.rubric.length) {
|
|
75
|
+
errors.push(`${rel}: behavioral entry missing task/nonempty rubric`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
cases.push({
|
|
79
|
+
name: raw.name,
|
|
80
|
+
skill: raw.skill,
|
|
81
|
+
type,
|
|
82
|
+
behavioral: [b],
|
|
83
|
+
expect_lane: raw.expect_lane,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!cases.length) { errors.push('no behavioral cases in evals/cases/'); }
|
|
89
|
+
|
|
90
|
+
// coverage gates: the suite must exercise routing in all directions.
|
|
91
|
+
const explicitTypes = cases.filter(c => c.type);
|
|
92
|
+
const allTypes = new Set(explicitTypes.map(c => c.type).filter(Boolean));
|
|
93
|
+
if (explicitTypes.filter(c => c.type === 'adversarial').length < 2) errors.push('need >=2 adversarial cases');
|
|
94
|
+
if (!allTypes.has('lane')) errors.push('need >=1 lane case');
|
|
95
|
+
return cases;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scoreRubric(answer: string, c: Case): { pass: number; total: number; matched: string[] } {
|
|
99
|
+
const a = answer.toLowerCase();
|
|
100
|
+
const rubric = c.behavioral?.[0]?.rubric ?? [];
|
|
101
|
+
const matched = rubric.filter(r => {
|
|
102
|
+
const terms = r.toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 3 && !['does', 'not', 'with', 'into', 'from'].includes(w));
|
|
103
|
+
return terms.some(t => a.includes(t));
|
|
104
|
+
});
|
|
105
|
+
return { pass: matched.length, total: rubric.length, matched };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runCases(env: { execFileSync: typeof import('node:child_process').execFileSync; bin: string; pre: string[] }): Promise<void> {
|
|
109
|
+
const cases = validateSuite();
|
|
110
|
+
if (errors.length) { console.error(errors.map(e => `✗ ${e}`).join('\n')); process.exit(1); }
|
|
111
|
+
const { execFileSync, bin, pre } = env;
|
|
112
|
+
let total = 0, passed = 0;
|
|
113
|
+
for (const c of cases) {
|
|
114
|
+
const task = c.behavioral?.[0]?.task ?? '';
|
|
115
|
+
const prompt = `Task: ${task}\n\nWhich mugiwara skill should run and why? Be concrete.`;
|
|
116
|
+
let raw = '';
|
|
117
|
+
try {
|
|
118
|
+
raw = execFileSync(bin, [...pre, prompt], { encoding: 'utf8', timeout: 60000, maxBuffer: 10 * 1024 * 1024 });
|
|
119
|
+
} catch (e: any) {
|
|
120
|
+
raw = e.stdout?.toString?.() ?? String(e.message);
|
|
121
|
+
}
|
|
122
|
+
const { pass, total: t, matched } = scoreRubric(raw, c);
|
|
123
|
+
total += t;
|
|
124
|
+
passed += pass;
|
|
125
|
+
const pct = Math.round((pass / t) * 100);
|
|
126
|
+
console.log(`${pct >= 70 ? '✓' : '✗'} ${c.name} — ${pass}/${t} rubric (${pct}%)`);
|
|
127
|
+
if (pass < t) {
|
|
128
|
+
console.log(` task: ${task}`);
|
|
129
|
+
console.log(` matched: ${matched.length ? matched.join('; ') : 'none'}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const overall = Math.round((passed / total) * 100);
|
|
133
|
+
console.log(`\nOVERALL ${passed}/${total} (${overall}%)`);
|
|
134
|
+
if (overall < 70) process.exitCode = 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const runArg = process.argv.indexOf('--run');
|
|
138
|
+
if (runArg !== -1) {
|
|
139
|
+
const { execFileSync } = require('node:child_process');
|
|
140
|
+
const cmd = process.env.MUGIWARA_EVAL_CMD ?? 'claude -p';
|
|
141
|
+
const [bin, ...pre] = cmd.split(' ');
|
|
142
|
+
runCases({ execFileSync, bin, pre });
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const cases = validateSuite();
|
|
147
|
+
if (errors.length) { console.error(errors.map(e => `✗ ${e}`).join('\n')); process.exit(1); }
|
|
148
|
+
console.log(`✓ eval suite OK: ${cases.length} cases (${cases.filter(c => c.type === 'positive').length} positive, ${cases.filter(c => c.type === 'negative').length} negative, ${cases.filter(c => c.type === 'adversarial').length} adversarial, ${cases.filter(c => c.type === 'lane').length} lane)`);
|
|
149
|
+
for (const c of cases) console.log(` ${c.name} [${c.type ?? 'default'}]`);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/savepoint.sh — write .mugiwara/state.json at every wave boundary.
|
|
3
|
+
# Computed from git + file counts; zero model judgement.
|
|
4
|
+
set -u
|
|
5
|
+
|
|
6
|
+
die() { echo "savepoint: $*" >&2; exit 1; }
|
|
7
|
+
|
|
8
|
+
MUGIWARA_DIR="${MUGIWARA_DIR:-.mugiwara}"
|
|
9
|
+
|
|
10
|
+
# --- parse mission args ---
|
|
11
|
+
MISSION="${1:-${STATE_MISSION:-}}"
|
|
12
|
+
ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${USER:-}}}}"
|
|
13
|
+
BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
|
|
14
|
+
WAVE="${4:-${STATE_WAVE:-1}}"
|
|
15
|
+
MODE="${5:-${STATE_MODE:-guided}}"
|
|
16
|
+
|
|
17
|
+
# --branch flag for per-branch state
|
|
18
|
+
BRANCH_MODE=0
|
|
19
|
+
if [ "${1:-}" = "--branch" ]; then
|
|
20
|
+
BRANCH_MODE=1
|
|
21
|
+
MISSION="${2:-${STATE_MISSION:-}}"
|
|
22
|
+
BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
|
|
23
|
+
shift 2 2>/dev/null || true
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# per-branch state file when --branch used
|
|
27
|
+
BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-')
|
|
28
|
+
if [ "$BRANCH_MODE" -eq 1 ]; then
|
|
29
|
+
STATE_FILE="$MUGIWARA_DIR/state-${BRANCH_SLUG}.json"
|
|
30
|
+
else
|
|
31
|
+
STATE_FILE="$MUGIWARA_DIR/state.json"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
[ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [actor] [branch] [wave] [mode]"
|
|
35
|
+
[ -d .git ] || die "not a git repository"
|
|
36
|
+
|
|
37
|
+
# --- computed fields ---
|
|
38
|
+
BASE_SHA=$(git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null || git merge-base HEAD "$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')" 2>/dev/null || git rev-parse HEAD~1 2>/dev/null || echo "unknown")
|
|
39
|
+
HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
|
|
40
|
+
|
|
41
|
+
CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..HEAD 2>/dev/null || git diff --name-only --cached 2>/dev/null || true)
|
|
42
|
+
FILES_TOUCHED=$( [ -n "$CHANGED_FILES" ] && echo "$CHANGED_FILES" | wc -l | tr -d ' ' || echo 0 )
|
|
43
|
+
|
|
44
|
+
LOC_DELTA=0
|
|
45
|
+
if [ "$BASE_SHA" != "unknown" ]; then
|
|
46
|
+
STAT=$(git diff --shortstat "$BASE_SHA"..HEAD 2>/dev/null || echo "")
|
|
47
|
+
INS=$(echo "$STAT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)
|
|
48
|
+
DEL=$(echo "$STAT" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)
|
|
49
|
+
LOC_DELTA=$(( ${INS:-0} - ${DEL:-0} ))
|
|
50
|
+
fi
|
|
51
|
+
[ -z "$LOC_DELTA" ] && LOC_DELTA=0
|
|
52
|
+
|
|
53
|
+
SENSITIVE_PATTERNS="auth/|payment/|billing/|crypto/|secrets/|\.env|config/|migration/|\.sql$|schema\.|\.prisma$"
|
|
54
|
+
SENSITIVE_PATHS=$(echo "$CHANGED_FILES" | grep -E "$SENSITIVE_PATTERNS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
|
|
55
|
+
|
|
56
|
+
LANE="direct"
|
|
57
|
+
LANE_REASON=""
|
|
58
|
+
if [ "$FILES_TOUCHED" -ge 9 ] 2>/dev/null || [ -n "$SENSITIVE_PATHS" ]; then
|
|
59
|
+
LANE="full"
|
|
60
|
+
LANE_REASON="$( [ -n "$SENSITIVE_PATHS" ] && echo "sensitive paths: $SENSITIVE_PATHS" || echo "$FILES_TOUCHED files")"
|
|
61
|
+
elif [ "$FILES_TOUCHED" -ge 3 ] 2>/dev/null; then
|
|
62
|
+
LANE="standard"
|
|
63
|
+
LANE_REASON="$FILES_TOUCHED files"
|
|
64
|
+
elif [ "$FILES_TOUCHED" -ge 2 ] 2>/dev/null; then
|
|
65
|
+
LANE="lean"
|
|
66
|
+
LANE_REASON="$FILES_TOUCHED files"
|
|
67
|
+
else
|
|
68
|
+
LANE="direct"
|
|
69
|
+
LANE_REASON="$FILES_TOUCHED file(s) under 20 LOC"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# task counts from plan doc
|
|
73
|
+
PLAN_FILE=$(ls "$MUGIWARA_DIR/plans/${MISSION}.md" 2>/dev/null || true)
|
|
74
|
+
TASKS_DONE=0
|
|
75
|
+
TASKS_TOTAL=0
|
|
76
|
+
if [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
|
|
77
|
+
TASKS_TOTAL=$(grep -c '\[ \]' "$PLAN_FILE" 2>/dev/null || echo 0)
|
|
78
|
+
TASKS_DONE=$(grep -c '\[x\]' "$PLAN_FILE" 2>/dev/null || echo 0)
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# blocker count
|
|
82
|
+
BLOCKERS_FILE=$(ls "$MUGIWARA_DIR/issues/${MISSION}-blockers.md" 2>/dev/null || true)
|
|
83
|
+
BLOCKERS_OPEN=0
|
|
84
|
+
if [ -n "$BLOCKERS_FILE" ] && [ -f "$BLOCKERS_FILE" ]; then
|
|
85
|
+
BLOCKERS_OPEN=$(grep -c '|' "$BLOCKERS_FILE" 2>/dev/null || echo 0)
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# heal cycle
|
|
89
|
+
HEAL_CYCLE=1
|
|
90
|
+
TRACE_FILE=$(ls "$MUGIWARA_DIR/results/${MISSION}-trace.md" 2>/dev/null || true)
|
|
91
|
+
if [ -n "$TRACE_FILE" ] && [ -f "$TRACE_FILE" ]; then
|
|
92
|
+
HEAL_COUNT=$(grep -ci 'Wave 8\|wave 8\|heal' "$TRACE_FILE" 2>/dev/null || echo 0)
|
|
93
|
+
HEAL_CYCLE=$((HEAL_COUNT + 1))
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# evidence paths
|
|
97
|
+
EVIDENCE=$(ls "$MUGIWARA_DIR/results/" 2>/dev/null | grep "$MISSION" | sed 's|^|.mugiwara/results/|' | tr '\n' ',' | sed 's/,$//' || true)
|
|
98
|
+
|
|
99
|
+
# skill version from package.json
|
|
100
|
+
SKILL_VERSION="1"
|
|
101
|
+
if [ -f package.json ]; then
|
|
102
|
+
PKG_JSON="$MUGIWARA_DIR/../package.json"
|
|
103
|
+
[ -f "$PKG_JSON" ] && SKILL_VERSION=$(python3 -c "import json; v=json.load(open('$PKG_JSON')).get('version','1'); print(v.split('.')[0])" 2>/dev/null || echo "1")
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# tokens from env var (harness exports estimated tokens consumed)
|
|
107
|
+
TOKENS_EST=${MUGIWARA_TOKENS:-0}
|
|
108
|
+
|
|
109
|
+
# budget per lane
|
|
110
|
+
BUDGET=0
|
|
111
|
+
case "$LANE" in
|
|
112
|
+
lean) BUDGET=4000 ;;
|
|
113
|
+
standard) BUDGET=10000 ;;
|
|
114
|
+
full) BUDGET=20000 ;;
|
|
115
|
+
spike) BUDGET=3000 ;;
|
|
116
|
+
*) BUDGET=0 ;;
|
|
117
|
+
esac
|
|
118
|
+
|
|
119
|
+
# token budget gate
|
|
120
|
+
WARN_AT=$(( BUDGET * 3 / 2 ))
|
|
121
|
+
STOP_AT=$(( BUDGET * 3 ))
|
|
122
|
+
STATUS="ok"
|
|
123
|
+
if [ "$BUDGET" -gt 0 ] && [ "$TOKENS_EST" -ge "$STOP_AT" ] 2>/dev/null; then
|
|
124
|
+
STATUS="stop"
|
|
125
|
+
elif [ "$BUDGET" -gt 0 ] && [ "$TOKENS_EST" -ge "$WARN_AT" ] 2>/dev/null; then
|
|
126
|
+
STATUS="warn"
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
SENSITIVE_ARR=""
|
|
130
|
+
if [ -n "$SENSITIVE_PATHS" ]; then
|
|
131
|
+
SENSITIVE_ARR=$(echo "$SENSITIVE_PATHS" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')
|
|
132
|
+
fi
|
|
133
|
+
EVIDENCE_ARR=""
|
|
134
|
+
if [ -n "$EVIDENCE" ]; then
|
|
135
|
+
EVIDENCE_ARR=$(echo "$EVIDENCE" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
mkdir -p "$MUGIWARA_DIR"
|
|
139
|
+
|
|
140
|
+
cat > "$STATE_FILE" <<JSON
|
|
141
|
+
{
|
|
142
|
+
"mission": "$MISSION",
|
|
143
|
+
"actor": "$ACTOR",
|
|
144
|
+
"branch": "$BRANCH",
|
|
145
|
+
"lane": "$LANE",
|
|
146
|
+
"lane_reason": "$LANE_REASON",
|
|
147
|
+
"wave": $WAVE,
|
|
148
|
+
"mode": "$MODE",
|
|
149
|
+
"base_sha": "$BASE_SHA",
|
|
150
|
+
"head_sha": "$HEAD_SHA",
|
|
151
|
+
"files_touched": $FILES_TOUCHED,
|
|
152
|
+
"loc_delta": $LOC_DELTA,
|
|
153
|
+
"sensitive_paths": [${SENSITIVE_ARR}],
|
|
154
|
+
"tasks": { "done": $TASKS_DONE, "total": $TASKS_TOTAL },
|
|
155
|
+
"blockers_open": $BLOCKERS_OPEN,
|
|
156
|
+
"heal_cycle": $HEAL_CYCLE,
|
|
157
|
+
"tokens_est": $TOKENS_EST,
|
|
158
|
+
"budget": $BUDGET,
|
|
159
|
+
"budget_status": "$STATUS",
|
|
160
|
+
"skill_version": "$SKILL_VERSION",
|
|
161
|
+
"evidence": [${EVIDENCE_ARR}],
|
|
162
|
+
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
163
|
+
}
|
|
164
|
+
JSON
|
|
165
|
+
|
|
166
|
+
echo "✓ savepoint written: $STATE_FILE (lane=$LANE, wave=$WAVE, files=$FILES_TOUCHED)"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// scripts/sync-version.ts — syncs package.json version into the plugin manifests
|
|
3
|
+
// so Claude/Copilot plugin version always matches the published npm version.
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const root = join(import.meta.dirname, '..');
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
9
|
+
const v = pkg.version;
|
|
10
|
+
|
|
11
|
+
const targets = [
|
|
12
|
+
join(root, '.claude-plugin', 'plugin.json'),
|
|
13
|
+
join(root, 'plugin.json'),
|
|
14
|
+
join(root, '.claude-plugin', 'marketplace.json'),
|
|
15
|
+
join(root, 'gemini-extension.json'),
|
|
16
|
+
join(root, '.codex-plugin', 'plugin.json'),
|
|
17
|
+
join(root, '.cursor-plugin', 'plugin.json'),
|
|
18
|
+
join(root, '.kimi-plugin', 'plugin.json'),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
for (const file of targets) {
|
|
22
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
23
|
+
if (doc.version !== undefined) doc.version = v;
|
|
24
|
+
if (doc.metadata?.version !== undefined) doc.metadata.version = v;
|
|
25
|
+
if (Array.isArray(doc.plugins)) {
|
|
26
|
+
for (const p of doc.plugins) if (p.version !== undefined) p.version = v;
|
|
27
|
+
}
|
|
28
|
+
writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
|
|
29
|
+
console.log(`synced ${file} → ${v}`);
|
|
30
|
+
}
|