@backburner/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Backburner is a local GitHub-driven agent orchestration CLI. It uses GitHub labels, issues, and pull requests as the control plane, keeps configuration and state as readable JSON on your machine, and dispatches eligible work to provider CLIs that you already have installed locally.
4
4
 
5
- Backburner currently supports:
5
+ Backburner supports:
6
6
 
7
7
  - `backburner init` for interactive or non-interactive local setup
8
8
  - `backburner run` for one orchestration cycle
@@ -75,7 +75,7 @@ npm run install:local -- --prefix "$env:TEMP\backburner-local"
75
75
  & "$env:TEMP\backburner-local\backburner.cmd" journal --json
76
76
  ```
77
77
 
78
- Once the current package is published:
78
+ Install from npm:
79
79
 
80
80
  ```sh
81
81
  npm install -g @backburner/cli
@@ -117,12 +117,16 @@ Useful `init` flags:
117
117
  --yes, -y Accept defaults without prompts
118
118
  ```
119
119
 
120
- `backburner init` checks local requirements, confirms `gh auth status`, discovers repositories, detects provider CLIs, and writes these files into the management directory:
120
+ `backburner init` checks local requirements, confirms `gh auth status`, discovers repositories, detects provider CLIs, attempts model-list discovery when supported by those CLIs, writes configuration, and creates the configured Backburner labels on each selected repository.
121
121
 
122
122
  - `repos.json`
123
123
  - `agents.json`
124
124
 
125
- If either file already exists, onboarding asks before overwriting it. With `--yes`, defaults are accepted non-interactively.
125
+ If either file already exists, onboarding asks before overwriting it. With `--yes`, defaults are accepted non-interactively, including selecting existing discovered repositories, enabling every discovered provider, creating strong and cheap agents for each one, and creating or updating labels on selected repositories. `--yes` does not create a private demo repository.
126
+
127
+ Repository setup first asks whether to select existing repositories, create a private Backburner demo repository, or skip repository setup for now. Before choosing existing repositories, onboarding tells you that Backburner will create or update its labels on those repositories. In an interactive terminal, repository and provider selection use a checkbox picker: arrow keys move, space toggles, and enter confirms. Non-TTY sessions fall back to text input and accept comma-separated numbers. If you are authenticated with GitHub but do not want to onboard an existing local clone yet, onboarding can explicitly create a private `backburner-demo` repository and clone it under the configured code root.
128
+
129
+ Label creation is best-effort. If GitHub rejects a label write because of permissions or repository policy, onboarding continues and prints the repository labels URL plus the exact label names to create manually. The final summary prints the next `backburner run` command, a prefilled GitHub issue URL for the first configured repository, and a terminal QR code for opening that repository's issues page from a phone.
126
130
 
127
131
  ## Default Directories
128
132
 
@@ -158,13 +162,13 @@ Minimal `repos.json`:
158
162
  "localPath": "your-github-user/example-repo",
159
163
  "enabled": true,
160
164
  "labels": {
161
- "agentInProgress": "agent-in-progress",
162
- "agentProductApproved": "agent-product-approved",
163
- "agentPlanApproved": "agent-plan-approved",
164
- "agentReview": "agent-review",
165
- "planBreakdownNeeded": "plan-breakdown-needed",
166
- "agentSyncParentBranch": "agent-sync-parent-branch",
167
- "prepareForMerge": "prepare-for-merge"
165
+ "agentInProgress": "backburner:in-progress",
166
+ "agentProductApproved": "backburner:product-approved",
167
+ "agentPlanApproved": "backburner:plan-approved",
168
+ "agentReview": "backburner:review",
169
+ "planBreakdownNeeded": "backburner:plan-breakdown-needed",
170
+ "agentSyncParentBranch": "backburner:sync-parent-branch",
171
+ "prepareForMerge": "backburner:prepare-for-merge"
168
172
  }
169
173
  }
170
174
  ]
@@ -180,7 +184,7 @@ Minimal `agents.json`:
180
184
  "id": "codex-strong",
181
185
  "provider": "codex",
182
186
  "modelClass": "strong",
183
- "model": "gpt-5",
187
+ "model": "sol",
184
188
  "roles": [
185
189
  "product",
186
190
  "planning",
@@ -192,15 +196,15 @@ Minimal `agents.json`:
192
196
  "enabled": true
193
197
  },
194
198
  {
195
- "id": "opencode-cheap",
196
- "provider": "opencode",
199
+ "id": "codex-cheap",
200
+ "provider": "codex",
197
201
  "modelClass": "cheap",
198
- "model": "your-fast-model",
202
+ "model": "luna",
199
203
  "roles": [
200
204
  "implementation",
201
205
  "discussion"
202
206
  ],
203
- "command": "opencode",
207
+ "command": "codex",
204
208
  "enabled": true
205
209
  }
206
210
  ]
@@ -209,18 +213,18 @@ Minimal `agents.json`:
209
213
 
210
214
  `allowedUsers` limits which GitHub users can feed issues, comments, reviews, and pull requests into task derivation. `localPath` must be relative to `--code-root`.
211
215
 
212
- Agent selection filters enabled agents by required role and `modelClass`, then breaks ties by lowest agent `id`. Provider model names are passed through to the provider CLI and are not validated against vendor catalogs.
216
+ Agent selection filters enabled agents by required role and `modelClass`, then breaks ties by lowest agent `id`. Provider model names are passed through to the provider CLI and are not validated against vendor catalogs. During onboarding, model setup shows discovered model options when available, marks strong and cheap defaults, and pressing enter accepts the default. Backburner prefers discovered `sol` for strong, `terra` for balanced/default, and `luna` for cheap model names; Codex defaults are `sol` and `luna`. OpenCode may use discovered OpenRouter model IDs such as `openrouter/openai/gpt-5.6-sol` and `openrouter/openai/gpt-5.6-luna`.
213
217
 
214
218
  ## Label Control Plane
215
219
 
216
- Configure labels per repository in `repos.json`. The common defaults are:
220
+ Configure labels per repository in `repos.json`. The workflow labels are:
217
221
 
218
- - `agent-product-approved`: approves product discovery and allows Backburner to create the implementation PR/worktree flow for an issue
219
- - `agent-plan-approved`: approves an implementation plan and allows implementation tasks to run
220
- - `agent-review`: requests an explicit code review; review tasks are dispatched when the PR has this label and its head SHA changed since the last review
221
- - `agent-sync-parent-branch`: authorizes Backburner to sync a blocked parent implementation branch from the repository default branch
222
+ - `agentProductApproved`: approves product discovery and allows Backburner to create the implementation PR/worktree flow for an issue
223
+ - `agentPlanApproved`: approves an implementation plan and allows implementation tasks to run
224
+ - `agentReview`: requests an explicit code review; review tasks are dispatched when the PR has this label and its head SHA changed since the last review
225
+ - `agentSyncParentBranch`: authorizes Backburner to sync a blocked parent implementation branch from the repository default branch
222
226
 
223
- Backburner also uses `agent-in-progress`, `plan-breakdown-needed`, and `prepare-for-merge` when those labels are configured.
227
+ Backburner also uses `agentInProgress`, `planBreakdownNeeded`, and `prepareForMerge` when those labels are configured. Onboarding writes `repos.json` with these label values by default: `backburner:product-approved`, `backburner:plan-approved`, `backburner:review`, `backburner:in-progress`, `backburner:plan-breakdown-needed`, `backburner:sync-parent-branch`, and `backburner:prepare-for-merge`. It creates or updates the configured labels through `gh label create --force`. Custom label names, including older unprefixed names, are supported when configured in `repos.json`.
224
228
 
225
229
  ## Commands
226
230
 
@@ -313,7 +317,7 @@ Generated files are readable JSON. Control-plane state is written under `<output
313
317
 
314
318
  ## Platform Support
315
319
 
316
- Backburner is intended to run on macOS, Linux, and Windows with Node.js 20 or newer. The current CI workflow in this checkout runs on Ubuntu, macOS, and Windows with Node 20 and 22, and includes build, typecheck, tests, package dry-run, and local package install smoke checks.
320
+ Backburner is intended to run on macOS, Linux, and Windows with Node.js 20 or newer. The CI workflow runs on Ubuntu, macOS, and Windows with Node 20 and 22, and includes build, typecheck, tests, package dry-run, and local package install smoke checks.
317
321
 
318
322
  ## Verification
319
323
 
@@ -1,4 +1,3 @@
1
- import * as path from "node:path";
2
1
  import { ExecFileCommandRunner } from "../../utils/command-runner.js";
3
2
  import { loadConfig } from "../../config/loader.js";
4
3
  import { GhCliGitHubGateway } from "../../github/gateway.js";
@@ -9,11 +8,11 @@ import { PlanBreakdownSessionStore } from "../../workstreams/plan-breakdown-gene
9
8
  import { PlanBreakdownToolService } from "../../workstreams/plan-breakdown-generator/tools.js";
10
9
  import { WaveAssessmentSessionStore } from "../../workstreams/wave-assessment/store.js";
11
10
  import { WaveAssessmentToolService } from "../../workstreams/wave-assessment/tools.js";
12
- import { defaultBackburnerManagementRoot } from "../../utils/paths.js";
11
+ import { defaultBackburnerManagementRoot, resolveUserPath } from "../../utils/paths.js";
13
12
  import { defaultStderrLogger } from "../../utils/logger.js";
14
13
  import { DEFAULT_GITHUB_BROKER_PORT, readOptionalNumberFlag, readOptionalStringFlag, readStringFlag } from "../options.js";
15
14
  export async function runBrokerSmokeCli(argv, logger = defaultStderrLogger) {
16
- const managementDir = path.resolve(readStringFlag(argv, "--management-dir", defaultBackburnerManagementRoot()));
15
+ const managementDir = resolveUserPath(readStringFlag(argv, "--management-dir", defaultBackburnerManagementRoot()));
17
16
  const host = "127.0.0.1";
18
17
  const config = await loadConfig(managementDir);
19
18
  const commandRunner = new ExecFileCommandRunner();
@@ -1,4 +1,3 @@
1
- import * as path from "node:path";
2
1
  import { ExecFileCommandRunner } from "../../utils/command-runner.js";
3
2
  import { loadConfig } from "../../config/loader.js";
4
3
  import { GhCliGitHubGateway } from "../../github/gateway.js";
@@ -9,11 +8,11 @@ import { PlanBreakdownSessionStore } from "../../workstreams/plan-breakdown-gene
9
8
  import { PlanBreakdownToolService } from "../../workstreams/plan-breakdown-generator/tools.js";
10
9
  import { WaveAssessmentSessionStore } from "../../workstreams/wave-assessment/store.js";
11
10
  import { WaveAssessmentToolService } from "../../workstreams/wave-assessment/tools.js";
12
- import { defaultBackburnerManagementRoot } from "../../utils/paths.js";
11
+ import { defaultBackburnerManagementRoot, resolveUserPath } from "../../utils/paths.js";
13
12
  import { defaultStderrLogger } from "../../utils/logger.js";
14
13
  import { DEFAULT_GITHUB_BROKER_PORT, readNumberFlag, readStringFlag } from "../options.js";
15
14
  export async function runBrokerCli(argv, logger = defaultStderrLogger) {
16
- const managementDir = path.resolve(readStringFlag(argv, "--management-dir", defaultBackburnerManagementRoot()));
15
+ const managementDir = resolveUserPath(readStringFlag(argv, "--management-dir", defaultBackburnerManagementRoot()));
17
16
  const port = readNumberFlag(argv, "--port", DEFAULT_GITHUB_BROKER_PORT);
18
17
  const host = readStringFlag(argv, "--host", "127.0.0.1");
19
18
  const config = await loadConfig(managementDir);
@@ -1,11 +1,10 @@
1
- import * as path from "node:path";
2
1
  import { JournalReader } from "../../journal/index.js";
3
2
  import { renderJournalEntries } from "../../journal/renderer.js";
4
- import { defaultBackburnerOutputsRoot, resolveOutputsJournalRoot } from "../../utils/paths.js";
3
+ import { defaultBackburnerOutputsRoot, resolveOutputsJournalRoot, resolveUserPath } from "../../utils/paths.js";
5
4
  import { CliUsageError } from "../errors.js";
6
5
  import { readOptionalStringFlag, readStringFlag } from "../options.js";
7
6
  export async function runJournalCli(argv, injectedJournal) {
8
- const outputsDir = path.resolve(readStringFlag(argv, "--outputs-dir", defaultBackburnerOutputsRoot()));
7
+ const outputsDir = resolveUserPath(readStringFlag(argv, "--outputs-dir", defaultBackburnerOutputsRoot()));
9
8
  const journalDir = resolveOutputsJournalRoot(outputsDir);
10
9
  const reader = injectedJournal ? injectedJournal.reader : new JournalReader(journalDir);
11
10
  const repoId = readOptionalStringFlag(argv, "--repo");
@@ -1,7 +1,6 @@
1
- import * as path from "node:path";
2
1
  import { loadConfig } from "../../config/loader.js";
3
2
  import { StateStore } from "../../state/loader.js";
4
- import { defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, resolveOutputsLogsRoot, resolveOutputsStateRoot } from "../../utils/paths.js";
3
+ import { defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, resolveOutputsLogsRoot, resolveOutputsStateRoot, resolveUserPath } from "../../utils/paths.js";
5
4
  import { RuntimeStatusTracker, TuiScreen } from "../tui/index.js";
6
5
  import { readIntervalMs, readOptionalStringFlag, readStringFlag } from "../options.js";
7
6
  import { runCli } from "./run.js";
@@ -73,7 +72,7 @@ export class SerializedCycleRunner {
73
72
  }
74
73
  export async function runInteractiveTui(argv) {
75
74
  const rest = argv.filter((arg) => arg !== "run");
76
- const managementDir = path.resolve(readStringFlag(rest, "--management-dir", defaultBackburnerManagementRoot()));
75
+ const managementDir = resolveUserPath(readStringFlag(rest, "--management-dir", defaultBackburnerManagementRoot()));
77
76
  let repoCount = 0;
78
77
  let initialConfig = null;
79
78
  try {
@@ -91,9 +90,9 @@ export async function runInteractiveTui(argv) {
91
90
  tracker.setIntervalMs(intervalMs);
92
91
  // Load initial state for the explorer immediately on start
93
92
  try {
94
- const outputsDir = path.resolve(readOptionalStringFlag(rest, "--outputs-dir") ?? defaultBackburnerOutputsRoot());
95
- const stateDir = path.resolve(resolveOutputsStateRoot(outputsDir));
96
- const logDir = path.resolve(resolveOutputsLogsRoot(outputsDir));
93
+ const outputsDir = resolveUserPath(readOptionalStringFlag(rest, "--outputs-dir") ?? defaultBackburnerOutputsRoot());
94
+ const stateDir = resolveUserPath(resolveOutputsStateRoot(outputsDir));
95
+ const logDir = resolveUserPath(resolveOutputsLogsRoot(outputsDir));
97
96
  const stateStore = new StateStore({ stateDir, executionDir: logDir });
98
97
  const state = await stateStore.load();
99
98
  tracker.setState(state);
@@ -114,9 +113,9 @@ export async function runInteractiveTui(argv) {
114
113
  logger: silentLogger,
115
114
  cycleNo: currentCycleNo
116
115
  });
117
- const outputsDir = path.resolve(readOptionalStringFlag(rest, "--outputs-dir") ?? defaultBackburnerOutputsRoot());
118
- const stateDir = path.resolve(resolveOutputsStateRoot(outputsDir));
119
- const logDir = path.resolve(resolveOutputsLogsRoot(outputsDir));
116
+ const outputsDir = resolveUserPath(readOptionalStringFlag(rest, "--outputs-dir") ?? defaultBackburnerOutputsRoot());
117
+ const stateDir = resolveUserPath(resolveOutputsStateRoot(outputsDir));
118
+ const logDir = resolveUserPath(resolveOutputsLogsRoot(outputsDir));
120
119
  const stateStore = new StateStore({ stateDir, executionDir: logDir });
121
120
  const state = await stateStore.load();
122
121
  try {
@@ -14,6 +14,10 @@ export async function main(argv = process.argv.slice(2)) {
14
14
  process.stdout.write(`${usage}\n`);
15
15
  return;
16
16
  }
17
+ if (command !== undefined && hasHelpFlag(rest)) {
18
+ process.stdout.write(`${usage}\n`);
19
+ return;
20
+ }
17
21
  if (command === "run") {
18
22
  if (shouldUseRuntimeStatusScreen(rest)) {
19
23
  await runInteractiveTui([command, ...rest]);
@@ -49,3 +53,6 @@ export async function main(argv = process.argv.slice(2)) {
49
53
  }
50
54
  throw new CliUsageError(usage);
51
55
  }
56
+ function hasHelpFlag(argv) {
57
+ return argv.includes("--help") || argv.includes("-h");
58
+ }
@@ -1,6 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { ExecFileCommandRunner } from "../utils/command-runner.js";
3
- import { defaultBackburnerCodeRoot, defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, defaultWorkspacesRootForCodeRoot } from "../utils/paths.js";
3
+ import { defaultBackburnerCodeRoot, defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, defaultWorkspacesRootForCodeRoot, resolveUserPath } from "../utils/paths.js";
4
4
  import { OnboardingEngine } from "../onboarding/engine.js";
5
5
  import { NodeReadlinePrompter, NonInteractivePrompter } from "../onboarding/prompt.js";
6
6
  import { WelcomeStep } from "../onboarding/steps/welcome.js";
@@ -12,8 +12,10 @@ import { ConfigurePathsStep } from "../onboarding/steps/configure-paths.js";
12
12
  import { DiscoverProvidersStep } from "../onboarding/steps/discover-providers.js";
13
13
  import { ConfigureModelsStep } from "../onboarding/steps/configure-models.js";
14
14
  import { GenerateConfigStep } from "../onboarding/steps/generate-config.js";
15
+ import { ProvisionLabelsStep } from "../onboarding/steps/provision-labels.js";
15
16
  import { ReadinessValidationStep } from "../onboarding/steps/readiness-validation.js";
16
17
  import { ShowSummaryStep } from "../onboarding/steps/show-summary.js";
18
+ import { DemoRepoCreator } from "../onboarding/services/demo-repo.js";
17
19
  export async function runInitCli(argv) {
18
20
  const codeRoot = readStringFlag(argv, "--code-root") ?? defaultBackburnerCodeRoot();
19
21
  const managementDir = readStringFlag(argv, "--management-dir");
@@ -37,14 +39,14 @@ export async function runInitCli(argv) {
37
39
  export async function runInit(options) {
38
40
  const commandRunner = new ExecFileCommandRunner();
39
41
  const { codeRoot, nonInteractive = false } = options;
40
- const resolvedCodeRoot = path.resolve(codeRoot);
41
- const resolvedOutputsDir = path.resolve(options.outputsDir ?? defaultBackburnerOutputsRoot());
42
+ const resolvedCodeRoot = resolveUserPath(codeRoot);
43
+ const resolvedOutputsDir = resolveUserPath(options.outputsDir ?? defaultBackburnerOutputsRoot());
42
44
  const ctx = {
43
45
  codeRoot: resolvedCodeRoot,
44
- managementDir: path.resolve(options.managementDir ?? defaultBackburnerManagementRoot()),
46
+ managementDir: resolveUserPath(options.managementDir ?? defaultBackburnerManagementRoot()),
45
47
  outputsDir: resolvedOutputsDir,
46
- logDir: path.resolve(options.logDir ?? path.join(resolvedOutputsDir, "logs")),
47
- workspacesDir: path.resolve(options.workspacesDir ?? defaultWorkspacesRootForCodeRoot(resolvedCodeRoot)),
48
+ logDir: resolveUserPath(options.logDir ?? path.join(resolvedOutputsDir, "logs")),
49
+ workspacesDir: resolveUserPath(options.workspacesDir ?? defaultWorkspacesRootForCodeRoot(resolvedCodeRoot)),
48
50
  discoveredRepos: [],
49
51
  selectedRepos: [],
50
52
  discoveredProviders: [],
@@ -56,11 +58,12 @@ export async function runInit(options) {
56
58
  new CheckRequirementsStep(commandRunner),
57
59
  new CheckGitHubAuthStep(commandRunner),
58
60
  new DiscoverReposStep(commandRunner, ctx.codeRoot),
59
- new SelectReposStep(),
61
+ new SelectReposStep(new DemoRepoCreator(commandRunner)),
60
62
  new ConfigurePathsStep(),
61
63
  new DiscoverProvidersStep(commandRunner),
62
64
  new ConfigureModelsStep(),
63
65
  new GenerateConfigStep(),
66
+ new ProvisionLabelsStep(commandRunner),
64
67
  new ReadinessValidationStep(commandRunner),
65
68
  new ShowSummaryStep()
66
69
  ];
@@ -15,19 +15,19 @@ import { createSmitheryMcpResolverFromEnv, reconcileMcpStatus } from "../../mcps
15
15
  import { StateStore } from "../../state/loader.js";
16
16
  import { ExecFileCommandRunner } from "../../utils/command-runner.js";
17
17
  import { defaultStderrLogger } from "../../utils/logger.js";
18
- import { defaultBackburnerCodeRoot, defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, resolveOutputsStateRoot, resolveOutputsLogsRoot, resolveOutputsJournalRoot } from "../../utils/paths.js";
18
+ import { defaultBackburnerCodeRoot, defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, resolveOutputsStateRoot, resolveOutputsLogsRoot, resolveOutputsJournalRoot, resolveUserPath } from "../../utils/paths.js";
19
19
  import { TriageFollowUpService } from "../../workflows/triage.js";
20
20
  import { ImplementationWorkflowService } from "../../worktrees/service.js";
21
21
  import { WorkspaceRecoveryService } from "../../worktrees/workspace-recovery.js";
22
22
  import { DEFAULT_GITHUB_BROKER_PORT as CLI_DEFAULT_GITHUB_BROKER_PORT, readNumberFlag, readOptionalStringFlag, readStringFlag } from "../options.js";
23
23
  export async function createRunContext(input) {
24
24
  const { rest, options } = input;
25
- const managementDir = path.resolve(options.managementDir ?? readStringFlag(rest, "--management-dir", defaultBackburnerManagementRoot()));
25
+ const managementDir = resolveUserPath(options.managementDir ?? readStringFlag(rest, "--management-dir", defaultBackburnerManagementRoot()));
26
26
  const configuredOutputsDir = options.outputsDir ?? readOptionalStringFlag(rest, "--outputs-dir");
27
- const outputsDir = path.resolve(configuredOutputsDir ?? defaultBackburnerOutputsRoot());
27
+ const outputsDir = resolveUserPath(configuredOutputsDir ?? defaultBackburnerOutputsRoot());
28
28
  const useLegacyStateDirWithoutOutputs = options.stateDir !== undefined && configuredOutputsDir === undefined;
29
29
  const logger = options.logger ?? defaultStderrLogger;
30
- const codeRoot = path.resolve(readOptionalStringFlag(rest, "--code-root") ?? defaultBackburnerCodeRoot());
30
+ const codeRoot = resolveUserPath(readOptionalStringFlag(rest, "--code-root") ?? defaultBackburnerCodeRoot());
31
31
  const brokerPort = readNumberFlag(rest, "--broker-port", CLI_DEFAULT_GITHUB_BROKER_PORT);
32
32
  const commandRunner = options.commandRunner ?? new ExecFileCommandRunner();
33
33
  const gitGateway = options.gitGateway ?? new GitCliGateway(commandRunner);
@@ -48,9 +48,9 @@ export async function createRunContext(input) {
48
48
  logger.info(`Preparing outputs root ${outputsDir}`);
49
49
  outputsGitOpsPrepared = await gitOpsLifecycle.prepareOutputsRoot(outputsDir);
50
50
  }
51
- const stateDir = path.resolve(options.stateDir ?? resolveOutputsStateRoot(outputsDir));
51
+ const stateDir = resolveUserPath(options.stateDir ?? resolveOutputsStateRoot(outputsDir));
52
52
  const defaultLogDir = options.stateDir !== undefined ? stateDir : resolveOutputsLogsRoot(outputsDir);
53
- const logDir = path.resolve(options.logDir ?? readStringFlag(rest, "--log-dir", defaultLogDir));
53
+ const logDir = resolveUserPath(options.logDir ?? readStringFlag(rest, "--log-dir", defaultLogDir));
54
54
  let managementRepo;
55
55
  try {
56
56
  const remoteResult = await commandRunner.run("git", ["remote", "get-url", "origin"], { cwd: managementDir });
@@ -1,6 +1,15 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { writeJsonFileAtomic } from "../utils/json.js";
4
+ export const DEFAULT_BACKBURNER_LABELS = {
5
+ agentInProgress: "backburner:in-progress",
6
+ agentPlanApproved: "backburner:plan-approved",
7
+ agentProductApproved: "backburner:product-approved",
8
+ agentReview: "backburner:review",
9
+ planBreakdownNeeded: "backburner:plan-breakdown-needed",
10
+ agentSyncParentBranch: "backburner:sync-parent-branch",
11
+ prepareForMerge: "backburner:prepare-for-merge"
12
+ };
4
13
  export class ConfigWriter {
5
14
  async detectExisting(managementDir) {
6
15
  const reposFile = path.join(managementDir, "repos.json");
@@ -55,15 +64,7 @@ function buildRepoEntry(repo) {
55
64
  defaultBranch: repo.defaultBranch,
56
65
  localPath: repo.localPath,
57
66
  enabled: repo.enabled,
58
- labels: {
59
- agentInProgress: "agent-in-progress",
60
- agentPlanApproved: "agent-plan-approved",
61
- agentProductApproved: "agent-product-approved",
62
- agentReview: "agent-review",
63
- planBreakdownNeeded: "plan-breakdown-needed",
64
- agentSyncParentBranch: "agent-sync-parent-branch",
65
- prepareForMerge: "prepare-for-merge"
66
- }
67
+ labels: repo.labels
67
68
  };
68
69
  }
69
70
  function buildAgentsJson(draft) {
@@ -1,3 +1,4 @@
1
+ import { emitKeypressEvents } from "node:readline";
1
2
  import * as readline from "node:readline/promises";
2
3
  /**
3
4
  * Interactive prompter backed by Node.js readline/promises.
@@ -5,10 +6,14 @@ import * as readline from "node:readline/promises";
5
6
  */
6
7
  export class NodeReadlinePrompter {
7
8
  rl;
8
- constructor() {
9
+ input;
10
+ output;
11
+ constructor(options = {}) {
12
+ this.input = options.input ?? process.stdin;
13
+ this.output = options.output ?? process.stdout;
9
14
  this.rl = readline.createInterface({
10
- input: process.stdin,
11
- output: process.stdout
15
+ input: this.input,
16
+ output: this.output
12
17
  });
13
18
  }
14
19
  close() {
@@ -32,7 +37,7 @@ export class NodeReadlinePrompter {
32
37
  const marker = i === defaultIndex ? "*" : " ";
33
38
  lines.push(` ${marker} ${i + 1}. ${options[i] ?? ""}`);
34
39
  }
35
- process.stdout.write(`${lines.join("\n")}\n`);
40
+ this.output.write(`${lines.join("\n")}\n`);
36
41
  const defaultHint = defaultIndex !== undefined ? ` [${defaultIndex + 1}]` : "";
37
42
  // eslint-disable-next-line no-constant-condition
38
43
  while (true) {
@@ -44,10 +49,16 @@ export class NodeReadlinePrompter {
44
49
  if (Number.isInteger(n) && n >= 1 && n <= options.length) {
45
50
  return n - 1;
46
51
  }
47
- process.stdout.write(` Please enter a number between 1 and ${options.length}\n`);
52
+ this.output.write(` Please enter a number between 1 and ${options.length}\n`);
48
53
  }
49
54
  }
50
55
  async multiSelect(question, options, preSelected) {
56
+ if (canUseTerminalPicker(this.input, this.output)) {
57
+ return this.terminalMultiSelect(question, options, preSelected);
58
+ }
59
+ return this.textMultiSelect(question, options, preSelected);
60
+ }
61
+ async textMultiSelect(question, options, preSelected) {
51
62
  const preSet = new Set(preSelected ?? []);
52
63
  const lines = [
53
64
  question,
@@ -57,30 +68,100 @@ export class NodeReadlinePrompter {
57
68
  const marker = preSet.has(i) ? "[x]" : "[ ]";
58
69
  lines.push(` ${marker} ${i + 1}. ${options[i] ?? ""}`);
59
70
  }
60
- process.stdout.write(`${lines.join("\n")}\n`);
71
+ this.output.write(`${lines.join("\n")}\n`);
61
72
  // eslint-disable-next-line no-constant-condition
62
73
  while (true) {
63
74
  const answer = (await this.rl.question("Selection: ")).trim().toLowerCase();
64
- if (answer === "all")
65
- return options.map((_, i) => i);
66
- if (answer === "" || answer === "none")
67
- return preSelected ?? [];
68
- const parts = answer.split(",").map((p) => p.trim());
69
- const indices = [];
70
- let valid = true;
71
- for (const part of parts) {
72
- const n = Number(part);
73
- if (!Number.isInteger(n) || n < 1 || n > options.length) {
74
- process.stdout.write(` Invalid selection "${part}". Enter numbers between 1 and ${options.length}\n`);
75
- valid = false;
76
- break;
77
- }
78
- indices.push(n - 1);
75
+ const parsed = parseTextMultiSelectAnswer(answer, options.length, preSelected);
76
+ if (parsed.ok)
77
+ return parsed.indices;
78
+ if (parsed.invalidPart !== undefined) {
79
+ this.output.write(` Invalid selection "${parsed.invalidPart}". Enter numbers between 1 and ${options.length}\n`);
79
80
  }
80
- if (valid)
81
- return [...new Set(indices)];
82
81
  }
83
82
  }
83
+ async terminalMultiSelect(question, options, preSelected) {
84
+ const selected = new Set(preSelected ?? []);
85
+ let cursor = preSelected?.[0] ?? 0;
86
+ if (cursor < 0 || cursor >= options.length)
87
+ cursor = 0;
88
+ this.rl.pause();
89
+ emitKeypressEvents(this.input);
90
+ this.input.setRawMode?.(true);
91
+ this.input.resume();
92
+ return await new Promise((resolve) => {
93
+ const render = () => {
94
+ const lines = [
95
+ question,
96
+ "Use ↑/↓ to move, space to toggle, enter to confirm.",
97
+ ...options.map((option, i) => {
98
+ const pointer = i === cursor ? ">" : " ";
99
+ const marker = selected.has(i) ? "[x]" : "[ ]";
100
+ return `${pointer} ${marker} ${option}`;
101
+ })
102
+ ];
103
+ this.output.write(`\x1b[?25l\x1b[2K\r${lines.join("\n")}`);
104
+ this.output.write(`\x1b[${lines.length - 1}A`);
105
+ };
106
+ const finish = () => {
107
+ this.input.off("keypress", onKeypress);
108
+ this.input.setRawMode?.(false);
109
+ this.output.write(`\x1b[?25h\x1b[${options.length + 1}B\n`);
110
+ this.rl.resume();
111
+ resolve([...selected].sort((a, b) => a - b));
112
+ };
113
+ const onKeypress = (_chunk, key) => {
114
+ if (key.ctrl === true && key.name === "c") {
115
+ finish();
116
+ return;
117
+ }
118
+ switch (key.name) {
119
+ case "up":
120
+ cursor = cursor <= 0 ? options.length - 1 : cursor - 1;
121
+ render();
122
+ break;
123
+ case "down":
124
+ cursor = cursor >= options.length - 1 ? 0 : cursor + 1;
125
+ render();
126
+ break;
127
+ case "space":
128
+ if (selected.has(cursor)) {
129
+ selected.delete(cursor);
130
+ }
131
+ else {
132
+ selected.add(cursor);
133
+ }
134
+ render();
135
+ break;
136
+ case "return":
137
+ case "enter":
138
+ finish();
139
+ break;
140
+ }
141
+ };
142
+ this.input.on("keypress", onKeypress);
143
+ render();
144
+ });
145
+ }
146
+ }
147
+ function canUseTerminalPicker(input, output) {
148
+ return input.isTTY === true && output.isTTY === true && typeof input.setRawMode === "function";
149
+ }
150
+ export function parseTextMultiSelectAnswer(answer, optionCount, preSelected) {
151
+ if (answer === "all")
152
+ return { ok: true, indices: Array.from({ length: optionCount }, (_, i) => i) };
153
+ if (answer === "" || answer === "none")
154
+ return { ok: true, indices: preSelected ?? [] };
155
+ const parts = answer.split(",").map((p) => p.trim());
156
+ const indices = [];
157
+ for (const part of parts) {
158
+ const n = Number(part);
159
+ if (!Number.isInteger(n) || n < 1 || n > optionCount) {
160
+ return { ok: false, invalidPart: part };
161
+ }
162
+ indices.push(n - 1);
163
+ }
164
+ return { ok: true, indices: [...new Set(indices)] };
84
165
  }
85
166
  /**
86
167
  * Non-interactive prompter that accepts all defaults.
@@ -0,0 +1,33 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ export class DemoRepoCreator {
4
+ commandRunner;
5
+ constructor(commandRunner) {
6
+ this.commandRunner = commandRunner;
7
+ }
8
+ async createPrivateDemoRepo(options) {
9
+ await fs.mkdir(options.codeRoot, { recursive: true });
10
+ await this.commandRunner.run("gh", [
11
+ "repo",
12
+ "create",
13
+ `${options.owner}/${options.name}`,
14
+ "--private",
15
+ "--clone",
16
+ "--add-readme",
17
+ "--description",
18
+ "Private Backburner onboarding demo repository"
19
+ ], { cwd: options.codeRoot, timeoutMs: 60_000 });
20
+ return {
21
+ owner: options.owner,
22
+ name: options.name,
23
+ localPath: path.join(options.codeRoot, options.name),
24
+ defaultBranch: "main",
25
+ isLocalClone: true,
26
+ canFetch: true,
27
+ canPush: true
28
+ };
29
+ }
30
+ }
31
+ export function defaultDemoRepoName() {
32
+ return "backburner-demo";
33
+ }
@@ -0,0 +1,39 @@
1
+ const LABEL_TIMEOUT_MS = 30_000;
2
+ export class GitHubLabelProvisioner {
3
+ commandRunner;
4
+ constructor(commandRunner) {
5
+ this.commandRunner = commandRunner;
6
+ }
7
+ async provisionLabels(targets) {
8
+ const results = [];
9
+ for (const target of targets) {
10
+ const repoSlug = `${target.owner}/${target.name}`;
11
+ for (const label of target.labels) {
12
+ try {
13
+ await this.commandRunner.run("gh", [
14
+ "label",
15
+ "create",
16
+ label.name,
17
+ "--repo",
18
+ repoSlug,
19
+ "--description",
20
+ label.description,
21
+ "--color",
22
+ label.color,
23
+ "--force"
24
+ ], { timeoutMs: LABEL_TIMEOUT_MS });
25
+ results.push({ repoSlug, labelName: label.name, status: "created_or_updated" });
26
+ }
27
+ catch (err) {
28
+ results.push({
29
+ repoSlug,
30
+ labelName: label.name,
31
+ status: "failed",
32
+ message: err instanceof Error ? err.message : String(err)
33
+ });
34
+ }
35
+ }
36
+ }
37
+ return results;
38
+ }
39
+ }