@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
@@ -22,6 +22,7 @@ export const FRAMEWORK_CHECK_GATES = [
22
22
  "verify:skill-external-fetch-gate",
23
23
  "verify:contract-drift",
24
24
  "verify:cursor-tier1",
25
+ "verify:openclaw-tier1",
25
26
  "verify:go-freeze",
26
27
  "verify:bridge-drift",
27
28
  "verify:branch",
@@ -6,11 +6,12 @@ export declare function runAgentHooksHealthCheck(projectRoot: string, consumerCo
6
6
  export declare function runAgentHooksLiveProbeCheck(projectRoot: string, sink: ReturnType<typeof createPlainSink>, addFinding: (finding: Finding) => void, seams: DoctorSeams): void;
7
7
  /**
8
8
  * Never emit a bare `task ...` remediation in a project without Taskfile wiring
9
- * (#2267). The `directive` surface always works; `task deft:X` is optional and
10
- * only present when the consumer wired the include. `plan()` already emits the
11
- * `directive` / `npx` / `npm` surface, so this guard is a defensive invariant:
12
- * any `task`-prefixed command is rewritten to the `directive` surface unless the
13
- * project actually has the include.
9
+ * (#2267 / #2893). The `directive`/`deft` surface always works; `task deft:X` is
10
+ * the go-task namespaced form only when the consumer wired the include (bare
11
+ * `task pr:watch` is not the consumer form under include key `deft:`). `plan()`
12
+ * already emits the `directive` / `npx` / `npm` surface, so this guard is a
13
+ * defensive invariant: any `task`-prefixed command is rewritten to the
14
+ * `directive` surface unless the project actually has the include.
14
15
  */
15
16
  /**
16
17
  * Detect Taskfile wiring through the injected seam (#2267). Mirrors
@@ -20,7 +20,7 @@ import { createPlainSink } from "./output.js";
20
20
  import { readTextSafe, resolveFrameworkRootForProject, resolvePath, resolveVersion, runningInsideDeftRepo, } from "./paths.js";
21
21
  import { runPayloadStalenessCheck } from "./payload-staleness.js";
22
22
  import { runLocalSignpostChecks } from "./signpost-checks.js";
23
- import { classifyTaskfileInclude, formatMissingIncludeSnippet, includesBlockHasDeftTaskfile, resolveConsumerTaskfile, } from "./taskfile.js";
23
+ import { classifyTaskfileInclude, formatGatesSurfaceDualRemediation, formatMissingIncludeSnippet, GATES_SURFACE_DEFT_REMEDIATION, includesBlockHasDeftTaskfile, resolveConsumerTaskfile, } from "./taskfile.js";
24
24
  import { defaultWhich } from "./which.js";
25
25
  const DEFAULT_RESOLUTION_PLATFORMS = ["linux", "darwin", "win32"];
26
26
  /**
@@ -291,7 +291,7 @@ export function cmdDoctor(args, seams = {}) {
291
291
  if (!jsonMode) {
292
292
  sink.blank();
293
293
  }
294
- sink.info("Checking optional root Taskfile.yml include...");
294
+ sink.info("Checking gates-surface readiness (Taskfile include for deep-think agent gates)...");
295
295
  runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFinding, seams);
296
296
  let resolution = null;
297
297
  if (!runningInsideDeftRepo(projectRoot, seams)) {
@@ -640,15 +640,21 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
640
640
  }
641
641
  const includeStatus = classifyTaskfileInclude(projectRoot);
642
642
  if (includeStatus === "ok") {
643
- sink.success("Root Taskfile.yml includes the deft framework");
643
+ sink.success("Gates-surface ready: root Taskfile.yml includes the deft framework (`task deft:<verb>`)");
644
644
  return;
645
645
  }
646
646
  if (includeStatus === "missing-file") {
647
647
  let includeMissing = true;
648
648
  const target = join(projectRoot, "Taskfile.yml");
649
- const message = "Root Taskfile.yml missing. This is OK for package-manager installs that use the `deft X` surface directly. To also enable the optional `task deft:X` surface, paste this into " +
650
- `${target}:`;
651
- sink.info(message);
649
+ // #2893: elevate to warning deep-think agent gates need a working invoke path.
650
+ // Dual remediations: (1) deft CLI primary (2) Taskfile include for task deft: verbs.
651
+ const message = "Gates-surface readiness: root Taskfile.yml missing. Deep-think agent gates " +
652
+ "(`pr:watch`, `review-monitor:*`) need a working invoke path — not optional convenience. " +
653
+ "1. " +
654
+ GATES_SURFACE_DEFT_REMEDIATION +
655
+ ` 2. Create ${target} with the canonical include so go-task exposes \`task deft:<verb>\` ` +
656
+ "(include key `deft:` → namespaced tasks; bare `task pr:watch` is not the consumer form):";
657
+ sink.warn(message);
652
658
  if (!jsonMode) {
653
659
  sink.blank();
654
660
  sink.raw(TASKFILE_INCLUDE_SNIPPET);
@@ -668,22 +674,27 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
668
674
  }
669
675
  }
670
676
  else {
671
- sink.info("Skipped Taskfile.yml creation -- paste the snippet above when you are ready.");
677
+ sink.info("Skipped Taskfile.yml creation -- use `deft <verb>` now, or paste the include snippet when ready.");
672
678
  }
673
679
  }
674
680
  if (includeMissing) {
675
681
  addFinding({
676
682
  severity: "warning",
677
- message: "Root Taskfile.yml missing; optional Taskfile include unavailable",
683
+ message: "Gates-surface: root Taskfile.yml missing deep-think gates need `deft` CLI or `task deft:` include",
678
684
  check: "taskfile-include",
679
685
  file: target,
680
- suggestion: TASKFILE_INCLUDE_SNIPPET,
686
+ suggestion: formatGatesSurfaceDualRemediation("missing-file"),
681
687
  });
682
688
  }
683
689
  return;
684
690
  }
685
691
  if (includeStatus === "missing-include") {
686
- const message = "Root Taskfile.yml exists but does not include the deft framework. The `deft X` surface still works; add this to the Taskfile `includes:` block only if you want the optional `task deft:X` surface (doctor NEVER mutates an existing user-owned Taskfile):";
692
+ const message = "Gates-surface readiness: root Taskfile.yml exists but does not include the deft framework. " +
693
+ "Deep-think agent gates (`pr:watch`, `review-monitor:*`) need a working invoke path. " +
694
+ "1. " +
695
+ GATES_SURFACE_DEFT_REMEDIATION +
696
+ " 2. Add this to the Taskfile `includes:` block so go-task exposes `task deft:<verb>` " +
697
+ "(doctor NEVER mutates an existing user-owned Taskfile; bare `task pr:watch` is not the consumer form when the include key is `deft:`):";
687
698
  sink.warn(message);
688
699
  if (!jsonMode) {
689
700
  sink.blank();
@@ -692,21 +703,23 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
692
703
  const tf = resolveConsumerTaskfile(projectRoot);
693
704
  addFinding({
694
705
  severity: "warning",
695
- message: "Root Taskfile.yml does not include the deft framework",
706
+ message: "Gates-surface: root Taskfile.yml does not include the deft framework — deep-think gates need `deft` CLI or `task deft:` include",
696
707
  check: "taskfile-include",
697
708
  file: tf,
698
- suggestion: formatMissingIncludeSnippet(),
709
+ suggestion: formatGatesSurfaceDualRemediation("missing-include"),
699
710
  });
700
711
  return;
701
712
  }
702
713
  const taskfilePath = resolveConsumerTaskfile(projectRoot) ?? join(projectRoot, "Taskfile.yml");
703
- const message = `Root Taskfile.yml at ${taskfilePath} exists but could not be read -- check file permissions.`;
714
+ const message = `Gates-surface readiness: root Taskfile.yml at ${taskfilePath} exists but could not be read ` +
715
+ "check file permissions. Deep-think gates still work via `deft <verb>` until the include is readable.";
704
716
  sink.warn(message);
705
717
  addFinding({
706
718
  severity: "warning",
707
719
  message,
708
720
  check: "taskfile-include",
709
721
  file: taskfilePath,
722
+ suggestion: GATES_SURFACE_DEFT_REMEDIATION,
710
723
  });
711
724
  }
712
725
  /**
@@ -786,11 +799,12 @@ function runPlanExtensionShadowCheck(projectRoot, sink, addFinding, seams) {
786
799
  }
787
800
  /**
788
801
  * Never emit a bare `task ...` remediation in a project without Taskfile wiring
789
- * (#2267). The `directive` surface always works; `task deft:X` is optional and
790
- * only present when the consumer wired the include. `plan()` already emits the
791
- * `directive` / `npx` / `npm` surface, so this guard is a defensive invariant:
792
- * any `task`-prefixed command is rewritten to the `directive` surface unless the
793
- * project actually has the include.
802
+ * (#2267 / #2893). The `directive`/`deft` surface always works; `task deft:X` is
803
+ * the go-task namespaced form only when the consumer wired the include (bare
804
+ * `task pr:watch` is not the consumer form under include key `deft:`). `plan()`
805
+ * already emits the `directive` / `npx` / `npm` surface, so this guard is a
806
+ * defensive invariant: any `task`-prefixed command is rewritten to the
807
+ * `directive` surface unless the project actually has the include.
794
808
  */
795
809
  /**
796
810
  * Detect Taskfile wiring through the injected seam (#2267). Mirrors
@@ -3,4 +3,12 @@ export declare function resolveConsumerTaskfile(projectRoot: string): string | n
3
3
  export type TaskfileIncludeStatus = "ok" | "missing-file" | "missing-include" | "unreadable";
4
4
  export declare function classifyTaskfileInclude(projectRoot: string): TaskfileIncludeStatus;
5
5
  export declare function formatMissingIncludeSnippet(): string;
6
+ /** Primary remediation for deep-think gates when Taskfile include is absent (#2893). */
7
+ export declare const GATES_SURFACE_DEFT_REMEDIATION: string;
8
+ /**
9
+ * Dual remediations for gates-surface readiness (#2893):
10
+ * 1. `deft <verb>` CLI (works without Taskfile)
11
+ * 2. Taskfile include so go-task exposes `task deft:<verb>` (not bare `task pr:watch`)
12
+ */
13
+ export declare function formatGatesSurfaceDualRemediation(kind: "missing-file" | "missing-include"): string;
6
14
  //# sourceMappingURL=taskfile.d.ts.map
@@ -62,4 +62,23 @@ export function classifyTaskfileInclude(projectRoot) {
62
62
  export function formatMissingIncludeSnippet() {
63
63
  return " deft:\n taskfile: ./.deft/core/Taskfile.yml\n optional: true\n";
64
64
  }
65
+ /** Primary remediation for deep-think gates when Taskfile include is absent (#2893). */
66
+ export const GATES_SURFACE_DEFT_REMEDIATION = "Prefer `deft pr:watch` / `deft review-monitor:register` / `deft verify:review-monitor` " +
67
+ "(primary npm/CLI surface; no Taskfile required).";
68
+ /**
69
+ * Dual remediations for gates-surface readiness (#2893):
70
+ * 1. `deft <verb>` CLI (works without Taskfile)
71
+ * 2. Taskfile include so go-task exposes `task deft:<verb>` (not bare `task pr:watch`)
72
+ */
73
+ export function formatGatesSurfaceDualRemediation(kind) {
74
+ const includePart = kind === "missing-file"
75
+ ? "Create root Taskfile.yml with the canonical deft include so go-task exposes `task deft:<verb>` " +
76
+ "(not bare `task pr:watch`):\n" +
77
+ "version: '3'\n\nincludes:\n" +
78
+ formatMissingIncludeSnippet()
79
+ : "Add the deft include to the existing Taskfile `includes:` block so go-task exposes " +
80
+ "`task deft:<verb>` (doctor NEVER mutates an existing user-owned Taskfile):\n" +
81
+ formatMissingIncludeSnippet();
82
+ return `${GATES_SURFACE_DEFT_REMEDIATION}\n2. ${includePart}`;
83
+ }
65
84
  //# sourceMappingURL=taskfile.js.map
@@ -13,7 +13,9 @@ export type CompactHookHost = (typeof COMPACT_HOOK_HOSTS)[number];
13
13
  /** Hosts without a native compact hook surface — deposit skips cleanly (#2113). */
14
14
  export declare const COMPACT_HOOK_SKIP_HOSTS: readonly ["codex"];
15
15
  export type HookVerdict = "allow" | "deny";
16
- export type HookDecisionCode = "session-start" | "session-start-degraded" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready" | "read-only-deny" | "spawn-explore-ready" | "spawn-ready" | "spawn-not-ready" | "runtime-policy-deny-path" | "runtime-policy-deny-scope";
16
+ export type HookDecisionCode = "session-start" | "session-start-degraded" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input"
17
+ /** Host closed stdin with zero bytes — integration failure, not a policy gate (#2864). */
18
+ | "stdin-empty" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready" | "read-only-deny" | "spawn-explore-ready" | "spawn-ready" | "spawn-not-ready" | "runtime-policy-deny-path" | "runtime-policy-deny-scope";
17
19
  export interface HookDecision {
18
20
  readonly verdict: HookVerdict;
19
21
  readonly code: HookDecisionCode;
@@ -62,6 +64,19 @@ export declare function hookToolName(payload: unknown, host?: HookHost): string
62
64
  export declare function hookWriteTargetPath(payload: unknown): string | null;
63
65
  /** POSIX-ish project-relative path for lifecycle matching. */
64
66
  export declare function toProjectRelativePosix(projectRoot: string, targetPath: string): string;
67
+ /**
68
+ * Lexical "outside project root" predicate used by #2885.
69
+ * - `".."` / `"../…"` are outside (not bare `startsWith("..")` — that matches `..secret`).
70
+ * - Absolute relatives and win32 cross-drive paths (`D:/…`) are outside.
71
+ * - Drive-letter form is win32-only so POSIX children like `D:/tmp/x` stay in-repo.
72
+ */
73
+ export declare function isLexicalOutsideProjectRoot(relPosix: string): boolean;
74
+ /**
75
+ * True when a write target is outside `projectRoot` for the active-scope skip (#2885).
76
+ * Lexically outside paths still fail the skip when a symlink/junction re-enters the project.
77
+ * When the project root cannot be realpath'd (unit fixtures), lexical classification wins.
78
+ */
79
+ export declare function isOutsideProjectRootWrite(projectRoot: string, targetPath: string): boolean;
65
80
  /**
66
81
  * Proposing a scope under xbrief/proposed/ (or legacy vbrief/proposed/) is
67
82
  * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
@@ -81,6 +96,11 @@ export declare function decideHook(input: HookDispatchInput, seams?: HookPolicyS
81
96
  * hook failure and blocks the tool — so Cursor allows must emit explicit
82
97
  * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
83
98
  * flow is unchanged.
99
+ *
100
+ * Cursor stdout always includes `code` (stable machine-readable decision code)
101
+ * so agents can distinguish policy denials from host-integration failures
102
+ * without parsing English (#2864). Exit status still does not encode the
103
+ * verdict — see hook-dispatch `run()` exit-code contract.
84
104
  */
85
105
  export declare function renderHostDecision(host: HookHost, decision: HookDecision): string;
86
106
  //# sourceMappingURL=dispatcher.d.ts.map
@@ -1,4 +1,5 @@
1
- import { relative, resolve, sep } from "node:path";
1
+ import { realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
2
3
  import { hasArtifactSuffix } from "../layout/resolve.js";
3
4
  import { evaluateRuntimeAuthorityDirectWrite, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
4
5
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
@@ -145,6 +146,57 @@ export function toProjectRelativePosix(projectRoot, targetPath) {
145
146
  const rel = relative(resolve(projectRoot), abs);
146
147
  return rel.split(sep).join("/").replace(/\\/g, "/");
147
148
  }
149
+ function posixRelative(fromAbs, toAbs) {
150
+ return relative(fromAbs, toAbs).split(sep).join("/").replace(/\\/g, "/");
151
+ }
152
+ /**
153
+ * Lexical "outside project root" predicate used by #2885.
154
+ * - `".."` / `"../…"` are outside (not bare `startsWith("..")` — that matches `..secret`).
155
+ * - Absolute relatives and win32 cross-drive paths (`D:/…`) are outside.
156
+ * - Drive-letter form is win32-only so POSIX children like `D:/tmp/x` stay in-repo.
157
+ */
158
+ export function isLexicalOutsideProjectRoot(relPosix) {
159
+ if (relPosix === ".." || relPosix.startsWith("../") || isAbsolute(relPosix)) {
160
+ return true;
161
+ }
162
+ // path.relative returns absolute drive paths across volumes on Windows only.
163
+ return process.platform === "win32" && /^[A-Za-z]:\//.test(relPosix);
164
+ }
165
+ /**
166
+ * True when a write target is outside `projectRoot` for the active-scope skip (#2885).
167
+ * Lexically outside paths still fail the skip when a symlink/junction re-enters the project.
168
+ * When the project root cannot be realpath'd (unit fixtures), lexical classification wins.
169
+ */
170
+ export function isOutsideProjectRootWrite(projectRoot, targetPath) {
171
+ const projectAbs = resolve(projectRoot);
172
+ const targetAbs = resolve(projectRoot, targetPath.replace(/\\/g, "/"));
173
+ const rel = posixRelative(projectAbs, targetAbs);
174
+ if (!isLexicalOutsideProjectRoot(rel))
175
+ return false;
176
+ try {
177
+ const projectReal = realpathSync(projectAbs);
178
+ let probe = targetAbs;
179
+ for (;;) {
180
+ try {
181
+ const probeReal = realpathSync(probe);
182
+ const reenter = posixRelative(projectReal, probeReal);
183
+ // Empty reenter ⇒ probeReal === projectReal (inside). Lexical-outside reenter ⇒ truly out.
184
+ if (reenter === "" || !isLexicalOutsideProjectRoot(reenter))
185
+ return false;
186
+ return true;
187
+ }
188
+ catch {
189
+ const parent = dirname(probe);
190
+ if (parent === probe)
191
+ return true;
192
+ probe = parent;
193
+ }
194
+ }
195
+ }
196
+ catch {
197
+ return true;
198
+ }
199
+ }
148
200
  /**
149
201
  * Proposing a scope under xbrief/proposed/ (or legacy vbrief/proposed/) is
150
202
  * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
@@ -298,18 +350,25 @@ function inspectMutationGates(input, toolName, seams, options) {
298
350
  if (!scope.ready) {
299
351
  const writeTarget = hookWriteTargetPath(input.payload);
300
352
  const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
301
- const proposedPathHint = options.proposedLifecycleExempt &&
302
- relTarget !== null &&
303
- (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
304
- ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
305
- "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
306
- "planning writes (#2625)."
307
- : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
308
- (options.proposedLifecycleExempt
309
- ? "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption)."
310
- : "then re-run the pre-start_agent gate stack.");
311
- const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
312
- return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
353
+ // Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
354
+ // (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
355
+ // targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
356
+ // Lexical ../ + realpath re-entry guard (not bare startsWith(".."); not symlink aliases).
357
+ const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
358
+ if (!outsideRoot || isSpawnTool(toolName)) {
359
+ const proposedPathHint = options.proposedLifecycleExempt &&
360
+ relTarget !== null &&
361
+ (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
362
+ ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
363
+ "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
364
+ "planning writes (#2625)."
365
+ : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
366
+ (options.proposedLifecycleExempt
367
+ ? "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption)."
368
+ : "then re-run the pre-start_agent gate stack.");
369
+ const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
370
+ return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
371
+ }
313
372
  }
314
373
  const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
315
374
  if (!isSpawnTool(toolName)) {
@@ -414,7 +473,12 @@ export function decideHook(input, seams = {}) {
414
473
  }
415
474
  const toolName = hookToolName(input.payload, input.host);
416
475
  if (toolName === null) {
417
- return deny(input, "invalid-input", null, missingToolNameMessage({
476
+ // stdin-empty is a host-integration failure; keep it distinct from invalid-input
477
+ // so agents can retry without treating it as a policy refusal (#2864).
478
+ const missingCode = input.payloadContext?.stdinEmpty
479
+ ? "stdin-empty"
480
+ : "invalid-input";
481
+ return deny(input, missingCode, null, missingToolNameMessage({
418
482
  host: input.host,
419
483
  payload: input.payload,
420
484
  context: input.payloadContext,
@@ -467,11 +531,16 @@ export function decideHook(input, seams = {}) {
467
531
  * hook failure and blocks the tool — so Cursor allows must emit explicit
468
532
  * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
469
533
  * flow is unchanged.
534
+ *
535
+ * Cursor stdout always includes `code` (stable machine-readable decision code)
536
+ * so agents can distinguish policy denials from host-integration failures
537
+ * without parsing English (#2864). Exit status still does not encode the
538
+ * verdict — see hook-dispatch `run()` exit-code contract.
470
539
  */
471
540
  export function renderHostDecision(host, decision) {
472
541
  if (decision.verdict === "allow") {
473
542
  if (host === "cursor") {
474
- return JSON.stringify({ permission: "allow" });
543
+ return JSON.stringify({ permission: "allow", code: decision.code });
475
544
  }
476
545
  return "";
477
546
  }
@@ -492,6 +561,7 @@ export function renderHostDecision(host, decision) {
492
561
  permission: "deny",
493
562
  user_message: decision.message,
494
563
  agent_message: decision.message,
564
+ code: decision.code,
495
565
  });
496
566
  }
497
567
  }
@@ -1,11 +1,51 @@
1
1
  import type { ScmCallFn } from "./reconcile-issues.js";
2
2
  export declare const GITHUB_ISSUE_REF_TYPE = "x-xbrief/github-issue";
3
3
  export declare const EXTERNAL_TRUST_LEVEL = "external";
4
+ /**
5
+ * Structured post-create failure: remote issue exists; local stamp/ledger may not.
6
+ * `createdUrl` is the durable handle for retry (also mirrored in process + OS-temp recovery).
7
+ */
4
8
  export declare class IssueEmitError extends Error {
5
- constructor(message: string);
9
+ readonly createdUrl?: string;
10
+ constructor(message: string, options?: {
11
+ createdUrl?: string;
12
+ });
6
13
  }
14
+ export declare function recoverySidecarPath(vbriefAbsPath: string): string;
15
+ /**
16
+ * Always-on recovery after remote create: process memory + private OS-temp sidecar.
17
+ * Independent of project-contained ledger so dual local failure still reconciles on retry (#2880).
18
+ */
19
+ export declare function rememberCreatedUrl(vbriefAbsPath: string, url: string): void;
20
+ export declare function loadRecoveredUrl(vbriefAbsPath: string): string | undefined;
21
+ export declare function clearRecoveredUrl(vbriefAbsPath: string): void;
22
+ /** Project ledger first, then process/OS-temp recovery (#2880). */
23
+ export declare function resolvePriorCreatedUrl(projectRoot: string, vbriefAbsPath: string): string | undefined;
24
+ /**
25
+ * Record URL immediately after remote create. Project ledger is best-effort;
26
+ * recovery layers always run first so dual local failure cannot force re-create (#2880).
27
+ */
28
+ export declare function recordCreatedUrlDurable(projectRoot: string, vbriefAbsPath: string, url: string): void;
29
+ /** Contained durable map: abs vbrief path -> issue URL for in-flight emits (#2871). */
30
+ export declare function pendingEmitLedgerPath(projectRoot: string): string;
31
+ export declare function loadPendingEmitUrls(projectRoot: string): Record<string, string>;
32
+ export declare function savePendingEmitUrl(projectRoot: string, vbriefAbsPath: string, url: string): void;
33
+ export declare function clearPendingEmitUrl(projectRoot: string, vbriefAbsPath: string): void;
7
34
  export declare function loadVbrief(path: string): Record<string, unknown>;
8
- export declare function writeVbrief(path: string, data: Record<string, unknown>): void;
35
+ /**
36
+ * Resolve project root and refuse unsafe write targets before any emit side-effect.
37
+ * Callers that create remote issues MUST invoke this before `fileIssue` so a
38
+ * containment refusal cannot leave an orphan GitHub issue (#2869 / #2871).
39
+ *
40
+ * ⊗ Do not fall back to `dirname(path)` as the containment root — that trusts a
41
+ * possibly-symlinked parent and would re-open the escape (Greptile P1 on #2871).
42
+ */
43
+ export declare function assertVbriefWriteTargetSafe(path: string, projectRoot?: string | null): string;
44
+ /**
45
+ * Persist an xBRIEF/vBRIEF JSON document. Gates the write target so a leaf or
46
+ * parent-directory symlink cannot divert the stamped file outside the project (#2869).
47
+ */
48
+ export declare function writeVbrief(path: string, data: Record<string, unknown>, projectRoot?: string | null): void;
9
49
  export declare function vbriefTitle(data: Record<string, unknown>): string;
10
50
  export declare function existingGithubIssueRef(data: Record<string, unknown>): string | null | undefined;
11
51
  export declare function addGithubIssueReference(data: Record<string, unknown>, url: string): Record<string, unknown>;
@@ -23,12 +63,14 @@ export declare function emitSingle(path: string, options: {
23
63
  scmCall?: ScmCallFn;
24
64
  noNetwork?: boolean;
25
65
  displayPath?: string | null;
66
+ projectRoot?: string | null;
26
67
  }): EmitAction;
27
68
  export declare function emitPerVbrief(paths: string[], options: {
28
69
  repo: string;
29
70
  scmCall?: ScmCallFn;
30
71
  noNetwork?: boolean;
31
72
  displayPaths?: string[] | null;
73
+ projectRoot?: string | null;
32
74
  }): EmitAction[];
33
75
  export interface UmbrellaAction {
34
76
  result: "created" | "dryrun" | "skipped";
@@ -45,6 +87,7 @@ export declare function emitUmbrella(paths: string[], options: {
45
87
  noNetwork?: boolean;
46
88
  title?: string | null;
47
89
  displayPaths?: string[] | null;
90
+ projectRoot?: string | null;
48
91
  }): UmbrellaAction;
49
92
  export declare function expandPatterns(patterns: string[], root?: string | null): string[];
50
93
  export declare function isNoNetwork(dryRun: boolean): boolean;