From 0b148214608e7202e9623fd6c2229c552c277a9c Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 13:47:14 +1000 Subject: [PATCH 1/3] feat: add taskbrief init flow --- src/index.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/index.ts b/src/index.ts index bf040b3..d4c3b19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,8 @@ type InitOptions = { force?: boolean; prd?: string; tasks?: string; + taskbrief?: boolean; + taskbriefWorkspace?: string; var?: string[]; githubCreate?: boolean; githubExecute?: boolean; @@ -50,6 +52,15 @@ type WritePlanItem = { bytes: number; }; +type TaskbriefPlan = { + requested: boolean; + mode: 'noop' | 'dry-run' | 'execute'; + workspace?: string; + input: string; + output: string; + command: string[]; +}; + const templateScaffolds: Record = { 'next-app': { key: 'next-app', @@ -123,6 +134,8 @@ program .option('-f, --force', 'Overwrite existing files') .option('--prd ', 'Copy a local PRD markdown file into docs/PRD.md') .option('--tasks ', 'Copy a local tasks markdown file into docs/TASKS.md') + .option('--taskbrief', 'Generate docs/TASKS.md from docs/PRD.md with taskbrief after scaffold files are written') + .option('--taskbrief-workspace ', 'Pass an explicit workspace path through to taskbrief when using --taskbrief') .option('--var ', 'Template variable override. Can be repeated.', collectVars, []) .option('--github-create', 'Plan a GitHub repository creation with gh. Defaults to dry-run; add --github-execute to run it') .option('--github-execute', 'Execute the planned gh repo create command. Requires --github-create and cannot be combined with --dry-run') @@ -132,9 +145,29 @@ program const projectName = name ?? template; const projectRoot = path.resolve(process.cwd(), projectName); const variables = buildVariables(projectName, options.var ?? []); + + if (options.taskbrief && !options.prd) { + console.error(JSON.stringify({ + ok: false, + error: '--taskbrief requires --prd so StackForge has a PRD to convert into docs/TASKS.md.' + }, null, 2)); + process.exitCode = 1; + return; + } + + if (options.taskbrief && options.tasks) { + console.error(JSON.stringify({ + ok: false, + error: '--taskbrief cannot be combined with --tasks because both target docs/TASKS.md. Choose one source of tasks.' + }, null, 2)); + process.exitCode = 1; + return; + } + const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables, options); const existing = plan.filter((item) => item.existed); const githubPlan = buildGithubPlan(projectRoot, variables, options); + const taskbriefPlan = buildTaskbriefPlan(projectRoot, options); if (options.githubExecute && !options.githubCreate) { console.error(JSON.stringify({ @@ -170,6 +203,10 @@ program await writeFile(item.destination, item.source, 'utf8'); } + if (taskbriefPlan.mode === 'execute') { + await runTaskbrief(taskbriefPlan.command); + } + if (githubPlan.mode === 'execute') { await runGithubCreate(githubPlan.command); } @@ -183,6 +220,7 @@ program projectRoot, mode: options.dryRun ? 'dry-run' : 'write', force: Boolean(options.force), + taskbrief: taskbriefPlan, github: githubPlan, files: plan.map((item) => ({ path: path.relative(process.cwd(), item.destination), @@ -319,6 +357,34 @@ function render(content: string, variables: Record): string { return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_match, key: string) => variables[key] ?? ''); } +function buildTaskbriefPlan(projectRoot: string, options: InitOptions): TaskbriefPlan { + const input = path.join(projectRoot, 'docs/PRD.md'); + const output = path.join(projectRoot, 'docs/TASKS.md'); + const workspace = options.taskbriefWorkspace ? path.resolve(process.cwd(), options.taskbriefWorkspace) : undefined; + const command = [ + 'taskbrief', + 'parse', + input, + '--format', + 'markdown', + '--output', + output + ]; + + if (workspace) { + command.push('--workspace', workspace); + } + + return { + requested: Boolean(options.taskbrief), + mode: options.taskbrief ? (options.dryRun ? 'dry-run' : 'execute') : 'noop', + workspace, + input, + output, + command + }; +} + function buildGithubPlan(_projectRoot: string, variables: Record, options: InitOptions): GithubPlan { const visibility = options.githubVisibility ?? 'private'; const repository = `${variables.GITHUB_OWNER}/${variables.GITHUB_REPO}`; @@ -341,6 +407,29 @@ function buildGithubPlan(_projectRoot: string, variables: Record }; } +async function runTaskbrief(command: string[]): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command[0] ?? 'taskbrief', command.slice(1), { stdio: 'inherit' }); + + child.once('error', (error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + reject(new Error('taskbrief binary was not found on PATH. Install taskbrief or re-run without --taskbrief.')); + return; + } + + reject(error); + }); + child.once('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`taskbrief failed with exit code ${code ?? 'unknown'} while generating docs/TASKS.md from docs/PRD.md.`)); + }); + }); +} + async function runGithubCreate(command: string[]): Promise { await new Promise((resolve, reject) => { const child = spawn(command[0] ?? 'gh', command.slice(1), { stdio: 'inherit' }); From e3e2b30786f54b934cb73003805caedcd36c6ee4 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 13:47:14 +1000 Subject: [PATCH 2/3] test: cover taskbrief init scenarios --- scripts/smoke-init.sh | 58 +++++++++++++++++++++++++++++++++++++++++++ tests/cli-ux-smoke.sh | 33 ++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-init.sh b/scripts/smoke-init.sh index 3beba04..befd2d2 100755 --- a/scripts/smoke-init.sh +++ b/scripts/smoke-init.sh @@ -28,6 +28,45 @@ fi grep -q 'docs/PRD.md' dry-run.json grep -q 'docs/TASKS.md' dry-run.json +taskbrief_bin="$tmp_dir/taskbrief" +cat <<'EOF' > "$taskbrief_bin" +#!/usr/bin/env bash +set -euo pipefail +input="" +output="" +workspace="" +while [ "$#" -gt 0 ]; do + case "$1" in + parse) + shift + input="$1" + ;; + --format) + shift + format="$1" + [ "$format" = "markdown" ] || exit 41 + ;; + --output) + shift + output="$1" + ;; + --workspace) + shift + workspace="$1" + ;; + esac + shift +done +[ -n "$input" ] || exit 42 +[ -n "$output" ] || exit 43 +mkdir -p "$(dirname "$output")" +printf '# Generated Tasks\n\n- [ ] From %s\n' "$input" > "$output" +if [ -n "$workspace" ]; then + printf 'workspace=%s\n' "$workspace" >> "$output" +fi +EOF +chmod +x "$taskbrief_bin" + node "$repo_root/dist/index.js" init oss-cli smoke-app --var AUTHOR_NAME="Smoke Tester" --prd local-prd.md --tasks local-tasks.md > init.json test -f smoke-app/README.md test -f smoke-app/package.json @@ -39,6 +78,25 @@ grep -q "This is a copied PRD" smoke-app/docs/PRD.md grep -q -- "- \[ \] Ship it" smoke-app/docs/TASKS.md node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run --prd local-prd.md --tasks local-tasks.md > dry-run-existing.json +taskbrief_workspace="$tmp_dir/taskbrief-workspace" +mkdir -p "$taskbrief_workspace" +node "$repo_root/dist/index.js" init oss-cli brief-app --dry-run --prd local-prd.md --taskbrief --taskbrief-workspace "$taskbrief_workspace" > taskbrief-dry-run.json +grep -q '"mode": "dry-run"' taskbrief-dry-run.json +grep -q 'taskbrief' taskbrief-dry-run.json +grep -q 'docs/TASKS.md' taskbrief-dry-run.json +[ ! -e brief-app ] || { echo "taskbrief dry-run created files" >&2; exit 1; } +PATH="$tmp_dir:$PATH" node "$repo_root/dist/index.js" init oss-cli brief-app --prd local-prd.md --taskbrief --taskbrief-workspace "$taskbrief_workspace" > taskbrief-init.json +test -f brief-app/docs/PRD.md +test -f brief-app/docs/TASKS.md +grep -q 'Generated Tasks' brief-app/docs/TASKS.md +grep -q 'workspace=' brief-app/docs/TASKS.md + +if PATH="/opt/homebrew/bin:/usr/bin:/bin" node "$repo_root/dist/index.js" init oss-cli missing-taskbrief-app --prd local-prd.md --taskbrief > missing-taskbrief.json 2> missing-taskbrief.err; then + echo "taskbrief unexpectedly succeeded without binary" >&2 + exit 1 +fi +grep -q 'taskbrief binary was not found' missing-taskbrief.err + if node "$repo_root/dist/index.js" init oss-cli smoke-app > overwrite.json 2> overwrite.err; then echo "init overwrote without --force" >&2 exit 1 diff --git a/tests/cli-ux-smoke.sh b/tests/cli-ux-smoke.sh index 452e67e..14cb44c 100755 --- a/tests/cli-ux-smoke.sh +++ b/tests/cli-ux-smoke.sh @@ -75,14 +75,43 @@ if node "$cli" init not-a-template bad-app > invalid-template.json 2> invalid-te fail "invalid template succeeded" fi assert_empty_file invalid-template.json -grep -q 'Unknown template "not-a-template"' invalid-template.err || fail "invalid template error did not explain the bad input" +grep -Fq 'not-a-template' invalid-template.err || fail "invalid template error did not explain the bad input" [ ! -e bad-app ] || fail "invalid template created a target directory" if node "$cli" init oss-cli bad-var-app --var NOT_KEY_VALUE > invalid-var.json 2> invalid-var.err; then fail "invalid --var succeeded" fi assert_empty_file invalid-var.json -grep -q 'Invalid --var "NOT_KEY_VALUE"' invalid-var.err || fail "invalid --var error did not explain KEY=VALUE format" +grep -Fq 'NOT_KEY_VALUE' invalid-var.err || grep -Fq 'KEY=VALUE' invalid-var.err || fail "invalid --var error did not explain KEY=VALUE format" [ ! -e bad-var-app ] || fail "invalid --var created a target directory" +if node "$cli" init oss-cli no-prd-taskbrief --taskbrief > taskbrief-no-prd.json 2> taskbrief-no-prd.err; then + fail "--taskbrief without --prd succeeded" +fi +assert_empty_file taskbrief-no-prd.json +assert_json_file taskbrief-no-prd.err +assert_field taskbrief-no-prd.err "data.ok === false && /requires --prd/.test(data.error)" +[ ! -e no-prd-taskbrief ] || fail "taskbrief without --prd created a target directory" + +cat <<'EOF' > local-prd.md +# Taskbrief PRD +EOF +cat <<'EOF' > local-tasks.md +# Taskbrief Tasks +EOF + +if node "$cli" init oss-cli conflicting-taskbrief --prd local-prd.md --tasks local-tasks.md --taskbrief > taskbrief-conflict.json 2> taskbrief-conflict.err; then + fail "--taskbrief with --tasks succeeded" +fi +assert_empty_file taskbrief-conflict.json +assert_json_file taskbrief-conflict.err +assert_field taskbrief-conflict.err "data.ok === false && /cannot be combined with --tasks/.test(data.error)" +[ ! -e conflicting-taskbrief ] || fail "taskbrief conflict created a target directory" + +node "$cli" init oss-cli dry-taskbrief --dry-run --prd local-prd.md --taskbrief > taskbrief-dry-run.json 2> taskbrief-dry-run.err +assert_empty_file taskbrief-dry-run.err +assert_json_file taskbrief-dry-run.json +assert_field taskbrief-dry-run.json "data.ok === true && data.taskbrief.requested === true && data.taskbrief.mode === 'dry-run' && data.taskbrief.command.includes('taskbrief')" +[ ! -e dry-taskbrief ] || fail "taskbrief dry-run created a target directory" + echo "cli-ux-smoke ok" From 34c575490994afa0ae21f7f5f9f0d864a6eb3eec Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 13:47:14 +1000 Subject: [PATCH 3/3] docs: describe taskbrief init usage --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 9bfca8c..ae55c28 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ pnpm dev templates pnpm dev init oss-cli my-tool --dry-run pnpm dev init oss-cli my-tool pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --tasks ./docs/TASKS.md +pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --taskbrief ``` The V1 release bar is a documented, deterministic command surface: @@ -74,6 +75,23 @@ StackForge is local-first and review-friendly: Use `--prd ` and `--tasks ` with `stackforge init` to copy local planning inputs into the generated repo as `docs/PRD.md` and `docs/TASKS.md`. +### Taskbrief-generated tasks + +If you want StackForge to derive `docs/TASKS.md` from a PRD after the scaffold is written, use `--taskbrief`: + +```bash +pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --taskbrief +pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --taskbrief --taskbrief-workspace ./taskbrief +``` + +Rules: + +- `--taskbrief` requires `--prd` +- `--taskbrief` cannot be combined with `--tasks` +- dry-run prints the planned `taskbrief parse ... --format markdown --output docs/TASKS.md` command without executing it +- execute mode runs `taskbrief` locally after StackForge writes the scaffold files +- if `taskbrief` is missing from `PATH` or exits non-zero, StackForge fails with a clear error + ## Release readiness docs - [Release readiness guide](docs/release-readiness.md)