@zyaiting/keelson 0.4.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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -0
  3. package/README_CN.md +101 -0
  4. package/bin/keelson.js +15 -0
  5. package/hooks/codebuddy-session.mjs +67 -0
  6. package/hooks/opencode-session.mjs +65 -0
  7. package/hooks/prompt-state.mjs +66 -0
  8. package/hooks/session-start.mjs +94 -0
  9. package/package.json +64 -0
  10. package/registry/models.json +118 -0
  11. package/registry/platforms.json +92 -0
  12. package/skills/keelson/SKILL.md +44 -0
  13. package/skills/keelson/references/build.md +61 -0
  14. package/skills/keelson/references/context.md +34 -0
  15. package/skills/keelson/references/debug.md +46 -0
  16. package/skills/keelson/references/design-lenses.md +78 -0
  17. package/skills/keelson/references/discover.md +70 -0
  18. package/skills/keelson/references/engineer.md +110 -0
  19. package/skills/keelson/references/frontend-delivery.md +38 -0
  20. package/skills/keelson/references/frontend-interaction.md +31 -0
  21. package/skills/keelson/references/frontend-review.md +33 -0
  22. package/skills/keelson/references/frontend-visual.md +31 -0
  23. package/skills/keelson/references/frontend.md +33 -0
  24. package/skills/keelson/references/handoff.md +43 -0
  25. package/skills/keelson/references/harness.md +54 -0
  26. package/skills/keelson/references/interview.md +120 -0
  27. package/skills/keelson/references/land.md +47 -0
  28. package/skills/keelson/references/model.md +29 -0
  29. package/skills/keelson/references/plan.md +106 -0
  30. package/skills/keelson/references/reconcile.md +61 -0
  31. package/skills/keelson/references/shape.md +86 -0
  32. package/skills/keelson/references/verify.md +64 -0
  33. package/skills/keelson/templates/GLOSSARY.md +5 -0
  34. package/skills/keelson/templates/INTENT.md +22 -0
  35. package/skills/keelson/templates/NOW.md +9 -0
  36. package/skills/keelson/templates/README.md +60 -0
  37. package/skills/keelson/templates/ROADMAP.md +12 -0
  38. package/skills/keelson/templates/change-quick.md +16 -0
  39. package/skills/keelson/templates/change.md +32 -0
  40. package/skills/keelson/templates/delta-spec.md +12 -0
  41. package/skills/keelson/templates/handoff.md +27 -0
  42. package/skills/keelson/templates/ledger.md +3 -0
  43. package/skills/keelson/templates/resident-block.md +7 -0
  44. package/skills/keelson/templates/rules-general.md +10 -0
  45. package/skills/keelson/templates/rules-index.md +5 -0
  46. package/skills/keelson/templates/spec.md +14 -0
  47. package/skills/keelson/templates/tasks.md +9 -0
  48. package/skills/keelson/templates/workflow.md +18 -0
  49. package/skills/zh/keelson/SKILL.md +46 -0
  50. package/skills/zh/keelson/references/build.md +61 -0
  51. package/skills/zh/keelson/references/context.md +34 -0
  52. package/skills/zh/keelson/references/debug.md +46 -0
  53. package/skills/zh/keelson/references/design-lenses.md +78 -0
  54. package/skills/zh/keelson/references/discover.md +70 -0
  55. package/skills/zh/keelson/references/engineer.md +110 -0
  56. package/skills/zh/keelson/references/frontend-delivery.md +38 -0
  57. package/skills/zh/keelson/references/frontend-interaction.md +31 -0
  58. package/skills/zh/keelson/references/frontend-review.md +33 -0
  59. package/skills/zh/keelson/references/frontend-visual.md +31 -0
  60. package/skills/zh/keelson/references/frontend.md +33 -0
  61. package/skills/zh/keelson/references/handoff.md +43 -0
  62. package/skills/zh/keelson/references/harness.md +54 -0
  63. package/skills/zh/keelson/references/interview.md +120 -0
  64. package/skills/zh/keelson/references/land.md +47 -0
  65. package/skills/zh/keelson/references/model.md +29 -0
  66. package/skills/zh/keelson/references/plan.md +106 -0
  67. package/skills/zh/keelson/references/reconcile.md +61 -0
  68. package/skills/zh/keelson/references/shape.md +86 -0
  69. package/skills/zh/keelson/references/verify.md +64 -0
  70. package/skills/zh/keelson/templates/GLOSSARY.md +5 -0
  71. package/skills/zh/keelson/templates/INTENT.md +22 -0
  72. package/skills/zh/keelson/templates/NOW.md +9 -0
  73. package/skills/zh/keelson/templates/README.md +60 -0
  74. package/skills/zh/keelson/templates/ROADMAP.md +12 -0
  75. package/skills/zh/keelson/templates/change-quick.md +16 -0
  76. package/skills/zh/keelson/templates/change.md +32 -0
  77. package/skills/zh/keelson/templates/delta-spec.md +12 -0
  78. package/skills/zh/keelson/templates/handoff.md +27 -0
  79. package/skills/zh/keelson/templates/ledger.md +3 -0
  80. package/skills/zh/keelson/templates/resident-block.md +7 -0
  81. package/skills/zh/keelson/templates/rules-general.md +10 -0
  82. package/skills/zh/keelson/templates/rules-index.md +5 -0
  83. package/skills/zh/keelson/templates/spec.md +14 -0
  84. package/skills/zh/keelson/templates/tasks.md +9 -0
  85. package/skills/zh/keelson/templates/workflow.md +18 -0
  86. package/src/cli.js +87 -0
  87. package/src/commands/ablate.js +96 -0
  88. package/src/commands/ask.js +64 -0
  89. package/src/commands/attest.js +71 -0
  90. package/src/commands/check.js +127 -0
  91. package/src/commands/context.js +95 -0
  92. package/src/commands/design.js +63 -0
  93. package/src/commands/doctor.js +157 -0
  94. package/src/commands/focus.js +84 -0
  95. package/src/commands/guide.js +59 -0
  96. package/src/commands/handoff.js +41 -0
  97. package/src/commands/hook.js +23 -0
  98. package/src/commands/impact.js +58 -0
  99. package/src/commands/init.js +289 -0
  100. package/src/commands/land.js +258 -0
  101. package/src/commands/models.js +62 -0
  102. package/src/commands/new.js +70 -0
  103. package/src/commands/platforms.js +39 -0
  104. package/src/commands/retro.js +114 -0
  105. package/src/commands/status.js +115 -0
  106. package/src/commands/uninstall.js +30 -0
  107. package/src/commands/validate.js +117 -0
  108. package/src/lib/args.js +30 -0
  109. package/src/lib/changes.js +114 -0
  110. package/src/lib/check-activity.js +29 -0
  111. package/src/lib/config.js +102 -0
  112. package/src/lib/decisions.js +59 -0
  113. package/src/lib/evidence.js +127 -0
  114. package/src/lib/fs.js +126 -0
  115. package/src/lib/git.js +353 -0
  116. package/src/lib/glob.js +54 -0
  117. package/src/lib/health.js +113 -0
  118. package/src/lib/lifecycle.js +120 -0
  119. package/src/lib/maintenance.js +66 -0
  120. package/src/lib/markdown.js +438 -0
  121. package/src/lib/models.js +195 -0
  122. package/src/lib/out.js +13 -0
  123. package/src/lib/paths.js +82 -0
  124. package/src/lib/rules.js +27 -0
  125. package/src/lib/runtime-path.js +22 -0
  126. package/src/lib/session.js +100 -0
  127. package/src/lib/specs.js +345 -0
  128. package/src/lib/transaction.js +93 -0
  129. package/src/platforms/index.js +3 -0
  130. package/src/platforms/integration.js +384 -0
  131. package/src/platforms/registry.js +46 -0
  132. package/src/platforms/runtime.js +249 -0
@@ -0,0 +1,258 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { requireProjectRoot, projectPaths, resolveWithin } from '../lib/paths.js';
4
+ import { exists, read, write, rmrf, mkdirp, readOr, withLock } from '../lib/fs.js';
5
+ import { runtimeDir } from '../lib/runtime-path.js';
6
+ import { recordStatement } from '../lib/evidence.js';
7
+ import { activeChecks } from '../lib/check-activity.js';
8
+ import { recoverLanding, landingTransaction } from '../lib/transaction.js';
9
+ import { loadConfig } from '../lib/config.js';
10
+ import { loadChange, loadAllChanges, sharedContracts } from '../lib/changes.js';
11
+ import { evaluateLifecycle } from '../lib/lifecycle.js';
12
+ import { parseSpec, parseDelta, renderSpec, parseFrontmatter } from '../lib/markdown.js';
13
+ import { worktreeFingerprint } from '../lib/git.js';
14
+ import { ok, warn, info, heading } from '../lib/out.js';
15
+ import { clearChangeBindings, readSession } from '../lib/session.js';
16
+ import { budgetStatus } from '../lib/health.js';
17
+ import { readCapabilitySpec, planCapabilityStorage, writeCapabilityStorage, capabilityStorageOptions, changeSpecDrift } from '../lib/specs.js';
18
+
19
+ export function mergeDelta(mainText, deltaText, capability) {
20
+ const main = mainText ? parseSpec(mainText) : { purpose: '', requirements: [], decisions: [] };
21
+ const d = parseDelta(parseFrontmatter(deltaText).body);
22
+ if (d.issues?.length) throw new Error(`malformed delta: ${d.issues.join('; ')}`);
23
+ const names = [...d.added, ...d.modified, ...d.removed].map((r) => r.name.normalize('NFC').toLowerCase());
24
+ if (new Set(names).size !== names.length) throw new Error('malformed delta: duplicate requirement');
25
+ if ([...d.added, ...d.modified].some((r) => !r.body.trim())) throw new Error('malformed delta: empty requirement body');
26
+ const reqs = [...main.requirements];
27
+ const report = { added: [], modified: [], removed: [], missing: [] };
28
+ for (const r of d.removed) {
29
+ const i = reqs.findIndex((x) => x.name.toLowerCase() === r.name.toLowerCase());
30
+ if (i === -1) report.missing.push(`REMOVED "${r.name}" not found`);
31
+ else {
32
+ reqs.splice(i, 1);
33
+ report.removed.push(r.name);
34
+ }
35
+ }
36
+ for (const r of d.modified) {
37
+ const i = reqs.findIndex((x) => x.name.toLowerCase() === r.name.toLowerCase());
38
+ if (i === -1) {
39
+ report.missing.push(`MODIFIED "${r.name}" not found; added instead`);
40
+ reqs.push(r);
41
+ } else {
42
+ reqs[i] = r;
43
+ report.modified.push(r.name);
44
+ }
45
+ }
46
+ for (const r of d.added) {
47
+ const i = reqs.findIndex((x) => x.name.toLowerCase() === r.name.toLowerCase());
48
+ if (i === -1) {
49
+ reqs.push(r);
50
+ report.added.push(r.name);
51
+ } else {
52
+ reqs[i] = r;
53
+ report.modified.push(`${r.name} (ADDED over existing)`);
54
+ }
55
+ }
56
+ return { text: renderSpec({ ...main, name: main.name || capability, requirements: reqs }), report };
57
+ }
58
+
59
+ export function appendDecisions(specText, capability, lines) {
60
+ const s = specText ? parseSpec(specText) : { purpose: '', requirements: [], decisions: [] };
61
+ const existing = new Set(s.decisions.map((d) => d.toLowerCase()));
62
+ const fresh = lines.filter((l) => !existing.has(`${capability}: ${l}`.toLowerCase()) && !existing.has(l.toLowerCase()));
63
+ return renderSpec({ ...s, name: s.name || capability, decisions: [...s.decisions, ...fresh.map((l) => `${capability}: ${l}`)] });
64
+ }
65
+
66
+ /** Everything that stops a landing. Pure; used by `land` and `doctor`. */
67
+ export function landingBlockers(c, fingerprint, { confirmAssumptions = false, acceptDrift = false, specsDir, activeNames = [] } = {}) {
68
+ const contractDrift = specsDir ? changeSpecDrift(c, specsDir) : [];
69
+ return evaluateLifecycle(c, fingerprint, {
70
+ activeNames,
71
+ confirmAssumptions,
72
+ contractDrift,
73
+ acceptDrift,
74
+ }).blockers;
75
+ }
76
+
77
+ export async function land(args, cwd = process.cwd()) {
78
+ const root = requireProjectRoot(cwd);
79
+ return withLock(path.join(runtimeDir(root), 'landing'), () => {
80
+ if (activeChecks(root).length) throw new Error('cannot land while checks are running; wait for every active check to finish');
81
+ if (recoverLanding(root)) throw new Error('restored an interrupted landing; review the restored files and run checks again');
82
+ return landUnlocked(args, cwd);
83
+ });
84
+ }
85
+
86
+ function landUnlocked({ flags, positional }, cwd) {
87
+ const root = requireProjectRoot(cwd);
88
+ if (activeChecks(root).length) throw new Error('cannot land while checks are running; wait for every active check to finish');
89
+ if (flags.force && (typeof flags.reason !== 'string' || !flags.reason.trim())) throw new Error('--force requires --reason "<owner-authorized reason>"; every bypass is archived.');
90
+ const cfg = loadConfig(projectPaths(root).config);
91
+ const p = projectPaths(root, cfg);
92
+ let name = positional[0];
93
+ if (!name) {
94
+ const all = loadAllChanges(p.changes);
95
+ const focused = readSession(root).state?.change;
96
+ if (focused && all.some((c) => c.name === focused)) name = focused;
97
+ else if (all.length === 1) name = all[0].name;
98
+ else throw new Error(all.length ? `several active changes (${all.map((c) => c.name).join(', ')}); bind one with \`keelson focus <name>\` or name one` : 'no active change to land');
99
+ }
100
+ const allChanges = loadAllChanges(p.changes);
101
+ const c = loadChange(p.changes, name);
102
+ if (!c) throw new Error(`no change named "${name}"`);
103
+ const fp = worktreeFingerprint(root);
104
+ const activeNames = new Set(allChanges.map((x) => x.name));
105
+ const blockers = landingBlockers(c, fp, { confirmAssumptions: Boolean(flags.confirmAssumptions), acceptDrift: Boolean(flags.acceptDrift), specsDir: p.specs, activeNames });
106
+ if (blockers.length && !flags.force) throw new Error(`cannot land "${name}":\n - ${blockers.join('\n - ')}\nFix them, or pass --force if the user explicitly asked.`);
107
+ if (blockers.length) warn(`landing with --force despite:\n - ${blockers.join('\n - ')}`);
108
+ const uncheckedPlan = c.progress.total ? c.progress.total - c.progress.done : 0;
109
+ if (uncheckedPlan > 0) warn(`${uncheckedPlan} task(s) remain unchecked; tasks are planning notes, not landing gates. Reconcile or remove stale plan items if they still matter.`);
110
+
111
+ heading(`Landing ${name} (${c.tier})`);
112
+ const dry = Boolean(flags.dryRun);
113
+ for (const k of sharedContracts(allChanges).filter((k) => k.a === name || k.b === name)) {
114
+ const other = k.a === name ? k.b : k.a;
115
+ const o = loadChange(p.changes, other);
116
+ warn(`shared contract with active change ${other}${o?.owner ? ` (${o.owner})` : ''}: ${[...k.capabilities.map((cap) => `${p.specsRel}/${cap}`), ...k.paths].join(', ')} — its delta will drift after this landing and its owner must re-read the merged spec before landing`);
117
+ }
118
+ // Project every durable write before mutating project truth.
119
+ const projected = new Map();
120
+ const deltaReports = [];
121
+ for (const df of c.deltaFiles) {
122
+ const cap = path.dirname(df).replace(/\\/g, '/');
123
+ if (cap === '.' || cap.includes('<')) {
124
+ warn(`skipping delta at specs/${df}: capability directory is a placeholder`);
125
+ continue;
126
+ }
127
+ const mainPath = path.join(p.specs, cap, 'spec.md');
128
+ const baseText = projected.get(cap)?.text ?? readCapabilitySpec(p.specs, cap);
129
+ const { text, report } = mergeDelta(baseText, read(path.join(c.dir, 'specs', df)), cap);
130
+ projected.set(cap, { path: mainPath, text });
131
+ deltaReports.push({ cap, report });
132
+ }
133
+
134
+ const byCap = new Map();
135
+ for (const d of c.decisions) {
136
+ if (!d.capability) {
137
+ warn(`decision without capability prefix skipped: "${d.text}"`);
138
+ continue;
139
+ }
140
+ byCap.set(d.capability, [...(byCap.get(d.capability) ?? []), d.text]);
141
+ }
142
+ for (const [cap, lines] of byCap) {
143
+ const mainPath = path.join(p.specs, cap, 'spec.md');
144
+ const baseText = projected.get(cap)?.text ?? readCapabilitySpec(p.specs, cap);
145
+ projected.set(cap, { path: mainPath, text: appendDecisions(baseText, cap, lines) });
146
+ }
147
+
148
+ for (const [cap, item] of projected) {
149
+ resolveWithin(p.specs, cap);
150
+ const storage = planCapabilityStorage(cap, item.text, cfg.budgets?.spec, capabilityStorageOptions(p.specs, cap));
151
+ item.storage = storage;
152
+ if (storage.hardOver.length && !flags.force) {
153
+ const detail = storage.hardOver.map((f) => `${f.rel} ${f.lines} lines`).join(', ');
154
+ throw new Error(`cannot land "${name}": auto-sharding still leaves oversized spec shard(s) in ${p.specsRel}/${cap}: ${detail} (hard limit ${storage.hardLimit}). Semantically compact that requirement first; --force is only for an explicit owner override.`);
155
+ }
156
+ if (storage.mode === 'sharded') info(`${p.specsRel}/${cap}: auto-organize → bounded index + ${storage.files.length - 1} shard(s)`);
157
+ if (storage.hardOver.length) warn(`${p.specsRel}/${cap}: owner forced oversized shard(s): ${storage.hardOver.map((f) => f.rel).join(', ')}`);
158
+ }
159
+
160
+ let nextNowText = null;
161
+ if (flags.now) {
162
+ const body = String(flags.now).trim().replace(/^#\s*Now\s*\n+/i, '');
163
+ nextNowText = `# Now\n\n${body}\n`;
164
+ const pressure = budgetStatus(nextNowText, cfg.budgets?.NOW);
165
+ if (pressure.state === 'hard' && !flags.force) {
166
+ throw new Error(`cannot land "${name}": projected NOW.md is ${pressure.lines} lines, above hard limit ${pressure.hardLimit} (budget ${pressure.budget}). Rewrite NOW as current state only.`);
167
+ }
168
+ }
169
+
170
+ const mode = flags.keep || flags.force || exists(path.join(c.dir, 'ledger.jsonl')) ? 'keep' : cfg.land;
171
+ let dest = path.join(p.archive, `${new Date().toISOString().slice(0, 10)}-${name}`);
172
+ if (exists(dest)) dest += `-${Date.now()}`;
173
+ const apply = () => {
174
+ if (!dry && flags.force) {
175
+ const reason = String(flags.reason).trim();
176
+ const bypassed = [...blockers, ...[...projected].flatMap(([cap, item]) => item.storage.hardOver.map((f) => `oversized ${cap}/${f.rel}: ${f.lines} lines`))];
177
+ if (nextNowText !== null && budgetStatus(nextNowText, cfg.budgets?.NOW).state === 'hard') bypassed.push('NOW.md hard budget exceeded');
178
+ if (!bypassed.length) bypassed.push('explicit force requested (no lifecycle blockers)');
179
+ write(path.join(c.dir, 'forced.md'), `# Forced landing\n\nReason: ${reason}\n\n${bypassed.map((b) => `- ${b}`).join('\n')}\n`);
180
+ recordStatement(root, c.dir, {
181
+ _type: 'https://in-toto.io/Statement/v1',
182
+ subject: [{ name: 'worktree', digest: { [fp.length === 40 ? 'gitTree' : 'sha256']: fp } }],
183
+ predicateType: 'https://github.com/Atingaii/keelson/override/v1',
184
+ predicate: { reason, bypassed, timestamp: new Date().toISOString() },
185
+ });
186
+ }
187
+ for (const [cap, item] of projected) if (!dry) writeCapabilityStorage(p.specs, cap, item.storage);
188
+ for (const { cap, report } of deltaReports) {
189
+ ok(`${p.specsRel}/${cap}: +${report.added.length} added, ~${report.modified.length} modified, -${report.removed.length} removed${report.missing.length ? ` (${report.missing.join('; ')})` : ''}`);
190
+ }
191
+ for (const [cap, lines] of byCap) {
192
+ ok(`${p.specsRel}/${cap}: ${lines.length} decision line${lines.length > 1 ? 's' : ''} folded${c.assumed.length ? ` (${c.assumed.length} confirmed by --confirm-assumptions)` : ''}`);
193
+ }
194
+ if (mode === 'keep') {
195
+ if (!dry) {
196
+ mkdirp(p.archive);
197
+ write(path.join(c.dir, 'landed.json'), JSON.stringify({ status: 'integrated', change: name, at: new Date().toISOString(), forced: Boolean(flags.force) }) + '\n');
198
+ fs.renameSync(c.dir, dest);
199
+ }
200
+ ok(`archived → .keelson/changes/archive/${path.basename(dest)}`);
201
+ } else {
202
+ if (!dry) rmrf(c.dir);
203
+ ok(`removed .keelson/changes/${name} (ledger and handoff stay in git history)`);
204
+ }
205
+ if (!dry) clearChangeBindings(root, name);
206
+ if (nextNowText !== null) {
207
+ if (!dry) write(p.now, nextNowText);
208
+ ok('NOW.md rewritten');
209
+ } else info('session focus was cleared for the landed change; NOW.md is optional project-level context, not the source of work lifecycle state');
210
+ info('commit the landing together with the last code change; release status is derived from git tags');
211
+ if (dry) warn('dry run: nothing written');
212
+ return 0;
213
+ };
214
+ if (dry) return apply();
215
+ return landingTransaction(root, [
216
+ ...[...projected.keys()].map((cap) => resolveWithin(p.specs, cap)),
217
+ c.dir, dest, p.now, p.sessions,
218
+ ], apply);
219
+ }
220
+
221
+ export async function cancel(args, cwd = process.cwd()) {
222
+ const root = requireProjectRoot(cwd);
223
+ return withLock(path.join(runtimeDir(root), 'landing'), () => {
224
+ if (activeChecks(root).length) throw new Error('cannot cancel while checks are running; wait for them to finish');
225
+ if (recoverLanding(root)) throw new Error('restored an interrupted landing; review the files before cancelling');
226
+ return cancelUnlocked(args, cwd);
227
+ });
228
+ }
229
+
230
+ function cancelUnlocked({ flags, positional }, cwd) {
231
+ const root = requireProjectRoot(cwd);
232
+ const cfg = loadConfig(projectPaths(root).config);
233
+ const p = projectPaths(root, cfg);
234
+ let name = positional[0];
235
+ if (!name) {
236
+ const all = loadAllChanges(p.changes);
237
+ const focused = readSession(root).state?.change;
238
+ if (focused && all.some((c) => c.name === focused)) name = focused;
239
+ else if (all.length === 1) name = all[0].name;
240
+ else throw new Error('usage: keelson cancel <name> [--reason "<why>"]');
241
+ }
242
+ const c = loadChange(p.changes, name);
243
+ if (!c) throw new Error(`no change named "${name}"`);
244
+ const reason = flags.reason ? String(flags.reason) : 'no reason given';
245
+ let dest = path.join(p.archive, `${new Date().toISOString().slice(0, 10)}-${name}-cancelled`);
246
+ if (exists(dest)) dest += `-${Date.now()}`;
247
+ return landingTransaction(root, [c.dir, dest, p.sessions], () => {
248
+ const cm = path.join(c.dir, 'change.md');
249
+ write(cm, read(cm).replace(/^status:.*$/m, `status: cancelled`).replace(/\n*$/, `\n\n## Cancelled\n${new Date().toISOString().slice(0, 10)}: ${reason}\n`));
250
+ write(path.join(c.dir, 'landed.json'), JSON.stringify({ status: 'cancelled', change: name, at: new Date().toISOString() }) + '\n');
251
+ mkdirp(p.archive);
252
+ fs.renameSync(c.dir, dest);
253
+ clearChangeBindings(root, name);
254
+ ok(`cancelled ${name} → ${path.relative(root, dest)} (nothing merged into specs)`);
255
+ info('if a decision was ruled out for good, record it in the affected spec\'s Decisions so the path is not retried');
256
+ return 0;
257
+ });
258
+ }
@@ -0,0 +1,62 @@
1
+ import { requireProjectRoot, findProjectRoot, projectPaths } from '../lib/paths.js';
2
+ import { loadConfig } from '../lib/config.js';
3
+ import { TIERS, resolveAll, resolveTier, detectAndCache, loadCache, refresh, loadRegistry, loadUserOverrides, saveUserOverrides, datedIdPatterns } from '../lib/models.js';
4
+ import { PLATFORMS } from '../platforms/index.js';
5
+ import { heading, ok, warn, info, dim } from '../lib/out.js';
6
+
7
+ export async function models({ flags, positional }, cwd = process.cwd()) {
8
+ const root = findProjectRoot(cwd);
9
+ const cfg = root ? loadConfig(projectPaths(root).config) : null;
10
+ const platform = flags.platform ?? cfg?.tools?.[0] ?? 'claude';
11
+ if (!PLATFORMS[platform]) throw new Error(`unknown platform "${platform}"`);
12
+
13
+ if (positional[0] === 'rank') {
14
+ const [, alias, tier] = positional;
15
+ if (!alias || !TIERS.includes(tier)) throw new Error('usage: keelson models rank <alias> <light|standard|deep> [--platform <id>]');
16
+ for (const re of datedIdPatterns()) if (re.test(alias)) throw new Error(`"${alias}" looks like a dated model ID; use the family alias instead`);
17
+ const o = loadUserOverrides();
18
+ o[platform] ??= {};
19
+ o[platform][tier] = alias;
20
+ saveUserOverrides(o);
21
+ ok(`~/.keelson/models.yaml: ${platform}.${tier} = ${alias}`);
22
+ return 0;
23
+ }
24
+ if (flags.detect) {
25
+ const c = detectAndCache();
26
+ ok('detected and cached');
27
+ if (flags.json) console.log(JSON.stringify(c, null, 2));
28
+ else for (const [id, t] of Object.entries(c.tools)) console.log(` ${id.padEnd(9)} ${t.installed ? `installed ${dim(t.version ?? '')}` : dim('not found')}${t.defaultModel ? ` default: ${t.defaultModel}` : ''}`);
29
+ return 0;
30
+ }
31
+ if (flags.refresh) {
32
+ const r = await refresh({ withProviders: !flags.noProviders });
33
+ ok(`registry: ${r.registry}`);
34
+ for (const [pid, s] of Object.entries(r.providers)) info(`${pid}: ${s}`);
35
+ if (r.newModels.length) {
36
+ warn(`${r.newModels.length} new model(s) seen in provider catalogues:`);
37
+ for (const m of r.newModels) console.log(` ${m.id} ${m.inferredTier ? dim(`inferred ${m.inferredTier}`) : dim('unranked — `keelson models rank <alias> <tier>` when the host supports it')}`);
38
+ }
39
+ return 0;
40
+ }
41
+ if (flags.resolve) {
42
+ const tier = String(flags.resolve);
43
+ const r = resolveTier(tier, { platform, projectModels: cfg?.models ?? {} });
44
+ if (flags.json) console.log(JSON.stringify({ platform, tier, ...r }));
45
+ else console.log(r.alias ?? '');
46
+ return r.alias ? 0 : 1;
47
+ }
48
+ // default: table
49
+ const cache = loadCache();
50
+ const reg = loadRegistry();
51
+ heading(`Keelson models — platform: ${platform} ${dim(`(registry ${reg.updated})`)}`);
52
+ const all = resolveAll(platform, cfg?.models ?? {});
53
+ for (const t of TIERS) console.log(` ${t.padEnd(9)} → ${(all[t].alias ?? '—').padEnd(12)} ${dim(all[t].source)}`);
54
+ if (!PLATFORMS[platform] || !reg.platforms?.[platform]?.subagents) info(`${PLATFORMS[platform].label} has no subagent dispatch in the registry; tiers still guide inline effort`);
55
+ console.log('');
56
+ if (cache) {
57
+ console.log(dim(`local detection${cache.stale ? ' (stale, run --detect)' : ''}: ${Object.entries(cache.tools).filter(([, t]) => t.installed).map(([id, t]) => `${id}${t.defaultModel ? `=${t.defaultModel}` : ''}`).join(', ') || 'no tools found'}`));
58
+ if (cache.unranked?.length) warn(`${cache.unranked.length} unranked model(s) from provider catalogues: ${cache.unranked.map((m) => m.id).join(', ')}`);
59
+ } else info('no local detection yet — run `keelson models --detect`');
60
+ if (flags.json) console.log(JSON.stringify({ platform, tiers: all, cache }, null, 2));
61
+ return 0;
62
+ }
@@ -0,0 +1,70 @@
1
+ import path from 'node:path';
2
+ import { requireProjectRoot, projectPaths } from '../lib/paths.js';
3
+ import { exists, write, read, mkdirp, readOr } from '../lib/fs.js';
4
+ import { loadConfig } from '../lib/config.js';
5
+ import { skillSource } from '../platforms/index.js';
6
+ import { slugify, TIERS } from '../lib/changes.js';
7
+ import { git, isGitRepo, currentBranch, gitUserName } from '../lib/git.js';
8
+ import { list } from '../lib/args.js';
9
+ import { ok, info, warn } from '../lib/out.js';
10
+ import { bindSession } from '../lib/session.js';
11
+ import { readCapabilitySpec, specFingerprint } from '../lib/specs.js';
12
+
13
+ const fill = (tpl, vars) => tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
14
+ export const specBase = specFingerprint;
15
+
16
+ export async function newChange({ flags, positional }, cwd = process.cwd()) {
17
+ const root = requireProjectRoot(cwd);
18
+ const cfg = loadConfig(projectPaths(root).config);
19
+ const p = projectPaths(root, cfg);
20
+ const raw = positional[0] ?? flags.name;
21
+ if (!raw) throw new Error('usage: keelson new <name> [--tier quick|spec] [--capability a,b] [--touches src/api/**,...] [--depends other-change] [--worktree]');
22
+ const name = slugify(raw);
23
+ const tier = (flags.tier ?? 'quick').toLowerCase();
24
+ if (!TIERS.includes(tier)) throw new Error(`tier must be one of ${TIERS.join('|')}`);
25
+ const dir = path.join(p.changes, name);
26
+ if (exists(dir)) throw new Error(`change "${name}" already exists`);
27
+ const tpl = path.join(skillSource(cfg.lang), 'templates');
28
+ const caps = list(flags.capability).map(slugify);
29
+ const owner = flags.owner ?? gitUserName(root);
30
+ const branch = isGitRepo(root) ? currentBranch(root) : null;
31
+ const vars = { name, title: raw, tier, date: new Date().toISOString().slice(0, 10), capability: caps[0] ?? '<capability>' };
32
+
33
+ let worktree = null;
34
+ if (flags.worktree) {
35
+ if (!isGitRepo(root)) throw new Error('--worktree needs a git repository');
36
+ const wt = path.resolve(root, '..', `${path.basename(root)}-${name}`);
37
+ const out = git(root, ['worktree', 'add', '-b', name, wt], { allowFail: true });
38
+ if (out === null && !exists(wt)) throw new Error(`git worktree add failed for ${wt}`);
39
+ worktree = wt;
40
+ ok(`worktree ${wt} on branch ${name}`);
41
+ }
42
+
43
+ const front = [
44
+ `tier: ${tier}`,
45
+ `created: ${vars.date}`,
46
+ `status: ${tier === 'spec' ? 'clarifying' : 'in-progress'}`,
47
+ `owner: ${owner}`,
48
+ ...(worktree ? [`branch: ${name}`, `worktree: ${path.relative(root, worktree)}`] : branch ? [`branch: ${branch}`] : []),
49
+ ...(list(flags.depends).length ? [`depends: [${list(flags.depends).join(', ')}]`] : []),
50
+ ...(list(flags.touches).length ? [`touches: [${list(flags.touches).join(', ')}]`] : []),
51
+ ];
52
+ const body = fill(read(path.join(tpl, tier === 'quick' ? 'change-quick.md' : 'change.md')), vars).replace(/^---\n[\s\S]*?\n---\n/, '');
53
+ mkdirp(dir);
54
+ write(path.join(dir, 'change.md'), `---\n${front.join('\n')}\n---\n${body}`);
55
+ if (tier === 'spec') write(path.join(dir, 'tasks.md'), fill(read(path.join(tpl, 'tasks.md')), vars));
56
+ // ledger.md and handoff.md are event artifacts: create them only when evidence or a handoff actually exists.
57
+ for (const cap of caps) {
58
+ const main = readCapabilitySpec(p.specs, cap);
59
+ const delta = fill(read(path.join(tpl, 'delta-spec.md')), { ...vars, capability: cap });
60
+ // Stamp the base so `keelson land` can detect that the main spec moved while this delta was being written.
61
+ write(path.join(dir, 'specs', cap, 'spec.md'), `---\nbase: ${main ? specBase(main) : 'new'}\n---\n${delta}`);
62
+ }
63
+ ok(`created ${path.relative(root, dir)} (${tier}${owner ? `, owner ${owner}` : ''}${branch && !worktree ? `, branch ${branch}` : ''})`);
64
+ const focused = bindSession(root, name, { branch: worktree ? name : branch, source: 'new' });
65
+ if (focused) info(`session focus → ${name}`);
66
+ info(tier === 'spec' ? `fill change.md${caps.length ? ', specs/<capability>/spec.md' : ''}, then tasks.md — see references/plan.md` : 'fill change.md acceptance; tasks.md is optional for a quick change');
67
+ if (tier === 'spec' && !caps.length) warn('spec tier without --capability: add specs/<capability>/spec.md by hand if behaviour changes');
68
+ if (flags.json) console.log(JSON.stringify({ name, tier, dir, owner, branch: worktree ? name : branch, worktree, capabilities: caps, focused: Boolean(focused) }));
69
+ return 0;
70
+ }
@@ -0,0 +1,39 @@
1
+ import path from 'node:path';
2
+ import { PLATFORMS, PLATFORM_IDS } from '../platforms/index.js';
3
+ import { detectLocal } from '../lib/models.js';
4
+ import { findProjectRoot, projectPaths } from '../lib/paths.js';
5
+ import { loadConfig } from '../lib/config.js';
6
+ import { exists } from '../lib/fs.js';
7
+ import { heading, dim } from '../lib/out.js';
8
+
9
+ export async function platforms({ flags }, cwd = process.cwd()) {
10
+ const root = findProjectRoot(cwd);
11
+ const cfg = root ? loadConfig(projectPaths(root).config) : null;
12
+ const det = detectLocal().tools;
13
+ const portableConfigured = Boolean(root && cfg?.tools?.includes('agents') && exists(path.join(root, 'AGENTS.md')) && exists(path.join(root, '.agents', 'skills', 'keelson', 'SKILL.md')));
14
+ const rows = PLATFORM_IDS.map((id) => {
15
+ const p = PLATFORMS[id];
16
+ const configured = id === 'agents' ? portableConfigured : cfg?.tools?.includes(id) ?? false;
17
+ const sessionFocus = p.sessionFocus ?? 'degraded';
18
+ const effectiveSessionFocus =
19
+ configured && cfg?.hooks === false && p.sessionAdapter && p.sessionAdapter !== 'pi-env'
20
+ ? 'degraded'
21
+ : sessionFocus;
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 };
23
+ });
24
+ if (flags.json) {
25
+ console.log(JSON.stringify(rows, null, 2));
26
+ return 0;
27
+ }
28
+ heading(`Keelson platforms (${rows.length})`);
29
+ console.log(dim('support: first-class = tested discovery/lifecycle contract · portable = standards fallback; sessionFocus shows effective project behavior (native|degraded)'));
30
+ console.log('');
31
+ const w = Math.max(...rows.map((r) => r.label.length));
32
+ for (const r of rows) {
33
+ const marks = [r.configured ? 'configured' : null, r.installed ? 'on this machine' : null].filter(Boolean).join(', ');
34
+ console.log(` --${r.id.padEnd(12)} ${r.label.padEnd(w)} ${r.instructions.padEnd(16)} ${r.skillDiscovery.padEnd(20)} ${(r.support ?? '').padEnd(11)} ${(r.effectiveSessionFocus ?? r.sessionFocus ?? 'degraded').padEnd(9)} ${r.hooks ? 'hooks ' : ' '} ${dim(r.confidence)}${marks ? dim(` [${marks}]`) : ''}`);
35
+ }
36
+ console.log('');
37
+ console.log(dim('guidance stays in the installed package by default; use `keelson guide` or opt in to `keelson init --vendor` for a project copy'));
38
+ return 0;
39
+ }
@@ -0,0 +1,114 @@
1
+ import path from 'node:path';
2
+ import { requireProjectRoot, projectPaths } from '../lib/paths.js';
3
+ import { readOr, walk, listDirs, read, exists } from '../lib/fs.js';
4
+ import { skillSource } from '../platforms/index.js';
5
+ import { parseLedger, ROOT_CAUSES } from '../lib/markdown.js';
6
+ import { loadAllChanges } from '../lib/changes.js';
7
+ import { historicalLedgers } from '../lib/git.js';
8
+ import { heading, info, warn, ok, dim } from '../lib/out.js';
9
+
10
+ export function collectLedgers(root) {
11
+ const p = projectPaths(root);
12
+ const out = [];
13
+ for (const c of loadAllChanges(p.changes)) out.push({ source: `active:${c.name}`, entries: c.ledger });
14
+ for (const d of listDirs(p.archive)) {
15
+ const f = path.join(p.archive, d, 'ledger.md');
16
+ if (exists(f)) out.push({ source: `archive:${d}`, entries: parseLedger(read(f)) });
17
+ }
18
+ for (const h of historicalLedgers(root)) out.push({ source: `git:${h.commit.slice(0, 7)}`, entries: parseLedger(h.content) });
19
+ return out;
20
+ }
21
+
22
+ export function computeMetrics(ledgers) {
23
+ const entries = ledgers.flatMap((l) => l.entries);
24
+ const rootCauses = Object.fromEntries(ROOT_CAUSES.map((c) => [c, 0]));
25
+ for (const e of entries) if (e.kind === 'root-cause' && e.category in rootCauses) rootCauses[e.category]++;
26
+ const dispatches = entries.filter((e) => e.kind === 'dispatch');
27
+ const escalations = entries.filter((e) => e.kind === 'escalate');
28
+ const byTier = {};
29
+ for (const t of ['light', 'standard', 'deep']) {
30
+ const d = dispatches.filter((x) => x.tier === t);
31
+ const esc = escalations.filter((x) => x.from === t).length;
32
+ const fails = d.filter((x) => x.result === 'fail').length;
33
+ const passes = d.filter((x) => x.result === 'pass').length;
34
+ const known = passes + fails;
35
+ byTier[t] = { dispatches: d.length, failures: fails, unknown: d.length - known, escalatedFrom: esc, firstPass: known ? Math.round((passes / known) * 100) : null };
36
+ }
37
+ const verifies = entries.filter((e) => e.kind === 'verify');
38
+ return {
39
+ ledgers: ledgers.length,
40
+ entries: entries.length,
41
+ rulings: entries.filter((e) => e.kind === 'ruling').length,
42
+ rootCauses,
43
+ rootCauseTotal: Object.values(rootCauses).reduce((a, b) => a + b, 0),
44
+ byTier,
45
+ verifies: { total: verifies.length, failed: verifies.filter((v) => v.exit !== 0).length },
46
+ };
47
+ }
48
+
49
+ /** Parse guidance annotations from a vendored skill or the package guidance. */
50
+ export function collectGuidance(root) {
51
+ const out = [];
52
+ const vendored = path.join(root, '.keelson', 'skill', 'references');
53
+ const dir = exists(vendored) ? vendored : path.join(skillSource('en'), 'references');
54
+ const relBase = exists(vendored) ? '.keelson/skill/references' : 'package:skills/keelson/references';
55
+ for (const f of walk(dir)) {
56
+ const txt = read(path.join(dir, f));
57
+ for (const m of txt.matchAll(/<!--\s*keelson:\s*id=([\w.-]+)\s*\|\s*without:\s*([^|]*?)\s*\|\s*sunset:\s*(.*?)\s*-->/g)) {
58
+ out.push({ id: m[1], without: m[2].trim(), sunset: m[3].trim(), file: `${relBase}/${f}` });
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export function suggestions(metrics, guidance) {
65
+ const s = [];
66
+ const rc = metrics.rootCauses;
67
+ if (metrics.rootCauseTotal >= 20 && rc['guessed-fix'] === 0) s.push({ kind: 'prune', id: 'debug.reproduce-first', why: `0 guessed-fix in ${metrics.rootCauseTotal} root-cause entries; the reproduce-first guidance is no longer preventing anything` });
68
+ if (rc['cross-layer'] >= 3) s.push({ kind: 'rule', why: `${rc['cross-layer']} cross-layer root causes; add a contract rule or a spec requirement for the layers involved` });
69
+ if (rc['missing-rule'] >= 3) s.push({ kind: 'rule', why: `${rc['missing-rule']} bugs traced to a missing convention; check that each one produced a rule` });
70
+ if (rc['propagation'] >= 3) s.push({ kind: 'rule', why: `${rc['propagation']} propagation failures; add a "find all call sites" check to the relevant rules file` });
71
+ const light = metrics.byTier.light;
72
+ if (light.dispatches >= 20 && light.firstPass !== null && light.firstPass < 70) s.push({ kind: 'effort', why: `light-tier first-pass rate ${light.firstPass}% over ${light.dispatches} dispatches; tag fewer tasks as light or tighten the light criteria` });
73
+ if (light.dispatches >= 100 && light.firstPass >= 90) s.push({ kind: 'effort', id: 'plan.effort', why: `light-tier first-pass rate ${light.firstPass}% over ${light.dispatches} dispatches; light can take more than it does now` });
74
+ const std = metrics.byTier.standard;
75
+ if (std.dispatches >= 20 && std.escalatedFrom / std.dispatches > 0.3) s.push({ kind: 'effort', why: `standard escalates to deep ${Math.round((std.escalatedFrom / std.dispatches) * 100)}% of the time; route design-adjacent tasks to deep up front` });
76
+ if (metrics.verifies.total >= 10 && metrics.verifies.failed / metrics.verifies.total > 0.4) s.push({ kind: 'verify', why: `${metrics.verifies.failed}/${metrics.verifies.total} verify entries failed; checks may be flaky or tasks are being ticked before verification` });
77
+ for (const g of guidance) {
78
+ const m = g.sunset.match(/(guessed-fix|cross-layer|missing-rule|propagation|test-gap|implicit-assumption)\s*=\s*0\s*across the last\s*(\d+)/i);
79
+ if (m && metrics.rootCauseTotal >= Number(m[2]) && rc[m[1]] === 0 && !s.some((x) => x.id === g.id)) s.push({ kind: 'prune', id: g.id, why: `sunset condition met: ${g.sunset}` });
80
+ }
81
+ return s;
82
+ }
83
+
84
+ export async function retro({ flags }, cwd = process.cwd()) {
85
+ const root = requireProjectRoot(cwd);
86
+ const ledgers = collectLedgers(root);
87
+ const metrics = computeMetrics(ledgers);
88
+ const guidance = collectGuidance(root);
89
+ const sug = suggestions(metrics, guidance);
90
+ if (flags.json) {
91
+ console.log(JSON.stringify({ metrics, guidance, suggestions: sug }, null, 2));
92
+ return 0;
93
+ }
94
+ heading('Keelson retro');
95
+ console.log(`${metrics.ledgers} ledgers (active + archive + git history) · ${metrics.entries} entries · ${metrics.rulings} rulings`);
96
+ console.log('');
97
+ heading('Root causes');
98
+ for (const [k, v] of Object.entries(metrics.rootCauses)) console.log(` ${k.padEnd(20)} ${v}`);
99
+ console.log('');
100
+ heading('Effort tiers');
101
+ for (const [t, m] of Object.entries(metrics.byTier)) console.log(` ${t.padEnd(9)} dispatches ${m.dispatches} failures ${m.failures}${m.unknown ? ` no-result ${m.unknown}` : ''} escalated ${m.escalatedFrom} first-pass ${m.firstPass ?? '—'}${m.firstPass !== null ? '%' : ''}`);
102
+ console.log(` verify entries ${metrics.verifies.total}, failed ${metrics.verifies.failed}`);
103
+ console.log('');
104
+ heading('Guidance with sunset conditions');
105
+ if (!guidance.length) console.log(dim(' none found in package guidance'));
106
+ for (const g of guidance) console.log(` ${g.id.padEnd(26)} ${dim(g.sunset)}`);
107
+ console.log('');
108
+ heading('Suggestions');
109
+ if (!sug.length) console.log(dim(' nothing to change yet — keep working; suggestions need data'));
110
+ for (const s of sug) console.log(` [${s.kind}] ${s.id ? s.id + ': ' : ''}${s.why}`);
111
+ if (metrics.entries === 0) warn('no ledger entries found. Ledgers are written during build/verify; see the skill references.');
112
+ else ok('done');
113
+ return 0;
114
+ }