@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.
Files changed (48) hide show
  1. package/README.md +15 -12
  2. package/README_CN.md +15 -12
  3. package/hooks/codebuddy-session.mjs +13 -28
  4. package/hooks/codex-session.mjs +16 -0
  5. package/hooks/prompt-state.mjs +5 -29
  6. package/hooks/session-start.mjs +7 -0
  7. package/hooks/workflow-guard.mjs +95 -0
  8. package/package.json +1 -1
  9. package/registry/platforms.json +4 -4
  10. package/registry/runtime-hashes.json +41 -0
  11. package/skills/keelson/SKILL.md +6 -3
  12. package/skills/keelson/references/build.md +12 -0
  13. package/skills/keelson/references/discover.md +5 -3
  14. package/skills/keelson/references/frontend.md +1 -1
  15. package/skills/keelson/references/interview.md +26 -7
  16. package/skills/keelson/references/land.md +2 -0
  17. package/skills/keelson/references/shape.md +3 -3
  18. package/skills/keelson/references/verify.md +1 -1
  19. package/skills/keelson/templates/resident-block.md +1 -1
  20. package/skills/keelson/templates/workflow.md +3 -3
  21. package/skills/zh/keelson/SKILL.md +6 -3
  22. package/skills/zh/keelson/references/build.md +12 -0
  23. package/skills/zh/keelson/references/discover.md +5 -3
  24. package/skills/zh/keelson/references/frontend.md +1 -1
  25. package/skills/zh/keelson/references/interview.md +26 -7
  26. package/skills/zh/keelson/references/land.md +2 -0
  27. package/skills/zh/keelson/references/shape.md +3 -3
  28. package/skills/zh/keelson/references/verify.md +2 -0
  29. package/skills/zh/keelson/templates/resident-block.md +1 -1
  30. package/skills/zh/keelson/templates/workflow.md +3 -3
  31. package/src/cli.js +4 -3
  32. package/src/commands/ablate.js +2 -1
  33. package/src/commands/ask.js +4 -1
  34. package/src/commands/context.js +7 -0
  35. package/src/commands/doctor.js +3 -1
  36. package/src/commands/hook.js +6 -1
  37. package/src/commands/init.js +12 -7
  38. package/src/commands/new.js +1 -1
  39. package/src/commands/platforms.js +1 -1
  40. package/src/commands/start.js +33 -0
  41. package/src/commands/uninstall.js +1 -1
  42. package/src/lib/decisions.js +3 -1
  43. package/src/lib/hook-context.js +30 -0
  44. package/src/lib/markdown.js +1 -1
  45. package/src/lib/rules.js +10 -2
  46. package/src/lib/workflow.js +102 -0
  47. package/src/platforms/integration.js +75 -93
  48. package/src/platforms/runtime.js +68 -17
@@ -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 (session snapshot + per-prompt state line)`);
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 });
@@ -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
- `status: ${tier === 'spec' ? 'clarifying' : 'in-progress'}`,
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 && p.sessionAdapter !== 'pi-env'
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
  }
@@ -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: ready.filter((d) => d.owner === 'user').slice(0, limit),
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
+ }
@@ -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,40})\b/i) || [])[1] ?? null;
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
- export function matchRules(rulesDir, paths) {
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, { legacyOnly: true });
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 isCodeBuddyHook = (hook) => hook?.type === 'command' && hook.command === CODEBUDDY_SESSION_COMMAND;
222
-
223
- function ensureCodeBuddyHook(settings, event, matcher = null) {
224
- settings.hooks ??= {};
225
- settings.hooks[event] ??= [];
226
- const found = settings.hooks[event].find((g) =>
227
- (g.hooks ?? []).some(isCodeBuddyHook),
228
- );
229
- if (found) {
230
- if (matcher && found.matcher !== matcher) found.matcher = matcher;
231
- else if (!matcher && 'matcher' in found) delete found.matcher;
232
- return;
233
- }
234
- const group = {
235
- hooks: [{
236
- type: 'command',
237
- command: CODEBUDDY_SESSION_COMMAND,
238
- timeout: 10,
239
- }],
240
- };
241
- if (matcher) group.matcher = matcher;
242
- settings.hooks[event].push(group);
243
- }
244
-
245
- export function installSessionAdapter(root, target) {
246
- const p = typeof target === 'string' ? PLATFORMS[target] : target;
247
- if (!p?.sessionAdapter || p.sessionAdapter === 'pi-env' || p.sessionAdapter === 'claude-hooks') return [];
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) && (legacyOnly || !isCodeBuddyHook(h)),
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 removeSessionAdapter(root, target, keep = new Set()) {
269
+ export function installSessionAdapter(root, target) {
305
270
  const p = typeof target === 'string' ? PLATFORMS[target] : target;
306
- const removed = [];
307
- if (p?.sessionAdapter === 'codebuddy-hooks') {
308
- if (removeCodeBuddyHooks(root)) removed.push('.codebuddy/settings.json (Keelson hooks)');
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
- return removed;
285
+ writeJson(settingsPath, settings);
286
+ return [adapter.file];
311
287
  }
312
288
 
313
- export function sessionAdapterProblems(root, target) {
289
+ export function plannedSessionAdapterFiles(root, target) {
314
290
  const p = typeof target === 'string' ? PLATFORMS[target] : target;
315
- const problems = [];
316
- if (!p?.sessionAdapter || p.sessionAdapter === 'pi-env' || p.sessionAdapter === 'claude-hooks') return problems;
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
- if (p.sessionAdapter === 'codebuddy-hooks') {
319
- const settings = readJson(path.join(root, '.codebuddy', 'settings.json'), {}) ?? {};
320
- const has = (event, matcher = null) => (settings.hooks?.[event] ?? []).some((g) =>
321
- (matcher === null || g.matcher === matcher) &&
322
- (g.hooks ?? []).some(isCodeBuddyHook),
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
- return problems;
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)];