@deftai/directive-core 0.90.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/openclaw-skills.d.ts +42 -8
- package/dist/doctor/openclaw-skills.js +281 -38
- package/dist/eval/health.d.ts +10 -1
- package/dist/eval/health.js +16 -16
- 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/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/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/session-start.d.ts +7 -0
- package/dist/session/session-start.js +54 -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 +3 -3
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenClaw always-pin skill detect + doctor --fix wire (#3001).
|
|
2
|
+
* OpenClaw always-pin skill detect + doctor --fix wire (#3001 / #3008).
|
|
3
3
|
*
|
|
4
4
|
* When OpenClaw signals are present, doctor checks the main workspace skills
|
|
5
5
|
* root for the four always-pin skills (#2508). Missing pins emit a warning with
|
|
6
|
-
* remediation `deft doctor --fix`. Under fixMode, pins are
|
|
7
|
-
*
|
|
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.
|
|
8
14
|
*
|
|
9
15
|
* Multi-seat (`workspace-*`) targets only when `--openclaw-all-agents` is set.
|
|
10
16
|
*/
|
|
@@ -29,6 +35,7 @@ export interface OpenClawSkillPinsSeams {
|
|
|
29
35
|
readonly symlinkDir?: (target: string, path: string) => void;
|
|
30
36
|
readonly copyDir?: (src: string, dst: string) => void;
|
|
31
37
|
readonly removePath?: (path: string) => void;
|
|
38
|
+
readonly renamePath?: (from: string, to: string) => void;
|
|
32
39
|
readonly contentRootFor?: (frameworkRoot: string) => string;
|
|
33
40
|
readonly isTty?: () => boolean;
|
|
34
41
|
readonly readYn?: (prompt: string, defaultYes: boolean) => boolean;
|
|
@@ -77,21 +84,48 @@ export declare function listInScopeSkillsDirs(stateDir: string, allAgents: boole
|
|
|
77
84
|
* Resolve content package skills/<id> for a pin.
|
|
78
85
|
*/
|
|
79
86
|
export declare function resolvePinSourceDir(contentBase: string, skillId: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* True when `path` is a symlink whose resolved real path is outside `skillsDir`
|
|
89
|
+
* (OpenClaw `symlink-escape` / #3008).
|
|
90
|
+
*/
|
|
91
|
+
export declare function isEscapingSkillSymlink(skillsDir: string, path: string, seams?: Pick<OpenClawSkillPinsSeams, "lstatKind">): boolean;
|
|
80
92
|
/**
|
|
81
93
|
* Assess which always-pins are present / missing / divergent in a skills root.
|
|
82
94
|
*
|
|
83
|
-
* - present: directory (or symlink) with SKILL.md
|
|
95
|
+
* - present: real directory (or non-escaping symlink) with SKILL.md matching
|
|
96
|
+
* the content package when `contentBase` is supplied
|
|
84
97
|
* - missing: path does not exist
|
|
85
|
-
* - divergent: path exists but is not a usable pin (file, empty dir,
|
|
98
|
+
* - divergent: path exists but is not a usable OpenClaw pin (file, empty dir,
|
|
99
|
+
* broken link, **escaping symlink**, or **stale SKILL.md** vs package — #3008)
|
|
86
100
|
*/
|
|
87
|
-
export declare function assessOpenClawPins(skillsDir: string, seams?: Pick<OpenClawSkillPinsSeams, "isDir" | "isFile" | "pathExists" | "lstatKind"
|
|
101
|
+
export declare function assessOpenClawPins(skillsDir: string, seams?: Pick<OpenClawSkillPinsSeams, "isDir" | "isFile" | "pathExists" | "lstatKind">, options?: {
|
|
102
|
+
contentBase?: string;
|
|
103
|
+
}): OpenClawPinAssessment;
|
|
88
104
|
/**
|
|
89
|
-
* Install one pin into a skills root.
|
|
90
|
-
*
|
|
105
|
+
* Install one pin into a skills root.
|
|
106
|
+
*
|
|
107
|
+
* Default: **copy** into the workspace skills root (#3008). OpenClaw rejects
|
|
108
|
+
* skills that resolve outside the workspace skills root via symlink-escape, so
|
|
109
|
+
* symlinking into the npm content package leaves pins unloaded while doctor
|
|
110
|
+
* reports "present". Opt into legacy symlink-first with `preferSymlink: true`
|
|
111
|
+
* (still falls back to copy). Never deletes unrelated user skills. Divergent
|
|
112
|
+
* targets (including escaping symlinks) require force or TTY confirm.
|
|
113
|
+
*
|
|
114
|
+
* Stale copies (SKILL.md differs from package) are not "already-present" —
|
|
115
|
+
* doctor --fix refreshes them so upgrades land without manual delete (#3008 P1).
|
|
116
|
+
* Forced replace stages into a sibling temp dir first so a failed copy does not
|
|
117
|
+
* leave the pin permanently deleted (#3008 P1).
|
|
91
118
|
*/
|
|
92
119
|
export declare function installOpenClawPin(skillId: OpenClawAlwaysPinSkill, sourceDir: string, skillsDir: string, options?: {
|
|
93
120
|
force?: boolean;
|
|
94
121
|
allowOverwrite?: boolean;
|
|
122
|
+
/** When true, try symlink first (legacy #3001). Default false — copy (#3008). */
|
|
123
|
+
preferSymlink?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* When true, refresh copied pins whose SKILL.md no longer matches the
|
|
126
|
+
* package (package upgrade path). Doctor --fix sets this.
|
|
127
|
+
*/
|
|
128
|
+
refreshStale?: boolean;
|
|
95
129
|
}, seams?: OpenClawSkillPinsSeams): PinInstallResult;
|
|
96
130
|
export interface RunOpenClawSkillPinsOptions {
|
|
97
131
|
readonly frameworkRoot: string;
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenClaw always-pin skill detect + doctor --fix wire (#3001).
|
|
2
|
+
* OpenClaw always-pin skill detect + doctor --fix wire (#3001 / #3008).
|
|
3
3
|
*
|
|
4
4
|
* When OpenClaw signals are present, doctor checks the main workspace skills
|
|
5
5
|
* root for the four always-pin skills (#2508). Missing pins emit a warning with
|
|
6
|
-
* remediation `deft doctor --fix`. Under fixMode, pins are
|
|
7
|
-
*
|
|
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.
|
|
8
14
|
*
|
|
9
15
|
* Multi-seat (`workspace-*`) targets only when `--openclaw-all-agents` is set.
|
|
10
16
|
*/
|
|
11
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, symlinkSync, } from "node:fs";
|
|
17
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, } from "node:fs";
|
|
12
18
|
import { homedir } from "node:os";
|
|
13
|
-
import { join, resolve } from "node:path";
|
|
19
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
14
20
|
import { contentRoot } from "../content-root.js";
|
|
15
21
|
/** Stable doctor check id for JSON findings. */
|
|
16
22
|
export const OPENCLAW_SKILL_PINS_CHECK = "openclaw-skill-pins";
|
|
@@ -159,18 +165,57 @@ function skillHasBody(skillDir, isFile, isDir) {
|
|
|
159
165
|
// Accept dir or symlink-to-dir that contains SKILL.md
|
|
160
166
|
return isFile(join(skillDir, "SKILL.md"));
|
|
161
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
|
+
}
|
|
162
204
|
/**
|
|
163
205
|
* Assess which always-pins are present / missing / divergent in a skills root.
|
|
164
206
|
*
|
|
165
|
-
* - present: directory (or symlink) with SKILL.md
|
|
207
|
+
* - present: real directory (or non-escaping symlink) with SKILL.md matching
|
|
208
|
+
* the content package when `contentBase` is supplied
|
|
166
209
|
* - missing: path does not exist
|
|
167
|
-
* - divergent: path exists but is not a usable pin (file, empty dir,
|
|
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)
|
|
168
212
|
*/
|
|
169
|
-
export function assessOpenClawPins(skillsDir, seams = {}) {
|
|
213
|
+
export function assessOpenClawPins(skillsDir, seams = {}, options = {}) {
|
|
170
214
|
const isDir = seams.isDir ?? defaultIsDir;
|
|
171
215
|
const isFile = seams.isFile ?? defaultIsFile;
|
|
172
216
|
const pathExists = seams.pathExists ?? defaultPathExists;
|
|
173
217
|
const lstatKind = seams.lstatKind ?? defaultLstatKind;
|
|
218
|
+
const contentBase = options.contentBase;
|
|
174
219
|
const present = [];
|
|
175
220
|
const missing = [];
|
|
176
221
|
const divergent = [];
|
|
@@ -181,7 +226,21 @@ export function assessOpenClawPins(skillsDir, seams = {}) {
|
|
|
181
226
|
missing.push(skillId);
|
|
182
227
|
continue;
|
|
183
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
|
+
}
|
|
184
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
|
+
}
|
|
185
244
|
present.push(skillId);
|
|
186
245
|
continue;
|
|
187
246
|
}
|
|
@@ -218,8 +277,19 @@ function defaultCopyDir(src, dst) {
|
|
|
218
277
|
cpSync(src, dst, { recursive: true });
|
|
219
278
|
}
|
|
220
279
|
/**
|
|
221
|
-
* Install one pin into a skills root.
|
|
222
|
-
*
|
|
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).
|
|
223
293
|
*/
|
|
224
294
|
export function installOpenClawPin(skillId, sourceDir, skillsDir, options = {}, seams = {}) {
|
|
225
295
|
const isFile = seams.isFile ?? defaultIsFile;
|
|
@@ -234,8 +304,11 @@ export function installOpenClawPin(skillId, sourceDir, skillsDir, options = {},
|
|
|
234
304
|
});
|
|
235
305
|
const copyDir = seams.copyDir ?? defaultCopyDir;
|
|
236
306
|
const removePath = seams.removePath ?? ((p) => rmSync(p, { recursive: true, force: true }));
|
|
307
|
+
const renamePath = seams.renamePath ?? ((from, to) => renameSync(from, to));
|
|
237
308
|
const target = join(skillsDir, skillId);
|
|
238
309
|
const force = options.force === true || options.allowOverwrite === true;
|
|
310
|
+
const preferSymlink = options.preferSymlink === true;
|
|
311
|
+
const refreshStale = options.refreshStale === true;
|
|
239
312
|
if (!isDir(sourceDir) || !isFile(join(sourceDir, "SKILL.md"))) {
|
|
240
313
|
return {
|
|
241
314
|
skillId,
|
|
@@ -248,43 +321,199 @@ export function installOpenClawPin(skillId, sourceDir, skillsDir, options = {},
|
|
|
248
321
|
mkdirp(skillsDir);
|
|
249
322
|
const kind = lstatKind(target);
|
|
250
323
|
if (kind !== "missing") {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
258
346
|
}
|
|
259
|
-
if (!force) {
|
|
347
|
+
else if (!force) {
|
|
260
348
|
return {
|
|
261
349
|
skillId,
|
|
262
350
|
method: "skipped",
|
|
263
351
|
target,
|
|
264
352
|
source: sourceDir,
|
|
265
|
-
detail:
|
|
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",
|
|
266
356
|
};
|
|
267
357
|
}
|
|
268
|
-
removePath(target);
|
|
269
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}`;
|
|
270
422
|
try {
|
|
271
|
-
|
|
272
|
-
return { skillId, method: "symlink", target, source: sourceDir };
|
|
423
|
+
removePath(staging);
|
|
273
424
|
}
|
|
274
425
|
catch {
|
|
426
|
+
// ignore missing staging
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
copyDir(sourceDir, staging);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
275
432
|
try {
|
|
276
|
-
|
|
277
|
-
return { skillId, method: "copy", target, source: sourceDir };
|
|
433
|
+
removePath(staging);
|
|
278
434
|
}
|
|
279
|
-
catch
|
|
280
|
-
|
|
281
|
-
skillId,
|
|
282
|
-
method: "skipped",
|
|
283
|
-
target,
|
|
284
|
-
source: sourceDir,
|
|
285
|
-
detail: `install failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
286
|
-
};
|
|
435
|
+
catch {
|
|
436
|
+
// ignore cleanup
|
|
287
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
|
+
};
|
|
288
517
|
}
|
|
289
518
|
}
|
|
290
519
|
/**
|
|
@@ -318,7 +547,7 @@ export function runOpenClawSkillPinsCheck(sink, addFinding, options) {
|
|
|
318
547
|
const divergentByDir = [];
|
|
319
548
|
let allPresent = true;
|
|
320
549
|
for (const skillsDir of skillsDirs) {
|
|
321
|
-
const assessment = assessOpenClawPins(skillsDir, { isDir, isFile, lstatKind: seams.lstatKind });
|
|
550
|
+
const assessment = assessOpenClawPins(skillsDir, { isDir, isFile, lstatKind: seams.lstatKind }, { contentBase });
|
|
322
551
|
if (assessment.missing.length > 0) {
|
|
323
552
|
allPresent = false;
|
|
324
553
|
missingByDir.push({ skillsDir, missing: [...assessment.missing] });
|
|
@@ -363,21 +592,35 @@ export function runOpenClawSkillPinsCheck(sink, addFinding, options) {
|
|
|
363
592
|
isDir,
|
|
364
593
|
isFile,
|
|
365
594
|
lstatKind: seams.lstatKind,
|
|
366
|
-
});
|
|
595
|
+
}, { contentBase });
|
|
367
596
|
const toInstall = new Set([
|
|
368
597
|
...assessment.missing,
|
|
369
598
|
...(options.force ? assessment.divergent : []),
|
|
370
599
|
]);
|
|
371
|
-
|
|
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) {
|
|
372
603
|
for (const skillId of assessment.divergent) {
|
|
373
|
-
|
|
604
|
+
const sourceDir = resolvePinSourceDir(contentBase, skillId);
|
|
605
|
+
const target = join(skillsDir, skillId);
|
|
606
|
+
if (skillHasBody(target, isFile, isDir) &&
|
|
607
|
+
!skillBodyMatchesPackage(sourceDir, target, isFile)) {
|
|
374
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
|
+
}
|
|
375
615
|
}
|
|
376
616
|
}
|
|
377
617
|
}
|
|
378
618
|
for (const skillId of toInstall) {
|
|
379
619
|
const sourceDir = resolvePinSourceDir(contentBase, skillId);
|
|
380
|
-
const result = installOpenClawPin(skillId, sourceDir, skillsDir, {
|
|
620
|
+
const result = installOpenClawPin(skillId, sourceDir, skillsDir, {
|
|
621
|
+
force: options.force || assessment.divergent.includes(skillId),
|
|
622
|
+
refreshStale: true,
|
|
623
|
+
}, seams);
|
|
381
624
|
installResults.push(result);
|
|
382
625
|
if (result.method === "symlink" || result.method === "copy") {
|
|
383
626
|
sink.success(`OpenClaw pin ${skillId}: ${result.method} → ${result.target}${result.method === "symlink" ? ` (from ${result.source})` : ""}`);
|
|
@@ -395,7 +638,7 @@ export function runOpenClawSkillPinsCheck(sink, addFinding, options) {
|
|
|
395
638
|
let stillMissing = false;
|
|
396
639
|
const postMissing = [];
|
|
397
640
|
for (const skillsDir of skillsDirs) {
|
|
398
|
-
const post = assessOpenClawPins(skillsDir, { isDir, isFile, lstatKind: seams.lstatKind });
|
|
641
|
+
const post = assessOpenClawPins(skillsDir, { isDir, isFile, lstatKind: seams.lstatKind }, { contentBase });
|
|
399
642
|
if (post.missing.length > 0 || post.divergent.length > 0) {
|
|
400
643
|
stillMissing = true;
|
|
401
644
|
postMissing.push(...post.missing.map((id) => `${skillsDir}/${id}`), ...post.divergent.map((id) => `${skillsDir}/${id} (divergent)`));
|
package/dist/eval/health.d.ts
CHANGED
|
@@ -40,7 +40,16 @@ export interface EvaluateHealthResult {
|
|
|
40
40
|
}
|
|
41
41
|
/** Absolute path to the versioned health history ledger (#2545). */
|
|
42
42
|
export declare function healthHistoryPath(projectRoot: string): string | null;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Residual regression detector for the #1694 wipCap unsatisfiable-nudge.
|
|
45
|
+
*
|
|
46
|
+
* Pre-fix: classify treated absent `plan.policy.wipCap` as incomplete while
|
|
47
|
+
* omit-by-design (#1186 D1) made materializing the default a self-check trap.
|
|
48
|
+
* The fix records decision-provenance out-of-band (`x-directive/onboarding`),
|
|
49
|
+
* so greenfield "missing wipCap decision" is satisfiable without materializing
|
|
50
|
+
* the value field. This detector now only fires if a decision marker is set
|
|
51
|
+
* but classify still lists wipCap as missing (classifier regression).
|
|
52
|
+
*/
|
|
44
53
|
export declare function detectWipCapUnsatisfiableNudge(projectRoot: string): ContradictionEvidence | null;
|
|
45
54
|
/** Run all registered contradictory-gate detectors. */
|
|
46
55
|
export declare function detectContradictoryGates(projectRoot: string): ContradictionEvidence[];
|
package/dist/eval/health.js
CHANGED
|
@@ -6,7 +6,6 @@ import { readCorePackageVersion } from "../engine-version.js";
|
|
|
6
6
|
import { containedWrite } from "../fs/contained-write.js";
|
|
7
7
|
import { resolveProjectDefinitionPath } from "../layout/resolve.js";
|
|
8
8
|
import { healthMetricsHistoryPath } from "../metrics/resolve-metrics-home.js";
|
|
9
|
-
import { readPlanPolicy } from "../policy/plan-extensions.js";
|
|
10
9
|
import { classifyOnboarding, detectPriorState } from "../triage/welcome/prior-state.js";
|
|
11
10
|
import { validateLinks } from "../validate-content/index.js";
|
|
12
11
|
import { evaluateConformance } from "../vbrief-validate/conformance.js";
|
|
@@ -106,26 +105,27 @@ function probeContentManifest(projectRoot) {
|
|
|
106
105
|
export function healthHistoryPath(projectRoot) {
|
|
107
106
|
return healthMetricsHistoryPath(projectRoot);
|
|
108
107
|
}
|
|
109
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Residual regression detector for the #1694 wipCap unsatisfiable-nudge.
|
|
110
|
+
*
|
|
111
|
+
* Pre-fix: classify treated absent `plan.policy.wipCap` as incomplete while
|
|
112
|
+
* omit-by-design (#1186 D1) made materializing the default a self-check trap.
|
|
113
|
+
* The fix records decision-provenance out-of-band (`x-directive/onboarding`),
|
|
114
|
+
* so greenfield "missing wipCap decision" is satisfiable without materializing
|
|
115
|
+
* the value field. This detector now only fires if a decision marker is set
|
|
116
|
+
* but classify still lists wipCap as missing (classifier regression).
|
|
117
|
+
*/
|
|
110
118
|
export function detectWipCapUnsatisfiableNudge(projectRoot) {
|
|
111
119
|
const loaded = loadPlan(projectRoot);
|
|
112
120
|
if (loaded === null) {
|
|
113
121
|
return null;
|
|
114
122
|
}
|
|
115
|
-
const { plan, filepath } = loaded;
|
|
116
|
-
const policy = readPlanPolicy(plan);
|
|
117
|
-
const wipCapPresent = typeof policy === "object" &&
|
|
118
|
-
policy !== null &&
|
|
119
|
-
!Array.isArray(policy) &&
|
|
120
|
-
"wipCap" in policy;
|
|
121
|
-
if (wipCapPresent) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
123
|
const state = detectPriorState(projectRoot);
|
|
125
124
|
const [, missing] = classifyOnboarding(state);
|
|
126
|
-
if (!missing.includes("wipCap")) {
|
|
125
|
+
if (!(state.wipCapDecided && missing.includes("wipCap"))) {
|
|
127
126
|
return null;
|
|
128
127
|
}
|
|
128
|
+
const { plan, filepath } = loaded;
|
|
129
129
|
const validatorErrors = validateWipCapOnPlan(plan, filepath);
|
|
130
130
|
if (validatorErrors.length > 0) {
|
|
131
131
|
return null;
|
|
@@ -133,11 +133,11 @@ export function detectWipCapUnsatisfiableNudge(projectRoot) {
|
|
|
133
133
|
return {
|
|
134
134
|
id: "wipCap-unsatisfiable-nudge",
|
|
135
135
|
kind: "unsatisfiable-nudge",
|
|
136
|
-
summary: "
|
|
136
|
+
summary: "wipCap decision marker is set but classifyOnboarding still lists wipCap as missing (#1694 regression).",
|
|
137
137
|
signals: [
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
138
|
+
"x-directive/onboarding.wipCapDecided: true",
|
|
139
|
+
"classifyOnboarding: wipCap still in missing onboarding signals",
|
|
140
|
+
"decision-provenance and completeness classifier are out of sync",
|
|
141
141
|
],
|
|
142
142
|
};
|
|
143
143
|
}
|
|
@@ -45,6 +45,9 @@ export function installerManagedMatchers() {
|
|
|
45
45
|
// `deft update` framework-deposit PR trips no-mixed-core-and-app (#2277).
|
|
46
46
|
{ exact: "xbrief/.deft-version" },
|
|
47
47
|
{ exact: "xbrief/xbrief.md" },
|
|
48
|
+
// Minimal render-ready seed from init (#3013); operator may later edit identity.
|
|
49
|
+
{ exact: "xbrief/PROJECT-DEFINITION.xbrief.json" },
|
|
50
|
+
{ exact: "vbrief/PROJECT-DEFINITION.vbrief.json" },
|
|
48
51
|
{ prefix: "xbrief/schemas/" },
|
|
49
52
|
{ prefix: "xbrief/migration/" },
|
|
50
53
|
...VBRIEF_LIFECYCLE_DIRS.map((sub) => ({ exact: `xbrief/${sub}/.gitkeep` })),
|
|
@@ -34,6 +34,11 @@ export declare function userConfigDir(): string;
|
|
|
34
34
|
export declare function createUserConfigDir(io: InitDepositIo): string;
|
|
35
35
|
export declare function buildInstallSummaryJson(result: InitDepositResult, options: InitDepositArgs): Record<string, unknown>;
|
|
36
36
|
export declare function printNextSteps(result: InitDepositResult, io: InitDepositIo): void;
|
|
37
|
+
/**
|
|
38
|
+
* Ensure a minimal render-ready PROJECT-DEFINITION exists after lifecycle dirs
|
|
39
|
+
* are deposited (#3013 / epic #3009). Idempotent: never overwrites existing identity.
|
|
40
|
+
*/
|
|
41
|
+
export declare function seedMinimalProjectDefinition(projectDir: string, io: InitDepositIo): boolean;
|
|
37
42
|
export declare function runInitDeposit(args: InitDepositArgs, io: InitDepositIo, seams?: InitDepositSeams): Promise<InitDepositResult>;
|
|
38
43
|
export interface RunInitDepositCliOptions extends InitDepositArgs {
|
|
39
44
|
readonly writeOut: (text: string) => void;
|