@mikenguyen69/harness 0.1.0-beta.1

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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/build-identity.json +1 -0
  4. package/dist/cancellation.js +138 -0
  5. package/dist/cli.js +47 -0
  6. package/dist/dispatch.js +70 -0
  7. package/dist/help.js +47 -0
  8. package/dist/internal/agent/adapter/conformance.js +53 -0
  9. package/dist/internal/agent/adapter/hook.js +113 -0
  10. package/dist/internal/agent/adapter/install.js +30 -0
  11. package/dist/internal/agent/adapter/scope.js +55 -0
  12. package/dist/internal/agent/adapter/session.js +63 -0
  13. package/dist/internal/agent/adapter/settings.js +51 -0
  14. package/dist/internal/agent/cli.js +79 -0
  15. package/dist/internal/agent/evals/index.js +111 -0
  16. package/dist/internal/agent/index.js +15 -0
  17. package/dist/internal/agent/ledger-read.js +27 -0
  18. package/dist/internal/agent/routing/cli.js +104 -0
  19. package/dist/internal/agent/routing/index.js +123 -0
  20. package/dist/internal/agent/telemetry/index.js +97 -0
  21. package/dist/internal/orchestration/activity/index.js +111 -0
  22. package/dist/internal/orchestration/analyze/index.js +237 -0
  23. package/dist/internal/orchestration/cli.js +475 -0
  24. package/dist/internal/orchestration/config/index.js +66 -0
  25. package/dist/internal/orchestration/doctor/index.js +202 -0
  26. package/dist/internal/orchestration/forge/index.js +121 -0
  27. package/dist/internal/orchestration/git/index.js +181 -0
  28. package/dist/internal/orchestration/harness/cli.js +378 -0
  29. package/dist/internal/orchestration/harness/index.js +186 -0
  30. package/dist/internal/orchestration/harness/shim.js +182 -0
  31. package/dist/internal/orchestration/index.js +29 -0
  32. package/dist/internal/orchestration/loop/index.js +1107 -0
  33. package/dist/internal/orchestration/prompts/index.js +199 -0
  34. package/dist/internal/orchestration/report/index.js +125 -0
  35. package/dist/internal/orchestration/routes/index.js +85 -0
  36. package/dist/internal/orchestration/run/index.js +221 -0
  37. package/dist/internal/orchestration/runner/index.js +90 -0
  38. package/dist/internal/orchestration/runners/claude/index.js +246 -0
  39. package/dist/internal/orchestration/runners/claude/sdk.js +198 -0
  40. package/dist/internal/orchestration/runners/claude/wiring.js +98 -0
  41. package/dist/internal/orchestration/runners/codex/exec.js +114 -0
  42. package/dist/internal/orchestration/runners/codex/index.js +72 -0
  43. package/dist/internal/orchestration/runners/conformance/index.js +169 -0
  44. package/dist/internal/orchestration/runners/cursor/exec.js +531 -0
  45. package/dist/internal/orchestration/runners/cursor/index.js +184 -0
  46. package/dist/internal/orchestration/runners/stub/index.js +168 -0
  47. package/dist/internal/orchestration/runners/telemetry.js +36 -0
  48. package/dist/internal/orchestration/runstate/index.js +213 -0
  49. package/dist/internal/orchestration/runstate/publish.js +115 -0
  50. package/dist/internal/orchestration/worktree/index.js +89 -0
  51. package/dist/internal/system/adapters/index.js +19 -0
  52. package/dist/internal/system/agentops/index.js +16 -0
  53. package/dist/internal/system/agentops/scope.js +67 -0
  54. package/dist/internal/system/assets.js +45 -0
  55. package/dist/internal/system/board/cli.js +56 -0
  56. package/dist/internal/system/board/index.js +276 -0
  57. package/dist/internal/system/board/live.js +277 -0
  58. package/dist/internal/system/calibration/cli.js +45 -0
  59. package/dist/internal/system/calibration/index.js +141 -0
  60. package/dist/internal/system/cli.js +174 -0
  61. package/dist/internal/system/corpus/replay.js +79 -0
  62. package/dist/internal/system/digest/cli.js +23 -0
  63. package/dist/internal/system/digest/index.js +145 -0
  64. package/dist/internal/system/doors/classify.js +94 -0
  65. package/dist/internal/system/doors/cli.js +184 -0
  66. package/dist/internal/system/doors/index.js +249 -0
  67. package/dist/internal/system/doors/metrics.js +47 -0
  68. package/dist/internal/system/escapes/cli.js +45 -0
  69. package/dist/internal/system/escapes/index.js +81 -0
  70. package/dist/internal/system/explore/cli.js +77 -0
  71. package/dist/internal/system/explore/index.js +91 -0
  72. package/dist/internal/system/gates/cli.js +43 -0
  73. package/dist/internal/system/gates/detectors.js +55 -0
  74. package/dist/internal/system/gates/pre.js +51 -0
  75. package/dist/internal/system/hub/cli.js +51 -0
  76. package/dist/internal/system/hub/index.js +146 -0
  77. package/dist/internal/system/init/index.js +83 -0
  78. package/dist/internal/system/ledger/cli.js +54 -0
  79. package/dist/internal/system/ledger/index.js +176 -0
  80. package/dist/internal/system/manifest/index.js +207 -0
  81. package/dist/internal/system/provenance/index.js +13 -0
  82. package/dist/internal/system/report/index.js +15 -0
  83. package/dist/internal/system/runner/index.js +74 -0
  84. package/dist/internal/system/runstatus/cli.js +86 -0
  85. package/dist/internal/system/runstatus/index.js +168 -0
  86. package/dist/internal/system/sequencer/briefing.js +75 -0
  87. package/dist/internal/system/sequencer/cli.js +170 -0
  88. package/dist/internal/system/sequencer/index.js +156 -0
  89. package/dist/internal/system/spec/cli.js +110 -0
  90. package/dist/internal/system/spec/index.js +212 -0
  91. package/dist/internal/system/spec/materialize.js +111 -0
  92. package/dist/internal/system/spec/openspec-parse.js +83 -0
  93. package/dist/internal/system/spec/project.js +208 -0
  94. package/dist/internal/system/substrate/index.js +14 -0
  95. package/dist/internal/system/system/index.js +159 -0
  96. package/dist/internal/system/units/index.js +87 -0
  97. package/dist/internal/system/verify/cli.js +103 -0
  98. package/dist/internal/system/verify/declined-reasons.js +61 -0
  99. package/dist/internal/system/verify/index.js +188 -0
  100. package/dist/internal/system/verify/signing.js +79 -0
  101. package/dist/paths.js +50 -0
  102. package/dist/routes.js +93 -0
  103. package/dist/version.js +30 -0
  104. package/dist/win32-process-group.js +360 -0
  105. package/package.json +41 -0
  106. package/profiles/python/bindings.toml +59 -0
  107. package/profiles/terraform/bindings.toml +66 -0
  108. package/profiles/typescript/bindings.toml +64 -0
  109. package/profiles/typescript/smoke/README.md +8 -0
  110. package/schema/baseline.schema.json +26 -0
  111. package/schema/decision-classes.yaml +45 -0
  112. package/schema/envelope.schema.json +186 -0
  113. package/schema/ledger-event.schema.json +235 -0
  114. package/schema/manifest.schema.json +119 -0
  115. package/schema/routes.schema.json +55 -0
  116. package/schema/run-status.schema.json +162 -0
  117. package/schema/session-event.schema.json +74 -0
  118. package/schema/spec.schema.json +114 -0
  119. package/schema/system.schema.json +142 -0
  120. package/templates/ci/verify.yml +23 -0
  121. package/templates/target-kit/README.md +78 -0
  122. package/templates/target-kit/component.toml +20 -0
  123. package/templates/target-kit/orchestration.json +9 -0
  124. package/templates/target-kit/profiles/target/bindings.toml +6 -0
  125. package/templates/target-kit/routes.toml +5 -0
  126. package/templates/target-kit/specs/first-unit.yaml +11 -0
  127. package/templates/target-kit/system.toml +7 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * session — emits `session.*` events into the record plane.
3
+ *
4
+ * The interface law (implementation plan §1): agent-harness never imports
5
+ * system-harness source. It appends through the `harness ledger append`
6
+ * subcommand so chaining and signing stay in one implementation. The binary is
7
+ * resolved from $HARNESS_BIN (default "harness"); the ledger dir from
8
+ * $HARNESS_LEDGER_DIR (default ".harness/ledger").
9
+ *
10
+ * Contract D (schema/session-event.schema.json) is the body shape.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ const PHASE_TYPE = {
14
+ started: "session.started",
15
+ heartbeat: "session.heartbeat",
16
+ gate_fired: "session.gate_fired",
17
+ escalated: "session.escalated",
18
+ ended: "session.ended",
19
+ };
20
+ /** The `harness ledger append` payload for a session event — pure, testable. */
21
+ export function buildSessionEvent(phase, body, opts = {}) {
22
+ const system = opts.system ?? process.env.HARNESS_SYSTEM ?? body.component_id;
23
+ return {
24
+ system,
25
+ actor: { kind: "agent", id: body.session_id },
26
+ type: PHASE_TYPE[phase],
27
+ body: { ...body, phase },
28
+ };
29
+ }
30
+ export function emitSessionEvent(phase, body, opts = {}) {
31
+ const bin = opts.harnessBin ?? process.env.HARNESS_BIN ?? "harness";
32
+ let binArgs = [];
33
+ try {
34
+ const raw = process.env.HARNESS_BIN_ARGS;
35
+ if (raw)
36
+ binArgs = JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return { ok: false, reason: "HARNESS_BIN_ARGS must be a JSON string array" };
40
+ }
41
+ const ledgerDir = opts.ledgerDir ?? process.env.HARNESS_LEDGER_DIR ?? ".harness/ledger";
42
+ const event = buildSessionEvent(phase, body, opts);
43
+ try {
44
+ const r = spawnSync(bin, [...binArgs, "ledger", "append", "--dir", ledgerDir], {
45
+ input: JSON.stringify(event),
46
+ encoding: "utf8",
47
+ windowsHide: true,
48
+ });
49
+ if (r.status === 0)
50
+ return { ok: true };
51
+ const reason = `harness ledger append exited ${r.status ?? "null"}: ${(r.stderr ?? r.error?.message ?? "").trim()}`;
52
+ if (opts.soft ?? true)
53
+ return { ok: false, reason };
54
+ throw new Error(reason);
55
+ }
56
+ catch (e) {
57
+ const reason = `could not reach '${bin}': ${e.message}`;
58
+ if (opts.soft ?? true)
59
+ return { ok: false, reason };
60
+ throw e;
61
+ }
62
+ }
63
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * settings — the `.claude/settings.json` hooks block that wires this adapter.
3
+ * `agent-harness install` prints it; `harness init` writes it by invoking
4
+ * `adapter/install` (the hook-install path). Generated commands always call
5
+ * the public facade entry — never an internal binary path and never
6
+ * AGENT_HARNESS_BIN.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ export const HOOK_COMMAND = "harness hook pre-tool-use";
11
+ export function hooksSnippet(binCommand = HOOK_COMMAND) {
12
+ return {
13
+ hooks: {
14
+ PreToolUse: [
15
+ {
16
+ matcher: "Write|Edit|MultiEdit|NotebookEdit",
17
+ hooks: [{ type: "command", command: binCommand }],
18
+ },
19
+ ],
20
+ },
21
+ };
22
+ }
23
+ function alreadyHasPublicHook(pre) {
24
+ const text = JSON.stringify(pre);
25
+ return text.includes("harness hook pre-tool-use") || text.includes("hook pre-tool-use");
26
+ }
27
+ /**
28
+ * Hook-install write path — merge PreToolUse into `<repo>/.claude/settings.json`.
29
+ * Idempotent. Owned by this adapter; `harness init` invokes it as a composition.
30
+ */
31
+ export function installHooks(repoRoot, command = HOOK_COMMAND) {
32
+ const path = resolve(repoRoot, ".claude", "settings.json");
33
+ let settings = {};
34
+ if (existsSync(path)) {
35
+ try {
36
+ settings = JSON.parse(readFileSync(path, "utf8"));
37
+ }
38
+ catch {
39
+ settings = {};
40
+ }
41
+ }
42
+ const hooks = (settings.hooks ??= {});
43
+ const pre = (hooks.PreToolUse ??= []);
44
+ if (!alreadyHasPublicHook(pre)) {
45
+ pre.push(...hooksSnippet(command).hooks.PreToolUse);
46
+ }
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
49
+ return path;
50
+ }
51
+ //# sourceMappingURL=settings.js.map
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-harness — the fleet adapter surface.
4
+ *
5
+ * agent-harness hook pre-tool-use PreToolUse handler (payload on stdin)
6
+ * agent-harness session <phase> < body append a session.<phase> event
7
+ * agent-harness escalate --reason R --wants W raise session.escalated for the current unit
8
+ * agent-harness route <unit-id> --profile p print the resolved route as JSON (P7.0)
9
+ * agent-harness install print the .claude/settings.json hooks block
10
+ *
11
+ * Never imports @harness/system source — it reaches the ledger through the
12
+ * `harness ledger append` subcommand (interface law).
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { loadUnitContext, runHookFromStdin } from "./adapter/hook.js";
16
+ import { emitSessionEvent } from "./adapter/session.js";
17
+ import { hooksSnippet } from "./adapter/settings.js";
18
+ import { routeCommand } from "./routing/cli.js";
19
+ const argv = process.argv.slice(2);
20
+ const [cmd, sub] = argv;
21
+ function flag(name) {
22
+ const i = argv.indexOf(`--${name}`);
23
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : undefined;
24
+ }
25
+ if (cmd === "hook" && sub === "pre-tool-use") {
26
+ const result = runHookFromStdin(readFileSync(0, "utf8"));
27
+ if (result.message)
28
+ process.stderr.write(result.message + "\n");
29
+ process.exit(result.exitCode);
30
+ }
31
+ if (cmd === "session" && sub) {
32
+ const phases = [
33
+ "started",
34
+ "heartbeat",
35
+ "gate_fired",
36
+ "escalated",
37
+ "ended",
38
+ ];
39
+ if (!phases.includes(sub)) {
40
+ process.stderr.write(`unknown session phase "${sub}" (${phases.join(" | ")})\n`);
41
+ process.exit(2);
42
+ }
43
+ const body = JSON.parse(readFileSync(0, "utf8") || "{}");
44
+ const r = emitSessionEvent(sub, body, { soft: true });
45
+ if (!r.ok)
46
+ process.stderr.write(`session ${sub}: ${r.reason}\n`);
47
+ process.exit(r.ok ? 0 : 1);
48
+ }
49
+ if (cmd === "escalate") {
50
+ const reason = flag("reason");
51
+ const wants = flag("wants");
52
+ if (!reason || !wants) {
53
+ process.stderr.write('agent-harness escalate --reason "<why>" --wants "<what you need>"\n');
54
+ process.exit(2);
55
+ }
56
+ const ctx = loadUnitContext(flag("cwd") ?? process.cwd());
57
+ if (!ctx) {
58
+ process.stderr.write("escalate: no current-unit context (.harness/current-unit.json or HARNESS_UNIT_* env)\n");
59
+ process.exit(2);
60
+ }
61
+ const r = emitSessionEvent("escalated", {
62
+ session_id: flag("session") ?? process.env.HARNESS_SESSION_ID ?? "unknown",
63
+ unit_id: ctx.unit_id,
64
+ component_id: ctx.component_id,
65
+ escalation: { reason, wants },
66
+ }, { soft: true });
67
+ process.stdout.write(r.ok ? `escalated ${ctx.unit_id}\n` : `escalate: ${r.reason}\n`);
68
+ process.exit(r.ok ? 0 : 1);
69
+ }
70
+ if (cmd === "route") {
71
+ process.exit(routeCommand(argv.slice(1)));
72
+ }
73
+ if (cmd === "install") {
74
+ process.stdout.write(JSON.stringify(hooksSnippet(), null, 2) + "\n");
75
+ process.exit(0);
76
+ }
77
+ process.stderr.write("agent-harness <hook pre-tool-use | session <phase> | escalate | route | install>\n");
78
+ process.exit(2);
79
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * evals — seeded-defect scoring. For a route (model[/config]) and a set of
3
+ * seeded-defect cases, run the route's ring-0+1 loop over each and score the
4
+ * catch rate. `eval.run {route, catch_rate, n}` is appended to the ledger.
5
+ *
6
+ * Below a case-count floor the score is `null` — "insufficient data", never a
7
+ * fake number. Ships with a synthetic case set so the runner works before a
8
+ * real corpus exists.
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ export const DEFAULT_FLOOR = 5;
12
+ /** Synthetic seeded-defect cases — a labelled fallback until the real corpus feeds this. */
13
+ export function syntheticCases() {
14
+ return [
15
+ {
16
+ id: "syn-spec-1",
17
+ klass: "spec",
18
+ input: { vague: true },
19
+ mustCatch: true,
20
+ },
21
+ {
22
+ id: "syn-spec-2",
23
+ klass: "spec",
24
+ input: { vague: false },
25
+ mustCatch: false,
26
+ },
27
+ {
28
+ id: "syn-supp-1",
29
+ klass: "suppression",
30
+ input: { added: 1 },
31
+ mustCatch: true,
32
+ },
33
+ {
34
+ id: "syn-supp-2",
35
+ klass: "suppression",
36
+ input: { added: 0 },
37
+ mustCatch: false,
38
+ },
39
+ {
40
+ id: "syn-scope-1",
41
+ klass: "scope",
42
+ input: { outside: 2 },
43
+ mustCatch: true,
44
+ },
45
+ {
46
+ id: "syn-scope-2",
47
+ klass: "scope",
48
+ input: { outside: 0 },
49
+ mustCatch: false,
50
+ },
51
+ {
52
+ id: "syn-testweak-1",
53
+ klass: "test-weakening",
54
+ input: { removed: 3 },
55
+ mustCatch: true,
56
+ },
57
+ ];
58
+ }
59
+ export async function runEvals(route, cases, catcher, floor = DEFAULT_FLOOR) {
60
+ const perClass = {};
61
+ let caught = 0;
62
+ let mustCount = 0;
63
+ for (const c of cases) {
64
+ const hit = await catcher(c);
65
+ (perClass[c.klass] ??= { n: 0, caught: 0 }).n++;
66
+ if (c.mustCatch) {
67
+ mustCount++;
68
+ if (hit) {
69
+ caught++;
70
+ perClass[c.klass].caught++;
71
+ }
72
+ }
73
+ }
74
+ const insufficient = cases.length < floor;
75
+ return {
76
+ route,
77
+ n: cases.length,
78
+ caught,
79
+ missed: mustCount - caught,
80
+ catchRate: insufficient || mustCount === 0
81
+ ? null
82
+ : Number((caught / mustCount).toFixed(3)),
83
+ insufficient,
84
+ perClass,
85
+ };
86
+ }
87
+ /** Append `eval.run {route, catch_rate, n}` through `harness ledger append`. */
88
+ export function emitEvalRun(result, opts = {}) {
89
+ const bin = opts.harnessBin ?? process.env.HARNESS_BIN ?? "harness";
90
+ const ledgerDir = opts.ledgerDir ?? process.env.HARNESS_LEDGER_DIR ?? ".harness/ledger";
91
+ const event = {
92
+ system: opts.system ?? process.env.HARNESS_SYSTEM ?? "fleet",
93
+ actor: { kind: "ci", id: "eval-job" },
94
+ type: "eval.run",
95
+ body: { route: result.route, catch_rate: result.catchRate, n: result.n },
96
+ };
97
+ try {
98
+ const r = spawnSync(bin, ["ledger", "append", "--dir", ledgerDir], {
99
+ input: JSON.stringify(event),
100
+ encoding: "utf8",
101
+ windowsHide: true,
102
+ });
103
+ return r.status === 0
104
+ ? { ok: true }
105
+ : { ok: false, reason: (r.stderr ?? "").trim() };
106
+ }
107
+ catch (e) {
108
+ return { ok: false, reason: e.message };
109
+ }
110
+ }
111
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @harness/agent — the agent fleet: the Claude Code adapter (telemetry + scope
3
+ * enforcement, ring 0) now; evals and routing in Phase 6.
4
+ */
5
+ export { checkToolCall, } from "./adapter/scope.js";
6
+ export { emitSessionEvent, buildSessionEvent, } from "./adapter/session.js";
7
+ export { handlePreToolUse, runHookFromStdin, loadUnitContext, } from "./adapter/hook.js";
8
+ export { hooksSnippet } from "./adapter/settings.js";
9
+ export { readLedger } from "./ledger-read.js";
10
+ export { join as joinTelemetry, joinLedger, } from "./telemetry/index.js";
11
+ export { runEvals, emitEvalRun, syntheticCases, DEFAULT_FLOOR, } from "./evals/index.js";
12
+ export { loadRoutes, resolveRoute, effectiveMode, untilSatisfied, driftFlag, RoutesError, } from "./routing/index.js";
13
+ export { routeCommand, resolveForCli, latestEvalScore, } from "./routing/cli.js";
14
+ export const PRODUCT = "@harness/agent";
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * ledger-read — agent-harness's read-only view of the record plane.
3
+ *
4
+ * The ledger FORMAT is a published interface (implementation plan §1): JSONL
5
+ * segments, one event per line. agent-harness reads it here without importing
6
+ * @harness/system source. It never writes — appends go through
7
+ * `harness ledger append` (see adapter/session.ts).
8
+ */
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ const SEGMENT_RE = /^\d{6}\.jsonl$/;
12
+ export function readLedger(dir) {
13
+ if (!existsSync(dir))
14
+ return [];
15
+ const out = [];
16
+ for (const seg of readdirSync(dir)
17
+ .filter((n) => SEGMENT_RE.test(n))
18
+ .sort()) {
19
+ for (const line of readFileSync(join(dir, seg), "utf8").split("\n")) {
20
+ if (line.trim() === "")
21
+ continue;
22
+ out.push(JSON.parse(line));
23
+ }
24
+ }
25
+ return out.sort((a, b) => a.seq - b.seq);
26
+ }
27
+ //# sourceMappingURL=ledger-read.js.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `agent-harness route` — the routing consumption CLI (P7.0).
3
+ *
4
+ * agent-harness route <unit-id> --profile <p> [--routes <path>] [--ledger <dir>] [--json]
5
+ *
6
+ * `resolveRoute` is otherwise a source-only function, and portfolio rule 1
7
+ * forbids importing it across `repos/` — so this subcommand is the ONLY legal
8
+ * way for a sibling (orchestration-harness's loop, its M2 dispatch) to read the
9
+ * routing decision for a unit.
10
+ *
11
+ * Inputs:
12
+ * <unit-id> the A/B split key — the arm a unit lands on is stable per id
13
+ * --profile the unit's component profile (the orchestrator has this from
14
+ * the `harness claim` briefing's "component + profile context");
15
+ * `resolveRoute` matches routes by profile, so it is required
16
+ * --routes routes.toml (default: ./routes.toml, like the sibling CLIs)
17
+ * --ledger ledger dir for the latest `eval.run` score behind `until`
18
+ * (default: .harness/ledger)
19
+ *
20
+ * Output (stdout, JSON): `{ runner, model, config?, mode, until? }`
21
+ * runner from the route (P7.1); "claude" when the route omits it
22
+ * mode effectiveMode — a `propose-only` route reads back `standard` once
23
+ * its `until` (e.g. route-eval>=90) is met by the ledger score
24
+ *
25
+ * Exit: 0 with JSON · 1 when no route matches the profile · 2 on usage error.
26
+ */
27
+ import { readLedger } from "../ledger-read.js";
28
+ import { effectiveMode, loadRoutes, resolveRoute, RoutesError, } from "./index.js";
29
+ function flag(args, name) {
30
+ const i = args.indexOf(`--${name}`);
31
+ return i >= 0 && args[i + 1] ? args[i + 1] : undefined;
32
+ }
33
+ /**
34
+ * The latest `eval.run` catch rate for a route, as a 0..100 score for
35
+ * `untilSatisfied` (the ledger stores `catch_rate` as a 0..1 fraction). `null`
36
+ * when there is no measurement — `effectiveMode` then keeps the tight mode.
37
+ */
38
+ export function latestEvalScore(ledgerDir, route) {
39
+ const keys = new Set(route.config ? [route.model, `${route.model}/${route.config}`] : [route.model]);
40
+ let score = null;
41
+ for (const e of readLedger(ledgerDir)) {
42
+ if (e.type !== "eval.run")
43
+ continue;
44
+ const body = e.body;
45
+ if (typeof body.route !== "string" || !keys.has(body.route))
46
+ continue;
47
+ score = typeof body.catch_rate === "number" ? body.catch_rate * 100 : null;
48
+ }
49
+ return score;
50
+ }
51
+ export function resolveForCli(opts) {
52
+ const routes = loadRoutes(opts.routesPath);
53
+ const route = resolveRoute(routes, opts.profile, opts.unitId);
54
+ if (!route)
55
+ return null;
56
+ const score = latestEvalScore(opts.ledgerDir, route);
57
+ const resolved = {
58
+ runner: route.runner,
59
+ model: route.model,
60
+ mode: effectiveMode(route, score),
61
+ };
62
+ if (route.config !== undefined)
63
+ resolved.config = route.config;
64
+ if (route.until !== undefined)
65
+ resolved.until = route.until;
66
+ return resolved;
67
+ }
68
+ export function routeCommand(args) {
69
+ const unitId = args[0];
70
+ if (!unitId || unitId.startsWith("--")) {
71
+ process.stderr.write("agent-harness route <unit-id> --profile <p> [--routes <path>] [--ledger <dir>] [--json]\n");
72
+ return 2;
73
+ }
74
+ const profile = flag(args, "profile");
75
+ if (!profile) {
76
+ process.stderr.write("route: --profile <p> is required (the unit's component profile — resolveRoute matches by profile)\n");
77
+ return 2;
78
+ }
79
+ const routesPath = flag(args, "routes") ?? "routes.toml";
80
+ const ledgerDir = flag(args, "ledger") ?? ".harness/ledger";
81
+ let resolved;
82
+ try {
83
+ resolved = resolveForCli({ unitId, profile, routesPath, ledgerDir });
84
+ }
85
+ catch (e) {
86
+ if (e instanceof RoutesError) {
87
+ process.stderr.write(`route: ${e.message}\n`);
88
+ return 2;
89
+ }
90
+ const err = e;
91
+ if (err.code === "ENOENT") {
92
+ process.stderr.write(`route: routes file not found: ${routesPath}\n`);
93
+ return 2;
94
+ }
95
+ throw e;
96
+ }
97
+ if (!resolved) {
98
+ process.stderr.write(`route: no route matches profile "${profile}"\n`);
99
+ return 1;
100
+ }
101
+ process.stdout.write(JSON.stringify(resolved) + "\n");
102
+ return 0;
103
+ }
104
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,123 @@
1
+ /**
2
+ * routing — routes.toml as policy data (Contract D, schema/routes.schema.json).
3
+ * Maps a component profile to a model + config, an optional A/B split, and an
4
+ * optional `until` that relaxes a tightened mode once the eval loop measures it
5
+ * (D8). Also flags eval drift across a model-rev boundary.
6
+ *
7
+ * Calibration may PROPOSE route changes; it never applies them.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ import { parse as parseToml } from "smol-toml";
12
+ export class RoutesError extends Error {
13
+ name = "RoutesError";
14
+ }
15
+ export function loadRoutes(path) {
16
+ const raw = parseToml(readFileSync(path, "utf8"));
17
+ if (!Array.isArray(raw.route) || raw.route.length === 0) {
18
+ throw new RoutesError(`${path}: at least one [[route]] is required`);
19
+ }
20
+ const routes = raw.route.map((r, i) => {
21
+ const o = r;
22
+ if (typeof o.profile !== "string" || typeof o.model !== "string") {
23
+ throw new RoutesError(`route[${i}]: profile and model are required`);
24
+ }
25
+ if (o.split !== undefined &&
26
+ (typeof o.split !== "number" || o.split < 0 || o.split > 1)) {
27
+ throw new RoutesError(`route[${i}]: split must be in [0,1]`);
28
+ }
29
+ const mode = o.mode ?? "standard";
30
+ if (mode !== "standard" && mode !== "propose-only") {
31
+ throw new RoutesError(`route[${i}]: mode must be standard|propose-only`);
32
+ }
33
+ const runner = o.runner ?? "claude";
34
+ if (runner !== "claude" && runner !== "codex" && runner !== "cursor") {
35
+ throw new RoutesError(`route[${i}]: runner must be claude|codex|cursor`);
36
+ }
37
+ return {
38
+ profile: o.profile,
39
+ model: o.model,
40
+ runner,
41
+ mode,
42
+ ...(typeof o.config === "string" ? { config: o.config } : {}),
43
+ ...(typeof o.split === "number" ? { split: o.split } : {}),
44
+ ...(typeof o.until === "string" ? { until: o.until } : {}),
45
+ };
46
+ });
47
+ // resolveRoute treats a candidate with no `split` as weight 0 once ANY
48
+ // sibling declares one — a partial split within a profile silently strands
49
+ // the splitless route(s), so require all-or-nothing per profile.
50
+ const byProfile = new Map();
51
+ for (const r of routes) {
52
+ byProfile.set(r.profile, [...(byProfile.get(r.profile) ?? []), r]);
53
+ }
54
+ for (const [profile, group] of byProfile) {
55
+ if (group.length <= 1)
56
+ continue;
57
+ const withSplit = group.filter((r) => r.split !== undefined).length;
58
+ if (withSplit > 0 && withSplit < group.length) {
59
+ throw new RoutesError(`${path}: profile "${profile}" has ${group.length} routes but only ${withSplit} declare split — either all or none must (a splitless route among split siblings is unreachable)`);
60
+ }
61
+ }
62
+ return routes;
63
+ }
64
+ /**
65
+ * Pick the route for a profile. When >1 route shares a profile, `unitKey`
66
+ * deterministically selects an A/B arm by split weight (stable per unit).
67
+ */
68
+ export function resolveRoute(routes, profile, unitKey = "") {
69
+ const candidates = routes.filter((r) => r.profile === profile);
70
+ if (candidates.length <= 1)
71
+ return candidates[0];
72
+ const total = candidates.reduce((s, r) => s + (r.split ?? 0), 0) || candidates.length;
73
+ const frac = candidates.some((r) => r.split !== undefined)
74
+ ? undefined
75
+ : 1 / candidates.length;
76
+ const roll = (parseInt(createHash("sha256").update(unitKey).digest("hex").slice(0, 8), 16) /
77
+ 0xffffffff) *
78
+ total;
79
+ let acc = 0;
80
+ for (const r of candidates) {
81
+ acc += frac ?? r.split ?? 0;
82
+ if (roll <= acc)
83
+ return r;
84
+ }
85
+ return candidates.at(-1);
86
+ }
87
+ /** Parse an `until` like "route-eval>=90" and evaluate it against a score (0..100). */
88
+ export function untilSatisfied(until, evalScore) {
89
+ if (!until)
90
+ return false;
91
+ const m = /^route-eval\s*(>=|>|==)\s*(\d+(?:\.\d+)?)$/.exec(until.trim());
92
+ if (!m || evalScore === null)
93
+ return false;
94
+ const n = Number(m[2]);
95
+ return m[1] === ">"
96
+ ? evalScore > n
97
+ : m[1] === "=="
98
+ ? evalScore === n
99
+ : evalScore >= n;
100
+ }
101
+ /** The effective mode: a `propose-only` route relaxes to `standard` once its `until` is met. */
102
+ export function effectiveMode(route, evalScore) {
103
+ if (route.mode === "propose-only" && untilSatisfied(route.until, evalScore))
104
+ return "standard";
105
+ return route.mode;
106
+ }
107
+ /**
108
+ * Flag drift when a route's catch rate drops by more than `threshold` (fraction,
109
+ * e.g. 0.09 for 9 points) after a model revision.
110
+ */
111
+ export function driftFlag(prev, curr, threshold = 0.09) {
112
+ if (prev === null || curr === null)
113
+ return { drifted: false, delta: null };
114
+ const delta = Number((curr - prev).toFixed(3));
115
+ return delta < -threshold
116
+ ? {
117
+ drifted: true,
118
+ delta,
119
+ reason: `catch rate fell ${Math.abs(delta * 100).toFixed(0)} points after a model rev`,
120
+ }
121
+ : { drifted: false, delta };
122
+ }
123
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * telemetry — the (session ↔ envelope) join. Pure projection over the ledger:
3
+ * every envelope names its session; every session names its model + cost. The
4
+ * join gives cost per merged unit, in-loop (ring-0) catch counts, and
5
+ * escalation rate — per route.
6
+ *
7
+ * D2 boundary: this is READ by agent-harness only; system-harness never reads a
8
+ * session record to decide a merge.
9
+ */
10
+ import { readLedger } from "../ledger-read.js";
11
+ function routeKey(s) {
12
+ const m = s.model ?? "unknown";
13
+ return s.configHash ? `${m}/${s.configHash}` : m;
14
+ }
15
+ export function join(events) {
16
+ // 1. fold sessions
17
+ const sessions = new Map();
18
+ for (const e of events) {
19
+ if (!e.type.startsWith("session."))
20
+ continue;
21
+ const b = e.body;
22
+ const id = b.session_id ?? "unknown";
23
+ const s = sessions.get(id) ?? {
24
+ model: null,
25
+ configHash: null,
26
+ costUsd: 0,
27
+ unitId: null,
28
+ escalated: false,
29
+ };
30
+ if (b.model)
31
+ s.model = b.model;
32
+ if (b.config_hash)
33
+ s.configHash = b.config_hash;
34
+ if (typeof b.cost_usd === "number")
35
+ s.costUsd += b.cost_usd;
36
+ if (b.unit_id)
37
+ s.unitId = b.unit_id;
38
+ if (e.type === "session.escalated")
39
+ s.escalated = true;
40
+ sessions.set(id, s);
41
+ }
42
+ // 2. envelopes carry session_id + pre_failures; unit.merged marks a merged unit
43
+ const preFailuresBySession = new Map();
44
+ const mergedUnits = new Set();
45
+ for (const e of events) {
46
+ if (e.type === "unit.merged") {
47
+ const uid = e.body.unit_id;
48
+ if (uid)
49
+ mergedUnits.add(uid);
50
+ }
51
+ if (e.type !== "envelope")
52
+ continue;
53
+ const b = e.body;
54
+ if (!b.session_id)
55
+ continue;
56
+ const pf = (b.gates ?? []).reduce((n, g) => n + (g.pre_failures ?? 0), 0);
57
+ preFailuresBySession.set(b.session_id, (preFailuresBySession.get(b.session_id) ?? 0) + pf);
58
+ }
59
+ // 3. roll up per route
60
+ const routes = new Map();
61
+ for (const [id, s] of sessions) {
62
+ const key = routeKey(s);
63
+ const r = routes.get(key) ??
64
+ {
65
+ route: key,
66
+ sessions: 0,
67
+ mergedUnits: 0,
68
+ totalCostUsd: 0,
69
+ costPerMergedUnit: null,
70
+ ring0Catches: 0,
71
+ escalations: 0,
72
+ escalationRate: 0,
73
+ };
74
+ r.sessions++;
75
+ r.totalCostUsd += s.costUsd;
76
+ r.ring0Catches += preFailuresBySession.get(id) ?? 0;
77
+ if (s.escalated)
78
+ r.escalations++;
79
+ if (s.unitId && mergedUnits.has(s.unitId))
80
+ r.mergedUnits++;
81
+ routes.set(key, r);
82
+ }
83
+ for (const r of routes.values()) {
84
+ r.costPerMergedUnit =
85
+ r.mergedUnits > 0
86
+ ? Number((r.totalCostUsd / r.mergedUnits).toFixed(2))
87
+ : null;
88
+ r.escalationRate =
89
+ r.sessions > 0 ? Number((r.escalations / r.sessions).toFixed(3)) : 0;
90
+ r.totalCostUsd = Number(r.totalCostUsd.toFixed(2));
91
+ }
92
+ return [...routes.values()].sort((a, b) => a.route.localeCompare(b.route));
93
+ }
94
+ export function joinLedger(ledgerDir) {
95
+ return join(readLedger(ledgerDir));
96
+ }
97
+ //# sourceMappingURL=index.js.map