harnery 0.31.5 → 0.32.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/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts +0 -9
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -95
- package/dist/commands/browse-session.d.ts +21 -0
- package/dist/commands/browse-session.d.ts.map +1 -0
- package/dist/commands/browse-session.js +157 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +205 -37
- package/dist/commands/checkpoint.js +1 -1
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +40 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +100 -17
- package/dist/commands/init.d.ts +1 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -5
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -3
- package/dist/core/agents/canonical-emit.d.ts +1 -2
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +1 -2
- package/dist/core/agents/cli.js +123 -88
- package/dist/core/agents/coord-client.d.ts +11 -4
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +27 -9
- package/dist/core/agents/finalization.d.ts +68 -0
- package/dist/core/agents/finalization.d.ts.map +1 -0
- package/dist/core/agents/finalization.js +443 -0
- package/dist/core/agents/git-hook.d.ts +51 -0
- package/dist/core/agents/git-hook.d.ts.map +1 -0
- package/dist/core/agents/git-hook.js +118 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +26 -13
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +15 -2
- package/dist/core/agents/rules/claim-conflict.js +3 -3
- package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +21 -5
- package/dist/core/agents/rules/stop-hook.d.ts +3 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +59 -23
- package/dist/core/agents/session-events.d.ts +8 -16
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +12 -26
- package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +44 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +54 -17
- package/dist/core/agents/state/names.d.ts +22 -1
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +40 -2
- package/dist/core/config.d.ts +30 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +74 -11
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +12 -13
- package/dist/core/hooks/adapter/detect.d.ts +2 -4
- package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
- package/dist/core/hooks/adapter/detect.js +4 -14
- package/dist/core/hooks/adapter/output.d.ts +1 -1
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +5 -4
- 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/dist/core/hooks/adapter/wiring.d.ts +23 -0
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +32 -0
- package/dist/core/hooks/cli.d.ts +1 -2
- package/dist/core/hooks/cli.d.ts.map +1 -1
- package/dist/core/hooks/cli.js +169 -27
- package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
- package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
- package/dist/core/hooks/codex-wsl-bridge.js +137 -0
- package/dist/core/hooks/effects/index.d.ts +1 -1
- package/dist/core/hooks/effects/index.js +3 -3
- package/dist/core/hooks/events/schema.d.ts +27 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.d.ts +7 -1
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +15 -0
- package/dist/core/hooks/resolve/transcript.d.ts +44 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +176 -1
- package/dist/core/hooks/session-name-presence.d.ts +30 -0
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
- package/dist/core/hooks/session-name-presence.js +43 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
- package/dist/core/hooks/unsafe-cross-shell.js +151 -0
- package/dist/core/work/state.d.ts +4 -5
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +5 -8
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +2 -2
- package/dist/core/workflow/run-state.d.ts +2 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +2 -2
- package/dist/core/workflow/types.d.ts +3 -3
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.d.ts +2 -2
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.js +5 -0
- package/dist/core/workflow/workspaces/index.d.ts +1 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +1 -2
- package/dist/core/workflow/workspaces/paths.d.ts +2 -2
- package/dist/core/workflow/workspaces/types.d.ts +0 -6
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.d.ts +0 -2
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.js +0 -2
- package/dist/lib/browser/client.d.ts +118 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +435 -6
- package/dist/lib/browser/geometry.d.ts.map +1 -1
- package/dist/lib/browser/geometry.js +180 -37
- package/dist/lib/browser/index.d.ts +5 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +5 -1
- package/dist/lib/browser/netscape-cookies.d.ts +6 -0
- package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
- package/dist/lib/browser/netscape-cookies.js +31 -0
- package/dist/lib/browser/proxy.d.ts +24 -0
- package/dist/lib/browser/proxy.d.ts.map +1 -0
- package/dist/lib/browser/proxy.js +84 -0
- package/dist/lib/browser/runts.d.ts +6 -0
- package/dist/lib/browser/runts.d.ts.map +1 -1
- package/dist/lib/browser/runts.js +21 -4
- package/dist/lib/browser/session-control.d.ts +112 -0
- package/dist/lib/browser/session-control.d.ts.map +1 -0
- package/dist/lib/browser/session-control.js +670 -0
- package/dist/lib/docs-links.d.ts +108 -0
- package/dist/lib/docs-links.d.ts.map +1 -0
- package/dist/lib/docs-links.js +555 -0
- package/dist/lib/exec.d.ts +1 -1
- package/dist/lib/exec.js +1 -1
- package/dist/lib/identities/assume.d.ts +4 -2
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +15 -2
- package/dist/lib/instructions/git-hooks.d.ts +75 -0
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
- package/dist/lib/instructions/git-hooks.js +238 -0
- package/dist/lib/instructions/splice.d.ts +10 -4
- package/dist/lib/instructions/splice.d.ts.map +1 -1
- package/dist/lib/instructions/splice.js +19 -11
- package/package.json +1 -1
- package/schemas/config.schema.json +32 -0
- package/src/commander.ts +2 -0
- package/src/commands/agents.ts +96 -115
- package/src/commands/browse-session.ts +245 -0
- package/src/commands/browse.ts +287 -44
- package/src/commands/checkpoint.ts +1 -1
- package/src/commands/deinit.ts +7 -1
- package/src/commands/docs.ts +56 -0
- package/src/commands/doctor.ts +100 -18
- package/src/commands/init.ts +17 -9
- package/src/commands/tunnel.ts +2 -5
- package/src/core/agents/canonical-emit.ts +1 -2
- package/src/core/agents/cli.ts +137 -88
- package/src/core/agents/coord-client.ts +38 -9
- package/src/core/agents/finalization.ts +595 -0
- package/src/core/agents/git-hook.ts +126 -0
- package/src/core/agents/render/prompt-context.ts +28 -13
- package/src/core/agents/render/session-context.ts +15 -2
- package/src/core/agents/rules/claim-conflict.ts +3 -3
- package/src/core/agents/rules/commit-conflict.ts +35 -8
- package/src/core/agents/rules/stop-hook.ts +71 -23
- package/src/core/agents/session-events.ts +14 -41
- package/src/core/agents/state/heartbeat-projector.ts +45 -1
- package/src/core/agents/state/heartbeat-writer.ts +78 -16
- package/src/core/agents/state/names.ts +54 -2
- package/src/core/config.ts +92 -12
- package/src/core/governor/planning.ts +17 -12
- package/src/core/hooks/adapter/detect.ts +4 -13
- package/src/core/hooks/adapter/output.ts +9 -4
- package/src/core/hooks/adapter/parse.ts +24 -5
- package/src/core/hooks/adapter/wiring.ts +43 -0
- package/src/core/hooks/cli.ts +201 -23
- package/src/core/hooks/codex-wsl-bridge.ts +188 -0
- package/src/core/hooks/effects/index.ts +5 -5
- package/src/core/hooks/events/schema.ts +27 -0
- package/src/core/hooks/resolve/owner.ts +22 -1
- package/src/core/hooks/resolve/transcript.ts +164 -1
- package/src/core/hooks/session-name-presence.ts +52 -0
- package/src/core/hooks/unsafe-cross-shell.ts +160 -0
- package/src/core/work/state.ts +8 -12
- package/src/core/workflow/engine.ts +2 -2
- package/src/core/workflow/index.ts +0 -1
- package/src/core/workflow/proof.ts +4 -4
- package/src/core/workflow/run-state.ts +5 -5
- package/src/core/workflow/types.ts +3 -3
- package/src/core/workflow/workspaces/execution.ts +11 -4
- package/src/core/workflow/workspaces/index.ts +0 -3
- package/src/core/workflow/workspaces/local-git.ts +1 -2
- package/src/core/workflow/workspaces/paths.ts +2 -2
- package/src/core/workflow/workspaces/types.ts +0 -9
- package/src/core/workflow/workspaces/validate.ts +1 -5
- package/src/lib/browser/client.ts +528 -6
- package/src/lib/browser/geometry.ts +197 -37
- package/src/lib/browser/index.ts +39 -0
- package/src/lib/browser/netscape-cookies.ts +39 -0
- package/src/lib/browser/proxy.ts +105 -0
- package/src/lib/browser/runts.ts +27 -3
- package/src/lib/browser/session-control.ts +892 -0
- package/src/lib/docs-links.ts +674 -0
- package/src/lib/exec.ts +1 -1
- package/src/lib/identities/assume.ts +22 -1
- package/src/lib/instructions/git-hooks.ts +259 -0
- package/src/lib/instructions/splice.ts +41 -11
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Command/narration event emitter for the coordination layer.
|
|
3
3
|
*
|
|
4
|
-
* `writeSessionEvent` emits command
|
|
4
|
+
* `writeSessionEvent` emits command and narration events straight to the
|
|
5
5
|
* **canonical** `.harnery/events.ndjson` (alongside the hook events), which the
|
|
6
|
-
* `/live` web viewer reads.
|
|
7
|
-
* `newCmdId`, `clampField`, `readLastIntent`) is stable so the session-tee
|
|
8
|
-
* middleware callers need no edits.
|
|
6
|
+
* `/live` web viewer reads.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import { randomBytes } from "node:crypto";
|
|
@@ -15,28 +13,16 @@ import { dirname, resolve } from "node:path";
|
|
|
15
13
|
import { normalizeAdapter, resolveEmitRoot } from "./canonical-emit.ts";
|
|
16
14
|
import { emit } from "./events/emit.ts";
|
|
17
15
|
|
|
18
|
-
/** Event types accepted by `writeSessionEvent`.
|
|
19
|
-
|
|
20
|
-
* call-site compatibility but are no-ops (the agents CLI emits those itself). */
|
|
21
|
-
export type SessionEventType =
|
|
22
|
-
| "command_start"
|
|
23
|
-
| "output"
|
|
24
|
-
| "command_end"
|
|
25
|
-
| "end_of_turn"
|
|
26
|
-
| "hook_event"
|
|
27
|
-
| "set_task"
|
|
28
|
-
| "file_claim"
|
|
29
|
-
| "file_release"
|
|
30
|
-
| "peer_change"
|
|
31
|
-
| "narration";
|
|
16
|
+
/** Event types accepted by `writeSessionEvent`. */
|
|
17
|
+
export type SessionEventType = "command_start" | "output" | "command_end" | "narration";
|
|
32
18
|
|
|
33
19
|
/**
|
|
34
20
|
* Resolved path of the ndjson sidecar file. Lives inside `.harnery/` so a
|
|
35
21
|
* containerized reader can pick it up through a single bind mount.
|
|
36
22
|
*/
|
|
37
|
-
export function
|
|
23
|
+
export function canonicalEventsPath(): string {
|
|
38
24
|
// Explicit override (tests + non-monorepo invocations).
|
|
39
|
-
const explicit = process.env.
|
|
25
|
+
const explicit = process.env.HARNERY_EVENTS_PATH;
|
|
40
26
|
if (explicit) return explicit;
|
|
41
27
|
// Superproject-aware root resolution (git first, cwd walk fallback) via
|
|
42
28
|
// resolveEmitRoot. A plain cwd walk here mis-anchored to a NESTED
|
|
@@ -64,7 +50,7 @@ export function newCmdId(): string {
|
|
|
64
50
|
*/
|
|
65
51
|
export function readLastIntent(instanceId?: string): string | null {
|
|
66
52
|
if (!instanceId) return null;
|
|
67
|
-
// Same superproject-aware root resolution as
|
|
53
|
+
// Same superproject-aware root resolution as canonicalEventsPath(): the
|
|
68
54
|
// intent stamp is written by the PreToolUse hook into the SUPERPROJECT's
|
|
69
55
|
// .harnery/, so a nested-`.harnery/` cwd must not redirect the read.
|
|
70
56
|
const root = resolveEmitRoot();
|
|
@@ -81,15 +67,7 @@ export function readLastIntent(instanceId?: string): string | null {
|
|
|
81
67
|
}
|
|
82
68
|
}
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
* Dual-write: mirror command/narration session-events
|
|
86
|
-
* into the canonical `.harnery/events.ndjson` stream so the legacy
|
|
87
|
-
* session-events.ndjson writer + its web consumers can be retired.
|
|
88
|
-
* Only the command-stream + narration types migrate; the coord/state types
|
|
89
|
-
* (`set_task`, `file_claim`, `peer_change`, …) are already emitted canonically
|
|
90
|
-
* by the agents CLI, so re-emitting them here would double-count.
|
|
91
|
-
*/
|
|
92
|
-
const CANONICAL_TYPE: Partial<Record<SessionEventType, string>> = {
|
|
70
|
+
const CANONICAL_TYPE: Record<SessionEventType, string> = {
|
|
93
71
|
command_start: "command.start",
|
|
94
72
|
output: "command.output",
|
|
95
73
|
command_end: "command.end",
|
|
@@ -131,7 +109,7 @@ function enrichFromHeartbeat(coordRoot: string, instanceId: string): HeartbeatEn
|
|
|
131
109
|
}
|
|
132
110
|
}
|
|
133
111
|
|
|
134
|
-
/** Project the flat
|
|
112
|
+
/** Project the flat middleware `fields` into the canonical event's `data` shape.
|
|
135
113
|
* Unknown types never reach here, guarded by CANONICAL_TYPE. */
|
|
136
114
|
function canonicalData(
|
|
137
115
|
type: SessionEventType,
|
|
@@ -158,15 +136,15 @@ function canonicalData(
|
|
|
158
136
|
|
|
159
137
|
/** Emit a command/narration event to the canonical stream. Swallows every
|
|
160
138
|
* error and skips when identity can't be resolved: telemetry must never break
|
|
161
|
-
*
|
|
139
|
+
* or slow down a command. */
|
|
162
140
|
function emitCanonicalCommand(type: SessionEventType, fields: Record<string, unknown>): void {
|
|
163
141
|
const eventType = CANONICAL_TYPE[type];
|
|
164
142
|
if (!eventType) return;
|
|
165
143
|
const instanceId = typeof fields.instance_id === "string" ? fields.instance_id : undefined;
|
|
166
144
|
if (!instanceId) return;
|
|
167
145
|
try {
|
|
168
|
-
// coordRoot = the dir containing `.harnery/`;
|
|
169
|
-
const coordRoot = dirname(dirname(
|
|
146
|
+
// coordRoot = the dir containing `.harnery/`; canonicalEventsPath() anchors it.
|
|
147
|
+
const coordRoot = dirname(dirname(canonicalEventsPath()));
|
|
170
148
|
const enrich = enrichFromHeartbeat(coordRoot, instanceId);
|
|
171
149
|
if (!enrich) return;
|
|
172
150
|
emit(coordRoot, {
|
|
@@ -182,16 +160,11 @@ function emitCanonicalCommand(type: SessionEventType, fields: Record<string, unk
|
|
|
182
160
|
}
|
|
183
161
|
|
|
184
162
|
/**
|
|
185
|
-
* Emit a
|
|
186
|
-
*
|
|
187
|
-
* are accepted for call-site compatibility but are no-ops here (the agents CLI
|
|
188
|
-
* emits those itself). Best-effort, never throws into the caller; a command
|
|
189
|
-
* must never break or slow on telemetry. The `agentName` arg is retained for
|
|
190
|
-
* the stable call signature (canonical events key on instance_id, not name).
|
|
163
|
+
* Emit a command or narration event to `.harnery/events.ndjson`. Best-effort,
|
|
164
|
+
* never throws into the caller; telemetry must not break or slow a command.
|
|
191
165
|
*/
|
|
192
166
|
export function writeSessionEvent(
|
|
193
167
|
type: SessionEventType,
|
|
194
|
-
_agentName: string,
|
|
195
168
|
fields: Record<string, unknown> = {},
|
|
196
169
|
): void {
|
|
197
170
|
emitCanonicalCommand(type, fields);
|
|
@@ -34,6 +34,9 @@ export interface V2Heartbeat {
|
|
|
34
34
|
last_tool_at?: string;
|
|
35
35
|
task?: string;
|
|
36
36
|
task_updated_at?: string;
|
|
37
|
+
suggested_session_name?: string;
|
|
38
|
+
session_name_seen_at?: string;
|
|
39
|
+
session_name_seen_for?: string;
|
|
37
40
|
last_status_at?: string;
|
|
38
41
|
presence?: "mobile" | "office";
|
|
39
42
|
last_intent?: string;
|
|
@@ -245,6 +248,18 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
|
|
|
245
248
|
case "turn.stop":
|
|
246
249
|
hb.last_turn_stop_at = ev.ts;
|
|
247
250
|
hb.last_turn_status_box_present = pickBool(d, "status_box_present");
|
|
251
|
+
// Rebuild fidelity for the naming ritual: the live stamp is written by
|
|
252
|
+
// the Stop hook (stampSessionNameSeen); reproduce it from the event so a
|
|
253
|
+
// full projector rebuild doesn't re-open a satisfied naming window.
|
|
254
|
+
if (d.session_name_present === true) {
|
|
255
|
+
hb.session_name_seen_at ??= ev.ts;
|
|
256
|
+
// Attribute the sighting to the name the scan actually covered. Reading
|
|
257
|
+
// the name current at this point in the replay instead re-attributed an
|
|
258
|
+
// old sighting to a re-minted name, which faked "already seen" for a
|
|
259
|
+
// name no reply had shown. An event without the field predates it, so
|
|
260
|
+
// leave the attribution unset and let the next stop re-scan.
|
|
261
|
+
hb.session_name_seen_for = pickStr(d, "session_name_present_for");
|
|
262
|
+
}
|
|
248
263
|
{
|
|
249
264
|
const summary = pickStr(d, "turn_summary");
|
|
250
265
|
if (summary) {
|
|
@@ -301,6 +316,11 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
|
|
|
301
316
|
hb.task = task;
|
|
302
317
|
}
|
|
303
318
|
hb.task_updated_at = ev.ts;
|
|
319
|
+
// Rebuild fidelity: the naming call carries the name it produced.
|
|
320
|
+
const suggested = pickStr(d, "suggested_session_name");
|
|
321
|
+
if (suggested && !hb.suggested_session_name) {
|
|
322
|
+
hb.suggested_session_name = suggested;
|
|
323
|
+
}
|
|
304
324
|
break;
|
|
305
325
|
}
|
|
306
326
|
|
|
@@ -322,6 +342,19 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
|
|
|
322
342
|
break;
|
|
323
343
|
}
|
|
324
344
|
|
|
345
|
+
case "claim.acquire": {
|
|
346
|
+
const path = pickStr(d, "path");
|
|
347
|
+
const mode = pickStr(d, "mode");
|
|
348
|
+
if (path && mode === "write") {
|
|
349
|
+
const canonical = path.startsWith(`${coordRoot}/`)
|
|
350
|
+
? path.slice(coordRoot.length + 1)
|
|
351
|
+
: path;
|
|
352
|
+
if (!hb.files_touched) hb.files_touched = [];
|
|
353
|
+
if (!hb.files_touched.includes(canonical)) hb.files_touched.push(canonical);
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
325
358
|
case "claim.release": {
|
|
326
359
|
const path = pickStr(d, "path");
|
|
327
360
|
if (path && hb.files_touched) {
|
|
@@ -341,7 +374,7 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
|
|
|
341
374
|
}
|
|
342
375
|
|
|
343
376
|
function adapterToPlatform(adapter: string): string {
|
|
344
|
-
if (adapter === "claude-code") return "
|
|
377
|
+
if (adapter === "claude-code") return "claude-code";
|
|
345
378
|
if (adapter === "cursor") return "cursor";
|
|
346
379
|
if (adapter === "codex") return "codex";
|
|
347
380
|
return adapter;
|
|
@@ -493,6 +526,17 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
|
|
|
493
526
|
setIfDefined(merged, "last_tool_at", hb.last_tool_at);
|
|
494
527
|
setIfDefined(merged, "task", hb.task);
|
|
495
528
|
setIfDefined(merged, "task_updated_at", hb.task_updated_at);
|
|
529
|
+
setIfDefined(merged, "suggested_session_name", hb.suggested_session_name);
|
|
530
|
+
setIfDefined(merged, "session_name_seen_at", hb.session_name_seen_at);
|
|
531
|
+
if (hb.session_name_seen_for) {
|
|
532
|
+
merged.session_name_seen_for = hb.session_name_seen_for;
|
|
533
|
+
} else {
|
|
534
|
+
// Unlike most projected fields, an absent attribution is meaningful: a
|
|
535
|
+
// legacy sighting cannot prove which suggested name it covered. Remove a
|
|
536
|
+
// stale value inherited through the additive merge so the next stop
|
|
537
|
+
// scans the current name instead of treating it as already satisfied.
|
|
538
|
+
delete merged.session_name_seen_for;
|
|
539
|
+
}
|
|
496
540
|
setIfDefined(merged, "last_status_at", hb.last_status_at);
|
|
497
541
|
setIfDefined(merged, "turn_summary", hb.turn_summary);
|
|
498
542
|
setIfDefined(merged, "turn_summary_updated_at", hb.turn_summary_updated_at);
|
|
@@ -33,14 +33,13 @@ function adapterOf(platform: string | undefined): "claude-code" | "cursor" | "co
|
|
|
33
33
|
return "claude-code";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the same platform value projection would have written. */
|
|
36
|
+
/** Preserve the canonical adapter id in heartbeat state. Mirrors
|
|
37
|
+
* heartbeat-projector.adapterToPlatform so a healed heartbeat carries the same
|
|
38
|
+
* platform value projection would have written. */
|
|
40
39
|
function adapterToPlatform(adapter: string | undefined): string {
|
|
41
40
|
if (adapter === "cursor") return "cursor";
|
|
42
41
|
if (adapter === "codex") return "codex";
|
|
43
|
-
return "
|
|
42
|
+
return "claude-code";
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
/**
|
|
@@ -87,6 +86,17 @@ export interface Heartbeat {
|
|
|
87
86
|
files_touched: string[];
|
|
88
87
|
task?: string;
|
|
89
88
|
task_updated_at?: string | null;
|
|
89
|
+
/** Session name built on the first non-empty set-task (never rebuilt). Its
|
|
90
|
+
* presence is the "this session has been named" signal the prompt-context
|
|
91
|
+
* nudge and the Stop-hook naming rule key on. */
|
|
92
|
+
suggested_session_name?: string;
|
|
93
|
+
/** Stamped by turn.stop once the suggested name is seen in assistant reply
|
|
94
|
+
* text, ending the per-turn transcript scan. */
|
|
95
|
+
session_name_seen_at?: string;
|
|
96
|
+
/** WHICH name that sighting was for. The scan is skipped only while this
|
|
97
|
+
* matches the current suggested name, so a re-minted name is detectable
|
|
98
|
+
* again rather than being suppressed by the earlier sighting. */
|
|
99
|
+
session_name_seen_for?: string;
|
|
90
100
|
last_status_at?: string;
|
|
91
101
|
turn_summary?: string | null;
|
|
92
102
|
turn_summary_updated_at?: string | null;
|
|
@@ -95,6 +105,7 @@ export interface Heartbeat {
|
|
|
95
105
|
last_tool_at?: string;
|
|
96
106
|
current_turn_id?: string;
|
|
97
107
|
parent_instance_id?: string;
|
|
108
|
+
workflow_run_id?: string;
|
|
98
109
|
[extra: string]: unknown;
|
|
99
110
|
}
|
|
100
111
|
|
|
@@ -139,18 +150,53 @@ function mutate(
|
|
|
139
150
|
export function setTask(coordRoot: string, instanceId: string, task: string): Heartbeat | null {
|
|
140
151
|
return mutate(coordRoot, instanceId, (hb) => {
|
|
141
152
|
const cleared = !task || task.length === 0;
|
|
153
|
+
// Name the session on its first NON-EMPTY declaration. Keyed on the
|
|
154
|
+
// suggested_session_name stamp, not task_updated_at, so a bare clear as
|
|
155
|
+
// the first call never burns the naming window. Subagents and workflow
|
|
156
|
+
// children have no human-owned tab to rename, so they are never named.
|
|
157
|
+
const humanFacing = hb.kind !== "subagent" && hb.kind !== "transient" && !hb.workflow_run_id;
|
|
158
|
+
const built =
|
|
159
|
+
!cleared && !hb.suggested_session_name && humanFacing
|
|
160
|
+
? buildSuggestedName(hb.name ?? "unknown", [task])
|
|
161
|
+
: null;
|
|
142
162
|
return {
|
|
143
163
|
...hb,
|
|
144
164
|
task: cleared ? undefined : task,
|
|
145
165
|
task_updated_at: nowIsoSeconds(),
|
|
166
|
+
...(built ? { suggested_session_name: built.suggestedName } : {}),
|
|
146
167
|
};
|
|
147
168
|
});
|
|
148
169
|
}
|
|
149
170
|
|
|
150
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Build the copy-pasteable session name from the coord identity + the agent's
|
|
173
|
+
* description parts. Pure (no coord-state reads) so it's unit-testable; collapses
|
|
174
|
+
* internal whitespace and trims. Returns null when the description is empty.
|
|
175
|
+
*/
|
|
176
|
+
export function buildSuggestedName(
|
|
177
|
+
agentName: string,
|
|
178
|
+
descriptionParts: string[],
|
|
179
|
+
): { suggestedName: string; description: string } | null {
|
|
180
|
+
const description = descriptionParts.join(" ").replace(/\s+/g, " ").trim();
|
|
181
|
+
if (!description) return null;
|
|
182
|
+
const name = agentName?.trim() || "unknown";
|
|
183
|
+
return { suggestedName: `Agent ${name} - ${description}`, description };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Stamp the sighting once the suggested name has been observed in assistant
|
|
187
|
+
* reply text; later turns skip the transcript scan. Records WHICH name was seen,
|
|
188
|
+
* because the skip is only valid for that one: if a later set-task mints a
|
|
189
|
+
* different suggested name, a bare "already seen" stamp would suppress the scan
|
|
190
|
+
* forever and leave the Stop-hook naming rule permanently unsatisfiable. */
|
|
191
|
+
export function stampSessionNameSeen(
|
|
192
|
+
coordRoot: string,
|
|
193
|
+
instanceId: string,
|
|
194
|
+
name?: string,
|
|
195
|
+
): Heartbeat | null {
|
|
151
196
|
return mutate(coordRoot, instanceId, (hb) => ({
|
|
152
197
|
...hb,
|
|
153
|
-
|
|
198
|
+
session_name_seen_at: hb.session_name_seen_at ?? nowIsoSeconds(),
|
|
199
|
+
...(name ? { session_name_seen_for: name } : {}),
|
|
154
200
|
}));
|
|
155
201
|
}
|
|
156
202
|
|
|
@@ -200,9 +246,8 @@ export interface GroupUnclaimHit {
|
|
|
200
246
|
* durable `claim.release` events — a file-only prune is silently reverted by
|
|
201
247
|
* the next projector replay.
|
|
202
248
|
*
|
|
203
|
-
*
|
|
204
|
-
* repo-relative entries
|
|
205
|
-
* so both sides are normalized before comparing — an exact-string match
|
|
249
|
+
* Tool payloads and release calls can supply either absolute-under-coordRoot or
|
|
250
|
+
* canonical repo-relative entries, so both sides are normalized before comparing. An exact-string match
|
|
206
251
|
* silently no-ops on the mixed-form case and the claim never releases.
|
|
207
252
|
*
|
|
208
253
|
* This is the Option B fix for post-commit's pid-map attribution hole: a
|
|
@@ -264,7 +309,7 @@ export function healPidmap(coordRoot: string, instanceId: string, pid: number):
|
|
|
264
309
|
const dir = join(coordRoot, ".harnery", "pid-map");
|
|
265
310
|
mkdirSync(dir, { recursive: true });
|
|
266
311
|
const hb = readHeartbeat(coordRoot, instanceId);
|
|
267
|
-
const platform = hb?.platform ?? "
|
|
312
|
+
const platform = hb?.platform ?? "claude-code";
|
|
268
313
|
const pmPath = join(dir, String(pid));
|
|
269
314
|
// Drift guard: only write + emit telemetry when the entry is missing or
|
|
270
315
|
// points at a different owner. Without this, a per-tool-call heal would
|
|
@@ -291,6 +336,7 @@ export function healHeartbeat(
|
|
|
291
336
|
sessionId?: string,
|
|
292
337
|
model?: string,
|
|
293
338
|
adapter?: string,
|
|
339
|
+
opts?: { forkedFrom?: string },
|
|
294
340
|
): Heartbeat | null {
|
|
295
341
|
const path = heartbeatPath(coordRoot, instanceId);
|
|
296
342
|
if (existsSync(path)) {
|
|
@@ -306,14 +352,28 @@ export function healHeartbeat(
|
|
|
306
352
|
let agentId = "";
|
|
307
353
|
try {
|
|
308
354
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
309
|
-
const
|
|
310
|
-
const resolved = resolveName(coordRoot, instanceId, sessionId);
|
|
355
|
+
const names = require("./names.ts") as typeof import("./names.ts");
|
|
356
|
+
const resolved = names.resolveName(coordRoot, instanceId, sessionId);
|
|
311
357
|
if (resolved) {
|
|
312
358
|
name = resolved.name;
|
|
313
359
|
kind = resolved.kind;
|
|
314
360
|
// An explicitly assumed session carries its durable persona UUID in
|
|
315
361
|
// name-history. Native subagents continue to use instance_id.
|
|
316
362
|
agentId = resolved.agent_id ?? (resolved.kind === "subagent" ? instanceId : "");
|
|
363
|
+
} else if (!sessionId || sessionId === instanceId) {
|
|
364
|
+
// A main session with NO history at all never fired session.start —
|
|
365
|
+
// the CC fork flow (SessionStart fires under the parent's id before
|
|
366
|
+
// the fork id is minted), or a partially-wired adapter. Mint its pool
|
|
367
|
+
// name here instead of leaving a nameless heartbeat, and stamp the
|
|
368
|
+
// detected fork lineage while we're at it. A sessionId that differs
|
|
369
|
+
// from instanceId is left alone: that shape is a subagent whose parent
|
|
370
|
+
// is also unknown, and guessing "session" would be wrong.
|
|
371
|
+
name = names.assignName(
|
|
372
|
+
coordRoot,
|
|
373
|
+
instanceId,
|
|
374
|
+
"session",
|
|
375
|
+
opts?.forkedFrom ? { forkedFrom: opts.forkedFrom } : undefined,
|
|
376
|
+
);
|
|
317
377
|
}
|
|
318
378
|
} catch {
|
|
319
379
|
/* names module unavailable, fall back to empty */
|
|
@@ -330,19 +390,21 @@ export function healHeartbeat(
|
|
|
330
390
|
started_at: now,
|
|
331
391
|
last_heartbeat: now,
|
|
332
392
|
files_touched: [],
|
|
333
|
-
// Default to
|
|
393
|
+
// Default to claude-code only when the caller can't tell us the adapter
|
|
334
394
|
// (e.g. manual `harn agents heal`). The live tool.pre_use heal threads the
|
|
335
395
|
// detected adapter so a pruned Cursor/Codex heartbeat is recreated with
|
|
336
|
-
// the correct platform instead of being mislabeled
|
|
396
|
+
// the correct platform instead of being mislabeled claude-code.
|
|
337
397
|
platform: adapterToPlatform(adapter),
|
|
338
398
|
};
|
|
339
399
|
atomicWrite(path, JSON.stringify(hb, null, 2));
|
|
340
400
|
// Write-only telemetry: only the actual-recreate branch reaches here (the
|
|
341
401
|
// already-alive case returned above), so this records exactly the heals that
|
|
342
|
-
// happened.
|
|
402
|
+
// happened. Recorded fork lineage rides the event too, so derived readers
|
|
403
|
+
// rebuilding from the ledger converge with .name-history.
|
|
343
404
|
emitHealthHeal(coordRoot, "health.heartbeat_heal", instanceId, hb, {
|
|
344
405
|
reason: "missing",
|
|
345
406
|
kind: "heartbeat",
|
|
407
|
+
...(opts?.forkedFrom ? { forked_from: opts.forkedFrom } : {}),
|
|
346
408
|
});
|
|
347
409
|
return hb;
|
|
348
410
|
}
|
|
@@ -312,6 +312,10 @@ export interface NameHistoryRow {
|
|
|
312
312
|
/** Audit marker distinguishing an explicit role adoption from pool assignment. */
|
|
313
313
|
source?: "pool" | "identity.assume";
|
|
314
314
|
previous_name?: string;
|
|
315
|
+
/** Instance this session was forked/branched from (recorded fork lineage).
|
|
316
|
+
* Stamped only on the row that first assigns this instance, when the adapter
|
|
317
|
+
* layer detected or supplied a parent conversation. */
|
|
318
|
+
forked_from?: string;
|
|
315
319
|
}
|
|
316
320
|
|
|
317
321
|
function atomicWrite(path: string, content: string): void {
|
|
@@ -433,8 +437,15 @@ export function recordNameAssumption(
|
|
|
433
437
|
* Assign a name to <instanceId> with the given <kind>. Counter-consuming when
|
|
434
438
|
* the owner is new. Idempotent: returns existing name on resume.
|
|
435
439
|
*/
|
|
436
|
-
export function assignName(
|
|
437
|
-
|
|
440
|
+
export function assignName(
|
|
441
|
+
coordRoot: string,
|
|
442
|
+
instanceId: string,
|
|
443
|
+
kind: NameKind,
|
|
444
|
+
opts?: { forkedFrom?: string },
|
|
445
|
+
): string {
|
|
446
|
+
// Check 1: existing history row → original name. A resume re-enters here,
|
|
447
|
+
// which also makes fork stamping naturally idempotent: lineage lands only on
|
|
448
|
+
// the row that first assigns the instance.
|
|
438
449
|
const existing = resolveName(coordRoot, instanceId);
|
|
439
450
|
if (existing) return existing.name;
|
|
440
451
|
|
|
@@ -447,12 +458,53 @@ export function assignName(coordRoot: string, instanceId: string, kind: NameKind
|
|
|
447
458
|
}
|
|
448
459
|
const name = COORD_NAMES[counter % 260]!;
|
|
449
460
|
atomicWrite(cPath, String(counter + 1));
|
|
461
|
+
const forkedFrom = opts?.forkedFrom;
|
|
450
462
|
appendHistory(coordRoot, {
|
|
451
463
|
instance_id: instanceId,
|
|
452
464
|
name,
|
|
453
465
|
kind,
|
|
454
466
|
source: "pool",
|
|
467
|
+
...(forkedFrom && forkedFrom !== instanceId ? { forked_from: forkedFrom } : {}),
|
|
455
468
|
ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
456
469
|
});
|
|
457
470
|
return name;
|
|
458
471
|
}
|
|
472
|
+
|
|
473
|
+
/** One step of recorded fork lineage: the latest row for <instanceId> that
|
|
474
|
+
* carries `forked_from` (latest-row-wins, matching resolveName). */
|
|
475
|
+
export function readForkParent(
|
|
476
|
+
coordRoot: string,
|
|
477
|
+
instanceId: string,
|
|
478
|
+
): { instance_id: string; name: string | null } | null {
|
|
479
|
+
const history = readHistory(coordRoot);
|
|
480
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
481
|
+
const row = history[i]!;
|
|
482
|
+
if (row.instance_id !== instanceId) continue;
|
|
483
|
+
if (!row.forked_from) return null;
|
|
484
|
+
const parent = resolveName(coordRoot, row.forked_from);
|
|
485
|
+
return { instance_id: row.forked_from, name: parent?.name ?? null };
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Full recorded fork ancestry for <instanceId>, nearest ancestor first, each
|
|
492
|
+
* with its latest resolved name. Depth-capped and cycle-guarded: lineage is
|
|
493
|
+
* append-only operational data, not something to trust unboundedly.
|
|
494
|
+
*/
|
|
495
|
+
export function resolveForkAncestry(
|
|
496
|
+
coordRoot: string,
|
|
497
|
+
instanceId: string,
|
|
498
|
+
): Array<{ instance_id: string; name: string | null }> {
|
|
499
|
+
const out: Array<{ instance_id: string; name: string | null }> = [];
|
|
500
|
+
const seen = new Set<string>([instanceId]);
|
|
501
|
+
let cursor = instanceId;
|
|
502
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
503
|
+
const parent = readForkParent(coordRoot, cursor);
|
|
504
|
+
if (!parent || seen.has(parent.instance_id)) break;
|
|
505
|
+
out.push(parent);
|
|
506
|
+
seen.add(parent.instance_id);
|
|
507
|
+
cursor = parent.instance_id;
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 2. `<project-root>/.harnery/config.jsonc` — project override (authoritative)
|
|
8
8
|
*
|
|
9
9
|
* Fields owned here: `binName` (host CLI name for agent-facing strings),
|
|
10
|
-
* `hooksSetupHint`, `tools`, `workflow`, `skills`, `presence`, plus the tunable
|
|
10
|
+
* `hooksSetupHint`, `agents`, `tools`, `workflow`, `skills`, `presence`, plus the tunable
|
|
11
11
|
* `coord` (heartbeat freshness), `artifacts` (working-file retention),
|
|
12
12
|
* `backup` (restic repo/password/prune policy), and `sync` (rclone
|
|
13
13
|
* remote/prefix) sections. The `files` deny/override section
|
|
@@ -30,6 +30,13 @@ export const DEFAULT_BIN_NAME = "harn";
|
|
|
30
30
|
/** Heartbeat-freshness default (seconds): the sweep window when nothing overrides it. */
|
|
31
31
|
export const DEFAULT_FRESHNESS_SECS = 600;
|
|
32
32
|
|
|
33
|
+
export type AgentFinalizationDisposition = "git" | "output";
|
|
34
|
+
|
|
35
|
+
export interface AgentFinalizationRoot {
|
|
36
|
+
path: string;
|
|
37
|
+
disposition: AgentFinalizationDisposition;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
interface HarneryConfig {
|
|
34
41
|
/** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "acme"). */
|
|
35
42
|
binName?: string;
|
|
@@ -41,6 +48,15 @@ interface HarneryConfig {
|
|
|
41
48
|
* declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint.
|
|
42
49
|
*/
|
|
43
50
|
hooksSetupHint?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Agent-ritual policy owned by the host project. Git finalization is opt-in:
|
|
53
|
+
* standalone Harnery and embedding hosts keep the ordinary status ritual
|
|
54
|
+
* unless the project deliberately requires the guarded check.
|
|
55
|
+
*/
|
|
56
|
+
agents?: {
|
|
57
|
+
requireGitFinalization?: boolean;
|
|
58
|
+
finalizationRoots?: AgentFinalizationRoot[];
|
|
59
|
+
};
|
|
44
60
|
/**
|
|
45
61
|
* Managed-tool provisioning consent. `{ ripgrep: { autoInstall: true } }`
|
|
46
62
|
* lets `grep` download the pinned, checksum-verified ripgrep into the
|
|
@@ -157,6 +173,16 @@ function statMtime(p: string): number {
|
|
|
157
173
|
}
|
|
158
174
|
}
|
|
159
175
|
|
|
176
|
+
/** Cache signature, or null when the file can't be stat'd (missing). */
|
|
177
|
+
function statSignature(p: string): string | null {
|
|
178
|
+
try {
|
|
179
|
+
const stat = statSync(p);
|
|
180
|
+
return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
160
186
|
/** Parse one JSONC config file to an object; missing/unparseable → `{}`. */
|
|
161
187
|
function parseConfigFile(p: string): HarneryConfig {
|
|
162
188
|
try {
|
|
@@ -186,8 +212,13 @@ function mergeConfig(base: HarneryConfig, override: HarneryConfig): HarneryConfi
|
|
|
186
212
|
return out as HarneryConfig;
|
|
187
213
|
}
|
|
188
214
|
|
|
189
|
-
//
|
|
190
|
-
let cache: {
|
|
215
|
+
// Stat-signature-keyed per-process cache (both layers): a stat is cheap, a parse on every render isn't.
|
|
216
|
+
let cache: {
|
|
217
|
+
root: string;
|
|
218
|
+
projSignature: string | null;
|
|
219
|
+
userSignature: string | null;
|
|
220
|
+
cfg: HarneryConfig;
|
|
221
|
+
} | null = null;
|
|
191
222
|
|
|
192
223
|
/**
|
|
193
224
|
* The effective config for `root`: user-global (`~/.config/harnery/config.jsonc`)
|
|
@@ -197,20 +228,20 @@ let cache: { root: string; projMtime: number; userMtime: number; cfg: HarneryCon
|
|
|
197
228
|
function readConfig(root: string): HarneryConfig {
|
|
198
229
|
const projPath = join(root, ".harnery", "config.jsonc");
|
|
199
230
|
const userPath = userConfigPath();
|
|
200
|
-
const
|
|
201
|
-
const
|
|
231
|
+
const projSignature = statSignature(projPath);
|
|
232
|
+
const userSignature = statSignature(userPath);
|
|
202
233
|
if (
|
|
203
234
|
cache &&
|
|
204
235
|
cache.root === root &&
|
|
205
|
-
cache.
|
|
206
|
-
cache.
|
|
236
|
+
cache.projSignature === projSignature &&
|
|
237
|
+
cache.userSignature === userSignature
|
|
207
238
|
) {
|
|
208
239
|
return cache.cfg;
|
|
209
240
|
}
|
|
210
|
-
const user =
|
|
211
|
-
const project =
|
|
241
|
+
const user = userSignature === null ? {} : parseConfigFile(userPath);
|
|
242
|
+
const project = projSignature === null ? {} : parseConfigFile(projPath);
|
|
212
243
|
const cfg = mergeConfig(user, project);
|
|
213
|
-
cache = { root,
|
|
244
|
+
cache = { root, projSignature, userSignature, cfg };
|
|
214
245
|
return cfg;
|
|
215
246
|
}
|
|
216
247
|
|
|
@@ -270,6 +301,55 @@ export function resolveHooksSetupHint(coordRoot?: string | null): string | null
|
|
|
270
301
|
return typeof hint === "string" && hint.trim() ? hint.trim() : null;
|
|
271
302
|
}
|
|
272
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Whether the host requires the guarded Git check at the end of tool-using
|
|
306
|
+
* turns. Default false: Harnery exposes `agents status --end-turn` as a capability
|
|
307
|
+
* but does not impose a commit-and-push policy on embedding projects.
|
|
308
|
+
*
|
|
309
|
+
* `.harnery/config.jsonc`:
|
|
310
|
+
* `{ "agents": { "requireGitFinalization": true } }`
|
|
311
|
+
*
|
|
312
|
+
* `HARNERY_AGENTS_REQUIRE_GIT_FINALIZATION=1|0` overrides per process.
|
|
313
|
+
*/
|
|
314
|
+
export function agentsRequireGitFinalization(coordRoot?: string | null): boolean {
|
|
315
|
+
const env = coordEnv("AGENTS_REQUIRE_GIT_FINALIZATION");
|
|
316
|
+
if (env === "1") return true;
|
|
317
|
+
if (env === "0") return false;
|
|
318
|
+
const root = coordRoot ?? findCoordRoot();
|
|
319
|
+
if (!root) return false;
|
|
320
|
+
return readConfig(root).agents?.requireGitFinalization === true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Extra roots whose guarded writes have an explicit end-turn disposition.
|
|
325
|
+
*
|
|
326
|
+
* This trust boundary comes only from the project config. A user-global config
|
|
327
|
+
* may tune ordinary behavior, but it cannot grant one project filesystem
|
|
328
|
+
* authority outside its coordination root. Paths may be absolute or relative
|
|
329
|
+
* to the coordination root. Invalid entries are ignored here and fail closed
|
|
330
|
+
* when the finalization policy validates them.
|
|
331
|
+
*/
|
|
332
|
+
export function agentsFinalizationRoots(coordRoot?: string | null): AgentFinalizationRoot[] {
|
|
333
|
+
const root = coordRoot ?? findCoordRoot();
|
|
334
|
+
if (!root) return [];
|
|
335
|
+
const entries = readProjectConfig(root).agents?.finalizationRoots;
|
|
336
|
+
if (!Array.isArray(entries)) return [];
|
|
337
|
+
return entries.flatMap((entry) => {
|
|
338
|
+
if (!entry || typeof entry !== "object") return [];
|
|
339
|
+
const path = typeof entry.path === "string" ? entry.path.trim() : "";
|
|
340
|
+
const disposition = entry.disposition;
|
|
341
|
+
if (!path || (disposition !== "git" && disposition !== "output")) return [];
|
|
342
|
+
return [{ path, disposition }];
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The status command automatic prompts and Stop remediation should request. */
|
|
347
|
+
export function endOfTurnStatusCommand(coordRoot?: string | null): string {
|
|
348
|
+
const root = coordRoot ?? findCoordRoot();
|
|
349
|
+
const suffix = agentsRequireGitFinalization(root) ? " --end-turn" : "";
|
|
350
|
+
return `${resolveBinName(root)} agents status${suffix}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
273
353
|
/**
|
|
274
354
|
* Whether the host project consented to automatic ripgrep provisioning:
|
|
275
355
|
* `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`.
|
|
@@ -349,13 +429,13 @@ function posIntOr(v: unknown, fallback: number): number {
|
|
|
349
429
|
* The heartbeat-freshness window (seconds): the age above which the sweeper
|
|
350
430
|
* prunes an agent, and the cutoff the `agents` surface uses to fold stale peers.
|
|
351
431
|
* Precedence:
|
|
352
|
-
* 1. `HARNERY_AGENT_COORD_FRESHNESS` env
|
|
432
|
+
* 1. `HARNERY_AGENT_COORD_FRESHNESS` env
|
|
353
433
|
* 2. `.harnery/config.jsonc` `coord.freshness_seconds`
|
|
354
434
|
* 3. `600` (10 minutes)
|
|
355
435
|
* `coordRoot` is resolved via `findCoordRoot()` when not passed.
|
|
356
436
|
*/
|
|
357
437
|
export function coordFreshnessSeconds(coordRoot?: string | null): number {
|
|
358
|
-
const env = coordEnv("AGENT_COORD_FRESHNESS")
|
|
438
|
+
const env = coordEnv("AGENT_COORD_FRESHNESS");
|
|
359
439
|
if (env !== undefined) {
|
|
360
440
|
const n = Number.parseInt(env, 10);
|
|
361
441
|
if (Number.isFinite(n) && n > 0) return n;
|