@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.
Files changed (100) hide show
  1. package/dist/doctor/constants.d.ts +1 -1
  2. package/dist/doctor/constants.js +2 -0
  3. package/dist/doctor/flags.js +21 -1
  4. package/dist/doctor/index.d.ts +1 -0
  5. package/dist/doctor/index.js +1 -0
  6. package/dist/doctor/main.js +22 -0
  7. package/dist/doctor/openclaw-skills.d.ts +143 -0
  8. package/dist/doctor/openclaw-skills.js +706 -0
  9. package/dist/doctor/types.d.ts +11 -0
  10. package/dist/eval/health.d.ts +10 -1
  11. package/dist/eval/health.js +16 -16
  12. package/dist/hooks/dispatcher.js +20 -5
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/init-deposit/hygiene.js +3 -0
  16. package/dist/init-deposit/init-deposit.d.ts +5 -0
  17. package/dist/init-deposit/init-deposit.js +37 -2
  18. package/dist/intake/github-auth-modes.d.ts +1 -1
  19. package/dist/intake/github-auth-modes.js +25 -3
  20. package/dist/intake/issue-ingest-cli.js +13 -0
  21. package/dist/intake/reconcile-issues-cli.js +13 -0
  22. package/dist/lifecycle/events.js +2 -0
  23. package/dist/lifecycle/index.d.ts +1 -0
  24. package/dist/lifecycle/index.js +1 -0
  25. package/dist/lifecycle/stats.d.ts +53 -0
  26. package/dist/lifecycle/stats.js +286 -0
  27. package/dist/policy/plan-extensions.d.ts +10 -0
  28. package/dist/policy/plan-extensions.js +16 -0
  29. package/dist/pr-closing-keywords/detect.d.ts +13 -0
  30. package/dist/pr-closing-keywords/detect.js +73 -8
  31. package/dist/pr-closing-keywords/index.d.ts +2 -2
  32. package/dist/pr-closing-keywords/index.js +1 -1
  33. package/dist/pr-closing-keywords/main.js +119 -90
  34. package/dist/pr-closing-keywords/types.d.ts +10 -0
  35. package/dist/release/spawn.js +13 -0
  36. package/dist/release-e2e/git-ops.d.ts +7 -0
  37. package/dist/release-e2e/git-ops.js +35 -2
  38. package/dist/render/constants.d.ts +16 -0
  39. package/dist/render/constants.js +16 -1
  40. package/dist/render/export-spec.d.ts +7 -1
  41. package/dist/render/export-spec.js +51 -5
  42. package/dist/render/index.d.ts +3 -2
  43. package/dist/render/index.js +2 -2
  44. package/dist/render/roadmap-render.js +164 -60
  45. package/dist/render/scope-outlook.d.ts +5 -0
  46. package/dist/render/scope-outlook.js +3 -0
  47. package/dist/render/spec-render.d.ts +28 -2
  48. package/dist/render/spec-render.js +111 -14
  49. package/dist/render/text-utils.d.ts +6 -0
  50. package/dist/render/text-utils.js +23 -0
  51. package/dist/scm/binary.d.ts +3 -0
  52. package/dist/scm/binary.js +10 -2
  53. package/dist/scm/call.d.ts +5 -0
  54. package/dist/scm/call.js +5 -0
  55. package/dist/scm/index.d.ts +1 -0
  56. package/dist/scm/index.js +1 -0
  57. package/dist/scm/main.d.ts +5 -0
  58. package/dist/scm/main.js +27 -0
  59. package/dist/scm/readiness-cli.d.ts +24 -0
  60. package/dist/scm/readiness-cli.js +85 -0
  61. package/dist/scm/readiness.d.ts +104 -0
  62. package/dist/scm/readiness.js +421 -0
  63. package/dist/scope/batch-promote.d.ts +26 -0
  64. package/dist/scope/batch-promote.js +167 -0
  65. package/dist/scope/index.d.ts +1 -0
  66. package/dist/scope/index.js +1 -0
  67. package/dist/scope/main.d.ts +2 -0
  68. package/dist/scope/main.js +58 -7
  69. package/dist/scope/wip-cap-check.d.ts +6 -0
  70. package/dist/scope/wip-cap-check.js +14 -2
  71. package/dist/session/index.d.ts +2 -0
  72. package/dist/session/index.js +2 -0
  73. package/dist/session/process-cost-constants.d.ts +9 -0
  74. package/dist/session/process-cost-constants.js +11 -0
  75. package/dist/session/process-cost.d.ts +53 -0
  76. package/dist/session/process-cost.js +82 -0
  77. package/dist/session/ritual-sentinel.d.ts +5 -0
  78. package/dist/session/ritual-sentinel.js +12 -1
  79. package/dist/session/session-ready.d.ts +67 -0
  80. package/dist/session/session-ready.js +264 -0
  81. package/dist/session/session-start.d.ts +63 -1
  82. package/dist/session/session-start.js +432 -24
  83. package/dist/session/verify-session-ritual.d.ts +8 -0
  84. package/dist/session/verify-session-ritual.js +95 -35
  85. package/dist/tool-events/classify.d.ts +20 -0
  86. package/dist/tool-events/classify.js +634 -0
  87. package/dist/tool-events/index.d.ts +10 -0
  88. package/dist/tool-events/index.js +10 -0
  89. package/dist/tool-events/summarize.d.ts +34 -0
  90. package/dist/tool-events/summarize.js +93 -0
  91. package/dist/tool-events/types.d.ts +52 -0
  92. package/dist/tool-events/types.js +13 -0
  93. package/dist/triage/welcome/index.d.ts +1 -1
  94. package/dist/triage/welcome/index.js +1 -1
  95. package/dist/triage/welcome/onboard.js +7 -1
  96. package/dist/triage/welcome/prior-state.d.ts +7 -0
  97. package/dist/triage/welcome/prior-state.js +16 -2
  98. package/dist/triage/welcome/writers.d.ts +10 -0
  99. package/dist/triage/welcome/writers.js +72 -2
  100. 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