From ad3a1e057e9963aa8b65f607a0e5583e922fb63d Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Thu, 5 Mar 2026 13:44:54 -0800 Subject: [PATCH] Add orchestrator hooks and state management CLI --- .github/hooks/orchestrator.json | 18 +++ .github/hooks/state-utils.js | 273 ++++++++++++++++++++++++++++++++ .github/hooks/subagent-start.js | 97 ++++++++++++ .github/hooks/subagent-stop.js | 86 ++++++++++ 4 files changed, 474 insertions(+) create mode 100644 .github/hooks/orchestrator.json create mode 100644 .github/hooks/state-utils.js create mode 100644 .github/hooks/subagent-start.js create mode 100644 .github/hooks/subagent-stop.js diff --git a/.github/hooks/orchestrator.json b/.github/hooks/orchestrator.json new file mode 100644 index 00000000..adec1e5c --- /dev/null +++ b/.github/hooks/orchestrator.json @@ -0,0 +1,18 @@ +{ + "hooks": { + "SubagentStart": [ + { + "type": "command", + "command": "node .github/hooks/subagent-start.js", + "timeout": 10 + } + ], + "SubagentStop": [ + { + "type": "command", + "command": "node .github/hooks/subagent-stop.js", + "timeout": 10 + } + ] + } +} diff --git a/.github/hooks/state-utils.js b/.github/hooks/state-utils.js new file mode 100644 index 00000000..d9a027c2 --- /dev/null +++ b/.github/hooks/state-utils.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * Shared state file utilities for the Feature Orchestrator. + * + * The state file (state.json) lives at ~/.android-auth-orchestrator/ and is + * read/written by both hooks (via this CLI script) and the VS Code extension. + * + * Usage from hooks: + * node .github/hooks/state-utils.js get → prints full state JSON + * node .github/hooks/state-utils.js get-feature → prints one feature + * node .github/hooks/state-utils.js set-step → updates a feature's step + * node .github/hooks/state-utils.js add-feature → adds/updates a feature + * node .github/hooks/state-utils.js set-agent-info → sets agent session info + * node .github/hooks/state-utils.js set-design → sets design artifact + * node .github/hooks/state-utils.js add-pbi → adds a PBI artifact + * node .github/hooks/state-utils.js add-agent-pr → adds an agent PR artifact + * + * State file schema: + * { + * "version": 2, + * "features": [ + * { + * "id": "feature--", + * "name": "Short feature name", + * "prompt": "Original user prompt", + * "step": "idle|designing|design_review|planning|plan_review|backlogging|backlog_review|dispatching|monitoring|done", + * "artifacts": { + * "design": { "docPath": "design-docs/.../spec.md", "prUrl": "https://...", "status": "draft|in-review|approved" }, + * "pbis": [ + * { "adoId": 12345, "title": "...", "targetRepo": "AzureAD/...", "module": "common", "adoUrl": "https://...", "status": "new|committed|active|resolved|closed", "priority": 1 } + * ], + * "agentPrs": [ + * { "repo": "common", "prNumber": 2916, "prUrl": "https://...", "status": "open|merged|closed|draft", "title": "..." } + * ] + * }, + * "designDocPath": "design-docs/.../spec.md", + * "designPrUrl": "https://dev.azure.com/...", + * "pbis": [ + * { "adoId": 12345, "title": "...", "targetRepo": "AzureAD/...", "dependsOn": [], "status": "pending" } + * ], + * "agentSessions": [ + * { "repo": "AzureAD/...", "prNumber": 2916, "prUrl": "https://...", "sessionUrl": "https://...", "status": "in_progress" } + * ], + * "startedAt": 1740000000000, + * "updatedAt": 1740000000000 + * } + * ], + * "lastUpdated": 1740000000000 + * } + */ + +const fs = require('fs'); +const path = require('path'); + +const os = require('os'); + +// State file lives in user's home directory (not workspace root) +const STATE_DIR = path.join(os.homedir(), '.android-auth-orchestrator'); +const STATE_FILE = path.join(STATE_DIR, 'state.json'); + +function ensureStateDir() { + if (!fs.existsSync(STATE_DIR)) { + fs.mkdirSync(STATE_DIR, { recursive: true }); + } +} + +function readState() { + if (!fs.existsSync(STATE_FILE)) { + return { version: 1, features: [], lastUpdated: Date.now() }; + } + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + } catch { + return { version: 1, features: [], lastUpdated: Date.now() }; + } +} + +function writeState(state) { + ensureStateDir(); + state.lastUpdated = Date.now(); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8'); +} + +/** + * Find a feature by ID or name (case-insensitive). + * The agent may pass either the auto-generated ID (feature-17723...) or the + * human-readable name ("IPC Retry with Exponential Backoff"). Support both. + * If multiple features match by name, return the most recently updated one. + */ +function findFeature(state, identifier) { + // Try exact ID match first + const byId = state.features.find(f => f.id === identifier); + if (byId) return byId; + + // Try exact name match (case-insensitive) + const lower = identifier.toLowerCase(); + const byName = state.features + .filter(f => f.name && f.name.toLowerCase() === lower) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + if (byName.length > 0) return byName[0]; + + // Try partial name match as a last resort + const partial = state.features + .filter(f => f.name && f.name.toLowerCase().includes(lower)) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + return partial[0] || null; +} + +// CLI +const [, , command, ...args] = process.argv; + +switch (command) { + case 'get': { + console.log(JSON.stringify(readState(), null, 2)); + break; + } + case 'list-features': { + const state = readState(); + const features = state.features.map(f => ({ + id: f.id, + name: f.name, + step: f.step, + pbis: (f.artifacts?.pbis || f.pbis || []).length, + prs: (f.artifacts?.agentPrs || f.agentSessions || []).length, + updatedAt: new Date(f.updatedAt).toISOString(), + })); + console.log(JSON.stringify(features, null, 2)); + break; + } + case 'get-feature': { + const state = readState(); + const feature = findFeature(state, args[0]); + console.log(JSON.stringify(feature || null, null, 2)); + break; + } + case 'set-step': { + const state = readState(); + const feature = findFeature(state, args[0]); + if (feature) { + feature.step = args[1]; + feature.updatedAt = Date.now(); + // Record phase timestamp for duration tracking + if (!feature.phaseTimestamps) { feature.phaseTimestamps = {}; } + feature.phaseTimestamps[args[1]] = Date.now(); + writeState(state); + console.log(JSON.stringify({ ok: true, id: args[0], step: args[1] })); + } else { + console.log(JSON.stringify({ ok: false, error: 'Feature not found' })); + } + break; + } + case 'add-feature': { + const state = readState(); + const feature = JSON.parse(args[0]); + // Auto-generate ID if not provided + if (!feature.id) { + feature.id = 'feature-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); + } + // Ensure required fields have defaults + if (!feature.pbis) feature.pbis = []; + if (!feature.agentSessions) feature.agentSessions = []; + if (!feature.prompt) feature.prompt = ''; + // Also match by name when deduplicating + const idx = state.features.findIndex(f => f.id === feature.id || (f.name && feature.name && f.name.toLowerCase() === feature.name.toLowerCase())); + if (idx >= 0) { + state.features[idx] = { ...state.features[idx], ...feature, updatedAt: Date.now() }; + } else { + // Record initial phase timestamp + const initialStep = feature.step || 'idle'; + const phaseTimestamps = { [initialStep]: Date.now() }; + state.features.push({ ...feature, startedAt: Date.now(), updatedAt: Date.now(), phaseTimestamps }); + } + writeState(state); + console.log(JSON.stringify({ ok: true, id: feature.id })); + break; + } + case 'set-agent-info': { + const state = readState(); + const feature = findFeature(state, args[0]); + if (feature) { + const info = JSON.parse(args[1]); + if (!feature.agentSessions) feature.agentSessions = []; + feature.agentSessions.push(info); + // Also add to artifacts.agentPrs + if (!feature.artifacts) feature.artifacts = { pbis: [], agentPrs: [] }; + if (!feature.artifacts.agentPrs) feature.artifacts.agentPrs = []; + feature.artifacts.agentPrs.push({ + repo: info.repo, + prNumber: info.prNumber || info.number, + prUrl: info.prUrl || info.url, + status: info.status || 'open', + title: info.title || '', + }); + feature.updatedAt = Date.now(); + writeState(state); + console.log(JSON.stringify({ ok: true })); + } + break; + } + case 'set-design': { + const state = readState(); + const feature = findFeature(state, args[0]); + if (feature) { + const design = JSON.parse(args[1]); + if (!feature.artifacts) feature.artifacts = { pbis: [], agentPrs: [] }; + feature.artifacts.design = design; + // Also set legacy fields for backward compat + if (design.docPath) feature.designDocPath = design.docPath; + if (design.prUrl) feature.designPrUrl = design.prUrl; + feature.updatedAt = Date.now(); + writeState(state); + console.log(JSON.stringify({ ok: true })); + } else { + console.log(JSON.stringify({ ok: false, error: 'Feature not found' })); + } + break; + } + case 'add-pbi': { + const state = readState(); + const feature = findFeature(state, args[0]); + if (feature) { + const pbi = JSON.parse(args[1]); + if (!feature.artifacts) feature.artifacts = { pbis: [], agentPrs: [] }; + if (!feature.artifacts.pbis) feature.artifacts.pbis = []; + // Avoid duplicates by adoId + const existingIdx = feature.artifacts.pbis.findIndex(p => p.adoId === pbi.adoId); + if (existingIdx >= 0) { + feature.artifacts.pbis[existingIdx] = { ...feature.artifacts.pbis[existingIdx], ...pbi }; + } else { + feature.artifacts.pbis.push(pbi); + } + // Also maintain legacy pbis array + if (!feature.pbis) feature.pbis = []; + const legacyIdx = feature.pbis.findIndex(p => p.adoId === pbi.adoId); + if (legacyIdx >= 0) { + feature.pbis[legacyIdx] = { ...feature.pbis[legacyIdx], ...pbi }; + } else { + feature.pbis.push({ adoId: pbi.adoId, title: pbi.title, targetRepo: pbi.targetRepo, status: pbi.status || 'pending' }); + } + feature.updatedAt = Date.now(); + writeState(state); + console.log(JSON.stringify({ ok: true, pbiCount: feature.artifacts.pbis.length })); + } else { + console.log(JSON.stringify({ ok: false, error: 'Feature not found' })); + } + break; + } + case 'add-agent-pr': { + const state = readState(); + const feature = findFeature(state, args[0]); + if (feature) { + const pr = JSON.parse(args[1]); + if (!feature.artifacts) feature.artifacts = { pbis: [], agentPrs: [] }; + if (!feature.artifacts.agentPrs) feature.artifacts.agentPrs = []; + // Avoid duplicates by prNumber+repo + const existingIdx = feature.artifacts.agentPrs.findIndex(p => p.prNumber === pr.prNumber && p.repo === pr.repo); + if (existingIdx >= 0) { + feature.artifacts.agentPrs[existingIdx] = { ...feature.artifacts.agentPrs[existingIdx], ...pr }; + } else { + feature.artifacts.agentPrs.push(pr); + } + feature.updatedAt = Date.now(); + writeState(state); + console.log(JSON.stringify({ ok: true, prCount: feature.artifacts.agentPrs.length })); + } else { + console.log(JSON.stringify({ ok: false, error: 'Feature not found' })); + } + break; + } + default: + console.error('Usage: state-utils.js [args]'); + process.exit(1); +} diff --git a/.github/hooks/subagent-start.js b/.github/hooks/subagent-start.js new file mode 100644 index 00000000..55d90836 --- /dev/null +++ b/.github/hooks/subagent-start.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * SubagentStart hook — injects orchestrator context into subagent sessions. + * + * SCOPE: This hook runs only when the orchestrator invokes a subagent, + * NOT for regular Agent Mode sessions. It injects active feature context + * so subagents are aware of the pipeline state. + */ + +var fs = require('fs'); +var path = require('path'); + +// Read stdin (hook input) +var hookInput = {}; +try { + hookInput = JSON.parse(fs.readFileSync(0, 'utf-8')); +} catch (e) { + // no stdin +} + +var os = require('os'); + +var stateFile = path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); + +function readState() { + if (!fs.existsSync(stateFile)) { + return { version: 1, features: [], lastUpdated: Date.now() }; + } + try { + return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + } catch (e) { + return { version: 1, features: [], lastUpdated: Date.now() }; + } +} + +var additionalContext = ''; + +try { + var state = readState(); + + // Check if there's an active feature being tracked by the orchestrator. + // If yes, inject its context. If no, just add basic workspace info. + // We do NOT auto-create feature entries here — that would pollute state + // for every normal Agent Mode session. Feature entries are created by + // the orchestrator agent itself (via the state-utils.js CLI). + var activeFeature = null; + if (state.features && state.features.length > 0) { + activeFeature = state.features + .filter(function(f) { return f.step !== 'done' && f.step !== 'idle'; }) + .sort(function(a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0); })[0] || null; + } + + if (activeFeature) { + // Orchestrator session — inject full feature context + var parts = [ + 'Active feature: "' + activeFeature.name + '"', + 'Current step: ' + activeFeature.step, + ]; + + if (activeFeature.designDocPath) { + parts.push('Design doc: ' + activeFeature.designDocPath); + } + + if (activeFeature.pbis && activeFeature.pbis.length > 0) { + var pbiSummary = activeFeature.pbis + .map(function(p) { return 'AB#' + p.adoId + ' (' + p.targetRepo + ') [' + p.status + ']'; }) + .join(', '); + parts.push('PBIs: ' + pbiSummary); + } + + additionalContext = parts.join('. ') + '.'; + } + + // Add basic workspace info (cwd is the workspace root when hooks run) + var root = process.cwd(); + var skillsDir = path.join(root, '.github', 'skills'); + var skills = fs.existsSync(skillsDir) + ? fs.readdirSync(skillsDir).join(', ') + : 'none'; + var hasDesignDocs = fs.existsSync(path.join(root, 'design-docs')); + + additionalContext += ' Android Auth workspace. Skills: ' + skills + '.'; + if (hasDesignDocs) { + additionalContext += ' Design docs available at design-docs/.'; + } + +} catch (e) { + additionalContext = 'Android Auth workspace (state read error: ' + e.message + ')'; +} + +// Output +console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'SubagentStart', + additionalContext: additionalContext.trim(), + } +})); diff --git a/.github/hooks/subagent-stop.js b/.github/hooks/subagent-stop.js new file mode 100644 index 00000000..36556e46 --- /dev/null +++ b/.github/hooks/subagent-stop.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * SubagentStop hook — advances orchestrator state when a subagent completes. + * + * Maps subagent names to pipeline steps, writes the next step to + * orchestrator-state.json. The VS Code extension watches this file + * and renders a clickable "next step" notification button. + */ + +const fs = require('fs'); +const path = require('path'); + +// Read stdin (hook input) +let hookInput = {}; +try { + hookInput = JSON.parse(fs.readFileSync(0, 'utf-8')); +} catch { /* no stdin */ } + +// Don't interfere if this is a re-entry +if (hookInput.stop_hook_active) { + console.log(JSON.stringify({ continue: true })); + process.exit(0); +} + +const agentType = hookInput.agent_type || ''; + +// Only handle subagents that are part of our orchestrator pipeline +var ourAgents = ['codebase-researcher', 'design-writer', 'feature-planner', 'pbi-creator', 'agent-dispatcher']; +if (ourAgents.indexOf(agentType) === -1) { + // Not one of our subagents — let it pass without modifying state + console.log(JSON.stringify({ continue: true })); + process.exit(0); +} + +// Map subagent names to the next pipeline step +const agentToNextStep = { + 'codebase-researcher': null, // research is intermediate, no step change + 'design-writer': 'design_review', + 'feature-planner': 'plan_review', + 'pbi-creator': 'backlog_review', + 'agent-dispatcher': 'monitoring', +}; + +const nextStep = agentToNextStep[agentType]; + +if (!nextStep) { + // Not a tracked subagent, let it pass + console.log(JSON.stringify({ continue: true })); + process.exit(0); +} + +const os = require('os'); + +const stateFile = path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); + +try { + if (fs.existsSync(stateFile)) { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + + // Find the most recently updated in-progress feature + const activeFeature = state.features + ?.filter(f => f.step !== 'done' && f.step !== 'idle') + ?.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))?.[0]; + + if (activeFeature) { + activeFeature.step = nextStep; + activeFeature.updatedAt = Date.now(); + + // Write a "pendingAction" field that the extension will consume + // to show a clickable notification button + activeFeature.pendingAction = { + completedAgent: agentType, + nextStep: nextStep, + timestamp: Date.now(), + }; + + state.lastUpdated = Date.now(); + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8'); + } + } +} catch (e) { + console.error('SubagentStop hook error:', e.message); +} + +// Always allow the subagent to stop normally +console.log(JSON.stringify({ continue: true }));