buildline 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +214 -2
- package/bin/buildline.js +131 -0
- package/package.json +44 -4
- package/src/cli.js +537 -0
- package/src/detect.js +228 -0
- package/src/features.js +157 -0
- package/src/files.js +107 -0
- package/src/install.js +280 -0
- package/src/models.js +66 -0
- package/src/render.js +9 -0
- package/src/server.js +143 -0
- package/src/ui/index.html +333 -0
- package/templates/AGENTS.block.md +48 -0
- package/templates/CLAUDE.block.md +20 -0
- package/templates/GEMINI.block.md +3 -0
- package/templates/agents/buildline-design-reviewer.md +21 -0
- package/templates/agents/buildline-mapper.md +23 -0
- package/templates/agents/buildline-reviewer.md +24 -0
- package/templates/agents/buildline-spec-critic.md +19 -0
- package/templates/agents/buildline-verifier.md +30 -0
- package/templates/bin/gates.mjs +65 -0
- package/templates/bin/hook.mjs +90 -0
- package/templates/buildline-readme.md +55 -0
- package/templates/ci/buildline.yml +71 -0
- package/templates/context/architecture.md +21 -0
- package/templates/context/design.md +22 -0
- package/templates/context/product.md +22 -0
- package/templates/cursor.mdc +11 -0
- package/templates/feature/progress.md +6 -0
- package/templates/feature/spec.md +34 -0
- package/templates/principles.md +23 -0
- package/templates/stations/autopilot.md +52 -0
- package/templates/stations/build.md +52 -0
- package/templates/stations/buildline.md +48 -0
- package/templates/stations/design.md +39 -0
- package/templates/stations/new.md +39 -0
- package/templates/stations/onboard.md +57 -0
- package/templates/stations/plan.md +44 -0
- package/templates/stations/review.md +37 -0
- package/templates/stations/ship.md +32 -0
- package/templates/stations/spec.md +46 -0
- package/templates/stations/verify.md +44 -0
package/src/detect.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Inspects a project directory and reports what is there: language, framework,
|
|
2
|
+
// package manager, and the commands the factory should use as quality gates.
|
|
3
|
+
// Everything is best-effort and read-only; unknown values stay null.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const exists = (dir, f) => fs.existsSync(path.join(dir, f));
|
|
8
|
+
const readJson = (file) => {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const readText = (file) => {
|
|
16
|
+
try {
|
|
17
|
+
return fs.readFileSync(file, 'utf8');
|
|
18
|
+
} catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const IGNORED = new Set(['.git', 'node_modules', '.DS_Store', '.buildline', '.claude', '.idea', '.vscode']);
|
|
24
|
+
|
|
25
|
+
export function isEmptyProject(dir) {
|
|
26
|
+
if (!fs.existsSync(dir)) return true;
|
|
27
|
+
const entries = fs.readdirSync(dir).filter((e) => !IGNORED.has(e));
|
|
28
|
+
const meaningful = entries.filter((e) => !/^(readme|license|\.gitignore|\.env)/i.test(e));
|
|
29
|
+
return meaningful.length === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function detectNode(dir, stack) {
|
|
33
|
+
const pkg = readJson(path.join(dir, 'package.json'));
|
|
34
|
+
if (!pkg) return;
|
|
35
|
+
stack.languages.push(exists(dir, 'tsconfig.json') ? 'TypeScript' : 'JavaScript');
|
|
36
|
+
|
|
37
|
+
stack.packageManager = exists(dir, 'pnpm-lock.yaml')
|
|
38
|
+
? 'pnpm'
|
|
39
|
+
: exists(dir, 'yarn.lock')
|
|
40
|
+
? 'yarn'
|
|
41
|
+
: exists(dir, 'bun.lockb') || exists(dir, 'bun.lock')
|
|
42
|
+
? 'bun'
|
|
43
|
+
: 'npm';
|
|
44
|
+
const run = stack.packageManager === 'npm' ? 'npm run' : stack.packageManager;
|
|
45
|
+
|
|
46
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
47
|
+
const frameworks = [
|
|
48
|
+
['next', 'Next.js'],
|
|
49
|
+
['nuxt', 'Nuxt'],
|
|
50
|
+
['@remix-run/react', 'Remix'],
|
|
51
|
+
['@sveltejs/kit', 'SvelteKit'],
|
|
52
|
+
['astro', 'Astro'],
|
|
53
|
+
['@angular/core', 'Angular'],
|
|
54
|
+
['vue', 'Vue'],
|
|
55
|
+
['svelte', 'Svelte'],
|
|
56
|
+
['solid-js', 'Solid'],
|
|
57
|
+
['react-native', 'React Native'],
|
|
58
|
+
['expo', 'Expo'],
|
|
59
|
+
['electron', 'Electron'],
|
|
60
|
+
['react', 'React'],
|
|
61
|
+
['@nestjs/core', 'NestJS'],
|
|
62
|
+
['express', 'Express'],
|
|
63
|
+
['fastify', 'Fastify'],
|
|
64
|
+
['hono', 'Hono'],
|
|
65
|
+
];
|
|
66
|
+
for (const [dep, name] of frameworks) if (deps[dep]) stack.frameworks.push(name);
|
|
67
|
+
// "React" is implied by Next/Remix/React Native; keep the list readable.
|
|
68
|
+
if (stack.frameworks.some((f) => ['Next.js', 'Remix', 'React Native', 'Expo'].includes(f))) {
|
|
69
|
+
stack.frameworks = stack.frameworks.filter((f) => f !== 'React');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ui = [
|
|
73
|
+
['tailwindcss', 'Tailwind CSS'],
|
|
74
|
+
['@mui/material', 'MUI'],
|
|
75
|
+
['@chakra-ui/react', 'Chakra UI'],
|
|
76
|
+
['@radix-ui/react-slot', 'Radix / shadcn'],
|
|
77
|
+
['styled-components', 'styled-components'],
|
|
78
|
+
];
|
|
79
|
+
for (const [dep, name] of ui) if (deps[dep]) stack.ui.push(name);
|
|
80
|
+
if (exists(dir, 'components.json')) stack.ui.push('shadcn/ui');
|
|
81
|
+
stack.ui = [...new Set(stack.ui)];
|
|
82
|
+
|
|
83
|
+
const s = pkg.scripts || {};
|
|
84
|
+
if (s.test && !/no test specified/.test(s.test)) stack.commands.test = `${run} test`;
|
|
85
|
+
if (s.lint) stack.commands.lint = `${run} lint`;
|
|
86
|
+
if (s.typecheck) stack.commands.typecheck = `${run} typecheck`;
|
|
87
|
+
else if (s['type-check']) stack.commands.typecheck = `${run} type-check`;
|
|
88
|
+
else if (exists(dir, 'tsconfig.json')) stack.commands.typecheck = 'npx tsc --noEmit';
|
|
89
|
+
if (s.build) stack.commands.build = `${run} build`;
|
|
90
|
+
if (s.dev) stack.commands.dev = `${run} dev`;
|
|
91
|
+
else if (s.start) stack.commands.dev = `${run} start`;
|
|
92
|
+
|
|
93
|
+
if (deps.vitest) stack.testing.push('Vitest');
|
|
94
|
+
if (deps.jest) stack.testing.push('Jest');
|
|
95
|
+
if (deps['@playwright/test']) stack.testing.push('Playwright');
|
|
96
|
+
if (deps.cypress) stack.testing.push('Cypress');
|
|
97
|
+
if (deps['@storybook/react'] || deps.storybook) stack.testing.push('Storybook');
|
|
98
|
+
|
|
99
|
+
if (pkg.workspaces || exists(dir, 'pnpm-workspace.yaml') || exists(dir, 'turbo.json') || exists(dir, 'nx.json')) {
|
|
100
|
+
stack.monorepo = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function detectPython(dir, stack) {
|
|
105
|
+
const pyproject = readText(path.join(dir, 'pyproject.toml'));
|
|
106
|
+
const reqs = readText(path.join(dir, 'requirements.txt'));
|
|
107
|
+
if (!pyproject && !reqs && !exists(dir, 'setup.py')) return;
|
|
108
|
+
stack.languages.push('Python');
|
|
109
|
+
const all = (pyproject + '\n' + reqs).toLowerCase();
|
|
110
|
+
stack.packageManager ??= exists(dir, 'uv.lock') ? 'uv' : exists(dir, 'poetry.lock') ? 'poetry' : 'pip';
|
|
111
|
+
for (const [dep, name] of [
|
|
112
|
+
['django', 'Django'],
|
|
113
|
+
['fastapi', 'FastAPI'],
|
|
114
|
+
['flask', 'Flask'],
|
|
115
|
+
['streamlit', 'Streamlit'],
|
|
116
|
+
]) {
|
|
117
|
+
if (all.includes(dep)) stack.frameworks.push(name);
|
|
118
|
+
}
|
|
119
|
+
const prefix = stack.packageManager === 'uv' ? 'uv run ' : stack.packageManager === 'poetry' ? 'poetry run ' : '';
|
|
120
|
+
if (all.includes('pytest') || exists(dir, 'tests')) stack.commands.test ??= `${prefix}pytest`;
|
|
121
|
+
if (all.includes('ruff')) stack.commands.lint ??= `${prefix}ruff check .`;
|
|
122
|
+
if (all.includes('mypy')) stack.commands.typecheck ??= `${prefix}mypy .`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function detectOthers(dir, stack) {
|
|
126
|
+
if (exists(dir, 'go.mod')) {
|
|
127
|
+
stack.languages.push('Go');
|
|
128
|
+
stack.commands.test ??= 'go test ./...';
|
|
129
|
+
stack.commands.lint ??= 'go vet ./...';
|
|
130
|
+
stack.commands.build ??= 'go build ./...';
|
|
131
|
+
}
|
|
132
|
+
if (exists(dir, 'Cargo.toml')) {
|
|
133
|
+
stack.languages.push('Rust');
|
|
134
|
+
stack.commands.test ??= 'cargo test';
|
|
135
|
+
stack.commands.lint ??= 'cargo clippy -- -D warnings';
|
|
136
|
+
stack.commands.build ??= 'cargo build';
|
|
137
|
+
}
|
|
138
|
+
if (exists(dir, 'Gemfile')) {
|
|
139
|
+
stack.languages.push('Ruby');
|
|
140
|
+
if (readText(path.join(dir, 'Gemfile')).includes('rails')) stack.frameworks.push('Rails');
|
|
141
|
+
stack.commands.test ??= exists(dir, 'spec') ? 'bundle exec rspec' : 'bin/rails test';
|
|
142
|
+
}
|
|
143
|
+
if (exists(dir, 'composer.json')) {
|
|
144
|
+
stack.languages.push('PHP');
|
|
145
|
+
if (exists(dir, 'artisan')) stack.frameworks.push('Laravel');
|
|
146
|
+
stack.commands.test ??= exists(dir, 'artisan') ? 'php artisan test' : 'vendor/bin/phpunit';
|
|
147
|
+
}
|
|
148
|
+
if (exists(dir, 'pom.xml')) {
|
|
149
|
+
stack.languages.push('Java');
|
|
150
|
+
stack.commands.test ??= 'mvn test';
|
|
151
|
+
}
|
|
152
|
+
if (exists(dir, 'build.gradle') || exists(dir, 'build.gradle.kts')) {
|
|
153
|
+
stack.languages.push(exists(dir, 'build.gradle.kts') ? 'Kotlin' : 'Java');
|
|
154
|
+
stack.commands.test ??= './gradlew test';
|
|
155
|
+
}
|
|
156
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).some((f) => f.endsWith('.csproj') || f.endsWith('.sln'))) {
|
|
157
|
+
stack.languages.push('C#');
|
|
158
|
+
stack.frameworks.push('.NET');
|
|
159
|
+
stack.commands.test ??= 'dotnet test';
|
|
160
|
+
}
|
|
161
|
+
if (exists(dir, 'pubspec.yaml')) {
|
|
162
|
+
stack.languages.push('Dart');
|
|
163
|
+
stack.frameworks.push('Flutter');
|
|
164
|
+
stack.commands.test ??= 'flutter test';
|
|
165
|
+
}
|
|
166
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).some((f) => f.endsWith('.xcodeproj') || f === 'Package.swift')) {
|
|
167
|
+
stack.languages.push('Swift');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function detectTooling(dir, stack) {
|
|
172
|
+
const agents = [
|
|
173
|
+
['CLAUDE.md', 'Claude Code'],
|
|
174
|
+
['.claude', 'Claude Code'],
|
|
175
|
+
['AGENTS.md', 'AGENTS.md'],
|
|
176
|
+
['.cursor', 'Cursor'],
|
|
177
|
+
['.cursorrules', 'Cursor'],
|
|
178
|
+
['GEMINI.md', 'Gemini CLI'],
|
|
179
|
+
['.github/copilot-instructions.md', 'GitHub Copilot'],
|
|
180
|
+
['.windsurfrules', 'Windsurf'],
|
|
181
|
+
['.kiro', 'Kiro'],
|
|
182
|
+
['.specify', 'Spec Kit'],
|
|
183
|
+
['.bmad-core', 'BMAD'],
|
|
184
|
+
['_bmad', 'BMAD'],
|
|
185
|
+
];
|
|
186
|
+
for (const [f, name] of agents) if (exists(dir, f)) stack.existingAgentConfig.push(name);
|
|
187
|
+
stack.existingAgentConfig = [...new Set(stack.existingAgentConfig)];
|
|
188
|
+
stack.git = exists(dir, '.git');
|
|
189
|
+
stack.ci = exists(dir, '.github/workflows') ? 'GitHub Actions' : exists(dir, '.gitlab-ci.yml') ? 'GitLab CI' : null;
|
|
190
|
+
stack.docker = exists(dir, 'Dockerfile') || exists(dir, 'docker-compose.yml') || exists(dir, 'compose.yaml');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function detect(dir) {
|
|
194
|
+
const stack = {
|
|
195
|
+
empty: isEmptyProject(dir),
|
|
196
|
+
name: path.basename(path.resolve(dir)),
|
|
197
|
+
languages: [],
|
|
198
|
+
frameworks: [],
|
|
199
|
+
ui: [],
|
|
200
|
+
testing: [],
|
|
201
|
+
packageManager: null,
|
|
202
|
+
monorepo: false,
|
|
203
|
+
commands: { test: null, lint: null, typecheck: null, build: null, dev: null },
|
|
204
|
+
existingAgentConfig: [],
|
|
205
|
+
git: false,
|
|
206
|
+
ci: null,
|
|
207
|
+
docker: false,
|
|
208
|
+
};
|
|
209
|
+
if (stack.empty) {
|
|
210
|
+
detectTooling(dir, stack);
|
|
211
|
+
return stack;
|
|
212
|
+
}
|
|
213
|
+
const pkg = readJson(path.join(dir, 'package.json'));
|
|
214
|
+
if (pkg?.name) stack.name = pkg.name;
|
|
215
|
+
detectNode(dir, stack);
|
|
216
|
+
detectPython(dir, stack);
|
|
217
|
+
detectOthers(dir, stack);
|
|
218
|
+
detectTooling(dir, stack);
|
|
219
|
+
stack.languages = [...new Set(stack.languages)];
|
|
220
|
+
stack.frameworks = [...new Set(stack.frameworks)];
|
|
221
|
+
return stack;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function describeStack(stack) {
|
|
225
|
+
if (stack.empty) return 'Empty project (greenfield)';
|
|
226
|
+
const parts = [...stack.frameworks, ...stack.languages.filter((l) => !stack.frameworks.length || l !== 'JavaScript')];
|
|
227
|
+
return parts.length ? parts.join(' · ') : 'Unknown stack';
|
|
228
|
+
}
|
package/src/features.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Reading and writing factory features (.buildline/features/<id>-<slug>/).
|
|
2
|
+
// The CLI, the web dashboard and agents all share this on-disk format.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { render, templatesDir } from './render.js';
|
|
6
|
+
|
|
7
|
+
export const STATIONS = ['new', 'spec', 'design', 'plan', 'build', 'verify', 'review', 'ship', 'done'];
|
|
8
|
+
export const TYPES = ['fix', 'feature', 'epic'];
|
|
9
|
+
export const APPROVAL_STAGES = ['spec', 'design', 'plan'];
|
|
10
|
+
|
|
11
|
+
export const featuresDir = (root) => path.join(root, '.buildline', 'features');
|
|
12
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
13
|
+
const now = () => new Date().toISOString();
|
|
14
|
+
|
|
15
|
+
export function slugify(title) {
|
|
16
|
+
return (
|
|
17
|
+
title
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.normalize('NFKD')
|
|
20
|
+
.replace(/[̀-ͯ]/g, '')
|
|
21
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
22
|
+
.trim()
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.slice(0, 5)
|
|
25
|
+
.join('-') || 'feature'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listFeatures(root) {
|
|
30
|
+
const dir = featuresDir(root);
|
|
31
|
+
if (!fs.existsSync(dir)) return [];
|
|
32
|
+
return fs
|
|
33
|
+
.readdirSync(dir)
|
|
34
|
+
.filter((d) => /^\d{3,}-/.test(d) && fs.existsSync(path.join(dir, d, 'feature.json')))
|
|
35
|
+
.sort()
|
|
36
|
+
.map((d) => {
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, d, 'feature.json'), 'utf8'));
|
|
39
|
+
return { ...data, dir: d, path: path.join(dir, d) };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { id: d.split('-')[0], dir: d, title: d, status: 'unknown', error: err.message, path: path.join(dir, d) };
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findFeature(root, ref) {
|
|
47
|
+
const all = listFeatures(root);
|
|
48
|
+
if (!ref) return null;
|
|
49
|
+
const r = String(ref).toLowerCase();
|
|
50
|
+
return (
|
|
51
|
+
all.find((f) => f.id === r || f.id === r.padStart(3, '0')) ||
|
|
52
|
+
all.find((f) => f.dir === r) ||
|
|
53
|
+
all.find((f) => f.slug === r) ||
|
|
54
|
+
all.find((f) => f.dir.includes(r)) ||
|
|
55
|
+
null
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function saveFeature(feature) {
|
|
60
|
+
const { dir, path: p, error, ...data } = feature;
|
|
61
|
+
data.updated = now();
|
|
62
|
+
fs.writeFileSync(path.join(p, 'feature.json'), JSON.stringify(data, null, 2) + '\n');
|
|
63
|
+
return feature;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createFeature(root, { title, type = 'feature', owner = null, parent = null }) {
|
|
67
|
+
if (!title?.trim()) throw new Error('A feature needs a title.');
|
|
68
|
+
if (!TYPES.includes(type)) throw new Error(`Type must be one of: ${TYPES.join(', ')}`);
|
|
69
|
+
const existing = listFeatures(root);
|
|
70
|
+
const next = existing.reduce((m, f) => Math.max(m, parseInt(f.id, 10) || 0), 0) + 1;
|
|
71
|
+
const id = String(next).padStart(3, '0');
|
|
72
|
+
const slug = slugify(title);
|
|
73
|
+
const dir = path.join(featuresDir(root), `${id}-${slug}`);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
const vars = { id, title: title.trim(), type, date: today() };
|
|
77
|
+
const feature = {
|
|
78
|
+
id,
|
|
79
|
+
slug,
|
|
80
|
+
title: title.trim(),
|
|
81
|
+
type,
|
|
82
|
+
status: type === 'fix' ? 'build' : 'spec',
|
|
83
|
+
owner,
|
|
84
|
+
parent,
|
|
85
|
+
blocked: null,
|
|
86
|
+
approvals: {},
|
|
87
|
+
tasks: [],
|
|
88
|
+
metrics: { reworks: 0, createdAt: now(), shippedAt: null },
|
|
89
|
+
history: [{ at: now(), station: 'new', note: `Opened as ${type}` }],
|
|
90
|
+
created: now(),
|
|
91
|
+
updated: now(),
|
|
92
|
+
};
|
|
93
|
+
fs.writeFileSync(path.join(dir, 'feature.json'), JSON.stringify(feature, null, 2) + '\n');
|
|
94
|
+
// Prefer the project's own (possibly customised) templates over the bundled ones.
|
|
95
|
+
for (const name of ['spec.md', 'progress.md']) {
|
|
96
|
+
const local = path.join(root, '.buildline', 'templates', name);
|
|
97
|
+
const source = fs.existsSync(local) ? local : path.join(templatesDir, 'feature', name);
|
|
98
|
+
fs.writeFileSync(path.join(dir, name), render(fs.readFileSync(source, 'utf8'), vars));
|
|
99
|
+
}
|
|
100
|
+
return { ...feature, dir: `${id}-${slug}`, path: dir };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setStatus(feature, status, note) {
|
|
104
|
+
if (!STATIONS.includes(status)) throw new Error(`Status must be one of: ${STATIONS.join(', ')}`);
|
|
105
|
+
feature.status = status;
|
|
106
|
+
if (status === 'done') feature.metrics = { ...feature.metrics, shippedAt: now() };
|
|
107
|
+
feature.history = [...(feature.history || []), { at: now(), station: status, note: note || `Moved to ${status}` }];
|
|
108
|
+
return saveFeature(feature);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function approve(feature, stage, by) {
|
|
112
|
+
if (!APPROVAL_STAGES.includes(stage)) throw new Error(`Approval stage must be one of: ${APPROVAL_STAGES.join(', ')}`);
|
|
113
|
+
feature.approvals = { ...feature.approvals, [stage]: { by: by || 'unknown', at: now() } };
|
|
114
|
+
feature.history = [...(feature.history || []), { at: now(), station: stage, note: `${stage} approved by ${by || 'unknown'}` }];
|
|
115
|
+
return saveFeature(feature);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What a feature is waiting on, in plain words, for status displays. */
|
|
119
|
+
export function nextAction(feature, autonomy = 2) {
|
|
120
|
+
const a = feature.approvals || {};
|
|
121
|
+
const tasks = feature.tasks || [];
|
|
122
|
+
const passing = tasks.filter((t) => t.passes).length;
|
|
123
|
+
if (feature.blocked) return { who: 'human', text: `blocked: ${feature.blocked}` };
|
|
124
|
+
switch (feature.status) {
|
|
125
|
+
case 'spec':
|
|
126
|
+
if (/TODO/.test(readSpecSection(feature, 'Acceptance') || 'TODO')) return { who: 'pm', text: 'needs requirements & acceptance', cmd: `/buildline-spec ${feature.id}` };
|
|
127
|
+
return autonomy <= 3 && !a.spec ? { who: 'pm', text: 'waiting on spec approval', cmd: `buildline approve ${feature.id} spec` } : { who: 'agent', text: 'ready for design', cmd: `/buildline-design ${feature.id}` };
|
|
128
|
+
case 'design':
|
|
129
|
+
return { who: 'designer', text: 'needs UX / technical design', cmd: `/buildline-design ${feature.id}` };
|
|
130
|
+
case 'plan':
|
|
131
|
+
return { who: 'eng', text: 'needs a task plan', cmd: `/buildline-plan ${feature.id}` };
|
|
132
|
+
case 'build':
|
|
133
|
+
if (feature.type !== 'fix' && autonomy <= 3 && !a.spec) return { who: 'pm', text: 'waiting on spec approval', cmd: `buildline approve ${feature.id} spec` };
|
|
134
|
+
if (feature.type !== 'fix' && autonomy <= 2 && !a.plan && tasks.length) return { who: 'eng', text: 'waiting on plan approval', cmd: `buildline approve ${feature.id} plan` };
|
|
135
|
+
return { who: 'agent', text: tasks.length ? `${passing}/${tasks.length} tasks passing` : 'ready to build', cmd: autonomy >= 3 ? `buildline run ${feature.id}` : `/buildline-build ${feature.id}` };
|
|
136
|
+
case 'verify':
|
|
137
|
+
return { who: 'agent', text: 'ready for verification', cmd: `/buildline-verify ${feature.id}` };
|
|
138
|
+
case 'review':
|
|
139
|
+
return { who: 'agent', text: 'ready for review', cmd: `/buildline-review ${feature.id}` };
|
|
140
|
+
case 'ship':
|
|
141
|
+
return { who: 'eng', text: 'ready to ship: open PR', cmd: `/buildline-ship ${feature.id}` };
|
|
142
|
+
case 'done':
|
|
143
|
+
return { who: null, text: 'shipped' };
|
|
144
|
+
default:
|
|
145
|
+
return { who: null, text: feature.status };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function readSpecSection(feature, heading) {
|
|
150
|
+
try {
|
|
151
|
+
const text = fs.readFileSync(path.join(feature.path, 'spec.md'), 'utf8');
|
|
152
|
+
const m = new RegExp(`^## ${heading}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text);
|
|
153
|
+
return m ? m[1].trim() : null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Safe file writing for installs into projects that may already be running.
|
|
2
|
+
//
|
|
3
|
+
// Two write modes:
|
|
4
|
+
// - "file": the factory owns the whole file. On update we only overwrite it
|
|
5
|
+
// if the user has not edited it since we wrote it (hash check).
|
|
6
|
+
// - "block": the file belongs to the user (AGENTS.md, CLAUDE.md, .gitignore).
|
|
7
|
+
// We only ever touch the region between our markers.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
export const MANIFEST = '.buildline/manifest.json';
|
|
13
|
+
const START = (c) => `${c[0]} buildline:start — managed by buildline, edit outside this block ${c[1]}`.trim();
|
|
14
|
+
const END = (c) => `${c[0]} buildline:end ${c[1]}`.trim();
|
|
15
|
+
const COMMENT = { md: ['<!--', '-->'], hash: ['#', ''] };
|
|
16
|
+
|
|
17
|
+
export const hash = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
18
|
+
|
|
19
|
+
export function loadManifest(root) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(path.join(root, MANIFEST), 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return { version: null, files: {} };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveManifest(root, manifest) {
|
|
28
|
+
const file = path.join(root, MANIFEST);
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
|
+
fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function blockRegex(style) {
|
|
34
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
35
|
+
const c = COMMENT[style];
|
|
36
|
+
return new RegExp(`${esc(c[0])} buildline:start[^\\n]*\\n[\\s\\S]*?${esc(END(c))}\\n?`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Plan a single write without touching disk. Returns
|
|
41
|
+
* { rel, action: 'create'|'update'|'skip-modified'|'unchanged'|'merge', content }.
|
|
42
|
+
*/
|
|
43
|
+
export function planWrite(root, manifest, entry, { force = false } = {}) {
|
|
44
|
+
const abs = path.join(root, entry.path);
|
|
45
|
+
const exists = fs.existsSync(abs);
|
|
46
|
+
const current = exists ? fs.readFileSync(abs, 'utf8') : null;
|
|
47
|
+
|
|
48
|
+
if (entry.mode === 'block') {
|
|
49
|
+
const style = entry.comment || 'md';
|
|
50
|
+
const c = COMMENT[style];
|
|
51
|
+
const block = `${START(c)}\n${entry.content.trimEnd()}\n${END(c)}\n`;
|
|
52
|
+
if (!exists) {
|
|
53
|
+
const content = (entry.header ? entry.header + '\n\n' : '') + block;
|
|
54
|
+
return { rel: entry.path, action: 'create', content, record: block };
|
|
55
|
+
}
|
|
56
|
+
const re = blockRegex(style);
|
|
57
|
+
const next = re.test(current)
|
|
58
|
+
? current.replace(re, block)
|
|
59
|
+
: entry.position === 'top'
|
|
60
|
+
? block + '\n' + current
|
|
61
|
+
: current.trimEnd() + '\n\n' + block;
|
|
62
|
+
if (next === current) return { rel: entry.path, action: 'unchanged' };
|
|
63
|
+
return { rel: entry.path, action: re.test(current) ? 'update' : 'merge', content: next, record: block };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Whole-file mode.
|
|
67
|
+
if (!exists) return { rel: entry.path, action: 'create', content: entry.content, record: entry.content };
|
|
68
|
+
// User-owned files (config, context, principles) are seeded once and then belong to the user.
|
|
69
|
+
if (entry.userOwned || current === entry.content) return { rel: entry.path, action: 'unchanged' };
|
|
70
|
+
const known = manifest.files[entry.path];
|
|
71
|
+
const untouched = known && known === hash(current);
|
|
72
|
+
if (!untouched && !force) return { rel: entry.path, action: 'skip-modified' };
|
|
73
|
+
return { rel: entry.path, action: 'update', content: entry.content, record: entry.content };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function applyPlan(root, manifest, plans) {
|
|
77
|
+
for (const p of plans) {
|
|
78
|
+
if (p.content === undefined) continue;
|
|
79
|
+
const abs = path.join(root, p.rel);
|
|
80
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
81
|
+
fs.writeFileSync(abs, p.content);
|
|
82
|
+
if (p.executable) fs.chmodSync(abs, 0o755);
|
|
83
|
+
// For block files we record the block hash; whole files record the file hash.
|
|
84
|
+
manifest.files[p.rel] = hash(p.mode === 'block' ? p.record : p.content);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Remove our blocks and owned files. User-modified files are left alone. */
|
|
89
|
+
export function planRemove(root, manifest, entries) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
const abs = path.join(root, e.path);
|
|
93
|
+
if (!fs.existsSync(abs)) continue;
|
|
94
|
+
const current = fs.readFileSync(abs, 'utf8');
|
|
95
|
+
if (e.mode === 'block') {
|
|
96
|
+
const next = current.replace(blockRegex(e.comment || 'md'), '').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
|
|
97
|
+
out.push({ rel: e.path, action: next.trim() ? 'strip-block' : 'delete', content: next });
|
|
98
|
+
} else if (e.userOwned) {
|
|
99
|
+
out.push({ rel: e.path, action: 'keep-user' });
|
|
100
|
+
} else if (manifest.files[e.path] === hash(current)) {
|
|
101
|
+
out.push({ rel: e.path, action: 'delete' });
|
|
102
|
+
} else {
|
|
103
|
+
out.push({ rel: e.path, action: 'keep-modified' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|