lazyclaw 4.2.2 → 5.0.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/README.ko.md +44 -0
- package/README.md +172 -353
- package/agents.mjs +19 -3
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +730 -27
- package/daemon.mjs +111 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +30 -1
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/mention_router.mjs +75 -4
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +331 -0
- package/mas/tool_runner.mjs +19 -48
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/skill_view.mjs +43 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +22 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Skill lifecycle curator — usage tracking, freshness classification and
|
|
2
|
+
// recoverable archival layered over the <configDir>/skills/ store from
|
|
3
|
+
// skills.mjs.
|
|
4
|
+
//
|
|
5
|
+
// Skills accrete: agents synthesise new ones on the fly (created_by:
|
|
6
|
+
// agent) and most are never touched again. Left unchecked the recall
|
|
7
|
+
// index (skillsIndex) bloats the system prompt with dead weight. This
|
|
8
|
+
// module ages skills out: it counts how often each is recalled, marks
|
|
9
|
+
// them active / stale / archived by idle time, and physically moves the
|
|
10
|
+
// long-idle *agent-authored* skills into <configDir>/skills/.archive/
|
|
11
|
+
// where they no longer appear in listSkills but remain recoverable.
|
|
12
|
+
//
|
|
13
|
+
// Human-authored skills are NEVER archived — they're curated by people,
|
|
14
|
+
// not garbage to be collected.
|
|
15
|
+
//
|
|
16
|
+
// Determinism: every time-dependent function takes an injected `now`
|
|
17
|
+
// (epoch-ms). The core logic never reads the wall-clock, so the 30d /
|
|
18
|
+
// 90d boundaries are exactly reproducible in tests.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
defaultConfigDir,
|
|
25
|
+
skillsDir,
|
|
26
|
+
skillPath,
|
|
27
|
+
listSkills,
|
|
28
|
+
parseFrontmatter,
|
|
29
|
+
} from './skills.mjs';
|
|
30
|
+
|
|
31
|
+
// Freshness windows, measured in idle time since lastUsedAt.
|
|
32
|
+
// active : used within the last 30 days
|
|
33
|
+
// stale : 30..90 days idle
|
|
34
|
+
// archived : 90+ days idle (and, for curate(), agent-authored)
|
|
35
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
export const STALE_MS = 30 * DAY_MS;
|
|
37
|
+
export const ARCHIVE_MS = 90 * DAY_MS;
|
|
38
|
+
|
|
39
|
+
const USAGE_FILENAME = '.usage.json';
|
|
40
|
+
const ARCHIVE_DIRNAME = '.archive';
|
|
41
|
+
const SKILL_EXT = '.md';
|
|
42
|
+
|
|
43
|
+
// Guard for the injected clock. The whole module is deterministic only
|
|
44
|
+
// because `now` is supplied by the caller; a NaN / Infinity / non-number
|
|
45
|
+
// would make every idle comparison false and silently classify all
|
|
46
|
+
// skills as 'archived', so we refuse it loudly instead.
|
|
47
|
+
function assertFiniteNow(now) {
|
|
48
|
+
if (typeof now !== 'number' || !Number.isFinite(now)) {
|
|
49
|
+
throw new TypeError(`now must be a finite number (got ${String(now)})`);
|
|
50
|
+
}
|
|
51
|
+
return now;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Validate a skill identifier used as a key into the usage store.
|
|
55
|
+
// Reserved prototype keys ('__proto__' etc.) are NOT rejected here:
|
|
56
|
+
// the store is a null-prototype object, so they round-trip safely as
|
|
57
|
+
// ordinary own properties. We only insist on a non-empty string so a
|
|
58
|
+
// number / undefined / object can never become a phantom key.
|
|
59
|
+
function assertSkillKey(name) {
|
|
60
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
61
|
+
throw new TypeError(`skill name must be a non-empty string (got ${String(name)})`);
|
|
62
|
+
}
|
|
63
|
+
return name;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function usagePath(configDir = defaultConfigDir()) {
|
|
67
|
+
return path.join(skillsDir(configDir), USAGE_FILENAME);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function archiveDir(configDir = defaultConfigDir()) {
|
|
71
|
+
return path.join(skillsDir(configDir), ARCHIVE_DIRNAME);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Read the whole usage map ({ <name>: { views, uses, lastUsedAt } }).
|
|
75
|
+
// A missing or corrupt file is treated as empty so a single bad write
|
|
76
|
+
// never bricks the tracker.
|
|
77
|
+
//
|
|
78
|
+
// The returned map is a null-prototype object, and only OWN enumerable
|
|
79
|
+
// keys of the parsed JSON are copied in. This is a hard prototype-
|
|
80
|
+
// pollution boundary: a hostile usage file carrying a `__proto__` /
|
|
81
|
+
// `constructor` payload can neither poison Object.prototype nor leak an
|
|
82
|
+
// inherited counter into an unrelated skill's read.
|
|
83
|
+
function readUsageStore(configDir = defaultConfigDir()) {
|
|
84
|
+
const p = usagePath(configDir);
|
|
85
|
+
const store = Object.create(null);
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
89
|
+
} catch {
|
|
90
|
+
return store;
|
|
91
|
+
}
|
|
92
|
+
if (!parsed || typeof parsed !== 'object') return store;
|
|
93
|
+
for (const key of Object.keys(parsed)) {
|
|
94
|
+
// JSON.parse already refuses to set a real `__proto__` accessor, but
|
|
95
|
+
// a literal "__proto__" string key still arrives as an own property;
|
|
96
|
+
// copy it onto the null-prototype map as plain data.
|
|
97
|
+
Object.defineProperty(store, key, {
|
|
98
|
+
value: parsed[key],
|
|
99
|
+
writable: true,
|
|
100
|
+
enumerable: true,
|
|
101
|
+
configurable: true,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return store;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Atomic write: serialise to a sibling temp file then rename over the
|
|
108
|
+
// target so a crash mid-write can never leave a half-written store.
|
|
109
|
+
function writeUsageStore(store, configDir = defaultConfigDir()) {
|
|
110
|
+
const dir = skillsDir(configDir);
|
|
111
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
112
|
+
const p = usagePath(configDir);
|
|
113
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
114
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
|
|
115
|
+
fs.renameSync(tmp, p);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Normalise a raw record into the full counter shape with sane zeros so
|
|
119
|
+
// callers never have to null-check individual fields.
|
|
120
|
+
function normalizeRecord(raw) {
|
|
121
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
122
|
+
return {
|
|
123
|
+
views: Number.isFinite(r.views) ? r.views : 0,
|
|
124
|
+
uses: Number.isFinite(r.uses) ? r.uses : 0,
|
|
125
|
+
lastUsedAt: Number.isFinite(r.lastUsedAt) ? r.lastUsedAt : 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bump a skill's usage counters and stamp lastUsedAt with the injected
|
|
131
|
+
* `now`. Both `views` and `uses` increment on each call — they're kept
|
|
132
|
+
* separate so a future "viewed the index but didn't pull the body"
|
|
133
|
+
* signal can diverge from a real recall without a schema change.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} name
|
|
136
|
+
* @param {string} [configDir]
|
|
137
|
+
* @param {number} now epoch-ms, injected (never read from the clock)
|
|
138
|
+
* @returns {{views:number, uses:number, lastUsedAt:number}}
|
|
139
|
+
*/
|
|
140
|
+
export function recordUsage(name, configDir = defaultConfigDir(), now) {
|
|
141
|
+
assertFiniteNow(now);
|
|
142
|
+
const key = assertSkillKey(name);
|
|
143
|
+
const store = readUsageStore(configDir);
|
|
144
|
+
// store is null-prototype, so store[key] never reaches the prototype
|
|
145
|
+
// chain even for reserved names like '__proto__'; Object.hasOwn keeps
|
|
146
|
+
// reads explicit regardless.
|
|
147
|
+
const rec = normalizeRecord(Object.hasOwn(store, key) ? store[key] : undefined);
|
|
148
|
+
rec.views += 1;
|
|
149
|
+
rec.uses += 1;
|
|
150
|
+
rec.lastUsedAt = now;
|
|
151
|
+
store[key] = rec;
|
|
152
|
+
writeUsageStore(store, configDir);
|
|
153
|
+
return rec;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read a skill's counters. Returns a zeroed record (never throws) when
|
|
158
|
+
* the skill has no recorded usage yet.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} name
|
|
161
|
+
* @param {string} [configDir]
|
|
162
|
+
* @returns {{views:number, uses:number, lastUsedAt:number}}
|
|
163
|
+
*/
|
|
164
|
+
export function usageOf(name, configDir = defaultConfigDir()) {
|
|
165
|
+
const key = assertSkillKey(name);
|
|
166
|
+
const store = readUsageStore(configDir);
|
|
167
|
+
// Object.hasOwn guards against pulling an inherited value out of a
|
|
168
|
+
// hostile usage file; the store is null-prototype anyway, but this
|
|
169
|
+
// keeps the read intent explicit and prototype-safe.
|
|
170
|
+
return normalizeRecord(Object.hasOwn(store, key) ? store[key] : undefined);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Classify a skill by how long it has been idle relative to the
|
|
175
|
+
* injected `now`. A skill that has never been used (lastUsedAt 0) is
|
|
176
|
+
* treated as maximally idle, so at any real epoch it classifies as
|
|
177
|
+
* 'archived'.
|
|
178
|
+
*
|
|
179
|
+
* idle < 30d → 'active'
|
|
180
|
+
* 30d <= idle < 90d → 'stale'
|
|
181
|
+
* idle >= 90d → 'archived'
|
|
182
|
+
*
|
|
183
|
+
* @param {string} skillName
|
|
184
|
+
* @param {string} [configDir]
|
|
185
|
+
* @param {number} now epoch-ms, injected
|
|
186
|
+
* @returns {'active'|'stale'|'archived'}
|
|
187
|
+
*/
|
|
188
|
+
export function classify(skillName, configDir = defaultConfigDir(), now) {
|
|
189
|
+
assertFiniteNow(now);
|
|
190
|
+
const { lastUsedAt } = usageOf(skillName, configDir);
|
|
191
|
+
const idle = now - lastUsedAt;
|
|
192
|
+
if (idle < STALE_MS) return 'active';
|
|
193
|
+
if (idle < ARCHIVE_MS) return 'stale';
|
|
194
|
+
return 'archived';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Read a skill's created_by frontmatter without surfacing read errors —
|
|
198
|
+
// an unreadable skill is treated as having no author, which keeps it out
|
|
199
|
+
// of the agent-only archival path (we never auto-archive what we can't
|
|
200
|
+
// positively identify as agent-authored).
|
|
201
|
+
function createdByOf(name, configDir) {
|
|
202
|
+
try {
|
|
203
|
+
const { meta } = parseFrontmatter(fs.readFileSync(skillPath(name, configDir), 'utf8'));
|
|
204
|
+
return meta.created_by || '';
|
|
205
|
+
} catch {
|
|
206
|
+
return '';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Sweep the live skills store and age skills out by freshness. Skills
|
|
212
|
+
* that have been idle for 90+ days AND were agent-authored
|
|
213
|
+
* (created_by: agent) are physically moved into
|
|
214
|
+
* <configDir>/skills/.archive/<name>.md — recoverable, but no longer
|
|
215
|
+
* surfaced by listSkills/skillsIndex. Human-authored skills are never
|
|
216
|
+
* touched, even when long idle (they're reported under `stale` so the
|
|
217
|
+
* caller can still see them).
|
|
218
|
+
*
|
|
219
|
+
* Each skill's body runs in its own try/catch so a single malformed
|
|
220
|
+
* entry (e.g. a leading-dot name that skillPath rejects) is skipped and
|
|
221
|
+
* collected under `invalid` rather than aborting the whole sweep.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} [configDir]
|
|
224
|
+
* @param {number} now epoch-ms, injected
|
|
225
|
+
* @returns {{archived:string[], stale:string[], active:string[], invalid:string[]}}
|
|
226
|
+
*/
|
|
227
|
+
export function curate(configDir = defaultConfigDir(), now) {
|
|
228
|
+
assertFiniteNow(now);
|
|
229
|
+
const result = { archived: [], stale: [], active: [], invalid: [] };
|
|
230
|
+
|
|
231
|
+
// listSkills already filters to top-level .md files, so the archive
|
|
232
|
+
// subdir is naturally excluded from this sweep.
|
|
233
|
+
for (const skill of listSkills(configDir)) {
|
|
234
|
+
try {
|
|
235
|
+
const bucket = classify(skill.name, configDir, now);
|
|
236
|
+
if (bucket === 'active') {
|
|
237
|
+
result.active.push(skill.name);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (bucket === 'stale') {
|
|
241
|
+
result.stale.push(skill.name);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// bucket === 'archived': only physically archive agent-authored
|
|
245
|
+
// skills. Human-authored idle skills are reported as stale instead
|
|
246
|
+
// of being moved — people curate those, we don't.
|
|
247
|
+
const createdBy = skill.createdBy || createdByOf(skill.name, configDir);
|
|
248
|
+
if (createdBy !== 'agent') {
|
|
249
|
+
result.stale.push(skill.name);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
moveToArchive(skill.name, configDir, now);
|
|
253
|
+
// Drop the stale usage record so a later same-name skill starts
|
|
254
|
+
// from a clean slate instead of inheriting this lastUsedAt (which
|
|
255
|
+
// would get it re-archived before its first real use).
|
|
256
|
+
dropUsageRecord(skill.name, configDir);
|
|
257
|
+
result.archived.push(skill.name);
|
|
258
|
+
} catch {
|
|
259
|
+
// A single bad skill (invalid name, unreadable file, failed move)
|
|
260
|
+
// must never abort curation of the rest. Collect it and move on.
|
|
261
|
+
result.invalid.push(skill.name);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Move a single skill file from the live store into .archive/, creating
|
|
269
|
+
// the archive dir on demand. rename is atomic on the same filesystem;
|
|
270
|
+
// fall back to copy+unlink for cross-device stores (e.g. tmpfs vs disk).
|
|
271
|
+
//
|
|
272
|
+
// On a destination collision (a prior archived copy of the same name)
|
|
273
|
+
// the new copy is disambiguated as <name>.<archivedAtMs>.md so the
|
|
274
|
+
// earlier, recoverable archive is never clobbered.
|
|
275
|
+
function moveToArchive(name, configDir, archivedAtMs) {
|
|
276
|
+
const src = skillPath(name, configDir);
|
|
277
|
+
const destDir = archiveDir(configDir);
|
|
278
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
279
|
+
const dest = pickArchiveDest(destDir, name, archivedAtMs);
|
|
280
|
+
try {
|
|
281
|
+
fs.renameSync(src, dest);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (err && err.code === 'EXDEV') {
|
|
284
|
+
fs.copyFileSync(src, dest);
|
|
285
|
+
fs.unlinkSync(src);
|
|
286
|
+
} else {
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return dest;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Choose a non-colliding archive destination. The preferred path is
|
|
294
|
+
// <name>.md; if that already holds a prior archive, fall back to
|
|
295
|
+
// <name>.<archivedAtMs>.md, and if even that is taken (same name
|
|
296
|
+
// archived twice at the same instant) append an incrementing suffix.
|
|
297
|
+
function pickArchiveDest(destDir, name, archivedAtMs) {
|
|
298
|
+
const preferred = path.join(destDir, `${name}${SKILL_EXT}`);
|
|
299
|
+
if (!fs.existsSync(preferred)) return preferred;
|
|
300
|
+
const stampBase = `${name}.${archivedAtMs}`;
|
|
301
|
+
let candidate = path.join(destDir, `${stampBase}${SKILL_EXT}`);
|
|
302
|
+
for (let i = 1; fs.existsSync(candidate) && i < 1000; i++) {
|
|
303
|
+
candidate = path.join(destDir, `${stampBase}.${i}${SKILL_EXT}`);
|
|
304
|
+
}
|
|
305
|
+
// Never return a path that still exists — clobbering a prior archived
|
|
306
|
+
// copy would break the "recoverable" guarantee. curate() surfaces this
|
|
307
|
+
// skill under `invalid` instead.
|
|
308
|
+
if (fs.existsSync(candidate)) {
|
|
309
|
+
throw new Error(`archive destination exhausted for "${name}"`);
|
|
310
|
+
}
|
|
311
|
+
return candidate;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Remove a skill's usage record (used after archival so a re-created
|
|
315
|
+
// same-name skill does not inherit a stale lastUsedAt). No-op when the
|
|
316
|
+
// skill has no recorded usage.
|
|
317
|
+
function dropUsageRecord(name, configDir) {
|
|
318
|
+
const key = assertSkillKey(name);
|
|
319
|
+
const store = readUsageStore(configDir);
|
|
320
|
+
if (!Object.hasOwn(store, key)) return;
|
|
321
|
+
delete store[key];
|
|
322
|
+
writeUsageStore(store, configDir);
|
|
323
|
+
}
|
package/workspace.mjs
CHANGED
|
@@ -22,9 +22,10 @@ import fs from 'node:fs';
|
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
24
|
const FILES = [
|
|
25
|
-
{ name: 'AGENTS.md',
|
|
26
|
-
{ name: 'SOUL.md',
|
|
27
|
-
{ name: 'TOOLS.md',
|
|
25
|
+
{ name: 'AGENTS.md', heading: 'AGENTS — what to do' },
|
|
26
|
+
{ name: 'SOUL.md', heading: 'SOUL — how to behave' },
|
|
27
|
+
{ name: 'TOOLS.md', heading: 'TOOLS — what is available' },
|
|
28
|
+
{ name: 'HEARTBEAT.md', heading: 'HEARTBEAT — proactive routines' },
|
|
28
29
|
];
|
|
29
30
|
|
|
30
31
|
export function workspaceRoot(cfgDir) {
|
|
@@ -109,6 +110,20 @@ What the assistant can reach for, and how to invoke each one.
|
|
|
109
110
|
- \`lazyclaw agent ...\` — one-shot LLM call
|
|
110
111
|
|
|
111
112
|
Add project-specific tools below.
|
|
113
|
+
`,
|
|
114
|
+
'HEARTBEAT.md':
|
|
115
|
+
`# Heartbeat
|
|
116
|
+
|
|
117
|
+
Proactive routines the assistant runs on a schedule — the things it
|
|
118
|
+
should do WITHOUT being asked. Wire each to a real trigger with
|
|
119
|
+
\`lazyclaw cron add <name> --schedule "<cron>"\` (or a goal's --cron).
|
|
120
|
+
|
|
121
|
+
- Every morning: summarise overnight changes and post to #standup.
|
|
122
|
+
- Hourly: check the deploy queue; alert on a stuck job.
|
|
123
|
+
- When idle: review recently-used skills (\`lazyclaw skills curate\`).
|
|
124
|
+
|
|
125
|
+
Keep routines reversible and cheap; a heartbeat that mutates state
|
|
126
|
+
should ask first or operate on a dry-run by default.
|
|
112
127
|
`,
|
|
113
128
|
};
|
|
114
129
|
for (const [name, body] of Object.entries(stubs)) {
|