valent-pipeline 0.1.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/bin/cli.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { readFileSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
11
+
12
+ const program = new Command();
13
+
14
+ program
15
+ .name('valent-pipeline')
16
+ .description('v3 multi-agent AI pipeline for software development lifecycle')
17
+ .version(pkg.version);
18
+
19
+ // init command
20
+ program
21
+ .command('init')
22
+ .description('Initialize a new v3 pipeline in the current project')
23
+ .option('--yes', 'Use defaults for all prompts (non-interactive)')
24
+ .option('--force', 'Overwrite existing pipeline configuration')
25
+ .action(async (options) => {
26
+ const { init } = await import('../src/commands/init.js');
27
+ await init(options);
28
+ });
29
+
30
+ // upgrade command
31
+ program
32
+ .command('upgrade')
33
+ .description('Upgrade pipeline infrastructure files to the latest version')
34
+ .option('--dry-run', 'Show what would change without writing')
35
+ .option('--force', 'Force upgrade even on major version bumps')
36
+ .action(async (options) => {
37
+ const { upgrade } = await import('../src/commands/upgrade.js');
38
+ await upgrade(options);
39
+ });
40
+
41
+ // config validate command
42
+ const configCmd = program
43
+ .command('config')
44
+ .description('Pipeline configuration commands');
45
+
46
+ configCmd
47
+ .command('validate')
48
+ .description('Validate pipeline-config.yaml against the schema')
49
+ .action(async () => {
50
+ const { validate } = await import('../src/commands/validate.js');
51
+ await validate();
52
+ });
53
+
54
+ // db commands
55
+ const dbCmd = program
56
+ .command('db')
57
+ .description('Knowledge database commands');
58
+
59
+ dbCmd
60
+ .command('init')
61
+ .description('Initialize the SQLite knowledge database')
62
+ .action(async () => {
63
+ const { dbInit } = await import('../src/commands/db-init.js');
64
+ await dbInit();
65
+ });
66
+
67
+ dbCmd
68
+ .command('rebuild')
69
+ .description('Rebuild the knowledge database from story artifacts')
70
+ .action(async () => {
71
+ const { dbRebuild } = await import('../src/commands/db-rebuild.js');
72
+ await dbRebuild();
73
+ });
74
+
75
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "valent-pipeline",
3
+ "version": "0.1.0",
4
+ "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
+ "type": "module",
6
+ "bin": {
7
+ "valent-pipeline": "./bin/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "pipeline/",
16
+ "skills/"
17
+ ],
18
+ "scripts": {
19
+ "test": "node scripts/test-local.js",
20
+ "prepublishOnly": "node scripts/test-local.js"
21
+ },
22
+ "dependencies": {
23
+ "commander": "^12.0.0",
24
+ "inquirer": "^9.0.0",
25
+ "better-sqlite3": "^11.0.0",
26
+ "sqlite-vec": "^0.1.0"
27
+ },
28
+ "keywords": [
29
+ "ai",
30
+ "pipeline",
31
+ "multi-agent",
32
+ "sdlc",
33
+ "claude"
34
+ ],
35
+ "license": "MIT"
36
+ }
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: v3-debug-export
3
+ description: 'Export pipeline diagnostic context for debugging in another session. Use when the user says "debug export", "export context", or "v3 debug"'
4
+ ---
5
+
6
+ # v3 Debug Export
7
+
8
+ Generate a concise diagnostic dump of the current pipeline state. The user will paste this into another Claude session for debugging assistance.
9
+
10
+ ## Output Format
11
+
12
+ Print the following to the conversation as a single fenced code block (```yaml). Do NOT write to a file.
13
+
14
+ ```yaml
15
+ v3_debug_export:
16
+ timestamp: {ISO-8601}
17
+ cwd: {current working directory}
18
+
19
+ install:
20
+ valent_version: {contents of v3/.valent-version, or "MISSING"}
21
+ config_exists: {true/false}
22
+ manifest_exists: {true/false for v3/agents-manifest.yaml}
23
+ skills_installed: {list which v3-* skills exist under .claude/skills/}
24
+ knowledge_mode: {from pipeline-config.yaml, or "NO CONFIG"}
25
+ db_exists: {true/false for v3/pipeline.db}
26
+ project_type: {from pipeline-config.yaml}
27
+
28
+ files:
29
+ prompts: {count of .md files in v3/prompts/}
30
+ step_dirs: {list of directories in v3/steps/}
31
+ step_files: {total count of .md files across v3/steps/}
32
+ task_graphs: {list of .yaml files in v3/task-graphs/}
33
+ templates: {count of .md files in v3/templates/}
34
+ spawn_templates: {count of .md files in v3/spawn-templates/}
35
+
36
+ config_summary:
37
+ tech_stack: {language, backend_framework, frontend_framework from config}
38
+ models: {opus, sonnet, haiku agent lists from config}
39
+ quality: {max_rejection_cycles, retrospective_every_n_stories}
40
+ knowledge: {mode, sqlite_db_path or chromadb_host}
41
+
42
+ backlog:
43
+ exists: {true/false}
44
+ total_items: {count}
45
+ pending: {count}
46
+ shipped: {count}
47
+
48
+ pipeline_state:
49
+ exists: {true/false for pipeline-state.json}
50
+ current_story: {id and status if exists, or "none"}
51
+ stories_since_retro: {count}
52
+
53
+ git:
54
+ branch: {current branch}
55
+ clean: {true/false}
56
+ recent_commits: {last 3 one-line commits}
57
+
58
+ errors: {list any missing expected files, parse errors, or anomalies found during export}
59
+ ```
60
+
61
+ ## How to Generate
62
+
63
+ 1. Read `v3/.valent-version`
64
+ 2. Read `v3/pipeline-config.yaml` — parse key fields
65
+ 3. Count/list files in v3/prompts/, v3/steps/, v3/task-graphs/, v3/templates/, v3/spawn-templates/
66
+ 4. Read `pipeline-backlog.yaml` — count items by status
67
+ 5. Read `pipeline-state.json` — extract current story
68
+ 6. Check `.claude/skills/` for v3-* directories
69
+ 7. Check if `v3/pipeline.db` exists
70
+ 8. Run `git branch --show-current` and `git status --short` and `git log --oneline -3`
71
+ 9. Collect any missing files or parse errors into the `errors` list
72
+
73
+ ## Rules
74
+
75
+ - Output ONLY the YAML block. No preamble, no explanation, no suggestions.
76
+ - Keep it under 80 lines. This is a paste target, not a report.
77
+ - If a file is missing, note it in `errors` — don't fail.
78
+ - If config can't be parsed, set fields to "PARSE_ERROR" and add to errors.
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: v3-help
3
+ description: 'Answer questions about the v3 pipeline — how it works, how to use it, agent roles, configuration, troubleshooting. Use when the user says "v3 help", "how does the pipeline work", "explain v3", or asks any question about the pipeline.'
4
+ ---
5
+
6
+ # v3 Pipeline Help
7
+
8
+ You are a helpful guide for the v3 multi-agent pipeline. Answer the user's question by reading the pipeline documentation and configuration.
9
+
10
+ ## How to Answer
11
+
12
+ 1. Read the user's question
13
+ 2. Search the relevant docs and config files below for the answer
14
+ 3. Give a clear, concise answer with file references so they can dig deeper
15
+ 4. If the question is about their specific setup, read their `v3/pipeline-config.yaml`
16
+
17
+ ## Documentation Sources
18
+
19
+ Read these as needed to answer questions:
20
+
21
+ | Topic | File |
22
+ |---|---|
23
+ | Pipeline overview, agent roles, execution flow | `v3/docs/pipeline-overview.md` |
24
+ | Agent reference (all agents, models, inputs/outputs) | `v3/docs/agent-reference.md` |
25
+ | Lead lifecycle (kick-off, monitoring, ship/teardown) | `v3/docs/lead-lifecycle.md` |
26
+ | Task graphs and dependency flow | `v3/docs/task-graph.md` |
27
+ | Communication protocol (inbox, message types) | `v3/docs/communication-standard.md` |
28
+ | Knowledge system (curated files, SQLite, ChromaDB) | `v3/docs/knowledge-system.md` |
29
+ | Pipeline state and crash recovery | `v3/docs/pipeline-state-schema.md` |
30
+ | NPX package and installation | `v3/docs/npx-packaging.md` |
31
+ | Template skeleton for handoff docs | `v3/docs/template-skeleton.md` |
32
+ | Agent manifest (definitions, model tiers) | `v3/agents-manifest.yaml` |
33
+ | Project configuration | `v3/pipeline-config.yaml` |
34
+
35
+ ## Common Questions — Quick Answers
36
+
37
+ **"How do I run a story?"**
38
+ → `/v3-run-story STORY-ID` or just `/v3-run-story` to auto-pick the next from the backlog.
39
+
40
+ **"How do I run an epic?"**
41
+ → `/v3-run-epic EPIC-ID` — runs all stories tagged with that epic sequentially.
42
+
43
+ **"How do I configure the pipeline?"**
44
+ → `/v3-configure` for interactive wizard, or edit `v3/pipeline-config.yaml` directly.
45
+
46
+ **"How do I add a story to the backlog?"**
47
+ → Add an entry to `pipeline-backlog.yaml` with id, type: story, status: pending, priority, and epic field.
48
+
49
+ **"What agents run and in what order?"**
50
+ → REQS → UXA → QA-A → JUDGE-G1 Pass 1 → BEND + FEND (parallel) → CRITIC → QA-B → JUDGE-G1 Pass 2 → JUDGE-G2. Agents are filtered by project type and testing profiles. See `v3/docs/agent-reference.md`.
51
+
52
+ **"How do agents communicate?"**
53
+ → Peer-to-peer via inbox. Terse messages: `[HANDOFF]`, `[BLOCKER]`, `[KNOWLEDGE-QUERY]`, etc. Lead monitors but doesn't relay. See `v3/docs/communication-standard.md`.
54
+
55
+ **"What does each agent do?"**
56
+ → Read `v3/docs/agent-reference.md` for the full roster, or check individual prompts in `v3/prompts/`.
57
+
58
+ **"How does the knowledge system work?"**
59
+ → Knowledge agent answers `[KNOWLEDGE-QUERY]` messages by searching correction directives, curated files, and the SQLite database (if configured). See `v3/docs/knowledge-system.md`.
60
+
61
+ **"How do I change which model an agent uses?"**
62
+ → Edit the `models` section in `v3/pipeline-config.yaml`. Agents are assigned to opus/sonnet/haiku tiers.
63
+
64
+ **"What happens when an agent gets rejected?"**
65
+ → Peer-to-peer: JUDGE-G1 rejects specs back to authors, CRITIC rejects code to devs, QA-B routes bugs to devs. Lead only handles G2 rejections and circuit breaker (after max_rejection_cycles). See `v3/docs/lead-lifecycle.md`.
66
+
67
+ **"How do correction directives work?"**
68
+ → The Retrospective Agent (every N stories) analyzes patterns and writes directives to `knowledge/correction-directives.yaml`. These are loaded at the next story's kick-off and passed to all agents. See `v3/docs/knowledge-system.md`.
69
+
70
+ **"How do I upgrade the pipeline?"**
71
+ → `npx valent-pipeline upgrade` — replaces infrastructure files, never touches your config or knowledge files.
72
+
73
+ **"How do I initialize the SQLite database?"**
74
+ → `npx valent-pipeline db init` to create it, `npx valent-pipeline db rebuild` to re-index from existing story artifacts.
75
+
76
+ ## Answer Format
77
+
78
+ Keep answers short and actionable. Reference specific files so the user can read more. Example:
79
+
80
+ > Stories execute in this order: REQS → UXA → QA-A → JUDGE-G1 → BEND/FEND → CRITIC → QA-B → JUDGE-G2. Agents spawn in 4 waves to avoid idle token waste. Your project type (`fullstack-web`) runs all agents. See `v3/docs/agent-reference.md` for details on each agent's role.
81
+
82
+ If you don't know the answer, say so and point them to the most relevant doc file to search manually.
@@ -0,0 +1,107 @@
1
+ import { join } from 'path';
2
+ import { existsSync, readFileSync } from 'fs';
3
+
4
+ export async function dbInit() {
5
+ const projectRoot = process.cwd();
6
+ const configPath = join(projectRoot, 'v3', 'pipeline-config.yaml');
7
+
8
+ if (!existsSync(configPath)) {
9
+ console.error('Error: v3/pipeline-config.yaml not found. Run "valent-pipeline init" first.');
10
+ process.exit(1);
11
+ }
12
+
13
+ // Read db path from config (simple extraction)
14
+ const content = readFileSync(configPath, 'utf-8');
15
+ const dbPathMatch = content.match(/sqlite_db_path:\s*"?([^"\n]+)"?/);
16
+ const dbPath = dbPathMatch ? dbPathMatch[1].trim() : './v3/pipeline.db';
17
+ const fullDbPath = join(projectRoot, dbPath);
18
+
19
+ let Database;
20
+ let sqliteVec;
21
+ try {
22
+ Database = (await import('better-sqlite3')).default;
23
+ sqliteVec = await import('sqlite-vec');
24
+ } catch (err) {
25
+ console.error('Error: better-sqlite3 or sqlite-vec not installed.');
26
+ console.error('Run: npm install better-sqlite3 sqlite-vec');
27
+ process.exit(1);
28
+ }
29
+
30
+ const db = new Database(fullDbPath);
31
+
32
+ // Load sqlite-vec extension
33
+ try {
34
+ sqliteVec.load(db);
35
+ console.log('Loaded sqlite-vec extension');
36
+ } catch (err) {
37
+ console.warn(`Warning: Could not load sqlite-vec: ${err.message}`);
38
+ console.warn('Vector search will not be available. FTS5 will still work.');
39
+ }
40
+
41
+ // Create tables
42
+ db.exec(`
43
+ CREATE TABLE IF NOT EXISTS artifacts (
44
+ id TEXT PRIMARY KEY,
45
+ story_id TEXT NOT NULL,
46
+ agent TEXT NOT NULL,
47
+ artifact_type TEXT NOT NULL,
48
+ content TEXT NOT NULL,
49
+ metadata TEXT,
50
+ created_at TEXT DEFAULT (datetime('now')),
51
+ UNIQUE(story_id, artifact_type)
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS correction_directives (
55
+ id TEXT PRIMARY KEY,
56
+ target_agent TEXT NOT NULL,
57
+ directive TEXT NOT NULL,
58
+ reason TEXT,
59
+ status TEXT DEFAULT 'active',
60
+ created_batch INTEGER,
61
+ metadata TEXT
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_artifacts_story ON artifacts(story_id);
65
+ CREATE INDEX IF NOT EXISTS idx_artifacts_type ON artifacts(artifact_type);
66
+ CREATE INDEX IF NOT EXISTS idx_artifacts_agent ON artifacts(agent);
67
+ CREATE INDEX IF NOT EXISTS idx_cd_target ON correction_directives(target_agent);
68
+ CREATE INDEX IF NOT EXISTS idx_cd_status ON correction_directives(status);
69
+ `);
70
+
71
+ // Create FTS5 virtual table
72
+ db.exec(`
73
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifacts_fts USING fts5(
74
+ content,
75
+ story_id UNINDEXED,
76
+ agent UNINDEXED,
77
+ artifact_type UNINDEXED,
78
+ content=artifacts,
79
+ content_rowid=rowid
80
+ );
81
+ `);
82
+
83
+ // Create FTS triggers for auto-sync
84
+ db.exec(`
85
+ CREATE TRIGGER IF NOT EXISTS artifacts_ai AFTER INSERT ON artifacts BEGIN
86
+ INSERT INTO artifacts_fts(rowid, content, story_id, agent, artifact_type)
87
+ VALUES (new.rowid, new.content, new.story_id, new.agent, new.artifact_type);
88
+ END;
89
+
90
+ CREATE TRIGGER IF NOT EXISTS artifacts_ad AFTER DELETE ON artifacts BEGIN
91
+ INSERT INTO artifacts_fts(artifacts_fts, rowid, content, story_id, agent, artifact_type)
92
+ VALUES ('delete', old.rowid, old.content, old.story_id, old.agent, old.artifact_type);
93
+ END;
94
+
95
+ CREATE TRIGGER IF NOT EXISTS artifacts_au AFTER UPDATE ON artifacts BEGIN
96
+ INSERT INTO artifacts_fts(artifacts_fts, rowid, content, story_id, agent, artifact_type)
97
+ VALUES ('delete', old.rowid, old.content, old.story_id, old.agent, old.artifact_type);
98
+ INSERT INTO artifacts_fts(rowid, content, story_id, agent, artifact_type)
99
+ VALUES (new.rowid, new.content, new.story_id, new.agent, new.artifact_type);
100
+ END;
101
+ `);
102
+
103
+ console.log(`Database initialized at ${fullDbPath}`);
104
+ console.log('Tables: artifacts, artifacts_fts, correction_directives');
105
+
106
+ db.close();
107
+ }
@@ -0,0 +1,124 @@
1
+ import { join } from 'path';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
3
+
4
+ // Artifact files we index (filename -> artifact_type mapping)
5
+ const ARTIFACT_MAP = {
6
+ 'reqs-brief.md': { type: 'reqs-brief', agent: 'REQS' },
7
+ 'uxa-spec.md': { type: 'uxa-spec', agent: 'UXA' },
8
+ 'qa-test-spec.md': { type: 'qa-test-spec', agent: 'QA-A' },
9
+ 'bend-handoff.md': { type: 'bend-handoff', agent: 'BEND' },
10
+ 'fend-handoff.md': { type: 'fend-handoff', agent: 'FEND' },
11
+ 'critic-review.md': { type: 'critic-review', agent: 'CRITIC' },
12
+ 'execution-report.md': { type: 'execution-report', agent: 'QA-B' },
13
+ 'bugs.md': { type: 'bugs', agent: 'QA-B' },
14
+ 'traceability-matrix.md': { type: 'traceability-matrix', agent: 'QA-B' },
15
+ 'judge-g1-review.md': { type: 'judge-g1-review', agent: 'JUDGE-G1' },
16
+ 'judge-g2-decision.md': { type: 'judge-g2-decision', agent: 'JUDGE-G2' },
17
+ 'story-report.md': { type: 'story-report', agent: 'JUDGE-G2' },
18
+ 'pmcp-evidence.md': { type: 'pmcp-evidence', agent: 'PMCP' },
19
+ 'visual-validation-checklist.md': { type: 'visual-validation-checklist', agent: 'QA-A' },
20
+ };
21
+
22
+ export async function dbRebuild() {
23
+ const projectRoot = process.cwd();
24
+ const configPath = join(projectRoot, 'v3', 'pipeline-config.yaml');
25
+
26
+ if (!existsSync(configPath)) {
27
+ console.error('Error: v3/pipeline-config.yaml not found. Run "valent-pipeline init" first.');
28
+ process.exit(1);
29
+ }
30
+
31
+ // Read db path from config
32
+ const content = readFileSync(configPath, 'utf-8');
33
+ const dbPathMatch = content.match(/sqlite_db_path:\s*"?([^"\n]+)"?/);
34
+ const dbPath = dbPathMatch ? dbPathMatch[1].trim() : './v3/pipeline.db';
35
+ const fullDbPath = join(projectRoot, dbPath);
36
+
37
+ if (!existsSync(fullDbPath)) {
38
+ console.error('Error: Database not found. Run "valent-pipeline db init" first.');
39
+ process.exit(1);
40
+ }
41
+
42
+ let Database;
43
+ try {
44
+ Database = (await import('better-sqlite3')).default;
45
+ } catch (err) {
46
+ console.error('Error: better-sqlite3 not installed. Run: npm install better-sqlite3');
47
+ process.exit(1);
48
+ }
49
+
50
+ const db = new Database(fullDbPath);
51
+
52
+ // Find stories directory
53
+ const storyDirMatch = content.match(/story_directory:\s*"?([^"\n]+)"?/);
54
+ const storyDir = storyDirMatch ? storyDirMatch[1].trim() : './stories';
55
+ const fullStoryDir = join(projectRoot, storyDir);
56
+
57
+ if (!existsSync(fullStoryDir)) {
58
+ console.log('No stories directory found. Nothing to rebuild.');
59
+ db.close();
60
+ return;
61
+ }
62
+
63
+ // Scan for story directories
64
+ const storyDirs = readdirSync(fullStoryDir).filter(name => {
65
+ const storyPath = join(fullStoryDir, name);
66
+ return statSync(storyPath).isDirectory();
67
+ });
68
+
69
+ console.log(`Found ${storyDirs.length} story directories`);
70
+
71
+ // Prepare insert statement
72
+ const insert = db.prepare(`
73
+ INSERT OR REPLACE INTO artifacts (id, story_id, agent, artifact_type, content, metadata)
74
+ VALUES (?, ?, ?, ?, ?, ?)
75
+ `);
76
+
77
+ let indexedCount = 0;
78
+
79
+ const insertMany = db.transaction((items) => {
80
+ for (const item of items) {
81
+ insert.run(item.id, item.storyId, item.agent, item.type, item.content, item.metadata);
82
+ indexedCount++;
83
+ }
84
+ });
85
+
86
+ const allItems = [];
87
+
88
+ for (const storyName of storyDirs) {
89
+ const outputDir = join(fullStoryDir, storyName, 'output');
90
+ if (!existsSync(outputDir)) continue;
91
+
92
+ const storyId = storyName.toUpperCase();
93
+
94
+ for (const [filename, info] of Object.entries(ARTIFACT_MAP)) {
95
+ const filePath = join(outputDir, filename);
96
+ if (!existsSync(filePath)) continue;
97
+
98
+ const fileContent = readFileSync(filePath, 'utf-8');
99
+ // Skip pass-through/skipped files
100
+ if (fileContent.includes('status: skipped')) continue;
101
+
102
+ allItems.push({
103
+ id: `${storyId}:${info.type}`,
104
+ storyId,
105
+ agent: info.agent,
106
+ type: info.type,
107
+ content: fileContent,
108
+ metadata: JSON.stringify({ source: 'rebuild', file: filePath }),
109
+ });
110
+ }
111
+ }
112
+
113
+ insertMany(allItems);
114
+ console.log(`Indexed ${indexedCount} artifacts from ${storyDirs.length} stories`);
115
+
116
+ // Rebuild correction directives
117
+ const cdPath = join(projectRoot, 'knowledge', 'correction-directives.yaml');
118
+ if (existsSync(cdPath)) {
119
+ console.log('Note: Correction directives should be re-indexed separately via the Retrospective Agent.');
120
+ }
121
+
122
+ db.close();
123
+ console.log('Rebuild complete.');
124
+ }
@@ -0,0 +1,227 @@
1
+ import { fileURLToPath } from 'url';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { defaults } from '../lib/config-schema.js';
5
+ import { copyDir, writeFileSafe, appendToGitignore, fileExists } from '../lib/file-ops.js';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+ const PACKAGE_ROOT = resolve(__dirname, '..', '..');
10
+
11
+ export async function init(options = {}) {
12
+ const projectRoot = process.cwd();
13
+ const configPath = join(projectRoot, 'v3', 'pipeline-config.yaml');
14
+
15
+ // Guard: warn if config already exists
16
+ if (fileExists(configPath) && !options.force) {
17
+ console.error('Error: v3/pipeline-config.yaml already exists.');
18
+ console.error('Use --force to overwrite, or run "valent-pipeline config validate" to check the existing config.');
19
+ process.exit(1);
20
+ }
21
+
22
+ let config;
23
+ if (options.yes) {
24
+ config = { ...defaults };
25
+ console.log('Using default configuration (--yes flag).');
26
+ } else {
27
+ config = await runWizard();
28
+ }
29
+
30
+ console.log('\nInitializing v3 pipeline...\n');
31
+
32
+ // 1. Copy pipeline infrastructure
33
+ const pipelineSrc = join(PACKAGE_ROOT, 'pipeline');
34
+ const pipelineDest = join(projectRoot, 'v3');
35
+ if (existsSync(pipelineSrc)) {
36
+ copyDir(pipelineSrc, pipelineDest);
37
+ console.log(' Copied pipeline infrastructure to v3/');
38
+ } else {
39
+ console.warn(' Warning: pipeline/ directory not found in package. Skipping infrastructure copy.');
40
+ }
41
+
42
+ // 2. Generate pipeline-config.yaml
43
+ const configContent = generateConfigYaml(config);
44
+ writeFileSafe(configPath, configContent);
45
+ console.log(' Generated v3/pipeline-config.yaml');
46
+
47
+ // 3. Initialize backlog
48
+ const backlogPath = join(projectRoot, config.project?.backlog_path || 'pipeline-backlog.yaml');
49
+ if (!fileExists(backlogPath)) {
50
+ writeFileSafe(backlogPath, `# Pipeline Backlog\n# Add stories and bugs here. See v3/docs/pipeline-overview.md for format.\n\nitems: []\n`);
51
+ console.log(' Created pipeline-backlog.yaml');
52
+ }
53
+
54
+ // 4. Install skills
55
+ const skillsSrc = join(PACKAGE_ROOT, 'skills');
56
+ const skillsDest = join(projectRoot, '.claude', 'skills');
57
+ if (existsSync(skillsSrc)) {
58
+ copyDir(skillsSrc, skillsDest);
59
+ console.log(' Installed skills to .claude/skills/');
60
+ }
61
+
62
+ // 5. Create knowledge directories
63
+ const curatedPath = join(projectRoot, 'knowledge', 'curated');
64
+ if (!existsSync(curatedPath)) {
65
+ writeFileSafe(join(curatedPath, 'README.md'),
66
+ '# Curated Knowledge Files\n\nAdd project-specific knowledge files here. These are indexed by the Knowledge Agent at story startup.\n\nExamples:\n- `api-reference.md` — API endpoint specifications\n- `code-conventions.md` — language and framework conventions\n- `project-context.md` — project identity and architecture\n');
67
+ console.log(' Created knowledge/curated/ with README');
68
+ }
69
+
70
+ // 6. Create correction directives
71
+ const cdPath = join(projectRoot, 'knowledge', 'correction-directives.yaml');
72
+ if (!fileExists(cdPath)) {
73
+ writeFileSafe(cdPath, '# Correction Directives\n# Populated by the Retrospective Agent after batch analysis.\n\ndirectives: []\n');
74
+ console.log(' Created knowledge/correction-directives.yaml');
75
+ }
76
+
77
+ // 7. Initialize SQLite DB if sqlite mode
78
+ if (config.knowledge?.mode === 'sqlite') {
79
+ console.log(' SQLite knowledge mode selected. Run "valent-pipeline db init" to create the database.');
80
+ }
81
+
82
+ // 8. Update .gitignore
83
+ appendToGitignore(projectRoot, 'v3/pipeline.db');
84
+ appendToGitignore(projectRoot, 'v3/pipeline.db-*');
85
+ appendToGitignore(projectRoot, 'v3/chromadb-data/');
86
+ console.log(' Updated .gitignore');
87
+
88
+ // 9. Write version file
89
+ const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
90
+ writeFileSafe(join(projectRoot, 'v3', '.valent-version'), pkg.version);
91
+ console.log(` Wrote v3/.valent-version (${pkg.version})`);
92
+
93
+ console.log('\nPipeline initialized. Run /v3-run-story to execute your first story.\n');
94
+ }
95
+
96
+ async function runWizard() {
97
+ const inquirer = (await import('inquirer')).default;
98
+ const config = JSON.parse(JSON.stringify(defaults));
99
+
100
+ console.log('\n valent-pipeline init\n');
101
+
102
+ // Project type
103
+ const { projectType } = await inquirer.prompt([{
104
+ type: 'list',
105
+ name: 'projectType',
106
+ message: 'Project type:',
107
+ choices: [
108
+ 'fullstack-web', 'backend-api', 'frontend-only',
109
+ 'data-pipeline', 'mcp-server', 'document-generation', 'library'
110
+ ],
111
+ default: 'fullstack-web',
112
+ }]);
113
+ config.project.type = projectType;
114
+
115
+ // Tech stack
116
+ const { language } = await inquirer.prompt([{
117
+ type: 'input', name: 'language', message: 'Language:', default: 'TypeScript',
118
+ }]);
119
+ config.tech_stack.language = language;
120
+
121
+ const { backendFramework } = await inquirer.prompt([{
122
+ type: 'input', name: 'backendFramework', message: 'Backend framework:', default: 'Express',
123
+ }]);
124
+ config.tech_stack.backend_framework = backendFramework;
125
+
126
+ if (!['backend-api', 'data-pipeline', 'mcp-server', 'library'].includes(projectType)) {
127
+ const { frontendFramework } = await inquirer.prompt([{
128
+ type: 'input', name: 'frontendFramework', message: 'Frontend framework:', default: 'React',
129
+ }]);
130
+ config.tech_stack.frontend_framework = frontendFramework;
131
+ } else {
132
+ config.tech_stack.frontend_framework = 'none';
133
+ }
134
+
135
+ const { databaseOrm } = await inquirer.prompt([{
136
+ type: 'input', name: 'databaseOrm', message: 'Database/ORM:', default: 'Prisma',
137
+ }]);
138
+ config.tech_stack.database_orm = databaseOrm;
139
+
140
+ const { testUnit } = await inquirer.prompt([{
141
+ type: 'input', name: 'testUnit', message: 'Unit test framework:', default: 'Vitest',
142
+ }]);
143
+ config.tech_stack.test_framework_unit = testUnit;
144
+
145
+ const { testE2e } = await inquirer.prompt([{
146
+ type: 'input', name: 'testE2e', message: 'E2E test framework:', default: 'Playwright',
147
+ }]);
148
+ config.tech_stack.test_framework_e2e = testE2e;
149
+
150
+ // Knowledge mode
151
+ const { knowledgeMode } = await inquirer.prompt([{
152
+ type: 'list',
153
+ name: 'knowledgeMode',
154
+ message: 'Knowledge store:',
155
+ choices: [
156
+ { name: 'SQLite (recommended — local, zero infrastructure)', value: 'sqlite' },
157
+ { name: 'None (curated files only)', value: 'none' },
158
+ { name: 'ChromaDB (local Docker)', value: 'local-docker' },
159
+ { name: 'ChromaDB (connect to existing)', value: 'connect-to-existing' },
160
+ ],
161
+ default: 'sqlite',
162
+ }]);
163
+ config.knowledge.mode = knowledgeMode;
164
+
165
+ if (knowledgeMode === 'local-docker' || knowledgeMode === 'connect-to-existing') {
166
+ const { chromadbHost } = await inquirer.prompt([{
167
+ type: 'input', name: 'chromadbHost', message: 'ChromaDB host:', default: 'http://localhost:8000',
168
+ }]);
169
+ config.knowledge.chromadb_host = chromadbHost;
170
+ }
171
+
172
+ return config;
173
+ }
174
+
175
+ function generateConfigYaml(config) {
176
+ return `# v3 Pipeline Configuration
177
+ # Generated by: npx valent-pipeline init
178
+ # To regenerate with the interactive wizard: npx valent-pipeline init --force
179
+ # To validate this file: npx valent-pipeline config validate
180
+
181
+ project:
182
+ type: "${config.project.type}"
183
+ root: "${config.project.root}"
184
+ story_directory: "${config.project.story_directory}"
185
+ story_output_directory: "${config.project.story_output_directory}"
186
+ backlog_path: "${config.project.backlog_path}"
187
+
188
+ tech_stack:
189
+ language: "${config.tech_stack.language}"
190
+ backend_framework: "${config.tech_stack.backend_framework}"
191
+ frontend_framework: "${config.tech_stack.frontend_framework}"
192
+ database_orm: "${config.tech_stack.database_orm}"
193
+ test_framework_unit: "${config.tech_stack.test_framework_unit}"
194
+ test_framework_e2e: "${config.tech_stack.test_framework_e2e}"
195
+ browser_automation_mcp: "${config.tech_stack.browser_automation_mcp || 'playwright-mcp'}"
196
+ state_management: "${config.tech_stack.state_management || 'React Context'}"
197
+
198
+ models:
199
+ opus: [${(config.models.opus || defaults.models.opus).map(a => `"${a}"`).join(', ')}]
200
+ sonnet: [${(config.models.sonnet || defaults.models.sonnet).map(a => `"${a}"`).join(', ')}]
201
+ haiku: [${(config.models.haiku || defaults.models.haiku).map(a => `"${a}"`).join(', ')}]
202
+
203
+ quality:
204
+ max_rejection_cycles: ${config.quality.max_rejection_cycles}
205
+ retrospective_every_n_stories: ${config.quality.retrospective_every_n_stories}
206
+ stall_threshold_minutes: ${config.quality.stall_threshold_minutes}
207
+
208
+ git:
209
+ target_branch: "${config.git.target_branch}"
210
+ story_branch_prefix: "${config.git.story_branch_prefix}"
211
+
212
+ communication:
213
+ handoff_format: "${config.communication.handoff_format}"
214
+ inbox_message_max_tokens: ${config.communication.inbox_message_max_tokens}
215
+
216
+ knowledge:
217
+ mode: "${config.knowledge.mode}"
218
+ ${config.knowledge.mode === 'sqlite' ? ` sqlite_db_path: "${config.knowledge.sqlite_db_path}"` : ''}${config.knowledge.chromadb_host ? ` chromadb_host: "${config.knowledge.chromadb_host}"
219
+ chromadb_collection_prefix: "${config.knowledge.chromadb_collection_prefix || 'v3-'}"` : ''}
220
+ curated_files_path: "${config.knowledge.curated_files_path}"
221
+ correction_directives_path: "${config.knowledge.correction_directives_path}"
222
+
223
+ orchestration:
224
+ recommended_context_window: "${config.orchestration?.recommended_context_window || '200k'}"
225
+ epic_progress_path: "${config.orchestration?.epic_progress_path || './epic-progress.md'}"
226
+ `;
227
+ }
@@ -0,0 +1,129 @@
1
+ import { fileURLToPath } from 'url';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, copyFileSync } from 'fs';
4
+ import { copyDir, writeFileSafe, fileExists, readFile } from '../lib/file-ops.js';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ const PACKAGE_ROOT = resolve(__dirname, '..', '..');
9
+
10
+ // Files that are NEVER overwritten during upgrade (project-specific)
11
+ const PROTECTED_FILES = [
12
+ 'pipeline-config.yaml',
13
+ 'knowledge/curated',
14
+ 'knowledge/correction-directives.yaml',
15
+ ];
16
+
17
+ export async function upgrade(options = {}) {
18
+ const projectRoot = process.cwd();
19
+ const versionFile = join(projectRoot, 'v3', '.valent-version');
20
+
21
+ if (!fileExists(versionFile)) {
22
+ console.error('Error: v3/.valent-version not found. Run "valent-pipeline init" first.');
23
+ process.exit(1);
24
+ }
25
+
26
+ const installedVersion = readFile(versionFile).trim();
27
+ const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
28
+ const packageVersion = pkg.version;
29
+
30
+ console.log(`Installed version: ${installedVersion}`);
31
+ console.log(`Package version: ${packageVersion}`);
32
+
33
+ if (installedVersion === packageVersion) {
34
+ console.log('\nAlready up to date.');
35
+ return;
36
+ }
37
+
38
+ // Check for major version bump
39
+ const installedMajor = parseInt(installedVersion.split('.')[0], 10);
40
+ const packageMajor = parseInt(packageVersion.split('.')[0], 10);
41
+ if (packageMajor > installedMajor && !options.force) {
42
+ console.error(`\nMajor version upgrade (${installedMajor} -> ${packageMajor}) requires --force flag.`);
43
+ console.error('Review migration notes at https://github.com/valent/valent-pipeline/releases');
44
+ process.exit(1);
45
+ }
46
+
47
+ const pipelineSrc = join(PACKAGE_ROOT, 'pipeline');
48
+ const pipelineDest = join(projectRoot, 'v3');
49
+ const skillsSrc = join(PACKAGE_ROOT, 'skills');
50
+ const skillsDest = join(projectRoot, '.claude', 'skills');
51
+
52
+ if (options.dryRun) {
53
+ console.log('\n--- Dry run: files that would be updated ---\n');
54
+ if (existsSync(pipelineSrc)) {
55
+ listChanges(pipelineSrc, pipelineDest, 'v3/');
56
+ }
57
+ if (existsSync(skillsSrc)) {
58
+ listChanges(skillsSrc, skillsDest, '.claude/skills/');
59
+ }
60
+ console.log('\n--- End dry run ---');
61
+ return;
62
+ }
63
+
64
+ // Copy infrastructure files (skip protected)
65
+ if (existsSync(pipelineSrc)) {
66
+ copyDirFiltered(pipelineSrc, pipelineDest, PROTECTED_FILES);
67
+ console.log('\nUpdated pipeline infrastructure in v3/');
68
+ }
69
+
70
+ // Copy skills
71
+ if (existsSync(skillsSrc)) {
72
+ copyDir(skillsSrc, skillsDest);
73
+ console.log('Updated skills in .claude/skills/');
74
+ }
75
+
76
+ // Update version file
77
+ writeFileSafe(versionFile, packageVersion);
78
+ console.log(`Updated v3/.valent-version to ${packageVersion}`);
79
+
80
+ console.log('\nUpgrade complete.');
81
+ }
82
+
83
+ function copyDirFiltered(src, dest, protectedPaths) {
84
+ const entries = readdirSync(src);
85
+ for (const entry of entries) {
86
+ const srcPath = join(src, entry);
87
+ const destPath = join(dest, entry);
88
+ const relativePath = entry;
89
+
90
+ // Skip protected files/dirs
91
+ if (protectedPaths.some(p => relativePath.startsWith(p))) {
92
+ continue;
93
+ }
94
+
95
+ const stat = statSync(srcPath);
96
+ if (stat.isDirectory()) {
97
+ copyDirFiltered(srcPath, destPath, protectedPaths.map(p => {
98
+ if (p.startsWith(entry + '/')) return p.slice(entry.length + 1);
99
+ return p;
100
+ }).filter(p => p.length > 0));
101
+ } else {
102
+ mkdirSync(dirname(destPath), { recursive: true });
103
+ copyFileSync(srcPath, destPath);
104
+ }
105
+ }
106
+ }
107
+
108
+ function listChanges(src, dest, prefix) {
109
+ const entries = readdirSync(src);
110
+ for (const entry of entries) {
111
+ const srcPath = join(src, entry);
112
+ const destPath = join(dest, entry);
113
+ const stat = statSync(srcPath);
114
+
115
+ if (stat.isDirectory()) {
116
+ listChanges(srcPath, destPath, prefix + entry + '/');
117
+ } else {
118
+ if (!existsSync(destPath)) {
119
+ console.log(` + ${prefix}${entry} (new)`);
120
+ } else {
121
+ const srcContent = readFileSync(srcPath, 'utf-8');
122
+ const destContent = readFileSync(destPath, 'utf-8');
123
+ if (srcContent !== destContent) {
124
+ console.log(` ~ ${prefix}${entry} (modified)`);
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,92 @@
1
+ import { join } from 'path';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { validateConfig } from '../lib/config-schema.js';
4
+
5
+ export async function validate() {
6
+ const projectRoot = process.cwd();
7
+ const configPath = join(projectRoot, 'v3', 'pipeline-config.yaml');
8
+
9
+ if (!existsSync(configPath)) {
10
+ console.error('Error: v3/pipeline-config.yaml not found.');
11
+ console.error('Run "valent-pipeline init" to create one.');
12
+ process.exit(1);
13
+ }
14
+
15
+ // Simple YAML parser (avoids adding yaml dependency)
16
+ const content = readFileSync(configPath, 'utf-8');
17
+ let config;
18
+ try {
19
+ config = parseSimpleYaml(content);
20
+ } catch (err) {
21
+ console.error(`Error parsing v3/pipeline-config.yaml: ${err.message}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const result = validateConfig(config);
26
+
27
+ if (result.valid) {
28
+ console.log('v3/pipeline-config.yaml is valid.');
29
+ process.exit(0);
30
+ } else {
31
+ console.error('Validation errors:');
32
+ for (const error of result.errors) {
33
+ console.error(` - ${error}`);
34
+ }
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Minimal YAML parser for pipeline-config.yaml.
41
+ * Handles the specific structure we generate (flat sections with string/number/array values).
42
+ * For complex YAML, users should install yaml package.
43
+ */
44
+ function parseSimpleYaml(content) {
45
+ const result = {};
46
+ let currentSection = null;
47
+
48
+ for (const line of content.split('\n')) {
49
+ // Skip comments and empty lines
50
+ if (line.trim().startsWith('#') || line.trim() === '') continue;
51
+
52
+ // Top-level section (no indentation)
53
+ const sectionMatch = line.match(/^(\w+):\s*$/);
54
+ if (sectionMatch) {
55
+ currentSection = sectionMatch[1];
56
+ result[currentSection] = {};
57
+ continue;
58
+ }
59
+
60
+ // Key-value pair (indented)
61
+ const kvMatch = line.match(/^\s+(\w+):\s+(.+)$/);
62
+ if (kvMatch && currentSection) {
63
+ const key = kvMatch[1];
64
+ let value = kvMatch[2].trim();
65
+
66
+ // Remove quotes
67
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
68
+ value = value.slice(1, -1);
69
+ }
70
+
71
+ // Parse arrays [a, b, c]
72
+ if (value.startsWith('[') && value.endsWith(']')) {
73
+ value = value.slice(1, -1).split(',').map(s => {
74
+ s = s.trim();
75
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
76
+ s = s.slice(1, -1);
77
+ }
78
+ return s;
79
+ }).filter(s => s.length > 0);
80
+ }
81
+
82
+ // Parse numbers
83
+ if (typeof value === 'string' && /^\d+$/.test(value)) {
84
+ value = parseInt(value, 10);
85
+ }
86
+
87
+ result[currentSection][key] = value;
88
+ }
89
+ }
90
+
91
+ return result;
92
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Validates a pipeline-config.yaml object against the expected schema.
3
+ * Returns { valid: boolean, errors: string[] }
4
+ */
5
+ export function validateConfig(config) {
6
+ const errors = [];
7
+
8
+ // Required top-level sections
9
+ const requiredSections = ['project', 'tech_stack', 'models', 'quality', 'git', 'communication', 'knowledge'];
10
+ for (const section of requiredSections) {
11
+ if (!config[section]) {
12
+ errors.push(`Missing required section: ${section}`);
13
+ }
14
+ }
15
+
16
+ if (errors.length > 0) {
17
+ return { valid: false, errors };
18
+ }
19
+
20
+ // Project section
21
+ const validProjectTypes = ['fullstack-web', 'backend-api', 'frontend-only', 'data-pipeline', 'mcp-server', 'document-generation', 'library'];
22
+ if (config.project?.type && !validProjectTypes.includes(config.project.type)) {
23
+ errors.push(`Invalid project.type: "${config.project.type}". Must be one of: ${validProjectTypes.join(', ')}`);
24
+ }
25
+ if (!config.project?.root) {
26
+ errors.push('Missing required field: project.root');
27
+ }
28
+
29
+ // Models section
30
+ const modelTiers = ['opus', 'sonnet', 'haiku'];
31
+ for (const tier of modelTiers) {
32
+ if (!config.models?.[tier]) {
33
+ errors.push(`Missing model tier: models.${tier}`);
34
+ }
35
+ }
36
+
37
+ // Quality section
38
+ const qualityFields = ['max_rejection_cycles', 'retrospective_every_n_stories', 'stall_threshold_minutes'];
39
+ for (const field of qualityFields) {
40
+ if (config.quality?.[field] !== undefined && typeof config.quality[field] !== 'number') {
41
+ errors.push(`quality.${field} must be a number, got: ${typeof config.quality[field]}`);
42
+ }
43
+ }
44
+
45
+ // Communication section
46
+ const validFormats = ['distilled', 'verbose'];
47
+ if (config.communication?.handoff_format && !validFormats.includes(config.communication.handoff_format)) {
48
+ errors.push(`Invalid communication.handoff_format: "${config.communication.handoff_format}". Must be: ${validFormats.join(' or ')}`);
49
+ }
50
+
51
+ // Knowledge section
52
+ const validModes = ['none', 'sqlite', 'local-docker', 'connect-to-existing'];
53
+ if (config.knowledge?.mode && !validModes.includes(config.knowledge.mode)) {
54
+ errors.push(`Invalid knowledge.mode: "${config.knowledge.mode}". Must be one of: ${validModes.join(', ')}`);
55
+ }
56
+
57
+ if (config.knowledge?.mode === 'sqlite' && !config.knowledge?.sqlite_db_path) {
58
+ errors.push('knowledge.sqlite_db_path is required when knowledge.mode is "sqlite"');
59
+ }
60
+
61
+ if ((config.knowledge?.mode === 'local-docker' || config.knowledge?.mode === 'connect-to-existing') && !config.knowledge?.chromadb_host) {
62
+ errors.push('knowledge.chromadb_host is required when knowledge.mode is "local-docker" or "connect-to-existing"');
63
+ }
64
+
65
+ return { valid: errors.length === 0, errors };
66
+ }
67
+
68
+ /**
69
+ * Default configuration values.
70
+ */
71
+ export const defaults = {
72
+ project: {
73
+ type: 'fullstack-web',
74
+ root: '.',
75
+ story_directory: './stories',
76
+ story_output_directory: './stories/{story_id}/output',
77
+ backlog_path: './pipeline-backlog.yaml',
78
+ },
79
+ tech_stack: {
80
+ language: 'TypeScript',
81
+ backend_framework: 'Express',
82
+ frontend_framework: 'React',
83
+ database_orm: 'Prisma',
84
+ test_framework_unit: 'Vitest',
85
+ test_framework_e2e: 'Playwright',
86
+ browser_automation_mcp: 'playwright-mcp',
87
+ state_management: 'React Context',
88
+ },
89
+ models: {
90
+ opus: ['BEND', 'FEND', 'CRITIC'],
91
+ sonnet: ['REQS', 'UXA', 'QA-A', 'QA-B', 'JUDGE-G1', 'JUDGE-G2', 'PMCP', 'Retrospective'],
92
+ haiku: ['Knowledge', 'Embed', 'Help'],
93
+ },
94
+ quality: {
95
+ max_rejection_cycles: 5,
96
+ retrospective_every_n_stories: 5,
97
+ stall_threshold_minutes: 15,
98
+ },
99
+ git: {
100
+ target_branch: '',
101
+ story_branch_prefix: 'story/',
102
+ },
103
+ communication: {
104
+ handoff_format: 'distilled',
105
+ inbox_message_max_tokens: 500,
106
+ },
107
+ knowledge: {
108
+ mode: 'sqlite',
109
+ sqlite_db_path: './v3/pipeline.db',
110
+ curated_files_path: './knowledge/curated',
111
+ correction_directives_path: './knowledge/correction-directives.yaml',
112
+ },
113
+ orchestration: {
114
+ recommended_context_window: '200k',
115
+ epic_progress_path: './epic-progress.md',
116
+ },
117
+ };
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, readdirSync, statSync, appendFileSync } from 'fs';
2
+ import { join, dirname, relative } from 'path';
3
+
4
+ /**
5
+ * Recursively copy a directory's contents to a destination.
6
+ * Creates directories as needed.
7
+ */
8
+ export function copyDir(src, dest) {
9
+ mkdirSync(dest, { recursive: true });
10
+ const entries = readdirSync(src);
11
+ for (const entry of entries) {
12
+ const srcPath = join(src, entry);
13
+ const destPath = join(dest, entry);
14
+ const stat = statSync(srcPath);
15
+ if (stat.isDirectory()) {
16
+ copyDir(srcPath, destPath);
17
+ } else {
18
+ copyFileSync(srcPath, destPath);
19
+ }
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Write a file, creating parent directories as needed.
25
+ */
26
+ export function writeFileSafe(filePath, content) {
27
+ mkdirSync(dirname(filePath), { recursive: true });
28
+ writeFileSync(filePath, content, 'utf-8');
29
+ }
30
+
31
+ /**
32
+ * Append a line to .gitignore if not already present.
33
+ */
34
+ export function appendToGitignore(projectRoot, line) {
35
+ const gitignorePath = join(projectRoot, '.gitignore');
36
+ let content = '';
37
+ if (existsSync(gitignorePath)) {
38
+ content = readFileSync(gitignorePath, 'utf-8');
39
+ }
40
+ if (!content.includes(line)) {
41
+ const separator = content.endsWith('\n') || content === '' ? '' : '\n';
42
+ appendFileSync(gitignorePath, `${separator}${line}\n`, 'utf-8');
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Check if a file exists.
48
+ */
49
+ export function fileExists(filePath) {
50
+ return existsSync(filePath);
51
+ }
52
+
53
+ /**
54
+ * Read a file as UTF-8 string.
55
+ */
56
+ export function readFile(filePath) {
57
+ return readFileSync(filePath, 'utf-8');
58
+ }