@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.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/README_CN.md +101 -0
- package/bin/keelson.js +15 -0
- package/hooks/codebuddy-session.mjs +67 -0
- package/hooks/opencode-session.mjs +65 -0
- package/hooks/prompt-state.mjs +66 -0
- package/hooks/session-start.mjs +94 -0
- package/package.json +64 -0
- package/registry/models.json +118 -0
- package/registry/platforms.json +92 -0
- package/skills/keelson/SKILL.md +44 -0
- package/skills/keelson/references/build.md +61 -0
- package/skills/keelson/references/context.md +34 -0
- package/skills/keelson/references/debug.md +46 -0
- package/skills/keelson/references/design-lenses.md +78 -0
- package/skills/keelson/references/discover.md +70 -0
- package/skills/keelson/references/engineer.md +110 -0
- package/skills/keelson/references/frontend-delivery.md +38 -0
- package/skills/keelson/references/frontend-interaction.md +31 -0
- package/skills/keelson/references/frontend-review.md +33 -0
- package/skills/keelson/references/frontend-visual.md +31 -0
- package/skills/keelson/references/frontend.md +33 -0
- package/skills/keelson/references/handoff.md +43 -0
- package/skills/keelson/references/harness.md +54 -0
- package/skills/keelson/references/interview.md +120 -0
- package/skills/keelson/references/land.md +47 -0
- package/skills/keelson/references/model.md +29 -0
- package/skills/keelson/references/plan.md +106 -0
- package/skills/keelson/references/reconcile.md +61 -0
- package/skills/keelson/references/shape.md +86 -0
- package/skills/keelson/references/verify.md +64 -0
- package/skills/keelson/templates/GLOSSARY.md +5 -0
- package/skills/keelson/templates/INTENT.md +22 -0
- package/skills/keelson/templates/NOW.md +9 -0
- package/skills/keelson/templates/README.md +60 -0
- package/skills/keelson/templates/ROADMAP.md +12 -0
- package/skills/keelson/templates/change-quick.md +16 -0
- package/skills/keelson/templates/change.md +32 -0
- package/skills/keelson/templates/delta-spec.md +12 -0
- package/skills/keelson/templates/handoff.md +27 -0
- package/skills/keelson/templates/ledger.md +3 -0
- package/skills/keelson/templates/resident-block.md +7 -0
- package/skills/keelson/templates/rules-general.md +10 -0
- package/skills/keelson/templates/rules-index.md +5 -0
- package/skills/keelson/templates/spec.md +14 -0
- package/skills/keelson/templates/tasks.md +9 -0
- package/skills/keelson/templates/workflow.md +18 -0
- package/skills/zh/keelson/SKILL.md +46 -0
- package/skills/zh/keelson/references/build.md +61 -0
- package/skills/zh/keelson/references/context.md +34 -0
- package/skills/zh/keelson/references/debug.md +46 -0
- package/skills/zh/keelson/references/design-lenses.md +78 -0
- package/skills/zh/keelson/references/discover.md +70 -0
- package/skills/zh/keelson/references/engineer.md +110 -0
- package/skills/zh/keelson/references/frontend-delivery.md +38 -0
- package/skills/zh/keelson/references/frontend-interaction.md +31 -0
- package/skills/zh/keelson/references/frontend-review.md +33 -0
- package/skills/zh/keelson/references/frontend-visual.md +31 -0
- package/skills/zh/keelson/references/frontend.md +33 -0
- package/skills/zh/keelson/references/handoff.md +43 -0
- package/skills/zh/keelson/references/harness.md +54 -0
- package/skills/zh/keelson/references/interview.md +120 -0
- package/skills/zh/keelson/references/land.md +47 -0
- package/skills/zh/keelson/references/model.md +29 -0
- package/skills/zh/keelson/references/plan.md +106 -0
- package/skills/zh/keelson/references/reconcile.md +61 -0
- package/skills/zh/keelson/references/shape.md +86 -0
- package/skills/zh/keelson/references/verify.md +64 -0
- package/skills/zh/keelson/templates/GLOSSARY.md +5 -0
- package/skills/zh/keelson/templates/INTENT.md +22 -0
- package/skills/zh/keelson/templates/NOW.md +9 -0
- package/skills/zh/keelson/templates/README.md +60 -0
- package/skills/zh/keelson/templates/ROADMAP.md +12 -0
- package/skills/zh/keelson/templates/change-quick.md +16 -0
- package/skills/zh/keelson/templates/change.md +32 -0
- package/skills/zh/keelson/templates/delta-spec.md +12 -0
- package/skills/zh/keelson/templates/handoff.md +27 -0
- package/skills/zh/keelson/templates/ledger.md +3 -0
- package/skills/zh/keelson/templates/resident-block.md +7 -0
- package/skills/zh/keelson/templates/rules-general.md +10 -0
- package/skills/zh/keelson/templates/rules-index.md +5 -0
- package/skills/zh/keelson/templates/spec.md +14 -0
- package/skills/zh/keelson/templates/tasks.md +9 -0
- package/skills/zh/keelson/templates/workflow.md +18 -0
- package/src/cli.js +87 -0
- package/src/commands/ablate.js +96 -0
- package/src/commands/ask.js +64 -0
- package/src/commands/attest.js +71 -0
- package/src/commands/check.js +127 -0
- package/src/commands/context.js +95 -0
- package/src/commands/design.js +63 -0
- package/src/commands/doctor.js +157 -0
- package/src/commands/focus.js +84 -0
- package/src/commands/guide.js +59 -0
- package/src/commands/handoff.js +41 -0
- package/src/commands/hook.js +23 -0
- package/src/commands/impact.js +58 -0
- package/src/commands/init.js +289 -0
- package/src/commands/land.js +258 -0
- package/src/commands/models.js +62 -0
- package/src/commands/new.js +70 -0
- package/src/commands/platforms.js +39 -0
- package/src/commands/retro.js +114 -0
- package/src/commands/status.js +115 -0
- package/src/commands/uninstall.js +30 -0
- package/src/commands/validate.js +117 -0
- package/src/lib/args.js +30 -0
- package/src/lib/changes.js +114 -0
- package/src/lib/check-activity.js +29 -0
- package/src/lib/config.js +102 -0
- package/src/lib/decisions.js +59 -0
- package/src/lib/evidence.js +127 -0
- package/src/lib/fs.js +126 -0
- package/src/lib/git.js +353 -0
- package/src/lib/glob.js +54 -0
- package/src/lib/health.js +113 -0
- package/src/lib/lifecycle.js +120 -0
- package/src/lib/maintenance.js +66 -0
- package/src/lib/markdown.js +438 -0
- package/src/lib/models.js +195 -0
- package/src/lib/out.js +13 -0
- package/src/lib/paths.js +82 -0
- package/src/lib/rules.js +27 -0
- package/src/lib/runtime-path.js +22 -0
- package/src/lib/session.js +100 -0
- package/src/lib/specs.js +345 -0
- package/src/lib/transaction.js +93 -0
- package/src/platforms/index.js +3 -0
- package/src/platforms/integration.js +384 -0
- package/src/platforms/registry.js +46 -0
- package/src/platforms/runtime.js +249 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/** Small, dependency-free helpers for the Markdown shapes Keelson relies on. */
|
|
2
|
+
|
|
3
|
+
export const normalizeNewlines = (text) => String(text ?? '').replace(/\r\n?/g, '\n');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Return ATX headings while deliberately ignoring fenced code blocks. Markdown
|
|
7
|
+
* examples are common in specs, and treating their headings as document
|
|
8
|
+
* structure corrupts everything that follows them.
|
|
9
|
+
*/
|
|
10
|
+
function headings(text) {
|
|
11
|
+
const out = [];
|
|
12
|
+
let fence = null;
|
|
13
|
+
for (const [line, raw] of normalizeNewlines(text).split('\n').entries()) {
|
|
14
|
+
const opening = raw.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
15
|
+
if (fence) {
|
|
16
|
+
if (new RegExp(`^ {0,3}${fence.char}{${fence.size},}\\s*$`).test(raw)) fence = null;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (opening) {
|
|
20
|
+
fence = { char: opening[1][0], size: opening[1].length };
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const m = raw.match(/^ {0,3}(#{1,6})(?:[ \t]+(.*?)\s*|[ \t]*)$/);
|
|
24
|
+
if (!m || !m[2]) continue;
|
|
25
|
+
out.push({ line, level: m[1].length, title: m[2].replace(/[ \t]+#+[ \t]*$/, '').trim() });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Expose the fence-aware structural view for consumers which must decide
|
|
32
|
+
* whether a Markdown document can be represented by a narrower data model.
|
|
33
|
+
*/
|
|
34
|
+
export const markdownHeadings = (text) => headings(text);
|
|
35
|
+
|
|
36
|
+
export function parseFrontmatter(text) {
|
|
37
|
+
text = normalizeNewlines(text);
|
|
38
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
39
|
+
if (!m) return { data: {}, body: text };
|
|
40
|
+
const data = {};
|
|
41
|
+
for (const line of m[1].split('\n')) {
|
|
42
|
+
const mm = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
43
|
+
if (mm) data[mm[1]] = mm[2].trim().replace(/^["']|["']$/g, '');
|
|
44
|
+
}
|
|
45
|
+
return { data, body: text.slice(m[0].length) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderFrontmatter(data) {
|
|
49
|
+
return `---\n${Object.entries(data)
|
|
50
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
51
|
+
.join('\n')}\n---\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Split into sections by heading level (default: h2). Returns [{title, level, body}]. */
|
|
55
|
+
export function sections(text, level = 2) {
|
|
56
|
+
text = normalizeNewlines(text);
|
|
57
|
+
const lines = text.split('\n');
|
|
58
|
+
const out = [];
|
|
59
|
+
let cur = null;
|
|
60
|
+
const byLine = new Map(headings(text).map((h) => [h.line, h]));
|
|
61
|
+
for (const [i, line] of lines.entries()) {
|
|
62
|
+
const heading = byLine.get(i);
|
|
63
|
+
if (heading?.level === level) {
|
|
64
|
+
cur = { title: heading.title, level, body: '' };
|
|
65
|
+
out.push(cur);
|
|
66
|
+
} else if (heading && heading.level < level) {
|
|
67
|
+
cur = null;
|
|
68
|
+
} else if (cur) {
|
|
69
|
+
cur.body += (cur.body ? '\n' : '') + line;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const hasSection = (text, title, level = 2) =>
|
|
76
|
+
sections(text, level).some((s) => s.title.toLowerCase().startsWith(title.toLowerCase()));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Tasks: "- [ ] 1.2 Do the thing (effort: light) — verify: `cmd`"
|
|
80
|
+
* Optional slices group tasks: "## Slice: Create and access" followed by "Delivers: <observable result>".
|
|
81
|
+
* Tasks before any slice heading belong to an implicit slice named after the change.
|
|
82
|
+
*/
|
|
83
|
+
export function parseTasks(text) {
|
|
84
|
+
text = normalizeNewlines(text);
|
|
85
|
+
const tasks = [];
|
|
86
|
+
let slice = null;
|
|
87
|
+
for (const line of text.split('\n')) {
|
|
88
|
+
const sm = line.match(/^##\s+Slice:\s*(.+)$/i);
|
|
89
|
+
if (sm) {
|
|
90
|
+
slice = /^…|\{\{/.test(sm[1].trim()) ? null : sm[1].trim();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const m = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/);
|
|
94
|
+
if (!m) continue;
|
|
95
|
+
const done = m[1] !== ' ';
|
|
96
|
+
let rest = m[2];
|
|
97
|
+
const effort = (rest.match(/\(effort:\s*(light|standard|deep)\)/i) || [])[1]?.toLowerCase() ?? null;
|
|
98
|
+
const verify = (rest.match(/verify:\s*`([^`]+)`/i) || [])[1] ?? null;
|
|
99
|
+
const id = (rest.match(/^(\d+(?:\.\d+)*)[.)]?\s+/) || [])[1] ?? null;
|
|
100
|
+
const cut = rest.search(/\s*(?:[—–-]\s*verify:|\(effort:)/i);
|
|
101
|
+
const title = (cut === -1 ? rest : rest.slice(0, cut)).replace(/^(\d+(?:\.\d+)*)[.)]?\s+/, '').trim();
|
|
102
|
+
tasks.push({ id, title, done, effort, verify, slice, raw: line });
|
|
103
|
+
}
|
|
104
|
+
return tasks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Slices with their "Delivers:" line and task progress. */
|
|
108
|
+
export function parseSlices(text) {
|
|
109
|
+
text = normalizeNewlines(text);
|
|
110
|
+
const out = [];
|
|
111
|
+
let cur = null;
|
|
112
|
+
for (const line of text.split('\n')) {
|
|
113
|
+
const sm = line.match(/^##\s+Slice:\s*(.+)$/i);
|
|
114
|
+
if (sm) {
|
|
115
|
+
const name = sm[1].trim();
|
|
116
|
+
cur = /^…|\{\{/.test(name) ? null : { name, delivers: null, done: 0, total: 0 };
|
|
117
|
+
if (cur) out.push(cur);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!cur) continue;
|
|
121
|
+
const dm = line.match(/^\s*\**Delivers\**\s*[::]\s*(.+)$/i);
|
|
122
|
+
if (dm && !cur.delivers) cur.delivers = dm[1].trim();
|
|
123
|
+
const tm = line.match(/^\s*[-*]\s+\[([ xX])\]\s+/);
|
|
124
|
+
if (tm) {
|
|
125
|
+
cur.total++;
|
|
126
|
+
if (tm[1] !== ' ') cur.done++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Acceptance: "- [ ] criterion — check: `cmd` | test: name | manual: how" under "## Acceptance". */
|
|
133
|
+
export function parseAcceptance(body) {
|
|
134
|
+
const sec = sections(body, 2).find((s) => /^Acceptance$/i.test(s.title));
|
|
135
|
+
if (!sec) return [];
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const line of sec.body.split('\n')) {
|
|
138
|
+
const m = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/);
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
const rest = m[2];
|
|
141
|
+
const cm = rest.match(/[—–-]\s*(check|test|manual|review)\s*:\s*(.+)$/i);
|
|
142
|
+
const text = (cm ? rest.slice(0, cm.index) : rest).trim();
|
|
143
|
+
if (!text || /^…|\{\{/.test(text)) continue;
|
|
144
|
+
out.push({ done: m[1] !== ' ', text, kind: cm ? cm[1].toLowerCase() : null, how: cm ? cm[2].trim() : null });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Open questions: "- question — blocks: slice a, slice b" under "## Open questions". */
|
|
150
|
+
export function parseOpenQuestions(body) {
|
|
151
|
+
const sec = sections(body, 2).find((s) => /^Open questions?$/i.test(s.title));
|
|
152
|
+
if (!sec) return [];
|
|
153
|
+
return sec.body
|
|
154
|
+
.split('\n')
|
|
155
|
+
.filter((l) => /^\s*[-*]\s+/.test(l))
|
|
156
|
+
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
|
|
157
|
+
.filter((l) => l && !/^(none|无|—|-)\.?$/i.test(l) && !/^…|\{\{/.test(l))
|
|
158
|
+
.map((l) => {
|
|
159
|
+
const bm = l.match(/[—–-]\s*blocks\s*:\s*(.+)$/i);
|
|
160
|
+
return { text: bm ? l.slice(0, bm.index).trim() : l, blocks: bm ? bm[1].split(/[,,]/).map((x) => x.trim()).filter(Boolean) : [] };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Decisions: "- capability: text" with an optional leading state tag "(confirmed)" or "(assumed)".
|
|
166
|
+
* Untagged lines count as confirmed; assumed lines are the agent's working assumptions awaiting the owner.
|
|
167
|
+
*/
|
|
168
|
+
export function parseDecisions(body) {
|
|
169
|
+
const sec = sections(body, 2).find((s) => /^Decisions?$/i.test(s.title));
|
|
170
|
+
if (!sec) return [];
|
|
171
|
+
return sec.body
|
|
172
|
+
.split('\n')
|
|
173
|
+
.filter((l) => /^\s*[-*]\s+/.test(l))
|
|
174
|
+
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
|
|
175
|
+
.filter((l) => l && !/^…|\{\{/.test(l))
|
|
176
|
+
.map((l) => {
|
|
177
|
+
const tm = l.match(/^\((confirmed|assumed|authorized)\)\s*/i);
|
|
178
|
+
const state = tm ? tm[1].toLowerCase() : 'confirmed';
|
|
179
|
+
const rest = tm ? l.slice(tm[0].length) : l;
|
|
180
|
+
const m = rest.match(/^([a-z0-9][a-z0-9/-]*)\s*:\s*(.+)$/i);
|
|
181
|
+
return m ? { state, capability: m[1].toLowerCase(), text: m[2].trim() } : { state, capability: null, text: rest };
|
|
182
|
+
})
|
|
183
|
+
.filter((d) => d.text && !/^…|\{\{|^<[a-z]/.test(d.text));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const WORK_STATUSES = ['clarifying', 'in-progress', 'blocked', 'ready', 'in-review', 'integrated', 'cancelled'];
|
|
187
|
+
|
|
188
|
+
export const EFFORT_TIERS = ['light', 'standard', 'deep'];
|
|
189
|
+
export const effortRank = (t) => EFFORT_TIERS.indexOf(t);
|
|
190
|
+
|
|
191
|
+
/** Ledger entries are h3 headings: "### Ruling: …", "### Root cause: implicit-assumption", "### Verify: …", "### Dispatch: …", "### Escalate: …" */
|
|
192
|
+
export function parseLedger(text) {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const s of sections(text, 3)) {
|
|
195
|
+
const m = s.title.match(/^(Ruling|Root cause|Verify|Dispatch|Escalate|Note)\s*:\s*(.*)$/i);
|
|
196
|
+
if (!m) continue;
|
|
197
|
+
const kind = m[1].toLowerCase().replace(' ', '-');
|
|
198
|
+
const entry = { kind, title: m[2].trim(), body: s.body.trim() };
|
|
199
|
+
if (kind === 'verify') {
|
|
200
|
+
const exits = [...s.body.matchAll(/exit\s*(?:code)?\s*[:=]?\s*(\d+)/gi)].map((m) => Number(m[1]));
|
|
201
|
+
entry.exit = exits.length ? Math.max(...exits) : null;
|
|
202
|
+
entry.command = (s.body.match(/`([^`]+)`/) || [])[1] ?? null;
|
|
203
|
+
entry.tree = (s.body.match(/\btree\s*[:=]?\s*([0-9a-f]{7,40})\b/i) || [])[1] ?? null;
|
|
204
|
+
}
|
|
205
|
+
if (kind === 'dispatch') {
|
|
206
|
+
const dm = entry.title.match(/(light|standard|deep)/i);
|
|
207
|
+
entry.tier = dm ? dm[1].toLowerCase() : null;
|
|
208
|
+
entry.task = (entry.title.match(/task\s*([\d.]+)/i) || [])[1] ?? null;
|
|
209
|
+
const rm = s.body.match(/^\s*Result:\s*(pass|fail|accepted|rejected|ok)\b/im);
|
|
210
|
+
entry.result = rm ? (/^(pass|accepted|ok)$/i.test(rm[1]) ? 'pass' : 'fail') : null;
|
|
211
|
+
}
|
|
212
|
+
if (kind === 'escalate') {
|
|
213
|
+
const em = entry.title.match(/(light|standard|deep)\s*(?:→|->|to)\s*(light|standard|deep)/i);
|
|
214
|
+
entry.from = em ? em[1].toLowerCase() : null;
|
|
215
|
+
entry.to = em ? em[2].toLowerCase() : null;
|
|
216
|
+
}
|
|
217
|
+
if (kind === 'root-cause') entry.category = entry.title.split(/\s/)[0].toLowerCase();
|
|
218
|
+
out.push(entry);
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const ROOT_CAUSES = ['missing-rule', 'cross-layer', 'propagation', 'test-gap', 'implicit-assumption', 'guessed-fix'];
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Specs: "## Requirement: Name" blocks each holding "### Scenario: …" blocks.
|
|
227
|
+
* Delta specs use "## ADDED Requirements" / "## MODIFIED Requirements" / "## REMOVED Requirements"
|
|
228
|
+
* containing "### Requirement: Name" blocks.
|
|
229
|
+
*/
|
|
230
|
+
export function parseSpec(text) {
|
|
231
|
+
const source = normalizeNewlines(text);
|
|
232
|
+
const { data, body } = parseFrontmatter(source);
|
|
233
|
+
const reqs = [];
|
|
234
|
+
const decisions = [];
|
|
235
|
+
let purpose = '';
|
|
236
|
+
for (const s of sections(body, 2)) {
|
|
237
|
+
const rm = s.title.match(/^Requirement:\s*(.+)$/i);
|
|
238
|
+
if (rm) reqs.push({ name: rm[1].trim(), body: s.body.trim() });
|
|
239
|
+
else if (/^Requirements$/i.test(s.title)) {
|
|
240
|
+
for (const r of sections(s.body, 3)) {
|
|
241
|
+
const requirement = r.title.match(/^Requirement:\s*(.+)$/i);
|
|
242
|
+
if (requirement) reqs.push({ name: requirement[1].trim(), body: r.body.trim() });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (/^Decisions?$/i.test(s.title)) decisions.push(...s.body.split('\n').filter((l) => /^\s*[-*]\s+/.test(l)).map((l) => l.replace(/^\s*[-*]\s+/, '').trim()));
|
|
246
|
+
else if (/^Purpose$/i.test(s.title)) purpose = s.body.trim();
|
|
247
|
+
}
|
|
248
|
+
const name = headings(body).find((h) => h.level === 1)?.title ?? '';
|
|
249
|
+
return {
|
|
250
|
+
data,
|
|
251
|
+
name,
|
|
252
|
+
purpose,
|
|
253
|
+
requirements: reqs,
|
|
254
|
+
decisions,
|
|
255
|
+
body,
|
|
256
|
+
// Keep the normalized source and its semantic snapshot. This lets callers
|
|
257
|
+
// round-trip a parsed contract without silently dropping sections the
|
|
258
|
+
// renderer does not own, while still falling back to canonical rendering
|
|
259
|
+
// after they edit any semantic field.
|
|
260
|
+
source,
|
|
261
|
+
snapshot: JSON.stringify({ data, name, purpose, requirements: reqs, decisions }),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function parseDelta(text) {
|
|
266
|
+
const out = { added: [], modified: [], removed: [], issues: [] };
|
|
267
|
+
const seen = new Set();
|
|
268
|
+
for (const s of sections(text, 2)) {
|
|
269
|
+
const m = s.title.match(/^(ADDED|MODIFIED|REMOVED)\s+Requirements?$/i);
|
|
270
|
+
if (!m) {
|
|
271
|
+
if (/Requirements?$/i.test(s.title)) out.issues.push(`unrecognized requirements section "${s.title}"`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const key = m[1].toLowerCase();
|
|
275
|
+
if (seen.has(key)) out.issues.push(`duplicate ${m[1].toUpperCase()} Requirements section`);
|
|
276
|
+
seen.add(key);
|
|
277
|
+
const before = out[key].length;
|
|
278
|
+
for (const r of sections(s.body, 3)) {
|
|
279
|
+
const rm = r.title.match(/^Requirement:\s*(.+)$/i);
|
|
280
|
+
if (rm) {
|
|
281
|
+
out[key].push({ name: rm[1].trim(), body: shiftHeadings(r.body.trim(), -1) });
|
|
282
|
+
} else if (/^Requirement:\s*$/i.test(r.title)) {
|
|
283
|
+
out.issues.push(`${m[1].toUpperCase()} Requirements has a requirement with no name`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Empty ADDED/MODIFIED sections are useful template no-ops. Prose (or an
|
|
287
|
+
// unrelated heading) in either section is almost certainly a malformed
|
|
288
|
+
// delta and must not be silently treated as an empty change.
|
|
289
|
+
if (['added', 'modified'].includes(key) && s.body.trim() && out[key].length === before) {
|
|
290
|
+
out.issues.push(`${m[1].toUpperCase()} Requirements has content but no Requirement sections`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (!seen.size) out.issues.push('contains no ADDED, MODIFIED, or REMOVED Requirements section');
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function shiftHeadings(text, by) {
|
|
298
|
+
const lines = normalizeNewlines(text).split('\n');
|
|
299
|
+
const byLine = new Map(headings(text).map((h) => [h.line, h]));
|
|
300
|
+
return lines.map((line, i) => {
|
|
301
|
+
const heading = byLine.get(i);
|
|
302
|
+
if (!heading || heading.level + by < 1) return line;
|
|
303
|
+
return line.replace(/^ {0,3}#+/, '#'.repeat(heading.level + by));
|
|
304
|
+
}).join('\n');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function hasScenario(text) {
|
|
308
|
+
return headings(text).some((heading) => heading.level >= 3 && /^Scenario:\s*\S/i.test(heading.title));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export const requirementKey = (name) => String(name ?? '').normalize('NFC').toLocaleLowerCase();
|
|
312
|
+
|
|
313
|
+
const renderRequirement = (level, requirement) => `${'#'.repeat(level)} Requirement: ${requirement.name}\n\n${requirement.body}\n`;
|
|
314
|
+
|
|
315
|
+
function headingRanges(text, level) {
|
|
316
|
+
const lines = normalizeNewlines(text).split('\n');
|
|
317
|
+
const found = headings(text).filter((h) => h.level === level);
|
|
318
|
+
return found.map((heading) => {
|
|
319
|
+
const next = headings(text).find((h) => h.line > heading.line && h.level <= level)?.line ?? lines.length;
|
|
320
|
+
return { heading, start: heading.line, end: next, raw: lines.slice(heading.line, next).join('\n') + (next < lines.length ? '\n' : '') };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function rewriteNestedRequirements(raw, take) {
|
|
325
|
+
const lines = raw.split('\n');
|
|
326
|
+
const ranges = headingRanges(raw, 3);
|
|
327
|
+
if (!ranges.length) return raw;
|
|
328
|
+
let out = lines.slice(0, ranges[0].start).join('\n');
|
|
329
|
+
if (ranges[0].start) out += '\n';
|
|
330
|
+
for (const range of ranges) {
|
|
331
|
+
const match = range.heading.title.match(/^Requirement:\s*(.+)$/i);
|
|
332
|
+
if (!match) { out += range.raw; continue; }
|
|
333
|
+
const current = take(match[1].trim());
|
|
334
|
+
if (!current) continue;
|
|
335
|
+
const oldBody = range.raw.split('\n').slice(1).join('\n').trim();
|
|
336
|
+
out += current.name === match[1].trim() && current.body === oldBody
|
|
337
|
+
? range.raw
|
|
338
|
+
: renderRequirement(3, current);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Update only Requirement blocks in a parsed source. Unknown sections, exact
|
|
345
|
+
* frontmatter, and fenced examples are copied verbatim instead of being
|
|
346
|
+
* reconstructed from the small semantic view used by Keelson.
|
|
347
|
+
*/
|
|
348
|
+
function renderUpdatedRequirements(spec) {
|
|
349
|
+
const available = spec.requirements.map((requirement) => ({ requirement, used: false }));
|
|
350
|
+
const take = (name) => {
|
|
351
|
+
const entry = available.find((candidate) => !candidate.used && requirementKey(candidate.requirement.name) === requirementKey(name));
|
|
352
|
+
if (entry) entry.used = true;
|
|
353
|
+
return entry?.requirement ?? null;
|
|
354
|
+
};
|
|
355
|
+
const lines = spec.source.split('\n');
|
|
356
|
+
const ranges = headingRanges(spec.source, 2);
|
|
357
|
+
const prefixEnd = ranges[0]?.start ?? lines.length;
|
|
358
|
+
let out = lines.slice(0, prefixEnd).join('\n') + (prefixEnd && prefixEnd < lines.length ? '\n' : '');
|
|
359
|
+
for (const range of ranges) {
|
|
360
|
+
const legacy = range.heading.title.match(/^Requirement:\s*(.+)$/i);
|
|
361
|
+
if (legacy) {
|
|
362
|
+
const current = take(legacy[1].trim());
|
|
363
|
+
if (!current) continue;
|
|
364
|
+
const oldBody = range.raw.split('\n').slice(1).join('\n').trim();
|
|
365
|
+
out += current.name === legacy[1].trim() && current.body === oldBody
|
|
366
|
+
? range.raw
|
|
367
|
+
: renderRequirement(2, current);
|
|
368
|
+
} else if (/^Requirements$/i.test(range.heading.title)) {
|
|
369
|
+
out += rewriteNestedRequirements(range.raw, take);
|
|
370
|
+
} else {
|
|
371
|
+
out += range.raw;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const added = available.filter((entry) => !entry.used).map((entry) => entry.requirement);
|
|
375
|
+
if (added.length) {
|
|
376
|
+
if (out && !out.endsWith('\n')) out += '\n';
|
|
377
|
+
if (out && !out.endsWith('\n\n')) out += '\n';
|
|
378
|
+
out += added.map((requirement) => renderRequirement(2, requirement)).join('\n');
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function renderSpec(spec) {
|
|
384
|
+
const { name, purpose = '', requirements = [], decisions = [] } = spec;
|
|
385
|
+
const now = JSON.stringify({
|
|
386
|
+
data: spec.data ?? {},
|
|
387
|
+
name: name ?? '',
|
|
388
|
+
purpose,
|
|
389
|
+
requirements,
|
|
390
|
+
decisions,
|
|
391
|
+
});
|
|
392
|
+
if (spec.source && spec.snapshot === now) return spec.source;
|
|
393
|
+
if (spec.source && spec.snapshot) {
|
|
394
|
+
const previous = JSON.parse(spec.snapshot);
|
|
395
|
+
// Landing only changes requirements and appends decisions. Preserve all
|
|
396
|
+
// foreign Markdown in that normal path; canonical rendering remains the
|
|
397
|
+
// fallback for callers that replace the document's other owned fields.
|
|
398
|
+
const decisionsAppendOnly = decisions.length >= previous.decisions.length
|
|
399
|
+
&& previous.decisions.every((decision, i) => decisions[i] === decision);
|
|
400
|
+
if (name === previous.name && purpose === previous.purpose && decisionsAppendOnly) {
|
|
401
|
+
let source = renderUpdatedRequirements({ ...spec, requirements });
|
|
402
|
+
const appended = decisions.slice(previous.decisions.length);
|
|
403
|
+
if (appended.length) {
|
|
404
|
+
const decisionSections = headingRanges(source, 2).filter((range) => /^Decisions?$/i.test(range.heading.title));
|
|
405
|
+
if (decisionSections.length) {
|
|
406
|
+
const last = decisionSections.at(-1);
|
|
407
|
+
const before = source.split('\n').slice(0, last.end).join('\n');
|
|
408
|
+
const after = source.split('\n').slice(last.end).join('\n');
|
|
409
|
+
source = `${before.replace(/\n*$/, '\n\n')}${appended.map((decision) => `- ${decision}`).join('\n')}\n${after}`;
|
|
410
|
+
} else {
|
|
411
|
+
source = `${source.replace(/\n*$/, '\n\n')}## Decisions\n\n${appended.map((decision) => `- ${decision}`).join('\n')}\n`;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return source;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const parts = [`# ${name}`, ''];
|
|
418
|
+
if (purpose) parts.push('## Purpose', '', purpose, '');
|
|
419
|
+
for (const r of requirements) parts.push(`## Requirement: ${r.name}`, '', r.body, '');
|
|
420
|
+
if (decisions?.length) parts.push('## Decisions', '', ...decisions.map((d) => `- ${d}`), '');
|
|
421
|
+
return parts.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Handoff: frontmatter (at, updated, by) + sections. Returns the "Next step" text for hooks and status. */
|
|
425
|
+
export function parseHandoff(text) {
|
|
426
|
+
const { data, body } = parseFrontmatter(text);
|
|
427
|
+
const secs = sections(body, 2);
|
|
428
|
+
const pick = (re) => secs.find((s) => re.test(s.title))?.body.trim() ?? '';
|
|
429
|
+
return {
|
|
430
|
+
at: data.at ?? null,
|
|
431
|
+
updated: data.updated ?? null,
|
|
432
|
+
by: data.by ?? null,
|
|
433
|
+
next: pick(/^Next/i),
|
|
434
|
+
open: pick(/^Open|^Blocked/i),
|
|
435
|
+
verification: pick(/^Verification/i),
|
|
436
|
+
body,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { PKG_ROOT, USER_HOME } from './paths.js';
|
|
6
|
+
import { readJson, writeJson, exists, readOr, mkdirp } from './fs.js';
|
|
7
|
+
import YAML from 'yaml';
|
|
8
|
+
|
|
9
|
+
export const TIERS = ['light', 'standard', 'deep'];
|
|
10
|
+
const REGISTRY_URL = 'https://raw.githubusercontent.com/Atingaii/keelson/main/registry/models.json';
|
|
11
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export const cachePath = () => path.join(USER_HOME, 'models.cache.json');
|
|
14
|
+
export const userOverridesPath = () => path.join(USER_HOME, 'models.yaml');
|
|
15
|
+
|
|
16
|
+
export function loadRegistry() {
|
|
17
|
+
const cached = readJson(path.join(USER_HOME, 'registry.json'));
|
|
18
|
+
const bundled = readJson(path.join(PKG_ROOT, 'registry', 'models.json'));
|
|
19
|
+
if (cached && bundled && (cached.updated ?? '') >= (bundled.updated ?? '')) return cached;
|
|
20
|
+
return bundled;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadUserOverrides() {
|
|
24
|
+
const raw = readOr(userOverridesPath(), '');
|
|
25
|
+
if (!raw.trim()) return {};
|
|
26
|
+
try {
|
|
27
|
+
return YAML.parse(raw) ?? {};
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveUserOverrides(obj) {
|
|
34
|
+
mkdirp(USER_HOME);
|
|
35
|
+
const header = '# Keelson user-level model overrides. platform -> tier -> alias. Aliases only, never dated IDs.\n';
|
|
36
|
+
fs.writeFileSync(userOverridesPath(), header + YAML.stringify(obj));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const which = (bin) => {
|
|
40
|
+
try {
|
|
41
|
+
execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const version = (bin) => {
|
|
49
|
+
try {
|
|
50
|
+
return execFileSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim().split('\n')[0];
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Local, network-free detection of tools, configured defaults, and provider keys. */
|
|
57
|
+
export function detectLocal() {
|
|
58
|
+
const home = os.homedir();
|
|
59
|
+
const tools = {};
|
|
60
|
+
const platforms = readJson(path.join(PKG_ROOT, 'registry', 'platforms.json'), { platforms: {} }).platforms;
|
|
61
|
+
for (const [id, p] of Object.entries(platforms)) {
|
|
62
|
+
if (!p.bin) {
|
|
63
|
+
tools[id] = { installed: null, version: null, defaultModel: null };
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const present = which(p.bin);
|
|
67
|
+
tools[id] = { installed: present, version: present ? version(p.bin) : null, defaultModel: null };
|
|
68
|
+
}
|
|
69
|
+
const claudeSettings = readJson(path.join(home, '.claude', 'settings.json'), {});
|
|
70
|
+
if (claudeSettings?.model) tools.claude.defaultModel = String(claudeSettings.model);
|
|
71
|
+
if (process.env.ANTHROPIC_MODEL) tools.claude.defaultModel = process.env.ANTHROPIC_MODEL;
|
|
72
|
+
const codexCfg = readOr(path.join(home, '.codex', 'config.toml'), '');
|
|
73
|
+
const cm = codexCfg.match(/^\s*model\s*=\s*"([^"]+)"/m);
|
|
74
|
+
if (cm) tools.codex.defaultModel = cm[1];
|
|
75
|
+
if (process.env.OPENAI_MODEL) tools.codex.defaultModel = process.env.OPENAI_MODEL;
|
|
76
|
+
|
|
77
|
+
const registry = loadRegistry();
|
|
78
|
+
const keys = {};
|
|
79
|
+
for (const [pid, p] of Object.entries(registry.providers ?? {})) keys[pid] = Boolean(process.env[p.env]);
|
|
80
|
+
|
|
81
|
+
return { detectedAt: new Date().toISOString(), tools, keys };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function loadCache() {
|
|
85
|
+
const c = readJson(cachePath());
|
|
86
|
+
if (!c) return null;
|
|
87
|
+
const age = Date.now() - new Date(c.detectedAt ?? 0).getTime();
|
|
88
|
+
return { ...c, stale: age > CACHE_TTL_MS };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function saveCache(c) {
|
|
92
|
+
mkdirp(USER_HOME);
|
|
93
|
+
writeJson(cachePath(), c);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function detectAndCache() {
|
|
97
|
+
const c = { ...detectLocal(), unranked: loadCache()?.unranked ?? [] };
|
|
98
|
+
saveCache(c);
|
|
99
|
+
return c;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve tier → alias for a platform.
|
|
104
|
+
* Order: explicit → project config → user overrides → registry → platform rank fallback.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveTier(tier, { platform = 'claude', projectModels = {}, explicit = null } = {}) {
|
|
107
|
+
if (!TIERS.includes(tier)) throw new Error(`unknown tier "${tier}" (expected ${TIERS.join('|')})`);
|
|
108
|
+
if (explicit) return { alias: explicit, source: 'explicit' };
|
|
109
|
+
const proj = projectModels?.[platform]?.[tier] ?? projectModels?.[tier];
|
|
110
|
+
if (proj) return { alias: proj, source: 'project config' };
|
|
111
|
+
const user = loadUserOverrides()?.[platform]?.[tier];
|
|
112
|
+
if (user) return { alias: user, source: 'user overrides' };
|
|
113
|
+
const reg = loadRegistry().platforms?.[platform];
|
|
114
|
+
if (reg?.tiers?.[tier]) return { alias: reg.tiers[tier], source: 'registry' };
|
|
115
|
+
if (reg?.rank?.length) {
|
|
116
|
+
const r = reg.rank;
|
|
117
|
+
const pick = { light: r[0], standard: r[Math.min(1, r.length - 1)], deep: r[r.length - 1] }[tier];
|
|
118
|
+
return { alias: pick, source: 'registry rank fallback' };
|
|
119
|
+
}
|
|
120
|
+
return { alias: null, source: 'unresolved' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveAll(platform, projectModels) {
|
|
124
|
+
return Object.fromEntries(TIERS.map((t) => [t, resolveTier(t, { platform, projectModels })]));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Infer a tier from a family rank list and a model name. Returns null when unsure. */
|
|
128
|
+
export function inferTierFromName(name, families) {
|
|
129
|
+
const n = name.toLowerCase();
|
|
130
|
+
const ordered = families.filter((f) => f !== '');
|
|
131
|
+
const hits = ordered.filter((f) => n.includes(f));
|
|
132
|
+
if (hits.length !== 1) return null;
|
|
133
|
+
const idx = families.indexOf(hits[0]);
|
|
134
|
+
const last = families.length - 1;
|
|
135
|
+
if (idx === 0) return 'light';
|
|
136
|
+
if (idx === last) return 'deep';
|
|
137
|
+
return 'standard';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function refresh({ fetchImpl = globalThis.fetch, withProviders = true } = {}) {
|
|
141
|
+
const report = { registry: 'unchanged', providers: {}, newModels: [] };
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetchImpl(REGISTRY_URL, { signal: AbortSignal.timeout(8000) });
|
|
144
|
+
if (res.ok) {
|
|
145
|
+
const remote = await res.json();
|
|
146
|
+
const local = loadRegistry();
|
|
147
|
+
if ((remote.updated ?? '') > (local.updated ?? '')) {
|
|
148
|
+
mkdirp(USER_HOME);
|
|
149
|
+
writeJson(path.join(USER_HOME, 'registry.json'), remote);
|
|
150
|
+
report.registry = `updated to ${remote.updated}`;
|
|
151
|
+
}
|
|
152
|
+
} else report.registry = `fetch failed (${res.status})`;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
report.registry = `offline (${e.name})`;
|
|
155
|
+
}
|
|
156
|
+
if (!withProviders) return report;
|
|
157
|
+
const registry = loadRegistry();
|
|
158
|
+
const cache = loadCache() ?? detectAndCache();
|
|
159
|
+
const known = new Set((cache.knownModels ?? []).map((m) => m.id));
|
|
160
|
+
for (const [pid, p] of Object.entries(registry.providers ?? {})) {
|
|
161
|
+
const key = process.env[p.env];
|
|
162
|
+
if (!key || !p.catalogue) {
|
|
163
|
+
report.providers[pid] = key ? 'no catalogue endpoint' : 'no key';
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const headers = pid === 'anthropic' ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : { Authorization: `Bearer ${key}` };
|
|
168
|
+
const res = await fetchImpl(p.catalogue, { headers, signal: AbortSignal.timeout(8000) });
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
report.providers[pid] = `HTTP ${res.status}`;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const body = await res.json();
|
|
174
|
+
const ids = (body.data ?? []).map((m) => m.id).filter(Boolean);
|
|
175
|
+
report.providers[pid] = `${ids.length} models`;
|
|
176
|
+
for (const id of ids) {
|
|
177
|
+
if (known.has(id)) continue;
|
|
178
|
+
known.add(id);
|
|
179
|
+
const inferred = inferTierFromName(id, p.families ?? []);
|
|
180
|
+
const entry = { id, provider: pid, seen: new Date().toISOString().slice(0, 10), inferredTier: inferred };
|
|
181
|
+
(cache.knownModels ??= []).push(entry);
|
|
182
|
+
if (!inferred) (cache.unranked ??= []).push(entry);
|
|
183
|
+
report.newModels.push(entry);
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
report.providers[pid] = `error (${e.name})`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
saveCache(cache);
|
|
190
|
+
return report;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function datedIdPatterns() {
|
|
194
|
+
return (loadRegistry().dated_id_patterns ?? []).map((p) => new RegExp(p, 'i'));
|
|
195
|
+
}
|
package/src/lib/out.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
const c = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
3
|
+
export const bold = c(1);
|
|
4
|
+
export const dim = c(2);
|
|
5
|
+
export const green = c(32);
|
|
6
|
+
export const yellow = c(33);
|
|
7
|
+
export const red = c(31);
|
|
8
|
+
export const cyan = c(36);
|
|
9
|
+
export const ok = (s) => console.log(`${green('✓')} ${s}`);
|
|
10
|
+
export const warn = (s) => console.log(`${yellow('!')} ${s}`);
|
|
11
|
+
export const fail = (s) => console.log(`${red('✗')} ${s}`);
|
|
12
|
+
export const info = (s) => console.log(`${cyan('·')} ${s}`);
|
|
13
|
+
export const heading = (s) => console.log(bold(s));
|