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.
- package/README.md +2 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +3 -1
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +212 -1
- package/dist/commands/claude-desktop.d.ts +2 -2
- package/dist/commands/claude-desktop.d.ts.map +1 -1
- package/dist/commands/claude-desktop.js +74 -3
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +45 -6
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +10 -0
- package/dist/commands/instructions.d.ts +4 -0
- package/dist/commands/instructions.d.ts.map +1 -0
- package/dist/commands/instructions.js +100 -0
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +4 -1
- package/dist/core/agents/canonical-emit.d.ts +1 -1
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +5 -3
- package/dist/core/agents/cli.js +5 -1
- package/dist/core/agents/coord-client.d.ts +11 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/index.d.ts +1 -0
- package/dist/core/agents/index.d.ts.map +1 -1
- package/dist/core/agents/index.js +1 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -0
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +46 -5
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +8 -2
- package/dist/core/agents/rules/stop-hook.js +4 -4
- package/dist/core/agents/state/heartbeat-projector.d.ts +11 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +54 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts +15 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +32 -0
- package/dist/core/agents/state/session-state.d.ts +44 -0
- package/dist/core/agents/state/session-state.d.ts.map +1 -0
- package/dist/core/agents/state/session-state.js +81 -0
- package/dist/core/hooks/adapter/events.d.ts.map +1 -1
- package/dist/core/hooks/adapter/events.js +1 -0
- package/dist/core/hooks/adapter/parse.d.ts +1 -1
- package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
- package/dist/core/hooks/adapter/parse.js +2 -0
- package/dist/core/hooks/cli.js +90 -6
- package/dist/core/hooks/codex-authorization.d.ts +30 -0
- package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
- package/dist/core/hooks/codex-authorization.js +199 -0
- package/dist/core/hooks/events/schema.d.ts +30 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/presence/blob.d.ts +4 -0
- package/dist/core/presence/blob.d.ts.map +1 -1
- package/dist/core/presence/blob.js +12 -0
- package/dist/core/presence/index.js +15 -1
- package/dist/lib/claude-desktop.d.ts +47 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -1
- package/dist/lib/claude-desktop.js +169 -1
- package/dist/lib/instructions/bundle.d.ts +33 -0
- package/dist/lib/instructions/bundle.d.ts.map +1 -0
- package/dist/lib/instructions/bundle.js +149 -0
- package/package.json +2 -2
- package/src/commander.ts +3 -1
- package/src/commands/agents.ts +248 -1
- package/src/commands/claude-desktop.ts +106 -5
- package/src/commands/doctor.ts +59 -7
- package/src/commands/init.ts +11 -0
- package/src/commands/instructions.ts +129 -0
- package/src/commands/presence.ts +7 -1
- package/src/core/agents/canonical-emit.ts +6 -4
- package/src/core/agents/cli.ts +6 -1
- package/src/core/agents/coord-client.ts +11 -0
- package/src/core/agents/index.ts +1 -0
- package/src/core/agents/render/prompt-context.ts +71 -6
- package/src/core/agents/render/session-context.ts +14 -2
- package/src/core/agents/rules/stop-hook.ts +5 -5
- package/src/core/agents/state/heartbeat-projector.ts +63 -0
- package/src/core/agents/state/heartbeat-writer.ts +51 -0
- package/src/core/agents/state/session-state.ts +131 -0
- package/src/core/hooks/adapter/events.ts +1 -0
- package/src/core/hooks/adapter/parse.ts +3 -0
- package/src/core/hooks/cli.ts +98 -7
- package/src/core/hooks/codex-authorization.ts +253 -0
- package/src/core/hooks/events/schema.ts +44 -0
- package/src/core/presence/blob.ts +18 -0
- package/src/core/presence/index.ts +18 -1
- package/src/lib/claude-desktop.ts +232 -1
- 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(
|
|
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[] {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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) {
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/presence.ts
CHANGED
|
@@ -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
|
-
|
|
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):
|
|
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
|
-
|
|
78
|
+
return false;
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
package/src/core/agents/cli.ts
CHANGED
|
@@ -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
|
/**
|
package/src/core/agents/index.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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 {
|