@lumenflow/agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { readFile, writeFile, mkdir, unlink, access } from 'node:fs/promises';
3
+ import { join } from 'path';
4
+ import { simpleGit } from 'simple-git';
5
+ import { appendIncident } from './agent-incidents.js';
6
+ import { PATTERNS, INCIDENT_SEVERITY, BEACON_PATHS } from '@lumenflow/core/lib/wu-constants.js';
7
+ const SESSION_DIR = BEACON_PATHS.SESSIONS;
8
+ const SESSION_FILE = join(SESSION_DIR, 'current.json');
9
+ /**
10
+ * Start a new agent session
11
+ * @param wuId - WU ID (e.g., "WU-1234")
12
+ * @param tier - Context tier from bootloader
13
+ * @param agentType - Agent type (default: "claude-code")
14
+ * @returns session_id
15
+ * @throws {Error} if session already active or WU format invalid
16
+ */
17
+ export async function startSession(wuId, tier, agentType = 'claude-code') {
18
+ // Check for existing session
19
+ const sessionExists = await access(SESSION_FILE)
20
+ .then(() => true)
21
+ .catch(() => false);
22
+ if (sessionExists) {
23
+ const content = await readFile(SESSION_FILE, { encoding: 'utf-8' });
24
+ const existing = JSON.parse(content);
25
+ throw new Error(`Session ${existing.session_id} already active for ${existing.wu_id}. ` +
26
+ `Run 'pnpm agent:session:end' first.`);
27
+ }
28
+ // Validate WU ID format
29
+ if (!PATTERNS.WU_ID.test(wuId)) {
30
+ throw new Error(`Invalid WU ID format: ${wuId}. Must match WU-XXX.`);
31
+ }
32
+ // Validate tier
33
+ if (![1, 2, 3].includes(tier)) {
34
+ throw new Error(`Invalid context tier: ${tier}. Must be 1, 2, or 3.`);
35
+ }
36
+ // Auto-detect lane from git branch if possible
37
+ const git = simpleGit();
38
+ let lane = 'Unknown';
39
+ try {
40
+ const branch = await git.revparse(['--abbrev-ref', 'HEAD']);
41
+ // Parse lane from branch name: lane/<lane>/wu-xxx → <lane>
42
+ const match = branch.match(/^lane\/([^/]+)\//);
43
+ if (match) {
44
+ lane = match[1]
45
+ .split('-')
46
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
47
+ .join(': ');
48
+ }
49
+ }
50
+ catch {
51
+ // Fallback: lane stays "Unknown"
52
+ }
53
+ const sessionId = randomUUID();
54
+ const session = {
55
+ session_id: sessionId,
56
+ wu_id: wuId,
57
+ lane,
58
+ started: new Date().toISOString(),
59
+ agent_type: agentType,
60
+ context_tier: tier,
61
+ incidents_logged: 0,
62
+ incidents_major: 0,
63
+ };
64
+ // Ensure directory exists
65
+ const dirExists = await access(SESSION_DIR)
66
+ .then(() => true)
67
+ .catch(() => false);
68
+ if (!dirExists) {
69
+ await mkdir(SESSION_DIR, { recursive: true });
70
+ }
71
+ await writeFile(SESSION_FILE, JSON.stringify(session, null, 2));
72
+ return sessionId;
73
+ }
74
+ /**
75
+ * Get the current active session
76
+ * @returns Session state or null if no active session
77
+ */
78
+ export async function getCurrentSession() {
79
+ const sessionExists = await access(SESSION_FILE)
80
+ .then(() => true)
81
+ .catch(() => false);
82
+ if (!sessionExists)
83
+ return null;
84
+ const content = await readFile(SESSION_FILE, { encoding: 'utf-8' });
85
+ return JSON.parse(content);
86
+ }
87
+ /**
88
+ * Log an incident and update session counters
89
+ * @param incidentData - Incident data (category, severity, title, description, etc.)
90
+ * @throws {Error} if no active session
91
+ */
92
+ export async function logIncident(incidentData) {
93
+ const session = await getCurrentSession();
94
+ if (!session) {
95
+ throw new Error('No active session. Run: pnpm agent:session start --wu WU-XXX --tier N');
96
+ }
97
+ // Get current git context
98
+ const git = simpleGit();
99
+ let gitBranch = 'unknown';
100
+ try {
101
+ gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
102
+ }
103
+ catch {
104
+ // Ignore git errors
105
+ }
106
+ // Build full incident record
107
+ const incident = {
108
+ timestamp: new Date().toISOString(),
109
+ session_id: session.session_id,
110
+ wu_id: session.wu_id,
111
+ lane: session.lane,
112
+ ...incidentData,
113
+ context: {
114
+ git_branch: gitBranch,
115
+ ...(incidentData.context ?? {}),
116
+ },
117
+ };
118
+ // Append to NDJSON (will validate)
119
+ appendIncident(incident);
120
+ // Update session counters
121
+ session.incidents_logged++;
122
+ if (incident.severity === INCIDENT_SEVERITY.MAJOR ||
123
+ incident.severity === INCIDENT_SEVERITY.BLOCKER) {
124
+ session.incidents_major++;
125
+ }
126
+ await writeFile(SESSION_FILE, JSON.stringify(session, null, 2));
127
+ }
128
+ /**
129
+ * End the current session and return summary
130
+ * @returns Session summary for appending to WU YAML
131
+ * @throws {Error} if no active session
132
+ */
133
+ export async function endSession() {
134
+ const session = await getCurrentSession();
135
+ if (!session) {
136
+ throw new Error('No active session to end.');
137
+ }
138
+ // Finalize session
139
+ session.completed = new Date().toISOString();
140
+ // Clean up session file
141
+ await unlink(SESSION_FILE);
142
+ // Return session object for WU YAML
143
+ return {
144
+ wu_id: session.wu_id,
145
+ lane: session.lane,
146
+ session_id: session.session_id,
147
+ started: session.started,
148
+ completed: session.completed,
149
+ agent_type: session.agent_type,
150
+ context_tier: session.context_tier,
151
+ incidents_logged: session.incidents_logged,
152
+ incidents_major: session.incidents_major,
153
+ // artifacts can be added manually later
154
+ };
155
+ }
@@ -0,0 +1,31 @@
1
+ type RunFn = (cmd: string) => string;
2
+ type ExistsFn = (path: string) => boolean;
3
+ /**
4
+ * Verification result type
5
+ */
6
+ export interface VerificationResult {
7
+ complete: boolean;
8
+ failures: string[];
9
+ }
10
+ /**
11
+ * Verification overrides for testing
12
+ */
13
+ interface VerificationOverrides {
14
+ run?: RunFn;
15
+ exists?: ExistsFn;
16
+ }
17
+ /**
18
+ * Verify that a WU has been completed and merged to main.
19
+ *
20
+ * Checks:
21
+ * 1. Working tree is clean
22
+ * 2. Completion stamp exists
23
+ * 3. Main history contains a commit updating the WU YAML
24
+ *
25
+ * @param wuId - Work Unit identifier (e.g., "WU-510")
26
+ * @param overrides - Test overrides
27
+ * @returns Verification result
28
+ */
29
+ export declare function verifyWUComplete(wuId: string, overrides?: VerificationOverrides): VerificationResult;
30
+ export declare function debugSummary(result: VerificationResult | null | undefined): string;
31
+ export {};
@@ -0,0 +1,118 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createError, ErrorCodes } from '@lumenflow/core/lib/error-handler.js';
6
+ import { PATTERNS, BRANCHES, STRING_LITERALS, EXIT_CODES, } from '@lumenflow/core/lib/wu-constants.js';
7
+ function run(cmd) {
8
+ try {
9
+ return execSync(cmd, { stdio: 'pipe', encoding: 'utf-8' }).trim();
10
+ }
11
+ catch {
12
+ return '';
13
+ }
14
+ }
15
+ function formatWUId(wuId) {
16
+ if (!wuId || typeof wuId !== 'string') {
17
+ throw createError(ErrorCodes.VALIDATION_ERROR, 'verifyWUComplete requires a WU id (e.g., WU-123)', { wuId, type: typeof wuId });
18
+ }
19
+ const normalized = wuId.trim().toUpperCase();
20
+ if (!PATTERNS.WU_ID.test(normalized)) {
21
+ throw createError(ErrorCodes.INVALID_WU_ID, `Invalid WU id "${wuId}". Expected format: WU-123`, { wuId, normalized });
22
+ }
23
+ return normalized;
24
+ }
25
+ function checkGitStatus(runFn = run) {
26
+ const status = runFn('git status --porcelain');
27
+ if (!status)
28
+ return null;
29
+ const lines = status.split(STRING_LITERALS.NEWLINE).filter(Boolean).slice(0, 10);
30
+ return `Working tree dirty (stage or discard changes): ${lines.join('; ')}`;
31
+ }
32
+ function stampPath(wuId) {
33
+ return path.join('.beacon', 'stamps', `${wuId}.done`);
34
+ }
35
+ function checkStamp(wuId, existsFn = existsSync) {
36
+ if (existsFn(stampPath(wuId)))
37
+ return null;
38
+ return `Missing stamp .beacon/stamps/${wuId}.done`;
39
+ }
40
+ function checkCommit(wuId, runFn = run) {
41
+ const history = runFn(`git log --oneline ${BRANCHES.MAIN} -- docs/04-operations/tasks/wu/${wuId}.yaml | head -n 1`);
42
+ if (history)
43
+ return null;
44
+ return `No commit on ${BRANCHES.MAIN} touching docs/04-operations/tasks/wu/${wuId}.yaml`;
45
+ }
46
+ /**
47
+ * Verify that a WU has been completed and merged to main.
48
+ *
49
+ * Checks:
50
+ * 1. Working tree is clean
51
+ * 2. Completion stamp exists
52
+ * 3. Main history contains a commit updating the WU YAML
53
+ *
54
+ * @param wuId - Work Unit identifier (e.g., "WU-510")
55
+ * @param overrides - Test overrides
56
+ * @returns Verification result
57
+ */
58
+ export function verifyWUComplete(wuId, overrides = {}) {
59
+ const normalized = formatWUId(wuId);
60
+ const failures = [];
61
+ const runFn = typeof overrides.run === 'function' ? overrides.run : run;
62
+ const existsFn = typeof overrides.exists === 'function'
63
+ ? overrides.exists
64
+ : (filePath) => existsSync(filePath);
65
+ const gitStatusFailure = checkGitStatus(runFn);
66
+ if (gitStatusFailure)
67
+ failures.push(gitStatusFailure);
68
+ const stampFailure = checkStamp(normalized, existsFn);
69
+ if (stampFailure)
70
+ failures.push(stampFailure);
71
+ const commitFailure = checkCommit(normalized, runFn);
72
+ if (commitFailure)
73
+ failures.push(commitFailure);
74
+ return {
75
+ complete: failures.length === 0,
76
+ failures,
77
+ };
78
+ }
79
+ export function debugSummary(result) {
80
+ if (!result || typeof result !== 'object') {
81
+ return 'No verification result';
82
+ }
83
+ if (result.complete) {
84
+ return 'Verification passed: WU complete.';
85
+ }
86
+ const failures = Array.isArray(result.failures) ? result.failures : [];
87
+ if (!failures.length) {
88
+ return 'Verification failed: unknown reason.';
89
+ }
90
+ return `Verification failed:${STRING_LITERALS.NEWLINE}- ${failures.join(`${STRING_LITERALS.NEWLINE}- `)}`;
91
+ }
92
+ const isDirectExecution = (() => {
93
+ if (typeof process === 'undefined')
94
+ return false;
95
+ if (!Array.isArray(process.argv))
96
+ return false;
97
+ if (!process.argv[1])
98
+ return false;
99
+ try {
100
+ return fileURLToPath(import.meta.url) === process.argv[1];
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ })();
106
+ if (isDirectExecution) {
107
+ const wuId = process.argv[2];
108
+ try {
109
+ const result = verifyWUComplete(wuId);
110
+ const message = debugSummary(result);
111
+ console.log(message);
112
+ process.exit(result.complete ? EXIT_CODES.SUCCESS : EXIT_CODES.ERROR);
113
+ }
114
+ catch (error) {
115
+ console.error(`Verification error: ${error.message}`);
116
+ process.exit(EXIT_CODES.ERROR);
117
+ }
118
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Session data stored in current.json
3
+ */
4
+ interface SessionFileData {
5
+ session_id: string;
6
+ wu_id: string;
7
+ started: string;
8
+ completed?: string;
9
+ agent_type: string;
10
+ context_tier: number;
11
+ incidents_logged: number;
12
+ incidents_major: number;
13
+ auto_started?: boolean;
14
+ }
15
+ /**
16
+ * Options for starting a session for a WU
17
+ */
18
+ interface StartSessionOptions {
19
+ wuId: string;
20
+ tier?: 1 | 2 | 3;
21
+ agentType?: string;
22
+ sessionDir?: string;
23
+ baseDir?: string;
24
+ }
25
+ /**
26
+ * Result of starting a session
27
+ */
28
+ interface StartSessionResult {
29
+ sessionId: string;
30
+ alreadyActive?: boolean;
31
+ memoryNodeId?: string | null;
32
+ }
33
+ /**
34
+ * Start a session for a WU (called by wu:claim)
35
+ *
36
+ * Unlike startSession in agent-session.mjs, this function:
37
+ * - Does NOT throw if a session already exists (returns existing session)
38
+ * - Uses default tier 2 if not specified
39
+ * - Supports custom session directory for testing
40
+ * - Creates memory layer session node for context restoration (WU-1466)
41
+ *
42
+ * @param options - Session options
43
+ * @returns Session result
44
+ */
45
+ export declare function startSessionForWU(options: StartSessionOptions): Promise<StartSessionResult>;
46
+ /**
47
+ * Options for ending a session
48
+ */
49
+ interface EndSessionOptions {
50
+ sessionDir?: string;
51
+ }
52
+ /**
53
+ * Session summary
54
+ */
55
+ interface SessionSummary {
56
+ wu_id: string;
57
+ session_id: string;
58
+ started: string;
59
+ completed: string;
60
+ agent_type: string;
61
+ context_tier: number;
62
+ incidents_logged: number;
63
+ incidents_major: number;
64
+ }
65
+ /**
66
+ * Result of ending a session
67
+ */
68
+ interface EndSessionResult {
69
+ ended: boolean;
70
+ summary?: SessionSummary;
71
+ reason?: string;
72
+ }
73
+ /**
74
+ * End the current session (called by wu:done)
75
+ *
76
+ * Unlike endSession in agent-session.mjs, this function:
77
+ * - Does NOT throw if no active session (returns { ended: false })
78
+ * - Returns structured result with summary
79
+ * - Supports custom session directory for testing
80
+ *
81
+ * @param options - Session options
82
+ * @returns Session end result
83
+ */
84
+ export declare function endSessionForWU(options?: EndSessionOptions): EndSessionResult;
85
+ /**
86
+ * Options for getting current session
87
+ */
88
+ interface GetSessionOptions {
89
+ sessionDir?: string;
90
+ }
91
+ /**
92
+ * Get the current active session
93
+ *
94
+ * @param options - Session options
95
+ * @returns Session object or null if no active session
96
+ */
97
+ export declare function getCurrentSessionForWU(options?: GetSessionOptions): SessionFileData | null;
98
+ /**
99
+ * Check if there's an active session for a specific WU
100
+ *
101
+ * @param wuId - WU ID to check
102
+ * @param options - Session options
103
+ * @returns True if session exists and matches WU ID
104
+ */
105
+ export declare function hasActiveSessionForWU(wuId: string, options?: GetSessionOptions): boolean;
106
+ export {};
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Auto-Session Integration for wu:claim and wu:done lifecycle (WU-1438, WU-1466)
3
+ *
4
+ * Provides wrapper functions around agent-session.mjs that:
5
+ * 1. Auto-start sessions on wu:claim with silent no-op if already active
6
+ * 2. Auto-end sessions on wu:done with silent no-op if not active
7
+ * 3. Store session_id in WU YAML for tracking
8
+ * 4. Create memory layer session nodes for context restoration (WU-1466)
9
+ *
10
+ * Design principles:
11
+ * - Composition over modification (wraps existing agent-session.mjs)
12
+ * - Silent failures for idempotent operations (no throw on duplicate start/end)
13
+ * - Configurable session directory for testing
14
+ */
15
+ import { randomUUID } from 'crypto';
16
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { startSession as startMemorySession } from '@lumenflow/memory/start';
19
+ // Default session directory (same as agent-session.mjs)
20
+ const DEFAULT_SESSION_DIR = '.beacon/sessions';
21
+ const SESSION_FILENAME = 'current.json';
22
+ // Default context tier for auto-started sessions
23
+ const DEFAULT_TIER = 2;
24
+ // Agent type for auto-started sessions
25
+ const DEFAULT_AGENT_TYPE = 'claude-code';
26
+ /**
27
+ * Map numeric tier values to string names for memory layer (WU-1466)
28
+ */
29
+ const CONTEXT_TIER_MAP = {
30
+ 1: 'minimal',
31
+ 2: 'core',
32
+ 3: 'full',
33
+ };
34
+ /**
35
+ * Get the session file path for a given session directory
36
+ * @param sessionDir - Session directory path
37
+ * @returns Full path to current.json
38
+ */
39
+ function getSessionFilePath(sessionDir) {
40
+ return join(sessionDir, SESSION_FILENAME);
41
+ }
42
+ /**
43
+ * Start a session for a WU (called by wu:claim)
44
+ *
45
+ * Unlike startSession in agent-session.mjs, this function:
46
+ * - Does NOT throw if a session already exists (returns existing session)
47
+ * - Uses default tier 2 if not specified
48
+ * - Supports custom session directory for testing
49
+ * - Creates memory layer session node for context restoration (WU-1466)
50
+ *
51
+ * @param options - Session options
52
+ * @returns Session result
53
+ */
54
+ export async function startSessionForWU(options) {
55
+ const { wuId, tier = DEFAULT_TIER, agentType = DEFAULT_AGENT_TYPE, sessionDir, baseDir = process.cwd(), } = options;
56
+ const sessDir = sessionDir ?? DEFAULT_SESSION_DIR;
57
+ const sessionFile = getSessionFilePath(sessDir);
58
+ // Check for existing session - return it instead of throwing
59
+ if (existsSync(sessionFile)) {
60
+ const existing = JSON.parse(readFileSync(sessionFile, { encoding: 'utf-8' }));
61
+ return {
62
+ sessionId: existing.session_id,
63
+ alreadyActive: true,
64
+ };
65
+ }
66
+ // Create session directory if needed
67
+ if (!existsSync(sessDir)) {
68
+ mkdirSync(sessDir, { recursive: true });
69
+ }
70
+ // Create new session
71
+ const sessionId = randomUUID();
72
+ const session = {
73
+ session_id: sessionId,
74
+ wu_id: wuId,
75
+ started: new Date().toISOString(),
76
+ agent_type: agentType,
77
+ context_tier: tier,
78
+ incidents_logged: 0,
79
+ incidents_major: 0,
80
+ auto_started: true, // Mark as auto-started by wu:claim
81
+ };
82
+ writeFileSync(sessionFile, JSON.stringify(session, null, 2), { encoding: 'utf-8' });
83
+ // WU-1466: Create memory layer session node for context restoration
84
+ // This enables context restoration after /clear by persisting session info to memory.jsonl
85
+ let memoryNodeId = null;
86
+ try {
87
+ const memResult = await startMemorySession(baseDir, {
88
+ wuId,
89
+ agentType,
90
+ contextTier: CONTEXT_TIER_MAP[tier] ?? 'full',
91
+ });
92
+ memoryNodeId = memResult.session?.id ?? null;
93
+ }
94
+ catch {
95
+ // Memory layer creation is non-blocking - log but don't fail
96
+ // Session file was already created, so the session is functional
97
+ }
98
+ return {
99
+ sessionId,
100
+ alreadyActive: false,
101
+ memoryNodeId,
102
+ };
103
+ }
104
+ /**
105
+ * End the current session (called by wu:done)
106
+ *
107
+ * Unlike endSession in agent-session.mjs, this function:
108
+ * - Does NOT throw if no active session (returns { ended: false })
109
+ * - Returns structured result with summary
110
+ * - Supports custom session directory for testing
111
+ *
112
+ * @param options - Session options
113
+ * @returns Session end result
114
+ */
115
+ export function endSessionForWU(options = {}) {
116
+ const { sessionDir } = options;
117
+ const sessDir = sessionDir ?? DEFAULT_SESSION_DIR;
118
+ const sessionFile = getSessionFilePath(sessDir);
119
+ // Check for active session - return early if none
120
+ if (!existsSync(sessionFile)) {
121
+ return {
122
+ ended: false,
123
+ reason: 'no_active_session',
124
+ };
125
+ }
126
+ // Read session data
127
+ const session = JSON.parse(readFileSync(sessionFile, { encoding: 'utf-8' }));
128
+ // Finalize session
129
+ session.completed = new Date().toISOString();
130
+ // Build summary for WU YAML
131
+ const summary = {
132
+ wu_id: session.wu_id,
133
+ session_id: session.session_id,
134
+ started: session.started,
135
+ completed: session.completed,
136
+ agent_type: session.agent_type,
137
+ context_tier: session.context_tier,
138
+ incidents_logged: session.incidents_logged,
139
+ incidents_major: session.incidents_major,
140
+ };
141
+ // Remove session file
142
+ unlinkSync(sessionFile);
143
+ return {
144
+ ended: true,
145
+ summary,
146
+ };
147
+ }
148
+ /**
149
+ * Get the current active session
150
+ *
151
+ * @param options - Session options
152
+ * @returns Session object or null if no active session
153
+ */
154
+ export function getCurrentSessionForWU(options = {}) {
155
+ const { sessionDir } = options;
156
+ const sessDir = sessionDir ?? DEFAULT_SESSION_DIR;
157
+ const sessionFile = getSessionFilePath(sessDir);
158
+ if (!existsSync(sessionFile)) {
159
+ return null;
160
+ }
161
+ return JSON.parse(readFileSync(sessionFile, { encoding: 'utf-8' }));
162
+ }
163
+ /**
164
+ * Check if there's an active session for a specific WU
165
+ *
166
+ * @param wuId - WU ID to check
167
+ * @param options - Session options
168
+ * @returns True if session exists and matches WU ID
169
+ */
170
+ export function hasActiveSessionForWU(wuId, options = {}) {
171
+ const session = getCurrentSessionForWU(options);
172
+ return session !== null && session.wu_id === wuId;
173
+ }