@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
@@ -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)];
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import crypto from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  import { PKG_ROOT } from '../lib/paths.js';
5
- import { exists, read, walk, write, rmrf, replaceDirSafe, withLock } from '../lib/fs.js';
5
+ import { exists, read, readJson, walk, write, rmrf, replaceDirSafe, withLock } from '../lib/fs.js';
6
6
  import { PLATFORMS } from './registry.js';
7
7
 
8
8
  export const CANONICAL_SKILL_DIR = path.join('.keelson', 'skill');
@@ -124,6 +124,42 @@ function treeHash(dir) {
124
124
  } catch { return null; }
125
125
  }
126
126
 
127
+ // Before 0.5.0, manifests carried a version but no content digest. Bootstrap
128
+ // only from exact published output; future releases use the installed digest.
129
+ const HISTORICAL_RUNTIME = readJson(path.join(PKG_ROOT, 'registry', 'runtime-hashes.json'), {}).versions ?? {};
130
+
131
+ function runtimeDigest(root, rel, kind) {
132
+ const target = path.join(root, rel);
133
+ if (kind !== 'workflows') return treeHash(target);
134
+ try { return fs.lstatSync(target).isFile() ? fileHash(read(target)) : null; } catch { return null; }
135
+ }
136
+
137
+ function previousRuntimeMatches(root, rel, kind, previousState) {
138
+ if (!previousState) return false;
139
+ const owned = kind === 'shims'
140
+ ? previousState.targets?.some((t) => t.skillsDir && path.join(t.skillsDir, 'keelson') === rel)
141
+ : previousState.vendor === true;
142
+ if (!owned) return false;
143
+ const actual = runtimeDigest(root, rel, kind);
144
+ if (!actual) return false;
145
+ const recorded = previousState.runtime?.[rel];
146
+ if (recorded) return actual === recorded;
147
+ return (HISTORICAL_RUNTIME[previousState.packageVersion]?.[kind] ?? []).includes(actual);
148
+ }
149
+
150
+ export function captureRuntimeOwnership(root, targets, vendor) {
151
+ const runtime = {};
152
+ for (const target of targets) {
153
+ const rel = path.join(target.skillsDir, 'keelson');
154
+ runtime[rel] = runtimeDigest(root, rel, 'shims');
155
+ }
156
+ if (vendor) {
157
+ runtime[CANONICAL_SKILL_DIR] = runtimeDigest(root, CANONICAL_SKILL_DIR, 'skills');
158
+ runtime[CANONICAL_WORKFLOW] = runtimeDigest(root, CANONICAL_WORKFLOW, 'workflows');
159
+ }
160
+ return runtime;
161
+ }
162
+
127
163
  function canonicalSkillMatches(root, { lang, profile, version }) {
128
164
  const dest = path.join(root, CANONICAL_SKILL_DIR);
129
165
  const files = renderSkillFiles(lang, profile, version);
@@ -132,12 +168,19 @@ function canonicalSkillMatches(root, { lang, profile, version }) {
132
168
  return version === '0.3.0' && treeHash(dest) === LEGACY_V03_SKILL_TREE_HASHES.get(`${lang}/${profile}`);
133
169
  }
134
170
 
135
- export function installCanonicalSkill(root, { lang, profile, version, force = false }) {
171
+ export function assertCanonicalSkillInstallable(root, { lang, profile, version, previousState, force = false }) {
136
172
  const dest = path.join(root, CANONICAL_SKILL_DIR);
137
- const files = renderSkillFiles(lang, profile, version);
138
- if (fs.lstatSync(dest, { throwIfNoEntry: false }) && !canonicalSkillMatches(root, { lang, profile, version }) && !force) {
173
+ if (fs.lstatSync(dest, { throwIfNoEntry: false }) && !canonicalSkillMatches(root, { lang, profile, version }) &&
174
+ !previousRuntimeMatches(root, CANONICAL_SKILL_DIR, 'skills', previousState) &&
175
+ !(previousState?.packageVersion === '0.3.0' && canonicalSkillMatches(root, { lang, profile, version: '0.3.0' })) && !force) {
139
176
  throw new Error('vendored skill differs from this CLI output; Keelson left it unchanged. Review it, then pass --force only if replacing the whole directory is intended.');
140
177
  }
178
+ }
179
+
180
+ export function installCanonicalSkill(root, options) {
181
+ assertCanonicalSkillInstallable(root, options);
182
+ const dest = path.join(root, CANONICAL_SKILL_DIR);
183
+ const files = renderSkillFiles(options.lang, options.profile, options.version);
141
184
  withLock(dest, () => {
142
185
  replaceDirSafe(dest, (tmp) => {
143
186
  for (const f of files) write(path.join(tmp, f.rel), f.content);
@@ -157,10 +200,10 @@ function skillShimMatches(dest, content, legacyVersion) {
157
200
  (normalize(read(skill)) === content || legacyV03SkillShimMatches(dest, legacyVersion));
158
201
  }
159
202
 
160
- function assertSkillInstallable(root, target, { lang, version, legacyVersion, previousVersion, force }) {
203
+ function assertSkillInstallable(root, target, { lang, version, legacyVersion, previousVersion, previousState, force }) {
161
204
  const p = typeof target === 'string' ? PLATFORMS[target] : target;
162
205
  const dest = path.join(root, p.skillsDir, 'keelson');
163
- const priorOutput = previousVersion && managedSkillShimMatches(root, p, previousVersion);
206
+ const priorOutput = previousVersion && managedSkillShimMatches(root, p, previousVersion, previousState);
164
207
  if (fs.lstatSync(dest, { throwIfNoEntry: false }) && !skillShimMatches(dest, renderSkillShim(lang, version), legacyVersion) && !priorOutput && !force) {
165
208
  throw new Error('discovery shim differs from this CLI output; Keelson left it unchanged. Review it, then pass --force only if replacing the whole directory is intended.');
166
209
  }
@@ -177,18 +220,19 @@ export function assertSkillsInstallable(root, targets, options) {
177
220
  }
178
221
  }
179
222
 
180
- /** True only for a complete current shim or the byte-exact v0.3 shim. */
181
- export function managedSkillShimMatches(root, target, version) {
223
+ /** True only for a complete generated shim or its recorded/published digest. */
224
+ export function managedSkillShimMatches(root, target, version, previousState) {
182
225
  const p = typeof target === 'string' ? PLATFORMS[target] : target;
183
226
  const dest = path.join(root, p.skillsDir, 'keelson');
184
- return ['en', 'zh'].some((lang) => skillShimMatches(dest, renderSkillShim(lang, version), version));
227
+ return previousRuntimeMatches(root, path.join(p.skillsDir, 'keelson'), 'shims', previousState) ||
228
+ ['en', 'zh'].some((lang) => skillShimMatches(dest, renderSkillShim(lang, version), version));
185
229
  }
186
230
 
187
- export function installSkill(root, target, { lang, version, legacyVersion = null, previousVersion = null, force = false }) {
231
+ export function installSkill(root, target, { lang, version, legacyVersion = null, previousVersion = null, previousState, force = false }) {
188
232
  const p = typeof target === 'string' ? PLATFORMS[target] : target;
189
233
  const dest = path.join(root, p.skillsDir, 'keelson');
190
234
  const content = renderSkillShim(lang, version);
191
- assertSkillInstallable(root, p, { lang, version, legacyVersion, previousVersion, force });
235
+ assertSkillInstallable(root, p, { lang, version, legacyVersion, previousVersion, previousState, force });
192
236
  withLock(dest, () => replaceDirSafe(dest, (tmp) => write(path.join(tmp, 'SKILL.md'), content)));
193
237
  return path.relative(root, dest);
194
238
  }
@@ -210,14 +254,21 @@ export function plannedWorkflowFile(root, { lang, guide = false }) {
210
254
  return { path: path.relative(root, target), status };
211
255
  }
212
256
 
213
- export function installWorkflow(root, { lang, guide = false, force = false }) {
257
+ export function assertWorkflowInstallable(root, { lang, guide = false, previousState, force = false }) {
214
258
  const target = path.join(root, CANONICAL_WORKFLOW);
215
259
  const content = workflowContent(lang, guide);
216
260
  const stat = fs.lstatSync(target, { throwIfNoEntry: false });
217
- if (stat && (stat.isSymbolicLink() || normalize(read(target)) !== content) && !force) {
261
+ if (stat && (!stat.isFile() || normalize(read(target)) !== content) &&
262
+ !previousRuntimeMatches(root, CANONICAL_WORKFLOW, 'workflows', previousState) &&
263
+ !(previousState?.packageVersion === '0.3.0' && stat.isFile() && LEGACY_V03_WORKFLOW_HASHES.has(fileHash(read(target)))) && !force) {
218
264
  throw new Error('vendored workflow differs from this CLI output; Keelson left it unchanged. Review it, then pass --force only if replacing it is intended.');
219
265
  }
220
- write(target, content);
266
+ }
267
+
268
+ export function installWorkflow(root, options) {
269
+ assertWorkflowInstallable(root, options);
270
+ const target = path.join(root, CANONICAL_WORKFLOW);
271
+ write(target, workflowContent(options.lang, options.guide));
221
272
  return path.relative(root, target);
222
273
  }
223
274
 
@@ -225,12 +276,12 @@ export function residentBlock(lang) {
225
276
  return read(path.join(skillSource(lang), 'templates', 'resident-block.md'));
226
277
  }
227
278
 
228
- export function removeCanonicalRuntime(root, { lang, profile, version, guide = false } = {}) {
279
+ export function removeCanonicalRuntime(root, { lang, profile, version, guide = false, previousState } = {}) {
229
280
  const removed = [];
230
281
  const preserved = [];
231
282
  const skill = path.join(root, CANONICAL_SKILL_DIR);
232
283
  if (exists(skill)) {
233
- if (canonicalSkillMatches(root, { lang, profile, version })) {
284
+ if (canonicalSkillMatches(root, { lang, profile, version }) || previousRuntimeMatches(root, CANONICAL_SKILL_DIR, 'skills', previousState)) {
234
285
  rmrf(skill);
235
286
  removed.push(CANONICAL_SKILL_DIR);
236
287
  } else preserved.push(CANONICAL_SKILL_DIR);
@@ -240,7 +291,7 @@ export function removeCanonicalRuntime(root, { lang, profile, version, guide = f
240
291
  preserved.push(CANONICAL_WORKFLOW);
241
292
  } else if (exists(workflow)) {
242
293
  const legacyWorkflow = version === '0.3.0' && LEGACY_V03_WORKFLOW_HASHES.has(fileHash(read(workflow)));
243
- if (normalize(read(workflow)) === workflowContent(lang, guide) || legacyWorkflow) {
294
+ if (normalize(read(workflow)) === workflowContent(lang, guide) || legacyWorkflow || previousRuntimeMatches(root, CANONICAL_WORKFLOW, 'workflows', previousState)) {
244
295
  rmrf(workflow);
245
296
  removed.push(CANONICAL_WORKFLOW);
246
297
  } else preserved.push(CANONICAL_WORKFLOW);