chore: push all design docs, V2 plan specs, and current working state

Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2,
AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code
changes across packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-12 17:12:29 +08:00
parent 8f55c962bb
commit ae44be31d5
364 changed files with 46779 additions and 2812 deletions

View File

@@ -126,18 +126,54 @@ export function e2eCommand(): void {
// P4: Worker IPC
{ label: 'P4: Worker IPC (test)', fn: () => runTest('P4', './packages/runtime/test/e2e/worker-fixture.test.ts ./packages/runtime/test/regression/worker-exit-code.test.ts ./packages/runtime/test/regression/worker-result-envelope.test.ts', repoRoot) },
// P5: C++ Toolchain
// P4.1: Tool envelope shape (Phase 5 gate #1)
{ label: 'P4.1: Tool envelope shape', fn: () => runTest('P4.1', './packages/runtime/test/e2e/tool-envelope-shape.test.ts', repoRoot) },
// P4.2: Shell.run functional (Phase 5 gate #2)
{ label: 'P4.2: Shell.run functional', fn: () => runTest('P4.2', './packages/runtime/test/e2e/shell-run-functional.test.ts', repoRoot) },
// P4.3: Scheduler result status (Phase 5 gate #4)
{ label: 'P4.3: Scheduler result status', fn: () => runTest('P4.3', './packages/runtime/test/e2e/scheduler-result-status.test.ts', repoRoot) },
// P4.4: Confirmation flow (Phase 5 gate #6)
{ label: 'P4.4: Confirmation flow', fn: () => runTest('P4.4', './packages/runtime/test/e2e/confirmation-flow.test.ts', repoRoot) },
// P5: C++ Toolchain (FR-017: complete C++ workflow)
{ label: 'P5: C++ Toolchain (test)', fn: () => runTest('P5', './packages/toolchain-cpp/test/', repoRoot) },
// P5.1: C++ build/test flow (FR-020: real C++ workflow)
{ label: 'P5.1: C++ build/test flow', fn: () => runTest('P5.1', './packages/toolchain-cpp/test/cpp-build-flow.test.ts', repoRoot) },
// P6: Projection/TUI
{ label: 'P6: Projection/TUI', fn: () => runTest('P6', './packages/runtime/test/regression/projection-store-apply.test.ts ./packages/runtime/test/regression/workspace-enum.test.ts', repoRoot) },
// P6.1: TUI startup (FR-020: TUI startup gate)
{ label: 'P6.1: TUI startup', fn: () => runTest('P6.1', './packages/runtime/test/e2e/tui-startup.test.ts', repoRoot) },
// P6.2: Artifact/event persistence (FR-020: artifact/event persistence gate)
{ label: 'P6.2: Artifact/event persistence', fn: () => runTest('P6.2', './packages/runtime/test/e2e/artifact-persistence.test.ts ./packages/runtime/test/e2e/event-persistence.test.ts', repoRoot) },
// P7: Agents
{ label: 'P7: Agents (test)', fn: () => runTest('P7', './packages/runtime/test/e2e/direct-mode-fixture.test.ts ./packages/runtime/test/e2e/architecture-review-fixture.test.ts ./packages/runtime/test/regression/main-agent-states.test.ts', repoRoot) },
// P8: Full regression suite
{ label: 'P8: Full regression suite', fn: () => runTest('P8', './packages/runtime/test/regression/', repoRoot) },
// FR-020: Additional release gates
// Gate: SQLite recovery
{ label: 'G1: SQLite recovery', fn: () => runTest('G1', './packages/runtime/test/e2e/sqlite-recovery.test.ts', repoRoot) },
// Gate: Project init (FR-020)
{ label: 'G2: Project init', fn: () => runTest('G2', './packages/cli/test/init-command.test.ts', repoRoot) },
// Gate: Real LLM E2E (FR-020) - only runs if LLM provider configured
{ label: 'G3: Real LLM E2E', fn: () => runTest('G3', './packages/runtime/test/e2e/llm-e2e.test.ts', repoRoot) },
// Gate: Child IPC (FR-020)
{ label: 'G4: Child IPC', fn: () => runTest('G4', './packages/runtime/test/e2e/child-ipc.test.ts', repoRoot) },
{ label: 'G5: ADR cascade invalidation (FR-007.5)', fn: () => runTest('G5', './packages/runtime/test/e2e/adr-cascade.test.ts', repoRoot) },
{ label: 'G6: Compression validation (FR-014)', fn: () => runTest('G6', './packages/runtime/test/e2e/compression-validator.test.ts', repoRoot) },
// Security
{ label: 'SEC: Command injection regression', fn: () => runTest('SEC', './packages/toolchain-cpp/test/command-injection.test.ts', repoRoot) },

View File

@@ -8,6 +8,7 @@
import { existsSync } from 'fs'
import { join } from 'path'
import { randomUUID } from 'crypto'
import { execSync } from 'child_process'
import { loadConfig } from '../bootstrap/loadConfig.js'
import { ToolRegistry, createToolRegistry, register_builtin_tools } from '@aircoding/runtime'
import type { ToolExecutionContext, ToolCall } from '@aircoding/contracts'
@@ -16,6 +17,22 @@ export async function initCommand(project_path?: string, toolRegistry?: ToolRegi
const project_root = project_path || process.cwd()
console.log(`Initializing AirCoding project at ${project_root}`)
// FR-007.5: Ensure git repository exists for worktree isolation and rollback snapshots
const git_dir = join(project_root, '.git')
if (!existsSync(git_dir)) {
try {
execSync('git init', { cwd: project_root, stdio: 'pipe', timeout: 10000 })
console.log(' Initialized git repository')
// Create initial commit so worktree add and revert have a base
execSync('git config user.email "aircoding@local"', { cwd: project_root, stdio: 'pipe' })
execSync('git config user.name "AirCoding"', { cwd: project_root, stdio: 'pipe' })
} catch (e: any) {
console.warn(' Warning: git not available — worktree isolation and rollback disabled')
}
} else {
console.log(' Git repository already exists')
}
// Create minimal ToolRegistry if not provided (INV-3 compliance)
let registry = toolRegistry
if (!registry) {
@@ -44,10 +61,15 @@ export async function initCommand(project_path?: string, toolRegistry?: ToolRegi
// Create .air directory structure via fs.write tool (INV-3)
const dirs = [
join(project_root, '.air', 'shared'),
join(project_root, '.air', 'shared', 'rules'),
join(project_root, '.air', 'shared', 'plan'),
join(project_root, '.air', 'local'),
join(project_root, '.air', 'sessions'),
join(project_root, '.air', 'logs'),
join(project_root, '.air', 'workspaces')
join(project_root, '.air', 'local', 'sessions'),
join(project_root, '.air', 'local', 'logs'),
join(project_root, '.air', 'local', 'workspaces'),
join(project_root, '.air', 'local', 'backups'),
join(project_root, '.air', 'local', 'tmp'),
join(project_root, '.air', 'local', 'locks'),
]
for (const dir of dirs) {
@@ -75,11 +97,91 @@ export async function initCommand(project_path?: string, toolRegistry?: ToolRegi
// Write default rules via fs.write (INV-3)
await call('fs.write', {
path: join(project_root, '.air', 'shared', 'rules.md'),
path: join(project_root, '.air', 'shared', 'rules', 'default.md'),
content: '# Project Rules\n\nAdd your project-specific rules here.\n',
create_dirs: true
})
// Write permissions.yaml (architecture §6, Doctor reads this)
await call('fs.write', {
path: join(project_root, '.air', 'shared', 'permissions.yaml'),
content: [
'# AirCoding Permission Configuration',
'# See architecture docs: security-model-v1.md',
'',
'default_policy: ask',
'profiles:',
' main_direct:',
' allow: [fs.read, shell.run, project.scan]',
' ask: [fs.write, fs.edit, fs.delete, git.*, cpp.*]',
' deny: [system.*, network.*]',
' executor:',
' allow: [fs.*, shell.*, git.*, cpp.*, artifact.*, context.*]',
' ask: [network.*, gui.*, debug.*]',
' deny: [system.*, permission.*]',
' reviewer:',
' allow: [fs.read, project.scan, context.*]',
' deny: [fs.write, fs.edit, fs.delete, shell.*, git.*, network.*, system.*]',
' debugger:',
' allow: [fs.read, shell.run, cpp.*, artifact.*, context.*, debug.*]',
' ask: [fs.write, fs.edit]',
' deny: [fs.delete, git.*, network.*, system.*]',
'protect_paths:',
' - .git/',
' - .air/',
' - node_modules/',
' - credentials/',
' - secrets/',
' - "*.key"',
' - "*.pem"',
' - ".env*"',
'build_dirs:',
' - build/',
' - dist/',
' - out/',
' - target/',
].join('\n') + '\n',
create_dirs: true
})
// Write compaction-rules.md (architecture §6)
await call('fs.write', {
path: join(project_root, '.air', 'shared', 'compaction-rules.md'),
content: [
'# Compaction Rules',
'',
'## Immutable Layers',
'- runtime_invariant (L0)',
'- role (L1)',
'',
'## Thresholds',
'- compaction_trigger: 85% token budget',
'- min_tokens_freed: 40000',
'',
'## Must Preserve Patterns',
'- File paths with extensions: .ts, .tsx, .js, .jsx, .cpp, .c, .h, .hpp, .cmake, .py, .rs, .go, .java',
'- ADR references (adr/xxx)',
'- FR references (FR-xxx)',
'- Task IDs (task_xxx)',
'- TODO/FIXME/HACK markers',
'- Function/method references',
'',
'## Max Loss Threshold',
'- 30% of MUST_PRESERVE patterns allowed',
].join('\n') + '\n',
create_dirs: true
})
console.log('\nProject initialized successfully!')
console.log(`Run 'air run' to start a session.`)
// FR-007.5: Create initial git commit so worktree and revert have a base
if (existsSync(git_dir)) {
try {
execSync('git add -A', { cwd: project_root, stdio: 'pipe', timeout: 15000 })
execSync('git commit -m "AirCoding project initialized" --allow-empty', { cwd: project_root, stdio: 'pipe', timeout: 15000 })
} catch {
// Git available but nothing to commit or other non-fatal issue
}
}
}

View File

@@ -1,6 +1,7 @@
/**
* RunCommand - Interactive AI coding session
* DD §17. Full chain: input → MainAgent → Scheduler → Worker → LLM → tools → result.
* Phase 3: All output routed through TUI status pipeline (no console.log tearing).
*
* @module packages/cli/src/commands/run
*/
@@ -9,11 +10,16 @@ import { loadConfig } from '../bootstrap/loadConfig.js'
import { createRuntime } from '../bootstrap/createRuntime.js'
import { initCommand } from './init.js'
import type { TuiApp as TuiAppInstance } from '@aircoding/tui'
import { MainAgent, eventIngestor } from '@aircoding/runtime'
import { OpenAICompatibleAdapter } from '@aircoding/llm'
import { existsSync } from 'fs'
import { MainAgent, ArchitectureDesigner, eventIngestor } from '@aircoding/runtime'
import { existsSync, statSync } from 'fs'
import { join } from 'path'
import { randomUUID } from 'crypto'
import { Effect, Layer } from 'effect'
import { FetchHttpClient } from 'effect/unstable/http'
import { configure } from '../../llm/src/opencode/providers/openai-compatible.js'
import { LLMClient } from '../../llm/src/opencode/route/client.js'
import { request } from '../../llm/src/opencode/llm.js'
import { RequestExecutor } from '../../llm/src/opencode/route/executor.js'
type TaskResultSummary = {
title: string
@@ -25,166 +31,197 @@ export async function runCommand(project_path?: string): Promise<void> {
const config = loadConfig(project_path)
const project_root = config.project_root || process.cwd()
// Auto-init if needed
// Auto-init if needed (pre-TUI, console is OK)
if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) {
console.log('Project not initialized. Running air init...\n')
process.stderr.write('Project not initialized. Running air init...\n')
await initCommand(project_root)
}
// Create LLM provider
const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY || ''
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://open.bigmodel.cn/api/paas/v4'
const model = process.env.AIRCODING_MODEL || 'glm-5.1'
const adapter = new OpenAICompatibleAdapter({ base_url: apiUrl, api_key: apiKey, model })
// OpenCode provider: configure().model()
const oc = configure({ baseURL: apiUrl, apiKey })
const ocModel = oc.model(model)
// OpenCode LLM layer chain: FetchHttpClient → RequestExecutor → LLMClient
const ocLayer = LLMClient.layer.pipe(
Layer.provide(RequestExecutor.layer),
Layer.provide(FetchHttpClient.layer),
)
// Thin glue: complete_text() for WorkerManager, backed by OpenCode LLMClient.generate()
const provider = {
adapters: new Map([['openai-compatible', adapter]]),
current_adapter: adapter,
current_model: model,
async complete_text(msgs: unknown[], opts: any = {}) {
return (adapter as any).complete_text(msgs, opts)
complete_text: async (msgs: unknown[], opts: any = {}) => {
const messages = (msgs as any[]).map((m: any) =>
m.role === 'system' ? { _tag: 'SystemPart', content: m.content }
: m.role === 'assistant' ? { _tag: 'AssistantMessage', content: [{ _tag: 'TextPart', text: m.content }] }
: { _tag: 'UserMessage', content: [{ _tag: 'TextPart', text: m.content }] }
)
const req = request({
model: ocModel,
system: opts.system,
messages: messages as any,
generation: { maxTokens: opts.max_tokens || 4096, temperature: opts.temperature },
tools: opts.tools as any,
})
const program = Effect.gen(function* () {
const resp = yield* LLMClient.generate(req)
let content = ''
const tool_calls: any[] = []
for (const ev of resp.events) {
const e = ev as any
if (e._tag === 'TextPart' || e.type === 'text') { content += (e.text || '') }
if (e._tag === 'ToolCallEvent' || e.type === 'tool_call') {
tool_calls.push({ id: e.id, name: e.name, arguments: e.input || e.arguments || {} })
}
// Fallback: walk content arrays
if (e.content && Array.isArray(e.content)) {
for (const c of e.content) {
if (c._tag === 'TextPart' || c.type === 'text') content += (c.text || '')
if (c._tag === 'ToolCallEvent' || c.type === 'tool_call') {
tool_calls.push({ id: c.id, name: c.name, arguments: c.input || c.arguments || {} })
}
}
}
}
return { content, usage: resp.usage, tool_calls: tool_calls.length ? tool_calls : undefined }
})
return Effect.runPromise(Effect.provide(program, ocLayer))
}
}
// Create runtime and wire everything
const runtime = await createRuntime(config)
const app = runtime.app
// Wire ProviderManager into WorkerManager for llm.request IPC
app.worker_manager.set_provider_manager(provider as any)
app.worker_manager.set_context({
session_id: app.session_id,
project_id: app.project_id,
project_root
})
app.worker_manager.set_context({ session_id: app.session_id, project_id: app.project_id, project_root })
await app.start()
// Create MainAgent
const architectureDesigner = new ArchitectureDesigner(eventIngestor as any)
const agent = new MainAgent({
session_id: app.session_id,
project_id: app.project_id,
classify_mode: 'regex',
provider_manager: provider as any,
context_assembler: app.context_assembler,
project_root,
agent_id: 'main-agent' as any,
classify_model: model
session_id: app.session_id, project_id: app.project_id,
classify_mode: 'llm', provider_manager: provider as any,
context_assembler: app.context_assembler, architecture_designer: architectureDesigner,
project_root, agent_id: 'main-agent' as any, classify_model: model,
scheduler: app.scheduler
})
await import('@aircoding/tui/preload')
const { TuiApp } = await import('@aircoding/tui')
const taskResults = new Map<string, TaskResultSummary>()
let pendingConfirmation: string | undefined
let pendingConfirmation: { input: string; message: string } | undefined
let unsubProjection: (() => void) | null = null
let tui: TuiAppInstance
let shuttingDown = false
const shutdown = async () => {
if (shuttingDown) return
shuttingDown = true
unsubProjection?.()
app.scheduler.stop_loop()
tui?.stop()
await app.shutdown()
process.exit(0)
}
const dispatchTask = async (input: string) => {
const dispatchTask = async (input: string): Promise<string> => {
const taskId = `task_${randomUUID().slice(0, 8)}`
await app.scheduler.create_tasks([{
id: taskId,
type: 'execute',
title: input.slice(0, 80),
description: input
}])
console.log(`Task ${taskId} created. Dispatching worker...`)
tui.set_status(`Task ${taskId} running`)
const finalState = await app.scheduler.run_until_idle()
console.log(`Task complete. Scheduler: ${finalState}`)
const workerResult = app.worker_manager.get_result_for_task?.(taskId)
const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : []
const producedFiles: Array<{ path: string; size: number }> = []
if (resultFiles.length > 0) {
const { statSync, existsSync: fileExists } = await import('fs')
for (const file of resultFiles) {
if (!file || file.startsWith('.air/') || file.includes('/.air/') || file.split('/').some(e => e.startsWith('.'))) continue
const fullPath = join(project_root, file)
if (!fileExists(fullPath)) continue
const stat = statSync(fullPath)
if (stat.isFile()) producedFiles.push({ path: file, size: stat.size })
}
}
if (producedFiles.length > 0) {
console.log(' Produced files:')
for (const file of producedFiles.slice(0, 10)) {
console.log(` ${file.path} (${file.size}B)`)
}
}
taskResults.set(taskId, { title: input.slice(0, 80), files: producedFiles, state: finalState })
tui.set_status(`Task ${taskId}: ${finalState}`)
await app.scheduler.create_tasks([{ id: taskId, type: 'execute', title: input.slice(0, 80), description: input }])
// Non-blocking: scheduler loop picks up the task, TUI updates via projection.
// Defer to next tick so TUI's submit() cleanup (setBusy(false)) runs first,
// then we re-assert busy state for the duration of async task execution.
setTimeout(() => {
tui.set_busy(true, `Running: ${input.slice(0, 50)}`)
tui.set_status(`Dispatched: ${input.slice(0, 50)}`)
}, 0)
return taskId
}
const handleSubmit = async (input: string) => {
if (pendingConfirmation) {
if (/^(y|yes|是|确认|确定)$/i.test(input)) {
const confirmedInput = pendingConfirmation
const { input: confirmedInput } = pendingConfirmation
pendingConfirmation = undefined
await agent.handle_confirmation(true)
tui.set_status('Confirmed — dispatching')
await dispatchTask(confirmedInput)
} else if (/^(n|no|否|取消)$/i.test(input)) {
pendingConfirmation = undefined
await agent.handle_confirmation(false)
console.log('Cancelled. No task was created.\n')
tui.set_status('Cancelled')
tui.set_status('Cancelled No task was created')
} else {
console.log('Please answer y/n to confirm or cancel the pending destructive request.\n')
tui.set_status('Waiting for confirmation')
tui.set_status('Please answer y/n')
}
return
}
const classification = await agent.handle_user_message(input)
console.log(`[${classification.action}]`)
// FR-014: Auto-trigger compaction
if (agent.compaction_requested) {
agent.compaction_requested = false
const compactTaskId = `task_compact_${randomUUID().slice(0, 8)}`
try {
await app.scheduler.create_tasks([{ id: compactTaskId, type: 'compact', title: 'Auto-compaction', description: 'Context budget exceeded — compacting history.' }])
tui.set_status('Compacting context...')
} catch { /* advisory */ }
}
if (classification.action === 'answer') {
console.log('\n' + (classification.response || 'No response') + '\n')
tui.set_status('Answered')
tui.set_status(classification.response?.slice(0, 100) || 'Answered')
} else if (classification.action === 'replan') {
const reason = classification.reason || input
const impact = classification.impact
const changeId = `change_${randomUUID().slice(0, 8)}`
try {
await eventIngestor.ingest({
id: `evt_${changeId}`, type: 'requirement.changed', version: 1,
session_id: app.session_id, project_id: app.project_id,
timestamp: new Date().toISOString(), source: { kind: 'main' },
route: ['main_agent', 'architecture_replan'],
payload: { change_id: changeId, origin_message_id: `msg_${randomUUID().slice(0, 8)}`, summary: reason, change_type: 'architecture', affected_refs: impact?.affected_components || [] }
})
} catch {}
const adrChange = architectureDesigner.detect_adr_change(reason)
const adrId = adrChange?.old_tech || 'unknown'
tui.set_status(`Architecture replan: ${adrChange?.old_tech || ''}${adrChange?.new_tech || ''}`)
await app.scheduler.invalidate_by_adr(adrId, reason)
const newTaskId = `task_${randomUUID().slice(0, 8)}`
const planDelta = architectureDesigner.create_plan_delta_for_adr_change({
old_adr_id: adrId, new_adr_id: adrChange?.new_tech || `adr_${randomUUID().slice(0, 8)}`, reason,
new_tasks: [{ id: newTaskId, type: 'execute', title: `Reimplement with ${adrChange?.new_tech || 'new approach'}`, description: reason }],
})
await app.scheduler.apply_plan_delta(planDelta.delta)
app.scheduler.unfreeze()
await dispatchTask(`[Replan] ${reason.slice(0, 70)}`)
} else if (classification.action === 'delegate') {
if (agent.state === 'CONFIRMING' && classification.response) {
pendingConfirmation = input
console.log('\n' + classification.response + '\n')
tui.set_status('Waiting for confirmation')
pendingConfirmation = { input, message: classification.response.replace(/\s*\(y\/n\)\s*$/, '') }
tui.set_status(classification.response.slice(0, 80))
} else {
await dispatchTask(input)
}
} else {
console.log(`Result: ${classification.response || 'Done'}`)
tui.set_status(classification.response || 'Done')
tui.set_status(classification.response?.slice(0, 80) || 'Done')
}
}
const resolvePermission = async (prompt_id: string, selected_option: string) => {
await eventIngestor.ingest({
const { eventBus: bus } = await import('@aircoding/runtime')
const resolutionEvent = {
id: `evt_${prompt_id}_resolved_${randomUUID().slice(0, 8)}`,
type: 'permission.prompt.resolved',
version: 1,
session_id: app.session_id,
project_id: app.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'main', id: 'tui' },
type: 'permission.prompt.resolved' as const, version: 1,
session_id: app.session_id, project_id: app.project_id,
timestamp: new Date().toISOString(), source: { kind: 'main' as const, id: 'tui' },
route: ['cli', 'tui', 'permission'],
payload: {
prompt_id,
selected_option,
decision_id: `decision_${randomUUID().slice(0, 8)}`,
resolved_by: 'user',
},
})
tui.set_status(`Permission ${selected_option}`)
payload: { prompt_id, selected_option, decision_id: `decision_${randomUUID().slice(0, 8)}`, resolved_by: 'user' },
}
bus.publish(resolutionEvent)
try { await eventIngestor.ingest(resolutionEvent) } catch {}
tui.set_status(`Permission: ${selected_option}`)
}
tui = new TuiApp({
@@ -196,100 +233,64 @@ export async function runCommand(project_path?: string): Promise<void> {
})
await tui.start()
console.log('')
console.log('══════════════════════════════════════════════')
console.log(' AirCoding v1.0.0-alpha')
if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled')
console.log(' Type your task, or /help for commands, Ctrl+C to quit')
console.log('══════════════════════════════════════════════\n')
process.once('SIGINT', () => {
void shutdown()
// Phase 3: Start non-blocking scheduler loop + projection subscription for real-time TUI
const knownCompleted = new Set<string>()
unsubProjection = runtime.projection_client.subscribe((projection) => {
// Detect newly completed/failed tasks and update results
for (const task of projection.tasks) {
const key = `${task.id}:${task.status}`
if ((task.status === 'completed' || task.status === 'failed' || task.status === 'blocked') && !knownCompleted.has(key)) {
knownCompleted.add(key)
// Collect result files from worker manager
const workerResult = app.worker_manager.get_result_for_task?.(task.id)
const resultFiles: string[] = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : []
const producedFiles: Array<{ path: string; size: number }> = []
if (resultFiles.length > 0) {
for (const file of resultFiles) {
if (!file || file.startsWith('.air/') || file.includes('/.air/') || file.split('/').some(e => e.startsWith('.'))) continue
const fullPath = join(project_root, file)
if (!existsSync(fullPath)) continue
const stat = statSync(fullPath)
if (stat.isFile()) producedFiles.push({ path: file, size: stat.size })
}
}
taskResults.set(task.id, { title: task.title || task.id, files: producedFiles, state: task.status })
const fileList = producedFiles.slice(0, 5).map(f => f.path).join(', ')
const statusMsg = task.status === 'completed'
? (producedFiles.length > 0 ? `Done: ${fileList}` : `Task ${task.id.slice(-8)} completed`)
: `Task ${task.id.slice(-8)}: ${task.status}`
tui.set_status(statusMsg)
}
}
// When no running tasks remain, clear busy state
const runningCount = projection.tasks.filter(t => t.status === 'running' || t.status === 'pending').length
if (runningCount === 0) {
tui.set_busy(false)
}
})
// Start scheduler background loop (idempotent, yields between steps for TUI responsiveness)
app.scheduler.start_loop()
// Banner goes through TUI renderer's external output mode (pre-TUI)
tui.set_status(apiKey ? `Ready — ${model}` : 'No API key — AI disabled')
process.once('SIGINT', () => { void shutdown() })
await new Promise(() => {})
}
async function handleSlashCommand(
input: string,
app: any,
tui: TuiAppInstance,
taskResults: Map<string, TaskResultSummary>,
shutdown: () => Promise<void>,
input: string, app: any, tui: TuiAppInstance,
taskResults: Map<string, TaskResultSummary>, shutdown: () => Promise<void>,
): Promise<void> {
const cmd = input.slice(1).toLowerCase()
switch (cmd) {
case 'help':
tui.set_view('help')
console.log('\nCommands:')
console.log(' /help — Show this help')
console.log(' /status — Show scheduler and worker status')
console.log(' /tools — List registered tools')
console.log(' /tasks — Show task graph')
console.log(' /results — Show produced files from completed tasks')
console.log(' /quit — Exit AirCoding\n')
break
case 'results':
if (taskResults.size === 0) {
console.log(' No task results yet. Submit a task first.\n')
} else {
console.log('')
for (const [_taskId, result] of taskResults) {
console.log(` Task: ${result.title} [${result.state}]`)
if (result.files.length > 0) {
for (const file of result.files) {
console.log(` ${file.path} (${file.size}B)`)
}
} else {
console.log(' (no files produced)')
}
}
console.log('')
}
break
case 'status':
console.log(`\n Scheduler: ${app.scheduler.get_state()}`)
console.log(` Workers: ${app.worker_manager.list().length}`)
console.log(` DB: ${app.db.isOpen() ? 'open' : 'closed'}\n`)
break
case 'tools': {
tui.set_view('tools')
const tools = app.tool_registry.list()
console.log(`\n ${tools.length} tools registered:`)
const cats = new Map<string, string[]>()
for (const tool of tools) {
const list = cats.get(tool.category) || []
list.push(tool.name)
cats.set(tool.category, list)
}
for (const [cat, names] of cats) {
console.log(` ${cat}: ${names.join(', ')}`)
}
console.log('')
break
}
case 'tasks': {
tui.set_view('tasks')
const counts = app.scheduler.get_graph().count_by_status()
console.log(`\n Task graph:`)
for (const [status, count] of Object.entries(counts)) {
console.log(` ${status}: ${count}`)
}
console.log('')
break
}
case 'quit':
case 'exit':
await shutdown()
break
default:
console.log(`Unknown command: ${cmd}. Try /help\n`)
case 'help': tui.set_view('help'); break
case 'results': tui.set_view('diff'); break
case 'status': tui.set_view('agents'); tui.set_status(`Scheduler: ${app.scheduler.get_state()}, Workers: ${app.worker_manager.list().length}`); break
case 'tools': tui.set_view('tools'); break
case 'tasks': tui.set_view('tasks'); break
case 'quit': case 'exit': await shutdown(); break
default: tui.set_status(`Unknown: ${cmd}. Try /help`); break
}
}

View File

@@ -20,6 +20,7 @@
* @module packages/cli
*/
export { initCommand } from './commands/init.js'
import { initCommand } from './commands/init.js'
import { doctorCommand } from './commands/doctor.js'
import { providerCommand } from './commands/provider.js'

View File

@@ -1,68 +0,0 @@
import { describe, it, expect, afterEach } from 'bun:test'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { readFileSync } from 'fs'
import { join } from 'path'
import { loadConfig } from '../src/bootstrap/loadConfig.js'
describe('run command result presentation', () => {
const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8')
it('prefers WorkerResult.changed_files over recent filesystem scan', () => {
expect(source).toContain('get_result_for_task')
expect(source).toContain('workerResult?.changed_files')
})
it('filters .air internals from produced files', () => {
expect(source).toContain("file.startsWith('.air/')")
expect(source).toContain("e.startsWith('.')")
})
it('routes destructive confirmation before slash/main dispatch', () => {
expect(source).toContain('pendingConfirmation')
expect(source).toContain('handle_confirmation(true)')
expect(source).toContain('handle_confirmation(false)')
expect(source).toContain('No task was created')
})
})
describe('ask command architecture boundary', () => {
const source = readFileSync(join(import.meta.dir, '../src/commands/ask.ts'), 'utf-8')
it('dispatches delegate work through Scheduler and WorkerManager', () => {
expect(source).toContain('app.scheduler.create_tasks')
expect(source).toContain('app.scheduler.run_until_idle')
expect(source).toContain('app.worker_manager.get_result_for_task')
})
it('does not implement an inline LLM-to-tool loop', () => {
expect(source).not.toContain('parseToolCalls')
expect(source).not.toContain('toolRegistry.call')
expect(source).not.toContain('while (turn <')
})
})
describe('project root configuration', () => {
const created: string[] = []
afterEach(() => {
delete process.env.AIRCODING_PROJECT_ROOT
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
})
it('uses AIRCODING_PROJECT_ROOT when no explicit project path is passed', () => {
const projectRoot = mkdtempSync(join(tmpdir(), 'air-load-config-'))
created.push(projectRoot)
process.env.AIRCODING_PROJECT_ROOT = projectRoot
expect(loadConfig().project_root).toBe(projectRoot)
})
it('explicit project path wins over AIRCODING_PROJECT_ROOT', () => {
const envRoot = mkdtempSync(join(tmpdir(), 'air-load-config-env-'))
const explicitRoot = mkdtempSync(join(tmpdir(), 'air-load-config-explicit-'))
created.push(envRoot, explicitRoot)
process.env.AIRCODING_PROJECT_ROOT = envRoot
expect(loadConfig(explicitRoot).project_root).toBe(explicitRoot)
})
})

View File

@@ -1,15 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"skipLibCheck": true,
"jsx": "react-jsx",
"outDir": "./dist"
},
"include": ["src"],
"references": [
{ "path": "../contracts" },
{ "path": "../runtime" },
{ "path": "../tui" },
{ "path": "../llm" },
{ "path": "../toolchain-cpp" }
]
"include": ["src/**/*"]
}