@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.
Files changed (62) hide show
  1. package/dist/doctor/openclaw-skills.d.ts +42 -8
  2. package/dist/doctor/openclaw-skills.js +281 -38
  3. package/dist/eval/health.d.ts +10 -1
  4. package/dist/eval/health.js +16 -16
  5. package/dist/init-deposit/hygiene.js +3 -0
  6. package/dist/init-deposit/init-deposit.d.ts +5 -0
  7. package/dist/init-deposit/init-deposit.js +37 -2
  8. package/dist/intake/github-auth-modes.d.ts +1 -1
  9. package/dist/intake/github-auth-modes.js +25 -3
  10. package/dist/intake/issue-ingest-cli.js +13 -0
  11. package/dist/intake/reconcile-issues-cli.js +13 -0
  12. package/dist/policy/plan-extensions.d.ts +10 -0
  13. package/dist/policy/plan-extensions.js +16 -0
  14. package/dist/pr-closing-keywords/detect.d.ts +13 -0
  15. package/dist/pr-closing-keywords/detect.js +73 -8
  16. package/dist/pr-closing-keywords/index.d.ts +2 -2
  17. package/dist/pr-closing-keywords/index.js +1 -1
  18. package/dist/pr-closing-keywords/main.js +119 -90
  19. package/dist/pr-closing-keywords/types.d.ts +10 -0
  20. package/dist/render/constants.d.ts +16 -0
  21. package/dist/render/constants.js +16 -1
  22. package/dist/render/export-spec.d.ts +7 -1
  23. package/dist/render/export-spec.js +51 -5
  24. package/dist/render/index.d.ts +3 -2
  25. package/dist/render/index.js +2 -2
  26. package/dist/render/roadmap-render.js +164 -60
  27. package/dist/render/scope-outlook.d.ts +5 -0
  28. package/dist/render/scope-outlook.js +3 -0
  29. package/dist/render/spec-render.d.ts +28 -2
  30. package/dist/render/spec-render.js +111 -14
  31. package/dist/render/text-utils.d.ts +6 -0
  32. package/dist/render/text-utils.js +23 -0
  33. package/dist/scm/binary.d.ts +3 -0
  34. package/dist/scm/binary.js +10 -2
  35. package/dist/scm/call.d.ts +5 -0
  36. package/dist/scm/call.js +5 -0
  37. package/dist/scm/index.d.ts +1 -0
  38. package/dist/scm/index.js +1 -0
  39. package/dist/scm/main.d.ts +5 -0
  40. package/dist/scm/main.js +27 -0
  41. package/dist/scm/readiness-cli.d.ts +24 -0
  42. package/dist/scm/readiness-cli.js +85 -0
  43. package/dist/scm/readiness.d.ts +104 -0
  44. package/dist/scm/readiness.js +421 -0
  45. package/dist/scope/batch-promote.d.ts +26 -0
  46. package/dist/scope/batch-promote.js +167 -0
  47. package/dist/scope/index.d.ts +1 -0
  48. package/dist/scope/index.js +1 -0
  49. package/dist/scope/main.d.ts +2 -0
  50. package/dist/scope/main.js +58 -7
  51. package/dist/scope/wip-cap-check.d.ts +6 -0
  52. package/dist/scope/wip-cap-check.js +14 -2
  53. package/dist/session/session-start.d.ts +7 -0
  54. package/dist/session/session-start.js +54 -0
  55. package/dist/triage/welcome/index.d.ts +1 -1
  56. package/dist/triage/welcome/index.js +1 -1
  57. package/dist/triage/welcome/onboard.js +7 -1
  58. package/dist/triage/welcome/prior-state.d.ts +7 -0
  59. package/dist/triage/welcome/prior-state.js +16 -2
  60. package/dist/triage/welcome/writers.d.ts +10 -0
  61. package/dist/triage/welcome/writers.js +72 -2
  62. 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 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
  */
@@ -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, broken link)
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">): OpenClawPinAssessment;
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. Prefer symlink; copy on failure.
90
- * Never deletes user skills. Divergent targets require force or TTY confirm.
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 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
  }
@@ -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;