* Merge origin/master into garrytan/check-resolvable-v1
Resolves CHANGELOG.md conflict: preserved v0.16.1/v0.16.2/v0.16.3 upstream
entries and added v0.16.4 (check-resolvable ship) above them.
* refactor: extract findRepoRoot to src/core/repo-root.ts
Moves findRepoRoot() from private in doctor.ts to a zero-dependency shared
module with a parameterized startDir for test hermeticity. Doctor imports
the shared version; no behavior change (default arg matches prior semantics).
The new gbrain check-resolvable CLI needs findRepoRoot too; importing from
doctor.ts would drag in DB/progress dependencies.
* feat: gbrain check-resolvable CLI wrapper
Standalone CLI gate over checkResolvable(). Exits 1 on any issue (warnings
or errors) per the README:259 contract, stricter than doctor's resolver_health
which ignores warnings. Doctor has 15 other checks to lean on; the standalone
command has nowhere to hide.
- Stable JSON envelope: {ok, skillsDir, report, autoFix, deferred, error, message}
- --fix auto-applies DRY fixes via autoFixDryViolations before re-checking
- --dry-run with --fix previews without writing; autoFix.fixed shows diff
- --verbose prints the deferred-checks note (Checks 5 + 6)
- --skills-dir PATH for hermetic test runs
- Permissive on unknown flags, matching lint/orphans/publish convention
Checks 5 (trigger routing eval) and 6 (brain filing) are tracked as separate
GitHub issues and surfaced via the deferred[] field in --json output.
Covered by 17 new test cases (flag parsing, JSON envelope shape, exit-code
regression gates, --fix wiring, --verbose output).
* chore: bump version and changelog (v0.16.4)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: track check-resolvable issue-URL swap in TODOS
Defers the filing of GitHub tracking issues for Checks 5 (trigger routing
eval) and 6 (brain filing) plus the TBD-check-5/TBD-check-6 URL replacement
in src/commands/check-resolvable.ts. Unblocks merging PR #325.
* test: fix repo-root CI failure — assert parity, not path contents
The 'default arg uses process.cwd()' test asserted the returned path
matched /honolulu/, which is the local workspace name but not the CI
runner's checkout path (/home/runner/work/gbrain/gbrain). The test's
real purpose is behavioral parity: findRepoRoot() === findRepoRoot(cwd).
Assert that directly instead of pattern-matching paths.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
340 lines
12 KiB
TypeScript
Executable File
340 lines
12 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* skillify-check — Post-task audit.
|
|
*
|
|
* Runs after any task that produced new code/features. Checks whether
|
|
* the work is "properly skilled" per the 10-item checklist in
|
|
* skills/skillify/SKILL.md and returns a score + recommendation.
|
|
*
|
|
* Usage:
|
|
* bun run scripts/skillify-check.ts <path-to-code-or-feature>
|
|
* bun run scripts/skillify-check.ts scripts/frameio-scraper.ts
|
|
* bun run scripts/skillify-check.ts --recent # check recently-modified
|
|
* bun run scripts/skillify-check.ts --json # machine-readable output
|
|
*
|
|
* Returns JSON when --json is passed: { path, score, total, items,
|
|
* recommendation }. Exit code is 0 when score == total, 1 otherwise.
|
|
*
|
|
* Ported from ~/git/your-openclaw/workspace/scripts/skillify-check.mjs
|
|
* (genericized: paths computed from $PROJECT_ROOT + runtime test-dir
|
|
* detection; replaces the manual `grep AGENTS.md` check with a reference
|
|
* to `gbrain check-resolvable` which validates the resolver better).
|
|
*/
|
|
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
import { join, basename, dirname, resolve } from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
function projectRoot(): string {
|
|
// Walk up from cwd until we find a package.json — that's the repo root.
|
|
let dir = process.cwd();
|
|
for (let i = 0; i < 20; i++) {
|
|
if (existsSync(join(dir, 'package.json'))) return dir;
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break;
|
|
dir = parent;
|
|
}
|
|
return process.cwd();
|
|
}
|
|
|
|
const ROOT = projectRoot();
|
|
const SKILLS_DIR = join(ROOT, 'skills');
|
|
const RESOLVER_MD = join(SKILLS_DIR, 'RESOLVER.md');
|
|
|
|
// Test dir detection: prefer test/, then __tests__/, then tests/, then spec/.
|
|
function detectTestDir(): string | null {
|
|
for (const candidate of ['test', '__tests__', 'tests', 'spec']) {
|
|
const p = join(ROOT, candidate);
|
|
if (existsSync(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
const TESTS_DIR = detectTestDir();
|
|
|
|
interface CheckItem {
|
|
name: string;
|
|
passed: boolean;
|
|
required: boolean;
|
|
detail?: string;
|
|
}
|
|
|
|
function check(name: string, passed: boolean, detail?: string): CheckItem {
|
|
return { name, passed, required: true, detail };
|
|
}
|
|
function checkOptional(name: string, passed: boolean, detail?: string): CheckItem {
|
|
return { name, passed, required: false, detail };
|
|
}
|
|
|
|
/**
|
|
* Invoke `gbrain check-resolvable --json` once and cache the result for the
|
|
* process lifetime. Binary-missing surfaces a loud error instead of silently
|
|
* passing — this is the critical guard the failure-mode audit flagged.
|
|
*/
|
|
interface ResolverResult {
|
|
ok: boolean;
|
|
detail: string;
|
|
}
|
|
let _resolverCache: ResolverResult | null = null;
|
|
function runCheckResolvableCached(): ResolverResult {
|
|
if (_resolverCache) return _resolverCache;
|
|
try {
|
|
const res = spawnSync('gbrain', ['check-resolvable', '--json'], {
|
|
encoding: 'utf-8',
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
if (res.error || res.status === null) {
|
|
const reason = res.error?.message ?? 'spawn returned null status';
|
|
console.error(`[skillify] gbrain check-resolvable not runnable: ${reason}`);
|
|
_resolverCache = { ok: false, detail: `check-resolvable unavailable: ${reason}` };
|
|
return _resolverCache;
|
|
}
|
|
const payload = JSON.parse(res.stdout);
|
|
if (payload.ok === true) {
|
|
_resolverCache = { ok: true, detail: 'all skill-tree checks pass' };
|
|
} else {
|
|
const count = payload.report?.issues?.length ?? 0;
|
|
const err = payload.error ? ` (${payload.error})` : '';
|
|
_resolverCache = { ok: false, detail: `${count} issue(s)${err} — run: gbrain check-resolvable` };
|
|
}
|
|
return _resolverCache;
|
|
} catch (err) {
|
|
console.error(`[skillify] check-resolvable parse failed: ${err}`);
|
|
_resolverCache = { ok: false, detail: `check-resolvable parse error: ${err}` };
|
|
return _resolverCache;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Guess the skill-directory name from a script path.
|
|
* scripts/frameio-scraper.ts → frameio-scraper
|
|
* src/commands/publish.ts → publish
|
|
* skills/foo/something.ts → foo
|
|
*/
|
|
function inferSkillName(scriptPath: string): string {
|
|
// If the path is inside skills/, the second segment is the skill name.
|
|
const abs = resolve(scriptPath);
|
|
const inSkills = abs.match(/skills\/([^/]+)\//);
|
|
if (inSkills) return inSkills[1];
|
|
|
|
const base = basename(scriptPath).replace(/\.(ts|mjs|js|py)$/, '');
|
|
|
|
// Check for an existing skill dir that matches.
|
|
if (existsSync(SKILLS_DIR)) {
|
|
for (const d of readdirSync(SKILLS_DIR)) {
|
|
if (d === base) return d;
|
|
// Fuzzy: script base stripped of common suffixes matches a dir name.
|
|
const normalized = base.replace(/[-_]?(scraper|monitor|check|poll|sync|ingest|core)$/, '');
|
|
if (d === normalized || d.replace(/-/g, '') === normalized.replace(/[-_]/g, '')) return d;
|
|
}
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
function findRelatedTests(scriptPath: string): string[] {
|
|
if (!TESTS_DIR) return [];
|
|
const base = basename(scriptPath).replace(/\.(ts|mjs|js|py)$/, '');
|
|
const patterns = [
|
|
`${base}.test.ts`,
|
|
`${base}.test.mjs`,
|
|
`${base}.test.js`,
|
|
`test-${base}.ts`,
|
|
`${base.replace(/-/g, '_')}.test.ts`,
|
|
];
|
|
const out: string[] = [];
|
|
for (const p of patterns) {
|
|
const f = join(TESTS_DIR, p);
|
|
if (existsSync(f)) out.push(f);
|
|
}
|
|
// Fuzzy partial match.
|
|
for (const f of readdirSync(TESTS_DIR)) {
|
|
const normalized = f.replace(/-/g, '').replace('.test.ts', '').replace('.test.mjs', '').replace('test-', '').toLowerCase();
|
|
const nbase = base.replace(/-/g, '').toLowerCase();
|
|
if (normalized.includes(nbase) || nbase.includes(normalized)) {
|
|
const fp = join(TESTS_DIR, f);
|
|
if (!out.includes(fp)) out.push(fp);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function isInResolver(skillName: string, scriptPath: string): boolean {
|
|
if (!existsSync(RESOLVER_MD)) return false;
|
|
const content = readFileSync(RESOLVER_MD, 'utf-8');
|
|
const base = basename(scriptPath).replace(/\.(ts|mjs|js|py)$/, '');
|
|
return content.includes(`skills/${skillName}`)
|
|
|| content.includes(skillName)
|
|
|| content.includes(base);
|
|
}
|
|
|
|
function runCheck(target: string): {
|
|
path: string;
|
|
skillName: string;
|
|
items: CheckItem[];
|
|
score: number;
|
|
total: number;
|
|
recommendation: string;
|
|
} {
|
|
const abs = resolve(target);
|
|
const skillName = inferSkillName(target);
|
|
const skillMd = join(SKILLS_DIR, skillName, 'SKILL.md');
|
|
|
|
const items: CheckItem[] = [];
|
|
|
|
// 1. SKILL.md exists
|
|
items.push(check('SKILL.md exists', existsSync(skillMd), skillMd));
|
|
|
|
// 2. Code exists at target path
|
|
items.push(check('Code file exists', existsSync(abs), abs));
|
|
|
|
// 3. Unit tests
|
|
const unitTests = findRelatedTests(target);
|
|
items.push(check('Unit tests', unitTests.length > 0, unitTests[0] ?? 'no matching *.test.ts in ' + (TESTS_DIR ?? '(no test dir)')));
|
|
|
|
// 4. Integration tests (heuristic: has a test that lives under test/e2e/)
|
|
const e2eDir = TESTS_DIR ? join(TESTS_DIR, 'e2e') : null;
|
|
const hasE2E = !!e2eDir && existsSync(e2eDir) && readdirSync(e2eDir).some(f =>
|
|
f.includes(skillName) || f.includes(basename(target).replace(/\.(ts|mjs|js|py)$/, '')),
|
|
);
|
|
items.push(checkOptional('Integration tests (E2E)', hasE2E, e2eDir ?? 'no e2e dir'));
|
|
|
|
// 5. LLM evals — heuristic: a file named *eval*.test.* in test dir referencing the skill name.
|
|
let hasEvals = false;
|
|
if (TESTS_DIR) {
|
|
for (const f of readdirSync(TESTS_DIR)) {
|
|
if (/eval/i.test(f) && (f.includes(skillName) || f.includes(basename(target)))) {
|
|
hasEvals = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
items.push(checkOptional('LLM evals', hasEvals));
|
|
|
|
// 6. Resolver entry
|
|
items.push(check('Resolver entry', isInResolver(skillName, target)));
|
|
|
|
// 7. Resolver trigger eval — heuristic: a resolver test that mentions skillName.
|
|
let hasTriggerEval = false;
|
|
if (TESTS_DIR) {
|
|
const resolverTest = join(TESTS_DIR, 'resolver.test.ts');
|
|
if (existsSync(resolverTest)) {
|
|
const content = readFileSync(resolverTest, 'utf-8');
|
|
hasTriggerEval = content.includes(skillName);
|
|
}
|
|
}
|
|
items.push(checkOptional('Resolver trigger eval', hasTriggerEval));
|
|
|
|
// 8. check-resolvable — invoke the real gate. Cached per process so
|
|
// iterating many skills only runs the subprocess once. Binary-missing
|
|
// is surfaced loudly so a silent false-pass can't happen.
|
|
const resolverResult = runCheckResolvableCached();
|
|
items.push(checkOptional('check-resolvable gate',
|
|
resolverResult.ok,
|
|
resolverResult.detail));
|
|
|
|
// 9. E2E — same as item 4 but required.
|
|
items.push(check('E2E test (either under e2e/ or integration test)', hasE2E, 'try /qa or test/e2e/'));
|
|
|
|
// 10. Brain filing — heuristic: if script mentions `addPage`, `upsertPage`,
|
|
// or `addBrainPage` then brain/RESOLVER.md should list a matching dir.
|
|
let writesBrain = false;
|
|
if (existsSync(abs)) {
|
|
try {
|
|
const src = readFileSync(abs, 'utf-8');
|
|
writesBrain = /addPage|upsertPage|addBrainPage|putPage/.test(src);
|
|
} catch { /* skip */ }
|
|
}
|
|
const brainResolver = join(ROOT, 'brain', 'RESOLVER.md');
|
|
const hasBrainEntry = writesBrain && existsSync(brainResolver)
|
|
&& readFileSync(brainResolver, 'utf-8').includes(skillName);
|
|
items.push(checkOptional('Brain filing (RESOLVER entry for brain writes)',
|
|
!writesBrain || hasBrainEntry,
|
|
writesBrain ? (hasBrainEntry ? 'entry present' : 'writes brain but no brain/RESOLVER.md entry') : 'n/a'));
|
|
|
|
// Score: required items pass; optional items contribute only if they pass.
|
|
const passed = items.filter(i => i.passed).length;
|
|
const total = items.length;
|
|
const missing = items.filter(i => !i.passed && i.required).map(i => i.name);
|
|
|
|
let recommendation: string;
|
|
if (missing.length === 0) {
|
|
recommendation = 'properly skilled';
|
|
} else if (missing.length <= 2) {
|
|
recommendation = `close — create: ${missing.join(', ')}`;
|
|
} else {
|
|
recommendation = `needs skillify — run /skillify on ${target}; missing: ${missing.join(', ')}`;
|
|
}
|
|
|
|
return { path: target, skillName, items, score: passed, total, recommendation };
|
|
}
|
|
|
|
function recentlyModified(days: number = 7): string[] {
|
|
const candidates: string[] = [];
|
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
const roots = ['src/commands', 'src/core', 'scripts'].map(r => join(ROOT, r)).filter(existsSync);
|
|
for (const root of roots) {
|
|
try {
|
|
for (const f of readdirSync(root)) {
|
|
if (!f.match(/\.(ts|mjs|js|py)$/)) continue;
|
|
const fp = join(root, f);
|
|
try {
|
|
const st = statSync(fp);
|
|
if (st.isFile() && st.mtimeMs >= cutoff) candidates.push(fp);
|
|
} catch { /* skip */ }
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
const json = args.includes('--json');
|
|
const recent = args.includes('--recent');
|
|
const help = args.includes('--help') || args.includes('-h');
|
|
|
|
if (help || (args.length === 0)) {
|
|
console.log(`skillify-check — 10-item checklist audit for gbrain features.
|
|
|
|
Usage:
|
|
bun run scripts/skillify-check.ts <path>
|
|
bun run scripts/skillify-check.ts --recent Check files modified in the last 7 days.
|
|
bun run scripts/skillify-check.ts --json Emit JSON.
|
|
|
|
Exit code 0 when everything required passes; 1 otherwise.
|
|
`);
|
|
process.exit(args.length === 0 ? 1 : 0);
|
|
}
|
|
|
|
const targets = recent
|
|
? recentlyModified(7)
|
|
: args.filter(a => !a.startsWith('--'));
|
|
|
|
if (targets.length === 0) {
|
|
console.error('No targets. Pass a path or --recent.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const results = targets.map(runCheck);
|
|
if (json) {
|
|
console.log(JSON.stringify(results, null, 2));
|
|
} else {
|
|
for (const r of results) {
|
|
console.log(`\n${r.path} [${r.skillName}] ${r.score}/${r.total}`);
|
|
for (const item of r.items) {
|
|
const mark = item.passed ? '✓' : (item.required ? '✗' : '·');
|
|
const tag = item.required ? '' : ' (optional)';
|
|
const detail = item.detail ? ` — ${item.detail}` : '';
|
|
console.log(` ${mark} ${item.name}${tag}${detail}`);
|
|
}
|
|
console.log(` → ${r.recommendation}`);
|
|
}
|
|
}
|
|
|
|
// Exit code: non-zero if any result has missing required items.
|
|
const anyFailed = results.some(r => r.items.some(i => !i.passed && i.required));
|
|
process.exit(anyFailed ? 1 : 0);
|
|
}
|
|
|
|
main();
|