@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
@@ -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);