harnery 0.31.4 → 0.31.6
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/dist/core/agents/coord-client.d.ts +4 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +22 -5
- package/dist/core/agents/rules/stop-hook.d.ts +16 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +88 -6
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +17 -2
- package/dist/core/hooks/adapter/parse.d.ts +1 -1
- package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
- package/dist/core/hooks/adapter/parse.js +22 -5
- package/package.json +1 -1
- package/src/core/agents/coord-client.ts +28 -4
- package/src/core/agents/rules/stop-hook.ts +95 -6
- package/src/core/hooks/adapter/output.ts +17 -4
- package/src/core/hooks/adapter/parse.ts +24 -5
|
@@ -162,7 +162,10 @@ export declare function resolveOwnerByPidmap(root: string): {
|
|
|
162
162
|
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
163
163
|
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
164
164
|
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
165
|
-
* doesn't resolve.
|
|
165
|
+
* doesn't resolve. When legacy Cursor Glass state contains both the canonical
|
|
166
|
+
* bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
|
|
167
|
+
* named heartbeat, then the newest heartbeat, with instance id as a stable
|
|
168
|
+
* final tie-breaker. Filesystem enumeration order never decides identity.
|
|
166
169
|
*
|
|
167
170
|
* Exported for unit testing with an injectable root.
|
|
168
171
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAWH;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAWhE;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,MAAM,CAI5E;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAOhF;AAED,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yIAAyI;IACzI,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CA+B7E;AAED,iEAAiE;AACjE,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE7D;AAeD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGtD;AAiED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,mFAAmF;AACnF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2CxD;AAgCD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAwCA;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG;IAClD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,QAAQ,GAAG,iBAAiB,GAAG,MAAM,CAAC;CAC/C,CAyBA;AA0DD
|
|
1
|
+
{"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -482,7 +482,10 @@ function shouldPreferSessionEnv() {
|
|
|
482
482
|
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
483
483
|
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
484
484
|
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
485
|
-
* doesn't resolve.
|
|
485
|
+
* doesn't resolve. When legacy Cursor Glass state contains both the canonical
|
|
486
|
+
* bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
|
|
487
|
+
* named heartbeat, then the newest heartbeat, with instance id as a stable
|
|
488
|
+
* final tie-breaker. Filesystem enumeration order never decides identity.
|
|
486
489
|
*
|
|
487
490
|
* Exported for unit testing with an injectable root.
|
|
488
491
|
*/
|
|
@@ -502,24 +505,38 @@ export function resolveOwnerBySessionEnv(root) {
|
|
|
502
505
|
catch {
|
|
503
506
|
return null;
|
|
504
507
|
}
|
|
508
|
+
const matches = [];
|
|
505
509
|
for (const file of files) {
|
|
506
510
|
if (!file.endsWith(".json"))
|
|
507
511
|
continue;
|
|
508
512
|
try {
|
|
509
513
|
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
510
|
-
if (!parsed
|
|
514
|
+
if (!parsed)
|
|
515
|
+
continue;
|
|
516
|
+
const sessionPreference = sessionIds.indexOf(parsed.session_id);
|
|
517
|
+
if (sessionPreference === -1)
|
|
511
518
|
continue;
|
|
512
519
|
if (typeof parsed.instance_id !== "string")
|
|
513
520
|
continue;
|
|
514
521
|
const ts = Date.parse(parsed.last_heartbeat);
|
|
515
|
-
if (Number.isFinite(ts)
|
|
516
|
-
|
|
522
|
+
if (!Number.isFinite(ts) || ts < cutoffMs)
|
|
523
|
+
continue;
|
|
524
|
+
matches.push({
|
|
525
|
+
instanceId: parsed.instance_id,
|
|
526
|
+
sessionPreference,
|
|
527
|
+
hasName: typeof parsed.name === "string" && parsed.name.trim().length > 0,
|
|
528
|
+
lastHeartbeatMs: ts,
|
|
529
|
+
});
|
|
517
530
|
}
|
|
518
531
|
catch {
|
|
519
532
|
// skip malformed
|
|
520
533
|
}
|
|
521
534
|
}
|
|
522
|
-
|
|
535
|
+
matches.sort((a, b) => a.sessionPreference - b.sessionPreference ||
|
|
536
|
+
Number(b.hasName) - Number(a.hasName) ||
|
|
537
|
+
b.lastHeartbeatMs - a.lastHeartbeatMs ||
|
|
538
|
+
a.instanceId.localeCompare(b.instanceId));
|
|
539
|
+
return matches[0]?.instanceId ?? null;
|
|
523
540
|
}
|
|
524
541
|
/**
|
|
525
542
|
* Return the instance_id of the sole live agent in this coord root, or null
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* Pure-prose-turn exemption: when zero `tool.pre_use` events fire in
|
|
16
16
|
* the current turn, rules 1/3 and 3/3 do not apply. Rule 2/3 still applies
|
|
17
17
|
* as a user-visible mobile cue.
|
|
18
|
+
*
|
|
19
|
+
* Remediation continuity: an adapter whose Stop channel re-prompts by
|
|
20
|
+
* submitting a new user message (Cursor's `followup_message`) opens a fresh
|
|
21
|
+
* turn to repair the previous one. Such a turn inherits the window of the turn
|
|
22
|
+
* it repairs, so ritual events accumulate instead of alternating. See
|
|
23
|
+
* `resolveTurnStartMs`.
|
|
18
24
|
*/
|
|
19
25
|
export type { VerdictResult } from "./verdict.js";
|
|
20
26
|
import type { VerdictResult } from "./verdict.js";
|
|
@@ -43,6 +49,16 @@ export interface StopHookRequest {
|
|
|
43
49
|
* event capture on: the child stays visible to peers and the coord layer. */
|
|
44
50
|
workflow_child?: boolean;
|
|
45
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Machine marker that opens a Stop-remediation message. Written by
|
|
54
|
+
* `hooks/adapter/output.ts` on the adapters whose Stop channel is a new user
|
|
55
|
+
* message, and read back here off `user_prompt.submit.data.prompt_text` to
|
|
56
|
+
* recognize a turn Harnery itself opened.
|
|
57
|
+
*
|
|
58
|
+
* Leading position is deliberate: `prompt_text` is clamped when recorded, so a
|
|
59
|
+
* trailing marker could be truncated away on a long reason.
|
|
60
|
+
*/
|
|
61
|
+
export declare const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
|
|
46
62
|
/**
|
|
47
63
|
* Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
|
|
48
64
|
* when all three pass). Soft-fails open when the event stream isn't
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAclD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;4EACwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;iFAM6E;IAC7E,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AA+BD;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,8BAA8B,CAAC;AAwEnE;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CAqHvF"}
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* Pure-prose-turn exemption: when zero `tool.pre_use` events fire in
|
|
16
16
|
* the current turn, rules 1/3 and 3/3 do not apply. Rule 2/3 still applies
|
|
17
17
|
* as a user-visible mobile cue.
|
|
18
|
+
*
|
|
19
|
+
* Remediation continuity: an adapter whose Stop channel re-prompts by
|
|
20
|
+
* submitting a new user message (Cursor's `followup_message`) opens a fresh
|
|
21
|
+
* turn to repair the previous one. Such a turn inherits the window of the turn
|
|
22
|
+
* it repairs, so ritual events accumulate instead of alternating. See
|
|
23
|
+
* `resolveTurnStartMs`.
|
|
18
24
|
*/
|
|
19
25
|
import { existsSync, readFileSync } from "node:fs";
|
|
20
26
|
import { join } from "node:path";
|
|
@@ -23,6 +29,80 @@ function ackSignalFor(adapter) {
|
|
|
23
29
|
return adapter === "cursor" ? "status_checked" : "status_box_present";
|
|
24
30
|
}
|
|
25
31
|
const RECENT_EVENT_WINDOW_LINES = 5_000;
|
|
32
|
+
/**
|
|
33
|
+
* Machine marker that opens a Stop-remediation message. Written by
|
|
34
|
+
* `hooks/adapter/output.ts` on the adapters whose Stop channel is a new user
|
|
35
|
+
* message, and read back here off `user_prompt.submit.data.prompt_text` to
|
|
36
|
+
* recognize a turn Harnery itself opened.
|
|
37
|
+
*
|
|
38
|
+
* Leading position is deliberate: `prompt_text` is clamped when recorded, so a
|
|
39
|
+
* trailing marker could be truncated away on a long reason.
|
|
40
|
+
*/
|
|
41
|
+
export const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
|
|
42
|
+
/**
|
|
43
|
+
* The pre-marker message shape, still recognized on purpose.
|
|
44
|
+
*
|
|
45
|
+
* A followup already sitting in the stream was written by whichever build was
|
|
46
|
+
* running when it fired. An agent whose session spans an upgrade would otherwise
|
|
47
|
+
* keep alternating until the first new-format followup appeared, which is the
|
|
48
|
+
* exact failure this change removes. Recognizing the older trailing form closes
|
|
49
|
+
* that rollout window at no cost: it only ever appears in a `prompt_text`
|
|
50
|
+
* because Harnery put it there.
|
|
51
|
+
*/
|
|
52
|
+
const STOP_REMEDIATION_LEGACY_MARKER = "[agent-hook stop]: rule=";
|
|
53
|
+
/** How far back the remediation walk may travel. Adapters cap their own
|
|
54
|
+
* followup chains (Cursor's `loop_limit`), and each prompt can appear once per
|
|
55
|
+
* observing adapter, so this only guards a pathological stream. */
|
|
56
|
+
const MAX_REMEDIATION_WALK_BACK = 16;
|
|
57
|
+
function isStopRemediationPrompt(event) {
|
|
58
|
+
const text = event.data.prompt_text;
|
|
59
|
+
if (typeof text !== "string")
|
|
60
|
+
return false;
|
|
61
|
+
return text.includes(STOP_REMEDIATION_MARKER) || text.includes(STOP_REMEDIATION_LEGACY_MARKER);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Start of the window this Stop verdict judges.
|
|
65
|
+
*
|
|
66
|
+
* Normally the most recent `user_prompt.submit`. The exception is the
|
|
67
|
+
* remediation chain: Cursor does not continue a turn on Stop, it auto-submits
|
|
68
|
+
* our message as a new user turn (`followup_message`). Scoping the verdict to
|
|
69
|
+
* that new turn alone makes the ritual unsatisfiable in one pass, because
|
|
70
|
+
* remediation itself runs a tool, so the repair turn needs every rule again
|
|
71
|
+
* while the signal from the previous turn sits just outside the window. Running
|
|
72
|
+
* `set-task` then clears rule 3/3 and fails rule 1/3, running `status` clears
|
|
73
|
+
* 1/3 and fails 3/3, and the pair alternates until the adapter's loop cap.
|
|
74
|
+
*
|
|
75
|
+
* Anchoring the window at the last prompt Harnery did not author makes progress
|
|
76
|
+
* monotonic: each repair adds a signal and none are lost, so the chain ends.
|
|
77
|
+
* A genuine human prompt always anchors a fresh window, so this cannot be used
|
|
78
|
+
* to inherit ritual credit across real turns.
|
|
79
|
+
*/
|
|
80
|
+
function resolveTurnStartMs(ownerEvents, nowMs, fallbackMs) {
|
|
81
|
+
// Honor the cutoff: `now_ms` marks what is relevant yet, so a prompt after it
|
|
82
|
+
// cannot anchor this turn. In production the cutoff is the clock and nothing
|
|
83
|
+
// follows it, but a replay over recorded history passes a past cutoff, and an
|
|
84
|
+
// unclamped search would anchor on a later turn and hand back an empty window.
|
|
85
|
+
const prompts = ownerEvents.filter((e) => {
|
|
86
|
+
if (e.event_type !== "user_prompt.submit")
|
|
87
|
+
return false;
|
|
88
|
+
const t = Date.parse(e.ts);
|
|
89
|
+
return Number.isFinite(t) && t <= nowMs;
|
|
90
|
+
});
|
|
91
|
+
if (prompts.length === 0)
|
|
92
|
+
return fallbackMs;
|
|
93
|
+
let index = prompts.length - 1;
|
|
94
|
+
let hops = 0;
|
|
95
|
+
while (index > 0 && hops < MAX_REMEDIATION_WALK_BACK) {
|
|
96
|
+
const prompt = prompts[index];
|
|
97
|
+
if (!prompt || !isStopRemediationPrompt(prompt))
|
|
98
|
+
break;
|
|
99
|
+
index -= 1;
|
|
100
|
+
hops += 1;
|
|
101
|
+
}
|
|
102
|
+
const anchor = prompts[index];
|
|
103
|
+
const parsed = anchor ? Date.parse(anchor.ts) : Number.NaN;
|
|
104
|
+
return Number.isFinite(parsed) ? parsed : fallbackMs;
|
|
105
|
+
}
|
|
26
106
|
/**
|
|
27
107
|
* Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
|
|
28
108
|
* when all three pass). Soft-fails open when the event stream isn't
|
|
@@ -81,17 +161,19 @@ export function evaluateStopHook(coordRoot, req) {
|
|
|
81
161
|
};
|
|
82
162
|
}
|
|
83
163
|
// Turn window: from the most recent user_prompt.submit (this owner) up to
|
|
84
|
-
// either the explicit now_ms or the last event we see
|
|
164
|
+
// either the explicit now_ms or the last event we see, walking back over any
|
|
165
|
+
// Harnery-authored Stop-remediation prompts so a repair turn is judged
|
|
166
|
+
// together with the turn it repairs (see `resolveTurnStartMs`). Fall back to a
|
|
85
167
|
// 5-minute window when no user_prompt.submit is in scope (fresh wiring,
|
|
86
168
|
// out-of-window prompt, etc.) so a stale stream doesn't silently pass.
|
|
87
169
|
const nowMs = req.now_ms ?? Date.now();
|
|
88
|
-
const
|
|
89
|
-
.
|
|
90
|
-
|
|
91
|
-
const
|
|
170
|
+
const startMs = req.turn_window
|
|
171
|
+
? req.turn_window.start_ms
|
|
172
|
+
: resolveTurnStartMs(ownerEvents, nowMs, nowMs - 5 * 60 * 1000);
|
|
173
|
+
const endMs = req.turn_window ? req.turn_window.end_ms : nowMs;
|
|
92
174
|
const inTurn = ownerEvents.filter((e) => {
|
|
93
175
|
const t = Date.parse(e.ts);
|
|
94
|
-
return Number.isFinite(t) && t >= startMs && t <=
|
|
176
|
+
return Number.isFinite(t) && t >= startMs && t <= endMs;
|
|
95
177
|
});
|
|
96
178
|
const toolPreUseInTurn = inTurn.some((e) => e.event_type === "tool.pre_use");
|
|
97
179
|
const statusChecked = inTurn.some((e) => e.event_type === "state.status_checked");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/output.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;
|
|
1
|
+
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/output.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG,kBAAkB,GAAG,eAAe,CAAC;AAEhF,+EAA+E;AAC/E,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAIpF;AAED,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAI/D;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC,GAAG,CAAC,CAuBjG"}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* Every helper writes to process.stdout + newline-terminates so callers can
|
|
18
18
|
* fire-and-forget. Empty text → no-op (no JSON written).
|
|
19
19
|
*/
|
|
20
|
+
import { STOP_REMEDIATION_MARKER } from "../../agents/rules/stop-hook.js";
|
|
21
|
+
import { resolveBinName } from "../../config.js";
|
|
20
22
|
/** Emit a context-injection (peer table, wiring check, council pending, …). */
|
|
21
23
|
export function emitContext(adapter, event, text) {
|
|
22
24
|
if (!text || text.length === 0)
|
|
@@ -56,12 +58,25 @@ export function emitDeny(adapter, reason) {
|
|
|
56
58
|
export function emitStopBlock(adapter, verdict) {
|
|
57
59
|
if (adapter === "codex")
|
|
58
60
|
return 0;
|
|
59
|
-
const
|
|
61
|
+
const reason = verdict.reason ?? "End-of-turn coordination ritual incomplete.";
|
|
60
62
|
if (adapter === "cursor") {
|
|
63
|
+
// The marker leads the message so the Stop verdict can recognize the turn
|
|
64
|
+
// Cursor opens from it (`STOP_REMEDIATION_MARKER`), and so the operator can
|
|
65
|
+
// see in chat that the message came from Harnery rather than from them.
|
|
66
|
+
// Both commands are named because one message that repairs the whole ritual
|
|
67
|
+
// ends the chain in a single followup; the failing rule is context.
|
|
68
|
+
const bin = resolveBinName();
|
|
69
|
+
const message = [
|
|
70
|
+
`${STOP_REMEDIATION_MARKER} rule=${verdict.rule}]`,
|
|
71
|
+
reason,
|
|
72
|
+
`Repair the ritual in this turn: run \`${bin} agents set-task "<short focus>"\` and then \`${bin} agents status\` as your last tool call.`,
|
|
73
|
+
].join("\n");
|
|
61
74
|
process.stdout.write(`${JSON.stringify({ followup_message: message })}\n`);
|
|
62
75
|
return 0;
|
|
63
76
|
}
|
|
64
|
-
|
|
77
|
+
// Claude Code continues the SAME turn on exit 2, so the single failing rule is
|
|
78
|
+
// both accurate and sufficient there. Left unchanged deliberately.
|
|
79
|
+
process.stderr.write(`${reason}\n[agent-hook stop]: rule=${verdict.rule}\n`);
|
|
65
80
|
return 2;
|
|
66
81
|
}
|
|
67
82
|
function buildContextJson(adapter, event, text) {
|
|
@@ -40,7 +40,7 @@ export interface ParsedPayload {
|
|
|
40
40
|
* Parse the raw stdin payload string for any adapter. Returns null when JSON
|
|
41
41
|
* parse fails (Cursor occasionally fires hooks with no payload).
|
|
42
42
|
*/
|
|
43
|
-
export declare function parsePayload(raw: string,
|
|
43
|
+
export declare function parsePayload(raw: string, adapter: Adapter): ParsedPayload | null;
|
|
44
44
|
/**
|
|
45
45
|
* Pull the bash command string out of a Bash/Shell tool_input. Returns
|
|
46
46
|
* undefined for non-shell tools.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CA+ChF;AAQD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,SAAS,EAAE,OAAO,GACjB,MAAM,GAAG,SAAS,CASpB;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAO7E;AAmBD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAChB;IAAE,UAAU,EAAE,mBAAmB,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAiCjE;AAED,MAAM,MAAM,mBAAmB,GAC3B,eAAe,GACf,aAAa,GACb,oBAAoB,GACpB,WAAW,GACX,gBAAgB,GAChB,eAAe,GACf,cAAc,GACd,eAAe,GACf,uBAAuB,GACvB,4BAA4B,GAC5B,8BAA8B,CAAC"}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Parse the raw stdin payload string for any adapter. Returns null when JSON
|
|
14
14
|
* parse fails (Cursor occasionally fires hooks with no payload).
|
|
15
15
|
*/
|
|
16
|
-
export function parsePayload(raw,
|
|
16
|
+
export function parsePayload(raw, adapter) {
|
|
17
17
|
if (!raw || raw.trim().length === 0)
|
|
18
18
|
return null;
|
|
19
19
|
let json;
|
|
@@ -23,13 +23,24 @@ export function parsePayload(raw, _adapter) {
|
|
|
23
23
|
catch {
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
|
+
const sessionId = normalizeSessionId(adapter, pickStr(json, "session_id"));
|
|
27
|
+
const conversationId = normalizeSessionId(adapter, pickStr(json, "conversation_id"));
|
|
28
|
+
const parentSessionId = normalizeSessionId(adapter, pickStr(json, "parent_session_id"));
|
|
29
|
+
const normalizedRaw = adapter === "cursor"
|
|
30
|
+
? {
|
|
31
|
+
...json,
|
|
32
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
33
|
+
...(conversationId ? { conversation_id: conversationId } : {}),
|
|
34
|
+
...(parentSessionId ? { parent_session_id: parentSessionId } : {}),
|
|
35
|
+
}
|
|
36
|
+
: json;
|
|
26
37
|
return {
|
|
27
38
|
hook_event_name: pickStr(json, "hook_event_name"),
|
|
28
|
-
session_id:
|
|
39
|
+
session_id: sessionId,
|
|
29
40
|
agent_id: pickStr(json, "agent_id"),
|
|
30
41
|
subagent_id: pickStr(json, "subagent_id"),
|
|
31
|
-
conversation_id:
|
|
32
|
-
parent_session_id:
|
|
42
|
+
conversation_id: conversationId,
|
|
43
|
+
parent_session_id: parentSessionId,
|
|
33
44
|
turn_id: pickStr(json, "turn_id"),
|
|
34
45
|
parent_turn_id: pickStr(json, "parent_turn_id"),
|
|
35
46
|
transcript_path: pickStr(json, "transcript_path"),
|
|
@@ -46,9 +57,15 @@ export function parsePayload(raw, _adapter) {
|
|
|
46
57
|
clean_exit: pickBool(json, "clean_exit"),
|
|
47
58
|
exit_status: pickStr(json, "exit_status"),
|
|
48
59
|
reason: pickStr(json, "reason"),
|
|
49
|
-
raw:
|
|
60
|
+
raw: normalizedRaw,
|
|
50
61
|
};
|
|
51
62
|
}
|
|
63
|
+
/** Cursor Glass prefixes conversation ids with `bc-`; coordination uses the bare id everywhere. */
|
|
64
|
+
function normalizeSessionId(adapter, value) {
|
|
65
|
+
if (adapter !== "cursor" || !value?.startsWith("bc-") || value.length <= 3)
|
|
66
|
+
return value;
|
|
67
|
+
return value.slice(3);
|
|
68
|
+
}
|
|
52
69
|
/**
|
|
53
70
|
* Pull the bash command string out of a Bash/Shell tool_input. Returns
|
|
54
71
|
* undefined for non-shell tools.
|
package/package.json
CHANGED
|
@@ -528,7 +528,10 @@ function shouldPreferSessionEnv(): boolean {
|
|
|
528
528
|
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
529
529
|
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
530
530
|
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
531
|
-
* doesn't resolve.
|
|
531
|
+
* doesn't resolve. When legacy Cursor Glass state contains both the canonical
|
|
532
|
+
* bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
|
|
533
|
+
* named heartbeat, then the newest heartbeat, with instance id as a stable
|
|
534
|
+
* final tie-breaker. Filesystem enumeration order never decides identity.
|
|
532
535
|
*
|
|
533
536
|
* Exported for unit testing with an injectable root.
|
|
534
537
|
*/
|
|
@@ -546,19 +549,40 @@ export function resolveOwnerBySessionEnv(root: string): string | null {
|
|
|
546
549
|
} catch {
|
|
547
550
|
return null;
|
|
548
551
|
}
|
|
552
|
+
const matches: Array<{
|
|
553
|
+
instanceId: string;
|
|
554
|
+
sessionPreference: number;
|
|
555
|
+
hasName: boolean;
|
|
556
|
+
lastHeartbeatMs: number;
|
|
557
|
+
}> = [];
|
|
549
558
|
for (const file of files) {
|
|
550
559
|
if (!file.endsWith(".json")) continue;
|
|
551
560
|
try {
|
|
552
561
|
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
553
|
-
if (!parsed
|
|
562
|
+
if (!parsed) continue;
|
|
563
|
+
const sessionPreference = sessionIds.indexOf(parsed.session_id);
|
|
564
|
+
if (sessionPreference === -1) continue;
|
|
554
565
|
if (typeof parsed.instance_id !== "string") continue;
|
|
555
566
|
const ts = Date.parse(parsed.last_heartbeat);
|
|
556
|
-
if (Number.isFinite(ts)
|
|
567
|
+
if (!Number.isFinite(ts) || ts < cutoffMs) continue;
|
|
568
|
+
matches.push({
|
|
569
|
+
instanceId: parsed.instance_id,
|
|
570
|
+
sessionPreference,
|
|
571
|
+
hasName: typeof parsed.name === "string" && parsed.name.trim().length > 0,
|
|
572
|
+
lastHeartbeatMs: ts,
|
|
573
|
+
});
|
|
557
574
|
} catch {
|
|
558
575
|
// skip malformed
|
|
559
576
|
}
|
|
560
577
|
}
|
|
561
|
-
|
|
578
|
+
matches.sort(
|
|
579
|
+
(a, b) =>
|
|
580
|
+
a.sessionPreference - b.sessionPreference ||
|
|
581
|
+
Number(b.hasName) - Number(a.hasName) ||
|
|
582
|
+
b.lastHeartbeatMs - a.lastHeartbeatMs ||
|
|
583
|
+
a.instanceId.localeCompare(b.instanceId),
|
|
584
|
+
);
|
|
585
|
+
return matches[0]?.instanceId ?? null;
|
|
562
586
|
}
|
|
563
587
|
|
|
564
588
|
/**
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* Pure-prose-turn exemption: when zero `tool.pre_use` events fire in
|
|
16
16
|
* the current turn, rules 1/3 and 3/3 do not apply. Rule 2/3 still applies
|
|
17
17
|
* as a user-visible mobile cue.
|
|
18
|
+
*
|
|
19
|
+
* Remediation continuity: an adapter whose Stop channel re-prompts by
|
|
20
|
+
* submitting a new user message (Cursor's `followup_message`) opens a fresh
|
|
21
|
+
* turn to repair the previous one. Such a turn inherits the window of the turn
|
|
22
|
+
* it repairs, so ritual events accumulate instead of alternating. See
|
|
23
|
+
* `resolveTurnStartMs`.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -89,6 +95,87 @@ function ackSignalFor(adapter?: string): AckSignal {
|
|
|
89
95
|
|
|
90
96
|
const RECENT_EVENT_WINDOW_LINES = 5_000;
|
|
91
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Machine marker that opens a Stop-remediation message. Written by
|
|
100
|
+
* `hooks/adapter/output.ts` on the adapters whose Stop channel is a new user
|
|
101
|
+
* message, and read back here off `user_prompt.submit.data.prompt_text` to
|
|
102
|
+
* recognize a turn Harnery itself opened.
|
|
103
|
+
*
|
|
104
|
+
* Leading position is deliberate: `prompt_text` is clamped when recorded, so a
|
|
105
|
+
* trailing marker could be truncated away on a long reason.
|
|
106
|
+
*/
|
|
107
|
+
export const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The pre-marker message shape, still recognized on purpose.
|
|
111
|
+
*
|
|
112
|
+
* A followup already sitting in the stream was written by whichever build was
|
|
113
|
+
* running when it fired. An agent whose session spans an upgrade would otherwise
|
|
114
|
+
* keep alternating until the first new-format followup appeared, which is the
|
|
115
|
+
* exact failure this change removes. Recognizing the older trailing form closes
|
|
116
|
+
* that rollout window at no cost: it only ever appears in a `prompt_text`
|
|
117
|
+
* because Harnery put it there.
|
|
118
|
+
*/
|
|
119
|
+
const STOP_REMEDIATION_LEGACY_MARKER = "[agent-hook stop]: rule=";
|
|
120
|
+
|
|
121
|
+
/** How far back the remediation walk may travel. Adapters cap their own
|
|
122
|
+
* followup chains (Cursor's `loop_limit`), and each prompt can appear once per
|
|
123
|
+
* observing adapter, so this only guards a pathological stream. */
|
|
124
|
+
const MAX_REMEDIATION_WALK_BACK = 16;
|
|
125
|
+
|
|
126
|
+
function isStopRemediationPrompt(event: CanonicalEvent): boolean {
|
|
127
|
+
const text = event.data.prompt_text;
|
|
128
|
+
if (typeof text !== "string") return false;
|
|
129
|
+
return text.includes(STOP_REMEDIATION_MARKER) || text.includes(STOP_REMEDIATION_LEGACY_MARKER);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Start of the window this Stop verdict judges.
|
|
134
|
+
*
|
|
135
|
+
* Normally the most recent `user_prompt.submit`. The exception is the
|
|
136
|
+
* remediation chain: Cursor does not continue a turn on Stop, it auto-submits
|
|
137
|
+
* our message as a new user turn (`followup_message`). Scoping the verdict to
|
|
138
|
+
* that new turn alone makes the ritual unsatisfiable in one pass, because
|
|
139
|
+
* remediation itself runs a tool, so the repair turn needs every rule again
|
|
140
|
+
* while the signal from the previous turn sits just outside the window. Running
|
|
141
|
+
* `set-task` then clears rule 3/3 and fails rule 1/3, running `status` clears
|
|
142
|
+
* 1/3 and fails 3/3, and the pair alternates until the adapter's loop cap.
|
|
143
|
+
*
|
|
144
|
+
* Anchoring the window at the last prompt Harnery did not author makes progress
|
|
145
|
+
* monotonic: each repair adds a signal and none are lost, so the chain ends.
|
|
146
|
+
* A genuine human prompt always anchors a fresh window, so this cannot be used
|
|
147
|
+
* to inherit ritual credit across real turns.
|
|
148
|
+
*/
|
|
149
|
+
function resolveTurnStartMs(
|
|
150
|
+
ownerEvents: CanonicalEvent[],
|
|
151
|
+
nowMs: number,
|
|
152
|
+
fallbackMs: number,
|
|
153
|
+
): number {
|
|
154
|
+
// Honor the cutoff: `now_ms` marks what is relevant yet, so a prompt after it
|
|
155
|
+
// cannot anchor this turn. In production the cutoff is the clock and nothing
|
|
156
|
+
// follows it, but a replay over recorded history passes a past cutoff, and an
|
|
157
|
+
// unclamped search would anchor on a later turn and hand back an empty window.
|
|
158
|
+
const prompts = ownerEvents.filter((e) => {
|
|
159
|
+
if (e.event_type !== "user_prompt.submit") return false;
|
|
160
|
+
const t = Date.parse(e.ts);
|
|
161
|
+
return Number.isFinite(t) && t <= nowMs;
|
|
162
|
+
});
|
|
163
|
+
if (prompts.length === 0) return fallbackMs;
|
|
164
|
+
|
|
165
|
+
let index = prompts.length - 1;
|
|
166
|
+
let hops = 0;
|
|
167
|
+
while (index > 0 && hops < MAX_REMEDIATION_WALK_BACK) {
|
|
168
|
+
const prompt = prompts[index];
|
|
169
|
+
if (!prompt || !isStopRemediationPrompt(prompt)) break;
|
|
170
|
+
index -= 1;
|
|
171
|
+
hops += 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const anchor = prompts[index];
|
|
175
|
+
const parsed = anchor ? Date.parse(anchor.ts) : Number.NaN;
|
|
176
|
+
return Number.isFinite(parsed) ? parsed : fallbackMs;
|
|
177
|
+
}
|
|
178
|
+
|
|
92
179
|
/**
|
|
93
180
|
* Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
|
|
94
181
|
* when all three pass). Soft-fails open when the event stream isn't
|
|
@@ -151,18 +238,20 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
|
|
|
151
238
|
}
|
|
152
239
|
|
|
153
240
|
// Turn window: from the most recent user_prompt.submit (this owner) up to
|
|
154
|
-
// either the explicit now_ms or the last event we see
|
|
241
|
+
// either the explicit now_ms or the last event we see, walking back over any
|
|
242
|
+
// Harnery-authored Stop-remediation prompts so a repair turn is judged
|
|
243
|
+
// together with the turn it repairs (see `resolveTurnStartMs`). Fall back to a
|
|
155
244
|
// 5-minute window when no user_prompt.submit is in scope (fresh wiring,
|
|
156
245
|
// out-of-window prompt, etc.) so a stale stream doesn't silently pass.
|
|
157
246
|
const nowMs = req.now_ms ?? Date.now();
|
|
158
|
-
const
|
|
159
|
-
.
|
|
160
|
-
|
|
161
|
-
const
|
|
247
|
+
const startMs = req.turn_window
|
|
248
|
+
? req.turn_window.start_ms
|
|
249
|
+
: resolveTurnStartMs(ownerEvents, nowMs, nowMs - 5 * 60 * 1000);
|
|
250
|
+
const endMs = req.turn_window ? req.turn_window.end_ms : nowMs;
|
|
162
251
|
|
|
163
252
|
const inTurn = ownerEvents.filter((e) => {
|
|
164
253
|
const t = Date.parse(e.ts);
|
|
165
|
-
return Number.isFinite(t) && t >= startMs && t <=
|
|
254
|
+
return Number.isFinite(t) && t >= startMs && t <= endMs;
|
|
166
255
|
});
|
|
167
256
|
|
|
168
257
|
const toolPreUseInTurn = inTurn.some((e) => e.event_type === "tool.pre_use");
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* fire-and-forget. Empty text → no-op (no JSON written).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { STOP_REMEDIATION_MARKER } from "../../agents/rules/stop-hook.ts";
|
|
22
|
+
import { resolveBinName } from "../../config.ts";
|
|
21
23
|
import type { Adapter } from "../events/schema.ts";
|
|
22
24
|
|
|
23
25
|
export type SystemEvent = "SessionStart" | "UserPromptSubmit" | "SubagentStart";
|
|
@@ -61,14 +63,25 @@ export function emitDeny(adapter: Adapter, reason: string): void {
|
|
|
61
63
|
export function emitStopBlock(adapter: Adapter, verdict: { reason?: string; rule: string }): 0 | 2 {
|
|
62
64
|
if (adapter === "codex") return 0;
|
|
63
65
|
|
|
64
|
-
const
|
|
65
|
-
verdict.reason ?? "End-of-turn coordination ritual incomplete."
|
|
66
|
-
}\n[agent-hook stop]: rule=${verdict.rule}`;
|
|
66
|
+
const reason = verdict.reason ?? "End-of-turn coordination ritual incomplete.";
|
|
67
67
|
if (adapter === "cursor") {
|
|
68
|
+
// The marker leads the message so the Stop verdict can recognize the turn
|
|
69
|
+
// Cursor opens from it (`STOP_REMEDIATION_MARKER`), and so the operator can
|
|
70
|
+
// see in chat that the message came from Harnery rather than from them.
|
|
71
|
+
// Both commands are named because one message that repairs the whole ritual
|
|
72
|
+
// ends the chain in a single followup; the failing rule is context.
|
|
73
|
+
const bin = resolveBinName();
|
|
74
|
+
const message = [
|
|
75
|
+
`${STOP_REMEDIATION_MARKER} rule=${verdict.rule}]`,
|
|
76
|
+
reason,
|
|
77
|
+
`Repair the ritual in this turn: run \`${bin} agents set-task "<short focus>"\` and then \`${bin} agents status\` as your last tool call.`,
|
|
78
|
+
].join("\n");
|
|
68
79
|
process.stdout.write(`${JSON.stringify({ followup_message: message })}\n`);
|
|
69
80
|
return 0;
|
|
70
81
|
}
|
|
71
|
-
|
|
82
|
+
// Claude Code continues the SAME turn on exit 2, so the single failing rule is
|
|
83
|
+
// both accurate and sufficient there. Left unchanged deliberately.
|
|
84
|
+
process.stderr.write(`${reason}\n[agent-hook stop]: rule=${verdict.rule}\n`);
|
|
72
85
|
return 2;
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -43,7 +43,7 @@ export interface ParsedPayload {
|
|
|
43
43
|
* Parse the raw stdin payload string for any adapter. Returns null when JSON
|
|
44
44
|
* parse fails (Cursor occasionally fires hooks with no payload).
|
|
45
45
|
*/
|
|
46
|
-
export function parsePayload(raw: string,
|
|
46
|
+
export function parsePayload(raw: string, adapter: Adapter): ParsedPayload | null {
|
|
47
47
|
if (!raw || raw.trim().length === 0) return null;
|
|
48
48
|
let json: Record<string, unknown>;
|
|
49
49
|
try {
|
|
@@ -52,13 +52,26 @@ export function parsePayload(raw: string, _adapter: Adapter): ParsedPayload | nu
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const sessionId = normalizeSessionId(adapter, pickStr(json, "session_id"));
|
|
56
|
+
const conversationId = normalizeSessionId(adapter, pickStr(json, "conversation_id"));
|
|
57
|
+
const parentSessionId = normalizeSessionId(adapter, pickStr(json, "parent_session_id"));
|
|
58
|
+
const normalizedRaw =
|
|
59
|
+
adapter === "cursor"
|
|
60
|
+
? {
|
|
61
|
+
...json,
|
|
62
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
63
|
+
...(conversationId ? { conversation_id: conversationId } : {}),
|
|
64
|
+
...(parentSessionId ? { parent_session_id: parentSessionId } : {}),
|
|
65
|
+
}
|
|
66
|
+
: json;
|
|
67
|
+
|
|
55
68
|
return {
|
|
56
69
|
hook_event_name: pickStr(json, "hook_event_name"),
|
|
57
|
-
session_id:
|
|
70
|
+
session_id: sessionId,
|
|
58
71
|
agent_id: pickStr(json, "agent_id"),
|
|
59
72
|
subagent_id: pickStr(json, "subagent_id"),
|
|
60
|
-
conversation_id:
|
|
61
|
-
parent_session_id:
|
|
73
|
+
conversation_id: conversationId,
|
|
74
|
+
parent_session_id: parentSessionId,
|
|
62
75
|
turn_id: pickStr(json, "turn_id"),
|
|
63
76
|
parent_turn_id: pickStr(json, "parent_turn_id"),
|
|
64
77
|
transcript_path: pickStr(json, "transcript_path"),
|
|
@@ -75,10 +88,16 @@ export function parsePayload(raw: string, _adapter: Adapter): ParsedPayload | nu
|
|
|
75
88
|
clean_exit: pickBool(json, "clean_exit"),
|
|
76
89
|
exit_status: pickStr(json, "exit_status"),
|
|
77
90
|
reason: pickStr(json, "reason"),
|
|
78
|
-
raw:
|
|
91
|
+
raw: normalizedRaw,
|
|
79
92
|
};
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
/** Cursor Glass prefixes conversation ids with `bc-`; coordination uses the bare id everywhere. */
|
|
96
|
+
function normalizeSessionId(adapter: Adapter, value: string | undefined): string | undefined {
|
|
97
|
+
if (adapter !== "cursor" || !value?.startsWith("bc-") || value.length <= 3) return value;
|
|
98
|
+
return value.slice(3);
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
/**
|
|
83
102
|
* Pull the bash command string out of a Bash/Shell tool_input. Returns
|
|
84
103
|
* undefined for non-shell tools.
|