@zyaiting/keelson 0.4.0 → 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 +48 -30
  2. package/README_CN.md +48 -30
  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
package/src/cli.js CHANGED
@@ -5,7 +5,7 @@ const require = createRequire(import.meta.url);
5
5
  const { version } = require('../package.json');
6
6
 
7
7
  const COMMANDS = {
8
- ask: ['ask <add|frontier|list|settle|assume|reject|reopen> [id] [--change name] [--json]', 'Persist decisions and show up to three ready owner questions', () => import('./commands/ask.js').then((m) => m.ask)],
8
+ ask: ['ask <add|frontier|list|settle|assume|reject|reopen> [id] [--change name] [--all|--limit n] [--json]', 'Persist decisions and show ready owner questions; --all returns the whole frontier', () => import('./commands/ask.js').then((m) => m.ask)],
9
9
  design: ['design [action] [target] [--lang en|zh] [--json]', 'Prepare focused frontend design guidance for your agent', () => import('./commands/design.js').then((m) => m.design)],
10
10
  guide: ['guide [reference] [--list] [--json] [--lang en|zh]', 'Read the installed workflow or one reference on demand', () => import('./commands/guide.js').then((m) => m.guide)],
11
11
  hook: ['hook <event>', 'Run an installed host adapter', () => import('./commands/hook.js').then((m) => m.hook)],
@@ -13,10 +13,11 @@ const COMMANDS = {
13
13
  init: ['init [--<platform> ...] [--tools a,b] [--guide] [--profile lean|guided] [--lang en|zh] [--no-hooks] [--vendor] [--dry-run]', 'Set up the minimal .keelson/ control plane and host discovery. Project artifacts grow only when the work needs them', () => import('./commands/init.js').then((m) => m.init)],
14
14
  platforms: ['platforms [--json]', 'List supported coding tools, their file locations, and which are installed or configured', () => import('./commands/platforms.js').then((m) => m.platforms)],
15
15
  update: ['update [--vendor] [--dry-run]', 'Refresh owned host shims and configuration; --vendor opts into copied guidance', () => import('./commands/init.js').then((m) => m.init)],
16
- context: ['context [--paths a/,b/**] [--json]', 'Print INTENT, ROADMAP, NOW, active changes, existing references, and the rules matching the given paths', () => import('./commands/context.js').then((m) => m.context)],
16
+ context: ['context [--paths a/,b/**] [--change name] [--phase implement|check] [--json]', 'Print INTENT, ROADMAP, NOW, active changes, existing references, and the rules matching the given paths', () => import('./commands/context.js').then((m) => m.context)],
17
17
  impact: ['impact <file> [file...] [--json]', 'Mechanical impact hints: importers, specs and rules that may be affected, active changes that overlap', () => import('./commands/impact.js').then((m) => m.impact)],
18
18
  focus: ['focus [change] [--auto|--clear] [--json]', 'Bind this AI session to one active change without changing the change lifecycle', () => import('./commands/focus.js').then((m) => m.focus)],
19
19
  new: ['new <name> [--tier quick|spec] [--capability a,b] [--touches globs] [--depends change] [--worktree]', 'Scaffold a change directory (owner, branch, delta base recorded)', () => import('./commands/new.js').then((m) => m.newChange)],
20
+ start: ['start [change] [--json]', 'Enter implementation after the decision and plan gates pass; run by the agent', () => import('./commands/start.js').then((m) => m.start)],
20
21
  status: ['status [--json]', 'Work, verification, and release status per change; slices, open questions, conflicts, handoffs', () => import('./commands/status.js').then((m) => m.status)],
21
22
  handoff: ['handoff [name] [--by who]', 'Create or re-stamp handoff.md for a change (at, updated, by)', () => import('./commands/handoff.js').then((m) => m.handoff)],
22
23
  validate: ['validate [--json]', 'Check .keelson/ structure, specs, changes, ledgers; non-zero on errors', () => import('./commands/validate.js').then((m) => m.validate)],
@@ -33,7 +34,7 @@ const COMMANDS = {
33
34
 
34
35
  const COMMAND_GROUPS = [
35
36
  ['Your commands', ['init', 'design', 'status', 'doctor', 'update', 'platforms', 'uninstall']],
36
- ['Agent workflow', ['ask', 'context', 'impact', 'focus', 'new', 'check', 'handoff', 'validate', 'land', 'cancel']],
37
+ ['Agent workflow', ['ask', 'context', 'impact', 'focus', 'new', 'start', 'check', 'handoff', 'validate', 'land', 'cancel']],
37
38
  ['Maintenance / advanced', ['guide', 'attest', 'retro', 'models', 'ablate', 'restore']],
38
39
  ];
39
40
 
@@ -29,8 +29,9 @@ export async function ablate({ flags }, cwd = process.cwd()) {
29
29
  for (const pl of surfaceTargets) {
30
30
  surfaces.push(pl.instructions, path.join(pl.skillsDir, 'keelson'));
31
31
  if (pl.rulesFile) surfaces.push(pl.rulesFile);
32
- if (pl.hooks) surfaces.push('.claude/settings.json');
32
+ if (pl.hooks && pl.id === 'claude') surfaces.push('.claude/settings.json');
33
33
  if (pl.sessionAdapter === 'opencode-plugin') surfaces.push('.opencode/plugins/keelson-session.js');
34
+ if (pl.sessionAdapter === 'codex-thread-env') surfaces.push('.codex/hooks.json');
34
35
  if (pl.sessionAdapter === 'codebuddy-hooks') surfaces.push('.codebuddy/settings.json');
35
36
  }
36
37
  // Canonical runtime, hook scripts, session runtime and project facts are
@@ -18,6 +18,8 @@ function askUnlocked({ positional, flags }, cwd) {
18
18
  const change = changes.find((c) => c.name === name);
19
19
  if (!change) throw new Error('choose an active change with --change <name>');
20
20
  const [action = 'frontier', id] = positional;
21
+ const limit = flags.all ? Infinity : flags.limit === undefined ? 3 : Number(flags.limit);
22
+ if (limit !== Infinity && (!Number.isInteger(limit) || limit < 1)) throw new Error('--limit must be a positive integer; use --all for the whole ready frontier');
21
23
  let data = readDecisions(change.dir);
22
24
  const text = (value, label) => {
23
25
  if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} is required`);
@@ -52,11 +54,12 @@ function askUnlocked({ positional, flags }, cwd) {
52
54
  }
53
55
  });
54
56
  } else if (!['frontier', 'list'].includes(action)) throw new Error('usage: keelson ask <add|frontier|list|settle|assume|reject|reopen> [id] --change <name>');
55
- const result = action === 'list' ? data : decisionFrontier(data);
57
+ const result = action === 'list' ? data : decisionFrontier(data, limit);
56
58
  if (flags.json) console.log(JSON.stringify(result, null, 2));
57
59
  else if (action === 'list') for (const d of data.decisions) console.log(`${d.id} [${d.owner}/${d.state}] ${d.question}${d.answer ? ` → ${d.answer}` : ''}`);
58
60
  else {
59
61
  for (const d of result.questions) console.log(`${d.id}. ${d.question}${d.recommended ? ` (recommended: ${d.recommended})` : ''}`);
62
+ if (result.remaining.length) console.log(`${result.remaining.length} more ready owner decision(s); use --all for a complex interview round.`);
60
63
  for (const d of result.investigate) console.log(`Investigate ${d.id} (${d.owner}): ${d.question}`);
61
64
  if (!result.questions.length) console.log(result.complete ? 'Decision frontier complete.' : 'No owner question is ready; resolve investigations, dependencies or assumptions.');
62
65
  }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { activeWorkflow, phaseContext, renderPhaseContext } from '../lib/workflow.js';
2
3
  import { requireProjectRoot, projectPaths } from '../lib/paths.js';
3
4
  import { readOr, listDirs, exists } from '../lib/fs.js';
4
5
  import { loadConfig } from '../lib/config.js';
@@ -14,6 +15,12 @@ import { changeSpecDrift } from '../lib/specs.js';
14
15
 
15
16
  export async function context({ flags, positional }, cwd = process.cwd()) {
16
17
  const root = requireProjectRoot(cwd);
18
+ if (flags.phase) {
19
+ const workflow = activeWorkflow(root, flags.change);
20
+ const pack = phaseContext(root, workflow, flags.phase, [...list(flags.paths), ...positional]);
21
+ console.log(flags.json ? JSON.stringify(pack, null, 2) : renderPhaseContext(pack));
22
+ return 0;
23
+ }
17
24
  maintainRuntime(root);
18
25
  const cfg = loadConfig(projectPaths(root).config);
19
26
  const p = projectPaths(root, cfg);
@@ -85,12 +85,14 @@ export async function doctor({ flags }, cwd = process.cwd()) {
85
85
  }
86
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
87
  for (const problem of sessionAdapterProblems(root, pl)) add('error', `${problem} (run \`keelson update\`)`);
88
- if (pl.hooks) {
88
+ if (pl.hooks && pl.id === 'claude') {
89
89
  const settings = readJson(path.join(root, '.claude', 'settings.json'), {}) ?? {};
90
90
  const has = (ev, script) => (settings.hooks?.[ev] ?? []).some((g) => (g.hooks ?? []).some((h) => String(h.command ?? '').includes(script)));
91
91
  const registrations = [
92
92
  ['SessionStart', 'session-start'],
93
93
  ['UserPromptSubmit', 'prompt-state'],
94
+ ['PreToolUse', 'workflow-guard'],
95
+ ['SubagentStart', 'workflow-guard'],
94
96
  ];
95
97
  for (const [event, script] of registrations) {
96
98
  const registered = has(event, script);
@@ -5,6 +5,10 @@ import { PKG_ROOT } from '../lib/paths.js';
5
5
  const HOOKS = new Map([
6
6
  ['session-start', 'session-start.mjs'],
7
7
  ['prompt-state', 'prompt-state.mjs'],
8
+ ['workflow-guard', 'workflow-guard.mjs'],
9
+ ['codex-workflow', 'workflow-guard.mjs'],
10
+ ['codebuddy-workflow', 'workflow-guard.mjs'],
11
+ ['codex-session', 'codex-session.mjs'],
8
12
  ['codebuddy-session', 'codebuddy-session.mjs'],
9
13
  ]);
10
14
 
@@ -13,7 +17,8 @@ export async function hook({ positional = [] }, cwd = process.cwd()) {
13
17
  const name = positional[0];
14
18
  const file = HOOKS.get(name);
15
19
  if (!file) throw new Error(`unknown hook "${name}". Supported hooks: ${[...HOOKS.keys()].join(', ')}`);
16
- const child = spawnSync(process.execPath, [path.join(PKG_ROOT, 'hooks', file)], {
20
+ const hostArgs = name === 'codex-workflow' ? ['codex'] : name === 'codebuddy-workflow' ? ['codebuddy'] : [];
21
+ const child = spawnSync(process.execPath, [path.join(PKG_ROOT, 'hooks', file), ...hostArgs], {
17
22
  cwd,
18
23
  env: process.env,
19
24
  stdio: 'inherit',
@@ -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
+ }