harnery 0.6.0 → 0.7.1

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 (137) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/cli.js +48 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/coord-client.js +32 -8
  27. package/dist/core/agents/events/emit.d.ts.map +1 -1
  28. package/dist/core/agents/events/emit.js +4 -0
  29. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  30. package/dist/core/agents/rules/claim-conflict.js +16 -5
  31. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  32. package/dist/core/agents/state/heartbeat-projector.js +10 -3
  33. package/dist/core/config.d.ts +10 -0
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +13 -0
  36. package/dist/core/hooks/cli.js +3 -3
  37. package/dist/core/hooks/effects/index.d.ts +11 -7
  38. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  39. package/dist/core/hooks/effects/index.js +15 -18
  40. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  41. package/dist/core/hooks/events/emit.js +4 -0
  42. package/dist/core/hooks/events/rotate.d.ts +43 -0
  43. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  44. package/dist/core/hooks/events/rotate.js +142 -0
  45. package/dist/core/hooks/harness/events.d.ts +11 -1
  46. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/events.js +22 -3
  48. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  49. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  50. package/dist/core/hooks/harness/wiring.js +34 -5
  51. package/dist/core/scratch/index.d.ts.map +1 -0
  52. package/dist/{lib → core}/scratch/index.js +2 -2
  53. package/dist/lib/devtools.d.ts +178 -0
  54. package/dist/lib/devtools.d.ts.map +1 -0
  55. package/dist/lib/devtools.js +1328 -0
  56. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  59. package/dist/lib/docs-frontmatter.d.ts +33 -0
  60. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  61. package/dist/lib/docs-frontmatter.js +130 -0
  62. package/dist/lib/docs-index.d.ts +1 -0
  63. package/dist/lib/docs-index.d.ts.map +1 -1
  64. package/dist/lib/docs-index.js +4 -5
  65. package/dist/lib/docs-lint.d.ts +2 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +18 -12
  68. package/dist/lib/docs-meta.d.ts +14 -0
  69. package/dist/lib/docs-meta.d.ts.map +1 -0
  70. package/dist/lib/docs-meta.js +34 -0
  71. package/dist/lib/docs-sweep.d.ts +12 -0
  72. package/dist/lib/docs-sweep.d.ts.map +1 -1
  73. package/dist/lib/docs-sweep.js +98 -103
  74. package/dist/lib/format.js +2 -2
  75. package/dist/lib/http/index.d.ts +1 -0
  76. package/dist/lib/http/index.d.ts.map +1 -1
  77. package/dist/lib/http/index.js +1 -0
  78. package/dist/lib/http/request.d.ts +77 -0
  79. package/dist/lib/http/request.d.ts.map +1 -0
  80. package/dist/lib/http/request.js +105 -0
  81. package/dist/lib/instructions/apply.d.ts +63 -0
  82. package/dist/lib/instructions/apply.d.ts.map +1 -0
  83. package/dist/lib/instructions/apply.js +255 -0
  84. package/dist/lib/instructions/splice.d.ts +73 -0
  85. package/dist/lib/instructions/splice.d.ts.map +1 -0
  86. package/dist/lib/instructions/splice.js +118 -0
  87. package/dist/lib/instructions/templates.d.ts +45 -0
  88. package/dist/lib/instructions/templates.d.ts.map +1 -0
  89. package/dist/lib/instructions/templates.js +258 -0
  90. package/dist/lib/tunnel/gate.d.ts +1 -0
  91. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  92. package/dist/lib/tunnel/gate.js +14 -9
  93. package/dist/lib/tunnel/state.d.ts +11 -1
  94. package/dist/lib/tunnel/state.d.ts.map +1 -1
  95. package/dist/lib/tunnel/state.js +8 -3
  96. package/package.json +7 -6
  97. package/src/commander.ts +23 -0
  98. package/src/commands/agents.ts +50 -3
  99. package/src/commands/deinit.ts +5 -0
  100. package/src/commands/devtools.ts +284 -0
  101. package/src/commands/docs.ts +81 -1
  102. package/src/commands/doctor.ts +13 -4
  103. package/src/commands/env.ts +11 -77
  104. package/src/commands/init.ts +66 -15
  105. package/src/commands/scratch.ts +1 -1
  106. package/src/commands/tunnel.ts +316 -65
  107. package/src/commands/web-fetch.ts +1 -1
  108. package/src/core/agents/cli.ts +55 -0
  109. package/src/core/agents/coord-client.ts +34 -7
  110. package/src/core/agents/events/emit.ts +5 -0
  111. package/src/core/agents/rules/claim-conflict.ts +17 -6
  112. package/src/core/agents/state/heartbeat-projector.ts +11 -3
  113. package/src/core/config.ts +14 -0
  114. package/src/core/hooks/cli.ts +3 -3
  115. package/src/core/hooks/effects/index.ts +23 -17
  116. package/src/core/hooks/events/emit.ts +5 -0
  117. package/src/core/hooks/events/rotate.ts +151 -0
  118. package/src/core/hooks/harness/events.ts +30 -3
  119. package/src/core/hooks/harness/wiring.ts +46 -5
  120. package/src/{lib → core}/scratch/index.ts +2 -2
  121. package/src/lib/devtools.ts +1653 -0
  122. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  123. package/src/lib/docs-frontmatter.ts +151 -0
  124. package/src/lib/docs-index.ts +4 -5
  125. package/src/lib/docs-lint.ts +17 -11
  126. package/src/lib/docs-meta.ts +44 -0
  127. package/src/lib/docs-sweep.ts +104 -102
  128. package/src/lib/format.ts +2 -2
  129. package/src/lib/http/index.ts +1 -0
  130. package/src/lib/http/request.ts +154 -0
  131. package/src/lib/instructions/apply.ts +318 -0
  132. package/src/lib/instructions/splice.ts +148 -0
  133. package/src/lib/instructions/templates.ts +295 -0
  134. package/src/lib/tunnel/gate.ts +14 -9
  135. package/src/lib/tunnel/state.ts +19 -4
  136. package/dist/lib/scratch/index.d.ts.map +0 -1
  137. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -112,6 +112,15 @@ export function resolveOwnerWithSource() {
112
112
  const root = monorepoRoot();
113
113
  if (!root)
114
114
  return { owner: null, source: "none" };
115
+ // Cursor's Glass/Agents UI can run several chats under one long-lived node
116
+ // process, so the pid-map row for that shared ancestor is last-writer-wins.
117
+ // Prefer the per-chat session id when Cursor exposes one in the tool env.
118
+ if (shouldPreferSessionEnv()) {
119
+ const bySession = resolveOwnerBySessionEnv(root);
120
+ if (bySession) {
121
+ return { owner: bySession, source: "session_env" };
122
+ }
123
+ }
115
124
  const pidmapDir = resolve(root, ".harnery", "pid-map");
116
125
  if (!existsSync(pidmapDir))
117
126
  return { owner: null, source: "none" };
@@ -172,16 +181,31 @@ const SESSION_ID_ENV_VARS = [
172
181
  "HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
173
182
  "CLAUDE_CODE_SESSION_ID",
174
183
  "CURSOR_SESSION_ID",
184
+ "CURSOR_CONVERSATION_ID",
175
185
  "CODEX_SESSION_ID",
176
186
  ];
177
- /** Read the first non-empty harness session-id env var, or null. */
178
- function sessionIdFromEnv() {
187
+ /** Read normalized candidates from the first non-empty harness session-id env var. */
188
+ function sessionIdsFromEnv() {
179
189
  for (const key of SESSION_ID_ENV_VARS) {
180
190
  const v = process.env[key]?.trim();
181
- if (v)
182
- return v;
191
+ if (!v)
192
+ continue;
193
+ if (key === "CURSOR_CONVERSATION_ID" && v.startsWith("bc-") && v.length > 3) {
194
+ return [v.slice(3), v];
195
+ }
196
+ return [v];
183
197
  }
184
- return null;
198
+ return [];
199
+ }
200
+ /** Read the first non-empty harness session-id env var, or null. */
201
+ function sessionIdFromEnv() {
202
+ return sessionIdsFromEnv()[0] ?? null;
203
+ }
204
+ function shouldPreferSessionEnv() {
205
+ if (!sessionIdFromEnv())
206
+ return false;
207
+ const platform = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim();
208
+ return process.env.CURSOR_AGENT === "1" || platform === "cursor";
185
209
  }
186
210
  /**
187
211
  * Resolve the owner by matching the harness session-id env var against the
@@ -194,8 +218,8 @@ function sessionIdFromEnv() {
194
218
  * Exported for unit testing with an injectable root.
195
219
  */
196
220
  export function resolveOwnerBySessionEnv(root) {
197
- const sessionId = sessionIdFromEnv();
198
- if (!sessionId)
221
+ const sessionIds = sessionIdsFromEnv();
222
+ if (sessionIds.length === 0)
199
223
  return null;
200
224
  const activeDir = resolve(root, ".harnery", "active");
201
225
  if (!existsSync(activeDir))
@@ -214,7 +238,7 @@ export function resolveOwnerBySessionEnv(root) {
214
238
  continue;
215
239
  try {
216
240
  const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
217
- if (!parsed || parsed.session_id !== sessionId)
241
+ if (!parsed || !sessionIds.includes(parsed.session_id))
218
242
  continue;
219
243
  if (typeof parsed.instance_id !== "string")
220
244
  continue;
@@ -1 +1 @@
1
- {"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/events/emit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,QAAA,MAAM,cAAc,EAAG,CAAU,CAAC;AAKlC,MAAM,MAAM,OAAO,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;AACzD,MAAM,MAAM,MAAM,GAAG,aAAa,GAAG,aAAa,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvE,MAAM,WAAW,QAAQ;IACvB,cAAc,EAAE,OAAO,cAAc,CAAC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,QAAQ,CAexD;AAED,wBAAgB,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,QAAQ,CAwBlE"}
1
+ {"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/events/emit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAOH,QAAA,MAAM,cAAc,EAAG,CAAU,CAAC;AAKlC,MAAM,MAAM,OAAO,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;AACzD,MAAM,MAAM,MAAM,GAAG,aAAa,GAAG,aAAa,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvE,MAAM,WAAW,QAAQ;IACvB,cAAc,EAAE,OAAO,cAAc,CAAC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,QAAQ,CAexD;AAED,wBAAgB,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,QAAQ,CA4BlE"}
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
+ import { maybeRotateEventStream } from "../../hooks/events/rotate.js";
13
14
  import { ulid } from "./ulid.js";
14
15
  const SCHEMA_VERSION = 1;
15
16
  const STREAM_FILE = ".harnery/events.ndjson";
@@ -35,6 +36,9 @@ export function emit(coordRoot, input) {
35
36
  const envelope = buildEnvelope(input);
36
37
  const streamPath = join(coordRoot, STREAM_FILE);
37
38
  const lockPath = join(coordRoot, LOCK_FILE);
39
+ // Size-triggered rotation (shared with the agent-hooks emitter): roll the
40
+ // active file to a dated archive when it crosses the cap. Fail-soft.
41
+ maybeRotateEventStream(coordRoot);
38
42
  ensureDir(dirname(streamPath));
39
43
  ensureFile(lockPath);
40
44
  let line = `${JSON.stringify(envelope)}\n`;
@@ -1 +1 @@
1
- {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAyFjF"}
1
+ {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAoGjF"}
@@ -58,10 +58,21 @@ export function evaluateClaim(coordRoot, req) {
58
58
  }
59
59
  // Ordering check: if we hold any claim with path < req.path, fine.
60
60
  // Otherwise the new claim would create a backward-edge in the dependency
61
- // graph and risk deadlock. Only applies when there are OTHER fresh peers:
62
- // single-agent flow can't deadlock with itself, and the rule otherwise
63
- // forces release-and-reacquire cycles on every reverse-order edit pair.
64
- const hasFreshPeers = otherPeers.some((p) => isFresh(p.last_heartbeat) && p.files_touched.length > 0);
61
+ // graph and risk deadlock. Only applies when a fresh peer genuinely CONTENDS
62
+ // with us — i.e. holds a path that also sits in our own footprint (held
63
+ // claims ∪ the path we're now requesting). A wait-for cycle is a
64
+ // strongly-connected set of agents linked by shared files; if no fresh peer
65
+ // shares any file with our footprint, we're in a disjoint component of the
66
+ // resource graph and cannot be part of any cycle, so sorted-order acquisition
67
+ // buys nothing and the block is pure false-positive friction. Sharing a file
68
+ // is the necessary condition for a cycle through this agent, so this narrowing
69
+ // leaves the deadlock-prevention invariant intact for genuine contention while
70
+ // removing the dominant real-world cost — a peer editing unrelated files
71
+ // walling off every backward-order edit. (Single-agent flow can't deadlock
72
+ // with itself and never arms, since there are no other peers to contend.)
73
+ const myFootprint = new Set(myPeer?.files_touched ?? []);
74
+ myFootprint.add(req.path);
75
+ const hasContendingPeer = otherPeers.some((p) => isFresh(p.last_heartbeat) && p.files_touched.some((f) => myFootprint.has(f)));
65
76
  // Re-editing a path already in our own files_touched acquires no new lock
66
77
  // edge, so it can't create a circular wait — the ordering rule must not block
67
78
  // it. Without this exemption, an agent that edits a higher-sorting file and
@@ -70,7 +81,7 @@ export function evaluateClaim(coordRoot, req) {
70
81
  // both agent-Gibson holding README.md and agent-Ophelia holding AGENTS.md were
71
82
  // blocked re-editing those held files after touching a higher path, 2026-07-03).
72
83
  const alreadyHeld = myPeer?.files_touched.includes(req.path) ?? false;
73
- if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
84
+ if (hasContendingPeer && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
74
85
  // Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
75
86
  // a committed-clean file is a finished edit, not a held lock, so it must not
76
87
  // wall off a lower-sorted acquisition. Without this, a long session
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat-projector.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,oCAAoC;IACpC,OAAO,EAAE;QACP,cAAc,EAAE,CAAC,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAE,CAoD9D;AA8VD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3E"}
1
+ {"version":3,"file":"heartbeat-projector.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,oCAAoC;IACpC,OAAO,EAAE;QACP,cAAc,EAAE,CAAC,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAE,CAoD9D;AAsWD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3E"}
@@ -38,7 +38,7 @@ export function projectHeartbeats(coordRoot, events) {
38
38
  continue;
39
39
  perOwner[ev.instance_id] = existing ?? seed(ev, coordRoot);
40
40
  }
41
- apply(perOwner[ev.instance_id], ev);
41
+ apply(perOwner[ev.instance_id], ev, coordRoot);
42
42
  }
43
43
  const written = [];
44
44
  for (const [instance_id, hb] of Object.entries(perOwner)) {
@@ -101,7 +101,7 @@ function seed(ev, coordRoot) {
101
101
  }
102
102
  return hb;
103
103
  }
104
- function apply(hb, ev) {
104
+ function apply(hb, ev, coordRoot) {
105
105
  hb.last_heartbeat = ev.ts;
106
106
  hb.last_event_id = ev.event_id;
107
107
  hb.events_applied += 1;
@@ -238,7 +238,14 @@ function apply(hb, ev) {
238
238
  case "claim.release": {
239
239
  const path = pickStr(d, "path");
240
240
  if (path && hb.files_touched) {
241
- hb.files_touched = hb.files_touched.filter((p) => p !== path);
241
+ // files_touched holds a mix of absolute-under-coordRoot and canonical
242
+ // repo-relative entries (Edit events report absolute; release-claim
243
+ // canonicalizes to relative). Normalize both sides so a release
244
+ // subtracts regardless of form — an exact-string compare silently
245
+ // no-ops on the mismatch and the claim resurrects on the next replay.
246
+ const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
247
+ const target = norm(path);
248
+ hb.files_touched = hb.files_touched.filter((p) => norm(p) !== target);
242
249
  }
243
250
  break;
244
251
  }
@@ -26,6 +26,16 @@ export declare function stripJsonComments(input: string): string;
26
26
  * `coordRoot` is resolved via `findCoordRoot()` when not passed.
27
27
  */
28
28
  export declare function resolveBinName(coordRoot?: string | null): string;
29
+ /**
30
+ * The binName explicitly pinned in `<projectRoot>/.harnery/config.jsonc`, or
31
+ * null when absent. Unlike `resolveBinName()` this ignores `HARNERY_BIN` and
32
+ * never falls back to the default — it answers "did someone deliberately pin
33
+ * a name for THIS project?". `init` uses it so a re-run from a different host
34
+ * CLI can't silently re-stamp its own name over a committed pin (the harnery
35
+ * repo itself pins `"harn"` while living embedded in a host monorepo whose
36
+ * CLI would otherwise stamp the host's name into public, committed surfaces).
37
+ */
38
+ export declare function pinnedBinName(projectRoot: string): string | null;
29
39
  /**
30
40
  * The host's git-hook (re)install command, for the "commit guard not wired"
31
41
  * nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,SAAS,CAAC;AAgBvC,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAqCvD;AAyBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAShE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAK9E"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,SAAS,CAAC;AAgBvC,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAqCvD;AAyBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAShE;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAK9E"}
@@ -106,6 +106,19 @@ export function resolveBinName(coordRoot) {
106
106
  }
107
107
  return DEFAULT_BIN_NAME;
108
108
  }
109
+ /**
110
+ * The binName explicitly pinned in `<projectRoot>/.harnery/config.jsonc`, or
111
+ * null when absent. Unlike `resolveBinName()` this ignores `HARNERY_BIN` and
112
+ * never falls back to the default — it answers "did someone deliberately pin
113
+ * a name for THIS project?". `init` uses it so a re-run from a different host
114
+ * CLI can't silently re-stamp its own name over a committed pin (the harnery
115
+ * repo itself pins `"harn"` while living embedded in a host monorepo whose
116
+ * CLI would otherwise stamp the host's name into public, committed surfaces).
117
+ */
118
+ export function pinnedBinName(projectRoot) {
119
+ const binName = readConfig(projectRoot).binName;
120
+ return typeof binName === "string" && binName.trim() ? binName.trim() : null;
121
+ }
109
122
  /**
110
123
  * The host's git-hook (re)install command, for the "commit guard not wired"
111
124
  * nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
@@ -26,7 +26,7 @@ import { consumeSince, writeCursor } from "../agents/events/consume.js";
26
26
  import { evaluateStopHook } from "../agents/rules/stop-hook.js";
27
27
  import { projectHeartbeats } from "../agents/state/heartbeat-projector.js";
28
28
  import { shellMutationPaths } from "../agents/state/shell-mutation.js";
29
- import { captureImages, detectPresence, imageJanitor, playSound, resetSoundCounters, runTurnSummary, scratchArchive, scratchJanitor, scratchRecoveryCue, soundForEvent, syncClaudeSessions, } from "./effects/index.js";
29
+ import { captureImages, detectPresence, imageJanitor, playSound, resetSoundCounters, runSessionSyncExtension, runTurnSummary, scratchArchive, scratchJanitor, scratchRecoveryCue, soundForEvent, } from "./effects/index.js";
30
30
  import { emit } from "./events/emit.js";
31
31
  import { canonicalize } from "./guard-path.js";
32
32
  import { detectHarness } from "./harness/detect.js";
@@ -441,7 +441,7 @@ async function main() {
441
441
  // session-telemetry sync (via HARNERY_CLAUDE_SESSIONS_FORCE=1).
442
442
  if (harness === "claude-code") {
443
443
  scratchArchive(coordRoot, owner.instance_id);
444
- syncClaudeSessions(coordRoot, true);
444
+ runSessionSyncExtension(coordRoot, true);
445
445
  }
446
446
  }
447
447
  // Phase 8: SubagentStart: sync-project to create the subagent heartbeat,
@@ -528,7 +528,7 @@ async function main() {
528
528
  // CC effects: rate-limited session-telemetry sync + turn-summary Haiku
529
529
  // auto-summary.
530
530
  if (harness === "claude-code") {
531
- syncClaudeSessions(coordRoot, false);
531
+ runSessionSyncExtension(coordRoot, false);
532
532
  runTurnSummary(coordRoot, owner.instance_id, sessionId, payload?.transcript_path);
533
533
  }
534
534
  // Master-state heartbeat projection. Drains events.ndjson since the last
@@ -37,14 +37,18 @@ export declare function scratchRecoveryCue(repoRoot: string): string;
37
37
  /** Archive the ending agent's scratchpad. Fire-and-forget. */
38
38
  export declare function scratchArchive(repoRoot: string, owner: string): void;
39
39
  /**
40
- * Sync Claude Code session JSONL → BigQuery (`harn claude-sessions sync`). Lives
41
- * in the host CLI (parses Claude Code's own ~/.claude/projects/**\/*.jsonl), so harnery
42
- * shells out to the harn binary rather than importing it. Detached + unref'd so a
43
- * slow BigQuery round-trip never blocks the hook. Stop-path syncs are rate-
44
- * limited inside `harn claude-sessions sync`; SessionEnd forces via env. Caller
45
- * gates to the claude-code harness.
40
+ * Fire the optional host session-sync extension on turn stop / session end.
41
+ * harnery core has no session-telemetry sink of its own; a host that wants one
42
+ * drops an executable at
43
+ * `scripts/hooks/harness/claude_code/extensions/session-sync.sh` under the coord
44
+ * root, and core runs it detached + unref'd so a slow sink never blocks the
45
+ * hook. `force` arrives as argv $1 ("1" on session end, "0" on turn stop) so the
46
+ * host can rate-limit the stop path and force-flush on end. No-op when the
47
+ * script is absent, so a plain public install spawns nothing. Mirrors
48
+ * `runTurnSummary`'s extension-script pattern. Caller gates to the claude-code
49
+ * harness.
46
50
  */
47
- export declare function syncClaudeSessions(repoRoot: string, force: boolean): void;
51
+ export declare function runSessionSyncExtension(repoRoot: string, force: boolean): void;
48
52
  /**
49
53
  * Fire the turn-summary extension (Haiku auto-summary of the turn → heartbeat
50
54
  * `turn_summary`). Detached + unref'd; it makes an Anthropic API call and must
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/effects/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEjE;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,SAAI,GACX,IAAI,CAYN;AAED,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAW3F;AAOD,oFAAoF;AACpF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAYrD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa3D;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAYpE;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAkBzE;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GAAG,SAAS,GACjC,IAAI,CAqBN;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAa1D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAOnD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/effects/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEjE;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,SAAI,GACX,IAAI,CAYN;AAED,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAW3F;AAOD,oFAAoF;AACpF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAYrD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa3D;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAYpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAqB9E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GAAG,SAAS,GACjC,IAAI,CAqBN;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAa1D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAOnD"}
@@ -16,7 +16,6 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
16
16
  import os from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { applyDetection } from "../../../lib/presence.js";
19
- import { resolveBinName } from "../../config.js";
20
19
  export { captureImages, imageJanitor } from "./image-capture.js";
21
20
  /**
22
21
  * Play a notification sound via the cross-platform utility
@@ -111,26 +110,24 @@ export function scratchArchive(repoRoot, owner) {
111
110
  }
112
111
  }
113
112
  /**
114
- * Sync Claude Code session JSONL → BigQuery (`harn claude-sessions sync`). Lives
115
- * in the host CLI (parses Claude Code's own ~/.claude/projects/**\/*.jsonl), so harnery
116
- * shells out to the harn binary rather than importing it. Detached + unref'd so a
117
- * slow BigQuery round-trip never blocks the hook. Stop-path syncs are rate-
118
- * limited inside `harn claude-sessions sync`; SessionEnd forces via env. Caller
119
- * gates to the claude-code harness.
113
+ * Fire the optional host session-sync extension on turn stop / session end.
114
+ * harnery core has no session-telemetry sink of its own; a host that wants one
115
+ * drops an executable at
116
+ * `scripts/hooks/harness/claude_code/extensions/session-sync.sh` under the coord
117
+ * root, and core runs it detached + unref'd so a slow sink never blocks the
118
+ * hook. `force` arrives as argv $1 ("1" on session end, "0" on turn stop) so the
119
+ * host can rate-limit the stop path and force-flush on end. No-op when the
120
+ * script is absent, so a plain public install spawns nothing. Mirrors
121
+ * `runTurnSummary`'s extension-script pattern. Caller gates to the claude-code
122
+ * harness.
120
123
  */
121
- export function syncClaudeSessions(repoRoot, force) {
124
+ export function runSessionSyncExtension(repoRoot, force) {
122
125
  try {
123
- const bin = join(repoRoot, "bin", resolveBinName(repoRoot));
124
- if (!existsSync(bin))
126
+ const script = join(repoRoot, "scripts", "hooks", "harness", "claude_code", "extensions", "session-sync.sh");
127
+ if (!existsSync(script))
125
128
  return;
126
- const env = {
127
- ...process.env,
128
- HARNERY_OUTPUT_SESSION_TEE: "0",
129
- };
130
- if (force)
131
- env.HARNERY_CLAUDE_SESSIONS_FORCE = "1";
132
- const child = spawn("bash", [bin, "claude-sessions", "sync", "--quiet"], {
133
- env,
129
+ const child = spawn("bash", [script, force ? "1" : "0"], {
130
+ env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
134
131
  detached: true,
135
132
  stdio: "ignore",
136
133
  });
@@ -1 +1 @@
1
- {"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/events/emit.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,OAAO,EAEZ,KAAK,MAAM,EACZ,MAAM,aAAa,CAAC;AAQrB,MAAM,WAAW,SAAS,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK;IACvD,UAAU,EAAE,KAAK,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,KAAK,CAAC;CACb;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK,EAC1D,KAAK,EAAE,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,GAC7B,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAe7B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,IAAI,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK,EACjD,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,GAC7B,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAqC7B"}
1
+ {"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/events/emit.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,OAAO,EAEZ,KAAK,MAAM,EACZ,MAAM,aAAa,CAAC;AAQrB,MAAM,WAAW,SAAS,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK;IACvD,UAAU,EAAE,KAAK,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,KAAK,CAAC;CACb;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK,EAC1D,KAAK,EAAE,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,GAC7B,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAe7B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,IAAI,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK,EACjD,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,GAC7B,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAyC7B"}
@@ -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.js";
3
4
  import { SCHEMA_VERSION, } from "./schema.js";
4
5
  import { ulid } from "./ulid.js";
5
6
  const LOCK_FILE = ".harnery/events.ndjson.lock";
@@ -40,6 +41,9 @@ export function emit(coordRoot, input) {
40
41
  const envelope = buildEnvelope(input);
41
42
  const streamPath = join(coordRoot, STREAM_FILE);
42
43
  const lockPath = join(coordRoot, LOCK_FILE);
44
+ // Size-triggered rotation: roll the active file to a dated archive when it
45
+ // crosses the cap, so this append lands in a bounded fresh file. Fail-soft.
46
+ maybeRotateEventStream(coordRoot);
43
47
  ensureDir(dirname(streamPath));
44
48
  ensureFile(lockPath);
45
49
  let line = `${JSON.stringify(envelope)}\n`;
@@ -0,0 +1,43 @@
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
+ /** Archive glob prefix. Readers enumerate `events-*.ndjson` newest-first.
30
+ * The pre-existing manual `events-legacy.ndjson` matches and is picked up for
31
+ * free. Kept in sync with the reader-side pattern in web/lib/coord-reader.ts. */
32
+ export declare const ARCHIVE_PREFIX = "events-";
33
+ export declare const ARCHIVE_SUFFIX = ".ndjson";
34
+ /**
35
+ * Roll `events.ndjson` to a dated archive when it exceeds the byte cap. Cheap
36
+ * no-op (a single `statSync`) below the cap. Fail-soft: any error is swallowed
37
+ * so a rotation problem can never break the append that triggered it.
38
+ *
39
+ * `nowMs` is injectable for the stale-lock check in tests; production leaves it
40
+ * unset (the roll date always comes from the file's mtime, never `nowMs`).
41
+ */
42
+ export declare function maybeRotateEventStream(coordRoot: string, nowMs?: number): boolean;
43
+ //# sourceMappingURL=rotate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rotate.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/events/rotate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAyBH;;iFAEiF;AACjF,eAAO,MAAM,cAAc,YAAY,CAAC;AACxC,eAAO,MAAM,cAAc,YAAY,CAAC;AA6BxC;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAmCjF"}
@@ -0,0 +1,142 @@
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
+ import { closeSync, existsSync, mkdirSync, openSync, renameSync, statSync, unlinkSync, } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { coordEnv } from "../../../lib/env.js";
32
+ const STREAM_FILE = ".harnery/events.ndjson";
33
+ const ROLL_LOCK_FILE = ".harnery/events.ndjson.roll.lock";
34
+ /** Default active-file cap: 256 MiB — half of V8's ~512MB string cliff, so even
35
+ * a reader that loads the whole active file whole stays clear of the limit. */
36
+ const DEFAULT_ROLL_BYTES = 256 * 1024 * 1024;
37
+ /** A roll-lock older than this is treated as abandoned (a crashed roller) and
38
+ * stolen. Rolls are sub-millisecond, so 60s is orders of magnitude of slack. */
39
+ const STALE_LOCK_MS = 60_000;
40
+ /** Archive glob prefix. Readers enumerate `events-*.ndjson` newest-first.
41
+ * The pre-existing manual `events-legacy.ndjson` matches and is picked up for
42
+ * free. Kept in sync with the reader-side pattern in web/lib/coord-reader.ts. */
43
+ export const ARCHIVE_PREFIX = "events-";
44
+ export const ARCHIVE_SUFFIX = ".ndjson";
45
+ function resolveRollBytes() {
46
+ const env = coordEnv("EVENTS_ROLL_BYTES");
47
+ const n = env ? Number(env) : Number.NaN;
48
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_ROLL_BYTES;
49
+ }
50
+ /** `YYYY-MM-DD` (UTC) from a millisecond timestamp. Uses the file's own mtime
51
+ * rather than `Date.now()` so it's deterministic and independent of the harness
52
+ * runtime's clock surface. */
53
+ function utcDateStamp(mtimeMs) {
54
+ const d = new Date(mtimeMs);
55
+ const y = d.getUTCFullYear();
56
+ const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
57
+ const day = String(d.getUTCDate()).padStart(2, "0");
58
+ return `${y}-${mo}-${day}`;
59
+ }
60
+ /** First non-colliding `events-<stamp>[.N].ndjson` path under `coordDir`. */
61
+ function archivePathFor(coordDir, stamp) {
62
+ const base = join(coordDir, `${ARCHIVE_PREFIX}${stamp}${ARCHIVE_SUFFIX}`);
63
+ if (!existsSync(base))
64
+ return base;
65
+ for (let n = 1;; n++) {
66
+ const cand = join(coordDir, `${ARCHIVE_PREFIX}${stamp}.${n}${ARCHIVE_SUFFIX}`);
67
+ if (!existsSync(cand))
68
+ return cand;
69
+ }
70
+ }
71
+ /**
72
+ * Roll `events.ndjson` to a dated archive when it exceeds the byte cap. Cheap
73
+ * no-op (a single `statSync`) below the cap. Fail-soft: any error is swallowed
74
+ * so a rotation problem can never break the append that triggered it.
75
+ *
76
+ * `nowMs` is injectable for the stale-lock check in tests; production leaves it
77
+ * unset (the roll date always comes from the file's mtime, never `nowMs`).
78
+ */
79
+ export function maybeRotateEventStream(coordRoot, nowMs) {
80
+ try {
81
+ const streamPath = join(coordRoot, STREAM_FILE);
82
+ if (!existsSync(streamPath))
83
+ return false;
84
+ const cap = resolveRollBytes();
85
+ let st = statSync(streamPath);
86
+ if (st.size < cap)
87
+ return false;
88
+ const coordDir = dirname(streamPath);
89
+ const lockPath = join(coordRoot, ROLL_LOCK_FILE);
90
+ mkdirSync(coordDir, { recursive: true });
91
+ if (!acquireRollLock(lockPath, nowMs))
92
+ return false; // another roller active
93
+ try {
94
+ // Re-check under the lock: a concurrent process may have just rolled.
95
+ if (!existsSync(streamPath))
96
+ return false;
97
+ st = statSync(streamPath);
98
+ if (st.size < cap)
99
+ return false;
100
+ const archive = archivePathFor(coordDir, utcDateStamp(st.mtimeMs));
101
+ renameSync(streamPath, archive);
102
+ // Recreate an empty active file so the very next append (and any reader's
103
+ // existsSync) sees a valid, present stream.
104
+ closeSync(openSync(streamPath, "a"));
105
+ return true;
106
+ }
107
+ finally {
108
+ try {
109
+ unlinkSync(lockPath);
110
+ }
111
+ catch {
112
+ /* best-effort */
113
+ }
114
+ }
115
+ }
116
+ catch {
117
+ return false; // fail-soft: never break the caller's append
118
+ }
119
+ }
120
+ /** Create the roll-lock with O_EXCL. Returns true on acquisition. On collision,
121
+ * steals a stale lock (crashed roller) once, else returns false. */
122
+ function acquireRollLock(lockPath, nowMs) {
123
+ try {
124
+ closeSync(openSync(lockPath, "wx"));
125
+ return true;
126
+ }
127
+ catch {
128
+ // Exists: steal if abandoned.
129
+ try {
130
+ const age = (nowMs ?? Date.now()) - statSync(lockPath).mtimeMs;
131
+ if (age > STALE_LOCK_MS) {
132
+ unlinkSync(lockPath);
133
+ closeSync(openSync(lockPath, "wx"));
134
+ return true;
135
+ }
136
+ }
137
+ catch {
138
+ /* lost the steal race — someone else holds it */
139
+ }
140
+ return false;
141
+ }
142
+ }
@@ -30,6 +30,12 @@ export interface HarnessSpec {
30
30
  entryShape: HookEntryShape;
31
31
  /** When set, ensure this top-level `version` key in the file (Cursor requires `1`). */
32
32
  rootVersion?: number;
33
+ /** Harness-owned entries from older specs that `init` should remove during migration. */
34
+ legacyEvents?: HookEvent[];
35
+ /** Strict top-level settings keys accepted by this harness, when its parser is closed. */
36
+ allowedTopLevelKeys?: string[];
37
+ /** Strict hook event keys accepted by this harness, including events harnery does not wire. */
38
+ allowedEventKeys?: string[];
33
39
  }
34
40
  /** Claude Code: `.claude/settings.json`. */
35
41
  export declare const CLAUDE_CODE_EVENTS: HookEvent[];
@@ -38,8 +44,12 @@ export declare const CLAUDE_CODE_EVENTS: HookEvent[];
38
44
  * no `StopFailure` event, but a distinct `beforeShellExecution` (shell-mutation warn).
39
45
  */
40
46
  export declare const CURSOR_EVENTS: HookEvent[];
41
- /** Codex: `.codex/hooks.json`. PascalCase keys + the same entry shape as Claude Code. */
47
+ /** Codex events that harnery uses from the current native lifecycle surface. */
42
48
  export declare const CODEX_EVENTS: HookEvent[];
49
+ /** Entries written by harnery before Codex adopted a strict native hook schema. */
50
+ export declare const LEGACY_CODEX_EVENTS: HookEvent[];
51
+ /** Every hook key accepted by Codex 0.144, including events harnery does not consume. */
52
+ export declare const CODEX_ALLOWED_EVENT_KEYS: string[];
43
53
  /** Every supported harness, fully wireable by `harn init`. */
44
54
  export declare const HARNESS_SPECS: Record<HarnessId, HarnessSpec>;
45
55
  //# sourceMappingURL=events.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/harness/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE3D,MAAM,WAAW,SAAS;IACxB,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD,MAAM,WAAW,WAAW;IAC1B,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,wDAAwD;IACxD,UAAU,EAAE,cAAc,CAAC;IAC3B,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,4CAA4C;AAC5C,eAAO,MAAM,kBAAkB,EAAE,SAAS,EAWzC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,SAAS,EAWpC,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,YAAY,EAAE,SAAS,EAWnC,CAAC;AAEF,8DAA8D;AAC9D,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,SAAS,EAAE,WAAW,CAiBxD,CAAC"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/harness/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE3D,MAAM,WAAW,SAAS;IACxB,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD,MAAM,WAAW,WAAW;IAC1B,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,wDAAwD;IACxD,UAAU,EAAE,cAAc,CAAC;IAC3B,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yFAAyF;IACzF,YAAY,CAAC,EAAE,SAAS,EAAE,CAAC;IAC3B,0FAA0F;IAC1F,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,+FAA+F;IAC/F,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,4CAA4C;AAC5C,eAAO,MAAM,kBAAkB,EAAE,SAAS,EAWzC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,SAAS,EAWpC,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,YAAY,EAAE,SAAS,EAQnC,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,mBAAmB,EAAE,SAAS,EAI1C,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,wBAAwB,UAWpC,CAAC;AAEF,8DAA8D;AAC9D,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,SAAS,EAAE,WAAW,CAoBxD,CAAC"}