harnery 0.9.0 → 0.11.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.
Files changed (211) hide show
  1. package/README.md +1 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +6 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +2 -3
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/harness.d.ts +11 -0
  23. package/dist/commands/harness.d.ts.map +1 -0
  24. package/dist/commands/harness.js +118 -0
  25. package/dist/commands/presence.d.ts +9 -4
  26. package/dist/commands/presence.d.ts.map +1 -1
  27. package/dist/commands/presence.js +88 -5
  28. package/dist/commands/relay.d.ts +9 -0
  29. package/dist/commands/relay.d.ts.map +1 -0
  30. package/dist/commands/relay.js +143 -0
  31. package/dist/commands/sync.d.ts.map +1 -1
  32. package/dist/commands/sync.js +5 -0
  33. package/dist/commands/workflow.d.ts.map +1 -1
  34. package/dist/commands/workflow.js +53 -17
  35. package/dist/core/agents/canonical-emit.d.ts +15 -0
  36. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  37. package/dist/core/agents/canonical-emit.js +41 -3
  38. package/dist/core/agents/cli.js +4 -2
  39. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/prompt-context.js +44 -3
  41. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  42. package/dist/core/agents/render/session-context.js +26 -1
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +4 -37
  45. package/dist/core/agents/session-events.d.ts.map +1 -1
  46. package/dist/core/agents/session-events.js +16 -43
  47. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  48. package/dist/core/agents/state/heartbeat-projector.js +11 -2
  49. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  50. package/dist/core/agents/state/heartbeat-writer.js +3 -3
  51. package/dist/core/agents/state/names.d.ts +38 -3
  52. package/dist/core/agents/state/names.d.ts.map +1 -1
  53. package/dist/core/agents/state/names.js +46 -7
  54. package/dist/core/agents/state/pidmap.d.ts +5 -1
  55. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  56. package/dist/core/agents/state/pidmap.js +58 -2
  57. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  58. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  59. package/dist/core/agents/state/stale-sweep.js +6 -6
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +191 -25
  63. package/dist/core/context/index.d.ts +144 -0
  64. package/dist/core/context/index.d.ts.map +1 -0
  65. package/dist/core/context/index.js +380 -0
  66. package/dist/core/harnesses/bench.d.ts +29 -0
  67. package/dist/core/harnesses/bench.d.ts.map +1 -0
  68. package/dist/core/harnesses/bench.js +151 -0
  69. package/dist/core/harnesses/index.d.ts +9 -0
  70. package/dist/core/harnesses/index.d.ts.map +1 -0
  71. package/dist/core/harnesses/index.js +4 -0
  72. package/dist/core/harnesses/profiles.d.ts +62 -0
  73. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  74. package/dist/core/harnesses/profiles.js +115 -0
  75. package/dist/core/harnesses/registry.d.ts +15 -0
  76. package/dist/core/harnesses/registry.d.ts.map +1 -0
  77. package/dist/core/harnesses/registry.js +113 -0
  78. package/dist/core/harnesses/types.d.ts +64 -0
  79. package/dist/core/harnesses/types.d.ts.map +1 -0
  80. package/dist/core/harnesses/types.js +20 -0
  81. package/dist/core/hooks/cli.js +280 -63
  82. package/dist/core/hooks/events/schema.d.ts +38 -1
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  85. package/dist/core/hooks/harness/events.js +3 -0
  86. package/dist/core/hooks/harness/parse.d.ts +1 -1
  87. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  88. package/dist/core/hooks/harness/parse.js +4 -0
  89. package/dist/core/presence/blob.d.ts +40 -0
  90. package/dist/core/presence/blob.d.ts.map +1 -0
  91. package/dist/core/presence/blob.js +91 -0
  92. package/dist/core/presence/git.d.ts +68 -0
  93. package/dist/core/presence/git.d.ts.map +1 -0
  94. package/dist/core/presence/git.js +173 -0
  95. package/dist/core/presence/index.d.ts +74 -0
  96. package/dist/core/presence/index.d.ts.map +1 -0
  97. package/dist/core/presence/index.js +224 -0
  98. package/dist/core/presence/relay-client.d.ts +39 -0
  99. package/dist/core/presence/relay-client.d.ts.map +1 -0
  100. package/dist/core/presence/relay-client.js +295 -0
  101. package/dist/core/presence/relay-protocol.d.ts +94 -0
  102. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  103. package/dist/core/presence/relay-protocol.js +169 -0
  104. package/dist/core/workflow/billing.d.ts +1 -1
  105. package/dist/core/workflow/billing.d.ts.map +1 -1
  106. package/dist/core/workflow/billing.js +7 -1
  107. package/dist/core/workflow/engine.d.ts +5 -0
  108. package/dist/core/workflow/engine.d.ts.map +1 -1
  109. package/dist/core/workflow/engine.js +164 -16
  110. package/dist/core/workflow/harnesses.d.ts +3 -3
  111. package/dist/core/workflow/harnesses.d.ts.map +1 -1
  112. package/dist/core/workflow/harnesses.js +8 -17
  113. package/dist/core/workflow/index.d.ts +5 -0
  114. package/dist/core/workflow/index.d.ts.map +1 -0
  115. package/dist/core/workflow/index.js +3 -0
  116. package/dist/core/workflow/proof.d.ts +42 -0
  117. package/dist/core/workflow/proof.d.ts.map +1 -0
  118. package/dist/core/workflow/proof.js +342 -0
  119. package/dist/core/workflow/spawn-claude.d.ts +4 -1
  120. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  121. package/dist/core/workflow/spawn-claude.js +39 -18
  122. package/dist/core/workflow/spawn-codex.d.ts +4 -1
  123. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  124. package/dist/core/workflow/spawn-codex.js +44 -22
  125. package/dist/core/workflow/spawn-cursor.d.ts +4 -1
  126. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  127. package/dist/core/workflow/spawn-cursor.js +35 -16
  128. package/dist/core/workflow/types.d.ts +156 -2
  129. package/dist/core/workflow/types.d.ts.map +1 -1
  130. package/dist/core/workflow/types.js +1 -1
  131. package/dist/lib/browser/client.d.ts +16 -0
  132. package/dist/lib/browser/client.d.ts.map +1 -1
  133. package/dist/lib/browser/client.js +22 -0
  134. package/dist/lib/browser/index.d.ts +1 -0
  135. package/dist/lib/browser/index.d.ts.map +1 -1
  136. package/dist/lib/browser/index.js +1 -0
  137. package/dist/lib/browser/launch-args.d.ts +21 -0
  138. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  139. package/dist/lib/browser/launch-args.js +32 -0
  140. package/dist/lib/claude-desktop.d.ts +105 -0
  141. package/dist/lib/claude-desktop.d.ts.map +1 -0
  142. package/dist/lib/claude-desktop.js +217 -0
  143. package/dist/lib/docs-lint.d.ts.map +1 -1
  144. package/dist/lib/docs-lint.js +5 -2
  145. package/dist/lib/identities/assume.d.ts +51 -0
  146. package/dist/lib/identities/assume.d.ts.map +1 -0
  147. package/dist/lib/identities/assume.js +274 -0
  148. package/dist/lib/identities/index.d.ts +7 -7
  149. package/dist/lib/identities/index.d.ts.map +1 -1
  150. package/dist/lib/identities/index.js +24 -24
  151. package/dist/lib/instructions/templates.d.ts.map +1 -1
  152. package/dist/lib/instructions/templates.js +15 -2
  153. package/package.json +22 -6
  154. package/schemas/config.schema.json +88 -19
  155. package/src/commander.ts +6 -0
  156. package/src/commands/agents.ts +280 -14
  157. package/src/commands/backup.ts +17 -16
  158. package/src/commands/browse.ts +32 -0
  159. package/src/commands/claude-desktop.ts +215 -0
  160. package/src/commands/context.ts +174 -1
  161. package/src/commands/doctor.ts +2 -3
  162. package/src/commands/eml.ts +22 -4
  163. package/src/commands/harness.ts +147 -0
  164. package/src/commands/presence.ts +111 -5
  165. package/src/commands/relay.ts +160 -0
  166. package/src/commands/sync.ts +4 -0
  167. package/src/commands/workflow.ts +62 -19
  168. package/src/core/agents/canonical-emit.ts +41 -3
  169. package/src/core/agents/cli.ts +4 -2
  170. package/src/core/agents/render/prompt-context.ts +48 -3
  171. package/src/core/agents/render/session-context.ts +27 -1
  172. package/src/core/agents/rules/commit-conflict.ts +4 -33
  173. package/src/core/agents/session-events.ts +15 -39
  174. package/src/core/agents/state/heartbeat-projector.ts +10 -1
  175. package/src/core/agents/state/heartbeat-writer.ts +3 -2
  176. package/src/core/agents/state/names.ts +68 -9
  177. package/src/core/agents/state/pidmap.ts +59 -2
  178. package/src/core/agents/state/stale-sweep.ts +6 -11
  179. package/src/core/config.ts +248 -24
  180. package/src/core/context/index.ts +575 -0
  181. package/src/core/harnesses/bench.ts +214 -0
  182. package/src/core/harnesses/index.ts +31 -0
  183. package/src/core/harnesses/profiles.ts +141 -0
  184. package/src/core/harnesses/registry.ts +140 -0
  185. package/src/core/harnesses/types.ts +90 -0
  186. package/src/core/hooks/cli.ts +331 -62
  187. package/src/core/hooks/events/schema.ts +68 -0
  188. package/src/core/hooks/harness/events.ts +3 -0
  189. package/src/core/hooks/harness/parse.ts +7 -1
  190. package/src/core/presence/blob.ts +125 -0
  191. package/src/core/presence/git.ts +191 -0
  192. package/src/core/presence/index.ts +274 -0
  193. package/src/core/presence/relay-client.ts +322 -0
  194. package/src/core/presence/relay-protocol.ts +246 -0
  195. package/src/core/workflow/billing.ts +8 -2
  196. package/src/core/workflow/engine.ts +182 -16
  197. package/src/core/workflow/harnesses.ts +14 -19
  198. package/src/core/workflow/index.ts +43 -0
  199. package/src/core/workflow/proof.ts +434 -0
  200. package/src/core/workflow/spawn-claude.ts +39 -20
  201. package/src/core/workflow/spawn-codex.ts +47 -28
  202. package/src/core/workflow/spawn-cursor.ts +37 -18
  203. package/src/core/workflow/types.ts +176 -2
  204. package/src/lib/browser/client.ts +37 -0
  205. package/src/lib/browser/index.ts +1 -0
  206. package/src/lib/browser/launch-args.ts +33 -0
  207. package/src/lib/claude-desktop.ts +293 -0
  208. package/src/lib/docs-lint.ts +5 -2
  209. package/src/lib/identities/assume.ts +363 -0
  210. package/src/lib/identities/index.ts +28 -24
  211. package/src/lib/instructions/templates.ts +16 -2
@@ -0,0 +1,215 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import {
4
+ applyMirror,
5
+ type DesktopAccount,
6
+ findDesktopDataDirs,
7
+ listAccounts,
8
+ planMirror,
9
+ readCliAccount,
10
+ } from "../lib/claude-desktop.ts";
11
+
12
+ /**
13
+ * `claude-desktop`: make Claude desktop-app sessions survive account
14
+ * switches.
15
+ *
16
+ * The desktop app scopes its Claude Code session sidebar per signed-in
17
+ * account (plain JSON entry files under
18
+ * `<dataDir>/claude-code-sessions/<account-uuid>/<env-id>/`), while the
19
+ * transcripts live account-agnostically under `~/.claude/projects/`. A user
20
+ * who hops accounts (e.g. when one hits its usage limit) loses sidebar
21
+ * access to every session the other account created — even though the data
22
+ * is all still on disk. `mirror` copies the entry files across account
23
+ * directories so each account's sidebar lists the union; restarting the
24
+ * desktop app picks the copies up, and opening one resumes with full
25
+ * history.
26
+ */
27
+ export function registerClaudeDesktopCommand(program: Command, emit: EmitContext): void {
28
+ const cmd = program
29
+ .command("claude-desktop")
30
+ .description(
31
+ "Claude desktop-app session index: list accounts/sessions, mirror sessions across accounts",
32
+ )
33
+ .action(() => {
34
+ const dirs = requireDataDirs(emit, undefined);
35
+ for (const dir of dirs) {
36
+ emit.data({
37
+ data_dir: dir,
38
+ accounts: listAccounts(dir).map((a) => accountSummary(a)),
39
+ cli_account: readCliAccount(),
40
+ });
41
+ }
42
+ });
43
+
44
+ cmd
45
+ .command("accounts")
46
+ .description("List account directories + session counts (labels the CLI's account when known)")
47
+ .option("--data-dir <path>", "Explicit desktop-app data directory")
48
+ .action((opts: { dataDir?: string }) => {
49
+ const dirs = requireDataDirs(emit, opts.dataDir);
50
+ const cli = readCliAccount();
51
+ emit.data(
52
+ dirs.map((dir) => ({
53
+ data_dir: dir,
54
+ accounts: listAccounts(dir).map((a) => ({
55
+ ...accountSummary(a),
56
+ is_cli_account: cli !== null && a.accountUuid === cli.accountUuid,
57
+ cli_email: cli !== null && a.accountUuid === cli.accountUuid ? cli.email : null,
58
+ })),
59
+ })),
60
+ );
61
+ });
62
+
63
+ cmd
64
+ .command("sessions")
65
+ .description("List session entries across every account (newest activity first)")
66
+ .option("--data-dir <path>", "Explicit desktop-app data directory")
67
+ .option("--account <uuid-prefix>", "Only this account (uuid prefix match)")
68
+ .option("--archived", "Include archived entries")
69
+ .action((opts: { dataDir?: string; account?: string; archived?: boolean }) => {
70
+ const dirs = requireDataDirs(emit, opts.dataDir);
71
+ const rows = [];
72
+ for (const dir of dirs) {
73
+ for (const account of listAccounts(dir)) {
74
+ if (opts.account && !account.accountUuid.startsWith(opts.account)) continue;
75
+ for (const e of account.entries) {
76
+ if (e.isArchived && !opts.archived) continue;
77
+ rows.push({
78
+ account_uuid: account.accountUuid,
79
+ cli_session_id: e.cliSessionId,
80
+ title: e.title,
81
+ cwd: e.cwd,
82
+ model: e.model,
83
+ archived: e.isArchived,
84
+ last_activity_at: e.lastActivityAt,
85
+ });
86
+ }
87
+ }
88
+ }
89
+ rows.sort((a, b) => (b.last_activity_at ?? 0) - (a.last_activity_at ?? 0));
90
+ emit.data(rows);
91
+ });
92
+
93
+ cmd
94
+ .command("mirror")
95
+ .description(
96
+ "Copy session entries across accounts so each sidebar lists the union. " +
97
+ "Dry-run by default; --yes applies. Restart the desktop app afterward to pick up copies.",
98
+ )
99
+ .option("--data-dir <path>", "Explicit desktop-app data directory")
100
+ .option(
101
+ "--session <id-or-title>",
102
+ "Only sessions matching this cliSessionId or title substring (repeatable)",
103
+ collect,
104
+ [] as string[],
105
+ )
106
+ .option("--all", "Mirror every (non-archived) session")
107
+ .option(
108
+ "--to <uuid-prefix>",
109
+ "Only copy INTO accounts matching this prefix (repeatable)",
110
+ collect,
111
+ [] as string[],
112
+ )
113
+ .option(
114
+ "--from <uuid-prefix>",
115
+ "Only copy FROM accounts matching this prefix (repeatable)",
116
+ collect,
117
+ [] as string[],
118
+ )
119
+ .option("--include-archived", "Also mirror archived entries")
120
+ .option("--yes", "Apply the plan (default is dry-run)")
121
+ .action(
122
+ (opts: {
123
+ dataDir?: string;
124
+ session: string[];
125
+ all?: boolean;
126
+ to: string[];
127
+ from: string[];
128
+ includeArchived?: boolean;
129
+ yes?: boolean;
130
+ }) => {
131
+ if (!opts.all && opts.session.length === 0) {
132
+ emit.error({
133
+ code: "no_selection",
134
+ message: "pass --session <id-or-title> (repeatable) or --all",
135
+ });
136
+ process.exit(2);
137
+ }
138
+ const dirs = requireDataDirs(emit, opts.dataDir);
139
+ for (const dir of dirs) {
140
+ const accounts = listAccounts(dir);
141
+ if (accounts.length < 2) {
142
+ emit.error({
143
+ code: "single_account",
144
+ message: `only ${accounts.length} account dir(s) under ${dir} — nothing to mirror across`,
145
+ });
146
+ process.exit(2);
147
+ }
148
+ const plan = planMirror(accounts, {
149
+ to: opts.to,
150
+ from: opts.from,
151
+ sessions: opts.all ? undefined : opts.session,
152
+ includeArchived: opts.includeArchived,
153
+ });
154
+ const planned = plan.actions.map((a) => ({
155
+ title: a.entry.title,
156
+ cli_session_id: a.entry.cliSessionId,
157
+ to_account: a.targetAccountUuid,
158
+ to_file: a.to,
159
+ }));
160
+ if (!opts.yes) {
161
+ emit.data({
162
+ data_dir: dir,
163
+ dry_run: true,
164
+ planned,
165
+ skipped_existing: plan.skippedExisting,
166
+ skipped_archived: plan.skippedArchived,
167
+ hint: plan.actions.length > 0 ? "re-run with --yes to apply" : "nothing to copy",
168
+ });
169
+ continue;
170
+ }
171
+ const { copied } = applyMirror(plan);
172
+ emit.data({
173
+ data_dir: dir,
174
+ dry_run: false,
175
+ copied,
176
+ planned,
177
+ skipped_existing: plan.skippedExisting,
178
+ skipped_archived: plan.skippedArchived,
179
+ hint:
180
+ copied > 0
181
+ ? "fully quit the Claude desktop app (tray icon, not the X) and relaunch to see the sessions"
182
+ : "nothing to copy",
183
+ });
184
+ }
185
+ },
186
+ );
187
+ }
188
+
189
+ function collect(value: string, previous: string[]): string[] {
190
+ return previous.concat([value]);
191
+ }
192
+
193
+ function accountSummary(a: DesktopAccount) {
194
+ return {
195
+ account_uuid: a.accountUuid,
196
+ sessions: a.entries.length,
197
+ archived: a.entries.filter((e) => e.isArchived).length,
198
+ latest_title: a.entries[0]?.title ?? null,
199
+ latest_activity_at: a.entries[0]?.lastActivityAt ?? null,
200
+ };
201
+ }
202
+
203
+ function requireDataDirs(emit: EmitContext, explicit: string | undefined): string[] {
204
+ const dirs = findDesktopDataDirs(explicit);
205
+ if (dirs.length === 0) {
206
+ emit.error({
207
+ code: "not_found",
208
+ message:
209
+ "no Claude desktop-app data directory with a claude-code-sessions/ index found " +
210
+ "(is the desktop app installed? pass --data-dir or set HARNERY_CLAUDE_DESKTOP_DIR)",
211
+ });
212
+ process.exit(2);
213
+ }
214
+ return dirs;
215
+ }
@@ -1,5 +1,13 @@
1
1
  import type { Command } from "commander";
2
2
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
3
+ import { monorepoRoot, readHeartbeat, resolveOwner } from "../core/agents/index.ts";
4
+ import {
5
+ type CheckpointReason,
6
+ checkpointContext,
7
+ readContextState,
8
+ readLatestCapsule,
9
+ } from "../core/context/index.ts";
10
+ import type { Harness } from "../core/hooks/events/schema.ts";
3
11
  import {
4
12
  buildContext,
5
13
  type ContextReport,
@@ -20,7 +28,7 @@ export function registerContextCommand(
20
28
  emit: EmitContext,
21
29
  context?: HarneryProgramContext,
22
30
  ): void {
23
- program
31
+ const command = program
24
32
  .command("context")
25
33
  .description(
26
34
  `One-shot orientation snapshot: self, repo, submodules, peers, recent commits. Default sections: ${DEFAULT_SECTIONS.join(" / ")}. Opt-in: ${OPT_IN_SECTIONS.join(" / ")}.`,
@@ -68,6 +76,171 @@ export function registerContextCommand(
68
76
  }
69
77
  },
70
78
  );
79
+
80
+ command
81
+ .command("status")
82
+ .description("Show context-continuity phase, generation, and latest telemetry")
83
+ .option("--session <id>", "Session id (defaults to the current agent heartbeat)")
84
+ .option("--instance <id>", "Agent instance id (defaults to the current owner)")
85
+ .option("--json", "Structured JSON output")
86
+ .action((opts: { session?: string; instance?: string; json?: boolean }) => {
87
+ try {
88
+ const json = opts.json || Boolean(command.opts().json);
89
+ const identity = resolveContinuityIdentity(context, opts);
90
+ const state = readContextState(identity.coordRoot, identity.sessionId);
91
+ if (json) {
92
+ emit.config({ format: "json" });
93
+ emit.data({ state, identity });
94
+ return;
95
+ }
96
+ if (!state) {
97
+ emit.text(`No context continuity state for session ${identity.sessionId}.\n`);
98
+ return;
99
+ }
100
+ const sample = state.latest_context;
101
+ const usage = sample
102
+ ? `${sample.used_tokens ?? "?"}/${sample.window_tokens ?? "?"} tokens${sample.used_percent === undefined ? "" : ` (${sample.used_percent}%)`}`
103
+ : "not reported by harness";
104
+ emit.text(
105
+ `${[
106
+ `Session: ${state.session_id}`,
107
+ `Phase: ${state.phase}`,
108
+ `Generation: ${state.generation}`,
109
+ `Telemetry: ${usage}`,
110
+ `Capsule: ${state.latest_capsule ?? "none"}`,
111
+ state.degraded_reason ? `Degraded: ${state.degraded_reason}` : "",
112
+ ]
113
+ .filter(Boolean)
114
+ .join("\n")}\n`,
115
+ );
116
+ } catch (err) {
117
+ emitCommandError(emit, "context_status_failed", err);
118
+ }
119
+ });
120
+
121
+ command
122
+ .command("checkpoint")
123
+ .description("Create a durable context-continuity capsule for the current work")
124
+ .option("--session <id>", "Session id (defaults to the current agent heartbeat)")
125
+ .option("--instance <id>", "Agent instance id (defaults to the current owner)")
126
+ .option("--harness <id>", "claude-code, codex, or cursor (inferred from heartbeat)")
127
+ .option("--reason <reason>", "manual, pressure, pre_compact, or session_end", "manual")
128
+ .option("--note <text>", "Short continuation note for the recovered agent")
129
+ .option("--json", "Structured JSON output")
130
+ .action(
131
+ (opts: {
132
+ session?: string;
133
+ instance?: string;
134
+ harness?: string;
135
+ reason: string;
136
+ note?: string;
137
+ json?: boolean;
138
+ }) => {
139
+ try {
140
+ const json = opts.json || Boolean(command.opts().json);
141
+ const reason = parseCheckpointReason(opts.reason);
142
+ const identity = resolveContinuityIdentity(context, opts);
143
+ if (!identity.harness) {
144
+ throw new Error("could not infer the harness; pass --harness claude-code|codex|cursor");
145
+ }
146
+ const result = checkpointContext(identity.coordRoot, {
147
+ sessionId: identity.sessionId,
148
+ instanceId: identity.instanceId,
149
+ harness: identity.harness,
150
+ cwd: context?.repoRoot ?? process.cwd(),
151
+ reason,
152
+ continuationNote: opts.note,
153
+ });
154
+ const output = {
155
+ capsule_id: result.capsule.capsule_id,
156
+ generation: result.capsule.generation,
157
+ path: result.state.latest_capsule,
158
+ phase: result.state.phase,
159
+ reused: result.reused,
160
+ };
161
+ if (json) {
162
+ emit.config({ format: "json" });
163
+ emit.data(output);
164
+ return;
165
+ }
166
+ emit.text(
167
+ `Checkpointed context generation ${output.generation}: ${output.path ?? result.path}\n`,
168
+ );
169
+ } catch (err) {
170
+ emitCommandError(emit, "context_checkpoint_failed", err);
171
+ }
172
+ },
173
+ );
174
+
175
+ command
176
+ .command("show")
177
+ .description("Show the latest durable context-continuity capsule")
178
+ .option("--session <id>", "Session id (defaults to the current agent heartbeat)")
179
+ .option("--instance <id>", "Agent instance id (defaults to the current owner)")
180
+ .option("--json", "Structured JSON output")
181
+ .action((opts: { session?: string; instance?: string; json?: boolean }) => {
182
+ try {
183
+ const json = opts.json || Boolean(command.opts().json);
184
+ const identity = resolveContinuityIdentity(context, opts);
185
+ const capsule = readLatestCapsule(identity.coordRoot, identity.sessionId);
186
+ if (!capsule) {
187
+ throw new Error(`no context capsule exists for session ${identity.sessionId}`);
188
+ }
189
+ if (json) emit.config({ format: "json" });
190
+ emit.data(capsule);
191
+ } catch (err) {
192
+ emitCommandError(emit, "context_show_failed", err);
193
+ }
194
+ });
195
+ }
196
+
197
+ function resolveContinuityIdentity(
198
+ context: HarneryProgramContext | undefined,
199
+ opts: { session?: string; instance?: string; harness?: string },
200
+ ): { coordRoot: string; instanceId: string; sessionId: string; harness: Harness | null } {
201
+ const coordRoot = context?.resolveCoordRoot?.() ?? context?.repoRoot ?? monorepoRoot();
202
+ if (!coordRoot) throw new Error("could not resolve a project containing .harnery/");
203
+ const instanceId = opts.instance ?? resolveOwner();
204
+ if (!instanceId) {
205
+ throw new Error("could not resolve the current agent; pass --instance and --session");
206
+ }
207
+ const heartbeat = readHeartbeat(instanceId);
208
+ const sessionId = opts.session ?? heartbeat?.session_id ?? instanceId;
209
+ const harness = opts.harness
210
+ ? parseHarness(opts.harness)
211
+ : harnessFromPlatform(heartbeat?.platform);
212
+ return { coordRoot, instanceId, sessionId, harness };
213
+ }
214
+
215
+ function harnessFromPlatform(platform: string | undefined): Harness | null {
216
+ if (platform === "claude_code" || platform === "claude-code") return "claude-code";
217
+ if (platform === "cursor") return "cursor";
218
+ if (platform === "codex") return "codex";
219
+ return null;
220
+ }
221
+
222
+ function parseHarness(value: string): Harness {
223
+ if (value === "claude-code" || value === "codex" || value === "cursor") return value;
224
+ throw new Error(`invalid harness "${value}"; expected claude-code, codex, or cursor`);
225
+ }
226
+
227
+ function parseCheckpointReason(value: string): CheckpointReason {
228
+ if (
229
+ value === "manual" ||
230
+ value === "pressure" ||
231
+ value === "pre_compact" ||
232
+ value === "session_end"
233
+ ) {
234
+ return value;
235
+ }
236
+ throw new Error(
237
+ `invalid reason "${value}"; expected manual, pressure, pre_compact, or session_end`,
238
+ );
239
+ }
240
+
241
+ function emitCommandError(emit: EmitContext, code: string, err: unknown): void {
242
+ emit.error({ code, message: err instanceof Error ? err.message : String(err) });
243
+ emit.setExitCode(1);
71
244
  }
72
245
 
73
246
  function collectSections(value: string, prev: SectionName[]): SectionName[] {
@@ -18,6 +18,7 @@ import path from "node:path";
18
18
  import type { Command } from "commander";
19
19
  import type { EmitContext } from "../commander.ts";
20
20
  import { resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
21
+ import { BUILTIN_HARNESS_IDS } from "../core/harnesses/index.ts";
21
22
  import { loadHarnessWiring } from "../core/hooks/harness/wiring.ts";
22
23
  import { probeBilling } from "../core/workflow/billing.ts";
23
24
  import {
@@ -103,9 +104,7 @@ export function runChecks(): Check[] {
103
104
  checkRipgrep(),
104
105
  checkHarneryDir(),
105
106
  checkHarnessHooks(),
106
- checkWorkflowHarness("claude-code"),
107
- checkWorkflowHarness("codex"),
108
- checkWorkflowHarness("cursor"),
107
+ ...BUILTIN_HARNESS_IDS.map(checkWorkflowHarness),
109
108
  checkRestic(),
110
109
  checkRclone(),
111
110
  checkPlaywright(),
@@ -27,7 +27,7 @@ export function registerEmlCommand(program: Command, emit: EmitContext): void {
27
27
  .argument("<file>", "Path to the .eml file")
28
28
  .option("-o, --output <path>", "Write output to a file instead of stdout")
29
29
  .option("--format <type>", "Output format: markdown, json", "markdown")
30
- .option("--headers", "Include full email headers per message")
30
+ .option("--headers", "Include the source message's full email headers")
31
31
  .option("--attachments", "List attachment filenames and sizes")
32
32
  .action(async (file: string, opts: EmlOpts) => {
33
33
  try {
@@ -40,7 +40,7 @@ export function registerEmlCommand(program: Command, emit: EmitContext): void {
40
40
  });
41
41
  }
42
42
 
43
- interface EmlOpts {
43
+ export interface EmlOpts {
44
44
  output?: string;
45
45
  format: string;
46
46
  headers?: boolean;
@@ -88,7 +88,7 @@ async function handleEml(file: string, opts: EmlOpts, emit: EmitContext): Promis
88
88
  * Extract individual messages from the parsed email.
89
89
  * Strategy: use HTML body (Gmail quote structure) first, fall back to plain text.
90
90
  */
91
- function extractThread(parsed: ParsedMail): ThreadMessage[] {
91
+ export function extractThread(parsed: ParsedMail): ThreadMessage[] {
92
92
  if (parsed.html && typeof parsed.html === "string") {
93
93
  const messages = extractFromHtml(parsed.html, parsed);
94
94
  if (messages.length > 0) return messages;
@@ -451,7 +451,11 @@ function formatAddress(addr: ParsedMail["from"]): string {
451
451
  /**
452
452
  * Render the thread as markdown.
453
453
  */
454
- function renderMarkdown(parsed: ParsedMail, messages: ThreadMessage[], opts: EmlOpts): string {
454
+ export function renderMarkdown(
455
+ parsed: ParsedMail,
456
+ messages: ThreadMessage[],
457
+ opts: EmlOpts,
458
+ ): string {
455
459
  const subject = parsed.subject ?? "Untitled Thread";
456
460
  const participants = new Map<string, boolean>();
457
461
  for (const m of messages) {
@@ -471,6 +475,20 @@ function renderMarkdown(parsed: ParsedMail, messages: ThreadMessage[], opts: Eml
471
475
  lines.push(`**Date range:** ${minDate} to ${maxDate}`);
472
476
  lines.push(`**Messages:** ${messages.length}`);
473
477
 
478
+ if (opts.headers && parsed.headerLines?.length) {
479
+ // A Gmail thread export is a SINGLE .eml (older messages are quoted HTML,
480
+ // not separate MIME parts), so there is one real header set — the source
481
+ // message's. Render it verbatim.
482
+ lines.push("");
483
+ lines.push("**Source headers:**");
484
+ lines.push("");
485
+ lines.push("```");
486
+ for (const h of parsed.headerLines) {
487
+ lines.push(h.line);
488
+ }
489
+ lines.push("```");
490
+ }
491
+
474
492
  if (opts.attachments && parsed.attachments?.length) {
475
493
  lines.push("");
476
494
  lines.push("**Attachments:**");
@@ -0,0 +1,147 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import {
4
+ type BenchResult,
5
+ createBuiltinHarnessRegistry,
6
+ type HarnessBenchReport,
7
+ runHarnessBench,
8
+ } from "../core/harnesses/index.ts";
9
+ import type { HarnessProfile } from "../core/harnesses/types.ts";
10
+
11
+ interface FormatOpts {
12
+ json?: boolean;
13
+ }
14
+
15
+ interface BenchOpts extends FormatOpts {
16
+ requireInstalled?: boolean;
17
+ }
18
+
19
+ const registry = createBuiltinHarnessRegistry();
20
+
21
+ export function registerHarnessCommand(program: Command, emit: EmitContext): void {
22
+ const command = program
23
+ .command("harness")
24
+ .description("Inspect registered harness capabilities and run their conformance bench.");
25
+
26
+ command
27
+ .command("list")
28
+ .description("List registered harness adapters and their high-signal capability claims.")
29
+ .option("--json", "Machine-readable profile catalog")
30
+ .action((opts: FormatOpts) => {
31
+ const profiles = registry.list().map((adapter) => adapter.profile);
32
+ if (opts.json) {
33
+ emit.config({ format: "json" });
34
+ emit.data({ harnesses: profiles });
35
+ } else {
36
+ emit.text(renderProfileTable(profiles));
37
+ }
38
+ });
39
+
40
+ command
41
+ .command("show <id>")
42
+ .description("Show one harness's complete capability declaration.")
43
+ .option("--json", "Machine-readable profile")
44
+ .action((id: string, opts: FormatOpts) => {
45
+ const adapter = registry.get(id);
46
+ if (!adapter) {
47
+ emit.error({
48
+ code: "unknown_harness",
49
+ message: `unknown harness ${JSON.stringify(id)} (registered: ${registry.ids().join(", ")})`,
50
+ });
51
+ emit.setExitCode(1);
52
+ return;
53
+ }
54
+ if (opts.json) {
55
+ emit.config({ format: "json" });
56
+ emit.data(adapter.profile);
57
+ } else {
58
+ emit.text(renderProfile(adapter.profile));
59
+ }
60
+ });
61
+
62
+ command
63
+ .command("bench [harnesses...]")
64
+ .description("Run the offline adapter-contract bench (no model calls); drift exits non-zero.")
65
+ .option("--require-installed", "Also fail when a registered vendor CLI is missing")
66
+ .option("--json", "Machine-readable conformance report")
67
+ .action((harnesses: string[], opts: BenchOpts) => {
68
+ try {
69
+ const report = runHarnessBench(registry, { harnesses });
70
+ if (opts.json) {
71
+ emit.config({ format: "json" });
72
+ emit.data(report);
73
+ } else {
74
+ emit.text(renderBenchReport(report));
75
+ }
76
+ emit.setExitCode(report.drift || (opts.requireInstalled && report.skipped) ? 1 : 0);
77
+ } catch (error) {
78
+ emit.error({ code: "harness_bench_failed", message: (error as Error).message });
79
+ emit.setExitCode(1);
80
+ }
81
+ });
82
+ }
83
+
84
+ export function renderProfileTable(profiles: readonly HarnessProfile[]): string {
85
+ const rows = profiles.map((profile) => [
86
+ profile.id,
87
+ profile.binary,
88
+ profile.capabilities.modelSelection.support,
89
+ profile.capabilities.effortSelection.support,
90
+ profile.capabilities.sessionId.support,
91
+ profile.capabilities.cost.support,
92
+ ]);
93
+ return renderTable(["HARNESS", "BINARY", "MODEL", "EFFORT", "SESSION", "COST"], rows);
94
+ }
95
+
96
+ export function renderProfile(profile: HarnessProfile): string {
97
+ const lines = [
98
+ `${profile.displayName} (${profile.id})`,
99
+ `binary: ${profile.binary}`,
100
+ `integration: ${profile.integrationMode}`,
101
+ `auth: ${profile.authModel}`,
102
+ `model family: ${profile.modelFamily}`,
103
+ `effort values: ${profile.effortValues.join(", ") || "none"}`,
104
+ profile.verified
105
+ ? `verified: ${profile.verified.date} (${profile.verified.version})`
106
+ : "verified: not recorded",
107
+ "",
108
+ "capabilities:",
109
+ ];
110
+ for (const [dimension, claim] of Object.entries(profile.capabilities)) {
111
+ lines.push(` ${dimension.padEnd(18)} ${claim.support}${claim.note ? ` — ${claim.note}` : ""}`);
112
+ }
113
+ return lines.join("\n");
114
+ }
115
+
116
+ export function renderBenchReport(report: HarnessBenchReport): string {
117
+ const rows = report.results.map((result) => [
118
+ result.harness,
119
+ result.dimension,
120
+ result.declared,
121
+ result.observed,
122
+ result.verdict,
123
+ ]);
124
+ const table = renderTable(["HARNESS", "DIMENSION", "DECLARED", "OBSERVED", "VERDICT"], rows);
125
+ const summary = Object.entries(report.summary)
126
+ .filter(([, count]) => count > 0)
127
+ .map(([verdict, count]) => `${verdict}=${count}`)
128
+ .join(", ");
129
+ return `${table}\n\nmode: ${report.mode} (no model calls)\n${summary}`;
130
+ }
131
+
132
+ function renderTable(headers: string[], rows: string[][]): string {
133
+ const widths = headers.map((header, index) =>
134
+ Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)),
135
+ );
136
+ const line = (cells: string[]) =>
137
+ cells
138
+ .map((cell, index) => cell.padEnd(widths[index]))
139
+ .join(" ")
140
+ .trimEnd();
141
+ return [line(headers), line(widths.map((width) => "-".repeat(width))), ...rows.map(line)].join(
142
+ "\n",
143
+ );
144
+ }
145
+
146
+ /** Kept exported so command tests can assert row semantics without model calls. */
147
+ export type { BenchResult };