@zyaiting/keelson 0.4.1 → 0.5.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/README.md +15 -12
- package/README_CN.md +15 -12
- package/hooks/codebuddy-session.mjs +13 -28
- package/hooks/codex-session.mjs +16 -0
- package/hooks/prompt-state.mjs +5 -29
- package/hooks/session-start.mjs +7 -0
- package/hooks/workflow-guard.mjs +95 -0
- package/package.json +1 -1
- package/registry/platforms.json +4 -4
- package/registry/runtime-hashes.json +41 -0
- package/skills/keelson/SKILL.md +6 -3
- package/skills/keelson/references/build.md +12 -0
- package/skills/keelson/references/discover.md +5 -3
- package/skills/keelson/references/frontend.md +1 -1
- package/skills/keelson/references/interview.md +26 -7
- package/skills/keelson/references/land.md +2 -0
- package/skills/keelson/references/shape.md +3 -3
- package/skills/keelson/references/verify.md +1 -1
- package/skills/keelson/templates/resident-block.md +1 -1
- package/skills/keelson/templates/workflow.md +3 -3
- package/skills/zh/keelson/SKILL.md +6 -3
- package/skills/zh/keelson/references/build.md +12 -0
- package/skills/zh/keelson/references/discover.md +5 -3
- package/skills/zh/keelson/references/frontend.md +1 -1
- package/skills/zh/keelson/references/interview.md +26 -7
- package/skills/zh/keelson/references/land.md +2 -0
- package/skills/zh/keelson/references/shape.md +3 -3
- package/skills/zh/keelson/references/verify.md +2 -0
- package/skills/zh/keelson/templates/resident-block.md +1 -1
- package/skills/zh/keelson/templates/workflow.md +3 -3
- package/src/cli.js +4 -3
- package/src/commands/ablate.js +2 -1
- package/src/commands/ask.js +4 -1
- package/src/commands/context.js +7 -0
- package/src/commands/doctor.js +3 -1
- package/src/commands/hook.js +6 -1
- package/src/commands/init.js +12 -7
- package/src/commands/new.js +1 -1
- package/src/commands/platforms.js +1 -1
- package/src/commands/start.js +33 -0
- package/src/commands/uninstall.js +1 -1
- package/src/lib/decisions.js +3 -1
- package/src/lib/hook-context.js +30 -0
- package/src/lib/markdown.js +1 -1
- package/src/lib/rules.js +10 -2
- package/src/lib/workflow.js +102 -0
- package/src/platforms/integration.js +75 -93
- package/src/platforms/runtime.js +68 -17
package/src/commands/init.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import { projectPaths, findProjectRoot } from '../lib/paths.js';
|
|
5
5
|
import { exists, write, read, mkdirp, readOr } from '../lib/fs.js';
|
|
6
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';
|
|
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, assertCanonicalSkillInstallable, assertWorkflowInstallable } from '../platforms/index.js';
|
|
8
8
|
import { list } from '../lib/args.js';
|
|
9
9
|
import { ok, info, warn, heading, dim } from '../lib/out.js';
|
|
10
10
|
import { detectAndCache, detectLocal } from '../lib/models.js';
|
|
@@ -149,7 +149,11 @@ export async function init({ flags }, cwd = process.cwd()) {
|
|
|
149
149
|
|
|
150
150
|
heading(`Keelson ${fresh ? 'init' : 'update'} in ${root}`);
|
|
151
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 });
|
|
152
|
+
assertSkillsInstallable(root, targets, { lang: cfg.lang, version: PKG_VERSION, legacyVersion, previousVersion, previousState: priorManaged, force: flags.force });
|
|
153
|
+
if (cfg.vendor) {
|
|
154
|
+
assertWorkflowInstallable(root, { lang: cfg.lang, guide: cfg.guide, previousState: priorManaged, force: flags.force });
|
|
155
|
+
assertCanonicalSkillInstallable(root, { lang: cfg.lang, profile: cfg.profile, version: PKG_VERSION, previousState: priorManaged, force: flags.force });
|
|
156
|
+
}
|
|
153
157
|
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
158
|
if (retiredOverrides.length) warn(`retired platform overrides removed: ${retiredOverrides.join(', ')}`);
|
|
155
159
|
if (retiredModelOverrides.length) warn(`retired model overrides removed: ${retiredModelOverrides.join(', ')}`);
|
|
@@ -201,8 +205,8 @@ export async function init({ flags }, cwd = process.cwd()) {
|
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
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 });
|
|
208
|
+
const workflowPath = installWorkflow(root, { lang: cfg.lang, guide: cfg.guide, previousState: priorManaged, force: flags.force });
|
|
209
|
+
const canonicalSkillPath = installCanonicalSkill(root, { lang: cfg.lang, profile: cfg.profile, version: PKG_VERSION, previousState: priorManaged, force: flags.force });
|
|
206
210
|
ok(`vendored guidance → ${workflowPath}; ${canonicalSkillPath}`);
|
|
207
211
|
} else info('guidance stays in the installed package; agents load it with `keelson guide`');
|
|
208
212
|
|
|
@@ -211,18 +215,19 @@ export async function init({ flags }, cwd = process.cwd()) {
|
|
|
211
215
|
const discoveryKey = `${t.instructions}|${t.skillsDir}|${t.rulesFile ?? ''}`;
|
|
212
216
|
if (!discoveryInstalled.has(discoveryKey)) {
|
|
213
217
|
discoveryInstalled.add(discoveryKey);
|
|
214
|
-
const skillPath = installSkill(root, t, { lang: cfg.lang, version: PKG_VERSION, legacyVersion, previousVersion, force: flags.force });
|
|
218
|
+
const skillPath = installSkill(root, t, { lang: cfg.lang, version: PKG_VERSION, legacyVersion, previousVersion, previousState: priorManaged, force: flags.force });
|
|
215
219
|
const files = installInstructions(root, t, { lang: cfg.lang });
|
|
216
220
|
ok(`${t.label}: discovery shim → ${skillPath}; instructions → ${files.join(', ')}${t.confidence === 'convention' ? dim(' (path by convention; run `keelson doctor` after your first session)') : ''}`);
|
|
217
221
|
} else {
|
|
218
222
|
info(`${t.label}: reuses existing discovery surface ${t.instructions} + ${t.skillsDir}/keelson`);
|
|
219
223
|
}
|
|
220
|
-
if (t.hooks) {
|
|
224
|
+
if (t.hooks && t.id === 'claude') {
|
|
221
225
|
installHooks(root);
|
|
222
|
-
ok(`${t.label}: hooks → .claude/settings.json (
|
|
226
|
+
ok(`${t.label}: hooks → .claude/settings.json (workflow restore + file-tool gate + phase context injection)`);
|
|
223
227
|
}
|
|
224
228
|
const sessionFiles = installSessionAdapter(root, t);
|
|
225
229
|
if (sessionFiles.length) ok(`${t.label}: native session adapter → ${sessionFiles.join(', ')}`);
|
|
230
|
+
if (t.sessionAdapter === 'codex-thread-env') info('Codex: review and trust the generated project hooks in /hooks when prompted; host permissions stay in control');
|
|
226
231
|
else if (t.sessionAdapter === 'pi-env') info(`${t.label}: native session focus uses PI_SESSION_ID; no adapter file needed`);
|
|
227
232
|
}
|
|
228
233
|
writeManagedState(root, targets, PKG_VERSION, { vendor: cfg.vendor });
|
package/src/commands/new.js
CHANGED
|
@@ -43,7 +43,7 @@ export async function newChange({ flags, positional }, cwd = process.cwd()) {
|
|
|
43
43
|
const front = [
|
|
44
44
|
`tier: ${tier}`,
|
|
45
45
|
`created: ${vars.date}`,
|
|
46
|
-
|
|
46
|
+
'status: clarifying',
|
|
47
47
|
`owner: ${owner}`,
|
|
48
48
|
...(worktree ? [`branch: ${name}`, `worktree: ${path.relative(root, worktree)}`] : branch ? [`branch: ${branch}`] : []),
|
|
49
49
|
...(list(flags.depends).length ? [`depends: [${list(flags.depends).join(', ')}]`] : []),
|
|
@@ -16,7 +16,7 @@ export async function platforms({ flags }, cwd = process.cwd()) {
|
|
|
16
16
|
const configured = id === 'agents' ? portableConfigured : cfg?.tools?.includes(id) ?? false;
|
|
17
17
|
const sessionFocus = p.sessionFocus ?? 'degraded';
|
|
18
18
|
const effectiveSessionFocus =
|
|
19
|
-
configured && cfg?.hooks === false && p.sessionAdapter &&
|
|
19
|
+
configured && cfg?.hooks === false && p.sessionAdapter && !['pi-env', 'codex-thread-env'].includes(p.sessionAdapter)
|
|
20
20
|
? 'degraded'
|
|
21
21
|
: sessionFocus;
|
|
22
22
|
return { id, label: p.label, support: p.support ?? 'first-class', sessionFocus, effectiveSessionFocus, instructions: p.instructions, skills: p.skillsDir, skillDiscovery: p.skillsDir, rules: p.rulesFile ?? null, hooks: p.hooks, confidence: p.confidence, examples: p.examples ?? null, installed: det[id]?.installed ?? null, configured };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { requireProjectRoot } from '../lib/paths.js';
|
|
3
|
+
import { exists, read, write, writeJson, withLock } from '../lib/fs.js';
|
|
4
|
+
import { runtimeDir } from '../lib/runtime-path.js';
|
|
5
|
+
import { bindSession } from '../lib/session.js';
|
|
6
|
+
import { activeWorkflow, planningBlockers, planFingerprint, phaseContext } from '../lib/workflow.js';
|
|
7
|
+
|
|
8
|
+
export async function start({ positional = [], flags = {} }, cwd = process.cwd()) {
|
|
9
|
+
const root = requireProjectRoot(cwd);
|
|
10
|
+
return withLock(path.join(runtimeDir(root), 'landing'), () => {
|
|
11
|
+
const workflow = activeWorkflow(root, positional[0] ?? flags.change);
|
|
12
|
+
const blockers = planningBlockers(workflow.change, workflow.changes);
|
|
13
|
+
if (blockers.length) throw new Error(`cannot start implementation: ${blockers.join(' ')}`);
|
|
14
|
+
const { change } = workflow;
|
|
15
|
+
const pack = phaseContext(root, workflow); // Validate before changing state.
|
|
16
|
+
const file = path.join(change.dir, 'change.md');
|
|
17
|
+
const text = read(file);
|
|
18
|
+
const updated = /^status:/m.test(text.split('\n---')[0])
|
|
19
|
+
? text.replace(/^status:.*$/m, 'status: in-progress')
|
|
20
|
+
: text.replace(/^---\r?\n/, '---\nstatus: in-progress\n');
|
|
21
|
+
write(file, updated);
|
|
22
|
+
writeJson(path.join(change.dir, 'execution.json'), { schema: 1, plan: planFingerprint(change), startedAt: new Date().toISOString() });
|
|
23
|
+
bindSession(root, change.name, { source: 'start' });
|
|
24
|
+
const contextFile = path.join(change.dir, 'context.json');
|
|
25
|
+
if (!exists(contextFile)) {
|
|
26
|
+
const files = pack.files.map(({ file }) => file.replace(' (including shards)', ''));
|
|
27
|
+
writeJson(contextFile, { schema: 1, implement: files, check: files });
|
|
28
|
+
}
|
|
29
|
+
const result = { change: change.name, state: 'in-progress' };
|
|
30
|
+
console.log(flags.json ? JSON.stringify(result) : `Started ${change.name}; follow \`keelson context --phase implement\` before editing.`);
|
|
31
|
+
return 0;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -14,7 +14,7 @@ export async function uninstall({ flags }, cwd = process.cwd()) {
|
|
|
14
14
|
const removed = removeSurfaces(root, cfg.tools ?? [], cfg);
|
|
15
15
|
for (const r of removed) ok(`removed ${r}`);
|
|
16
16
|
if (managed?.vendor === true) {
|
|
17
|
-
const canonical = removeCanonicalRuntime(root, { lang: cfg.lang, profile: cfg.profile, version: managed.packageVersion, guide: cfg.guide });
|
|
17
|
+
const canonical = removeCanonicalRuntime(root, { lang: cfg.lang, profile: cfg.profile, version: managed.packageVersion, guide: cfg.guide, previousState: managed });
|
|
18
18
|
for (const r of canonical.removed) ok(`removed ${r}`);
|
|
19
19
|
for (const r of canonical.preserved) warn(`kept ${r}: it differs from the vendored Keelson output`);
|
|
20
20
|
}
|
package/src/lib/decisions.js
CHANGED
|
@@ -37,8 +37,10 @@ export function validateDecisionData(data) {
|
|
|
37
37
|
|
|
38
38
|
export function decisionFrontier(data, limit = 3) {
|
|
39
39
|
const ready = data.decisions.filter((d) => d.state === 'open' && d.depends.every((id) => data.decisions.some((x) => x.id === id && x.state === 'settled')));
|
|
40
|
+
const ownerReady = ready.filter((d) => d.owner === 'user');
|
|
40
41
|
return {
|
|
41
|
-
questions:
|
|
42
|
+
questions: ownerReady.slice(0, limit),
|
|
43
|
+
remaining: ownerReady.slice(limit),
|
|
42
44
|
investigate: ready.filter((d) => d.owner !== 'user'),
|
|
43
45
|
blocked: data.decisions.filter((d) => d.state === 'open' && !ready.includes(d)),
|
|
44
46
|
assumptions: data.decisions.filter((d) => d.state === 'assumed'),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { readSession, writeSession } from './session.js';
|
|
3
|
+
import { activeWorkflow, phaseContext, renderPhaseContext, workflowHint } from './workflow.js';
|
|
4
|
+
|
|
5
|
+
/** Match the identity exported to each host's CLI tools, without persisting raw IDs. */
|
|
6
|
+
export function hookEnvironment(host, input, inherited = process.env) {
|
|
7
|
+
const env = { ...inherited };
|
|
8
|
+
// Codex child events identify the child thread with agent_id; session_id
|
|
9
|
+
// identifies the shared root. Match the child's CODEX_THREAD_ID in shell tools.
|
|
10
|
+
const child = host === 'codex' && typeof input.agent_id === 'string' ? input.agent_id.trim() : '';
|
|
11
|
+
const id = child || (typeof input.session_id === 'string' ? input.session_id.trim() : '');
|
|
12
|
+
if (id) {
|
|
13
|
+
const namespace = host === 'codex' ? 'codex_thread_id' : host;
|
|
14
|
+
env.KEELSON_SESSION_ID = crypto.createHash('sha256').update(`${namespace}:${id}`).digest('hex').slice(0, 32);
|
|
15
|
+
}
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sessionContext(root, env, event, host) {
|
|
20
|
+
const session = writeSession(root, {
|
|
21
|
+
source: `${host}-hook`, ...(event === 'SessionStart' ? { injectedContexts: [] } : {}),
|
|
22
|
+
}, env);
|
|
23
|
+
const lines = [workflowHint(root, env)];
|
|
24
|
+
const focus = session?.state.change ?? readSession(root, env).state?.change;
|
|
25
|
+
if (focus) {
|
|
26
|
+
const workflow = activeWorkflow(root, focus, env);
|
|
27
|
+
if (workflow.change) lines.push(renderPhaseContext(phaseContext(root, workflow)));
|
|
28
|
+
}
|
|
29
|
+
return lines.join('\n\n');
|
|
30
|
+
}
|
package/src/lib/markdown.js
CHANGED
|
@@ -200,7 +200,7 @@ export function parseLedger(text) {
|
|
|
200
200
|
const exits = [...s.body.matchAll(/exit\s*(?:code)?\s*[:=]?\s*(\d+)/gi)].map((m) => Number(m[1]));
|
|
201
201
|
entry.exit = exits.length ? Math.max(...exits) : null;
|
|
202
202
|
entry.command = (s.body.match(/`([^`]+)`/) || [])[1] ?? null;
|
|
203
|
-
entry.tree = (s.body.match(/\btree\s*[:=]?\s*([0-9a-f]{7,
|
|
203
|
+
entry.tree = (s.body.match(/\btree\s*[:=]?\s*([0-9a-f]{7,64})\b/i) || [])[1] ?? null;
|
|
204
204
|
}
|
|
205
205
|
if (kind === 'dispatch') {
|
|
206
206
|
const dm = entry.title.match(/(light|standard|deep)/i);
|
package/src/lib/rules.js
CHANGED
|
@@ -12,12 +12,20 @@ export function parseRulesIndex(text) {
|
|
|
12
12
|
return entries;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Declared touches can themselves be globs. Keep every rule whose literal
|
|
16
|
+
// prefix can overlap; extra context is preferable to silently missing a rule.
|
|
17
|
+
function mayOverlap(a, b) {
|
|
18
|
+
const prefix = (s) => s.replace(/^\.\//, '').split(/[*?{]/, 1)[0].replace(/\/$/, '');
|
|
19
|
+
const x = prefix(a), y = prefix(b);
|
|
20
|
+
return x.startsWith(y) || y.startsWith(x);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function matchRules(rulesDir, paths, { patterns = false } = {}) {
|
|
16
24
|
const index = parseRulesIndex(readOr(path.join(rulesDir, 'index.md')));
|
|
17
25
|
const hits = new Map();
|
|
18
26
|
for (const e of index) {
|
|
19
27
|
const always = e.glob === '**' || e.glob === '*';
|
|
20
|
-
const matched = always || paths.some((p) => globMatch(e.glob, p));
|
|
28
|
+
const matched = always || paths.some((p) => globMatch(e.glob, p) || (patterns && /[*?{]/.test(p) && mayOverlap(e.glob, p)));
|
|
21
29
|
if (!matched) continue;
|
|
22
30
|
const f = path.join(rulesDir, e.file);
|
|
23
31
|
if (!hits.has(e.file)) hits.set(e.file, { file: e.file, globs: [], exists: exists(f), content: readOr(f) });
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exists, read, readJson } from './fs.js';
|
|
4
|
+
import { projectPaths, resolveWithin } from './paths.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { loadAllChanges } from './changes.js';
|
|
7
|
+
import { readSession } from './session.js';
|
|
8
|
+
import { decisionFrontier } from './decisions.js';
|
|
9
|
+
import { matchRules } from './rules.js';
|
|
10
|
+
import { readCapabilitySpec } from './specs.js';
|
|
11
|
+
|
|
12
|
+
export function activeWorkflow(root, name, env = process.env) {
|
|
13
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
14
|
+
const paths = projectPaths(root, cfg);
|
|
15
|
+
const changes = loadAllChanges(paths.changes);
|
|
16
|
+
const focused = name ?? readSession(root, env).state?.change;
|
|
17
|
+
// A sole change is a CLI convenience, not permission for an unrelated host
|
|
18
|
+
// session to start writing. Hooks require an explicit session binding.
|
|
19
|
+
const change = changes.find((c) => c.name === (focused ?? (changes.length === 1 ? changes[0].name : null)));
|
|
20
|
+
return { cfg, paths, changes, change };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function planFingerprint(change) {
|
|
24
|
+
return crypto.createHash('sha256').update(JSON.stringify({
|
|
25
|
+
body: change.body.replace(/\[[xX ]\]/g, '[ ]'),
|
|
26
|
+
decisions: change.decisionRecords,
|
|
27
|
+
depends: change.depends, touches: change.touches,
|
|
28
|
+
deltas: change.deltaFiles.map((file) => [file, read(path.join(change.dir, 'specs', file)).replace(/\r\n?/g, '\n')]),
|
|
29
|
+
})).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function planningBlockers(change, changes = []) {
|
|
33
|
+
if (!change) return ['No active change. Create the smallest useful change and run `keelson start <name>`.'];
|
|
34
|
+
const blocked = [];
|
|
35
|
+
if (['blocked', 'cancelled', 'integrated'].includes(change.storedWork)) blocked.push(`Change is ${change.storedWork}.`);
|
|
36
|
+
if (!decisionFrontier({ decisions: change.decisionRecords }, Infinity).complete) blocked.push('Resolve the registered decision tree with `keelson ask frontier --all`.');
|
|
37
|
+
if (change.open.length || change.assumed.length) blocked.push('Resolve the change\'s open questions and unconfirmed assumptions.');
|
|
38
|
+
if (!change.acceptance.length || change.acceptance.some((a) => /^\s*(?:…|<[^>]+>)(?:\s|$)/.test(a.text))) blocked.push('Write concrete acceptance checks in change.md.');
|
|
39
|
+
if (change.depends.some((name) => changes.some((c) => c.name === name))) blocked.push('Finish the prerequisite changes first.');
|
|
40
|
+
return blocked;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function implementationBlockers(change, changes = []) {
|
|
44
|
+
const blockers = planningBlockers(change, changes);
|
|
45
|
+
if (!change) return blockers;
|
|
46
|
+
const receipt = readJson(path.join(change.dir, 'execution.json'), null);
|
|
47
|
+
if (receipt?.schema !== 1 || receipt.plan !== planFingerprint(change)) blockers.push('Run `keelson start` after settling the current plan; its start record is missing or stale.');
|
|
48
|
+
if (change.storedWork !== 'in-progress') blockers.push('Implementation requires the in-progress state set by `keelson start`.');
|
|
49
|
+
return blockers;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Declarative additions augment mandatory context; they cannot omit a contract. */
|
|
53
|
+
export function phaseContext(root, workflow, phase = 'implement', touched = []) {
|
|
54
|
+
if (!['implement', 'check'].includes(phase)) throw new Error('phase must be implement or check');
|
|
55
|
+
const { cfg, paths, change } = workflow;
|
|
56
|
+
const files = new Map();
|
|
57
|
+
const add = (file, required = false) => {
|
|
58
|
+
const rel = path.relative(root, file).replace(/\\/g, '/');
|
|
59
|
+
const safe = resolveWithin(root, rel);
|
|
60
|
+
if (exists(safe)) files.set(rel, read(safe).trim());
|
|
61
|
+
else if (required) throw new Error(`declared context file ${rel} is missing; restore it or correct context.json`);
|
|
62
|
+
};
|
|
63
|
+
add(paths.intent);
|
|
64
|
+
if (change) {
|
|
65
|
+
for (const name of ['request.md', 'change.md', 'decisions.json', 'tasks.md']) add(path.join(change.dir, name));
|
|
66
|
+
for (const file of change.deltaFiles) add(path.join(change.dir, 'specs', file), true);
|
|
67
|
+
for (const cap of change.capabilities) {
|
|
68
|
+
const logical = readCapabilitySpec(paths.specs, cap);
|
|
69
|
+
if (logical) files.set(`${paths.specsRel}/${cap}/spec.md (including shards)`, logical);
|
|
70
|
+
}
|
|
71
|
+
const manifestPath = path.join(change.dir, 'context.json');
|
|
72
|
+
if (exists(manifestPath)) {
|
|
73
|
+
const manifest = JSON.parse(read(manifestPath));
|
|
74
|
+
if (manifest.schema !== 1 || !['implement', 'check'].every((key) => Array.isArray(manifest[key]) && manifest[key].every((v) => typeof v === 'string'))) throw new Error('context.json requires schema 1 and implement/check path arrays');
|
|
75
|
+
for (const rel of manifest[phase]) add(resolveWithin(root, rel), true);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const rule of matchRules(paths.rules, [...(change?.touches ?? []), ...touched], { patterns: true })) add(path.join(paths.rules, rule.file), true);
|
|
79
|
+
const instructions = phase === 'check'
|
|
80
|
+
? 'Review the original request, acceptance, current/delta specs and diff in a fresh context. Find uncovered behavior and risky assumptions. Report findings with evidence; implementation owns repairs, then re-review the affected result. Do not treat the implementer\'s summary as proof.'
|
|
81
|
+
: 'Resolve owner decisions before dependent implementation. Follow the supplied contracts, run relevant checks, and send material changes to an independent reviewer. Continue within existing authorization.';
|
|
82
|
+
return {
|
|
83
|
+
phase, change: change?.name ?? null, instructions,
|
|
84
|
+
checks: cfg.check ?? [],
|
|
85
|
+
files: [...files].map(([file, content]) => ({ file, content })),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderPhaseContext(pack) {
|
|
90
|
+
return [`[keelson] ${pack.phase} context${pack.change ? `: ${pack.change}` : ''}`, pack.instructions,
|
|
91
|
+
`Configured checks (review trust before running): ${JSON.stringify(pack.checks)}`,
|
|
92
|
+
...pack.files.map(({ file, content }) => `\n--- ${file} ---\n${content}`)].join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function workflowHint(root, env = process.env) {
|
|
96
|
+
const focus = readSession(root, env).state?.change;
|
|
97
|
+
if (!focus) return '[keelson] Read `keelson guide` for this request. Investigate facts first; use one question for a simple gap or the whole ready frontier for connected uncertainty. Before modifying files, create/focus a change and run `keelson start`; keep read-only requests read-only.';
|
|
98
|
+
const workflow = activeWorkflow(root, focus, env);
|
|
99
|
+
if (!workflow.change) return '[keelson] Previous focus is no longer active. Re-read context and select work for the current request.';
|
|
100
|
+
const blocked = implementationBlockers(workflow.change, workflow.changes);
|
|
101
|
+
return `[keelson] ${focus}: ${blocked.length ? 'planning — ' + blocked.join(' ') : 'implement — follow the current context, then independent review → fresh checks → land → reconcile.'} Verification is determined by signed current-tree evidence in \`keelson status\`, never by handwritten ledger text.`;
|
|
102
|
+
}
|
|
@@ -3,7 +3,7 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { exists, read, readJson, readOr, rmrf, write, writeJson } from '../lib/fs.js';
|
|
5
5
|
import { installTargets, PLATFORMS } from './registry.js';
|
|
6
|
-
import { managedSkillShimMatches, residentBlock } from './runtime.js';
|
|
6
|
+
import { captureRuntimeOwnership, managedSkillShimMatches, residentBlock } from './runtime.js';
|
|
7
7
|
|
|
8
8
|
export const MANAGED_STATE = path.join('.keelson', 'manifest.json');
|
|
9
9
|
export const LEGACY_MANAGED_STATE = path.join('.keelson', '.managed.json');
|
|
@@ -100,7 +100,7 @@ export function managedStateMatches(root, targets) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export function writeManagedState(root, targets, packageVersion, { vendor = false } = {}) {
|
|
103
|
-
writeJson(path.join(root, MANAGED_STATE), { schema: 1, packageVersion, vendor: Boolean(vendor), targets: targets.map(managedTarget) });
|
|
103
|
+
writeJson(path.join(root, MANAGED_STATE), { schema: 1, packageVersion, vendor: Boolean(vendor), targets: targets.map(managedTarget), runtime: captureRuntimeOwnership(root, targets, vendor) });
|
|
104
104
|
if (exists(path.join(root, LEGACY_MANAGED_STATE))) rmrf(path.join(root, LEGACY_MANAGED_STATE));
|
|
105
105
|
return MANAGED_STATE;
|
|
106
106
|
}
|
|
@@ -173,11 +173,11 @@ const LEGACY_CLAUDE_HOOK_COMMANDS = new Set([
|
|
|
173
173
|
'node "$CLAUDE_PROJECT_DIR/.keelson/hooks/session-start.mjs"',
|
|
174
174
|
'node "$CLAUDE_PROJECT_DIR/.keelson/hooks/prompt-state.mjs"',
|
|
175
175
|
]);
|
|
176
|
-
const CLAUDE_HOOK_COMMANDS = new Set(['keelson hook session-start', 'keelson hook prompt-state', ...LEGACY_CLAUDE_HOOK_COMMANDS]);
|
|
176
|
+
const CLAUDE_HOOK_COMMANDS = new Set(['keelson hook session-start', 'keelson hook prompt-state', 'keelson hook workflow-guard', ...LEGACY_CLAUDE_HOOK_COMMANDS]);
|
|
177
177
|
const isClaudeHook = (hook) => hook?.type === 'command' && CLAUDE_HOOK_COMMANDS.has(String(hook.command));
|
|
178
178
|
|
|
179
179
|
export function installHooks(root) {
|
|
180
|
-
removeHooks(root
|
|
180
|
+
removeHooks(root);
|
|
181
181
|
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
182
182
|
const settings = readJson(settingsPath, {}) ?? {};
|
|
183
183
|
settings.hooks ??= {};
|
|
@@ -192,6 +192,8 @@ export function installHooks(root) {
|
|
|
192
192
|
};
|
|
193
193
|
ensure('SessionStart', 'startup|resume|clear|compact', 'session-start.mjs');
|
|
194
194
|
ensure('UserPromptSubmit', null, 'prompt-state.mjs');
|
|
195
|
+
ensure('PreToolUse', 'Edit|Write|MultiEdit|NotebookEdit|Agent|Task', 'workflow-guard.mjs');
|
|
196
|
+
ensure('SubagentStart', null, 'workflow-guard.mjs');
|
|
195
197
|
writeJson(settingsPath, settings);
|
|
196
198
|
return ['.claude/settings.json'];
|
|
197
199
|
}
|
|
@@ -216,78 +218,41 @@ export function removeHooks(root, { legacyOnly = false } = {}) {
|
|
|
216
218
|
writeJson(settingsPath, settings);
|
|
217
219
|
}
|
|
218
220
|
|
|
219
|
-
const CODEBUDDY_SESSION_COMMAND = 'keelson hook codebuddy-session';
|
|
220
221
|
const LEGACY_CODEBUDDY_SESSION_COMMAND = 'node "$CODEBUDDY_PROJECT_DIR/.keelson/hooks/codebuddy-session.mjs"';
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (p.sessionAdapter === 'codebuddy-hooks') {
|
|
250
|
-
removeCodeBuddyHooks(root, { legacyOnly: true });
|
|
251
|
-
const settingsPath = path.join(root, '.codebuddy', 'settings.json');
|
|
252
|
-
const settings = readJson(settingsPath, {}) ?? {};
|
|
253
|
-
ensureCodeBuddyHook(settings, 'SessionStart');
|
|
254
|
-
ensureCodeBuddyHook(settings, 'UserPromptSubmit');
|
|
255
|
-
ensureCodeBuddyHook(settings, 'PreToolUse', 'Bash|PowerShell');
|
|
256
|
-
writeJson(settingsPath, settings);
|
|
257
|
-
return [path.relative(root, settingsPath)];
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return [];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export function plannedSessionAdapterFiles(root, target) {
|
|
264
|
-
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
265
|
-
const rows = [];
|
|
266
|
-
if (!p?.sessionAdapter || p.sessionAdapter === 'pi-env' || p.sessionAdapter === 'claude-hooks') return rows;
|
|
267
|
-
|
|
268
|
-
if (p.sessionAdapter === 'codebuddy-hooks') {
|
|
269
|
-
const settings = readJson(path.join(root, '.codebuddy', 'settings.json'), {}) ?? {};
|
|
270
|
-
const has = (event, matcher = null) => (settings.hooks?.[event] ?? []).some((g) =>
|
|
271
|
-
(matcher === null || g.matcher === matcher) &&
|
|
272
|
-
(g.hooks ?? []).some(isCodeBuddyHook),
|
|
273
|
-
);
|
|
274
|
-
const ready = has('SessionStart') && has('UserPromptSubmit') && has('PreToolUse', 'Bash|PowerShell');
|
|
275
|
-
rows.push({ path: '.codebuddy/settings.json (Keelson session hooks)', status: ready ? 'unchanged' : exists(path.join(root, '.codebuddy', 'settings.json')) ? 'update' : 'create' });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return rows;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function removeCodeBuddyHooks(root, { legacyOnly = false } = {}) {
|
|
282
|
-
const settingsPath = path.join(root, '.codebuddy', 'settings.json');
|
|
222
|
+
const HOST_HOOKS = {
|
|
223
|
+
'codebuddy-hooks': {
|
|
224
|
+
file: '.codebuddy/settings.json',
|
|
225
|
+
registrations: [
|
|
226
|
+
['SessionStart', null, 'codebuddy-session'],
|
|
227
|
+
['UserPromptSubmit', null, 'codebuddy-session'],
|
|
228
|
+
['PreToolUse', 'Bash|PowerShell', 'codebuddy-session'],
|
|
229
|
+
['PreToolUse', '^(Edit|Write|MultiEdit|NotebookEdit|NotebookWrite|Agent|Task)$', 'codebuddy-workflow'],
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
'codex-thread-env': {
|
|
233
|
+
file: '.codex/hooks.json',
|
|
234
|
+
registrations: [
|
|
235
|
+
['SessionStart', null, 'codex-session'],
|
|
236
|
+
['UserPromptSubmit', null, 'codex-session'],
|
|
237
|
+
['PreToolUse', '^(apply_patch|Edit|Write|Agent|spawn_agent)$', 'codex-workflow'],
|
|
238
|
+
['SubagentStart', null, 'codex-workflow'],
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
const adapterFor = (target) => HOST_HOOKS[target?.sessionAdapter];
|
|
243
|
+
const isAdapterHook = (h, adapter) => h?.type === 'command' && adapter.registrations.some(([, , name]) => h.command === `keelson hook ${name}`);
|
|
244
|
+
const hasRegistration = (settings, [event, matcher, name]) => (settings.hooks?.[event] ?? []).some((g) =>
|
|
245
|
+
(g.matcher ?? null) === matcher && (g.hooks ?? []).some((h) => h.type === 'command' && h.command === `keelson hook ${name}`));
|
|
246
|
+
|
|
247
|
+
function removeAdapterHooks(root, adapter) {
|
|
248
|
+
const settingsPath = path.join(root, adapter.file);
|
|
283
249
|
const settings = readJson(settingsPath, null);
|
|
284
250
|
if (!settings?.hooks) return false;
|
|
285
251
|
let changed = false;
|
|
286
252
|
for (const event of Object.keys(settings.hooks)) {
|
|
287
253
|
settings.hooks[event] = settings.hooks[event].map((g) => {
|
|
288
|
-
const hooks = (g.hooks ?? []).filter((h) =>
|
|
289
|
-
!(h?.type === 'command' && h.command === LEGACY_CODEBUDDY_SESSION_COMMAND)
|
|
290
|
-
);
|
|
254
|
+
const hooks = (g.hooks ?? []).filter((h) => !isAdapterHook(h, adapter) &&
|
|
255
|
+
!(adapter.file.startsWith('.codebuddy/') && h?.type === 'command' && h.command === LEGACY_CODEBUDDY_SESSION_COMMAND));
|
|
291
256
|
if (hooks.length === (g.hooks ?? []).length) return g;
|
|
292
257
|
changed = true;
|
|
293
258
|
return hooks.length ? { ...g, hooks } : null;
|
|
@@ -301,32 +266,49 @@ function removeCodeBuddyHooks(root, { legacyOnly = false } = {}) {
|
|
|
301
266
|
return true;
|
|
302
267
|
}
|
|
303
268
|
|
|
304
|
-
export function
|
|
269
|
+
export function installSessionAdapter(root, target) {
|
|
305
270
|
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
|
|
271
|
+
const adapter = adapterFor(p);
|
|
272
|
+
if (!adapter) return [];
|
|
273
|
+
// Split owned hooks out of mixed groups; never change a user's matcher.
|
|
274
|
+
removeAdapterHooks(root, adapter);
|
|
275
|
+
const settingsPath = path.join(root, adapter.file);
|
|
276
|
+
const settings = readJson(settingsPath, {}) ?? {};
|
|
277
|
+
settings.hooks ??= {};
|
|
278
|
+
for (const [event, matcher, name] of adapter.registrations) {
|
|
279
|
+
settings.hooks[event] ??= [];
|
|
280
|
+
settings.hooks[event].push({
|
|
281
|
+
...(matcher ? { matcher } : {}),
|
|
282
|
+
hooks: [{ type: 'command', command: `keelson hook ${name}`, timeout: 10 }],
|
|
283
|
+
});
|
|
309
284
|
}
|
|
310
|
-
|
|
285
|
+
writeJson(settingsPath, settings);
|
|
286
|
+
return [adapter.file];
|
|
311
287
|
}
|
|
312
288
|
|
|
313
|
-
export function
|
|
289
|
+
export function plannedSessionAdapterFiles(root, target) {
|
|
314
290
|
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
315
|
-
const
|
|
316
|
-
if (!
|
|
291
|
+
const adapter = adapterFor(p);
|
|
292
|
+
if (!adapter) return [];
|
|
293
|
+
const file = path.join(root, adapter.file);
|
|
294
|
+
const settings = readJson(file, {}) ?? {};
|
|
295
|
+
const ready = adapter.registrations.every((registration) => hasRegistration(settings, registration));
|
|
296
|
+
return [{ path: `${adapter.file} (Keelson workflow hooks)`, status: ready ? 'unchanged' : exists(file) ? 'update' : 'create' }];
|
|
297
|
+
}
|
|
317
298
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
);
|
|
324
|
-
if (!has('SessionStart')) problems.push(`${p.label}: SessionStart session hook not registered`);
|
|
325
|
-
if (!has('UserPromptSubmit')) problems.push(`${p.label}: UserPromptSubmit session hook not registered`);
|
|
326
|
-
if (!has('PreToolUse', 'Bash|PowerShell')) problems.push(`${p.label}: Bash|PowerShell PreToolUse session hook not registered`);
|
|
327
|
-
}
|
|
299
|
+
export function removeSessionAdapter(root, target, keep = new Set()) {
|
|
300
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
301
|
+
const adapter = adapterFor(p);
|
|
302
|
+
return adapter && removeAdapterHooks(root, adapter) ? [`${adapter.file} (Keelson hooks)`] : [];
|
|
303
|
+
}
|
|
328
304
|
|
|
329
|
-
|
|
305
|
+
export function sessionAdapterProblems(root, target) {
|
|
306
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
307
|
+
const adapter = adapterFor(p);
|
|
308
|
+
if (!adapter) return [];
|
|
309
|
+
const settings = readJson(path.join(root, adapter.file), {}) ?? {};
|
|
310
|
+
return adapter.registrations.filter((r) => !hasRegistration(settings, r))
|
|
311
|
+
.map(([event, matcher, name]) => `${p.label}: ${event}${matcher ? ` (${matcher})` : ''} hook ${name} not registered`);
|
|
330
312
|
}
|
|
331
313
|
|
|
332
314
|
function removeTargetSurfaces(root, p, keep = new Set()) {
|
|
@@ -334,7 +316,7 @@ function removeTargetSurfaces(root, p, keep = new Set()) {
|
|
|
334
316
|
const skillRel = path.join(p.skillsDir, 'keelson');
|
|
335
317
|
const skill = path.join(root, skillRel);
|
|
336
318
|
const managed = readManagedState(root);
|
|
337
|
-
if (!keep.has(skillRel) && exists(skill) && managedSkillShimMatches(root, p, managed?.packageVersion)) {
|
|
319
|
+
if (!keep.has(skillRel) && exists(skill) && managedSkillShimMatches(root, p, managed?.packageVersion, managed)) {
|
|
338
320
|
rmrf(skill);
|
|
339
321
|
removed.push(path.relative(root, skill));
|
|
340
322
|
}
|
|
@@ -348,7 +330,7 @@ function removeTargetSurfaces(root, p, keep = new Set()) {
|
|
|
348
330
|
rmrf(path.join(root, p.rulesFile));
|
|
349
331
|
removed.push(p.rulesFile);
|
|
350
332
|
}
|
|
351
|
-
if (p.hooks && !keep.has('.claude/settings.json')) removeHooks(root);
|
|
333
|
+
if (p.hooks && p.id === 'claude' && !keep.has('.claude/settings.json')) removeHooks(root);
|
|
352
334
|
removed.push(...removeSessionAdapter(root, p, keep));
|
|
353
335
|
return removed;
|
|
354
336
|
}
|
|
@@ -357,13 +339,13 @@ export function reconcileManagedTargets(root, targets) {
|
|
|
357
339
|
const removed = [];
|
|
358
340
|
const stale = staleManagedTargets(root, targets);
|
|
359
341
|
const keep = new Set(targets.flatMap(managedSurfacePaths));
|
|
360
|
-
if (targets.some((p) => p.hooks)) keep.add('.claude/settings.json');
|
|
342
|
+
if (targets.some((p) => p.hooks && p.id === 'claude')) keep.add('.claude/settings.json');
|
|
361
343
|
for (const p of stale) removed.push(...removeTargetSurfaces(root, p, keep));
|
|
362
344
|
for (const rel of legacyManagedRemovals(root, targets)) {
|
|
363
345
|
rmrf(path.join(root, rel));
|
|
364
346
|
removed.push(rel);
|
|
365
347
|
}
|
|
366
|
-
if (stale.some((p) => p.hooks) && !targets.some((p) => p.hooks)) {
|
|
348
|
+
if (stale.some((p) => p.hooks && p.id === 'claude') && !targets.some((p) => p.hooks && p.id === 'claude')) {
|
|
367
349
|
removeHooks(root);
|
|
368
350
|
}
|
|
369
351
|
return [...new Set(removed)];
|