From 4d79a4884e15ab09227669273506446c0a2f8198 Mon Sep 17 00:00:00 2001 From: Danil Ulmashev Date: Fri, 26 Jun 2026 22:09:34 -0400 Subject: [PATCH 1/2] feat: add codex dashboard telemetry --- .platform/domains/vscode-extension.md | 51 ++++ .platform/memory/log.md | 1 + .platform/roles/docs-reviewer.md | 2 +- .platform/scripts/hooks/codex-hook-bridge.js | 191 +++++++++++++++ .platform/scripts/hooks/event-logger.sh | 44 ++-- .platform/scripts/hooks/session-snapshot.js | 229 ++++++++++++++++++ .platform/scripts/session-track.sh | 31 +++ .platform/work/ACTIVE.md | 1 + .platform/work/BRIEF.md | 17 +- .platform/work/codex-dashboard-support.md | 24 +- .../qa/codex-dashboard-support-manual-qa.md | 55 +++++ CHEATSHEET.md | 2 +- README.md | 10 +- templates/codex/config.toml | 51 ++++ templates/platform/roles/docs-reviewer.md | 2 +- templates/platform/scripts/codex-ab | 19 +- .../scripts/hooks/codex-hook-bridge.js | 191 +++++++++++++++ .../platform/scripts/hooks/event-logger.sh | 44 ++-- .../scripts/hooks/session-snapshot.js | 229 ++++++++++++++++++ templates/platform/scripts/session-track.sh | 31 +++ templates/root/AGENTS.md.template | 5 +- templates/root/GEMINI.md.template | 5 +- tests/unit/codex_hook_bridge_test.sh | 73 ++++++ tests/unit/event_logger_skill_role_test.sh | 46 ++++ tests/unit/roles_pack_test.sh | 15 +- tests/unit/session_track_test.sh | 46 ++++ 26 files changed, 1326 insertions(+), 89 deletions(-) create mode 100644 .platform/domains/vscode-extension.md create mode 100755 .platform/scripts/hooks/codex-hook-bridge.js create mode 100755 .platform/scripts/hooks/session-snapshot.js create mode 100644 .platform/work/qa/codex-dashboard-support-manual-qa.md create mode 100755 templates/platform/scripts/hooks/codex-hook-bridge.js create mode 100755 templates/platform/scripts/hooks/session-snapshot.js create mode 100755 tests/unit/codex_hook_bridge_test.sh diff --git a/.platform/domains/vscode-extension.md b/.platform/domains/vscode-extension.md new file mode 100644 index 0000000..b58e9c9 --- /dev/null +++ b/.platform/domains/vscode-extension.md @@ -0,0 +1,51 @@ +--- +domain_id: dom-vscode-extension +slug: vscode-extension +status: active +repo_ids: [repo-primary] +related_domain_slugs: [orchestration, templates] +created_at: 2026-06-26 +updated_at: 2026-06-26 +--- + +# vscode-extension + +## What this domain does + +This domain owns the Agentboard VS Code extension and the runtime files that feed its live dashboard. It lets a developer see active agent sessions, workstreams, catalog metadata, file activity, cost, model, and staleness inside the editor. + +## Backend / source of truth + +- `~/.agentboard/sessions/*.json` is the cross-provider live session snapshot source for the dashboard. +- `.platform/events.jsonl` is the per-project activity/event source used by the activity feed and code activity stats. +- Provider hooks and wrappers must normalize to the existing Claude-compatible session/event schemas instead of forcing dashboard-specific provider branches. + +## Frontend / clients + +- `extensions/vscode/src/dashboardPanel.ts` renders the Live/Catalog dashboard webview. +- `extensions/vscode/src/extension.ts` registers commands, sidebar providers, and dashboard open/refresh behavior. +- `extensions/vscode/src/workspaceRoot.ts` resolves the active Agentboard workspace root. + +## API contract locked + +- Session JSON must remain provider-neutral enough for Claude and Codex to appear together. +- Event records must stay newline-delimited JSON in `.platform/events.jsonl`. +- Runtime artifacts remain ignored by git; templates and hook scripts are tracked. +- The dashboard should prefer the current VS Code workspace over stale global live metadata. + +## Key files + +- `extensions/vscode/src/dashboardPanel.ts` +- `extensions/vscode/src/extension.ts` +- `extensions/vscode/src/workspaceRoot.ts` +- `templates/platform/scripts/hooks/status-bridge.js` +- `templates/platform/scripts/hooks/event-logger.sh` +- `templates/platform/scripts/session-track.sh` +- `templates/codex/` +- `templates/root/AGENTS.md.template` + +## Decisions locked + +- Keep the dashboard data model provider-compatible instead of adding separate Claude/Codex UI paths. +- Keep runtime telemetry local and gitignored. +- Prefer small hook/wrapper adapters that emit the existing session/event formats. diff --git a/.platform/memory/log.md b/.platform/memory/log.md index 341da44..410834e 100644 --- a/.platform/memory/log.md +++ b/.platform/memory/log.md @@ -105,3 +105,4 @@ Format: `YYYY-MM-DD — ` 2026-06-14 — Silicon Valley product mindset — added provider-neutral workflow, role, root-entry, and contract-test coverage so PM/engineering agents pursue best-in-class product ambition with explicit scope and approval guardrails 2026-06-15 — manual QA artifact gate — upgraded manual QA from chat-only final plan to stream-scoped markdown artifact gate with archived QA history — future agents must ship with executable QA evidence or a documented not-required reason 2026-06-15 — QA execution journal — added a chronological journal requirement for LLM-driven interactive QA so Maestro/browser agents document what they did, saw, fixed, retested, passed, skipped, and escalated +2026-06-26 — Codex dashboard telemetry — Codex now emits Claude-compatible session snapshots and dashboard-readable file activity through native hooks plus wrapper fallback diff --git a/.platform/roles/docs-reviewer.md b/.platform/roles/docs-reviewer.md index 963bae1..de993ea 100644 --- a/.platform/roles/docs-reviewer.md +++ b/.platform/roles/docs-reviewer.md @@ -51,7 +51,7 @@ reveal to be confusing. - **Prioritised fix list** — ordered by user-impact; each item names the doc location, the problem, and the correct current behaviour -## Hard Rules +## Constraints - **Every inaccuracy finding includes the current correct behaviour** — do not just flag it, state the truth. diff --git a/.platform/scripts/hooks/codex-hook-bridge.js b/.platform/scripts/hooks/codex-hook-bridge.js new file mode 100755 index 0000000..daa1b40 --- /dev/null +++ b/.platform/scripts/hooks/codex-hook-bridge.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * codex-hook-bridge.js — normalize Codex native hook payloads for Agentboard. + * + * Codex project hooks run in the session cwd and send event payloads on stdin. + * This adapter writes the same session JSON and events.jsonl shapes that the + * VS Code dashboard already consumes for Claude Code. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { writeSnapshot, deriveSessionId, findProjectRoot } = require('./session-snapshot.js'); + +const MAX_STDIN = 512 * 1024; + +function readPayload() { + try { + if (process.stdin.isTTY) return {}; + const raw = fs.readFileSync(0, { encoding: 'utf8' }).slice(0, MAX_STDIN).trim(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function get(obj, pathExpr) { + let cur = obj; + for (const part of pathExpr.split('.')) { + if (cur == null || typeof cur !== 'object') return undefined; + cur = cur[part]; + } + return cur; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return ''; +} + +function toolName(payload) { + return firstString( + payload.tool_name, + payload.toolName, + payload.tool, + payload.name, + payload.matcher, + get(payload, 'tool.name'), + get(payload, 'tool_call.name'), + get(payload, 'toolCall.name'), + ); +} + +function eventName(payload) { + return firstString( + process.env.AGENTBOARD_CODEX_HOOK_EVENT, + payload.hook_event_name, + payload.hookEventName, + payload.event, + payload.event_name, + payload.type, + ); +} + +function filePath(payload) { + return firstString( + payload.file_path, + payload.filePath, + payload.path, + get(payload, 'tool_input.file_path'), + get(payload, 'tool_input.path'), + get(payload, 'toolInput.file_path'), + get(payload, 'toolInput.path'), + get(payload, 'input.file_path'), + get(payload, 'input.path'), + get(payload, 'params.file_path'), + get(payload, 'params.path'), + get(payload, 'arguments.file_path'), + get(payload, 'arguments.path'), + ); +} + +function command(payload) { + return firstString( + payload.command, + get(payload, 'tool_input.command'), + get(payload, 'toolInput.command'), + get(payload, 'input.command'), + get(payload, 'params.command'), + get(payload, 'arguments.command'), + ); +} + +function label(payload) { + return firstString( + payload.label, + payload.subagent_type, + payload.subagentType, + payload.agent_type, + payload.agentType, + payload.name, + get(payload, 'subagent.type'), + get(payload, 'agent.type'), + ); +} + +function runEventLogger(root, payload, env = {}) { + const hook = path.join(root, '.platform', 'scripts', 'hooks', 'event-logger.sh'); + if (!fs.existsSync(hook)) return; + spawnSync('bash', [hook], { + cwd: root, + input: JSON.stringify(payload), + stdio: ['pipe', 'ignore', 'ignore'], + env: { + ...process.env, + AGENTBOARD_PROVIDER: 'codex', + ...env, + }, + timeout: 3000, + }); +} + +function normalizePayload(payload, hookEvent, sessionId) { + const t = toolName(payload); + const fp = filePath(payload); + const cmd = command(payload); + const out = { + ...payload, + hook_event_name: hookEvent, + session_id: sessionId, + }; + + if (!out.tool_name && t) out.tool_name = t === 'apply_patch' ? 'Edit' : t; + if (fp && !out.file_path) out.file_path = fp; + if (cmd && !out.command) out.command = cmd; + return out; +} + +function main() { + const payload = readPayload(); + const root = findProjectRoot(firstString(payload.cwd, get(payload, 'workspace.current_dir')) || process.cwd()); + const provider = 'codex'; + const sessionId = deriveSessionId(payload, provider); + const hookEvent = eventName(payload) || 'PostToolUse'; + + writeSnapshot(payload, { provider, sessionId, cwd: root }); + + if (hookEvent === 'SubagentStart') { + runEventLogger(root, { + hook_event_name: hookEvent, + session_id: sessionId, + tool_name: 'Agent', + label: label(payload) || 'sub-agent', + subagent_type: label(payload) || 'sub-agent', + }, { AGENTBOARD_HOOK_TYPE: 'agent_start' }); + return; + } + + if (hookEvent === 'SubagentStop') { + runEventLogger(root, { + hook_event_name: hookEvent, + session_id: sessionId, + tool_name: 'Agent', + label: label(payload) || 'sub-agent', + subagent_type: label(payload) || 'sub-agent', + }, { AGENTBOARD_HOOK_TYPE: 'agent_done' }); + return; + } + + if (hookEvent === 'SessionStart' || hookEvent === 'Stop') { + runEventLogger(root, { + hook_event_name: hookEvent === 'Stop' ? 'SessionEnd' : 'SessionStart', + session_id: sessionId, + provider, + }); + return; + } + + runEventLogger(root, normalizePayload(payload, hookEvent, sessionId)); +} + +try { + main(); +} catch { + process.exit(0); +} diff --git a/.platform/scripts/hooks/event-logger.sh b/.platform/scripts/hooks/event-logger.sh index fae454c..889ebd4 100755 --- a/.platform/scripts/hooks/event-logger.sh +++ b/.platform/scripts/hooks/event-logger.sh @@ -1,16 +1,11 @@ #!/usr/bin/env bash # event-logger.sh — append lean AI agent events to .platform/events.jsonl # -# Invoked by Claude Code hooks (PostToolUse + UserPromptSubmit) via stdin. -# Can also be called from Codex/Gemini wrappers — any JSON payload accepted. +# Invoked by provider hooks/wrappers via stdin. # Fail-open: errors never block a tool call. # -# Output format (one JSON object per line): -# {"ts":"","provider":"

","stream":"","tool":"","file":""} -# {"ts":"","provider":"

","stream":"","tool":"Bash","cmd":""} -# -# UserPromptSubmit events are dropped — they're noise, not signal. -# The raw hook payload is never stored — only the meaningful fields. +# UserPromptSubmit events are dropped except /skill invocations. +# Raw hook payloads are never stored. set -u [[ -d ".platform" ]] || exit 0 @@ -37,7 +32,6 @@ _json_string_field() { ' } -# For UserPromptSubmit: only capture /skill invocations, drop everything else hook_event="$(_json_string_field "hook_event_name")" if [[ "$hook_event" == "UserPromptSubmit" ]]; then _prompt="$(_json_string_field "prompt")" @@ -47,8 +41,8 @@ if [[ "$hook_event" == "UserPromptSubmit" ]]; then ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || exit 0 _session_id="$(_json_string_field "session_id")" _jsesc() { printf '%s' "$1" | awk '{ gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); printf "%s", $0 }'; } - printf '{"ts":"%s","provider":"claude","stream":"","tool":"Skill","skill":"%s","session_id":"%s"}\n' \ - "$ts" "$(_jsesc "$_skill")" "$(_jsesc "$_session_id")" >> "$log_file" 2>/dev/null + printf '{"ts":"%s","provider":"%s","stream":"","tool":"Skill","skill":"%s","session_id":"%s"}\n' \ + "$ts" "$(_jsesc "$provider")" "$(_jsesc "$_skill")" "$(_jsesc "$_session_id")" >> "$log_file" 2>/dev/null fi exit 0 fi @@ -135,8 +129,6 @@ stream_e="$(_jsesc "$stream")" tool_e="$(_jsesc "$tool")" hook_e="$(_jsesc "$hook_event")" -# ── Agent start (PreToolUse on Agent tool) ──────────────────────────────────── -# Label format enforced by CLAUDE.md: "role: · skill: · " if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_start" ]]; then _label="$(_json_string_field "label")" _subtype="$(_json_string_field "subagent_type")" @@ -156,8 +148,6 @@ if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_start" ]]; then exit 0 fi -# ── Agent done (PostToolUse on Agent tool) ──────────────────────────────────── -# Matches the AgentStart label so the dashboard can flip done=true. if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_done" ]]; then _label="$(_json_string_field "label")" _task="${_label:-sub-agent}" @@ -167,8 +157,6 @@ if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_done" ]]; then exit 0 fi -# Skip Bash events that are ab meta-calls — those commands produce -# their own structured events (Reason, checkpoint, etc.) which are the signal. if [[ "$tool" == "Bash" ]]; then _cmd_peek="$(_json_string_field "command")" case "$_cmd_peek" in @@ -176,31 +164,32 @@ if [[ "$tool" == "Bash" ]]; then esac fi -# Session events (SessionStart/End, FileChange, Reason) are identified by -# hook_event_name — regardless of whether tool_name is also present. case "$hook_event" in SessionStart|SessionEnd|FileChange|Reason) - # ── Session event: preserve hook_event_name + session_id + file_path ───── _sid_e="$(_jsesc "${session_id:-}")" _fp="$(_json_string_field "file_path")" if [[ -n "$_fp" ]]; then _fp_e="$(_jsesc "$_fp")" - _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"session_id\":\"$_sid_e\",\"file_path\":\"$_fp_e\"}" + if [[ "$hook_event" == "FileChange" ]]; then + _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"tool\":\"Edit\",\"session_id\":\"$_sid_e\",\"file_path\":\"$_fp_e\",\"file\":\"$_fp_e\"}" + else + _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"session_id\":\"$_sid_e\",\"file_path\":\"$_fp_e\"}" + fi else _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"session_id\":\"$_sid_e\"}" fi ;; *) - # ── Tool event (PostToolUse): extract one meaningful detail, no raw dump ── detail_key="" detail_val="" case "$tool" in Read) _fp="$(_json_string_field "file_path")" - # Detect skill file reads — log as Skill invocation case "$_fp" in - */.claude/skills/*/SKILL.md|*/.claude/skills/*/*) - _skill_name="$(printf '%s' "$_fp" | sed 's|.*\.claude/skills/\([^/]*\)/.*|\1|')" + */.claude/skills/*/SKILL.md|*/.claude/skills/*/*|*/.agents/skills/*/SKILL.md|*/.agents/skills/*/*) + _skill_name="${_fp#*/.claude/skills/}" + [[ "$_skill_name" == "$_fp" ]] && _skill_name="${_fp#*/.agents/skills/}" + _skill_name="${_skill_name%%/*}" if [[ -n "$_skill_name" ]]; then printf '{"ts":"%s","provider":"%s","stream":"%s","tool":"Skill","skill":"%s","session_id":"%s"}\n' \ "$ts" "$provider_e" "$stream_e" "$(_jsesc "$_skill_name")" "$(_jsesc "$session_id")" >> "$log_file" 2>/dev/null @@ -222,7 +211,6 @@ case "$hook_event" in ;; Edit|Write|MultiEdit|NotebookEdit) _fp="$(_json_string_field "file_path")" - # Skip .platform/ meta-file edits (memory, stream files, daemon state) _rel="${_fp##"$(pwd)/"}" case "$_rel" in .platform/*) exit 0 ;; esac if [[ -n "$_fp" ]]; then @@ -232,7 +220,6 @@ case "$hook_event" in ;; Bash) _cmd="$(_json_string_field "command")" - # Skip trivial internal commands — keep everything else case "$_cmd" in echo\ *|printf\ *|cat\ *|ls\ *|cd\ *|pwd|true|false|:|\ mkdir\ *|rm\ *|mv\ *|cp\ *|touch\ *|chmod\ *|wc\ *|head\ *|tail\ *|\ @@ -262,7 +249,7 @@ case "$hook_event" in detail_val="$_agent_id" ;; WebSearch|WebFetch) - exit 0 # internal research — not "what I changed" + exit 0 ;; esac _sid_e="$(_jsesc "${session_id:-}")" @@ -275,7 +262,6 @@ case "$hook_event" in ;; esac -# Write via daemon (concurrent-safe) or direct append fallback _port_file=".platform/.daemon-port" _daemon_ok=0 if [[ -f "$_port_file" ]] && command -v curl >/dev/null 2>&1; then diff --git a/.platform/scripts/hooks/session-snapshot.js b/.platform/scripts/hooks/session-snapshot.js new file mode 100755 index 0000000..9e4f700 --- /dev/null +++ b/.platform/scripts/hooks/session-snapshot.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * session-snapshot.js — write Agentboard live session JSON for provider wrappers/hooks. + * + * Claude Code writes this shape from status-bridge.js. Codex/Gemini wrappers and + * Codex native hooks call this helper so the VS Code dashboard can stay + * provider-agnostic and keep reading ~/.agentboard/sessions/*.json. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +const MAX_STDIN = 512 * 1024; + +function readStdin() { + try { + if (process.stdin.isTTY) return {}; + const chunks = []; + let total = 0; + const buf = fs.readFileSync(0); + total += buf.length; + if (total > MAX_STDIN) return {}; + chunks.push(buf); + const raw = Buffer.concat(chunks).toString('utf8').trim(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function get(obj, pathExpr) { + let cur = obj; + for (const part of pathExpr.split('.')) { + if (cur == null || typeof cur !== 'object') return undefined; + cur = cur[part]; + } + return cur; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return ''; +} + +function findProjectRoot(cwd) { + let dir = cwd || process.cwd(); + for (let i = 0; i < 30; i++) { + if (fs.existsSync(path.join(dir, '.platform')) || fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return cwd || process.cwd(); +} + +function readJson(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; } +} + +function writeJsonAtomic(file, data) { + const tmp = `${file}.tmp.${process.pid}`; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); + fs.renameSync(tmp, file); +} + +function gitBranch(root) { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, timeout: 500, encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} + +function deriveSessionId(payload, provider) { + return firstString( + payload.session_id, + payload.sessionId, + payload.conversation_id, + payload.conversationId, + payload.thread_id, + payload.threadId, + payload.transcript_id, + payload.transcriptId, + get(payload, 'session.id'), + get(payload, 'conversation.id'), + get(payload, 'thread.id'), + process.env.AGENTBOARD_SESSION_ID, + ) || `${provider || 'agent'}-${process.ppid || process.pid}`; +} + +function deriveModel(payload) { + const model = payload.model; + return firstString( + typeof model === 'string' ? model : '', + get(model || {}, 'api_name'), + get(model || {}, 'display_name'), + payload.model_name, + payload.modelName, + payload.model_id, + payload.modelId, + process.env.AGENTBOARD_MODEL, + ); +} + +function numberOrNull(...values) { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value); + } + return null; +} + +function writeSnapshot(payload = {}, opts = {}) { + const provider = firstString(opts.provider, process.env.AGENTBOARD_PROVIDER, payload.provider) || 'unknown'; + const cwd = firstString(opts.cwd, process.env.AGENTBOARD_CWD, get(payload, 'workspace.current_dir'), payload.cwd) || process.cwd(); + const root = findProjectRoot(cwd); + const sessionId = firstString(opts.sessionId, deriveSessionId(payload, provider)); + const model = firstString(opts.model, deriveModel(payload), provider); + const now = new Date().toISOString(); + const globalDir = path.join(os.homedir(), '.agentboard'); + const sessionsDir = path.join(globalDir, 'sessions'); + const sessionPath = path.join(sessionsDir, `${sessionId}.json`); + const hudPath = path.join(root, 'agentboard.hud-status.json'); + const existing = readJson(sessionPath); + const existingHud = readJson(hudPath); + const existingCtx = existing.context || {}; + const existingCost = existing.cost || {}; + const startedAt = firstString( + opts.startedAt, + existingCtx.started_at, + get(existingHud, 'context.started_at'), + ) || now; + + const ctxRemaining = numberOrNull( + opts.contextRemainingPct, + get(payload, 'context_window.remaining_percentage'), + payload.context_remaining_pct, + payload.contextRemainingPct, + existingCtx.context_remaining_pct, + ); + const ctxTokens = numberOrNull( + opts.contextTokens, + get(payload, 'context_window.current_tokens'), + payload.context_tokens, + payload.contextTokens, + existingCtx.context_tokens, + ); + const costUsd = numberOrNull( + opts.costUsd, + get(payload, 'cost.total_cost_usd'), + payload.total_cost_usd, + payload.cost_usd, + existingCost.session_usd, + ); + const shellPid = numberOrNull( + opts.shellPid, + process.env.AGENTBOARD_SHELL_PID, + payload.shell_pid, + payload.shellPid, + existing._shell_pid, + ) || 0; + + const snapshot = { + ...existing, + provider, + last_updated: now, + context: { + ...existingCtx, + provider, + model, + session_id: sessionId, + branch: firstString(existingCtx.branch, gitBranch(root)), + started_at: startedAt, + context_remaining_pct: ctxRemaining, + context_tokens: ctxTokens, + }, + cost: { + ...existingCost, + session_usd: costUsd, + session_tokens: ctxTokens, + }, + active_agents: [{ + role: get(existing, 'active_agents.0.role') || provider, + model, + started_at: startedAt, + session_id: sessionId, + provider, + }], + _root: root, + _session_id: sessionId, + _last_updated: now, + _shell_pid: shellPid, + }; + + writeJsonAtomic(sessionPath, snapshot); + writeJsonAtomic(hudPath, snapshot); + writeJsonAtomic(path.join(globalDir, 'live.json'), { ...snapshot, _root: root }); + + try { + const cutoff = Date.now() - 8 * 60 * 60 * 1000; + for (const file of fs.readdirSync(sessionsDir)) { + if (!file.endsWith('.json')) continue; + const fp = path.join(sessionsDir, file); + try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {} + } + } catch {} + + return snapshot; +} + +if (require.main === module) { + try { + const payload = readStdin(); + const snapshot = writeSnapshot(payload); + if (process.argv.includes('--print')) process.stdout.write(JSON.stringify(snapshot)); + } catch { + process.exit(0); + } +} + +module.exports = { writeSnapshot, deriveSessionId, findProjectRoot }; diff --git a/.platform/scripts/session-track.sh b/.platform/scripts/session-track.sh index 2067ad4..f84121a 100755 --- a/.platform/scripts/session-track.sh +++ b/.platform/scripts/session-track.sh @@ -15,6 +15,7 @@ # AGENTBOARD_PROVIDER and AGENTBOARD_SESSION_ID before calling these fns. _ab_events_hook=".platform/scripts/hooks/event-logger.sh" +_ab_session_snapshot_hook=".platform/scripts/hooks/session-snapshot.js" _ab_session_event() { local kind="$1" session_id="$2" extra="${3:-}" @@ -36,6 +37,36 @@ _ab_session_event() { printf '%s' "$payload" | bash "$_ab_events_hook" 2>/dev/null || true } +_ab_write_session_snapshot() { + local session_id="$1" provider="$2" + [[ -f "$_ab_session_snapshot_hook" ]] || return 0 + command -v node >/dev/null 2>&1 || return 0 + AGENTBOARD_PROVIDER="$provider" \ + AGENTBOARD_SESSION_ID="$session_id" \ + AGENTBOARD_CWD="$(pwd)" \ + node "$_ab_session_snapshot_hook" >/dev/null 2>&1 || true +} + +_ab_start_session_heartbeat() { + local session_id="$1" provider="$2" interval="${3:-15}" + [[ -f "$_ab_session_snapshot_hook" ]] || { printf '0'; return 0; } + command -v node >/dev/null 2>&1 || { printf '0'; return 0; } + _ab_write_session_snapshot "$session_id" "$provider" + ( + while sleep "$interval"; do + AGENTBOARD_PROVIDER="$provider" \ + AGENTBOARD_SESSION_ID="$session_id" \ + AGENTBOARD_CWD="$(pwd)" \ + node "$_ab_session_snapshot_hook" >/dev/null 2>&1 || true + done + ) >/dev/null 2>&1 & + printf '%s' $! +} + +_ab_stop_session_heartbeat() { + _ab_stop_file_poller "${1:-0}" +} + # Best-effort: start daemon if not already running. # Sets _ab_daemon_was_started=1 so the caller can stop it on exit. _ab_daemon_was_started=0 diff --git a/.platform/work/ACTIVE.md b/.platform/work/ACTIVE.md index 2e198e5..c53bc5b 100644 --- a/.platform/work/ACTIVE.md +++ b/.platform/work/ACTIVE.md @@ -5,6 +5,7 @@ | Stream | Type | Status | Agent | Last updated | |---|---|---|---|---| +| codex-dashboard-support | feature | awaiting-verification | codex | 2026-06-26 | | code-cleanup-skill-role | feature | awaiting-verification | codex | 2026-06-13 | | qa-self-heal-maestro | feature | awaiting-verification | codex | 2026-06-14 | --- diff --git a/.platform/work/BRIEF.md b/.platform/work/BRIEF.md index a562424..66a42d4 100644 --- a/.platform/work/BRIEF.md +++ b/.platform/work/BRIEF.md @@ -4,34 +4,37 @@ > 30-second orientation: what we're building, why, and where we stand. > Replace entirely when the active feature changes. Keep ≤60 lines. -**Feature:** active streams +**Feature:** Codex dashboard integration **Status:** awaiting-verification -**Stream file:** `.platform/work/ACTIVE.md` +**Stream file:** `.platform/work/codex-dashboard-support.md` --- ## Active streams +- `codex-dashboard-support` — awaiting verification. - `code-cleanup-skill-role` — awaiting verification. - `qa-self-heal-maestro` — awaiting verification. ## Current state -`qa-execution-journal` was approved by the owner and archived on 2026-06-15. -The remaining active streams are both awaiting owner verification before any -commit, push, merge, release, or closure. +Codex dashboard integration implementation is ready for human verification. +The automated tests pass for session snapshots, hook normalization, wrapper +fallback telemetry, and existing VS Code extension helpers. ## Relevant context > Only load files relevant to the next task. Do not auto-load archived streams. -**Primary stream:** choose from `.platform/work/ACTIVE.md` -**Domains:** load only the domain listed by the selected stream handoff +**Primary stream:** `.platform/work/codex-dashboard-support.md` +**Domains:** `.platform/domains/vscode-extension.md`, `.platform/domains/orchestration.md`, `.platform/domains/templates.md` **Do not load:** unrelated `work/archive/*` **Never load:** unrelated `BACKLOG.md` or `learnings.md` at session start ## Key files - `.platform/work/ACTIVE.md` +- `.platform/work/codex-dashboard-support.md` +- `.platform/domains/vscode-extension.md` - `.platform/work/code-cleanup-skill-role.md` - `.platform/work/qa-self-heal-maestro.md` diff --git a/.platform/work/codex-dashboard-support.md b/.platform/work/codex-dashboard-support.md index f2c3a88..4231d68 100644 --- a/.platform/work/codex-dashboard-support.md +++ b/.platform/work/codex-dashboard-support.md @@ -2,8 +2,8 @@ stream_id: stream-codex-dashboard-support slug: codex-dashboard-support type: feature -status: planned -agent_owner: claude +status: awaiting-verification +agent_owner: codex domain_slugs: [vscode-extension] repo_ids: [repo-primary] base_branch: develop @@ -66,6 +66,17 @@ _Append-only. Format: `YYYY-MM-DD — `_ 2026-06-20 — Deferred until after Claude Code dashboard is stable — focus on one provider at a time, validate the schema before locking it for cross-provider use. +## Worktree / Local environment + +| Repo | Worktree path | Branch | Base | Dependencies | Local command | Localhost port(s) | +|---|---|---|---|---|---|---| +| repo-primary | `/private/tmp/agentboard-codex-dashboard-support` | `feature/codex-dashboard-support` | `develop` | installed: `npm ci` in `extensions/vscode`; root has no lockfile/deps | Root tests: `npm test`; extension compile: `cd extensions/vscode && npm run compile` | none; VS Code extension host/manual reload rather than localhost | + +## Manual QA + +- Artifact: `.platform/work/qa/codex-dashboard-support-manual-qa.md` +- Status: pending human VS Code/Codex click-through. Automated unit and compile checks passed; live dashboard verification requires a real Codex session in VS Code. + ## Reference - Claude Code bridge: `templates/platform/scripts/hooks/status-bridge.js` @@ -79,14 +90,18 @@ _Append-only. Format: `YYYY-MM-DD — `_ _Overwritten by `ab checkpoint` — the compact payload the next agent reads first. Keep this block under ~10 lines._ - **Last updated:** 2026-06-26 by danilulmashev -- **What just happened:** Pushed VS Code workspace-root fix to develop and prepared platform-pack sync; updated role test to derive expected roles from shipped templates. +- **What just happened:** Implemented Codex dashboard telemetry: native hook bridge, session snapshots, wrapper heartbeat fallback, event normalization, docs, and tests. - **Current focus:** — -- **Next action:** Reload VS Code, close/reopen Agentboard dashboard, and verify it shows the current agentboard workspace instead of stale global live project data. +- **Next action:** Run manual VS Code QA with a trusted Codex project, then commit if approved. - **Blockers:** none ## Progress log _Append-only. Auto-trimmed by `ab checkpoint` to last 10 entries._ +2026-06-26 19:06 — Implemented Codex dashboard telemetry: native hook bridge, session snapshots, wrapper heartbeat fallback, event normalization, docs, and tests. + +2026-06-26 19:02 — Implemented Codex dashboard telemetry: session snapshots, native hook bridge, wrapper heartbeat fallback, FileChange normalization, tests, docs, and manual QA artifact. + 2026-06-26 17:11 — Pushed VS Code workspace-root fix to develop and prepared platform-pack sync; updated role test to derive expected roles from shipped templates. 2026-06-26 16:44 — Found a second root hijack in DashboardPanel._buildDataSync: refresh read ~/.agentboard/live.json and overwrote the panel root. Patched extension.ts and dashboardPanel.ts, repackaged, and reinstalled rootfix VSIX. @@ -94,4 +109,3 @@ _Append-only. Auto-trimmed by `ab checkpoint` to last 10 entries._ 2026-06-26 16:38 — Fixed VS Code dashboard root detection in worktree /private/tmp/agentboard-vscode-root-detection: workspace folders now win over stale ~/.agentboard/live.json; packaged and installed local rootfix VSIX. 2026-06-26 16:16 — Oriented on VS Code extension/Codex dashboard stream; found local Codex wrapper/session-track telemetry already exists and current Codex docs support native lifecycle hooks. - diff --git a/.platform/work/qa/codex-dashboard-support-manual-qa.md b/.platform/work/qa/codex-dashboard-support-manual-qa.md new file mode 100644 index 0000000..3e2f16c --- /dev/null +++ b/.platform/work/qa/codex-dashboard-support-manual-qa.md @@ -0,0 +1,55 @@ +## Manual QA Artifact + +Scope: Codex dashboard/session integration in the Agentboard VS Code extension. + +Environment: local checkout on `feature/codex-dashboard-support`; VS Code desktop; Agentboard extension built from `extensions/vscode`; Codex CLI with project `.codex/config.toml` trusted or shell alias routed through `.platform/scripts/codex-ab`. + +Test data: this repo or a small Agentboard-initialized fixture repo with one active stream and at least one tracked source file. + +Safety limits: do not commit, push, delete branches, or run destructive shell commands during QA. Use a disposable test file for edits and revert it after verification. + +Happy path: + +1. From the feature worktree, run `cd extensions/vscode && npm run compile`. + Expected: TypeScript compile succeeds. +2. Package without writing a tracked VSIX: `npx @vscode/vsce package --out /tmp/agentboard-codex-dashboard-support.vsix`. + Expected: VSIX is created under `/tmp`, not in the repo. +3. Install: `code --install-extension /tmp/agentboard-codex-dashboard-support.vsix --force`, then reload VS Code. + Expected: Agentboard extension reloads without activation errors. +4. Open the Agentboard repo in VS Code and run **Agentboard: Open Dashboard**. + Expected: dashboard project label is `agentboard`; no stale global project hijack. +5. Start Codex via the wrapper alias or directly through `.platform/scripts/codex-ab`. + Expected: `~/.agentboard/sessions/.json` appears with `_root` pointing at the current repo and `provider: codex`. +6. In that Codex session, edit a tracked disposable file. + Expected: `.platform/events.jsonl` gets a Codex event with `hook_event_name: FileChange`, `tool: Edit`, `file_path`, and `file`. +7. Refresh/open the Agentboard dashboard Live tab. + Expected: a Codex session column appears with model/runtime/branch; activity shows the edited file and line diff stats. +8. Launch a Codex subagent if available. + Expected: Agentboard logs `AgentStart`/`AgentDone` with `provider: codex`; dashboard sub-agent list updates. +9. End the Codex session and wait at least one refresh cycle. + Expected: session remains visible until idle threshold, then disappears or marks stale according to existing dashboard behavior. + +Bug repro / regression: + +1. Before this change, run Codex wrapper and edit a tracked file. + Expected old behavior: no Codex session column because no `~/.agentboard/sessions/*.json`; file activity may be missing because only `file_path` was present. +2. After this change, repeat the same flow. + Expected fixed behavior: session column exists and file activity is visible. + +Edge cases: + +- Codex native hooks not trusted: wrapper still writes session snapshots and file-poller events. +- Codex native hooks trusted: `codex-hook-bridge.js` writes direct tool/subagent events. +- Cost/context not present in Codex payload: dashboard leaves those fields blank/zero without crashing. +- Multiple active streams: event logger uses session mapping or explicit `AGENTBOARD_STREAM`; no cross-session contamination. +- `.agents/skills//SKILL.md` read: event logger records a `Skill` event. + +Browser/device checks: VS Code desktop on macOS; no browser viewport checks required. + +Accessibility checks: verify dashboard remains keyboard-scrollable and existing buttons retain visible labels/tooltips; no UI layout changes were made. + +Evidence to capture: screenshot of Codex session column, sample redacted session JSON, sample event JSONL line, terminal output for compile/tests. + +Maestro / automation notes: not applicable; this is VS Code desktop/manual verification. + +Signoff: pending human QA, 2026-06-26, BLOCKED until a live Codex session is run through the checklist. diff --git a/CHEATSHEET.md b/CHEATSHEET.md index 6a3c529..3e43f3c 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -114,7 +114,7 @@ agentboard install-hooks [--force] [--dry-run] [--aliases] # `codex` and `gemini` into ~/.zshrc / # ~/.bashrc so they auto-route through # .platform/scripts/codex-ab|gemini-ab - # (runs `agentboard brief` before launch). + # (briefing + dashboard session telemetry). agentboard progress [--base ] [--note ""] [--dry-run] # append git diff --stat to stream's Progress log ``` diff --git a/README.md b/README.md index 232e923..f616c51 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ agentboard help - `close` runs in two steps: first call prints the harvest checklist (distill gotchas/decisions/learnings into `.platform/memory/`); second call with `--confirm` archives the stream and logs closure - `brief` prints the compact session-start briefing: active streams, recent gotchas, open questions, top usage pattern; run this at the start of every session - `watch` background poller that auto-checkpoints the active stream whenever tracked files change via `git status`; `--install` registers a per-project scheduler (launchd on macOS, systemd on Linux); `--aliases` on `install-hooks` wires `codex`/`gemini` CLI through the project wrappers globally -- `install-hooks` installs the full hook stack: Claude Code PreToolUse bash-guard, git pre-commit closure gate, git post-commit activity log, and Codex/Gemini provider wrappers; `--aliases` writes shell functions to `~/.zshrc`/`~/.bashrc` so `codex` and `gemini` auto-route through the project wrappers +- `install-hooks` installs the full hook stack: Claude Code PreToolUse bash-guard, git pre-commit closure gate, git post-commit activity log, Codex native hook templates, and Codex/Gemini provider wrappers; `--aliases` writes shell functions to `~/.zshrc`/`~/.bashrc` so `codex` and `gemini` auto-route through the project wrappers - `progress` appends a git-diff summary (`git diff --stat ...HEAD`) to the stream's `## Progress log` section, stamped with timestamp and branch; use this instead of hand-typing what changed - `status` prints `.platform/STATUS.md` - `add-repo` scaffolds entry files into a sibling repo in hub mode and refuses to overwrite existing root entry files @@ -391,7 +391,7 @@ A webview dashboard extension ships in `extensions/vscode/`. It gives you a live ### Live tab -Real-time session grid — one column per active Claude Code session: +Real-time session grid — one column per active Claude Code or Codex session: - **Session header** — deterministic pet name (e.g. `frost-condor`), model, cost, runtime, context bar, git branch, last-active time, current role and skill - **`⌨ terminal` button** — focuses the VS Code terminal that belongs to that session; works across multiple concurrent sessions in the same project by matching process tree + elapsed time @@ -407,10 +407,12 @@ Real-time session grid — one column per active Claude Code session: Three columns: Skills (`~40`), Roles (`~26`), and CLI Commands (`14`). Each card shows a description and "used by" badges; clicking expands the full protocol. -### Status line integration +### Session telemetry The `status-bridge.js` hook writes a deterministic session nickname to the Claude Code status line (e.g. `frost-condor · Opus 4.8 · $1.20 · …`). Names are stable — the same session ID always produces the same name. The word pool has 40 adjectives × 40 animals (1 600 combinations) with no visually similar words in the same pool, so concurrent sessions stay clearly distinct. +Codex sessions use the same `~/.agentboard/sessions/*.json` dashboard schema via `.codex/config.toml` hooks when trusted, and via the `codex-ab` wrapper as a fallback. Codex currently supplies live session/model/activity presence; cost and context fields are left empty unless Codex exposes them in hook payloads. + ### File size thresholds The same 500 / 800 / 1 000-line thresholds that power the activity badges are written into `workflow.md` as hard rules, so agents see the same signals and proactively flag or refuse to grow large files without a refactor plan. @@ -426,7 +428,7 @@ code --install-extension agentboard-2.2.1.vsix Open the dashboard via **Agentboard: Open Dashboard** in the command palette (`Cmd+Shift+P`). Lightweight sidebar tree views (Session Status, Streams, Catalog, Sessions, Worktrees) remain available as an alternative. -The extension reads `~/.agentboard/sessions/*.json` written by the `status-bridge.js` Stop hook and `.platform/events.jsonl` written by the `event-logger.sh` PostToolUse hook. Both are installed by `agentboard install-hooks`. The dashboard does **not** require the control plane to be running for core functionality. +The extension reads `~/.agentboard/sessions/*.json` written by provider hooks/wrappers and `.platform/events.jsonl` written by `event-logger.sh`. Both are installed by `agentboard init` / `agentboard update`; `agentboard install-hooks --aliases` wires Codex/Gemini wrappers into your shell. The dashboard does **not** require the control plane to be running for core functionality. ## Control plane diff --git a/templates/codex/config.toml b/templates/codex/config.toml index bb4ef82..7ed9c13 100644 --- a/templates/codex/config.toml +++ b/templates/codex/config.toml @@ -25,3 +25,54 @@ config_file = "agents/auditor.toml" [agents.mapper] description = "Fast file discovery, grep, glob — trivial mechanical ops" config_file = "agents/mapper.toml" + +# ── Agentboard dashboard telemetry ─────────────────────────────────────────── +# Codex loads project hooks only after the project .codex/ layer is trusted. +# These hooks normalize Codex lifecycle/tool events into the same local files +# used by Claude Code: +# - ~/.agentboard/sessions/.json +# - .platform/events.jsonl + +[[hooks.SessionStart]] +matcher = "startup|resume|clear|compact" + +[[hooks.SessionStart.hooks]] +type = "command" +command = 'AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=SessionStart node "$(git rev-parse --show-toplevel)/.platform/scripts/hooks/codex-hook-bridge.js"' +timeout = 10 +statusMessage = "Agentboard: starting Codex session" + +[[hooks.PostToolUse]] +matcher = "*" + +[[hooks.PostToolUse.hooks]] +type = "command" +command = 'AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=PostToolUse node "$(git rev-parse --show-toplevel)/.platform/scripts/hooks/codex-hook-bridge.js"' +timeout = 10 +statusMessage = "Agentboard: logging Codex activity" + +[[hooks.SubagentStart]] +matcher = "*" + +[[hooks.SubagentStart.hooks]] +type = "command" +command = 'AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=SubagentStart node "$(git rev-parse --show-toplevel)/.platform/scripts/hooks/codex-hook-bridge.js"' +timeout = 10 +statusMessage = "Agentboard: tracking Codex subagent" + +[[hooks.SubagentStop]] +matcher = "*" + +[[hooks.SubagentStop.hooks]] +type = "command" +command = 'AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=SubagentStop node "$(git rev-parse --show-toplevel)/.platform/scripts/hooks/codex-hook-bridge.js"' +timeout = 10 +statusMessage = "Agentboard: finishing Codex subagent" + +[[hooks.Stop]] + +[[hooks.Stop.hooks]] +type = "command" +command = 'AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=Stop node "$(git rev-parse --show-toplevel)/.platform/scripts/hooks/codex-hook-bridge.js"' +timeout = 10 +statusMessage = "Agentboard: ending Codex session" diff --git a/templates/platform/roles/docs-reviewer.md b/templates/platform/roles/docs-reviewer.md index 963bae1..de993ea 100644 --- a/templates/platform/roles/docs-reviewer.md +++ b/templates/platform/roles/docs-reviewer.md @@ -51,7 +51,7 @@ reveal to be confusing. - **Prioritised fix list** — ordered by user-impact; each item names the doc location, the problem, and the correct current behaviour -## Hard Rules +## Constraints - **Every inaccuracy finding includes the current correct behaviour** — do not just flag it, state the truth. diff --git a/templates/platform/scripts/codex-ab b/templates/platform/scripts/codex-ab index d9d98c1..be815f4 100755 --- a/templates/platform/scripts/codex-ab +++ b/templates/platform/scripts/codex-ab @@ -15,7 +15,8 @@ AGENTBOARD_PROVIDER="codex" AGENTBOARD_SESSION_ID="codex-$$-$(date +%s 2>/dev/null || printf 'unknown')" -export AGENTBOARD_PROVIDER AGENTBOARD_SESSION_ID +AGENTBOARD_SHELL_PID="${AGENTBOARD_SHELL_PID:-$PPID}" +export AGENTBOARD_PROVIDER AGENTBOARD_SESSION_ID AGENTBOARD_SHELL_PID if [[ -d ".platform" ]] && command -v agentboard >/dev/null 2>&1; then _ab_stream="$(agentboard current-stream --session-id "$AGENTBOARD_SESSION_ID" --remember --quiet 2>/dev/null || true)" if [[ -n "$_ab_stream" ]]; then @@ -65,7 +66,13 @@ AGENTBOARD_MODEL="gpt-5.4/$_ab_effort" export AGENTBOARD_MODEL # ── Log SessionStart (after effort is known) + start file poller ───────────── +_ab_heartbeat_pid=0 if [[ -d ".platform" ]] && declare -f _ab_session_event >/dev/null 2>&1; then + if declare -f _ab_start_session_heartbeat >/dev/null 2>&1; then + _ab_heartbeat_pid="$(_ab_start_session_heartbeat "$AGENTBOARD_SESSION_ID" "codex" 15)" + elif declare -f _ab_write_session_snapshot >/dev/null 2>&1; then + _ab_write_session_snapshot "$AGENTBOARD_SESSION_ID" "codex" + fi _ab_session_event "SessionStart" "$AGENTBOARD_SESSION_ID" "\"provider\":\"codex\",\"effort\":\"$_ab_effort\"" _ab_poller_pid="$(_ab_start_file_poller "$AGENTBOARD_SESSION_ID" "codex" 5)" fi @@ -80,6 +87,16 @@ if [[ "$_ab_poller_pid" -gt 0 ]] 2>/dev/null; then _ab_stop_file_poller "$_ab_poller_pid" fi fi +if [[ "$_ab_heartbeat_pid" -gt 0 ]] 2>/dev/null; then + if declare -f _ab_stop_session_heartbeat >/dev/null 2>&1; then + _ab_stop_session_heartbeat "$_ab_heartbeat_pid" + elif declare -f _ab_stop_file_poller >/dev/null 2>&1; then + _ab_stop_file_poller "$_ab_heartbeat_pid" + fi +fi +if declare -f _ab_write_session_snapshot >/dev/null 2>&1; then + _ab_write_session_snapshot "$AGENTBOARD_SESSION_ID" "codex" +fi if declare -f _ab_check_unreasoned_changes >/dev/null 2>&1; then _ab_check_unreasoned_changes "$AGENTBOARD_SESSION_ID" fi diff --git a/templates/platform/scripts/hooks/codex-hook-bridge.js b/templates/platform/scripts/hooks/codex-hook-bridge.js new file mode 100755 index 0000000..daa1b40 --- /dev/null +++ b/templates/platform/scripts/hooks/codex-hook-bridge.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * codex-hook-bridge.js — normalize Codex native hook payloads for Agentboard. + * + * Codex project hooks run in the session cwd and send event payloads on stdin. + * This adapter writes the same session JSON and events.jsonl shapes that the + * VS Code dashboard already consumes for Claude Code. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { writeSnapshot, deriveSessionId, findProjectRoot } = require('./session-snapshot.js'); + +const MAX_STDIN = 512 * 1024; + +function readPayload() { + try { + if (process.stdin.isTTY) return {}; + const raw = fs.readFileSync(0, { encoding: 'utf8' }).slice(0, MAX_STDIN).trim(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function get(obj, pathExpr) { + let cur = obj; + for (const part of pathExpr.split('.')) { + if (cur == null || typeof cur !== 'object') return undefined; + cur = cur[part]; + } + return cur; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return ''; +} + +function toolName(payload) { + return firstString( + payload.tool_name, + payload.toolName, + payload.tool, + payload.name, + payload.matcher, + get(payload, 'tool.name'), + get(payload, 'tool_call.name'), + get(payload, 'toolCall.name'), + ); +} + +function eventName(payload) { + return firstString( + process.env.AGENTBOARD_CODEX_HOOK_EVENT, + payload.hook_event_name, + payload.hookEventName, + payload.event, + payload.event_name, + payload.type, + ); +} + +function filePath(payload) { + return firstString( + payload.file_path, + payload.filePath, + payload.path, + get(payload, 'tool_input.file_path'), + get(payload, 'tool_input.path'), + get(payload, 'toolInput.file_path'), + get(payload, 'toolInput.path'), + get(payload, 'input.file_path'), + get(payload, 'input.path'), + get(payload, 'params.file_path'), + get(payload, 'params.path'), + get(payload, 'arguments.file_path'), + get(payload, 'arguments.path'), + ); +} + +function command(payload) { + return firstString( + payload.command, + get(payload, 'tool_input.command'), + get(payload, 'toolInput.command'), + get(payload, 'input.command'), + get(payload, 'params.command'), + get(payload, 'arguments.command'), + ); +} + +function label(payload) { + return firstString( + payload.label, + payload.subagent_type, + payload.subagentType, + payload.agent_type, + payload.agentType, + payload.name, + get(payload, 'subagent.type'), + get(payload, 'agent.type'), + ); +} + +function runEventLogger(root, payload, env = {}) { + const hook = path.join(root, '.platform', 'scripts', 'hooks', 'event-logger.sh'); + if (!fs.existsSync(hook)) return; + spawnSync('bash', [hook], { + cwd: root, + input: JSON.stringify(payload), + stdio: ['pipe', 'ignore', 'ignore'], + env: { + ...process.env, + AGENTBOARD_PROVIDER: 'codex', + ...env, + }, + timeout: 3000, + }); +} + +function normalizePayload(payload, hookEvent, sessionId) { + const t = toolName(payload); + const fp = filePath(payload); + const cmd = command(payload); + const out = { + ...payload, + hook_event_name: hookEvent, + session_id: sessionId, + }; + + if (!out.tool_name && t) out.tool_name = t === 'apply_patch' ? 'Edit' : t; + if (fp && !out.file_path) out.file_path = fp; + if (cmd && !out.command) out.command = cmd; + return out; +} + +function main() { + const payload = readPayload(); + const root = findProjectRoot(firstString(payload.cwd, get(payload, 'workspace.current_dir')) || process.cwd()); + const provider = 'codex'; + const sessionId = deriveSessionId(payload, provider); + const hookEvent = eventName(payload) || 'PostToolUse'; + + writeSnapshot(payload, { provider, sessionId, cwd: root }); + + if (hookEvent === 'SubagentStart') { + runEventLogger(root, { + hook_event_name: hookEvent, + session_id: sessionId, + tool_name: 'Agent', + label: label(payload) || 'sub-agent', + subagent_type: label(payload) || 'sub-agent', + }, { AGENTBOARD_HOOK_TYPE: 'agent_start' }); + return; + } + + if (hookEvent === 'SubagentStop') { + runEventLogger(root, { + hook_event_name: hookEvent, + session_id: sessionId, + tool_name: 'Agent', + label: label(payload) || 'sub-agent', + subagent_type: label(payload) || 'sub-agent', + }, { AGENTBOARD_HOOK_TYPE: 'agent_done' }); + return; + } + + if (hookEvent === 'SessionStart' || hookEvent === 'Stop') { + runEventLogger(root, { + hook_event_name: hookEvent === 'Stop' ? 'SessionEnd' : 'SessionStart', + session_id: sessionId, + provider, + }); + return; + } + + runEventLogger(root, normalizePayload(payload, hookEvent, sessionId)); +} + +try { + main(); +} catch { + process.exit(0); +} diff --git a/templates/platform/scripts/hooks/event-logger.sh b/templates/platform/scripts/hooks/event-logger.sh index fae454c..889ebd4 100755 --- a/templates/platform/scripts/hooks/event-logger.sh +++ b/templates/platform/scripts/hooks/event-logger.sh @@ -1,16 +1,11 @@ #!/usr/bin/env bash # event-logger.sh — append lean AI agent events to .platform/events.jsonl # -# Invoked by Claude Code hooks (PostToolUse + UserPromptSubmit) via stdin. -# Can also be called from Codex/Gemini wrappers — any JSON payload accepted. +# Invoked by provider hooks/wrappers via stdin. # Fail-open: errors never block a tool call. # -# Output format (one JSON object per line): -# {"ts":"","provider":"

","stream":"","tool":"","file":""} -# {"ts":"","provider":"

","stream":"","tool":"Bash","cmd":""} -# -# UserPromptSubmit events are dropped — they're noise, not signal. -# The raw hook payload is never stored — only the meaningful fields. +# UserPromptSubmit events are dropped except /skill invocations. +# Raw hook payloads are never stored. set -u [[ -d ".platform" ]] || exit 0 @@ -37,7 +32,6 @@ _json_string_field() { ' } -# For UserPromptSubmit: only capture /skill invocations, drop everything else hook_event="$(_json_string_field "hook_event_name")" if [[ "$hook_event" == "UserPromptSubmit" ]]; then _prompt="$(_json_string_field "prompt")" @@ -47,8 +41,8 @@ if [[ "$hook_event" == "UserPromptSubmit" ]]; then ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || exit 0 _session_id="$(_json_string_field "session_id")" _jsesc() { printf '%s' "$1" | awk '{ gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); printf "%s", $0 }'; } - printf '{"ts":"%s","provider":"claude","stream":"","tool":"Skill","skill":"%s","session_id":"%s"}\n' \ - "$ts" "$(_jsesc "$_skill")" "$(_jsesc "$_session_id")" >> "$log_file" 2>/dev/null + printf '{"ts":"%s","provider":"%s","stream":"","tool":"Skill","skill":"%s","session_id":"%s"}\n' \ + "$ts" "$(_jsesc "$provider")" "$(_jsesc "$_skill")" "$(_jsesc "$_session_id")" >> "$log_file" 2>/dev/null fi exit 0 fi @@ -135,8 +129,6 @@ stream_e="$(_jsesc "$stream")" tool_e="$(_jsesc "$tool")" hook_e="$(_jsesc "$hook_event")" -# ── Agent start (PreToolUse on Agent tool) ──────────────────────────────────── -# Label format enforced by CLAUDE.md: "role: · skill: · " if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_start" ]]; then _label="$(_json_string_field "label")" _subtype="$(_json_string_field "subagent_type")" @@ -156,8 +148,6 @@ if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_start" ]]; then exit 0 fi -# ── Agent done (PostToolUse on Agent tool) ──────────────────────────────────── -# Matches the AgentStart label so the dashboard can flip done=true. if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_done" ]]; then _label="$(_json_string_field "label")" _task="${_label:-sub-agent}" @@ -167,8 +157,6 @@ if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_done" ]]; then exit 0 fi -# Skip Bash events that are ab meta-calls — those commands produce -# their own structured events (Reason, checkpoint, etc.) which are the signal. if [[ "$tool" == "Bash" ]]; then _cmd_peek="$(_json_string_field "command")" case "$_cmd_peek" in @@ -176,31 +164,32 @@ if [[ "$tool" == "Bash" ]]; then esac fi -# Session events (SessionStart/End, FileChange, Reason) are identified by -# hook_event_name — regardless of whether tool_name is also present. case "$hook_event" in SessionStart|SessionEnd|FileChange|Reason) - # ── Session event: preserve hook_event_name + session_id + file_path ───── _sid_e="$(_jsesc "${session_id:-}")" _fp="$(_json_string_field "file_path")" if [[ -n "$_fp" ]]; then _fp_e="$(_jsesc "$_fp")" - _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"session_id\":\"$_sid_e\",\"file_path\":\"$_fp_e\"}" + if [[ "$hook_event" == "FileChange" ]]; then + _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"tool\":\"Edit\",\"session_id\":\"$_sid_e\",\"file_path\":\"$_fp_e\",\"file\":\"$_fp_e\"}" + else + _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"session_id\":\"$_sid_e\",\"file_path\":\"$_fp_e\"}" + fi else _payload="{\"ts\":\"$ts\",\"provider\":\"$provider_e\",\"stream\":\"$stream_e\",\"hook_event_name\":\"$hook_e\",\"session_id\":\"$_sid_e\"}" fi ;; *) - # ── Tool event (PostToolUse): extract one meaningful detail, no raw dump ── detail_key="" detail_val="" case "$tool" in Read) _fp="$(_json_string_field "file_path")" - # Detect skill file reads — log as Skill invocation case "$_fp" in - */.claude/skills/*/SKILL.md|*/.claude/skills/*/*) - _skill_name="$(printf '%s' "$_fp" | sed 's|.*\.claude/skills/\([^/]*\)/.*|\1|')" + */.claude/skills/*/SKILL.md|*/.claude/skills/*/*|*/.agents/skills/*/SKILL.md|*/.agents/skills/*/*) + _skill_name="${_fp#*/.claude/skills/}" + [[ "$_skill_name" == "$_fp" ]] && _skill_name="${_fp#*/.agents/skills/}" + _skill_name="${_skill_name%%/*}" if [[ -n "$_skill_name" ]]; then printf '{"ts":"%s","provider":"%s","stream":"%s","tool":"Skill","skill":"%s","session_id":"%s"}\n' \ "$ts" "$provider_e" "$stream_e" "$(_jsesc "$_skill_name")" "$(_jsesc "$session_id")" >> "$log_file" 2>/dev/null @@ -222,7 +211,6 @@ case "$hook_event" in ;; Edit|Write|MultiEdit|NotebookEdit) _fp="$(_json_string_field "file_path")" - # Skip .platform/ meta-file edits (memory, stream files, daemon state) _rel="${_fp##"$(pwd)/"}" case "$_rel" in .platform/*) exit 0 ;; esac if [[ -n "$_fp" ]]; then @@ -232,7 +220,6 @@ case "$hook_event" in ;; Bash) _cmd="$(_json_string_field "command")" - # Skip trivial internal commands — keep everything else case "$_cmd" in echo\ *|printf\ *|cat\ *|ls\ *|cd\ *|pwd|true|false|:|\ mkdir\ *|rm\ *|mv\ *|cp\ *|touch\ *|chmod\ *|wc\ *|head\ *|tail\ *|\ @@ -262,7 +249,7 @@ case "$hook_event" in detail_val="$_agent_id" ;; WebSearch|WebFetch) - exit 0 # internal research — not "what I changed" + exit 0 ;; esac _sid_e="$(_jsesc "${session_id:-}")" @@ -275,7 +262,6 @@ case "$hook_event" in ;; esac -# Write via daemon (concurrent-safe) or direct append fallback _port_file=".platform/.daemon-port" _daemon_ok=0 if [[ -f "$_port_file" ]] && command -v curl >/dev/null 2>&1; then diff --git a/templates/platform/scripts/hooks/session-snapshot.js b/templates/platform/scripts/hooks/session-snapshot.js new file mode 100755 index 0000000..9e4f700 --- /dev/null +++ b/templates/platform/scripts/hooks/session-snapshot.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * session-snapshot.js — write Agentboard live session JSON for provider wrappers/hooks. + * + * Claude Code writes this shape from status-bridge.js. Codex/Gemini wrappers and + * Codex native hooks call this helper so the VS Code dashboard can stay + * provider-agnostic and keep reading ~/.agentboard/sessions/*.json. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +const MAX_STDIN = 512 * 1024; + +function readStdin() { + try { + if (process.stdin.isTTY) return {}; + const chunks = []; + let total = 0; + const buf = fs.readFileSync(0); + total += buf.length; + if (total > MAX_STDIN) return {}; + chunks.push(buf); + const raw = Buffer.concat(chunks).toString('utf8').trim(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function get(obj, pathExpr) { + let cur = obj; + for (const part of pathExpr.split('.')) { + if (cur == null || typeof cur !== 'object') return undefined; + cur = cur[part]; + } + return cur; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return ''; +} + +function findProjectRoot(cwd) { + let dir = cwd || process.cwd(); + for (let i = 0; i < 30; i++) { + if (fs.existsSync(path.join(dir, '.platform')) || fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return cwd || process.cwd(); +} + +function readJson(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; } +} + +function writeJsonAtomic(file, data) { + const tmp = `${file}.tmp.${process.pid}`; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); + fs.renameSync(tmp, file); +} + +function gitBranch(root) { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, timeout: 500, encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} + +function deriveSessionId(payload, provider) { + return firstString( + payload.session_id, + payload.sessionId, + payload.conversation_id, + payload.conversationId, + payload.thread_id, + payload.threadId, + payload.transcript_id, + payload.transcriptId, + get(payload, 'session.id'), + get(payload, 'conversation.id'), + get(payload, 'thread.id'), + process.env.AGENTBOARD_SESSION_ID, + ) || `${provider || 'agent'}-${process.ppid || process.pid}`; +} + +function deriveModel(payload) { + const model = payload.model; + return firstString( + typeof model === 'string' ? model : '', + get(model || {}, 'api_name'), + get(model || {}, 'display_name'), + payload.model_name, + payload.modelName, + payload.model_id, + payload.modelId, + process.env.AGENTBOARD_MODEL, + ); +} + +function numberOrNull(...values) { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value); + } + return null; +} + +function writeSnapshot(payload = {}, opts = {}) { + const provider = firstString(opts.provider, process.env.AGENTBOARD_PROVIDER, payload.provider) || 'unknown'; + const cwd = firstString(opts.cwd, process.env.AGENTBOARD_CWD, get(payload, 'workspace.current_dir'), payload.cwd) || process.cwd(); + const root = findProjectRoot(cwd); + const sessionId = firstString(opts.sessionId, deriveSessionId(payload, provider)); + const model = firstString(opts.model, deriveModel(payload), provider); + const now = new Date().toISOString(); + const globalDir = path.join(os.homedir(), '.agentboard'); + const sessionsDir = path.join(globalDir, 'sessions'); + const sessionPath = path.join(sessionsDir, `${sessionId}.json`); + const hudPath = path.join(root, 'agentboard.hud-status.json'); + const existing = readJson(sessionPath); + const existingHud = readJson(hudPath); + const existingCtx = existing.context || {}; + const existingCost = existing.cost || {}; + const startedAt = firstString( + opts.startedAt, + existingCtx.started_at, + get(existingHud, 'context.started_at'), + ) || now; + + const ctxRemaining = numberOrNull( + opts.contextRemainingPct, + get(payload, 'context_window.remaining_percentage'), + payload.context_remaining_pct, + payload.contextRemainingPct, + existingCtx.context_remaining_pct, + ); + const ctxTokens = numberOrNull( + opts.contextTokens, + get(payload, 'context_window.current_tokens'), + payload.context_tokens, + payload.contextTokens, + existingCtx.context_tokens, + ); + const costUsd = numberOrNull( + opts.costUsd, + get(payload, 'cost.total_cost_usd'), + payload.total_cost_usd, + payload.cost_usd, + existingCost.session_usd, + ); + const shellPid = numberOrNull( + opts.shellPid, + process.env.AGENTBOARD_SHELL_PID, + payload.shell_pid, + payload.shellPid, + existing._shell_pid, + ) || 0; + + const snapshot = { + ...existing, + provider, + last_updated: now, + context: { + ...existingCtx, + provider, + model, + session_id: sessionId, + branch: firstString(existingCtx.branch, gitBranch(root)), + started_at: startedAt, + context_remaining_pct: ctxRemaining, + context_tokens: ctxTokens, + }, + cost: { + ...existingCost, + session_usd: costUsd, + session_tokens: ctxTokens, + }, + active_agents: [{ + role: get(existing, 'active_agents.0.role') || provider, + model, + started_at: startedAt, + session_id: sessionId, + provider, + }], + _root: root, + _session_id: sessionId, + _last_updated: now, + _shell_pid: shellPid, + }; + + writeJsonAtomic(sessionPath, snapshot); + writeJsonAtomic(hudPath, snapshot); + writeJsonAtomic(path.join(globalDir, 'live.json'), { ...snapshot, _root: root }); + + try { + const cutoff = Date.now() - 8 * 60 * 60 * 1000; + for (const file of fs.readdirSync(sessionsDir)) { + if (!file.endsWith('.json')) continue; + const fp = path.join(sessionsDir, file); + try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {} + } + } catch {} + + return snapshot; +} + +if (require.main === module) { + try { + const payload = readStdin(); + const snapshot = writeSnapshot(payload); + if (process.argv.includes('--print')) process.stdout.write(JSON.stringify(snapshot)); + } catch { + process.exit(0); + } +} + +module.exports = { writeSnapshot, deriveSessionId, findProjectRoot }; diff --git a/templates/platform/scripts/session-track.sh b/templates/platform/scripts/session-track.sh index 2067ad4..f84121a 100755 --- a/templates/platform/scripts/session-track.sh +++ b/templates/platform/scripts/session-track.sh @@ -15,6 +15,7 @@ # AGENTBOARD_PROVIDER and AGENTBOARD_SESSION_ID before calling these fns. _ab_events_hook=".platform/scripts/hooks/event-logger.sh" +_ab_session_snapshot_hook=".platform/scripts/hooks/session-snapshot.js" _ab_session_event() { local kind="$1" session_id="$2" extra="${3:-}" @@ -36,6 +37,36 @@ _ab_session_event() { printf '%s' "$payload" | bash "$_ab_events_hook" 2>/dev/null || true } +_ab_write_session_snapshot() { + local session_id="$1" provider="$2" + [[ -f "$_ab_session_snapshot_hook" ]] || return 0 + command -v node >/dev/null 2>&1 || return 0 + AGENTBOARD_PROVIDER="$provider" \ + AGENTBOARD_SESSION_ID="$session_id" \ + AGENTBOARD_CWD="$(pwd)" \ + node "$_ab_session_snapshot_hook" >/dev/null 2>&1 || true +} + +_ab_start_session_heartbeat() { + local session_id="$1" provider="$2" interval="${3:-15}" + [[ -f "$_ab_session_snapshot_hook" ]] || { printf '0'; return 0; } + command -v node >/dev/null 2>&1 || { printf '0'; return 0; } + _ab_write_session_snapshot "$session_id" "$provider" + ( + while sleep "$interval"; do + AGENTBOARD_PROVIDER="$provider" \ + AGENTBOARD_SESSION_ID="$session_id" \ + AGENTBOARD_CWD="$(pwd)" \ + node "$_ab_session_snapshot_hook" >/dev/null 2>&1 || true + done + ) >/dev/null 2>&1 & + printf '%s' $! +} + +_ab_stop_session_heartbeat() { + _ab_stop_file_poller "${1:-0}" +} + # Best-effort: start daemon if not already running. # Sets _ab_daemon_was_started=1 so the caller can stop it on exit. _ab_daemon_was_started=0 diff --git a/templates/root/AGENTS.md.template b/templates/root/AGENTS.md.template index 4a00bae..cb01de5 100644 --- a/templates/root/AGENTS.md.template +++ b/templates/root/AGENTS.md.template @@ -124,8 +124,9 @@ explicit checkpoints with specific `--what` / `--next`. The watcher skips ticks when it sees a manual checkpoint happened in the last 5 minutes, so it never clobbers fresh state. Stop with `ab watch --stop`. -> **Codex / Gemini:** start `ab watch &` at the beginning of every -> session — it's the closest equivalent to Claude Code's auto-checkpoint hooks. +> **Codex:** `ab init` also ships `.codex/config.toml` hooks and the +> `.platform/scripts/codex-ab` wrapper for dashboard telemetry. `ab watch` +> remains the extra checkpoint safety net for long sessions. ### New task bootstrap (before any non-trivial task — mandatory) diff --git a/templates/root/GEMINI.md.template b/templates/root/GEMINI.md.template index d65b7fb..b16c3a4 100644 --- a/templates/root/GEMINI.md.template +++ b/templates/root/GEMINI.md.template @@ -122,8 +122,9 @@ explicit checkpoints with specific `--what` / `--next`. The watcher skips ticks when it sees a manual checkpoint happened in the last 5 minutes, so it never clobbers fresh state. Stop with `ab watch --stop`. -> **Gemini / Codex:** start `ab watch &` at the beginning of every -> session — it's the closest equivalent to Claude Code's auto-checkpoint hooks. +> **Gemini / Codex:** provider wrappers under `.platform/scripts/` provide +> dashboard/session telemetry when aliases are installed. `ab watch` remains +> the extra checkpoint safety net for long sessions. ### New task bootstrap (before any non-trivial task — mandatory) diff --git a/tests/unit/codex_hook_bridge_test.sh b/tests/unit/codex_hook_bridge_test.sh new file mode 100755 index 0000000..8632356 --- /dev/null +++ b/tests/unit/codex_hook_bridge_test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/helpers.sh" + +export NO_COLOR=1 + +setup_codex_hook_fixture() { + local dir="$1" + printf '{}\n' > "$dir/package.json" + mkdir -p "$dir/src" + printf 'export const x = 1;\n' > "$dir/src/main.ts" + make_git_repo "$dir" main + commit_all "$dir" "initial" + init_project_fixture "$dir" + ( + cd "$dir" + git add .platform .claude CLAUDE.md >/dev/null 2>&1 || true + git commit -m "ab init" >/dev/null 2>&1 + "$TEST_ROOT/bin/ab" new-domain eng >/dev/null + "$TEST_ROOT/bin/ab" new-stream codex-work \ + --domain eng --base-branch main --branch feat/codex >/dev/null + ) +} + +BRIDGE="$TEST_ROOT/templates/platform/scripts/hooks/codex-hook-bridge.js" + +test_codex_post_tool_use_writes_session_and_event() { + local dir home + dir="$(mktemp -d)" + home="$(mktemp -d)" + setup_codex_hook_fixture "$dir" + + printf '%s' '{"session_id":"codex-native-1","tool_name":"Write","file_path":"src/main.ts","model":"gpt-5.4"}' \ + | (cd "$dir"; HOME="$home" AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=PostToolUse node "$BRIDGE") + + local log="$dir/.platform/events.jsonl" + [[ -f "$log" ]] || fail "events.jsonl not created by Codex hook bridge" + assert_file_contains "$log" '"provider":"codex"' + assert_file_contains "$log" '"tool":"Write"' + assert_file_contains "$log" '"file":"src/main.ts"' + assert_file_contains "$log" '"session_id":"codex-native-1"' + + local session_json="$home/.agentboard/sessions/codex-native-1.json" + [[ -f "$session_json" ]] || fail "session JSON not created by Codex hook bridge" + assert_file_contains "$session_json" '"provider": "codex"' + assert_file_contains "$session_json" '"_session_id": "codex-native-1"' + assert_file_contains "$session_json" '"model": "gpt-5.4"' +} + +test_codex_subagent_hooks_emit_agent_events() { + local dir home + dir="$(mktemp -d)" + home="$(mktemp -d)" + setup_codex_hook_fixture "$dir" + + printf '%s' '{"session_id":"codex-native-2","subagent_type":"researcher"}' \ + | (cd "$dir"; HOME="$home" AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=SubagentStart node "$BRIDGE") + printf '%s' '{"session_id":"codex-native-2","subagent_type":"researcher"}' \ + | (cd "$dir"; HOME="$home" AGENTBOARD_PROVIDER=codex AGENTBOARD_CODEX_HOOK_EVENT=SubagentStop node "$BRIDGE") + + local log="$dir/.platform/events.jsonl" + [[ -f "$log" ]] || fail "events.jsonl not created for Codex subagent hooks" + assert_file_contains "$log" '"tool":"AgentStart"' + assert_file_contains "$log" '"tool":"AgentDone"' + assert_file_contains "$log" '"label":"researcher"' + assert_file_contains "$log" '"session_id":"codex-native-2"' +} + +test_codex_post_tool_use_writes_session_and_event +test_codex_subagent_hooks_emit_agent_events diff --git a/tests/unit/event_logger_skill_role_test.sh b/tests/unit/event_logger_skill_role_test.sh index fac7594..ff0eb78 100644 --- a/tests/unit/event_logger_skill_role_test.sh +++ b/tests/unit/event_logger_skill_role_test.sh @@ -112,6 +112,50 @@ test_user_prompt_submit_skill_invocation() { rm -rf "$dir" } +# --------------------------------------------------------------------------- +# Test 3b: UserPromptSubmit respects AGENTBOARD_PROVIDER (Codex hooks/wrappers) +# --------------------------------------------------------------------------- +test_user_prompt_submit_skill_uses_provider_env() { + local dir + dir="$(mktemp -d)" + setup_logger_fixture "$dir" + + printf '%s' '{"hook_event_name":"UserPromptSubmit","prompt":"/ab-debug some task","session_id":"sess-ups-codex"}' \ + | (cd "$dir"; AGENTBOARD_PROVIDER=codex bash "$HOOK") + + local log="$dir/.platform/events.jsonl" + [[ -f "$log" ]] || fail "events.jsonl not created for provider-tagged UPS Skill event" + + assert_file_contains "$log" '"provider":"codex"' + assert_file_contains "$log" '"tool":"Skill"' + assert_file_contains "$log" '"skill":"ab-debug"' + + rm -rf "$dir" +} + +# --------------------------------------------------------------------------- +# Test 3c: Read of .agents/skills//SKILL.md emits Skill (Codex/Gemini) +# --------------------------------------------------------------------------- +test_agents_skill_read_emits_skill_event() { + local dir + dir="$(mktemp -d)" + setup_logger_fixture "$dir" + + mkdir -p "$dir/.agents/skills/ab-review" + printf '# Review\n' > "$dir/.agents/skills/ab-review/SKILL.md" + + _fire "$dir" "{\"tool_name\":\"Read\",\"file_path\":\"$dir/.agents/skills/ab-review/SKILL.md\",\"session_id\":\"sess-agent-skill\"}" + + local log="$dir/.platform/events.jsonl" + [[ -f "$log" ]] || fail "events.jsonl not created for .agents skill read" + + assert_file_contains "$log" '"tool":"Skill"' + assert_file_contains "$log" '"skill":"ab-review"' + assert_file_contains "$log" '"session_id":"sess-agent-skill"' + + rm -rf "$dir" +} + # --------------------------------------------------------------------------- # Test 4: UserPromptSubmit non-slash prompt → NOT logged (dropped) # --------------------------------------------------------------------------- @@ -236,6 +280,8 @@ test_skill_tool_type_field_fallback() { test_skill_tool_emits_skill_event test_role_adopt_from_platform_roles_read test_user_prompt_submit_skill_invocation +test_user_prompt_submit_skill_uses_provider_env +test_agents_skill_read_emits_skill_event test_user_prompt_submit_non_slash_is_dropped test_malformed_input_exits_zero test_empty_input_exits_zero diff --git a/tests/unit/roles_pack_test.sh b/tests/unit/roles_pack_test.sh index bc6d97e..30edb52 100644 --- a/tests/unit/roles_pack_test.sh +++ b/tests/unit/roles_pack_test.sh @@ -10,9 +10,6 @@ export NO_COLOR=1 ROLES_DIR="$TEST_ROOT/templates/platform/roles" INDEX="$ROLES_DIR/INDEX.md" -# The shipped role pack — alphabetical, one slug per word. -EXPECTED_SLUGS="backend-architect code-auditor code-cleanup-engineer data-analyst debugger devops-engineer feature-builder frontend-engineer pair-programmer perf-engineer product-manager qa-automation-engineer qa-engineer refactor-architect security-engineer startup-mvp tech-advisor tech-writer" - # pack_role_files — one role file path per line, skipping INDEX.md pack_role_files() { local f @@ -24,6 +21,10 @@ pack_role_files() { return 0 } +pack_role_slugs() { + pack_role_files | sed 's|.*/||; s|\.md$||' | sort +} + # pack_frontmatter — frontmatter value, surrounding quotes stripped. # Deliberately independent of the CLI's frontmatter parser so this contract # test keeps watching the templates even if the parser regresses. @@ -49,15 +50,15 @@ test_index_exists_with_roles_markers() { test_index_routing_table_lists_exactly_the_shipped_slugs() { local actual expected # Routing-table rows start with a backticked slug: | `startup-mvp` | … - actual="$(sed -n 's/^| `\([a-z-]*\)` |.*/\1/p' "$INDEX" | sort | tr '\n' ' ')" - expected="$(printf '%s\n' $EXPECTED_SLUGS | sort | tr '\n' ' ')" + actual="$(sed -n 's/^| `\([a-z0-9-]*\)` |.*/\1/p' "$INDEX" | sort | tr '\n' ' ')" + expected="$(pack_role_slugs | tr '\n' ' ')" assert_eq "$actual" "$expected" } test_index_routing_table_matches_files_on_disk() { local from_disk expected - from_disk="$(pack_role_files | sed 's|.*/||; s|\.md$||' | sort | tr '\n' ' ')" - expected="$(printf '%s\n' $EXPECTED_SLUGS | sort | tr '\n' ' ')" + from_disk="$(pack_role_slugs | tr '\n' ' ')" + expected="$(pack_role_slugs | tr '\n' ' ')" assert_eq "$from_disk" "$expected" } diff --git a/tests/unit/session_track_test.sh b/tests/unit/session_track_test.sh index 4d8d703..6df34b7 100644 --- a/tests/unit/session_track_test.sh +++ b/tests/unit/session_track_test.sh @@ -53,6 +53,31 @@ test_session_event_writes_jsonl_line() { assert_file_contains "$log" '"stream":"login"' } +test_session_snapshot_writes_dashboard_json() { + local dir home + dir="$(mktemp -d)" + home="$(mktemp -d)" + setup_track_fixture "$dir" + ( + cd "$dir" + export HOME="$home" + export AGENTBOARD_MODEL="gpt-5.4/medium" + export AGENTBOARD_SHELL_PID="12345" + # shellcheck disable=SC1091 + . "$dir/.platform/scripts/session-track.sh" + _ab_write_session_snapshot "codex-test-json" "codex" + ) + local session_json="$home/.agentboard/sessions/codex-test-json.json" + [[ -f "$session_json" ]] || fail "session snapshot JSON not created" + assert_file_contains "$session_json" '"provider": "codex"' + assert_file_contains "$session_json" '"_session_id": "codex-test-json"' + assert_file_contains "$session_json" '"_root": "'"$dir"'"' + assert_file_contains "$session_json" '"model": "gpt-5.4/medium"' + assert_file_contains "$session_json" '"_shell_pid": 12345' + [[ -f "$dir/agentboard.hud-status.json" ]] || fail "workspace hud snapshot not created" + [[ -f "$home/.agentboard/live.json" ]] || fail "global live snapshot not created" +} + test_file_poller_logs_changed_tracked_files() { local dir dir="$(mktemp -d)" @@ -73,6 +98,9 @@ test_file_poller_logs_changed_tracked_files() { [[ -f "$log" ]] || fail "events.jsonl not created by poller" assert_file_contains "$log" '"hook_event_name":"FileChange"' assert_file_contains "$log" '"file_path":"package.json"' + assert_file_contains "$log" '"tool":"Edit"' + assert_file_contains "$log" '"file":"package.json"' + assert_file_contains "$log" '"provider":"codex"' assert_file_contains "$log" '"session_id":"test-session"' } @@ -176,6 +204,22 @@ test_wrappers_reference_session_track() { || fail "codex-ab does not emit SessionStart event" grep -q "SessionEnd" "$codex" \ || fail "codex-ab does not emit SessionEnd event" + grep -q "_ab_start_session_heartbeat" "$codex" \ + || fail "codex-ab does not start dashboard session heartbeat" +} + +test_codex_config_wires_agentboard_hooks() { + local config="$TEST_ROOT/templates/codex/config.toml" + grep -q "hooks.SessionStart" "$config" \ + || fail "Codex config does not wire SessionStart hook" + grep -q "hooks.PostToolUse" "$config" \ + || fail "Codex config does not wire PostToolUse hook" + grep -q "hooks.SubagentStart" "$config" \ + || fail "Codex config does not wire SubagentStart hook" + grep -q "hooks.SubagentStop" "$config" \ + || fail "Codex config does not wire SubagentStop hook" + grep -q "codex-hook-bridge.js" "$config" \ + || fail "Codex config does not reference codex-hook-bridge.js" } test_update_refreshes_wrappers_and_tracker() { @@ -246,10 +290,12 @@ test_check_unreasoned_changes_ignores_other_sessions() { test_session_track_helper_installed test_session_event_writes_jsonl_line +test_session_snapshot_writes_dashboard_json test_file_poller_logs_changed_tracked_files test_file_poller_stops_cleanly test_file_poller_dedupes_across_concurrent_sessions test_file_poller_relogs_same_file_after_new_diff test_file_poller_clears_state_after_file_returns_clean test_wrappers_reference_session_track +test_codex_config_wires_agentboard_hooks test_update_refreshes_wrappers_and_tracker From bfd4c72b62cc684202e64b88e3290b7db338cb5d Mon Sep 17 00:00:00 2001 From: Danil Ulmashev Date: Fri, 26 Jun 2026 22:22:29 -0400 Subject: [PATCH 2/2] fix: align agentboard validation contracts --- .agents/skills/ab-graphify/SKILL.md | 5 +++++ .claude/skills/ab-graphify/SKILL.md | 5 +++++ lib/agentboard/commands/validate.sh | 6 +++--- templates/skills/ab-graphify/SKILL.md | 5 +++++ templates/skills/ab-qa-self-heal/SKILL.md | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.agents/skills/ab-graphify/SKILL.md b/.agents/skills/ab-graphify/SKILL.md index da63cdc..22b86c1 100644 --- a/.agents/skills/ab-graphify/SKILL.md +++ b/.agents/skills/ab-graphify/SKILL.md @@ -1,3 +1,8 @@ +--- +name: ab-graphify +description: "Graphify maps the codebase into a queryable structural knowledge graph for research and architecture navigation." +--- + # ab-graphify Graphify maps your entire codebase into a queryable structural knowledge graph stored at diff --git a/.claude/skills/ab-graphify/SKILL.md b/.claude/skills/ab-graphify/SKILL.md index da63cdc..22b86c1 100644 --- a/.claude/skills/ab-graphify/SKILL.md +++ b/.claude/skills/ab-graphify/SKILL.md @@ -1,3 +1,8 @@ +--- +name: ab-graphify +description: "Graphify maps the codebase into a queryable structural knowledge graph for research and architecture navigation." +--- + # ab-graphify Graphify maps your entire codebase into a queryable structural knowledge graph stored at diff --git a/lib/agentboard/commands/validate.sh b/lib/agentboard/commands/validate.sh index 610279c..c9c8a3d 100644 --- a/lib/agentboard/commands/validate.sh +++ b/lib/agentboard/commands/validate.sh @@ -23,7 +23,7 @@ _fm() { _ci_warn() { printf 'agentboard-validate: WARNING %s\n' "$1"; } # _ci_annotation — emit GitHub Actions error annotation -_ci_annotation() { printf '::error file=%s::%s\n' "$1" "$2"; } +_ci_annotation() { printf '::error file=%s::%s\n' "$1" "$2" >&2; } # _check_fields # Warns on missing fields. In CI mode also emits ::error annotations. @@ -77,7 +77,7 @@ _validate_skills() { fi _sw=$((_sw+1)); continue fi - _m="$(_check_fields "$_ci" "$_f" "skill" name description version origin)" + _m="$(_check_fields "$_ci" "$_f" "skill" name description)" if [[ "$_m" -eq 0 ]]; then [[ "$_ci" != "1" ]] && ok "${_lbl}" _sk=$((_sk+1)) @@ -115,7 +115,7 @@ _validate_roles() { fi _rw=$((_rw+1)); continue fi - _m="$(_check_fields "$_ci" "$_f" "role" name description routes_to)" + _m="$(_check_fields "$_ci" "$_f" "role" slug name label ansi_color mission)" if [[ "$_m" -eq 0 ]]; then [[ "$_ci" != "1" ]] && ok "${_lbl}" _rk=$((_rk+1)) diff --git a/templates/skills/ab-graphify/SKILL.md b/templates/skills/ab-graphify/SKILL.md index da63cdc..22b86c1 100644 --- a/templates/skills/ab-graphify/SKILL.md +++ b/templates/skills/ab-graphify/SKILL.md @@ -1,3 +1,8 @@ +--- +name: ab-graphify +description: "Graphify maps the codebase into a queryable structural knowledge graph for research and architecture navigation." +--- + # ab-graphify Graphify maps your entire codebase into a queryable structural knowledge graph stored at diff --git a/templates/skills/ab-qa-self-heal/SKILL.md b/templates/skills/ab-qa-self-heal/SKILL.md index 0e62145..30b5f84 100644 --- a/templates/skills/ab-qa-self-heal/SKILL.md +++ b/templates/skills/ab-qa-self-heal/SKILL.md @@ -85,7 +85,7 @@ Probe for project-provided tools before inventing new ones: - **Maestro mobile/web:** `.maestro/`, `maestro`, `scripts/*maestro*`, `docs/*maestro*`, app ids, simulator/emulator docs. - **Browser/web:** Playwright, Cypress, Selenium, project dev server scripts, Browser plugin, Playwright skill. -- **API/backend:** package scripts, test runners, Postman/Newman, curl examples, OpenAPI specs, integration tests. +- **API/backend:** package scripts, test runners, Postman/Newman, API request examples, OpenAPI specs, integration tests. - **Unit/integration:** existing test commands and focused test filters. - **Load/rate-limit:** project-local load scripts, fake services, emulator suites, rate-limit unit tests.