@zyaiting/keelson 0.4.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/LICENSE +21 -0
- package/README.md +101 -0
- package/README_CN.md +101 -0
- package/bin/keelson.js +15 -0
- package/hooks/codebuddy-session.mjs +67 -0
- package/hooks/opencode-session.mjs +65 -0
- package/hooks/prompt-state.mjs +66 -0
- package/hooks/session-start.mjs +94 -0
- package/package.json +64 -0
- package/registry/models.json +118 -0
- package/registry/platforms.json +92 -0
- package/skills/keelson/SKILL.md +44 -0
- package/skills/keelson/references/build.md +61 -0
- package/skills/keelson/references/context.md +34 -0
- package/skills/keelson/references/debug.md +46 -0
- package/skills/keelson/references/design-lenses.md +78 -0
- package/skills/keelson/references/discover.md +70 -0
- package/skills/keelson/references/engineer.md +110 -0
- package/skills/keelson/references/frontend-delivery.md +38 -0
- package/skills/keelson/references/frontend-interaction.md +31 -0
- package/skills/keelson/references/frontend-review.md +33 -0
- package/skills/keelson/references/frontend-visual.md +31 -0
- package/skills/keelson/references/frontend.md +33 -0
- package/skills/keelson/references/handoff.md +43 -0
- package/skills/keelson/references/harness.md +54 -0
- package/skills/keelson/references/interview.md +120 -0
- package/skills/keelson/references/land.md +47 -0
- package/skills/keelson/references/model.md +29 -0
- package/skills/keelson/references/plan.md +106 -0
- package/skills/keelson/references/reconcile.md +61 -0
- package/skills/keelson/references/shape.md +86 -0
- package/skills/keelson/references/verify.md +64 -0
- package/skills/keelson/templates/GLOSSARY.md +5 -0
- package/skills/keelson/templates/INTENT.md +22 -0
- package/skills/keelson/templates/NOW.md +9 -0
- package/skills/keelson/templates/README.md +60 -0
- package/skills/keelson/templates/ROADMAP.md +12 -0
- package/skills/keelson/templates/change-quick.md +16 -0
- package/skills/keelson/templates/change.md +32 -0
- package/skills/keelson/templates/delta-spec.md +12 -0
- package/skills/keelson/templates/handoff.md +27 -0
- package/skills/keelson/templates/ledger.md +3 -0
- package/skills/keelson/templates/resident-block.md +7 -0
- package/skills/keelson/templates/rules-general.md +10 -0
- package/skills/keelson/templates/rules-index.md +5 -0
- package/skills/keelson/templates/spec.md +14 -0
- package/skills/keelson/templates/tasks.md +9 -0
- package/skills/keelson/templates/workflow.md +18 -0
- package/skills/zh/keelson/SKILL.md +46 -0
- package/skills/zh/keelson/references/build.md +61 -0
- package/skills/zh/keelson/references/context.md +34 -0
- package/skills/zh/keelson/references/debug.md +46 -0
- package/skills/zh/keelson/references/design-lenses.md +78 -0
- package/skills/zh/keelson/references/discover.md +70 -0
- package/skills/zh/keelson/references/engineer.md +110 -0
- package/skills/zh/keelson/references/frontend-delivery.md +38 -0
- package/skills/zh/keelson/references/frontend-interaction.md +31 -0
- package/skills/zh/keelson/references/frontend-review.md +33 -0
- package/skills/zh/keelson/references/frontend-visual.md +31 -0
- package/skills/zh/keelson/references/frontend.md +33 -0
- package/skills/zh/keelson/references/handoff.md +43 -0
- package/skills/zh/keelson/references/harness.md +54 -0
- package/skills/zh/keelson/references/interview.md +120 -0
- package/skills/zh/keelson/references/land.md +47 -0
- package/skills/zh/keelson/references/model.md +29 -0
- package/skills/zh/keelson/references/plan.md +106 -0
- package/skills/zh/keelson/references/reconcile.md +61 -0
- package/skills/zh/keelson/references/shape.md +86 -0
- package/skills/zh/keelson/references/verify.md +64 -0
- package/skills/zh/keelson/templates/GLOSSARY.md +5 -0
- package/skills/zh/keelson/templates/INTENT.md +22 -0
- package/skills/zh/keelson/templates/NOW.md +9 -0
- package/skills/zh/keelson/templates/README.md +60 -0
- package/skills/zh/keelson/templates/ROADMAP.md +12 -0
- package/skills/zh/keelson/templates/change-quick.md +16 -0
- package/skills/zh/keelson/templates/change.md +32 -0
- package/skills/zh/keelson/templates/delta-spec.md +12 -0
- package/skills/zh/keelson/templates/handoff.md +27 -0
- package/skills/zh/keelson/templates/ledger.md +3 -0
- package/skills/zh/keelson/templates/resident-block.md +7 -0
- package/skills/zh/keelson/templates/rules-general.md +10 -0
- package/skills/zh/keelson/templates/rules-index.md +5 -0
- package/skills/zh/keelson/templates/spec.md +14 -0
- package/skills/zh/keelson/templates/tasks.md +9 -0
- package/skills/zh/keelson/templates/workflow.md +18 -0
- package/src/cli.js +87 -0
- package/src/commands/ablate.js +96 -0
- package/src/commands/ask.js +64 -0
- package/src/commands/attest.js +71 -0
- package/src/commands/check.js +127 -0
- package/src/commands/context.js +95 -0
- package/src/commands/design.js +63 -0
- package/src/commands/doctor.js +157 -0
- package/src/commands/focus.js +84 -0
- package/src/commands/guide.js +59 -0
- package/src/commands/handoff.js +41 -0
- package/src/commands/hook.js +23 -0
- package/src/commands/impact.js +58 -0
- package/src/commands/init.js +289 -0
- package/src/commands/land.js +258 -0
- package/src/commands/models.js +62 -0
- package/src/commands/new.js +70 -0
- package/src/commands/platforms.js +39 -0
- package/src/commands/retro.js +114 -0
- package/src/commands/status.js +115 -0
- package/src/commands/uninstall.js +30 -0
- package/src/commands/validate.js +117 -0
- package/src/lib/args.js +30 -0
- package/src/lib/changes.js +114 -0
- package/src/lib/check-activity.js +29 -0
- package/src/lib/config.js +102 -0
- package/src/lib/decisions.js +59 -0
- package/src/lib/evidence.js +127 -0
- package/src/lib/fs.js +126 -0
- package/src/lib/git.js +353 -0
- package/src/lib/glob.js +54 -0
- package/src/lib/health.js +113 -0
- package/src/lib/lifecycle.js +120 -0
- package/src/lib/maintenance.js +66 -0
- package/src/lib/markdown.js +438 -0
- package/src/lib/models.js +195 -0
- package/src/lib/out.js +13 -0
- package/src/lib/paths.js +82 -0
- package/src/lib/rules.js +27 -0
- package/src/lib/runtime-path.js +22 -0
- package/src/lib/session.js +100 -0
- package/src/lib/specs.js +345 -0
- package/src/lib/transaction.js +93 -0
- package/src/platforms/index.js +3 -0
- package/src/platforms/integration.js +384 -0
- package/src/platforms/registry.js +46 -0
- package/src/platforms/runtime.js +249 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PKG_ROOT, findProjectRoot, projectPaths } from '../lib/paths.js';
|
|
4
|
+
import { exists, read } from '../lib/fs.js';
|
|
5
|
+
import { loadConfig } from '../lib/config.js';
|
|
6
|
+
import { applyProfile, skillSource, stampVersion, workflowContent } from '../platforms/index.js';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { version: PKG_VERSION } = require('../../package.json');
|
|
11
|
+
const cleanReference = (value) => String(value ?? '').trim().replace(/\.md$/i, '');
|
|
12
|
+
|
|
13
|
+
export function guidanceOptions(flags, cwd) {
|
|
14
|
+
const root = findProjectRoot(cwd);
|
|
15
|
+
const cfg = root ? loadConfig(projectPaths(root).config) : {};
|
|
16
|
+
const lang = flags.lang ?? cfg.lang ?? 'en';
|
|
17
|
+
if (!['en', 'zh'].includes(lang)) throw new Error('guidance language must be en or zh');
|
|
18
|
+
return {
|
|
19
|
+
lang,
|
|
20
|
+
profile: flags.profile ?? cfg.profile ?? 'lean',
|
|
21
|
+
guided: flags.guide === undefined ? Boolean(cfg.guide) : flags.guide !== false && flags.guide !== 'false',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readGuidance(value, { lang, profile, guided }) {
|
|
26
|
+
const reference = cleanReference(value);
|
|
27
|
+
const rendered = (file) => applyProfile(read(file).replace(/\r\n?/g, '\n'), profile);
|
|
28
|
+
if (!reference) return stampVersion(rendered(path.join(skillSource(lang), 'SKILL.md')), PKG_VERSION);
|
|
29
|
+
if (reference === 'workflow') return workflowContent(lang, guided);
|
|
30
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(reference)) {
|
|
31
|
+
throw new Error('guide reference must be a simple name, for example `keelson guide build`');
|
|
32
|
+
}
|
|
33
|
+
const file = path.join(skillSource(lang), 'references', `${reference}.md`);
|
|
34
|
+
const fallback = path.join(PKG_ROOT, 'skills', 'keelson', 'references', `${reference}.md`);
|
|
35
|
+
if (!exists(file) && !exists(fallback)) throw new Error(`unknown guidance reference "${reference}". Run \`keelson guide --list\` for the index.`);
|
|
36
|
+
return rendered(exists(file) ? file : fallback);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Print package guidance without copying it into the project. */
|
|
40
|
+
export async function guide({ positional = [], flags = {} }, cwd = process.cwd()) {
|
|
41
|
+
const options = guidanceOptions(flags, cwd);
|
|
42
|
+
if (flags.list) {
|
|
43
|
+
if (positional.length) throw new Error('use `keelson guide --list` or `keelson guide <reference>`, not both');
|
|
44
|
+
const directories = [path.join(PKG_ROOT, 'skills', 'keelson', 'references'), path.join(skillSource(options.lang), 'references')];
|
|
45
|
+
const names = [...new Set(directories.flatMap((dir) => fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map(cleanReference)))].sort();
|
|
46
|
+
const references = ['workflow', ...names].map((name) => ({ name, title: readGuidance(name, options).match(/^#\s+(.+)$/m)?.[1] ?? name }));
|
|
47
|
+
if (flags.json) console.log(JSON.stringify({ lang: options.lang, references }, null, 2));
|
|
48
|
+
else console.log([
|
|
49
|
+
options.lang === 'zh' ? '按需指导' : 'Installed guidance', '',
|
|
50
|
+
...references.map(({ name, title }) => ` ${name.padEnd(24)} ${title}`), '',
|
|
51
|
+
'keelson guide <reference>',
|
|
52
|
+
].join('\n'));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (positional.length > 1) throw new Error('guide accepts one reference; run `keelson guide --list`');
|
|
56
|
+
if (flags.json) throw new Error('use `keelson guide --list --json` for a machine-readable index');
|
|
57
|
+
process.stdout.write(readGuidance(positional[0], options));
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
3
|
+
import { exists, read, write } from '../lib/fs.js';
|
|
4
|
+
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { loadChange, loadAllChanges } from '../lib/changes.js';
|
|
6
|
+
import { parseFrontmatter } from '../lib/markdown.js';
|
|
7
|
+
import { headSha, gitUserName } from '../lib/git.js';
|
|
8
|
+
import { skillSource } from '../platforms/index.js';
|
|
9
|
+
import { ok, info } from '../lib/out.js';
|
|
10
|
+
import { readSession } from '../lib/session.js';
|
|
11
|
+
|
|
12
|
+
/** Create or re-stamp handoff.md for a change. The agent writes the content; the CLI keeps `at`, `updated`, `by` truthful. */
|
|
13
|
+
export async function handoff({ flags, positional }, cwd = process.cwd()) {
|
|
14
|
+
const root = requireProjectRoot(cwd);
|
|
15
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
16
|
+
const p = projectPaths(root, cfg);
|
|
17
|
+
let name = positional[0];
|
|
18
|
+
if (!name) {
|
|
19
|
+
const all = loadAllChanges(p.changes);
|
|
20
|
+
const focused = readSession(root).state?.change;
|
|
21
|
+
if (focused && all.some((c) => c.name === focused)) name = focused;
|
|
22
|
+
else if (all.length === 1) name = all[0].name;
|
|
23
|
+
else throw new Error(all.length ? `several active changes (${all.map((c) => c.name).join(', ')}); bind one with \`keelson focus <name>\` or name one` : 'no active change');
|
|
24
|
+
}
|
|
25
|
+
const c = loadChange(p.changes, name);
|
|
26
|
+
if (!c) throw new Error(`no change named "${name}"`);
|
|
27
|
+
const file = path.join(c.dir, 'handoff.md');
|
|
28
|
+
const front = `---\nat: ${headSha(root) ?? 'no-git'}\nupdated: ${new Date().toISOString().slice(0, 16).replace('T', ' ')}\nby: ${flags.by ?? gitUserName(root)}\n---\n`;
|
|
29
|
+
if (exists(file)) {
|
|
30
|
+
const cur = parseFrontmatter(read(file)).body;
|
|
31
|
+
write(file, front + cur);
|
|
32
|
+
ok(`re-stamped ${path.relative(root, file)} (fill in what changed since the last handoff)`);
|
|
33
|
+
} else {
|
|
34
|
+
const tpl = parseFrontmatter(read(path.join(skillSource(cfg.lang), 'templates', 'handoff.md'))).body.replace(/\{\{name\}\}/g, name);
|
|
35
|
+
write(file, front + tpl);
|
|
36
|
+
ok(`created ${path.relative(root, file)}`);
|
|
37
|
+
}
|
|
38
|
+
info('handoff is for real ownership/machine transfer; ordinary session continuation uses private per-machine session state and the durable change artifacts');
|
|
39
|
+
if (flags.json) console.log(JSON.stringify({ name, file }));
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { PKG_ROOT } from '../lib/paths.js';
|
|
4
|
+
|
|
5
|
+
const HOOKS = new Map([
|
|
6
|
+
['session-start', 'session-start.mjs'],
|
|
7
|
+
['prompt-state', 'prompt-state.mjs'],
|
|
8
|
+
['codebuddy-session', 'codebuddy-session.mjs'],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
/** Execute a package-owned hook; project installs never copy executable hook code. */
|
|
12
|
+
export async function hook({ positional = [] }, cwd = process.cwd()) {
|
|
13
|
+
const name = positional[0];
|
|
14
|
+
const file = HOOKS.get(name);
|
|
15
|
+
if (!file) throw new Error(`unknown hook "${name}". Supported hooks: ${[...HOOKS.keys()].join(', ')}`);
|
|
16
|
+
const child = spawnSync(process.execPath, [path.join(PKG_ROOT, 'hooks', file)], {
|
|
17
|
+
cwd,
|
|
18
|
+
env: process.env,
|
|
19
|
+
stdio: 'inherit',
|
|
20
|
+
});
|
|
21
|
+
if (child.error) throw child.error;
|
|
22
|
+
return Number.isInteger(child.status) ? child.status : 1;
|
|
23
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
3
|
+
import { exists, listDirs } from '../lib/fs.js';
|
|
4
|
+
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { matchRules } from '../lib/rules.js';
|
|
6
|
+
import { importers } from '../lib/git.js';
|
|
7
|
+
import { loadAllChanges } from '../lib/changes.js';
|
|
8
|
+
import { list } from '../lib/args.js';
|
|
9
|
+
import { heading, dim, warn } from '../lib/out.js';
|
|
10
|
+
import { readCapabilitySpec } from '../lib/specs.js';
|
|
11
|
+
|
|
12
|
+
/** Mechanical impact hints for a set of files. Navigation, never proof of completeness. */
|
|
13
|
+
export function impactOf(root, cfg, files) {
|
|
14
|
+
const p = projectPaths(root, cfg);
|
|
15
|
+
const rel = files.map((f) => path.relative(root, path.resolve(root, f)).replace(/\\/g, '/'));
|
|
16
|
+
const callers = importers(root, rel);
|
|
17
|
+
const words = [...new Set(rel.flatMap((f) => path.basename(f).replace(/\.[^.]+$/, '').split(/[-_.]/)).filter((w) => w.length > 2))];
|
|
18
|
+
const specs = [];
|
|
19
|
+
for (const cap of listDirs(p.specs)) {
|
|
20
|
+
const f = path.join(p.specs, cap, 'spec.md');
|
|
21
|
+
if (!exists(f)) continue;
|
|
22
|
+
const txt = readCapabilitySpec(p.specs, cap).toLowerCase();
|
|
23
|
+
const hit = words.filter((w) => txt.includes(w.toLowerCase()));
|
|
24
|
+
if (hit.length || rel.some((r) => r.toLowerCase().includes(cap.toLowerCase()))) specs.push({ capability: cap, matched: hit });
|
|
25
|
+
}
|
|
26
|
+
const rules = matchRules(p.rules, rel).map((r) => ({ file: r.file, globs: r.globs }));
|
|
27
|
+
const changes = loadAllChanges(p.changes).filter((c) => c.touches.some((t) => rel.some((r) => r.startsWith(t.replace(/\/?\*\*$/, '')))) || c.capabilities.some((cap) => specs.some((s) => s.capability === cap)));
|
|
28
|
+
return { files: rel, callers, specs, rules, activeChanges: changes.map((c) => ({ name: c.name, owner: c.owner, work: c.work })) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function impact({ flags, positional }, cwd = process.cwd()) {
|
|
32
|
+
const root = requireProjectRoot(cwd);
|
|
33
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
34
|
+
const files = [...list(flags.paths), ...positional];
|
|
35
|
+
if (!files.length) throw new Error('usage: keelson impact <file> [file...]');
|
|
36
|
+
const r = impactOf(root, cfg, files);
|
|
37
|
+
if (flags.json) {
|
|
38
|
+
console.log(JSON.stringify(r, null, 2));
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
heading(`Impact hints for ${r.files.join(', ')}`);
|
|
42
|
+
console.log(dim('Mechanical hints only: importers by name, specs and rules by path and words. They do not prove the impact list is complete.'));
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log(`Callers / importers (${r.callers.length}):`);
|
|
45
|
+
for (const c of r.callers) console.log(` ${c}`);
|
|
46
|
+
if (!r.callers.length) console.log(dim(' none found by import name — check dynamic entry points by hand'));
|
|
47
|
+
console.log(`Specs that may be affected (${r.specs.length}):`);
|
|
48
|
+
for (const s of r.specs) console.log(` ${s.capability}${s.matched.length ? dim(` (mentions ${s.matched.join(', ')})`) : ''}`);
|
|
49
|
+
console.log(`Rules that apply (${r.rules.length}):`);
|
|
50
|
+
for (const ru of r.rules) console.log(` rules/${ru.file} ${dim(ru.globs.join(', '))}`);
|
|
51
|
+
if (r.activeChanges.length) {
|
|
52
|
+
console.log('');
|
|
53
|
+
for (const c of r.activeChanges) warn(`active change ${c.name} (${c.owner ?? 'unowned'}, ${c.work}) declares these paths or capabilities — coordinate before editing`);
|
|
54
|
+
}
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(dim('Still to check by reading: other entry points (CLI, jobs, API routes), data constraints, permission rules, compatibility promises.'));
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { projectPaths, findProjectRoot } from '../lib/paths.js';
|
|
5
|
+
import { exists, write, read, mkdirp, readOr } from '../lib/fs.js';
|
|
6
|
+
import { loadConfig, saveConfig, DEFAULT_CONFIG, CONFIG_VERSION } from '../lib/config.js';
|
|
7
|
+
import { PLATFORMS, PLATFORM_IDS, RETIRED_PLATFORM_IDS, installTargets, installCanonicalSkill, installSkill, installWorkflow, installInstructions, installHooks, installSessionAdapter, skillSource, plannedCanonicalSkillFiles, plannedSkillFiles, plannedWorkflowFile, plannedManagedRemovals, plannedSessionAdapterFiles, readManagedState, reconcileManagedTargets, removeCanonicalRuntime, removeLegacyCopiedHooks, writeManagedState, assertSkillsInstallable } from '../platforms/index.js';
|
|
8
|
+
import { list } from '../lib/args.js';
|
|
9
|
+
import { ok, info, warn, heading, dim } from '../lib/out.js';
|
|
10
|
+
import { detectAndCache, detectLocal } from '../lib/models.js';
|
|
11
|
+
import { git, isGitRepo } from '../lib/git.js';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { version: PKG_VERSION } = require('../../package.json');
|
|
15
|
+
const fill = (tpl, vars) => tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
16
|
+
|
|
17
|
+
/** Existing project material Keelson should reference instead of duplicating. */
|
|
18
|
+
export function detectRefs(root) {
|
|
19
|
+
const first = (cands) => cands.find((c) => exists(path.join(root, c))) ?? null;
|
|
20
|
+
const refs = {
|
|
21
|
+
architecture: first(['ARCHITECTURE.md', 'docs/architecture', 'docs/architecture.md', 'docs/ARCHITECTURE.md', 'doc/architecture']),
|
|
22
|
+
decisions: first(['docs/adr', 'docs/decisions', 'doc/adr', 'adr', 'decisions', 'docs/ADR']),
|
|
23
|
+
tasks: null,
|
|
24
|
+
ci: first(['.github/workflows', '.gitlab-ci.yml', 'Jenkinsfile', '.circleci', 'azure-pipelines.yml']),
|
|
25
|
+
};
|
|
26
|
+
if (isGitRepo(root)) {
|
|
27
|
+
const remote = git(root, ['remote', 'get-url', 'origin']);
|
|
28
|
+
const m = remote?.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
29
|
+
if (m) refs.tasks = `https://github.com/${m[1]}/issues`;
|
|
30
|
+
}
|
|
31
|
+
const specs = first(['docs/specs', 'docs/contracts', 'specs', 'spec']);
|
|
32
|
+
return { refs, specsCandidate: specs };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function chooseDetectedTools(detected = {}) {
|
|
36
|
+
const installed = PLATFORM_IDS.filter((id) => detected[id]?.installed);
|
|
37
|
+
const reliable = installed.filter((id) => PLATFORMS[id]?.confidence !== 'convention');
|
|
38
|
+
const conventionDetected = installed.filter((id) => PLATFORMS[id]?.confidence === 'convention');
|
|
39
|
+
return reliable.length
|
|
40
|
+
? { tools: reliable, conventionDetected, portableFallback: false }
|
|
41
|
+
: { tools: ['agents'], conventionDetected, portableFallback: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function detectChecks(root) {
|
|
45
|
+
const pkg = readOr(path.join(root, 'package.json'), '');
|
|
46
|
+
const checks = [];
|
|
47
|
+
if (pkg) {
|
|
48
|
+
try {
|
|
49
|
+
const scripts = JSON.parse(pkg).scripts ?? {};
|
|
50
|
+
for (const s of ['lint', 'typecheck', 'type-check', 'test']) if (scripts[s]) checks.push(`npm run ${s}`);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
if (exists(path.join(root, 'pyproject.toml')) || exists(path.join(root, 'pytest.ini'))) checks.push('pytest -q');
|
|
54
|
+
if (exists(path.join(root, 'go.mod'))) checks.push('go vet ./...', 'go test ./...');
|
|
55
|
+
if (exists(path.join(root, 'Cargo.toml'))) checks.push('cargo test');
|
|
56
|
+
return checks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function init({ flags }, cwd = process.cwd()) {
|
|
60
|
+
const root = path.resolve(flags.dir ?? cwd);
|
|
61
|
+
const existing = findProjectRoot(root);
|
|
62
|
+
if (existing && existing !== root) warn(`a parent directory already has .keelson/ (${existing}); creating a nested one here anyway`);
|
|
63
|
+
const fresh = !exists(path.join(root, '.keelson', 'config.yaml')) && !exists(path.join(root, '.keelson', 'INTENT.md'));
|
|
64
|
+
const cfgPath = path.join(root, '.keelson', 'config.yaml');
|
|
65
|
+
const rawVersion = fresh ? CONFIG_VERSION : Number((readOr(cfgPath, '').match(/^version:\s*(\d+)/m) || [])[1] ?? 1);
|
|
66
|
+
const cfg = fresh ? structuredClone(DEFAULT_CONFIG) : loadConfig(cfgPath);
|
|
67
|
+
|
|
68
|
+
// Tools: explicit selection · saved config · reliable auto-detection · portable fallback.
|
|
69
|
+
const retiredFlagged = RETIRED_PLATFORM_IDS.filter((id) => flags[id] === true);
|
|
70
|
+
if (retiredFlagged.length) throw new Error(`retired host adapter flag(s): ${retiredFlagged.map((id) => `--${id}`).join(', ')}. Use --agents for the portable layer, or choose one of: ${PLATFORM_IDS.filter((id) => id !== 'agents').map((id) => `--${id}`).join(', ')}`);
|
|
71
|
+
const flagged = PLATFORM_IDS.filter((id) => flags[id] === true);
|
|
72
|
+
const explicitlySelected = list(flags.tools).length > 0 || flagged.length > 0;
|
|
73
|
+
let tools = list(flags.tools).length ? list(flags.tools) : flagged.length ? flagged : cfg.tools?.length && !fresh ? cfg.tools : [];
|
|
74
|
+
let detectedTools = false;
|
|
75
|
+
let portableFallback = false;
|
|
76
|
+
let conventionDetected = [];
|
|
77
|
+
let retiredFromConfig = [];
|
|
78
|
+
if (!explicitlySelected && !fresh) {
|
|
79
|
+
retiredFromConfig = tools.filter((id) => RETIRED_PLATFORM_IDS.includes(id));
|
|
80
|
+
if (retiredFromConfig.length) tools = tools.filter((id) => !RETIRED_PLATFORM_IDS.includes(id));
|
|
81
|
+
if (!tools.length && retiredFromConfig.length) {
|
|
82
|
+
tools = ['agents'];
|
|
83
|
+
portableFallback = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!tools.length) {
|
|
87
|
+
const detected = chooseDetectedTools(detectLocal().tools);
|
|
88
|
+
tools = detected.tools;
|
|
89
|
+
conventionDetected = detected.conventionDetected;
|
|
90
|
+
portableFallback = detected.portableFallback;
|
|
91
|
+
detectedTools = !portableFallback;
|
|
92
|
+
}
|
|
93
|
+
for (const t of tools) if (!PLATFORMS[t]) {
|
|
94
|
+
if (RETIRED_PLATFORM_IDS.includes(t)) throw new Error(`retired host adapter "${t}". Use "agents" for the portable layer, or choose one of: ${PLATFORM_IDS.filter((id) => id !== 'agents').join(', ')}`);
|
|
95
|
+
throw new Error(`unknown tool "${t}". Known: ${PLATFORM_IDS.join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
cfg.tools = [...new Set(tools)];
|
|
98
|
+
const retiredOverrides = Object.keys(cfg.platforms ?? {}).filter((id) => RETIRED_PLATFORM_IDS.includes(id));
|
|
99
|
+
for (const id of retiredOverrides) delete cfg.platforms[id];
|
|
100
|
+
if (cfg.platforms && !Object.keys(cfg.platforms).length) delete cfg.platforms;
|
|
101
|
+
const retiredModelOverrides = Object.keys(cfg.models ?? {}).filter((id) => RETIRED_PLATFORM_IDS.includes(id));
|
|
102
|
+
for (const id of retiredModelOverrides) delete cfg.models[id];
|
|
103
|
+
cfg.lang = flags.lang ?? cfg.lang ?? 'en';
|
|
104
|
+
cfg.profile = flags.profile ?? cfg.profile ?? 'lean';
|
|
105
|
+
if (flags.vendor) cfg.vendor = true;
|
|
106
|
+
if (flags.guide !== undefined) cfg.guide = flags.guide !== 'false' && flags.guide !== false;
|
|
107
|
+
if (flags.hooks && flags.noHooks) throw new Error('choose either --hooks or --no-hooks, not both');
|
|
108
|
+
if (flags.hooks) cfg.hooks = true;
|
|
109
|
+
else if (flags.noHooks) cfg.hooks = false;
|
|
110
|
+
else cfg.hooks = cfg.hooks !== false;
|
|
111
|
+
if (!['lean', 'guided'].includes(cfg.profile)) throw new Error('profile must be lean or guided');
|
|
112
|
+
const targets = installTargets(cfg.tools, cfg);
|
|
113
|
+
const priorManaged = readManagedState(root);
|
|
114
|
+
const previousVersion = priorManaged?.packageVersion;
|
|
115
|
+
const legacyVersion = priorManaged?.vendor === undefined ? priorManaged?.packageVersion : null;
|
|
116
|
+
|
|
117
|
+
const project = path.basename(root);
|
|
118
|
+
const tpl = path.join(skillSource(cfg.lang), 'templates');
|
|
119
|
+
const projectMap = fill(read(path.join(tpl, 'README.md')), { project });
|
|
120
|
+
|
|
121
|
+
if (flags.dryRun) {
|
|
122
|
+
heading(`Keelson ${fresh ? 'init' : 'update'} (dry run) in ${root}`);
|
|
123
|
+
const mapPath = path.join(root, '.keelson', 'README.md');
|
|
124
|
+
console.log(` ${(!exists(mapPath) ? 'create' : read(mapPath) === projectMap ? 'unchanged' : 'update').padEnd(9)} .keelson/README.md`);
|
|
125
|
+
if (cfg.vendor) {
|
|
126
|
+
const workflow = plannedWorkflowFile(root, { lang: cfg.lang, guide: cfg.guide });
|
|
127
|
+
console.log(` ${workflow.status.padEnd(9)} ${workflow.path}`);
|
|
128
|
+
for (const f of plannedCanonicalSkillFiles(root, { lang: cfg.lang, profile: cfg.profile, version: PKG_VERSION })) console.log(` ${f.status.padEnd(9)} ${f.path}`);
|
|
129
|
+
}
|
|
130
|
+
for (const rel of plannedManagedRemovals(root, targets)) console.log(` ${'remove'.padEnd(9)} ${rel} (stale managed surface)`);
|
|
131
|
+
const dryDiscovery = new Set();
|
|
132
|
+
for (const t of targets) {
|
|
133
|
+
const discoveryKey = `${t.instructions}|${t.skillsDir}|${t.rulesFile ?? ''}`;
|
|
134
|
+
if (!dryDiscovery.has(discoveryKey)) {
|
|
135
|
+
dryDiscovery.add(discoveryKey);
|
|
136
|
+
for (const f of plannedSkillFiles(root, t, { lang: cfg.lang, version: PKG_VERSION })) console.log(` ${f.status.padEnd(9)} ${f.path}`);
|
|
137
|
+
const ins = path.join(root, t.instructions);
|
|
138
|
+
console.log(` ${(exists(ins) ? (read(ins).includes('<!-- keelson:start -->') ? 'refresh' : 'append') : 'create').padEnd(9)} ${t.instructions}`);
|
|
139
|
+
}
|
|
140
|
+
for (const f of plannedSessionAdapterFiles(root, t)) console.log(` ${f.status.padEnd(9)} ${f.path}`);
|
|
141
|
+
}
|
|
142
|
+
if (retiredFromConfig.length) console.log(` migrate config tools: drop retired ${retiredFromConfig.join(', ')}`);
|
|
143
|
+
if (retiredOverrides.length) console.log(` migrate config platforms: drop retired ${retiredOverrides.join(', ')}`);
|
|
144
|
+
if (retiredModelOverrides.length) console.log(` migrate config models: drop retired ${retiredModelOverrides.join(', ')}`);
|
|
145
|
+
if (rawVersion < CONFIG_VERSION) console.log(` migrate .keelson/config.yaml v${rawVersion} → v${CONFIG_VERSION}`);
|
|
146
|
+
console.log(dim('nothing written'));
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
heading(`Keelson ${fresh ? 'init' : 'update'} in ${root}`);
|
|
151
|
+
// Refuse a protected shim before changing config or removing v0.3 runtime.
|
|
152
|
+
assertSkillsInstallable(root, targets, { lang: cfg.lang, version: PKG_VERSION, legacyVersion, previousVersion, force: flags.force });
|
|
153
|
+
if (retiredFromConfig.length) warn(`retired host adapters removed from config: ${retiredFromConfig.join(', ')}; use the portable agents layer or select one of: ${PLATFORM_IDS.filter((id) => id !== 'agents').join(', ')}`);
|
|
154
|
+
if (retiredOverrides.length) warn(`retired platform overrides removed: ${retiredOverrides.join(', ')}`);
|
|
155
|
+
if (retiredModelOverrides.length) warn(`retired model overrides removed: ${retiredModelOverrides.join(', ')}`);
|
|
156
|
+
if (detectedTools) info(`tools detected on this machine: ${cfg.tools.map((t) => PLATFORMS[t].label).join(', ')} (override with --tools or --<platform>)`);
|
|
157
|
+
else if (portableFallback) {
|
|
158
|
+
const note = conventionDetected.length ? `; convention-only detections: ${conventionDetected.map((t) => PLATFORMS[t].label).join(', ')} (opt in explicitly if wanted)` : '';
|
|
159
|
+
info(`no verified/documented host detected; using portable AGENTS.md + .agents/skills discovery${note}`);
|
|
160
|
+
}
|
|
161
|
+
const p0 = projectPaths(root, cfg);
|
|
162
|
+
mkdirp(p0.keelson);
|
|
163
|
+
if (fresh) {
|
|
164
|
+
const det = detectRefs(root);
|
|
165
|
+
cfg.refs = { ...cfg.refs, ...Object.fromEntries(Object.entries(det.refs).filter(([, v]) => v)) };
|
|
166
|
+
if (det.specsCandidate && !exists(p0.specs)) info(`found ${det.specsCandidate}/ — if it holds behaviour contracts, set paths.specs to it in config.yaml`);
|
|
167
|
+
for (const [k, v] of Object.entries(det.refs)) if (v) ok(`referencing existing ${k}: ${v}`);
|
|
168
|
+
if (!cfg.check.length) cfg.check = detectChecks(root);
|
|
169
|
+
}
|
|
170
|
+
const p = projectPaths(root, cfg);
|
|
171
|
+
const mapState = !exists(p.readme) ? 'created' : read(p.readme) === projectMap ? null : 'refreshed';
|
|
172
|
+
if (mapState) {
|
|
173
|
+
write(p.readme, projectMap);
|
|
174
|
+
ok(`.keelson/README.md (human project map; ${mapState} by Keelson)`);
|
|
175
|
+
}
|
|
176
|
+
const seed = (file, target, vars = {}) => {
|
|
177
|
+
if (exists(target)) return false;
|
|
178
|
+
write(target, fill(read(path.join(tpl, file)), { project, ...vars }));
|
|
179
|
+
return true;
|
|
180
|
+
};
|
|
181
|
+
if (seed('INTENT.md', p.intent)) ok('.keelson/INTENT.md (the agent derives it from repository evidence on first contact; owner questions only when a boundary is load-bearing)');
|
|
182
|
+
if (seed('NOW.md', p.now)) ok('.keelson/NOW.md');
|
|
183
|
+
// Progressive disclosure: ROADMAP, GLOSSARY, rules/, specs/, and changes/ are created only when the project actually needs them.
|
|
184
|
+
saveConfig(p.config, cfg);
|
|
185
|
+
ok(`.keelson/config.yaml${rawVersion < CONFIG_VERSION ? ` (migrated v${rawVersion} → v${CONFIG_VERSION})` : ''}`);
|
|
186
|
+
|
|
187
|
+
for (const rel of reconcileManagedTargets(root, targets)) ok(`removed stale managed surface → ${rel}`);
|
|
188
|
+
|
|
189
|
+
// v0.3 had no vendor bit and always copied guidance and executable hooks. Its
|
|
190
|
+
// manifest is the ownership proof; every removal below still verifies content.
|
|
191
|
+
if (priorManaged && priorManaged.vendor === undefined) {
|
|
192
|
+
const legacy = removeCanonicalRuntime(root, {
|
|
193
|
+
lang: cfg.lang,
|
|
194
|
+
profile: cfg.profile,
|
|
195
|
+
version: priorManaged.packageVersion,
|
|
196
|
+
guide: cfg.guide,
|
|
197
|
+
});
|
|
198
|
+
const hooks = removeLegacyCopiedHooks(root);
|
|
199
|
+
for (const rel of [...legacy.removed, ...hooks.removed]) ok(`removed legacy copied runtime → ${rel}`);
|
|
200
|
+
for (const rel of [...legacy.preserved, ...hooks.preserved]) warn(`kept ${rel}: it differs from the v0.3 Keelson output`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (cfg.vendor) {
|
|
204
|
+
const workflowPath = installWorkflow(root, { lang: cfg.lang, guide: cfg.guide, force: flags.force });
|
|
205
|
+
const canonicalSkillPath = installCanonicalSkill(root, { lang: cfg.lang, profile: cfg.profile, version: PKG_VERSION, force: flags.force });
|
|
206
|
+
ok(`vendored guidance → ${workflowPath}; ${canonicalSkillPath}`);
|
|
207
|
+
} else info('guidance stays in the installed package; agents load it with `keelson guide`');
|
|
208
|
+
|
|
209
|
+
const discoveryInstalled = new Set();
|
|
210
|
+
for (const t of targets) {
|
|
211
|
+
const discoveryKey = `${t.instructions}|${t.skillsDir}|${t.rulesFile ?? ''}`;
|
|
212
|
+
if (!discoveryInstalled.has(discoveryKey)) {
|
|
213
|
+
discoveryInstalled.add(discoveryKey);
|
|
214
|
+
const skillPath = installSkill(root, t, { lang: cfg.lang, version: PKG_VERSION, legacyVersion, previousVersion, force: flags.force });
|
|
215
|
+
const files = installInstructions(root, t, { lang: cfg.lang });
|
|
216
|
+
ok(`${t.label}: discovery shim → ${skillPath}; instructions → ${files.join(', ')}${t.confidence === 'convention' ? dim(' (path by convention; run `keelson doctor` after your first session)') : ''}`);
|
|
217
|
+
} else {
|
|
218
|
+
info(`${t.label}: reuses existing discovery surface ${t.instructions} + ${t.skillsDir}/keelson`);
|
|
219
|
+
}
|
|
220
|
+
if (t.hooks) {
|
|
221
|
+
installHooks(root);
|
|
222
|
+
ok(`${t.label}: hooks → .claude/settings.json (session snapshot + per-prompt state line)`);
|
|
223
|
+
}
|
|
224
|
+
const sessionFiles = installSessionAdapter(root, t);
|
|
225
|
+
if (sessionFiles.length) ok(`${t.label}: native session adapter → ${sessionFiles.join(', ')}`);
|
|
226
|
+
else if (t.sessionAdapter === 'pi-env') info(`${t.label}: native session focus uses PI_SESSION_ID; no adapter file needed`);
|
|
227
|
+
}
|
|
228
|
+
writeManagedState(root, targets, PKG_VERSION, { vendor: cfg.vendor });
|
|
229
|
+
ok('.keelson/manifest.json (generated-surface ownership)');
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
detectAndCache();
|
|
233
|
+
info(`model detection cached in ~/.keelson/models.cache.json ${dim('(keelson models)')}`);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
warn(`model detection skipped: ${e.message}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A fresh project starts with one invisible onboarding task: infer project intent
|
|
239
|
+
// from repository evidence. Owner questions are reserved for load-bearing boundaries.
|
|
240
|
+
// Specs and rules grow later only when real work exposes durable truth.
|
|
241
|
+
if (fresh || flags.onboard) writeOnboardNote(p, project, cfg, hasCode(root));
|
|
242
|
+
|
|
243
|
+
console.log('');
|
|
244
|
+
if (fresh) {
|
|
245
|
+
heading('Done. Open your agent in this directory and start talking.');
|
|
246
|
+
console.log(dim(' On first contact it derives .keelson/INTENT.md from repository evidence; it asks only if a project boundary actually changes the work.'));
|
|
247
|
+
}
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function hasCode(root) {
|
|
252
|
+
const skip = new Set(['.git', '.keelson', 'node_modules', '.claude', '.agents', '.cursor', '.github', 'docs', 'dist', 'build']);
|
|
253
|
+
const visit = (d, depth) => {
|
|
254
|
+
if (depth > 3) return false;
|
|
255
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
256
|
+
if (skip.has(e.name) || e.name.startsWith('.')) continue;
|
|
257
|
+
if (e.isFile() && /\.(m?js|cjs|ts|tsx|jsx|py|go|rs|rb|java|kt|swift|cs|php|c|cc|cpp|h|hpp|scala|ex|exs|clj|vue|svelte)$/.test(e.name)) return true;
|
|
258
|
+
if (e.isDirectory() && visit(path.join(d, e.name), depth + 1)) return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
return visit(root, 0);
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function writeOnboardNote(p, project, cfg, existingCode) {
|
|
270
|
+
const refs = Object.entries(cfg.refs ?? {}).filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`);
|
|
271
|
+
const intent = `Draft \`.keelson/INTENT.md\` from what the repository already shows (README, package manifest, directory layout${existingCode ? ', and the code' : ''}): why it exists, its boundaries, hard constraints, and a first Authorizations section. Treat repository-backed facts as established. Do not ask for blanket approval; only ask one owner question if a missing project boundary is load-bearing for the current work.`;
|
|
272
|
+
const grow = existingCode
|
|
273
|
+
? ' Do not inventory the whole repository into specs or rules. As the first real task touches a capability or stable engineering invariant, create only the spec/rule needed to preserve that truth across future sessions.'
|
|
274
|
+
: '';
|
|
275
|
+
write(
|
|
276
|
+
p.now,
|
|
277
|
+
`# Now
|
|
278
|
+
|
|
279
|
+
First contact with ${project}: Keelson was just initialised. Project intent will be derived from repository evidence as part of the first real task.
|
|
280
|
+
|
|
281
|
+
## Context
|
|
282
|
+
Existing documents${refs.length ? ` (${refs.join(', ')})` : ''} are referenced, never copied. INTENT.md may be refined silently when evidence is clear; owner input is needed only for a load-bearing boundary the repository cannot answer.
|
|
283
|
+
|
|
284
|
+
## Next
|
|
285
|
+
${intent}${grow} Do this before, or together with, the first non-trivial thing the owner asks for. Then rewrite this file to the actual current state.
|
|
286
|
+
`,
|
|
287
|
+
);
|
|
288
|
+
ok('.keelson/NOW.md: first-contact task written for the agent (derive project intent; ask only at a load-bearing boundary)');
|
|
289
|
+
}
|