@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.
Files changed (101) hide show
  1. package/dist/cache/fetch.js +3 -2
  2. package/dist/cache/io.d.ts +13 -2
  3. package/dist/cache/io.js +36 -7
  4. package/dist/cache/operations.js +5 -3
  5. package/dist/doctor/main.js +37 -0
  6. package/dist/doctor/openclaw-skills.d.ts +42 -8
  7. package/dist/doctor/openclaw-skills.js +281 -38
  8. package/dist/eval/health.d.ts +10 -1
  9. package/dist/eval/health.js +16 -16
  10. package/dist/hooks/dispatcher.d.ts +6 -1
  11. package/dist/hooks/dispatcher.js +30 -0
  12. package/dist/init-deposit/gitignore.js +3 -0
  13. package/dist/init-deposit/hygiene.d.ts +43 -1
  14. package/dist/init-deposit/hygiene.js +112 -7
  15. package/dist/init-deposit/init-deposit.d.ts +5 -0
  16. package/dist/init-deposit/init-deposit.js +37 -2
  17. package/dist/init-deposit/scaffold.js +3 -1
  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/orchestration/probe-session.d.ts +5 -1
  23. package/dist/orchestration/probe-session.js +24 -13
  24. package/dist/platform/agents-md.js +1 -1
  25. package/dist/policy/deft-directive-disable.d.ts +86 -0
  26. package/dist/policy/deft-directive-disable.js +167 -0
  27. package/dist/policy/delivery-branch.d.ts +33 -0
  28. package/dist/policy/delivery-branch.js +124 -0
  29. package/dist/policy/index.d.ts +2 -0
  30. package/dist/policy/index.js +17 -1
  31. package/dist/policy/plan-extensions.d.ts +10 -0
  32. package/dist/policy/plan-extensions.js +16 -0
  33. package/dist/pr-closing-keywords/detect.d.ts +13 -0
  34. package/dist/pr-closing-keywords/detect.js +73 -8
  35. package/dist/pr-closing-keywords/index.d.ts +2 -2
  36. package/dist/pr-closing-keywords/index.js +1 -1
  37. package/dist/pr-closing-keywords/main.js +119 -90
  38. package/dist/pr-closing-keywords/types.d.ts +10 -0
  39. package/dist/render/constants.d.ts +16 -0
  40. package/dist/render/constants.js +16 -1
  41. package/dist/render/export-spec.d.ts +7 -1
  42. package/dist/render/export-spec.js +51 -5
  43. package/dist/render/index.d.ts +3 -2
  44. package/dist/render/index.js +2 -2
  45. package/dist/render/roadmap-render.js +164 -60
  46. package/dist/render/scope-outlook.d.ts +5 -0
  47. package/dist/render/scope-outlook.js +3 -0
  48. package/dist/render/spec-render.d.ts +28 -2
  49. package/dist/render/spec-render.js +111 -14
  50. package/dist/render/text-utils.d.ts +6 -0
  51. package/dist/render/text-utils.js +23 -0
  52. package/dist/scm/binary.d.ts +3 -0
  53. package/dist/scm/binary.js +10 -2
  54. package/dist/scm/call.d.ts +5 -0
  55. package/dist/scm/call.js +5 -0
  56. package/dist/scm/index.d.ts +1 -0
  57. package/dist/scm/index.js +1 -0
  58. package/dist/scm/main.d.ts +5 -0
  59. package/dist/scm/main.js +27 -0
  60. package/dist/scm/readiness-cli.d.ts +24 -0
  61. package/dist/scm/readiness-cli.js +85 -0
  62. package/dist/scm/readiness.d.ts +104 -0
  63. package/dist/scm/readiness.js +421 -0
  64. package/dist/scope/batch-promote.d.ts +26 -0
  65. package/dist/scope/batch-promote.js +167 -0
  66. package/dist/scope/brief-io.d.ts +3 -1
  67. package/dist/scope/brief-io.js +5 -3
  68. package/dist/scope/delivery-evidence.d.ts +112 -0
  69. package/dist/scope/delivery-evidence.js +419 -0
  70. package/dist/scope/index.d.ts +2 -0
  71. package/dist/scope/index.js +2 -0
  72. package/dist/scope/main.d.ts +7 -0
  73. package/dist/scope/main.js +155 -9
  74. package/dist/scope/registry-artifact-sync.js +4 -1
  75. package/dist/scope/transition.d.ts +11 -1
  76. package/dist/scope/transition.js +30 -4
  77. package/dist/scope/wip-cap-check.d.ts +6 -0
  78. package/dist/scope/wip-cap-check.js +14 -2
  79. package/dist/session/ritual-sentinel.js +21 -12
  80. package/dist/session/session-start-hook.d.ts +3 -0
  81. package/dist/session/session-start-hook.js +21 -0
  82. package/dist/session/session-start.d.ts +7 -0
  83. package/dist/session/session-start.js +85 -0
  84. package/dist/swarm/complete-cohort.d.ts +20 -1
  85. package/dist/swarm/complete-cohort.js +56 -9
  86. package/dist/swarm/finalize-cohort-cli.js +9 -0
  87. package/dist/swarm/finalize-cohort.d.ts +8 -0
  88. package/dist/swarm/finalize-cohort.js +217 -21
  89. package/dist/triage/welcome/index.d.ts +1 -1
  90. package/dist/triage/welcome/index.js +1 -1
  91. package/dist/triage/welcome/onboard.js +7 -1
  92. package/dist/triage/welcome/prior-state.d.ts +7 -0
  93. package/dist/triage/welcome/prior-state.js +16 -2
  94. package/dist/triage/welcome/writers.d.ts +10 -0
  95. package/dist/triage/welcome/writers.js +72 -2
  96. package/dist/user-config/experimental-rules.d.ts +43 -0
  97. package/dist/user-config/experimental-rules.js +162 -0
  98. package/dist/user-config/index.d.ts +1 -0
  99. package/dist/user-config/index.js +1 -0
  100. package/dist/verify-source/contained-writes.js +1 -1
  101. 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 symlinked (preferred)
7
- * or copied from the installed content package into the target skills dir.
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, broken link)
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. Prefer symlink; copy on failure.
222
- * Never deletes user skills. Divergent targets require force or TTY confirm.
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
- if (skillHasBody(target, isFile, isDir)) {
252
- return {
253
- skillId,
254
- method: "already-present",
255
- target,
256
- source: sourceDir,
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: "divergent target exists; re-run with --force or confirm on TTY to replace",
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
- symlinkDir(sourceDir, target);
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
- copyDir(sourceDir, target);
277
- return { skillId, method: "copy", target, source: sourceDir };
433
+ removePath(staging);
278
434
  }
279
- catch (err) {
280
- return {
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
- if (!options.force && assessment.divergent.length > 0 && isTty() && !options.jsonMode) {
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
- if (readYn(`Replace divergent OpenClaw skill dir ${join(skillsDir, skillId)} with pin from content package?`, false)) {
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, { force: options.force || assessment.divergent.includes(skillId) }, seams);
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)`));
@@ -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
- /** Detect the canonical wipCap unsatisfiable-nudge contradiction (#1694). */
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[];
@@ -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
- /** Detect the canonical wipCap unsatisfiable-nudge contradiction (#1694). */
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: "Onboarding completeness treats absent plan.policy.wipCap as incomplete, but omit-by-design accepts absence as valid (#1694 / #1186 D1).",
136
+ summary: "wipCap decision marker is set but classifyOnboarding still lists wipCap as missing (#1694 regression).",
137
137
  signals: [
138
- "classifyOnboarding: wipCap listed in missing onboarding signals",
139
- "validateWipCapOnPlan: omitted wipCap is valid",
140
- "triage:welcome --onboard nudge cannot clear without violating omit-by-design contract",
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" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input"
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;
@@ -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
- * Refs #1576, #1453, #1430.
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