Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/architecture/adrs/003-agent-workflow-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# ADR 003: Agent Workflow Tracking & 100% CLI Interception

## Status
Accepted

## Context
FlyCLI is increasingly being operated by autonomous AI agents (like Google Gemini / Antigravity). These agents operate contextually, meaning if a session reboots or another agent takes over, the context of what was done previously is lost. The users required a mechanism to explicitly set plans and log actions (`flycli agent log`, `flycli agent plan`).
However, we discovered that agents often "forget" to explicitly log their actions. The business requirement demands 100% auditability: every command executed through FlyCLI must be tracked, even if the agent fails to explicitly declare it.

## Decision
1. **JSONL Storage with Rotation**:
We will store the audit logs as local `JSONL` files (`.flycli/agent_logs/agent_workflow_X.jsonl`) instead of using SQLite. This bypasses binary build issues encountered when using `@yao-pkg/pkg` for binary distribution. A manifest file will track the list of all generated logs. Logs rotate automatically when exceeding 1MB.

2. **Interface Decoration for 100% Interception**:
Instead of using `Commander.js` lifecycle hooks (e.g., `program.hook('preAction')`) which fail to catch built-in arguments like `--version` or `--help` (because they exit the process before hooks run), we will implement a top-level **Interface Decoration** pattern.
- At the absolute top-level of `index.js`, we read `process.argv.slice(2)`.
- We log the raw execution arguments directly to `AgentWorkflowService` as `[SYSTEM] CLI Execution`.
- This guarantees 100% interception regardless of how Commander parses the command or if it early-exits.

## Consequences
- **Positive**: Absolute auditability. Agents cannot secretly execute commands without leaving a local footprint.
- **Positive**: JSONL is easily parsable and append-only, reducing corruption risks.
- **Negative**: Adds a slight overhead to every CLI execution (a few milliseconds to append to a file).
- **Negative**: Need to be careful to filter out internal commands (e.g., `agent log`) from the global interceptor to prevent infinite loops or redundancy.
8 changes: 8 additions & 0 deletions docs/business/OKR.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,11 @@ Transform FlyCLI from a mere configuration tool into a hardware development assi
- **KR2.1:** Implement the `flycli cad` command that seamlessly orchestrates the FreeCAD GUI from Node.js by `v1.3.0`.
- **KR2.2:** Establish a reliable IPC/CadQuery bridge allowing the AI (Gemini) to generate and instantly visualize solid models without user scripting.
- **KR2.3:** Ensure a zero-friction distribution strategy where users don't need to manually configure FreeCAD paths or Python environments.

## Objective 3: Agent Auditability & Context Recovery
Ensure that autonomous AI agents operating FlyCLI have a robust, machine-readable audit trail that tracks 100% of actions and maintains contextual continuity across sessions.

**Key Results:**
- **KR3.1:** Implement a 100% interception layer for all CLI command executions, logging them automatically without requiring explicit agent calls.
- **KR3.2:** Provide a local, rotation-based JSONL storage mechanism (max 1MB per file) for agent logs to bypass binary packaging constraints.
- **KR3.3:** Expose a `flycli agent context` command that immediately yields a structured summary of recent actions and current active plans.
5 changes: 5 additions & 0 deletions docs/business/REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ Currently, AI Agents configuring Flight Controllers (FC) lack the ability to ver
## 4. Constraints
- Must not break the existing textual CLI execution architecture (`src/interfaces/cli/execute.js`).
- Must operate over the same USB VCP connection without requiring external tools.

## 5. Agent Workflow Tracking (New Feature)
- **As an Autonomous Agent**, I want to explicitly log my active plan and completed actions (`flycli agent plan set`, `flycli agent log`) so that if I am restarted or a new agent takes over, the context is preserved locally.
- **As a System Auditor**, I want 100% of CLI command executions to be automatically intercepted and logged without relying on the agent's explicit logging commands, ensuring absolute accountability.
- **As an AI Agent**, I want to retrieve a clean, machine-readable summary of my recent context (`flycli agent context`) without ANSI colors or verbose formatting.
19 changes: 18 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import healthCommand from './src/interfaces/cli/health.js';
import contextCommand from './src/interfaces/cli/context.js';
import wizardCommand from './src/interfaces/cli/wizard.js';
import cadCommand from './src/interfaces/cli/cad.js';
import agentCommand from './src/interfaces/cli/agent.js';
import AgentWorkflowService from './src/application/AgentWorkflowService.js';
import AgentStorage from './src/infrastructure/storage/AgentStorage.js';

const program = new Command();

program
.name('flycli')
.description('CLI tool for Betaflight flight controller interaction')
.version('1.4.0');
.version('1.5.0');

program
.command('scan')
Expand Down Expand Up @@ -60,4 +63,18 @@ program
.description('Start an interactive AI-powered CAD session with FreeCAD (MCP Server)')
.action(cadCommand);

// 100% Command Execution Logging (Interface Decoration)
try {
const rawArgs = process.argv.slice(2).join(' ') || 'empty command';

// Prevent infinite loops/redundancy for agent tracking commands
if (!rawArgs.startsWith('agent log') && !rawArgs.startsWith('agent context') && !rawArgs.startsWith('agent plan set')) {
const storage = new AgentStorage();
const service = new AgentWorkflowService(storage);
await service.logAction('CLI Execution', rawArgs, 'SYSTEM');
}
} catch (err) {
// Ignore logging errors silently
}
program.addCommand(agentCommand);
program.parse();
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flycli",
"version": "1.4.0",
"version": "1.5.0",
"description": "A reliable CLI tool for Betaflight flight controller interaction and automation.",
"license": "MIT",
"keywords": [
Expand Down
39 changes: 39 additions & 0 deletions src/application/AgentWorkflowService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export default class AgentWorkflowService {
constructor(storage) {
if (!storage) throw new Error('storage dependency required');
this.storage = storage;
}

async logAction(actionName, description, role = null) {
const entry = { action: actionName };
if (description) entry.description = description;
if (role) entry.role = role;
await this.storage.logAction(entry);
}

async setPlan(planText) {
await this.storage.setPlan(planText);
}

async getContextSummary() {
const plan = await this.storage.getPlan();
const recent = await this.storage.getRecentActions(10);

let summary = '=== AGENT CONTEXT ===\n\n';
summary += '## Current Plan:\n';
summary += plan || 'No plan set. Please define a plan using `flycli agent plan set <text>`.\n';

summary += '\n## Recent Actions:\n';
if (recent.length === 0) {
summary += 'No recent actions logged.\n';
} else {
recent.forEach((a, i) => {
const roleStr = a.role ? `[${a.role}] ` : '';
const descStr = a.description ? `\n ${a.description}` : '';
summary += `${i + 1}. ${roleStr}${a.action} (${a.timestamp})${descStr}\n`;
});
}

return summary;
}
}
6 changes: 5 additions & 1 deletion src/infrastructure/ai/McpCadTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ function assertValidScript(code) {
export class McpCadTools {
/**
* @param {import('../cad/CadEngineProcess.js').CadEngineProcess} engine
* @param {Function} [lazyStart] - Optional async function to lazily start the engine
*/
constructor(engine) {
constructor(engine, lazyStart) {
this.engine = engine;
this.lazyStart = lazyStart || (async () => {});
}

/**
Expand All @@ -81,6 +83,7 @@ export class McpCadTools {
*/
async renderCadQuery(code) {
assertValidScript(code);
await this.lazyStart();
return this.engine.executeScript(code);
}

Expand All @@ -91,6 +94,7 @@ export class McpCadTools {
* @returns {Promise<Object>} Serialized EngineState (object tree)
*/
async getEngineState() {
await this.lazyStart();
return this.engine.getDocumentState();
}

Expand Down
88 changes: 88 additions & 0 deletions src/infrastructure/storage/AgentStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import fs from 'fs-extra';
import path from 'path';

const MAX_FILE_SIZE = 1024 * 1024;

export default class AgentStorage {
constructor(projectRoot = process.cwd()) {
this.storageDir = path.join(projectRoot, '.flycli', 'agent_logs');
this.planFile = path.join(this.storageDir, 'current_plan.md');
this.manifestFile = path.join(this.storageDir, 'manifest.json');
}

async init() {
await fs.ensureDir(this.storageDir);
if (!(await fs.pathExists(this.manifestFile))) {
await fs.writeJson(this.manifestFile, { currentFileIndex: 1, files: ['agent_workflow_1.jsonl'] });
}
}

async getManifest() {
return fs.readJson(this.manifestFile);
}

async saveManifest(manifest) {
await fs.writeJson(this.manifestFile, manifest);
}

async logAction(actionEntry) {
await this.init();
const manifest = await this.getManifest();
let currentFileName = manifest.files[manifest.files.length - 1];
let currentFilePath = path.join(this.storageDir, currentFileName);

const stats = await fs.stat(currentFilePath).catch(() => ({ size: 0 }));

if (stats.size >= MAX_FILE_SIZE) {
manifest.currentFileIndex += 1;
currentFileName = `agent_workflow_${manifest.currentFileIndex}.jsonl`;
currentFilePath = path.join(this.storageDir, currentFileName);
manifest.files.push(currentFileName);
await this.saveManifest(manifest);
}

const logLine = `${JSON.stringify({ ...actionEntry, timestamp: new Date().toISOString() })}\n`;
await fs.appendFile(currentFilePath, logLine);
}

async setPlan(planText) {
await this.init();
await fs.writeFile(this.planFile, planText, 'utf-8');
}

async getPlan() {
await this.init();
try {
return await fs.readFile(this.planFile, 'utf-8');
} catch (e) {
return null;
}
}

async getRecentActions(count = 10) {
await this.init();
const manifest = await this.getManifest();

let actions = [];
const readPromises = manifest.files.slice().reverse().map((fileName) => {
const fPath = path.join(this.storageDir, fileName);
return fs.readFile(fPath, 'utf-8').catch(() => '');
});

const fileContents = await Promise.all(readPromises);

for (let i = 0; i < fileContents.length; i += 1) {
const content = fileContents[i];
if (content) {
const lines = content.trim().split('\n').filter((l) => l.length > 0);
const fileActions = lines.map((l) => JSON.parse(l)).reverse();
actions = actions.concat(fileActions);
if (actions.length >= count) {
break;
}
}
}

return actions.slice(0, count).reverse();
}
}
59 changes: 59 additions & 0 deletions src/interfaces/cli/agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Command } from 'commander';
import AgentWorkflowService from '../../application/AgentWorkflowService.js';
import AgentStorage from '../../infrastructure/storage/AgentStorage.js';
import ConsoleLogger from '../../infrastructure/Logger.js';

const agentCommand = new Command('agent')
.description('Commands for autonomous AI agent continuity and context tracking');

const storage = new AgentStorage();
const service = new AgentWorkflowService(storage);
const logger = new ConsoleLogger();

agentCommand
.command('log')
.description('Log an agent action to the local tracking history')
.argument('<action>', 'Short name or description of the action (e.g. "Create File")')
.argument('[description...]', 'Detailed description of what was done')
.option('-r, --role <role>', 'The role or mode the agent is acting as (e.g. DEVELOPER, ARCHITECT)')
.action(async (action, descriptionArray, options) => {
try {
const description = descriptionArray.join(' ');
await service.logAction(action, description, options.role);
logger.info('Agent action logged successfully.');
} catch (err) {
logger.error('Failed to log agent action:', err.message);
process.exit(1);
}
});

agentCommand
.command('plan')
.description('Manage the overarching execution plan')
.command('set <plan_text>')
.description('Set the current execution plan for context recovery')
.action(async (planText) => {
try {
await service.setPlan(planText);
logger.info('Agent plan updated successfully.');
} catch (err) {
logger.error('Failed to set agent plan:', err.message);
process.exit(1);
}
});

agentCommand
.command('context')
.description('Get the current execution plan and recent history context')
.action(async () => {
try {
const summary = await service.getContextSummary();
// Output directly to stdout so it can be easily read by LLMs without logger prefixes
process.stdout.write(`${summary}\n`);
} catch (err) {
logger.error('Failed to get context:', err.message);
process.exit(1);
}
});

export default agentCommand;
32 changes: 17 additions & 15 deletions src/interfaces/cli/cad.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,31 @@ import ConsoleLogger from '../../infrastructure/Logger.js';
function buildDependencies(logger) {
const env = new EnvironmentManager(logger);
const engine = new CadEngineProcess(logger);
const tools = new McpCadTools(engine);

let startPromise = null;
const lazyStart = async () => {
if (engine.process) return;
if (!startPromise) {
startPromise = (async () => {
const executablePath = await env.ensureEnvironmentReady();
await engine.start(executablePath);
})();
}
await startPromise;
};

const tools = new McpCadTools(engine, lazyStart);
const mcpServer = new McpServerAdapter(tools, logger);
return { env, engine, mcpServer };
}

/**
* Resolves the FreeCAD executable and starts the CadEngine process.
* @param {EnvironmentManager} env
* @param {CadEngineProcess} engine
* @returns {Promise<void>}
*/
async function startEngine(env, engine) {
const executablePath = await env.ensureEnvironmentReady();
await engine.start(executablePath);
}

/**
* Prints the CadAgent welcome banner to stdout.
*/
function printBanner() {
process.stderr.write('\n🚀 FlyCLI CAD Agent — Model Context Protocol (MCP) Server\n');
process.stderr.write(`${'-'.repeat(50)}\n`);
process.stderr.write('💡 FreeCAD is starting… Please wait.\n');
process.stderr.write('💡 FreeCAD will be started lazily when a tool is invoked.\n');
}

/**
Expand Down Expand Up @@ -82,7 +84,7 @@ async function stopServices(mcpServer, engine, resolveExit) {
*/
export default async function cadCommand() {
const logger = new ConsoleLogger();
const { env, engine, mcpServer } = buildDependencies(logger);
const { engine, mcpServer } = buildDependencies(logger);

printBanner();

Expand All @@ -100,11 +102,11 @@ export default async function cadCommand() {
process.on('SIGTERM', cleanup);

try {
await startEngine(env, engine);
/*
* Note: Start the MCP Server on stdio.
* The Promise from mcpServer.start() will resolve when the server is ready,
* but the node process will stay alive because of the stdio event listeners.
* FreeCAD will be started lazily when a tool is first invoked.
*/
await mcpServer.start();

Expand Down
Loading
Loading