Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
29 changes: 29 additions & 0 deletions .aiox/current-session/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Current Session Runtime Artifacts

This directory contains **runtime-only** artifacts for the current Claude Code session.
These files are gitignored and regenerated automatically by hooks.

## Files

### micro-handoff.json (Tier 1)

Created automatically when an agent switch (`@agent`) is detected.
Contains: from/to agent, story context, decisions, files modified, blockers, next action.
Schema enforced: max 5 decisions, 10 files, 3 blockers.

### state.yaml (Tier 2)

Append-only YAML timeline of session events.
Updated automatically every 5 messages and on milestone events.
Events: agent_switch, story_start, story_complete, qa_gate, commit, periodic.

## Lifecycle

- Created: On first relevant trigger in a session
- Updated: Automatically by hooks (UserPromptSubmit, PreCompact)
- Archived: On session end or `/compact`, state moves to `.aiox/session-history/`
- Deleted: Safe to delete at any time (will be regenerated)

## Reference

See `.claude/rules/unified-handoff.md` for the full 3-tier specification.
110 changes: 110 additions & 0 deletions .aiox/docs/handoff-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Unified Handoff System -- Design Document

**Story:** AIOX-HO-1
**Version:** 1.0
**Author:** @dev (Dex)
**Date:** 2026-03-25

## Problem

The original AIOX handoff system had 6 critical gaps: unreliable context bracket triggers, agent handoff artifacts that were never actually persisted, unbounded session handoff files (430+ lines), siloed agent/session handoff systems, soft enforcement dependent on LLM compliance, and no recovery validation.

## Solution: 3-Tier Architecture

### Tier 1: Micro-Handoff (Agent Switch)

- **File:** `.aiox/current-session/micro-handoff.json`
- **Trigger:** UserPromptSubmit hook detects `@agent` in prompt
- **Content:** From/to agent, story context, decisions (max 5), files (max 10), blockers (max 3)
- **Rotation:** Max 3 unconsumed handoffs; oldest auto-discarded
- **Module:** `.claude/lib/handoff/micro-handoff.js`

### Tier 2: Session State (In-Session Timeline)

- **File:** `.aiox/current-session/state.yaml`
- **Trigger:** Every 5 prompts (periodic) + milestone events
- **Events:** agent_switch, story_start, story_complete, qa_gate, commit, periodic
- **Pattern:** Append-only YAML (no rewrite, preserves timeline)
- **Module:** `.claude/lib/handoff/session-state.js`

### Tier 3: Cross-Session Handoff

- **File:** `docs/session-handoff-{project}.md`
- **Trigger:** PreCompact hook (before `/compact`)
- **Size limit:** ~200 lines (auto-trimmed)
- **Archive:** `.aiox/session-history/{project}/archive-{timestamp}.md`
- **Recovery:** Validates handoff vs `git status --short`, warns if >20% drift
- **Module:** `.claude/lib/handoff/cross-session-handoff.js`

## Hook Integration

### UserPromptSubmit: `handoff-auto.cjs`

Second entry in the UserPromptSubmit hooks array (does NOT modify synapse-wrapper.cjs).

1. Reads stdin JSON from Claude Code hook protocol
2. Extracts user prompt text
3. Detects `@agent` pattern -> saves Tier 1 + Tier 2 agent_switch event
4. Increments prompt counter -> every 5th prompt triggers Tier 2 periodic snapshot
5. All errors caught silently (never blocks SYNAPSE)

### PreCompact: `handoff-saver.cjs` (chained by `precompact-wrapper.cjs`)

1. Wrapper calls handoff-saver BEFORE session-digest
2. handoff-saver discovers all `docs/session-handoff-*.md` files
3. Trims any that exceed ~200 lines, archiving excess
4. 5000ms timeout, silent failure (never blocks PreCompact)

## File Map

```
.claude/rules/unified-handoff.md # Unified rule (replaces 2 deprecated rules)
.claude/hooks/handoff-auto.cjs # UserPromptSubmit hook (Tier 1+2 triggers)
.claude/hooks/handoff-saver.cjs # PreCompact hook component (Tier 3 trigger)
.claude/hooks/precompact-wrapper.cjs # Modified to chain handoff-saver
.claude/settings.json # Updated with handoff-auto hook entry

.claude/lib/handoff/micro-handoff.js # Tier 1 module
.claude/lib/handoff/session-state.js # Tier 2 module
.claude/lib/handoff/cross-session-handoff.js # Tier 3 module
.claude/lib/handoff/migrate-handoffs.js # Migration script

.aiox/current-session/ # Runtime (gitignored)
micro-handoff.json # Tier 1 artifact
state.yaml # Tier 2 artifact
.prompt-count # Prompt counter
README.md # Documentation

.aiox/session-history/{project}/ # Archives (gitignored)
archive-{timestamp}.md # Archived handoff content

tests/handoff/
micro-handoff.test.js # 18 tests
session-state.test.js # 17 tests
cross-session-handoff.test.js # 24 tests
integration.test.js # 12 tests
```

## Test Summary

- **micro-handoff.test.js**: 18 tests (save, read, consume, rotate, schema validation)
- **session-state.test.js**: 17 tests (init, update, events, reset, roundtrip, event types)
- **cross-session-handoff.test.js**: 24 tests (save, trim, archive, validate, drift, parse, extract)
- **integration.test.js**: 12 tests (agent flow, milestones, trimming, recovery, hooks, manual)
- **Total: 71 tests, all passing**

## Constraints

- Node.js stdlib only (fs, path, os, child_process)
- CommonJS (require/module.exports)
- ES2022 features allowed
- Zero changes to `.aiox-core/` (L1/L2 protected)
- Handoff save errors never block other hooks
- 5000ms timeout on all handoff save operations

## Deprecated Rules

- `.claude/rules/agent-handoff.md` -- Superseded by Tier 1 (micro-handoff)
- `.claude/rules/auto-session-handoff.md` -- Superseded by Tier 3 (cross-session)

Both files have deprecation banners pointing to `.claude/rules/unified-handoff.md`.
226 changes: 226 additions & 0 deletions .claude/hooks/handoff-auto.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#!/usr/bin/env node
'use strict';

/**
* Handoff Auto — UserPromptSubmit Hook
*
* Registered as SECOND entry in UserPromptSubmit hooks array.
* Does NOT modify synapse-wrapper.cjs.
*
* Detects:
* 1. Agent switch (@agent pattern) → Tier 1 micro-handoff save + Tier 2 agent_switch event
* 2. Every 5 prompts → Tier 2 periodic snapshot
*
* CRITICAL: Errors here MUST NOT block SYNAPSE processing.
* Timeout: 5000ms max.
*
* @module handoff-auto
* @see .claude/rules/unified-handoff.md
* @see Story AIOX-HO-1
*/

const path = require('path');
const fs = require('fs');

const TIMEOUT_MS = 5000;
const PERIODIC_INTERVAL = 5;

/** Agent switch regex: matches @agent-name patterns */
const AGENT_PATTERN = /@(dev|qa|architect|pm|po|sm|analyst|data-engineer|ux-design-expert|devops|aiox-master)\b/;

/**
* Read JSON from stdin synchronously (Claude Code hook protocol).
* @returns {object|null} Parsed input or null
*/
function readStdinSync() {
try {
const data = fs.readFileSync(0, 'utf8');
return data ? JSON.parse(data) : null;
} catch (_) {
return null;
}
}

/**
* Get or increment prompt count from a simple counter file.
* @param {string} projectRoot - Project root directory
* @returns {number} Current prompt count after increment
*/
function incrementPromptCount(projectRoot) {
const counterPath = path.join(projectRoot, '.aiox', 'current-session', '.prompt-count');
let count = 0;
try {
count = parseInt(fs.readFileSync(counterPath, 'utf8').trim(), 10) || 0;
} catch (_) {
// File doesn't exist yet
}
count++;
try {
const dir = path.dirname(counterPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(counterPath, String(count), 'utf8');
} catch (_) {
// Ignore write errors
}
return count;
}

/**
* Detect agent switch from user prompt content.
* @param {string} prompt - User prompt text
* @returns {{ detected: boolean, agent: string|null }}
*/
function detectAgentSwitch(prompt) {
if (!prompt || typeof prompt !== 'string') {
return { detected: false, agent: null };
}
const match = prompt.match(AGENT_PATTERN);
if (match) {
return { detected: true, agent: match[1] };
}
return { detected: false, agent: null };
}

/**
* Get the user prompt from hook input.
* Claude Code sends different formats, handle both.
* @param {object} input - Hook input
* @returns {string} User prompt text
*/
function extractPrompt(input) {
if (!input) return '';

// Claude Code hook format: input.prompt or input.user_prompt or input.message
if (typeof input.prompt === 'string') return input.prompt;
if (typeof input.user_prompt === 'string') return input.user_prompt;
if (typeof input.message === 'string') return input.message;

// Try to find prompt in conversation array
if (Array.isArray(input.messages)) {
const last = input.messages[input.messages.length - 1];
if (last && typeof last.content === 'string') return last.content;
}

return '';
}

/**
* Main handler: detect agent switches and periodic snapshots.
*/
function main() {
const input = readStdinSync();
if (!input) return;

const projectRoot = input.cwd || path.resolve(__dirname, '..', '..');
const prompt = extractPrompt(input);

// Increment prompt count
const promptCount = incrementPromptCount(projectRoot);

// 1. Detect agent switch
const { detected, agent } = detectAgentSwitch(prompt);

if (detected) {
// Determine the outgoing agent from session state (last known agent_switch)
let fromAgent = 'unknown';
try {
const sessionState = require(path.join(projectRoot, '.claude', 'lib', 'handoff', 'session-state'));
const state = sessionState.getSessionState(projectRoot);
const agentEvents = (state.events || []).filter((e) => e.type === 'agent_switch' && e.agent);
if (agentEvents.length > 0) {
fromAgent = agentEvents[agentEvents.length - 1].agent;
}
} catch (_) {
// Session state not available -- use 'unknown'
}

// Extract memory hints from the outgoing agent's MEMORY.md (Story AIOX-HO-2.2)
let memoryHints = [];
try {
const memHints = require(path.join(projectRoot, '.claude', 'lib', 'handoff', 'memory-hints'));
memoryHints = memHints.extractMemoryHints(fromAgent, {
story_id: '',
current_task: '',
}, projectRoot);
} catch (_) {
// Memory hints module not available or error -- skip silently
}

// Tier 1: Save micro-handoff
try {
const microHandoff = require(path.join(projectRoot, '.claude', 'lib', 'handoff', 'micro-handoff'));
microHandoff.saveMicroHandoff(fromAgent, agent, {
story_context: {
story_id: '',
story_path: '',
story_status: '',
current_task: '',
branch: '',
},
memory_hints: memoryHints,
next_action: `Agent switch to @${agent} detected`,
}, projectRoot);
} catch (_) {
// Module not available -- skip silently
}

// Tier 2: Log agent_switch event
try {
const sessionState = require(path.join(projectRoot, '.claude', 'lib', 'handoff', 'session-state'));
sessionState.updateSessionState('agent_switch', {
agent: agent,
details: `Agent switch to @${agent} detected in prompt`,
prompt_count: promptCount,
}, projectRoot);
} catch (_) {
// Module not available -- skip silently
}
}

// 2. Periodic snapshot every PERIODIC_INTERVAL prompts
if (promptCount > 0 && promptCount % PERIODIC_INTERVAL === 0) {
try {
const sessionState = require(path.join(projectRoot, '.claude', 'lib', 'handoff', 'session-state'));
sessionState.updateSessionState('periodic', {
prompt_count: promptCount,
}, projectRoot);
} catch (_) {
// Module not available -- skip silently
}
}
}

/**
* Entry point with timeout protection.
*/
function run() {
const timer = setTimeout(() => {
process.exit(0);
}, TIMEOUT_MS);
timer.unref();

try {
main();
} catch (_) {
// Silent exit -- never block SYNAPSE
}

clearTimeout(timer);
process.exitCode = 0;
}

if (require.main === module) run();

module.exports = {
readStdinSync,
incrementPromptCount,
detectAgentSwitch,
extractPrompt,
main,
run,
AGENT_PATTERN,
PERIODIC_INTERVAL,
TIMEOUT_MS,
};
Loading
Loading