harnery 0.32.0 → 0.33.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 (91) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +3 -1
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +212 -1
  6. package/dist/commands/claude-desktop.d.ts +2 -2
  7. package/dist/commands/claude-desktop.d.ts.map +1 -1
  8. package/dist/commands/claude-desktop.js +74 -3
  9. package/dist/commands/doctor.d.ts +4 -2
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +45 -6
  12. package/dist/commands/init.d.ts +1 -0
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +10 -0
  15. package/dist/commands/instructions.d.ts +4 -0
  16. package/dist/commands/instructions.d.ts.map +1 -0
  17. package/dist/commands/instructions.js +100 -0
  18. package/dist/commands/presence.d.ts.map +1 -1
  19. package/dist/commands/presence.js +4 -1
  20. package/dist/core/agents/canonical-emit.d.ts +1 -1
  21. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  22. package/dist/core/agents/canonical-emit.js +5 -3
  23. package/dist/core/agents/cli.js +5 -1
  24. package/dist/core/agents/coord-client.d.ts +11 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/index.d.ts +1 -0
  27. package/dist/core/agents/index.d.ts.map +1 -1
  28. package/dist/core/agents/index.js +1 -0
  29. package/dist/core/agents/render/prompt-context.d.ts +6 -0
  30. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  31. package/dist/core/agents/render/prompt-context.js +46 -5
  32. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  33. package/dist/core/agents/render/session-context.js +8 -2
  34. package/dist/core/agents/rules/stop-hook.js +4 -4
  35. package/dist/core/agents/state/heartbeat-projector.d.ts +11 -0
  36. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  37. package/dist/core/agents/state/heartbeat-projector.js +54 -0
  38. package/dist/core/agents/state/heartbeat-writer.d.ts +15 -0
  39. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  40. package/dist/core/agents/state/heartbeat-writer.js +32 -0
  41. package/dist/core/agents/state/session-state.d.ts +44 -0
  42. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  43. package/dist/core/agents/state/session-state.js +81 -0
  44. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  45. package/dist/core/hooks/adapter/events.js +1 -0
  46. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  47. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  48. package/dist/core/hooks/adapter/parse.js +2 -0
  49. package/dist/core/hooks/cli.js +90 -6
  50. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  51. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  52. package/dist/core/hooks/codex-authorization.js +199 -0
  53. package/dist/core/hooks/events/schema.d.ts +30 -1
  54. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  55. package/dist/core/presence/blob.d.ts +4 -0
  56. package/dist/core/presence/blob.d.ts.map +1 -1
  57. package/dist/core/presence/blob.js +12 -0
  58. package/dist/core/presence/index.js +15 -1
  59. package/dist/lib/claude-desktop.d.ts +47 -0
  60. package/dist/lib/claude-desktop.d.ts.map +1 -1
  61. package/dist/lib/claude-desktop.js +169 -1
  62. package/dist/lib/instructions/bundle.d.ts +33 -0
  63. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  64. package/dist/lib/instructions/bundle.js +149 -0
  65. package/package.json +2 -2
  66. package/src/commander.ts +3 -1
  67. package/src/commands/agents.ts +248 -1
  68. package/src/commands/claude-desktop.ts +106 -5
  69. package/src/commands/doctor.ts +59 -7
  70. package/src/commands/init.ts +11 -0
  71. package/src/commands/instructions.ts +129 -0
  72. package/src/commands/presence.ts +7 -1
  73. package/src/core/agents/canonical-emit.ts +6 -4
  74. package/src/core/agents/cli.ts +6 -1
  75. package/src/core/agents/coord-client.ts +11 -0
  76. package/src/core/agents/index.ts +1 -0
  77. package/src/core/agents/render/prompt-context.ts +71 -6
  78. package/src/core/agents/render/session-context.ts +14 -2
  79. package/src/core/agents/rules/stop-hook.ts +5 -5
  80. package/src/core/agents/state/heartbeat-projector.ts +63 -0
  81. package/src/core/agents/state/heartbeat-writer.ts +51 -0
  82. package/src/core/agents/state/session-state.ts +131 -0
  83. package/src/core/hooks/adapter/events.ts +1 -0
  84. package/src/core/hooks/adapter/parse.ts +3 -0
  85. package/src/core/hooks/cli.ts +98 -7
  86. package/src/core/hooks/codex-authorization.ts +253 -0
  87. package/src/core/hooks/events/schema.ts +44 -0
  88. package/src/core/presence/blob.ts +18 -0
  89. package/src/core/presence/index.ts +18 -1
  90. package/src/lib/claude-desktop.ts +232 -1
  91. package/src/lib/instructions/bundle.ts +199 -0
@@ -1,12 +1,16 @@
1
1
  import type { Command } from "commander";
2
- import type { EmitContext } from "../commander.ts";
2
+ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
3
+ import { monorepoRoot } from "../core/agents/index.ts";
3
4
  import {
4
5
  applyMirror,
6
+ applyTidy,
5
7
  type DesktopAccount,
6
8
  findDesktopDataDirs,
7
9
  listAccounts,
8
10
  planMirror,
11
+ planTidy,
9
12
  readCliAccount,
13
+ readDesktopLifecycleIndex,
10
14
  } from "../lib/claude-desktop.ts";
11
15
 
12
16
  /**
@@ -24,12 +28,14 @@ import {
24
28
  * desktop app picks the copies up, and opening one resumes with full
25
29
  * history.
26
30
  */
27
- export function registerClaudeDesktopCommand(program: Command, emit: EmitContext): void {
31
+ export function registerClaudeDesktopCommand(
32
+ program: Command,
33
+ emit: EmitContext,
34
+ context?: HarneryProgramContext,
35
+ ): void {
28
36
  const cmd = program
29
37
  .command("claude-desktop")
30
- .description(
31
- "Claude desktop-app session index: list accounts/sessions, mirror sessions across accounts",
32
- )
38
+ .description("Claude desktop-app session index: list, mirror, and archive completed sessions")
33
39
  .action(() => {
34
40
  const dirs = requireDataDirs(emit, undefined);
35
41
  for (const dir of dirs) {
@@ -184,6 +190,101 @@ export function registerClaudeDesktopCommand(program: Command, emit: EmitContext
184
190
  }
185
191
  },
186
192
  );
193
+
194
+ cmd
195
+ .command("tidy")
196
+ .description(
197
+ "Archive desktop sidebar entries whose mapped Harnery lifecycle is done. " +
198
+ "Dry-run by default; --yes applies atomic entry updates.",
199
+ )
200
+ .option("--data-dir <path>", "Explicit desktop-app data directory")
201
+ .option(
202
+ "--account <uuid-prefix>",
203
+ "Only entries under accounts matching this UUID prefix (repeatable)",
204
+ collect,
205
+ [] as string[],
206
+ )
207
+ .option(
208
+ "--include-legacy-prefix",
209
+ "Also archive unmatched historical entries whose title starts with [DONE]",
210
+ )
211
+ .option("--yes", "Apply the archive plan (default is dry-run)")
212
+ .action(
213
+ async (opts: {
214
+ dataDir?: string;
215
+ account: string[];
216
+ includeLegacyPrefix?: boolean;
217
+ yes?: boolean;
218
+ }) => {
219
+ const dirs = requireDataDirs(emit, opts.dataDir);
220
+ const coordRoot =
221
+ context?.resolveCoordRoot?.() ?? context?.repoRoot ?? monorepoRoot() ?? process.cwd();
222
+ const lifecycle = await readDesktopLifecycleIndex(coordRoot);
223
+
224
+ for (const dir of dirs) {
225
+ const plan = planTidy(listAccounts(dir), lifecycle, {
226
+ accounts: opts.account,
227
+ includeLegacyPrefix: opts.includeLegacyPrefix,
228
+ });
229
+ const planned = plan.actions.map((action) => ({
230
+ account_uuid: action.entry.accountUuid,
231
+ cli_session_id: action.entry.cliSessionId,
232
+ title: action.entry.title,
233
+ source: action.source,
234
+ lifecycle_updated_at: action.lifecycle?.updatedAt ?? null,
235
+ file: action.entry.file,
236
+ }));
237
+ const skipped = plan.skipped
238
+ .map((skip) => ({
239
+ account_uuid: skip.entry.accountUuid,
240
+ cli_session_id: skip.entry.cliSessionId,
241
+ title: skip.entry.title,
242
+ reason: skip.reason,
243
+ lifecycle: skip.lifecycle?.state ?? null,
244
+ }))
245
+ .filter((skip) => skip.reason !== "already_archived");
246
+ const skippedByReason = Object.fromEntries(
247
+ [
248
+ "already_archived",
249
+ "blocked_title",
250
+ "lifecycle_active",
251
+ "lifecycle_blocked",
252
+ "unmatched",
253
+ ].map((reason) => [
254
+ reason,
255
+ plan.skipped.filter((skip) => skip.reason === reason).length,
256
+ ]),
257
+ );
258
+
259
+ if (!opts.yes) {
260
+ emit.data({
261
+ data_dir: dir,
262
+ dry_run: true,
263
+ planned,
264
+ skipped,
265
+ skipped_by_reason: skippedByReason,
266
+ hint: plan.actions.length > 0 ? "re-run with --yes to archive" : "nothing to archive",
267
+ });
268
+ continue;
269
+ }
270
+
271
+ const result = applyTidy(plan);
272
+ emit.data({
273
+ data_dir: dir,
274
+ dry_run: false,
275
+ archived: result.archived,
276
+ already_archived: result.alreadyArchived,
277
+ planned,
278
+ skipped,
279
+ skipped_by_reason: skippedByReason,
280
+ hint:
281
+ result.archived > 0
282
+ ? "fully quit the Claude desktop app (tray icon, not the X) and relaunch to refresh the sidebar"
283
+ : "nothing to archive",
284
+ });
285
+ }
286
+ },
287
+ );
187
288
  }
188
289
 
189
290
  function collect(value: string, previous: string[]): string[] {
@@ -21,7 +21,14 @@ import { BUILTIN_ADAPTER_IDS } from "../core/adapters/index.ts";
21
21
  import { resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
22
22
  import { ADAPTER_SPECS, type AdapterId } from "../core/hooks/adapter/events.ts";
23
23
  import { loadAdapterWiring, summarizeAdapterWiring } from "../core/hooks/adapter/wiring.ts";
24
- import { inspectCodexWslBridge } from "../core/hooks/codex-wsl-bridge.ts";
24
+ import {
25
+ type CodexHookAuthorizationResult,
26
+ probeCodexHookAuthorization,
27
+ } from "../core/hooks/codex-authorization.ts";
28
+ import {
29
+ type CodexWslBridgeStatus,
30
+ inspectCodexWslBridge,
31
+ } from "../core/hooks/codex-wsl-bridge.ts";
25
32
  import {
26
33
  ADAPTER_BINARIES,
27
34
  ADAPTER_INSTALL_HINTS,
@@ -34,7 +41,7 @@ import { findRg, installRg, managedRgPath, rgInstallSupported } from "../lib/too
34
41
 
35
42
  type Severity = "ok" | "warn" | "fail";
36
43
 
37
- interface Check {
44
+ export interface Check {
38
45
  name: string;
39
46
  severity: Severity;
40
47
  detail: string;
@@ -66,7 +73,7 @@ export function registerDoctorCommand(program: Command, emit: EmitContext): void
66
73
  emit.text(`ripgrep install failed: ${(err as Error).message}\n`);
67
74
  }
68
75
  }
69
- const checks = runChecks();
76
+ const checks = await runChecks();
70
77
  const requiredFailed = checks.some((c) => c.severity === "fail");
71
78
 
72
79
  if (opts.json) {
@@ -99,13 +106,15 @@ export function registerDoctorCommand(program: Command, emit: EmitContext): void
99
106
  });
100
107
  }
101
108
 
102
- export function runChecks(): Check[] {
103
- const codexWslBridge = checkCodexWslBridge();
109
+ export async function runChecks(): Promise<Check[]> {
110
+ const codexWslStatus = inspectCodexWslBridge();
111
+ const codexWslBridge = checkCodexWslBridge(codexWslStatus);
104
112
  // Probe the adapter CLIs before the hook check runs, not in output order:
105
113
  // an unwired adapter only matters if its CLI is actually installed, and
106
114
  // probing once here keeps that join to one spawn per CLI.
107
115
  const workflow = BUILTIN_ADAPTER_IDS.map((id) => ({ id, ...checkWorkflowAdapter(id) }));
108
116
  const installed = workflow.filter((w) => w.installed).map((w) => w.id as AdapterId);
117
+ const codexAuthorization = await checkCodexHookAuthorization(installed, codexWslStatus);
109
118
  return [
110
119
  checkNode(),
111
120
  checkGit(),
@@ -113,6 +122,7 @@ export function runChecks(): Check[] {
113
122
  checkRipgrep(),
114
123
  checkHarneryDir(),
115
124
  checkAdapterHooks(installed),
125
+ ...(codexAuthorization ? [codexAuthorization] : []),
116
126
  checkGitHookRegions(),
117
127
  ...(codexWslBridge ? [codexWslBridge] : []),
118
128
  ...workflow.map((w) => w.check),
@@ -153,8 +163,7 @@ function checkGitHookRegions(): Check {
153
163
  }
154
164
  }
155
165
 
156
- function checkCodexWslBridge(): Check | null {
157
- const status = inspectCodexWslBridge();
166
+ function checkCodexWslBridge(status: CodexWslBridgeStatus | null): Check | null {
158
167
  if (!status) return null;
159
168
  return {
160
169
  name: "codex:WSL bridge",
@@ -166,6 +175,49 @@ function checkCodexWslBridge(): Check | null {
166
175
  };
167
176
  }
168
177
 
178
+ export function codexAuthorizationCheck(result: CodexHookAuthorizationResult): Check {
179
+ const reviewHint =
180
+ "terminal UI: run `/hooks`; Codex Desktop: open Settings > Hooks; after approval, start a fresh task";
181
+ if (result.status === "runnable") {
182
+ return { name: "codex:hook authorization", severity: "ok", detail: result.detail };
183
+ }
184
+ if (result.status === "review_required" || result.status === "disabled") {
185
+ return {
186
+ name: "codex:hook authorization",
187
+ severity: "warn",
188
+ detail: result.detail,
189
+ hint: reviewHint,
190
+ };
191
+ }
192
+ return {
193
+ name: "codex:hook authorization",
194
+ severity: "warn",
195
+ detail: `authorization unverified: ${result.detail}`,
196
+ hint: reviewHint,
197
+ };
198
+ }
199
+
200
+ async function checkCodexHookAuthorization(
201
+ installedAdapters: AdapterId[],
202
+ codexWslStatus: CodexWslBridgeStatus | null,
203
+ ): Promise<Check | null> {
204
+ const root = findCoordProjectRoot();
205
+ if (!root || !summarizeAdapterWiring(root).wired.includes("codex")) return null;
206
+
207
+ if (codexWslStatus) {
208
+ return {
209
+ name: "codex:hook authorization",
210
+ severity: "warn",
211
+ detail:
212
+ "unverified from WSL; the Windows-native Codex runtime owns this workspace's trust state",
213
+ hint: "run the host's Windows bridge doctor, or open Codex Desktop Settings > Hooks; start a fresh task after approval",
214
+ };
215
+ }
216
+ if (!installedAdapters.includes("codex")) return null;
217
+
218
+ return codexAuthorizationCheck(await probeCodexHookAuthorization({ cwd: root }));
219
+ }
220
+
169
221
  function checkRipgrep(): Check {
170
222
  const found = findRg();
171
223
  if (found) {
@@ -175,6 +175,8 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
175
175
  `${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`,
176
176
  );
177
177
  }
178
+ const authorizationReview = codexHookReviewAction(adapter);
179
+ if (authorizationReview) actions.push(authorizationReview);
178
180
 
179
181
  // ── 3. agent-facing instructions block + skills ────────────────────────
180
182
  // A misconfigured host addendum aborts here rather than half-writing: the
@@ -202,6 +204,15 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
202
204
  });
203
205
  }
204
206
 
207
+ export function codexHookReviewAction(adapter: AdapterId): string | null {
208
+ if (adapter !== "codex") return null;
209
+ return (
210
+ "! Codex authorizes hook commands separately. Before starting work, review them in " +
211
+ "the terminal UI with `/hooks` or in Codex Desktop under Settings > Hooks. " +
212
+ "After approval, start a fresh task so SessionStart can run."
213
+ );
214
+ }
215
+
205
216
  /**
206
217
  * Merge agent-hook entries into a adapter settings object in place, idempotently.
207
218
  * Preserves every existing hook; rewrites events already wired to
@@ -0,0 +1,129 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import type { Command } from "commander";
5
+ import { countTokens } from "gpt-tokenizer/model/gpt-4o";
6
+ import type { EmitContext } from "../commander.ts";
7
+ import { monorepoRoot } from "../core/agents/index.ts";
8
+ import type { Adapter } from "../core/hooks/events/schema.ts";
9
+ import {
10
+ buildInstructionBundle,
11
+ type InstructionBundleComponent,
12
+ } from "../lib/instructions/bundle.ts";
13
+
14
+ interface ManifestOptions {
15
+ adapter: string;
16
+ format: string;
17
+ output?: string;
18
+ }
19
+
20
+ export function registerInstructionsCommand(program: Command, emit: EmitContext): void {
21
+ const command = program
22
+ .command("instructions")
23
+ .description(
24
+ "Inspect and version the repo-authored instruction bundle loaded by each adapter.",
25
+ );
26
+
27
+ command
28
+ .command("manifest [paths...]")
29
+ .description("Emit a machine-readable bundle manifest for one or more working paths")
30
+ .option("--adapter <adapter>", "claude-code, cursor, codex, or all", "all")
31
+ .option("--format <type>", "Output format: json or table", "json")
32
+ .option("-o, --output <file>", "Write the complete JSON manifest to a file")
33
+ .action((paths: string[], options: ManifestOptions) => {
34
+ if (options.format === "json") emit.config({ format: "json" });
35
+ const coordRoot = monorepoRoot() ?? process.cwd();
36
+ const adapters = parseAdapters(options.adapter);
37
+ const profiles = (paths.length > 0 ? paths : [process.cwd()]).flatMap((path) =>
38
+ adapters.map((adapter) =>
39
+ enrichBundle(
40
+ buildInstructionBundle({ coordRoot, cwd: resolve(path), adapter }),
41
+ coordRoot,
42
+ ),
43
+ ),
44
+ );
45
+
46
+ const manifest = {
47
+ ok: true,
48
+ schema_version: 1,
49
+ generated_at: new Date().toISOString(),
50
+ source_commit: gitHead(coordRoot),
51
+ tokenizer: "o200k_base",
52
+ coord_root: ".",
53
+ profile_count: profiles.length,
54
+ profiles,
55
+ };
56
+ if (options.output) {
57
+ const output = resolve(options.output);
58
+ writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
59
+ emit.data({
60
+ ok: true,
61
+ output,
62
+ schema_version: manifest.schema_version,
63
+ generated_at: manifest.generated_at,
64
+ source_commit: manifest.source_commit,
65
+ profile_count: manifest.profile_count,
66
+ });
67
+ } else {
68
+ emit.data(manifest);
69
+ }
70
+ });
71
+ }
72
+
73
+ function parseAdapters(value: string): Adapter[] {
74
+ if (value === "all") return ["claude-code", "cursor", "codex"];
75
+ if (value === "claude-code" || value === "cursor" || value === "codex") return [value];
76
+ throw new Error(`unsupported adapter '${value}' (expected claude-code, cursor, codex, or all)`);
77
+ }
78
+
79
+ function enrichBundle(bundle: ReturnType<typeof buildInstructionBundle>, coordRoot: string) {
80
+ const components = bundle.components.map((component) => enrichComponent(component, coordRoot));
81
+ const canonicalSources = bundle.canonical_sources.map((component) =>
82
+ enrichComponent(component, coordRoot),
83
+ );
84
+ return {
85
+ ...bundle,
86
+ coord_root: ".",
87
+ components,
88
+ canonical_sources: canonicalSources,
89
+ totals: totals(components),
90
+ canonical_source_totals: totals(canonicalSources),
91
+ };
92
+ }
93
+
94
+ function enrichComponent(component: InstructionBundleComponent, coordRoot: string) {
95
+ const bytes = readFileSync(resolve(coordRoot, component.path));
96
+ const text = bytes.includes(0) ? null : bytes.toString("utf8");
97
+ return {
98
+ ...component,
99
+ chars: text?.length ?? null,
100
+ tokens: text === null ? null : countTokens(text),
101
+ };
102
+ }
103
+
104
+ function totals(components: ReturnType<typeof enrichComponent>[]) {
105
+ const roles = ["always_loaded", "just_in_time", "runtime_config"] as const;
106
+ return {
107
+ files: components.length,
108
+ bytes: components.reduce((sum, component) => sum + component.bytes, 0),
109
+ tokens: components.reduce((sum, component) => sum + (component.tokens ?? 0), 0),
110
+ by_role: Object.fromEntries(
111
+ roles.map((role) => {
112
+ const selected = components.filter((component) => component.role === role);
113
+ return [
114
+ role,
115
+ {
116
+ files: selected.length,
117
+ bytes: selected.reduce((sum, component) => sum + component.bytes, 0),
118
+ tokens: selected.reduce((sum, component) => sum + (component.tokens ?? 0), 0),
119
+ },
120
+ ];
121
+ }),
122
+ ),
123
+ };
124
+ }
125
+
126
+ function gitHead(root: string): string | null {
127
+ const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" });
128
+ return result.status === 0 ? result.stdout.trim() : null;
129
+ }
@@ -175,7 +175,13 @@ export function registerPresenceCommand(program: Command, emit: EmitContext): vo
175
175
  for (const a of m.agents) {
176
176
  const task = a.task ? ` "${a.task.slice(0, 70)}"` : "";
177
177
  const files = a.files_touched?.length ? ` [${a.files_touched.length} files]` : "";
178
- emit.text(` agent-${a.name ?? a.instance_id.slice(0, 8)}${task}${files}\n`);
178
+ const lifecycle =
179
+ a.task_state === "blocked" && a.task_state_reason
180
+ ? `${a.task_state}: ${a.task_state_reason}`
181
+ : a.task_state;
182
+ emit.text(
183
+ ` agent-${a.name ?? a.instance_id.slice(0, 8)}${task}${files} activity=${a.activity} lifecycle=${lifecycle}\n`,
184
+ );
179
185
  }
180
186
  }
181
187
  });
@@ -37,11 +37,11 @@ export function resolveEmitRoot(start: string = process.cwd()): string | null {
37
37
  return resolveCoordRoot(start);
38
38
  }
39
39
 
40
- export function emitCanonical(input: CanonicalEmitInput): void {
40
+ export function emitCanonical(input: CanonicalEmitInput): boolean {
41
41
  const root = resolveEmitRoot();
42
- if (!root) return;
42
+ if (!root) return false;
43
43
  const binary = coordBinPath("agent-coord", root);
44
- if (!binary) return;
44
+ if (!binary) return false;
45
45
  try {
46
46
  const args = [
47
47
  "emit-event",
@@ -71,9 +71,11 @@ export function emitCanonical(input: CanonicalEmitInput): void {
71
71
  if (result.error || result.status !== 0) {
72
72
  const why = result.error ? result.error.message : `exit ${result.status}`;
73
73
  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
74
+ return false;
74
75
  }
76
+ return true;
75
77
  } catch {
76
- /* never break the caller */
78
+ return false;
77
79
  }
78
80
  }
79
81
 
@@ -620,9 +620,13 @@ async function handlePromptContext(root: string, rest: string[]): Promise<number
620
620
  const sessionNameNudge = args["session-name-nudge"] === "true";
621
621
  const taskNudge = args["task-nudge"] === "true";
622
622
  const statusFooterNudge = args["status-footer-nudge"] === "true";
623
+ // Value is the adapter id; a bare flag (parsed as "true") still enables the
624
+ // reminder with the adapter-neutral wording.
625
+ const turnRitualNudge =
626
+ args["turn-ritual-nudge"] === "true" ? "generic" : args["turn-ritual-nudge"];
623
627
  if (!instanceId) {
624
628
  process.stderr.write(
625
- "agent-coord prompt-context --instance <id> [--session <id>] [--name <agent-name>] [--session-name-nudge] [--task-nudge] [--status-footer-nudge]\n",
629
+ "agent-coord prompt-context --instance <id> [--session <id>] [--name <agent-name>] [--session-name-nudge] [--task-nudge] [--status-footer-nudge] [--turn-ritual-nudge <adapter>]\n",
626
630
  );
627
631
  return 2;
628
632
  }
@@ -635,6 +639,7 @@ async function handlePromptContext(root: string, rest: string[]): Promise<number
635
639
  sessionNameNudge,
636
640
  taskNudge,
637
641
  statusFooterNudge,
642
+ turnRitualNudge,
638
643
  });
639
644
  process.stdout.write(text);
640
645
  return 0;
@@ -75,16 +75,27 @@ export interface Heartbeat {
75
75
  task?: string;
76
76
  /** UTC ISO-8601 timestamp when task was last set/cleared. Used by adapters without Stop enforcement to compute staleness. 2026-05-24. */
77
77
  task_updated_at?: string | null;
78
+ /** Current evidence-derived activity axis. */
79
+ activity?: "unknown" | "working" | "needs_input" | "idle";
80
+ activity_updated_at?: string;
81
+ activity_source?: string;
82
+ /** Explicit task lifecycle axis. */
83
+ task_state?: "active" | "blocked" | "done";
84
+ task_state_updated_at?: string;
85
+ task_state_reason?: string;
78
86
  /** Session name built on the first non-empty set-task; its presence means "this session has been named". 2026-08-09. */
79
87
  suggested_session_name?: string;
80
88
  /** Stamped by turn.stop once the suggested name is seen in assistant reply text. 2026-08-09. */
81
89
  session_name_seen_at?: string;
90
+ /** Which suggested name the latest sighting covered. */
91
+ session_name_seen_for?: string;
82
92
  /** Auto-generated per-turn summary written by the Stop hook via Haiku. 2026-05-23. */
83
93
  turn_summary?: string | null;
84
94
  /** UTC ISO-8601 timestamp when turn_summary was last refreshed. */
85
95
  turn_summary_updated_at?: string | null;
86
96
  /** Hook client: `claude-code` (default) or `cursor`. Cursor Phase 1. */
87
97
  platform?: string;
98
+ workflow_run_id?: string;
88
99
  }
89
100
 
90
101
  /**
@@ -12,3 +12,4 @@
12
12
  export * from "./canonical-emit.js";
13
13
  export * from "./coord-client.js";
14
14
  export * from "./session-events.js";
15
+ export * from "./state/session-state.js";
@@ -45,6 +45,7 @@ import { join } from "node:path";
45
45
  import { coordEnv } from "../../../lib/env.ts";
46
46
  import { endOfTurnStatusCommand, resolveBinName } from "../../config.ts";
47
47
  import { type RemoteMachine, readRemoteMachines } from "../../presence/index.ts";
48
+ import type { AgentActivity, TaskState } from "../state/session-state.ts";
48
49
  import { formatPendingCouncils } from "./session-context.ts";
49
50
 
50
51
  interface HeartbeatRow {
@@ -59,8 +60,12 @@ interface HeartbeatRow {
59
60
  files_touched?: string[];
60
61
  platform?: string;
61
62
  task?: string;
63
+ activity?: AgentActivity;
64
+ task_state?: TaskState;
65
+ task_state_reason?: string;
62
66
  task_updated_at?: string;
63
67
  suggested_session_name?: string;
68
+ session_name_seen_for?: string;
64
69
  turn_summary?: string;
65
70
  workflow_run_id?: string;
66
71
  }
@@ -79,6 +84,12 @@ export interface PromptContextOpts {
79
84
  /** When true, append the non-deduplicated Codex status-footer reminder.
80
85
  * The caller enables this only for human-facing Codex sessions. */
81
86
  statusFooterNudge?: boolean;
87
+ /** Adapter id whose Stop hook ENFORCES the end-of-turn ritual (task_set +
88
+ * status check). When set, append a fresh per-prompt reminder of that
89
+ * contract so the model satisfies it before ending the turn instead of
90
+ * being bounced into a retry. Not hash-deduped, same rationale as the
91
+ * status footer. */
92
+ turnRitualNudge?: string;
82
93
  }
83
94
 
84
95
  /**
@@ -98,6 +109,7 @@ export function renderPromptContext(opts: PromptContextOpts): string {
98
109
  sessionNameNudge,
99
110
  taskNudge,
100
111
  statusFooterNudge,
112
+ turnRitualNudge,
101
113
  } = opts;
102
114
  const sections: string[] = [];
103
115
 
@@ -130,9 +142,41 @@ export function renderPromptContext(opts: PromptContextOpts): string {
130
142
  if (statusMsg) sections.push(statusMsg);
131
143
  }
132
144
 
145
+ // 5. Adapters whose Stop hook ENFORCES the turn ritual get a fresh reminder
146
+ // on every prompt. Reminding before the reply is drafted is far cheaper than
147
+ // the bounce-and-retry the Stop hook otherwise forces: enforcement-only
148
+ // compliance was measured missing on a double-digit share of turns. Not
149
+ // hash-deduped for the same reason as the Codex footer.
150
+ if (turnRitualNudge) {
151
+ const ritualMsg = renderTurnRitualReminder(coordRoot, instanceId, turnRitualNudge);
152
+ if (ritualMsg) sections.push(ritualMsg);
153
+ }
154
+
133
155
  return sections.join("\n\n");
134
156
  }
135
157
 
158
+ function renderTurnRitualReminder(
159
+ coordRoot: string,
160
+ selfInstanceId: string,
161
+ adapter: string,
162
+ ): string {
163
+ const hb = readHeartbeat(join(coordRoot, ".harnery", "active", `${selfInstanceId}.json`));
164
+ if (!hb || hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id) return "";
165
+
166
+ const bin = resolveBinName(coordRoot);
167
+ const statusCommand = endOfTurnStatusCommand(coordRoot);
168
+ // Claude Code renders the status box only if the reply carries it; Cursor
169
+ // shows the box inline on its own, so it is never asked for a second copy.
170
+ const paste =
171
+ adapter === "claude-code"
172
+ ? " and paste its output verbatim in a fenced code block at the end of your reply"
173
+ : "";
174
+ return (
175
+ `Turn ritual (Stop-enforced): on any turn that uses tools, run \`${bin} agents set-task "<short focus>"\` at some point during the turn (required every turn, even when the focus is unchanged; pass an empty string when there is no meaningful focus), then make \`${statusCommand}\` your final tool call${paste}. ` +
176
+ "A turn that ends without these is bounced by the Stop hook and costs a retry."
177
+ );
178
+ }
179
+
136
180
  function renderStatusFooterReminder(coordRoot: string, selfInstanceId: string): string {
137
181
  const hb = readHeartbeat(join(coordRoot, ".harnery", "active", `${selfInstanceId}.json`));
138
182
  if (!hb || hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id) return "";
@@ -179,15 +223,21 @@ function computeFocusNudgeIfChanged(
179
223
  let message = "";
180
224
  let nudgeKind = "";
181
225
 
182
- if (opts.sessionNameNudge && !hb.suggested_session_name) {
226
+ if (
227
+ opts.sessionNameNudge &&
228
+ (!hb.suggested_session_name ||
229
+ (hb.session_name_seen_for !== undefined &&
230
+ hb.suggested_session_name !== hb.session_name_seen_for))
231
+ ) {
183
232
  // Keyed on "a name was ever produced", not task_updated_at: a bare clear
184
233
  // as the first declaration must not end the naming window.
185
234
  needsNudge = true;
186
235
  nudgeKind = "session-name";
187
- message =
188
- `This session has no name yet: run \`${bin} agents set-task "<2-5 word session topic>"\` as your first tool call. ` +
189
- "When it returns `first_of_session: true`, reproduce its `suggested_session_name` value by itself inside a fenced code block at the very top of your reply so the operator can one-click-copy it as the session/tab title. " +
190
- "Then continue with the task; that `set-task` also satisfies this turn's focus declaration.";
236
+ message = hb.suggested_session_name
237
+ ? `This session's lifecycle changed its suggested name. Reproduce this value by itself inside a fenced code block at the very top of your reply so the operator can one-click-copy it as the session/tab title: ${hb.suggested_session_name}`
238
+ : `This session has no name yet: run \`${bin} agents set-task "<2-5 word session topic>"\` as your first tool call. ` +
239
+ "When it returns `first_of_session: true`, reproduce its `suggested_session_name` value by itself inside a fenced code block at the very top of your reply so the operator can one-click-copy it as the session/tab title. " +
240
+ "Then continue with the task; that `set-task` also satisfies this turn's focus declaration.";
191
241
  } else if (opts.taskNudge && !taskValue) {
192
242
  needsNudge = true;
193
243
  nudgeKind = "task-unset";
@@ -269,6 +319,9 @@ function computePeerTableIfChanged(
269
319
  started_at: p.started_at ?? null,
270
320
  files_touched: Array.from(p.files_touched ?? []).sort(),
271
321
  platform: p.platform ?? null,
322
+ activity: p.activity ?? "unknown",
323
+ task_state: p.task_state ?? "active",
324
+ task_state_reason: p.task_state_reason ?? null,
272
325
  }))
273
326
  .sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
274
327
  // Remote basis: machine + per-agent identity/task/files (published_at is
@@ -279,6 +332,9 @@ function computePeerTableIfChanged(
279
332
  instance_id: a.instance_id,
280
333
  name: a.name ?? null,
281
334
  task: a.task ?? null,
335
+ activity: a.activity,
336
+ task_state: a.task_state,
337
+ task_state_reason: a.task_state_reason ?? null,
282
338
  files_touched: Array.from(a.files_touched ?? []).sort(),
283
339
  })),
284
340
  }));
@@ -493,6 +549,7 @@ function renderSubtable(
493
549
 
494
550
  function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number): string {
495
551
  const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
552
+ const statePart = formatState(r);
496
553
  // Fall back started_at → last_heartbeat; if neither is a valid timestamp,
497
554
  // show "age unknown" rather than the epoch-derived "20608d ago" ghost.
498
555
  const startedSec = parseIsoSec(r.started_at) ?? parseIsoSec(r.last_heartbeat);
@@ -503,7 +560,15 @@ function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number
503
560
  // Prefer a short instance_id over a bare "unknown" so an incomplete row is
504
561
  // still identifiable.
505
562
  const label = r.name ?? (r.instance_id ? r.instance_id.slice(0, 8) : "unknown");
506
- return ` - agent-${label}${taskPart} (${ageFrom}, ${filesPart}${lastActivity})${turnSummary}`;
563
+ return ` - agent-${label}${taskPart} (${statePart}, ${ageFrom}, ${filesPart}${lastActivity})${turnSummary}`;
564
+ }
565
+
566
+ function formatState(r: HeartbeatRow): string {
567
+ const activity = r.activity ?? "unknown";
568
+ const lifecycle = r.task_state ?? "active";
569
+ const reason =
570
+ lifecycle === "blocked" && r.task_state_reason ? `: ${r.task_state_reason.slice(0, 80)}` : "";
571
+ return `activity=${activity}, lifecycle=${lifecycle}${reason}`;
507
572
  }
508
573
 
509
574
  function fmtFiles(files: string[]): string {