@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,113 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { exists, read, readOr, listDirs, walk } from './fs.js';
4
+ import { parseRulesIndex } from './rules.js';
5
+ import { parseSpec } from './markdown.js';
6
+ import { loadAllChanges } from './changes.js';
7
+ import { capabilityPhysicalDocs, readCapabilitySpec } from './specs.js';
8
+
9
+ const lines = (t) => String(t ?? '').split('\n').length;
10
+ export const HARD_BUDGET_MULTIPLIER = 2;
11
+
12
+ export function budgetStatus(text, budget, { hard = true } = {}) {
13
+ const count = lines(text);
14
+ const soft = Number(budget) || 0;
15
+ const hardLimit = hard && soft ? soft * HARD_BUDGET_MULTIPLIER : null;
16
+ if (!soft) return { state: 'unbounded', lines: count, budget: null, hardLimit: null };
17
+ if (hardLimit && count > hardLimit) return { state: 'hard', lines: count, budget: soft, hardLimit };
18
+ if (count > soft) return { state: 'compact', lines: count, budget: soft, hardLimit };
19
+ return { state: 'ok', lines: count, budget: soft, hardLimit };
20
+ }
21
+ // Narrative markers: dated change sentences and "we later/then changed" phrasing. Words like "no longer" are legitimate present tense and are not flagged.
22
+ const HISTORY_NARRATIVE = /\b(used to be|was changed to|has been replaced by|we (then|later|subsequently) (moved|switched|changed|replaced)|as of (20\d\d|[A-Z][a-z]+ 20\d\d))\b|\b(20\d\d)[-/](0?[1-9]|1[0-2])\b[^\n]*\b(changed|moved|switched|replaced)\b/i;
23
+ const ORDINAL_UPDATE = /^#{2,4}\s+(update|changelog|history|migration notes)\b/im;
24
+
25
+ /**
26
+ * Knowledge-health findings for a project. Pure over the file system; never edits.
27
+ * Each finding: { level: 'error'|'warn'|'info', kind, text, fix }.
28
+ * Durable truth has a hard ceiling at 2x its soft budget; temporary change/handoff
29
+ * artifacts only receive compaction warnings because they disappear after landing.
30
+ */
31
+ export function knowledgeHealth(root, cfg, p) {
32
+ const out = [];
33
+ const b = cfg.budgets ?? {};
34
+ const over = (label, file, budget, { kind = 'budget', hard = true } = {}) => {
35
+ if (!exists(file) || !budget) return;
36
+ const pressure = budgetStatus(read(file), budget, { hard });
37
+ if (pressure.state === 'hard') {
38
+ out.push({ level: 'error', kind: 'budget-hard', text: `${label} is ${pressure.lines} lines (hard limit ${pressure.hardLimit}, budget ${pressure.budget})`, fix: 'compact before adding more durable truth: rewrite current state, split by capability/scope, delete history kept by git, and automate checkable rules' });
39
+ } else if (pressure.state === 'compact') {
40
+ out.push({ level: 'warn', kind, text: `${label} is ${pressure.lines} lines (budget ${pressure.budget})`, fix: 'compact: rewrite the current truth, split by capability or scope, delete history that git already keeps, move automatable rules into checks' });
41
+ }
42
+ };
43
+ over('INTENT.md', p.intent, b.INTENT);
44
+ over('ROADMAP.md', p.roadmap, b.ROADMAP);
45
+ over('NOW.md', p.now, b.NOW);
46
+ over('GLOSSARY.md', p.glossary, b.GLOSSARY);
47
+
48
+ // always-on rules budget
49
+ const idx = parseRulesIndex(readOr(p.rulesIndex));
50
+ let alwaysOn = 0;
51
+ for (const e of idx) if (e.glob === '**' || e.glob === '*') alwaysOn += lines(readOr(path.join(p.rules, e.file)));
52
+ if (b['always-on']) {
53
+ const pressure = budgetStatus('\n'.repeat(Math.max(0, alwaysOn - 1)), b['always-on']);
54
+ if (pressure.state === 'hard') out.push({ level: 'error', kind: 'budget-hard', text: `always-on rules total ${alwaysOn} lines (hard limit ${pressure.hardLimit}, budget ${pressure.budget}); every session pays for them`, fix: 'scope rules to paths, split broad rules, or move checkable invariants into `check:` before adding more always-on prose' });
55
+ else if (pressure.state === 'compact') out.push({ level: 'warn', kind: 'budget', text: `always-on rules total ${alwaysOn} lines (budget ${pressure.budget}); every session pays for them`, fix: 'scope rules to paths, or move checkable rules into `check:`' });
56
+ }
57
+ for (const e of idx) over(`rules/${e.file}`, path.join(p.rules, e.file), b.rule);
58
+
59
+ // specs: budget, history narrative, duplicates across capabilities
60
+ const reqIndex = new Map();
61
+ for (const cap of listDirs(p.specs)) {
62
+ const f = path.join(p.specs, cap, 'spec.md');
63
+ if (!exists(f)) continue;
64
+ for (const doc of capabilityPhysicalDocs(p.specs, cap)) {
65
+ over(`${p.specsRel}/${cap}/${doc.rel}`, doc.file, b.spec);
66
+ }
67
+ const txt = readCapabilitySpec(p.specs, cap);
68
+ const parsed = parseSpec(txt);
69
+ const bodies = parsed.requirements.map((r) => r.body).join('\n');
70
+ if (HISTORY_NARRATIVE.test(bodies) || ORDINAL_UPDATE.test(txt)) out.push({ level: 'warn', kind: 'narrative', text: `${p.specsRel}/${cap} reads like history in places`, fix: 'current truth is present tense; reasons go to Decisions, the sequence of changes stays in git' });
71
+ for (const r of parsed.requirements) {
72
+ const key = r.name.toLowerCase();
73
+ if (reqIndex.has(key)) out.push({ level: 'warn', kind: 'duplicate', text: `requirement "${r.name}" appears in both ${reqIndex.get(key)} and ${cap}`, fix: 'one capability owns a requirement; the other links to it' });
74
+ else reqIndex.set(key, cap);
75
+ }
76
+ }
77
+
78
+ // changes: budget, idle, oversized
79
+ const now = Date.now();
80
+ for (const c of loadAllChanges(p.changes)) {
81
+ over(`changes/${c.name}/change.md`, path.join(c.dir, 'change.md'), b.change, { hard: false });
82
+ if (exists(path.join(c.dir, 'handoff.md'))) over(`changes/${c.name}/handoff.md`, path.join(c.dir, 'handoff.md'), b.handoff, { hard: false });
83
+ if (c.tasks.length > 25) out.push({ level: 'warn', kind: 'oversized', text: `changes/${c.name} has ${c.tasks.length} tasks`, fix: 'split into changes that can be accepted on their own; `depends:` links them' });
84
+ const stamp = fs.statSync(path.join(c.dir, 'change.md')).mtimeMs;
85
+ const newest = Math.max(stamp, ...['tasks.md', 'ledger.md', 'handoff.md'].map((f) => (exists(path.join(c.dir, f)) ? fs.statSync(path.join(c.dir, f)).mtimeMs : 0)));
86
+ const days = Math.floor((now - newest) / 86400000);
87
+ if (days >= 14) out.push({ level: 'warn', kind: 'idle', text: `changes/${c.name} has not been touched for ${days} days (work: ${c.work})`, fix: 'finish it, `keelson handoff` it with a next step, or `keelson cancel` it with a reason' });
88
+ }
89
+
90
+ // generated docs older than the code they describe
91
+ const gen = path.join(root, 'docs', 'generated');
92
+ if (exists(gen)) {
93
+ const srcNewest = newestMtime(path.join(root, 'src')) ?? newestMtime(root);
94
+ for (const f of walk(gen)) {
95
+ const m = fs.statSync(path.join(gen, f)).mtimeMs;
96
+ if (srcNewest && m + 86400000 < srcNewest) out.push({ level: 'info', kind: 'stale-generated', text: `docs/generated/${f} is older than the source tree`, fix: 'regenerate it from the code, or delete it if nothing reads it' });
97
+ }
98
+ }
99
+
100
+ // refs pointing at missing files are caught by validate; here: specs that name no scenario are caught by validate too.
101
+ return out;
102
+ }
103
+
104
+ function newestMtime(dir) {
105
+ if (!exists(dir)) return null;
106
+ let m = 0;
107
+ for (const f of walk(dir, { ignore: ['node_modules', '.git', '.keelson', 'dist', 'build', 'docs'] })) {
108
+ try {
109
+ m = Math.max(m, fs.statSync(path.join(dir, f)).mtimeMs);
110
+ } catch {}
111
+ }
112
+ return m || null;
113
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * One lifecycle engine for status, context, check, doctor, and land.
3
+ *
4
+ * Work lifecycle is derived from durable outcome gates and fresh evidence.
5
+ * Session focus and tasks.md are advisory runtime/planning state only.
6
+ */
7
+
8
+ export function verificationStatus(change, fingerprint) {
9
+ const v = change.evidence;
10
+ if (!v) return { state: 'not-run', detail: 'no structured evidence; run `keelson check --record`' };
11
+ if (v.state !== 'passed') return { state: v.state, detail: v.detail };
12
+ if (!fingerprint || v.tree !== fingerprint) {
13
+ return { state: 'stale', detail: `verified at tree ${v.tree}, worktree is ${fingerprint}` };
14
+ }
15
+ return { state: 'passed', detail: v.detail };
16
+ }
17
+
18
+ const gate = (code, pass, detail) => ({ code, pass, detail });
19
+
20
+ /**
21
+ * Evaluate the durable work lifecycle without touching the filesystem.
22
+ *
23
+ * activeNames: names of other active changes, used to enforce depends:.
24
+ * confirmAssumptions: land-only owner confirmation; status/check leave this false.
25
+ */
26
+ export function evaluateLifecycle(change, fingerprint, {
27
+ activeNames = [],
28
+ confirmAssumptions = false,
29
+ contractDrift = [],
30
+ acceptDrift = false,
31
+ } = {}) {
32
+ const active = activeNames instanceof Set ? activeNames : new Set(activeNames);
33
+ const verification = verificationStatus(change, fingerprint);
34
+ const blockedBy = change.depends.filter((name) => active.has(name) && name !== change.name);
35
+ const acceptanceComplete = change.acceptance.length
36
+ ? change.acceptanceProgress.done === change.acceptanceProgress.total
37
+ : change.tier === 'quick';
38
+ const contractComplete = change.tier === 'quick' || change.acceptance.length > 0;
39
+ const rolloutReady = !change.breaking || change.hasRollout;
40
+
41
+ const pendingDecisions = (change.decisionRecords ?? []).filter((d) => d.state === 'open' || d.state === 'assumed');
42
+ const gates = [
43
+ gate('decisions', pendingDecisions.length === 0, `${pendingDecisions.length} unresolved decision(s): ${pendingDecisions.map((d) => d.id).join(', ')}; use keelson ask frontier`),
44
+ gate(
45
+ 'contract',
46
+ contractComplete,
47
+ 'spec tier without an "## Acceptance" list'
48
+ ),
49
+ gate(
50
+ 'acceptance',
51
+ acceptanceComplete,
52
+ `${Math.max(0, change.acceptance.length - change.acceptanceProgress.done)} acceptance item(s) unchecked`
53
+ ),
54
+ gate(
55
+ 'questions',
56
+ change.open.length === 0,
57
+ `${change.open.length} open question(s): ${change.open.map((o) => o.text).join('; ')}`
58
+ ),
59
+ gate(
60
+ 'dependencies',
61
+ blockedBy.length === 0,
62
+ `depends on active change(s): ${blockedBy.join(', ')}`
63
+ ),
64
+ gate(
65
+ 'drift',
66
+ acceptDrift || contractDrift.length === 0,
67
+ contractDrift.map((d) => d.detail ?? String(d)).join('; ')
68
+ ),
69
+ gate(
70
+ 'assumptions',
71
+ change.assumed.length === 0 || confirmAssumptions,
72
+ `${change.assumed.length} assumed decision(s) would be folded as confirmed; pass --confirm-assumptions once the owner agrees`
73
+ ),
74
+ gate(
75
+ 'rollout',
76
+ rolloutReady,
77
+ 'change is marked **BREAKING** but has no "## Rollout" section (compatibility, migration, rollback)'
78
+ ),
79
+ gate(
80
+ 'verification',
81
+ verification.state === 'passed',
82
+ `verification ${verification.state} (${verification.detail})`
83
+ ),
84
+ ];
85
+
86
+ if (change.storedWork === 'blocked') {
87
+ gates.unshift(gate('explicit-block', false, 'change status is blocked'));
88
+ }
89
+
90
+ // A missing acceptance list on a quick change is intentionally allowed; do not
91
+ // emit a synthetic "0 acceptance unchecked" blocker.
92
+ const blockers = gates
93
+ .filter((g) => !g.pass)
94
+ .filter((g) => !(g.code === 'acceptance' && !change.acceptance.length));
95
+
96
+ const explicit = change.storedWork;
97
+ let work;
98
+ if (['integrated', 'cancelled'].includes(explicit)) work = explicit;
99
+ else if (explicit === 'blocked') work = 'blocked';
100
+ else if (blockers.length === 0) work = 'ready';
101
+ else if (explicit === 'clarifying' && change.progress.done === 0 && verification.state === 'not-run') work = 'clarifying';
102
+ else work = 'in-progress';
103
+
104
+ const uncheckedTasks = change.progress.total
105
+ ? Math.max(0, change.progress.total - change.progress.done)
106
+ : 0;
107
+ const warnings = uncheckedTasks
108
+ ? [`${uncheckedTasks} task(s) remain unchecked; tasks are planning notes, not lifecycle gates`]
109
+ : [];
110
+
111
+ return {
112
+ work,
113
+ verification,
114
+ gates,
115
+ blockers: blockers.map((g) => g.detail),
116
+ blockedBy,
117
+ contractDrift,
118
+ warnings,
119
+ };
120
+ }
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { exists, listFiles, rmrf, walk } from './fs.js';
4
+ import { runtimeDir } from './runtime-path.js';
5
+
6
+ const DAY = 24 * 60 * 60 * 1000;
7
+
8
+ function safeMtime(file) {
9
+ try {
10
+ return fs.statSync(file).mtimeMs;
11
+ } catch {
12
+ return 0;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Runtime state is a cache, not durable project truth. Prune it opportunistically
18
+ * from normal Keelson commands so users never need a maintenance command.
19
+ */
20
+ export function maintainRuntime(root, {
21
+ now = Date.now(),
22
+ sessionMaxAgeDays = 30,
23
+ evidenceMaxAgeDays = 14,
24
+ maxEvidenceFiles = 200,
25
+ } = {}) {
26
+ const runtime = runtimeDir(root);
27
+ const sessions = path.join(runtime, 'sessions');
28
+ const evidence = path.join(runtime, 'evidence');
29
+ const result = { sessionsRemoved: 0, evidenceRemoved: 0 };
30
+
31
+ if (exists(sessions)) {
32
+ const cutoff = now - sessionMaxAgeDays * DAY;
33
+ for (const name of listFiles(sessions)) {
34
+ const file = path.join(sessions, name);
35
+ if (safeMtime(file) && safeMtime(file) < cutoff) {
36
+ rmrf(file);
37
+ result.sessionsRemoved += 1;
38
+ }
39
+ }
40
+ }
41
+
42
+ if (exists(evidence)) {
43
+ const cutoff = now - evidenceMaxAgeDays * DAY;
44
+ const files = walk(evidence).map((rel) => ({
45
+ rel,
46
+ file: path.join(evidence, rel),
47
+ mtime: safeMtime(path.join(evidence, rel)),
48
+ }));
49
+
50
+ for (const item of files) {
51
+ if (item.mtime && item.mtime < cutoff) {
52
+ rmrf(item.file);
53
+ item.removed = true;
54
+ result.evidenceRemoved += 1;
55
+ }
56
+ }
57
+
58
+ const remaining = files.filter((x) => !x.removed).sort((a, b) => b.mtime - a.mtime);
59
+ for (const item of remaining.slice(maxEvidenceFiles)) {
60
+ rmrf(item.file);
61
+ result.evidenceRemoved += 1;
62
+ }
63
+ }
64
+
65
+ return result;
66
+ }