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
|
@@ -75,11 +75,15 @@ export interface Heartbeat {
|
|
|
75
75
|
task?: string;
|
|
76
76
|
/** UTC ISO-8601 timestamp when task was last set/cleared. Used by adapters without Stop enforcement to compute staleness. 2026-05-24. */
|
|
77
77
|
task_updated_at?: string | null;
|
|
78
|
+
/** Session name built on the first non-empty set-task; its presence means "this session has been named". 2026-08-09. */
|
|
79
|
+
suggested_session_name?: string;
|
|
80
|
+
/** Stamped by turn.stop once the suggested name is seen in assistant reply text. 2026-08-09. */
|
|
81
|
+
session_name_seen_at?: string;
|
|
78
82
|
/** Auto-generated per-turn summary written by the Stop hook via Haiku. 2026-05-23. */
|
|
79
83
|
turn_summary?: string | null;
|
|
80
84
|
/** UTC ISO-8601 timestamp when turn_summary was last refreshed. */
|
|
81
85
|
turn_summary_updated_at?: string | null;
|
|
82
|
-
/** Hook client: `
|
|
86
|
+
/** Hook client: `claude-code` (default) or `cursor`. Cursor Phase 1. */
|
|
83
87
|
platform?: string;
|
|
84
88
|
}
|
|
85
89
|
|
|
@@ -260,10 +264,10 @@ export function parsePidmapRowOwner(row: string): string {
|
|
|
260
264
|
return tab >= 0 ? trimmed.slice(0, tab) : trimmed;
|
|
261
265
|
}
|
|
262
266
|
|
|
263
|
-
/** Parse platform from a pid-map row; legacy rows default to `
|
|
267
|
+
/** Parse platform from a pid-map row; legacy rows default to `claude-code`. */
|
|
264
268
|
export function parsePidmapRowPlatform(row: string): string {
|
|
265
269
|
const platform = row.trim().split("\t")[1]?.trim();
|
|
266
|
-
return platform || "
|
|
270
|
+
return platform || "claude-code";
|
|
267
271
|
}
|
|
268
272
|
|
|
269
273
|
/** Parse the start token from a pid-map row; rows written before it carry none. */
|
|
@@ -368,7 +372,7 @@ function readPidmapRow(pidmapDir: string, pid: number): string | null {
|
|
|
368
372
|
*
|
|
369
373
|
* Pid-map files are `instance_id` or `instance_id\tplatform` (Cursor Phase 1).
|
|
370
374
|
* Prefer a row whose platform matches `HARNERY_AGENT_COORD_PLATFORM` (default
|
|
371
|
-
* `
|
|
375
|
+
* `claude-code`); otherwise return the first owner seen on the walk.
|
|
372
376
|
*
|
|
373
377
|
* Subagents intentionally do not write pid-map entries; a Bash-tool ppid-walk
|
|
374
378
|
* from inside a subagent therefore resolves to the *parent's* pid-map entry.
|
|
@@ -443,7 +447,7 @@ export function resolveOwnerByPidmap(root: string): {
|
|
|
443
447
|
const pidmapDir = resolve(root, ".harnery", "pid-map");
|
|
444
448
|
if (!existsSync(pidmapDir)) return { owner: null, source: "none" };
|
|
445
449
|
|
|
446
|
-
const prefer = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim() || "
|
|
450
|
+
const prefer = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim() || "claude-code";
|
|
447
451
|
let fallbackOwner: string | null = null;
|
|
448
452
|
let pid: number | null = process.pid;
|
|
449
453
|
|
|
@@ -482,6 +486,7 @@ const SESSION_ID_ENV_VARS = [
|
|
|
482
486
|
"CURSOR_SESSION_ID",
|
|
483
487
|
"CURSOR_CONVERSATION_ID",
|
|
484
488
|
"CODEX_SESSION_ID",
|
|
489
|
+
"CODEX_THREAD_ID",
|
|
485
490
|
] as const;
|
|
486
491
|
|
|
487
492
|
/** Read normalized candidates from the first non-empty adapter session-id env var. */
|
|
@@ -528,7 +533,10 @@ function shouldPreferSessionEnv(): boolean {
|
|
|
528
533
|
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
529
534
|
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
530
535
|
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
531
|
-
* doesn't resolve.
|
|
536
|
+
* doesn't resolve. When legacy Cursor Glass state contains both the canonical
|
|
537
|
+
* bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
|
|
538
|
+
* named heartbeat, then the newest heartbeat, with instance id as a stable
|
|
539
|
+
* final tie-breaker. Filesystem enumeration order never decides identity.
|
|
532
540
|
*
|
|
533
541
|
* Exported for unit testing with an injectable root.
|
|
534
542
|
*/
|
|
@@ -546,19 +554,40 @@ export function resolveOwnerBySessionEnv(root: string): string | null {
|
|
|
546
554
|
} catch {
|
|
547
555
|
return null;
|
|
548
556
|
}
|
|
557
|
+
const matches: Array<{
|
|
558
|
+
instanceId: string;
|
|
559
|
+
sessionPreference: number;
|
|
560
|
+
hasName: boolean;
|
|
561
|
+
lastHeartbeatMs: number;
|
|
562
|
+
}> = [];
|
|
549
563
|
for (const file of files) {
|
|
550
564
|
if (!file.endsWith(".json")) continue;
|
|
551
565
|
try {
|
|
552
566
|
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
553
|
-
if (!parsed
|
|
567
|
+
if (!parsed) continue;
|
|
568
|
+
const sessionPreference = sessionIds.indexOf(parsed.session_id);
|
|
569
|
+
if (sessionPreference === -1) continue;
|
|
554
570
|
if (typeof parsed.instance_id !== "string") continue;
|
|
555
571
|
const ts = Date.parse(parsed.last_heartbeat);
|
|
556
|
-
if (Number.isFinite(ts)
|
|
572
|
+
if (!Number.isFinite(ts) || ts < cutoffMs) continue;
|
|
573
|
+
matches.push({
|
|
574
|
+
instanceId: parsed.instance_id,
|
|
575
|
+
sessionPreference,
|
|
576
|
+
hasName: typeof parsed.name === "string" && parsed.name.trim().length > 0,
|
|
577
|
+
lastHeartbeatMs: ts,
|
|
578
|
+
});
|
|
557
579
|
} catch {
|
|
558
580
|
// skip malformed
|
|
559
581
|
}
|
|
560
582
|
}
|
|
561
|
-
|
|
583
|
+
matches.sort(
|
|
584
|
+
(a, b) =>
|
|
585
|
+
a.sessionPreference - b.sessionPreference ||
|
|
586
|
+
Number(b.hasName) - Number(a.hasName) ||
|
|
587
|
+
b.lastHeartbeatMs - a.lastHeartbeatMs ||
|
|
588
|
+
a.instanceId.localeCompare(b.instanceId),
|
|
589
|
+
);
|
|
590
|
+
return matches[0]?.instanceId ?? null;
|
|
562
591
|
}
|
|
563
592
|
|
|
564
593
|
/**
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership-aware Git completion checks for the end-of-turn status ritual.
|
|
3
|
+
*
|
|
4
|
+
* The heartbeat's durable `files_touched` ledger is the ownership boundary. We
|
|
5
|
+
* inspect only those paths, plus any enclosing submodule gitlinks, so a peer's
|
|
6
|
+
* active edits elsewhere in a shared checkout do not block this session.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
11
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
|
+
import { type AgentFinalizationDisposition, agentsFinalizationRoots } from "../config.ts";
|
|
13
|
+
import { readStreamTailBounded } from "./events/consume.ts";
|
|
14
|
+
|
|
15
|
+
const CLAIM_HISTORY_CAP_BYTES = 128 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
const GIT_DISCOVERY_VARS = [
|
|
18
|
+
"GIT_DIR",
|
|
19
|
+
"GIT_WORK_TREE",
|
|
20
|
+
"GIT_COMMON_DIR",
|
|
21
|
+
"GIT_INDEX_FILE",
|
|
22
|
+
"GIT_OBJECT_DIRECTORY",
|
|
23
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
24
|
+
"GIT_PREFIX",
|
|
25
|
+
"GIT_NAMESPACE",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
interface GitResult {
|
|
29
|
+
status: number;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RepoWork {
|
|
35
|
+
root: string;
|
|
36
|
+
paths: Set<string>;
|
|
37
|
+
gitlinks: Set<string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GitFinalizationResult {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
claim_history_complete: boolean;
|
|
43
|
+
dirty_paths: string[];
|
|
44
|
+
unpushed_repos: string[];
|
|
45
|
+
host_output_paths: string[];
|
|
46
|
+
unsupported_paths: UnsupportedFinalizationPath[];
|
|
47
|
+
unverifiable_paths: string[];
|
|
48
|
+
unverifiable_repos: string[];
|
|
49
|
+
repos_checked: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type UnsupportedFinalizationReason =
|
|
53
|
+
| "outside_finalization_roots"
|
|
54
|
+
| "invalid_finalization_root"
|
|
55
|
+
| "ambiguous_finalization_roots"
|
|
56
|
+
| "path_escapes_finalization_root"
|
|
57
|
+
| "git_repository_unavailable"
|
|
58
|
+
| "output_path_is_git";
|
|
59
|
+
|
|
60
|
+
export interface UnsupportedFinalizationPath {
|
|
61
|
+
path: string;
|
|
62
|
+
reason: UnsupportedFinalizationReason;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ClaimFinalizationDescriptor {
|
|
66
|
+
disposition: AgentFinalizationDisposition;
|
|
67
|
+
root: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ClaimFinalizationDecision =
|
|
71
|
+
| {
|
|
72
|
+
allow: true;
|
|
73
|
+
path: string;
|
|
74
|
+
descriptor: ClaimFinalizationDescriptor;
|
|
75
|
+
authorityRoot: string;
|
|
76
|
+
repo?: { root: string; path: string };
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
allow: false;
|
|
80
|
+
path: string;
|
|
81
|
+
reason: UnsupportedFinalizationReason;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export interface SessionWriteClaims {
|
|
85
|
+
paths: string[];
|
|
86
|
+
complete: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read every write claim made by this session, including claims released after
|
|
91
|
+
* a commit. Current heartbeat claims describe dirty ownership; this history is
|
|
92
|
+
* what keeps the touched repository in scope until its commits are pushed.
|
|
93
|
+
*/
|
|
94
|
+
export function readSessionWriteClaims(
|
|
95
|
+
coordRoot: string,
|
|
96
|
+
instanceId: string,
|
|
97
|
+
sessionId: string,
|
|
98
|
+
): SessionWriteClaims {
|
|
99
|
+
const streamPath = join(coordRoot, ".harnery", "events.ndjson");
|
|
100
|
+
const { text, truncated } = readStreamTailBounded(streamPath, CLAIM_HISTORY_CAP_BYTES);
|
|
101
|
+
const paths = new Set<string>();
|
|
102
|
+
let sawSessionStart = false;
|
|
103
|
+
|
|
104
|
+
for (const line of text.split("\n")) {
|
|
105
|
+
if (line.trim().length === 0) continue;
|
|
106
|
+
try {
|
|
107
|
+
const event = JSON.parse(line) as {
|
|
108
|
+
event_type?: string;
|
|
109
|
+
instance_id?: string;
|
|
110
|
+
session_id?: string;
|
|
111
|
+
data?: { path?: unknown; mode?: unknown };
|
|
112
|
+
};
|
|
113
|
+
if (event.instance_id !== instanceId || event.session_id !== sessionId) continue;
|
|
114
|
+
if (event.event_type === "session.start") sawSessionStart = true;
|
|
115
|
+
if (
|
|
116
|
+
event.event_type === "claim.acquire" &&
|
|
117
|
+
event.data?.mode === "write" &&
|
|
118
|
+
typeof event.data.path === "string"
|
|
119
|
+
) {
|
|
120
|
+
paths.add(event.data.path);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Ignore malformed and crash-truncated event lines, matching the
|
|
124
|
+
// canonical stream consumer's recovery behavior.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { paths: [...paths], complete: !truncated || sawSessionStart };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function git(args: string[], cwd: string): GitResult {
|
|
132
|
+
const env = { ...process.env };
|
|
133
|
+
for (const name of GIT_DISCOVERY_VARS) delete env[name];
|
|
134
|
+
const result = spawnSync("git", args, { cwd, env, encoding: "utf8", timeout: 5_000 });
|
|
135
|
+
return {
|
|
136
|
+
status: result.status ?? 1,
|
|
137
|
+
stdout: result.stdout ?? "",
|
|
138
|
+
stderr: result.stderr ?? result.error?.message ?? "",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function repoLabel(coordRoot: string, repoRoot: string): string {
|
|
143
|
+
const rel = relative(coordRoot, repoRoot).replaceAll("\\", "/");
|
|
144
|
+
return rel.length === 0 ? "." : rel;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pathInside(root: string, candidate: string): boolean {
|
|
148
|
+
const rel = relative(root, candidate);
|
|
149
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function existingProbeDir(authorityRoot: string, absolutePath: string): string | null {
|
|
153
|
+
let current = absolutePath;
|
|
154
|
+
if (existsSync(current) && !statSync(current).isDirectory()) current = dirname(current);
|
|
155
|
+
while (!existsSync(current)) {
|
|
156
|
+
const parent = dirname(current);
|
|
157
|
+
if (parent === current || !pathInside(authorityRoot, parent)) return null;
|
|
158
|
+
current = parent;
|
|
159
|
+
}
|
|
160
|
+
return pathInside(authorityRoot, current) ? current : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Resolve existing ancestors through symlinks while preserving a missing tail. */
|
|
164
|
+
function physicalPath(absolutePath: string): string | null {
|
|
165
|
+
let existing = absolutePath;
|
|
166
|
+
while (!existsSync(existing)) {
|
|
167
|
+
const parent = dirname(existing);
|
|
168
|
+
if (parent === existing) return null;
|
|
169
|
+
existing = parent;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const physical = realpathSync.native(existing);
|
|
173
|
+
const tail = relative(existing, absolutePath);
|
|
174
|
+
return resolve(physical, tail);
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Translate a Windows-native WSL UNC claim back to the Linux path seen by the
|
|
182
|
+
* coordination process. Codex can report `\\\\wsl.localhost\\<distro>\\...`
|
|
183
|
+
* while Harnery runs inside that distro with a `/...` coord root.
|
|
184
|
+
*/
|
|
185
|
+
function resolveHeldPath(coordRoot: string, heldPath: string): string {
|
|
186
|
+
const wslUnc = /^\\\\(?:wsl\.localhost|wsl\$)\\[^\\]+\\(.+)$/i.exec(heldPath);
|
|
187
|
+
if (wslUnc) {
|
|
188
|
+
const linuxPath = `/${wslUnc[1]!.replaceAll("\\", "/")}`;
|
|
189
|
+
const slashRoot = coordRoot.replaceAll("\\", "/");
|
|
190
|
+
if (slashRoot.startsWith("/") || pathInside(coordRoot, linuxPath)) return linuxPath;
|
|
191
|
+
}
|
|
192
|
+
return resolve(coordRoot, heldPath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function discoverRepo(
|
|
196
|
+
authorityRoot: string,
|
|
197
|
+
absolutePath: string,
|
|
198
|
+
): { root: string; path: string } | null {
|
|
199
|
+
const probeDir = existingProbeDir(authorityRoot, absolutePath);
|
|
200
|
+
if (!probeDir) return null;
|
|
201
|
+
const rootResult = git(["rev-parse", "--show-toplevel"], probeDir);
|
|
202
|
+
if (rootResult.status !== 0) return null;
|
|
203
|
+
const repoRoot = physicalPath(resolve(rootResult.stdout.trim()));
|
|
204
|
+
if (!repoRoot || !pathInside(authorityRoot, repoRoot) || !pathInside(repoRoot, absolutePath)) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const repoPath = relative(repoRoot, absolutePath).replaceAll("\\", "/") || ".";
|
|
208
|
+
return { root: repoRoot, path: repoPath };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface FinalizationRoot {
|
|
212
|
+
configuredPath: string;
|
|
213
|
+
disposition: AgentFinalizationDisposition;
|
|
214
|
+
lexicalRoot: string;
|
|
215
|
+
physicalRoot: string | null;
|
|
216
|
+
invalid: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function rootIsGitRepository(root: string): boolean {
|
|
220
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) return false;
|
|
221
|
+
const result = git(["rev-parse", "--show-toplevel"], root);
|
|
222
|
+
if (result.status !== 0) return false;
|
|
223
|
+
const discovered = physicalPath(resolve(result.stdout.trim()));
|
|
224
|
+
const physicalRoot = physicalPath(root);
|
|
225
|
+
return discovered !== null && physicalRoot !== null && discovered === physicalRoot;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function outputRootIsInsideGit(root: string): boolean {
|
|
229
|
+
let probe = root;
|
|
230
|
+
while (!existsSync(probe)) {
|
|
231
|
+
const parent = dirname(probe);
|
|
232
|
+
if (parent === probe) return false;
|
|
233
|
+
probe = parent;
|
|
234
|
+
}
|
|
235
|
+
if (!statSync(probe).isDirectory()) probe = dirname(probe);
|
|
236
|
+
return git(["rev-parse", "--show-toplevel"], probe).status === 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function finalizationRoots(coordRoot: string): FinalizationRoot[] {
|
|
240
|
+
const root = resolve(coordRoot);
|
|
241
|
+
const configured = agentsFinalizationRoots(root).map((entry) => {
|
|
242
|
+
const lexicalRoot = resolveHeldPath(root, entry.path);
|
|
243
|
+
const physicalRoot = physicalPath(lexicalRoot);
|
|
244
|
+
const invalid =
|
|
245
|
+
physicalRoot === null ||
|
|
246
|
+
(entry.disposition === "git"
|
|
247
|
+
? !rootIsGitRepository(physicalRoot)
|
|
248
|
+
: outputRootIsInsideGit(physicalRoot));
|
|
249
|
+
return {
|
|
250
|
+
configuredPath: entry.path,
|
|
251
|
+
disposition: entry.disposition,
|
|
252
|
+
lexicalRoot,
|
|
253
|
+
physicalRoot,
|
|
254
|
+
invalid,
|
|
255
|
+
} satisfies FinalizationRoot;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const rootPhysical = physicalPath(root);
|
|
259
|
+
if (rootPhysical && rootIsGitRepository(rootPhysical)) {
|
|
260
|
+
configured.push({
|
|
261
|
+
configuredPath: ".",
|
|
262
|
+
disposition: "git",
|
|
263
|
+
lexicalRoot: root,
|
|
264
|
+
physicalRoot: rootPhysical,
|
|
265
|
+
invalid: false,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const current of configured) {
|
|
270
|
+
if (!current.physicalRoot) continue;
|
|
271
|
+
for (const peer of configured) {
|
|
272
|
+
if (current === peer || !peer.physicalRoot || current.disposition === peer.disposition) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (
|
|
276
|
+
pathInside(current.physicalRoot, peer.physicalRoot) ||
|
|
277
|
+
pathInside(peer.physicalRoot, current.physicalRoot)
|
|
278
|
+
) {
|
|
279
|
+
current.invalid = true;
|
|
280
|
+
peer.invalid = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return configured;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Resolve one guarded write to the host's single supported end-turn
|
|
290
|
+
* disposition. Claim event data never grants authority: every call starts from
|
|
291
|
+
* the project-owned finalization roots and only then inspects the selected
|
|
292
|
+
* repository or output tree.
|
|
293
|
+
*/
|
|
294
|
+
export function classifyWriteClaimFinalization(
|
|
295
|
+
coordRoot: string,
|
|
296
|
+
heldPath: string,
|
|
297
|
+
): ClaimFinalizationDecision {
|
|
298
|
+
const root = resolve(coordRoot);
|
|
299
|
+
const absolutePath = resolveHeldPath(root, heldPath);
|
|
300
|
+
const roots = finalizationRoots(root);
|
|
301
|
+
const lexicalMatches = roots.filter((entry) => pathInside(entry.lexicalRoot, absolutePath));
|
|
302
|
+
if (lexicalMatches.length === 0) {
|
|
303
|
+
return { allow: false, path: heldPath, reason: "outside_finalization_roots" };
|
|
304
|
+
}
|
|
305
|
+
if (lexicalMatches.some((entry) => entry.invalid)) {
|
|
306
|
+
return { allow: false, path: heldPath, reason: "invalid_finalization_root" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const physical = physicalPath(absolutePath);
|
|
310
|
+
if (!physical) {
|
|
311
|
+
return { allow: false, path: heldPath, reason: "path_escapes_finalization_root" };
|
|
312
|
+
}
|
|
313
|
+
const matches = lexicalMatches.filter(
|
|
314
|
+
(entry) => entry.physicalRoot && pathInside(entry.physicalRoot, physical),
|
|
315
|
+
);
|
|
316
|
+
if (matches.length === 0) {
|
|
317
|
+
return { allow: false, path: heldPath, reason: "path_escapes_finalization_root" };
|
|
318
|
+
}
|
|
319
|
+
const dispositions = new Set(matches.map((entry) => entry.disposition));
|
|
320
|
+
if (dispositions.size !== 1) {
|
|
321
|
+
return { allow: false, path: heldPath, reason: "ambiguous_finalization_roots" };
|
|
322
|
+
}
|
|
323
|
+
const selected = [...matches].sort(
|
|
324
|
+
(a, b) => (b.physicalRoot?.length ?? 0) - (a.physicalRoot?.length ?? 0),
|
|
325
|
+
)[0]!;
|
|
326
|
+
const normalizedPath = relative(root, absolutePath).replaceAll("\\", "/") || ".";
|
|
327
|
+
const descriptor: ClaimFinalizationDescriptor = {
|
|
328
|
+
disposition: selected.disposition,
|
|
329
|
+
root: selected.configuredPath,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if (selected.disposition === "output") {
|
|
333
|
+
const probe = existingProbeDir(selected.physicalRoot!, physical);
|
|
334
|
+
if (probe && git(["rev-parse", "--show-toplevel"], probe).status === 0) {
|
|
335
|
+
return { allow: false, path: heldPath, reason: "output_path_is_git" };
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
allow: true,
|
|
339
|
+
path: normalizedPath,
|
|
340
|
+
descriptor,
|
|
341
|
+
authorityRoot: selected.physicalRoot!,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const repo = discoverRepo(selected.physicalRoot!, physical);
|
|
346
|
+
if (!repo) {
|
|
347
|
+
return { allow: false, path: heldPath, reason: "git_repository_unavailable" };
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
allow: true,
|
|
351
|
+
path: normalizedPath,
|
|
352
|
+
descriptor,
|
|
353
|
+
authorityRoot: selected.physicalRoot!,
|
|
354
|
+
repo,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function addRepoPath(
|
|
359
|
+
repos: Map<string, RepoWork>,
|
|
360
|
+
root: string,
|
|
361
|
+
path: string,
|
|
362
|
+
gitlink = false,
|
|
363
|
+
): void {
|
|
364
|
+
const work = repos.get(root) ?? {
|
|
365
|
+
root,
|
|
366
|
+
paths: new Set<string>(),
|
|
367
|
+
gitlinks: new Set<string>(),
|
|
368
|
+
};
|
|
369
|
+
work.paths.add(path);
|
|
370
|
+
if (gitlink) work.gitlinks.add(path);
|
|
371
|
+
repos.set(root, work);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function addSuperprojects(
|
|
375
|
+
authorityRoot: string,
|
|
376
|
+
repos: Map<string, RepoWork>,
|
|
377
|
+
leafRoot: string,
|
|
378
|
+
): void {
|
|
379
|
+
let childRoot = leafRoot;
|
|
380
|
+
for (;;) {
|
|
381
|
+
const result = git(["rev-parse", "--show-superproject-working-tree"], childRoot);
|
|
382
|
+
if (result.status !== 0 || result.stdout.trim().length === 0) return;
|
|
383
|
+
const parentRoot = physicalPath(resolve(result.stdout.trim()));
|
|
384
|
+
if (!parentRoot || !pathInside(authorityRoot, parentRoot) || parentRoot === childRoot) return;
|
|
385
|
+
const gitlink = relative(parentRoot, childRoot).replaceAll("\\", "/");
|
|
386
|
+
addRepoPath(repos, parentRoot, gitlink, true);
|
|
387
|
+
childRoot = parentRoot;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function repoHasRemote(repoRoot: string): boolean {
|
|
392
|
+
const result = git(["remote"], repoRoot);
|
|
393
|
+
return result.status === 0 && result.stdout.trim().length > 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function repoSyncState(repoRoot: string): "synced" | "unpushed" | "unverifiable" {
|
|
397
|
+
if (!repoHasRemote(repoRoot)) return "synced";
|
|
398
|
+
if (git(["rev-parse", "--verify", "HEAD"], repoRoot).status !== 0) return "unverifiable";
|
|
399
|
+
|
|
400
|
+
const upstream = git(
|
|
401
|
+
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
402
|
+
repoRoot,
|
|
403
|
+
);
|
|
404
|
+
if (upstream.status === 0) {
|
|
405
|
+
const ahead = git(["rev-list", "--count", "@{upstream}..HEAD"], repoRoot);
|
|
406
|
+
if (ahead.status !== 0) return "unverifiable";
|
|
407
|
+
return Number.parseInt(ahead.stdout.trim(), 10) > 0 ? "unpushed" : "synced";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Detached submodules commonly have no upstream. A remote ref containing
|
|
411
|
+
// HEAD proves that commit is available elsewhere without requiring a branch.
|
|
412
|
+
const containing = git(
|
|
413
|
+
["branch", "--remotes", "--contains", "HEAD", "--format=%(refname:short)"],
|
|
414
|
+
repoRoot,
|
|
415
|
+
);
|
|
416
|
+
if (containing.status !== 0) return "unverifiable";
|
|
417
|
+
return containing.stdout.trim().length > 0 ? "synced" : "unpushed";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Compare gitlink commits without treating dirty contents inside the child as a pointer change. */
|
|
421
|
+
function gitlinkIsDirty(parentRoot: string, gitlink: string): boolean {
|
|
422
|
+
const head = git(["rev-parse", `HEAD:${gitlink}`], parentRoot);
|
|
423
|
+
const index = git(["ls-files", "--stage", "--", gitlink], parentRoot);
|
|
424
|
+
const child = git(["rev-parse", "HEAD"], join(parentRoot, gitlink));
|
|
425
|
+
if (head.status !== 0 || index.status !== 0 || child.status !== 0) return true;
|
|
426
|
+
const indexMatch = /^160000 ([0-9a-f]{40}) 0\t/.exec(index.stdout.trim());
|
|
427
|
+
if (!indexMatch) return true;
|
|
428
|
+
return head.stdout.trim() !== indexMatch[1] || child.stdout.trim() !== indexMatch[1];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Check that this session's held paths are committed and their repositories are pushed. */
|
|
432
|
+
export function checkGitFinalization(
|
|
433
|
+
coordRoot: string,
|
|
434
|
+
heldPaths: readonly string[],
|
|
435
|
+
options: { claimHistoryComplete?: boolean } = {},
|
|
436
|
+
): GitFinalizationResult {
|
|
437
|
+
const root = resolve(coordRoot);
|
|
438
|
+
const repos = new Map<string, RepoWork>();
|
|
439
|
+
const hostOutputPaths = new Set<string>();
|
|
440
|
+
const unsupportedPaths: UnsupportedFinalizationPath[] = [];
|
|
441
|
+
const unverifiablePaths: string[] = [];
|
|
442
|
+
|
|
443
|
+
for (const heldPath of heldPaths) {
|
|
444
|
+
const decision = classifyWriteClaimFinalization(root, heldPath);
|
|
445
|
+
if (!decision.allow) {
|
|
446
|
+
unsupportedPaths.push({ path: heldPath, reason: decision.reason });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (decision.descriptor.disposition === "output") {
|
|
450
|
+
hostOutputPaths.add(decision.path);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (!decision.repo) {
|
|
454
|
+
unsupportedPaths.push({ path: heldPath, reason: "git_repository_unavailable" });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
addRepoPath(repos, decision.repo.root, decision.repo.path);
|
|
458
|
+
addSuperprojects(decision.authorityRoot, repos, decision.repo.root);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const dirtyPaths = new Set<string>();
|
|
462
|
+
const unpushedRepos: string[] = [];
|
|
463
|
+
const unverifiableRepos: string[] = [];
|
|
464
|
+
|
|
465
|
+
for (const work of repos.values()) {
|
|
466
|
+
for (const repoPath of work.paths) {
|
|
467
|
+
if (work.gitlinks.has(repoPath)) {
|
|
468
|
+
if (gitlinkIsDirty(work.root, repoPath)) {
|
|
469
|
+
const label = repoLabel(root, work.root);
|
|
470
|
+
dirtyPaths.add(label === "." ? repoPath : `${label}/${repoPath}`);
|
|
471
|
+
}
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const status = git(
|
|
475
|
+
["status", "--porcelain=v1", "--untracked-files=all", "--", repoPath],
|
|
476
|
+
work.root,
|
|
477
|
+
);
|
|
478
|
+
if (status.status !== 0) {
|
|
479
|
+
unverifiablePaths.push(join(repoLabel(root, work.root), repoPath).replaceAll("\\", "/"));
|
|
480
|
+
} else if (status.stdout.length > 0) {
|
|
481
|
+
const label = repoLabel(root, work.root);
|
|
482
|
+
dirtyPaths.add(label === "." ? repoPath : `${label}/${repoPath}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const syncState = repoSyncState(work.root);
|
|
487
|
+
const label = repoLabel(root, work.root);
|
|
488
|
+
if (syncState === "unpushed") unpushedRepos.push(label);
|
|
489
|
+
if (syncState === "unverifiable") unverifiableRepos.push(label);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const result: GitFinalizationResult = {
|
|
493
|
+
ok: false,
|
|
494
|
+
claim_history_complete: options.claimHistoryComplete ?? true,
|
|
495
|
+
dirty_paths: [...dirtyPaths].sort(),
|
|
496
|
+
unpushed_repos: [...new Set(unpushedRepos)].sort(),
|
|
497
|
+
host_output_paths: [...hostOutputPaths].sort(),
|
|
498
|
+
unsupported_paths: unsupportedPaths.sort(
|
|
499
|
+
(a, b) => a.path.localeCompare(b.path) || a.reason.localeCompare(b.reason),
|
|
500
|
+
),
|
|
501
|
+
unverifiable_paths: [...new Set(unverifiablePaths)].sort(),
|
|
502
|
+
unverifiable_repos: [...new Set(unverifiableRepos)].sort(),
|
|
503
|
+
repos_checked: [...repos.keys()].map((repo) => repoLabel(root, repo)).sort(),
|
|
504
|
+
};
|
|
505
|
+
result.ok =
|
|
506
|
+
result.claim_history_complete &&
|
|
507
|
+
result.dirty_paths.length === 0 &&
|
|
508
|
+
result.unpushed_repos.length === 0 &&
|
|
509
|
+
result.unsupported_paths.length === 0 &&
|
|
510
|
+
result.unverifiable_paths.length === 0 &&
|
|
511
|
+
result.unverifiable_repos.length === 0;
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function formatGitFinalizationFailure(result: GitFinalizationResult, bin: string): string {
|
|
516
|
+
const lines = ["Owned work is not finalized; the status box was not issued."];
|
|
517
|
+
if (!result.claim_history_complete) {
|
|
518
|
+
lines.push("This session's write-claim history is older than the bounded event window.");
|
|
519
|
+
}
|
|
520
|
+
if (result.dirty_paths.length > 0) {
|
|
521
|
+
lines.push("Dirty owned paths:", ...result.dirty_paths.map((path) => ` - ${path}`));
|
|
522
|
+
}
|
|
523
|
+
if (result.unpushed_repos.length > 0) {
|
|
524
|
+
lines.push(
|
|
525
|
+
"Repositories with commits not on their remote:",
|
|
526
|
+
...result.unpushed_repos.map((repo) => ` - ${repo}`),
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (result.unsupported_paths.length > 0) {
|
|
530
|
+
lines.push(
|
|
531
|
+
"Paths without an authorized end-turn disposition:",
|
|
532
|
+
...result.unsupported_paths.map(
|
|
533
|
+
({ path, reason }) => ` - ${path}: ${unsupportedReasonMessage(reason)}`,
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (result.unverifiable_paths.length > 0) {
|
|
538
|
+
lines.push(
|
|
539
|
+
"Paths whose Git state could not be verified:",
|
|
540
|
+
...result.unverifiable_paths.map((path) => ` - ${path}`),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (result.unverifiable_repos.length > 0) {
|
|
544
|
+
lines.push(
|
|
545
|
+
"Repositories whose remote state could not be verified:",
|
|
546
|
+
...result.unverifiable_repos.map((repo) => ` - ${repo}`),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (
|
|
550
|
+
result.dirty_paths.length > 0 ||
|
|
551
|
+
result.unpushed_repos.length > 0 ||
|
|
552
|
+
result.unverifiable_paths.length > 0 ||
|
|
553
|
+
result.unverifiable_repos.length > 0
|
|
554
|
+
) {
|
|
555
|
+
lines.push(
|
|
556
|
+
`Commit and push the owned Git work, then rerun \`${bin} agents status --end-turn\`.`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if (result.unsupported_paths.length > 0) {
|
|
560
|
+
lines.push(
|
|
561
|
+
"For a legacy claim, add the containing Git repository or intentional non-Git output root to the project's `agents.finalizationRoots`, then rerun the guarded status. For new work, start the task from the target Git project or configure that root before editing.",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
return lines.join("\n");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function unsupportedReasonMessage(reason: UnsupportedFinalizationReason): string {
|
|
568
|
+
switch (reason) {
|
|
569
|
+
case "outside_finalization_roots":
|
|
570
|
+
return "outside the coordination repository and project-configured finalization roots";
|
|
571
|
+
case "invalid_finalization_root":
|
|
572
|
+
return "the matching project-configured root is invalid or overlaps another disposition";
|
|
573
|
+
case "ambiguous_finalization_roots":
|
|
574
|
+
return "more than one finalization disposition matches this path";
|
|
575
|
+
case "path_escapes_finalization_root":
|
|
576
|
+
return "the resolved path escapes its configured root";
|
|
577
|
+
case "git_repository_unavailable":
|
|
578
|
+
return "the configured Git root does not contain a verifiable repository for this path";
|
|
579
|
+
case "output_path_is_git":
|
|
580
|
+
return "the configured non-Git output path is inside a Git repository";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function formatWriteClaimFinalizationDenial(
|
|
585
|
+
decision: Extract<ClaimFinalizationDecision, { allow: false }>,
|
|
586
|
+
bin: string,
|
|
587
|
+
): string {
|
|
588
|
+
return (
|
|
589
|
+
`Harnery denied the write to ${decision.path} before mutation: ` +
|
|
590
|
+
`${unsupportedReasonMessage(decision.reason)}. ` +
|
|
591
|
+
"Start the task from the target Git project, or add its Git repository root " +
|
|
592
|
+
"(or an intentional non-Git output root) to the project's `agents.finalizationRoots` " +
|
|
593
|
+
`before editing. Then retry the tool and finish with \`${bin} agents status --end-turn\`.`
|
|
594
|
+
);
|
|
595
|
+
}
|