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
package/src/commands/sync.ts
CHANGED
|
@@ -25,6 +25,7 @@ import os from "node:os";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import type { Command } from "commander";
|
|
27
27
|
import type { EmitContext } from "../commander.ts";
|
|
28
|
+
import { syncJsoncConfig } from "../core/config.ts";
|
|
28
29
|
|
|
29
30
|
// What we sync. Anything matching one of these (relative to .harnery/) is
|
|
30
31
|
// considered part of the sync set. Everything else stays local-only.
|
|
@@ -40,9 +41,12 @@ function configPath(): string {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
function loadConfig(): SyncConfig | null {
|
|
44
|
+
// Precedence: env → .harnery/config.jsonc `sync` → ~/.config/harnery/sync.json.
|
|
43
45
|
const envRemote = process.env.HARNERY_SYNC_REMOTE;
|
|
44
46
|
const envPrefix = process.env.HARNERY_SYNC_PREFIX ?? "harnery";
|
|
45
47
|
if (envRemote) return { remote: envRemote, prefix: envPrefix };
|
|
48
|
+
const jsonc = syncJsoncConfig();
|
|
49
|
+
if (jsonc) return jsonc;
|
|
46
50
|
const p = configPath();
|
|
47
51
|
if (!existsSync(p)) return null;
|
|
48
52
|
try {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { EmitContext } from "../commander.ts";
|
|
3
|
+
import { workflowSubscriptionOnly } from "../core/config.ts";
|
|
4
|
+
import { createBuiltinHarnessRegistry } from "../core/harnesses/index.ts";
|
|
5
|
+
import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `workflow run <script>`: execute a workflow script — bounded, schema-gated,
|
|
9
|
+
* conditionally-routed stages fanning out to headless harness-CLI subagents.
|
|
10
|
+
* The script (deterministic code), not a model, decides routing between
|
|
11
|
+
* stages, and the run terminates when the script returns.
|
|
12
|
+
*
|
|
13
|
+
* Phase 2 surface: `run` only, claude-code spawn adapter only. Design +
|
|
14
|
+
* phasing: decision 0015.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface WorkflowRunOpts {
|
|
18
|
+
maxAgents?: string;
|
|
19
|
+
concurrency?: string;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
harness?: string;
|
|
22
|
+
resumeFrom?: string;
|
|
23
|
+
subscriptionOnly?: boolean;
|
|
24
|
+
allowApiBilling?: boolean;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
|
|
29
|
+
const registry = createBuiltinHarnessRegistry();
|
|
30
|
+
const harnesses = registry.ids();
|
|
31
|
+
const workflow = program
|
|
32
|
+
.command("workflow")
|
|
33
|
+
.description("Run bounded, schema-gated multi-subagent workflow scripts.");
|
|
34
|
+
|
|
35
|
+
workflow
|
|
36
|
+
.command("run <script>")
|
|
37
|
+
.description(
|
|
38
|
+
"Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
|
|
39
|
+
"Subagents spawn as headless harness-CLI subprocesses, coordination-registered.",
|
|
40
|
+
)
|
|
41
|
+
.option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
|
|
42
|
+
.option("--concurrency <n>", "Concurrent-subagent cap (default 4)")
|
|
43
|
+
.option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
|
|
44
|
+
.option(
|
|
45
|
+
"--harness <name>",
|
|
46
|
+
`Default harness for agent() calls: ${harnesses.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`,
|
|
47
|
+
)
|
|
48
|
+
.option(
|
|
49
|
+
"--resume-from <run-id>",
|
|
50
|
+
"Reuse completed agent results from a prior run's journal; only changed/failed calls re-run",
|
|
51
|
+
)
|
|
52
|
+
.option(
|
|
53
|
+
"--subscription-only",
|
|
54
|
+
"Guarantee subscription billing: scrub API-key vars from child envs; fail loud when a " +
|
|
55
|
+
"harness has no stored login (repo default via config.jsonc workflow.subscriptionOnly)",
|
|
56
|
+
)
|
|
57
|
+
.option(
|
|
58
|
+
"--allow-api-billing",
|
|
59
|
+
"Permit an exported API key to override a stored subscription login (per-token billing); " +
|
|
60
|
+
"without this the engine refuses that silent-override state",
|
|
61
|
+
)
|
|
62
|
+
.option("--json", "Emit the full RunReport as JSON")
|
|
63
|
+
.action(async (script: string, opts: WorkflowRunOpts) => {
|
|
64
|
+
const coordRoot = findCoordRoot();
|
|
65
|
+
if (!coordRoot) {
|
|
66
|
+
emit.error({
|
|
67
|
+
code: "no_coord_root",
|
|
68
|
+
message: "no .harnery/ coordination root found; run `init` first",
|
|
69
|
+
});
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (opts.harness && !registry.get(opts.harness)) {
|
|
73
|
+
emit.error({
|
|
74
|
+
code: "bad_harness",
|
|
75
|
+
message: `unknown harness "${opts.harness}" (expected: ${harnesses.join(" | ")})`,
|
|
76
|
+
});
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
// Flag beats config; the two flags contradict each other by design.
|
|
80
|
+
const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
|
|
81
|
+
if (subscriptionOnly && opts.allowApiBilling) {
|
|
82
|
+
emit.error({
|
|
83
|
+
code: "billing_flags_conflict",
|
|
84
|
+
message:
|
|
85
|
+
"--allow-api-billing contradicts subscription-only mode " +
|
|
86
|
+
"(flag or config.jsonc workflow.subscriptionOnly); pick one",
|
|
87
|
+
});
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const { runWorkflow } = await import("../core/workflow/engine.ts");
|
|
92
|
+
const report = await runWorkflow(script, {
|
|
93
|
+
coordRoot,
|
|
94
|
+
spawners: registry.spawners(),
|
|
95
|
+
defaultHarness: opts.harness,
|
|
96
|
+
resumeFrom: opts.resumeFrom,
|
|
97
|
+
subscriptionOnly,
|
|
98
|
+
allowApiBilling: opts.allowApiBilling,
|
|
99
|
+
maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
|
|
100
|
+
concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
|
|
101
|
+
cwd: opts.cwd,
|
|
102
|
+
});
|
|
103
|
+
if (opts.json) {
|
|
104
|
+
emit.config({ format: "json" });
|
|
105
|
+
emit.data(report);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const cachedPart = report.agentsCached > 0 ? ` (+${report.agentsCached} cached)` : "";
|
|
109
|
+
const billingPart = report.billing.length
|
|
110
|
+
? `billing: ${report.billing.map((b) => `${b.harness}=${b.mode}`).join(", ")}\n`
|
|
111
|
+
: "";
|
|
112
|
+
emit.text(
|
|
113
|
+
`run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
|
|
114
|
+
`$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
|
|
115
|
+
`journal: ${report.journalPath}\n` +
|
|
116
|
+
`result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
|
|
117
|
+
);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
emit.error({ code: "workflow_failed", message: (err as Error).message });
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
13
|
import { existsSync } from "node:fs";
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { monorepoRoot } from "./coord-client.ts";
|
|
15
16
|
|
|
16
17
|
export interface CanonicalEmitInput {
|
|
17
18
|
type: string;
|
|
@@ -24,8 +25,37 @@ export interface CanonicalEmitInput {
|
|
|
24
25
|
parentTurnId?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the coord root for a canonical emit.
|
|
30
|
+
*
|
|
31
|
+
* Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
|
|
32
|
+
* `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
|
|
33
|
+
* hosts). The git-aware step is load-bearing: a shell cd'd into a nested
|
|
34
|
+
* directory that carries its own `.harnery/` (e.g. an embedded harnery
|
|
35
|
+
* checkout) made the old cwd-walk resolve the NESTED root, from which
|
|
36
|
+
* `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
|
|
37
|
+
* `set-task` emits silently vanished and the Stop-hook's rule 1/3
|
|
38
|
+
* (`state.status_checked` in-turn) blocked turns that had done the ritual.
|
|
39
|
+
* Same bug class as the coordHelperOpts root-pin fix; this closes the
|
|
40
|
+
* emitCanonical instance of it.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveEmitRoot(start: string = process.cwd()): string | null {
|
|
43
|
+
const override = process.env.HARNERY_COORD_ROOT_OVERRIDE;
|
|
44
|
+
if (override) return override;
|
|
45
|
+
// Memoized per start-dir: the session-tee middleware resolves once per
|
|
46
|
+
// emitted event, and monorepoRoot() spawns git — cache so a streaming
|
|
47
|
+
// command doesn't pay 1-3 subprocess spawns per output line.
|
|
48
|
+
if (cachedRoot && cachedRoot.start === start) return cachedRoot.root;
|
|
49
|
+
const gitRoot = monorepoRoot();
|
|
50
|
+
const root = gitRoot && existsSync(join(gitRoot, ".harnery")) ? gitRoot : findRepoRoot(start);
|
|
51
|
+
cachedRoot = { start, root };
|
|
52
|
+
return root;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let cachedRoot: { start: string; root: string | null } | undefined;
|
|
56
|
+
|
|
27
57
|
export function emitCanonical(input: CanonicalEmitInput): void {
|
|
28
|
-
const root =
|
|
58
|
+
const root = resolveEmitRoot();
|
|
29
59
|
if (!root) return;
|
|
30
60
|
const binary = resolve(root, "harnery", "bin", "agent-coord");
|
|
31
61
|
if (!existsSync(binary)) return;
|
|
@@ -46,11 +76,19 @@ export function emitCanonical(input: CanonicalEmitInput): void {
|
|
|
46
76
|
if (input.turnId) args.push("--turn-id", input.turnId);
|
|
47
77
|
if (input.parentSessionId) args.push("--parent-session-id", input.parentSessionId);
|
|
48
78
|
if (input.parentTurnId) args.push("--parent-turn-id", input.parentTurnId);
|
|
49
|
-
|
|
79
|
+
// Pin cwd + HARNERY_COORD_ROOT_OVERRIDE so the child writes to the SAME
|
|
80
|
+
// stream we resolved, regardless of where the caller's shell is cd'd.
|
|
81
|
+
const result = spawnSync(binary, args, {
|
|
50
82
|
encoding: "utf8",
|
|
51
83
|
stdio: "ignore",
|
|
52
84
|
timeout: 3000,
|
|
85
|
+
cwd: root,
|
|
86
|
+
env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root },
|
|
53
87
|
});
|
|
88
|
+
if (result.error || result.status !== 0) {
|
|
89
|
+
const why = result.error ? result.error.message : `exit ${result.status}`;
|
|
90
|
+
process.stderr.write(`emitCanonical: ${input.type} emit failed (${why})\n`); // lint-ok-emission: soft-fail diagnostic promised by the module doc; silent drops cost blocked turns
|
|
91
|
+
}
|
|
54
92
|
} catch {
|
|
55
93
|
/* never break the caller */
|
|
56
94
|
}
|
|
@@ -66,7 +104,7 @@ export function normalizeHarness(platform: string | undefined): "claude-code" |
|
|
|
66
104
|
return "claude-code";
|
|
67
105
|
}
|
|
68
106
|
|
|
69
|
-
function findRepoRoot(start: string
|
|
107
|
+
function findRepoRoot(start: string): string | null {
|
|
70
108
|
let dir = resolve(start);
|
|
71
109
|
while (true) {
|
|
72
110
|
if (existsSync(join(dir, ".harnery"))) return dir;
|
package/src/core/agents/cli.ts
CHANGED
|
@@ -176,7 +176,7 @@ async function emitClaimRelease(
|
|
|
176
176
|
owner: string,
|
|
177
177
|
hb: { session_id?: string; platform?: string },
|
|
178
178
|
path: string,
|
|
179
|
-
reason: "explicit" | "heal",
|
|
179
|
+
reason: "explicit" | "heal" | "commit" | "checkout",
|
|
180
180
|
): Promise<void> {
|
|
181
181
|
try {
|
|
182
182
|
const { emit } = await import("./events/emit.ts");
|
|
@@ -210,8 +210,10 @@ async function handleStateAction(root: string, action: string, rest: string[]):
|
|
|
210
210
|
const task = args.join(" ");
|
|
211
211
|
const hb = writer.setTask(root, owner, task);
|
|
212
212
|
if (!hb) {
|
|
213
|
+
// Name the RESOLVED root: when a nested .harnery/ shadows the real
|
|
214
|
+
// coordination home, the full path is what makes that diagnosable.
|
|
213
215
|
process.stderr.write(
|
|
214
|
-
`agent-coord set-task: no heartbeat at
|
|
216
|
+
`agent-coord set-task: no heartbeat at ${root}/.harnery/active/${owner}.json\n`,
|
|
215
217
|
);
|
|
216
218
|
return 1;
|
|
217
219
|
}
|
|
@@ -475,11 +477,16 @@ async function handlePostCommit(root: string): Promise<number> {
|
|
|
475
477
|
const { groupUnclaim } = await import("./state/heartbeat-writer.ts");
|
|
476
478
|
|
|
477
479
|
// Session-group-wide unclaim. `owner` is the parent's session_id which is
|
|
478
|
-
// also the group key (parent + subagents share session_id).
|
|
480
|
+
// also the group key (parent + subagents share session_id). Each actual
|
|
481
|
+
// removal also emits a durable claim.release event — the projector rebuilds
|
|
482
|
+
// files_touched from the permanent Edit/Write events, so a file-only prune
|
|
483
|
+
// would resurrect on the next replay.
|
|
479
484
|
if (req.owner && Array.isArray(req.prune)) {
|
|
480
485
|
for (const path of req.prune) {
|
|
481
486
|
try {
|
|
482
|
-
groupUnclaim(root, req.owner, path)
|
|
487
|
+
for (const hit of groupUnclaim(root, req.owner, path)) {
|
|
488
|
+
await emitClaimRelease(root, hit.instance_id, hit, path, "commit");
|
|
489
|
+
}
|
|
483
490
|
} catch {
|
|
484
491
|
/* best-effort */
|
|
485
492
|
}
|
|
@@ -500,7 +507,9 @@ async function handlePostCheckout(root: string, _rest: string[]): Promise<number
|
|
|
500
507
|
if (req.owner && Array.isArray(req.removed)) {
|
|
501
508
|
for (const path of req.removed) {
|
|
502
509
|
try {
|
|
503
|
-
groupUnclaim(root, req.owner, path)
|
|
510
|
+
for (const hit of groupUnclaim(root, req.owner, path)) {
|
|
511
|
+
await emitClaimRelease(root, hit.instance_id, hit, path, "checkout");
|
|
512
|
+
}
|
|
504
513
|
} catch {
|
|
505
514
|
/* best-effort */
|
|
506
515
|
}
|
|
@@ -709,7 +718,7 @@ async function handleResolveName(root: string, rest: string[]): Promise<number>
|
|
|
709
718
|
return 0;
|
|
710
719
|
}
|
|
711
720
|
process.stdout.write(
|
|
712
|
-
`${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind })}\n`,
|
|
721
|
+
`${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind, agent_id: resolved.agent_id ?? null })}\n`,
|
|
713
722
|
);
|
|
714
723
|
return 0;
|
|
715
724
|
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import { join } from "node:path";
|
|
37
37
|
|
|
38
38
|
import { coordEnv } from "../../../lib/env.ts";
|
|
39
|
+
import { type RemoteMachine, readRemoteMachines } from "../../presence/index.ts";
|
|
39
40
|
import { formatPendingCouncils } from "./session-context.ts";
|
|
40
41
|
|
|
41
42
|
interface HeartbeatRow {
|
|
@@ -170,7 +171,10 @@ function computePeerTableIfChanged(
|
|
|
170
171
|
if (!hb?.instance_id || hb.instance_id === selfInstanceId) continue;
|
|
171
172
|
peers.push(hb);
|
|
172
173
|
}
|
|
173
|
-
|
|
174
|
+
// Cross-machine presence (ADR 0016): sessions on other machines, from the
|
|
175
|
+
// locally-fetched presence refs. Advisory (no claim blocking in v1).
|
|
176
|
+
const remote = readRemoteMachinesSafe(coordRoot);
|
|
177
|
+
if (peers.length === 0 && remote.length === 0) return "";
|
|
174
178
|
|
|
175
179
|
// Build hash basis: sorted-by-instance_id projection of semantically-relevant fields.
|
|
176
180
|
const basis = peers
|
|
@@ -184,14 +188,27 @@ function computePeerTableIfChanged(
|
|
|
184
188
|
platform: p.platform ?? null,
|
|
185
189
|
}))
|
|
186
190
|
.sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
|
|
187
|
-
|
|
191
|
+
// Remote basis: machine + per-agent identity/task/files (published_at is
|
|
192
|
+
// excluded — a keepalive re-publish alone shouldn't re-emit the table).
|
|
193
|
+
const remoteBasis = remote.map((m) => ({
|
|
194
|
+
machine: m.machine,
|
|
195
|
+
agents: m.agents.map((a) => ({
|
|
196
|
+
instance_id: a.instance_id,
|
|
197
|
+
name: a.name ?? null,
|
|
198
|
+
task: a.task ?? null,
|
|
199
|
+
files_touched: Array.from(a.files_touched ?? []).sort(),
|
|
200
|
+
})),
|
|
201
|
+
}));
|
|
202
|
+
const newHash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
|
|
188
203
|
|
|
189
204
|
const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
|
|
190
205
|
const oldHash = safeRead(hashFile);
|
|
191
206
|
if (oldHash && oldHash === newHash) return "";
|
|
192
207
|
|
|
193
208
|
// Render peer table via the same formatter used at SessionStart.
|
|
194
|
-
const
|
|
209
|
+
const local = formatPeerTable(peers, mySessionId);
|
|
210
|
+
const remoteTable = formatRemoteTable(remote);
|
|
211
|
+
const table = [local, remoteTable].filter(Boolean).join("\n\n");
|
|
195
212
|
if (!table) return "";
|
|
196
213
|
|
|
197
214
|
// Persist hash atomically (temp + rename, same convention as other coord writes).
|
|
@@ -199,6 +216,34 @@ function computePeerTableIfChanged(
|
|
|
199
216
|
return table;
|
|
200
217
|
}
|
|
201
218
|
|
|
219
|
+
/** readRemoteMachines with a local failure guard (rendering must never throw). */
|
|
220
|
+
function readRemoteMachinesSafe(coordRoot: string): RemoteMachine[] {
|
|
221
|
+
try {
|
|
222
|
+
return readRemoteMachines(coordRoot);
|
|
223
|
+
} catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Render sessions on other machines (presence transport) as a subtable. */
|
|
229
|
+
function formatRemoteTable(remote: RemoteMachine[]): string {
|
|
230
|
+
if (remote.length === 0) return "";
|
|
231
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
232
|
+
const lines: string[] = [];
|
|
233
|
+
for (const m of remote) {
|
|
234
|
+
for (const a of m.agents.slice(0, 10)) {
|
|
235
|
+
lines.push(
|
|
236
|
+
formatRow(
|
|
237
|
+
{ ...a, display_files: Array.from(a.files_touched ?? []).sort() },
|
|
238
|
+
nowSec,
|
|
239
|
+
).replace(/^ {2}- (agent-[^ ]+)/, ` - $1 @${m.machine}`),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (lines.length === 0) return "";
|
|
244
|
+
return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
202
247
|
function computeCouncilPendingIfChanged(
|
|
203
248
|
coordRoot: string,
|
|
204
249
|
selfInstanceId: string,
|
|
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
|
|
14
14
|
import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.ts";
|
|
15
|
+
import { readRemoteMachines } from "../../presence/index.ts";
|
|
15
16
|
|
|
16
17
|
interface HeartbeatRow {
|
|
17
18
|
instance_id?: string;
|
|
@@ -48,7 +49,9 @@ export function renderSessionContext(opts: RenderOpts): string {
|
|
|
48
49
|
|
|
49
50
|
// 1. Self-name line + peer table (folded if peers present)
|
|
50
51
|
const peers = readActivePeers(coordRoot, instanceId);
|
|
51
|
-
const
|
|
52
|
+
const localTable = formatPeerTable(peers, sessionId);
|
|
53
|
+
// Cross-machine presence (ADR 0016): sessions on other machines (advisory).
|
|
54
|
+
const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
|
|
52
55
|
if (agentName) {
|
|
53
56
|
const suffix = platformLabel ? ` (${platformLabel})` : "";
|
|
54
57
|
const selfLine = `You are agent-${agentName}${suffix}.`;
|
|
@@ -107,6 +110,29 @@ export function renderSessionContext(opts: RenderOpts): string {
|
|
|
107
110
|
return messages.join("\n\n");
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
/** One-line-per-agent view of sessions on other machines (presence refs). */
|
|
114
|
+
function formatRemoteMachines(coordRoot: string): string {
|
|
115
|
+
try {
|
|
116
|
+
const remote = readRemoteMachines(coordRoot);
|
|
117
|
+
if (remote.length === 0) return "";
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
for (const m of remote) {
|
|
120
|
+
for (const a of m.agents.slice(0, 10)) {
|
|
121
|
+
const task = a.task ? ` "${a.task.slice(0, 60)}"` : "";
|
|
122
|
+
const files = a.files_touched?.length
|
|
123
|
+
? `holds: ${a.files_touched.slice(0, 3).join(", ")}${a.files_touched.length > 3 ? `, +${a.files_touched.length - 3} more` : ""}`
|
|
124
|
+
: "nothing held";
|
|
125
|
+
lines.push(
|
|
126
|
+
` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (${files})`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
|
|
131
|
+
} catch {
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
110
136
|
/** Read all peer heartbeats from .harnery/active/, excluding self. */
|
|
111
137
|
function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRow[] {
|
|
112
138
|
const out: HeartbeatRow[] = [];
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
+
import { emit } from "../events/emit.ts";
|
|
20
21
|
|
|
21
22
|
const FRESHNESS_SECS = 600;
|
|
22
23
|
|
|
@@ -306,12 +307,36 @@ function pruneClaimFromPeer(coordRoot: string, instanceId: string, relPath: stri
|
|
|
306
307
|
try {
|
|
307
308
|
const body = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
308
309
|
const files = (body.files_touched as string[] | undefined) ?? [];
|
|
309
|
-
|
|
310
|
+
// Normalize both sides: files_touched can hold absolute-under-coordRoot
|
|
311
|
+
// entries (legacy projections) as well as canonical relative ones — an
|
|
312
|
+
// exact-string filter silently no-ops on the mixed-form case and the
|
|
313
|
+
// stale claim never heals.
|
|
314
|
+
const norm = (p: string): string =>
|
|
315
|
+
p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
|
|
316
|
+
const target = norm(relPath);
|
|
317
|
+
const next = files.filter((p) => norm(p) !== target);
|
|
310
318
|
if (next.length === files.length) return;
|
|
311
319
|
body.files_touched = next;
|
|
312
320
|
const tmp = `${path}.tmp.${process.pid}`;
|
|
313
321
|
writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
|
|
314
322
|
renameSync(tmp, path);
|
|
323
|
+
// Durable subtraction: the projector rebuilds files_touched from the
|
|
324
|
+
// permanent Edit/Write events, so a file-only prune resurrects on the
|
|
325
|
+
// next replay. Soft-fail — the heartbeat write already landed.
|
|
326
|
+
try {
|
|
327
|
+
const platform = body.platform as string | undefined;
|
|
328
|
+
emit(coordRoot, {
|
|
329
|
+
event_type: "claim.release",
|
|
330
|
+
instance_id: instanceId,
|
|
331
|
+
session_id: (body.session_id as string | undefined) ?? instanceId,
|
|
332
|
+
harness: platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code",
|
|
333
|
+
source: "agent-coord",
|
|
334
|
+
data: { path: target, reason: "heal" },
|
|
335
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
336
|
+
} as Parameters<typeof emit>[1]);
|
|
337
|
+
} catch {
|
|
338
|
+
/* soft-fail */
|
|
339
|
+
}
|
|
315
340
|
} catch {
|
|
316
341
|
/* silent */
|
|
317
342
|
}
|
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
import { spawnSync } from "node:child_process";
|
|
24
24
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
const DEFAULT_FRESHNESS_SECS = 600;
|
|
26
|
+
import { coordFreshnessSeconds } from "../../config.ts";
|
|
27
|
+
import { instanceHasLivePid } from "../state/pidmap.ts";
|
|
29
28
|
|
|
30
29
|
interface PeerHeartbeat {
|
|
31
30
|
instance_id: string;
|
|
@@ -86,7 +85,7 @@ export function evaluateCommit(coordRoot: string, req: CommitVerdictRequest): Co
|
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
const peers = readActivePeers(coordRoot);
|
|
89
|
-
const cutoffMs = Date.now() -
|
|
88
|
+
const cutoffMs = Date.now() - coordFreshnessSeconds(coordRoot) * 1000;
|
|
90
89
|
const stagedSet = new Set(req.staged_paths);
|
|
91
90
|
const gitlinkSet = new Set(req.staged_gitlinks ?? []);
|
|
92
91
|
|
|
@@ -233,28 +232,7 @@ function isHolderSelfAttributed(
|
|
|
233
232
|
}
|
|
234
233
|
|
|
235
234
|
function holderHasLiveForeignPid(coordRoot: string, holderId: string): boolean {
|
|
236
|
-
|
|
237
|
-
if (!existsSync(dir)) return false;
|
|
238
|
-
for (const f of readdirSync(dir)) {
|
|
239
|
-
let row = "";
|
|
240
|
-
try {
|
|
241
|
-
row = readFileSync(join(dir, f), "utf8").trim();
|
|
242
|
-
} catch {
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
const owner = row.split("\t")[0]?.trim() ?? "";
|
|
246
|
-
if (owner !== holderId) continue;
|
|
247
|
-
// Process still alive?
|
|
248
|
-
const pid = Number.parseInt(f, 10);
|
|
249
|
-
if (!Number.isFinite(pid)) continue;
|
|
250
|
-
try {
|
|
251
|
-
process.kill(pid, 0); // signal 0 = liveness probe
|
|
252
|
-
return true; // live foreign pid found
|
|
253
|
-
} catch {
|
|
254
|
-
// ESRCH (no such process): pid-map entry is stale, skip
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return false;
|
|
235
|
+
return instanceHasLivePid(coordRoot, holderId);
|
|
258
236
|
}
|
|
259
237
|
|
|
260
238
|
function isPathCleanInHead(coordRoot: string, relPath: string): boolean {
|
|
@@ -290,13 +268,6 @@ function readActivePeers(coordRoot: string): PeerHeartbeat[] {
|
|
|
290
268
|
return out;
|
|
291
269
|
}
|
|
292
270
|
|
|
293
|
-
function freshnessSecs(): number {
|
|
294
|
-
const raw = coordEnv("AGENT_FRESHNESS");
|
|
295
|
-
if (!raw) return DEFAULT_FRESHNESS_SECS;
|
|
296
|
-
const n = Number.parseInt(raw, 10);
|
|
297
|
-
return Number.isFinite(n) && n > 0 ? n : DEFAULT_FRESHNESS_SECS;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
271
|
function shortName(peer: PeerHeartbeat): string {
|
|
301
272
|
if (peer.name && peer.name.length > 0) return `agent-${peer.name}`;
|
|
302
273
|
return `agent-${peer.instance_id.slice(0, 8)}`;
|
|
@@ -49,6 +49,14 @@ export interface StopHookRequest {
|
|
|
49
49
|
turn_window?: { start_ms: number; end_ms: number };
|
|
50
50
|
/** Bypass switch: operator escape hatch identical to HARNERY_AGENT_COORD_BYPASS_STOP. */
|
|
51
51
|
bypass?: boolean;
|
|
52
|
+
/** Headless child spawned by `harn workflow` (HARNERY_WORKFLOW_CHILD=1).
|
|
53
|
+
* The end-of-turn ritual exists to surface status to a HUMAN reader; a
|
|
54
|
+
* workflow child reports to the engine's journal instead, so the ritual is
|
|
55
|
+
* meaningless there — worse, blocking burns the child's turn budget on
|
|
56
|
+
* re-prompts (observed as error_max_turns in the Phase 1 spike). Exempting
|
|
57
|
+
* here, rather than disabling the child's hooks wholesale, keeps heartbeat +
|
|
58
|
+
* event capture on: the child stays visible to peers and the coord layer. */
|
|
59
|
+
workflow_child?: boolean;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/**
|
|
@@ -95,6 +103,15 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
|
|
|
95
103
|
};
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
if (req.workflow_child) {
|
|
107
|
+
return {
|
|
108
|
+
allow: true,
|
|
109
|
+
exit_code: 0,
|
|
110
|
+
rule: "stop-hook.workflow_child",
|
|
111
|
+
reason: "HARNERY_WORKFLOW_CHILD=1: headless workflow child; ritual not applicable",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
let events: CanonicalEvent[];
|
|
99
116
|
try {
|
|
100
117
|
events = readRecentEvents(coordRoot, RECENT_EVENT_WINDOW_LINES);
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { existsSync, readFileSync
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { dirname, resolve } from "node:path";
|
|
14
14
|
// Kept dependency-light: vendored verbatim into a downstream consumer, so no coordEnv import.
|
|
15
|
-
import { normalizeHarness } from "./canonical-emit.ts";
|
|
15
|
+
import { normalizeHarness, resolveEmitRoot } from "./canonical-emit.ts";
|
|
16
16
|
import { emit } from "./events/emit.ts";
|
|
17
17
|
|
|
18
18
|
/** Event types accepted by `writeSessionEvent`. Only the command stream +
|
|
@@ -38,25 +38,14 @@ export function sessionEventsPath(): string {
|
|
|
38
38
|
// Explicit override (tests + non-monorepo invocations).
|
|
39
39
|
const explicit = process.env.HARNERY_OUTPUT_SESSION_EVENTS;
|
|
40
40
|
if (explicit) return explicit;
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
41
|
+
// Superproject-aware root resolution (git first, cwd walk fallback) via
|
|
42
|
+
// resolveEmitRoot. A plain cwd walk here mis-anchored to a NESTED
|
|
43
|
+
// `.harnery/` when the shell sat inside a submodule carrying its own
|
|
44
|
+
// (e.g. an embedded harnery checkout), silently splitting the event
|
|
45
|
+
// stream. Don't reintroduce CLAUDE.md / .git as anchors, which creates
|
|
45
46
|
// stray .harnery/ dirs under submodule subtrees.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const candidate = resolve(dir, ".harnery");
|
|
49
|
-
try {
|
|
50
|
-
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
51
|
-
return resolve(candidate, "session-events.ndjson");
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
/* keep walking */
|
|
55
|
-
}
|
|
56
|
-
const parent = dirname(dir);
|
|
57
|
-
if (parent === dir) break;
|
|
58
|
-
dir = parent;
|
|
59
|
-
}
|
|
47
|
+
const root = resolveEmitRoot();
|
|
48
|
+
if (root) return resolve(root, ".harnery", "session-events.ndjson");
|
|
60
49
|
return resolve(process.env.HOME || "/tmp", ".harnery", "session-events.ndjson");
|
|
61
50
|
}
|
|
62
51
|
|
|
@@ -75,25 +64,12 @@ export function newCmdId(): string {
|
|
|
75
64
|
*/
|
|
76
65
|
export function readLastIntent(instanceId?: string): string | null {
|
|
77
66
|
if (!instanceId) return null;
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
86
|
-
agentsDir = candidate;
|
|
87
|
-
break;
|
|
88
|
-
}
|
|
89
|
-
} catch {
|
|
90
|
-
/* keep walking */
|
|
91
|
-
}
|
|
92
|
-
const parent = dirname(dir);
|
|
93
|
-
if (parent === dir) break;
|
|
94
|
-
dir = parent;
|
|
95
|
-
}
|
|
96
|
-
if (!agentsDir) return null;
|
|
67
|
+
// Same superproject-aware root resolution as sessionEventsPath(): the
|
|
68
|
+
// intent stamp is written by the PreToolUse hook into the SUPERPROJECT's
|
|
69
|
+
// .harnery/, so a nested-`.harnery/` cwd must not redirect the read.
|
|
70
|
+
const root = resolveEmitRoot();
|
|
71
|
+
if (!root) return null;
|
|
72
|
+
const agentsDir = resolve(root, ".harnery");
|
|
97
73
|
const intentPath = resolve(agentsDir, `.last-intent.${instanceId}`);
|
|
98
74
|
if (!existsSync(intentPath)) return null;
|
|
99
75
|
try {
|