harnery 0.5.0 → 0.7.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.
- package/README.md +16 -6
- package/dist/commander.d.ts +29 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +94 -33
- package/dist/commands/browse-ai.js +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +41 -9
- package/dist/commands/cookies.js +1 -1
- package/dist/commands/decision.d.ts +4 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +354 -0
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +74 -2
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/fetch.js +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- package/dist/core/agents/events/consume.d.ts +25 -2
- package/dist/core/agents/events/consume.d.ts.map +1 -1
- package/dist/core/agents/events/consume.js +55 -7
- package/dist/core/agents/events/emit.d.ts +2 -1
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +6 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -17
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- package/dist/lib/agent-browser/client.js +1 -1
- package/dist/lib/browser/client.d.ts +14 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +20 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/runts.d.ts +44 -0
- package/dist/lib/browser/runts.d.ts.map +1 -0
- package/dist/lib/browser/runts.js +193 -0
- package/dist/lib/completion/walk.js +1 -1
- package/dist/lib/cookies/client.d.ts +1 -1
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +1 -1
- package/dist/lib/decision/index.d.ts +212 -0
- package/dist/lib/decision/index.d.ts.map +1 -0
- package/dist/lib/decision/index.js +523 -0
- package/dist/lib/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +3 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +67 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +15 -10
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +9 -6
- package/src/commander.ts +35 -0
- package/src/commands/agents.ts +97 -29
- package/src/commands/browse-ai.ts +1 -1
- package/src/commands/browse.ts +63 -8
- package/src/commands/cookies.ts +1 -1
- package/src/commands/decision.ts +438 -0
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +86 -2
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/fetch.ts +1 -1
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +7 -1
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +15 -1
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -16
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- package/src/lib/agent-browser/client.ts +1 -1
- package/src/lib/browser/client.ts +28 -0
- package/src/lib/browser/index.ts +4 -0
- package/src/lib/browser/runts.ts +218 -0
- package/src/lib/completion/walk.ts +1 -1
- package/src/lib/cookies/client.ts +2 -2
- package/src/lib/decision/index.ts +685 -0
- package/src/lib/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +61 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +15 -10
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /package/dist/{lib → core}/scratch/index.d.ts +0 -0
|
@@ -153,6 +153,17 @@ export function resolveOwnerWithSource(): {
|
|
|
153
153
|
|
|
154
154
|
const root = monorepoRoot();
|
|
155
155
|
if (!root) return { owner: null, source: "none" };
|
|
156
|
+
|
|
157
|
+
// Cursor's Glass/Agents UI can run several chats under one long-lived node
|
|
158
|
+
// process, so the pid-map row for that shared ancestor is last-writer-wins.
|
|
159
|
+
// Prefer the per-chat session id when Cursor exposes one in the tool env.
|
|
160
|
+
if (shouldPreferSessionEnv()) {
|
|
161
|
+
const bySession = resolveOwnerBySessionEnv(root);
|
|
162
|
+
if (bySession) {
|
|
163
|
+
return { owner: bySession, source: "session_env" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
156
167
|
const pidmapDir = resolve(root, ".harnery", "pid-map");
|
|
157
168
|
if (!existsSync(pidmapDir)) return { owner: null, source: "none" };
|
|
158
169
|
|
|
@@ -216,16 +227,32 @@ const SESSION_ID_ENV_VARS = [
|
|
|
216
227
|
"HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
|
|
217
228
|
"CLAUDE_CODE_SESSION_ID",
|
|
218
229
|
"CURSOR_SESSION_ID",
|
|
230
|
+
"CURSOR_CONVERSATION_ID",
|
|
219
231
|
"CODEX_SESSION_ID",
|
|
220
232
|
] as const;
|
|
221
233
|
|
|
222
|
-
/** Read the first non-empty harness session-id env var
|
|
223
|
-
function
|
|
234
|
+
/** Read normalized candidates from the first non-empty harness session-id env var. */
|
|
235
|
+
function sessionIdsFromEnv(): string[] {
|
|
224
236
|
for (const key of SESSION_ID_ENV_VARS) {
|
|
225
237
|
const v = process.env[key]?.trim();
|
|
226
|
-
if (v)
|
|
238
|
+
if (!v) continue;
|
|
239
|
+
if (key === "CURSOR_CONVERSATION_ID" && v.startsWith("bc-") && v.length > 3) {
|
|
240
|
+
return [v.slice(3), v];
|
|
241
|
+
}
|
|
242
|
+
return [v];
|
|
227
243
|
}
|
|
228
|
-
return
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Read the first non-empty harness session-id env var, or null. */
|
|
248
|
+
function sessionIdFromEnv(): string | null {
|
|
249
|
+
return sessionIdsFromEnv()[0] ?? null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function shouldPreferSessionEnv(): boolean {
|
|
253
|
+
if (!sessionIdFromEnv()) return false;
|
|
254
|
+
const platform = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim();
|
|
255
|
+
return process.env.CURSOR_AGENT === "1" || platform === "cursor";
|
|
229
256
|
}
|
|
230
257
|
|
|
231
258
|
/**
|
|
@@ -239,8 +266,8 @@ function sessionIdFromEnv(): string | null {
|
|
|
239
266
|
* Exported for unit testing with an injectable root.
|
|
240
267
|
*/
|
|
241
268
|
export function resolveOwnerBySessionEnv(root: string): string | null {
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
269
|
+
const sessionIds = sessionIdsFromEnv();
|
|
270
|
+
if (sessionIds.length === 0) return null;
|
|
244
271
|
|
|
245
272
|
const activeDir = resolve(root, ".harnery", "active");
|
|
246
273
|
if (!existsSync(activeDir)) return null;
|
|
@@ -256,7 +283,7 @@ export function resolveOwnerBySessionEnv(root: string): string | null {
|
|
|
256
283
|
if (!file.endsWith(".json")) continue;
|
|
257
284
|
try {
|
|
258
285
|
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
259
|
-
if (!parsed || parsed.session_id
|
|
286
|
+
if (!parsed || !sessionIds.includes(parsed.session_id)) continue;
|
|
260
287
|
if (typeof parsed.instance_id !== "string") continue;
|
|
261
288
|
const ts = Date.parse(parsed.last_heartbeat);
|
|
262
289
|
if (Number.isFinite(ts) && ts >= cutoffMs) return parsed.instance_id;
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
* with a bounded **tail read** (reading only the last `tailBytes` of the file
|
|
12
12
|
* and locating the cursor there) which turns an O(file-size) read into
|
|
13
13
|
* O(window). If the cursor is older than the window (a long-idle system, or a
|
|
14
|
-
* cursor that's been rotated out) we fall back to a
|
|
15
|
-
*
|
|
14
|
+
* cursor that's been rotated out) we fall back to a wider read that is itself
|
|
15
|
+
* capped (`fallbackCapBytes`): the stream is an append-only ledger that grows
|
|
16
|
+
* without bound, so a whole-file `readFileSync` throws V8's max-string-length
|
|
17
|
+
* error ("Cannot create a string longer than 0x1fffffe8 characters") once it
|
|
18
|
+
* passes ~512MB, which would abort the projection. Events older than the cap are
|
|
19
|
+
* stale for coord-state purposes, so a bounded replay is the correct fallback.
|
|
16
20
|
*
|
|
17
21
|
* Idempotency: projectors are idempotent by `event_id`. The
|
|
18
22
|
* cursor file makes this cheap (we never replay an already-projected event by
|
|
@@ -36,6 +40,11 @@ import { coordEnv } from "../../../lib/env.ts";
|
|
|
36
40
|
const STREAM_REL = ".harnery/events.ndjson";
|
|
37
41
|
const CURSOR_REL = ".harnery/.events-cursor";
|
|
38
42
|
const DEFAULT_TAIL_BYTES = 2 * 1024 * 1024; // 2 MiB, thousands of events of headroom
|
|
43
|
+
/** Cap for the fall-through read when the cursor misses the tail window. Well
|
|
44
|
+
* under V8's ~512MB max string length so it never throws on the unbounded
|
|
45
|
+
* ledger; comfortably larger than any realistic cursor drift (the global cursor
|
|
46
|
+
* advances on every agent's turn.stop, so it sits near EOF in practice). */
|
|
47
|
+
const DEFAULT_FALLBACK_CAP_BYTES = 64 * 1024 * 1024; // 64 MiB
|
|
39
48
|
|
|
40
49
|
export interface CanonicalEvent {
|
|
41
50
|
schema_version: number;
|
|
@@ -69,6 +78,13 @@ export interface ConsumeOpts {
|
|
|
69
78
|
* callers should leave it unset.
|
|
70
79
|
*/
|
|
71
80
|
tailBytes?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Cap in bytes for the fall-through read (cursor missed the tail window).
|
|
83
|
+
* Defaults to `HARNERY_AGENT_COORD_FALLBACK_CAP_BYTES` env or 64 MiB. Bounds
|
|
84
|
+
* the read so the unbounded ledger can never overflow V8's max string length.
|
|
85
|
+
* Mainly a test seam; production callers should leave it unset.
|
|
86
|
+
*/
|
|
87
|
+
fallbackCapBytes?: number;
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
/**
|
|
@@ -103,8 +119,19 @@ export function consumeSince(coordRoot: string, opts: ConsumeOpts = {}): Consume
|
|
|
103
119
|
// Cursor older than the tail window → fall through to a full read.
|
|
104
120
|
}
|
|
105
121
|
|
|
106
|
-
//
|
|
107
|
-
|
|
122
|
+
// Bounded fall-through: first run, replayAll, small file, or cursor not found
|
|
123
|
+
// in the tail. This must NEVER read the whole file: the stream grows without
|
|
124
|
+
// bound and a >512MB readFileSync throws V8's max-string-length error, which
|
|
125
|
+
// would abort the projection. Read at most `cap` bytes from the tail; when we
|
|
126
|
+
// start mid-file, drop the (likely partial) first line.
|
|
127
|
+
const cap = resolveFallbackCap(opts.fallbackCapBytes);
|
|
128
|
+
const readBytes = Math.min(fileSize, cap);
|
|
129
|
+
let text = readTailUtf8(streamPath, fileSize, readBytes);
|
|
130
|
+
if (readBytes < fileSize) {
|
|
131
|
+
const firstNl = text.indexOf("\n");
|
|
132
|
+
text = firstNl >= 0 ? text.slice(firstNl + 1) : "";
|
|
133
|
+
}
|
|
134
|
+
const all = parseLines(text);
|
|
108
135
|
const parsed = eventsAfterCursor(all, cursor);
|
|
109
136
|
if (parsed.foundCursor) {
|
|
110
137
|
return {
|
|
@@ -114,12 +141,19 @@ export function consumeSince(coordRoot: string, opts: ConsumeOpts = {}): Consume
|
|
|
114
141
|
};
|
|
115
142
|
}
|
|
116
143
|
|
|
117
|
-
// The cursor names an event
|
|
118
|
-
//
|
|
119
|
-
// event_id).
|
|
144
|
+
// The cursor names an event older than the capped window (or rotated out), so
|
|
145
|
+
// replay what the window holds (safer than silent state drift; the projector
|
|
146
|
+
// is idempotent by event_id). lastEventId null keeps the cursor put.
|
|
120
147
|
return { events: all, lastEventId: null, streamBytes: fileSize };
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
function resolveFallbackCap(override?: number): number {
|
|
151
|
+
if (override !== undefined && override > 0) return override;
|
|
152
|
+
const env = coordEnv("AGENT_COORD_FALLBACK_CAP_BYTES");
|
|
153
|
+
const n = env ? Number(env) : Number.NaN;
|
|
154
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_FALLBACK_CAP_BYTES;
|
|
155
|
+
}
|
|
156
|
+
|
|
123
157
|
function resolveTailBytes(override?: number): number {
|
|
124
158
|
if (override !== undefined && override > 0) return override;
|
|
125
159
|
const env = coordEnv("AGENT_COORD_TAIL_BYTES");
|
|
@@ -127,6 +161,30 @@ function resolveTailBytes(override?: number): number {
|
|
|
127
161
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TAIL_BYTES;
|
|
128
162
|
}
|
|
129
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Read at most `capBytes` from the tail of the event stream as UTF-8, dropping
|
|
166
|
+
* the (partial) leading line when the file is larger than the cap. Shared
|
|
167
|
+
* bounded reader for CLI consumers (`agents trace` / `agents health`) that must
|
|
168
|
+
* never `readFileSync` the whole unbounded ledger — a >512MB read throws V8's
|
|
169
|
+
* max-string-length error. `truncated` is true when older bytes were skipped, so
|
|
170
|
+
* callers can surface the cap rather than silently under-reporting.
|
|
171
|
+
*/
|
|
172
|
+
export function readStreamTailBounded(
|
|
173
|
+
streamPath: string,
|
|
174
|
+
capBytes: number,
|
|
175
|
+
): { text: string; truncated: boolean } {
|
|
176
|
+
if (!existsSync(streamPath)) return { text: "", truncated: false };
|
|
177
|
+
const fileSize = statSync(streamPath).size;
|
|
178
|
+
const readBytes = Math.min(fileSize, capBytes);
|
|
179
|
+
let text = readTailUtf8(streamPath, fileSize, readBytes);
|
|
180
|
+
const truncated = readBytes < fileSize;
|
|
181
|
+
if (truncated) {
|
|
182
|
+
const nl = text.indexOf("\n");
|
|
183
|
+
text = nl >= 0 ? text.slice(nl + 1) : "";
|
|
184
|
+
}
|
|
185
|
+
return { text, truncated };
|
|
186
|
+
}
|
|
187
|
+
|
|
130
188
|
/** Read the trailing `windowBytes` of a file as UTF-8 without loading the rest. */
|
|
131
189
|
function readTailUtf8(streamPath: string, fileSize: number, windowBytes: number): string {
|
|
132
190
|
const start = Math.max(0, fileSize - windowBytes);
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* event/schema types).
|
|
6
6
|
*
|
|
7
7
|
* Phase 4: agent-coord uses this from CLI handlers (state.task_set,
|
|
8
|
-
* state.status_checked, state.scratch_append, council.*,
|
|
8
|
+
* state.status_checked, state.scratch_append, council.*, decision.*,
|
|
9
|
+
* presence.*).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
|
|
12
13
|
import { dirname, join } from "node:path";
|
|
14
|
+
import { maybeRotateEventStream } from "../../hooks/events/rotate.ts";
|
|
13
15
|
import { ulid } from "./ulid.ts";
|
|
14
16
|
|
|
15
17
|
const SCHEMA_VERSION = 1 as const;
|
|
@@ -70,6 +72,10 @@ export function emit(coordRoot: string, input: EmitInput): Envelope {
|
|
|
70
72
|
const streamPath = join(coordRoot, STREAM_FILE);
|
|
71
73
|
const lockPath = join(coordRoot, LOCK_FILE);
|
|
72
74
|
|
|
75
|
+
// Size-triggered rotation (shared with the agent-hooks emitter): roll the
|
|
76
|
+
// active file to a dated archive when it crosses the cap. Fail-soft.
|
|
77
|
+
maybeRotateEventStream(coordRoot);
|
|
78
|
+
|
|
73
79
|
ensureDir(dirname(streamPath));
|
|
74
80
|
ensureFile(lockPath);
|
|
75
81
|
|
|
@@ -95,11 +95,22 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
|
|
|
95
95
|
|
|
96
96
|
// Ordering check: if we hold any claim with path < req.path, fine.
|
|
97
97
|
// Otherwise the new claim would create a backward-edge in the dependency
|
|
98
|
-
// graph and risk deadlock. Only applies when
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
// graph and risk deadlock. Only applies when a fresh peer genuinely CONTENDS
|
|
99
|
+
// with us — i.e. holds a path that also sits in our own footprint (held
|
|
100
|
+
// claims ∪ the path we're now requesting). A wait-for cycle is a
|
|
101
|
+
// strongly-connected set of agents linked by shared files; if no fresh peer
|
|
102
|
+
// shares any file with our footprint, we're in a disjoint component of the
|
|
103
|
+
// resource graph and cannot be part of any cycle, so sorted-order acquisition
|
|
104
|
+
// buys nothing and the block is pure false-positive friction. Sharing a file
|
|
105
|
+
// is the necessary condition for a cycle through this agent, so this narrowing
|
|
106
|
+
// leaves the deadlock-prevention invariant intact for genuine contention while
|
|
107
|
+
// removing the dominant real-world cost — a peer editing unrelated files
|
|
108
|
+
// walling off every backward-order edit. (Single-agent flow can't deadlock
|
|
109
|
+
// with itself and never arms, since there are no other peers to contend.)
|
|
110
|
+
const myFootprint = new Set<string>(myPeer?.files_touched ?? []);
|
|
111
|
+
myFootprint.add(req.path);
|
|
112
|
+
const hasContendingPeer = otherPeers.some(
|
|
113
|
+
(p) => isFresh(p.last_heartbeat) && p.files_touched.some((f) => myFootprint.has(f)),
|
|
103
114
|
);
|
|
104
115
|
// Re-editing a path already in our own files_touched acquires no new lock
|
|
105
116
|
// edge, so it can't create a circular wait — the ordering rule must not block
|
|
@@ -109,7 +120,7 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
|
|
|
109
120
|
// both agent-Gibson holding README.md and agent-Ophelia holding AGENTS.md were
|
|
110
121
|
// blocked re-editing those held files after touching a higher path, 2026-07-03).
|
|
111
122
|
const alreadyHeld = myPeer?.files_touched.includes(req.path) ?? false;
|
|
112
|
-
if (
|
|
123
|
+
if (hasContendingPeer && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
|
|
113
124
|
// Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
|
|
114
125
|
// a committed-clean file is a finished edit, not a held lock, so it must not
|
|
115
126
|
// wall off a lower-sorted acquisition. Without this, a long session
|
|
@@ -81,7 +81,7 @@ export function appendScratch(
|
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Replace the scratchpad with new body, archiving prior contents and
|
|
84
|
-
* appending an "(edited via UI by
|
|
84
|
+
* appending an "(edited via UI by the operator)" audit-marker note.
|
|
85
85
|
*/
|
|
86
86
|
export function editScratchpad(
|
|
87
87
|
coordRoot: string,
|
|
@@ -112,7 +112,7 @@ export function editScratchpad(
|
|
|
112
112
|
|
|
113
113
|
const summaryText = summary && summary.length > 0 ? summary : "(no summary)";
|
|
114
114
|
const auditMarker =
|
|
115
|
-
`## [${ts}] note (edited via UI by
|
|
115
|
+
`## [${ts}] note (edited via UI by the operator)\n` +
|
|
116
116
|
`${summaryText}\n` +
|
|
117
117
|
`Pre-edit archived at .harnery/scratch/archived/${instanceId}-${archiveSuffix}.md\n\n`;
|
|
118
118
|
|
package/src/core/config.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { findCoordRoot } from "./hooks/resolve/coord-root.ts";
|
|
|
23
23
|
export const DEFAULT_BIN_NAME = "harn";
|
|
24
24
|
|
|
25
25
|
interface HarneryConfig {
|
|
26
|
-
/** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "
|
|
26
|
+
/** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "acme"). */
|
|
27
27
|
binName?: string;
|
|
28
28
|
/**
|
|
29
29
|
* Host-specific command that (re)installs the project's git hooks, surfaced
|
|
@@ -118,6 +118,20 @@ export function resolveBinName(coordRoot?: string | null): string {
|
|
|
118
118
|
return DEFAULT_BIN_NAME;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The binName explicitly pinned in `<projectRoot>/.harnery/config.jsonc`, or
|
|
123
|
+
* null when absent. Unlike `resolveBinName()` this ignores `HARNERY_BIN` and
|
|
124
|
+
* never falls back to the default — it answers "did someone deliberately pin
|
|
125
|
+
* a name for THIS project?". `init` uses it so a re-run from a different host
|
|
126
|
+
* CLI can't silently re-stamp its own name over a committed pin (the harnery
|
|
127
|
+
* repo itself pins `"harn"` while living embedded in a host monorepo whose
|
|
128
|
+
* CLI would otherwise stamp the host's name into public, committed surfaces).
|
|
129
|
+
*/
|
|
130
|
+
export function pinnedBinName(projectRoot: string): string | null {
|
|
131
|
+
const binName = readConfig(projectRoot).binName;
|
|
132
|
+
return typeof binName === "string" && binName.trim() ? binName.trim() : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
121
135
|
/**
|
|
122
136
|
* The host's git-hook (re)install command, for the "commit guard not wired"
|
|
123
137
|
* nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
|
package/src/core/hooks/cli.ts
CHANGED
|
@@ -33,12 +33,12 @@ import {
|
|
|
33
33
|
imageJanitor,
|
|
34
34
|
playSound,
|
|
35
35
|
resetSoundCounters,
|
|
36
|
+
runSessionSyncExtension,
|
|
36
37
|
runTurnSummary,
|
|
37
38
|
scratchArchive,
|
|
38
39
|
scratchJanitor,
|
|
39
40
|
scratchRecoveryCue,
|
|
40
41
|
soundForEvent,
|
|
41
|
-
syncClaudeSessions,
|
|
42
42
|
} from "./effects/index.ts";
|
|
43
43
|
import { emit } from "./events/emit.ts";
|
|
44
44
|
import type { Harness } from "./events/schema.ts";
|
|
@@ -508,7 +508,7 @@ async function main(): Promise<number> {
|
|
|
508
508
|
// session-telemetry sync (via HARNERY_CLAUDE_SESSIONS_FORCE=1).
|
|
509
509
|
if (harness === "claude-code") {
|
|
510
510
|
scratchArchive(coordRoot, owner.instance_id);
|
|
511
|
-
|
|
511
|
+
runSessionSyncExtension(coordRoot, true);
|
|
512
512
|
}
|
|
513
513
|
}
|
|
514
514
|
|
|
@@ -603,7 +603,7 @@ async function main(): Promise<number> {
|
|
|
603
603
|
// CC effects: rate-limited session-telemetry sync + turn-summary Haiku
|
|
604
604
|
// auto-summary.
|
|
605
605
|
if (harness === "claude-code") {
|
|
606
|
-
|
|
606
|
+
runSessionSyncExtension(coordRoot, false);
|
|
607
607
|
runTurnSummary(coordRoot, owner.instance_id, sessionId, payload?.transcript_path);
|
|
608
608
|
}
|
|
609
609
|
|
|
@@ -117,24 +117,31 @@ export function scratchArchive(repoRoot: string, owner: string): void {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
120
|
+
* Fire the optional host session-sync extension on turn stop / session end.
|
|
121
|
+
* harnery core has no session-telemetry sink of its own; a host that wants one
|
|
122
|
+
* drops an executable at
|
|
123
|
+
* `scripts/hooks/harness/claude_code/extensions/session-sync.sh` under the coord
|
|
124
|
+
* root, and core runs it detached + unref'd so a slow sink never blocks the
|
|
125
|
+
* hook. `force` arrives as argv $1 ("1" on session end, "0" on turn stop) so the
|
|
126
|
+
* host can rate-limit the stop path and force-flush on end. No-op when the
|
|
127
|
+
* script is absent, so a plain public install spawns nothing. Mirrors
|
|
128
|
+
* `runTurnSummary`'s extension-script pattern. Caller gates to the claude-code
|
|
129
|
+
* harness.
|
|
126
130
|
*/
|
|
127
|
-
export function
|
|
131
|
+
export function runSessionSyncExtension(repoRoot: string, force: boolean): void {
|
|
128
132
|
try {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
const script = join(
|
|
134
|
+
repoRoot,
|
|
135
|
+
"scripts",
|
|
136
|
+
"hooks",
|
|
137
|
+
"harness",
|
|
138
|
+
"claude_code",
|
|
139
|
+
"extensions",
|
|
140
|
+
"session-sync.sh",
|
|
141
|
+
);
|
|
142
|
+
if (!existsSync(script)) return;
|
|
143
|
+
const child = spawn("bash", [script, force ? "1" : "0"], {
|
|
144
|
+
env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
|
|
138
145
|
detached: true,
|
|
139
146
|
stdio: "ignore",
|
|
140
147
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { maybeRotateEventStream } from "./rotate.ts";
|
|
3
4
|
import {
|
|
4
5
|
type EventEnvelope,
|
|
5
6
|
type EventType,
|
|
@@ -69,6 +70,10 @@ export function emit<TType extends EventType, TData>(
|
|
|
69
70
|
const streamPath = join(coordRoot, STREAM_FILE);
|
|
70
71
|
const lockPath = join(coordRoot, LOCK_FILE);
|
|
71
72
|
|
|
73
|
+
// Size-triggered rotation: roll the active file to a dated archive when it
|
|
74
|
+
// crosses the cap, so this append lands in a bounded fresh file. Fail-soft.
|
|
75
|
+
maybeRotateEventStream(coordRoot);
|
|
76
|
+
|
|
72
77
|
ensureDir(dirname(streamPath));
|
|
73
78
|
ensureFile(lockPath);
|
|
74
79
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size-triggered rotation for the canonical event stream
|
|
3
|
+
* `.harnery/events.ndjson`.
|
|
4
|
+
*
|
|
5
|
+
* The stream is a deliberately-immutable, append-only ledger. Left unbounded it
|
|
6
|
+
* grows past V8's ~512MB max string length, at which point any code that does a
|
|
7
|
+
* whole-file `readFileSync` throws ("Cannot create a string longer than
|
|
8
|
+
* 0x1fffffe8 characters"). Rather than police every current + future reader,
|
|
9
|
+
* we bound the *active* file: once it crosses a byte cap we rename it to a dated
|
|
10
|
+
* archive (`events-YYYY-MM-DD.ndjson`) and start a fresh active file. Archives
|
|
11
|
+
* are kept — the audit trail is preserved, just spread across files that readers
|
|
12
|
+
* glob newest-first.
|
|
13
|
+
*
|
|
14
|
+
* Both independent append paths (agent-hooks `hooks/events/emit.ts` and
|
|
15
|
+
* agent-coord `agents/events/emit.ts`) call `maybeRotateEventStream` before
|
|
16
|
+
* appending, so a roll is triggered continuously, not only at session
|
|
17
|
+
* boundaries.
|
|
18
|
+
*
|
|
19
|
+
* Concurrency: many short-lived hook processes append at once. The rename itself
|
|
20
|
+
* is atomic — a concurrent appender opening the path by name lands its line in
|
|
21
|
+
* whichever inode the name currently resolves to (old archive or new active
|
|
22
|
+
* file), never nowhere. The only hazard is two processes both renaming (the
|
|
23
|
+
* second would move the fresh empty file to a second archive name), which an
|
|
24
|
+
* `O_EXCL` roll-lock prevents.
|
|
25
|
+
*
|
|
26
|
+
* Design + rationale: harnery ADR 0009; decision docket
|
|
27
|
+
* `should-harnery-adopt-a-retention-2026-07-07-ed07`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
closeSync,
|
|
32
|
+
existsSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
openSync,
|
|
35
|
+
renameSync,
|
|
36
|
+
statSync,
|
|
37
|
+
unlinkSync,
|
|
38
|
+
} from "node:fs";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { coordEnv } from "../../../lib/env.ts";
|
|
41
|
+
|
|
42
|
+
const STREAM_FILE = ".harnery/events.ndjson";
|
|
43
|
+
const ROLL_LOCK_FILE = ".harnery/events.ndjson.roll.lock";
|
|
44
|
+
|
|
45
|
+
/** Default active-file cap: 256 MiB — half of V8's ~512MB string cliff, so even
|
|
46
|
+
* a reader that loads the whole active file whole stays clear of the limit. */
|
|
47
|
+
const DEFAULT_ROLL_BYTES = 256 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
/** A roll-lock older than this is treated as abandoned (a crashed roller) and
|
|
50
|
+
* stolen. Rolls are sub-millisecond, so 60s is orders of magnitude of slack. */
|
|
51
|
+
const STALE_LOCK_MS = 60_000;
|
|
52
|
+
|
|
53
|
+
/** Archive glob prefix. Readers enumerate `events-*.ndjson` newest-first.
|
|
54
|
+
* The pre-existing manual `events-legacy.ndjson` matches and is picked up for
|
|
55
|
+
* free. Kept in sync with the reader-side pattern in web/lib/coord-reader.ts. */
|
|
56
|
+
export const ARCHIVE_PREFIX = "events-";
|
|
57
|
+
export const ARCHIVE_SUFFIX = ".ndjson";
|
|
58
|
+
|
|
59
|
+
function resolveRollBytes(): number {
|
|
60
|
+
const env = coordEnv("EVENTS_ROLL_BYTES");
|
|
61
|
+
const n = env ? Number(env) : Number.NaN;
|
|
62
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_ROLL_BYTES;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `YYYY-MM-DD` (UTC) from a millisecond timestamp. Uses the file's own mtime
|
|
66
|
+
* rather than `Date.now()` so it's deterministic and independent of the harness
|
|
67
|
+
* runtime's clock surface. */
|
|
68
|
+
function utcDateStamp(mtimeMs: number): string {
|
|
69
|
+
const d = new Date(mtimeMs);
|
|
70
|
+
const y = d.getUTCFullYear();
|
|
71
|
+
const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
72
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
73
|
+
return `${y}-${mo}-${day}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** First non-colliding `events-<stamp>[.N].ndjson` path under `coordDir`. */
|
|
77
|
+
function archivePathFor(coordDir: string, stamp: string): string {
|
|
78
|
+
const base = join(coordDir, `${ARCHIVE_PREFIX}${stamp}${ARCHIVE_SUFFIX}`);
|
|
79
|
+
if (!existsSync(base)) return base;
|
|
80
|
+
for (let n = 1; ; n++) {
|
|
81
|
+
const cand = join(coordDir, `${ARCHIVE_PREFIX}${stamp}.${n}${ARCHIVE_SUFFIX}`);
|
|
82
|
+
if (!existsSync(cand)) return cand;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Roll `events.ndjson` to a dated archive when it exceeds the byte cap. Cheap
|
|
88
|
+
* no-op (a single `statSync`) below the cap. Fail-soft: any error is swallowed
|
|
89
|
+
* so a rotation problem can never break the append that triggered it.
|
|
90
|
+
*
|
|
91
|
+
* `nowMs` is injectable for the stale-lock check in tests; production leaves it
|
|
92
|
+
* unset (the roll date always comes from the file's mtime, never `nowMs`).
|
|
93
|
+
*/
|
|
94
|
+
export function maybeRotateEventStream(coordRoot: string, nowMs?: number): boolean {
|
|
95
|
+
try {
|
|
96
|
+
const streamPath = join(coordRoot, STREAM_FILE);
|
|
97
|
+
if (!existsSync(streamPath)) return false;
|
|
98
|
+
const cap = resolveRollBytes();
|
|
99
|
+
let st = statSync(streamPath);
|
|
100
|
+
if (st.size < cap) return false;
|
|
101
|
+
|
|
102
|
+
const coordDir = dirname(streamPath);
|
|
103
|
+
const lockPath = join(coordRoot, ROLL_LOCK_FILE);
|
|
104
|
+
mkdirSync(coordDir, { recursive: true });
|
|
105
|
+
|
|
106
|
+
if (!acquireRollLock(lockPath, nowMs)) return false; // another roller active
|
|
107
|
+
try {
|
|
108
|
+
// Re-check under the lock: a concurrent process may have just rolled.
|
|
109
|
+
if (!existsSync(streamPath)) return false;
|
|
110
|
+
st = statSync(streamPath);
|
|
111
|
+
if (st.size < cap) return false;
|
|
112
|
+
|
|
113
|
+
const archive = archivePathFor(coordDir, utcDateStamp(st.mtimeMs));
|
|
114
|
+
renameSync(streamPath, archive);
|
|
115
|
+
// Recreate an empty active file so the very next append (and any reader's
|
|
116
|
+
// existsSync) sees a valid, present stream.
|
|
117
|
+
closeSync(openSync(streamPath, "a"));
|
|
118
|
+
return true;
|
|
119
|
+
} finally {
|
|
120
|
+
try {
|
|
121
|
+
unlinkSync(lockPath);
|
|
122
|
+
} catch {
|
|
123
|
+
/* best-effort */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
return false; // fail-soft: never break the caller's append
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Create the roll-lock with O_EXCL. Returns true on acquisition. On collision,
|
|
132
|
+
* steals a stale lock (crashed roller) once, else returns false. */
|
|
133
|
+
function acquireRollLock(lockPath: string, nowMs?: number): boolean {
|
|
134
|
+
try {
|
|
135
|
+
closeSync(openSync(lockPath, "wx"));
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
// Exists: steal if abandoned.
|
|
139
|
+
try {
|
|
140
|
+
const age = (nowMs ?? Date.now()) - statSync(lockPath).mtimeMs;
|
|
141
|
+
if (age > STALE_LOCK_MS) {
|
|
142
|
+
unlinkSync(lockPath);
|
|
143
|
+
closeSync(openSync(lockPath, "wx"));
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
/* lost the steal race — someone else holds it */
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -34,6 +34,12 @@ export interface HarnessSpec {
|
|
|
34
34
|
entryShape: HookEntryShape;
|
|
35
35
|
/** When set, ensure this top-level `version` key in the file (Cursor requires `1`). */
|
|
36
36
|
rootVersion?: number;
|
|
37
|
+
/** Harness-owned entries from older specs that `init` should remove during migration. */
|
|
38
|
+
legacyEvents?: HookEvent[];
|
|
39
|
+
/** Strict top-level settings keys accepted by this harness, when its parser is closed. */
|
|
40
|
+
allowedTopLevelKeys?: string[];
|
|
41
|
+
/** Strict hook event keys accepted by this harness, including events harnery does not wire. */
|
|
42
|
+
allowedEventKeys?: string[];
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/** Claude Code: `.claude/settings.json`. */
|
|
@@ -67,20 +73,38 @@ export const CURSOR_EVENTS: HookEvent[] = [
|
|
|
67
73
|
{ settingsKey: "stop", subcommand: "stop" },
|
|
68
74
|
];
|
|
69
75
|
|
|
70
|
-
/** Codex
|
|
76
|
+
/** Codex events that harnery uses from the current native lifecycle surface. */
|
|
71
77
|
export const CODEX_EVENTS: HookEvent[] = [
|
|
72
78
|
{ settingsKey: "SessionStart", subcommand: "session-start" },
|
|
73
|
-
{ settingsKey: "SessionEnd", subcommand: "session-end" },
|
|
74
79
|
{ settingsKey: "PreToolUse", subcommand: "pre-tool-use" },
|
|
75
80
|
{ settingsKey: "PostToolUse", subcommand: "post-tool-use" },
|
|
76
|
-
{ settingsKey: "PostToolUseFailure", subcommand: "post-tool-use-failure" },
|
|
77
81
|
{ settingsKey: "UserPromptSubmit", subcommand: "user-prompt-submit" },
|
|
78
82
|
{ settingsKey: "SubagentStart", subcommand: "sub-agent-start" },
|
|
79
83
|
{ settingsKey: "SubagentStop", subcommand: "sub-agent-stop" },
|
|
80
84
|
{ settingsKey: "Stop", subcommand: "stop" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/** Entries written by harnery before Codex adopted a strict native hook schema. */
|
|
88
|
+
export const LEGACY_CODEX_EVENTS: HookEvent[] = [
|
|
89
|
+
{ settingsKey: "SessionEnd", subcommand: "session-end" },
|
|
90
|
+
{ settingsKey: "PostToolUseFailure", subcommand: "post-tool-use-failure" },
|
|
81
91
|
{ settingsKey: "StopFailure", subcommand: "stop-failure" },
|
|
82
92
|
];
|
|
83
93
|
|
|
94
|
+
/** Every hook key accepted by Codex 0.144, including events harnery does not consume. */
|
|
95
|
+
export const CODEX_ALLOWED_EVENT_KEYS = [
|
|
96
|
+
"SessionStart",
|
|
97
|
+
"PreToolUse",
|
|
98
|
+
"PermissionRequest",
|
|
99
|
+
"PostToolUse",
|
|
100
|
+
"PreCompact",
|
|
101
|
+
"PostCompact",
|
|
102
|
+
"UserPromptSubmit",
|
|
103
|
+
"SubagentStart",
|
|
104
|
+
"SubagentStop",
|
|
105
|
+
"Stop",
|
|
106
|
+
];
|
|
107
|
+
|
|
84
108
|
/** Every supported harness, fully wireable by `harn init`. */
|
|
85
109
|
export const HARNESS_SPECS: Record<HarnessId, HarnessSpec> = {
|
|
86
110
|
"claude-code": {
|
|
@@ -98,5 +122,8 @@ export const HARNESS_SPECS: Record<HarnessId, HarnessSpec> = {
|
|
|
98
122
|
settingsFile: ".codex/hooks.json",
|
|
99
123
|
events: CODEX_EVENTS,
|
|
100
124
|
entryShape: "claude",
|
|
125
|
+
legacyEvents: LEGACY_CODEX_EVENTS,
|
|
126
|
+
allowedTopLevelKeys: ["description", "hooks"],
|
|
127
|
+
allowedEventKeys: CODEX_ALLOWED_EVENT_KEYS,
|
|
101
128
|
},
|
|
102
129
|
};
|