@fusengine/harness 0.1.91 → 0.1.93

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 (83) hide show
  1. package/README.md +17 -0
  2. package/dist/adapters/claude/index.mjs +1 -1
  3. package/dist/adapters/codex/index.mjs +1 -1
  4. package/dist/adapters/cursor/index.mjs +1 -1
  5. package/dist/adapters/hermes/index.mjs +1 -1
  6. package/dist/adapters/kimi/index.mjs +1 -1
  7. package/dist/apex-target-Xc2M32Pl.mjs +48 -0
  8. package/dist/apex-task-store-Cb65rE5i.mjs +467 -0
  9. package/dist/{claude-Ckv2_TgP.mjs → claude-D62hkUfS.mjs} +2 -84
  10. package/dist/cli/bin.mjs +7 -5
  11. package/dist/cli/index.d.mts +69 -1
  12. package/dist/cli/index.mjs +2 -2
  13. package/dist/config/index.mjs +2 -1
  14. package/dist/{dotenv-BLBkBTww.mjs → dotenv-C1LkcfW-.mjs} +1 -26
  15. package/dist/{handle-C43gA-Pr.mjs → handle-B-g9KL_9.mjs} +4837 -3466
  16. package/dist/{hermes-B9-p_3IF.mjs → hermes-ByopGx6C.mjs} +1 -1
  17. package/dist/index.d.mts +1 -1
  18. package/dist/index.mjs +3 -2
  19. package/dist/{kimi-G2wcSh5-.mjs → kimi-C-Oia9q-.mjs} +1 -1
  20. package/dist/{normalize-BjG6unTj.mjs → normalize-Dy8g9Ybl.mjs} +83 -3
  21. package/dist/policy/index.mjs +1 -1
  22. package/dist/prd-PvK7PKWS.mjs +494 -0
  23. package/dist/runtime/index.d.mts +13 -1
  24. package/dist/runtime/index.mjs +1 -1
  25. package/dist/runtime-io-DuumUeE6.mjs +84 -0
  26. package/dist/{session-state-D5gLr66m.d.mts → session-state-COg7Ej_2.d.mts} +32 -1
  27. package/dist/{skill-path-DVML3zfp.mjs → skill-path-Cz8WFaGu.mjs} +1 -1
  28. package/dist/{store-5-ZPKb0u.mjs → store-BVY6gIYM.mjs} +75 -3
  29. package/dist/tracking/index.d.mts +2 -2
  30. package/dist/tracking/index.mjs +2 -2
  31. package/dist/ttl-Dgwg_QAv.mjs +26 -0
  32. package/dist/{validate-KjZ1X9tH.mjs → validate-Dcjl0LUS.mjs} +4 -49
  33. package/package.json +1 -1
  34. package/src/adapters/cursor/context-budget.ts +144 -0
  35. package/src/adapters/cursor/context-limit.ts +115 -0
  36. package/src/adapters/cursor/context.ts +21 -2
  37. package/src/adapters/cursor/interfaces/context-budget.ts +25 -0
  38. package/src/adapters/cursor/native-response.ts +10 -129
  39. package/src/adapters/cursor/native-schemas.ts +161 -0
  40. package/src/adapters/cursor/normalize.ts +65 -0
  41. package/src/adapters/cursor/plugin-root.ts +103 -0
  42. package/src/adapters/cursor/respond.ts +94 -47
  43. package/src/cli/bin.ts +3 -0
  44. package/src/cli/index.ts +1 -0
  45. package/src/cli/prd/compact.ts +53 -0
  46. package/src/cli/prd/format.ts +23 -0
  47. package/src/cli/prd/index.ts +23 -0
  48. package/src/cli/prd/resolve.ts +88 -0
  49. package/src/cli/prd/shared.ts +80 -0
  50. package/src/cli/prd/status.ts +87 -0
  51. package/src/cli/prd/validate.ts +90 -0
  52. package/src/policy/prd/index.ts +36 -0
  53. package/src/policy/prd/interfaces/types.ts +103 -0
  54. package/src/policy/prd/prd-compact.ts +28 -0
  55. package/src/policy/prd/prd-context.ts +131 -0
  56. package/src/policy/prd/prd-crosscheck.ts +76 -0
  57. package/src/policy/prd/prd-enabled.ts +39 -0
  58. package/src/policy/prd/prd-io.ts +89 -0
  59. package/src/policy/prd/prd-ownership.ts +99 -0
  60. package/src/policy/prd/prd-paths.ts +88 -0
  61. package/src/policy/prd/prd-schema.ts +151 -0
  62. package/src/runtime/handle-post.ts +2 -0
  63. package/src/runtime/handle-pre.ts +11 -0
  64. package/src/runtime/handle.ts +87 -6
  65. package/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +7 -1
  66. package/src/runtime/lifecycle/dispatch.ts +18 -5
  67. package/src/runtime/lifecycle/failure-lesson.ts +6 -2
  68. package/src/runtime/lifecycle/rules-root.ts +18 -2
  69. package/src/runtime/normalize.ts +4 -0
  70. package/src/runtime/prd/index.ts +8 -0
  71. package/src/runtime/prd/prd-bash-targets.ts +199 -0
  72. package/src/runtime/prd/prd-candidate-files.ts +23 -0
  73. package/src/runtime/prd/prd-canon.ts +50 -0
  74. package/src/runtime/prd/prd-identity.ts +32 -0
  75. package/src/runtime/prd/prd-post-check.ts +73 -0
  76. package/src/runtime/prd/prd-pre-gate.ts +172 -0
  77. package/src/runtime/prd/prd-stop-gate.ts +101 -0
  78. package/src/runtime/prd/prd-subagent-context.ts +69 -0
  79. package/src/runtime/prd/prd-subagent-stop.ts +152 -0
  80. package/src/tracking/session-state.ts +46 -0
  81. package/src/tracking/track-diff.ts +3 -0
  82. package/src/tracking/track-journal.ts +10 -1
  83. package/dist/run-DkrzC0gb.mjs +0 -42
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @module context-limit
3
+ * Cursor 3.18.25's `hooks-carriers` drops an `additional_context` carrier
4
+ * once `o.length>1e4` — but `o` is the MERGED text of every hook's
5
+ * `additional_context` for that event (concatenated with `"\n\n---\n\n"`
6
+ * before the 10,000-char check), not this harness's response in isolation.
7
+ * Capping our own contribution at {@link ADDITIONAL_CONTEXT_LIMIT} is
8
+ * therefore the LAST-RESORT guard, not the real protection: on its own it
9
+ * only proves OUR piece stays under 10,000, while the total across every
10
+ * hook plugin configured on the same event can still exceed it and get
11
+ * dropped wholesale — measured at ~8,400 chars on `sessionStart` from core
12
+ * plugins alone, close enough to the ceiling that one more plugin tips it
13
+ * over. The actual protection is the cross-process shared budget registry
14
+ * in `./context-budget.ts` (Cursor id only), which reserves a slice of the
15
+ * 10,000 ceiling per (session, event, generation) key BEFORE calling
16
+ * {@link truncateAdditionalContext} here with the reserved amount instead of
17
+ * the flat {@link ADDITIONAL_CONTEXT_LIMIT} — this module stays a pure,
18
+ * budget-agnostic primitive so it keeps working unbudgeted (its historical,
19
+ * still-correct behavior) wherever no budget context is available. The
20
+ * limit unit is UTF-16 code units (`String.prototype.length`), matching
21
+ * `value.length` here exactly. Only 5 events carry `additional_context`
22
+ * through this carrier — sessionStart, beforeSubmitPrompt, preToolUse,
23
+ * postToolUse, postToolUseFailure — subagentStart/subagentStop use a
24
+ * different, unlimited channel. "Drops silently" also only holds when no
25
+ * `failClosed: true` hook is declared on that step/tool: with one declared,
26
+ * an oversized carrier REJECTS the tool call instead of being dropped quiet.
27
+ */
28
+
29
+ /** Cursor's hard `additional_context` character ceiling. */
30
+ export const ADDITIONAL_CONTEXT_LIMIT = 10_000;
31
+
32
+ /** Suffix appended by {@link truncateAdditionalContext} once a value is cut. */
33
+ export const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit";
34
+
35
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ /**
40
+ * Truncate a string to end with {@link TRUNCATION_MARKER} once its length
41
+ * exceeds `limit`. Leaves shorter values untouched. Idempotent under a
42
+ * SHRINKING `limit` across repeated calls (e.g. an unbudgeted flat-cap pass
43
+ * followed by a budgeted re-cap of the same stdout — see `./respond.ts`'s
44
+ * `toCursorLifecycleResponse` doc): when `value` already ends with
45
+ * {@link TRUNCATION_MARKER}, that marker is stripped BEFORE re-slicing so the
46
+ * result carries exactly one marker instead of risking a duplicated/cut one.
47
+ * @param value - Candidate `additional_context` body.
48
+ * @param limit - Effective ceiling for this call (defaults to the flat
49
+ * {@link ADDITIONAL_CONTEXT_LIMIT}; a shared-budget caller passes a smaller,
50
+ * per-reservation value instead).
51
+ */
52
+ export function truncateAdditionalContext(value: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string {
53
+ const alreadyMarked = value.endsWith(TRUNCATION_MARKER);
54
+ if (!alreadyMarked && value.length <= limit) return value;
55
+ if (limit <= TRUNCATION_MARKER.length) return TRUNCATION_MARKER.slice(0, Math.max(0, limit));
56
+ const base = alreadyMarked ? value.slice(0, value.length - TRUNCATION_MARKER.length) : value;
57
+ return base.slice(0, limit - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
58
+ }
59
+
60
+ /**
61
+ * Length of a Cursor stdout JSON's `additional_context` string field, or 0
62
+ * when the stdout is not JSON, has no such field, or that field isn't a
63
+ * string.
64
+ * @param stdout - A native Cursor JSON stdout candidate.
65
+ */
66
+ export function additionalContextLength(stdout: string): number {
67
+ let parsed: unknown;
68
+ try {
69
+ parsed = JSON.parse(stdout);
70
+ } catch {
71
+ return 0;
72
+ }
73
+ return isPlainObject(parsed) && typeof parsed.additional_context === "string" ? parsed.additional_context.length : 0;
74
+ }
75
+
76
+ /**
77
+ * Re-serialize a Cursor stdout string with its `additional_context` field
78
+ * dropped entirely — used once the shared budget has no room left even for
79
+ * a truncated marker. Returns the input byte-for-byte unchanged when it is
80
+ * not JSON or has no string `additional_context` field.
81
+ * @param stdout - A native Cursor JSON stdout candidate.
82
+ */
83
+ export function omitAdditionalContext(stdout: string): string {
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(stdout);
87
+ } catch {
88
+ return stdout;
89
+ }
90
+ if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
91
+ const { additional_context: _omitted, ...rest } = parsed;
92
+ return JSON.stringify(rest);
93
+ }
94
+
95
+ /**
96
+ * Re-serialize a Cursor stdout string with its `additional_context` field
97
+ * capped at `limit` characters. Returns the input byte-for-byte unchanged
98
+ * when it is not JSON, has no string `additional_context` field, or that
99
+ * field is already within the limit — so callers can wrap every return path
100
+ * unconditionally.
101
+ * @param stdout - A native Cursor JSON stdout candidate.
102
+ * @param limit - Effective ceiling for this call (see {@link truncateAdditionalContext}).
103
+ */
104
+ export function capAdditionalContext(stdout: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string {
105
+ let parsed: unknown;
106
+ try {
107
+ parsed = JSON.parse(stdout);
108
+ } catch {
109
+ return stdout;
110
+ }
111
+ if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
112
+ const truncated = truncateAdditionalContext(parsed.additional_context, limit);
113
+ if (truncated === parsed.additional_context) return stdout;
114
+ return JSON.stringify({ ...parsed, additional_context: truncated });
115
+ }
@@ -30,17 +30,36 @@ function contains(root: string, filePath: string): boolean {
30
30
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
31
31
  }
32
32
 
33
- /** Select Cursor's project scope without replacing a valid payload cwd. */
33
+ /**
34
+ * Select Cursor's project scope without replacing a valid payload cwd. Order:
35
+ * payload `cwd` -> longest workspace root containing `filePath` ->
36
+ * `workspaceRoots[0]` -> `CURSOR_PROJECT_DIR` env -> `CLAUDE_PROJECT_DIR` env
37
+ * -> `fallback`. Both env vars are validated the same way as any other Cursor
38
+ * path (`cursorAbsolutePath`: absolute, NUL-free, realpath-resolved), so an
39
+ * unset or malformed value is silently skipped rather than trusted.
40
+ * @param cwd - Cursor payload `cwd`, when present.
41
+ * @param workspaceRoots - Validated, deduped Cursor `workspace_roots`.
42
+ * @param filePath - The file the current event targets, when present.
43
+ * @param fallback - Caller-supplied last resort (never `process.cwd()`).
44
+ * @param env - Environment (defaults to `process.env`).
45
+ * @returns The resolved project root.
46
+ */
34
47
  export function cursorProjectCwd(
35
48
  cwd: string | undefined,
36
49
  workspaceRoots: readonly string[],
37
50
  filePath: string | undefined,
38
51
  fallback: string,
52
+ env: Record<string, string | undefined> = process.env,
39
53
  ): string {
40
54
  if (cwd) return cwd;
41
55
  if (filePath) {
42
56
  const matches = workspaceRoots.filter((root) => contains(root, filePath));
43
57
  if (matches.length > 0) return matches.sort((a, b) => b.length - a.length)[0]!;
44
58
  }
45
- return workspaceRoots[0] ?? fallback;
59
+ if (workspaceRoots[0]) return workspaceRoots[0];
60
+ const fromCursorEnv = cursorAbsolutePath(env.CURSOR_PROJECT_DIR);
61
+ if (fromCursorEnv) return fromCursorEnv;
62
+ const fromClaudeEnv = cursorAbsolutePath(env.CLAUDE_PROJECT_DIR);
63
+ if (fromClaudeEnv) return fromClaudeEnv;
64
+ return fallback;
46
65
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Reservation key + registry location for one hook invocation's slice of
3
+ * Cursor's shared, cross-process `additional_context` budget (see
4
+ * `../context-budget.ts`). `undefined` at a call site means "no shared
5
+ * budget available" — callers then fall back to the flat per-response cap.
6
+ */
7
+ export interface CursorBudgetContext {
8
+ /** Project state directory the registry file lives under (see `defaultStateDir`). */
9
+ stateDir: string;
10
+ /** Cursor `session_id` (its `conversation_id`). */
11
+ sessionId: string;
12
+ /** Raw Cursor `hook_event_name` (e.g. `"sessionStart"`). */
13
+ event: string;
14
+ /** Cursor `generation_id`; absent on `sessionStart`/`workspaceOpen`. */
15
+ generationId?: string;
16
+ /**
17
+ * Cursor `tool_use_id`; present on preToolUse/postToolUse/postToolUseFailure
18
+ * — Cursor merges `additional_context` PER TOOL CALL for these events, not
19
+ * once per (session, event, generation), so this must join the key or
20
+ * concurrent tool calls in the same generation would wrongly share one slice.
21
+ */
22
+ toolUseId?: string;
23
+ /** Test seam: injectable clock (defaults to `Date.now()`). */
24
+ now?: number;
25
+ }
@@ -1,132 +1,13 @@
1
- type FieldValidator = (value: unknown) => boolean;
2
-
3
- interface NativeSchema {
4
- fields: Readonly<Record<string, FieldValidator>>;
5
- required?: readonly string[];
6
- }
7
-
8
- const stringValue: FieldValidator = (value) => typeof value === "string";
9
- const booleanValue: FieldValidator = (value) => typeof value === "boolean";
10
- const plainRecord = (value: unknown): value is Record<string, unknown> => {
11
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
12
- try {
13
- const prototype = Object.getPrototypeOf(value);
14
- return prototype === Object.prototype || prototype === null;
15
- } catch {
16
- return false;
17
- }
18
- };
19
-
20
- type JsonFrame = { value: unknown; leave?: false } | { value: object; leave: true };
21
-
22
- function jsonChildren(value: object): unknown[] | null {
23
- const keys = Reflect.ownKeys(value);
24
- const descriptors = Object.getOwnPropertyDescriptors(value);
25
- if (Array.isArray(value)) {
26
- if (Object.getPrototypeOf(value) !== Array.prototype) return null;
27
- const length = descriptors.length;
28
- if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0) return null;
29
- if (keys.length !== length.value + 1 || keys.some((key) => typeof key === "symbol")) return null;
30
- const children: unknown[] = [];
31
- for (let index = 0; index < length.value; index += 1) {
32
- const descriptor = descriptors[String(index)];
33
- if (!descriptor?.enumerable || !("value" in descriptor)) return null;
34
- children.push(descriptor.value);
35
- }
36
- return children;
37
- }
38
- if (!plainRecord(value) || keys.some((key) => typeof key === "symbol")) return null;
39
- const children: unknown[] = [];
40
- for (const key of keys) {
41
- const descriptor = descriptors[key as string];
42
- if (!descriptor?.enumerable || !("value" in descriptor)) return null;
43
- children.push(descriptor.value);
44
- }
45
- return children;
46
- }
47
-
48
- function jsonValue(root: unknown): boolean {
49
- const active = new WeakSet<object>();
50
- const stack: JsonFrame[] = [{ value: root }];
51
- while (stack.length > 0) {
52
- const frame = stack.pop()!;
53
- if (frame.leave) {
54
- active.delete(frame.value);
55
- continue;
56
- }
57
- const { value } = frame;
58
- if (value === null || typeof value === "string" || typeof value === "boolean") continue;
59
- if (typeof value === "number") {
60
- if (!Number.isFinite(value)) return false;
61
- continue;
62
- }
63
- if (typeof value !== "object" || active.has(value)) return false;
64
- let children: unknown[] | null;
65
- try {
66
- children = jsonChildren(value);
67
- } catch {
68
- return false;
69
- }
70
- if (!children) return false;
71
- active.add(value);
72
- stack.push({ value, leave: true });
73
- for (let index = children.length - 1; index >= 0; index -= 1) stack.push({ value: children[index] });
74
- }
75
- return true;
76
- }
77
-
78
- const recordValue: FieldValidator = (value) => plainRecord(value) && jsonValue(value);
79
- const stringRecord: FieldValidator = (value) => {
80
- if (!recordValue(value)) return false;
81
- try {
82
- return Object.values(Object.getOwnPropertyDescriptors(value as object))
83
- .every((descriptor) => "value" in descriptor && typeof descriptor.value === "string");
84
- } catch {
85
- return false;
86
- }
87
- };
88
- const stringArray: FieldValidator = (value) => Array.isArray(value) && value.every(stringValue);
89
- const permission = (...values: string[]): FieldValidator => (value) => typeof value === "string" && values.includes(value);
90
-
91
- const EMPTY: NativeSchema = { fields: {} };
92
- const FOLLOWUP: NativeSchema = { fields: { followup_message: stringValue } };
93
- const PERMISSION_ASK: NativeSchema = {
94
- fields: { permission: permission("allow", "deny", "ask"), user_message: stringValue, agent_message: stringValue },
95
- required: ["permission"],
96
- };
97
-
98
- const NATIVE_SCHEMAS = {
99
- sessionStart: {
100
- fields: { env: stringRecord, additional_context: stringValue, continue: booleanValue, user_message: stringValue },
101
- },
102
- sessionEnd: EMPTY,
103
- beforeSubmitPrompt: { fields: { continue: booleanValue, user_message: stringValue }, required: ["continue"] },
104
- preCompact: { fields: { user_message: stringValue } },
105
- subagentStart: {
106
- fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
107
- },
108
- subagentStop: FOLLOWUP,
109
- preToolUse: {
110
- fields: { ...PERMISSION_ASK.fields, updated_input: recordValue }, required: ["permission"],
111
- },
112
- postToolUse: { fields: { updated_mcp_tool_output: recordValue, additional_context: stringValue } },
113
- postToolUseFailure: EMPTY,
114
- beforeShellExecution: PERMISSION_ASK,
115
- afterShellExecution: EMPTY,
116
- beforeMCPExecution: PERMISSION_ASK,
117
- afterMCPExecution: EMPTY,
118
- beforeReadFile: {
119
- fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
120
- },
121
- afterFileEdit: EMPTY,
122
- beforeTabFileRead: { fields: { permission: permission("allow", "deny") }, required: ["permission"] },
123
- afterTabFileEdit: EMPTY,
124
- afterAgentResponse: EMPTY,
125
- afterAgentThought: EMPTY,
126
- stop: FOLLOWUP,
127
- workspaceOpen: { fields: { pluginPaths: stringArray } },
128
- } as const satisfies Record<string, NativeSchema>;
129
-
1
+ import { NATIVE_SCHEMAS, recordValue, type NativeSchema } from "./native-schemas";
2
+
3
+ /**
4
+ * Check that every enumerable own key of `value` is a documented field for
5
+ * `eventName` and passes its validator, and that every required field is
6
+ * present. Rejects prototype-polluted or exotic-shaped candidates via
7
+ * {@link recordValue}.
8
+ * @param value - Parsed JSON candidate.
9
+ * @param eventName - The Cursor hook event the candidate would answer.
10
+ */
130
11
  function isNativeCursorResponse(value: unknown, eventName: string): boolean {
131
12
  try {
132
13
  if (!recordValue(value)) return false;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @module native-schemas
3
+ * Per-event field validators for Cursor's documented native stdout contract.
4
+ * Extracted from native-response.ts to keep that module focused on the
5
+ * passthrough decision logic (SOLID file-size split, not a plafond workaround).
6
+ *
7
+ * Field lists are binary-verified against Cursor 3.18.25 (agent-cli
8
+ * `190.index.js` / `workbench.desktop.main.js`, validators `R`/`Ded`) and
9
+ * match the published hooks documentation.
10
+ */
11
+
12
+ /** A single-field runtime type check used to build a {@link NativeSchema}. */
13
+ export type FieldValidator = (value: unknown) => boolean;
14
+
15
+ /** The exact field set (and per-field validator) Cursor reads for one event. */
16
+ export interface NativeSchema {
17
+ fields: Readonly<Record<string, FieldValidator>>;
18
+ required?: readonly string[];
19
+ }
20
+
21
+ const stringValue: FieldValidator = (value) => typeof value === "string";
22
+ const booleanValue: FieldValidator = (value) => typeof value === "boolean";
23
+
24
+ /** A plain `{}`-literal or `Object.create(null)` object — never a class instance or array. */
25
+ export const plainRecord = (value: unknown): value is Record<string, unknown> => {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
27
+ try {
28
+ const prototype = Object.getPrototypeOf(value);
29
+ return prototype === Object.prototype || prototype === null;
30
+ } catch {
31
+ return false;
32
+ }
33
+ };
34
+
35
+ type JsonFrame = { value: unknown; leave?: false } | { value: object; leave: true };
36
+
37
+ function jsonChildren(value: object): unknown[] | null {
38
+ const keys = Reflect.ownKeys(value);
39
+ const descriptors = Object.getOwnPropertyDescriptors(value);
40
+ if (Array.isArray(value)) {
41
+ if (Object.getPrototypeOf(value) !== Array.prototype) return null;
42
+ const length = descriptors.length;
43
+ if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0) return null;
44
+ if (keys.length !== length.value + 1 || keys.some((key) => typeof key === "symbol")) return null;
45
+ const children: unknown[] = [];
46
+ for (let index = 0; index < length.value; index += 1) {
47
+ const descriptor = descriptors[String(index)];
48
+ if (!descriptor?.enumerable || !("value" in descriptor)) return null;
49
+ children.push(descriptor.value);
50
+ }
51
+ return children;
52
+ }
53
+ if (!plainRecord(value) || keys.some((key) => typeof key === "symbol")) return null;
54
+ const children: unknown[] = [];
55
+ for (const key of keys) {
56
+ const descriptor = descriptors[key as string];
57
+ if (!descriptor?.enumerable || !("value" in descriptor)) return null;
58
+ children.push(descriptor.value);
59
+ }
60
+ return children;
61
+ }
62
+
63
+ function jsonValue(root: unknown): boolean {
64
+ const active = new WeakSet<object>();
65
+ const stack: JsonFrame[] = [{ value: root }];
66
+ while (stack.length > 0) {
67
+ const frame = stack.pop()!;
68
+ if (frame.leave) {
69
+ active.delete(frame.value);
70
+ continue;
71
+ }
72
+ const { value } = frame;
73
+ if (value === null || typeof value === "string" || typeof value === "boolean") continue;
74
+ if (typeof value === "number") {
75
+ if (!Number.isFinite(value)) return false;
76
+ continue;
77
+ }
78
+ if (typeof value !== "object" || active.has(value)) return false;
79
+ let children: unknown[] | null;
80
+ try {
81
+ children = jsonChildren(value);
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (!children) return false;
86
+ active.add(value);
87
+ stack.push({ value, leave: true });
88
+ for (let index = children.length - 1; index >= 0; index -= 1) stack.push({ value: children[index] });
89
+ }
90
+ return true;
91
+ }
92
+
93
+ /** A JSON-safe plain object (no cycles, no non-finite numbers, no exotic prototypes). */
94
+ export const recordValue: FieldValidator = (value) => plainRecord(value) && jsonValue(value);
95
+ const stringRecord: FieldValidator = (value) => {
96
+ if (!recordValue(value)) return false;
97
+ try {
98
+ return Object.values(Object.getOwnPropertyDescriptors(value as object))
99
+ .every((descriptor) => "value" in descriptor && typeof descriptor.value === "string");
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+ const stringArray: FieldValidator = (value) => Array.isArray(value) && value.every(stringValue);
105
+ const permission = (...values: string[]): FieldValidator => (value) => typeof value === "string" && values.includes(value);
106
+
107
+ const EMPTY: NativeSchema = { fields: {} };
108
+ const FOLLOWUP: NativeSchema = { fields: { followup_message: stringValue } };
109
+ const PERMISSION_ASK: NativeSchema = {
110
+ fields: { permission: permission("allow", "deny", "ask"), user_message: stringValue, agent_message: stringValue },
111
+ required: ["permission"],
112
+ };
113
+
114
+ const PRE_TOOL_USE: NativeSchema = {
115
+ fields: {
116
+ permission: permission("allow", "deny", "ask"),
117
+ user_message: stringValue,
118
+ agent_message: stringValue,
119
+ updated_input: recordValue,
120
+ additional_context: stringValue,
121
+ },
122
+ required: ["permission"],
123
+ };
124
+
125
+ /**
126
+ * Exact native stdout field set Cursor 3.18.25 reads per hook event.
127
+ * Nothing beyond this list is invented: any additional key on a candidate
128
+ * value fails {@link isNativeCursorResponse} in native-response.ts.
129
+ */
130
+ export const NATIVE_SCHEMAS: Readonly<Record<string, NativeSchema>> = {
131
+ sessionStart: {
132
+ fields: { env: stringRecord, additional_context: stringValue, continue: booleanValue, user_message: stringValue },
133
+ },
134
+ sessionEnd: EMPTY,
135
+ beforeSubmitPrompt: {
136
+ fields: { continue: booleanValue, user_message: stringValue, additional_context: stringValue },
137
+ required: ["continue"],
138
+ },
139
+ preCompact: { fields: { user_message: stringValue } },
140
+ subagentStart: {
141
+ fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
142
+ },
143
+ subagentStop: FOLLOWUP,
144
+ preToolUse: PRE_TOOL_USE,
145
+ postToolUse: { fields: { updated_mcp_tool_output: recordValue, additional_context: stringValue } },
146
+ postToolUseFailure: { fields: { additional_context: stringValue } },
147
+ beforeShellExecution: PERMISSION_ASK,
148
+ afterShellExecution: EMPTY,
149
+ beforeMCPExecution: PERMISSION_ASK,
150
+ afterMCPExecution: EMPTY,
151
+ beforeReadFile: {
152
+ fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
153
+ },
154
+ afterFileEdit: EMPTY,
155
+ beforeTabFileRead: { fields: { permission: permission("allow", "deny") }, required: ["permission"] },
156
+ afterTabFileEdit: EMPTY,
157
+ afterAgentResponse: EMPTY,
158
+ afterAgentThought: EMPTY,
159
+ stop: FOLLOWUP,
160
+ workspaceOpen: { fields: { pluginPaths: stringArray } },
161
+ };
@@ -49,12 +49,77 @@ function sanitizedCursorInput(input: Record<string, unknown>): Record<string, un
49
49
  return safe;
50
50
  }
51
51
 
52
+ /**
53
+ * Closed table: Cursor's `MCP:<tool>` tool_name form on preToolUse/
54
+ * postToolUse/postToolUseFailure LOSES the MCP server name (ground truth:
55
+ * Cursor CLI 3.18.25 + official docs — only beforeMCPExecution/
56
+ * afterMCPExecution carry `mcp_server_name`). This reconstructs the real
57
+ * server for the closed set of tool names this repo's gates actually depend
58
+ * on (GATED_TOOLS in doc-cache-gate.ts, CONTEXT7_SOURCE, RESEARCH_TOOLS,
59
+ * SHOT_TOOLS, gemini-mcp-gate, shadcn-skill-gate) — same closed-table
60
+ * philosophy as `mcp-tool-name.ts`'s Codex aliasing, never a blanket
61
+ * reversal. Coordinator decision: a tool name OUTSIDE this table (server
62
+ * genuinely unrecoverable, and no safe placeholder) is left as Cursor's raw
63
+ * `MCP:<tool>` string — `test/cursor-followup-normalize.test.ts` pins this
64
+ * as the committed contract ("commandless MCP tools keep their name"), so a
65
+ * fabricated `mcp__cursor__<tool>` placeholder is never introduced for the
66
+ * unknown case.
67
+ */
68
+ const CURSOR_MCP_TOOL_SERVERS: Readonly<Record<string, string>> = Object.assign(Object.create(null), {
69
+ "query-docs": "context7",
70
+ "resolve-library-id": "context7",
71
+ web_search_exa: "exa",
72
+ get_code_context_exa: "exa",
73
+ deep_researcher_start: "exa",
74
+ deep_researcher_check: "exa",
75
+ create_frontend: "gemini-design",
76
+ modify_frontend: "gemini-design",
77
+ snippet_frontend: "gemini-design",
78
+ search_items_in_registries: "shadcn",
79
+ view_items_in_registries: "shadcn",
80
+ get_item_examples_from_registries: "shadcn",
81
+ get_add_command_for_items: "shadcn",
82
+ get_audit_checklist: "shadcn",
83
+ });
84
+
85
+ /**
86
+ * The real MCP server for a bare Cursor tool name (the part after `MCP:`),
87
+ * or `undefined` when it isn't in the closed table. fuse-browser is inferred
88
+ * from the `browser_*` prefix — every fuse-browser tool is named that way
89
+ * and no other server in this ecosystem uses it — the remaining,
90
+ * non-distinctive tool names go through {@link CURSOR_MCP_TOOL_SERVERS}.
91
+ * NO placeholder fallback (coordinator decision, see {@link CURSOR_MCP_TOOL_SERVERS}):
92
+ * an unknown tool name means the server is genuinely unrecoverable, so the
93
+ * caller leaves the raw `MCP:<tool>` string untouched instead of fabricating one.
94
+ */
95
+ function cursorMcpServer(bareTool: string): string | undefined {
96
+ if (bareTool.startsWith("browser_")) return "fuse-browser";
97
+ return CURSOR_MCP_TOOL_SERVERS[bareTool];
98
+ }
99
+
100
+ /**
101
+ * Canonicalize Cursor's `MCP:<tool>` tool_name (preToolUse/postToolUse/
102
+ * postToolUseFailure) into the shared `mcp__<server>__<tool>` shape every
103
+ * other harness/gate expects. Returns `undefined` — meaning "leave the raw
104
+ * `MCP:<tool>` string as-is" — both when `tool` isn't the `MCP:` form and
105
+ * when the bare tool name is outside the closed {@link CURSOR_MCP_TOOL_SERVERS}
106
+ * table (server unrecoverable, no placeholder fabricated).
107
+ */
108
+ function cursorBareMcpToolName(tool: string | undefined): string | undefined {
109
+ if (!tool || !tool.startsWith("MCP:")) return undefined;
110
+ const bare = tool.slice(4);
111
+ const server = cursorMcpServer(bare);
112
+ return server ? `mcp__${server}__${bare}` : undefined;
113
+ }
114
+
52
115
  function cursorToolName(raw: Record<string, unknown>, event: string, tool: string | undefined, hasCommand: boolean): string {
53
116
  if (hasCommand) return "Bash";
54
117
  const server = str(raw.mcp_server_name)?.trim().replace(/[^A-Za-z0-9_-]+/g, "_");
55
118
  if (/^(before|after)MCPExecution$/i.test(event) && server && tool && !tool.startsWith("mcp__")) {
56
119
  return `mcp__${server}__${tool}`;
57
120
  }
121
+ const bareMcp = cursorBareMcpToolName(tool);
122
+ if (bareMcp) return bareMcp;
58
123
  if (tool === "Write") return "Edit";
59
124
  return tool ?? "";
60
125
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Cursor plugin-root resolution — independent from the rules-plugin probing
3
+ * in `../../runtime/lifecycle/rules-root.ts`. Ground truth (Cursor 3.18.25
4
+ * binary + cursor.com/docs/hooks): `CURSOR_PLUGIN_ROOT` / `CLAUDE_PLUGIN_ROOT`
5
+ * (both equal to the plugin install dir) are injected ONLY into
6
+ * plugin-declared hook processes — never user (`~/.cursor/hooks.json`),
7
+ * project (`.cursor/hooks.json`), or enterprise hooks. A plugin hook's cwd is
8
+ * the plugin install dir, EXCEPT for `stop`/`subagentStop`, where it is the
9
+ * workspace root — callers must pass the right `cwd` for the event they are
10
+ * handling. Precedence: (1) `CURSOR_PLUGIN_ROOT` env, (2) `CLAUDE_PLUGIN_ROOT`
11
+ * env, (3) `cwd` when it carries a Cursor plugin marker
12
+ * (`.cursor-plugin/plugin.json`, `plugin.json` + `hooks/hooks.json`, or a
13
+ * bare `hooks/hooks.json` — matches installed-plugin layouts under
14
+ * `~/.cursor/plugins/cache/**` and `~/.cursor/plugins/local/<name>/`), (4)
15
+ * `none`. Cursor refuses symlinked config paths itself; we do not share that
16
+ * constraint, so every resolved candidate is realpath-followed instead.
17
+ */
18
+ import { existsSync, realpathSync, statSync } from "node:fs";
19
+ import { isAbsolute, join } from "node:path";
20
+
21
+ /** How the resolved Cursor plugin root was determined. */
22
+ export type CursorPluginRootSource =
23
+ | "env:CURSOR_PLUGIN_ROOT"
24
+ | "env:CLAUDE_PLUGIN_ROOT"
25
+ | "cwd:plugin-marker"
26
+ | "none";
27
+
28
+ /** Result of resolving the Cursor plugin install root. */
29
+ export interface CursorPluginRootResult {
30
+ /** Realpath-resolved plugin install directory, or `null` when unproven. */
31
+ root: string | null;
32
+ /** Which precedence step produced `root`. */
33
+ source: CursorPluginRootSource;
34
+ /** One diagnostic entry per candidate that was examined and rejected. */
35
+ checked: string[];
36
+ }
37
+
38
+ /** Validate an env candidate: non-empty, NUL-free, absolute, existing dir. */
39
+ function validateEnvCandidate(label: string, value: string | undefined, checked: string[]): string | null {
40
+ if (value === undefined || value === "") {
41
+ checked.push(`${label}: unset`);
42
+ return null;
43
+ }
44
+ if (value.includes("\0")) {
45
+ checked.push(`${label}: invalid (contains NUL): "${value}"`);
46
+ return null;
47
+ }
48
+ if (!isAbsolute(value)) {
49
+ checked.push(`${label}: invalid (not absolute): "${value}"`);
50
+ return null;
51
+ }
52
+ try {
53
+ if (!statSync(value).isDirectory()) {
54
+ checked.push(`${label}: invalid (not a directory): "${value}"`);
55
+ return null;
56
+ }
57
+ } catch {
58
+ checked.push(`${label}: invalid (no such directory): "${value}"`);
59
+ return null;
60
+ }
61
+ try {
62
+ return realpathSync.native(value);
63
+ } catch {
64
+ checked.push(`${label}: invalid (realpath failed): "${value}"`);
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** True when `dir` carries a recognized Cursor plugin install marker. */
70
+ function hasPluginMarker(dir: string): boolean {
71
+ if (existsSync(join(dir, ".cursor-plugin", "plugin.json"))) return true;
72
+ if (existsSync(join(dir, "plugin.json")) && existsSync(join(dir, "hooks", "hooks.json"))) return true;
73
+ return existsSync(join(dir, "hooks", "hooks.json"));
74
+ }
75
+
76
+ /**
77
+ * Resolve the Cursor plugin install root a plugin-declared hook runs from.
78
+ * @param env - Environment (defaults to `process.env`).
79
+ * @param cwd - The hook process's cwd for the current event (plugin root for
80
+ * most events, workspace root for `stop`/`subagentStop` — caller's choice).
81
+ * @returns The resolved root, its source, and every rejected candidate.
82
+ */
83
+ export function resolveCursorPluginRoot(
84
+ env: Record<string, string | undefined>,
85
+ cwd: string,
86
+ ): CursorPluginRootResult {
87
+ const checked: string[] = [];
88
+ const fromCursor = validateEnvCandidate("env:CURSOR_PLUGIN_ROOT", env.CURSOR_PLUGIN_ROOT, checked);
89
+ if (fromCursor) return { root: fromCursor, source: "env:CURSOR_PLUGIN_ROOT", checked };
90
+ const fromClaude = validateEnvCandidate("env:CLAUDE_PLUGIN_ROOT", env.CLAUDE_PLUGIN_ROOT, checked);
91
+ if (fromClaude) return { root: fromClaude, source: "env:CLAUDE_PLUGIN_ROOT", checked };
92
+ if (hasPluginMarker(cwd)) {
93
+ let resolved = cwd;
94
+ try {
95
+ resolved = realpathSync.native(cwd);
96
+ } catch {
97
+ /* keep raw cwd when realpath fails (e.g. already-canonical or unreadable parent) */
98
+ }
99
+ return { root: resolved, source: "cwd:plugin-marker", checked };
100
+ }
101
+ checked.push(`cwd:"${cwd}": no plugin marker found`);
102
+ return { root: null, source: "none", checked };
103
+ }