@bamptee/aia-code 0.2.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.
package/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # AIA - AI Architecture Assistant
2
+
3
+ CLI tool that orchestrates AI-assisted development workflows using a `.aia` folder convention.
4
+
5
+ AIA structures your feature development into steps (brief, spec, tech-spec, etc.), builds rich prompts from project context and knowledge files, and delegates execution to AI CLI tools (Claude Code, Codex CLI, Gemini CLI) with weighted random model selection.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ npm install
11
+ node bin/aia.js init
12
+ ```
13
+
14
+ ## Prerequisites
15
+
16
+ AIA delegates to AI CLI tools. Install the ones you need:
17
+
18
+ | Provider | CLI | Install |
19
+ |----------|-----|---------|
20
+ | Anthropic | `claude` (Claude Code) | `npm install -g @anthropic-ai/claude-code` |
21
+ | OpenAI | `codex` (Codex CLI) | `npm install -g @openai/codex` |
22
+ | Google | `gemini` (Gemini CLI) | `npm install -g @anthropic-ai/gemini-cli` |
23
+
24
+ Each CLI manages its own authentication. Run `claude`, `codex`, or `gemini` once to log in before using AIA.
25
+
26
+ ## Commands
27
+
28
+ | Command | Description |
29
+ |---------|-------------|
30
+ | `aia init` | Create `.aia/` folder structure and default config |
31
+ | `aia feature <name>` | Create a new feature workspace |
32
+ | `aia run <step> <feature>` | Execute a step for a feature using AI |
33
+ | `aia status <feature>` | Show the current status of a feature |
34
+ | `aia reset <step> <feature>` | Reset a step to pending so it can be re-run |
35
+ | `aia repo scan` | Scan codebase and generate `repo-map.json` |
36
+
37
+ ## Integrate into an existing project
38
+
39
+ ### 1. Install
40
+
41
+ ```bash
42
+ cd your-project
43
+ npm install /path/to/aia-code
44
+ ```
45
+
46
+ Or add it as a dev dependency in your `package.json`:
47
+
48
+ ```json
49
+ {
50
+ "devDependencies": {
51
+ "aia": "file:../aia-code"
52
+ }
53
+ }
54
+ ```
55
+
56
+ ### 2. Initialize
57
+
58
+ ```bash
59
+ npx aia init
60
+ ```
61
+
62
+ This creates:
63
+
64
+ ```
65
+ your-project/
66
+ .aia/
67
+ config.yaml
68
+ context/
69
+ knowledge/
70
+ prompts/
71
+ features/
72
+ logs/
73
+ ```
74
+
75
+ ### 3. Write context files
76
+
77
+ These files describe your project to the AI. They are injected into every prompt.
78
+
79
+ ```markdown
80
+ <!-- .aia/context/project.md -->
81
+ # Project
82
+ E-commerce SaaS platform built with Node.js and MongoDB.
83
+ Stack: Express, React, Redis, PostgreSQL.
84
+ ```
85
+
86
+ ```markdown
87
+ <!-- .aia/context/architecture.md -->
88
+ # Architecture
89
+ Microservices communicating via RabbitMQ.
90
+ API gateway with JWT auth.
91
+ ```
92
+
93
+ Reference them in `config.yaml`:
94
+
95
+ ```yaml
96
+ context_files:
97
+ - context/project.md
98
+ - context/architecture.md
99
+ ```
100
+
101
+ ### 4. Write knowledge files
102
+
103
+ Knowledge files contain reusable technical guidelines, organized by category.
104
+
105
+ ```
106
+ .aia/knowledge/
107
+ backend/
108
+ nodejs.md # Node.js patterns and conventions
109
+ mongo-patterns.md # MongoDB query patterns
110
+ api-design.md # REST API guidelines
111
+ frontend/
112
+ react-patterns.md # React component patterns
113
+ ```
114
+
115
+ Set the default knowledge categories in `config.yaml`:
116
+
117
+ ```yaml
118
+ knowledge_default:
119
+ - backend
120
+ ```
121
+
122
+ Each feature can override this via its `status.yaml` `knowledge` field.
123
+
124
+ ### 5. Write prompt templates
125
+
126
+ One template per step, stored in `.aia/prompts/`:
127
+
128
+ ```markdown
129
+ <!-- .aia/prompts/brief.md -->
130
+ Write a product brief for this feature.
131
+ Include: problem statement, target users, success metrics.
132
+ ```
133
+
134
+ ```markdown
135
+ <!-- .aia/prompts/tech-spec.md -->
136
+ Write a technical specification.
137
+ Include: data models, API endpoints, architecture decisions, trade-offs.
138
+ ```
139
+
140
+ Required templates (one per step you want to run):
141
+
142
+ ```
143
+ .aia/prompts/brief.md
144
+ .aia/prompts/ba-spec.md
145
+ .aia/prompts/questions.md
146
+ .aia/prompts/tech-spec.md
147
+ .aia/prompts/challenge.md
148
+ .aia/prompts/dev-plan.md
149
+ .aia/prompts/review.md
150
+ ```
151
+
152
+ ### 6. Configure models
153
+
154
+ In `config.yaml`, assign models to steps with probability weights:
155
+
156
+ ```yaml
157
+ models:
158
+ brief:
159
+ - model: claude-sonnet-4-6
160
+ weight: 1
161
+
162
+ questions:
163
+ - model: claude-sonnet-4-6
164
+ weight: 0.5
165
+ - model: o3
166
+ weight: 0.5
167
+
168
+ tech-spec:
169
+ - model: gpt-4.1
170
+ weight: 0.6
171
+ - model: gemini-2.5-pro
172
+ weight: 0.4
173
+ ```
174
+
175
+ Weights don't need to sum to 1 -- they are normalized at runtime.
176
+
177
+ Supported model prefixes and the CLI used:
178
+
179
+ | Prefix | CLI | Examples |
180
+ |--------|-----|----------|
181
+ | `claude-*` | `claude -p --model` | `claude-sonnet-4-6`, `claude-3-7-sonnet` |
182
+ | `gpt-*`, `o[0-9]*` | `codex exec` | `gpt-4.1`, `o3`, `o4-mini` |
183
+ | `gemini-*` | `gemini` | `gemini-2.5-pro`, `gemini-2.5-flash` |
184
+
185
+ ### 7. Create a feature and run steps
186
+
187
+ ```bash
188
+ npx aia feature session-replay
189
+ npx aia run brief session-replay
190
+ npx aia status session-replay
191
+ npx aia run tech-spec session-replay
192
+ ```
193
+
194
+ Each run:
195
+ 1. Loads context files + knowledge + prior step outputs
196
+ 2. Selects a model based on weights
197
+ 3. Sends the assembled prompt to the CLI tool via stdin
198
+ 4. Streams the response to stdout in real-time
199
+ 5. Saves the output to `.aia/features/<name>/<step>.md`
200
+ 6. Updates `status.yaml` (marks step `done`, advances `current_step`)
201
+ 7. Logs execution to `.aia/logs/execution.log`
202
+
203
+ To re-run a step:
204
+
205
+ ```bash
206
+ npx aia reset tech-spec session-replay
207
+ npx aia run tech-spec session-replay
208
+ ```
209
+
210
+ ### 8. Scan your repo
211
+
212
+ ```bash
213
+ npx aia repo scan
214
+ ```
215
+
216
+ Generates `.aia/repo-map.json` -- a categorized index of your source files (services, models, routes, controllers, middleware, utils, config). Useful as additional context for prompts.
217
+
218
+ ## Project structure
219
+
220
+ ```
221
+ bin/
222
+ aia.js # CLI entrypoint
223
+ src/
224
+ cli.js # Commander program, registers commands
225
+ constants.js # Shared constants (dirs, steps, scan config)
226
+ models.js # Config loader + validation, weighted model selection
227
+ logger.js # Execution log writer
228
+ knowledge-loader.js # Recursive markdown loader by category
229
+ prompt-builder.js # Assembles full prompt from all sources
230
+ utils.js # Shared filesystem helpers
231
+ commands/
232
+ init.js # aia init
233
+ feature.js # aia feature <name>
234
+ run.js # aia run <step> <feature>
235
+ status.js # aia status <feature>
236
+ reset.js # aia reset <step> <feature>
237
+ repo.js # aia repo scan
238
+ providers/
239
+ registry.js # Model name -> provider routing
240
+ cli-runner.js # Shared CLI spawn logic (stdout streaming, timeout, error handling)
241
+ openai.js # codex exec
242
+ anthropic.js # claude -p
243
+ gemini.js # gemini
244
+ services/
245
+ scaffold.js # .aia/ folder creation
246
+ config.js # Default config generation
247
+ feature.js # Feature workspace creation + validation
248
+ status.js # status.yaml read/write/reset
249
+ runner.js # Step execution orchestrator
250
+ model-call.js # Provider dispatch
251
+ repo-scan.js # Codebase scanner + categorizer
252
+ ```
253
+
254
+ ## Feature workflow
255
+
256
+ Each feature follows a fixed pipeline:
257
+
258
+ ```
259
+ brief -> ba-spec -> questions -> tech-spec -> challenge -> dev-plan -> review
260
+ ```
261
+
262
+ `status.yaml` tracks progress:
263
+
264
+ ```yaml
265
+ feature: session-replay
266
+ current_step: tech-spec
267
+ steps:
268
+ brief: done
269
+ ba-spec: done
270
+ questions: pending
271
+ tech-spec: pending
272
+ challenge: pending
273
+ dev-plan: pending
274
+ review: pending
275
+ knowledge:
276
+ - backend
277
+ ```
278
+
279
+ ## Prompt assembly
280
+
281
+ When you run a step, the prompt is built from four sections:
282
+
283
+ ```
284
+ === CONTEXT ===
285
+ (content of context files from config.yaml)
286
+
287
+ === KNOWLEDGE ===
288
+ (all .md files from the knowledge categories)
289
+
290
+ === FEATURE ===
291
+ (outputs of all prior steps for this feature)
292
+
293
+ === TASK ===
294
+ (content of prompts/<step>.md)
295
+ ```
296
+
297
+ The full prompt is piped to the CLI tool via stdin, so there are no argument length limits.
298
+
299
+ ## Dependencies
300
+
301
+ Only four runtime dependencies:
302
+
303
+ - `commander` -- CLI framework
304
+ - `yaml` -- YAML parse/stringify
305
+ - `fs-extra` -- filesystem utilities
306
+ - `chalk` -- terminal colors
307
+
308
+ AI calls use `child_process.spawn` to delegate to installed CLI tools.
package/bin/aia.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createCli } from '../src/cli.js';
4
+
5
+ const program = createCli();
6
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@bamptee/aia-code",
3
+ "version": "0.2.0",
4
+ "description": "AI Architecture Assistant - orchestrate AI-assisted development workflows via CLI tools (Claude, Codex, Gemini)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "aia": "bin/aia.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src"
13
+ ],
14
+ "keywords": [
15
+ "ai",
16
+ "cli",
17
+ "architecture",
18
+ "claude",
19
+ "codex",
20
+ "gemini",
21
+ "orchestrator",
22
+ "development",
23
+ "workflow"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/bamptee/aia-code.git"
31
+ },
32
+ "scripts": {
33
+ "start": "node bin/aia.js"
34
+ },
35
+ "dependencies": {
36
+ "chalk": "^5.3.0",
37
+ "commander": "^12.1.0",
38
+ "fs-extra": "^11.2.0",
39
+ "yaml": "^2.7.0"
40
+ }
41
+ }
package/src/cli.js ADDED
@@ -0,0 +1,25 @@
1
+ import { Command } from 'commander';
2
+ import { registerInitCommand } from './commands/init.js';
3
+ import { registerFeatureCommand } from './commands/feature.js';
4
+ import { registerRunCommand } from './commands/run.js';
5
+ import { registerRepoCommand } from './commands/repo.js';
6
+ import { registerStatusCommand } from './commands/status.js';
7
+ import { registerResetCommand } from './commands/reset.js';
8
+
9
+ export function createCli() {
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('aia')
14
+ .description('AI Architecture Assistant')
15
+ .version('0.1.0');
16
+
17
+ registerInitCommand(program);
18
+ registerFeatureCommand(program);
19
+ registerRunCommand(program);
20
+ registerRepoCommand(program);
21
+ registerStatusCommand(program);
22
+ registerResetCommand(program);
23
+
24
+ return program;
25
+ }
@@ -0,0 +1,17 @@
1
+ import chalk from 'chalk';
2
+ import { createFeature } from '../services/feature.js';
3
+
4
+ export function registerFeatureCommand(program) {
5
+ program
6
+ .command('feature <name>')
7
+ .description('Create a new feature workspace under .aia/features/')
8
+ .action(async (name) => {
9
+ try {
10
+ await createFeature(name);
11
+ console.log(chalk.green(`Feature "${name}" created.`));
12
+ } catch (err) {
13
+ console.error(chalk.red(err.message));
14
+ process.exit(1);
15
+ }
16
+ });
17
+ }
@@ -0,0 +1,20 @@
1
+ import chalk from 'chalk';
2
+ import { createAiaStructure } from '../services/scaffold.js';
3
+ import { writeDefaultConfig } from '../services/config.js';
4
+ import { AIA_DIR } from '../constants.js';
5
+
6
+ export function registerInitCommand(program) {
7
+ program
8
+ .command('init')
9
+ .description('Initialize .aia folder structure and default config')
10
+ .action(async () => {
11
+ try {
12
+ await createAiaStructure();
13
+ await writeDefaultConfig();
14
+ console.log(chalk.green(`Initialized ${AIA_DIR}/ project structure.`));
15
+ } catch (err) {
16
+ console.error(chalk.red(`Init failed: ${err.message}`));
17
+ process.exit(1);
18
+ }
19
+ });
20
+ }
@@ -0,0 +1,29 @@
1
+ import chalk from 'chalk';
2
+ import { scanRepo } from '../services/repo-scan.js';
3
+
4
+ export function registerRepoCommand(program) {
5
+ const repo = program
6
+ .command('repo')
7
+ .description('Repository intelligence tools');
8
+
9
+ repo
10
+ .command('scan')
11
+ .description('Scan the codebase and generate .aia/repo-map.json')
12
+ .action(async () => {
13
+ try {
14
+ const { map, total, outputPath } = await scanRepo();
15
+
16
+ const categories = Object.keys(map);
17
+ const matched = Object.values(map).reduce((s, f) => s + f.length, 0);
18
+
19
+ console.log(chalk.green(`Scanned ${total} files, categorized ${matched} into ${categories.length} groups.`));
20
+ for (const [cat, files] of Object.entries(map)) {
21
+ console.log(chalk.cyan(` ${cat}: ${files.length} files`));
22
+ }
23
+ console.log(chalk.gray(`Written to ${outputPath}`));
24
+ } catch (err) {
25
+ console.error(chalk.red(err.message));
26
+ process.exit(1);
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,17 @@
1
+ import chalk from 'chalk';
2
+ import { resetStep } from '../services/status.js';
3
+
4
+ export function registerResetCommand(program) {
5
+ program
6
+ .command('reset <step> <feature>')
7
+ .description('Reset a step to pending so it can be re-run')
8
+ .action(async (step, feature) => {
9
+ try {
10
+ await resetStep(feature, step);
11
+ console.log(chalk.green(`Step "${step}" reset to pending for feature "${feature}".`));
12
+ } catch (err) {
13
+ console.error(chalk.red(err.message));
14
+ process.exit(1);
15
+ }
16
+ });
17
+ }
@@ -0,0 +1,16 @@
1
+ import chalk from 'chalk';
2
+ import { runStep } from '../services/runner.js';
3
+
4
+ export function registerRunCommand(program) {
5
+ program
6
+ .command('run <step> <feature>')
7
+ .description('Execute a step for a feature using the configured AI model')
8
+ .action(async (step, feature) => {
9
+ try {
10
+ await runStep(step, feature);
11
+ } catch (err) {
12
+ console.error(chalk.red(err.message));
13
+ process.exit(1);
14
+ }
15
+ });
16
+ }
@@ -0,0 +1,37 @@
1
+ import chalk from 'chalk';
2
+ import { STEP_STATUS } from '../constants.js';
3
+ import { loadStatus } from '../services/status.js';
4
+
5
+ const STATUS_COLORS = {
6
+ [STEP_STATUS.DONE]: chalk.green,
7
+ [STEP_STATUS.IN_PROGRESS]: chalk.yellow,
8
+ [STEP_STATUS.ERROR]: chalk.red,
9
+ [STEP_STATUS.PENDING]: chalk.gray,
10
+ };
11
+
12
+ export function registerStatusCommand(program) {
13
+ program
14
+ .command('status <feature>')
15
+ .description('Show the current status of a feature')
16
+ .action(async (feature) => {
17
+ try {
18
+ const status = await loadStatus(feature);
19
+
20
+ console.log(chalk.bold(`Feature: ${status.feature}`));
21
+ console.log(chalk.bold(`Current step: ${status.current_step}\n`));
22
+
23
+ for (const [step, value] of Object.entries(status.steps)) {
24
+ const colorize = STATUS_COLORS[value] ?? chalk.white;
25
+ const marker = value === STEP_STATUS.DONE ? 'v' : value === STEP_STATUS.ERROR ? 'x' : '-';
26
+ console.log(` ${colorize(`[${marker}] ${step}: ${value}`)}`);
27
+ }
28
+
29
+ if (status.knowledge?.length) {
30
+ console.log(chalk.gray(`\nKnowledge: ${status.knowledge.join(', ')}`));
31
+ }
32
+ } catch (err) {
33
+ console.error(chalk.red(err.message));
34
+ process.exit(1);
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,43 @@
1
+ export const AIA_DIR = '.aia';
2
+
3
+ export const AIA_FOLDERS = [
4
+ 'context',
5
+ 'knowledge',
6
+ 'prompts',
7
+ 'features',
8
+ 'logs',
9
+ ];
10
+
11
+ export const SCAN_IGNORE = new Set([
12
+ 'node_modules',
13
+ 'dist',
14
+ '.git',
15
+ '.aia',
16
+ ]);
17
+
18
+ export const SCAN_CATEGORIES = {
19
+ services: /\bservices?\b/i,
20
+ models: /\bmodels?\b/i,
21
+ routes: /\broutes?\b/i,
22
+ controllers: /\bcontrollers?\b/i,
23
+ middleware: /\bmiddleware\b/i,
24
+ utils: /\b(utils?|helpers?)\b/i,
25
+ config: /\bconfig\b/i,
26
+ };
27
+
28
+ export const FEATURE_STEPS = [
29
+ 'brief',
30
+ 'ba-spec',
31
+ 'questions',
32
+ 'tech-spec',
33
+ 'challenge',
34
+ 'dev-plan',
35
+ 'review',
36
+ ];
37
+
38
+ export const STEP_STATUS = {
39
+ PENDING: 'pending',
40
+ IN_PROGRESS: 'in-progress',
41
+ DONE: 'done',
42
+ ERROR: 'error',
43
+ };
@@ -0,0 +1,43 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { AIA_DIR } from './constants.js';
4
+
5
+ async function collectMarkdownFiles(dir) {
6
+ const entries = await fs.readdir(dir, { withFileTypes: true });
7
+ const files = [];
8
+
9
+ for (const entry of entries) {
10
+ const fullPath = path.join(dir, entry.name);
11
+ if (entry.isDirectory()) {
12
+ files.push(...await collectMarkdownFiles(fullPath));
13
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
14
+ files.push(fullPath);
15
+ }
16
+ }
17
+
18
+ return files;
19
+ }
20
+
21
+ export async function loadKnowledge(categories, root = process.cwd()) {
22
+ const knowledgeDir = path.join(root, AIA_DIR, 'knowledge');
23
+ const sections = [];
24
+
25
+ for (const category of categories) {
26
+ const categoryDir = path.join(knowledgeDir, category);
27
+
28
+ if (!(await fs.pathExists(categoryDir))) {
29
+ continue;
30
+ }
31
+
32
+ const files = (await collectMarkdownFiles(categoryDir)).sort();
33
+
34
+ for (const filePath of files) {
35
+ const content = (await fs.readFile(filePath, 'utf-8')).trim();
36
+ if (content) {
37
+ sections.push(content);
38
+ }
39
+ }
40
+ }
41
+
42
+ return sections.join('\n\n---\n\n');
43
+ }
package/src/logger.js ADDED
@@ -0,0 +1,28 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { AIA_DIR } from './constants.js';
4
+
5
+ const LOG_FILE = 'execution.log';
6
+
7
+ function formatEntry({ feature, step, model, duration }) {
8
+ const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, '');
9
+ const durationSec = (duration / 1000).toFixed(1);
10
+
11
+ return [
12
+ timestamp,
13
+ `feature=${feature}`,
14
+ `step=${step}`,
15
+ `model=${model}`,
16
+ `duration=${durationSec}s`,
17
+ '',
18
+ ].join('\n');
19
+ }
20
+
21
+ export async function logExecution(entry, root = process.cwd()) {
22
+ const logDir = path.join(root, AIA_DIR, 'logs');
23
+ await fs.ensureDir(logDir);
24
+
25
+ const logPath = path.join(logDir, LOG_FILE);
26
+ const line = formatEntry(entry);
27
+ await fs.appendFile(logPath, line + '\n', 'utf-8');
28
+ }
package/src/models.js ADDED
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import yaml from 'yaml';
4
+ import chalk from 'chalk';
5
+ import { AIA_DIR } from './constants.js';
6
+
7
+ export async function loadConfig(root = process.cwd()) {
8
+ const configPath = path.join(root, AIA_DIR, 'config.yaml');
9
+
10
+ if (!(await fs.pathExists(configPath))) {
11
+ throw new Error(`Config not found: ${configPath}`);
12
+ }
13
+
14
+ const raw = await fs.readFile(configPath, 'utf-8');
15
+ const config = yaml.parse(raw);
16
+
17
+ validateConfig(config, configPath);
18
+
19
+ return config;
20
+ }
21
+
22
+ function validateConfig(config, configPath) {
23
+ if (!config || typeof config !== 'object') {
24
+ throw new Error(`Invalid config: ${configPath} must be a YAML object.`);
25
+ }
26
+
27
+ if (!config.models || typeof config.models !== 'object') {
28
+ throw new Error(`Invalid config: "models" section is required in ${configPath}.`);
29
+ }
30
+
31
+ for (const [step, models] of Object.entries(config.models)) {
32
+ if (!Array.isArray(models)) {
33
+ throw new Error(`Invalid config: models.${step} must be an array.`);
34
+ }
35
+ for (const entry of models) {
36
+ if (!entry.model || typeof entry.model !== 'string') {
37
+ throw new Error(`Invalid config: each entry in models.${step} must have a "model" string.`);
38
+ }
39
+ if (typeof entry.weight !== 'number' || entry.weight <= 0) {
40
+ throw new Error(`Invalid config: each entry in models.${step} must have a positive "weight".`);
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ export function selectByWeight(models) {
47
+ if (models.length === 1) {
48
+ return models[0].model;
49
+ }
50
+
51
+ const totalWeight = models.reduce((sum, m) => sum + m.weight, 0);
52
+ const roll = Math.random() * totalWeight;
53
+
54
+ let cumulative = 0;
55
+ for (const entry of models) {
56
+ cumulative += entry.weight;
57
+ if (roll < cumulative) {
58
+ return entry.model;
59
+ }
60
+ }
61
+
62
+ return models[models.length - 1].model;
63
+ }
64
+
65
+ export async function resolveModel(step, root = process.cwd()) {
66
+ const config = await loadConfig(root);
67
+ const models = config.models?.[step];
68
+
69
+ if (!models || models.length === 0) {
70
+ throw new Error(`No models configured for step "${step}".`);
71
+ }
72
+
73
+ const selected = selectByWeight(models);
74
+
75
+ console.log(chalk.cyan(`[AI] step=${step} model=${selected}`));
76
+
77
+ return selected;
78
+ }
@@ -0,0 +1,97 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import yaml from 'yaml';
4
+ import { AIA_DIR, FEATURE_STEPS } from './constants.js';
5
+ import { loadConfig } from './models.js';
6
+ import { loadKnowledge } from './knowledge-loader.js';
7
+ import { readIfExists } from './utils.js';
8
+
9
+ async function loadContextFiles(config, root) {
10
+ const files = config.context_files ?? [];
11
+ const sections = [];
12
+
13
+ for (const file of files) {
14
+ const content = await readIfExists(path.join(root, AIA_DIR, file));
15
+ if (content) {
16
+ sections.push(content);
17
+ }
18
+ }
19
+
20
+ return sections.join('\n\n');
21
+ }
22
+
23
+ async function loadFeatureFiles(feature, step, root) {
24
+ const stepIndex = FEATURE_STEPS.indexOf(step);
25
+ if (stepIndex === -1) {
26
+ throw new Error(`Unknown step "${step}".`);
27
+ }
28
+
29
+ const featureDir = path.join(root, AIA_DIR, 'features', feature);
30
+ if (!(await fs.pathExists(featureDir))) {
31
+ throw new Error(`Feature "${feature}" not found.`);
32
+ }
33
+
34
+ const priorSteps = FEATURE_STEPS.slice(0, stepIndex);
35
+ const sections = [];
36
+
37
+ for (const s of priorSteps) {
38
+ const content = await readIfExists(path.join(featureDir, `${s}.md`));
39
+ if (content) {
40
+ sections.push(content);
41
+ }
42
+ }
43
+
44
+ return sections.join('\n\n');
45
+ }
46
+
47
+ async function resolveKnowledgeCategories(feature, config, root) {
48
+ const statusPath = path.join(root, AIA_DIR, 'features', feature, 'status.yaml');
49
+ const raw = await readIfExists(statusPath);
50
+
51
+ if (raw) {
52
+ const status = yaml.parse(raw);
53
+ if (status?.knowledge?.length) {
54
+ return status.knowledge;
55
+ }
56
+ }
57
+
58
+ return config.knowledge_default ?? [];
59
+ }
60
+
61
+ async function loadPromptTemplate(step, root) {
62
+ const templatePath = path.join(root, AIA_DIR, 'prompts', `${step}.md`);
63
+ const content = await readIfExists(templatePath);
64
+ if (!content) {
65
+ throw new Error(`Prompt template not found: prompts/${step}.md`);
66
+ }
67
+ return content;
68
+ }
69
+
70
+ export async function buildPrompt(feature, step, root = process.cwd()) {
71
+ const config = await loadConfig(root);
72
+
73
+ const [context, knowledgeCategories, featureContent, task] = await Promise.all([
74
+ loadContextFiles(config, root),
75
+ resolveKnowledgeCategories(feature, config, root),
76
+ loadFeatureFiles(feature, step, root),
77
+ loadPromptTemplate(step, root),
78
+ ]);
79
+
80
+ const knowledge = await loadKnowledge(knowledgeCategories, root);
81
+
82
+ const parts = [];
83
+
84
+ parts.push('=== CONTEXT ===\n');
85
+ parts.push(context || '(no context files)');
86
+
87
+ parts.push('\n\n=== KNOWLEDGE ===\n');
88
+ parts.push(knowledge || '(no knowledge)');
89
+
90
+ parts.push('\n\n=== FEATURE ===\n');
91
+ parts.push(featureContent || '(no prior steps)');
92
+
93
+ parts.push('\n\n=== TASK ===\n');
94
+ parts.push(task);
95
+
96
+ return parts.join('\n');
97
+ }
@@ -0,0 +1,11 @@
1
+ import { runCli } from './cli-runner.js';
2
+
3
+ export async function generate(prompt, model) {
4
+ const args = ['-p'];
5
+ if (model) {
6
+ args.push('--model', model);
7
+ }
8
+ args.push('-');
9
+
10
+ return runCli('claude', args, { stdin: prompt });
11
+ }
@@ -0,0 +1,53 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 300_000;
4
+
5
+ export function runCli(command, args, { stdin: stdinData, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(command, args, {
8
+ stdio: ['pipe', 'pipe', 'pipe'],
9
+ env: { ...process.env, FORCE_COLOR: '0' },
10
+ });
11
+
12
+ const chunks = [];
13
+ let stderr = '';
14
+
15
+ child.stdout.on('data', (data) => {
16
+ const text = data.toString();
17
+ process.stdout.write(text);
18
+ chunks.push(text);
19
+ });
20
+
21
+ child.stderr.on('data', (data) => {
22
+ stderr += data.toString();
23
+ });
24
+
25
+ const timer = setTimeout(() => {
26
+ child.kill('SIGTERM');
27
+ reject(new Error(`CLI timed out after ${timeoutMs / 1000}s: ${command} ${args.join(' ')}`));
28
+ }, timeoutMs);
29
+
30
+ child.on('error', (err) => {
31
+ clearTimeout(timer);
32
+ if (err.code === 'ENOENT') {
33
+ reject(new Error(`CLI not found: "${command}". Make sure it is installed and in your PATH.`));
34
+ } else {
35
+ reject(err);
36
+ }
37
+ });
38
+
39
+ child.on('close', (code) => {
40
+ clearTimeout(timer);
41
+ if (code !== 0) {
42
+ reject(new Error(`${command} exited with code ${code}:\n${stderr.trim()}`));
43
+ } else {
44
+ resolve(chunks.join(''));
45
+ }
46
+ });
47
+
48
+ if (stdinData) {
49
+ child.stdin.write(stdinData);
50
+ child.stdin.end();
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,11 @@
1
+ import { runCli } from './cli-runner.js';
2
+
3
+ export async function generate(prompt, model) {
4
+ const args = [];
5
+ if (model) {
6
+ args.push('-m', model);
7
+ }
8
+ args.push('-');
9
+
10
+ return runCli('gemini', args, { stdin: prompt });
11
+ }
@@ -0,0 +1,11 @@
1
+ import { runCli } from './cli-runner.js';
2
+
3
+ export async function generate(prompt, model) {
4
+ const args = ['exec'];
5
+ if (model) {
6
+ args.push('-c', `model="${model}"`);
7
+ }
8
+ args.push('-');
9
+
10
+ return runCli('codex', args, { stdin: prompt });
11
+ }
@@ -0,0 +1,38 @@
1
+ import * as openai from './openai.js';
2
+ import * as anthropic from './anthropic.js';
3
+ import * as gemini from './gemini.js';
4
+
5
+ const MODEL_ALIASES = {
6
+ 'claude-default': { provider: anthropic, model: null },
7
+ 'openai-default': { provider: openai, model: null },
8
+ 'codex-default': { provider: openai, model: null },
9
+ 'gemini-default': { provider: gemini, model: null },
10
+ };
11
+
12
+ const MODEL_PREFIXES = [
13
+ { test: (m) => m.startsWith('gpt-') || /^o[0-9]/.test(m), provider: openai },
14
+ { test: (m) => m.startsWith('claude-'), provider: anthropic },
15
+ { test: (m) => m.startsWith('gemini-'), provider: gemini },
16
+ ];
17
+
18
+ export function resolveModelAlias(model) {
19
+ if (!model || typeof model !== 'string') {
20
+ throw new Error('Model name must be a non-empty string.');
21
+ }
22
+
23
+ const alias = MODEL_ALIASES[model];
24
+ if (alias) {
25
+ return { provider: alias.provider, model: alias.model };
26
+ }
27
+
28
+ const match = MODEL_PREFIXES.find((entry) => entry.test(model));
29
+ if (!match) {
30
+ throw new Error(`No provider found for model "${model}".`);
31
+ }
32
+
33
+ return { provider: match.provider, model };
34
+ }
35
+
36
+ export function getProvider(model) {
37
+ return resolveModelAlias(model).provider;
38
+ }
@@ -0,0 +1,33 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import yaml from 'yaml';
4
+ import { AIA_DIR } from '../constants.js';
5
+
6
+ const DEFAULT_CONFIG = {
7
+ models: {
8
+ questions: [
9
+ { model: 'claude-default', weight: 0.5 },
10
+ { model: 'openai-default', weight: 0.5 },
11
+ ],
12
+ 'tech-spec': [
13
+ { model: 'claude-default', weight: 0.5 },
14
+ { model: 'openai-default', weight: 0.5 },
15
+ ],
16
+ },
17
+ knowledge_default: ['backend'],
18
+ context_files: [
19
+ 'context/project.md',
20
+ 'context/architecture.md',
21
+ ],
22
+ };
23
+
24
+ export async function writeDefaultConfig(root = process.cwd()) {
25
+ const configPath = path.join(root, AIA_DIR, 'config.yaml');
26
+
27
+ if (await fs.pathExists(configPath)) {
28
+ return;
29
+ }
30
+
31
+ const content = yaml.stringify(DEFAULT_CONFIG);
32
+ await fs.writeFile(configPath, content, 'utf-8');
33
+ }
@@ -0,0 +1,57 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import yaml from 'yaml';
4
+ import { AIA_DIR, FEATURE_STEPS } from '../constants.js';
5
+
6
+ const FEATURE_FILES = [
7
+ 'status.yaml',
8
+ 'brief.md',
9
+ 'ba-spec.md',
10
+ 'questions.md',
11
+ 'tech-spec.md',
12
+ 'challenge.md',
13
+ 'dev-plan.md',
14
+ 'review.md',
15
+ ];
16
+
17
+ const FEATURE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
18
+
19
+ export function validateFeatureName(name) {
20
+ if (!name || !FEATURE_NAME_RE.test(name)) {
21
+ throw new Error(
22
+ `Invalid feature name "${name}". Use lowercase alphanumeric with hyphens (e.g. session-replay).`,
23
+ );
24
+ }
25
+ }
26
+
27
+ function buildStatusYaml(name) {
28
+ const steps = {};
29
+ for (const step of FEATURE_STEPS) {
30
+ steps[step] = 'pending';
31
+ }
32
+
33
+ return yaml.stringify({
34
+ feature: name,
35
+ current_step: 'brief',
36
+ steps,
37
+ knowledge: ['backend'],
38
+ });
39
+ }
40
+
41
+ export async function createFeature(name, root = process.cwd()) {
42
+ validateFeatureName(name);
43
+
44
+ const featureDir = path.join(root, AIA_DIR, 'features', name);
45
+
46
+ if (await fs.pathExists(featureDir)) {
47
+ throw new Error(`Feature "${name}" already exists.`);
48
+ }
49
+
50
+ await fs.ensureDir(featureDir);
51
+
52
+ for (const file of FEATURE_FILES) {
53
+ const filePath = path.join(featureDir, file);
54
+ const content = file === 'status.yaml' ? buildStatusYaml(name) : '';
55
+ await fs.writeFile(filePath, content, 'utf-8');
56
+ }
57
+ }
@@ -0,0 +1,11 @@
1
+ import chalk from 'chalk';
2
+ import { resolveModelAlias } from '../providers/registry.js';
3
+
4
+ export async function callModel(model, prompt) {
5
+ const resolved = resolveModelAlias(model);
6
+ const displayName = resolved.model ?? `${model} (CLI default)`;
7
+
8
+ console.log(chalk.yellow(`[AI] Calling ${displayName}...`));
9
+
10
+ return resolved.provider.generate(prompt, resolved.model);
11
+ }
@@ -0,0 +1,59 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { AIA_DIR, SCAN_IGNORE, SCAN_CATEGORIES } from '../constants.js';
4
+
5
+ const CODE_EXTENSIONS = new Set([
6
+ '.js', '.mjs', '.cjs',
7
+ '.ts', '.mts', '.cts',
8
+ '.jsx', '.tsx',
9
+ '.json',
10
+ ]);
11
+
12
+ async function walk(dir, root) {
13
+ const entries = await fs.readdir(dir, { withFileTypes: true });
14
+ const files = [];
15
+
16
+ for (const entry of entries) {
17
+ if (SCAN_IGNORE.has(entry.name)) continue;
18
+
19
+ const fullPath = path.join(dir, entry.name);
20
+
21
+ if (entry.isDirectory()) {
22
+ files.push(...await walk(fullPath, root));
23
+ } else if (entry.isFile() && CODE_EXTENSIONS.has(path.extname(entry.name))) {
24
+ files.push(path.relative(root, fullPath));
25
+ }
26
+ }
27
+
28
+ return files;
29
+ }
30
+
31
+ function categorize(files) {
32
+ const map = {};
33
+
34
+ for (const file of files) {
35
+ for (const [category, pattern] of Object.entries(SCAN_CATEGORIES)) {
36
+ if (pattern.test(file)) {
37
+ (map[category] ??= []).push(file);
38
+ break;
39
+ }
40
+ }
41
+ }
42
+
43
+ for (const key of Object.keys(map)) {
44
+ map[key].sort();
45
+ }
46
+
47
+ return map;
48
+ }
49
+
50
+ export async function scanRepo(root = process.cwd()) {
51
+ const files = (await walk(root, root)).sort();
52
+ const map = categorize(files);
53
+
54
+ const outputPath = path.join(root, AIA_DIR, 'repo-map.json');
55
+ await fs.ensureDir(path.dirname(outputPath));
56
+ await fs.writeJson(outputPath, map, { spaces: 2 });
57
+
58
+ return { map, total: files.length, outputPath };
59
+ }
@@ -0,0 +1,46 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import chalk from 'chalk';
4
+ import { AIA_DIR, FEATURE_STEPS, STEP_STATUS } from '../constants.js';
5
+ import { resolveModel } from '../models.js';
6
+ import { buildPrompt } from '../prompt-builder.js';
7
+ import { callModel } from './model-call.js';
8
+ import { loadStatus, updateStepStatus } from './status.js';
9
+ import { logExecution } from '../logger.js';
10
+
11
+ export async function runStep(step, feature, root = process.cwd()) {
12
+ if (!FEATURE_STEPS.includes(step)) {
13
+ throw new Error(`Unknown step "${step}". Valid steps: ${FEATURE_STEPS.join(', ')}`);
14
+ }
15
+
16
+ const status = await loadStatus(feature, root);
17
+
18
+ if (status.steps[step] === STEP_STATUS.DONE) {
19
+ throw new Error(
20
+ `Step "${step}" already done for feature "${feature}". Use "aia reset ${step} ${feature}" to re-run.`,
21
+ );
22
+ }
23
+
24
+ await updateStepStatus(feature, step, STEP_STATUS.IN_PROGRESS, root);
25
+
26
+ try {
27
+ const model = await resolveModel(step, root);
28
+ const prompt = await buildPrompt(feature, step, root);
29
+
30
+ const start = performance.now();
31
+ const output = await callModel(model, prompt);
32
+ const duration = performance.now() - start;
33
+
34
+ const outputPath = path.join(root, AIA_DIR, 'features', feature, `${step}.md`);
35
+ await fs.writeFile(outputPath, output, 'utf-8');
36
+
37
+ await updateStepStatus(feature, step, STEP_STATUS.DONE, root);
38
+ await logExecution({ feature, step, model, duration }, root);
39
+
40
+ console.log(chalk.green(`Step "${step}" completed for feature "${feature}".`));
41
+ return output;
42
+ } catch (err) {
43
+ await updateStepStatus(feature, step, STEP_STATUS.ERROR, root);
44
+ throw err;
45
+ }
46
+ }
@@ -0,0 +1,9 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { AIA_DIR, AIA_FOLDERS } from '../constants.js';
4
+
5
+ export async function createAiaStructure(root = process.cwd()) {
6
+ for (const folder of AIA_FOLDERS) {
7
+ await fs.ensureDir(path.join(root, AIA_DIR, folder));
8
+ }
9
+ }
@@ -0,0 +1,70 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import yaml from 'yaml';
4
+ import { AIA_DIR, FEATURE_STEPS, STEP_STATUS } from '../constants.js';
5
+
6
+ function statusPath(feature, root) {
7
+ return path.join(root, AIA_DIR, 'features', feature, 'status.yaml');
8
+ }
9
+
10
+ function validateStatus(status, feature) {
11
+ if (!status || typeof status !== 'object') {
12
+ throw new Error(`Corrupted status.yaml for feature "${feature}".`);
13
+ }
14
+ if (!status.steps || typeof status.steps !== 'object') {
15
+ throw new Error(`Missing "steps" in status.yaml for feature "${feature}".`);
16
+ }
17
+ }
18
+
19
+ export async function loadStatus(feature, root = process.cwd()) {
20
+ const filePath = statusPath(feature, root);
21
+
22
+ if (!(await fs.pathExists(filePath))) {
23
+ throw new Error(`Feature "${feature}" not found.`);
24
+ }
25
+
26
+ const raw = await fs.readFile(filePath, 'utf-8');
27
+ const status = yaml.parse(raw);
28
+
29
+ validateStatus(status, feature);
30
+
31
+ return status;
32
+ }
33
+
34
+ export async function updateStepStatus(feature, step, value, root = process.cwd()) {
35
+ const status = await loadStatus(feature, root);
36
+
37
+ status.steps[step] = value;
38
+
39
+ const stepIndex = FEATURE_STEPS.indexOf(step);
40
+ const nextStep = FEATURE_STEPS[stepIndex + 1] ?? null;
41
+ if (value === STEP_STATUS.DONE && nextStep) {
42
+ status.current_step = nextStep;
43
+ }
44
+
45
+ const content = yaml.stringify(status);
46
+ await fs.writeFile(statusPath(feature, root), content, 'utf-8');
47
+ }
48
+
49
+ export async function resetStep(feature, step, root = process.cwd()) {
50
+ if (!FEATURE_STEPS.includes(step)) {
51
+ throw new Error(`Unknown step "${step}". Valid steps: ${FEATURE_STEPS.join(', ')}`);
52
+ }
53
+
54
+ const status = await loadStatus(feature, root);
55
+
56
+ status.steps[step] = STEP_STATUS.PENDING;
57
+
58
+ const firstPending = FEATURE_STEPS.find((s) => status.steps[s] !== STEP_STATUS.DONE);
59
+ if (firstPending) {
60
+ status.current_step = firstPending;
61
+ }
62
+
63
+ const content = yaml.stringify(status);
64
+ await fs.writeFile(statusPath(feature, root), content, 'utf-8');
65
+
66
+ const outputPath = path.join(root, AIA_DIR, 'features', feature, `${step}.md`);
67
+ if (await fs.pathExists(outputPath)) {
68
+ await fs.writeFile(outputPath, '', 'utf-8');
69
+ }
70
+ }
package/src/utils.js ADDED
@@ -0,0 +1,8 @@
1
+ import fs from 'fs-extra';
2
+
3
+ export async function readIfExists(filePath) {
4
+ if (await fs.pathExists(filePath)) {
5
+ return (await fs.readFile(filePath, 'utf-8')).trim();
6
+ }
7
+ return '';
8
+ }