@deftai/directive-core 0.90.0 → 0.92.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/cache/fetch.js +3 -2
- package/dist/cache/io.d.ts +13 -2
- package/dist/cache/io.js +36 -7
- package/dist/cache/operations.js +5 -3
- package/dist/doctor/main.js +37 -0
- 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/hooks/dispatcher.d.ts +6 -1
- package/dist/hooks/dispatcher.js +30 -0
- package/dist/init-deposit/gitignore.js +3 -0
- package/dist/init-deposit/hygiene.d.ts +43 -1
- package/dist/init-deposit/hygiene.js +112 -7
- package/dist/init-deposit/init-deposit.d.ts +5 -0
- package/dist/init-deposit/init-deposit.js +37 -2
- package/dist/init-deposit/scaffold.js +3 -1
- 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/orchestration/probe-session.d.ts +5 -1
- package/dist/orchestration/probe-session.js +24 -13
- package/dist/platform/agents-md.js +1 -1
- package/dist/policy/deft-directive-disable.d.ts +86 -0
- package/dist/policy/deft-directive-disable.js +167 -0
- package/dist/policy/delivery-branch.d.ts +33 -0
- package/dist/policy/delivery-branch.js +124 -0
- package/dist/policy/index.d.ts +2 -0
- package/dist/policy/index.js +17 -1
- 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/brief-io.d.ts +3 -1
- package/dist/scope/brief-io.js +5 -3
- package/dist/scope/delivery-evidence.d.ts +112 -0
- package/dist/scope/delivery-evidence.js +419 -0
- package/dist/scope/index.d.ts +2 -0
- package/dist/scope/index.js +2 -0
- package/dist/scope/main.d.ts +7 -0
- package/dist/scope/main.js +155 -9
- package/dist/scope/registry-artifact-sync.js +4 -1
- package/dist/scope/transition.d.ts +11 -1
- package/dist/scope/transition.js +30 -4
- package/dist/scope/wip-cap-check.d.ts +6 -0
- package/dist/scope/wip-cap-check.js +14 -2
- package/dist/session/ritual-sentinel.js +21 -12
- package/dist/session/session-start-hook.d.ts +3 -0
- package/dist/session/session-start-hook.js +21 -0
- package/dist/session/session-start.d.ts +7 -0
- package/dist/session/session-start.js +85 -0
- package/dist/swarm/complete-cohort.d.ts +20 -1
- package/dist/swarm/complete-cohort.js +56 -9
- package/dist/swarm/finalize-cohort-cli.js +9 -0
- package/dist/swarm/finalize-cohort.d.ts +8 -0
- package/dist/swarm/finalize-cohort.js +217 -21
- 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/dist/user-config/experimental-rules.d.ts +43 -0
- package/dist/user-config/experimental-rules.js +162 -0
- package/dist/user-config/index.d.ts +1 -0
- package/dist/user-config/index.js +1 -0
- package/dist/verify-source/contained-writes.js +1 -1
- package/package.json +3 -3
|
@@ -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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AuthzState, type HumanOriginGrant } from "../authz/index.js";
|
|
2
|
+
import { detectDeftDirectiveDisable } from "../policy/deft-directive-disable.js";
|
|
2
3
|
import { detectNoDeftDirective } from "../policy/no-deft-directive.js";
|
|
3
4
|
import { type RuntimeAuthorityPolicy } from "../policy/runtime-authority.js";
|
|
4
5
|
import { type VerifyResult } from "../session/verify-session-ritual.js";
|
|
@@ -16,7 +17,9 @@ export type CompactHookHost = (typeof COMPACT_HOOK_HOSTS)[number];
|
|
|
16
17
|
/** Hosts without a native compact hook surface — deposit skips cleanly (#2113). */
|
|
17
18
|
export declare const COMPACT_HOOK_SKIP_HOSTS: readonly ["codex"];
|
|
18
19
|
export type HookVerdict = "allow" | "deny";
|
|
19
|
-
export type HookDecisionCode = "session-start" | "session-start-disabled" | "session-start-degraded"
|
|
20
|
+
export type HookDecisionCode = "session-start" | "session-start-disabled" | "session-start-degraded"
|
|
21
|
+
/** Enforcement skipped: root `.deft-directive-disable` test kill-switch (#3039). */
|
|
22
|
+
| "directive-disabled" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input"
|
|
20
23
|
/** Host closed stdin with zero bytes — integration failure, not a policy gate (#2864). */
|
|
21
24
|
| "stdin-empty" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready" | "read-only-deny" | "spawn-explore-ready" | "spawn-ready" | "spawn-not-ready" | "runtime-policy-deny-path" | "runtime-policy-deny-scope"
|
|
22
25
|
/** Shell/MCP classifiable push/merge allowed under runtimeAuthority (#2711). */
|
|
@@ -56,6 +59,8 @@ export interface HookPolicySeams {
|
|
|
56
59
|
};
|
|
57
60
|
/** Test seam for #2926 root opt-out on SessionStart. */
|
|
58
61
|
readonly detectNoDeftDirective?: typeof detectNoDeftDirective;
|
|
62
|
+
/** Test seam for #3039 root test kill-switch (precedence over #2926 for enforcement). */
|
|
63
|
+
readonly detectDeftDirectiveDisable?: typeof detectDeftDirectiveDisable;
|
|
59
64
|
readonly markCompactStale?: (projectRoot: string) => {
|
|
60
65
|
changed: boolean;
|
|
61
66
|
statePath: string;
|
package/dist/hooks/dispatcher.js
CHANGED
|
@@ -2,6 +2,7 @@ import { realpathSync } from "node:fs";
|
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { appendAuthzAudit, classifyHookAuthzOps, evaluateAuthzMutation, evidenceSatisfiesImplementationApproval, listActiveHumanGrants, loadAuthzStateResult, markGrantUsed, shouldConsumeSingleUseGrant, utcIso, } from "../authz/index.js";
|
|
4
4
|
import { hasArtifactSuffix } from "../layout/resolve.js";
|
|
5
|
+
import { detectDeftDirectiveDisable, formatDeftDirectiveDisableMessage, isDeftDirectiveDisableActive, } from "../policy/deft-directive-disable.js";
|
|
5
6
|
import { evaluateIntentCeilingFromEnv } from "../policy/intent-ceiling.js";
|
|
6
7
|
import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, } from "../policy/no-deft-directive.js";
|
|
7
8
|
import { classifyMcpTool, DEFAULT_RUNTIME_AUTHORITY_POLICY, evaluateRuntimeAuthorityDirectWrite, evaluateRuntimeAuthorityShellOp, listShellOps, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
|
|
@@ -570,6 +571,35 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
570
571
|
/** Decide a normalized event using only the P0 direct-write policy. */
|
|
571
572
|
export function decideHook(input, seams = {}) {
|
|
572
573
|
const projectRoot = resolve(input.projectRoot);
|
|
574
|
+
// #3039: local (untracked) `.deft-directive-disable` wins for enforcement
|
|
575
|
+
// short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
|
|
576
|
+
// Tracked/committed flags do NOT bypass gates (repo-controlled content must
|
|
577
|
+
// not disable enforcement for clones) — doctor warns instead.
|
|
578
|
+
{
|
|
579
|
+
const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
|
|
580
|
+
const kill = detectKill(projectRoot);
|
|
581
|
+
const killActive = seams.detectDeftDirectiveDisable !== undefined
|
|
582
|
+
? kill.active
|
|
583
|
+
: isDeftDirectiveDisableActive(projectRoot);
|
|
584
|
+
if (killActive) {
|
|
585
|
+
const detectOptOut = seams.detectNoDeftDirective ?? detectNoDeftDirective;
|
|
586
|
+
const optOut = detectOptOut(projectRoot);
|
|
587
|
+
const message = formatDeftDirectiveDisableMessage({
|
|
588
|
+
permanentOptOutAlsoPresent: optOut.present,
|
|
589
|
+
trackedByGit: false,
|
|
590
|
+
});
|
|
591
|
+
return {
|
|
592
|
+
verdict: "allow",
|
|
593
|
+
code: input.event === "session.start" ? "session-start-disabled" : "directive-disabled",
|
|
594
|
+
event: input.event,
|
|
595
|
+
host: input.host,
|
|
596
|
+
toolName: null,
|
|
597
|
+
projectRoot,
|
|
598
|
+
message,
|
|
599
|
+
scopePath: null,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
573
603
|
if (input.event === "session.compact") {
|
|
574
604
|
try {
|
|
575
605
|
const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
|
|
@@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
13
13
|
import { join, resolve } from "node:path";
|
|
14
14
|
import { containedWrite } from "../fs/contained-write.js";
|
|
15
15
|
import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
|
|
16
|
+
import { DEFT_DIRECTIVE_DISABLE_GITIGNORE_LINE } from "../policy/deft-directive-disable.js";
|
|
16
17
|
import { FORBIDDEN_BLANKET_EVAL_LINES, stripGitignoreInlineComment, } from "../triage/bootstrap/gitignore.js";
|
|
17
18
|
/** Directory ignore entry for the hybrid deposit (greenfield only). */
|
|
18
19
|
export const GITIGNORE_DEFT_CORE_LINE = ".deft/core/";
|
|
@@ -29,6 +30,8 @@ export const CANONICAL_GITIGNORE_BASELINE = [
|
|
|
29
30
|
".deft/ritual-state.json",
|
|
30
31
|
".deft/last-session.json",
|
|
31
32
|
".deft/routing.local.json",
|
|
33
|
+
// Temporary test/local kill-switch — must stay untracked (#3039).
|
|
34
|
+
DEFT_DIRECTIVE_DISABLE_GITIGNORE_LINE,
|
|
32
35
|
"vbrief/.triage-cache/candidates.jsonl",
|
|
33
36
|
"vbrief/.triage-cache/summary-history.jsonl",
|
|
34
37
|
"vbrief/.triage-cache/scope-lifecycle.jsonl",
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
* Scoped staging + installer-managed allowlist for TS-native init/update (#1453).
|
|
3
3
|
*
|
|
4
4
|
* Mirrors cmd/deft-install/hygiene.go + deposit.go installerManagedMatchers.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* CRITICAL (#1430 / #3030): the allowlist MUST honor the SPEC consumer-path
|
|
7
|
+
* denylist (`CONSUMER_GUARD_MUST_FIRE`). Consumer-authored PROJECT-DEFINITION
|
|
8
|
+
* and scope briefs are never installer-managed; if they reappear in
|
|
9
|
+
* `installerManagedMatchers()`, unit tests and deposit-time assert fail closed.
|
|
10
|
+
*
|
|
11
|
+
* Refs #1576, #1453, #1430, #3029, #3030.
|
|
6
12
|
*/
|
|
7
13
|
import { type InitDepositIo } from "./constants.js";
|
|
8
14
|
export declare const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
|
|
@@ -11,11 +17,47 @@ export interface InstallerManagedMatcher {
|
|
|
11
17
|
readonly exact?: string;
|
|
12
18
|
readonly prefix?: string;
|
|
13
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Consumer paths that MUST trip no-mixed-core-and-app when mixed with
|
|
22
|
+
* `.deft/core/**` (#1430 SPEC). These probe paths must never match
|
|
23
|
+
* `installerManagedMatchers()` / the deposited guard ERE.
|
|
24
|
+
*
|
|
25
|
+
* Legitimate installer scaffolding (xbrief/.deft-version, lifecycle .gitkeep,
|
|
26
|
+
* schemas/, migration/, xbrief.md) is NOT in this denylist — see #2277.
|
|
27
|
+
* Init may still *create* PROJECT-DEFINITION (#3013); create ≠ allowlist.
|
|
28
|
+
*
|
|
29
|
+
* Refs #3030, #3029, #1430.
|
|
30
|
+
*/
|
|
31
|
+
export declare const CONSUMER_GUARD_MUST_FIRE: readonly string[];
|
|
14
32
|
/** Single source of truth for installer-managed paths (#1440 / #1576). */
|
|
15
33
|
export declare function installerManagedMatchers(): InstallerManagedMatcher[];
|
|
34
|
+
/**
|
|
35
|
+
* Fail closed when the installer-managed allowlist would exempt consumer
|
|
36
|
+
* PROJECT-DEFINITION or scope briefs (#3030 / #1430). Pure over `matchers` so
|
|
37
|
+
* tests can inject a bad matcher without mutating production state.
|
|
38
|
+
*
|
|
39
|
+
* Checks both:
|
|
40
|
+
* 1. Explicit probe paths in {@link CONSUMER_GUARD_MUST_FIRE}
|
|
41
|
+
* 2. Structural patterns (any exact consumer scope brief / PD; forbidden prefixes)
|
|
42
|
+
* so an unlisted `xbrief/active/another-scope.xbrief.json` matcher still fails.
|
|
43
|
+
*/
|
|
44
|
+
export declare function assertInstallerAllowlistHonors1430(matchers?: readonly InstallerManagedMatcher[]): void;
|
|
16
45
|
/** POSIX ERE alternation embedded in the deposited deft-core-guard workflow. */
|
|
17
46
|
export declare function installerManagedGuardEre(): string;
|
|
18
47
|
export declare function isInstallerManagedPath(path: string): boolean;
|
|
48
|
+
export interface MixedCoreAndAppClassification {
|
|
49
|
+
readonly core: string[];
|
|
50
|
+
readonly installerManaged: string[];
|
|
51
|
+
readonly app: string[];
|
|
52
|
+
/** True when both core and app are non-empty — the deposited guard fails. */
|
|
53
|
+
readonly wouldFail: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* TS twin of Go `classifyChangedPaths` / deposited shell guard (#1430).
|
|
57
|
+
* Core = `.deft/core/**`; installer-managed = allowlist; app = everything else.
|
|
58
|
+
* Guard fails iff both core and app are non-empty.
|
|
59
|
+
*/
|
|
60
|
+
export declare function classifyMixedCoreAndApp(changedPaths: readonly string[], matchers?: readonly InstallerManagedMatcher[]): MixedCoreAndAppClassification;
|
|
19
61
|
export interface FrameworkStagePathsOptions {
|
|
20
62
|
/**
|
|
21
63
|
* Include the vendored `.deft/core` payload in the stage set. Defaults to
|