diff --git a/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/.openspec.yaml b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/.openspec.yaml new file mode 100644 index 0000000..9f70866 --- /dev/null +++ b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/proposal.md b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/proposal.md new file mode 100644 index 0000000..8036ece --- /dev/null +++ b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/proposal.md @@ -0,0 +1,19 @@ +## Why + +OMX lifecycle SessionStart refreshes active-session telemetry before the normal +SessionStart attention contract renders. When the refreshed lane was previously +stale or dead, the startup context could lose the stalled-lane signal that told +the agent it was resuming a risky lane. + +## What Changes + +Capture stalled-lane attention before routing lifecycle SessionStart through the +hook runner, then prepend a bounded startup banner to the returned context. Add a +focused lifecycle-envelope regression that proves the stale-lane signal survives +the telemetry refresh. + +## Impact + +Affected surface is the OMX lifecycle SessionStart route. Output is bounded to +three stalled lanes and falls back silently if attention collection is +unavailable, matching existing hook best-effort behavior. diff --git a/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/specs/startup-banner-from-hook-contracts-on-stalled-lane/spec.md b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/specs/startup-banner-from-hook-contracts-on-stalled-lane/spec.md new file mode 100644 index 0000000..617b46b --- /dev/null +++ b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/specs/startup-banner-from-hook-contracts-on-stalled-lane/spec.md @@ -0,0 +1,18 @@ +## ADDED Requirements + +### Requirement: startup-banner-from-hook-contracts-on-stalled-lane behavior +The OMX lifecycle SessionStart route SHALL preserve stalled-lane attention +signals that exist before the SessionStart hook refreshes active-session +telemetry. + +#### Scenario: Lifecycle SessionStart resumes a stale lane +- **GIVEN** the repo has a stale or dead active-session lane before lifecycle routing +- **WHEN** an OMX `session_start` envelope is routed for that repo +- **THEN** the returned SessionStart context includes a bounded stalled-lane banner +- **AND** the banner is collected before the SessionStart hook refreshes telemetry. + +#### Scenario: Stalled lane attention is noisy +- **GIVEN** more stalled lanes exist than the startup banner can show +- **WHEN** lifecycle SessionStart renders the startup context +- **THEN** the banner shows at most three stalled lanes +- **AND** collapsed lanes are summarized with an `attention_inbox` follow-up hint. diff --git a/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/tasks.md b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/tasks.md new file mode 100644 index 0000000..36ad887 --- /dev/null +++ b/openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/tasks.md @@ -0,0 +1,38 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`; branch=`agent/codex/startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`; scope=`packages/hooks/src/lifecycle-envelope.ts`; action=`finish verification and cleanup`. +- Copy prompt: Continue `agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47` on branch `agent/codex/startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`. Work inside the existing sandbox, review `openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/startup-banner-from-hook-contracts-on-st-2026-05-15-20-47 --base dev --via-pr --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`. +- [x] 1.2 Define normative requirements in `specs/startup-banner-from-hook-contracts-on-stalled-lane/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. + - `pnpm --filter @colony/hooks test -- lifecycle-envelope.test.ts session-start.test.ts` + - `pnpm --filter @colony/hooks typecheck` + - `pnpm --filter @colony/hooks build` + - `git diff --check` +- [x] 3.2 Run `openspec validate agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/packages/hooks/src/lifecycle-envelope.ts b/packages/hooks/src/lifecycle-envelope.ts index 3afbce1..7ad211a 100644 --- a/packages/hooks/src/lifecycle-envelope.ts +++ b/packages/hooks/src/lifecycle-envelope.ts @@ -1,6 +1,6 @@ import path, { join } from 'node:path'; import { loadSettings, resolveDataDir } from '@colony/config'; -import { MemoryStore, TaskThread, inferIdeFromSessionId } from '@colony/core'; +import { MemoryStore, TaskThread, buildAttentionInbox, inferIdeFromSessionId } from '@colony/core'; import type { ObservationRow } from '@colony/storage'; import { extractTouchedFiles, pathExtractionWarningsForToolUse } from './handlers/post-tool-use.js'; import { runHook } from './runner.js'; @@ -73,6 +73,8 @@ const EVENT_TYPES = new Set([ 'finish_result', ]); +const SESSION_START_STALLED_LANE_LIMIT = 3; + export function isOmxLifecycleEnvelopeLike(value: unknown): boolean { const root = asRecord(value); if (!root) return false; @@ -260,7 +262,11 @@ async function routeLifecycleEvent( } if (event.event_type === 'session_start') { - const result = await runHook('session-start', hookInputFromLifecycle(event), { store }); + const stalledLaneBanner = sessionStartStalledLaneBanner(store, event); + const result = prependContext( + await runHook('session-start', hookInputFromLifecycle(event), { store }), + stalledLaneBanner, + ); bindTaskFromLifecycle(store, event); return hookRouteResult('session-start', result); } @@ -418,6 +424,67 @@ function hookRouteResult( }; } +function prependContext(result: T, prefix: string): T { + if (!prefix) return result; + return { + ...result, + context: result.context ? `${prefix}\n\n${result.context}` : prefix, + }; +} + +function sessionStartStalledLaneBanner( + store: MemoryStore, + event: NormalizedOmxLifecycleEvent, +): string { + try { + const inbox = buildAttentionInbox(store, { + session_id: event.session_id, + agent: event.agent, + repo_root: event.repo_root, + stalled_lane_limit: SESSION_START_STALLED_LANE_LIMIT, + }); + const lanes = inbox.stalled_lanes.slice(0, SESSION_START_STALLED_LANE_LIMIT); + if (lanes.length === 0) return ''; + + const total = inbox.summary.stalled_lane_count; + const lines = [`Stalled lanes at SessionStart (${lanes.length} of ${total}):`]; + for (const lane of lanes) { + lines.push( + ` -> ${lane.owner} ${lane.activity} on ${lane.branch}: ${compactBannerText( + lane.task, + )}${bannerTiming(lane.updated_at, inbox.generated_at)}`, + ); + } + const collapsed = Math.max(0, total - lanes.length); + if (collapsed > 0 || inbox.stalled_lanes_truncated) { + lines.push(` Plus ${collapsed} collapsed. Run attention_inbox to see all.`); + } + return lines.join('\n'); + } catch (err) { + console.error(`[colony] sessionStartStalledLaneBanner: ${(err as Error)?.message ?? err}`); + return ''; + } +} + +function compactBannerText(value: string): string { + const compact = value.replace(/\s+/g, ' ').trim(); + return compact.length <= 140 ? compact : `${compact.slice(0, 137)}...`; +} + +function bannerTiming(updatedAt: string, now: number): string { + const updated = Date.parse(updatedAt); + if (!Number.isFinite(updated)) return ''; + return ` (${formatDuration(now - updated)} old)`; +} + +function formatDuration(ms: number): string { + const minutes = Math.max(0, Math.round(ms / 60_000)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + function bindTaskFromLifecycle( store: MemoryStore, event: NormalizedOmxLifecycleEvent, diff --git a/packages/hooks/test/lifecycle-envelope.test.ts b/packages/hooks/test/lifecycle-envelope.test.ts index b9d5bb4..1f95c1e 100644 --- a/packages/hooks/test/lifecycle-envelope.test.ts +++ b/packages/hooks/test/lifecycle-envelope.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defaultSettings } from '@colony/config'; @@ -291,6 +291,47 @@ describe('OMX lifecycle envelope', () => { expect(handoff?.content).toContain('src/git-only.ts'); expect(handoff?.content).toContain('claimed_files=src/runtime.ts'); }); + + it('keeps a bounded stalled-lane banner when lifecycle SessionStart refreshes telemetry', async () => { + const repo = fakeGitRepo('repo-stalled-start', 'agent/codex/stalled-start'); + const sessionFile = writeActiveSession(repo, { + sessionKey: 'codex@stalled', + branch: 'agent/codex/stalled-start', + taskName: 'stale stalled task', + latestTaskPreview: 'stale stalled task', + lastHeartbeatAt: new Date(Date.now() - 10 * 60_000).toISOString(), + state: 'working', + }); + + const result = await runOmxLifecycleEnvelope( + envelope({ + event_id: 'evt_stalled_session_start', + event_name: 'session_start', + session_id: 'codex@stalled', + cwd: repo, + repo_root: repo, + branch: 'agent/codex/stalled-start', + }), + { store }, + ); + + expect(result).toMatchObject({ + ok: true, + event_id: 'evt_stalled_session_start', + route: 'session-start', + }); + expect(result.context).toContain('Stalled lanes at SessionStart (1 of 1):'); + expect(result.context).toContain('codex/codex dead on agent/codex/stalled-start'); + expect(result.context).toContain('stale stalled task'); + + const refreshedSession = JSON.parse(readFileSync(sessionFile, 'utf8')) as { + lastHeartbeatAt?: string; + }; + expect(refreshedSession.lastHeartbeatAt).not.toBeUndefined(); + expect(Date.parse(refreshedSession.lastHeartbeatAt ?? '')).toBeGreaterThan( + Date.now() - 60_000, + ); + }); }); function envelope(overrides: Record): Record { @@ -315,6 +356,51 @@ function fakeGitRepo(name: string, branch: string): string { return repo; } +function writeActiveSession( + repo: string, + record: { + sessionKey: string; + branch: string; + taskName: string; + latestTaskPreview: string; + lastHeartbeatAt: string; + state: string; + }, +): string { + const sessionFile = join( + repo, + '.omx', + 'state', + 'active-sessions', + `${record.sessionKey.replace(/[^a-zA-Z0-9._-]+/g, '_')}.json`, + ); + mkdirSync(join(repo, '.omx', 'state', 'active-sessions'), { recursive: true }); + writeFileSync( + sessionFile, + `${JSON.stringify( + { + schemaVersion: 1, + repoRoot: repo, + branch: record.branch, + taskName: record.taskName, + latestTaskPreview: record.latestTaskPreview, + agentName: 'codex', + cliName: 'codex', + worktreePath: repo, + taskRoutingReason: 'test stale hook contract', + startedAt: new Date(Date.now() - 20 * 60_000).toISOString(), + lastHeartbeatAt: record.lastHeartbeatAt, + state: record.state, + sessionKey: record.sessionKey, + }, + null, + 2, + )}\n`, + 'utf8', + ); + return sessionFile; +} + function parseMetadata(value: string | null | undefined): Record | null { if (!value) return null; return JSON.parse(value) as Record; diff --git a/packages/hooks/test/session-start.test.ts b/packages/hooks/test/session-start.test.ts index c1a443a..f047396 100644 --- a/packages/hooks/test/session-start.test.ts +++ b/packages/hooks/test/session-start.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { defaultSettings } from '@colony/config'; +import { defaultSettings, type Settings } from '@colony/config'; import { type Embedder, MemoryStore, TaskThread } from '@colony/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -21,6 +21,7 @@ const THRESHOLDS = { let dir: string; let repo: string; let store: MemoryStore; +let storeRevision: number; class FakeEmbedder implements Embedder { readonly model = 'fake-model'; @@ -118,6 +119,7 @@ function noSuggestionDeps(): SuggestionPrefaceDeps { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'colony-session-start-suggestions-')); repo = join(dir, 'repo'); + storeRevision = 0; mkdirSync(repo, { recursive: true }); fakeGitCheckout(repo, 'agent/codex/sessionstart-suggest'); store = new MemoryStore({ @@ -129,6 +131,15 @@ beforeEach(() => { }); }); +function resetStoreWithSettings(settings: Settings): void { + store.close(); + storeRevision += 1; + store = new MemoryStore({ + dbPath: join(dir, `data-${storeRevision}.db`), + settings, + }); +} + afterEach(() => { store.close(); rmSync(dir, { recursive: true, force: true }); @@ -192,10 +203,10 @@ describe('SessionStart predictive suggestion preface', () => { ); it('injects the legacy verbose contract when sessionStart.contractMode is full', async () => { - store.settings = { + resetStoreWithSettings({ ...store.settings, sessionStart: { contractMode: 'full' }, - }; + }); const preface = await sessionStart( store, { session_id: 'S-full', ide: 'codex', cwd: repo }, @@ -210,10 +221,10 @@ describe('SessionStart predictive suggestion preface', () => { }); it('omits the contract section when sessionStart.contractMode is none', async () => { - store.settings = { + resetStoreWithSettings({ ...store.settings, sessionStart: { contractMode: 'none' }, - }; + }); const preface = await sessionStart( store, { session_id: 'S-none', ide: 'codex', cwd: repo },