@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,71 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
3
|
+
import { loadAllChanges, loadChange } from '../lib/changes.js';
|
|
4
|
+
import { listDirs, readOr } from '../lib/fs.js';
|
|
5
|
+
import { verificationStatus } from '../lib/lifecycle.js';
|
|
6
|
+
import { worktreeFingerprint } from '../lib/git.js';
|
|
7
|
+
|
|
8
|
+
const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
|
|
10
|
+
function archivedChangeName(archiveDir, directory) {
|
|
11
|
+
const landed = readOr(path.join(archiveDir, directory, 'landed.json')).trim();
|
|
12
|
+
if (!landed) return null;
|
|
13
|
+
try {
|
|
14
|
+
const data = JSON.parse(landed);
|
|
15
|
+
return typeof data.change === 'string' && data.change ? data.change : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function legacyArchiveMatches(directory, name) {
|
|
22
|
+
// A plain dated legacy directory is the only shape that proves its original
|
|
23
|
+
// name. A "-cancelled" or numeric suffix can itself be part of that name.
|
|
24
|
+
return new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escaped(name)}$`).test(directory);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function uncertainLegacyArchives(directory, name) {
|
|
28
|
+
return new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escaped(name)}(?:-cancelled|-\\d+)(?:-\\d+)?$`).test(directory);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveArchivedChange(archiveDir, name) {
|
|
32
|
+
const exact = loadChange(archiveDir, name);
|
|
33
|
+
if (exact) return exact;
|
|
34
|
+
|
|
35
|
+
const candidates = listDirs(archiveDir)
|
|
36
|
+
.map((directory) => ({
|
|
37
|
+
directory,
|
|
38
|
+
recorded: archivedChangeName(archiveDir, directory),
|
|
39
|
+
change: loadChange(archiveDir, directory),
|
|
40
|
+
}))
|
|
41
|
+
.filter(({ change }) => change);
|
|
42
|
+
const matches = candidates
|
|
43
|
+
.filter(({ recorded, directory: archiveName }) => {
|
|
44
|
+
return recorded === name || (!recorded && legacyArchiveMatches(archiveName, name));
|
|
45
|
+
});
|
|
46
|
+
const uncertain = candidates.filter(({ recorded, directory }) => !recorded && uncertainLegacyArchives(directory, name));
|
|
47
|
+
if (uncertain.length) {
|
|
48
|
+
throw new Error(`cannot prove original name "${name}" from legacy archive suffixes; specify an exact archive directory: ${uncertain.map(({ directory }) => directory).join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
if (matches.length === 1) return matches[0].change;
|
|
51
|
+
if (matches.length > 1) {
|
|
52
|
+
throw new Error(`ambiguous archived change "${name}"; specify an exact archive directory: ${matches.map(({ directory }) => directory).join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function attest({ positional }, cwd = process.cwd()) {
|
|
58
|
+
const root = requireProjectRoot(cwd);
|
|
59
|
+
const p = projectPaths(root);
|
|
60
|
+
const changes = loadAllChanges(p.changes);
|
|
61
|
+
const name = positional[0] ?? (changes.length === 1 ? changes[0].name : null);
|
|
62
|
+
if (!name) throw new Error('usage: keelson attest <change>');
|
|
63
|
+
// An active change always wins. Archived changes may be addressed by their
|
|
64
|
+
// exact directory, or by the original change name only when it is unique.
|
|
65
|
+
const c = loadChange(p.changes, name) ?? resolveArchivedChange(p.archive, name);
|
|
66
|
+
if (!c) throw new Error(`no change named ${name}`);
|
|
67
|
+
const verification = verificationStatus(c, worktreeFingerprint(root));
|
|
68
|
+
const envelopes = readOr(path.join(c.dir, 'ledger.jsonl')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
69
|
+
console.log(JSON.stringify({ change: name, verification, trust: 'local-machine; exported key is not an independent trust anchor', publicKey: readOr(path.join(c.dir, 'evidence', 'public-key.pem')), envelopes }, null, 2));
|
|
70
|
+
return verification.state === 'passed' ? 0 : 1;
|
|
71
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
4
|
+
import { loadConfig, checkEntries } from '../lib/config.js';
|
|
5
|
+
import { append, write } from '../lib/fs.js';
|
|
6
|
+
import { loadAllChanges, loadChange } from '../lib/changes.js';
|
|
7
|
+
import { evaluateLifecycle } from '../lib/lifecycle.js';
|
|
8
|
+
import { changeSpecDrift } from '../lib/specs.js';
|
|
9
|
+
import { worktreeFingerprint } from '../lib/git.js';
|
|
10
|
+
import { readSession } from '../lib/session.js';
|
|
11
|
+
import { contractFingerprint, recordStatement, sha256, trustCommands, verificationStatement } from '../lib/evidence.js';
|
|
12
|
+
import { beginCheck } from '../lib/check-activity.js';
|
|
13
|
+
|
|
14
|
+
export function verifyLine(claim, results, tree) {
|
|
15
|
+
return `### Verify: ${String(claim).replace(/[\r\n]/g, ' ')}\n${results.map((r) => `\`${r.cmd}\` exit ${r.exit}`).join('; ')}${tree ? ` · tree ${tree}` : ''}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Kill the process group on deadline, including shell descendants holding pipes. */
|
|
19
|
+
export function runCommand(cmd, root, timeout) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
const child = spawn(cmd, { cwd: root, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } });
|
|
23
|
+
let output = '';
|
|
24
|
+
let timedOut = false;
|
|
25
|
+
let truncated = false;
|
|
26
|
+
let size = 0;
|
|
27
|
+
let errorText = '';
|
|
28
|
+
let settled = false;
|
|
29
|
+
let forcedClose;
|
|
30
|
+
const finish = (code, signal, terminationUnconfirmed = false) => {
|
|
31
|
+
if (settled) return;
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
clearTimeout(forcedClose);
|
|
35
|
+
const exit = timedOut ? 124 : truncated ? 125 : code ?? 1;
|
|
36
|
+
resolve({ exit, signal, timedOut, truncated, terminationUnconfirmed, durationMs: Date.now() - started, output: output + (errorText ? `\n${errorText}\n` : '') });
|
|
37
|
+
};
|
|
38
|
+
const kill = () => {
|
|
39
|
+
// An escaped descendant can retain the pipes even after its original
|
|
40
|
+
// process group exits. Bound our wait too; do not claim it was reaped.
|
|
41
|
+
forcedClose ??= setTimeout(() => {
|
|
42
|
+
child.stdout.destroy();
|
|
43
|
+
child.stderr.destroy();
|
|
44
|
+
child.unref();
|
|
45
|
+
finish(null, null, true);
|
|
46
|
+
}, 250);
|
|
47
|
+
if (!child.pid) return;
|
|
48
|
+
try {
|
|
49
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 });
|
|
50
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
51
|
+
} catch { try { child.kill('SIGKILL'); } catch { /* deadline still bounds this process */ } }
|
|
52
|
+
};
|
|
53
|
+
const timer = setTimeout(() => { timedOut = true; kill(); }, timeout);
|
|
54
|
+
const consume = (chunk) => {
|
|
55
|
+
size += Buffer.byteLength(chunk);
|
|
56
|
+
if (size <= 2 * 1024 * 1024) output += chunk;
|
|
57
|
+
else if (!truncated) { truncated = true; output += '\n[output limit exceeded]\n'; kill(); }
|
|
58
|
+
};
|
|
59
|
+
child.stdout.setEncoding('utf8');
|
|
60
|
+
child.stderr.setEncoding('utf8');
|
|
61
|
+
child.stdout.on('data', consume);
|
|
62
|
+
child.stderr.on('data', consume);
|
|
63
|
+
child.on('error', (error) => { errorText = error.message; });
|
|
64
|
+
child.on('close', (code, signal) => finish(code, signal));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function check({ flags, positional }, cwd = process.cwd()) {
|
|
69
|
+
const root = requireProjectRoot(cwd);
|
|
70
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
71
|
+
const p = projectPaths(root, cfg);
|
|
72
|
+
const configured = checkEntries(cfg);
|
|
73
|
+
const entries = positional.length ? [{ name: positional.join(' '), command: positional.join(' '), kind: 'check' }] : configured;
|
|
74
|
+
if (!entries.length) throw new Error('no check commands configured. Add `check:` commands to .keelson/config.yaml before claiming completion.');
|
|
75
|
+
const timeout = Number(flags.timeout ?? cfg.check_timeout_ms ?? 600000);
|
|
76
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 86400000) throw new Error('--timeout must be an integer in milliseconds (1..86400000)');
|
|
77
|
+
// Register before resolving a change: otherwise landing could archive it
|
|
78
|
+
// between lookup and registration and the check would recreate its old path.
|
|
79
|
+
const finish = beginCheck(root);
|
|
80
|
+
try {
|
|
81
|
+
let change;
|
|
82
|
+
if (flags.record !== undefined) {
|
|
83
|
+
const all = loadAllChanges(p.changes);
|
|
84
|
+
const focused = readSession(root).state?.change;
|
|
85
|
+
const name = flags.change ?? (all.some((c) => c.name === focused) ? focused : all.length === 1 ? all[0].name : null);
|
|
86
|
+
change = all.find((c) => c.name === name);
|
|
87
|
+
if (!change) throw new Error('recording requires one active change; use `keelson new <name>` or `--change <name>`');
|
|
88
|
+
}
|
|
89
|
+
trustCommands(root, entries.map((e) => e.command), Boolean(flags.trust));
|
|
90
|
+
const dir = change?.dir ?? p.runtime;
|
|
91
|
+
const before = { tree: worktreeFingerprint(root), contract: contractFingerprint(root, dir) };
|
|
92
|
+
const startedOn = new Date().toISOString();
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const run = await runCommand(entry.command, root, timeout);
|
|
96
|
+
const log = `$ ${entry.command}\nexit ${run.exit}\n\n${run.output}`;
|
|
97
|
+
const logDigest = sha256(log);
|
|
98
|
+
const file = path.join(dir, 'evidence', `${logDigest}.log`);
|
|
99
|
+
write(file, log);
|
|
100
|
+
const { output, ...metadata } = run;
|
|
101
|
+
results.push({ cmd: entry.command, name: entry.name, kind: entry.kind, ...metadata, logDigest });
|
|
102
|
+
if (!flags.json && !flags.quiet && output) process.stdout.write(output);
|
|
103
|
+
if (!flags.json) console.log(`${run.exit ? '✗' : '✓'} ${entry.name}: exit ${run.exit}${run.timedOut ? ' (timeout)' : ''}`);
|
|
104
|
+
}
|
|
105
|
+
const unchanged = before.tree === worktreeFingerprint(root) && before.contract === contractFingerprint(root, dir);
|
|
106
|
+
const complete = JSON.stringify(entries.map((e) => e.command)) === JSON.stringify(configured.map((e) => e.command));
|
|
107
|
+
const passed = unchanged && results.every((r) => r.exit === 0);
|
|
108
|
+
const claim = typeof flags.record === 'string' ? flags.record : passed ? 'checks pass' : 'checks failed';
|
|
109
|
+
if (change) {
|
|
110
|
+
recordStatement(root, dir, verificationStatement({ ...before, results, claim, startedOn, unchanged, complete, model: typeof flags.model === 'string' ? flags.model : null, host: process.env.CODEX_THREAD_ID ? 'codex' : null }));
|
|
111
|
+
append(path.join(dir, 'ledger.md'), `\n${verifyLine(claim, results, before.tree)}\n`);
|
|
112
|
+
}
|
|
113
|
+
const result = { ok: passed, complete, unchanged, tree: before.tree, contract: before.contract, results, record: change ? path.relative(root, path.join(dir, 'ledger.jsonl')) : null };
|
|
114
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
115
|
+
else {
|
|
116
|
+
console.log(passed ? 'all checks passed' : unchanged ? 'checks failed' : 'inputs changed during checking; re-run checks');
|
|
117
|
+
if (change) console.log(`recorded in ${result.record}`);
|
|
118
|
+
if (!complete) console.log('partial suite: this run cannot authorize landing');
|
|
119
|
+
if (change && passed && complete) {
|
|
120
|
+
const current = loadChange(p.changes, change.name);
|
|
121
|
+
const lifecycle = evaluateLifecycle(current, before.tree, { activeNames: loadAllChanges(p.changes).map((c) => c.name), contractDrift: changeSpecDrift(current, p.specs) });
|
|
122
|
+
if (lifecycle.work === 'ready') console.log(`${change.name}: ready → run \`keelson land ${change.name}\``);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return passed ? 0 : 1;
|
|
126
|
+
} finally { finish(); }
|
|
127
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
3
|
+
import { readOr, listDirs, exists } from '../lib/fs.js';
|
|
4
|
+
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { matchRules } from '../lib/rules.js';
|
|
6
|
+
import { loadAllChanges } from '../lib/changes.js';
|
|
7
|
+
import { evaluateLifecycle } from '../lib/lifecycle.js';
|
|
8
|
+
import { readSession } from '../lib/session.js';
|
|
9
|
+
import { gitStatusShort, recentCommits, worktreeFingerprint } from '../lib/git.js';
|
|
10
|
+
import { list } from '../lib/args.js';
|
|
11
|
+
import { knowledgeHealth } from '../lib/health.js';
|
|
12
|
+
import { maintainRuntime } from '../lib/maintenance.js';
|
|
13
|
+
import { changeSpecDrift } from '../lib/specs.js';
|
|
14
|
+
|
|
15
|
+
export async function context({ flags, positional }, cwd = process.cwd()) {
|
|
16
|
+
const root = requireProjectRoot(cwd);
|
|
17
|
+
maintainRuntime(root);
|
|
18
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
19
|
+
const p = projectPaths(root, cfg);
|
|
20
|
+
const paths = [...list(flags.paths), ...positional].map((x) => path.relative(root, path.resolve(root, x)).replace(/\\/g, '/'));
|
|
21
|
+
const rules = matchRules(p.rules, paths);
|
|
22
|
+
const fp = worktreeFingerprint(root);
|
|
23
|
+
const changes = loadAllChanges(p.changes);
|
|
24
|
+
const session = readSession(root);
|
|
25
|
+
const focus = session.state?.change && changes.some((c) => c.name === session.state.change) ? session.state.change : null;
|
|
26
|
+
const activeNames = new Set(changes.map((c) => c.name));
|
|
27
|
+
const orderedChanges = focus ? [...changes].sort((a, b) => (a.name === focus ? -1 : b.name === focus ? 1 : 0)) : changes;
|
|
28
|
+
const specs = listDirs(p.specs);
|
|
29
|
+
const refs = Object.entries(cfg.refs ?? {}).filter(([, v]) => v);
|
|
30
|
+
const knowledgeFindings = knowledgeHealth(root, cfg, p);
|
|
31
|
+
const data = {
|
|
32
|
+
root,
|
|
33
|
+
intent: readOr(p.intent).trim(),
|
|
34
|
+
now: readOr(p.now).trim(),
|
|
35
|
+
roadmap: exists(p.roadmap) ? readOr(p.roadmap).trim() : '',
|
|
36
|
+
glossary: exists(p.glossary) && !/^- …/m.test(readOr(p.glossary)) ? readOr(p.glossary).trim() : '',
|
|
37
|
+
guide: Boolean(cfg.guide),
|
|
38
|
+
context: cfg.context?.trim() ?? '',
|
|
39
|
+
refs: Object.fromEntries(refs),
|
|
40
|
+
paths,
|
|
41
|
+
rules: rules.map((r) => ({ file: `.keelson/rules/${r.file}`, globs: r.globs, content: r.content.trim(), missing: !r.exists })),
|
|
42
|
+
specs: specs.map((s) => `${p.specsRel}/${s}/spec.md`),
|
|
43
|
+
focus,
|
|
44
|
+
sessionAvailable: session.available,
|
|
45
|
+
knowledge: {
|
|
46
|
+
critical: knowledgeFindings.filter((h) => h.level === 'error').length,
|
|
47
|
+
overBudget: knowledgeFindings.filter((h) => h.level === 'warn').length,
|
|
48
|
+
maintenance: knowledgeFindings.slice(0, 8).map((h) => ({ level: h.level, kind: h.kind, text: h.text, fix: h.fix })),
|
|
49
|
+
},
|
|
50
|
+
changes: orderedChanges.map((c) => {
|
|
51
|
+
const contractDrift = changeSpecDrift(c, p.specs);
|
|
52
|
+
const lifecycle = evaluateLifecycle(c, fp, { activeNames, contractDrift });
|
|
53
|
+
return { name: c.name, tier: c.tier, owner: c.owner, work: lifecycle.work, verification: lifecycle.verification.state, blockedBy: lifecycle.blockedBy, progress: c.progress, open: c.open.map((o) => o.text), handoffNext: c.handoff?.next ?? null };
|
|
54
|
+
}),
|
|
55
|
+
git: { dirty: gitStatusShort(root), recent: recentCommits(root, 5) },
|
|
56
|
+
};
|
|
57
|
+
if (flags.json) {
|
|
58
|
+
console.log(JSON.stringify(data, null, 2));
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const out = [`# Keelson context — ${path.basename(root)}`, ''];
|
|
62
|
+
if (data.context) out.push('## Project context (config.yaml)', '', data.context, '');
|
|
63
|
+
out.push('## INTENT.md', '', data.intent || '(empty — fill in .keelson/INTENT.md)', '');
|
|
64
|
+
if (data.roadmap) out.push('## ROADMAP.md', '', data.roadmap, '');
|
|
65
|
+
if (data.glossary) out.push('## GLOSSARY.md', '', data.glossary, '');
|
|
66
|
+
if (data.guide) out.push('Guided mode is on: keep normal accessible questioning, and add concise teaching about the engineering ideas and constraint rationale behind settled decisions.', '');
|
|
67
|
+
out.push('## NOW.md', '', data.now || '(empty)', '');
|
|
68
|
+
if (data.knowledge.maintenance.length) {
|
|
69
|
+
out.push('## Internal knowledge maintenance', '');
|
|
70
|
+
out.push('Handle these automatically during RECONCILE; do not ask the owner to run maintenance or report budget mechanics unless a semantic product decision is required.');
|
|
71
|
+
for (const item of data.knowledge.maintenance.slice(0, 8)) out.push(`- ${item.text} → ${item.fix}`);
|
|
72
|
+
out.push('');
|
|
73
|
+
}
|
|
74
|
+
if (refs.length) out.push('## Existing project material (read, do not duplicate)', '', ...refs.map(([k, v]) => `- ${k}: ${v}`), '');
|
|
75
|
+
out.push(`## Active changes${data.focus ? ` (session focus: ${data.focus})` : ''}`, '');
|
|
76
|
+
if (!data.changes.length) out.push('none');
|
|
77
|
+
for (const c of data.changes) {
|
|
78
|
+
out.push(`- ${c.name} · ${c.tier} · work ${c.work} · verify ${c.verification} · ${c.progress.done}/${c.progress.total} tasks${c.owner ? ` · ${c.owner}` : ''}`);
|
|
79
|
+
if (c.blockedBy?.length) out.push(` depends on active: ${c.blockedBy.join(', ')}`);
|
|
80
|
+
if (c.open.length) out.push(` open: ${c.open.join('; ')}`);
|
|
81
|
+
if (c.handoffNext) out.push(` handoff next: ${c.handoffNext.split('\n')[0]}`);
|
|
82
|
+
}
|
|
83
|
+
out.push('');
|
|
84
|
+
out.push(`## Capabilities with specs (${p.specsRel})`, '', specs.length ? specs.map((s) => `- ${s}`).join('\n') : 'none yet', '');
|
|
85
|
+
out.push(`## Rules matched${paths.length ? ` for ${paths.join(', ')}` : ' (always-on only; pass --paths to route)'}`, '');
|
|
86
|
+
if (!rules.length) out.push('none');
|
|
87
|
+
for (const r of rules) {
|
|
88
|
+
out.push(`### .keelson/rules/${r.file} (${r.globs.join(', ')})`, '');
|
|
89
|
+
out.push(r.exists ? r.content.trim() : '(file missing — referenced in index.md but not found)', '');
|
|
90
|
+
}
|
|
91
|
+
if (paths.length) out.push('Path routing is navigation, not proof: run `keelson impact <files>` for callers and affected specs, then check other entry points by reading.', '');
|
|
92
|
+
if (data.git.dirty?.length) out.push('## Uncommitted', '', ...data.git.dirty.slice(0, 20).map((l) => `- ${l}`), '');
|
|
93
|
+
console.log(out.join('\n'));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { guidanceOptions, readGuidance } from './guide.js';
|
|
2
|
+
|
|
3
|
+
// A brief is input to the host agent, never a claim that an audit already ran.
|
|
4
|
+
const actions = [
|
|
5
|
+
['plan', 'frontend', 'Plan the experience', '规划体验', 'Map the primary task, constraints, screen structure and acceptance before implementation.', '先明确主要任务、约束、页面结构和验收,再实施。'],
|
|
6
|
+
['build', 'frontend', 'Build a complete interface', '构建完整界面', 'Implement a working vertical slice, including real states and browser verification.', '实现能走通的完整任务,补齐真实状态并在浏览器验收。'],
|
|
7
|
+
['audit', 'frontend-review', 'Audit interface quality', '审查界面质量', 'Inspect the running interface for accessibility, responsive, state and performance failures; report evidence before changing code.', '检查运行中的可访问性、响应式、状态与性能问题;先报告证据。'],
|
|
8
|
+
['critique', 'frontend-review', 'Review the design', '评审设计', 'Explain how hierarchy, composition and interaction help or obstruct the primary task.', '指出层级、构图与交互如何帮助或阻碍主要任务。'],
|
|
9
|
+
['polish', 'frontend-review', 'Finish the interface', '打磨界面', 'Fix the highest-impact inconsistencies, then repeat the task and visual checks.', '修复影响最大的细节不一致,再复测任务与视觉表现。'],
|
|
10
|
+
['simplify', 'frontend-visual', 'Simplify the surface', '简化界面', 'Remove redundant decoration and choices while preserving information, discoverability and function.', '去掉冗余装饰和选择,保留信息、可发现性和功能。'],
|
|
11
|
+
['bolder', 'frontend-visual', 'Strengthen the expression', '增强表现力', 'Make a deliberate focal point through scale, composition or color without competing with the task.', '通过比例、构图或颜色建立明确焦点,服务主要任务。'],
|
|
12
|
+
['quieter', 'frontend-visual', 'Reduce visual noise', '收敛视觉噪声', 'Reduce competing emphasis and ornament while retaining hierarchy and brand recognition.', '减少互相争抢的强调与装饰,保留层级和品牌识别。'],
|
|
13
|
+
['typeset', 'frontend-visual', 'Refine typography', '优化排版', 'Establish readable type roles, hierarchy, line length and robust fallback fonts.', '建立可读的字体角色、层级、行长和可靠回退。'],
|
|
14
|
+
['color', 'frontend-visual', 'Refine color', '优化配色', 'Use semantic color roles and verify contrast and non-color state cues.', '使用语义色彩角色,验证对比度与非颜色状态提示。'],
|
|
15
|
+
['layout', 'frontend-visual', 'Refine layout', '优化布局', 'Improve grouping, alignment, density and responsive reading order.', '改进分组、对齐、密度和响应式阅读顺序。'],
|
|
16
|
+
['animate', 'frontend-visual', 'Add purposeful motion', '完善动效', 'Clarify transitions and feedback with interruptible motion and a reduced-motion path.', '用可中断动效说明转换与反馈,并提供减少动态效果路径。'],
|
|
17
|
+
['clarify', 'frontend-interaction', 'Clarify interface copy', '优化界面文案', 'Make labels, actions, errors and recovery specific to what the user is doing.', '让标签、操作、错误与恢复文案准确对应用户任务。'],
|
|
18
|
+
['onboard', 'frontend-interaction', 'Improve first use', '完善首次使用', 'Create a short path from an honest empty state to the first useful outcome.', '从真实空状态出发,以最短路径得到首个有用结果。'],
|
|
19
|
+
['harden', 'frontend-interaction', 'Harden interactions', '补齐交互边界', 'Exercise failures, retries, permissions, slow work, long content and localization.', '覆盖失败、重试、权限、慢操作、长内容与本地化。'],
|
|
20
|
+
['adapt', 'frontend-delivery', 'Adapt across devices', '适配设备', 'Preserve the primary task across content-driven breakpoints, input modes and zoom.', '在由内容决定的断点、输入方式和缩放下保留主要任务。'],
|
|
21
|
+
['optimize', 'frontend-delivery', 'Improve measured performance', '改进实测性能', 'Measure the affected journey, fix the dominant bottleneck, then remeasure correctness and speed.', '测量受影响流程,修复主要瓶颈,再复测正确性和速度。'],
|
|
22
|
+
['extract', 'frontend-delivery', 'Extract reusable design', '提取复用设计', 'Consolidate repeated stable tokens or components while preserving their behavior.', '提取已稳定重复的 token 或组件,并保持行为一致。'],
|
|
23
|
+
['document', 'frontend-delivery', 'Document the design system', '记录设计系统', 'Record observed tokens, components, states and usage decisions in existing project docs.', '在现有文档记录观察到的 token、组件、状态和使用决策。'],
|
|
24
|
+
['explore', 'frontend-delivery', 'Explore visual directions', '探索视觉方向', 'Compare materially different compositions on the same real content and task; label tradeoffs.', '用相同真实内容与任务比较明显不同的构图,并说明取舍。'],
|
|
25
|
+
['iterate', 'frontend-delivery', 'Iterate in the browser', '浏览器迭代', 'Observe, change one coherent area, reload, compare and repeat until acceptance is met.', '观察、修改一个完整区域、重载对照,直到满足验收。'],
|
|
26
|
+
['delight', 'frontend-visual', 'Add a thoughtful detail', '增加体验细节', 'Add a small context-appropriate moment that rewards progress without delaying the task.', '增加契合场景的小反馈,回应进展而不拖慢任务。'],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export async function design({ positional = [], flags = {} }, cwd = process.cwd()) {
|
|
30
|
+
const options = guidanceOptions(flags, cwd);
|
|
31
|
+
const zh = options.lang === 'zh';
|
|
32
|
+
const catalog = actions.map(([name, reference, en, cn, goal, goalZh]) => ({ name, reference, title: zh ? cn : en, objective: zh ? goalZh : goal }));
|
|
33
|
+
const [action, ...parts] = positional;
|
|
34
|
+
if (!action) {
|
|
35
|
+
if (flags.json) console.log(JSON.stringify({ kind: 'design-catalog', lang: options.lang, actions: catalog }, null, 2));
|
|
36
|
+
else console.log([
|
|
37
|
+
zh ? 'Keelson 前端设计' : 'Keelson frontend design', '',
|
|
38
|
+
'Usage: keelson design <action> [target] [--lang en|zh] [--json]', '',
|
|
39
|
+
...catalog.map(({ name, title }) => ` ${name.padEnd(12)} ${title}`), '',
|
|
40
|
+
zh ? '命令生成 Agent 执行指导;宿主 Agent 负责修改和浏览器验证。' : 'Commands prepare agent guidance; the host agent implements and verifies in the browser.',
|
|
41
|
+
zh ? '例如:keelson design harden "设置表单"' : 'Example: keelson design harden "settings form"',
|
|
42
|
+
].join('\n'));
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
const selected = catalog.find((entry) => entry.name === action);
|
|
46
|
+
if (!selected) throw new Error(`unknown design action "${action}". Run \`keelson design\` to list actions.`);
|
|
47
|
+
const references = [...new Set(['frontend', selected.reference])];
|
|
48
|
+
const brief = {
|
|
49
|
+
kind: 'design-brief', lang: options.lang, action,
|
|
50
|
+
target: parts.join(' ').trim() || (zh ? '当前请求涉及的界面' : 'the interface in the current request'),
|
|
51
|
+
title: selected.title, objective: selected.objective,
|
|
52
|
+
execution: zh ? '这是指导,尚未执行审查或修改。由宿主 Agent 使用项目代码和可用浏览器工具完成;目标文本不是 shell 命令。' : 'Guidance only; no audit or modification has run. The host agent uses project code and available browser tools. The target is text, not a shell command.',
|
|
53
|
+
mode: ['audit', 'critique', 'plan', 'explore'].includes(action) ? 'review' : 'change',
|
|
54
|
+
references: references.map((name) => ({ name, content: readGuidance(name, options) })),
|
|
55
|
+
};
|
|
56
|
+
if (flags.json) console.log(JSON.stringify(brief, null, 2));
|
|
57
|
+
else console.log([
|
|
58
|
+
`# ${brief.title}`, '', `${zh ? '目标' : 'Target'}: ${brief.target}`, '', brief.objective, '', brief.execution,
|
|
59
|
+
...(brief.mode === 'review' ? ['', zh ? '先诊断或提出方案;仅在已有修改授权范围内改动。' : 'Start with findings or a proposal; modify only within existing authorization.'] : []),
|
|
60
|
+
...brief.references.flatMap(({ name, content }) => ['', `--- ${name} ---`, '', content.trimEnd()]),
|
|
61
|
+
].join('\n'));
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
4
|
+
import { exists, readOr, readJson, walk } from '../lib/fs.js';
|
|
5
|
+
import { loadConfig, CONFIG_VERSION } from '../lib/config.js';
|
|
6
|
+
import { PLATFORMS, installTargets, managedStateMatches, readManagedState, renderSkillFiles, renderSkillShim, residentBlock, workflowContent, sessionAdapterProblems } from '../platforms/index.js';
|
|
7
|
+
import { validateProject } from './validate.js';
|
|
8
|
+
import { projectStatus } from './status.js';
|
|
9
|
+
import { parseFrontmatter } from '../lib/markdown.js';
|
|
10
|
+
import { detectLocal } from '../lib/models.js';
|
|
11
|
+
import { knowledgeHealth } from '../lib/health.js';
|
|
12
|
+
import { listSessionStates, readSession } from '../lib/session.js';
|
|
13
|
+
import { maintainRuntime } from '../lib/maintenance.js';
|
|
14
|
+
import { ok, warn, fail, heading, dim } from '../lib/out.js';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const { version: PKG_VERSION } = require('../../package.json');
|
|
18
|
+
|
|
19
|
+
export async function doctor({ flags }, cwd = process.cwd()) {
|
|
20
|
+
const root = requireProjectRoot(cwd);
|
|
21
|
+
maintainRuntime(root);
|
|
22
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
23
|
+
const p = projectPaths(root, cfg);
|
|
24
|
+
const findings = [];
|
|
25
|
+
const add = (level, text) => findings.push({ level, text });
|
|
26
|
+
|
|
27
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
28
|
+
(nodeMajor >= 20 ? ok : fail)(`node ${process.versions.node}`);
|
|
29
|
+
if (nodeMajor < 20) add('error', 'Node 20 or newer is required');
|
|
30
|
+
|
|
31
|
+
const rawVersion = Number((readOr(p.config, '').match(/^version:\s*(\d+)/m) || [])[1] ?? 1);
|
|
32
|
+
if (rawVersion < CONFIG_VERSION) add('warn', `config.yaml is v${rawVersion}; run \`keelson update\` to migrate to v${CONFIG_VERSION}`);
|
|
33
|
+
|
|
34
|
+
const managed = readManagedState(root);
|
|
35
|
+
const vendor = cfg.vendor === true || managed?.vendor === true;
|
|
36
|
+
const normalize = (text) => String(text ?? '').replace(/\r\n?/g, '\n');
|
|
37
|
+
if (vendor) {
|
|
38
|
+
const expectedSkillFiles = renderSkillFiles(cfg.lang, cfg.profile, PKG_VERSION);
|
|
39
|
+
const expectedSkillPaths = expectedSkillFiles.map((f) => f.rel).sort();
|
|
40
|
+
const actualSkillPaths = walk(p.skill);
|
|
41
|
+
if (JSON.stringify(actualSkillPaths) !== JSON.stringify(expectedSkillPaths)) add('error', 'canonical skill file set drifted from this CLI version (run `keelson update`)');
|
|
42
|
+
for (const file of expectedSkillFiles) {
|
|
43
|
+
const actual = readOr(path.join(p.skill, file.rel), null);
|
|
44
|
+
if (actual !== null && normalize(actual) !== file.content) add('error', `canonical skill drift: .keelson/skill/${file.rel} (run \`keelson update\`)`);
|
|
45
|
+
}
|
|
46
|
+
if (exists(p.workflow) && normalize(readOr(p.workflow)) !== workflowContent(cfg.lang, cfg.guide)) add('error', 'canonical workflow drifted from config/profile (run `keelson update`)');
|
|
47
|
+
for (const suffix of ['.keelson-tmp', '.keelson-bak']) if (exists(p.skill + suffix)) add('warn', `interrupted runtime replacement residue: .keelson/skill${suffix.replace('.keelson', '')} (run \`keelson update\`)`);
|
|
48
|
+
const canonicalSkill = path.join(p.skill, 'SKILL.md');
|
|
49
|
+
if (!exists(canonicalSkill)) add('error', 'canonical skill missing at .keelson/skill/SKILL.md (run `keelson update`)');
|
|
50
|
+
else {
|
|
51
|
+
const v = parseFrontmatter(readOr(canonicalSkill)).data.version ?? null;
|
|
52
|
+
if (v && v !== PKG_VERSION) add('warn', `canonical skill is ${v}, CLI is ${PKG_VERSION} (run \`keelson update\`)`);
|
|
53
|
+
}
|
|
54
|
+
if (!exists(p.workflow)) add('error', 'canonical workflow missing at .keelson/workflow.md (run `keelson update`)');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const t of cfg.tools ?? []) if (!PLATFORMS[t]) add('error', `unknown tool "${t}" in config.yaml`);
|
|
58
|
+
const currentTargets = installTargets((cfg.tools ?? []).filter((t) => PLATFORMS[t]), cfg);
|
|
59
|
+
if (!managed) add('warn', 'generated-surface ownership manifest is missing (run `keelson update`)');
|
|
60
|
+
else {
|
|
61
|
+
if (managed.packageVersion && managed.packageVersion !== PKG_VERSION) add('warn', `managed surfaces were last written by Keelson ${managed.packageVersion}; CLI is ${PKG_VERSION}`);
|
|
62
|
+
if (!managedStateMatches(root, currentTargets)) add('error', 'configured tools and generated-surface ownership differ (run `keelson update` to reconcile stale adapters)');
|
|
63
|
+
}
|
|
64
|
+
const expectedBlock = normalize(residentBlock(cfg.lang).trim());
|
|
65
|
+
for (const pl of currentTargets) {
|
|
66
|
+
const skill = path.join(root, pl.skillsDir, 'keelson', 'SKILL.md');
|
|
67
|
+
if (!exists(skill)) add('error', `${pl.label}: skill shim missing at ${pl.skillsDir}/keelson (run \`keelson update\`)`);
|
|
68
|
+
else {
|
|
69
|
+
const shimText = readOr(skill);
|
|
70
|
+
const v = parseFrontmatter(shimText).data.version ?? null;
|
|
71
|
+
if (v && v !== PKG_VERSION) add('warn', `${pl.label}: installed shim is ${v}, CLI is ${PKG_VERSION} (run \`keelson update\`)`);
|
|
72
|
+
if (normalize(shimText) !== renderSkillShim(cfg.lang, PKG_VERSION)) add('error', `${pl.label}: skill discovery shim drifted (run \`keelson update\`)`);
|
|
73
|
+
}
|
|
74
|
+
const ins = path.join(root, pl.instructions);
|
|
75
|
+
const insText = readOr(ins, '');
|
|
76
|
+
if (pl.instructionsFormat === 'kiro' ? !insText.includes('Keelson') : !insText.includes('<!-- keelson:start -->')) add('error', `${pl.label}: discovery block missing from ${pl.instructions}`);
|
|
77
|
+
else if (pl.instructionsFormat !== 'kiro') {
|
|
78
|
+
const found = normalize(insText).match(/<!-- keelson:start -->[\s\S]*?<!-- keelson:end -->/)?.[0] ?? '';
|
|
79
|
+
if (found.trim() !== expectedBlock) add('error', `${pl.label}: discovery block drifted in ${pl.instructions} (run \`keelson update\`)`);
|
|
80
|
+
}
|
|
81
|
+
if (pl.rulesFile) {
|
|
82
|
+
const rules = path.join(root, pl.rulesFile);
|
|
83
|
+
if (!exists(rules)) add('error', `${pl.label}: discovery rules file missing at ${pl.rulesFile} (run \`keelson update\`)`);
|
|
84
|
+
else if (!readOr(rules).includes('.keelson/workflow.md')) add('error', `${pl.label}: discovery rules file does not point to .keelson/workflow.md`);
|
|
85
|
+
}
|
|
86
|
+
if (pl.confidence === 'convention') add('info', `${pl.label}: file locations follow the tool's convention and have not been exercised by the maintainers; if the agent does not pick up the skill, override platforms.${pl.id} in config.yaml`);
|
|
87
|
+
for (const problem of sessionAdapterProblems(root, pl)) add('error', `${problem} (run \`keelson update\`)`);
|
|
88
|
+
if (pl.hooks) {
|
|
89
|
+
const settings = readJson(path.join(root, '.claude', 'settings.json'), {}) ?? {};
|
|
90
|
+
const has = (ev, script) => (settings.hooks?.[ev] ?? []).some((g) => (g.hooks ?? []).some((h) => String(h.command ?? '').includes(script)));
|
|
91
|
+
const registrations = [
|
|
92
|
+
['SessionStart', 'session-start'],
|
|
93
|
+
['UserPromptSubmit', 'prompt-state'],
|
|
94
|
+
];
|
|
95
|
+
for (const [event, script] of registrations) {
|
|
96
|
+
const registered = has(event, script);
|
|
97
|
+
if (!registered) {
|
|
98
|
+
add('warn', `${pl.label}: ${event} hook not registered (fine after init --no-hooks; the agent falls back to the discovery workflow)`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { errors, warnings } = validateProject(root);
|
|
106
|
+
for (const e of errors) add('error', `validate: ${e}`);
|
|
107
|
+
for (const w of warnings) add('warn', `validate: ${w}`);
|
|
108
|
+
|
|
109
|
+
const st = projectStatus(root);
|
|
110
|
+
for (const c of st.changes) {
|
|
111
|
+
if (c.verification.state === 'stale') add('warn', `${c.name}: verification is stale (${c.verification.detail})`);
|
|
112
|
+
if (c.handoff?.headMoved) add('warn', `${c.name}: HEAD moved since its handoff; check the worktree before resuming`);
|
|
113
|
+
if (c.blockedBy.length) add('info', `${c.name}: waits on ${c.blockedBy.join(', ')}`);
|
|
114
|
+
}
|
|
115
|
+
for (const k of st.conflicts) add('warn', `shared contract between ${k.a} and ${k.b}: ${[...k.capabilities, ...k.paths].join(', ')}`);
|
|
116
|
+
|
|
117
|
+
const activeNames = new Set(st.changes.map((c) => c.name));
|
|
118
|
+
for (const session of listSessionStates(root)) {
|
|
119
|
+
if (session.change && !activeNames.has(session.change)) add('warn', `stale session focus points to missing change "${session.change}" in Keelson local runtime`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let sessionStatus = null;
|
|
123
|
+
if (flags.session) {
|
|
124
|
+
const current = readSession(root);
|
|
125
|
+
sessionStatus = { current: { available: current.available, source: current.source, focusedChange: current.state?.change ?? null }, tools: [] };
|
|
126
|
+
for (const tool of cfg.tools ?? []) {
|
|
127
|
+
if (tool === 'codex') {
|
|
128
|
+
const available = Boolean(process.env.CODEX_THREAD_ID);
|
|
129
|
+
sessionStatus.tools.push({ tool, available, mode: available ? 'native' : 'degraded', source: available ? 'CODEX_THREAD_ID' : null, reason: available ? null : 'CODEX_THREAD_ID is unavailable in this process' });
|
|
130
|
+
} else if (tool === 'pi') {
|
|
131
|
+
const available = Boolean(process.env.PI_SESSION_ID);
|
|
132
|
+
sessionStatus.tools.push({ tool, available, mode: available ? 'native' : 'degraded', source: available ? 'PI_SESSION_ID' : null, reason: available ? null : 'PI_SESSION_ID is unavailable in this process' });
|
|
133
|
+
} else if (tool === 'claude' || tool === 'codebuddy') {
|
|
134
|
+
const available = Boolean(process.env.KEELSON_SESSION_ID);
|
|
135
|
+
sessionStatus.tools.push({ tool, available, mode: available ? 'bridge' : 'degraded', source: available ? 'KEELSON_SESSION_ID' : null, reason: available ? null : 'this command did not receive a bridge session identity' });
|
|
136
|
+
} else sessionStatus.tools.push({ tool, available: false, mode: 'degraded', source: null, reason: 'this host has no zero-copy session bridge' });
|
|
137
|
+
}
|
|
138
|
+
for (const item of sessionStatus.tools) add('info', `session ${item.tool}: ${item.mode}${item.source ? ` via ${item.source}` : ` — ${item.reason}`}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const health = knowledgeHealth(root, cfg, p);
|
|
142
|
+
// Critical budget findings already arrive through validateProject; add the
|
|
143
|
+
// advisory health findings here without duplicating hard-limit errors.
|
|
144
|
+
for (const h of health) if (h.level !== 'error') add(h.level, `${h.kind}: ${h.text} → ${h.fix}`);
|
|
145
|
+
|
|
146
|
+
const det = detectLocal();
|
|
147
|
+
for (const t of cfg.tools ?? []) if (det.tools[t] && !det.tools[t].installed) add('info', `${PLATFORMS[t]?.label ?? t} CLI not found on PATH (fine if you use it through an IDE)`);
|
|
148
|
+
|
|
149
|
+
heading(`keelson doctor — ${path.basename(root)} ${dim(`(keelson ${PKG_VERSION})`)}`);
|
|
150
|
+
if (health.length) console.log(dim('knowledge health: structural maintenance is automatic; remaining findings are internal Agent reconciliation signals unless semantics require owner input'));
|
|
151
|
+
for (const f of findings) (f.level === 'error' ? fail : f.level === 'warn' ? warn : ok)(f.text);
|
|
152
|
+
const errs = findings.filter((f) => f.level === 'error').length;
|
|
153
|
+
if (!findings.length) ok('everything in place');
|
|
154
|
+
else console.log(`${errs} error${errs === 1 ? '' : 's'}, ${findings.filter((f) => f.level === 'warn').length} warnings`);
|
|
155
|
+
if (flags.json) console.log(JSON.stringify({ ok: !errs, version: PKG_VERSION, findings, session: sessionStatus }, null, 2));
|
|
156
|
+
return errs ? 1 : 0;
|
|
157
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
2
|
+
import { loadConfig } from '../lib/config.js';
|
|
3
|
+
import { loadAllChanges, loadChange } from '../lib/changes.js';
|
|
4
|
+
import { currentBranch } from '../lib/git.js';
|
|
5
|
+
import { bindSession, clearSession, readSession } from '../lib/session.js';
|
|
6
|
+
import { ok, info, warn } from '../lib/out.js';
|
|
7
|
+
|
|
8
|
+
function candidatesFor(root, changes) {
|
|
9
|
+
const branch = currentBranch(root);
|
|
10
|
+
const branchMatches = branch ? changes.filter((c) => c.branch === branch) : [];
|
|
11
|
+
if (branchMatches.length === 1) return { preferred: branchMatches[0], reason: `branch ${branch}` };
|
|
12
|
+
if (changes.length === 1) return { preferred: changes[0], reason: 'only active change' };
|
|
13
|
+
return { preferred: null, reason: null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function focus({ flags, positional }, cwd = process.cwd()) {
|
|
17
|
+
const root = requireProjectRoot(cwd);
|
|
18
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
19
|
+
const p = projectPaths(root, cfg);
|
|
20
|
+
const changes = loadAllChanges(p.changes);
|
|
21
|
+
const session = readSession(root);
|
|
22
|
+
|
|
23
|
+
if (!session.available) {
|
|
24
|
+
const candidate = candidatesFor(root, changes);
|
|
25
|
+
const requested = positional[0] ?? null;
|
|
26
|
+
if (requested && !loadChange(p.changes, requested)) throw new Error(`no active change named "${requested}"`);
|
|
27
|
+
const suggested = requested ?? (flags.auto ? candidate.preferred?.name ?? null : null);
|
|
28
|
+
const payload = { available: false, focus: null, suggested, reason: requested ? 'explicit' : candidate.reason, candidates: changes.map((c) => c.name) };
|
|
29
|
+
if (flags.json) console.log(JSON.stringify(payload, null, 2));
|
|
30
|
+
else {
|
|
31
|
+
warn('session identity is unavailable; Keelson will not persist a shared/global focus because parallel sessions could cross-wire');
|
|
32
|
+
if (suggested) info(`use ${suggested} explicitly for this work (${requested ? 'requested' : candidate.reason}); commands accept a change name/--change where needed`);
|
|
33
|
+
else if (changes.length) info(`active changes: ${changes.map((c) => c.name).join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (flags.clear) {
|
|
39
|
+
clearSession(root);
|
|
40
|
+
if (flags.json) console.log(JSON.stringify({ available: true, focus: null, cleared: true }));
|
|
41
|
+
else ok('session focus cleared; active changes themselves are unchanged');
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let name = positional[0] ?? null;
|
|
46
|
+
if (!name && flags.auto) {
|
|
47
|
+
if (session.state?.change && loadChange(p.changes, session.state.change)) name = session.state.change;
|
|
48
|
+
else {
|
|
49
|
+
const candidate = candidatesFor(root, changes);
|
|
50
|
+
if (candidate.preferred) {
|
|
51
|
+
name = candidate.preferred.name;
|
|
52
|
+
if (!flags.json) info(`resume candidate selected from ${candidate.reason}: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (name) {
|
|
58
|
+
const change = loadChange(p.changes, name);
|
|
59
|
+
if (!change) throw new Error(`no active change named "${name}"`);
|
|
60
|
+
const bound = bindSession(root, name, { branch: currentBranch(root), source: flags.auto ? 'auto' : 'focus' });
|
|
61
|
+
const payload = { available: true, focus: name, key: bound?.key ?? session.key };
|
|
62
|
+
if (flags.json) console.log(JSON.stringify(payload, null, 2));
|
|
63
|
+
else ok(`session focus → ${name}`);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const active = session.state?.change && loadChange(p.changes, session.state.change) ? session.state.change : null;
|
|
68
|
+
const candidate = candidatesFor(root, changes);
|
|
69
|
+
const payload = {
|
|
70
|
+
available: true,
|
|
71
|
+
focus: active,
|
|
72
|
+
updatedAt: session.state?.updatedAt ?? null,
|
|
73
|
+
suggested: active ? null : candidate.preferred?.name ?? null,
|
|
74
|
+
candidates: changes.map((c) => c.name),
|
|
75
|
+
};
|
|
76
|
+
if (flags.json) console.log(JSON.stringify(payload, null, 2));
|
|
77
|
+
else if (active) console.log(active);
|
|
78
|
+
else {
|
|
79
|
+
console.log('no session focus');
|
|
80
|
+
if (candidate.preferred) info(`suggested resume: ${candidate.preferred.name} (${candidate.reason}); run \`keelson focus ${candidate.preferred.name}\` to bind it`);
|
|
81
|
+
else if (changes.length) info(`active changes: ${changes.map((c) => c.name).join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
}
|