@deftai/directive-core 0.86.0 → 0.87.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 (35) hide show
  1. package/dist/check/gate-lists.js +1 -0
  2. package/dist/doctor/main.d.ts +6 -5
  3. package/dist/doctor/main.js +32 -18
  4. package/dist/doctor/taskfile.d.ts +8 -0
  5. package/dist/doctor/taskfile.js +19 -0
  6. package/dist/hooks/dispatcher.d.ts +21 -1
  7. package/dist/hooks/dispatcher.js +85 -15
  8. package/dist/intake/issue-emit.d.ts +45 -2
  9. package/dist/intake/issue-emit.js +420 -17
  10. package/dist/intake/issue-ingest.js +54 -4
  11. package/dist/platform/platform-capabilities.js +3 -0
  12. package/dist/review-monitor/constants.js +3 -2
  13. package/dist/review-monitor/tier-detection.d.ts +6 -2
  14. package/dist/review-monitor/tier-detection.js +27 -2
  15. package/dist/scope/transition.js +43 -0
  16. package/dist/session/release-availability.d.ts +2 -0
  17. package/dist/session/release-availability.js +23 -8
  18. package/dist/swarm/routing-set-cli.js +5 -10
  19. package/dist/swarm/routing.d.ts +3 -2
  20. package/dist/swarm/routing.js +16 -4
  21. package/dist/triage/help/registry-data.d.ts +7 -7
  22. package/dist/triage/help/registry-data.js +15 -6
  23. package/dist/triage/queue/index.d.ts +1 -0
  24. package/dist/triage/queue/index.js +1 -0
  25. package/dist/triage/queue/show.d.ts +69 -0
  26. package/dist/triage/queue/show.js +293 -0
  27. package/dist/triage/scope/cli.js +3 -0
  28. package/dist/triage/scope/coverage.d.ts +2 -0
  29. package/dist/triage/scope/coverage.js +18 -3
  30. package/dist/verify-source/index.d.ts +1 -0
  31. package/dist/verify-source/index.js +1 -0
  32. package/dist/verify-source/openclaw-tier1.d.ts +37 -0
  33. package/dist/verify-source/openclaw-tier1.js +100 -0
  34. package/dist/xbrief-migrate/migrate-project.js +9 -5
  35. package/package.json +3 -3
@@ -1,17 +1,278 @@
1
- import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, globSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
- import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
5
  import { referenceTypeMatches } from "@deftai/directive-types";
6
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
5
7
  import { call } from "../scm/call.js";
6
8
  import { resolveProjectRoot } from "../scope/project-context.js";
7
9
  import { resolveProjectRepo } from "../slice/project-context.js";
8
10
  export const GITHUB_ISSUE_REF_TYPE = "x-xbrief/github-issue";
9
11
  export const EXTERNAL_TRUST_LEVEL = "external";
10
12
  const ISSUE_URL_PATTERN = /https?:\/\/\S+?\/issues\/\d+/;
13
+ /**
14
+ * Structured post-create failure: remote issue exists; local stamp/ledger may not.
15
+ * `createdUrl` is the durable handle for retry (also mirrored in process + OS-temp recovery).
16
+ */
11
17
  export class IssueEmitError extends Error {
12
- constructor(message) {
18
+ createdUrl;
19
+ constructor(message, options) {
13
20
  super(message);
14
21
  this.name = "IssueEmitError";
22
+ if (options?.createdUrl !== undefined && options.createdUrl.length > 0) {
23
+ this.createdUrl = options.createdUrl;
24
+ }
25
+ }
26
+ }
27
+ /**
28
+ * Process-local fallback for post-create URL when project ledger and xBRIEF stamp both fail (#2880).
29
+ * Survives same-process retry without re-create; paired with private OS-temp sidecar for restarts.
30
+ */
31
+ const processPendingUrls = new Map();
32
+ /** Vitest-only failure injection via env (not a production export surface). */
33
+ function testFailProjectLedger() {
34
+ return process.env.VITEST === "true" && process.env.DEFT_ISSUE_EMIT_TEST_FAIL_LEDGER === "1";
35
+ }
36
+ function testFailStamp() {
37
+ return process.env.VITEST === "true" && process.env.DEFT_ISSUE_EMIT_TEST_FAIL_STAMP === "1";
38
+ }
39
+ function privateRecoveryRoot() {
40
+ const base = typeof process.env.XDG_RUNTIME_DIR === "string" && process.env.XDG_RUNTIME_DIR.length > 0
41
+ ? process.env.XDG_RUNTIME_DIR
42
+ : tmpdir();
43
+ const uid = typeof process.getuid === "function" ? String(process.getuid()) : "u";
44
+ return join(base, `deft-issue-emit-recovery-${uid}`);
45
+ }
46
+ /** Refuse dirs/files we do not own or that are group/other-writable (forged recovery, #2880). */
47
+ function isTrustedStat(st) {
48
+ if (st.isSymbolicLink()) {
49
+ return false;
50
+ }
51
+ if (typeof process.getuid === "function" && typeof st.uid === "number") {
52
+ if (st.uid !== process.getuid()) {
53
+ return false;
54
+ }
55
+ }
56
+ // Group/other write bits allow another local principal to rewrite recovery state.
57
+ const mode = Number(st.mode);
58
+ if ((mode & 0o022) !== 0) {
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+ function ensureTrustedRecoveryDir() {
64
+ const dir = privateRecoveryRoot();
65
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
66
+ const dirStat = lstatSync(dir);
67
+ if (!isTrustedStat(dirStat) || !dirStat.isDirectory()) {
68
+ throw new Error("issue-emit recovery dir is not a trusted directory owned by the current user");
69
+ }
70
+ if ((dirStat.mode & 0o777) !== 0o700) {
71
+ try {
72
+ chmodSync(dir, 0o700);
73
+ }
74
+ catch {
75
+ // Best-effort tighten; isTrustedStat already refused group/other write.
76
+ }
77
+ }
78
+ return dir;
79
+ }
80
+ export function recoverySidecarPath(vbriefAbsPath) {
81
+ const key = createHash("sha256").update(resolve(vbriefAbsPath)).digest("hex").slice(0, 40);
82
+ return join(privateRecoveryRoot(), `${key}.json`);
83
+ }
84
+ /**
85
+ * Write recovery sidecar without following symlinks (#2880 Greptile P1).
86
+ * Trusted per-uid dir (ownership + mode) + O_EXCL|O_NOFOLLOW create.
87
+ */
88
+ function writeRecoverySidecarSafe(sidePath, payload) {
89
+ const dir = ensureTrustedRecoveryDir();
90
+ if (resolve(dirname(sidePath)) !== resolve(dir)) {
91
+ // Always write under the validated recovery root.
92
+ throw new Error("issue-emit recovery path escapes trusted root");
93
+ }
94
+ try {
95
+ const existing = lstatSync(sidePath);
96
+ if (existing.isSymbolicLink() || existing.isFile()) {
97
+ // unlink of a symlink removes the link itself (does not follow).
98
+ unlinkSync(sidePath);
99
+ }
100
+ else {
101
+ throw new Error("issue-emit recovery path exists and is not a regular file");
102
+ }
103
+ }
104
+ catch (err) {
105
+ const code = err.code;
106
+ if (code !== "ENOENT") {
107
+ throw err;
108
+ }
109
+ }
110
+ let flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
111
+ if (typeof constants.O_NOFOLLOW === "number") {
112
+ flags |= constants.O_NOFOLLOW;
113
+ }
114
+ const fd = openSync(sidePath, flags, 0o600);
115
+ try {
116
+ writeSync(fd, payload, undefined, "utf8");
117
+ }
118
+ finally {
119
+ closeSync(fd);
120
+ }
121
+ }
122
+ /**
123
+ * Always-on recovery after remote create: process memory + private OS-temp sidecar.
124
+ * Independent of project-contained ledger so dual local failure still reconciles on retry (#2880).
125
+ */
126
+ export function rememberCreatedUrl(vbriefAbsPath, url) {
127
+ const key = resolve(vbriefAbsPath);
128
+ processPendingUrls.set(key, url);
129
+ try {
130
+ writeRecoverySidecarSafe(recoverySidecarPath(key), `${JSON.stringify({ path: key, url }, null, 2)}\n`);
131
+ }
132
+ catch {
133
+ // Best-effort disk mirror; process map still holds the URL for same-process retry.
134
+ }
135
+ }
136
+ export function loadRecoveredUrl(vbriefAbsPath) {
137
+ const key = resolve(vbriefAbsPath);
138
+ const mem = processPendingUrls.get(key);
139
+ if (typeof mem === "string" && mem.length > 0) {
140
+ return mem;
141
+ }
142
+ try {
143
+ const side = recoverySidecarPath(key);
144
+ // Parent dir must be trusted before we read any sidecar (forged-dir attack, #2880).
145
+ const parent = dirname(side);
146
+ const parentStat = lstatSync(parent);
147
+ if (!isTrustedStat(parentStat) || !parentStat.isDirectory()) {
148
+ return undefined;
149
+ }
150
+ const st = lstatSync(side);
151
+ if (!st.isFile() || !isTrustedStat(st)) {
152
+ return undefined;
153
+ }
154
+ const raw = readFileSync(side, "utf8");
155
+ const parsed = JSON.parse(raw);
156
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
157
+ const obj = parsed;
158
+ const url = obj.url;
159
+ const pathField = obj.path;
160
+ if (typeof url === "string" && url.length > 0 && ISSUE_URL_PATTERN.test(url)) {
161
+ if (typeof pathField === "string" && resolve(pathField) !== key) {
162
+ return undefined;
163
+ }
164
+ processPendingUrls.set(key, url);
165
+ return url;
166
+ }
167
+ }
168
+ }
169
+ catch {
170
+ // Missing or untrusted recovery file is empty.
171
+ }
172
+ return undefined;
173
+ }
174
+ export function clearRecoveredUrl(vbriefAbsPath) {
175
+ const key = resolve(vbriefAbsPath);
176
+ processPendingUrls.delete(key);
177
+ try {
178
+ const side = recoverySidecarPath(key);
179
+ try {
180
+ const st = lstatSync(side);
181
+ if (st.isSymbolicLink() || st.isFile()) {
182
+ unlinkSync(side);
183
+ }
184
+ }
185
+ catch {
186
+ rmSync(side, { force: true });
187
+ }
188
+ }
189
+ catch {
190
+ // Best-effort clear.
191
+ }
192
+ }
193
+ /** Project ledger first, then process/OS-temp recovery (#2880). */
194
+ export function resolvePriorCreatedUrl(projectRoot, vbriefAbsPath) {
195
+ const absPath = resolve(vbriefAbsPath);
196
+ const pending = loadPendingEmitUrls(projectRoot)[absPath];
197
+ if (typeof pending === "string" && pending.length > 0) {
198
+ return pending;
199
+ }
200
+ return loadRecoveredUrl(absPath);
201
+ }
202
+ /**
203
+ * Record URL immediately after remote create. Project ledger is best-effort;
204
+ * recovery layers always run first so dual local failure cannot force re-create (#2880).
205
+ */
206
+ export function recordCreatedUrlDurable(projectRoot, vbriefAbsPath, url) {
207
+ rememberCreatedUrl(vbriefAbsPath, url);
208
+ if (testFailProjectLedger()) {
209
+ return;
210
+ }
211
+ try {
212
+ savePendingEmitUrl(projectRoot, vbriefAbsPath, url);
213
+ }
214
+ catch {
215
+ // Recovery layers already hold the URL.
216
+ }
217
+ }
218
+ function stampUrlOntoVbrief(path, data, url, projectRoot) {
219
+ if (testFailStamp()) {
220
+ throw new Error("issue-emit test hook: stamp failure");
221
+ }
222
+ addGithubIssueReference(data, url);
223
+ writeVbrief(path, data, projectRoot);
224
+ clearPendingEmitUrl(projectRoot, path);
225
+ clearRecoveredUrl(path);
226
+ }
227
+ /** Contained durable map: abs vbrief path -> issue URL for in-flight emits (#2871). */
228
+ export function pendingEmitLedgerPath(projectRoot) {
229
+ return join(resolve(projectRoot), ".deft-cache", "issue-emit-pending.json");
230
+ }
231
+ export function loadPendingEmitUrls(projectRoot) {
232
+ const ledger = pendingEmitLedgerPath(projectRoot);
233
+ try {
234
+ assertWriteTargetSafe(projectRoot, ledger);
235
+ const raw = readFileSync(ledger, "utf8");
236
+ const parsed = JSON.parse(raw);
237
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
238
+ const out = {};
239
+ for (const [k, v] of Object.entries(parsed)) {
240
+ if (typeof v === "string" && v.length > 0) {
241
+ out[k] = v;
242
+ }
243
+ }
244
+ return out;
245
+ }
246
+ }
247
+ catch {
248
+ // Missing or unreadable ledger is empty.
249
+ }
250
+ return {};
251
+ }
252
+ export function savePendingEmitUrl(projectRoot, vbriefAbsPath, url) {
253
+ const ledger = pendingEmitLedgerPath(projectRoot);
254
+ assertWriteTargetSafe(projectRoot, ledger);
255
+ mkdirSync(dirname(ledger), { recursive: true });
256
+ const map = loadPendingEmitUrls(projectRoot);
257
+ map[resolve(vbriefAbsPath)] = url;
258
+ assertWriteTargetSafe(projectRoot, ledger);
259
+ writeFileSync(ledger, `${JSON.stringify(map, null, 2)}\n`, "utf8");
260
+ }
261
+ export function clearPendingEmitUrl(projectRoot, vbriefAbsPath) {
262
+ const ledger = pendingEmitLedgerPath(projectRoot);
263
+ const key = resolve(vbriefAbsPath);
264
+ const map = loadPendingEmitUrls(projectRoot);
265
+ if (!(key in map)) {
266
+ return;
267
+ }
268
+ delete map[key];
269
+ try {
270
+ assertWriteTargetSafe(projectRoot, ledger);
271
+ mkdirSync(dirname(ledger), { recursive: true });
272
+ writeFileSync(ledger, `${JSON.stringify(map, null, 2)}\n`, "utf8");
273
+ }
274
+ catch {
275
+ // Best-effort clear; next successful stamp still works via existingGithubIssueRef.
15
276
  }
16
277
  }
17
278
  export function loadVbrief(path) {
@@ -20,8 +281,39 @@ export function loadVbrief(path) {
20
281
  ? data
21
282
  : {};
22
283
  }
23
- export function writeVbrief(path, data) {
24
- writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
284
+ /**
285
+ * Resolve project root and refuse unsafe write targets before any emit side-effect.
286
+ * Callers that create remote issues MUST invoke this before `fileIssue` so a
287
+ * containment refusal cannot leave an orphan GitHub issue (#2869 / #2871).
288
+ *
289
+ * ⊗ Do not fall back to `dirname(path)` as the containment root — that trusts a
290
+ * possibly-symlinked parent and would re-open the escape (Greptile P1 on #2871).
291
+ */
292
+ export function assertVbriefWriteTargetSafe(path, projectRoot) {
293
+ const absPath = resolve(path);
294
+ // Resolve root only from explicit projectRoot, DEFT_PROJECT_ROOT, or cwd walk-up.
295
+ // Never start discovery from dirname(absPath) — that trusts a possibly-symlinked parent
296
+ // of the write target as the containment root (Greptile/SLizard P1 on #2871).
297
+ const root = projectRoot !== undefined && projectRoot !== null && projectRoot.length > 0
298
+ ? resolve(projectRoot)
299
+ : resolveProjectRoot(null);
300
+ if (root === null) {
301
+ throw new ProjectionContainmentError(`projection write refused: could not resolve project root for ${absPath}; pass projectRoot or run from a project checkout`, {
302
+ projectDir: process.cwd(),
303
+ targetPath: absPath,
304
+ offendingPath: absPath,
305
+ });
306
+ }
307
+ assertWriteTargetSafe(root, absPath);
308
+ return root;
309
+ }
310
+ /**
311
+ * Persist an xBRIEF/vBRIEF JSON document. Gates the write target so a leaf or
312
+ * parent-directory symlink cannot divert the stamped file outside the project (#2869).
313
+ */
314
+ export function writeVbrief(path, data, projectRoot) {
315
+ assertVbriefWriteTargetSafe(path, projectRoot);
316
+ writeFileSync(resolve(path), `${JSON.stringify(data, null, 2)}\n`, "utf8");
25
317
  }
26
318
  export function vbriefTitle(data) {
27
319
  const plan = (data.plan ?? {});
@@ -160,10 +452,37 @@ export function emitSingle(path, options) {
160
452
  if (options.noNetwork) {
161
453
  return { result: "dryrun", vbrief: shown, url: null, title };
162
454
  }
455
+ // Atomic emit contract (#2871 / #2880):
456
+ // 1) Resolve a trusted project root (never dirname of target).
457
+ // 2) Reconcile prior URL from project ledger OR process/OS-temp recovery (no re-create).
458
+ // 3) Pre-persist current payload so the write path is proven.
459
+ // 4) Create remote issue, then always record URL in recovery + best-effort project ledger.
460
+ // 5) Stamp the vbrief; clear recovery on success. Dual local failure still retries safely.
461
+ const root = assertVbriefWriteTargetSafe(path, options.projectRoot);
462
+ const absPath = resolve(path);
463
+ const priorUrl = resolvePriorCreatedUrl(root, absPath);
464
+ if (typeof priorUrl === "string" && priorUrl.length > 0) {
465
+ try {
466
+ stampUrlOntoVbrief(path, data, priorUrl, root);
467
+ }
468
+ catch (stampErr) {
469
+ throw new IssueEmitError(`reconcile ${priorUrl} but failed to stamp local vbrief: ${String(stampErr)}`, { createdUrl: priorUrl });
470
+ }
471
+ return { result: "created", vbrief: shown, url: priorUrl, title };
472
+ }
473
+ writeVbrief(path, data, root);
163
474
  const body = renderIssueBody(data);
475
+ assertVbriefWriteTargetSafe(path, root);
164
476
  const url = fileIssue(options.repo, title, body, options.scmCall);
165
- addGithubIssueReference(data, url);
166
- writeVbrief(path, data);
477
+ // Recovery layers first (process + OS-temp), then best-effort project ledger (#2880).
478
+ // Dual ledger+stamp failure still leaves URL for retry without re-create.
479
+ recordCreatedUrlDurable(root, absPath, url);
480
+ try {
481
+ stampUrlOntoVbrief(path, data, url, root);
482
+ }
483
+ catch (stampErr) {
484
+ throw new IssueEmitError(`created ${url} but failed to stamp local vbrief: ${String(stampErr)}`, { createdUrl: url });
485
+ }
167
486
  return { result: "created", vbrief: shown, url, title };
168
487
  }
169
488
  export function emitPerVbrief(paths, options) {
@@ -188,12 +507,22 @@ export function emitUmbrella(paths, options) {
188
507
  loaded.push([paths[i], shown[i], loadVbrief(paths[i])]);
189
508
  }
190
509
  const pending = loaded.filter(([, , data]) => existingGithubIssueRef(data) === undefined);
191
- const already = loaded
192
- .filter(([, , data]) => existingGithubIssueRef(data) !== undefined)
193
- .map(([, disp]) => ({ vbrief: disp, result: "skipped" }));
510
+ const alreadyEntries = loaded.filter(([, , data]) => existingGithubIssueRef(data) !== undefined);
511
+ const already = alreadyEntries.map(([, disp]) => ({ vbrief: disp, result: "skipped" }));
512
+ // Already-stamped siblings seed the umbrella URL and participate in conflict checks (#2880).
513
+ let stampedSiblingUrl = null;
514
+ for (const [, , data] of alreadyEntries) {
515
+ const ref = existingGithubIssueRef(data);
516
+ if (typeof ref === "string" && ref.length > 0) {
517
+ if (stampedSiblingUrl !== null && stampedSiblingUrl !== ref) {
518
+ throw new IssueEmitError(`umbrella siblings already stamped with conflicting issue URLs: ${stampedSiblingUrl} vs ${ref}`, { createdUrl: stampedSiblingUrl });
519
+ }
520
+ stampedSiblingUrl = ref;
521
+ }
522
+ }
194
523
  const umbrellaTitle = options.title ?? defaultUmbrellaTitle(loaded.length);
195
524
  if (pending.length === 0) {
196
- return { result: "skipped", url: null, title: umbrellaTitle, vbriefs: already };
525
+ return { result: "skipped", url: stampedSiblingUrl, title: umbrellaTitle, vbriefs: already };
197
526
  }
198
527
  if (options.noNetwork) {
199
528
  return {
@@ -203,13 +532,80 @@ export function emitUmbrella(paths, options) {
203
532
  vbriefs: [...pending.map(([, disp]) => ({ vbrief: disp, result: "dryrun" })), ...already],
204
533
  };
205
534
  }
206
- const body = renderUmbrellaBody(pending.map(([, disp, data]) => [disp, data]));
207
- const url = fileIssue(options.repo, umbrellaTitle, body, options.scmCall);
535
+ // Umbrella emit: same durability contract as emitSingle (#2871 / #2880).
536
+ // Reconcile pending/recovery URLs, pre-persist, create once, record all, stamp all.
537
+ // Mid-loop ledger/stamp failure must not force a second remote create on retry.
538
+ const root = options.projectRoot !== undefined &&
539
+ options.projectRoot !== null &&
540
+ options.projectRoot.length > 0
541
+ ? resolve(options.projectRoot)
542
+ : assertVbriefWriteTargetSafe(String(pending[0]?.[0] || ""), options.projectRoot);
208
543
  const written = [];
544
+ const stillNeedRemote = [];
545
+ // Pass 1: resolve recovered URLs and reject conflicts BEFORE any stamp (#2880).
546
+ // Include already-stamped sibling URLs so partial umbrella cohorts cannot split.
547
+ let reconciledUrl = stampedSiblingUrl;
548
+ const withPrior = [];
209
549
  for (const [path, disp, data] of pending) {
210
- addGithubIssueReference(data, url);
211
- writeVbrief(path, data);
212
- written.push({ vbrief: disp, result: "created" });
550
+ const prior = resolvePriorCreatedUrl(root, path);
551
+ if (typeof prior === "string" && prior.length > 0) {
552
+ if (reconciledUrl !== null && reconciledUrl !== prior) {
553
+ throw new IssueEmitError(`umbrella recovered conflicting issue URLs: ${reconciledUrl} vs ${prior}; resolve local pending/recovery state before retry`, { createdUrl: reconciledUrl });
554
+ }
555
+ reconciledUrl = prior;
556
+ withPrior.push([path, disp, data, prior]);
557
+ }
558
+ else {
559
+ writeVbrief(path, data, root);
560
+ stillNeedRemote.push([path, disp, data]);
561
+ }
562
+ }
563
+ // Pass 2: stamp only after the recovered set is conflict-free.
564
+ for (const [path, disp, data, prior] of withPrior) {
565
+ try {
566
+ stampUrlOntoVbrief(path, data, prior, root);
567
+ written.push({ vbrief: disp, result: "created" });
568
+ }
569
+ catch {
570
+ // Leave recovery/ledger for retry; still surface URL via reconciledUrl.
571
+ written.push({ vbrief: disp, result: "pending-reconcile" });
572
+ }
573
+ }
574
+ if (stillNeedRemote.length === 0) {
575
+ const pendingLeft = written.some((w) => w.result === "pending-reconcile");
576
+ if (pendingLeft && reconciledUrl !== null) {
577
+ throw new IssueEmitError(`created ${reconciledUrl} but failed to stamp one or more umbrella vbriefs; retry to reconcile without re-create`, { createdUrl: reconciledUrl });
578
+ }
579
+ return {
580
+ result: "created",
581
+ url: reconciledUrl,
582
+ title: umbrellaTitle,
583
+ vbriefs: [...written, ...already],
584
+ };
585
+ }
586
+ // Sibling recovery: reuse recovered/stamped umbrella URL instead of a second remote create (#2880).
587
+ let url = reconciledUrl;
588
+ if (url === null || url.length === 0) {
589
+ const body = renderUmbrellaBody(stillNeedRemote.map(([, disp, data]) => [disp, data]));
590
+ url = fileIssue(options.repo, umbrellaTitle, body, options.scmCall);
591
+ }
592
+ // Record URL for every remaining artifact before any stamp can throw (#2880).
593
+ for (const [path] of stillNeedRemote) {
594
+ recordCreatedUrlDurable(root, path, url);
595
+ }
596
+ const stampErrors = [];
597
+ for (const [path, disp, data] of stillNeedRemote) {
598
+ try {
599
+ stampUrlOntoVbrief(path, data, url, root);
600
+ written.push({ vbrief: disp, result: "created" });
601
+ }
602
+ catch (stampErr) {
603
+ stampErrors.push(`${disp}: ${String(stampErr)}`);
604
+ written.push({ vbrief: disp, result: "pending-reconcile" });
605
+ }
606
+ }
607
+ if (stampErrors.length > 0) {
608
+ throw new IssueEmitError(`created ${url} but failed to stamp ${stampErrors.length} umbrella vbrief(s): ${stampErrors.join("; ")}`, { createdUrl: url });
213
609
  }
214
610
  return { result: "created", url, title: umbrellaTitle, vbriefs: [...written, ...already] };
215
611
  }
@@ -286,11 +682,17 @@ export function issueEmitMain(args) {
286
682
  noNetwork,
287
683
  title: args.title,
288
684
  displayPaths: display,
685
+ projectRoot,
289
686
  });
290
687
  summary = { mode: "umbrella", no_network: noNetwork, umbrella: action };
291
688
  }
292
689
  else if (args.perVbrief) {
293
- const actions = emitPerVbrief(paths, { repo, noNetwork, displayPaths: display });
690
+ const actions = emitPerVbrief(paths, {
691
+ repo,
692
+ noNetwork,
693
+ displayPaths: display,
694
+ projectRoot,
695
+ });
294
696
  summary = { mode: "per-vbrief", no_network: noNetwork, actions };
295
697
  }
296
698
  else {
@@ -302,6 +704,7 @@ export function issueEmitMain(args) {
302
704
  repo,
303
705
  noNetwork,
304
706
  displayPath: display[0],
707
+ projectRoot,
305
708
  });
306
709
  summary = { mode: "single", no_network: noNetwork, actions: [action] };
307
710
  }
@@ -1,7 +1,8 @@
1
- import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
- import { basename, join, resolve } from "node:path";
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { cacheGet } from "../cache/operations.js";
4
4
  import { scan } from "../cache/scanner.js";
5
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
5
6
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
6
7
  import { call } from "../scm/call.js";
7
8
  import { resolveProjectRoot } from "../scope/project-context.js";
@@ -537,6 +538,42 @@ export function fetchIssue(repo, number, options = {}) {
537
538
  const comments = fetchIssueComments(repo, number, options);
538
539
  return attachIssueCommentThread(cached, comments);
539
540
  }
541
+ /**
542
+ * Resolve the containment root for ingest writes (#2869 / #2871).
543
+ *
544
+ * Precedence:
545
+ * 1. Explicit `cwd` (CLI project root)
546
+ * 2. Sentinel walk from vbriefDir (`xbrief` / `vbrief` / `.git`)
547
+ * 3. Parent of a layout-named lifecycle dir (`…/xbrief` or `…/vbrief`) — even when
548
+ * that dir does not exist yet (mkdir is about to create it)
549
+ * 4. An already-existing non-symlink vbriefDir used as a bare lifecycle root (tests)
550
+ *
551
+ * ⊗ Never use a non-existent or symlink path as a silent dirname fallback.
552
+ */
553
+ function resolveIngestProjectRoot(vbriefDir, cwd) {
554
+ if (cwd !== undefined && cwd !== null && cwd.length > 0) {
555
+ return resolve(cwd);
556
+ }
557
+ const walked = resolveProjectRoot(null, vbriefDir);
558
+ if (walked !== null) {
559
+ return walked;
560
+ }
561
+ const abs = resolve(vbriefDir);
562
+ const base = basename(abs);
563
+ if (base === "xbrief" || base === "vbrief" || base === "vBRIEF") {
564
+ return dirname(abs);
565
+ }
566
+ try {
567
+ const info = lstatSync(abs);
568
+ if (info.isDirectory() && !info.isSymbolicLink()) {
569
+ return abs;
570
+ }
571
+ }
572
+ catch {
573
+ // missing dir — cannot contain writes safely without an explicit root
574
+ }
575
+ return null;
576
+ }
540
577
  export function ingestOne(issue, options) {
541
578
  const number = Number(issue.number);
542
579
  const refs = options.existingRefs ?? scanProvenanceRefs(options.vbriefDir);
@@ -559,11 +596,24 @@ export function ingestOne(issue, options) {
559
596
  infoVersion: emissionLayout.infoVersion,
560
597
  });
561
598
  const filename = targetFilename(number, String(issue.title ?? ""), emissionLayout.artifactSuffix);
562
- const target = join(options.vbriefDir, folder, filename);
599
+ const folderPath = join(options.vbriefDir, folder);
600
+ const target = join(folderPath, filename);
563
601
  if (options.dryRun) {
564
602
  return ["dryrun", target, `DRY-RUN would write ${folder}/${filename}`];
565
603
  }
566
- mkdirSync(join(options.vbriefDir, folder), { recursive: true });
604
+ // Gate lifecycle folder + leaf before mkdir/write so folder/parent symlinks
605
+ // cannot divert issue:ingest / triage:accept outside the project (#2869).
606
+ const projectRoot = resolveIngestProjectRoot(options.vbriefDir, options.cwd);
607
+ if (projectRoot === null) {
608
+ throw new ProjectionContainmentError(`projection write refused: could not resolve project root for ingest into ${options.vbriefDir}`, {
609
+ projectDir: options.vbriefDir,
610
+ targetPath: target,
611
+ offendingPath: options.vbriefDir,
612
+ });
613
+ }
614
+ assertWriteTargetSafe(projectRoot, folderPath);
615
+ assertWriteTargetSafe(projectRoot, target);
616
+ mkdirSync(folderPath, { recursive: true });
567
617
  writeFileSync(target, `${JSON.stringify(vbrief, null, 2)}\n`, "utf8");
568
618
  return ["created", target, `CREATED ${folder}/${filename}`];
569
619
  }
@@ -14,6 +14,9 @@ const CURSOR_SIGNAL_VARS = [
14
14
  const CLOUD_SIGNAL_VARS = [
15
15
  "CURSOR_AGENT",
16
16
  "GROK_BUILD",
17
+ "OPENCLAW",
18
+ "DEFT_HAS_SESSIONS_SPAWN",
19
+ "DEFT_PROBE_SESSIONS_SPAWN",
17
20
  "DEFT_AGENT_RUNTIME",
18
21
  "CI",
19
22
  "GITHUB_ACTIONS",
@@ -40,7 +40,7 @@ export const REVIEW_MONITOR_HELP = "usage: task verify:review-monitor -- --pr <N
40
40
  "\n" +
41
41
  "Claim a lease after spawning Approach 1:\n" +
42
42
  " task review-monitor:register -- --pr <N> --monitor-agent-id <id> \\\n" +
43
- " --platform-primitive cursor-task|spawn_subagent|start_agent \\\n" +
43
+ " --platform-primitive cursor-task|spawn_subagent|start_agent|sessions_spawn \\\n" +
44
44
  " [--head-sha SHA] [--repo OWNER/REPO] [--force]\n" +
45
45
  "\n" +
46
46
  "Release when done:\n" +
@@ -54,7 +54,8 @@ export const REGISTER_HELP = "usage: task review-monitor:register -- --pr <N> --
54
54
  "required:\n" +
55
55
  " --pr N Pull request number\n" +
56
56
  " --monitor-agent-id ID Stable poller agent id / Task handle\n" +
57
- " --platform-primitive P start_agent | spawn_subagent | cursor-task\n" +
57
+ " --platform-primitive P start_agent | spawn_subagent | cursor-task |\n" +
58
+ " sessions_spawn | openclaw-sessions-spawn (#2876)\n" +
58
59
  "\n" +
59
60
  "options:\n" +
60
61
  " --repo OWNER/REPO Repository (default: origin / DEFT_TRIAGE_REPO)\n" +
@@ -1,5 +1,9 @@
1
1
  import { MONITORING_TIER_1, MONITORING_TIER_2, MONITORING_TIER_3 } from "./constants.js";
2
- export type PlatformPrimitive = "start_agent" | "spawn_subagent" | "cursor-task";
2
+ /** Canonical Approach-1 platform primitives for review-monitor register/verify (#2655 / #2876). */
3
+ export type PlatformPrimitive = "start_agent" | "spawn_subagent" | "cursor-task" | "sessions_spawn" | "openclaw-sessions-spawn";
4
+ /** Accepted `--platform-primitive` values (register CLI + help text). */
5
+ export declare const PLATFORM_PRIMITIVES: readonly PlatformPrimitive[];
6
+ export declare const PLATFORM_PRIMITIVE_SET: Set<string>;
3
7
  export interface MonitoringTierProbe {
4
8
  readonly tier: typeof MONITORING_TIER_1 | typeof MONITORING_TIER_2 | typeof MONITORING_TIER_3;
5
9
  readonly primitive: PlatformPrimitive | null;
@@ -7,7 +11,7 @@ export interface MonitoringTierProbe {
7
11
  }
8
12
  /**
9
13
  * Inline Tier-1 detection aligned with the swarm Phase 3 / review-cycle matrix
10
- * (#1877 / #2655). Prefer `task platform:capabilities` when available (#1357);
14
+ * (#1877 / #2655 / #2876). Prefer `task platform:capabilities` when available (#1357);
11
15
  * this probe does not block MVP.
12
16
  */
13
17
  export declare function probeMonitoringTier(environ?: NodeJS.ProcessEnv): MonitoringTierProbe;