harnery 0.31.6 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +5 -1
- package/dist/commands/agents.d.ts +0 -9
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +280 -94
- 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/claude-desktop.d.ts +2 -2
- package/dist/commands/claude-desktop.d.ts.map +1 -1
- package/dist/commands/claude-desktop.js +74 -3
- 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 +4 -2
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +141 -19
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +26 -5
- package/dist/commands/instructions.d.ts +4 -0
- package/dist/commands/instructions.d.ts.map +1 -0
- package/dist/commands/instructions.js +100 -0
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +4 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -3
- package/dist/core/agents/canonical-emit.d.ts +2 -3
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +6 -5
- package/dist/core/agents/cli.js +128 -89
- package/dist/core/agents/coord-client.d.ts +18 -3
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +5 -4
- 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/index.d.ts +1 -0
- package/dist/core/agents/index.d.ts.map +1 -1
- package/dist/core/agents/index.js +1 -0
- package/dist/core/agents/render/prompt-context.d.ts +12 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +70 -16
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +23 -4
- 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 +14 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +98 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +47 -5
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +86 -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/agents/state/session-state.d.ts +44 -0
- package/dist/core/agents/state/session-state.d.ts.map +1 -0
- package/dist/core/agents/state/session-state.js +81 -0
- 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/events.d.ts.map +1 -1
- package/dist/core/hooks/adapter/events.js +1 -0
- 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 +2 -0
- 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 +258 -32
- package/dist/core/hooks/codex-authorization.d.ts +30 -0
- package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
- package/dist/core/hooks/codex-authorization.js +199 -0
- 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 +57 -1
- 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/presence/blob.d.ts +4 -0
- package/dist/core/presence/blob.d.ts.map +1 -1
- package/dist/core/presence/blob.js +12 -0
- package/dist/core/presence/index.js +15 -1
- 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/claude-desktop.d.ts +47 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -1
- package/dist/lib/claude-desktop.js +169 -1
- 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/bundle.d.ts +33 -0
- package/dist/lib/instructions/bundle.d.ts.map +1 -0
- package/dist/lib/instructions/bundle.js +149 -0
- 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 +2 -2
- package/schemas/config.schema.json +32 -0
- package/src/commander.ts +5 -1
- package/src/commands/agents.ts +341 -113
- 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/claude-desktop.ts +106 -5
- package/src/commands/deinit.ts +7 -1
- package/src/commands/docs.ts +56 -0
- package/src/commands/doctor.ts +155 -21
- package/src/commands/init.ts +28 -9
- package/src/commands/instructions.ts +129 -0
- package/src/commands/presence.ts +7 -1
- package/src/commands/tunnel.ts +2 -5
- package/src/core/agents/canonical-emit.ts +7 -6
- package/src/core/agents/cli.ts +143 -89
- package/src/core/agents/coord-client.ts +21 -5
- package/src/core/agents/finalization.ts +595 -0
- package/src/core/agents/git-hook.ts +126 -0
- package/src/core/agents/index.ts +1 -0
- package/src/core/agents/render/prompt-context.ts +96 -16
- package/src/core/agents/render/session-context.ts +29 -4
- 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 +108 -1
- package/src/core/agents/state/heartbeat-writer.ts +129 -16
- package/src/core/agents/state/names.ts +54 -2
- package/src/core/agents/state/session-state.ts +131 -0
- 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/events.ts +1 -0
- package/src/core/hooks/adapter/output.ts +9 -4
- package/src/core/hooks/adapter/parse.ts +3 -0
- package/src/core/hooks/adapter/wiring.ts +43 -0
- package/src/core/hooks/cli.ts +296 -27
- package/src/core/hooks/codex-authorization.ts +253 -0
- 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 +71 -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/presence/blob.ts +18 -0
- package/src/core/presence/index.ts +18 -1
- 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/claude-desktop.ts +232 -1
- 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/bundle.ts +199 -0
- package/src/lib/instructions/git-hooks.ts +259 -0
- package/src/lib/instructions/splice.ts +41 -11
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
} from "node:fs";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
26
|
import { emit } from "../events/emit.ts";
|
|
27
|
+
import {
|
|
28
|
+
type AgentActivity,
|
|
29
|
+
applySessionStateEvent,
|
|
30
|
+
type SessionStateEvidenceEvent,
|
|
31
|
+
type TaskState,
|
|
32
|
+
} from "./session-state.ts";
|
|
27
33
|
|
|
28
34
|
/** Inline adapter normalizer (mirrors canonical-emit.normalizeAdapter), kept
|
|
29
35
|
* local so the writer's only cross-module dep is the in-process emit(). */
|
|
@@ -33,14 +39,13 @@ function adapterOf(platform: string | undefined): "claude-code" | "cursor" | "co
|
|
|
33
39
|
return "claude-code";
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the same platform value projection would have written. */
|
|
42
|
+
/** Preserve the canonical adapter id in heartbeat state. Mirrors
|
|
43
|
+
* heartbeat-projector.adapterToPlatform so a healed heartbeat carries the same
|
|
44
|
+
* platform value projection would have written. */
|
|
40
45
|
function adapterToPlatform(adapter: string | undefined): string {
|
|
41
46
|
if (adapter === "cursor") return "cursor";
|
|
42
47
|
if (adapter === "codex") return "codex";
|
|
43
|
-
return "
|
|
48
|
+
return "claude-code";
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
/**
|
|
@@ -87,6 +92,23 @@ export interface Heartbeat {
|
|
|
87
92
|
files_touched: string[];
|
|
88
93
|
task?: string;
|
|
89
94
|
task_updated_at?: string | null;
|
|
95
|
+
activity?: AgentActivity;
|
|
96
|
+
activity_updated_at?: string;
|
|
97
|
+
activity_source?: string;
|
|
98
|
+
task_state?: TaskState;
|
|
99
|
+
task_state_updated_at?: string;
|
|
100
|
+
task_state_reason?: string;
|
|
101
|
+
/** Session name built on the first non-empty set-task (never rebuilt). Its
|
|
102
|
+
* presence is the "this session has been named" signal the prompt-context
|
|
103
|
+
* nudge and the Stop-hook naming rule key on. */
|
|
104
|
+
suggested_session_name?: string;
|
|
105
|
+
/** Stamped by turn.stop once the suggested name is seen in assistant reply
|
|
106
|
+
* text, ending the per-turn transcript scan. */
|
|
107
|
+
session_name_seen_at?: string;
|
|
108
|
+
/** WHICH name that sighting was for. The scan is skipped only while this
|
|
109
|
+
* matches the current suggested name, so a re-minted name is detectable
|
|
110
|
+
* again rather than being suppressed by the earlier sighting. */
|
|
111
|
+
session_name_seen_for?: string;
|
|
90
112
|
last_status_at?: string;
|
|
91
113
|
turn_summary?: string | null;
|
|
92
114
|
turn_summary_updated_at?: string | null;
|
|
@@ -95,6 +117,7 @@ export interface Heartbeat {
|
|
|
95
117
|
last_tool_at?: string;
|
|
96
118
|
current_turn_id?: string;
|
|
97
119
|
parent_instance_id?: string;
|
|
120
|
+
workflow_run_id?: string;
|
|
98
121
|
[extra: string]: unknown;
|
|
99
122
|
}
|
|
100
123
|
|
|
@@ -123,6 +146,32 @@ export function readHeartbeat(coordRoot: string, instanceId: string): Heartbeat
|
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
148
|
|
|
149
|
+
/** Apply one evidence event to the live state fields without draining the
|
|
150
|
+
* global event stream. This keeps permission prompts current while avoiding
|
|
151
|
+
* unrelated projector side effects before a tool authorization verdict. */
|
|
152
|
+
export function stampSessionStateEvent(
|
|
153
|
+
coordRoot: string,
|
|
154
|
+
instanceId: string,
|
|
155
|
+
event: Omit<SessionStateEvidenceEvent, "data"> & { data: unknown },
|
|
156
|
+
): Heartbeat | null {
|
|
157
|
+
return mutate(coordRoot, instanceId, (heartbeat) => {
|
|
158
|
+
const data =
|
|
159
|
+
typeof event.data === "object" && event.data !== null && !Array.isArray(event.data)
|
|
160
|
+
? (event.data as Record<string, unknown>)
|
|
161
|
+
: {};
|
|
162
|
+
const state = applySessionStateEvent(heartbeat, { ...event, data });
|
|
163
|
+
return {
|
|
164
|
+
...heartbeat,
|
|
165
|
+
activity: state.activity,
|
|
166
|
+
activity_updated_at: state.activity_updated_at,
|
|
167
|
+
activity_source: state.activity_source,
|
|
168
|
+
task_state: state.task_state,
|
|
169
|
+
task_state_updated_at: state.task_state_updated_at,
|
|
170
|
+
task_state_reason: state.task_state_reason,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
126
175
|
function mutate(
|
|
127
176
|
coordRoot: string,
|
|
128
177
|
instanceId: string,
|
|
@@ -139,18 +188,66 @@ function mutate(
|
|
|
139
188
|
export function setTask(coordRoot: string, instanceId: string, task: string): Heartbeat | null {
|
|
140
189
|
return mutate(coordRoot, instanceId, (hb) => {
|
|
141
190
|
const cleared = !task || task.length === 0;
|
|
191
|
+
// Name the session on its first NON-EMPTY declaration. Keyed on the
|
|
192
|
+
// suggested_session_name stamp, not task_updated_at, so a bare clear as
|
|
193
|
+
// the first call never burns the naming window. Subagents and workflow
|
|
194
|
+
// children have no human-owned tab to rename, so they are never named.
|
|
195
|
+
const humanFacing = hb.kind !== "subagent" && hb.kind !== "transient" && !hb.workflow_run_id;
|
|
196
|
+
const built =
|
|
197
|
+
!cleared && !hb.suggested_session_name && humanFacing
|
|
198
|
+
? buildSuggestedName(hb.name ?? "unknown", [task])
|
|
199
|
+
: null;
|
|
142
200
|
return {
|
|
143
201
|
...hb,
|
|
144
202
|
task: cleared ? undefined : task,
|
|
145
203
|
task_updated_at: nowIsoSeconds(),
|
|
204
|
+
...(built ? { suggested_session_name: built.suggestedName } : {}),
|
|
146
205
|
};
|
|
147
206
|
});
|
|
148
207
|
}
|
|
149
208
|
|
|
150
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Build the copy-pasteable session name from the coord identity + the agent's
|
|
211
|
+
* description parts. Pure (no coord-state reads) so it's unit-testable; collapses
|
|
212
|
+
* internal whitespace and trims. Returns null when the description is empty.
|
|
213
|
+
*/
|
|
214
|
+
export function buildSuggestedName(
|
|
215
|
+
agentName: string,
|
|
216
|
+
descriptionParts: string[],
|
|
217
|
+
): { suggestedName: string; description: string } | null {
|
|
218
|
+
const description = descriptionParts.join(" ").replace(/\s+/g, " ").trim();
|
|
219
|
+
if (!description) return null;
|
|
220
|
+
const name = agentName?.trim() || "unknown";
|
|
221
|
+
return { suggestedName: `Agent ${name} - ${description}`, description };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Build the operator-facing title projected from an explicit task lifecycle. */
|
|
225
|
+
export function buildLifecycleSuggestedName(
|
|
226
|
+
agentName: string,
|
|
227
|
+
task: string | undefined,
|
|
228
|
+
state: TaskState,
|
|
229
|
+
): string | null {
|
|
230
|
+
const built = buildSuggestedName(agentName, task ? [task] : []);
|
|
231
|
+
if (!built) return null;
|
|
232
|
+
if (state === "blocked") return `[BLOCKED] - ${built.suggestedName}`;
|
|
233
|
+
if (state === "done") return `[DONE] - ${built.suggestedName}`;
|
|
234
|
+
return built.suggestedName;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Stamp the sighting once the suggested name has been observed in assistant
|
|
238
|
+
* reply text; later turns skip the transcript scan. Records WHICH name was seen,
|
|
239
|
+
* because the skip is only valid for that one: if a later set-task mints a
|
|
240
|
+
* different suggested name, a bare "already seen" stamp would suppress the scan
|
|
241
|
+
* forever and leave the Stop-hook naming rule permanently unsatisfiable. */
|
|
242
|
+
export function stampSessionNameSeen(
|
|
243
|
+
coordRoot: string,
|
|
244
|
+
instanceId: string,
|
|
245
|
+
name?: string,
|
|
246
|
+
): Heartbeat | null {
|
|
151
247
|
return mutate(coordRoot, instanceId, (hb) => ({
|
|
152
248
|
...hb,
|
|
153
|
-
|
|
249
|
+
session_name_seen_at: hb.session_name_seen_at ?? nowIsoSeconds(),
|
|
250
|
+
...(name ? { session_name_seen_for: name } : {}),
|
|
154
251
|
}));
|
|
155
252
|
}
|
|
156
253
|
|
|
@@ -200,9 +297,8 @@ export interface GroupUnclaimHit {
|
|
|
200
297
|
* durable `claim.release` events — a file-only prune is silently reverted by
|
|
201
298
|
* the next projector replay.
|
|
202
299
|
*
|
|
203
|
-
*
|
|
204
|
-
* repo-relative entries
|
|
205
|
-
* so both sides are normalized before comparing — an exact-string match
|
|
300
|
+
* Tool payloads and release calls can supply either absolute-under-coordRoot or
|
|
301
|
+
* canonical repo-relative entries, so both sides are normalized before comparing. An exact-string match
|
|
206
302
|
* silently no-ops on the mixed-form case and the claim never releases.
|
|
207
303
|
*
|
|
208
304
|
* This is the Option B fix for post-commit's pid-map attribution hole: a
|
|
@@ -264,7 +360,7 @@ export function healPidmap(coordRoot: string, instanceId: string, pid: number):
|
|
|
264
360
|
const dir = join(coordRoot, ".harnery", "pid-map");
|
|
265
361
|
mkdirSync(dir, { recursive: true });
|
|
266
362
|
const hb = readHeartbeat(coordRoot, instanceId);
|
|
267
|
-
const platform = hb?.platform ?? "
|
|
363
|
+
const platform = hb?.platform ?? "claude-code";
|
|
268
364
|
const pmPath = join(dir, String(pid));
|
|
269
365
|
// Drift guard: only write + emit telemetry when the entry is missing or
|
|
270
366
|
// points at a different owner. Without this, a per-tool-call heal would
|
|
@@ -291,6 +387,7 @@ export function healHeartbeat(
|
|
|
291
387
|
sessionId?: string,
|
|
292
388
|
model?: string,
|
|
293
389
|
adapter?: string,
|
|
390
|
+
opts?: { forkedFrom?: string },
|
|
294
391
|
): Heartbeat | null {
|
|
295
392
|
const path = heartbeatPath(coordRoot, instanceId);
|
|
296
393
|
if (existsSync(path)) {
|
|
@@ -306,14 +403,28 @@ export function healHeartbeat(
|
|
|
306
403
|
let agentId = "";
|
|
307
404
|
try {
|
|
308
405
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
309
|
-
const
|
|
310
|
-
const resolved = resolveName(coordRoot, instanceId, sessionId);
|
|
406
|
+
const names = require("./names.ts") as typeof import("./names.ts");
|
|
407
|
+
const resolved = names.resolveName(coordRoot, instanceId, sessionId);
|
|
311
408
|
if (resolved) {
|
|
312
409
|
name = resolved.name;
|
|
313
410
|
kind = resolved.kind;
|
|
314
411
|
// An explicitly assumed session carries its durable persona UUID in
|
|
315
412
|
// name-history. Native subagents continue to use instance_id.
|
|
316
413
|
agentId = resolved.agent_id ?? (resolved.kind === "subagent" ? instanceId : "");
|
|
414
|
+
} else if (!sessionId || sessionId === instanceId) {
|
|
415
|
+
// A main session with NO history at all never fired session.start —
|
|
416
|
+
// the CC fork flow (SessionStart fires under the parent's id before
|
|
417
|
+
// the fork id is minted), or a partially-wired adapter. Mint its pool
|
|
418
|
+
// name here instead of leaving a nameless heartbeat, and stamp the
|
|
419
|
+
// detected fork lineage while we're at it. A sessionId that differs
|
|
420
|
+
// from instanceId is left alone: that shape is a subagent whose parent
|
|
421
|
+
// is also unknown, and guessing "session" would be wrong.
|
|
422
|
+
name = names.assignName(
|
|
423
|
+
coordRoot,
|
|
424
|
+
instanceId,
|
|
425
|
+
"session",
|
|
426
|
+
opts?.forkedFrom ? { forkedFrom: opts.forkedFrom } : undefined,
|
|
427
|
+
);
|
|
317
428
|
}
|
|
318
429
|
} catch {
|
|
319
430
|
/* names module unavailable, fall back to empty */
|
|
@@ -330,19 +441,21 @@ export function healHeartbeat(
|
|
|
330
441
|
started_at: now,
|
|
331
442
|
last_heartbeat: now,
|
|
332
443
|
files_touched: [],
|
|
333
|
-
// Default to
|
|
444
|
+
// Default to claude-code only when the caller can't tell us the adapter
|
|
334
445
|
// (e.g. manual `harn agents heal`). The live tool.pre_use heal threads the
|
|
335
446
|
// detected adapter so a pruned Cursor/Codex heartbeat is recreated with
|
|
336
|
-
// the correct platform instead of being mislabeled
|
|
447
|
+
// the correct platform instead of being mislabeled claude-code.
|
|
337
448
|
platform: adapterToPlatform(adapter),
|
|
338
449
|
};
|
|
339
450
|
atomicWrite(path, JSON.stringify(hb, null, 2));
|
|
340
451
|
// Write-only telemetry: only the actual-recreate branch reaches here (the
|
|
341
452
|
// already-alive case returned above), so this records exactly the heals that
|
|
342
|
-
// happened.
|
|
453
|
+
// happened. Recorded fork lineage rides the event too, so derived readers
|
|
454
|
+
// rebuilding from the ledger converge with .name-history.
|
|
343
455
|
emitHealthHeal(coordRoot, "health.heartbeat_heal", instanceId, hb, {
|
|
344
456
|
reason: "missing",
|
|
345
457
|
kind: "heartbeat",
|
|
458
|
+
...(opts?.forkedFrom ? { forked_from: opts.forkedFrom } : {}),
|
|
346
459
|
});
|
|
347
460
|
return hb;
|
|
348
461
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** What the agent is doing now. `unknown` is evidence-safe, not an error. */
|
|
2
|
+
export type AgentActivity = "unknown" | "working" | "needs_input" | "idle";
|
|
3
|
+
|
|
4
|
+
/** Whether the declared task remains open. Activity never changes this axis. */
|
|
5
|
+
export type TaskState = "active" | "blocked" | "done";
|
|
6
|
+
|
|
7
|
+
/** Raw fields stored in a live heartbeat and rebuilt from canonical events. */
|
|
8
|
+
export interface SessionStateFields {
|
|
9
|
+
activity?: AgentActivity;
|
|
10
|
+
activity_updated_at?: string;
|
|
11
|
+
activity_source?: string;
|
|
12
|
+
task_state?: TaskState;
|
|
13
|
+
task_state_updated_at?: string;
|
|
14
|
+
task_state_reason?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Reader-facing state with compatibility defaults applied. */
|
|
18
|
+
export interface SessionState extends SessionStateFields {
|
|
19
|
+
activity: AgentActivity;
|
|
20
|
+
task_state: TaskState;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionStateSelector {
|
|
24
|
+
instance_id?: string;
|
|
25
|
+
session_id?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SessionStateEvidenceEvent {
|
|
29
|
+
event_type: string;
|
|
30
|
+
ts: string;
|
|
31
|
+
data?: Record<string, unknown>;
|
|
32
|
+
instance_id?: string;
|
|
33
|
+
session_id?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TERMINAL_ACTIVITY_EVENTS = new Set(["session.end", "subagent.stop", "turn.stop"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply one canonical event to the two independent state axes.
|
|
40
|
+
*
|
|
41
|
+
* The table is deliberately evidence-only. Output chunks, post-tool events,
|
|
42
|
+
* narration, coordination commands, and elapsed time cannot clear an observed
|
|
43
|
+
* input wait. A command start only counts as progress when the session was
|
|
44
|
+
* already inside an open turn (`working` or `needs_input`).
|
|
45
|
+
*/
|
|
46
|
+
export function applySessionStateEvent(
|
|
47
|
+
current: Readonly<SessionStateFields>,
|
|
48
|
+
event: SessionStateEvidenceEvent,
|
|
49
|
+
): SessionStateFields {
|
|
50
|
+
const next: SessionStateFields = { ...current };
|
|
51
|
+
|
|
52
|
+
if (event.event_type === "session.start" || event.event_type === "subagent.start") {
|
|
53
|
+
setActivity(next, "idle", event);
|
|
54
|
+
} else if (event.event_type === "user_prompt.submit" || event.event_type === "tool.pre_use") {
|
|
55
|
+
setActivity(next, "working", event);
|
|
56
|
+
} else if (event.event_type === "interaction.input_requested") {
|
|
57
|
+
setActivity(next, "needs_input", event);
|
|
58
|
+
} else if (
|
|
59
|
+
event.event_type === "command.start" &&
|
|
60
|
+
(current.activity === "working" || current.activity === "needs_input")
|
|
61
|
+
) {
|
|
62
|
+
setActivity(next, "working", event);
|
|
63
|
+
} else if (TERMINAL_ACTIVITY_EVENTS.has(event.event_type)) {
|
|
64
|
+
setActivity(next, "idle", event);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (event.event_type === "state.task_state") {
|
|
68
|
+
const data = event.data ?? {};
|
|
69
|
+
const state = field(data, "state");
|
|
70
|
+
if (state === "active" || state === "blocked" || state === "done") {
|
|
71
|
+
next.task_state = state;
|
|
72
|
+
next.task_state_updated_at = event.ts;
|
|
73
|
+
const reason = field(data, "reason");
|
|
74
|
+
if (typeof reason === "string" && reason.length > 0) {
|
|
75
|
+
next.task_state_reason = reason;
|
|
76
|
+
} else {
|
|
77
|
+
delete next.task_state_reason;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Rebuild both axes from canonical events after a heartbeat has disappeared.
|
|
87
|
+
* Events may be the whole ledger or an already-filtered session slice.
|
|
88
|
+
*/
|
|
89
|
+
export function foldSessionState(
|
|
90
|
+
events: readonly SessionStateEvidenceEvent[],
|
|
91
|
+
selector: SessionStateSelector = {},
|
|
92
|
+
): SessionState {
|
|
93
|
+
const ordered = events
|
|
94
|
+
.map((event, index) => ({ event, index }))
|
|
95
|
+
.filter(({ event }) => matches(event, selector))
|
|
96
|
+
.sort((a, b) => timestamp(a.event) - timestamp(b.event) || a.index - b.index);
|
|
97
|
+
|
|
98
|
+
let fields: SessionStateFields = {};
|
|
99
|
+
for (const { event } of ordered) fields = applySessionStateEvent(fields, event);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
...fields,
|
|
103
|
+
activity: fields.activity ?? "unknown",
|
|
104
|
+
task_state: fields.task_state ?? "active",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setActivity(
|
|
109
|
+
target: SessionStateFields,
|
|
110
|
+
activity: AgentActivity,
|
|
111
|
+
event: SessionStateEvidenceEvent,
|
|
112
|
+
): void {
|
|
113
|
+
target.activity = activity;
|
|
114
|
+
target.activity_updated_at = event.ts;
|
|
115
|
+
target.activity_source = event.event_type;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function matches(event: SessionStateEvidenceEvent, selector: SessionStateSelector): boolean {
|
|
119
|
+
if (selector.instance_id && event.instance_id !== selector.instance_id) return false;
|
|
120
|
+
if (selector.session_id && event.session_id !== selector.session_id) return false;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function timestamp(event: SessionStateEvidenceEvent): number {
|
|
125
|
+
const parsed = Date.parse(event.ts);
|
|
126
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function field(data: Record<string, unknown>, key: string): unknown {
|
|
130
|
+
return data[key];
|
|
131
|
+
}
|
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;
|