@deftai/directive-core 0.86.0 → 0.88.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 (70) hide show
  1. package/dist/cache/scanner.d.ts +11 -1
  2. package/dist/cache/scanner.js +29 -4
  3. package/dist/check/gate-lists.js +2 -0
  4. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  5. package/dist/content-contracts/skills/helpers.js +35 -0
  6. package/dist/deposit/copy-tree.d.ts +19 -1
  7. package/dist/deposit/copy-tree.js +134 -5
  8. package/dist/doctor/main.d.ts +6 -5
  9. package/dist/doctor/main.js +80 -18
  10. package/dist/doctor/taskfile.d.ts +8 -0
  11. package/dist/doctor/taskfile.js +19 -0
  12. package/dist/fs/projection-containment.d.ts +18 -0
  13. package/dist/fs/projection-containment.js +40 -0
  14. package/dist/hooks/dispatcher.d.ts +34 -2
  15. package/dist/hooks/dispatcher.js +234 -21
  16. package/dist/hooks/tools.d.ts +31 -0
  17. package/dist/hooks/tools.js +74 -0
  18. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  19. package/dist/init-deposit/agent-hooks.js +38 -2
  20. package/dist/init-deposit/hygiene.d.ts +16 -0
  21. package/dist/init-deposit/hygiene.js +26 -0
  22. package/dist/init-deposit/init-dispatch.js +28 -0
  23. package/dist/init-deposit/prettierignore.js +2 -2
  24. package/dist/init-deposit/refresh.js +38 -7
  25. package/dist/init-deposit/scaffold.js +7 -3
  26. package/dist/init-deposit/xbrief-projections.js +6 -6
  27. package/dist/intake/issue-emit.d.ts +45 -2
  28. package/dist/intake/issue-emit.js +420 -17
  29. package/dist/intake/issue-ingest.js +65 -6
  30. package/dist/packs/pack-render.d.ts +33 -0
  31. package/dist/packs/pack-render.js +155 -9
  32. package/dist/packs/quarantine-ext.d.ts +10 -0
  33. package/dist/packs/quarantine-ext.js +26 -2
  34. package/dist/platform/platform-capabilities.js +3 -0
  35. package/dist/policy/index.d.ts +1 -0
  36. package/dist/policy/index.js +1 -0
  37. package/dist/policy/no-deft-directive.d.ts +59 -0
  38. package/dist/policy/no-deft-directive.js +103 -0
  39. package/dist/policy/org-force-on-migration.d.ts +52 -0
  40. package/dist/policy/org-force-on-migration.js +260 -22
  41. package/dist/policy/runtime-authority.d.ts +41 -0
  42. package/dist/policy/runtime-authority.js +274 -0
  43. package/dist/review-monitor/constants.js +3 -2
  44. package/dist/review-monitor/tier-detection.d.ts +6 -2
  45. package/dist/review-monitor/tier-detection.js +27 -2
  46. package/dist/scope/transition.js +43 -0
  47. package/dist/session/release-availability.d.ts +2 -0
  48. package/dist/session/release-availability.js +23 -8
  49. package/dist/session/session-start-hook.d.ts +3 -0
  50. package/dist/session/session-start-hook.js +15 -0
  51. package/dist/session/session-start.js +30 -0
  52. package/dist/swarm/routing-set-cli.js +5 -10
  53. package/dist/swarm/routing.d.ts +3 -2
  54. package/dist/swarm/routing.js +16 -4
  55. package/dist/triage/help/registry-data.d.ts +7 -7
  56. package/dist/triage/help/registry-data.js +15 -6
  57. package/dist/triage/queue/index.d.ts +1 -0
  58. package/dist/triage/queue/index.js +1 -0
  59. package/dist/triage/queue/show.d.ts +69 -0
  60. package/dist/triage/queue/show.js +293 -0
  61. package/dist/triage/scope/cli.js +3 -0
  62. package/dist/triage/scope/coverage.d.ts +2 -0
  63. package/dist/triage/scope/coverage.js +18 -3
  64. package/dist/verify-source/cursor-tier1.js +7 -2
  65. package/dist/verify-source/index.d.ts +1 -0
  66. package/dist/verify-source/index.js +1 -0
  67. package/dist/verify-source/openclaw-tier1.d.ts +37 -0
  68. package/dist/verify-source/openclaw-tier1.js +105 -0
  69. package/dist/xbrief-migrate/migrate-project.js +9 -5
  70. package/package.json +4 -3
@@ -177,4 +177,278 @@ export function evaluateRuntimeAuthorityDirectWrite(input) {
177
177
  }
178
178
  return { allowed: true, reason: null, code: null };
179
179
  }
180
+ /** True when token is a shell env assignment (FOO=1 / FOO=). Linear; no nested quantifiers. */
181
+ function isShellEnvAssignToken(token) {
182
+ const eq = token.indexOf("=");
183
+ if (eq <= 0)
184
+ return false;
185
+ const name = token.slice(0, eq);
186
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
187
+ return false;
188
+ return true;
189
+ }
190
+ /**
191
+ * Normalize a shell token for classification (#2711).
192
+ * Shell strips quotes, empty quote pairs, and backslash escapes before exec
193
+ * (`g''it` / `g\it` / `'push'` → `git` / `push`). Dropping `'`/`"`/`\` after
194
+ * whitespace split closes those bypasses without nested-quantifier regex. O(n).
195
+ */
196
+ function normalizeShellToken(token) {
197
+ return token.replace(/['"\\]/g, "");
198
+ }
199
+ /**
200
+ * Split a shell command into list/pipeline/newline segments without splitting
201
+ * on separators that appear inside quotes or after a backslash escape (#2711).
202
+ * Prevents `printf '%s' ';' 'git push'` and `hello\; git push` false denials.
203
+ */
204
+ function splitShellSegments(command) {
205
+ const segments = [];
206
+ let cur = "";
207
+ let quote = null;
208
+ for (let i = 0; i < command.length; i++) {
209
+ const c = command[i];
210
+ if (c === undefined)
211
+ break;
212
+ if (quote !== null) {
213
+ if (c === quote)
214
+ quote = null;
215
+ cur += c;
216
+ continue;
217
+ }
218
+ // Outside quotes: backslash escapes the next character (including separators).
219
+ if (c === "\\" && i + 1 < command.length) {
220
+ cur += c;
221
+ cur += command[i + 1] ?? "";
222
+ i++;
223
+ continue;
224
+ }
225
+ if (c === "'" || c === '"') {
226
+ quote = c;
227
+ cur += c;
228
+ continue;
229
+ }
230
+ // Outside quotes: list/pipeline separators and newlines start a new segment.
231
+ if (c === "&" && command[i + 1] === "&") {
232
+ segments.push(cur);
233
+ cur = "";
234
+ i++;
235
+ continue;
236
+ }
237
+ if (c === "|" && command[i + 1] === "|") {
238
+ segments.push(cur);
239
+ cur = "";
240
+ i++;
241
+ continue;
242
+ }
243
+ if (c === ";" || c === "|" || c === "&" || c === "\n" || c === "\r") {
244
+ segments.push(cur);
245
+ cur = "";
246
+ continue;
247
+ }
248
+ cur += c;
249
+ }
250
+ segments.push(cur);
251
+ return segments;
252
+ }
253
+ /** Git global options that take a separate value token (not `--opt=value`). */
254
+ const GIT_GLOBAL_VALUE_OPTS = new Set([
255
+ "-C",
256
+ "-c",
257
+ "--git-dir",
258
+ "--work-tree",
259
+ "--namespace",
260
+ "--config-env",
261
+ "--super-prefix",
262
+ "--list-cmds",
263
+ ]);
264
+ /**
265
+ * Classify one shell list/pipeline segment for push/merge (#2711).
266
+ * Token walk is O(n) — avoids nested-quantifier ReDoS that CodeQL flags on
267
+ * `git (?:options)* push` style regexes (alerts #77 / #78 on this PR).
268
+ */
269
+ function classifyShellSegment(segment) {
270
+ const tokens = segment
271
+ .trim()
272
+ .split(/\s+/)
273
+ .filter((t) => t.length > 0);
274
+ let i = 0;
275
+ while (i < tokens.length) {
276
+ const tok = tokens[i];
277
+ if (tok === undefined || !isShellEnvAssignToken(tok))
278
+ break;
279
+ i++;
280
+ }
281
+ const wrapTok = tokens[i];
282
+ if (wrapTok !== undefined) {
283
+ const wrap = normalizeShellToken(wrapTok).toLowerCase();
284
+ if (wrap === "sudo" || wrap === "env" || wrap === "command") {
285
+ i++;
286
+ while (i < tokens.length) {
287
+ const tok = tokens[i];
288
+ if (tok === undefined || !isShellEnvAssignToken(tok))
289
+ break;
290
+ i++;
291
+ }
292
+ }
293
+ }
294
+ const binTok = tokens[i];
295
+ if (binTok === undefined)
296
+ return null;
297
+ const bin = normalizeShellToken(binTok).toLowerCase();
298
+ if (bin === "git" || bin === "git.exe") {
299
+ i++;
300
+ // Skip git global options before the subcommand (-C, --git-dir, -c, …).
301
+ while (i < tokens.length) {
302
+ const raw = tokens[i];
303
+ if (raw === undefined)
304
+ return null;
305
+ const t = normalizeShellToken(raw);
306
+ const lower = t.toLowerCase();
307
+ if (!t.startsWith("-")) {
308
+ return lower === "push" ? "push" : null;
309
+ }
310
+ // --opt=value forms never consume a following token.
311
+ if (t.startsWith("--") && t.includes("=")) {
312
+ i++;
313
+ continue;
314
+ }
315
+ // Value-taking globals: -C path, --git-dir /repo, -c name=value, …
316
+ if (GIT_GLOBAL_VALUE_OPTS.has(t)) {
317
+ i += 2;
318
+ continue;
319
+ }
320
+ // Glued short forms: -C/path, -cname=value
321
+ if (t.startsWith("-C") || t.startsWith("-c")) {
322
+ i++;
323
+ continue;
324
+ }
325
+ // Boolean / other short/long flags without a separate value.
326
+ i++;
327
+ }
328
+ return null;
329
+ }
330
+ if (bin === "gh" || bin === "gh.exe") {
331
+ i++;
332
+ while (i < tokens.length) {
333
+ const flagRaw = tokens[i];
334
+ if (flagRaw === undefined)
335
+ break;
336
+ const flag = normalizeShellToken(flagRaw);
337
+ if (!flag.startsWith("-"))
338
+ break;
339
+ i++;
340
+ }
341
+ const pr = tokens[i];
342
+ const merge = tokens[i + 1];
343
+ if (pr !== undefined &&
344
+ merge !== undefined &&
345
+ normalizeShellToken(pr).toLowerCase() === "pr" &&
346
+ normalizeShellToken(merge).toLowerCase() === "merge") {
347
+ return "merge";
348
+ }
349
+ return null;
350
+ }
351
+ return null;
352
+ }
353
+ /**
354
+ * List all classifiable push/merge ops in a shell command (#2711).
355
+ * Scans every list/pipeline/newline segment so compound commands like
356
+ * `gh pr merge 1 && git push` surface both ops (dispatcher evaluates each).
357
+ * Newlines are delimiters so multi-line scripts cannot hide a later push/merge.
358
+ */
359
+ export function listShellOps(command) {
360
+ const cmd = command.trim();
361
+ if (cmd.length === 0)
362
+ return [];
363
+ const found = new Set();
364
+ // Quote-aware split so separators inside quotes are not treated as list ops.
365
+ for (const raw of splitShellSegments(cmd)) {
366
+ const op = classifyShellSegment(raw);
367
+ if (op !== null)
368
+ found.add(op);
369
+ }
370
+ const out = [];
371
+ // Stable order for deterministic multi-op evaluation.
372
+ if (found.has("push"))
373
+ out.push("push");
374
+ if (found.has("merge"))
375
+ out.push("merge");
376
+ return out;
377
+ }
378
+ /**
379
+ * Classify a shell command string for push/merge scopes (#2711).
380
+ * Unclassifiable commands return null (fail open at the gate).
381
+ * When a compound command has multiple ops, returns the first of listShellOps
382
+ * (push before merge); prefer listShellOps + evaluate-each for enforcement.
383
+ *
384
+ * Patterns (intentionally narrow; prefer false-open over false-deny):
385
+ * - push: `git push`, `git.exe push`, with optional env / -C / -c prefixes
386
+ * - merge: `gh pr merge`, `gh.exe pr merge`
387
+ */
388
+ export function classifyShellCommand(command) {
389
+ const ops = listShellOps(command);
390
+ return ops[0] ?? null;
391
+ }
392
+ /**
393
+ * Classify an MCP (or MCP-like) tool name + optional argument blob for push/merge (#2711).
394
+ * Returns null when the tool is not a known push/merge mutation (fail open).
395
+ */
396
+ export function classifyMcpTool(toolName, argsText = null) {
397
+ const name = toolName.trim().toLowerCase();
398
+ if (name.length === 0)
399
+ return null;
400
+ // Common GitHub MCP / bridge spellings for merge
401
+ if (/merge[_-]?pull[_-]?request/.test(name) ||
402
+ /pull[_-]?request[_-]?merge/.test(name) ||
403
+ /(^|__)merge_pr($|__)/.test(name) ||
404
+ /pr[_-]?merge/.test(name)) {
405
+ return "merge";
406
+ }
407
+ // Push-like tool names (narrow — prefer fail-open)
408
+ if (/(^|__)git[_-]?push($|__)/.test(name) || /push[_-]?branch/.test(name)) {
409
+ return "push";
410
+ }
411
+ if (/push/.test(name) && /(git|branch|remote|ref)/.test(name))
412
+ return "push";
413
+ const blob = (argsText ?? "").toLowerCase();
414
+ if (blob.length > 0) {
415
+ if (/\bgit(?:\.exe)?\s+push\b/.test(blob))
416
+ return "push";
417
+ if (/\bgh(?:\.exe)?\s+pr\s+merge\b/.test(blob))
418
+ return "merge";
419
+ }
420
+ return null;
421
+ }
422
+ /**
423
+ * Evaluate scopes.push / scopes.merge for a classifiable shell/MCP operation (#2711).
424
+ * null op → allow (unclassifiable fail-open). disabled policy → allow.
425
+ */
426
+ export function evaluateRuntimeAuthorityShellOp(input) {
427
+ const { policy, op } = input;
428
+ if (!policy.enabled) {
429
+ return { allowed: true, reason: null, code: null, unclassifiable: op === null };
430
+ }
431
+ if (op === null) {
432
+ return { allowed: true, reason: null, code: null, unclassifiable: true };
433
+ }
434
+ if (op === "push" && !policy.scopes.push) {
435
+ return {
436
+ allowed: false,
437
+ code: "runtime-policy-deny-scope",
438
+ unclassifiable: false,
439
+ reason: "Directive denied this shell/MCP operation: plan.policy.runtimeAuthority.scopes.push is false. " +
440
+ "Grant the push scope in PROJECT-DEFINITION or disable runtimeAuthority.",
441
+ };
442
+ }
443
+ if (op === "merge" && !policy.scopes.merge) {
444
+ return {
445
+ allowed: false,
446
+ code: "runtime-policy-deny-scope",
447
+ unclassifiable: false,
448
+ reason: "Directive denied this shell/MCP operation: plan.policy.runtimeAuthority.scopes.merge is false. " +
449
+ "Grant the merge scope in PROJECT-DEFINITION or disable runtimeAuthority.",
450
+ };
451
+ }
452
+ return { allowed: true, reason: null, code: null, unclassifiable: false };
453
+ }
180
454
  //# sourceMappingURL=runtime-authority.js.map
@@ -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;
@@ -1,12 +1,24 @@
1
1
  import { MONITORING_TIER_1, MONITORING_TIER_2, MONITORING_TIER_3 } from "./constants.js";
2
2
  const TRUTHY = new Set(["1", "true", "yes", "on"]);
3
+ /** Accepted `--platform-primitive` values (register CLI + help text). */
4
+ export const PLATFORM_PRIMITIVES = [
5
+ "start_agent",
6
+ "spawn_subagent",
7
+ "cursor-task",
8
+ "sessions_spawn",
9
+ "openclaw-sessions-spawn",
10
+ ];
11
+ export const PLATFORM_PRIMITIVE_SET = new Set(PLATFORM_PRIMITIVES);
3
12
  function envTruthy(environ, name) {
4
13
  return TRUTHY.has((environ[name] ?? "").trim().toLowerCase());
5
14
  }
6
15
  function probeOverride(environ) {
7
16
  const raw = (environ.DEFT_MONITOR_TIER ?? environ.DEFT_MONITOR_TIER_OVERRIDE ?? "").trim();
8
17
  if (raw === "1" || raw.toLowerCase() === "tier1") {
9
- const primitive = environ.DEFT_MONITOR_TIER1_PRIMITIVE ?? "cursor-task";
18
+ const requested = (environ.DEFT_MONITOR_TIER1_PRIMITIVE ?? "cursor-task").trim();
19
+ const primitive = PLATFORM_PRIMITIVE_SET.has(requested)
20
+ ? requested
21
+ : "cursor-task";
10
22
  return { tier: MONITORING_TIER_1, primitive, descriptor: "override-tier1" };
11
23
  }
12
24
  if (raw === "3" || raw.toLowerCase() === "tier3") {
@@ -16,7 +28,7 @@ function probeOverride(environ) {
16
28
  }
17
29
  /**
18
30
  * Inline Tier-1 detection aligned with the swarm Phase 3 / review-cycle matrix
19
- * (#1877 / #2655). Prefer `task platform:capabilities` when available (#1357);
31
+ * (#1877 / #2655 / #2876). Prefer `task platform:capabilities` when available (#1357);
20
32
  * this probe does not block MVP.
21
33
  */
22
34
  export function probeMonitoringTier(environ = process.env) {
@@ -41,6 +53,19 @@ export function probeMonitoringTier(environ = process.env) {
41
53
  };
42
54
  }
43
55
  const runtime = (environ.DEFT_AGENT_RUNTIME ?? "").trim().toLowerCase();
56
+ // OpenClaw: sessions_spawn is the Tier-1 Approach 1 primitive (#2876).
57
+ // Alias openclaw-sessions-spawn accepted on register for explicit naming.
58
+ if (envTruthy(environ, "DEFT_PROBE_SESSIONS_SPAWN") ||
59
+ envTruthy(environ, "DEFT_HAS_SESSIONS_SPAWN") ||
60
+ envTruthy(environ, "DEFT_PROBE_OPENCLAW") ||
61
+ envTruthy(environ, "OPENCLAW") ||
62
+ runtime === "openclaw" ||
63
+ runtime === "openclaw-sessions-spawn") {
64
+ const alias = (environ.DEFT_MONITOR_TIER1_PRIMITIVE ?? "").trim() === "openclaw-sessions-spawn"
65
+ ? "openclaw-sessions-spawn"
66
+ : "sessions_spawn";
67
+ return { tier: MONITORING_TIER_1, primitive: alias, descriptor: "openclaw" };
68
+ }
44
69
  if (envTruthy(environ, "DEFT_PROBE_GROK_BUILD") ||
45
70
  envTruthy(environ, "GROK_BUILD") ||
46
71
  runtime === "grok-build") {
@@ -11,6 +11,43 @@ import { detectLifecycleFolder, updateDecomposedChildBackReferences, updateDecom
11
11
  import { syncProjectDefinitionAfterScopeMove } from "./project-definition-sync.js";
12
12
  import { syncSpecificationAfterScopeMove } from "./specification-sync.js";
13
13
  import { utcNowIso } from "./vbrief-json.js";
14
+ /** Item statuses that still represent unfinished work and should advance on terminal transitions (#2862). */
15
+ const NON_TERMINAL_ITEM_STATUSES = new Set(["pending", "proposed", "running"]);
16
+ /** Terminal lifecycle actions that reconcile the brief's own plan.items (#2862). */
17
+ const OWN_ITEMS_RECONCILE_ACTIONS = new Set(["complete", "fail", "cancel"]);
18
+ /**
19
+ * Advance non-terminal plan.items / subItems to the terminal target status.
20
+ * Leaves cancelled / failed / completed / other non-pending-proposed-running items alone (#2862).
21
+ */
22
+ function advanceNonTerminalOwnItems(items, targetStatus) {
23
+ if (!Array.isArray(items)) {
24
+ return;
25
+ }
26
+ for (const item of items) {
27
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
28
+ continue;
29
+ }
30
+ const obj = item;
31
+ const status = String(obj.status ?? "");
32
+ if (NON_TERMINAL_ITEM_STATUSES.has(status)) {
33
+ obj.status = targetStatus;
34
+ }
35
+ advanceNonTerminalOwnItems(obj.subItems, targetStatus);
36
+ advanceNonTerminalOwnItems(obj.items, targetStatus);
37
+ }
38
+ }
39
+ /**
40
+ * Refresh the document envelope `updated` stamp to match `plan.updated`.
41
+ * Stamps whichever of xBRIEFInfo (v0.8) / vBRIEFInfo (v0.6) is present — never creates one (#2862 / #2346).
42
+ */
43
+ function stampEnvelopeUpdated(data, nowIso) {
44
+ for (const key of ["xBRIEFInfo", "vBRIEFInfo"]) {
45
+ const env = data[key];
46
+ if (typeof env === "object" && env !== null && !Array.isArray(env)) {
47
+ env.updated = nowIso;
48
+ }
49
+ }
50
+ }
14
51
  export function runTransition(action, filePath, now = new Date()) {
15
52
  if (!(action in TRANSITIONS)) {
16
53
  const valid = Object.keys(TRANSITIONS).sort().join(", ");
@@ -96,6 +133,12 @@ export function runTransition(action, filePath, now = new Date()) {
96
133
  const nowIso = utcNowIso(now);
97
134
  planObj.status = targetStatus;
98
135
  planObj.updated = nowIso;
136
+ // Keep the envelope clock aligned with plan.updated on every mutating transition (#2862).
137
+ stampEnvelopeUpdated(data, nowIso);
138
+ // Reconcile the completing brief's own plan.items (mirrors #1527 / #2566 registry sync) (#2862).
139
+ if (OWN_ITEMS_RECONCILE_ACTIONS.has(act)) {
140
+ advanceNonTerminalOwnItems(planObj.items, targetStatus);
141
+ }
99
142
  if (act === "complete") {
100
143
  stampCompletionMetadata(planObj, projectRoot, nowIso);
101
144
  }
@@ -1,3 +1,5 @@
1
+ /** Display/back-compat constant; resolution flows through resolveTriageCachePath (#2869). */
2
+ export declare const STATE_RELATIVE_PATH: string;
1
3
  export interface ReleaseAvailabilityProbeOptions {
2
4
  readonly now?: Date;
3
5
  readonly env?: NodeJS.ProcessEnv;
@@ -4,9 +4,15 @@ import { dirname, join } from "node:path";
4
4
  import { locateManifest, parseInstallManifest } from "../doctor/manifest.js";
5
5
  import { runningInsideDeftRepo } from "../doctor/paths.js";
6
6
  import { evaluateReleaseAvailability } from "../doctor/release-availability.js";
7
+ import { resolveTriageCachePath } from "../triage/cache-path.js";
7
8
  const THROTTLE_MS = 24 * 60 * 60 * 1000;
8
9
  const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/";
9
- const STATE_RELATIVE_PATH = join("xbrief", ".triage-cache", "release-availability-state.json");
10
+ const STATE_FILE_NAME = "release-availability-state.json";
11
+ /** Display/back-compat constant; resolution flows through resolveTriageCachePath (#2869). */
12
+ export const STATE_RELATIVE_PATH = join("xbrief", ".triage-cache", STATE_FILE_NAME);
13
+ function resolveReleaseAvailabilityStatePath(projectRoot) {
14
+ return resolveTriageCachePath(projectRoot, STATE_FILE_NAME);
15
+ }
10
16
  function defaultReadText(path) {
11
17
  try {
12
18
  return readFileSync(path, "utf8");
@@ -91,18 +97,27 @@ export function probeSessionReleaseAvailability(projectRoot, options = {}) {
91
97
  const availability = evaluateReleaseAvailability(installed, npmResult.ok ? npmResult.version : null);
92
98
  if (availability.status !== "available")
93
99
  return { lines };
94
- const statePath = join(projectRoot, STATE_RELATIVE_PATH);
95
- const state = parseState((options.readState ?? defaultReadText)(statePath));
100
+ let statePath = null;
101
+ try {
102
+ statePath = resolveReleaseAvailabilityStatePath(projectRoot);
103
+ }
104
+ catch {
105
+ // Symlink-escaping triage-cache path: skip throttle state; still emit advisory (#2869).
106
+ statePath = null;
107
+ }
108
+ const state = parseState(statePath !== null ? (options.readState ?? defaultReadText)(statePath) : null);
96
109
  const now = options.now ?? new Date();
97
110
  if (isThrottled(state, availability.latestVersion, now))
98
111
  return { lines: [] };
99
112
  const message = `[deft release] Newer Directive release available: v${availability.latestVersion} ` +
100
113
  `(installed v${availability.installedVersion}). Run \`npm i -g @deftai/directive@latest\`.`;
101
- try {
102
- (options.writeState ?? defaultWriteState)(statePath, `${JSON.stringify({ latestVersion: availability.latestVersion, notifiedAt: now.toISOString() }, null, 2)}\n`);
103
- }
104
- catch {
105
- // The advisory remains useful if its best-effort throttle state cannot persist.
114
+ if (statePath !== null) {
115
+ try {
116
+ (options.writeState ?? defaultWriteState)(statePath, `${JSON.stringify({ latestVersion: availability.latestVersion, notifiedAt: now.toISOString() }, null, 2)}\n`);
117
+ }
118
+ catch {
119
+ // The advisory remains useful if its best-effort throttle state cannot persist.
120
+ }
106
121
  }
107
122
  return { lines: [...lines, message] };
108
123
  }
@@ -1,9 +1,12 @@
1
+ import { detectNoDeftDirective } from "../policy/no-deft-directive.js";
1
2
  import { writeSentinel } from "./ritual-sentinel.js";
2
3
  export interface SessionStartHookOptions {
3
4
  readonly resolveVersionFn?: () => string;
4
5
  readonly detectBranchFn?: (projectRoot: string) => string | null;
5
6
  readonly detectLatestActiveVbriefFn?: (projectRoot: string) => string | null;
6
7
  readonly writeSentinelFn?: typeof writeSentinel;
8
+ /** Test seam for #2926 opt-out detection. */
9
+ readonly detectNoDeftDirectiveFn?: typeof detectNoDeftDirective;
7
10
  }
8
11
  /** Write ``.deft/last-session.json`` from current git state (#1269). */
9
12
  export declare function runSessionStartHookWrite(projectRoot: string, options?: SessionStartHookOptions): {
@@ -1,8 +1,23 @@
1
1
  import { resolveVersion } from "../doctor/paths.js";
2
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, } from "../policy/no-deft-directive.js";
2
3
  import { detectBranch } from "./git.js";
3
4
  import { detectLatestActiveVbrief, writeSentinel } from "./ritual-sentinel.js";
4
5
  /** Write ``.deft/last-session.json`` from current git state (#1269). */
5
6
  export function runSessionStartHookWrite(projectRoot, options = {}) {
7
+ const detectOptOut = options.detectNoDeftDirectiveFn ?? detectNoDeftDirective;
8
+ // #2926: root opt-out wins — host SessionStart must not write ritual bookkeeping.
9
+ const optOut = detectOptOut(projectRoot);
10
+ if (optOut.present) {
11
+ const lines = [NO_DEFT_DIRECTIVE_DISABLED_MESSAGE];
12
+ if (optOut.inconsistent) {
13
+ lines.push(NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE);
14
+ }
15
+ return {
16
+ code: optOut.inconsistent ? 1 : 0,
17
+ stdout: `${lines.join("\n")}\n`,
18
+ stderr: optOut.inconsistent ? `${NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE}\n` : "",
19
+ };
20
+ }
6
21
  const detectBranchFn = options.detectBranchFn ?? detectBranch;
7
22
  const detectVbriefFn = options.detectLatestActiveVbriefFn ?? detectLatestActiveVbrief;
8
23
  const resolveVersionFn = options.resolveVersionFn ?? resolveVersion;
@@ -4,6 +4,7 @@ import { emitSessionEvalReadback } from "../eval/readback.js";
4
4
  import { MIGRATE_COMPLETION_NUDGE, shouldEmitMigrateNudge } from "../init-deposit/migrate.js";
5
5
  import { detectEnvironmentContext, environmentContextToDict, formatEnvironmentContext, } from "../platform/shell-context.js";
6
6
  import { disclosureLine } from "../policy/disclosure.js";
7
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
7
8
  import { resolvePolicy } from "../policy/resolve.js";
8
9
  import { maybeFormatProductSignalConsentPrompt } from "../product-signal/consent-prompt.js";
9
10
  import { maybeRunStalenessTickler } from "../staleness-tickler/run.js";
@@ -233,6 +234,35 @@ export function runSessionStart(projectRoot, options = {}) {
233
234
  const deferrals = options.deferrals ?? {};
234
235
  const runGit = options.runGit ?? defaultGitRunner;
235
236
  const environment = (options.probeEnvironment ?? detectEnvironmentContext)();
237
+ // #2926: official root opt-out wins locally — skip Directive session ritual.
238
+ // disabled = skip ritual (exit 0 clean / 1 inconsistent). ready stays false so
239
+ // automation does not treat opt-out as "session fully initialized for work".
240
+ const optOut = detectNoDeftDirective(projectRoot);
241
+ if (optOut.present) {
242
+ const lines = [NO_DEFT_DIRECTIVE_DISABLED_MESSAGE];
243
+ if (optOut.inconsistent) {
244
+ lines.push(NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE);
245
+ }
246
+ const code = optOut.inconsistent ? 1 : 0;
247
+ return {
248
+ code,
249
+ payload: {
250
+ ready: false,
251
+ exit_code: code,
252
+ disabled: true,
253
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
254
+ inconsistent: optOut.inconsistent,
255
+ inconsistent_policy: optOut.inconsistent
256
+ ? NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY
257
+ : undefined,
258
+ deposit_present: optOut.depositPresent,
259
+ posture,
260
+ environment: environmentContextToDict(environment),
261
+ message: NO_DEFT_DIRECTIVE_DISABLED_MESSAGE,
262
+ },
263
+ lines,
264
+ };
265
+ }
236
266
  if (posture === READ_ONLY_POSTURE) {
237
267
  return runReadOnlySessionStart(projectRoot, options, instant, environment);
238
268
  }
@@ -2,9 +2,8 @@
2
2
  import { resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE, ProjectionContainmentError, } from "../fs/projection-containment.js";
5
- import { getPlatformCapabilities } from "../intake/platform-capabilities.js";
6
5
  import { EXIT_CONFIG_ERROR, EXIT_OK } from "./constants.js";
7
- import { dispatchProviderFromRuntime, HARNESS_BOUND_PROVIDERS, ROUTING_MODE_HARNESS_DEFAULT, ROUTING_MODE_PINNED, resolveRoutingPath, SWARM_WORKER_ROLES, writeModelDecision, } from "./routing.js";
6
+ import { HARNESS_BOUND_PROVIDERS, ROUTING_MODE_HARNESS_DEFAULT, ROUTING_MODE_PINNED, resolveDispatchProvider, resolveRoutingPath, SWARM_WORKER_ROLES, writeModelDecision, } from "./routing.js";
8
7
  export function routingSetMain(argv = process.argv.slice(2)) {
9
8
  let projectRoot = ".";
10
9
  let provider = null;
@@ -47,14 +46,10 @@ export function routingSetMain(argv = process.argv.slice(2)) {
47
46
  }
48
47
  let resolvedProvider = provider;
49
48
  if (resolvedProvider === null || resolvedProvider.length === 0) {
50
- let runtimeMode = "";
51
- try {
52
- runtimeMode = getPlatformCapabilities().runtimeMode;
53
- }
54
- catch {
55
- runtimeMode = "";
56
- }
57
- resolvedProvider = dispatchProviderFromRuntime(runtimeMode);
49
+ // Same key as launch + verify:routing: OPENCLAW / sessions_spawn → openclaw
50
+ // (#2875 Greptile P1). Do not map via runtimeMode alone — OpenClaw-only envs
51
+ // are often local-unsandboxed while still dispatching under provider openclaw.
52
+ resolvedProvider = resolveDispatchProvider(process.env);
58
53
  }
59
54
  if (harnessDefault) {
60
55
  if (model !== null) {
@@ -9,7 +9,7 @@ export declare const ROUTING_MODE_HARNESS_DEFAULT = "harness-default";
9
9
  export declare const ROUTING_FILENAME = "routing.local.json";
10
10
  /** Providers whose model is harness-bound -- deft cannot pin or verify a slug. */
11
11
  export declare const HARNESS_BOUND_PROVIDERS: Set<string>;
12
- /** Providers whose per-role model must be decided before sub-agent dispatch (#1739 / #1877). */
12
+ /** Providers whose per-role model must be decided before sub-agent dispatch (#1739 / #1877 / #2875). */
13
13
  export declare const ROUTING_GATED_DISPATCH_PROVIDERS: Set<string>;
14
14
  export interface RouteDecision {
15
15
  model: string | null;
@@ -54,7 +54,8 @@ export declare function dispatchProviderFromRuntime(runtimeMode: string): string
54
54
  * Resolve the `dispatch_provider` routing key from the active runtime envelope.
55
55
  * Separate from `runtime_mode` (#1557): Cursor sessions may carry
56
56
  * `runtime_mode=cloud-headless` for gh-auth purposes but route under provider
57
- * `cursor` for model selection (#1877).
57
+ * `cursor` for model selection (#1877). OpenClaw routes under `openclaw` when
58
+ * `sessions_spawn` / OPENCLAW signals are present (#2875).
58
59
  */
59
60
  export declare function resolveDispatchProvider(environ?: NodeJS.ProcessEnv): string;
60
61
  /**