harnery 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +84 -2
- package/bin/agent-coord +42 -0
- package/bin/agent-hook +44 -0
- package/bin/harn +40 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +18 -0
- package/dist/commander.d.ts +128 -0
- package/dist/commander.d.ts.map +1 -0
- package/dist/commander.js +126 -0
- package/dist/commands/agents.d.ts +18 -0
- package/dist/commands/agents.d.ts.map +1 -0
- package/dist/commands/agents.js +3946 -0
- package/dist/commands/backup.d.ts +22 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +262 -0
- package/dist/commands/browse-ai.d.ts +4 -0
- package/dist/commands/browse-ai.d.ts.map +1 -0
- package/dist/commands/browse-ai.js +156 -0
- package/dist/commands/browse.d.ts +4 -0
- package/dist/commands/browse.d.ts.map +1 -0
- package/dist/commands/browse.js +590 -0
- package/dist/commands/callers.d.ts +4 -0
- package/dist/commands/callers.d.ts.map +1 -0
- package/dist/commands/callers.js +276 -0
- package/dist/commands/completion.d.ts +17 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +158 -0
- package/dist/commands/config-get.d.ts +4 -0
- package/dist/commands/config-get.d.ts.map +1 -0
- package/dist/commands/config-get.js +131 -0
- package/dist/commands/context.d.ts +11 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +185 -0
- package/dist/commands/cookies.d.ts +4 -0
- package/dist/commands/cookies.d.ts.map +1 -0
- package/dist/commands/cookies.js +140 -0
- package/dist/commands/docs.d.ts +4 -0
- package/dist/commands/docs.d.ts.map +1 -0
- package/dist/commands/docs.js +137 -0
- package/dist/commands/doctor.d.ts +25 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +200 -0
- package/dist/commands/edit-batch.d.ts +18 -0
- package/dist/commands/edit-batch.d.ts.map +1 -0
- package/dist/commands/edit-batch.js +172 -0
- package/dist/commands/eml.d.ts +4 -0
- package/dist/commands/eml.d.ts.map +1 -0
- package/dist/commands/eml.js +428 -0
- package/dist/commands/env.d.ts +4 -0
- package/dist/commands/env.d.ts.map +1 -0
- package/dist/commands/env.js +201 -0
- package/dist/commands/fetch.d.ts +4 -0
- package/dist/commands/fetch.d.ts.map +1 -0
- package/dist/commands/fetch.js +99 -0
- package/dist/commands/file-history.d.ts +4 -0
- package/dist/commands/file-history.d.ts.map +1 -0
- package/dist/commands/file-history.js +152 -0
- package/dist/commands/grep.d.ts +4 -0
- package/dist/commands/grep.d.ts.map +1 -0
- package/dist/commands/grep.js +317 -0
- package/dist/commands/init.d.ts +82 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +288 -0
- package/dist/commands/outline.d.ts +4 -0
- package/dist/commands/outline.d.ts.map +1 -0
- package/dist/commands/outline.js +494 -0
- package/dist/commands/presence.d.ts +12 -0
- package/dist/commands/presence.d.ts.map +1 -0
- package/dist/commands/presence.js +123 -0
- package/dist/commands/read.d.ts +7 -0
- package/dist/commands/read.d.ts.map +1 -0
- package/dist/commands/read.js +46 -0
- package/dist/commands/scratch.d.ts +4 -0
- package/dist/commands/scratch.d.ts.map +1 -0
- package/dist/commands/scratch.js +426 -0
- package/dist/commands/session.d.ts +4 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +162 -0
- package/dist/commands/sync.d.ts +24 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +275 -0
- package/dist/commands/toc.d.ts +5 -0
- package/dist/commands/toc.d.ts.map +1 -0
- package/dist/commands/toc.js +153 -0
- package/dist/commands/tokens.d.ts +4 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +48 -0
- package/dist/commands/tunnel.d.ts +4 -0
- package/dist/commands/tunnel.d.ts.map +1 -0
- package/dist/commands/tunnel.js +513 -0
- package/dist/commands/uninstall.d.ts +22 -0
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/commands/uninstall.js +126 -0
- package/dist/commands/web.d.ts +4 -0
- package/dist/commands/web.d.ts.map +1 -0
- package/dist/commands/web.js +165 -0
- package/dist/core/agents/canonical-emit.d.ts +27 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -0
- package/dist/core/agents/canonical-emit.js +72 -0
- package/dist/core/agents/cli-emit.d.ts +27 -0
- package/dist/core/agents/cli-emit.d.ts.map +1 -0
- package/dist/core/agents/cli-emit.js +57 -0
- package/dist/core/agents/cli.d.ts +10 -0
- package/dist/core/agents/cli.d.ts.map +1 -0
- package/dist/core/agents/cli.js +757 -0
- package/dist/core/agents/codex-replay.d.ts +29 -0
- package/dist/core/agents/codex-replay.d.ts.map +1 -0
- package/dist/core/agents/codex-replay.js +138 -0
- package/dist/core/agents/coord-client.d.ts +98 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -0
- package/dist/core/agents/coord-client.js +212 -0
- package/dist/core/agents/events/consume.d.ts +59 -0
- package/dist/core/agents/events/consume.d.ts.map +1 -0
- package/dist/core/agents/events/consume.js +147 -0
- package/dist/core/agents/events/emit.d.ts +42 -0
- package/dist/core/agents/events/emit.d.ts.map +1 -0
- package/dist/core/agents/events/emit.js +70 -0
- package/dist/core/agents/events/ulid.d.ts +11 -0
- package/dist/core/agents/events/ulid.d.ts.map +1 -0
- package/dist/core/agents/events/ulid.js +47 -0
- package/dist/core/agents/index.d.ts +14 -0
- package/dist/core/agents/index.d.ts.map +1 -0
- package/dist/core/agents/index.js +13 -0
- package/dist/core/agents/paths.d.ts +6 -0
- package/dist/core/agents/paths.d.ts.map +1 -0
- package/dist/core/agents/paths.js +17 -0
- package/dist/core/agents/render/prompt-context.d.ts +43 -0
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -0
- package/dist/core/agents/render/prompt-context.js +335 -0
- package/dist/core/agents/render/session-context.d.ts +39 -0
- package/dist/core/agents/render/session-context.d.ts.map +1 -0
- package/dist/core/agents/render/session-context.js +283 -0
- package/dist/core/agents/rules/claim-conflict.d.ts +35 -0
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -0
- package/dist/core/agents/rules/claim-conflict.js +244 -0
- package/dist/core/agents/rules/commit-conflict.d.ts +59 -0
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -0
- package/dist/core/agents/rules/commit-conflict.js +244 -0
- package/dist/core/agents/rules/stop-hook.d.ts +44 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -0
- package/dist/core/agents/rules/stop-hook.js +161 -0
- package/dist/core/agents/session-events.d.ts +41 -0
- package/dist/core/agents/session-events.d.ts.map +1 -0
- package/dist/core/agents/session-events.js +205 -0
- package/dist/core/agents/state/activity-log.d.ts +18 -0
- package/dist/core/agents/state/activity-log.d.ts.map +1 -0
- package/dist/core/agents/state/activity-log.js +34 -0
- package/dist/core/agents/state/council.d.ts +39 -0
- package/dist/core/agents/state/council.d.ts.map +1 -0
- package/dist/core/agents/state/council.js +216 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts +59 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -0
- package/dist/core/agents/state/heartbeat-projector.js +436 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts +64 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -0
- package/dist/core/agents/state/heartbeat-writer.js +271 -0
- package/dist/core/agents/state/names.d.ts +35 -0
- package/dist/core/agents/state/names.d.ts.map +1 -0
- package/dist/core/agents/state/names.js +376 -0
- package/dist/core/agents/state/pidmap.d.ts +11 -0
- package/dist/core/agents/state/pidmap.d.ts.map +1 -0
- package/dist/core/agents/state/pidmap.js +32 -0
- package/dist/core/agents/state/scratch.d.ts +27 -0
- package/dist/core/agents/state/scratch.d.ts.map +1 -0
- package/dist/core/agents/state/scratch.js +90 -0
- package/dist/core/agents/state/shell-mutation.d.ts +17 -0
- package/dist/core/agents/state/shell-mutation.d.ts.map +1 -0
- package/dist/core/agents/state/shell-mutation.js +41 -0
- package/dist/core/agents/state/stale-sweep.d.ts +16 -0
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -0
- package/dist/core/agents/state/stale-sweep.js +166 -0
- package/dist/core/config.d.ts +29 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +108 -0
- package/dist/core/hooks/cli.d.ts +21 -0
- package/dist/core/hooks/cli.d.ts.map +1 -0
- package/dist/core/hooks/cli.js +1123 -0
- package/dist/core/hooks/effects/image-capture.d.ts +43 -0
- package/dist/core/hooks/effects/image-capture.d.ts.map +1 -0
- package/dist/core/hooks/effects/image-capture.js +288 -0
- package/dist/core/hooks/effects/index.d.ts +64 -0
- package/dist/core/hooks/effects/index.d.ts.map +1 -0
- package/dist/core/hooks/effects/index.js +197 -0
- package/dist/core/hooks/events/emit.d.ts +31 -0
- package/dist/core/hooks/events/emit.d.ts.map +1 -0
- package/dist/core/hooks/events/emit.js +89 -0
- package/dist/core/hooks/events/schema.d.ts +235 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -0
- package/dist/core/hooks/events/schema.js +12 -0
- package/dist/core/hooks/events/ulid.d.ts +10 -0
- package/dist/core/hooks/events/ulid.d.ts.map +1 -0
- package/dist/core/hooks/events/ulid.js +47 -0
- package/dist/core/hooks/harness/detect.d.ts +9 -0
- package/dist/core/hooks/harness/detect.d.ts.map +1 -0
- package/dist/core/hooks/harness/detect.js +29 -0
- package/dist/core/hooks/harness/events.d.ts +45 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -0
- package/dist/core/hooks/harness/events.js +71 -0
- package/dist/core/hooks/harness/output.d.ts +46 -0
- package/dist/core/hooks/harness/output.d.ts.map +1 -0
- package/dist/core/hooks/harness/output.js +87 -0
- package/dist/core/hooks/harness/parse.d.ts +67 -0
- package/dist/core/hooks/harness/parse.d.ts.map +1 -0
- package/dist/core/hooks/harness/parse.js +132 -0
- package/dist/core/hooks/index.d.ts +8 -0
- package/dist/core/hooks/index.d.ts.map +1 -0
- package/dist/core/hooks/index.js +7 -0
- package/dist/core/hooks/resolve/anchor.d.ts +37 -0
- package/dist/core/hooks/resolve/anchor.d.ts.map +1 -0
- package/dist/core/hooks/resolve/anchor.js +48 -0
- package/dist/core/hooks/resolve/coord-root.d.ts +6 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -0
- package/dist/core/hooks/resolve/coord-root.js +27 -0
- package/dist/core/hooks/resolve/intent.d.ts +33 -0
- package/dist/core/hooks/resolve/intent.d.ts.map +1 -0
- package/dist/core/hooks/resolve/intent.js +79 -0
- package/dist/core/hooks/resolve/owner.d.ts +42 -0
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -0
- package/dist/core/hooks/resolve/owner.js +140 -0
- package/dist/core/hooks/resolve/transcript.d.ts +26 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -0
- package/dist/core/hooks/resolve/transcript.js +73 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/lib/agent-browser/client.d.ts +99 -0
- package/dist/lib/agent-browser/client.d.ts.map +1 -0
- package/dist/lib/agent-browser/client.js +177 -0
- package/dist/lib/agent-browser/index.d.ts +2 -0
- package/dist/lib/agent-browser/index.d.ts.map +1 -0
- package/dist/lib/agent-browser/index.js +1 -0
- package/dist/lib/browser/client.d.ts +193 -0
- package/dist/lib/browser/client.d.ts.map +1 -0
- package/dist/lib/browser/client.js +325 -0
- package/dist/lib/browser/dev-overlay.d.ts +23 -0
- package/dist/lib/browser/dev-overlay.d.ts.map +1 -0
- package/dist/lib/browser/dev-overlay.js +153 -0
- package/dist/lib/browser/index.d.ts +5 -0
- package/dist/lib/browser/index.d.ts.map +1 -0
- package/dist/lib/browser/index.js +2 -0
- package/dist/lib/browser/layout.d.ts +79 -0
- package/dist/lib/browser/layout.d.ts.map +1 -0
- package/dist/lib/browser/layout.js +220 -0
- package/dist/lib/browser/visibility.d.ts +86 -0
- package/dist/lib/browser/visibility.d.ts.map +1 -0
- package/dist/lib/browser/visibility.js +333 -0
- package/dist/lib/browser/visual-diff.d.ts +38 -0
- package/dist/lib/browser/visual-diff.d.ts.map +1 -0
- package/dist/lib/browser/visual-diff.js +107 -0
- package/dist/lib/completion/bash.d.ts +25 -0
- package/dist/lib/completion/bash.d.ts.map +1 -0
- package/dist/lib/completion/bash.js +284 -0
- package/dist/lib/completion/fish.d.ts +16 -0
- package/dist/lib/completion/fish.d.ts.map +1 -0
- package/dist/lib/completion/fish.js +118 -0
- package/dist/lib/completion/index.d.ts +5 -0
- package/dist/lib/completion/index.d.ts.map +1 -0
- package/dist/lib/completion/index.js +4 -0
- package/dist/lib/completion/walk.d.ts +68 -0
- package/dist/lib/completion/walk.d.ts.map +1 -0
- package/dist/lib/completion/walk.js +102 -0
- package/dist/lib/completion/zsh.d.ts +13 -0
- package/dist/lib/completion/zsh.d.ts.map +1 -0
- package/dist/lib/completion/zsh.js +249 -0
- package/dist/lib/context/index.d.ts +107 -0
- package/dist/lib/context/index.d.ts.map +1 -0
- package/dist/lib/context/index.js +275 -0
- package/dist/lib/cookies/client.d.ts +131 -0
- package/dist/lib/cookies/client.d.ts.map +1 -0
- package/dist/lib/cookies/client.js +239 -0
- package/dist/lib/cookies/index.d.ts +2 -0
- package/dist/lib/cookies/index.d.ts.map +1 -0
- package/dist/lib/cookies/index.js +1 -0
- package/dist/lib/council/index.d.ts +266 -0
- package/dist/lib/council/index.d.ts.map +1 -0
- package/dist/lib/council/index.js +674 -0
- package/dist/lib/docs-index.d.ts +28 -0
- package/dist/lib/docs-index.d.ts.map +1 -0
- package/dist/lib/docs-index.js +169 -0
- package/dist/lib/docs-lint.d.ts +26 -0
- package/dist/lib/docs-lint.d.ts.map +1 -0
- package/dist/lib/docs-lint.js +378 -0
- package/dist/lib/docs-sweep.d.ts +34 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -0
- package/dist/lib/docs-sweep.js +304 -0
- package/dist/lib/docs.d.ts +27 -0
- package/dist/lib/docs.d.ts.map +1 -0
- package/dist/lib/docs.js +142 -0
- package/dist/lib/env.d.ts +11 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +12 -0
- package/dist/lib/exec.d.ts +32 -0
- package/dist/lib/exec.d.ts.map +1 -0
- package/dist/lib/exec.js +54 -0
- package/dist/lib/format.d.ts +29 -0
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/lib/format.js +139 -0
- package/dist/lib/http/client.d.ts +56 -0
- package/dist/lib/http/client.d.ts.map +1 -0
- package/dist/lib/http/client.js +160 -0
- package/dist/lib/http/index.d.ts +2 -0
- package/dist/lib/http/index.d.ts.map +1 -0
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/identities/index.d.ts +77 -0
- package/dist/lib/identities/index.d.ts.map +1 -0
- package/dist/lib/identities/index.js +190 -0
- package/dist/lib/machine.d.ts +19 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/machine.js +61 -0
- package/dist/lib/presence.d.ts +48 -0
- package/dist/lib/presence.d.ts.map +1 -0
- package/dist/lib/presence.js +123 -0
- package/dist/lib/readability/client.d.ts +32 -0
- package/dist/lib/readability/client.d.ts.map +1 -0
- package/dist/lib/readability/client.js +119 -0
- package/dist/lib/readability/index.d.ts +2 -0
- package/dist/lib/readability/index.d.ts.map +1 -0
- package/dist/lib/readability/index.js +1 -0
- package/dist/lib/scratch/index.d.ts +74 -0
- package/dist/lib/scratch/index.d.ts.map +1 -0
- package/dist/lib/scratch/index.js +393 -0
- package/dist/lib/tunnel/gate.d.ts +12 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -0
- package/dist/lib/tunnel/gate.js +101 -0
- package/dist/lib/tunnel/state.d.ts +34 -0
- package/dist/lib/tunnel/state.d.ts.map +1 -0
- package/dist/lib/tunnel/state.js +132 -0
- package/package.json +160 -8
- package/schemas/.gitkeep +0 -0
- package/schemas/config.schema.json +109 -0
- package/src/cli.ts +22 -0
- package/src/commander.ts +242 -0
- package/src/commands/.gitkeep +0 -0
- package/src/commands/agents.ts +4567 -0
- package/src/commands/backup.ts +305 -0
- package/src/commands/browse-ai.ts +198 -0
- package/src/commands/browse.ts +849 -0
- package/src/commands/callers.ts +363 -0
- package/src/commands/completion.ts +193 -0
- package/src/commands/config-get.ts +161 -0
- package/src/commands/context.ts +209 -0
- package/src/commands/cookies.ts +198 -0
- package/src/commands/docs.ts +174 -0
- package/src/commands/doctor.ts +231 -0
- package/src/commands/edit-batch.ts +233 -0
- package/src/commands/eml.ts +519 -0
- package/src/commands/env.ts +254 -0
- package/src/commands/fetch.ts +136 -0
- package/src/commands/file-history.ts +202 -0
- package/src/commands/grep.ts +371 -0
- package/src/commands/init.ts +335 -0
- package/src/commands/outline.ts +564 -0
- package/src/commands/presence.ts +152 -0
- package/src/commands/read.ts +64 -0
- package/src/commands/scratch.ts +445 -0
- package/src/commands/session.ts +187 -0
- package/src/commands/sync.ts +306 -0
- package/src/commands/toc.ts +218 -0
- package/src/commands/tokens.ts +79 -0
- package/src/commands/tunnel.ts +633 -0
- package/src/commands/uninstall.ts +144 -0
- package/src/commands/web.ts +193 -0
- package/src/core/agents/canonical-emit.ts +77 -0
- package/src/core/agents/cli-emit.ts +64 -0
- package/src/core/agents/cli.ts +838 -0
- package/src/core/agents/codex-replay.ts +163 -0
- package/src/core/agents/coord-client.ts +249 -0
- package/src/core/agents/events/consume.ts +196 -0
- package/src/core/agents/events/emit.ts +108 -0
- package/src/core/agents/events/ulid.ts +51 -0
- package/src/core/agents/index.ts +14 -0
- package/src/core/agents/paths.ts +16 -0
- package/src/core/agents/render/prompt-context.ts +401 -0
- package/src/core/agents/render/session-context.ts +341 -0
- package/src/core/agents/rules/claim-conflict.ts +282 -0
- package/src/core/agents/rules/commit-conflict.ts +303 -0
- package/src/core/agents/rules/stop-hook.ts +229 -0
- package/src/core/agents/session-events.ts +228 -0
- package/src/core/agents/state/activity-log.ts +33 -0
- package/src/core/agents/state/council.ts +265 -0
- package/src/core/agents/state/heartbeat-projector.ts +488 -0
- package/src/core/agents/state/heartbeat-writer.ts +333 -0
- package/src/core/agents/state/names.ts +399 -0
- package/src/core/agents/state/pidmap.ts +38 -0
- package/src/core/agents/state/scratch.ts +121 -0
- package/src/core/agents/state/shell-mutation.ts +44 -0
- package/src/core/agents/state/stale-sweep.ts +190 -0
- package/src/core/config.ts +111 -0
- package/src/core/hooks/cli.ts +1247 -0
- package/src/core/hooks/effects/image-capture.ts +330 -0
- package/src/core/hooks/effects/index.ts +210 -0
- package/src/core/hooks/events/emit.ts +120 -0
- package/src/core/hooks/events/schema.ts +430 -0
- package/src/core/hooks/events/ulid.ts +51 -0
- package/src/core/hooks/harness/detect.ts +30 -0
- package/src/core/hooks/harness/events.ts +102 -0
- package/src/core/hooks/harness/output.ts +100 -0
- package/src/core/hooks/harness/parse.ts +180 -0
- package/src/core/hooks/index.ts +16 -0
- package/src/core/hooks/resolve/anchor.ts +51 -0
- package/src/core/hooks/resolve/coord-root.ts +25 -0
- package/src/core/hooks/resolve/intent.ts +89 -0
- package/src/core/hooks/resolve/owner.ts +140 -0
- package/src/core/hooks/resolve/transcript.ts +72 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/index.ts +15 -0
- package/src/lib/agent-browser/client.ts +239 -0
- package/src/lib/agent-browser/index.ts +1 -0
- package/src/lib/browser/client.ts +449 -0
- package/src/lib/browser/dev-overlay.ts +207 -0
- package/src/lib/browser/index.ts +24 -0
- package/src/lib/browser/layout.ts +288 -0
- package/src/lib/browser/visibility.ts +419 -0
- package/src/lib/browser/visual-diff.ts +150 -0
- package/src/lib/completion/bash.ts +291 -0
- package/src/lib/completion/fish.ts +134 -0
- package/src/lib/completion/index.ts +10 -0
- package/src/lib/completion/walk.ts +184 -0
- package/src/lib/completion/zsh.ts +262 -0
- package/src/lib/context/index.ts +386 -0
- package/src/lib/cookies/client.ts +301 -0
- package/src/lib/cookies/index.ts +13 -0
- package/src/lib/council/index.ts +803 -0
- package/src/lib/docs-index.ts +216 -0
- package/src/lib/docs-lint.ts +413 -0
- package/src/lib/docs-sweep.ts +348 -0
- package/src/lib/docs.ts +199 -0
- package/src/lib/env.ts +12 -0
- package/src/lib/exec.ts +74 -0
- package/src/lib/format.ts +147 -0
- package/src/lib/http/client.ts +211 -0
- package/src/lib/http/index.ts +1 -0
- package/src/lib/identities/index.ts +210 -0
- package/src/lib/machine.ts +61 -0
- package/src/lib/presence.ts +154 -0
- package/src/lib/readability/client.ts +156 -0
- package/src/lib/readability/index.ts +5 -0
- package/src/lib/readability/turndown-plugin-gfm.d.ts +10 -0
- package/src/lib/scratch/index.ts +470 -0
- package/src/lib/tunnel/gate.ts +113 -0
- package/src/lib/tunnel/state.ts +167 -0
- package/src/web/.gitkeep +0 -0
- package/index.js +0 -1
|
@@ -0,0 +1,4567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `harn agents`: on-demand queries against the multi-agent coord layer.
|
|
3
|
+
*
|
|
4
|
+
* harn agents whoami current agent's name + instance_id + claims
|
|
5
|
+
* harn agents list all active agents (default: fold transients)
|
|
6
|
+
* harn agents list --all include raw kind=transient rows
|
|
7
|
+
* harn agents list --stale include heartbeats older than the freshness window
|
|
8
|
+
* harn agents list --json JSON output (alias for --format json)
|
|
9
|
+
* harn agents status end-of-turn status box (name + age + files + peers)
|
|
10
|
+
* harn agents heal-events PIDMAP_HEAL telemetry (pid-map self-heal frequency)
|
|
11
|
+
* harn agents heal-events --since 24h --limit 20
|
|
12
|
+
* harn agents health one-screen coord-layer health rollup
|
|
13
|
+
* harn agents health --since 7d --json
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
statSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { homedir, tmpdir } from "node:os";
|
|
28
|
+
import { basename, join, resolve } from "node:path";
|
|
29
|
+
import type { Command } from "commander";
|
|
30
|
+
import type { EmitContext } from "../commander.ts";
|
|
31
|
+
import {
|
|
32
|
+
emitCanonical,
|
|
33
|
+
type Heartbeat,
|
|
34
|
+
monorepoRoot,
|
|
35
|
+
normalizeHarness,
|
|
36
|
+
readHeartbeat,
|
|
37
|
+
resolveOwner,
|
|
38
|
+
resolveOwnerWithSource,
|
|
39
|
+
} from "../core/agents/index.ts";
|
|
40
|
+
import { resolveBinName } from "../core/config.ts";
|
|
41
|
+
import {
|
|
42
|
+
buildCouncilId,
|
|
43
|
+
buildInviteMarkdown,
|
|
44
|
+
COUNCIL_SCHEMA_VERSION,
|
|
45
|
+
type CouncilManifest,
|
|
46
|
+
type CouncilStatus,
|
|
47
|
+
contributorsInRound,
|
|
48
|
+
councilBodyDir,
|
|
49
|
+
councilsArchiveDir,
|
|
50
|
+
deleteArchivedCouncil,
|
|
51
|
+
effectiveSteward,
|
|
52
|
+
findManifestByPartialId,
|
|
53
|
+
listKnownAgents,
|
|
54
|
+
listManifests,
|
|
55
|
+
moveFromArchive,
|
|
56
|
+
moveToArchive,
|
|
57
|
+
normalizeAgentName,
|
|
58
|
+
pendingCouncilsForMember,
|
|
59
|
+
readArchivedManifest,
|
|
60
|
+
readManifest,
|
|
61
|
+
readRoundPrompts,
|
|
62
|
+
roundDir,
|
|
63
|
+
setCouncilSteward,
|
|
64
|
+
writeContribution,
|
|
65
|
+
writeManifest,
|
|
66
|
+
writePrompt,
|
|
67
|
+
} from "../lib/council/index.ts";
|
|
68
|
+
import {
|
|
69
|
+
displayName as displayAgentName,
|
|
70
|
+
ensureIdentity,
|
|
71
|
+
listIdentities,
|
|
72
|
+
lookupById as lookupIdentityById,
|
|
73
|
+
lookupByName as lookupIdentityByName,
|
|
74
|
+
} from "../lib/identities/index.ts";
|
|
75
|
+
import { appendEntry, resolveOwnerByName } from "../lib/scratch/index.ts";
|
|
76
|
+
|
|
77
|
+
const FRESHNESS_SECS = 600; // 10-minute heartbeat-freshness window.
|
|
78
|
+
|
|
79
|
+
const SUBAGENT_NOTE =
|
|
80
|
+
"Bash identity is process-level in v1; if you're running inside a subagent, " +
|
|
81
|
+
"this resolves to the parent group's name, not the subagent's. A subagent-aware " +
|
|
82
|
+
"bridge (per-shell marker file at .harnery/shells/<pid>) is out of scope.";
|
|
83
|
+
|
|
84
|
+
function formatPlatformLabel(platform?: string | null): string {
|
|
85
|
+
if (platform === "cursor") return "Cursor";
|
|
86
|
+
if (platform === "codex") return "Codex";
|
|
87
|
+
return "CC";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Row {
|
|
91
|
+
name: string;
|
|
92
|
+
instance_id: string;
|
|
93
|
+
session_id: string;
|
|
94
|
+
kind: string;
|
|
95
|
+
relation: "self" | "group" | "blocks" | "unknown";
|
|
96
|
+
started_at: string;
|
|
97
|
+
last_heartbeat: string;
|
|
98
|
+
files_touched: string[];
|
|
99
|
+
last_tool?: string | null;
|
|
100
|
+
last_tool_target?: string | null;
|
|
101
|
+
task?: string | null;
|
|
102
|
+
turn_summary?: string | null;
|
|
103
|
+
turn_summary_updated_at?: string | null;
|
|
104
|
+
platform?: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let emit: EmitContext;
|
|
108
|
+
|
|
109
|
+
export function registerAgentsCommand(program: Command, emitParam: EmitContext): void {
|
|
110
|
+
emit = emitParam;
|
|
111
|
+
const cmd = program
|
|
112
|
+
.command("agents")
|
|
113
|
+
.description("Query the multi-agent coordination layer (whoami / list / status / health)");
|
|
114
|
+
|
|
115
|
+
cmd
|
|
116
|
+
.command("whoami")
|
|
117
|
+
.description("Print the current agent's name + instance_id + files claimed")
|
|
118
|
+
.option("--json", "JSON output (alias for --format json)")
|
|
119
|
+
.action((opts: { json?: boolean }) => {
|
|
120
|
+
runWhoami(opts);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
cmd
|
|
124
|
+
.command("list")
|
|
125
|
+
.description("List all active agents (folds kind=transient by default)")
|
|
126
|
+
.option("--all", "Include raw kind=transient rows (no fold)")
|
|
127
|
+
.option("--stale", "Include heartbeats older than the freshness window")
|
|
128
|
+
.option("--json", "JSON output (alias for --format json)")
|
|
129
|
+
.action((opts: { all?: boolean; stale?: boolean; json?: boolean }) => {
|
|
130
|
+
runList(opts);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
cmd
|
|
134
|
+
.command("status")
|
|
135
|
+
.description("End-of-turn status box (name + session age + files held + peer count)")
|
|
136
|
+
.option("--json", "JSON output instead of the box")
|
|
137
|
+
.option(
|
|
138
|
+
"--session-id <id>",
|
|
139
|
+
"Lookup heartbeat by session_id directly, bypassing the ppid walk. " +
|
|
140
|
+
"Use this when calling from a hook (the hook's process tree may not lead back to Claude Code's session pid). " +
|
|
141
|
+
"The Stop hook payload includes session_id; pass it through.",
|
|
142
|
+
)
|
|
143
|
+
.action((opts: { json?: boolean; sessionId?: string }) => {
|
|
144
|
+
runStatus(opts);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
cmd
|
|
148
|
+
.command("watch")
|
|
149
|
+
.description(
|
|
150
|
+
"Stream peer state changes in real time (file watcher on .harnery/active/). " +
|
|
151
|
+
"Prints one line per delta: started / ended / activity / file claim / task change.",
|
|
152
|
+
)
|
|
153
|
+
.option("--poll-ms <n>", "Debounce window after a change event", "200")
|
|
154
|
+
.action(async (opts: { pollMs: string }) => {
|
|
155
|
+
await runWatch(Number.parseInt(opts.pollMs, 10));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
cmd
|
|
159
|
+
.command("show <name>")
|
|
160
|
+
.description(
|
|
161
|
+
"Deep-dive on one peer agent: registry state (files held, last tool, task) " +
|
|
162
|
+
"plus claude-sessions history (latest title, recent prompts, recent tools, tool-usage tallies). " +
|
|
163
|
+
"Disambiguates name → instance_id via prefix match.",
|
|
164
|
+
)
|
|
165
|
+
.option("--json", "JSON envelope output")
|
|
166
|
+
.action(async (name: string, opts: { json?: boolean }) => {
|
|
167
|
+
await runShow(name, opts);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
cmd
|
|
171
|
+
.command("trace <name>")
|
|
172
|
+
.description(
|
|
173
|
+
"Reconstruct one agent's coordination lifecycle from events.ndjson: " +
|
|
174
|
+
"session.start → prompts → turns → tools → heals/sweeps → claims → end, " +
|
|
175
|
+
"in chronological order. The answer to 'what happened to this agent / why did " +
|
|
176
|
+
"it vanish?' without hand-grepping the stream. Accepts a name (agent-Foo or Foo) " +
|
|
177
|
+
"or an instance_id.",
|
|
178
|
+
)
|
|
179
|
+
.option("--since <window>", "Only events newer than Nh|Nd (default: all)")
|
|
180
|
+
.option("--limit <n>", "Show at most N most-recent events. Default: 200.", "200")
|
|
181
|
+
.option("--all-tools", "Include tool.post_use + command.* (default: hidden as noise)")
|
|
182
|
+
.option("--json", "JSON envelope output")
|
|
183
|
+
.action(
|
|
184
|
+
(
|
|
185
|
+
name: string,
|
|
186
|
+
opts: { since?: string; limit: string; allTools?: boolean; json?: boolean },
|
|
187
|
+
) => {
|
|
188
|
+
runTrace(name, opts);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
cmd
|
|
193
|
+
.command("set-task <text...>")
|
|
194
|
+
.description(
|
|
195
|
+
"Declare what this agent is currently working on. Visible to peers in the " +
|
|
196
|
+
"per-prompt snapshot. Pass an empty string ('') to clear.",
|
|
197
|
+
)
|
|
198
|
+
.option(
|
|
199
|
+
"--session-id <id>",
|
|
200
|
+
`Set the task on the heartbeat with this session_id directly, bypassing the ppid walk. Mirror of \`status --session-id\`: use it when the ppid walk can't resolve self (e.g. Cursor, whose shell tool calls don't descend from a pid-map-registered anchor). Discover the id via \`${resolveBinName()} agents list --json\`.`,
|
|
201
|
+
)
|
|
202
|
+
.action((text: string[], opts: { sessionId?: string }) => {
|
|
203
|
+
runSetTask(text.join(" "), opts);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
cmd
|
|
207
|
+
.command("release-claim <path>")
|
|
208
|
+
.description(
|
|
209
|
+
"Drop a file claim from your heartbeat. Operator escape hatch when a " +
|
|
210
|
+
"PostToolUseFailure didn't fire (e.g., session ended mid-Edit) and a " +
|
|
211
|
+
"peer is now blocked on a path you no longer care about. Same write " +
|
|
212
|
+
"agent-hook's auto-release uses on failed Edit.",
|
|
213
|
+
)
|
|
214
|
+
.action((path: string) => {
|
|
215
|
+
runReleaseClaim(path);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
cmd
|
|
219
|
+
.command("ping <name> <message...>")
|
|
220
|
+
.description(
|
|
221
|
+
"Append a 'handoff' entry to a peer agent's scratchpad. Body prefixed with " +
|
|
222
|
+
"`from agent-<me>:`. Use to leave actionable coordination notes for peers " +
|
|
223
|
+
"currently holding files you need.",
|
|
224
|
+
)
|
|
225
|
+
.option("--json", "JSON output")
|
|
226
|
+
.action((name: string, message: string[], opts: { json?: boolean }) => {
|
|
227
|
+
runPing(name, message.join(" "), opts);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
cmd
|
|
231
|
+
.command("wait <name>")
|
|
232
|
+
.description(
|
|
233
|
+
`Block until a peer agent releases files (their \`files_touched\` becomes empty, OR they exit). Pair with \`${resolveBinName()} agents ping\` to coordinate hand-offs.`,
|
|
234
|
+
)
|
|
235
|
+
.option(
|
|
236
|
+
"--file <path>",
|
|
237
|
+
"Wait only for these specific paths (repeatable)",
|
|
238
|
+
collectPath,
|
|
239
|
+
[] as string[],
|
|
240
|
+
)
|
|
241
|
+
.option(
|
|
242
|
+
"--timeout <dur>",
|
|
243
|
+
"Give up after this duration; suffix s/m/h/d (e.g. 30s, 5m, 1h). Bare integer = minutes. Default 60m.",
|
|
244
|
+
"60m",
|
|
245
|
+
)
|
|
246
|
+
.option("--poll-secs <n>", "Poll interval in seconds (default 5)", "5")
|
|
247
|
+
.option("--quiet", "Suppress progress lines")
|
|
248
|
+
.option("--json", "JSON output (terminal status, only printed at exit)")
|
|
249
|
+
.action(
|
|
250
|
+
async (
|
|
251
|
+
name: string,
|
|
252
|
+
opts: {
|
|
253
|
+
file: string[];
|
|
254
|
+
timeout: string;
|
|
255
|
+
pollSecs: string;
|
|
256
|
+
quiet?: boolean;
|
|
257
|
+
json?: boolean;
|
|
258
|
+
},
|
|
259
|
+
) => {
|
|
260
|
+
await runWait(name, opts);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
cmd
|
|
265
|
+
.command("heal-events")
|
|
266
|
+
.description(
|
|
267
|
+
"PIDMAP_HEAL telemetry: how often pid-map self-heal had to fix drift. " +
|
|
268
|
+
"High counts surface the upstream sibling-claude-spawn bug.",
|
|
269
|
+
)
|
|
270
|
+
.option("--since <window>", "Time window (e.g. 1h, 24h, 7d). Default: 7d.", "7d")
|
|
271
|
+
.option("--limit <n>", "Max recent events to show in the table. Default: 20.", "20")
|
|
272
|
+
.option("--json", "JSON output (alias for --format json)")
|
|
273
|
+
.option("--csv", "CSV output of the events list")
|
|
274
|
+
.action((opts: { since: string; limit: string; json?: boolean; csv?: boolean }) => {
|
|
275
|
+
runHealEvents(opts);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
cmd
|
|
279
|
+
.command("health")
|
|
280
|
+
.description(
|
|
281
|
+
"One-screen coord-layer health rollup: heal events, schema validity, " +
|
|
282
|
+
"commit-guard activity, council activity, anomalies. Designed for " +
|
|
283
|
+
"daily glance + dashboard ingestion. Reads .harnery/.",
|
|
284
|
+
)
|
|
285
|
+
.option("--since <window>", "Window (Nh | Nd). Default: 24h.", "24h")
|
|
286
|
+
.option("--json", "JSON envelope output")
|
|
287
|
+
.action((opts: { since: string; json?: boolean }) => {
|
|
288
|
+
runHealth(opts);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
cmd
|
|
292
|
+
.command("harness-probe <id>")
|
|
293
|
+
.description(
|
|
294
|
+
"Harness wiring probe: ppid chain, comm names, pid-map anchor, sample payload paths. " +
|
|
295
|
+
"With --replay-samples, also replays every checked-in sample payload against the live " +
|
|
296
|
+
"adapter in an isolated sandbox to catch adapter / payload-shape drift. " +
|
|
297
|
+
"Complements heal-events (drift telemetry). Id: claude_code | cursor.",
|
|
298
|
+
)
|
|
299
|
+
.option("--json", "JSON envelope output")
|
|
300
|
+
.option(
|
|
301
|
+
"--replay-samples",
|
|
302
|
+
"Replay docs/api/<harness>-hooks/samples/*.json against the live adapter in an isolated sandbox. " +
|
|
303
|
+
"Exits non-zero if any sample crashes the adapter.",
|
|
304
|
+
)
|
|
305
|
+
.option(
|
|
306
|
+
"--sample <path>",
|
|
307
|
+
"Replay only the named sample file (basename match). Implies --replay-samples.",
|
|
308
|
+
)
|
|
309
|
+
.action((id: string, opts: { json?: boolean; replaySamples?: boolean; sample?: string }) => {
|
|
310
|
+
runHarnessProbe(id, opts);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
cmd
|
|
314
|
+
.command("heal")
|
|
315
|
+
.description(
|
|
316
|
+
"Force a coord-layer recovery action on a specific agent. " +
|
|
317
|
+
"Kinds: pidmap (force PIDMAP_HEAL), heartbeat (force HEARTBEAT_HEAL), " +
|
|
318
|
+
"kill (rm the heartbeat file). Runs through the same heartbeat flock " +
|
|
319
|
+
"the live hooks use, so it governs the operation safely.",
|
|
320
|
+
)
|
|
321
|
+
.requiredOption("--owner <id>", "Target agent's instance_id")
|
|
322
|
+
.requiredOption("--kind <kind>", "pidmap | heartbeat | kill")
|
|
323
|
+
.option(
|
|
324
|
+
"--session-id <id>",
|
|
325
|
+
"(--kind heartbeat) session_id to stamp on the heartbeat. " +
|
|
326
|
+
"Default: inherit from existing heartbeat if one exists. " +
|
|
327
|
+
"Required when no heartbeat exists yet (a heartbeat without " +
|
|
328
|
+
"session_id fails schema validation and pollutes the audit trail).",
|
|
329
|
+
)
|
|
330
|
+
.option(
|
|
331
|
+
"--pid <pid>",
|
|
332
|
+
"(--kind pidmap) PID to register in pid-map. Default: walk " +
|
|
333
|
+
"this shell's ppid chain for a claude process. Pass explicitly " +
|
|
334
|
+
"when calling from outside Claude Code's Bash tool tree (e.g. " +
|
|
335
|
+
"from cron or an external script).",
|
|
336
|
+
)
|
|
337
|
+
.option("--json", "JSON envelope output")
|
|
338
|
+
.action(
|
|
339
|
+
(opts: { owner: string; kind: string; sessionId?: string; pid?: string; json?: boolean }) => {
|
|
340
|
+
runHeal(opts);
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
registerCouncilCommands(cmd);
|
|
345
|
+
registerIdentityCommands(cmd);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function registerIdentityCommands(parent: Command): void {
|
|
349
|
+
const identity = parent
|
|
350
|
+
.command("identity")
|
|
351
|
+
.description(
|
|
352
|
+
"Agent persona registry: durable UUIDs per agent, independent of " +
|
|
353
|
+
"per-session instance_ids. Storage: .harnery/identities/<id>.json.",
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
identity
|
|
357
|
+
.command("list")
|
|
358
|
+
.description("List every known agent identity (sorted by created_at).")
|
|
359
|
+
.option("--json", "JSON envelope output")
|
|
360
|
+
.action((opts: { json?: boolean }) => {
|
|
361
|
+
if (opts.json) emit.config({ format: "json" });
|
|
362
|
+
const rows = listIdentities().map((id) => ({
|
|
363
|
+
agent_id: id.agent_id,
|
|
364
|
+
name: id.name,
|
|
365
|
+
display_name: displayAgentName(id.name),
|
|
366
|
+
aliases: id.aliases,
|
|
367
|
+
created_at: id.created_at,
|
|
368
|
+
}));
|
|
369
|
+
emit.data({ rows, meta: { count: rows.length } });
|
|
370
|
+
if (!opts.json) {
|
|
371
|
+
for (const r of rows) {
|
|
372
|
+
emit.text(`${r.agent_id} ${r.display_name} (since ${r.created_at})\n`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
identity
|
|
378
|
+
.command("show <name-or-id>")
|
|
379
|
+
.description("Show one identity by display name or agent_id. Accepts both.")
|
|
380
|
+
.option("--json", "JSON envelope output")
|
|
381
|
+
.action((arg: string, opts: { json?: boolean }) => {
|
|
382
|
+
if (opts.json) emit.config({ format: "json" });
|
|
383
|
+
const trimmed = arg.trim();
|
|
384
|
+
const byId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)
|
|
385
|
+
? lookupIdentityById(trimmed)
|
|
386
|
+
: null;
|
|
387
|
+
const identity = byId ?? lookupIdentityByName(trimmed);
|
|
388
|
+
if (!identity) {
|
|
389
|
+
emit.error({
|
|
390
|
+
code: "identity_not_found",
|
|
391
|
+
message: `no identity matching '${arg}'`,
|
|
392
|
+
});
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
emit.data({
|
|
396
|
+
rows: [
|
|
397
|
+
{
|
|
398
|
+
agent_id: identity.agent_id,
|
|
399
|
+
name: identity.name,
|
|
400
|
+
display_name: displayAgentName(identity.name),
|
|
401
|
+
aliases: identity.aliases,
|
|
402
|
+
created_at: identity.created_at,
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
meta: { action: "identity-show" },
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
identity
|
|
410
|
+
.command("ensure <name>")
|
|
411
|
+
.description(
|
|
412
|
+
"Resolve an identity by display name, minting a new one if absent. " +
|
|
413
|
+
"Idempotent. Prints the agent_id to stdout, useful from bash hooks.",
|
|
414
|
+
)
|
|
415
|
+
.option("--json", "JSON envelope output")
|
|
416
|
+
.option(
|
|
417
|
+
"--id-only",
|
|
418
|
+
"Print just the bare uuid (no newline, no envelope) for shell substitution",
|
|
419
|
+
)
|
|
420
|
+
.action((name: string, opts: { json?: boolean; idOnly?: boolean }) => {
|
|
421
|
+
if (opts.json) emit.config({ format: "json" });
|
|
422
|
+
const id = ensureIdentity(name);
|
|
423
|
+
if (opts.idOnly) {
|
|
424
|
+
process.stdout.write(id.agent_id); // lint-ok-emission: --id-only is a shell-substitution affordance for bash hooks; ctx() framing (newline) would break `id=$(harn agents identity ensure Foo --id-only)`
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
emit.data({
|
|
428
|
+
rows: [
|
|
429
|
+
{
|
|
430
|
+
agent_id: id.agent_id,
|
|
431
|
+
name: id.name,
|
|
432
|
+
display_name: displayAgentName(id.name),
|
|
433
|
+
aliases: id.aliases,
|
|
434
|
+
created_at: id.created_at,
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
meta: { action: "identity-ensure" },
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function registerCouncilCommands(parent: Command): void {
|
|
443
|
+
const council = parent
|
|
444
|
+
.command("council")
|
|
445
|
+
.description(
|
|
446
|
+
"Multi-agent deliberation: convene a temporary group around an " +
|
|
447
|
+
"objective, run N rounds of contribution, emit a transcript.",
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
council
|
|
451
|
+
.command("create <objective>")
|
|
452
|
+
.description(
|
|
453
|
+
"Create a council with the given objective. Members listed as " +
|
|
454
|
+
"agent-Name (or bare Name; agent- prefix added automatically).",
|
|
455
|
+
)
|
|
456
|
+
.requiredOption("--members <list>", "Comma-separated member names (e.g. 'Juno,Dahlia,Codex')")
|
|
457
|
+
.option(
|
|
458
|
+
"--target-doc <path>",
|
|
459
|
+
"Monorepo-relative path to a doc the council is reviewing (optional)",
|
|
460
|
+
)
|
|
461
|
+
.option(
|
|
462
|
+
"--steward <member>",
|
|
463
|
+
"Designate one member as the council steward, the ongoing process-tender " +
|
|
464
|
+
"who drafts per-round prompts for each contributor. Defaults to the convener. " +
|
|
465
|
+
"Must be a member of the council. agent- prefix added automatically.",
|
|
466
|
+
)
|
|
467
|
+
.option(
|
|
468
|
+
"--auto-advance",
|
|
469
|
+
"Auto-fire `council advance` when all members have contributed to the current round",
|
|
470
|
+
)
|
|
471
|
+
.option(
|
|
472
|
+
"--created-by <name>",
|
|
473
|
+
"Override the convener name. Defaults to the running agent's name. Used " +
|
|
474
|
+
"by the web UI council-create flow where the HTTP request has no agent " +
|
|
475
|
+
"identity. The operator picks a convener (typically the steward) and " +
|
|
476
|
+
"the API passes it through. agent- prefix added automatically.",
|
|
477
|
+
)
|
|
478
|
+
.option("--json", "JSON envelope output")
|
|
479
|
+
.action(
|
|
480
|
+
(
|
|
481
|
+
objective: string,
|
|
482
|
+
opts: {
|
|
483
|
+
members: string;
|
|
484
|
+
targetDoc?: string;
|
|
485
|
+
steward?: string;
|
|
486
|
+
autoAdvance?: boolean;
|
|
487
|
+
createdBy?: string;
|
|
488
|
+
json?: boolean;
|
|
489
|
+
},
|
|
490
|
+
) => {
|
|
491
|
+
runCouncilCreate(objective, opts);
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
council
|
|
496
|
+
.command("list")
|
|
497
|
+
.description(
|
|
498
|
+
"List councils. Default: every council in .harnery/councils/ (archive excluded). " +
|
|
499
|
+
"--mine filters to councils I'm a member of.",
|
|
500
|
+
)
|
|
501
|
+
.option("--status <status>", "Filter by status: active | closed | archived")
|
|
502
|
+
.option("--mine", "Only councils that include me as a member")
|
|
503
|
+
.option("--json", "JSON envelope output")
|
|
504
|
+
.action((opts: { status?: string; mine?: boolean; json?: boolean }) => {
|
|
505
|
+
runCouncilList(opts);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
council
|
|
509
|
+
.command("show <id>")
|
|
510
|
+
.description(
|
|
511
|
+
"Print one council's manifest + invite + (when round > 1) prior-rounds transcript. " +
|
|
512
|
+
"Accepts a partial id prefix.",
|
|
513
|
+
)
|
|
514
|
+
.option("--json", "JSON envelope output")
|
|
515
|
+
.action((id: string, opts: { json?: boolean }) => {
|
|
516
|
+
runCouncilShow(id, opts);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
council
|
|
520
|
+
.command("close <id>")
|
|
521
|
+
.description(
|
|
522
|
+
"Close a council: status → closed, closed_at stamped, transcript " +
|
|
523
|
+
"printed to stdout. Does NOT archive (use `archive` for that).",
|
|
524
|
+
)
|
|
525
|
+
.option("--json", "JSON envelope output")
|
|
526
|
+
.action((id: string, opts: { json?: boolean }) => {
|
|
527
|
+
runCouncilClose(id, opts);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
council
|
|
531
|
+
.command("archive <id>")
|
|
532
|
+
.description(
|
|
533
|
+
"Archive a council: status → archived, archived_at stamped, manifest + " +
|
|
534
|
+
"body dir moved to .harnery/councils/archive/. Idempotent.",
|
|
535
|
+
)
|
|
536
|
+
.option("--json", "JSON envelope output")
|
|
537
|
+
.action((id: string, opts: { json?: boolean }) => {
|
|
538
|
+
runCouncilArchive(id, opts);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
council
|
|
542
|
+
.command("unarchive <id>")
|
|
543
|
+
.description(
|
|
544
|
+
"Reverse of archive: move manifest + body dir back to active, drop " +
|
|
545
|
+
"archived_at, restore status from closed_at (closed if set, else " +
|
|
546
|
+
"active). Idempotent. Useful for testing the archive flow.",
|
|
547
|
+
)
|
|
548
|
+
.option("--json", "JSON envelope output")
|
|
549
|
+
.action((id: string, opts: { json?: boolean }) => {
|
|
550
|
+
runCouncilUnarchive(id, opts);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
council
|
|
554
|
+
.command("delete <id>")
|
|
555
|
+
.description(
|
|
556
|
+
"Permanently delete an archived council (manifest + body dir). " +
|
|
557
|
+
"Refuses unless the council is in .harnery/councils/archive/; " +
|
|
558
|
+
"archive it first (trash-can pattern). Without --yes this prints " +
|
|
559
|
+
"the paths that would be removed and exits 0 without touching " +
|
|
560
|
+
"anything. Does NOT touch target_doc, close_handoff_path, or " +
|
|
561
|
+
"session-events.ndjson, which are owned by separate authors.",
|
|
562
|
+
)
|
|
563
|
+
.option("-y, --yes", "Required to actually delete; without this, dry-run")
|
|
564
|
+
.option("--json", "JSON envelope output")
|
|
565
|
+
.action((id: string, opts: { yes?: boolean; json?: boolean }) => {
|
|
566
|
+
runCouncilDelete(id, opts);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
council
|
|
570
|
+
.command("set-steward <id> [steward]")
|
|
571
|
+
.description(
|
|
572
|
+
"Reassign the steward on an active or closed council. Pass --clear " +
|
|
573
|
+
"(or omit [steward]) to drop the field and revert to the default " +
|
|
574
|
+
"(the convener). Refuses to mutate archived councils. By default, " +
|
|
575
|
+
"rejects names not in the known-agents list (active heartbeats + " +
|
|
576
|
+
"scratchpads archived in the last 30 days); pass --allow-unknown " +
|
|
577
|
+
"to bypass when bootstrapping.",
|
|
578
|
+
)
|
|
579
|
+
.option("--clear", "Clear the steward field, reverting to created_by default")
|
|
580
|
+
.option(
|
|
581
|
+
"--allow-unknown",
|
|
582
|
+
"Skip the known-agents check (bootstrap an agent that hasn't run yet)",
|
|
583
|
+
)
|
|
584
|
+
.option("--json", "JSON envelope output")
|
|
585
|
+
.action(
|
|
586
|
+
(
|
|
587
|
+
id: string,
|
|
588
|
+
steward: string | undefined,
|
|
589
|
+
opts: { clear?: boolean; allowUnknown?: boolean; json?: boolean },
|
|
590
|
+
) => {
|
|
591
|
+
runCouncilSetSteward(id, steward, opts);
|
|
592
|
+
},
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
council
|
|
596
|
+
.command("contribute <id>")
|
|
597
|
+
.description(
|
|
598
|
+
"Contribute the running agent's take for the council's current round. " +
|
|
599
|
+
"Pass either --message <inline> or --file <path>. Writes to " +
|
|
600
|
+
".harnery/councils/<id>/round-<N>/<agent-Name>.md. Pass --as <member> " +
|
|
601
|
+
"to contribute under a council seat name that differs from the running " +
|
|
602
|
+
"agent's heartbeat name (useful for cross-harness councils where each " +
|
|
603
|
+
"reviewer has a different auto-generated session name).",
|
|
604
|
+
)
|
|
605
|
+
.option("--message <text>", "Inline contribution text (caps at 4KB)")
|
|
606
|
+
.option("--file <path>", "Path to a file containing the contribution")
|
|
607
|
+
.option(
|
|
608
|
+
"--as <member>",
|
|
609
|
+
"Contribute under this council seat name instead of the running agent's " +
|
|
610
|
+
"heartbeat name. Must be a member of the council. agent- prefix added " +
|
|
611
|
+
"automatically.",
|
|
612
|
+
)
|
|
613
|
+
.option("--json", "JSON envelope output")
|
|
614
|
+
.action(
|
|
615
|
+
(id: string, opts: { message?: string; file?: string; as?: string; json?: boolean }) => {
|
|
616
|
+
runCouncilContribute(id, opts);
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
council
|
|
621
|
+
.command("prompt <id> <member>")
|
|
622
|
+
.description(
|
|
623
|
+
"Steward-only: write or replace the round-<N> prompt for one member. " +
|
|
624
|
+
"Saved to .harnery/councils/<id>/round-<N>/prompts/<agent-Name>.md, " +
|
|
625
|
+
"rendered on the council page in the web UI, and auto-dimmed once that " +
|
|
626
|
+
"member's contribution lands. Use --message <inline> or --file <path>. " +
|
|
627
|
+
"<member> accepts bare 'Codex' or 'agent-Codex'.",
|
|
628
|
+
)
|
|
629
|
+
.option("--message <text>", "Inline prompt text (caps at 4KB)")
|
|
630
|
+
.option("--file <path>", "Path to a file containing the prompt")
|
|
631
|
+
.option(
|
|
632
|
+
"--as <steward>",
|
|
633
|
+
"Override the running agent's identity for the steward authority check. " +
|
|
634
|
+
"Same shape as `contribute --as`, useful when scripting from outside " +
|
|
635
|
+
"the steward's session.",
|
|
636
|
+
)
|
|
637
|
+
.option("--json", "JSON envelope output")
|
|
638
|
+
.action(
|
|
639
|
+
(
|
|
640
|
+
id: string,
|
|
641
|
+
member: string,
|
|
642
|
+
opts: {
|
|
643
|
+
message?: string;
|
|
644
|
+
file?: string;
|
|
645
|
+
as?: string;
|
|
646
|
+
json?: boolean;
|
|
647
|
+
},
|
|
648
|
+
) => {
|
|
649
|
+
runCouncilPrompt(id, member, opts);
|
|
650
|
+
},
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
council
|
|
654
|
+
.command("status <id>")
|
|
655
|
+
.description("Report round-N progress: who has contributed, who's pending.")
|
|
656
|
+
.option("--json", "JSON envelope output")
|
|
657
|
+
.action((id: string, opts: { json?: boolean }) => {
|
|
658
|
+
runCouncilStatus(id, opts);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
council
|
|
662
|
+
.command("advance <id>")
|
|
663
|
+
.description(
|
|
664
|
+
"Advance the council to the next round. By default requires every " +
|
|
665
|
+
"member to have contributed; --force drops no-shows for the round.",
|
|
666
|
+
)
|
|
667
|
+
.option(
|
|
668
|
+
"--force",
|
|
669
|
+
"Advance even when some members have not contributed (those members are dropped from THIS round's transcript; they can rejoin next round)",
|
|
670
|
+
)
|
|
671
|
+
.option("--json", "JSON envelope output")
|
|
672
|
+
.action((id: string, opts: { force?: boolean; json?: boolean }) => {
|
|
673
|
+
runCouncilAdvance(id, opts);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function runWhoami(opts: { json?: boolean }): void {
|
|
678
|
+
if (opts.json) emit.config({ format: "json" });
|
|
679
|
+
|
|
680
|
+
const root = monorepoRoot();
|
|
681
|
+
if (!root) {
|
|
682
|
+
emit.error({
|
|
683
|
+
code: "not_in_repo",
|
|
684
|
+
message: "not in an agent session; coord_root() returned null",
|
|
685
|
+
});
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const resolved = resolveOwnerWithSource();
|
|
690
|
+
const myOwner = resolved.owner;
|
|
691
|
+
if (!myOwner) {
|
|
692
|
+
emit.error({
|
|
693
|
+
code: "no_pidmap_entry",
|
|
694
|
+
message: "not in an agent session; ppid walk found no pid-map entry",
|
|
695
|
+
});
|
|
696
|
+
process.exit(1);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const hb = readHeartbeat(myOwner);
|
|
700
|
+
if (!hb) {
|
|
701
|
+
emit.error({
|
|
702
|
+
code: "no_heartbeat",
|
|
703
|
+
message: `pid-map resolved owner=${myOwner.slice(0, 8)} but no heartbeat exists`,
|
|
704
|
+
});
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const row: Row = {
|
|
709
|
+
name: hb.name || "unknown",
|
|
710
|
+
instance_id: hb.instance_id,
|
|
711
|
+
session_id: hb.session_id,
|
|
712
|
+
kind: normalizeKind(hb.kind),
|
|
713
|
+
relation: "self",
|
|
714
|
+
started_at: hb.started_at,
|
|
715
|
+
last_heartbeat: hb.last_heartbeat,
|
|
716
|
+
files_touched: hb.files_touched ?? [],
|
|
717
|
+
last_tool: hb.last_tool ?? null,
|
|
718
|
+
last_tool_target: hb.last_tool_target ?? null,
|
|
719
|
+
task: hb.task ?? null,
|
|
720
|
+
turn_summary: hb.turn_summary ?? null,
|
|
721
|
+
turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
|
|
722
|
+
platform: hb.platform ?? "claude_code",
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
emit.data({ ...row, resolution_source: resolved.source, note: SUBAGENT_NOTE });
|
|
726
|
+
|
|
727
|
+
if (process.stdout.isTTY && !opts.json) {
|
|
728
|
+
emit.text(`resolved via: ${resolved.source}\n`);
|
|
729
|
+
emit.text(`note: ${SUBAGENT_NOTE}\n`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void {
|
|
734
|
+
if (opts.json) emit.config({ format: "json" });
|
|
735
|
+
|
|
736
|
+
const root = monorepoRoot();
|
|
737
|
+
if (!root) {
|
|
738
|
+
emit.error({
|
|
739
|
+
code: "not_in_repo",
|
|
740
|
+
message: "not in an agent session; coord_root() returned null",
|
|
741
|
+
});
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
746
|
+
if (!existsSync(activeDir)) {
|
|
747
|
+
emit.data({ rows: [], note: SUBAGENT_NOTE });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Resolve self for relation column; best-effort, missing → "unknown" on every row.
|
|
752
|
+
const myOwner = resolveOwner();
|
|
753
|
+
const myHb = myOwner ? readHeartbeat(myOwner) : null;
|
|
754
|
+
const mySession = myHb?.session_id ?? null;
|
|
755
|
+
|
|
756
|
+
// Read every heartbeat.
|
|
757
|
+
const heartbeats: Heartbeat[] = [];
|
|
758
|
+
for (const file of readdirSync(activeDir)) {
|
|
759
|
+
if (!file.endsWith(".json")) continue;
|
|
760
|
+
try {
|
|
761
|
+
const raw = readFileSync(resolve(activeDir, file), "utf8");
|
|
762
|
+
const parsed = JSON.parse(raw);
|
|
763
|
+
if (parsed && typeof parsed.instance_id === "string") {
|
|
764
|
+
heartbeats.push(parsed as Heartbeat);
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// skip malformed
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Apply staleness filter unless --stale.
|
|
772
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
773
|
+
const cutoff = nowSec - FRESHNESS_SECS;
|
|
774
|
+
const live = opts.stale
|
|
775
|
+
? heartbeats
|
|
776
|
+
: heartbeats.filter((h) => {
|
|
777
|
+
const ts = Date.parse(h.last_heartbeat);
|
|
778
|
+
return Number.isFinite(ts) && ts / 1000 >= cutoff;
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Build fold map: parent instance_id → array of files contributed by transient stubs.
|
|
782
|
+
const fold = new Map<string, string[]>();
|
|
783
|
+
for (const h of live) {
|
|
784
|
+
if (normalizeKind(h.kind) === "transient") {
|
|
785
|
+
const parentOwner = h.session_id;
|
|
786
|
+
const existing = fold.get(parentOwner) ?? [];
|
|
787
|
+
fold.set(parentOwner, [...existing, ...(h.files_touched ?? [])]);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Track which transients have a known parent (for orphan detection).
|
|
792
|
+
const knownOwners = new Set(
|
|
793
|
+
live.filter((h) => normalizeKind(h.kind) !== "transient").map((h) => h.instance_id),
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
// Build rows.
|
|
797
|
+
const rows: Row[] = [];
|
|
798
|
+
for (const h of live) {
|
|
799
|
+
const kind = normalizeKind(h.kind);
|
|
800
|
+
if (kind === "transient" && !opts.all) {
|
|
801
|
+
// Folded into parent: skip rendering as own row UNLESS parent is missing
|
|
802
|
+
// (orphan transient: render with parent's name + (transient) marker).
|
|
803
|
+
if (knownOwners.has(h.session_id)) continue;
|
|
804
|
+
// Orphan transient case
|
|
805
|
+
rows.push({
|
|
806
|
+
name: h.name || "unknown",
|
|
807
|
+
instance_id: h.instance_id,
|
|
808
|
+
session_id: h.session_id,
|
|
809
|
+
kind: "transient",
|
|
810
|
+
relation: relationOf(h, myOwner ?? "", mySession),
|
|
811
|
+
started_at: h.started_at,
|
|
812
|
+
last_heartbeat: h.last_heartbeat,
|
|
813
|
+
files_touched: [...(h.files_touched ?? [])].sort(),
|
|
814
|
+
last_tool: h.last_tool ?? null,
|
|
815
|
+
last_tool_target: h.last_tool_target ?? null,
|
|
816
|
+
task: h.task ?? null,
|
|
817
|
+
turn_summary: h.turn_summary ?? null,
|
|
818
|
+
turn_summary_updated_at: h.turn_summary_updated_at ?? null,
|
|
819
|
+
platform: h.platform ?? "claude_code",
|
|
820
|
+
});
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
// Non-transient row, or --all forces transients to show as own rows.
|
|
824
|
+
let files = [...(h.files_touched ?? [])];
|
|
825
|
+
if (kind !== "transient" && !opts.all) {
|
|
826
|
+
const folded = fold.get(h.instance_id) ?? [];
|
|
827
|
+
files = Array.from(new Set([...files, ...folded])).sort();
|
|
828
|
+
}
|
|
829
|
+
rows.push({
|
|
830
|
+
name: h.name || "unknown",
|
|
831
|
+
instance_id: h.instance_id,
|
|
832
|
+
session_id: h.session_id,
|
|
833
|
+
kind: normalizeKind(h.kind),
|
|
834
|
+
relation: relationOf(h, myOwner ?? "", mySession),
|
|
835
|
+
started_at: h.started_at,
|
|
836
|
+
last_heartbeat: h.last_heartbeat,
|
|
837
|
+
files_touched: files,
|
|
838
|
+
last_tool: h.last_tool ?? null,
|
|
839
|
+
last_tool_target: h.last_tool_target ?? null,
|
|
840
|
+
task: h.task ?? null,
|
|
841
|
+
turn_summary: h.turn_summary ?? null,
|
|
842
|
+
turn_summary_updated_at: h.turn_summary_updated_at ?? null,
|
|
843
|
+
platform: h.platform ?? "claude_code",
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Guard missing started_at: a heartbeat seeded from a stray event can lack it
|
|
848
|
+
// (legacy zombies), and an unguarded .localeCompare throws, which is exactly
|
|
849
|
+
// what made `harn agents list --all --stale` crash.
|
|
850
|
+
rows.sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
|
|
851
|
+
|
|
852
|
+
// Emit. JSON format gets {rows, note}; TTY gets the rows with note as a footnote.
|
|
853
|
+
emit.data({ rows, note: SUBAGENT_NOTE });
|
|
854
|
+
if (process.stdout.isTTY && !opts.json) {
|
|
855
|
+
emit.text(`note: ${SUBAGENT_NOTE}\n`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function relationOf(
|
|
860
|
+
peer: Heartbeat,
|
|
861
|
+
myOwner: string,
|
|
862
|
+
mySession: string | null,
|
|
863
|
+
): "self" | "group" | "blocks" | "unknown" {
|
|
864
|
+
if (!mySession) return "unknown";
|
|
865
|
+
if (peer.instance_id === myOwner) return "self";
|
|
866
|
+
if (peer.session_id === mySession) return "group";
|
|
867
|
+
return "blocks";
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function normalizeKind(kind: string | undefined | null): string {
|
|
871
|
+
if (kind === undefined || kind === null || kind === "") return "unknown";
|
|
872
|
+
return kind;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function runWatch(pollMs: number): Promise<void> {
|
|
876
|
+
const root = monorepoRoot();
|
|
877
|
+
if (!root) {
|
|
878
|
+
emit.error({
|
|
879
|
+
code: "not_in_repo",
|
|
880
|
+
message: "not in an agent session; coord_root() returned null",
|
|
881
|
+
});
|
|
882
|
+
process.exit(1);
|
|
883
|
+
}
|
|
884
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
885
|
+
if (!existsSync(activeDir)) {
|
|
886
|
+
emit.error({ code: "no_active_dir", message: ".harnery/active/ missing" });
|
|
887
|
+
process.exit(1);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const fs = await import("node:fs");
|
|
891
|
+
const cache = new Map<string, Heartbeat>();
|
|
892
|
+
|
|
893
|
+
// Seed cache + print an initial roster line per live peer.
|
|
894
|
+
const initial = listActiveHeartbeats(activeDir);
|
|
895
|
+
process.stderr.write(`watching ${activeDir} (Ctrl-C to exit)\n`); // lint-ok-emission: banner goes to stderr, stdout is the live stream
|
|
896
|
+
for (const h of initial) {
|
|
897
|
+
cache.set(h.instance_id, h);
|
|
898
|
+
emitWatchLine(
|
|
899
|
+
`agent-${h.name ?? "?"} present (${formatAge(secondsSince(h.started_at))} old${h.task ? `, task: "${h.task}"` : ""})`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
let scheduled: NodeJS.Timeout | null = null;
|
|
904
|
+
const rescan = () => {
|
|
905
|
+
if (scheduled) return;
|
|
906
|
+
scheduled = setTimeout(() => {
|
|
907
|
+
scheduled = null;
|
|
908
|
+
const current = new Map<string, Heartbeat>();
|
|
909
|
+
for (const h of listActiveHeartbeats(activeDir)) current.set(h.instance_id, h);
|
|
910
|
+
|
|
911
|
+
// Removed agents.
|
|
912
|
+
for (const [id, old] of cache) {
|
|
913
|
+
if (!current.has(id)) {
|
|
914
|
+
emitWatchLine(`agent-${old.name ?? "?"} ended`);
|
|
915
|
+
cache.delete(id);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Added or changed agents.
|
|
919
|
+
for (const [id, h] of current) {
|
|
920
|
+
const prev = cache.get(id);
|
|
921
|
+
if (!prev) {
|
|
922
|
+
emitWatchLine(
|
|
923
|
+
`agent-${h.name ?? "?"} started (${formatAge(secondsSince(h.started_at))} old${h.task ? `, task: "${h.task}"` : ""})`,
|
|
924
|
+
);
|
|
925
|
+
cache.set(id, h);
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
// Diff fields we care about.
|
|
929
|
+
if ((prev.task ?? "") !== (h.task ?? "")) {
|
|
930
|
+
emitWatchLine(`agent-${h.name ?? "?"} task: ${h.task ? `"${h.task}"` : "(cleared)"}`);
|
|
931
|
+
}
|
|
932
|
+
if (
|
|
933
|
+
(prev.last_tool ?? "") !== (h.last_tool ?? "") ||
|
|
934
|
+
(prev.last_tool_target ?? "") !== (h.last_tool_target ?? "")
|
|
935
|
+
) {
|
|
936
|
+
if (h.last_tool) {
|
|
937
|
+
const target = h.last_tool_target ? ` ${truncate(h.last_tool_target, 80)}` : "";
|
|
938
|
+
emitWatchLine(`agent-${h.name ?? "?"} ${h.last_tool}${target}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// File additions/removals.
|
|
942
|
+
const prevFiles = new Set(prev.files_touched ?? []);
|
|
943
|
+
const currFiles = new Set(h.files_touched ?? []);
|
|
944
|
+
for (const f of currFiles) {
|
|
945
|
+
if (!prevFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} +claim ${f}`);
|
|
946
|
+
}
|
|
947
|
+
for (const f of prevFiles) {
|
|
948
|
+
if (!currFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} -release ${f}`);
|
|
949
|
+
}
|
|
950
|
+
cache.set(id, h);
|
|
951
|
+
}
|
|
952
|
+
}, pollMs);
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const watcher = fs.watch(activeDir, { persistent: true }, () => rescan());
|
|
956
|
+
|
|
957
|
+
await new Promise<void>((resolveP) => {
|
|
958
|
+
const stop = () => {
|
|
959
|
+
watcher.close();
|
|
960
|
+
resolveP();
|
|
961
|
+
};
|
|
962
|
+
process.on("SIGINT", stop);
|
|
963
|
+
process.on("SIGTERM", stop);
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function listActiveHeartbeats(activeDir: string): Heartbeat[] {
|
|
968
|
+
const out: Heartbeat[] = [];
|
|
969
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
970
|
+
const cutoff = nowSec - FRESHNESS_SECS;
|
|
971
|
+
for (const file of readdirSync(activeDir)) {
|
|
972
|
+
if (!file.endsWith(".json")) continue;
|
|
973
|
+
try {
|
|
974
|
+
const raw = readFileSync(resolve(activeDir, file), "utf8");
|
|
975
|
+
const parsed = JSON.parse(raw) as Heartbeat;
|
|
976
|
+
if (!parsed || typeof parsed.instance_id !== "string") continue;
|
|
977
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
978
|
+
if (Number.isFinite(ts) && ts / 1000 >= cutoff) out.push(parsed);
|
|
979
|
+
} catch {
|
|
980
|
+
// skip
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return out;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function secondsSince(iso: string): number {
|
|
987
|
+
const t = Date.parse(iso);
|
|
988
|
+
return Number.isFinite(t) ? Math.max(0, Math.floor((Date.now() - t) / 1000)) : 0;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function emitWatchLine(message: string): void {
|
|
992
|
+
const time = formatLocalShort(new Date().toISOString());
|
|
993
|
+
process.stdout.write(`[${time}] ${message}\n`); // lint-ok-emission: live event stream, per-line stdout flush; ctx() envelope serializes the whole stream and breaks the loop
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
|
|
997
|
+
const root = monorepoRoot();
|
|
998
|
+
if (!root) {
|
|
999
|
+
emit.error({
|
|
1000
|
+
code: "not_in_repo",
|
|
1001
|
+
message: "not in an agent session; coord_root() returned null",
|
|
1002
|
+
});
|
|
1003
|
+
process.exit(1);
|
|
1004
|
+
}
|
|
1005
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
1006
|
+
if (!existsSync(activeDir)) {
|
|
1007
|
+
emit.error({ code: "no_active_dir", message: ".harnery/active/ missing" });
|
|
1008
|
+
process.exit(1);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Read all heartbeats; match by name (case-insensitive). Apply freshness filter.
|
|
1012
|
+
const matches: Heartbeat[] = [];
|
|
1013
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
1014
|
+
const cutoff = nowSec - FRESHNESS_SECS;
|
|
1015
|
+
for (const file of readdirSync(activeDir)) {
|
|
1016
|
+
if (!file.endsWith(".json")) continue;
|
|
1017
|
+
try {
|
|
1018
|
+
const raw = readFileSync(resolve(activeDir, file), "utf8");
|
|
1019
|
+
const parsed = JSON.parse(raw) as Heartbeat;
|
|
1020
|
+
if (!parsed || typeof parsed.instance_id !== "string") continue;
|
|
1021
|
+
if ((parsed.name ?? "").toLowerCase() !== name.toLowerCase()) continue;
|
|
1022
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
1023
|
+
if (Number.isFinite(ts) && ts / 1000 >= cutoff) matches.push(parsed);
|
|
1024
|
+
} catch {
|
|
1025
|
+
// skip malformed
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (matches.length === 0) {
|
|
1030
|
+
emit.error({
|
|
1031
|
+
code: "no_match",
|
|
1032
|
+
message: `no live agent named "${name}" (case-insensitive). Try \`${resolveBinName()} agents list\` to see who's active.`,
|
|
1033
|
+
});
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
if (matches.length > 1) {
|
|
1037
|
+
emit.error({
|
|
1038
|
+
code: "ambiguous",
|
|
1039
|
+
message: `multiple live agents named "${name}" (${matches.length}). Disambiguation by instance_id not yet supported; rename or stop one.`,
|
|
1040
|
+
});
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
const hb = matches[0];
|
|
1044
|
+
|
|
1045
|
+
// Consumer-specific peer enrichment (e.g. BQ-backed claude-sessions history)
|
|
1046
|
+
// is intentionally out of scope here. Consumer CLIs that want richer
|
|
1047
|
+
// per-peer detail should plumb a `context.peerReport` callback in a future
|
|
1048
|
+
// revision; harn standalone reports the heartbeat data only.
|
|
1049
|
+
interface PeerReport {
|
|
1050
|
+
title: string | null;
|
|
1051
|
+
recent_prompts: { ts: string; text: string }[];
|
|
1052
|
+
recent_tools: { tool: string }[];
|
|
1053
|
+
tool_counts: { tool: string; count: number }[];
|
|
1054
|
+
total_events: number;
|
|
1055
|
+
}
|
|
1056
|
+
const report = null as PeerReport | null;
|
|
1057
|
+
const bqError = null as string | null;
|
|
1058
|
+
|
|
1059
|
+
const startedAtMs = Date.parse(hb.started_at);
|
|
1060
|
+
const ageSecs = Number.isFinite(startedAtMs)
|
|
1061
|
+
? Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))
|
|
1062
|
+
: 0;
|
|
1063
|
+
const heartbeatMs = Date.parse(hb.last_heartbeat);
|
|
1064
|
+
const heartbeatAgeSecs = Number.isFinite(heartbeatMs)
|
|
1065
|
+
? Math.max(0, Math.floor((Date.now() - heartbeatMs) / 1000))
|
|
1066
|
+
: 0;
|
|
1067
|
+
|
|
1068
|
+
const data = {
|
|
1069
|
+
name: hb.name ?? null,
|
|
1070
|
+
instance_id: hb.instance_id,
|
|
1071
|
+
session_id: hb.session_id,
|
|
1072
|
+
kind: normalizeKind(hb.kind),
|
|
1073
|
+
age_secs: ageSecs,
|
|
1074
|
+
last_heartbeat_secs_ago: heartbeatAgeSecs,
|
|
1075
|
+
task: hb.task ?? null,
|
|
1076
|
+
turn_summary: hb.turn_summary ?? null,
|
|
1077
|
+
turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
|
|
1078
|
+
title: report?.title ?? null,
|
|
1079
|
+
files_held: hb.files_touched ?? [],
|
|
1080
|
+
last_tool: hb.last_tool ?? null,
|
|
1081
|
+
last_tool_target: hb.last_tool_target ?? null,
|
|
1082
|
+
recent_prompts: report?.recent_prompts ?? [],
|
|
1083
|
+
recent_tools: report?.recent_tools ?? [],
|
|
1084
|
+
tool_counts: report?.tool_counts ?? [],
|
|
1085
|
+
total_events: report?.total_events ?? 0,
|
|
1086
|
+
bq_error: bqError,
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
if (opts.json) {
|
|
1090
|
+
emit.config({ format: "json" });
|
|
1091
|
+
emit.data(data);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Render text report.
|
|
1096
|
+
const lines: string[] = [];
|
|
1097
|
+
const subtitle = data.task
|
|
1098
|
+
? `"${data.task}"`
|
|
1099
|
+
: data.title
|
|
1100
|
+
? `"${data.title}"`
|
|
1101
|
+
: "(no task / title)";
|
|
1102
|
+
lines.push(`agent-${data.name} ${subtitle}`);
|
|
1103
|
+
lines.push(
|
|
1104
|
+
` session ${formatAge(ageSecs)} old · kind=${data.kind} · session_id=${data.session_id.slice(0, 8)}…`,
|
|
1105
|
+
);
|
|
1106
|
+
lines.push(` last heartbeat: ${formatAge(heartbeatAgeSecs)} ago`);
|
|
1107
|
+
if (data.last_tool) {
|
|
1108
|
+
const target = data.last_tool_target ? ` ${truncate(data.last_tool_target, 80)}` : "";
|
|
1109
|
+
lines.push(` last activity: ${data.last_tool}${target}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (data.files_held.length > 0) {
|
|
1112
|
+
lines.push(` holds ${data.files_held.length} file(s):`);
|
|
1113
|
+
for (const f of data.files_held.slice(0, 10)) lines.push(` ${f}`);
|
|
1114
|
+
if (data.files_held.length > 10) lines.push(` +${data.files_held.length - 10} more`);
|
|
1115
|
+
}
|
|
1116
|
+
if (bqError) {
|
|
1117
|
+
lines.push("");
|
|
1118
|
+
lines.push(` (claude-sessions BQ lookup failed: ${bqError})`);
|
|
1119
|
+
} else if (report) {
|
|
1120
|
+
lines.push("");
|
|
1121
|
+
lines.push(` total events in BQ: ${data.total_events}`);
|
|
1122
|
+
if (data.recent_prompts.length > 0) {
|
|
1123
|
+
lines.push(" recent user prompts:");
|
|
1124
|
+
for (const p of data.recent_prompts) {
|
|
1125
|
+
lines.push(
|
|
1126
|
+
` ${formatLocalShort(p.ts)} ${truncate(p.text.replace(/\s+/g, " ").trim(), 100)}`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (data.tool_counts.length > 0) {
|
|
1131
|
+
const summary = data.tool_counts
|
|
1132
|
+
.slice(0, 10)
|
|
1133
|
+
.map((t) => `${t.tool}(×${t.count})`)
|
|
1134
|
+
.join(", ");
|
|
1135
|
+
lines.push(` tool usage (last 200 events): ${summary}`);
|
|
1136
|
+
}
|
|
1137
|
+
if (data.recent_tools.length > 0) {
|
|
1138
|
+
// Reverse so the sequence reads chronologically (oldest → newest).
|
|
1139
|
+
const recent = [...data.recent_tools]
|
|
1140
|
+
.reverse()
|
|
1141
|
+
.slice(-8)
|
|
1142
|
+
.map((t) => t.tool)
|
|
1143
|
+
.join(" → ");
|
|
1144
|
+
lines.push(` recent tools: ${recent}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
process.stdout.write(`${lines.join("\n")}\n`); // lint-ok-emission: multi-line text report; JSON branch returns early; this is the plain TTY path
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function truncate(s: string, n: number): string {
|
|
1151
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** "1:29 AM CDT": short local-time form for inline use. */
|
|
1155
|
+
function formatLocalShort(iso: string): string {
|
|
1156
|
+
const d = new Date(iso);
|
|
1157
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
1158
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
1159
|
+
hour: "numeric",
|
|
1160
|
+
minute: "2-digit",
|
|
1161
|
+
timeZoneName: "short",
|
|
1162
|
+
hour12: true,
|
|
1163
|
+
timeZone: "America/Chicago",
|
|
1164
|
+
}).format(d);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function runReleaseClaim(path: string): void {
|
|
1168
|
+
const root = monorepoRoot();
|
|
1169
|
+
if (!root) {
|
|
1170
|
+
emit.error({
|
|
1171
|
+
code: "not_in_repo",
|
|
1172
|
+
message: "not in an agent session; coord_root() returned null",
|
|
1173
|
+
});
|
|
1174
|
+
process.exit(1);
|
|
1175
|
+
}
|
|
1176
|
+
const myOwner = resolveOwner();
|
|
1177
|
+
if (!myOwner) {
|
|
1178
|
+
emit.error({
|
|
1179
|
+
code: "no_pidmap_entry",
|
|
1180
|
+
message: "not in an agent session; ppid walk found no pid-map entry",
|
|
1181
|
+
});
|
|
1182
|
+
process.exit(1);
|
|
1183
|
+
}
|
|
1184
|
+
// Canonicalize: absolute paths under coordRoot get the prefix stripped;
|
|
1185
|
+
// relative paths pass through unchanged.
|
|
1186
|
+
let canonical = path;
|
|
1187
|
+
if (path.startsWith(`${root}/`)) canonical = path.slice(root.length + 1);
|
|
1188
|
+
|
|
1189
|
+
const helper = resolve(root, "harnery", "bin", "agent-coord");
|
|
1190
|
+
const result = spawnSync(helper, ["release-claim", myOwner, canonical], {
|
|
1191
|
+
encoding: "utf8",
|
|
1192
|
+
});
|
|
1193
|
+
if (result.status !== 0) {
|
|
1194
|
+
emit.error({
|
|
1195
|
+
code: "release_claim_failed",
|
|
1196
|
+
message: result.stderr.trim() || `agent-coord exited ${result.status}`,
|
|
1197
|
+
});
|
|
1198
|
+
process.exit(1);
|
|
1199
|
+
}
|
|
1200
|
+
process.stdout.write(result.stdout); // lint-ok-emission: raw JSON pass-through from agent-coord release-claim; mirrors runSetTask which writes the same envelope
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function runSetTask(task: string, opts?: { sessionId?: string }): void {
|
|
1204
|
+
const root = monorepoRoot();
|
|
1205
|
+
if (!root) {
|
|
1206
|
+
emit.error({
|
|
1207
|
+
code: "not_in_repo",
|
|
1208
|
+
message: "not in an agent session; coord_root() returned null",
|
|
1209
|
+
});
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
// Identity: prefer explicit --session-id (the ppid-walk-free escape hatch,
|
|
1213
|
+
// mirrors `status`), fall back to the ppid walk. Cursor shell tool calls
|
|
1214
|
+
// don't descend from a pid-map-registered anchor, so the walk can miss there.
|
|
1215
|
+
const myOwner = opts?.sessionId ?? resolveOwner();
|
|
1216
|
+
if (!myOwner) {
|
|
1217
|
+
emit.error({
|
|
1218
|
+
code: "no_pidmap_entry",
|
|
1219
|
+
message:
|
|
1220
|
+
"not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
|
|
1221
|
+
});
|
|
1222
|
+
process.exit(1);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Heartbeat mutation goes through agent-coord (atomic temp+rename).
|
|
1226
|
+
const helper = resolve(root, "harnery", "bin", "agent-coord");
|
|
1227
|
+
const result = spawnSync(helper, ["set-task", myOwner, task], {
|
|
1228
|
+
encoding: "utf8",
|
|
1229
|
+
});
|
|
1230
|
+
if (result.status !== 0) {
|
|
1231
|
+
emit.error({
|
|
1232
|
+
code: "set_task_failed",
|
|
1233
|
+
message: result.stderr.trim() || `agent-coord exited ${result.status}`,
|
|
1234
|
+
});
|
|
1235
|
+
process.exit(1);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const hb = readHeartbeat(myOwner);
|
|
1239
|
+
emitCanonical({
|
|
1240
|
+
type: "state.task_set",
|
|
1241
|
+
owner: myOwner,
|
|
1242
|
+
session: hb?.session_id ?? myOwner,
|
|
1243
|
+
harness: normalizeHarness(hb?.platform),
|
|
1244
|
+
data: { task, cleared: !task || task.length === 0 },
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
emit.data({
|
|
1248
|
+
instance_id: myOwner,
|
|
1249
|
+
name: hb?.name ?? null,
|
|
1250
|
+
task: hb?.task ?? null,
|
|
1251
|
+
cleared: !task || task.length === 0,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function runStatus(opts: { json?: boolean; sessionId?: string }): void {
|
|
1256
|
+
const root = monorepoRoot();
|
|
1257
|
+
if (!root) {
|
|
1258
|
+
emit.error({
|
|
1259
|
+
code: "not_in_repo",
|
|
1260
|
+
message: "not in an agent session; coord_root() returned null",
|
|
1261
|
+
});
|
|
1262
|
+
process.exit(1);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Identity resolution: prefer explicit --session-id (hook-friendly), fall
|
|
1266
|
+
// back to ppid walk for interactive shell usage.
|
|
1267
|
+
const myOwner = opts.sessionId ?? resolveOwner();
|
|
1268
|
+
if (!myOwner) {
|
|
1269
|
+
emit.error({
|
|
1270
|
+
code: "no_pidmap_entry",
|
|
1271
|
+
message:
|
|
1272
|
+
"not in an agent session; ppid walk found no pid-map entry (pass --session-id from a hook payload)",
|
|
1273
|
+
});
|
|
1274
|
+
process.exit(1);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const hb = readHeartbeat(myOwner);
|
|
1278
|
+
if (!hb) {
|
|
1279
|
+
emit.error({
|
|
1280
|
+
code: "no_heartbeat",
|
|
1281
|
+
message: `pid-map resolved owner=${myOwner.slice(0, 8)} but no heartbeat exists`,
|
|
1282
|
+
});
|
|
1283
|
+
process.exit(1);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Stamp .last_status_at = NOW. The verdict path reads state.status_checked
|
|
1287
|
+
// canonical events (emitted below), but the legacy heartbeat field is still
|
|
1288
|
+
// populated for back-compat with consumers reading v1 directly. The stamp
|
|
1289
|
+
// goes through agent-coord (atomic write).
|
|
1290
|
+
try {
|
|
1291
|
+
const helper = resolve(root, "harnery", "bin", "agent-coord");
|
|
1292
|
+
spawnSync(helper, ["stamp-status-call", myOwner], {
|
|
1293
|
+
encoding: "utf8",
|
|
1294
|
+
timeout: 2000,
|
|
1295
|
+
});
|
|
1296
|
+
} catch {
|
|
1297
|
+
/* non-fatal */
|
|
1298
|
+
}
|
|
1299
|
+
emitCanonical({
|
|
1300
|
+
type: "state.status_checked",
|
|
1301
|
+
owner: myOwner,
|
|
1302
|
+
session: hb.session_id ?? myOwner,
|
|
1303
|
+
harness: normalizeHarness(hb.platform),
|
|
1304
|
+
data: {
|
|
1305
|
+
format: opts.json ? "json" : "box",
|
|
1306
|
+
agent_count: 0, // computed below, not yet available here; Phase 5 verdict reads owner-scope only
|
|
1307
|
+
included_self: true,
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
const startedAtMs = Date.parse(hb.started_at);
|
|
1312
|
+
const ageSecs = Number.isFinite(startedAtMs)
|
|
1313
|
+
? Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))
|
|
1314
|
+
: 0;
|
|
1315
|
+
|
|
1316
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
1317
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
1318
|
+
const cutoff = nowSec - FRESHNESS_SECS;
|
|
1319
|
+
const livePeers: Heartbeat[] = [];
|
|
1320
|
+
let peersStale = 0;
|
|
1321
|
+
if (existsSync(activeDir)) {
|
|
1322
|
+
for (const file of readdirSync(activeDir)) {
|
|
1323
|
+
if (!file.endsWith(".json")) continue;
|
|
1324
|
+
try {
|
|
1325
|
+
const raw = readFileSync(resolve(activeDir, file), "utf8");
|
|
1326
|
+
const peer = JSON.parse(raw) as Heartbeat;
|
|
1327
|
+
if (!peer || typeof peer.instance_id !== "string") continue;
|
|
1328
|
+
if (peer.instance_id === myOwner) continue;
|
|
1329
|
+
const ts = Date.parse(peer.last_heartbeat);
|
|
1330
|
+
if (Number.isFinite(ts) && ts / 1000 >= cutoff) {
|
|
1331
|
+
livePeers.push(peer);
|
|
1332
|
+
} else {
|
|
1333
|
+
peersStale++;
|
|
1334
|
+
}
|
|
1335
|
+
} catch {
|
|
1336
|
+
// skip malformed
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Sort: file-holders first (by file count desc), then idle peers by recency.
|
|
1342
|
+
livePeers.sort((a, b) => {
|
|
1343
|
+
const af = a.files_touched?.length ?? 0;
|
|
1344
|
+
const bf = b.files_touched?.length ?? 0;
|
|
1345
|
+
if (af !== bf) return bf - af;
|
|
1346
|
+
return Date.parse(b.last_heartbeat || "") - Date.parse(a.last_heartbeat || "");
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
const filesHeld = hb.files_touched ?? [];
|
|
1350
|
+
const filesStr = formatList(
|
|
1351
|
+
filesHeld.map((p) => basename(p)),
|
|
1352
|
+
4,
|
|
1353
|
+
"0 held",
|
|
1354
|
+
);
|
|
1355
|
+
const peersStr = formatPeers(livePeers, 4, peersStale);
|
|
1356
|
+
|
|
1357
|
+
const ctxUsage = readContextUsage(hb.session_id, hb.platform);
|
|
1358
|
+
let ctxStr: string;
|
|
1359
|
+
if (!ctxUsage) {
|
|
1360
|
+
ctxStr = "unavailable";
|
|
1361
|
+
} else if (ctxUsage.percentOnly) {
|
|
1362
|
+
// Cursor reports percent only; absolutes are estimates against a hard-coded
|
|
1363
|
+
// window. Show the percent + window estimate, marked as estimated.
|
|
1364
|
+
const pct = Math.round((ctxUsage.used / ctxUsage.window) * 100);
|
|
1365
|
+
ctxStr = `~${pct}% (Cursor; ${fmtTokens(ctxUsage.window)} window est.)`;
|
|
1366
|
+
} else {
|
|
1367
|
+
ctxStr = `${fmtTokens(ctxUsage.used)} / ${fmtTokens(ctxUsage.window)} (${Math.round(
|
|
1368
|
+
(ctxUsage.used / ctxUsage.window) * 100,
|
|
1369
|
+
)}%)`;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const timeStr = formatLocalTime(new Date());
|
|
1373
|
+
const displayName = `agent-${hb.name || "unknown"}`;
|
|
1374
|
+
|
|
1375
|
+
// Council pending: list of council IDs where this agent is a member of an
|
|
1376
|
+
// active council in `open` round_status without a contribution to that round.
|
|
1377
|
+
// Best-effort: fails silently if .harnery/councils/ doesn't exist.
|
|
1378
|
+
let pendingCouncils: string[] = [];
|
|
1379
|
+
try {
|
|
1380
|
+
pendingCouncils = pendingCouncilsForMember(displayName);
|
|
1381
|
+
} catch {
|
|
1382
|
+
/* non-fatal: status box should not fail on council errors */
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const data = {
|
|
1386
|
+
name: displayName,
|
|
1387
|
+
instance_id: hb.instance_id,
|
|
1388
|
+
kind: normalizeKind(hb.kind),
|
|
1389
|
+
session_age_secs: ageSecs,
|
|
1390
|
+
files_held: filesHeld,
|
|
1391
|
+
peers_live: livePeers.length,
|
|
1392
|
+
peers_stale: peersStale,
|
|
1393
|
+
peers: livePeers.map((p) => ({
|
|
1394
|
+
name: p.name || "unnamed",
|
|
1395
|
+
files: p.files_touched?.length ?? 0,
|
|
1396
|
+
})),
|
|
1397
|
+
pending_councils: pendingCouncils,
|
|
1398
|
+
context_used: ctxUsage?.used ?? null,
|
|
1399
|
+
context_window: ctxUsage?.window ?? null,
|
|
1400
|
+
timestamp_iso: new Date().toISOString(),
|
|
1401
|
+
timestamp_local: timeStr,
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
if (opts.json) {
|
|
1405
|
+
emit.config({ format: "json" });
|
|
1406
|
+
emit.data(data);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const rows: Array<[string, string]> = [
|
|
1411
|
+
["session", formatAge(ageSecs)],
|
|
1412
|
+
["context", ctxStr],
|
|
1413
|
+
["files", filesStr],
|
|
1414
|
+
["peers", peersStr],
|
|
1415
|
+
["time", timeStr],
|
|
1416
|
+
];
|
|
1417
|
+
// task + turn_summary get full text; formatBox word-wraps to MAX_BOX_CONTENT_WIDTH.
|
|
1418
|
+
if (hb.turn_summary && hb.turn_summary.length > 0) {
|
|
1419
|
+
rows.splice(1, 0, ["last turn", hb.turn_summary]);
|
|
1420
|
+
}
|
|
1421
|
+
if (hb.task && hb.task.length > 0) {
|
|
1422
|
+
rows.splice(1, 0, ["task", hb.task]);
|
|
1423
|
+
}
|
|
1424
|
+
if (pendingCouncils.length > 0) {
|
|
1425
|
+
// Slot the council line right before `time` so it stays in the "what's
|
|
1426
|
+
// active for me" cluster of rows. Show the first ID + count; full list
|
|
1427
|
+
// available via `harn agents council list --mine`.
|
|
1428
|
+
const idx = rows.findIndex((r) => r[0] === "time");
|
|
1429
|
+
const summary =
|
|
1430
|
+
pendingCouncils.length === 1
|
|
1431
|
+
? `1 pending (${pendingCouncils[0]})`
|
|
1432
|
+
: `${pendingCouncils.length} pending (${pendingCouncils[0]}, +${pendingCouncils.length - 1})`;
|
|
1433
|
+
rows.splice(idx, 0, ["council", summary]);
|
|
1434
|
+
}
|
|
1435
|
+
// Box rendering needs predictable stdout regardless of TTY/pipe detection:
|
|
1436
|
+
// agent runs this via Bash (no TTY) and pastes captured stdout into chat.
|
|
1437
|
+
process.stdout.write(`${formatBox(displayName, rows)}\n`); // lint-ok-emission: chat-paste path; emit.text() auto-suppresses non-TTY
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function formatList(items: string[], cap: number, emptyLabel: string): string {
|
|
1441
|
+
if (items.length === 0) return emptyLabel;
|
|
1442
|
+
if (items.length <= cap) return items.join(", ");
|
|
1443
|
+
const shown = items.slice(0, cap).join(", ");
|
|
1444
|
+
return `${shown}, +${items.length - cap} more`;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function formatPeers(peers: Heartbeat[], cap: number, staleCount: number): string {
|
|
1448
|
+
if (peers.length === 0 && staleCount === 0) return "none";
|
|
1449
|
+
const labels = peers.map((p) => {
|
|
1450
|
+
const name = p.name || "unnamed";
|
|
1451
|
+
const plat = formatPlatformLabel(p.platform);
|
|
1452
|
+
const files = p.files_touched?.length ?? 0;
|
|
1453
|
+
const base = `${name} (${plat})`;
|
|
1454
|
+
return files > 0 ? `${base}, ${files} files` : base;
|
|
1455
|
+
});
|
|
1456
|
+
let main: string;
|
|
1457
|
+
if (labels.length === 0) {
|
|
1458
|
+
main = "0 live";
|
|
1459
|
+
} else if (labels.length <= cap) {
|
|
1460
|
+
main = labels.join(", ");
|
|
1461
|
+
} else {
|
|
1462
|
+
main = `${labels.slice(0, cap).join(", ")}, +${labels.length - cap} more`;
|
|
1463
|
+
}
|
|
1464
|
+
return staleCount > 0 ? `${main}; ${staleCount} stale` : main;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function fmtTokens(n: number): string {
|
|
1468
|
+
if (n < 1000) return `${n}`;
|
|
1469
|
+
if (n < 1000000) return `${Math.round(n / 1000)}K`;
|
|
1470
|
+
const m = n / 1000000;
|
|
1471
|
+
return Number.isInteger(m) ? `${m}M` : `${m.toFixed(1)}M`;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function readContextUsage(
|
|
1475
|
+
sessionId: string,
|
|
1476
|
+
platform?: string | null,
|
|
1477
|
+
): { used: number; window: number; percentOnly?: boolean } | null {
|
|
1478
|
+
if (!sessionId) return null;
|
|
1479
|
+
// Dispatch by platform: Codex's JSONL shape is different from Claude Code's
|
|
1480
|
+
// (event_msg/response_item vs user/assistant), and Codex transcripts live
|
|
1481
|
+
// under ~/.codex/sessions/YYYY/MM/DD/ rather than ~/.claude/projects/.
|
|
1482
|
+
// Cursor doesn't surface token counts in its transcript JSONL but DOES
|
|
1483
|
+
// store `contextUsagePercent` per composer in workspaceStorage's state.vscdb;
|
|
1484
|
+
// readCursorContextUsage reads that via bun:sqlite (percent-only).
|
|
1485
|
+
if (platform === "codex") return readCodexContextUsage(sessionId);
|
|
1486
|
+
if (platform === "cursor") return readCursorContextUsage(sessionId);
|
|
1487
|
+
return readClaudeContextUsage(sessionId);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function readCursorContextUsage(
|
|
1491
|
+
sessionId: string,
|
|
1492
|
+
): { used: number; window: number; percentOnly: true } | null {
|
|
1493
|
+
// Cursor stores per-chat (composer) context usage as a percent in each
|
|
1494
|
+
// workspace's state.vscdb at ItemTable.composer.composerData. The value
|
|
1495
|
+
// is a JSON blob with .allComposers[].contextUsagePercent. We find the
|
|
1496
|
+
// composer matching the agent's session_id (Cursor uses session_id == composerId
|
|
1497
|
+
// for parent chats).
|
|
1498
|
+
//
|
|
1499
|
+
// Roots searched: ~/.config/Cursor/User/workspaceStorage (Linux native install)
|
|
1500
|
+
// and /mnt/c/Users/*/AppData/Roaming/Cursor/User/workspaceStorage (WSL).
|
|
1501
|
+
// We don't filter by workspace path; we scan every workspace's state.vscdb
|
|
1502
|
+
// looking for a composerId match. Cheap enough at status time (~60K per file,
|
|
1503
|
+
// <10ms typical). When a match is found, returns synthetic { used, window }
|
|
1504
|
+
// pair where the percent is what's real; absolutes are derived as
|
|
1505
|
+
// (window * percent / 100). Window hard-coded to a Cursor-typical 200K.
|
|
1506
|
+
const roots: string[] = [];
|
|
1507
|
+
const linuxRoot = resolve(homedir(), ".config", "Cursor", "User", "workspaceStorage");
|
|
1508
|
+
if (existsSync(linuxRoot)) roots.push(linuxRoot);
|
|
1509
|
+
try {
|
|
1510
|
+
if (existsSync("/mnt/c/Users")) {
|
|
1511
|
+
for (const u of readdirSync("/mnt/c/Users")) {
|
|
1512
|
+
const p = `/mnt/c/Users/${u}/AppData/Roaming/Cursor/User/workspaceStorage`;
|
|
1513
|
+
if (existsSync(p)) roots.push(p);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
} catch {
|
|
1517
|
+
// ignore
|
|
1518
|
+
}
|
|
1519
|
+
for (const root of roots) {
|
|
1520
|
+
let workspaces: string[];
|
|
1521
|
+
try {
|
|
1522
|
+
workspaces = readdirSync(root);
|
|
1523
|
+
} catch {
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
for (const ws of workspaces) {
|
|
1527
|
+
const dbPath = `${root}/${ws}/state.vscdb`;
|
|
1528
|
+
if (!existsSync(dbPath)) continue;
|
|
1529
|
+
try {
|
|
1530
|
+
// Lazy-import bun:sqlite so non-Bun runtimes (if any) don't choke at load.
|
|
1531
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1532
|
+
const { Database } = require("bun:sqlite");
|
|
1533
|
+
const db = new Database(dbPath, { readonly: true });
|
|
1534
|
+
const row = db
|
|
1535
|
+
.query("SELECT value FROM ItemTable WHERE key = 'composer.composerData'")
|
|
1536
|
+
.get() as { value: string | Uint8Array } | null;
|
|
1537
|
+
db.close();
|
|
1538
|
+
if (!row?.value) continue;
|
|
1539
|
+
const text =
|
|
1540
|
+
typeof row.value === "string" ? row.value : Buffer.from(row.value).toString("utf8");
|
|
1541
|
+
const parsed = JSON.parse(text);
|
|
1542
|
+
const composers = Array.isArray(parsed?.allComposers) ? parsed.allComposers : [];
|
|
1543
|
+
for (const c of composers) {
|
|
1544
|
+
if (c?.composerId === sessionId && typeof c?.contextUsagePercent === "number") {
|
|
1545
|
+
const window = 200000; // hard-coded Cursor-typical window; refine when Cursor exposes the actual ceiling
|
|
1546
|
+
const used = Math.round((window * c.contextUsagePercent) / 100);
|
|
1547
|
+
return { used, window, percentOnly: true };
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
} catch {
|
|
1551
|
+
// ignore: DB locked, schema drift, etc. Move on to the next workspace.
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
return null;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function readClaudeContextUsage(sessionId: string): { used: number; window: number } | null {
|
|
1559
|
+
const root = monorepoRoot();
|
|
1560
|
+
if (!root) return null;
|
|
1561
|
+
// Claude Code's project dir scheme: prepend "-", replace "/" → "-".
|
|
1562
|
+
const encoded = `-${root.replace(/^\//, "").replace(/\//g, "-")}`;
|
|
1563
|
+
const transcriptPath = resolve(homedir(), ".claude", "projects", encoded, `${sessionId}.jsonl`);
|
|
1564
|
+
if (!existsSync(transcriptPath)) return null;
|
|
1565
|
+
let raw: string;
|
|
1566
|
+
try {
|
|
1567
|
+
raw = readFileSync(transcriptPath, "utf8");
|
|
1568
|
+
} catch {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
const lines = raw.split("\n");
|
|
1572
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1573
|
+
const line = lines[i].trim();
|
|
1574
|
+
if (!line) continue;
|
|
1575
|
+
try {
|
|
1576
|
+
const entry = JSON.parse(line);
|
|
1577
|
+
const usage = entry?.message?.usage;
|
|
1578
|
+
if (entry?.type === "assistant" && usage) {
|
|
1579
|
+
const used =
|
|
1580
|
+
(usage.input_tokens ?? 0) +
|
|
1581
|
+
(usage.cache_creation_input_tokens ?? 0) +
|
|
1582
|
+
(usage.cache_read_input_tokens ?? 0);
|
|
1583
|
+
// Window: hardcode 1M for Opus 4.7 1M (current default). Refine to
|
|
1584
|
+
// model-aware lookup when other models become routine.
|
|
1585
|
+
return { used, window: 1000000 };
|
|
1586
|
+
}
|
|
1587
|
+
} catch {
|
|
1588
|
+
// skip malformed line
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function readCodexContextUsage(sessionId: string): { used: number; window: number } | null {
|
|
1595
|
+
// Codex transcripts: ~/.codex/sessions/YYYY/MM/DD/rollout-<TS>-<sessionId>.jsonl
|
|
1596
|
+
// On WSL the active install often lives on the Windows side under
|
|
1597
|
+
// /mnt/c/Users/<user>/.codex/sessions/; both are searched.
|
|
1598
|
+
const path = findCodexTranscript(sessionId);
|
|
1599
|
+
if (!path) return null;
|
|
1600
|
+
let raw: string;
|
|
1601
|
+
try {
|
|
1602
|
+
raw = readFileSync(path, "utf8");
|
|
1603
|
+
} catch {
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
const lines = raw.split("\n");
|
|
1607
|
+
// Walk backwards: most recent token_count event wins.
|
|
1608
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1609
|
+
const line = lines[i].trim();
|
|
1610
|
+
if (!line) continue;
|
|
1611
|
+
try {
|
|
1612
|
+
const entry = JSON.parse(line);
|
|
1613
|
+
if (entry?.type === "event_msg" && entry?.payload?.type === "token_count") {
|
|
1614
|
+
const info = entry.payload.info;
|
|
1615
|
+
const used = info?.last_token_usage?.input_tokens;
|
|
1616
|
+
const window = info?.model_context_window;
|
|
1617
|
+
if (typeof used === "number" && typeof window === "number" && window > 0) {
|
|
1618
|
+
return { used, window };
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
} catch {
|
|
1622
|
+
// skip malformed line
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function findCodexTranscript(sessionId: string): string | null {
|
|
1629
|
+
// Candidate roots in priority order.
|
|
1630
|
+
const homeRoot = resolve(homedir(), ".codex", "sessions");
|
|
1631
|
+
const wslRoots: string[] = [];
|
|
1632
|
+
try {
|
|
1633
|
+
if (existsSync("/mnt/c/Users")) {
|
|
1634
|
+
for (const entry of readdirSync("/mnt/c/Users")) {
|
|
1635
|
+
wslRoots.push(`/mnt/c/Users/${entry}/.codex/sessions`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
} catch {
|
|
1639
|
+
// ignore
|
|
1640
|
+
}
|
|
1641
|
+
const roots = [homeRoot, ...wslRoots];
|
|
1642
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
1643
|
+
for (const root of roots) {
|
|
1644
|
+
if (!existsSync(root)) continue;
|
|
1645
|
+
try {
|
|
1646
|
+
// Recursive scan: sessions are partitioned by YYYY/MM/DD/, so depth
|
|
1647
|
+
// is bounded at 3 + one file per session. Cheap enough at status time.
|
|
1648
|
+
const stack: string[] = [root];
|
|
1649
|
+
while (stack.length) {
|
|
1650
|
+
const dir = stack.pop()!;
|
|
1651
|
+
let entries: string[];
|
|
1652
|
+
try {
|
|
1653
|
+
entries = readdirSync(dir);
|
|
1654
|
+
} catch {
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
for (const name of entries) {
|
|
1658
|
+
const full = `${dir}/${name}`;
|
|
1659
|
+
try {
|
|
1660
|
+
const stat = statSync(full);
|
|
1661
|
+
if (stat.isDirectory()) {
|
|
1662
|
+
stack.push(full);
|
|
1663
|
+
} else if (name.endsWith(suffix)) {
|
|
1664
|
+
return full;
|
|
1665
|
+
}
|
|
1666
|
+
} catch {
|
|
1667
|
+
// ignore
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
} catch {
|
|
1672
|
+
// ignore root scan failures
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return null;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function formatLocalTime(d: Date): string {
|
|
1679
|
+
// "Sat, May 9, 2026, 3:48 AM CDT", rendered in the Chicago timezone.
|
|
1680
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
1681
|
+
weekday: "short",
|
|
1682
|
+
year: "numeric",
|
|
1683
|
+
month: "short",
|
|
1684
|
+
day: "numeric",
|
|
1685
|
+
hour: "numeric",
|
|
1686
|
+
minute: "2-digit",
|
|
1687
|
+
timeZoneName: "short",
|
|
1688
|
+
hour12: true,
|
|
1689
|
+
timeZone: "America/Chicago",
|
|
1690
|
+
}).format(d);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function formatAge(secs: number): string {
|
|
1694
|
+
if (secs < 60) return `${secs}s`;
|
|
1695
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
|
1696
|
+
if (secs < 86400) {
|
|
1697
|
+
const h = Math.floor(secs / 3600);
|
|
1698
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
1699
|
+
return `${h}h ${m}m`;
|
|
1700
|
+
}
|
|
1701
|
+
const d = Math.floor(secs / 86400);
|
|
1702
|
+
const h = Math.floor((secs % 86400) / 3600);
|
|
1703
|
+
return `${d}d ${h}h`;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
interface HealEvent {
|
|
1707
|
+
ts: string;
|
|
1708
|
+
agent: string;
|
|
1709
|
+
kind: "pidmap" | "heartbeat";
|
|
1710
|
+
pid?: string;
|
|
1711
|
+
reason: "missing" | "stale";
|
|
1712
|
+
prior?: string;
|
|
1713
|
+
platform: string;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* One canonical event envelope from `.harnery/events.ndjson` (loose shape: we
|
|
1718
|
+
* only read the fields the health/heal aggregators need).
|
|
1719
|
+
*/
|
|
1720
|
+
interface CanonicalEvent {
|
|
1721
|
+
event_type: string;
|
|
1722
|
+
ts: string;
|
|
1723
|
+
instance_id?: string;
|
|
1724
|
+
harness?: string;
|
|
1725
|
+
data?: Record<string, unknown>;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* Read canonical events in a time window. The heal + council telemetry the
|
|
1730
|
+
* health/heal commands report lives here. Full-file read + ts filter is fine
|
|
1731
|
+
* for an on-demand diagnostic; events.ndjson is the canonical store.
|
|
1732
|
+
*/
|
|
1733
|
+
function readCanonicalEventsInWindow(root: string, cutoffMs: number): CanonicalEvent[] {
|
|
1734
|
+
const p = resolve(root, ".harnery", "events.ndjson");
|
|
1735
|
+
if (!existsSync(p)) return [];
|
|
1736
|
+
let raw: string;
|
|
1737
|
+
try {
|
|
1738
|
+
raw = readFileSync(p, "utf8");
|
|
1739
|
+
} catch {
|
|
1740
|
+
return [];
|
|
1741
|
+
}
|
|
1742
|
+
const out: CanonicalEvent[] = [];
|
|
1743
|
+
for (const line of raw.split("\n")) {
|
|
1744
|
+
if (!line) continue;
|
|
1745
|
+
try {
|
|
1746
|
+
const ev = JSON.parse(line) as CanonicalEvent;
|
|
1747
|
+
if (!ev.event_type || !ev.ts) continue;
|
|
1748
|
+
const tsMs = Date.parse(ev.ts);
|
|
1749
|
+
if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
|
|
1750
|
+
out.push(ev);
|
|
1751
|
+
} catch {
|
|
1752
|
+
/* skip malformed */
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
return out;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/** harness ("claude-code") → legacy platform label ("claude_code") so the
|
|
1759
|
+
* existing formatPlatformLabel rendering keeps working unchanged. */
|
|
1760
|
+
function harnessToPlatform(harness: string | undefined): string {
|
|
1761
|
+
if (harness === "claude-code") return "claude_code";
|
|
1762
|
+
if (harness === "cursor") return "cursor";
|
|
1763
|
+
if (harness === "codex") return "codex";
|
|
1764
|
+
return "claude_code";
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/** Project a canonical health.* event into the HealEvent shape the aggregators
|
|
1768
|
+
* already consume. Returns null for non-heal events. instance_id → display name
|
|
1769
|
+
* via `nameById` (full-UUID keyed). */
|
|
1770
|
+
function canonicalToHealEvent(ev: CanonicalEvent, nameById: Map<string, string>): HealEvent | null {
|
|
1771
|
+
if (ev.event_type !== "health.pidmap_heal" && ev.event_type !== "health.heartbeat_heal") {
|
|
1772
|
+
return null;
|
|
1773
|
+
}
|
|
1774
|
+
const kind = ev.event_type === "health.pidmap_heal" ? "pidmap" : "heartbeat";
|
|
1775
|
+
const data = ev.data ?? {};
|
|
1776
|
+
const reason: "missing" | "stale" = data.reason === "stale" ? "stale" : "missing";
|
|
1777
|
+
const instanceId = ev.instance_id ?? "";
|
|
1778
|
+
const name = nameById.get(instanceId);
|
|
1779
|
+
const agent = name ? `agent-${name}` : `agent-${instanceId.slice(0, 8) || "unknown"}`;
|
|
1780
|
+
const out: HealEvent = {
|
|
1781
|
+
ts: ev.ts,
|
|
1782
|
+
agent,
|
|
1783
|
+
kind,
|
|
1784
|
+
reason,
|
|
1785
|
+
platform: harnessToPlatform(ev.harness),
|
|
1786
|
+
};
|
|
1787
|
+
if (kind === "pidmap" && data.pid !== undefined && data.pid !== null) {
|
|
1788
|
+
out.pid = String(data.pid);
|
|
1789
|
+
}
|
|
1790
|
+
if (typeof data.prior === "string") out.prior = data.prior;
|
|
1791
|
+
return out;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/** Build a full-instance_id → display-name map from .name-history (one JSON
|
|
1795
|
+
* object per line). Used to label canonical heal events. */
|
|
1796
|
+
function buildNameById(root: string): Map<string, string> {
|
|
1797
|
+
const nameById = new Map<string, string>();
|
|
1798
|
+
const nameHistoryPath = resolve(root, ".harnery/.name-history");
|
|
1799
|
+
if (!existsSync(nameHistoryPath)) return nameById;
|
|
1800
|
+
for (const line of readFileSync(nameHistoryPath, "utf8").split("\n")) {
|
|
1801
|
+
if (!line.trim()) continue;
|
|
1802
|
+
try {
|
|
1803
|
+
const entry = JSON.parse(line) as { instance_id?: string; name?: string };
|
|
1804
|
+
if (entry.instance_id && entry.name) nameById.set(entry.instance_id, entry.name);
|
|
1805
|
+
} catch {
|
|
1806
|
+
/* skip */
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return nameById;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// Parse Nh|Nd window into seconds. Returns null on malformed input.
|
|
1813
|
+
function parseWindowSecs(window: string): number | null {
|
|
1814
|
+
const match = window.match(/^(\d+)([hd])$/);
|
|
1815
|
+
if (!match) return null;
|
|
1816
|
+
const n = Number.parseInt(match[1], 10);
|
|
1817
|
+
const unit = match[2];
|
|
1818
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
1819
|
+
return unit === "h" ? n * 3600 : n * 86400;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function runHealEvents(opts: {
|
|
1823
|
+
since: string;
|
|
1824
|
+
limit: string;
|
|
1825
|
+
json?: boolean;
|
|
1826
|
+
csv?: boolean;
|
|
1827
|
+
}): void {
|
|
1828
|
+
if (opts.json) emit.config({ format: "json" });
|
|
1829
|
+
|
|
1830
|
+
const root = monorepoRoot();
|
|
1831
|
+
if (!root) {
|
|
1832
|
+
emit.error({
|
|
1833
|
+
code: "not_in_repo",
|
|
1834
|
+
message: "not in an agent session; coord_root() returned null",
|
|
1835
|
+
});
|
|
1836
|
+
process.exit(1);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const sinceSecs = parseWindowSecs(opts.since);
|
|
1840
|
+
if (sinceSecs === null) {
|
|
1841
|
+
emit.error({
|
|
1842
|
+
code: "bad_since",
|
|
1843
|
+
message: `invalid --since value '${opts.since}': expected Nh or Nd (e.g. 24h, 7d)`,
|
|
1844
|
+
});
|
|
1845
|
+
process.exit(1);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const limit = Number.parseInt(opts.limit, 10);
|
|
1849
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
1850
|
+
emit.error({
|
|
1851
|
+
code: "bad_limit",
|
|
1852
|
+
message: `invalid --limit value '${opts.limit}': expected positive integer`,
|
|
1853
|
+
});
|
|
1854
|
+
process.exit(1);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Heal telemetry lives in the canonical .harnery/events.ndjson stream
|
|
1858
|
+
// (health.pidmap_heal / health.heartbeat_heal), emitted by the writer on
|
|
1859
|
+
// actual self-heal writes.
|
|
1860
|
+
const cutoffMs = Date.now() - sinceSecs * 1000;
|
|
1861
|
+
const nameById = buildNameById(root);
|
|
1862
|
+
const events: HealEvent[] = [];
|
|
1863
|
+
for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
|
|
1864
|
+
const heal = canonicalToHealEvent(ev, nameById);
|
|
1865
|
+
if (heal) events.push(heal);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Aggregate.
|
|
1869
|
+
const byReason: Record<string, number> = { missing: 0, stale: 0 };
|
|
1870
|
+
const byKind: Record<string, number> = { pidmap: 0, heartbeat: 0 };
|
|
1871
|
+
const byPlatform: Record<string, number> = {};
|
|
1872
|
+
const byAgent = new Map<string, number>();
|
|
1873
|
+
const buckets: Record<string, number> = {
|
|
1874
|
+
last_1h: 0,
|
|
1875
|
+
last_24h: 0,
|
|
1876
|
+
last_7d: 0,
|
|
1877
|
+
};
|
|
1878
|
+
const nowMs = Date.now();
|
|
1879
|
+
for (const ev of events) {
|
|
1880
|
+
byReason[ev.reason] = (byReason[ev.reason] ?? 0) + 1;
|
|
1881
|
+
byKind[ev.kind] = (byKind[ev.kind] ?? 0) + 1;
|
|
1882
|
+
byPlatform[ev.platform] = (byPlatform[ev.platform] ?? 0) + 1;
|
|
1883
|
+
byAgent.set(ev.agent, (byAgent.get(ev.agent) ?? 0) + 1);
|
|
1884
|
+
const ageMs = nowMs - Date.parse(ev.ts);
|
|
1885
|
+
if (ageMs <= 3600 * 1000) buckets.last_1h++;
|
|
1886
|
+
if (ageMs <= 24 * 3600 * 1000) buckets.last_24h++;
|
|
1887
|
+
if (ageMs <= 7 * 86400 * 1000) buckets.last_7d++;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const byAgentSorted = Array.from(byAgent.entries())
|
|
1891
|
+
.sort((a, b) => b[1] - a[1])
|
|
1892
|
+
.map(([agent, count]) => ({ agent, count }));
|
|
1893
|
+
|
|
1894
|
+
// Most-recent first for the events table.
|
|
1895
|
+
events.sort((a, b) => b.ts.localeCompare(a.ts));
|
|
1896
|
+
const recent = events.slice(0, limit);
|
|
1897
|
+
|
|
1898
|
+
const data = {
|
|
1899
|
+
since: opts.since,
|
|
1900
|
+
total: events.length,
|
|
1901
|
+
by_reason: byReason,
|
|
1902
|
+
by_kind: byKind,
|
|
1903
|
+
by_platform: byPlatform,
|
|
1904
|
+
by_agent: byAgentSorted,
|
|
1905
|
+
by_time_bucket: buckets,
|
|
1906
|
+
events: recent,
|
|
1907
|
+
};
|
|
1908
|
+
|
|
1909
|
+
if (opts.csv) {
|
|
1910
|
+
emit.config({ format: "csv" });
|
|
1911
|
+
emit.data(recent);
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
if (opts.json) {
|
|
1915
|
+
emit.data(data);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
emit.data(data);
|
|
1919
|
+
|
|
1920
|
+
// TTY rendering: table-ish summary + recent events.
|
|
1921
|
+
if (process.stdout.isTTY) {
|
|
1922
|
+
const lines: string[] = [];
|
|
1923
|
+
lines.push(
|
|
1924
|
+
`Heal events: ${events.length} total in last ${opts.since} (health.pidmap_heal + health.heartbeat_heal)`,
|
|
1925
|
+
);
|
|
1926
|
+
lines.push("");
|
|
1927
|
+
if (events.length === 0) {
|
|
1928
|
+
lines.push(" (none; pid-map/heartbeat drift is not happening in this window)");
|
|
1929
|
+
emit.text(`${lines.join("\n")}\n`);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
lines.push("By kind:");
|
|
1933
|
+
for (const kind of ["pidmap", "heartbeat"] as const) {
|
|
1934
|
+
const count = byKind[kind] ?? 0;
|
|
1935
|
+
if (count > 0) lines.push(` ${kind.padEnd(10)} ${count}`);
|
|
1936
|
+
}
|
|
1937
|
+
lines.push("");
|
|
1938
|
+
lines.push("By platform:");
|
|
1939
|
+
for (const [platform, count] of Object.entries(byPlatform).sort((a, b) => b[1] - a[1])) {
|
|
1940
|
+
lines.push(` ${formatPlatformLabel(platform).padEnd(10)} ${count}`);
|
|
1941
|
+
}
|
|
1942
|
+
lines.push("");
|
|
1943
|
+
lines.push("By reason:");
|
|
1944
|
+
for (const reason of ["missing", "stale"] as const) {
|
|
1945
|
+
const count = byReason[reason] ?? 0;
|
|
1946
|
+
if (count > 0) lines.push(` ${reason.padEnd(8)} ${count}`);
|
|
1947
|
+
}
|
|
1948
|
+
lines.push("");
|
|
1949
|
+
lines.push("By agent:");
|
|
1950
|
+
for (const { agent, count } of byAgentSorted.slice(0, 10)) {
|
|
1951
|
+
lines.push(` ${agent.padEnd(20)} ${count}`);
|
|
1952
|
+
}
|
|
1953
|
+
if (byAgentSorted.length > 10) {
|
|
1954
|
+
lines.push(` +${byAgentSorted.length - 10} more`);
|
|
1955
|
+
}
|
|
1956
|
+
lines.push("");
|
|
1957
|
+
lines.push("By time bucket:");
|
|
1958
|
+
lines.push(` last 1h ${buckets.last_1h}`);
|
|
1959
|
+
lines.push(` last 24h ${buckets.last_24h}`);
|
|
1960
|
+
lines.push(` last 7d ${buckets.last_7d}`);
|
|
1961
|
+
lines.push("");
|
|
1962
|
+
lines.push(`Recent (most recent first, capped at ${limit}):`);
|
|
1963
|
+
for (const ev of recent) {
|
|
1964
|
+
const reasonLabel = ev.prior ? `${ev.reason} prior=${ev.prior}` : ev.reason;
|
|
1965
|
+
const pidPart = ev.pid ? ` pid=${ev.pid.padEnd(7)}` : " ".repeat(12);
|
|
1966
|
+
lines.push(
|
|
1967
|
+
` ${ev.ts} ${ev.kind.padEnd(9)} ${formatPlatformLabel(ev.platform).padEnd(6)} ${ev.agent.padEnd(20)}${pidPart} ${reasonLabel}`,
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
emit.text(`${lines.join("\n")}\n`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
interface HealthReport {
|
|
1975
|
+
since: string;
|
|
1976
|
+
generated_at: string;
|
|
1977
|
+
active_agents: {
|
|
1978
|
+
total: number;
|
|
1979
|
+
by_platform: Record<string, number>;
|
|
1980
|
+
by_kind: Record<string, number>;
|
|
1981
|
+
by_schema_version: Record<string, number>;
|
|
1982
|
+
stale: number;
|
|
1983
|
+
};
|
|
1984
|
+
heal_events: {
|
|
1985
|
+
total: number;
|
|
1986
|
+
by_kind: { pidmap: number; heartbeat: number };
|
|
1987
|
+
by_reason: { missing: number; stale: number };
|
|
1988
|
+
by_platform: Record<string, number>;
|
|
1989
|
+
top_agents: Array<{ agent: string; count: number }>;
|
|
1990
|
+
};
|
|
1991
|
+
schema_invalid: { count: number; samples: string[] };
|
|
1992
|
+
commit_guards: {
|
|
1993
|
+
blocked: number;
|
|
1994
|
+
bypassed: number;
|
|
1995
|
+
suppressed: number;
|
|
1996
|
+
edit_blocked: number;
|
|
1997
|
+
shell_candidates: number;
|
|
1998
|
+
};
|
|
1999
|
+
councils: {
|
|
2000
|
+
active: number;
|
|
2001
|
+
archived_in_window: number;
|
|
2002
|
+
advanced_in_window: number;
|
|
2003
|
+
closed_in_window: number;
|
|
2004
|
+
};
|
|
2005
|
+
// Heartbeats removed by stale-sweep in the window (health.heartbeat_swept).
|
|
2006
|
+
swept_events: {
|
|
2007
|
+
total: number;
|
|
2008
|
+
by_reason: Record<string, number>;
|
|
2009
|
+
};
|
|
2010
|
+
// agent-hook failures in the window, from .harnery/debug/agent-hook.errors.ndjson,
|
|
2011
|
+
// grouped by `phase`. A dominant phase is the fastest pointer to a systemic hook
|
|
2012
|
+
// bug (e.g. a stop-projection crash that caused ~200 errors/day until it was fixed).
|
|
2013
|
+
hook_errors: {
|
|
2014
|
+
total: number;
|
|
2015
|
+
by_phase: Record<string, number>;
|
|
2016
|
+
top: Array<{ phase: string; count: number; sample: string }>;
|
|
2017
|
+
};
|
|
2018
|
+
// Canonical event stream growth + drain lag.
|
|
2019
|
+
stream: {
|
|
2020
|
+
bytes: number;
|
|
2021
|
+
lines: number;
|
|
2022
|
+
cursor_backlog: number;
|
|
2023
|
+
};
|
|
2024
|
+
// Heartbeats present in active/ but broken: no name, unparseable, or an
|
|
2025
|
+
// absurd (epoch-ish) last_heartbeat. These are the `agent-unknown` peer-table
|
|
2026
|
+
// ghosts; a positive count means dead files the sweep isn't catching.
|
|
2027
|
+
zombies: {
|
|
2028
|
+
count: number;
|
|
2029
|
+
samples: string[];
|
|
2030
|
+
};
|
|
2031
|
+
anomalies: string[];
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** Tally agent-hook failures (.harnery/debug/agent-hook.errors.ndjson) in the
|
|
2035
|
+
* window, grouped by `phase`. Each line is {ts, error, phase, ...}. A dominant
|
|
2036
|
+
* phase points straight at a systemic hook bug. */
|
|
2037
|
+
function readHookErrors(
|
|
2038
|
+
root: string,
|
|
2039
|
+
cutoffMs: number,
|
|
2040
|
+
): {
|
|
2041
|
+
total: number;
|
|
2042
|
+
byPhase: Record<string, number>;
|
|
2043
|
+
top: Array<{ phase: string; count: number; sample: string }>;
|
|
2044
|
+
} {
|
|
2045
|
+
const p = resolve(root, ".harnery", "debug", "agent-hook.errors.ndjson");
|
|
2046
|
+
const byPhase: Record<string, number> = {};
|
|
2047
|
+
const sampleByPhase: Record<string, string> = {};
|
|
2048
|
+
let total = 0;
|
|
2049
|
+
if (!existsSync(p)) return { total: 0, byPhase, top: [] };
|
|
2050
|
+
let raw: string;
|
|
2051
|
+
try {
|
|
2052
|
+
raw = readFileSync(p, "utf8");
|
|
2053
|
+
} catch {
|
|
2054
|
+
return { total: 0, byPhase, top: [] };
|
|
2055
|
+
}
|
|
2056
|
+
for (const line of raw.split("\n")) {
|
|
2057
|
+
if (!line.trim()) continue;
|
|
2058
|
+
try {
|
|
2059
|
+
const e = JSON.parse(line) as { ts?: string; phase?: string; error?: string };
|
|
2060
|
+
const tsMs = e.ts ? Date.parse(e.ts) : Number.NaN;
|
|
2061
|
+
if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
|
|
2062
|
+
const phase = e.phase ?? "(unknown)";
|
|
2063
|
+
byPhase[phase] = (byPhase[phase] ?? 0) + 1;
|
|
2064
|
+
if (!sampleByPhase[phase] && e.error) sampleByPhase[phase] = e.error;
|
|
2065
|
+
total++;
|
|
2066
|
+
} catch {
|
|
2067
|
+
/* skip malformed */
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
const top = Object.entries(byPhase)
|
|
2071
|
+
.sort((a, b) => b[1] - a[1])
|
|
2072
|
+
.slice(0, 5)
|
|
2073
|
+
.map(([phase, count]) => ({ phase, count, sample: sampleByPhase[phase] ?? "" }));
|
|
2074
|
+
return { total, byPhase, top };
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/** Canonical event stream size + drain lag (events appended after the cursor). */
|
|
2078
|
+
function readStreamStats(root: string): { bytes: number; lines: number; cursor_backlog: number } {
|
|
2079
|
+
const streamPath = resolve(root, ".harnery", "events.ndjson");
|
|
2080
|
+
if (!existsSync(streamPath)) return { bytes: 0, lines: 0, cursor_backlog: 0 };
|
|
2081
|
+
let bytes = 0;
|
|
2082
|
+
try {
|
|
2083
|
+
bytes = statSync(streamPath).size;
|
|
2084
|
+
} catch {
|
|
2085
|
+
/* ignore */
|
|
2086
|
+
}
|
|
2087
|
+
let cursor: string | null = null;
|
|
2088
|
+
const cursorPath = resolve(root, ".harnery", ".events-cursor");
|
|
2089
|
+
if (existsSync(cursorPath)) {
|
|
2090
|
+
try {
|
|
2091
|
+
cursor = readFileSync(cursorPath, "utf8").trim() || null;
|
|
2092
|
+
} catch {
|
|
2093
|
+
/* ignore */
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
let lines = 0;
|
|
2097
|
+
let backlog = 0;
|
|
2098
|
+
let seenCursor = cursor === null; // no cursor → everything is "backlog"
|
|
2099
|
+
try {
|
|
2100
|
+
const raw = readFileSync(streamPath, "utf8");
|
|
2101
|
+
for (const line of raw.split("\n")) {
|
|
2102
|
+
if (!line.trim()) continue;
|
|
2103
|
+
lines++;
|
|
2104
|
+
if (seenCursor) {
|
|
2105
|
+
backlog++;
|
|
2106
|
+
} else if (cursor && line.includes(`"event_id":"${cursor}"`)) {
|
|
2107
|
+
seenCursor = true;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
} catch {
|
|
2111
|
+
/* ignore */
|
|
2112
|
+
}
|
|
2113
|
+
return { bytes, lines, cursor_backlog: backlog };
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/** One rendered line in a trace. */
|
|
2117
|
+
interface TraceEntry {
|
|
2118
|
+
ts: string;
|
|
2119
|
+
event_type: string;
|
|
2120
|
+
detail: string;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/** Map a canonical event to a concise trace line, or null to drop it. */
|
|
2124
|
+
function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
|
|
2125
|
+
const d = (ev.data ?? {}) as Record<string, unknown>;
|
|
2126
|
+
const s = (k: string): string => (typeof d[k] === "string" ? (d[k] as string) : "");
|
|
2127
|
+
const clip = (v: string, n = 70): string => (v.length <= n ? v : `${v.slice(0, n - 1)}…`);
|
|
2128
|
+
let detail = "";
|
|
2129
|
+
switch (ev.event_type) {
|
|
2130
|
+
case "session.start":
|
|
2131
|
+
detail = `${s("source") || "startup"}${s("model") ? ` · model=${s("model")}` : ""}${s("name") ? ` · ${s("name")}` : ""}`;
|
|
2132
|
+
break;
|
|
2133
|
+
case "session.end":
|
|
2134
|
+
detail = `clean_exit=${d.clean_exit ?? "?"}`;
|
|
2135
|
+
break;
|
|
2136
|
+
case "subagent.start":
|
|
2137
|
+
detail = `${s("agent_type") || "subagent"}${s("name") ? ` · ${s("name")}` : ""}`;
|
|
2138
|
+
break;
|
|
2139
|
+
case "subagent.stop":
|
|
2140
|
+
detail = `clean_exit=${d.clean_exit ?? "?"}`;
|
|
2141
|
+
break;
|
|
2142
|
+
case "user_prompt.submit":
|
|
2143
|
+
detail = clip(s("prompt_text") || s("prompt"));
|
|
2144
|
+
break;
|
|
2145
|
+
case "turn.stop":
|
|
2146
|
+
detail = `status_box=${d.status_box_present ?? "?"}${s("turn_summary") ? ` · ${clip(s("turn_summary"), 50)}` : ""}`;
|
|
2147
|
+
break;
|
|
2148
|
+
case "tool.pre_use":
|
|
2149
|
+
detail = `${s("tool_name")}${s("tool_target") || s("intent") ? ` · ${clip(s("tool_target") || s("intent"), 60)}` : ""}`;
|
|
2150
|
+
break;
|
|
2151
|
+
case "state.task_set":
|
|
2152
|
+
detail = d.cleared ? "(cleared)" : clip(s("task"));
|
|
2153
|
+
break;
|
|
2154
|
+
case "state.status_checked":
|
|
2155
|
+
detail = "status box rendered";
|
|
2156
|
+
break;
|
|
2157
|
+
case "claim.acquire":
|
|
2158
|
+
case "claim.release":
|
|
2159
|
+
case "claim.conflict":
|
|
2160
|
+
detail = clip(s("path"));
|
|
2161
|
+
break;
|
|
2162
|
+
case "health.heartbeat_heal":
|
|
2163
|
+
case "health.pidmap_heal":
|
|
2164
|
+
detail = `reason=${s("reason")}`;
|
|
2165
|
+
break;
|
|
2166
|
+
case "health.heartbeat_swept":
|
|
2167
|
+
detail = `reason=${s("reason")}${d.age_secs !== undefined ? ` · age=${d.age_secs}s` : ""}`;
|
|
2168
|
+
break;
|
|
2169
|
+
default:
|
|
2170
|
+
// Noise unless --all-tools: per-line command.* + tool.post_use.
|
|
2171
|
+
if (!allTools) return null;
|
|
2172
|
+
if (
|
|
2173
|
+
ev.event_type === "tool.post_use" ||
|
|
2174
|
+
ev.event_type === "tool.post_use_failure" ||
|
|
2175
|
+
ev.event_type.startsWith("command.")
|
|
2176
|
+
) {
|
|
2177
|
+
detail = s("tool_name") || "";
|
|
2178
|
+
break;
|
|
2179
|
+
}
|
|
2180
|
+
return null;
|
|
2181
|
+
}
|
|
2182
|
+
return { ts: ev.ts, event_type: ev.event_type, detail };
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function runTrace(
|
|
2186
|
+
name: string,
|
|
2187
|
+
opts: { since?: string; limit: string; allTools?: boolean; json?: boolean },
|
|
2188
|
+
): void {
|
|
2189
|
+
if (opts.json) emit.config({ format: "json" });
|
|
2190
|
+
const root = monorepoRoot();
|
|
2191
|
+
if (!root) {
|
|
2192
|
+
emit.error({
|
|
2193
|
+
code: "not_in_repo",
|
|
2194
|
+
message: "not in an agent session; coord_root() returned null",
|
|
2195
|
+
});
|
|
2196
|
+
process.exit(1);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// Resolve the arg → instance_id. Accept agent-Foo / Foo (name) or a raw id.
|
|
2200
|
+
const nameById = buildNameById(root);
|
|
2201
|
+
const wanted = name.startsWith("agent-") ? name.slice("agent-".length) : name;
|
|
2202
|
+
const wantedLower = wanted.toLowerCase();
|
|
2203
|
+
let targetId: string | null = null;
|
|
2204
|
+
if (nameById.has(wanted)) {
|
|
2205
|
+
targetId = wanted; // arg was a raw instance_id present in name-history
|
|
2206
|
+
} else {
|
|
2207
|
+
// name match: may resolve to several instances over time; pick the one
|
|
2208
|
+
// with the most-recent event below (collect all candidates first).
|
|
2209
|
+
const candidates = [...nameById.entries()].filter(([, n]) => n.toLowerCase() === wantedLower);
|
|
2210
|
+
if (candidates.length === 1) targetId = candidates[0]![0];
|
|
2211
|
+
else if (candidates.length > 1)
|
|
2212
|
+
targetId = candidates.map(([id]) => id).join("\x00"); // sentinel; resolved below
|
|
2213
|
+
else if (/^[0-9a-f-]{8,}$/i.test(wanted)) targetId = wanted; // looks like an id not in history
|
|
2214
|
+
}
|
|
2215
|
+
if (!targetId) {
|
|
2216
|
+
emit.error({
|
|
2217
|
+
code: "not_found",
|
|
2218
|
+
message: `no agent named '${name}' in .name-history (and not an id)`,
|
|
2219
|
+
});
|
|
2220
|
+
process.exit(1);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
const sinceMs = opts.since ? Date.now() - (parseWindowSecs(opts.since) ?? 0) * 1000 : 0;
|
|
2224
|
+
const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 200);
|
|
2225
|
+
const candidateIds = targetId.includes("\x00") ? targetId.split("\x00") : [targetId];
|
|
2226
|
+
|
|
2227
|
+
// Scan the full stream once, bucket events by instance_id for the candidates.
|
|
2228
|
+
const streamPath = resolve(root, ".harnery", "events.ndjson");
|
|
2229
|
+
const byId = new Map<string, CanonicalEvent[]>();
|
|
2230
|
+
if (existsSync(streamPath)) {
|
|
2231
|
+
for (const line of readFileSync(streamPath, "utf8").split("\n")) {
|
|
2232
|
+
if (!line) continue;
|
|
2233
|
+
try {
|
|
2234
|
+
const ev = JSON.parse(line) as CanonicalEvent;
|
|
2235
|
+
if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
|
|
2236
|
+
if (sinceMs && Date.parse(ev.ts) < sinceMs) continue;
|
|
2237
|
+
const arr = byId.get(ev.instance_id) ?? [];
|
|
2238
|
+
arr.push(ev);
|
|
2239
|
+
byId.set(ev.instance_id, arr);
|
|
2240
|
+
} catch {
|
|
2241
|
+
/* skip */
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// If the name mapped to multiple instances, trace the one with the latest event.
|
|
2247
|
+
let resolvedId = candidateIds[0]!;
|
|
2248
|
+
if (candidateIds.length > 1) {
|
|
2249
|
+
let latest = -1;
|
|
2250
|
+
for (const id of candidateIds) {
|
|
2251
|
+
const evs = byId.get(id);
|
|
2252
|
+
const last = evs?.length ? Date.parse(evs[evs.length - 1]!.ts) : -1;
|
|
2253
|
+
if (last > latest) {
|
|
2254
|
+
latest = last;
|
|
2255
|
+
resolvedId = id;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
const events = byId.get(resolvedId) ?? [];
|
|
2261
|
+
const lines = events
|
|
2262
|
+
.map((ev) => traceLine(ev, !!opts.allTools))
|
|
2263
|
+
.filter((l): l is TraceEntry => l !== null)
|
|
2264
|
+
// Sort by timestamp, not file order: codex replays events (original ts,
|
|
2265
|
+
// appended later), so append-order ≠ chronological order.
|
|
2266
|
+
.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
|
|
2267
|
+
const shown = lines.slice(-limit);
|
|
2268
|
+
const displayName = nameById.get(resolvedId) ?? resolvedId.slice(0, 8);
|
|
2269
|
+
|
|
2270
|
+
const result = {
|
|
2271
|
+
name: displayName,
|
|
2272
|
+
instance_id: resolvedId,
|
|
2273
|
+
other_instances: candidateIds.filter((id) => id !== resolvedId),
|
|
2274
|
+
total_events: events.length,
|
|
2275
|
+
shown: shown.length,
|
|
2276
|
+
entries: shown,
|
|
2277
|
+
};
|
|
2278
|
+
|
|
2279
|
+
if (opts.json) {
|
|
2280
|
+
emit.data(result);
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
emit.data(result);
|
|
2284
|
+
const header = `Trace: agent-${displayName} (${resolvedId.slice(0, 8)}…) ${events.length} events${result.other_instances.length ? ` · ${result.other_instances.length} older instance(s) of this name` : ""}`;
|
|
2285
|
+
process.stdout.write(`${header}\n`); // lint-ok-emission: human trace view
|
|
2286
|
+
if (shown.length === 0) {
|
|
2287
|
+
process.stdout.write(" (no events)\n"); // lint-ok-emission: human trace view
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
for (const l of shown) {
|
|
2291
|
+
const t = formatLocalTime(new Date(l.ts)).replace(/^[A-Za-z]{3}, /, ""); // drop weekday for density
|
|
2292
|
+
process.stdout.write(` ${t} ${l.event_type.padEnd(22)} ${l.detail}\n`); // lint-ok-emission: human trace view
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function runHealth(opts: { since: string; json?: boolean }): void {
|
|
2297
|
+
if (opts.json) emit.config({ format: "json" });
|
|
2298
|
+
|
|
2299
|
+
const root = monorepoRoot();
|
|
2300
|
+
if (!root) {
|
|
2301
|
+
emit.error({
|
|
2302
|
+
code: "not_in_repo",
|
|
2303
|
+
message: "not in an agent session; coord_root() returned null",
|
|
2304
|
+
});
|
|
2305
|
+
process.exit(1);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
const sinceSecs = parseWindowSecs(opts.since);
|
|
2309
|
+
if (sinceSecs === null) {
|
|
2310
|
+
emit.error({
|
|
2311
|
+
code: "bad_since",
|
|
2312
|
+
message: `invalid --since value '${opts.since}': expected Nh or Nd (e.g. 24h, 7d)`,
|
|
2313
|
+
});
|
|
2314
|
+
process.exit(1);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
const cutoffMs = Date.now() - sinceSecs * 1000;
|
|
2318
|
+
const activeDir = resolve(root, ".harnery/active");
|
|
2319
|
+
const councilsDir = resolve(root, ".harnery/councils");
|
|
2320
|
+
|
|
2321
|
+
// Coordination telemetry reads from the canonical events.ndjson stream.
|
|
2322
|
+
// Heals come from health.*; council window-activity from council.*. The
|
|
2323
|
+
// commit-guard + schema-invalid counters below have NO canonical equivalent
|
|
2324
|
+
// yet. Their future home is the decision.* events (defined in schema.ts, not
|
|
2325
|
+
// yet wired). Until then they report 0 (fields kept for output-shape
|
|
2326
|
+
// compatibility).
|
|
2327
|
+
const heal: HealEvent[] = [];
|
|
2328
|
+
const schemaInvalid = 0;
|
|
2329
|
+
const schemaSamples: string[] = [];
|
|
2330
|
+
const commitBlocked = 0;
|
|
2331
|
+
const commitBypassed = 0;
|
|
2332
|
+
const commitSuppressed = 0;
|
|
2333
|
+
const editBlocked = 0;
|
|
2334
|
+
const shellCandidates = 0;
|
|
2335
|
+
let councilAdvanced = 0;
|
|
2336
|
+
let councilClosed = 0;
|
|
2337
|
+
let councilArchived = 0;
|
|
2338
|
+
let sweptTotal = 0;
|
|
2339
|
+
const sweptByReason: Record<string, number> = {};
|
|
2340
|
+
|
|
2341
|
+
const nameById = buildNameById(root);
|
|
2342
|
+
for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
|
|
2343
|
+
const healEv = canonicalToHealEvent(ev, nameById);
|
|
2344
|
+
if (healEv) {
|
|
2345
|
+
heal.push(healEv);
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
switch (ev.event_type) {
|
|
2349
|
+
case "council.round_open":
|
|
2350
|
+
councilAdvanced++;
|
|
2351
|
+
break;
|
|
2352
|
+
case "council.close":
|
|
2353
|
+
councilClosed++;
|
|
2354
|
+
break;
|
|
2355
|
+
case "council.archive":
|
|
2356
|
+
councilArchived++;
|
|
2357
|
+
break;
|
|
2358
|
+
case "health.heartbeat_swept": {
|
|
2359
|
+
sweptTotal++;
|
|
2360
|
+
const reason = String((ev.data as { reason?: unknown })?.reason ?? "unknown");
|
|
2361
|
+
sweptByReason[reason] = (sweptByReason[reason] ?? 0) + 1;
|
|
2362
|
+
break;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
const hookErrors = readHookErrors(root, cutoffMs);
|
|
2368
|
+
const stream = readStreamStats(root);
|
|
2369
|
+
|
|
2370
|
+
// Canonical health.* events carry the full instance_id, already resolved to
|
|
2371
|
+
// `agent-<name>` (or `agent-<hex8>` fallback) by canonicalToHealEvent via
|
|
2372
|
+
// buildNameById; no hex8→name dedup pass needed anymore.
|
|
2373
|
+
const healByReason: Record<string, number> = { missing: 0, stale: 0 };
|
|
2374
|
+
const healByKind: Record<string, number> = { pidmap: 0, heartbeat: 0 };
|
|
2375
|
+
const healByPlatform: Record<string, number> = {};
|
|
2376
|
+
const healByAgent = new Map<string, number>();
|
|
2377
|
+
for (const ev of heal) {
|
|
2378
|
+
healByReason[ev.reason] = (healByReason[ev.reason] ?? 0) + 1;
|
|
2379
|
+
healByKind[ev.kind] = (healByKind[ev.kind] ?? 0) + 1;
|
|
2380
|
+
healByPlatform[ev.platform] = (healByPlatform[ev.platform] ?? 0) + 1;
|
|
2381
|
+
healByAgent.set(ev.agent, (healByAgent.get(ev.agent) ?? 0) + 1);
|
|
2382
|
+
}
|
|
2383
|
+
const healTopAgents = Array.from(healByAgent.entries())
|
|
2384
|
+
.sort((a, b) => b[1] - a[1])
|
|
2385
|
+
.slice(0, 5)
|
|
2386
|
+
.map(([agent, count]) => ({ agent, count }));
|
|
2387
|
+
|
|
2388
|
+
// Active heartbeats: scan ALL files in active/, classify fresh vs stale ourselves.
|
|
2389
|
+
const activeByPlatform: Record<string, number> = {};
|
|
2390
|
+
const activeByKind: Record<string, number> = {};
|
|
2391
|
+
const activeBySchema: Record<string, number> = {};
|
|
2392
|
+
let activeTotal = 0;
|
|
2393
|
+
let staleHeartbeats = 0;
|
|
2394
|
+
// Zombies: files in active/ that are broken: unparseable, nameless, or an
|
|
2395
|
+
// absurd (epoch-ish) last_heartbeat. These show as `agent-unknown` ghosts and
|
|
2396
|
+
// mean dead files the sweep isn't reaping.
|
|
2397
|
+
let zombieCount = 0;
|
|
2398
|
+
const zombieSamples: string[] = [];
|
|
2399
|
+
const ABSURD_AGE_MS = 24 * 60 * 60 * 1000; // > 1 day = clearly not a live, self-healing agent
|
|
2400
|
+
const nowMs = Date.now();
|
|
2401
|
+
if (existsSync(activeDir)) {
|
|
2402
|
+
for (const file of readdirSync(activeDir)) {
|
|
2403
|
+
if (!file.endsWith(".json")) continue;
|
|
2404
|
+
const idFromFile = file.replace(/\.json$/, "");
|
|
2405
|
+
let hb: Heartbeat | null = null;
|
|
2406
|
+
try {
|
|
2407
|
+
hb = JSON.parse(readFileSync(resolve(activeDir, file), "utf8")) as Heartbeat;
|
|
2408
|
+
} catch {
|
|
2409
|
+
hb = null;
|
|
2410
|
+
}
|
|
2411
|
+
if (!hb || typeof hb.instance_id !== "string") {
|
|
2412
|
+
zombieCount++;
|
|
2413
|
+
if (zombieSamples.length < 5)
|
|
2414
|
+
zombieSamples.push(`${idFromFile.slice(0, 12)} (unparseable/no-id)`);
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
activeTotal++;
|
|
2418
|
+
const platform = formatPlatformLabel(hb.platform);
|
|
2419
|
+
activeByPlatform[platform] = (activeByPlatform[platform] ?? 0) + 1;
|
|
2420
|
+
const kind = hb.kind ?? "unknown";
|
|
2421
|
+
activeByKind[kind] = (activeByKind[kind] ?? 0) + 1;
|
|
2422
|
+
const sv = (hb as { schema_version?: number }).schema_version;
|
|
2423
|
+
const schemaKey = sv === undefined ? "v0" : `v${sv}`;
|
|
2424
|
+
activeBySchema[schemaKey] = (activeBySchema[schemaKey] ?? 0) + 1;
|
|
2425
|
+
const lastHbMs = hb.last_heartbeat ? Date.parse(hb.last_heartbeat) : Number.NaN;
|
|
2426
|
+
const ageMs = Number.isFinite(lastHbMs) ? nowMs - lastHbMs : Number.POSITIVE_INFINITY;
|
|
2427
|
+
if (ageMs > FRESHNESS_SECS * 1000) staleHeartbeats++;
|
|
2428
|
+
// Zombie heuristics on a parseable heartbeat: no name, or an age so large
|
|
2429
|
+
// it can only be a broken/epoch timestamp (a real agent would have healed).
|
|
2430
|
+
if (!hb.name || hb.name === "unknown" || ageMs > ABSURD_AGE_MS) {
|
|
2431
|
+
zombieCount++;
|
|
2432
|
+
if (zombieSamples.length < 5) {
|
|
2433
|
+
const why = !hb.name || hb.name === "unknown" ? "no-name" : "epoch-age";
|
|
2434
|
+
zombieSamples.push(`${idFromFile.slice(0, 12)} (${why})`);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Active councils on disk (excluding archive/).
|
|
2441
|
+
let activeCouncils = 0;
|
|
2442
|
+
if (existsSync(councilsDir)) {
|
|
2443
|
+
for (const entry of readdirSync(councilsDir, { withFileTypes: true })) {
|
|
2444
|
+
if (!entry.isDirectory() || entry.name === "archive") continue;
|
|
2445
|
+
const manifestPath = resolve(councilsDir, entry.name, "manifest.json");
|
|
2446
|
+
if (existsSync(manifestPath)) activeCouncils++;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// Anomaly detection.
|
|
2451
|
+
const anomalies: string[] = [];
|
|
2452
|
+
if (schemaInvalid > 0) {
|
|
2453
|
+
const sampleList = schemaSamples.slice(0, 3).join(", ");
|
|
2454
|
+
anomalies.push(
|
|
2455
|
+
`HEARTBEAT_SCHEMA_INVALID fired ${schemaInvalid}x; heartbeat shape failed validation${sampleList ? ` (samples: ${sampleList})` : ""}`,
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
for (const { agent, count } of healTopAgents) {
|
|
2459
|
+
if (count >= 5) {
|
|
2460
|
+
anomalies.push(
|
|
2461
|
+
`${agent} self-healed ${count}x in ${opts.since}; possible idle-prune loop or PID instability`,
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
if (staleHeartbeats > 0) {
|
|
2466
|
+
anomalies.push(
|
|
2467
|
+
`${staleHeartbeats} active heartbeat(s) older than ${Math.floor(FRESHNESS_SECS / 60)}min; heal mechanism may not be firing`,
|
|
2468
|
+
);
|
|
2469
|
+
}
|
|
2470
|
+
const unexpectedSchemas = Object.keys(activeBySchema).filter((k) => k !== "v1");
|
|
2471
|
+
if (unexpectedSchemas.length > 0) {
|
|
2472
|
+
anomalies.push(
|
|
2473
|
+
`Unexpected heartbeat schema versions in use: ${unexpectedSchemas.join(", ")} (expected v1)`,
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
// agent-hook failures: a dominant phase is the fastest pointer to a systemic
|
|
2477
|
+
// hook bug (this is the signal that would have surfaced the stop-projection
|
|
2478
|
+
// crash immediately instead of after an hour of log-grepping).
|
|
2479
|
+
if (hookErrors.total > 0) {
|
|
2480
|
+
const top = hookErrors.top[0];
|
|
2481
|
+
const detail = top
|
|
2482
|
+
? `: top phase '${top.phase}' x${top.count}${top.sample ? ` (${top.sample.slice(0, 80)})` : ""}`
|
|
2483
|
+
: "";
|
|
2484
|
+
anomalies.push(`agent-hook errored ${hookErrors.total}x in ${opts.since}${detail}`);
|
|
2485
|
+
}
|
|
2486
|
+
if (stream.cursor_backlog > 500) {
|
|
2487
|
+
anomalies.push(
|
|
2488
|
+
`projection cursor is ${stream.cursor_backlog} events behind; drain lagging (stop projection may be failing)`,
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
// NB: raw stream size is NOT an anomaly. events.ndjson is a deliberate
|
|
2492
|
+
// append-only ledger (names + forensics for the life of the log), and
|
|
2493
|
+
// consumeSince tail-reads it, so size no longer drives latency. The
|
|
2494
|
+
// bytes/lines still surface in the summary line for visibility;
|
|
2495
|
+
// cursor_backlog above is the real drain-lag signal.
|
|
2496
|
+
if ((sweptByReason.unparseable ?? 0) > 0) {
|
|
2497
|
+
anomalies.push(
|
|
2498
|
+
`${sweptByReason.unparseable} heartbeat(s) swept as unparseable in ${opts.since}; possible corruption or a non-atomic writer`,
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
if (zombieCount > 0) {
|
|
2502
|
+
anomalies.push(
|
|
2503
|
+
`${zombieCount} zombie heartbeat(s) in active/ (${zombieSamples.join(", ")}); broken files the sweep isn't reaping`,
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
const report: HealthReport = {
|
|
2508
|
+
since: opts.since,
|
|
2509
|
+
generated_at: new Date().toISOString(),
|
|
2510
|
+
active_agents: {
|
|
2511
|
+
total: activeTotal,
|
|
2512
|
+
by_platform: activeByPlatform,
|
|
2513
|
+
by_kind: activeByKind,
|
|
2514
|
+
by_schema_version: activeBySchema,
|
|
2515
|
+
stale: staleHeartbeats,
|
|
2516
|
+
},
|
|
2517
|
+
heal_events: {
|
|
2518
|
+
total: heal.length,
|
|
2519
|
+
by_kind: { pidmap: healByKind.pidmap, heartbeat: healByKind.heartbeat },
|
|
2520
|
+
by_reason: { missing: healByReason.missing, stale: healByReason.stale },
|
|
2521
|
+
by_platform: healByPlatform,
|
|
2522
|
+
top_agents: healTopAgents,
|
|
2523
|
+
},
|
|
2524
|
+
schema_invalid: { count: schemaInvalid, samples: schemaSamples },
|
|
2525
|
+
commit_guards: {
|
|
2526
|
+
blocked: commitBlocked,
|
|
2527
|
+
bypassed: commitBypassed,
|
|
2528
|
+
suppressed: commitSuppressed,
|
|
2529
|
+
edit_blocked: editBlocked,
|
|
2530
|
+
shell_candidates: shellCandidates,
|
|
2531
|
+
},
|
|
2532
|
+
councils: {
|
|
2533
|
+
active: activeCouncils,
|
|
2534
|
+
archived_in_window: councilArchived,
|
|
2535
|
+
advanced_in_window: councilAdvanced,
|
|
2536
|
+
closed_in_window: councilClosed,
|
|
2537
|
+
},
|
|
2538
|
+
swept_events: { total: sweptTotal, by_reason: sweptByReason },
|
|
2539
|
+
hook_errors: { total: hookErrors.total, by_phase: hookErrors.byPhase, top: hookErrors.top },
|
|
2540
|
+
stream,
|
|
2541
|
+
zombies: { count: zombieCount, samples: zombieSamples },
|
|
2542
|
+
anomalies,
|
|
2543
|
+
};
|
|
2544
|
+
|
|
2545
|
+
if (opts.json) {
|
|
2546
|
+
emit.data(report);
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
emit.data(report);
|
|
2550
|
+
renderHealthBox(report);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function renderHealthBox(report: HealthReport): void {
|
|
2554
|
+
const platforms = Object.entries(report.active_agents.by_platform)
|
|
2555
|
+
.map(([p, n]) => `${p} ${n}`)
|
|
2556
|
+
.join(" / ");
|
|
2557
|
+
const schemas = Object.entries(report.active_agents.by_schema_version)
|
|
2558
|
+
.map(([v, n]) => `${v} ${n}`)
|
|
2559
|
+
.join(" / ");
|
|
2560
|
+
|
|
2561
|
+
const healSubparts: string[] = [];
|
|
2562
|
+
if (report.heal_events.by_kind.pidmap > 0) {
|
|
2563
|
+
healSubparts.push(`pidmap ${report.heal_events.by_kind.pidmap}`);
|
|
2564
|
+
}
|
|
2565
|
+
if (report.heal_events.by_kind.heartbeat > 0) {
|
|
2566
|
+
healSubparts.push(`heartbeat ${report.heal_events.by_kind.heartbeat}`);
|
|
2567
|
+
}
|
|
2568
|
+
if (report.heal_events.by_reason.stale > 0) {
|
|
2569
|
+
healSubparts.push(`stale ${report.heal_events.by_reason.stale}`);
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
const topHealer = report.heal_events.top_agents[0];
|
|
2573
|
+
const topHealerStr = topHealer ? `${topHealer.agent} x${topHealer.count}` : "(none)";
|
|
2574
|
+
|
|
2575
|
+
const guardParts: string[] = [];
|
|
2576
|
+
if (report.commit_guards.blocked > 0) guardParts.push(`blocked ${report.commit_guards.blocked}`);
|
|
2577
|
+
if (report.commit_guards.bypassed > 0)
|
|
2578
|
+
guardParts.push(`bypassed ${report.commit_guards.bypassed}`);
|
|
2579
|
+
if (report.commit_guards.suppressed > 0)
|
|
2580
|
+
guardParts.push(`suppressed ${report.commit_guards.suppressed}`);
|
|
2581
|
+
if (report.commit_guards.edit_blocked > 0)
|
|
2582
|
+
guardParts.push(`edit-blocked ${report.commit_guards.edit_blocked}`);
|
|
2583
|
+
if (report.commit_guards.shell_candidates > 0)
|
|
2584
|
+
guardParts.push(`shell ${report.commit_guards.shell_candidates}`);
|
|
2585
|
+
|
|
2586
|
+
const councilParts: string[] = [`${report.councils.active} active`];
|
|
2587
|
+
if (report.councils.advanced_in_window > 0)
|
|
2588
|
+
councilParts.push(`${report.councils.advanced_in_window} advanced`);
|
|
2589
|
+
if (report.councils.closed_in_window > 0)
|
|
2590
|
+
councilParts.push(`${report.councils.closed_in_window} closed`);
|
|
2591
|
+
if (report.councils.archived_in_window > 0)
|
|
2592
|
+
councilParts.push(`${report.councils.archived_in_window} archived`);
|
|
2593
|
+
|
|
2594
|
+
const activeStr = `${report.active_agents.total}${platforms ? ` (${platforms})` : ""}${schemas ? ` · ${schemas}` : ""}${report.active_agents.stale > 0 ? ` · ${report.active_agents.stale} stale` : ""}`;
|
|
2595
|
+
|
|
2596
|
+
const sweptReasonStr = Object.entries(report.swept_events.by_reason)
|
|
2597
|
+
.map(([r, n]) => `${r} ${n}`)
|
|
2598
|
+
.join(", ");
|
|
2599
|
+
const hookErrStr =
|
|
2600
|
+
report.hook_errors.total === 0
|
|
2601
|
+
? "0"
|
|
2602
|
+
: `${report.hook_errors.total}${report.hook_errors.top[0] ? ` (${report.hook_errors.top[0].phase} x${report.hook_errors.top[0].count})` : ""}`;
|
|
2603
|
+
const streamStr = `${(report.stream.bytes / 1048576).toFixed(1)}MB · ${report.stream.lines} lines · ${report.stream.cursor_backlog} behind`;
|
|
2604
|
+
|
|
2605
|
+
const rows: Array<[string, string]> = [
|
|
2606
|
+
["window", `last ${report.since}`],
|
|
2607
|
+
["active", activeStr],
|
|
2608
|
+
[
|
|
2609
|
+
"heals",
|
|
2610
|
+
`${report.heal_events.total}${healSubparts.length ? ` (${healSubparts.join(", ")})` : ""}`,
|
|
2611
|
+
],
|
|
2612
|
+
["top healer", topHealerStr],
|
|
2613
|
+
["swept", `${report.swept_events.total}${sweptReasonStr ? ` (${sweptReasonStr})` : ""}`],
|
|
2614
|
+
["hook errors", hookErrStr],
|
|
2615
|
+
["stream", streamStr],
|
|
2616
|
+
[
|
|
2617
|
+
"zombies",
|
|
2618
|
+
report.zombies.count === 0
|
|
2619
|
+
? "0"
|
|
2620
|
+
: `${report.zombies.count} (${report.zombies.samples.join(", ")})`,
|
|
2621
|
+
],
|
|
2622
|
+
["schema invalid", String(report.schema_invalid.count)],
|
|
2623
|
+
["commit guards", guardParts.length ? guardParts.join(", ") : "0"],
|
|
2624
|
+
["councils", councilParts.join(", ")],
|
|
2625
|
+
["anomalies", report.anomalies.length === 0 ? "(clean)" : `${report.anomalies.length} flagged`],
|
|
2626
|
+
];
|
|
2627
|
+
|
|
2628
|
+
const localTime = formatLocalTime(new Date(report.generated_at));
|
|
2629
|
+
const title = `Coord Health (${localTime})`;
|
|
2630
|
+
|
|
2631
|
+
process.stdout.write(`${formatBox(title, rows)}\n`); // lint-ok-emission: chat-paste path; mirrors runStatus's direct write so the box surfaces in both TTY + bp-session-teed contexts
|
|
2632
|
+
|
|
2633
|
+
if (report.anomalies.length > 0) {
|
|
2634
|
+
process.stdout.write("\n"); // lint-ok-emission: same chat-paste path
|
|
2635
|
+
for (const a of report.anomalies) {
|
|
2636
|
+
process.stdout.write(` ! ${a}\n`); // lint-ok-emission: same
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
interface SampleReplayResult {
|
|
2642
|
+
file: string;
|
|
2643
|
+
event: string | null;
|
|
2644
|
+
status: "pass" | "fail" | "skipped" | "error";
|
|
2645
|
+
exit_code: number | null;
|
|
2646
|
+
stderr_excerpt?: string;
|
|
2647
|
+
message?: string;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function runHarnessProbe(
|
|
2651
|
+
id: string,
|
|
2652
|
+
opts: { json?: boolean; replaySamples?: boolean; sample?: string },
|
|
2653
|
+
): void {
|
|
2654
|
+
if (opts.json) emit.config({ format: "json" });
|
|
2655
|
+
|
|
2656
|
+
const harness = id.trim();
|
|
2657
|
+
if (harness !== "claude_code" && harness !== "cursor") {
|
|
2658
|
+
emit.error({
|
|
2659
|
+
code: "bad_harness",
|
|
2660
|
+
message: "harness id must be claude_code or cursor",
|
|
2661
|
+
});
|
|
2662
|
+
process.exit(1);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
const root = monorepoRoot();
|
|
2666
|
+
if (!root) {
|
|
2667
|
+
emit.error({
|
|
2668
|
+
code: "not_in_repo",
|
|
2669
|
+
message: "not in an agent session; coord_root() returned null",
|
|
2670
|
+
});
|
|
2671
|
+
process.exit(1);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
const subagentDir =
|
|
2675
|
+
harness === "cursor" ? ".harnery/.cursor-subagent-map" : `.harnery/.subagent-map/${harness}`;
|
|
2676
|
+
const sampleDir =
|
|
2677
|
+
harness === "cursor" ? "docs/api/cursor-hooks/samples" : "docs/api/claude-code-hooks/samples";
|
|
2678
|
+
const dispatchEntry =
|
|
2679
|
+
harness === "cursor"
|
|
2680
|
+
? "harnery/bin/agent-hook session-start --harness cursor"
|
|
2681
|
+
: "harnery/bin/agent-hook session-start --harness claude-code";
|
|
2682
|
+
|
|
2683
|
+
// TS-native probe. The owner + anchor-pid resolution it reports lives in
|
|
2684
|
+
// `findHarnessAnchorPid` (core/hooks/cli.ts, the /proc walk mirrored below)
|
|
2685
|
+
// and `resolveOwner` here, so the probe reports exactly what the live hot
|
|
2686
|
+
// path resolves.
|
|
2687
|
+
const anchorTokens = new Set(["claude", "claude-code", "cursor", "codex"]);
|
|
2688
|
+
const override = process.env.BP_AGENT_COORD_TEST_ANCHOR_PID;
|
|
2689
|
+
let anchorPid = override && Number(override) > 0 ? override : "";
|
|
2690
|
+
const chainParts: string[] = [];
|
|
2691
|
+
let walkPid = process.pid;
|
|
2692
|
+
for (let hops = 0; hops < 20; hops++) {
|
|
2693
|
+
let comm = "?";
|
|
2694
|
+
let ppid = 0;
|
|
2695
|
+
try {
|
|
2696
|
+
comm = readFileSync(`/proc/${walkPid}/comm`, "utf8").trim() || "?";
|
|
2697
|
+
const status = readFileSync(`/proc/${walkPid}/status`, "utf8");
|
|
2698
|
+
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
2699
|
+
ppid = m ? Number(m[1]) : 0;
|
|
2700
|
+
} catch {
|
|
2701
|
+
// non-Linux (no /proc) or the pid is gone: stop walking.
|
|
2702
|
+
}
|
|
2703
|
+
chainParts.push(`${walkPid}:${comm}`);
|
|
2704
|
+
if (!anchorPid && anchorTokens.has(comm)) anchorPid = String(walkPid);
|
|
2705
|
+
if (!ppid || ppid === 0) break;
|
|
2706
|
+
walkPid = ppid;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
const data: Record<string, unknown> = {
|
|
2710
|
+
harness,
|
|
2711
|
+
anchor_pid: anchorPid,
|
|
2712
|
+
hook_pid: String(process.pid),
|
|
2713
|
+
resolved_owner: resolveOwner() ?? "",
|
|
2714
|
+
ppid_chain: `${chainParts.join(" ")} `,
|
|
2715
|
+
subagent_map_dir: subagentDir,
|
|
2716
|
+
sample_ref: sampleDir,
|
|
2717
|
+
dispatch_entry: dispatchEntry,
|
|
2718
|
+
note: "heal-events counts drift; harness-probe answers wiring",
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
const wantReplay = opts.replaySamples || !!opts.sample;
|
|
2722
|
+
let samples: SampleReplayResult[] = [];
|
|
2723
|
+
let replayExitCode = 0;
|
|
2724
|
+
if (wantReplay) {
|
|
2725
|
+
const result = replayHarnessSamples(harness, root, sampleDir, opts.sample);
|
|
2726
|
+
samples = result.samples;
|
|
2727
|
+
replayExitCode = result.exitCode;
|
|
2728
|
+
data.samples = samples;
|
|
2729
|
+
data.samples_summary = result.summary;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
emit.data(data);
|
|
2733
|
+
if (process.stdout.isTTY && !opts.json) {
|
|
2734
|
+
const lines = [
|
|
2735
|
+
`Harness probe: ${harness}`,
|
|
2736
|
+
` anchor_pid: ${String(data.anchor_pid) || "(empty, expected in sandbox/non-IDE)"}`,
|
|
2737
|
+
` hook_pid: ${String(data.hook_pid)}`,
|
|
2738
|
+
` resolved_owner: ${String(data.resolved_owner) || "(none)"}`,
|
|
2739
|
+
` ppid_chain: ${String(data.ppid_chain)}`,
|
|
2740
|
+
` samples: ${String(data.sample_ref)}`,
|
|
2741
|
+
` entry: ${String(data.dispatch_entry)}`,
|
|
2742
|
+
];
|
|
2743
|
+
if (wantReplay) {
|
|
2744
|
+
const summary = data.samples_summary as
|
|
2745
|
+
| { total: number; pass: number; fail: number; skipped: number }
|
|
2746
|
+
| undefined;
|
|
2747
|
+
lines.push("");
|
|
2748
|
+
if (samples.length === 0) {
|
|
2749
|
+
lines.push(` Sample replay: no .json fixtures found under ${sampleDir}`);
|
|
2750
|
+
} else {
|
|
2751
|
+
lines.push(
|
|
2752
|
+
` Sample replay (${samples.length} fixture${samples.length === 1 ? "" : "s"}):`,
|
|
2753
|
+
);
|
|
2754
|
+
for (const s of samples) {
|
|
2755
|
+
const mark = s.status === "pass" ? "✓" : s.status === "skipped" ? "·" : "✗";
|
|
2756
|
+
const tail =
|
|
2757
|
+
s.status === "fail"
|
|
2758
|
+
? ` (exit ${s.exit_code ?? "?"}${s.stderr_excerpt ? `, stderr: ${s.stderr_excerpt}` : ""})`
|
|
2759
|
+
: s.status === "skipped"
|
|
2760
|
+
? ` (${s.message ?? "skipped"})`
|
|
2761
|
+
: s.status === "error"
|
|
2762
|
+
? ` (${s.message ?? "error"})`
|
|
2763
|
+
: "";
|
|
2764
|
+
const eventLabel = s.event ? `[${s.event}]`.padEnd(22) : "[?]".padEnd(22);
|
|
2765
|
+
lines.push(` ${mark} ${eventLabel} ${s.file}${tail}`);
|
|
2766
|
+
}
|
|
2767
|
+
if (summary) {
|
|
2768
|
+
lines.push(` → ${summary.pass} pass, ${summary.fail} fail, ${summary.skipped} skipped`);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
emit.text(`${lines.join("\n")}\n`);
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
if (wantReplay && replayExitCode !== 0) {
|
|
2776
|
+
process.exit(replayExitCode);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
/**
|
|
2781
|
+
* Replay every JSON fixture in <root>/<sampleDir> against the live harness
|
|
2782
|
+
* dispatcher in an isolated sandbox.
|
|
2783
|
+
*
|
|
2784
|
+
* Sandbox isolation strategy:
|
|
2785
|
+
* - mkdtempSync(tmpdir(), "bp-harness-probe-") creates a non-git tmp dir.
|
|
2786
|
+
* - The dispatcher's coord-root resolution falls back to
|
|
2787
|
+
* `BP_COORD_ROOT_OVERRIDE` when git rev-parse fails. We set it to the sandbox.
|
|
2788
|
+
* - We rewrite the payload's `cwd` field (Cursor cds to it) to the sandbox,
|
|
2789
|
+
* so real `.harnery/` never gets touched.
|
|
2790
|
+
* - We set `BP_AGENT_COORD_OFF=0` explicitly so any user-side off-switch in
|
|
2791
|
+
* the environment doesn't mask adapter crashes.
|
|
2792
|
+
*
|
|
2793
|
+
* Sample shape: probe-meta wrapped (`_probe_meta.event` + `.payload`) OR bare
|
|
2794
|
+
* payload with `.hook_event_name`. Event name resolution falls back to the
|
|
2795
|
+
* filename (without `.json`) when neither field exists.
|
|
2796
|
+
*/
|
|
2797
|
+
function replayHarnessSamples(
|
|
2798
|
+
harness: string,
|
|
2799
|
+
root: string,
|
|
2800
|
+
relativeSampleDir: string,
|
|
2801
|
+
filter?: string,
|
|
2802
|
+
): {
|
|
2803
|
+
samples: SampleReplayResult[];
|
|
2804
|
+
exitCode: number;
|
|
2805
|
+
summary: { total: number; pass: number; fail: number; skipped: number };
|
|
2806
|
+
} {
|
|
2807
|
+
const sampleDir = resolve(root, relativeSampleDir);
|
|
2808
|
+
if (!existsSync(sampleDir)) {
|
|
2809
|
+
return {
|
|
2810
|
+
samples: [],
|
|
2811
|
+
exitCode: 0,
|
|
2812
|
+
summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
const fixtures = readdirSync(sampleDir)
|
|
2817
|
+
.filter((f) => f.endsWith(".json"))
|
|
2818
|
+
.sort()
|
|
2819
|
+
.filter((f) => !filter || f === filter || f === `${filter}.json`);
|
|
2820
|
+
|
|
2821
|
+
if (fixtures.length === 0) {
|
|
2822
|
+
return {
|
|
2823
|
+
samples: [],
|
|
2824
|
+
exitCode: 0,
|
|
2825
|
+
summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// agent-hook is the single entry point. Replay sample payloads against it,
|
|
2830
|
+
// mapping the harness-native hook_event_name to the agent-hook CLI subcommand.
|
|
2831
|
+
const agentHook = resolve(root, "harnery/bin/agent-hook");
|
|
2832
|
+
if (!existsSync(agentHook)) {
|
|
2833
|
+
return {
|
|
2834
|
+
samples: fixtures.map((file) => ({
|
|
2835
|
+
file,
|
|
2836
|
+
event: null,
|
|
2837
|
+
status: "skipped" as const,
|
|
2838
|
+
exit_code: null,
|
|
2839
|
+
message: "harnery/bin/agent-hook not found",
|
|
2840
|
+
})),
|
|
2841
|
+
exitCode: 0,
|
|
2842
|
+
summary: { total: fixtures.length, pass: 0, fail: 0, skipped: fixtures.length },
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
const EVENT_SUBCOMMAND: Record<string, string> = {
|
|
2846
|
+
sessionStart: "session-start",
|
|
2847
|
+
SessionStart: "session-start",
|
|
2848
|
+
sessionEnd: "session-end",
|
|
2849
|
+
SessionEnd: "session-end",
|
|
2850
|
+
preToolUse: "pre-tool-use",
|
|
2851
|
+
PreToolUse: "pre-tool-use",
|
|
2852
|
+
beforeShellExecution: "before-shell-execution",
|
|
2853
|
+
postToolUse: "post-tool-use",
|
|
2854
|
+
PostToolUse: "post-tool-use",
|
|
2855
|
+
postToolUseFailure: "post-tool-use-failure",
|
|
2856
|
+
PostToolUseFailure: "post-tool-use-failure",
|
|
2857
|
+
subagentStart: "sub-agent-start",
|
|
2858
|
+
SubagentStart: "sub-agent-start",
|
|
2859
|
+
subagentStop: "sub-agent-stop",
|
|
2860
|
+
SubagentStop: "sub-agent-stop",
|
|
2861
|
+
beforeSubmitPrompt: "user-prompt-submit",
|
|
2862
|
+
UserPromptSubmit: "user-prompt-submit",
|
|
2863
|
+
stop: "stop",
|
|
2864
|
+
Stop: "stop",
|
|
2865
|
+
stopFailure: "stop-failure",
|
|
2866
|
+
StopFailure: "stop-failure",
|
|
2867
|
+
};
|
|
2868
|
+
const harnessFlag = harness === "claude_code" ? "claude-code" : harness;
|
|
2869
|
+
|
|
2870
|
+
const sandbox = mkdtempSync(join(tmpdir(), "bp-harness-probe-"));
|
|
2871
|
+
const results: SampleReplayResult[] = [];
|
|
2872
|
+
|
|
2873
|
+
try {
|
|
2874
|
+
for (const file of fixtures) {
|
|
2875
|
+
const path = resolve(sampleDir, file);
|
|
2876
|
+
let parsed: unknown;
|
|
2877
|
+
try {
|
|
2878
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2879
|
+
} catch (err) {
|
|
2880
|
+
results.push({
|
|
2881
|
+
file,
|
|
2882
|
+
event: null,
|
|
2883
|
+
status: "error",
|
|
2884
|
+
exit_code: null,
|
|
2885
|
+
message: `JSON parse failed: ${(err as Error).message}`,
|
|
2886
|
+
});
|
|
2887
|
+
continue;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
const { event, payload } = extractEventAndPayload(parsed, file);
|
|
2891
|
+
if (!event) {
|
|
2892
|
+
results.push({
|
|
2893
|
+
file,
|
|
2894
|
+
event: null,
|
|
2895
|
+
status: "skipped",
|
|
2896
|
+
exit_code: null,
|
|
2897
|
+
message: "no hook_event_name in fixture or filename",
|
|
2898
|
+
});
|
|
2899
|
+
continue;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// Rewrite cwd so the dispatcher's repo-cwd resolution lands inside the sandbox.
|
|
2903
|
+
const payloadObj =
|
|
2904
|
+
payload && typeof payload === "object" ? { ...(payload as Record<string, unknown>) } : {};
|
|
2905
|
+
payloadObj.cwd = sandbox;
|
|
2906
|
+
if (Array.isArray(payloadObj.workspace_roots)) {
|
|
2907
|
+
payloadObj.workspace_roots = [sandbox];
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
const subcommand = EVENT_SUBCOMMAND[event] ?? event;
|
|
2911
|
+
const dispatch = spawnSync("bash", [agentHook, subcommand, "--harness", harnessFlag], {
|
|
2912
|
+
cwd: sandbox,
|
|
2913
|
+
encoding: "utf8",
|
|
2914
|
+
input: JSON.stringify(payloadObj),
|
|
2915
|
+
timeout: 10_000,
|
|
2916
|
+
env: {
|
|
2917
|
+
...process.env,
|
|
2918
|
+
BP_COORD_ROOT_OVERRIDE: sandbox,
|
|
2919
|
+
BP_AGENT_COORD_HARNESS: harness,
|
|
2920
|
+
BP_AGENT_COORD_PLATFORM: harness,
|
|
2921
|
+
BP_AGENT_COORD_OFF: "0",
|
|
2922
|
+
},
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
const exit = dispatch.status ?? -1;
|
|
2926
|
+
const stderr = (dispatch.stderr || "").trim();
|
|
2927
|
+
const excerpt = stderr.length > 200 ? `${stderr.slice(0, 200)}…` : stderr;
|
|
2928
|
+
|
|
2929
|
+
if (dispatch.error) {
|
|
2930
|
+
results.push({
|
|
2931
|
+
file,
|
|
2932
|
+
event,
|
|
2933
|
+
status: "error",
|
|
2934
|
+
exit_code: exit,
|
|
2935
|
+
message: dispatch.error.message,
|
|
2936
|
+
stderr_excerpt: excerpt || undefined,
|
|
2937
|
+
});
|
|
2938
|
+
continue;
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
results.push({
|
|
2942
|
+
file,
|
|
2943
|
+
event,
|
|
2944
|
+
status: exit === 0 ? "pass" : "fail",
|
|
2945
|
+
exit_code: exit,
|
|
2946
|
+
stderr_excerpt: exit === 0 ? undefined : excerpt || undefined,
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
} finally {
|
|
2950
|
+
try {
|
|
2951
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
2952
|
+
} catch {
|
|
2953
|
+
// best-effort cleanup; tmp dir will eventually age out
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
const summary = {
|
|
2958
|
+
total: results.length,
|
|
2959
|
+
pass: results.filter((r) => r.status === "pass").length,
|
|
2960
|
+
fail: results.filter((r) => r.status === "fail" || r.status === "error").length,
|
|
2961
|
+
skipped: results.filter((r) => r.status === "skipped").length,
|
|
2962
|
+
};
|
|
2963
|
+
return { samples: results, exitCode: summary.fail > 0 ? 2 : 0, summary };
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
function extractEventAndPayload(
|
|
2967
|
+
parsed: unknown,
|
|
2968
|
+
filename: string,
|
|
2969
|
+
): { event: string | null; payload: unknown } {
|
|
2970
|
+
if (parsed && typeof parsed === "object") {
|
|
2971
|
+
const obj = parsed as Record<string, unknown>;
|
|
2972
|
+
const probeMeta = obj._probe_meta;
|
|
2973
|
+
if (probeMeta && typeof probeMeta === "object") {
|
|
2974
|
+
const meta = probeMeta as Record<string, unknown>;
|
|
2975
|
+
const event = typeof meta.event === "string" ? meta.event : null;
|
|
2976
|
+
const payload = obj.payload;
|
|
2977
|
+
if (event) return { event, payload };
|
|
2978
|
+
}
|
|
2979
|
+
if (typeof obj.hook_event_name === "string") {
|
|
2980
|
+
return { event: obj.hook_event_name, payload: obj };
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
// Fall back to filename: `before-shell.json` → `beforeShellExecution`?
|
|
2984
|
+
// Too lossy; only use exact basenames that match known events.
|
|
2985
|
+
const base = filename.replace(/\.json$/, "");
|
|
2986
|
+
const fileBasedMap: Record<string, string> = {
|
|
2987
|
+
sessionStart: "sessionStart",
|
|
2988
|
+
sessionEnd: "sessionEnd",
|
|
2989
|
+
preToolUse: "preToolUse",
|
|
2990
|
+
postToolUse: "postToolUse",
|
|
2991
|
+
postToolUseFailure: "postToolUseFailure",
|
|
2992
|
+
subagentStart: "subagentStart",
|
|
2993
|
+
subagentStop: "subagentStop",
|
|
2994
|
+
beforeSubmitPrompt: "beforeSubmitPrompt",
|
|
2995
|
+
beforeShellExecution: "beforeShellExecution",
|
|
2996
|
+
stop: "stop",
|
|
2997
|
+
};
|
|
2998
|
+
return { event: fileBasedMap[base] ?? null, payload: parsed };
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function collectPath(value: string, prev: string[]): string[] {
|
|
3002
|
+
return [...prev, value];
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/** Parse "30", "30s", "5m", "1h", "2d" → ms. Bare integer defaults to minutes (back-compat). */
|
|
3006
|
+
function parseDurationToMs(input: string): number | null {
|
|
3007
|
+
const match = input.trim().match(/^(\d+)([smhd]?)$/i);
|
|
3008
|
+
if (!match) return null;
|
|
3009
|
+
const n = Number.parseInt(match[1], 10);
|
|
3010
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
3011
|
+
const unit = (match[2] || "m").toLowerCase();
|
|
3012
|
+
const mult: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
3013
|
+
return n * mult[unit];
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
/** Format ms as "30s" / "5m" / "1h30m" / "2d3h". */
|
|
3017
|
+
function formatDuration(ms: number): string {
|
|
3018
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
3019
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
3020
|
+
if (ms < 86_400_000) {
|
|
3021
|
+
const h = Math.floor(ms / 3_600_000);
|
|
3022
|
+
const m = Math.round((ms % 3_600_000) / 60_000);
|
|
3023
|
+
return m > 0 ? `${h}h${m}m` : `${h}h`;
|
|
3024
|
+
}
|
|
3025
|
+
const d = Math.floor(ms / 86_400_000);
|
|
3026
|
+
const h = Math.round((ms % 86_400_000) / 3_600_000);
|
|
3027
|
+
return h > 0 ? `${d}d${h}h` : `${d}d`;
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
function runPing(name: string, message: string, opts: { json?: boolean }): void {
|
|
3031
|
+
if (!message || message.trim().length === 0) {
|
|
3032
|
+
emit.error({ code: "empty_message", message: "message is required (and non-empty)" });
|
|
3033
|
+
process.exit(1);
|
|
3034
|
+
}
|
|
3035
|
+
const myOwner = resolveOwner();
|
|
3036
|
+
if (!myOwner) {
|
|
3037
|
+
emit.error({
|
|
3038
|
+
code: "no_pidmap_entry",
|
|
3039
|
+
message: "not in an agent session; ppid walk found no pid-map entry",
|
|
3040
|
+
});
|
|
3041
|
+
process.exit(1);
|
|
3042
|
+
}
|
|
3043
|
+
const peerOwner = resolveOwnerByName(name);
|
|
3044
|
+
if (!peerOwner) {
|
|
3045
|
+
emit.error({
|
|
3046
|
+
code: "no_peer",
|
|
3047
|
+
message: `no live agent named "${name}" (case-insensitive). Run \`${resolveBinName()} agents list\` to see who's active.`,
|
|
3048
|
+
});
|
|
3049
|
+
process.exit(1);
|
|
3050
|
+
}
|
|
3051
|
+
const myHb = readHeartbeat(myOwner);
|
|
3052
|
+
const fromName = myHb?.name ?? "anonymous";
|
|
3053
|
+
const body = `from agent-${fromName}: ${message.trim()}`;
|
|
3054
|
+
const doc = appendEntry(peerOwner, "handoff", body);
|
|
3055
|
+
|
|
3056
|
+
const data = {
|
|
3057
|
+
peer: name,
|
|
3058
|
+
peer_instance_id: peerOwner,
|
|
3059
|
+
from: fromName,
|
|
3060
|
+
body,
|
|
3061
|
+
scratch_path: doc.path,
|
|
3062
|
+
scratch_bytes: doc.bytes,
|
|
3063
|
+
};
|
|
3064
|
+
|
|
3065
|
+
if (opts.json) {
|
|
3066
|
+
emit.config({ format: "json" });
|
|
3067
|
+
emit.data(data);
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
emit.data(data);
|
|
3071
|
+
emit.text(`pinged agent-${name}: "${truncate(message.trim(), 80)}"\n`);
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
async function runWait(
|
|
3075
|
+
name: string,
|
|
3076
|
+
opts: { file: string[]; timeout: string; pollSecs: string; quiet?: boolean; json?: boolean },
|
|
3077
|
+
): Promise<void> {
|
|
3078
|
+
const timeoutMs = parseDurationToMs(opts.timeout);
|
|
3079
|
+
const pollSecs = Number.parseInt(opts.pollSecs, 10);
|
|
3080
|
+
if (timeoutMs === null) {
|
|
3081
|
+
emit.error({
|
|
3082
|
+
code: "bad_timeout",
|
|
3083
|
+
message: `invalid --timeout: ${opts.timeout} (use 30s, 5m, 1h, 2d, or bare integer = minutes)`,
|
|
3084
|
+
});
|
|
3085
|
+
process.exit(1);
|
|
3086
|
+
}
|
|
3087
|
+
if (!Number.isFinite(pollSecs) || pollSecs <= 0) {
|
|
3088
|
+
emit.error({ code: "bad_poll", message: `invalid --poll-secs: ${opts.pollSecs}` });
|
|
3089
|
+
process.exit(1);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
const peerOwner = resolveOwnerByName(name);
|
|
3093
|
+
if (!peerOwner) {
|
|
3094
|
+
emit.error({
|
|
3095
|
+
code: "no_peer",
|
|
3096
|
+
message: `no live agent named "${name}" (case-insensitive)`,
|
|
3097
|
+
});
|
|
3098
|
+
process.exit(1);
|
|
3099
|
+
}
|
|
3100
|
+
const waitFor = new Set(opts.file ?? []);
|
|
3101
|
+
|
|
3102
|
+
const startMs = Date.now();
|
|
3103
|
+
const pollMs = pollSecs * 1000;
|
|
3104
|
+
|
|
3105
|
+
if (!opts.quiet) {
|
|
3106
|
+
const what = waitFor.size > 0 ? `[${Array.from(waitFor).join(", ")}]` : "all held files";
|
|
3107
|
+
const header = `waiting for agent-${name} to release ${what} (poll ${pollSecs}s, timeout ${formatDuration(timeoutMs)})\n`;
|
|
3108
|
+
process.stderr.write(header); // lint-ok-emission: progress banner to stderr; data resolution stays on stdout via ctx()
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
let lastProgressMs = 0;
|
|
3112
|
+
while (true) {
|
|
3113
|
+
const hb = readHeartbeat(peerOwner);
|
|
3114
|
+
const now = Date.now();
|
|
3115
|
+
const elapsedMs = now - startMs;
|
|
3116
|
+
|
|
3117
|
+
if (!hb) {
|
|
3118
|
+
const data = { peer: name, outcome: "gone", elapsed_ms: elapsedMs, files_held: [] };
|
|
3119
|
+
emitWaitResult(data, opts);
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
const held = new Set(hb.files_touched ?? []);
|
|
3123
|
+
const stillBlocking =
|
|
3124
|
+
waitFor.size > 0 ? Array.from(waitFor).filter((f) => held.has(f)) : Array.from(held);
|
|
3125
|
+
|
|
3126
|
+
if (stillBlocking.length === 0) {
|
|
3127
|
+
const data = {
|
|
3128
|
+
peer: name,
|
|
3129
|
+
outcome: "released",
|
|
3130
|
+
elapsed_ms: elapsedMs,
|
|
3131
|
+
files_held: Array.from(held),
|
|
3132
|
+
};
|
|
3133
|
+
emitWaitResult(data, opts);
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
if (elapsedMs >= timeoutMs) {
|
|
3138
|
+
const data = {
|
|
3139
|
+
peer: name,
|
|
3140
|
+
outcome: "timeout",
|
|
3141
|
+
elapsed_ms: elapsedMs,
|
|
3142
|
+
files_held: Array.from(held),
|
|
3143
|
+
still_blocking: stillBlocking,
|
|
3144
|
+
};
|
|
3145
|
+
emitWaitResult(data, opts);
|
|
3146
|
+
process.exit(1);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
// Progress line every ~30s (or every poll if interval > 30s).
|
|
3150
|
+
const progressGapMs = Math.max(pollMs, 30_000);
|
|
3151
|
+
if (!opts.quiet && now - lastProgressMs >= progressGapMs) {
|
|
3152
|
+
lastProgressMs = now;
|
|
3153
|
+
const lastTool = hb.last_tool ? `, last=${hb.last_tool}` : "";
|
|
3154
|
+
const elapsedStr = formatAge(Math.floor(elapsedMs / 1000));
|
|
3155
|
+
const progress = ` [${elapsedStr}] ${stillBlocking.length} file(s) blocking${lastTool}\n`;
|
|
3156
|
+
process.stderr.write(progress); // lint-ok-emission: per-poll progress heartbeat to stderr
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function emitWaitResult(
|
|
3164
|
+
data: {
|
|
3165
|
+
peer: string;
|
|
3166
|
+
outcome: string;
|
|
3167
|
+
elapsed_ms: number;
|
|
3168
|
+
files_held: string[];
|
|
3169
|
+
still_blocking?: string[];
|
|
3170
|
+
},
|
|
3171
|
+
opts: { quiet?: boolean; json?: boolean },
|
|
3172
|
+
): void {
|
|
3173
|
+
if (opts.json) {
|
|
3174
|
+
emit.config({ format: "json" });
|
|
3175
|
+
emit.data(data);
|
|
3176
|
+
return;
|
|
3177
|
+
}
|
|
3178
|
+
emit.data(data);
|
|
3179
|
+
if (opts.quiet) return;
|
|
3180
|
+
const elapsedStr = formatAge(Math.floor(data.elapsed_ms / 1000));
|
|
3181
|
+
if (data.outcome === "released") {
|
|
3182
|
+
emit.text(` ✓ agent-${data.peer} released after ${elapsedStr}\n`);
|
|
3183
|
+
} else if (data.outcome === "gone") {
|
|
3184
|
+
emit.text(` ✓ agent-${data.peer} session ended after ${elapsedStr}\n`);
|
|
3185
|
+
} else {
|
|
3186
|
+
emit.text(
|
|
3187
|
+
` ✗ timed out after ${elapsedStr}; agent-${data.peer} still holds ${data.still_blocking?.length ?? 0} file(s)\n`,
|
|
3188
|
+
);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
function runHeal(opts: {
|
|
3193
|
+
owner: string;
|
|
3194
|
+
kind: string;
|
|
3195
|
+
sessionId?: string;
|
|
3196
|
+
pid?: string;
|
|
3197
|
+
json?: boolean;
|
|
3198
|
+
}): void {
|
|
3199
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3200
|
+
|
|
3201
|
+
const owner = opts.owner.trim();
|
|
3202
|
+
const kind = opts.kind.trim();
|
|
3203
|
+
if (!owner) {
|
|
3204
|
+
emit.error({ code: "missing_owner", message: "--owner is required" });
|
|
3205
|
+
process.exit(1);
|
|
3206
|
+
}
|
|
3207
|
+
if (kind !== "pidmap" && kind !== "heartbeat" && kind !== "kill") {
|
|
3208
|
+
emit.error({
|
|
3209
|
+
code: "bad_kind",
|
|
3210
|
+
message: "--kind must be one of: pidmap, heartbeat, kill",
|
|
3211
|
+
});
|
|
3212
|
+
process.exit(1);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
const root = monorepoRoot();
|
|
3216
|
+
if (!root) {
|
|
3217
|
+
emit.error({
|
|
3218
|
+
code: "not_in_repo",
|
|
3219
|
+
message: "not in an agent session; coord_root() returned null",
|
|
3220
|
+
});
|
|
3221
|
+
process.exit(1);
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
const action =
|
|
3225
|
+
kind === "pidmap" ? "heal-pidmap" : kind === "heartbeat" ? "heal-heartbeat" : "kill-heartbeat";
|
|
3226
|
+
|
|
3227
|
+
// Build positional args. agent-coord's arg layout:
|
|
3228
|
+
// heal-pidmap <instance_id> [<pid>]
|
|
3229
|
+
// heal-heartbeat <instance_id> [<session_id>]
|
|
3230
|
+
// kill-heartbeat <instance_id>
|
|
3231
|
+
const helperArgs: string[] = [action, owner];
|
|
3232
|
+
if (kind === "pidmap" && opts.pid) helperArgs.push(opts.pid);
|
|
3233
|
+
if (kind === "heartbeat" && opts.sessionId) helperArgs.push(opts.sessionId);
|
|
3234
|
+
|
|
3235
|
+
// heal-pidmap / heal-heartbeat / kill-heartbeat are handled by the
|
|
3236
|
+
// agent-coord binary at harnery/bin/agent-coord.
|
|
3237
|
+
const helper = `${root}/harnery/bin/agent-coord`;
|
|
3238
|
+
const proc = spawnSync(helper, helperArgs, {
|
|
3239
|
+
cwd: root,
|
|
3240
|
+
encoding: "utf8",
|
|
3241
|
+
});
|
|
3242
|
+
|
|
3243
|
+
if (proc.status !== 0) {
|
|
3244
|
+
emit.error({
|
|
3245
|
+
code: "heal_failed",
|
|
3246
|
+
message: proc.stderr?.trim() || `agent-coord ${action} exited non-zero`,
|
|
3247
|
+
});
|
|
3248
|
+
process.exit(1);
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// Re-read the heartbeat to surface post-action state (or null if killed).
|
|
3252
|
+
let after: Heartbeat | null = null;
|
|
3253
|
+
try {
|
|
3254
|
+
after = readHeartbeat(owner);
|
|
3255
|
+
} catch {
|
|
3256
|
+
after = null;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// Outcome semantics differ per kind. kill-heartbeat targets the heartbeat
|
|
3260
|
+
// file directly; heal-heartbeat upserts it; heal-pidmap touches a pid-map
|
|
3261
|
+
// row whose existence is independent of the heartbeat. Reporting on
|
|
3262
|
+
// "heartbeat present after" for the pidmap path was misleading: it's
|
|
3263
|
+
// unrelated to whether the heal succeeded.
|
|
3264
|
+
const outcome =
|
|
3265
|
+
kind === "pidmap"
|
|
3266
|
+
? proc.status === 0
|
|
3267
|
+
? "ok"
|
|
3268
|
+
: "failed"
|
|
3269
|
+
: after
|
|
3270
|
+
? "heartbeat_present"
|
|
3271
|
+
: "heartbeat_absent";
|
|
3272
|
+
|
|
3273
|
+
emit.data({
|
|
3274
|
+
rows: [
|
|
3275
|
+
{
|
|
3276
|
+
instance_id: owner,
|
|
3277
|
+
action,
|
|
3278
|
+
outcome,
|
|
3279
|
+
after,
|
|
3280
|
+
},
|
|
3281
|
+
],
|
|
3282
|
+
meta: {
|
|
3283
|
+
kind,
|
|
3284
|
+
helper: "harnery/bin/agent-coord",
|
|
3285
|
+
},
|
|
3286
|
+
});
|
|
3287
|
+
if (!opts.json) {
|
|
3288
|
+
if (kind === "pidmap") {
|
|
3289
|
+
emit.text(`agent-coord ${action} ok\n`);
|
|
3290
|
+
} else {
|
|
3291
|
+
emit.text(`agent-coord ${action} ok: heartbeat ${after ? "present" : "absent"} after\n`);
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
// Canonical health.* emission is owned by the writer (heartbeat-writer.ts
|
|
3296
|
+
// healPidmap/healHeartbeat), so it fires inside the agent-coord subprocess
|
|
3297
|
+
// above on actual writes only: write-only telemetry, no double-emit, no
|
|
3298
|
+
// event when an already-correct heal no-ops. (Previously emitted here
|
|
3299
|
+
// unconditionally on every `harn agents heal`, which over-counted no-op heals.)
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
/**
|
|
3303
|
+
* Shared emitter for council.* events. Looks up the running agent's
|
|
3304
|
+
* heartbeat so each event carries a real instance_id / session_id; falls
|
|
3305
|
+
* through silently if no session (CI / direct invocation).
|
|
3306
|
+
*/
|
|
3307
|
+
function emitCouncilStateEvent(
|
|
3308
|
+
type: string,
|
|
3309
|
+
manifest: CouncilManifest,
|
|
3310
|
+
extraData: Record<string, unknown>,
|
|
3311
|
+
): void {
|
|
3312
|
+
const myOwner = resolveOwner();
|
|
3313
|
+
if (!myOwner) return;
|
|
3314
|
+
const hb = readHeartbeat(myOwner);
|
|
3315
|
+
emitCanonical({
|
|
3316
|
+
type,
|
|
3317
|
+
owner: myOwner,
|
|
3318
|
+
session: hb?.session_id ?? myOwner,
|
|
3319
|
+
harness: normalizeHarness(hb?.platform),
|
|
3320
|
+
data: { council_id: manifest.council_id, ...extraData },
|
|
3321
|
+
});
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// ──────── council subcommand impls ────────
|
|
3325
|
+
|
|
3326
|
+
function runCouncilCreate(
|
|
3327
|
+
objective: string,
|
|
3328
|
+
opts: {
|
|
3329
|
+
members: string;
|
|
3330
|
+
targetDoc?: string;
|
|
3331
|
+
steward?: string;
|
|
3332
|
+
autoAdvance?: boolean;
|
|
3333
|
+
createdBy?: string;
|
|
3334
|
+
json?: boolean;
|
|
3335
|
+
},
|
|
3336
|
+
): void {
|
|
3337
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3338
|
+
|
|
3339
|
+
const trimmedObjective = objective.trim();
|
|
3340
|
+
if (!trimmedObjective) {
|
|
3341
|
+
emit.error({
|
|
3342
|
+
code: "missing_objective",
|
|
3343
|
+
message: "<objective> must be a non-empty string",
|
|
3344
|
+
});
|
|
3345
|
+
process.exit(1);
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
const members = opts.members
|
|
3349
|
+
.split(",")
|
|
3350
|
+
.map((m) => normalizeAgentName(m))
|
|
3351
|
+
.filter(Boolean);
|
|
3352
|
+
if (members.length === 0) {
|
|
3353
|
+
emit.error({
|
|
3354
|
+
code: "no_members",
|
|
3355
|
+
message: "--members must list at least one agent",
|
|
3356
|
+
});
|
|
3357
|
+
process.exit(1);
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
const root = monorepoRoot();
|
|
3361
|
+
if (!root) {
|
|
3362
|
+
emit.error({
|
|
3363
|
+
code: "not_in_repo",
|
|
3364
|
+
message: "not in an agent session; coord_root() returned null",
|
|
3365
|
+
});
|
|
3366
|
+
process.exit(1);
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
// Resolve convener: explicit --created-by overrides; otherwise read the
|
|
3370
|
+
// running agent's heartbeat. Falls back to "agent-unknown" only when neither
|
|
3371
|
+
// path resolves (CI / direct script invocation with no session).
|
|
3372
|
+
const myOwner = resolveOwner();
|
|
3373
|
+
let createdBy = "agent-unknown";
|
|
3374
|
+
if (opts.createdBy?.trim()) {
|
|
3375
|
+
createdBy = normalizeAgentName(opts.createdBy);
|
|
3376
|
+
} else if (myOwner) {
|
|
3377
|
+
const myHb = readHeartbeat(myOwner);
|
|
3378
|
+
if (myHb?.name) {
|
|
3379
|
+
createdBy = normalizeAgentName(myHb.name);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
// Resolve steward: explicit --steward overrides; otherwise defaults to the
|
|
3384
|
+
// convener. If explicit, must be a member of the council.
|
|
3385
|
+
let steward: string | undefined;
|
|
3386
|
+
if (opts.steward?.trim()) {
|
|
3387
|
+
const normalized = normalizeAgentName(opts.steward);
|
|
3388
|
+
if (!members.includes(normalized)) {
|
|
3389
|
+
emit.error({
|
|
3390
|
+
code: "steward_not_a_member",
|
|
3391
|
+
message: `--steward '${normalized}' is not in --members list (${members.join(", ")})`,
|
|
3392
|
+
});
|
|
3393
|
+
process.exit(1);
|
|
3394
|
+
}
|
|
3395
|
+
steward = normalized;
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
// Mint identities for every persona referenced in the manifest (convener,
|
|
3399
|
+
// optional steward, and every member) so the canonical FK arrays are
|
|
3400
|
+
// populated before the manifest hits disk. ensureIdentity is idempotent.
|
|
3401
|
+
const createdByIdentity = ensureIdentity(createdBy);
|
|
3402
|
+
const stewardIdentity = steward ? ensureIdentity(steward) : null;
|
|
3403
|
+
const memberIdentities = members.map((m) => ensureIdentity(m));
|
|
3404
|
+
|
|
3405
|
+
const councilId = buildCouncilId(trimmedObjective);
|
|
3406
|
+
const manifest: CouncilManifest = {
|
|
3407
|
+
schema_version: COUNCIL_SCHEMA_VERSION,
|
|
3408
|
+
council_id: councilId,
|
|
3409
|
+
created_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
3410
|
+
created_by: createdBy,
|
|
3411
|
+
created_by_id: createdByIdentity.agent_id,
|
|
3412
|
+
...(steward && stewardIdentity ? { steward, steward_id: stewardIdentity.agent_id } : {}),
|
|
3413
|
+
objective: trimmedObjective,
|
|
3414
|
+
target_doc: opts.targetDoc?.trim() || null,
|
|
3415
|
+
members,
|
|
3416
|
+
member_ids: memberIdentities.map((m) => m.agent_id),
|
|
3417
|
+
current_round: 1,
|
|
3418
|
+
round_status: "open",
|
|
3419
|
+
status: "active",
|
|
3420
|
+
auto_advance: !!opts.autoAdvance,
|
|
3421
|
+
round_visibility: "next_round",
|
|
3422
|
+
};
|
|
3423
|
+
|
|
3424
|
+
// Create body dir + first round dir + write invite + manifest.
|
|
3425
|
+
const body = councilBodyDir(councilId);
|
|
3426
|
+
if (!body) {
|
|
3427
|
+
emit.error({
|
|
3428
|
+
code: "no_body_dir",
|
|
3429
|
+
message: "could not resolve .harnery/councils/<id>/: coord root missing",
|
|
3430
|
+
});
|
|
3431
|
+
process.exit(1);
|
|
3432
|
+
}
|
|
3433
|
+
mkdirSync(resolve(body, "round-1"), { recursive: true });
|
|
3434
|
+
writeFileSync(resolve(body, "invite.md"), buildInviteMarkdown(manifest), "utf8");
|
|
3435
|
+
writeManifest(manifest);
|
|
3436
|
+
|
|
3437
|
+
if (myOwner) {
|
|
3438
|
+
const myHbForEmit = readHeartbeat(myOwner);
|
|
3439
|
+
emitCanonical({
|
|
3440
|
+
type: "council.open",
|
|
3441
|
+
owner: myOwner,
|
|
3442
|
+
session: myHbForEmit?.session_id ?? myOwner,
|
|
3443
|
+
harness: normalizeHarness(myHbForEmit?.platform),
|
|
3444
|
+
data: {
|
|
3445
|
+
council_id: councilId,
|
|
3446
|
+
topic: trimmedObjective,
|
|
3447
|
+
members,
|
|
3448
|
+
target_doc: manifest.target_doc ?? undefined,
|
|
3449
|
+
},
|
|
3450
|
+
});
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
// Best-effort: ping each currently-active member's scratchpad with a
|
|
3454
|
+
// handoff entry pointing them at the council. Members not currently active
|
|
3455
|
+
// get nothing here; the Phase 2 SessionStart adapter will surface the
|
|
3456
|
+
// invite on their next session.
|
|
3457
|
+
const pingedMembers: string[] = [];
|
|
3458
|
+
const skippedMembers: string[] = [];
|
|
3459
|
+
for (const memberName of members) {
|
|
3460
|
+
const bareName = memberName.replace(/^agent-/, "");
|
|
3461
|
+
if (myOwner && readHeartbeat(myOwner)?.name === bareName) {
|
|
3462
|
+
// Convener is themselves a member; skip the self-ping
|
|
3463
|
+
continue;
|
|
3464
|
+
}
|
|
3465
|
+
const memberOwner = resolveOwnerByName(bareName);
|
|
3466
|
+
if (!memberOwner) {
|
|
3467
|
+
skippedMembers.push(memberName);
|
|
3468
|
+
continue;
|
|
3469
|
+
}
|
|
3470
|
+
try {
|
|
3471
|
+
appendEntry(
|
|
3472
|
+
memberOwner,
|
|
3473
|
+
"handoff",
|
|
3474
|
+
`from ${createdBy} (council create): you're in council \`${councilId}\`; ` +
|
|
3475
|
+
`objective: ${trimmedObjective.slice(0, 140)}${trimmedObjective.length > 140 ? "…" : ""}. ` +
|
|
3476
|
+
`Run \`${resolveBinName()} agents council show ${councilId}\` for context.`,
|
|
3477
|
+
);
|
|
3478
|
+
pingedMembers.push(memberName);
|
|
3479
|
+
} catch {
|
|
3480
|
+
skippedMembers.push(memberName);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
emit.data({
|
|
3485
|
+
rows: [
|
|
3486
|
+
{
|
|
3487
|
+
council_id: councilId,
|
|
3488
|
+
objective: trimmedObjective,
|
|
3489
|
+
members,
|
|
3490
|
+
target_doc: manifest.target_doc,
|
|
3491
|
+
auto_advance: manifest.auto_advance,
|
|
3492
|
+
pinged_members: pingedMembers,
|
|
3493
|
+
skipped_members: skippedMembers,
|
|
3494
|
+
manifest,
|
|
3495
|
+
},
|
|
3496
|
+
],
|
|
3497
|
+
meta: {
|
|
3498
|
+
action: "council-create",
|
|
3499
|
+
created_by: createdBy,
|
|
3500
|
+
},
|
|
3501
|
+
});
|
|
3502
|
+
if (!opts.json) {
|
|
3503
|
+
emit.text(
|
|
3504
|
+
`council ${councilId} created: round 1 open, ${members.length} member(s) (${pingedMembers.length} pinged, ${skippedMembers.length} dormant)\n` +
|
|
3505
|
+
`view: harn agents council show ${councilId}\n`,
|
|
3506
|
+
);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function runCouncilList(opts: { status?: string; mine?: boolean; json?: boolean }): void {
|
|
3511
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3512
|
+
|
|
3513
|
+
const root = monorepoRoot();
|
|
3514
|
+
if (!root) {
|
|
3515
|
+
emit.error({
|
|
3516
|
+
code: "not_in_repo",
|
|
3517
|
+
message: "not in an agent session; coord_root() returned null",
|
|
3518
|
+
});
|
|
3519
|
+
process.exit(1);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
let myName: string | null = null;
|
|
3523
|
+
if (opts.mine) {
|
|
3524
|
+
const myOwner = resolveOwner();
|
|
3525
|
+
if (myOwner) {
|
|
3526
|
+
const myHb = readHeartbeat(myOwner);
|
|
3527
|
+
if (myHb?.name) myName = normalizeAgentName(myHb.name);
|
|
3528
|
+
}
|
|
3529
|
+
if (!myName) {
|
|
3530
|
+
emit.error({
|
|
3531
|
+
code: "no_self_name",
|
|
3532
|
+
message: "--mine requires resolving the running agent's name; no heartbeat found",
|
|
3533
|
+
});
|
|
3534
|
+
process.exit(1);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
const allManifests = listManifests();
|
|
3539
|
+
const filtered = allManifests.filter((m) => {
|
|
3540
|
+
if (opts.status && m.status !== (opts.status as CouncilStatus)) return false;
|
|
3541
|
+
if (opts.mine && myName && !m.members.includes(myName)) return false;
|
|
3542
|
+
return true;
|
|
3543
|
+
});
|
|
3544
|
+
|
|
3545
|
+
filtered.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
3546
|
+
|
|
3547
|
+
emit.data({
|
|
3548
|
+
rows: filtered.map((m) => ({
|
|
3549
|
+
council_id: m.council_id,
|
|
3550
|
+
status: m.status,
|
|
3551
|
+
round: m.current_round,
|
|
3552
|
+
round_status: m.round_status,
|
|
3553
|
+
members: m.members,
|
|
3554
|
+
created_by: m.created_by,
|
|
3555
|
+
created_at: m.created_at,
|
|
3556
|
+
objective: m.objective,
|
|
3557
|
+
target_doc: m.target_doc,
|
|
3558
|
+
auto_advance: m.auto_advance,
|
|
3559
|
+
})),
|
|
3560
|
+
meta: {
|
|
3561
|
+
action: "council-list",
|
|
3562
|
+
total_active_dir: allManifests.length,
|
|
3563
|
+
filtered_count: filtered.length,
|
|
3564
|
+
mine: opts.mine ?? false,
|
|
3565
|
+
status_filter: opts.status ?? null,
|
|
3566
|
+
},
|
|
3567
|
+
});
|
|
3568
|
+
if (!opts.json) {
|
|
3569
|
+
if (filtered.length === 0) {
|
|
3570
|
+
emit.text(
|
|
3571
|
+
opts.mine
|
|
3572
|
+
? "no councils include you as a member.\n"
|
|
3573
|
+
: "no councils in .harnery/councils/.\n",
|
|
3574
|
+
);
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3577
|
+
const lines: string[] = [];
|
|
3578
|
+
for (const m of filtered) {
|
|
3579
|
+
const objShort = m.objective.length > 60 ? `${m.objective.slice(0, 59)}…` : m.objective;
|
|
3580
|
+
lines.push(
|
|
3581
|
+
`${m.council_id} [${m.status}; round ${m.current_round} ${m.round_status}] by ${m.created_by} members=${m.members.length}\n` +
|
|
3582
|
+
` └─ ${objShort}\n`,
|
|
3583
|
+
);
|
|
3584
|
+
}
|
|
3585
|
+
emit.text(lines.join(""));
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
function runCouncilShow(id: string, opts: { json?: boolean }): void {
|
|
3590
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3591
|
+
|
|
3592
|
+
const manifest = readManifest(id) || findManifestByPartialId(id);
|
|
3593
|
+
if (!manifest) {
|
|
3594
|
+
emit.error({
|
|
3595
|
+
code: "council_not_found",
|
|
3596
|
+
message: `no council matching '${id}' in .harnery/councils/`,
|
|
3597
|
+
});
|
|
3598
|
+
process.exit(1);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
// Read invite.md if present
|
|
3602
|
+
const body = councilBodyDir(manifest.council_id);
|
|
3603
|
+
let invite: string | null = null;
|
|
3604
|
+
if (body) {
|
|
3605
|
+
const invitePath = resolve(body, "invite.md");
|
|
3606
|
+
if (existsSync(invitePath)) {
|
|
3607
|
+
invite = readFileSync(invitePath, "utf8");
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
// Read prior rounds' contributions (current round held back per
|
|
3612
|
+
// round_visibility=next_round).
|
|
3613
|
+
const visibleRound = Math.max(0, manifest.current_round - 1);
|
|
3614
|
+
const priorRounds: Array<{
|
|
3615
|
+
round: number;
|
|
3616
|
+
contributions: Array<{ author: string; body: string }>;
|
|
3617
|
+
}> = [];
|
|
3618
|
+
if (body && visibleRound > 0) {
|
|
3619
|
+
for (let r = 1; r <= visibleRound; r++) {
|
|
3620
|
+
const roundDir = resolve(body, `round-${r}`);
|
|
3621
|
+
if (!existsSync(roundDir)) continue;
|
|
3622
|
+
const contribs: Array<{ author: string; body: string }> = [];
|
|
3623
|
+
for (const f of readdirSync(roundDir).sort()) {
|
|
3624
|
+
if (!f.endsWith(".md")) continue;
|
|
3625
|
+
const author = f.slice(0, -3);
|
|
3626
|
+
const content = readFileSync(resolve(roundDir, f), "utf8");
|
|
3627
|
+
contribs.push({ author, body: content });
|
|
3628
|
+
}
|
|
3629
|
+
priorRounds.push({ round: r, contributions: contribs });
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
// Read current-round prompts (steward-drafted routing instructions per
|
|
3634
|
+
// member). Each entry carries `completed` so the UI can dim/strike the
|
|
3635
|
+
// prompts for members who have already contributed this round.
|
|
3636
|
+
const currentRoundPrompts = readRoundPrompts(manifest, manifest.current_round);
|
|
3637
|
+
|
|
3638
|
+
emit.data({
|
|
3639
|
+
rows: [
|
|
3640
|
+
{
|
|
3641
|
+
manifest,
|
|
3642
|
+
invite,
|
|
3643
|
+
prior_rounds: priorRounds,
|
|
3644
|
+
current_round: manifest.current_round,
|
|
3645
|
+
visible_through_round: visibleRound,
|
|
3646
|
+
steward: effectiveSteward(manifest),
|
|
3647
|
+
current_round_prompts: currentRoundPrompts,
|
|
3648
|
+
},
|
|
3649
|
+
],
|
|
3650
|
+
meta: { action: "council-show" },
|
|
3651
|
+
});
|
|
3652
|
+
if (!opts.json) {
|
|
3653
|
+
const lines: string[] = [];
|
|
3654
|
+
if (invite) lines.push(invite);
|
|
3655
|
+
lines.push("---\n");
|
|
3656
|
+
lines.push(
|
|
3657
|
+
`**Status:** ${manifest.status}, round ${manifest.current_round} ${manifest.round_status}\n`,
|
|
3658
|
+
);
|
|
3659
|
+
if (priorRounds.length > 0) {
|
|
3660
|
+
lines.push("\n## Prior rounds\n");
|
|
3661
|
+
for (const r of priorRounds) {
|
|
3662
|
+
lines.push(`\n### Round ${r.round}\n`);
|
|
3663
|
+
for (const c of r.contributions) {
|
|
3664
|
+
lines.push(`\n#### ${c.author}\n\n${c.body}\n`);
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
} else if (manifest.current_round > 1) {
|
|
3668
|
+
lines.push("\n_(Prior rounds exist but no contributions on disk yet.)_\n");
|
|
3669
|
+
} else {
|
|
3670
|
+
lines.push(
|
|
3671
|
+
"\n_Round 1 open. Peer contributions surface here once round 2 opens (round_visibility=next_round)._\n",
|
|
3672
|
+
);
|
|
3673
|
+
}
|
|
3674
|
+
emit.text(lines.join(""));
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
function runCouncilClose(id: string, opts: { json?: boolean }): void {
|
|
3679
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3680
|
+
|
|
3681
|
+
const manifest = readManifest(id) || findManifestByPartialId(id);
|
|
3682
|
+
if (!manifest) {
|
|
3683
|
+
emit.error({
|
|
3684
|
+
code: "council_not_found",
|
|
3685
|
+
message: `no council matching '${id}' in .harnery/councils/`,
|
|
3686
|
+
});
|
|
3687
|
+
process.exit(1);
|
|
3688
|
+
}
|
|
3689
|
+
if (manifest.status === "archived") {
|
|
3690
|
+
emit.error({
|
|
3691
|
+
code: "already_archived",
|
|
3692
|
+
message: `council ${manifest.council_id} is already archived; close is a no-op`,
|
|
3693
|
+
});
|
|
3694
|
+
process.exit(1);
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
const next: CouncilManifest = {
|
|
3698
|
+
...manifest,
|
|
3699
|
+
status: "closed",
|
|
3700
|
+
closed_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
3701
|
+
};
|
|
3702
|
+
writeManifest(next);
|
|
3703
|
+
emitCouncilStateEvent("council.close", next, { closed_at: next.closed_at! });
|
|
3704
|
+
|
|
3705
|
+
// Build the transcript: every round's contributions in order.
|
|
3706
|
+
const transcript = buildTranscript(next);
|
|
3707
|
+
|
|
3708
|
+
emit.data({
|
|
3709
|
+
rows: [
|
|
3710
|
+
{
|
|
3711
|
+
council_id: next.council_id,
|
|
3712
|
+
status: next.status,
|
|
3713
|
+
closed_at: next.closed_at,
|
|
3714
|
+
rounds_with_contributions: transcript.rounds.length,
|
|
3715
|
+
manifest: next,
|
|
3716
|
+
},
|
|
3717
|
+
],
|
|
3718
|
+
meta: { action: "council-close" },
|
|
3719
|
+
});
|
|
3720
|
+
if (!opts.json) {
|
|
3721
|
+
emit.text(
|
|
3722
|
+
`council ${next.council_id} closed at ${next.closed_at}.\nmanifest kept in .harnery/councils/ (use 'harn agents council archive ${next.council_id}' to move it).\n\n${transcript.markdown}`,
|
|
3723
|
+
);
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
function runCouncilArchive(id: string, opts: { json?: boolean }): void {
|
|
3728
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3729
|
+
|
|
3730
|
+
const manifest = readManifest(id) || findManifestByPartialId(id);
|
|
3731
|
+
if (!manifest) {
|
|
3732
|
+
emit.error({
|
|
3733
|
+
code: "council_not_found",
|
|
3734
|
+
message: `no council matching '${id}' in .harnery/councils/`,
|
|
3735
|
+
});
|
|
3736
|
+
process.exit(1);
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
const next: CouncilManifest = {
|
|
3740
|
+
...manifest,
|
|
3741
|
+
status: "archived",
|
|
3742
|
+
archived_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
3743
|
+
};
|
|
3744
|
+
// Write the archived manifest BEFORE moving, so the moved file carries the
|
|
3745
|
+
// updated status. moveToArchive then physically relocates the artifacts.
|
|
3746
|
+
writeManifest(next);
|
|
3747
|
+
moveToArchive(next.council_id);
|
|
3748
|
+
emitCouncilStateEvent("council.archive", next, {});
|
|
3749
|
+
|
|
3750
|
+
emit.data({
|
|
3751
|
+
rows: [
|
|
3752
|
+
{
|
|
3753
|
+
council_id: next.council_id,
|
|
3754
|
+
status: next.status,
|
|
3755
|
+
archived_at: next.archived_at,
|
|
3756
|
+
manifest: next,
|
|
3757
|
+
},
|
|
3758
|
+
],
|
|
3759
|
+
meta: { action: "council-archive" },
|
|
3760
|
+
});
|
|
3761
|
+
if (!opts.json) {
|
|
3762
|
+
emit.text(
|
|
3763
|
+
`council ${next.council_id} archived at ${next.archived_at}, moved to .harnery/councils/archive/.\n`,
|
|
3764
|
+
);
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
function runCouncilUnarchive(id: string, opts: { json?: boolean }): void {
|
|
3769
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3770
|
+
|
|
3771
|
+
// Unarchive sources from the archive dir; the active dir is empty by
|
|
3772
|
+
// definition for an archived council. readArchivedManifest scopes the
|
|
3773
|
+
// lookup; we accept the full council_id only here (no partial-id
|
|
3774
|
+
// search across archive) to keep the safety surface tight.
|
|
3775
|
+
const manifest = readArchivedManifest(id);
|
|
3776
|
+
if (!manifest) {
|
|
3777
|
+
emit.error({
|
|
3778
|
+
code: "council_not_found",
|
|
3779
|
+
message: `no archived council matching '${id}' in .harnery/councils/archive/`,
|
|
3780
|
+
});
|
|
3781
|
+
process.exit(1);
|
|
3782
|
+
}
|
|
3783
|
+
if (manifest.status !== "archived") {
|
|
3784
|
+
emit.error({
|
|
3785
|
+
code: "council_not_archived",
|
|
3786
|
+
message: `council ${manifest.council_id} is ${manifest.status}, not archived; nothing to unarchive`,
|
|
3787
|
+
});
|
|
3788
|
+
process.exit(1);
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
// Restore status from closed_at: set means it was closed before archive,
|
|
3792
|
+
// empty means it was archived from an active state (unusual but valid).
|
|
3793
|
+
const restoredStatus: CouncilManifest["status"] = manifest.closed_at ? "closed" : "active";
|
|
3794
|
+
// Strip archived_at off the manifest. Keep closed_at if it was set so the
|
|
3795
|
+
// close-out handoff detection + banner state survive the round-trip.
|
|
3796
|
+
const { archived_at: _archived_at, ...rest } = manifest;
|
|
3797
|
+
void _archived_at;
|
|
3798
|
+
const next: CouncilManifest = {
|
|
3799
|
+
...rest,
|
|
3800
|
+
status: restoredStatus,
|
|
3801
|
+
};
|
|
3802
|
+
// Physically move first (rename within active dir), then write the
|
|
3803
|
+
// updated manifest. moveFromArchive is no-op when source missing
|
|
3804
|
+
// (allows re-running for testing).
|
|
3805
|
+
moveFromArchive(next.council_id);
|
|
3806
|
+
writeManifest(next);
|
|
3807
|
+
emitCouncilStateEvent("council.unarchive", next, { restored_status: restoredStatus });
|
|
3808
|
+
|
|
3809
|
+
emit.data({
|
|
3810
|
+
rows: [
|
|
3811
|
+
{
|
|
3812
|
+
council_id: next.council_id,
|
|
3813
|
+
status: next.status,
|
|
3814
|
+
closed_at: next.closed_at,
|
|
3815
|
+
manifest: next,
|
|
3816
|
+
},
|
|
3817
|
+
],
|
|
3818
|
+
meta: { action: "council-unarchive" },
|
|
3819
|
+
});
|
|
3820
|
+
if (!opts.json) {
|
|
3821
|
+
emit.text(
|
|
3822
|
+
`council ${next.council_id} unarchived: status restored to ${next.status}, manifest moved back to .harnery/councils/.\n`,
|
|
3823
|
+
);
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
function runCouncilDelete(id: string, opts: { yes?: boolean; json?: boolean }): void {
|
|
3828
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3829
|
+
|
|
3830
|
+
// Source from the archive dir only; refusing implicit-by-omission means
|
|
3831
|
+
// we never confuse delete with archive.
|
|
3832
|
+
const manifest = readArchivedManifest(id);
|
|
3833
|
+
if (!manifest) {
|
|
3834
|
+
emit.error({
|
|
3835
|
+
code: "council_not_archived",
|
|
3836
|
+
message: `no archived council matching '${id}' in .harnery/councils/archive/; archive it first (the trash-can pattern; archive is reversible, delete is not)`,
|
|
3837
|
+
});
|
|
3838
|
+
process.exit(1);
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
const archive = councilsArchiveDir();
|
|
3842
|
+
const manifestPath = archive ? `${archive}/${manifest.council_id}.json` : null;
|
|
3843
|
+
const bodyDir = archive ? `${archive}/${manifest.council_id}` : null;
|
|
3844
|
+
|
|
3845
|
+
if (!opts.yes) {
|
|
3846
|
+
// Dry-run: print the targets and exit 0. The web UI doesn't go through
|
|
3847
|
+
// this path (it always passes --yes) so this gate only
|
|
3848
|
+
// catches operator-side fumbles.
|
|
3849
|
+
emit.data({
|
|
3850
|
+
rows: [
|
|
3851
|
+
{
|
|
3852
|
+
council_id: manifest.council_id,
|
|
3853
|
+
would_delete: [manifestPath, bodyDir].filter(Boolean),
|
|
3854
|
+
confirmed: false,
|
|
3855
|
+
},
|
|
3856
|
+
],
|
|
3857
|
+
meta: { action: "council-delete", dry_run: true },
|
|
3858
|
+
});
|
|
3859
|
+
if (!opts.json) {
|
|
3860
|
+
emit.text(
|
|
3861
|
+
`dry-run, would delete:\n ${manifestPath}\n ${bodyDir}/\npass --yes to confirm.\n`,
|
|
3862
|
+
);
|
|
3863
|
+
}
|
|
3864
|
+
return;
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
const removed = deleteArchivedCouncil(manifest.council_id);
|
|
3868
|
+
if (removed) {
|
|
3869
|
+
emitCouncilStateEvent("council.delete", manifest, {});
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
emit.data({
|
|
3873
|
+
rows: [
|
|
3874
|
+
{
|
|
3875
|
+
council_id: manifest.council_id,
|
|
3876
|
+
removed,
|
|
3877
|
+
confirmed: true,
|
|
3878
|
+
},
|
|
3879
|
+
],
|
|
3880
|
+
meta: { action: "council-delete" },
|
|
3881
|
+
});
|
|
3882
|
+
if (!opts.json) {
|
|
3883
|
+
emit.text(
|
|
3884
|
+
removed
|
|
3885
|
+
? `council ${manifest.council_id} deleted: manifest + body dir removed from .harnery/councils/archive/.\n`
|
|
3886
|
+
: `council ${manifest.council_id} had nothing to delete (already gone).\n`,
|
|
3887
|
+
);
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
function runCouncilSetSteward(
|
|
3892
|
+
id: string,
|
|
3893
|
+
stewardArg: string | undefined,
|
|
3894
|
+
opts: { clear?: boolean; allowUnknown?: boolean; json?: boolean },
|
|
3895
|
+
): void {
|
|
3896
|
+
if (opts.json) emit.config({ format: "json" });
|
|
3897
|
+
|
|
3898
|
+
const lookup = readManifest(id) || findManifestByPartialId(id);
|
|
3899
|
+
if (!lookup) {
|
|
3900
|
+
emit.error({
|
|
3901
|
+
code: "council_not_found",
|
|
3902
|
+
message: `no council matching '${id}' in .harnery/councils/`,
|
|
3903
|
+
});
|
|
3904
|
+
process.exit(1);
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
let steward: string | null;
|
|
3908
|
+
if (opts.clear || !stewardArg) {
|
|
3909
|
+
steward = null;
|
|
3910
|
+
} else {
|
|
3911
|
+
steward = normalizeAgentName(stewardArg);
|
|
3912
|
+
if (!/^agent-[A-Za-z][A-Za-z0-9_-]*$/.test(steward)) {
|
|
3913
|
+
emit.error({
|
|
3914
|
+
code: "invalid_steward",
|
|
3915
|
+
message: `invalid steward '${stewardArg}' (must match agent-[A-Za-z][A-Za-z0-9_-]*)`,
|
|
3916
|
+
});
|
|
3917
|
+
process.exit(1);
|
|
3918
|
+
}
|
|
3919
|
+
if (!opts.allowUnknown) {
|
|
3920
|
+
const known = listKnownAgents();
|
|
3921
|
+
if (!known.some((a) => a.name === steward)) {
|
|
3922
|
+
const known_names = known.map((a) => a.name).join(", ") || "(none)";
|
|
3923
|
+
emit.error({
|
|
3924
|
+
code: "steward_not_known",
|
|
3925
|
+
message: `'${steward}' is not a known agent (active heartbeats + scratchpads archived in the last 30 days). Pass --allow-unknown to bootstrap. Known: ${known_names}`,
|
|
3926
|
+
});
|
|
3927
|
+
process.exit(1);
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
let next: CouncilManifest;
|
|
3933
|
+
try {
|
|
3934
|
+
next = setCouncilSteward(lookup.council_id, steward);
|
|
3935
|
+
} catch (err) {
|
|
3936
|
+
emit.error({
|
|
3937
|
+
code: "council_set_steward_failed",
|
|
3938
|
+
message: err instanceof Error ? err.message : String(err),
|
|
3939
|
+
});
|
|
3940
|
+
process.exit(1);
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
emit.data({
|
|
3944
|
+
rows: [
|
|
3945
|
+
{
|
|
3946
|
+
council_id: next.council_id,
|
|
3947
|
+
status: next.status,
|
|
3948
|
+
steward: next.steward ?? null,
|
|
3949
|
+
manifest: next,
|
|
3950
|
+
},
|
|
3951
|
+
],
|
|
3952
|
+
meta: { action: "council-set-steward" },
|
|
3953
|
+
});
|
|
3954
|
+
if (!opts.json) {
|
|
3955
|
+
const label = steward ?? `(cleared, defaults to ${next.created_by})`;
|
|
3956
|
+
emit.text(`council ${next.council_id} steward set to ${label}.\n`);
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
/** Build a markdown transcript of every round's contributions on disk. */
|
|
3961
|
+
function buildTranscript(manifest: CouncilManifest): {
|
|
3962
|
+
markdown: string;
|
|
3963
|
+
rounds: Array<{ round: number; contributions: number }>;
|
|
3964
|
+
} {
|
|
3965
|
+
const body = councilBodyDir(manifest.council_id);
|
|
3966
|
+
const out: string[] = [];
|
|
3967
|
+
const rounds: Array<{ round: number; contributions: number }> = [];
|
|
3968
|
+
out.push(`# Council transcript: ${manifest.council_id}\n`);
|
|
3969
|
+
out.push(`**Objective:** ${manifest.objective}\n`);
|
|
3970
|
+
out.push(`**Members:** ${manifest.members.join(", ")}\n`);
|
|
3971
|
+
out.push(`**Convened by:** ${manifest.created_by}\n`);
|
|
3972
|
+
out.push(`**Status:** ${manifest.status}`);
|
|
3973
|
+
if (manifest.closed_at) out.push(` (closed ${manifest.closed_at})`);
|
|
3974
|
+
out.push("\n\n");
|
|
3975
|
+
|
|
3976
|
+
if (!body || !existsSync(body)) {
|
|
3977
|
+
return { markdown: out.join(""), rounds };
|
|
3978
|
+
}
|
|
3979
|
+
for (let r = 1; r <= manifest.current_round; r++) {
|
|
3980
|
+
const roundDir = resolve(body, `round-${r}`);
|
|
3981
|
+
if (!existsSync(roundDir)) continue;
|
|
3982
|
+
const files = readdirSync(roundDir)
|
|
3983
|
+
.filter((f) => f.endsWith(".md"))
|
|
3984
|
+
.sort();
|
|
3985
|
+
if (files.length === 0) continue;
|
|
3986
|
+
rounds.push({ round: r, contributions: files.length });
|
|
3987
|
+
out.push(`## Round ${r}\n\n`);
|
|
3988
|
+
for (const f of files) {
|
|
3989
|
+
const author = f.slice(0, -3);
|
|
3990
|
+
const content = readFileSync(resolve(roundDir, f), "utf8");
|
|
3991
|
+
out.push(`### ${author}\n\n${content}\n\n`);
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
return { markdown: out.join(""), rounds };
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
const CONTRIBUTION_MAX_BYTES = 4 * 1024;
|
|
3998
|
+
|
|
3999
|
+
function runCouncilContribute(
|
|
4000
|
+
id: string,
|
|
4001
|
+
opts: { message?: string; file?: string; as?: string; json?: boolean },
|
|
4002
|
+
): void {
|
|
4003
|
+
if (opts.json) emit.config({ format: "json" });
|
|
4004
|
+
|
|
4005
|
+
if (!opts.message && !opts.file) {
|
|
4006
|
+
emit.error({
|
|
4007
|
+
code: "missing_body",
|
|
4008
|
+
message: "must pass either --message <inline> or --file <path>",
|
|
4009
|
+
});
|
|
4010
|
+
process.exit(1);
|
|
4011
|
+
}
|
|
4012
|
+
if (opts.message && opts.file) {
|
|
4013
|
+
emit.error({
|
|
4014
|
+
code: "ambiguous_body",
|
|
4015
|
+
message: "pass only one of --message or --file, not both",
|
|
4016
|
+
});
|
|
4017
|
+
process.exit(1);
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
const manifest = readManifest(id) || findManifestByPartialId(id);
|
|
4021
|
+
if (!manifest) {
|
|
4022
|
+
emit.error({
|
|
4023
|
+
code: "council_not_found",
|
|
4024
|
+
message: `no council matching '${id}'`,
|
|
4025
|
+
});
|
|
4026
|
+
process.exit(1);
|
|
4027
|
+
}
|
|
4028
|
+
if (manifest.status !== "active") {
|
|
4029
|
+
emit.error({
|
|
4030
|
+
code: "council_not_active",
|
|
4031
|
+
message: `council ${manifest.council_id} is ${manifest.status}; cannot accept contributions`,
|
|
4032
|
+
});
|
|
4033
|
+
process.exit(1);
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
// Resolve the contributor name. Two paths:
|
|
4037
|
+
// 1. --as <member> override: caller explicitly names the council seat. Used
|
|
4038
|
+
// for cross-harness councils where each reviewer agent has a different
|
|
4039
|
+
// auto-generated session name from a different name pool; they can
|
|
4040
|
+
// contribute under a fixed seat name without renaming the session.
|
|
4041
|
+
// 2. Heartbeat-derived (default): resolve owner via ppid walk + read the
|
|
4042
|
+
// .name field on the heartbeat. Original behavior.
|
|
4043
|
+
let myName: string;
|
|
4044
|
+
let actualName: string | null = null;
|
|
4045
|
+
if (opts.as) {
|
|
4046
|
+
myName = normalizeAgentName(opts.as);
|
|
4047
|
+
if (!manifest.members.includes(myName)) {
|
|
4048
|
+
emit.error({
|
|
4049
|
+
code: "not_a_member",
|
|
4050
|
+
message: `--as '${myName}' is not a member of council ${manifest.council_id}; members: ${manifest.members.join(", ")}`,
|
|
4051
|
+
});
|
|
4052
|
+
process.exit(1);
|
|
4053
|
+
}
|
|
4054
|
+
// Best-effort: capture the actual session name for the stderr note.
|
|
4055
|
+
// Failure is non-fatal; the override is the whole point.
|
|
4056
|
+
try {
|
|
4057
|
+
const myOwner = resolveOwner();
|
|
4058
|
+
if (myOwner) {
|
|
4059
|
+
const myHb = readHeartbeat(myOwner);
|
|
4060
|
+
if (myHb?.name) actualName = normalizeAgentName(myHb.name);
|
|
4061
|
+
}
|
|
4062
|
+
} catch {
|
|
4063
|
+
/* non-fatal */
|
|
4064
|
+
}
|
|
4065
|
+
} else {
|
|
4066
|
+
const myOwner = resolveOwner();
|
|
4067
|
+
if (!myOwner) {
|
|
4068
|
+
emit.error({
|
|
4069
|
+
code: "no_self",
|
|
4070
|
+
message:
|
|
4071
|
+
"not in an agent session; can't determine who is contributing (pass --as <member> to override)",
|
|
4072
|
+
});
|
|
4073
|
+
process.exit(1);
|
|
4074
|
+
}
|
|
4075
|
+
const myHb = readHeartbeat(myOwner);
|
|
4076
|
+
if (!myHb?.name) {
|
|
4077
|
+
emit.error({
|
|
4078
|
+
code: "no_self_name",
|
|
4079
|
+
message: `resolved owner ${myOwner.slice(0, 8)} has no name on heartbeat (pass --as <member> to override)`,
|
|
4080
|
+
});
|
|
4081
|
+
process.exit(1);
|
|
4082
|
+
}
|
|
4083
|
+
myName = normalizeAgentName(myHb.name);
|
|
4084
|
+
if (!manifest.members.includes(myName)) {
|
|
4085
|
+
emit.error({
|
|
4086
|
+
code: "not_a_member",
|
|
4087
|
+
message: `${myName} is not a member of council ${manifest.council_id}; members: ${manifest.members.join(", ")} (pass --as <member> to override)`,
|
|
4088
|
+
});
|
|
4089
|
+
process.exit(1);
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// Load body
|
|
4094
|
+
let body: string;
|
|
4095
|
+
if (opts.message) {
|
|
4096
|
+
if (opts.message.length > CONTRIBUTION_MAX_BYTES) {
|
|
4097
|
+
emit.error({
|
|
4098
|
+
code: "message_too_long",
|
|
4099
|
+
message: `--message exceeds ${CONTRIBUTION_MAX_BYTES} byte cap; use --file for longer contributions`,
|
|
4100
|
+
});
|
|
4101
|
+
process.exit(1);
|
|
4102
|
+
}
|
|
4103
|
+
body = opts.message;
|
|
4104
|
+
} else {
|
|
4105
|
+
const filePath = opts.file as string;
|
|
4106
|
+
if (!existsSync(filePath)) {
|
|
4107
|
+
emit.error({
|
|
4108
|
+
code: "file_not_found",
|
|
4109
|
+
message: `--file path does not exist: ${filePath}`,
|
|
4110
|
+
});
|
|
4111
|
+
process.exit(1);
|
|
4112
|
+
}
|
|
4113
|
+
body = readFileSync(filePath, "utf8");
|
|
4114
|
+
}
|
|
4115
|
+
|
|
4116
|
+
// Idempotency: if the contributor already has a file in this round, refuse
|
|
4117
|
+
// (force re-contribution would erase prior work without a "are you sure").
|
|
4118
|
+
const existing = contributorsInRound(manifest.council_id, manifest.current_round);
|
|
4119
|
+
if (existing.includes(myName)) {
|
|
4120
|
+
emit.error({
|
|
4121
|
+
code: "already_contributed",
|
|
4122
|
+
message: `${myName} already contributed to round ${manifest.current_round}; delete .harnery/councils/${manifest.council_id}/round-${manifest.current_round}/${myName}.md first if you need to re-submit`,
|
|
4123
|
+
});
|
|
4124
|
+
process.exit(1);
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
const path = writeContribution(manifest.council_id, manifest.current_round, myName, body);
|
|
4128
|
+
emitCouncilStateEvent("council.contribution", manifest, {
|
|
4129
|
+
round_no: manifest.current_round,
|
|
4130
|
+
member: myName,
|
|
4131
|
+
body_summary: body.length > 1000 ? `${body.slice(0, 997)}...` : body,
|
|
4132
|
+
});
|
|
4133
|
+
|
|
4134
|
+
// Update manifest: if all members have now contributed, flip round_status.
|
|
4135
|
+
const contributorsNow = contributorsInRound(manifest.council_id, manifest.current_round);
|
|
4136
|
+
const allIn = manifest.members.every((m) => contributorsNow.includes(m));
|
|
4137
|
+
let nextManifest = manifest;
|
|
4138
|
+
let autoAdvanced = false;
|
|
4139
|
+
if (allIn) {
|
|
4140
|
+
nextManifest = { ...manifest, round_status: "collected" };
|
|
4141
|
+
writeManifest(nextManifest);
|
|
4142
|
+
emitCouncilStateEvent("council.round_close", nextManifest, {
|
|
4143
|
+
round_no: nextManifest.current_round,
|
|
4144
|
+
});
|
|
4145
|
+
if (manifest.auto_advance) {
|
|
4146
|
+
nextManifest = advanceCouncil(nextManifest, /*force=*/ false);
|
|
4147
|
+
emitCouncilStateEvent("council.round_open", nextManifest, {
|
|
4148
|
+
round_no: nextManifest.current_round,
|
|
4149
|
+
});
|
|
4150
|
+
autoAdvanced = true;
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
emit.data({
|
|
4155
|
+
rows: [
|
|
4156
|
+
{
|
|
4157
|
+
council_id: manifest.council_id,
|
|
4158
|
+
contributor: myName,
|
|
4159
|
+
actual_session_name: actualName,
|
|
4160
|
+
round: manifest.current_round,
|
|
4161
|
+
bytes_written: Buffer.byteLength(body, "utf8"),
|
|
4162
|
+
path,
|
|
4163
|
+
round_status: nextManifest.round_status,
|
|
4164
|
+
all_members_in: allIn,
|
|
4165
|
+
auto_advanced: autoAdvanced,
|
|
4166
|
+
current_round: nextManifest.current_round,
|
|
4167
|
+
},
|
|
4168
|
+
],
|
|
4169
|
+
meta: { action: "council-contribute" },
|
|
4170
|
+
});
|
|
4171
|
+
if (!opts.json) {
|
|
4172
|
+
let summary = `${myName} contributed to round ${manifest.current_round} of ${manifest.council_id} (${contributorsNow.length}/${manifest.members.length} members in).`;
|
|
4173
|
+
if (allIn) summary += " round is collected";
|
|
4174
|
+
if (autoAdvanced) summary += `; auto-advanced to round ${nextManifest.current_round}.`;
|
|
4175
|
+
else if (allIn) summary += ".";
|
|
4176
|
+
if (opts.as && actualName && actualName !== myName) {
|
|
4177
|
+
summary += ` (contributed as '${myName}'; actual session is '${actualName}')`;
|
|
4178
|
+
}
|
|
4179
|
+
emit.text(`${summary}\n`);
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
function runCouncilPrompt(
|
|
4184
|
+
id: string,
|
|
4185
|
+
member: string,
|
|
4186
|
+
opts: { message?: string; file?: string; as?: string; json?: boolean },
|
|
4187
|
+
): void {
|
|
4188
|
+
if (opts.json) emit.config({ format: "json" });
|
|
4189
|
+
|
|
4190
|
+
if (!opts.message && !opts.file) {
|
|
4191
|
+
emit.error({
|
|
4192
|
+
code: "missing_body",
|
|
4193
|
+
message: "must pass either --message <inline> or --file <path>",
|
|
4194
|
+
});
|
|
4195
|
+
process.exit(1);
|
|
4196
|
+
}
|
|
4197
|
+
if (opts.message && opts.file) {
|
|
4198
|
+
emit.error({
|
|
4199
|
+
code: "ambiguous_body",
|
|
4200
|
+
message: "pass only one of --message or --file, not both",
|
|
4201
|
+
});
|
|
4202
|
+
process.exit(1);
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
const manifest = readManifest(id) || findManifestByPartialId(id);
|
|
4206
|
+
if (!manifest) {
|
|
4207
|
+
emit.error({
|
|
4208
|
+
code: "council_not_found",
|
|
4209
|
+
message: `no council matching '${id}'`,
|
|
4210
|
+
});
|
|
4211
|
+
process.exit(1);
|
|
4212
|
+
}
|
|
4213
|
+
if (manifest.status !== "active") {
|
|
4214
|
+
emit.error({
|
|
4215
|
+
code: "council_not_active",
|
|
4216
|
+
message: `council ${manifest.council_id} is ${manifest.status}; cannot accept prompts`,
|
|
4217
|
+
});
|
|
4218
|
+
process.exit(1);
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
// Validate target member is on the council.
|
|
4222
|
+
const targetName = normalizeAgentName(member);
|
|
4223
|
+
if (!manifest.members.includes(targetName)) {
|
|
4224
|
+
emit.error({
|
|
4225
|
+
code: "not_a_member",
|
|
4226
|
+
message: `'${targetName}' is not a member of council ${manifest.council_id}; members: ${manifest.members.join(", ")}`,
|
|
4227
|
+
});
|
|
4228
|
+
process.exit(1);
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
// Resolve caller identity (steward authority check). Same --as override
|
|
4232
|
+
// shape as `contribute` for cross-harness scripting.
|
|
4233
|
+
let callerName: string;
|
|
4234
|
+
let actualName: string | null = null;
|
|
4235
|
+
if (opts.as) {
|
|
4236
|
+
callerName = normalizeAgentName(opts.as);
|
|
4237
|
+
try {
|
|
4238
|
+
const myOwner = resolveOwner();
|
|
4239
|
+
if (myOwner) {
|
|
4240
|
+
const myHb = readHeartbeat(myOwner);
|
|
4241
|
+
if (myHb?.name) actualName = normalizeAgentName(myHb.name);
|
|
4242
|
+
}
|
|
4243
|
+
} catch {
|
|
4244
|
+
/* non-fatal */
|
|
4245
|
+
}
|
|
4246
|
+
} else {
|
|
4247
|
+
const myOwner = resolveOwner();
|
|
4248
|
+
if (!myOwner) {
|
|
4249
|
+
emit.error({
|
|
4250
|
+
code: "no_self",
|
|
4251
|
+
message:
|
|
4252
|
+
"not in an agent session; can't determine steward identity (pass --as <steward> to override)",
|
|
4253
|
+
});
|
|
4254
|
+
process.exit(1);
|
|
4255
|
+
}
|
|
4256
|
+
const myHb = readHeartbeat(myOwner);
|
|
4257
|
+
if (!myHb?.name) {
|
|
4258
|
+
emit.error({
|
|
4259
|
+
code: "no_self_name",
|
|
4260
|
+
message: `resolved owner ${myOwner.slice(0, 8)} has no name on heartbeat (pass --as <steward> to override)`,
|
|
4261
|
+
});
|
|
4262
|
+
process.exit(1);
|
|
4263
|
+
}
|
|
4264
|
+
callerName = normalizeAgentName(myHb.name);
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
// Steward authority: only the designated steward (defaults to convener) may
|
|
4268
|
+
// write prompts. This stops peer contributors from overwriting each other's
|
|
4269
|
+
// routing instructions mid-council.
|
|
4270
|
+
const stewardName = effectiveSteward(manifest);
|
|
4271
|
+
if (callerName !== stewardName) {
|
|
4272
|
+
emit.error({
|
|
4273
|
+
code: "not_the_steward",
|
|
4274
|
+
message: `${callerName} is not the steward of council ${manifest.council_id} (steward: ${stewardName}). Stewardship is set at council creation via --steward, or by direct manifest edit.`,
|
|
4275
|
+
});
|
|
4276
|
+
process.exit(1);
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
// Load body
|
|
4280
|
+
let body: string;
|
|
4281
|
+
if (opts.message) {
|
|
4282
|
+
if (opts.message.length > CONTRIBUTION_MAX_BYTES) {
|
|
4283
|
+
emit.error({
|
|
4284
|
+
code: "message_too_long",
|
|
4285
|
+
message: `--message exceeds ${CONTRIBUTION_MAX_BYTES} byte cap; use --file for longer prompts`,
|
|
4286
|
+
});
|
|
4287
|
+
process.exit(1);
|
|
4288
|
+
}
|
|
4289
|
+
body = opts.message;
|
|
4290
|
+
} else {
|
|
4291
|
+
const filePath = opts.file as string;
|
|
4292
|
+
if (!existsSync(filePath)) {
|
|
4293
|
+
emit.error({
|
|
4294
|
+
code: "file_not_found",
|
|
4295
|
+
message: `--file path does not exist: ${filePath}`,
|
|
4296
|
+
});
|
|
4297
|
+
process.exit(1);
|
|
4298
|
+
}
|
|
4299
|
+
body = readFileSync(filePath, "utf8");
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
// Prompts are idempotent: overwriting an existing one is intended (the
|
|
4303
|
+
// steward refines as the round evolves). No "already wrote" guard here.
|
|
4304
|
+
const path = writePrompt(manifest.council_id, manifest.current_round, targetName, body);
|
|
4305
|
+
|
|
4306
|
+
// Did the target already contribute? If so, the prompt is being written
|
|
4307
|
+
// for archival/audit only; surface that to the steward.
|
|
4308
|
+
const contributorsNow = contributorsInRound(manifest.council_id, manifest.current_round);
|
|
4309
|
+
const targetAlreadyIn = contributorsNow.includes(targetName);
|
|
4310
|
+
|
|
4311
|
+
emit.data({
|
|
4312
|
+
rows: [
|
|
4313
|
+
{
|
|
4314
|
+
council_id: manifest.council_id,
|
|
4315
|
+
steward: stewardName,
|
|
4316
|
+
target: targetName,
|
|
4317
|
+
actual_session_name: actualName,
|
|
4318
|
+
round: manifest.current_round,
|
|
4319
|
+
bytes_written: Buffer.byteLength(body, "utf8"),
|
|
4320
|
+
path,
|
|
4321
|
+
target_already_contributed: targetAlreadyIn,
|
|
4322
|
+
},
|
|
4323
|
+
],
|
|
4324
|
+
meta: { action: "council-prompt" },
|
|
4325
|
+
});
|
|
4326
|
+
if (!opts.json) {
|
|
4327
|
+
let summary = `${stewardName} wrote round-${manifest.current_round} prompt for ${targetName} (${Buffer.byteLength(body, "utf8")} bytes).`;
|
|
4328
|
+
if (targetAlreadyIn) {
|
|
4329
|
+
summary += ` Note: ${targetName} has already contributed to this round.`;
|
|
4330
|
+
}
|
|
4331
|
+
if (opts.as && actualName && actualName !== callerName) {
|
|
4332
|
+
summary += ` (acting as '${callerName}'; actual session is '${actualName}')`;
|
|
4333
|
+
}
|
|
4334
|
+
emit.text(`${summary}\n`);
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
function runCouncilStatus(id: string, opts: { json?: boolean }): void {
|
|
4339
|
+
if (opts.json) emit.config({ format: "json" });
|
|
4340
|
+
|
|
4341
|
+
const manifest = readManifest(id) || findManifestByPartialId(id);
|
|
4342
|
+
if (!manifest) {
|
|
4343
|
+
emit.error({
|
|
4344
|
+
code: "council_not_found",
|
|
4345
|
+
message: `no council matching '${id}'`,
|
|
4346
|
+
});
|
|
4347
|
+
process.exit(1);
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
const contributors = contributorsInRound(manifest.council_id, manifest.current_round);
|
|
4351
|
+
const pending = manifest.members.filter((m) => !contributors.includes(m));
|
|
4352
|
+
const allIn = pending.length === 0;
|
|
4353
|
+
|
|
4354
|
+
emit.data({
|
|
4355
|
+
rows: [
|
|
4356
|
+
{
|
|
4357
|
+
council_id: manifest.council_id,
|
|
4358
|
+
status: manifest.status,
|
|
4359
|
+
current_round: manifest.current_round,
|
|
4360
|
+
round_status: manifest.round_status,
|
|
4361
|
+
members: manifest.members,
|
|
4362
|
+
contributors,
|
|
4363
|
+
pending,
|
|
4364
|
+
all_in: allIn,
|
|
4365
|
+
auto_advance: manifest.auto_advance,
|
|
4366
|
+
},
|
|
4367
|
+
],
|
|
4368
|
+
meta: { action: "council-status" },
|
|
4369
|
+
});
|
|
4370
|
+
if (!opts.json) {
|
|
4371
|
+
const lines: string[] = [];
|
|
4372
|
+
lines.push(`council ${manifest.council_id}: ${manifest.status}`);
|
|
4373
|
+
lines.push(
|
|
4374
|
+
` round ${manifest.current_round} ${manifest.round_status}: ${contributors.length}/${manifest.members.length} members in`,
|
|
4375
|
+
);
|
|
4376
|
+
if (contributors.length > 0) {
|
|
4377
|
+
lines.push(` contributed: ${contributors.join(", ")}`);
|
|
4378
|
+
}
|
|
4379
|
+
if (pending.length > 0) {
|
|
4380
|
+
lines.push(` pending: ${pending.join(", ")}`);
|
|
4381
|
+
}
|
|
4382
|
+
if (allIn && manifest.round_status === "open") {
|
|
4383
|
+
lines.push(
|
|
4384
|
+
` (round is full but still marked 'open'; re-run any contribute to fix, or call 'council advance' to roll forward)`,
|
|
4385
|
+
);
|
|
4386
|
+
}
|
|
4387
|
+
emit.text(`${lines.join("\n")}\n`);
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
function runCouncilAdvance(id: string, opts: { force?: boolean; json?: boolean }): void {
|
|
4392
|
+
if (opts.json) emit.config({ format: "json" });
|
|
4393
|
+
|
|
4394
|
+
const manifest = readManifest(id) || findManifestByPartialId(id);
|
|
4395
|
+
if (!manifest) {
|
|
4396
|
+
emit.error({
|
|
4397
|
+
code: "council_not_found",
|
|
4398
|
+
message: `no council matching '${id}'`,
|
|
4399
|
+
});
|
|
4400
|
+
process.exit(1);
|
|
4401
|
+
}
|
|
4402
|
+
if (manifest.status !== "active") {
|
|
4403
|
+
emit.error({
|
|
4404
|
+
code: "council_not_active",
|
|
4405
|
+
message: `council ${manifest.council_id} is ${manifest.status}; cannot advance`,
|
|
4406
|
+
});
|
|
4407
|
+
process.exit(1);
|
|
4408
|
+
}
|
|
4409
|
+
|
|
4410
|
+
const contributors = contributorsInRound(manifest.council_id, manifest.current_round);
|
|
4411
|
+
const pending = manifest.members.filter((m) => !contributors.includes(m));
|
|
4412
|
+
if (pending.length > 0 && !opts.force) {
|
|
4413
|
+
emit.error({
|
|
4414
|
+
code: "pending_contributions",
|
|
4415
|
+
message: `pending members in round ${manifest.current_round}: ${pending.join(", ")}. Re-run with --force to advance anyway.`,
|
|
4416
|
+
});
|
|
4417
|
+
process.exit(1);
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
const next = advanceCouncil(manifest, !!opts.force);
|
|
4421
|
+
emitCouncilStateEvent("council.round_close", manifest, {
|
|
4422
|
+
round_no: manifest.current_round,
|
|
4423
|
+
});
|
|
4424
|
+
emitCouncilStateEvent("council.round_open", next, {
|
|
4425
|
+
round_no: next.current_round,
|
|
4426
|
+
});
|
|
4427
|
+
|
|
4428
|
+
emit.data({
|
|
4429
|
+
rows: [
|
|
4430
|
+
{
|
|
4431
|
+
council_id: next.council_id,
|
|
4432
|
+
previous_round: manifest.current_round,
|
|
4433
|
+
new_round: next.current_round,
|
|
4434
|
+
forced: !!opts.force,
|
|
4435
|
+
dropped_members: pending,
|
|
4436
|
+
contributors_in_previous: contributors,
|
|
4437
|
+
manifest: next,
|
|
4438
|
+
},
|
|
4439
|
+
],
|
|
4440
|
+
meta: { action: "council-advance" },
|
|
4441
|
+
});
|
|
4442
|
+
if (!opts.json) {
|
|
4443
|
+
const dropped = pending.length > 0 ? ` (dropped: ${pending.join(", ")})` : "";
|
|
4444
|
+
emit.text(
|
|
4445
|
+
`council ${next.council_id} advanced from round ${manifest.current_round} → ${next.current_round}${dropped}. Round ${next.current_round} is open.\n`,
|
|
4446
|
+
);
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
/**
|
|
4451
|
+
* Shared advance helper used by both `advance` and auto-advance from
|
|
4452
|
+
* `contribute`. Increments current_round, flips round_status back to open,
|
|
4453
|
+
* creates the new round directory, writes the manifest, and pings each
|
|
4454
|
+
* member's scratchpad with the advance notification.
|
|
4455
|
+
*/
|
|
4456
|
+
function advanceCouncil(manifest: CouncilManifest, force: boolean): CouncilManifest {
|
|
4457
|
+
const nextRound = manifest.current_round + 1;
|
|
4458
|
+
const next: CouncilManifest = {
|
|
4459
|
+
...manifest,
|
|
4460
|
+
current_round: nextRound,
|
|
4461
|
+
round_status: "open",
|
|
4462
|
+
};
|
|
4463
|
+
// Create round-N+1 directory
|
|
4464
|
+
const rd = roundDir(manifest.council_id, nextRound);
|
|
4465
|
+
if (rd && !existsSync(rd)) mkdirSync(rd, { recursive: true });
|
|
4466
|
+
writeManifest(next);
|
|
4467
|
+
|
|
4468
|
+
// Ping each member's scratchpad with the advance notification.
|
|
4469
|
+
// (Convener already knows; we skip pinging them if they convened it from
|
|
4470
|
+
// their own session.)
|
|
4471
|
+
const myOwner = resolveOwner();
|
|
4472
|
+
const myName = myOwner ? normalizeAgentName(readHeartbeat(myOwner)?.name ?? "") : "";
|
|
4473
|
+
for (const memberName of next.members) {
|
|
4474
|
+
if (memberName === myName) continue;
|
|
4475
|
+
const bareName = memberName.replace(/^agent-/, "");
|
|
4476
|
+
const memberOwner = resolveOwnerByName(bareName);
|
|
4477
|
+
if (!memberOwner) continue;
|
|
4478
|
+
try {
|
|
4479
|
+
appendEntry(
|
|
4480
|
+
memberOwner,
|
|
4481
|
+
"handoff",
|
|
4482
|
+
`from council advance (${manifest.council_id}): round ${nextRound} is now open${force ? " (advanced with --force; some round-N members dropped)" : ""}. Run 'harn agents council show ${manifest.council_id}' to read prior round + 'harn agents council contribute ${manifest.council_id}' to weigh in.`,
|
|
4483
|
+
);
|
|
4484
|
+
} catch {
|
|
4485
|
+
/* best-effort; member scratchpad may not exist yet */
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
4488
|
+
return next;
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
// ──────── end council impls ────────
|
|
4492
|
+
|
|
4493
|
+
// Hard cap on box width. Values longer than the per-row budget word-wrap to
|
|
4494
|
+
// continuation lines (blank key column, value resumes indented). Picked to
|
|
4495
|
+
// stay readable in narrow terminals + chat clients while giving long
|
|
4496
|
+
// turn_summary / task values room to breathe.
|
|
4497
|
+
const MAX_BOX_CONTENT_WIDTH = 100;
|
|
4498
|
+
|
|
4499
|
+
function formatBox(title: string, rows: Array<[string, string]>): string {
|
|
4500
|
+
const titleStr = ` ${title} `;
|
|
4501
|
+
const keyWidth = Math.max(...rows.map(([k]) => k.length));
|
|
4502
|
+
|
|
4503
|
+
// Per-row value budget: content_width (≤ MAX_BOX_CONTENT_WIDTH) minus
|
|
4504
|
+
// leading space, key + padding, two-space gap, trailing space.
|
|
4505
|
+
const valueBudget = Math.max(20, MAX_BOX_CONTENT_WIDTH - 1 - keyWidth - 2 - 1);
|
|
4506
|
+
|
|
4507
|
+
// Expand each row into 1+ visual rows by word-wrapping long values.
|
|
4508
|
+
// First wrapped row keeps the key; continuations get an empty key column.
|
|
4509
|
+
const visualRows: Array<[string, string]> = [];
|
|
4510
|
+
for (const [k, v] of rows) {
|
|
4511
|
+
const wrapped = wrapWords(v, valueBudget);
|
|
4512
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
4513
|
+
visualRows.push([i === 0 ? k : "", wrapped[i]]);
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
const contentWidth = Math.max(
|
|
4518
|
+
titleStr.length + 4,
|
|
4519
|
+
...visualRows.map(([_k, v]) => 1 + keyWidth + 2 + v.length + 1),
|
|
4520
|
+
);
|
|
4521
|
+
const top = `┌─${titleStr}${"─".repeat(Math.max(0, contentWidth - titleStr.length - 2))}─┐`;
|
|
4522
|
+
const bottom = `└${"─".repeat(contentWidth)}┘`;
|
|
4523
|
+
const lines = visualRows.map(([k, v]) => {
|
|
4524
|
+
const padding = " ".repeat(keyWidth - k.length);
|
|
4525
|
+
const content = ` ${k}${padding} ${v}`;
|
|
4526
|
+
const fill = " ".repeat(Math.max(0, contentWidth - content.length));
|
|
4527
|
+
return `│${content}${fill}│`;
|
|
4528
|
+
});
|
|
4529
|
+
return [top, ...lines, bottom].join("\n");
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4532
|
+
// Greedy word-wrap. Preserves single-word lines that exceed maxWidth by
|
|
4533
|
+
// hard-breaking them (rare: overly long URLs / paths). Leaves whitespace
|
|
4534
|
+
// runs intact except at the wrap boundary.
|
|
4535
|
+
function wrapWords(text: string, maxWidth: number): string[] {
|
|
4536
|
+
if (text.length <= maxWidth) return [text];
|
|
4537
|
+
const lines: string[] = [];
|
|
4538
|
+
let current = "";
|
|
4539
|
+
// Split keeping whitespace so we can put it back between words.
|
|
4540
|
+
const tokens = text.split(/(\s+)/);
|
|
4541
|
+
for (const token of tokens) {
|
|
4542
|
+
if (token.length === 0) continue;
|
|
4543
|
+
if ((current + token).length <= maxWidth) {
|
|
4544
|
+
current += token;
|
|
4545
|
+
continue;
|
|
4546
|
+
}
|
|
4547
|
+
// Doesn't fit. Flush current line first.
|
|
4548
|
+
if (current.length > 0) {
|
|
4549
|
+
lines.push(current.trimEnd());
|
|
4550
|
+
current = "";
|
|
4551
|
+
}
|
|
4552
|
+
// If the token itself is wider than the budget (e.g. a long URL/path),
|
|
4553
|
+
// hard-break it across rows. Otherwise start a fresh row with it.
|
|
4554
|
+
if (token.length > maxWidth) {
|
|
4555
|
+
let rest = token;
|
|
4556
|
+
while (rest.length > maxWidth) {
|
|
4557
|
+
lines.push(rest.slice(0, maxWidth));
|
|
4558
|
+
rest = rest.slice(maxWidth);
|
|
4559
|
+
}
|
|
4560
|
+
current = rest;
|
|
4561
|
+
} else {
|
|
4562
|
+
current = token.trimStart();
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
if (current.length > 0) lines.push(current.trimEnd());
|
|
4566
|
+
return lines.length > 0 ? lines : [text];
|
|
4567
|
+
}
|