harnery 0.8.0 → 0.10.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 +8 -0
- package/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +244 -13
- package/dist/commands/backup.d.ts +5 -4
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +15 -14
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/claude-desktop.d.ts +19 -0
- package/dist/commands/claude-desktop.d.ts.map +1 -0
- package/dist/commands/claude-desktop.js +168 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +149 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +37 -0
- package/dist/commands/eml.d.ts +32 -0
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +16 -3
- package/dist/commands/grep.d.ts +35 -2
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +427 -139
- package/dist/commands/harness.d.ts +11 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +118 -0
- package/dist/commands/init.d.ts +15 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +125 -14
- package/dist/commands/presence.d.ts +9 -4
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +88 -5
- package/dist/commands/relay.d.ts +9 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +143 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +5 -0
- package/dist/commands/workflow.d.ts +4 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +82 -0
- package/dist/core/agents/canonical-emit.d.ts +15 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +41 -3
- package/dist/core/agents/cli.js +14 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +44 -3
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +26 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +26 -1
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +4 -37
- package/dist/core/agents/rules/stop-hook.d.ts +8 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +8 -0
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +16 -43
- package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +24 -4
- package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +24 -7
- package/dist/core/agents/state/names.d.ts +38 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +46 -7
- package/dist/core/agents/state/pidmap.d.ts +5 -1
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +58 -2
- package/dist/core/agents/state/stale-sweep.d.ts +3 -2
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +6 -6
- package/dist/core/config.d.ts +77 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +210 -25
- package/dist/core/context/index.d.ts +144 -0
- package/dist/core/context/index.d.ts.map +1 -0
- package/dist/core/context/index.js +380 -0
- package/dist/core/harnesses/bench.d.ts +29 -0
- package/dist/core/harnesses/bench.d.ts.map +1 -0
- package/dist/core/harnesses/bench.js +151 -0
- package/dist/core/harnesses/index.d.ts +9 -0
- package/dist/core/harnesses/index.d.ts.map +1 -0
- package/dist/core/harnesses/index.js +4 -0
- package/dist/core/harnesses/profiles.d.ts +62 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -0
- package/dist/core/harnesses/profiles.js +115 -0
- package/dist/core/harnesses/registry.d.ts +15 -0
- package/dist/core/harnesses/registry.d.ts.map +1 -0
- package/dist/core/harnesses/registry.js +113 -0
- package/dist/core/harnesses/types.d.ts +64 -0
- package/dist/core/harnesses/types.d.ts.map +1 -0
- package/dist/core/harnesses/types.js +20 -0
- package/dist/core/hooks/cli.js +309 -66
- package/dist/core/hooks/events/schema.d.ts +42 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts +7 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +4 -0
- package/dist/core/hooks/harness/parse.d.ts +1 -1
- package/dist/core/hooks/harness/parse.d.ts.map +1 -1
- package/dist/core/hooks/harness/parse.js +4 -0
- package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +28 -6
- package/dist/core/presence/blob.d.ts +40 -0
- package/dist/core/presence/blob.d.ts.map +1 -0
- package/dist/core/presence/blob.js +91 -0
- package/dist/core/presence/git.d.ts +68 -0
- package/dist/core/presence/git.d.ts.map +1 -0
- package/dist/core/presence/git.js +173 -0
- package/dist/core/presence/index.d.ts +74 -0
- package/dist/core/presence/index.d.ts.map +1 -0
- package/dist/core/presence/index.js +224 -0
- package/dist/core/presence/relay-client.d.ts +39 -0
- package/dist/core/presence/relay-client.d.ts.map +1 -0
- package/dist/core/presence/relay-client.js +295 -0
- package/dist/core/presence/relay-protocol.d.ts +94 -0
- package/dist/core/presence/relay-protocol.d.ts.map +1 -0
- package/dist/core/presence/relay-protocol.js +169 -0
- package/dist/core/workflow/billing.d.ts +48 -0
- package/dist/core/workflow/billing.d.ts.map +1 -0
- package/dist/core/workflow/billing.js +108 -0
- package/dist/core/workflow/child-env.d.ts +30 -0
- package/dist/core/workflow/child-env.d.ts.map +1 -0
- package/dist/core/workflow/child-env.js +43 -0
- package/dist/core/workflow/engine.d.ts +21 -0
- package/dist/core/workflow/engine.d.ts.map +1 -0
- package/dist/core/workflow/engine.js +349 -0
- package/dist/core/workflow/harnesses.d.ts +17 -0
- package/dist/core/workflow/harnesses.d.ts.map +1 -0
- package/dist/core/workflow/harnesses.js +21 -0
- package/dist/core/workflow/spawn-claude.d.ts +25 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
- package/dist/core/workflow/spawn-claude.js +103 -0
- package/dist/core/workflow/spawn-codex.d.ts +22 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
- package/dist/core/workflow/spawn-codex.js +88 -0
- package/dist/core/workflow/spawn-cursor.d.ts +29 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
- package/dist/core/workflow/spawn-cursor.js +91 -0
- package/dist/core/workflow/types.d.ts +152 -0
- package/dist/core/workflow/types.d.ts.map +1 -0
- package/dist/core/workflow/types.js +9 -0
- package/dist/core/workflow/validate.d.ts +15 -0
- package/dist/core/workflow/validate.d.ts.map +1 -0
- package/dist/core/workflow/validate.js +70 -0
- package/dist/lib/browser/client.d.ts +16 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +22 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -0
- package/dist/lib/browser/launch-args.d.ts +21 -0
- package/dist/lib/browser/launch-args.d.ts.map +1 -0
- package/dist/lib/browser/launch-args.js +32 -0
- package/dist/lib/claude-desktop.d.ts +105 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -0
- package/dist/lib/claude-desktop.js +217 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +5 -2
- package/dist/lib/identities/assume.d.ts +51 -0
- package/dist/lib/identities/assume.d.ts.map +1 -0
- package/dist/lib/identities/assume.js +274 -0
- package/dist/lib/identities/index.d.ts +7 -7
- package/dist/lib/identities/index.d.ts.map +1 -1
- package/dist/lib/identities/index.js +24 -24
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +15 -2
- package/package.json +12 -1
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +8 -0
- package/src/commands/agents.ts +280 -14
- package/src/commands/backup.ts +17 -16
- package/src/commands/browse.ts +32 -0
- package/src/commands/claude-desktop.ts +215 -0
- package/src/commands/context.ts +174 -1
- package/src/commands/doctor.ts +44 -0
- package/src/commands/eml.ts +22 -4
- package/src/commands/grep.ts +535 -142
- package/src/commands/harness.ts +147 -0
- package/src/commands/init.ts +138 -17
- package/src/commands/presence.ts +111 -5
- package/src/commands/relay.ts +160 -0
- package/src/commands/sync.ts +4 -0
- package/src/commands/workflow.ts +123 -0
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +15 -6
- package/src/core/agents/render/prompt-context.ts +48 -3
- package/src/core/agents/render/session-context.ts +27 -1
- package/src/core/agents/rules/claim-conflict.ts +26 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/rules/stop-hook.ts +17 -0
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +23 -2
- package/src/core/agents/state/heartbeat-writer.ts +33 -7
- package/src/core/agents/state/names.ts +68 -9
- package/src/core/agents/state/pidmap.ts +59 -2
- package/src/core/agents/state/stale-sweep.ts +6 -11
- package/src/core/config.ts +271 -24
- package/src/core/context/index.ts +575 -0
- package/src/core/harnesses/bench.ts +214 -0
- package/src/core/harnesses/index.ts +31 -0
- package/src/core/harnesses/profiles.ts +141 -0
- package/src/core/harnesses/registry.ts +140 -0
- package/src/core/harnesses/types.ts +90 -0
- package/src/core/hooks/cli.ts +361 -65
- package/src/core/hooks/events/schema.ts +72 -0
- package/src/core/hooks/harness/events.ts +11 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/hooks/resolve/coord-root.ts +28 -6
- package/src/core/presence/blob.ts +125 -0
- package/src/core/presence/git.ts +191 -0
- package/src/core/presence/index.ts +274 -0
- package/src/core/presence/relay-client.ts +322 -0
- package/src/core/presence/relay-protocol.ts +246 -0
- package/src/core/workflow/billing.ts +152 -0
- package/src/core/workflow/child-env.ts +47 -0
- package/src/core/workflow/engine.ts +403 -0
- package/src/core/workflow/harnesses.ts +33 -0
- package/src/core/workflow/spawn-claude.ts +118 -0
- package/src/core/workflow/spawn-codex.ts +93 -0
- package/src/core/workflow/spawn-cursor.ts +108 -0
- package/src/core/workflow/types.ts +160 -0
- package/src/core/workflow/validate.ts +75 -0
- package/src/lib/browser/client.ts +37 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/launch-args.ts +33 -0
- package/src/lib/claude-desktop.ts +293 -0
- package/src/lib/docs-lint.ts +5 -2
- package/src/lib/identities/assume.ts +363 -0
- package/src/lib/identities/index.ts +28 -24
- package/src/lib/instructions/templates.ts +16 -2
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context continuity: durable, structured state around native harness
|
|
3
|
+
* compaction. Harnery does not decide when a harness compacts. It records
|
|
4
|
+
* context telemetry when the harness exposes it, checkpoints external work
|
|
5
|
+
* state before compaction, and renders a verified recovery briefing after it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import type { Harness } from "../hooks/events/schema.ts";
|
|
13
|
+
|
|
14
|
+
export const CONTEXT_SCHEMA_VERSION = 1 as const;
|
|
15
|
+
export const MAX_CAPSULE_BYTES = 32 * 1024;
|
|
16
|
+
const MAX_SNAPSHOT_PATHS = 15;
|
|
17
|
+
const MAX_SNAPSHOT_PATH_CHARS = 500;
|
|
18
|
+
|
|
19
|
+
export type ContextSampleSource = "hook" | "native_event" | "result" | "transcript" | "estimate";
|
|
20
|
+
export type ContextSampleConfidence = "exact" | "reported" | "estimated";
|
|
21
|
+
export type ContextPhase = "observing" | "checkpointed" | "recovered" | "degraded";
|
|
22
|
+
export type CheckpointReason = "manual" | "pressure" | "pre_compact" | "session_end";
|
|
23
|
+
|
|
24
|
+
export interface ContextSample {
|
|
25
|
+
session_id: string;
|
|
26
|
+
harness: Harness;
|
|
27
|
+
model?: string;
|
|
28
|
+
used_tokens?: number;
|
|
29
|
+
window_tokens?: number;
|
|
30
|
+
used_percent?: number;
|
|
31
|
+
source: ContextSampleSource;
|
|
32
|
+
confidence: ContextSampleConfidence;
|
|
33
|
+
observed_at: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ContextContinuityState {
|
|
37
|
+
schema_version: typeof CONTEXT_SCHEMA_VERSION;
|
|
38
|
+
session_id: string;
|
|
39
|
+
instance_id: string;
|
|
40
|
+
phase: ContextPhase;
|
|
41
|
+
generation: number;
|
|
42
|
+
latest_capsule?: string;
|
|
43
|
+
latest_context?: ContextSample;
|
|
44
|
+
updated_at: string;
|
|
45
|
+
recovered_at?: string;
|
|
46
|
+
compaction_completed_at?: string;
|
|
47
|
+
degraded_reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RepoSnapshot {
|
|
51
|
+
cwd: string;
|
|
52
|
+
root?: string;
|
|
53
|
+
branch?: string;
|
|
54
|
+
head?: string;
|
|
55
|
+
dirty_paths: string[];
|
|
56
|
+
dirty_paths_truncated?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ContinuityCapsule {
|
|
60
|
+
schema_version: typeof CONTEXT_SCHEMA_VERSION;
|
|
61
|
+
capsule_id: string;
|
|
62
|
+
generation: number;
|
|
63
|
+
created_at: string;
|
|
64
|
+
reason: CheckpointReason;
|
|
65
|
+
session: {
|
|
66
|
+
session_id: string;
|
|
67
|
+
instance_id: string;
|
|
68
|
+
harness: Harness;
|
|
69
|
+
model?: string;
|
|
70
|
+
};
|
|
71
|
+
context?: ContextSample;
|
|
72
|
+
work: {
|
|
73
|
+
task?: string;
|
|
74
|
+
turn_summary?: string;
|
|
75
|
+
continuation_note?: string;
|
|
76
|
+
files_held: string[];
|
|
77
|
+
files_held_truncated?: boolean;
|
|
78
|
+
last_tool?: string;
|
|
79
|
+
last_tool_target?: string;
|
|
80
|
+
};
|
|
81
|
+
repo: RepoSnapshot;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ExtractContextSampleOptions {
|
|
85
|
+
sessionId: string;
|
|
86
|
+
harness: Harness;
|
|
87
|
+
model?: string;
|
|
88
|
+
source?: ContextSampleSource;
|
|
89
|
+
confidence?: ContextSampleConfidence;
|
|
90
|
+
observedAt?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CheckpointContextInput {
|
|
94
|
+
sessionId: string;
|
|
95
|
+
instanceId: string;
|
|
96
|
+
harness: Harness;
|
|
97
|
+
cwd: string;
|
|
98
|
+
reason: CheckpointReason;
|
|
99
|
+
model?: string;
|
|
100
|
+
continuationNote?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface CheckpointContextResult {
|
|
104
|
+
capsule: ContinuityCapsule;
|
|
105
|
+
path: string;
|
|
106
|
+
state: ContextContinuityState;
|
|
107
|
+
reused: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RecoverContextResult {
|
|
111
|
+
state: ContextContinuityState;
|
|
112
|
+
capsule: ContinuityCapsule | null;
|
|
113
|
+
briefing: string;
|
|
114
|
+
recovered: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PreparedContextRecovery {
|
|
118
|
+
state: ContextContinuityState;
|
|
119
|
+
capsule: ContinuityCapsule;
|
|
120
|
+
briefing: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface HeartbeatSnapshot {
|
|
124
|
+
model?: string;
|
|
125
|
+
task?: string;
|
|
126
|
+
turn_summary?: string;
|
|
127
|
+
files_touched?: string[];
|
|
128
|
+
last_tool?: string;
|
|
129
|
+
last_tool_target?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Parse the context-window shapes exposed by current harness hook/status data. */
|
|
133
|
+
export function extractContextSample(
|
|
134
|
+
payload: Record<string, unknown>,
|
|
135
|
+
options: ExtractContextSampleOptions,
|
|
136
|
+
): ContextSample | null {
|
|
137
|
+
const contextValue = payload.context_window;
|
|
138
|
+
const context = record(contextValue);
|
|
139
|
+
const usage = record(context?.current_usage) ?? record(payload.usage);
|
|
140
|
+
|
|
141
|
+
const windowTokens =
|
|
142
|
+
number(context?.context_window_size) ??
|
|
143
|
+
number(context?.window_tokens) ??
|
|
144
|
+
number(payload.context_window_size) ??
|
|
145
|
+
(typeof contextValue === "number" ? finiteNonNegative(contextValue) : undefined);
|
|
146
|
+
|
|
147
|
+
const reportedUsed =
|
|
148
|
+
number(context?.used_tokens) ?? number(context?.input_tokens) ?? number(payload.used_tokens);
|
|
149
|
+
const usageParts = usage
|
|
150
|
+
? [
|
|
151
|
+
number(usage.input_tokens),
|
|
152
|
+
number(usage.cache_read_input_tokens),
|
|
153
|
+
number(usage.cache_creation_input_tokens),
|
|
154
|
+
]
|
|
155
|
+
: [];
|
|
156
|
+
const hasUsageParts = usageParts.some((part) => part !== undefined);
|
|
157
|
+
const usedTokens =
|
|
158
|
+
reportedUsed ??
|
|
159
|
+
(hasUsageParts
|
|
160
|
+
? usageParts.reduce<number>((total, part) => total + (part ?? 0), 0)
|
|
161
|
+
: undefined);
|
|
162
|
+
|
|
163
|
+
let usedPercent = number(context?.used_percentage) ?? number(payload.used_percentage);
|
|
164
|
+
if (usedPercent === undefined && usedTokens !== undefined && windowTokens && windowTokens > 0) {
|
|
165
|
+
usedPercent = (usedTokens / windowTokens) * 100;
|
|
166
|
+
}
|
|
167
|
+
if (usedPercent !== undefined) usedPercent = clamp(usedPercent, 0, 100);
|
|
168
|
+
|
|
169
|
+
if (usedTokens === undefined && windowTokens === undefined && usedPercent === undefined)
|
|
170
|
+
return null;
|
|
171
|
+
|
|
172
|
+
const model =
|
|
173
|
+
options.model ??
|
|
174
|
+
(typeof payload.model === "string" ? payload.model : undefined) ??
|
|
175
|
+
string(record(payload.model)?.id) ??
|
|
176
|
+
string(record(payload.model)?.display_name);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
session_id: options.sessionId,
|
|
180
|
+
harness: options.harness,
|
|
181
|
+
model,
|
|
182
|
+
used_tokens: usedTokens,
|
|
183
|
+
window_tokens: windowTokens,
|
|
184
|
+
used_percent: usedPercent === undefined ? undefined : round(usedPercent, 2),
|
|
185
|
+
source: options.source ?? "hook",
|
|
186
|
+
confidence: options.confidence ?? "reported",
|
|
187
|
+
observed_at: options.observedAt ?? new Date().toISOString(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Persist a latest context sample. Identical measurements are a no-op. */
|
|
192
|
+
export function recordContextSample(
|
|
193
|
+
coordRoot: string,
|
|
194
|
+
instanceId: string,
|
|
195
|
+
sample: ContextSample,
|
|
196
|
+
): { state: ContextContinuityState; changed: boolean } {
|
|
197
|
+
const current = readContextState(coordRoot, sample.session_id);
|
|
198
|
+
const state =
|
|
199
|
+
current ?? newState({ sessionId: sample.session_id, instanceId, now: sample.observed_at });
|
|
200
|
+
const changed = !sameMeasurement(state.latest_context, sample);
|
|
201
|
+
if (!changed) return { state, changed: false };
|
|
202
|
+
const next: ContextContinuityState = {
|
|
203
|
+
...state,
|
|
204
|
+
instance_id: instanceId,
|
|
205
|
+
latest_context: sample,
|
|
206
|
+
updated_at: sample.observed_at,
|
|
207
|
+
};
|
|
208
|
+
writeState(coordRoot, next);
|
|
209
|
+
return { state: next, changed: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Snapshot external work state. Repeated PreCompact events reuse the existing
|
|
214
|
+
* checkpoint until a matching recovery completes; manual checkpoints always
|
|
215
|
+
* create a new generation.
|
|
216
|
+
*/
|
|
217
|
+
export function checkpointContext(
|
|
218
|
+
coordRoot: string,
|
|
219
|
+
input: CheckpointContextInput,
|
|
220
|
+
): CheckpointContextResult {
|
|
221
|
+
const now = new Date().toISOString();
|
|
222
|
+
const current =
|
|
223
|
+
readContextState(coordRoot, input.sessionId) ??
|
|
224
|
+
newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
|
|
225
|
+
if (input.reason === "pre_compact" && current.phase === "checkpointed") {
|
|
226
|
+
const existing = readLatestCapsule(coordRoot, input.sessionId);
|
|
227
|
+
if (existing && current.latest_capsule) {
|
|
228
|
+
return {
|
|
229
|
+
capsule: existing,
|
|
230
|
+
path: join(coordRoot, ".harnery", current.latest_capsule),
|
|
231
|
+
state: current,
|
|
232
|
+
reused: true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const heartbeat = readHeartbeat(coordRoot, input.instanceId);
|
|
238
|
+
const generation = current.generation + 1;
|
|
239
|
+
const capsule: ContinuityCapsule = {
|
|
240
|
+
schema_version: CONTEXT_SCHEMA_VERSION,
|
|
241
|
+
capsule_id: randomUUID(),
|
|
242
|
+
generation,
|
|
243
|
+
created_at: now,
|
|
244
|
+
reason: input.reason,
|
|
245
|
+
session: {
|
|
246
|
+
session_id: input.sessionId,
|
|
247
|
+
instance_id: input.instanceId,
|
|
248
|
+
harness: input.harness,
|
|
249
|
+
model: input.model ?? current.latest_context?.model ?? heartbeat?.model,
|
|
250
|
+
},
|
|
251
|
+
context: current.latest_context,
|
|
252
|
+
work: buildWorkSnapshot(heartbeat, input.continuationNote),
|
|
253
|
+
repo: snapshotRepo(input.cwd),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const relativePath = capsuleRelativePath(input.sessionId, generation);
|
|
257
|
+
const absolutePath = join(coordRoot, ".harnery", relativePath);
|
|
258
|
+
writeBoundedJson(absolutePath, capsule, MAX_CAPSULE_BYTES);
|
|
259
|
+
const next: ContextContinuityState = {
|
|
260
|
+
...current,
|
|
261
|
+
instance_id: input.instanceId,
|
|
262
|
+
phase: "checkpointed",
|
|
263
|
+
generation,
|
|
264
|
+
latest_capsule: relativePath,
|
|
265
|
+
updated_at: now,
|
|
266
|
+
recovered_at: undefined,
|
|
267
|
+
compaction_completed_at: undefined,
|
|
268
|
+
degraded_reason: undefined,
|
|
269
|
+
};
|
|
270
|
+
writeState(coordRoot, next);
|
|
271
|
+
return { capsule, path: absolutePath, state: next, reused: false };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Prepare a briefing without claiming that a harness has accepted it yet. */
|
|
275
|
+
export function prepareContextRecovery(
|
|
276
|
+
coordRoot: string,
|
|
277
|
+
input: { sessionId: string; instanceId: string; cwd: string },
|
|
278
|
+
): PreparedContextRecovery | null {
|
|
279
|
+
const now = new Date().toISOString();
|
|
280
|
+
const current =
|
|
281
|
+
readContextState(coordRoot, input.sessionId) ??
|
|
282
|
+
newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
|
|
283
|
+
const capsule = readLatestCapsule(coordRoot, input.sessionId);
|
|
284
|
+
if (!capsule) {
|
|
285
|
+
const degraded: ContextContinuityState = {
|
|
286
|
+
...current,
|
|
287
|
+
instance_id: input.instanceId,
|
|
288
|
+
phase: "degraded",
|
|
289
|
+
updated_at: now,
|
|
290
|
+
degraded_reason: "post-compaction signal arrived without a continuity checkpoint",
|
|
291
|
+
};
|
|
292
|
+
writeState(coordRoot, degraded);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const liveRepo = snapshotRepo(input.cwd);
|
|
297
|
+
const briefing = renderRecoveryBriefing(capsule, liveRepo);
|
|
298
|
+
return { state: current, capsule, briefing };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Advance continuity only after the harness context channel accepted the briefing. */
|
|
302
|
+
export function completeContextRecovery(
|
|
303
|
+
coordRoot: string,
|
|
304
|
+
input: { sessionId: string; instanceId: string },
|
|
305
|
+
): ContextContinuityState {
|
|
306
|
+
const now = new Date().toISOString();
|
|
307
|
+
const current =
|
|
308
|
+
readContextState(coordRoot, input.sessionId) ??
|
|
309
|
+
newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
|
|
310
|
+
const recovered: ContextContinuityState = {
|
|
311
|
+
...current,
|
|
312
|
+
instance_id: input.instanceId,
|
|
313
|
+
phase: "recovered",
|
|
314
|
+
updated_at: now,
|
|
315
|
+
recovered_at: now,
|
|
316
|
+
degraded_reason: undefined,
|
|
317
|
+
};
|
|
318
|
+
writeState(coordRoot, recovered);
|
|
319
|
+
return recovered;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Record that the harness, rather than Harnery, reported compaction complete. */
|
|
323
|
+
export function markContextCompactionCompleted(
|
|
324
|
+
coordRoot: string,
|
|
325
|
+
input: { sessionId: string; instanceId: string; observedAt?: string },
|
|
326
|
+
): ContextContinuityState {
|
|
327
|
+
const now = input.observedAt ?? new Date().toISOString();
|
|
328
|
+
const current =
|
|
329
|
+
readContextState(coordRoot, input.sessionId) ??
|
|
330
|
+
newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
|
|
331
|
+
const next: ContextContinuityState = {
|
|
332
|
+
...current,
|
|
333
|
+
instance_id: input.instanceId,
|
|
334
|
+
phase: current.latest_capsule ? current.phase : "degraded",
|
|
335
|
+
compaction_completed_at: now,
|
|
336
|
+
updated_at: now,
|
|
337
|
+
degraded_reason: current.latest_capsule
|
|
338
|
+
? current.degraded_reason
|
|
339
|
+
: "post-compaction signal arrived without a continuity checkpoint",
|
|
340
|
+
};
|
|
341
|
+
writeState(coordRoot, next);
|
|
342
|
+
return next;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Convenience API for callers whose injection is synchronous and guaranteed. */
|
|
346
|
+
export function recoverContext(
|
|
347
|
+
coordRoot: string,
|
|
348
|
+
input: { sessionId: string; instanceId: string; cwd: string },
|
|
349
|
+
): RecoverContextResult {
|
|
350
|
+
const prepared = prepareContextRecovery(coordRoot, input);
|
|
351
|
+
if (!prepared) {
|
|
352
|
+
const state = readContextState(coordRoot, input.sessionId)!;
|
|
353
|
+
return { state, capsule: null, briefing: "", recovered: false };
|
|
354
|
+
}
|
|
355
|
+
const recovered = completeContextRecovery(coordRoot, input);
|
|
356
|
+
return {
|
|
357
|
+
state: recovered,
|
|
358
|
+
capsule: prepared.capsule,
|
|
359
|
+
briefing: prepared.briefing,
|
|
360
|
+
recovered: true,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function readContextState(
|
|
365
|
+
coordRoot: string,
|
|
366
|
+
sessionId: string,
|
|
367
|
+
): ContextContinuityState | null {
|
|
368
|
+
return readJson<ContextContinuityState>(statePath(coordRoot, sessionId));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function readLatestCapsule(coordRoot: string, sessionId: string): ContinuityCapsule | null {
|
|
372
|
+
const state = readContextState(coordRoot, sessionId);
|
|
373
|
+
if (!state?.latest_capsule) return null;
|
|
374
|
+
return readJson<ContinuityCapsule>(join(coordRoot, ".harnery", state.latest_capsule));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function renderRecoveryBriefing(capsule: ContinuityCapsule, liveRepo: RepoSnapshot): string {
|
|
378
|
+
const lines = [
|
|
379
|
+
`[harnery context continuity] Recovered generation ${capsule.generation} after native context compaction.`,
|
|
380
|
+
];
|
|
381
|
+
if (capsule.work.task) lines.push(`Task: ${capsule.work.task}`);
|
|
382
|
+
if (capsule.work.turn_summary) lines.push(`Last progress: ${capsule.work.turn_summary}`);
|
|
383
|
+
if (capsule.work.continuation_note) {
|
|
384
|
+
lines.push(`Continuation note: ${capsule.work.continuation_note}`);
|
|
385
|
+
}
|
|
386
|
+
if (capsule.work.files_held.length > 0) {
|
|
387
|
+
lines.push(`Files held: ${capsule.work.files_held.join(", ")}`);
|
|
388
|
+
}
|
|
389
|
+
if (capsule.work.last_tool) {
|
|
390
|
+
lines.push(
|
|
391
|
+
`Last tool: ${capsule.work.last_tool}${capsule.work.last_tool_target ? ` (${capsule.work.last_tool_target})` : ""}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const drift: string[] = [];
|
|
396
|
+
if (capsule.repo.branch && liveRepo.branch && capsule.repo.branch !== liveRepo.branch) {
|
|
397
|
+
drift.push(`branch changed ${capsule.repo.branch} -> ${liveRepo.branch}`);
|
|
398
|
+
}
|
|
399
|
+
if (capsule.repo.head && liveRepo.head && capsule.repo.head !== liveRepo.head) {
|
|
400
|
+
drift.push(`HEAD changed ${capsule.repo.head.slice(0, 8)} -> ${liveRepo.head.slice(0, 8)}`);
|
|
401
|
+
}
|
|
402
|
+
const beforeDirty = new Set(capsule.repo.dirty_paths);
|
|
403
|
+
const afterDirty = new Set(liveRepo.dirty_paths);
|
|
404
|
+
const added = [...afterDirty].filter((path) => !beforeDirty.has(path));
|
|
405
|
+
const cleared = [...beforeDirty].filter((path) => !afterDirty.has(path));
|
|
406
|
+
if (added.length > 0) drift.push(`new dirty paths: ${added.slice(0, 5).join(", ")}`);
|
|
407
|
+
if (cleared.length > 0) drift.push(`paths no longer dirty: ${cleared.slice(0, 5).join(", ")}`);
|
|
408
|
+
lines.push(
|
|
409
|
+
drift.length > 0
|
|
410
|
+
? `Repository changed since checkpoint: ${drift.join("; ")}. Re-check before editing.`
|
|
411
|
+
: "Repository state matches the checkpoint.",
|
|
412
|
+
);
|
|
413
|
+
return lines.join("\n");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function snapshotRepo(cwd: string): RepoSnapshot {
|
|
417
|
+
const root = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
418
|
+
if (!root) return { cwd, dirty_paths: [] };
|
|
419
|
+
const status = git(cwd, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
|
420
|
+
const dirty = status
|
|
421
|
+
? status
|
|
422
|
+
.split("\n")
|
|
423
|
+
.map((line) => line.trimEnd())
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.map((line) => clampString(line.slice(3).trim(), MAX_SNAPSHOT_PATH_CHARS) ?? "")
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
: [];
|
|
428
|
+
return {
|
|
429
|
+
cwd: clampString(cwd, 2_000) ?? cwd,
|
|
430
|
+
root: clampString(root, 2_000),
|
|
431
|
+
branch: git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
432
|
+
head: git(cwd, ["rev-parse", "HEAD"]),
|
|
433
|
+
dirty_paths: dirty.slice(0, MAX_SNAPSHOT_PATHS),
|
|
434
|
+
...(dirty.length > MAX_SNAPSHOT_PATHS ? { dirty_paths_truncated: true } : {}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function buildWorkSnapshot(
|
|
439
|
+
heartbeat: HeartbeatSnapshot | null,
|
|
440
|
+
continuationNote: string | undefined,
|
|
441
|
+
): ContinuityCapsule["work"] {
|
|
442
|
+
const files = Array.isArray(heartbeat?.files_touched)
|
|
443
|
+
? heartbeat.files_touched
|
|
444
|
+
.map((path) => clampString(path, MAX_SNAPSHOT_PATH_CHARS) ?? "")
|
|
445
|
+
.filter(Boolean)
|
|
446
|
+
: [];
|
|
447
|
+
return {
|
|
448
|
+
task: clampString(heartbeat?.task, 1_000),
|
|
449
|
+
turn_summary: clampString(heartbeat?.turn_summary, 3_000),
|
|
450
|
+
continuation_note: clampString(continuationNote, 3_000),
|
|
451
|
+
files_held: files.slice(0, MAX_SNAPSHOT_PATHS),
|
|
452
|
+
...(files.length > MAX_SNAPSHOT_PATHS ? { files_held_truncated: true } : {}),
|
|
453
|
+
last_tool: clampString(heartbeat?.last_tool, 200),
|
|
454
|
+
last_tool_target: clampString(heartbeat?.last_tool_target, 1_000),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function readHeartbeat(coordRoot: string, instanceId: string): HeartbeatSnapshot | null {
|
|
459
|
+
return readJson<HeartbeatSnapshot>(join(coordRoot, ".harnery", "active", `${instanceId}.json`));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function newState(input: {
|
|
463
|
+
sessionId: string;
|
|
464
|
+
instanceId: string;
|
|
465
|
+
now: string;
|
|
466
|
+
}): ContextContinuityState {
|
|
467
|
+
return {
|
|
468
|
+
schema_version: CONTEXT_SCHEMA_VERSION,
|
|
469
|
+
session_id: input.sessionId,
|
|
470
|
+
instance_id: input.instanceId,
|
|
471
|
+
phase: "observing",
|
|
472
|
+
generation: 0,
|
|
473
|
+
updated_at: input.now,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function writeState(coordRoot: string, state: ContextContinuityState): void {
|
|
478
|
+
writeBoundedJson(statePath(coordRoot, state.session_id), state, MAX_CAPSULE_BYTES);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function statePath(coordRoot: string, sessionId: string): string {
|
|
482
|
+
return join(contextDir(coordRoot, sessionId), "state.json");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function capsuleRelativePath(sessionId: string, generation: number): string {
|
|
486
|
+
return join(
|
|
487
|
+
"context",
|
|
488
|
+
sessionKey(sessionId),
|
|
489
|
+
"capsules",
|
|
490
|
+
`${String(generation).padStart(6, "0")}.json`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function contextDir(coordRoot: string, sessionId: string): string {
|
|
495
|
+
return join(coordRoot, ".harnery", "context", sessionKey(sessionId));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function sessionKey(sessionId: string): string {
|
|
499
|
+
return createHash("sha256").update(sessionId).digest("hex").slice(0, 24);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function writeBoundedJson(path: string, value: unknown, maxBytes: number): void {
|
|
503
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
504
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
505
|
+
if (bytes > maxBytes) {
|
|
506
|
+
throw new Error(`context continuity record exceeds ${maxBytes} bytes (${bytes})`);
|
|
507
|
+
}
|
|
508
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
509
|
+
const temp = `${path}.tmp.${process.pid}.${randomUUID()}`;
|
|
510
|
+
writeFileSync(temp, text, { encoding: "utf8", mode: 0o600 });
|
|
511
|
+
renameSync(temp, path);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function readJson<T>(path: string): T | null {
|
|
515
|
+
if (!existsSync(path)) return null;
|
|
516
|
+
try {
|
|
517
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
518
|
+
} catch {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function git(cwd: string, args: string[]): string | undefined {
|
|
524
|
+
try {
|
|
525
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 2_000 });
|
|
526
|
+
return result.status === 0 ? result.stdout.trim() || undefined : undefined;
|
|
527
|
+
} catch {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function sameMeasurement(a: ContextSample | undefined, b: ContextSample): boolean {
|
|
533
|
+
if (!a) return false;
|
|
534
|
+
return (
|
|
535
|
+
a.harness === b.harness &&
|
|
536
|
+
a.model === b.model &&
|
|
537
|
+
a.used_tokens === b.used_tokens &&
|
|
538
|
+
a.window_tokens === b.window_tokens &&
|
|
539
|
+
a.used_percent === b.used_percent &&
|
|
540
|
+
a.source === b.source &&
|
|
541
|
+
a.confidence === b.confidence
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
546
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
547
|
+
? (value as Record<string, unknown>)
|
|
548
|
+
: undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function string(value: unknown): string | undefined {
|
|
552
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function number(value: unknown): number | undefined {
|
|
556
|
+
return typeof value === "number" ? finiteNonNegative(value) : undefined;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function finiteNonNegative(value: number): number | undefined {
|
|
560
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function clamp(value: number, min: number, max: number): number {
|
|
564
|
+
return Math.min(max, Math.max(min, value));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function round(value: number, precision: number): number {
|
|
568
|
+
const factor = 10 ** precision;
|
|
569
|
+
return Math.round(value * factor) / factor;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function clampString(value: string | undefined, max: number): string | undefined {
|
|
573
|
+
if (!value) return undefined;
|
|
574
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
575
|
+
}
|