- Initialize .sdlc/ with config, guidance, and state machine - Register M0-M8 as released milestones (full engine track history) - Seed M9 (Community Sync & Revocation) and M10 (Governance & Agent Rights) with features - Seed product milestones P0-P4 and PG1 gate with features from existing planning docs - Add Team section to AGENTS.md (tidal-engineer, tidal-visionary, tidal-researcher, tidal-storyteller) - Add knowledge-librarian agent for .sdlc/knowledge/ curation - Gitignore .sdlc/telemetry.redb (volatile binary state) - Add .ai/ scaffold (project knowledge index)
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
/**
|
|
* Dev Driver
|
|
* ==========
|
|
* Finds the single most important next development action and dispatches it
|
|
* asynchronously. Designed to run on a schedule (e.g. every 4 hours) via the
|
|
* sdlc orchestrator to make development self-advancing.
|
|
*
|
|
* WHAT IT DOES
|
|
* ------------
|
|
* --run: Reads JSON from stdin: {} (no parameters)
|
|
* Applies a 5-level priority waterfall:
|
|
* 1. Flight lock — if .sdlc/.dev-driver.lock < 2h old, exit waiting
|
|
* 2. Quality — if quality-check fails, exit quality_failing
|
|
* 3. Features — if features have active directives, dispatch /sdlc-next
|
|
* 4. Wave — if a milestone wave is ready, dispatch /sdlc-run-wave
|
|
* 5. Idle — nothing to do, exit idle
|
|
* Returns ToolResult<DevDriverOutput>.
|
|
*
|
|
* --meta: Writes ToolMeta JSON to stdout. Used by `sdlc tool sync`.
|
|
*
|
|
* KEY INVARIANT
|
|
* -------------
|
|
* Level 3 dispatches /sdlc-next (one step), NOT /sdlc-run (to completion).
|
|
* The 4h recurrence IS the iteration rhythm. Each tick advances exactly one
|
|
* feature by one directive. This keeps the developer in control.
|
|
*
|
|
* HOW TO SKIP A FEATURE
|
|
* ---------------------
|
|
* Add a task to the feature with "skip:autonomous" in the title:
|
|
* sdlc task add <slug> --title "skip:autonomous: needs human review"
|
|
* The dev-driver will exclude this feature from Level 3 until the task is removed.
|
|
*
|
|
* LOCK FILE
|
|
* ---------
|
|
* Path: .sdlc/.dev-driver.lock
|
|
* Written before each dispatch. TTL: 2 hours. Format:
|
|
* { started_at: ISO, action: string, slug?: string, milestone?: string, pid: number }
|
|
*/
|
|
|
|
import type { ToolMeta, ToolResult } from '../_shared/types.ts'
|
|
import { makeLogger } from '../_shared/log.ts'
|
|
import { getArgs, readStdin, exit } from '../_shared/runtime.ts'
|
|
import { execSync, spawn } from 'node:child_process'
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
const log = makeLogger('dev-driver')
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool metadata
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const meta: ToolMeta = {
|
|
name: 'dev-driver',
|
|
display_name: 'Dev Driver',
|
|
description: 'Finds the next development action and dispatches it — advances the project one step per tick',
|
|
version: '1.0.0',
|
|
requires_setup: false,
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {},
|
|
additionalProperties: false,
|
|
},
|
|
output_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
action: {
|
|
type: 'string',
|
|
enum: ['waiting', 'quality_failing', 'feature_advanced', 'wave_started', 'idle'],
|
|
description: 'What the dev-driver decided to do',
|
|
},
|
|
lock_age_mins: {
|
|
type: 'number',
|
|
description: 'Age of the flight lock in minutes (present when action=waiting from lock)',
|
|
},
|
|
reason: {
|
|
type: 'string',
|
|
description: 'Human-readable reason (present when action=waiting or idle)',
|
|
},
|
|
failed_checks: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Names of failed quality checks (present when action=quality_failing)',
|
|
},
|
|
slug: {
|
|
type: 'string',
|
|
description: 'Feature slug that was advanced (present when action=feature_advanced)',
|
|
},
|
|
phase: {
|
|
type: 'string',
|
|
description: 'Current phase of the feature (present when action=feature_advanced)',
|
|
},
|
|
directive: {
|
|
type: 'string',
|
|
description: 'The /sdlc-next command that was dispatched (present when action=feature_advanced)',
|
|
},
|
|
milestone: {
|
|
type: 'string',
|
|
description: 'Milestone slug that started (present when action=wave_started)',
|
|
},
|
|
},
|
|
required: ['action'],
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type DevDriverOutput =
|
|
| { action: 'waiting'; lock_age_mins: number }
|
|
| { action: 'waiting'; reason: string }
|
|
| { action: 'quality_failing'; failed_checks: string[] }
|
|
| { action: 'feature_advanced'; slug: string; phase: string; directive: string }
|
|
| { action: 'wave_started'; milestone: string }
|
|
| { action: 'idle'; reason: string }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lock file (T2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface LockFile {
|
|
started_at: string
|
|
action: string
|
|
slug?: string
|
|
milestone?: string
|
|
pid: number
|
|
}
|
|
|
|
const LOCK_TTL_MINS = 120
|
|
|
|
function lockPath(root: string): string {
|
|
return join(root, '.sdlc', '.dev-driver.lock')
|
|
}
|
|
|
|
function readLock(root: string): LockFile | null {
|
|
const p = lockPath(root)
|
|
if (!existsSync(p)) return null
|
|
try {
|
|
return JSON.parse(readFileSync(p, 'utf8')) as LockFile
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isLockActive(lock: LockFile): boolean {
|
|
const ageMs = Date.now() - Date.parse(lock.started_at)
|
|
return ageMs < LOCK_TTL_MINS * 60 * 1000
|
|
}
|
|
|
|
function lockAgeMins(lock: LockFile): number {
|
|
return Math.floor((Date.now() - Date.parse(lock.started_at)) / 60000)
|
|
}
|
|
|
|
function writeLock(root: string, payload: Omit<LockFile, 'pid'> & { pid: number }): void {
|
|
writeFileSync(lockPath(root), JSON.stringify(payload, null, 2), 'utf8')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Quality check (T3 - Level 2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface QCCheck {
|
|
name: string
|
|
status: 'passed' | 'failed'
|
|
}
|
|
|
|
interface QCResult {
|
|
passed: number
|
|
failed: number
|
|
checks: QCCheck[]
|
|
}
|
|
|
|
function runQualityCheck(root: string): { failed: number; failedNames: string[] } {
|
|
const toolPath = join(root, '.sdlc', 'tools', 'quality-check', 'tool.ts')
|
|
if (!existsSync(toolPath)) {
|
|
log.warn('quality-check tool not found — skipping quality gate')
|
|
return { failed: 0, failedNames: [] }
|
|
}
|
|
try {
|
|
const raw = execSync(`node ${toolPath} --run`, {
|
|
input: '{}',
|
|
encoding: 'utf8',
|
|
cwd: root,
|
|
timeout: 120_000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
})
|
|
const result = JSON.parse(raw) as ToolResult<QCResult>
|
|
if (!result.data) return { failed: 0, failedNames: [] }
|
|
const failedNames = result.data.checks
|
|
.filter(c => c.status === 'failed')
|
|
.map(c => c.name)
|
|
return { failed: result.data.failed, failedNames }
|
|
} catch (e) {
|
|
log.warn(`quality-check execution error: ${e} — treating as no failures`)
|
|
return { failed: 0, failedNames: [] }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Active run check (T8)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function hasActiveRuns(root: string): boolean {
|
|
try {
|
|
const raw = execSync('sdlc run list --status running --json', {
|
|
encoding: 'utf8',
|
|
cwd: root,
|
|
timeout: 10_000,
|
|
})
|
|
const runs = JSON.parse(raw)
|
|
return Array.isArray(runs) && runs.length > 0
|
|
} catch {
|
|
// sdlc run list may not exist yet — skip this check gracefully
|
|
log.warn('sdlc run list not available — skipping active run check')
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Feature selection (T3 - Level 3, T7, T9, T10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FeatureDirective {
|
|
feature: string
|
|
current_phase: string
|
|
action: string
|
|
next_command: string
|
|
}
|
|
|
|
const ACTIVE_PHASES = new Set(['implementation', 'review', 'audit', 'qa'])
|
|
|
|
function hasSkipTag(slug: string, root: string): boolean {
|
|
const tasksPath = join(root, '.sdlc', 'features', slug, 'tasks.md')
|
|
if (!existsSync(tasksPath)) return false
|
|
try {
|
|
const content = readFileSync(tasksPath, 'utf8')
|
|
return /skip:autonomous/i.test(content)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function findActionableFeature(root: string): FeatureDirective | null {
|
|
try {
|
|
const raw = execSync('sdlc next --json', {
|
|
encoding: 'utf8',
|
|
cwd: root,
|
|
timeout: 30_000,
|
|
})
|
|
const all = JSON.parse(raw) as FeatureDirective[]
|
|
const actionable = all
|
|
.filter(d => d.action !== 'done')
|
|
.filter(d => ACTIVE_PHASES.has(d.current_phase))
|
|
.filter(d => !hasSkipTag(d.feature, root))
|
|
.sort((a, b) => a.feature.localeCompare(b.feature))
|
|
return actionable[0] ?? null
|
|
} catch (e) {
|
|
log.warn(`sdlc next --json failed: ${e}`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wave detection (T3 - Level 4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface MilestoneInfo {
|
|
slug: string
|
|
status: string
|
|
features: { phase: string; status: string }[]
|
|
done: number
|
|
total: number
|
|
}
|
|
|
|
const WAVE_READY_PHASES = new Set(['planned', 'ready'])
|
|
|
|
function findReadyWave(root: string): string | null {
|
|
try {
|
|
const raw = execSync('sdlc milestone list --json', {
|
|
encoding: 'utf8',
|
|
cwd: root,
|
|
timeout: 15_000,
|
|
})
|
|
const milestones = JSON.parse(raw) as MilestoneInfo[]
|
|
const ready = milestones
|
|
.filter(m => m.status !== 'released' && m.total > 0)
|
|
.filter(m =>
|
|
m.features.every(f =>
|
|
WAVE_READY_PHASES.has(f.phase) || f.phase === 'released'
|
|
)
|
|
)
|
|
.filter(m =>
|
|
m.features.some(f => WAVE_READY_PHASES.has(f.phase))
|
|
)
|
|
.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
return ready[0]?.slug ?? null
|
|
} catch (e) {
|
|
log.warn(`sdlc milestone list failed: ${e}`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Async spawn (T4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function spawnClaude(command: string, root: string): number {
|
|
const child = spawn('claude', ['--print', command], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
cwd: root,
|
|
env: { ...process.env, SDLC_ROOT: root },
|
|
})
|
|
child.unref()
|
|
return child.pid ?? 0
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main run function (T1, T3, T5)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function run(
|
|
_input: Record<string, never>,
|
|
root: string,
|
|
): Promise<ToolResult<DevDriverOutput>> {
|
|
const start = Date.now()
|
|
|
|
// ── Level 1: Flight lock ──────────────────────────────────────────────────
|
|
const lock = readLock(root)
|
|
if (lock && isLockActive(lock)) {
|
|
const mins = lockAgeMins(lock)
|
|
log.info(`flight lock active (${mins}m old) — waiting`)
|
|
return { ok: true, data: { action: 'waiting', lock_age_mins: mins }, duration_ms: Date.now() - start }
|
|
}
|
|
if (lock) {
|
|
log.info(`stale lock found (${lockAgeMins(lock)}m old) — proceeding`)
|
|
}
|
|
|
|
// ── Level 2: Quality check ────────────────────────────────────────────────
|
|
log.info('running quality check')
|
|
const qc = runQualityCheck(root)
|
|
if (qc.failed > 0) {
|
|
log.info(`quality failing: ${qc.failedNames.join(', ')}`)
|
|
return { ok: true, data: { action: 'quality_failing', failed_checks: qc.failedNames }, duration_ms: Date.now() - start }
|
|
}
|
|
log.info('quality checks passed')
|
|
|
|
// ── Level 3: Features with active directives ──────────────────────────────
|
|
if (hasActiveRuns(root)) {
|
|
log.info('active sdlc agent run detected — waiting')
|
|
return { ok: true, data: { action: 'waiting', reason: 'agent run in progress' }, duration_ms: Date.now() - start }
|
|
}
|
|
|
|
const feature = findActionableFeature(root)
|
|
if (feature) {
|
|
log.info(`advancing feature: ${feature.feature} (${feature.current_phase})`)
|
|
|
|
// Write lock before spawning
|
|
writeLock(root, {
|
|
started_at: new Date().toISOString(),
|
|
action: 'feature_advanced',
|
|
slug: feature.feature,
|
|
pid: 0, // will be overwritten after spawn
|
|
})
|
|
|
|
// Intentionally /sdlc-next — one step per tick, not /sdlc-run to completion
|
|
const pid = spawnClaude(`/sdlc-next ${feature.feature}`, root)
|
|
|
|
// Update lock with actual PID
|
|
writeLock(root, {
|
|
started_at: new Date().toISOString(),
|
|
action: 'feature_advanced',
|
|
slug: feature.feature,
|
|
pid,
|
|
})
|
|
|
|
log.info(`dispatched /sdlc-next ${feature.feature} (pid: ${pid})`)
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
action: 'feature_advanced',
|
|
slug: feature.feature,
|
|
phase: feature.current_phase,
|
|
directive: feature.next_command || `/sdlc-next ${feature.feature}`,
|
|
},
|
|
duration_ms: Date.now() - start,
|
|
}
|
|
}
|
|
|
|
// ── Level 4: Wave ready ───────────────────────────────────────────────────
|
|
const milestone = findReadyWave(root)
|
|
if (milestone) {
|
|
log.info(`wave ready for milestone: ${milestone}`)
|
|
|
|
writeLock(root, {
|
|
started_at: new Date().toISOString(),
|
|
action: 'wave_started',
|
|
milestone,
|
|
pid: 0,
|
|
})
|
|
|
|
const pid = spawnClaude(`/sdlc-run-wave ${milestone}`, root)
|
|
|
|
writeLock(root, {
|
|
started_at: new Date().toISOString(),
|
|
action: 'wave_started',
|
|
milestone,
|
|
pid,
|
|
})
|
|
|
|
log.info(`dispatched /sdlc-run-wave ${milestone} (pid: ${pid})`)
|
|
return {
|
|
ok: true,
|
|
data: { action: 'wave_started', milestone },
|
|
duration_ms: Date.now() - start,
|
|
}
|
|
}
|
|
|
|
// ── Level 5: Idle ─────────────────────────────────────────────────────────
|
|
log.info('no actionable work found — idle')
|
|
return {
|
|
ok: true,
|
|
data: { action: 'idle', reason: 'no actionable work found' },
|
|
duration_ms: Date.now() - start,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI entrypoint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mode = getArgs()[0] ?? '--run'
|
|
const root = process.env.SDLC_ROOT ?? process.cwd()
|
|
|
|
if (mode === '--meta') {
|
|
console.log(JSON.stringify(meta))
|
|
exit(0)
|
|
} else if (mode === '--run') {
|
|
readStdin()
|
|
.then(raw => run(JSON.parse(raw || '{}') as Record<string, never>, root))
|
|
.then(result => { console.log(JSON.stringify(result)); exit(result.ok ? 0 : 1) })
|
|
.catch(e => { console.log(JSON.stringify({ ok: false, error: String(e) })); exit(1) })
|
|
} else {
|
|
console.error(`Unknown mode: ${mode}. Use --meta or --run.`)
|
|
exit(1)
|
|
}
|