@deftai/directive-core 0.89.0 → 0.91.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/dist/doctor/constants.d.ts +1 -1
- package/dist/doctor/constants.js +2 -0
- package/dist/doctor/flags.js +21 -1
- package/dist/doctor/index.d.ts +1 -0
- package/dist/doctor/index.js +1 -0
- package/dist/doctor/main.js +22 -0
- package/dist/doctor/openclaw-skills.d.ts +143 -0
- package/dist/doctor/openclaw-skills.js +706 -0
- package/dist/doctor/types.d.ts +11 -0
- package/dist/eval/health.d.ts +10 -1
- package/dist/eval/health.js +16 -16
- package/dist/hooks/dispatcher.js +20 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init-deposit/hygiene.js +3 -0
- package/dist/init-deposit/init-deposit.d.ts +5 -0
- package/dist/init-deposit/init-deposit.js +37 -2
- package/dist/intake/github-auth-modes.d.ts +1 -1
- package/dist/intake/github-auth-modes.js +25 -3
- package/dist/intake/issue-ingest-cli.js +13 -0
- package/dist/intake/reconcile-issues-cli.js +13 -0
- package/dist/lifecycle/events.js +2 -0
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.js +1 -0
- package/dist/lifecycle/stats.d.ts +53 -0
- package/dist/lifecycle/stats.js +286 -0
- package/dist/policy/plan-extensions.d.ts +10 -0
- package/dist/policy/plan-extensions.js +16 -0
- package/dist/pr-closing-keywords/detect.d.ts +13 -0
- package/dist/pr-closing-keywords/detect.js +73 -8
- package/dist/pr-closing-keywords/index.d.ts +2 -2
- package/dist/pr-closing-keywords/index.js +1 -1
- package/dist/pr-closing-keywords/main.js +119 -90
- package/dist/pr-closing-keywords/types.d.ts +10 -0
- package/dist/release/spawn.js +13 -0
- package/dist/release-e2e/git-ops.d.ts +7 -0
- package/dist/release-e2e/git-ops.js +35 -2
- package/dist/render/constants.d.ts +16 -0
- package/dist/render/constants.js +16 -1
- package/dist/render/export-spec.d.ts +7 -1
- package/dist/render/export-spec.js +51 -5
- package/dist/render/index.d.ts +3 -2
- package/dist/render/index.js +2 -2
- package/dist/render/roadmap-render.js +164 -60
- package/dist/render/scope-outlook.d.ts +5 -0
- package/dist/render/scope-outlook.js +3 -0
- package/dist/render/spec-render.d.ts +28 -2
- package/dist/render/spec-render.js +111 -14
- package/dist/render/text-utils.d.ts +6 -0
- package/dist/render/text-utils.js +23 -0
- package/dist/scm/binary.d.ts +3 -0
- package/dist/scm/binary.js +10 -2
- package/dist/scm/call.d.ts +5 -0
- package/dist/scm/call.js +5 -0
- package/dist/scm/index.d.ts +1 -0
- package/dist/scm/index.js +1 -0
- package/dist/scm/main.d.ts +5 -0
- package/dist/scm/main.js +27 -0
- package/dist/scm/readiness-cli.d.ts +24 -0
- package/dist/scm/readiness-cli.js +85 -0
- package/dist/scm/readiness.d.ts +104 -0
- package/dist/scm/readiness.js +421 -0
- package/dist/scope/batch-promote.d.ts +26 -0
- package/dist/scope/batch-promote.js +167 -0
- package/dist/scope/index.d.ts +1 -0
- package/dist/scope/index.js +1 -0
- package/dist/scope/main.d.ts +2 -0
- package/dist/scope/main.js +58 -7
- package/dist/scope/wip-cap-check.d.ts +6 -0
- package/dist/scope/wip-cap-check.js +14 -2
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/process-cost-constants.d.ts +9 -0
- package/dist/session/process-cost-constants.js +11 -0
- package/dist/session/process-cost.d.ts +53 -0
- package/dist/session/process-cost.js +82 -0
- package/dist/session/ritual-sentinel.d.ts +5 -0
- package/dist/session/ritual-sentinel.js +12 -1
- package/dist/session/session-ready.d.ts +67 -0
- package/dist/session/session-ready.js +264 -0
- package/dist/session/session-start.d.ts +63 -1
- package/dist/session/session-start.js +432 -24
- package/dist/session/verify-session-ritual.d.ts +8 -0
- package/dist/session/verify-session-ritual.js +95 -35
- package/dist/tool-events/classify.d.ts +20 -0
- package/dist/tool-events/classify.js +634 -0
- package/dist/tool-events/index.d.ts +10 -0
- package/dist/tool-events/index.js +10 -0
- package/dist/tool-events/summarize.d.ts +34 -0
- package/dist/tool-events/summarize.js +93 -0
- package/dist/tool-events/types.d.ts +52 -0
- package/dist/tool-events/types.js +13 -0
- package/dist/triage/welcome/index.d.ts +1 -1
- package/dist/triage/welcome/index.js +1 -1
- package/dist/triage/welcome/onboard.js +7 -1
- package/dist/triage/welcome/prior-state.d.ts +7 -0
- package/dist/triage/welcome/prior-state.js +16 -2
- package/dist/triage/welcome/writers.d.ts +10 -0
- package/dist/triage/welcome/writers.js +72 -2
- package/package.json +7 -3
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw always-pin skill detect + doctor --fix wire (#3001 / #3008).
|
|
3
|
+
*
|
|
4
|
+
* When OpenClaw signals are present, doctor checks the main workspace skills
|
|
5
|
+
* root for the four always-pin skills (#2508). Missing pins emit a warning with
|
|
6
|
+
* remediation `deft doctor --fix`. Under fixMode, pins are **copied** into the
|
|
7
|
+
* workspace skills root (not symlinked into the npm content package).
|
|
8
|
+
*
|
|
9
|
+
* OpenClaw 2026.7.x rejects workspace skills that resolve outside the configured
|
|
10
|
+
* skills root (`reason=symlink-escape`). A pin that is only a symlink into
|
|
11
|
+
* `@deftai/directive-content` therefore looks "present" on disk while the host
|
|
12
|
+
* never loads it (#3008). Prefer real directory copies; treat escaping
|
|
13
|
+
* symlinks as divergent so `--fix` can replace them.
|
|
14
|
+
*
|
|
15
|
+
* Multi-seat (`workspace-*`) targets only when `--openclaw-all-agents` is set.
|
|
16
|
+
*/
|
|
17
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { contentRoot } from "../content-root.js";
|
|
21
|
+
/** Stable doctor check id for JSON findings. */
|
|
22
|
+
export const OPENCLAW_SKILL_PINS_CHECK = "openclaw-skill-pins";
|
|
23
|
+
/** Always-pin skill directory names (#2508 / cold-start). */
|
|
24
|
+
export const OPENCLAW_ALWAYS_PIN_SKILLS = [
|
|
25
|
+
"deft-directive-build",
|
|
26
|
+
"deft-directive-pre-pr",
|
|
27
|
+
"deft-directive-review-cycle",
|
|
28
|
+
"deft-directive-swarm",
|
|
29
|
+
];
|
|
30
|
+
/** Env keys that count as strong OpenClaw signals (#3001). */
|
|
31
|
+
export const OPENCLAW_ENV_SIGNAL_KEYS = [
|
|
32
|
+
"OPENCLAW",
|
|
33
|
+
"DEFT_PROBE_OPENCLAW",
|
|
34
|
+
"DEFT_AGENT_RUNTIME",
|
|
35
|
+
"OPENCLAW_STATE_DIR",
|
|
36
|
+
];
|
|
37
|
+
const DOC_OPENCLAW_HOST = "docs/openclaw-agent-host.md";
|
|
38
|
+
const DOC_HOST_LIFECYCLE = "contracts/host-lifecycle-duties.md";
|
|
39
|
+
const DOC_SKILL_PINS = "docs/skill-pin-policy.md";
|
|
40
|
+
const REMEDIATION_FIX = "deft doctor --fix";
|
|
41
|
+
const REMEDIATION_ALL_AGENTS = "deft doctor --fix --openclaw-all-agents";
|
|
42
|
+
function defaultIsDir(path) {
|
|
43
|
+
try {
|
|
44
|
+
return statSync(path).isDirectory();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function defaultIsFile(path) {
|
|
51
|
+
try {
|
|
52
|
+
return statSync(path).isFile();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function defaultPathExists(path) {
|
|
59
|
+
return existsSync(path);
|
|
60
|
+
}
|
|
61
|
+
function defaultLstatKind(path) {
|
|
62
|
+
try {
|
|
63
|
+
const st = lstatSync(path);
|
|
64
|
+
if (st.isSymbolicLink())
|
|
65
|
+
return "symlink";
|
|
66
|
+
if (st.isDirectory())
|
|
67
|
+
return "dir";
|
|
68
|
+
if (st.isFile())
|
|
69
|
+
return "file";
|
|
70
|
+
return "other";
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return "missing";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function defaultReadDir(path) {
|
|
77
|
+
try {
|
|
78
|
+
return readdirSync(path);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function envTruthy(value) {
|
|
85
|
+
if (value === undefined)
|
|
86
|
+
return false;
|
|
87
|
+
const v = value.trim().toLowerCase();
|
|
88
|
+
if (v === "" || v === "0" || v === "false" || v === "no" || v === "off")
|
|
89
|
+
return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve OpenClaw state directory: OPENCLAW_STATE_DIR or ~/.openclaw.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveOpenClawStateDir(env = process.env, home = homedir()) {
|
|
96
|
+
const fromEnv = env.OPENCLAW_STATE_DIR?.trim();
|
|
97
|
+
if (fromEnv)
|
|
98
|
+
return resolve(fromEnv);
|
|
99
|
+
return resolve(home, ".openclaw");
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Main agent workspace skills root under the state dir.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveMainSkillsDir(stateDir) {
|
|
105
|
+
return join(stateDir, "workspace", "skills");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Detect OpenClaw from env signals and/or presence of the state directory.
|
|
109
|
+
*/
|
|
110
|
+
export function detectOpenClaw(env = process.env, options = {}) {
|
|
111
|
+
const home = options.homeDir ?? homedir();
|
|
112
|
+
const isDir = options.isDir ?? defaultIsDir;
|
|
113
|
+
const stateDir = resolveOpenClawStateDir(env, home);
|
|
114
|
+
const reasons = [];
|
|
115
|
+
if (envTruthy(env.OPENCLAW))
|
|
116
|
+
reasons.push("env:OPENCLAW");
|
|
117
|
+
if (envTruthy(env.DEFT_PROBE_OPENCLAW))
|
|
118
|
+
reasons.push("env:DEFT_PROBE_OPENCLAW");
|
|
119
|
+
if ((env.DEFT_AGENT_RUNTIME ?? "").trim().toLowerCase() === "openclaw") {
|
|
120
|
+
reasons.push("env:DEFT_AGENT_RUNTIME=openclaw");
|
|
121
|
+
}
|
|
122
|
+
if (env.OPENCLAW_STATE_DIR?.trim())
|
|
123
|
+
reasons.push("env:OPENCLAW_STATE_DIR");
|
|
124
|
+
if (isDir(stateDir))
|
|
125
|
+
reasons.push(`dir:${stateDir}`);
|
|
126
|
+
return {
|
|
127
|
+
detected: reasons.length > 0,
|
|
128
|
+
reasons,
|
|
129
|
+
stateDir,
|
|
130
|
+
mainSkillsDir: resolveMainSkillsDir(stateDir),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* List in-scope skills directories. Default: main only.
|
|
135
|
+
* With allAgents: main + every workspace- star /skills under the state dir.
|
|
136
|
+
*/
|
|
137
|
+
export function listInScopeSkillsDirs(stateDir, allAgents, seams = {}) {
|
|
138
|
+
const isDir = seams.isDir ?? defaultIsDir;
|
|
139
|
+
const readDir = seams.readDir ?? defaultReadDir;
|
|
140
|
+
const main = resolveMainSkillsDir(stateDir);
|
|
141
|
+
const dirs = [main];
|
|
142
|
+
if (!allAgents)
|
|
143
|
+
return dirs;
|
|
144
|
+
for (const name of readDir(stateDir)) {
|
|
145
|
+
if (!name.startsWith("workspace-"))
|
|
146
|
+
continue;
|
|
147
|
+
const workspaceDir = join(stateDir, name);
|
|
148
|
+
if (!isDir(workspaceDir))
|
|
149
|
+
continue;
|
|
150
|
+
const skillsDir = join(workspaceDir, "skills");
|
|
151
|
+
if (!dirs.includes(skillsDir))
|
|
152
|
+
dirs.push(skillsDir);
|
|
153
|
+
}
|
|
154
|
+
return dirs;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Resolve content package skills/<id> for a pin.
|
|
158
|
+
*/
|
|
159
|
+
export function resolvePinSourceDir(contentBase, skillId) {
|
|
160
|
+
return join(contentBase, "skills", skillId);
|
|
161
|
+
}
|
|
162
|
+
function skillHasBody(skillDir, isFile, isDir) {
|
|
163
|
+
if (!isDir(skillDir) && !existsSync(skillDir))
|
|
164
|
+
return false;
|
|
165
|
+
// Accept dir or symlink-to-dir that contains SKILL.md
|
|
166
|
+
return isFile(join(skillDir, "SKILL.md"));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* True when both dirs have SKILL.md with identical bytes (pin still current
|
|
170
|
+
* after package upgrade). False / unreadable → treat as stale (#3008 P1).
|
|
171
|
+
*/
|
|
172
|
+
function skillBodyMatchesPackage(sourceDir, targetDir, isFile) {
|
|
173
|
+
const src = join(sourceDir, "SKILL.md");
|
|
174
|
+
const dst = join(targetDir, "SKILL.md");
|
|
175
|
+
if (!isFile(src) || !isFile(dst))
|
|
176
|
+
return false;
|
|
177
|
+
try {
|
|
178
|
+
return readFileSync(src, "utf8") === readFileSync(dst, "utf8");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* True when `path` is a symlink whose resolved real path is outside `skillsDir`
|
|
186
|
+
* (OpenClaw `symlink-escape` / #3008).
|
|
187
|
+
*/
|
|
188
|
+
export function isEscapingSkillSymlink(skillsDir, path, seams = {}) {
|
|
189
|
+
const lstatKind = seams.lstatKind ?? defaultLstatKind;
|
|
190
|
+
if (lstatKind(path) !== "symlink")
|
|
191
|
+
return false;
|
|
192
|
+
try {
|
|
193
|
+
const skillsReal = realpathSync(skillsDir);
|
|
194
|
+
const pathReal = realpathSync(path);
|
|
195
|
+
const rel = relative(skillsReal, pathReal);
|
|
196
|
+
// Outside root, or walks up with ".."
|
|
197
|
+
return rel === "" || rel.startsWith(`..${sep}`) || rel.startsWith("..") || rel === "..";
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Broken symlink / unreadable realpath — treat as escape / unusable.
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Assess which always-pins are present / missing / divergent in a skills root.
|
|
206
|
+
*
|
|
207
|
+
* - present: real directory (or non-escaping symlink) with SKILL.md matching
|
|
208
|
+
* the content package when `contentBase` is supplied
|
|
209
|
+
* - missing: path does not exist
|
|
210
|
+
* - divergent: path exists but is not a usable OpenClaw pin (file, empty dir,
|
|
211
|
+
* broken link, **escaping symlink**, or **stale SKILL.md** vs package — #3008)
|
|
212
|
+
*/
|
|
213
|
+
export function assessOpenClawPins(skillsDir, seams = {}, options = {}) {
|
|
214
|
+
const isDir = seams.isDir ?? defaultIsDir;
|
|
215
|
+
const isFile = seams.isFile ?? defaultIsFile;
|
|
216
|
+
const pathExists = seams.pathExists ?? defaultPathExists;
|
|
217
|
+
const lstatKind = seams.lstatKind ?? defaultLstatKind;
|
|
218
|
+
const contentBase = options.contentBase;
|
|
219
|
+
const present = [];
|
|
220
|
+
const missing = [];
|
|
221
|
+
const divergent = [];
|
|
222
|
+
for (const skillId of OPENCLAW_ALWAYS_PIN_SKILLS) {
|
|
223
|
+
const target = join(skillsDir, skillId);
|
|
224
|
+
const kind = lstatKind(target);
|
|
225
|
+
if (kind === "missing") {
|
|
226
|
+
missing.push(skillId);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// Escaping symlink: SKILL.md may resolve, but OpenClaw skips load (#3008).
|
|
230
|
+
if (kind === "symlink" && isEscapingSkillSymlink(skillsDir, target, { lstatKind })) {
|
|
231
|
+
divergent.push(skillId);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (skillHasBody(target, isFile, isDir)) {
|
|
235
|
+
if (contentBase) {
|
|
236
|
+
const sourceDir = resolvePinSourceDir(contentBase, skillId);
|
|
237
|
+
if (!skillBodyMatchesPackage(sourceDir, target, isFile)) {
|
|
238
|
+
// Stale copy after package upgrade — surface as divergent so doctor
|
|
239
|
+
// does not report "present" and skip repair (#3008 Greptile P1).
|
|
240
|
+
divergent.push(skillId);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
present.push(skillId);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// Path exists but is not a usable pin body (file, empty dir, broken link).
|
|
248
|
+
if (kind === "file" || kind === "other" || kind === "dir" || kind === "symlink") {
|
|
249
|
+
divergent.push(skillId);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!pathExists(target)) {
|
|
253
|
+
missing.push(skillId);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
divergent.push(skillId);
|
|
257
|
+
}
|
|
258
|
+
return { skillsDir, present, missing, divergent };
|
|
259
|
+
}
|
|
260
|
+
function trySymlinkDir(target, path) {
|
|
261
|
+
try {
|
|
262
|
+
symlinkSync(target, path, "dir");
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Windows: junctions do not require elevated privileges for directories.
|
|
267
|
+
try {
|
|
268
|
+
symlinkSync(target, path, "junction");
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function defaultCopyDir(src, dst) {
|
|
277
|
+
cpSync(src, dst, { recursive: true });
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Install one pin into a skills root.
|
|
281
|
+
*
|
|
282
|
+
* Default: **copy** into the workspace skills root (#3008). OpenClaw rejects
|
|
283
|
+
* skills that resolve outside the workspace skills root via symlink-escape, so
|
|
284
|
+
* symlinking into the npm content package leaves pins unloaded while doctor
|
|
285
|
+
* reports "present". Opt into legacy symlink-first with `preferSymlink: true`
|
|
286
|
+
* (still falls back to copy). Never deletes unrelated user skills. Divergent
|
|
287
|
+
* targets (including escaping symlinks) require force or TTY confirm.
|
|
288
|
+
*
|
|
289
|
+
* Stale copies (SKILL.md differs from package) are not "already-present" —
|
|
290
|
+
* doctor --fix refreshes them so upgrades land without manual delete (#3008 P1).
|
|
291
|
+
* Forced replace stages into a sibling temp dir first so a failed copy does not
|
|
292
|
+
* leave the pin permanently deleted (#3008 P1).
|
|
293
|
+
*/
|
|
294
|
+
export function installOpenClawPin(skillId, sourceDir, skillsDir, options = {}, seams = {}) {
|
|
295
|
+
const isFile = seams.isFile ?? defaultIsFile;
|
|
296
|
+
const isDir = seams.isDir ?? defaultIsDir;
|
|
297
|
+
const lstatKind = seams.lstatKind ?? defaultLstatKind;
|
|
298
|
+
const mkdirp = seams.mkdirp ?? ((p) => mkdirSync(p, { recursive: true }));
|
|
299
|
+
const symlinkDir = seams.symlinkDir ??
|
|
300
|
+
((target, path) => {
|
|
301
|
+
if (!trySymlinkDir(target, path)) {
|
|
302
|
+
throw new Error(`symlink failed for ${path}`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
const copyDir = seams.copyDir ?? defaultCopyDir;
|
|
306
|
+
const removePath = seams.removePath ?? ((p) => rmSync(p, { recursive: true, force: true }));
|
|
307
|
+
const renamePath = seams.renamePath ?? ((from, to) => renameSync(from, to));
|
|
308
|
+
const target = join(skillsDir, skillId);
|
|
309
|
+
const force = options.force === true || options.allowOverwrite === true;
|
|
310
|
+
const preferSymlink = options.preferSymlink === true;
|
|
311
|
+
const refreshStale = options.refreshStale === true;
|
|
312
|
+
if (!isDir(sourceDir) || !isFile(join(sourceDir, "SKILL.md"))) {
|
|
313
|
+
return {
|
|
314
|
+
skillId,
|
|
315
|
+
method: "skipped",
|
|
316
|
+
target,
|
|
317
|
+
source: sourceDir,
|
|
318
|
+
detail: `source pin missing or incomplete: ${sourceDir}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
mkdirp(skillsDir);
|
|
322
|
+
const kind = lstatKind(target);
|
|
323
|
+
if (kind !== "missing") {
|
|
324
|
+
const escaping = kind === "symlink" && isEscapingSkillSymlink(skillsDir, target, { lstatKind });
|
|
325
|
+
if (!escaping && skillHasBody(target, isFile, isDir)) {
|
|
326
|
+
const matches = skillBodyMatchesPackage(sourceDir, target, isFile);
|
|
327
|
+
if (matches) {
|
|
328
|
+
return {
|
|
329
|
+
skillId,
|
|
330
|
+
method: "already-present",
|
|
331
|
+
target,
|
|
332
|
+
source: sourceDir,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Stale copy after package upgrade — refresh under --fix / force.
|
|
336
|
+
if (!force && !refreshStale) {
|
|
337
|
+
return {
|
|
338
|
+
skillId,
|
|
339
|
+
method: "skipped",
|
|
340
|
+
target,
|
|
341
|
+
source: sourceDir,
|
|
342
|
+
detail: "stale pin (SKILL.md differs from content package); re-run with --force or doctor --fix to refresh",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
// fall through to safe replace
|
|
346
|
+
}
|
|
347
|
+
else if (!force) {
|
|
348
|
+
return {
|
|
349
|
+
skillId,
|
|
350
|
+
method: "skipped",
|
|
351
|
+
target,
|
|
352
|
+
source: sourceDir,
|
|
353
|
+
detail: escaping
|
|
354
|
+
? "escaping symlink (OpenClaw symlink-escape); re-run with --force to replace with a real copy (#3008)"
|
|
355
|
+
: "divergent target exists; re-run with --force or confirm on TTY to replace",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (preferSymlink && kind === "missing") {
|
|
360
|
+
try {
|
|
361
|
+
symlinkDir(sourceDir, target);
|
|
362
|
+
return { skillId, method: "symlink", target, source: sourceDir };
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// fall through to copy
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Exclusive lock dir serializes concurrent doctor --fix on the same pin
|
|
369
|
+
// (non-recursive mkdir fails with EEXIST — atomic on win32 + posix). No
|
|
370
|
+
// writeFileSync (contained-writes #2951). Crash recovery: reclaim locks older
|
|
371
|
+
// than 10 minutes (pin install is seconds; long steal window is intentional).
|
|
372
|
+
const lockDir = `${target}.deft-lock`;
|
|
373
|
+
const STALE_LOCK_MS = 10 * 60 * 1000;
|
|
374
|
+
const tryAcquireLock = () => {
|
|
375
|
+
try {
|
|
376
|
+
mkdirSync(lockDir);
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
try {
|
|
381
|
+
const ageMs = Date.now() - statSync(lockDir).mtimeMs;
|
|
382
|
+
if (ageMs >= STALE_LOCK_MS) {
|
|
383
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
384
|
+
mkdirSync(lockDir);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
try {
|
|
390
|
+
mkdirSync(lockDir);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
if (!tryAcquireLock()) {
|
|
401
|
+
return {
|
|
402
|
+
skillId,
|
|
403
|
+
method: "skipped",
|
|
404
|
+
target,
|
|
405
|
+
source: sourceDir,
|
|
406
|
+
detail: "another doctor process is installing this pin; re-run after it finishes",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const releaseLock = () => {
|
|
410
|
+
try {
|
|
411
|
+
removePath(lockDir);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// ignore
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
// Stage copy first so a failed install never deletes the prior target without
|
|
418
|
+
// a replacement ready (#3008 Greptile P1: failed copies lose replaced targets).
|
|
419
|
+
// Unique suffix avoids leftover collision if a prior crash left staging files.
|
|
420
|
+
const swapId = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
421
|
+
const staging = `${target}.deft-installing-${swapId}`;
|
|
422
|
+
try {
|
|
423
|
+
removePath(staging);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// ignore missing staging
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
copyDir(sourceDir, staging);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
try {
|
|
433
|
+
removePath(staging);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// ignore cleanup
|
|
437
|
+
}
|
|
438
|
+
releaseLock();
|
|
439
|
+
return {
|
|
440
|
+
skillId,
|
|
441
|
+
method: "skipped",
|
|
442
|
+
target,
|
|
443
|
+
source: sourceDir,
|
|
444
|
+
detail: `install failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
// Re-check under the lock: another process may have already refreshed to a
|
|
448
|
+
// matching real copy. Escaping symlinks still match SKILL.md bytes through
|
|
449
|
+
// the link — never short-circuit those; they must become real copies.
|
|
450
|
+
const kindUnderLock = lstatKind(target);
|
|
451
|
+
const escapingUnderLock = kindUnderLock === "symlink" && isEscapingSkillSymlink(skillsDir, target, { lstatKind });
|
|
452
|
+
if (kindUnderLock !== "missing" &&
|
|
453
|
+
!escapingUnderLock &&
|
|
454
|
+
skillHasBody(target, isFile, isDir) &&
|
|
455
|
+
skillBodyMatchesPackage(sourceDir, target, isFile)) {
|
|
456
|
+
try {
|
|
457
|
+
removePath(staging);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// ignore
|
|
461
|
+
}
|
|
462
|
+
releaseLock();
|
|
463
|
+
return {
|
|
464
|
+
skillId,
|
|
465
|
+
method: "already-present",
|
|
466
|
+
target,
|
|
467
|
+
source: sourceDir,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const backup = kindUnderLock !== "missing" ? `${target}.deft-backup-${swapId}` : null;
|
|
471
|
+
try {
|
|
472
|
+
if (backup !== null) {
|
|
473
|
+
try {
|
|
474
|
+
removePath(backup);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// ignore
|
|
478
|
+
}
|
|
479
|
+
renamePath(target, backup);
|
|
480
|
+
}
|
|
481
|
+
renamePath(staging, target);
|
|
482
|
+
if (backup !== null) {
|
|
483
|
+
try {
|
|
484
|
+
removePath(backup);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// leftover backup is harmless
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
releaseLock();
|
|
491
|
+
return { skillId, method: "copy", target, source: sourceDir };
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
// Restore backup if we moved it away and the swap failed.
|
|
495
|
+
try {
|
|
496
|
+
removePath(staging);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// ignore
|
|
500
|
+
}
|
|
501
|
+
if (backup !== null && lstatKind(target) === "missing" && lstatKind(backup) !== "missing") {
|
|
502
|
+
try {
|
|
503
|
+
renamePath(backup, target);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// best-effort restore
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
releaseLock();
|
|
510
|
+
return {
|
|
511
|
+
skillId,
|
|
512
|
+
method: "skipped",
|
|
513
|
+
target,
|
|
514
|
+
source: sourceDir,
|
|
515
|
+
detail: `install failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Doctor check: detect OpenClaw + missing always-pins; optionally fix.
|
|
521
|
+
* No-ops when OpenClaw signals are absent.
|
|
522
|
+
*/
|
|
523
|
+
export function runOpenClawSkillPinsCheck(sink, addFinding, options) {
|
|
524
|
+
const seams = options.seams ?? {};
|
|
525
|
+
const env = seams.env ?? process.env;
|
|
526
|
+
const homeDir = seams.homeDir ?? (() => homedir());
|
|
527
|
+
const isDir = seams.isDir ?? defaultIsDir;
|
|
528
|
+
const isFile = seams.isFile ?? defaultIsFile;
|
|
529
|
+
const detect = detectOpenClaw(env, { homeDir: homeDir(), isDir });
|
|
530
|
+
if (!detect.detected) {
|
|
531
|
+
sink.info(`${OPENCLAW_SKILL_PINS_CHECK}: skip -- OpenClaw not detected`);
|
|
532
|
+
addFinding({
|
|
533
|
+
severity: "skip",
|
|
534
|
+
message: "OpenClaw not detected",
|
|
535
|
+
check: OPENCLAW_SKILL_PINS_CHECK,
|
|
536
|
+
status: "skip",
|
|
537
|
+
reason: "openclaw-not-detected",
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const contentBase = (seams.contentRootFor ?? contentRoot)(options.frameworkRoot);
|
|
542
|
+
const skillsDirs = listInScopeSkillsDirs(detect.stateDir, options.allAgents, {
|
|
543
|
+
isDir,
|
|
544
|
+
readDir: seams.readDir,
|
|
545
|
+
});
|
|
546
|
+
const missingByDir = [];
|
|
547
|
+
const divergentByDir = [];
|
|
548
|
+
let allPresent = true;
|
|
549
|
+
for (const skillsDir of skillsDirs) {
|
|
550
|
+
const assessment = assessOpenClawPins(skillsDir, { isDir, isFile, lstatKind: seams.lstatKind }, { contentBase });
|
|
551
|
+
if (assessment.missing.length > 0) {
|
|
552
|
+
allPresent = false;
|
|
553
|
+
missingByDir.push({ skillsDir, missing: [...assessment.missing] });
|
|
554
|
+
}
|
|
555
|
+
if (assessment.divergent.length > 0) {
|
|
556
|
+
allPresent = false;
|
|
557
|
+
divergentByDir.push({ skillsDir, divergent: [...assessment.divergent] });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (allPresent) {
|
|
561
|
+
const scope = options.allAgents ? "main + workspace-* seats" : "main workspace";
|
|
562
|
+
const message = `${OPENCLAW_SKILL_PINS_CHECK}: OpenClaw host always-pins present ` +
|
|
563
|
+
`(${OPENCLAW_ALWAYS_PIN_SKILLS.join(", ")}) in ${scope}`;
|
|
564
|
+
sink.success(message);
|
|
565
|
+
addFinding({
|
|
566
|
+
severity: "skip",
|
|
567
|
+
message,
|
|
568
|
+
check: OPENCLAW_SKILL_PINS_CHECK,
|
|
569
|
+
status: "present",
|
|
570
|
+
skills_dirs: skillsDirs,
|
|
571
|
+
pins: [...OPENCLAW_ALWAYS_PIN_SKILLS],
|
|
572
|
+
detect_reasons: detect.reasons,
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// Optional fix path
|
|
577
|
+
const installResults = [];
|
|
578
|
+
if (options.fixMode) {
|
|
579
|
+
const isTty = seams.isTty ?? (() => process.stdin.isTTY === true);
|
|
580
|
+
const readYn = seams.readYn ?? (() => false);
|
|
581
|
+
let allowFix = true;
|
|
582
|
+
if (!options.jsonMode && isTty()) {
|
|
583
|
+
allowFix = readYn(`Wire missing OpenClaw always-pin skills into ${skillsDirs.join(", ")} now?`, true);
|
|
584
|
+
if (!allowFix) {
|
|
585
|
+
sink.info("Skipped OpenClaw pin wire -- re-run `deft doctor --fix` when ready.");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Non-interactive --fix applies additive installs (safe); divergent needs --force.
|
|
589
|
+
if (allowFix) {
|
|
590
|
+
for (const skillsDir of skillsDirs) {
|
|
591
|
+
const assessment = assessOpenClawPins(skillsDir, {
|
|
592
|
+
isDir,
|
|
593
|
+
isFile,
|
|
594
|
+
lstatKind: seams.lstatKind,
|
|
595
|
+
}, { contentBase });
|
|
596
|
+
const toInstall = new Set([
|
|
597
|
+
...assessment.missing,
|
|
598
|
+
...(options.force ? assessment.divergent : []),
|
|
599
|
+
]);
|
|
600
|
+
// Stale pins (SKILL.md ≠ package) are classified divergent; under --fix
|
|
601
|
+
// refresh them without TTY confirm (safe overwrite of our own pins).
|
|
602
|
+
if (!options.force) {
|
|
603
|
+
for (const skillId of assessment.divergent) {
|
|
604
|
+
const sourceDir = resolvePinSourceDir(contentBase, skillId);
|
|
605
|
+
const target = join(skillsDir, skillId);
|
|
606
|
+
if (skillHasBody(target, isFile, isDir) &&
|
|
607
|
+
!skillBodyMatchesPackage(sourceDir, target, isFile)) {
|
|
608
|
+
toInstall.add(skillId);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (isTty() && !options.jsonMode) {
|
|
612
|
+
if (readYn(`Replace divergent OpenClaw skill dir ${join(skillsDir, skillId)} with pin from content package?`, false)) {
|
|
613
|
+
toInstall.add(skillId);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
for (const skillId of toInstall) {
|
|
619
|
+
const sourceDir = resolvePinSourceDir(contentBase, skillId);
|
|
620
|
+
const result = installOpenClawPin(skillId, sourceDir, skillsDir, {
|
|
621
|
+
force: options.force || assessment.divergent.includes(skillId),
|
|
622
|
+
refreshStale: true,
|
|
623
|
+
}, seams);
|
|
624
|
+
installResults.push(result);
|
|
625
|
+
if (result.method === "symlink" || result.method === "copy") {
|
|
626
|
+
sink.success(`OpenClaw pin ${skillId}: ${result.method} → ${result.target}${result.method === "symlink" ? ` (from ${result.source})` : ""}`);
|
|
627
|
+
}
|
|
628
|
+
else if (result.method === "already-present") {
|
|
629
|
+
sink.info(`OpenClaw pin ${skillId}: already present at ${result.target}`);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
sink.warn(`OpenClaw pin ${skillId}: skipped${result.detail ? ` -- ${result.detail}` : ""}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// Re-assess after fix
|
|
638
|
+
let stillMissing = false;
|
|
639
|
+
const postMissing = [];
|
|
640
|
+
for (const skillsDir of skillsDirs) {
|
|
641
|
+
const post = assessOpenClawPins(skillsDir, { isDir, isFile, lstatKind: seams.lstatKind }, { contentBase });
|
|
642
|
+
if (post.missing.length > 0 || post.divergent.length > 0) {
|
|
643
|
+
stillMissing = true;
|
|
644
|
+
postMissing.push(...post.missing.map((id) => `${skillsDir}/${id}`), ...post.divergent.map((id) => `${skillsDir}/${id} (divergent)`));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (!stillMissing) {
|
|
648
|
+
const message = `${OPENCLAW_SKILL_PINS_CHECK}: wired OpenClaw always-pins; ` +
|
|
649
|
+
"restart the OpenClaw gateway or start a new session so available_skills refreshes";
|
|
650
|
+
sink.success(message);
|
|
651
|
+
addFinding({
|
|
652
|
+
severity: "skip",
|
|
653
|
+
message,
|
|
654
|
+
check: OPENCLAW_SKILL_PINS_CHECK,
|
|
655
|
+
status: "fixed",
|
|
656
|
+
skills_dirs: skillsDirs,
|
|
657
|
+
installs: installResults,
|
|
658
|
+
detect_reasons: detect.reasons,
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
// Fall through to warning with remaining gaps
|
|
663
|
+
missingByDir.length = 0;
|
|
664
|
+
divergentByDir.length = 0;
|
|
665
|
+
for (const skillsDir of skillsDirs) {
|
|
666
|
+
const post = assessOpenClawPins(skillsDir, { isDir, isFile, lstatKind: seams.lstatKind });
|
|
667
|
+
if (post.missing.length > 0)
|
|
668
|
+
missingByDir.push({ skillsDir, missing: [...post.missing] });
|
|
669
|
+
if (post.divergent.length > 0)
|
|
670
|
+
divergentByDir.push({ skillsDir, divergent: [...post.divergent] });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const missingIds = [...new Set(missingByDir.flatMap((m) => m.missing))];
|
|
674
|
+
const divergentIds = [...new Set(divergentByDir.flatMap((d) => d.divergent))];
|
|
675
|
+
const targetSummary = skillsDirs.join(", ");
|
|
676
|
+
const sourceHint = join(contentBase, "skills", "<pin-id>");
|
|
677
|
+
const multiHint = options.allAgents ? "" : ` Multi-seat workspaces: ${REMEDIATION_ALL_AGENTS}.`;
|
|
678
|
+
const message = `${OPENCLAW_SKILL_PINS_CHECK}: missing OpenClaw always-pin skill(s) ` +
|
|
679
|
+
`[${missingIds.join(", ") || "(none)"}]` +
|
|
680
|
+
(divergentIds.length > 0 ? `; divergent: [${divergentIds.join(", ")}]` : "") +
|
|
681
|
+
` under ${targetSummary}. ` +
|
|
682
|
+
`Source: ${sourceHint}. ` +
|
|
683
|
+
`Remediation: ${REMEDIATION_FIX}. ` +
|
|
684
|
+
`See ${DOC_OPENCLAW_HOST}, ${DOC_HOST_LIFECYCLE}, ${DOC_SKILL_PINS}.` +
|
|
685
|
+
multiHint;
|
|
686
|
+
sink.warn(message);
|
|
687
|
+
if (!options.jsonMode) {
|
|
688
|
+
sink.info("After wiring pins, restart the OpenClaw gateway or open a new session so host available_skills refreshes.");
|
|
689
|
+
}
|
|
690
|
+
addFinding({
|
|
691
|
+
severity: "warning",
|
|
692
|
+
message,
|
|
693
|
+
check: OPENCLAW_SKILL_PINS_CHECK,
|
|
694
|
+
status: "missing",
|
|
695
|
+
missing: missingIds,
|
|
696
|
+
divergent: divergentIds,
|
|
697
|
+
skills_dirs: skillsDirs,
|
|
698
|
+
main_skills_dir: detect.mainSkillsDir,
|
|
699
|
+
source_content_root: contentBase,
|
|
700
|
+
suggestion: REMEDIATION_FIX,
|
|
701
|
+
docs: [DOC_OPENCLAW_HOST, DOC_HOST_LIFECYCLE, DOC_SKILL_PINS],
|
|
702
|
+
detect_reasons: detect.reasons,
|
|
703
|
+
...(installResults.length > 0 ? { installs: installResults } : {}),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
//# sourceMappingURL=openclaw-skills.js.map
|