@backburner/cli 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/LICENSE +21 -0
- package/README.md +329 -0
- package/dist/src/agents/antigravity-launch.js +162 -0
- package/dist/src/agents/antigravity.js +251 -0
- package/dist/src/agents/claude-launch.js +117 -0
- package/dist/src/agents/claude.js +271 -0
- package/dist/src/agents/codex-launch.js +82 -0
- package/dist/src/agents/codex.js +261 -0
- package/dist/src/agents/composite-runner.js +13 -0
- package/dist/src/agents/extraction.js +442 -0
- package/dist/src/agents/failure-feedback.js +132 -0
- package/dist/src/agents/gemini-launch.js +96 -0
- package/dist/src/agents/gemini.js +392 -0
- package/dist/src/agents/job-agent-assignments.js +180 -0
- package/dist/src/agents/lifecycle-feedback.js +147 -0
- package/dist/src/agents/limit-detector.js +83 -0
- package/dist/src/agents/opencode-launch.js +138 -0
- package/dist/src/agents/opencode.js +156 -0
- package/dist/src/agents/plan-breakdown-feedback.js +97 -0
- package/dist/src/agents/pool.js +168 -0
- package/dist/src/agents/prompt.js +462 -0
- package/dist/src/agents/provider-sessions.js +105 -0
- package/dist/src/agents/review-feedback.js +255 -0
- package/dist/src/agents/selection.js +113 -0
- package/dist/src/agents/service.js +1087 -0
- package/dist/src/agents/types.js +1 -0
- package/dist/src/agents/wave-assessment-feedback.js +69 -0
- package/dist/src/attention/derive.js +337 -0
- package/dist/src/capabilities/index.js +2 -0
- package/dist/src/capabilities/projection.js +183 -0
- package/dist/src/capabilities/types.js +1 -0
- package/dist/src/capabilities/validator.js +87 -0
- package/dist/src/cli/commands/broker-smoke.js +96 -0
- package/dist/src/cli/commands/broker.js +56 -0
- package/dist/src/cli/commands/init.js +4 -0
- package/dist/src/cli/commands/journal.js +43 -0
- package/dist/src/cli/commands/run-loop.js +56 -0
- package/dist/src/cli/commands/run.js +1241 -0
- package/dist/src/cli/commands/tui.js +187 -0
- package/dist/src/cli/dispatcher.js +51 -0
- package/dist/src/cli/errors.js +2 -0
- package/dist/src/cli/init.js +92 -0
- package/dist/src/cli/options.js +86 -0
- package/dist/src/cli/output.js +231 -0
- package/dist/src/cli/run-journal.js +342 -0
- package/dist/src/cli/run-types.js +1 -0
- package/dist/src/cli/run.js +32 -0
- package/dist/src/cli/runtime/dispatch-lifecycle.js +772 -0
- package/dist/src/cli/runtime/provider-runtime-types.js +1 -0
- package/dist/src/cli/runtime/provider-runtime.js +152 -0
- package/dist/src/cli/runtime/provider-tools.js +73 -0
- package/dist/src/cli/runtime/providers/antigravity.js +26 -0
- package/dist/src/cli/runtime/providers/claude.js +20 -0
- package/dist/src/cli/runtime/providers/codex.js +20 -0
- package/dist/src/cli/runtime/providers/gemini.js +20 -0
- package/dist/src/cli/runtime/providers/opencode.js +20 -0
- package/dist/src/cli/runtime/run-context.js +131 -0
- package/dist/src/cli/tui/index.js +3 -0
- package/dist/src/cli/tui/screen.js +388 -0
- package/dist/src/cli/tui/tracker.js +558 -0
- package/dist/src/cli/tui/types.js +1 -0
- package/dist/src/cli/tui/views.js +1006 -0
- package/dist/src/config/loader.js +136 -0
- package/dist/src/config/schemas.js +403 -0
- package/dist/src/config/types.js +1 -0
- package/dist/src/context/types.js +331 -0
- package/dist/src/domain/entities.js +1 -0
- package/dist/src/git/backburner-git-tool-service.js +680 -0
- package/dist/src/git/gitops-lifecycle.js +56 -0
- package/dist/src/git/merge-prep-workspace.js +357 -0
- package/dist/src/git/types.js +484 -0
- package/dist/src/github/broker-model-content.js +215 -0
- package/dist/src/github/broker-server.js +237 -0
- package/dist/src/github/broker.js +638 -0
- package/dist/src/github/composed.js +53 -0
- package/dist/src/github/gateway.js +478 -0
- package/dist/src/github/normalize.js +222 -0
- package/dist/src/github/pr-classification.js +73 -0
- package/dist/src/github/scoped-broker.js +35 -0
- package/dist/src/github/security.js +126 -0
- package/dist/src/github/service.js +332 -0
- package/dist/src/github/types.js +1 -0
- package/dist/src/github/utils.js +16 -0
- package/dist/src/journal/index.js +4 -0
- package/dist/src/journal/reader.js +62 -0
- package/dist/src/journal/renderer.js +50 -0
- package/dist/src/journal/service.js +72 -0
- package/dist/src/journal/types.js +1 -0
- package/dist/src/journal/writer.js +19 -0
- package/dist/src/mcps/bridge.js +166 -0
- package/dist/src/mcps/index.js +5 -0
- package/dist/src/mcps/reconcile.js +113 -0
- package/dist/src/mcps/smithery-client.js +186 -0
- package/dist/src/mcps/smithery.js +164 -0
- package/dist/src/mcps/types.js +1 -0
- package/dist/src/memory/json-store.js +71 -0
- package/dist/src/memory/prompt.js +16 -0
- package/dist/src/memory/scope.js +31 -0
- package/dist/src/memory/store.js +1 -0
- package/dist/src/memory/tool-service.js +142 -0
- package/dist/src/memory/types.js +12 -0
- package/dist/src/onboarding/config-writer.js +96 -0
- package/dist/src/onboarding/engine.js +86 -0
- package/dist/src/onboarding/prompt.js +102 -0
- package/dist/src/onboarding/services/provider-discovery.js +58 -0
- package/dist/src/onboarding/services/repo-discovery.js +246 -0
- package/dist/src/onboarding/steps/check-github-auth.js +32 -0
- package/dist/src/onboarding/steps/check-requirements.js +34 -0
- package/dist/src/onboarding/steps/configure-models.js +105 -0
- package/dist/src/onboarding/steps/configure-paths.js +33 -0
- package/dist/src/onboarding/steps/discover-providers.js +38 -0
- package/dist/src/onboarding/steps/discover-repos.js +39 -0
- package/dist/src/onboarding/steps/generate-config.js +106 -0
- package/dist/src/onboarding/steps/readiness-validation.js +158 -0
- package/dist/src/onboarding/steps/select-repos.js +76 -0
- package/dist/src/onboarding/steps/show-summary.js +97 -0
- package/dist/src/onboarding/steps/welcome.js +27 -0
- package/dist/src/onboarding/types.js +1 -0
- package/dist/src/onboarding/validators/git-access.js +63 -0
- package/dist/src/onboarding/validators/github-auth.js +85 -0
- package/dist/src/onboarding/validators/tool-checker.js +153 -0
- package/dist/src/onboarding/validators/workspace.js +83 -0
- package/dist/src/prompts/resolver.js +106 -0
- package/dist/src/retrospectives/candidates.js +224 -0
- package/dist/src/retrospectives/derive.js +169 -0
- package/dist/src/retrospectives/ingest.js +321 -0
- package/dist/src/retrospectives/management-prs.js +380 -0
- package/dist/src/retrospectives/proposals.js +199 -0
- package/dist/src/retrospectives/scopes.js +272 -0
- package/dist/src/retrospectives/scoring.js +171 -0
- package/dist/src/retrospectives/types.js +1 -0
- package/dist/src/state/loader.js +473 -0
- package/dist/src/state/types.js +15 -0
- package/dist/src/tasks/derivation/builders.js +70 -0
- package/dist/src/tasks/derivation/handlers/comment-response.js +169 -0
- package/dist/src/tasks/derivation/handlers/implement-plan.js +103 -0
- package/dist/src/tasks/derivation/handlers/index.js +54 -0
- package/dist/src/tasks/derivation/handlers/packet-pr-control.js +92 -0
- package/dist/src/tasks/derivation/handlers/packet-worktree.js +29 -0
- package/dist/src/tasks/derivation/handlers/plan-breakdown.js +45 -0
- package/dist/src/tasks/derivation/handlers/prepare-for-merge.js +91 -0
- package/dist/src/tasks/derivation/handlers/product-discovery.js +36 -0
- package/dist/src/tasks/derivation/handlers/retry-failed-task.js +125 -0
- package/dist/src/tasks/derivation/handlers/review-pr.js +28 -0
- package/dist/src/tasks/derivation/handlers/shared.js +24 -0
- package/dist/src/tasks/derivation/handlers/sync-parent-branch.js +100 -0
- package/dist/src/tasks/derivation/handlers/wave-assessment.js +223 -0
- package/dist/src/tasks/derivation/handlers/write-plan.js +101 -0
- package/dist/src/tasks/derivation/handlers/write-product-spec.js +49 -0
- package/dist/src/tasks/derivation/runner.js +18 -0
- package/dist/src/tasks/derivation/types.js +1 -0
- package/dist/src/tasks/derive.js +810 -0
- package/dist/src/tasks/mcp-skip.js +10 -0
- package/dist/src/tasks/retry.js +10 -0
- package/dist/src/tasks/task.js +184 -0
- package/dist/src/tasks/types.js +1 -0
- package/dist/src/utils/command-runner.js +192 -0
- package/dist/src/utils/json.js +38 -0
- package/dist/src/utils/logger.js +43 -0
- package/dist/src/utils/paths.js +38 -0
- package/dist/src/utils/slug.js +10 -0
- package/dist/src/workflows/registry.js +174 -0
- package/dist/src/workflows/triage.js +90 -0
- package/dist/src/workflows/workstream-machine/derive-events.js +121 -0
- package/dist/src/workflows/workstream-machine/evaluate.js +24 -0
- package/dist/src/workflows/workstream-machine/events.js +1 -0
- package/dist/src/workflows/workstream-machine/machine.js +355 -0
- package/dist/src/workflows/workstream-machine/selectors.js +19 -0
- package/dist/src/workflows/workstream-machine/types.js +1 -0
- package/dist/src/workstreams/branch-sync.js +87 -0
- package/dist/src/workstreams/derive.js +629 -0
- package/dist/src/workstreams/mcp-blockers.js +420 -0
- package/dist/src/workstreams/packet-candidates.js +42 -0
- package/dist/src/workstreams/packet-lifecycle-feedback.js +60 -0
- package/dist/src/workstreams/packet-lifecycle-report.js +199 -0
- package/dist/src/workstreams/packet-plan.js +597 -0
- package/dist/src/workstreams/packet-projections.js +190 -0
- package/dist/src/workstreams/parent-branch-sync-action.js +312 -0
- package/dist/src/workstreams/parent-branch-sync.js +148 -0
- package/dist/src/workstreams/phases.js +4 -0
- package/dist/src/workstreams/plan-breakdown-generator/export.js +19 -0
- package/dist/src/workstreams/plan-breakdown-generator/index.js +6 -0
- package/dist/src/workstreams/plan-breakdown-generator/inspect.js +50 -0
- package/dist/src/workstreams/plan-breakdown-generator/patch.js +139 -0
- package/dist/src/workstreams/plan-breakdown-generator/session.js +19 -0
- package/dist/src/workstreams/plan-breakdown-generator/store.js +27 -0
- package/dist/src/workstreams/plan-breakdown-generator/tools.js +407 -0
- package/dist/src/workstreams/plan-breakdown-generator/types.js +6 -0
- package/dist/src/workstreams/plan-breakdown-generator/validate.js +212 -0
- package/dist/src/workstreams/plan-breakdown-schema.js +175 -0
- package/dist/src/workstreams/utils.js +15 -0
- package/dist/src/workstreams/wave-assessment/checkpoint.js +172 -0
- package/dist/src/workstreams/wave-assessment/index.js +3 -0
- package/dist/src/workstreams/wave-assessment/session.js +84 -0
- package/dist/src/workstreams/wave-assessment/store.js +51 -0
- package/dist/src/workstreams/wave-assessment/tools.js +419 -0
- package/dist/src/workstreams/wave-assessment/types.js +1 -0
- package/dist/src/workstreams/wave-assessment/validate.js +78 -0
- package/dist/src/worktrees/service.js +839 -0
- package/dist/src/worktrees/workspace-ownership.js +34 -0
- package/dist/src/worktrees/workspace-recovery.js +177 -0
- package/package.json +61 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as readline from "node:readline/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Interactive prompter backed by Node.js readline/promises.
|
|
4
|
+
* Writes prompts to stdout; reads from stdin.
|
|
5
|
+
*/
|
|
6
|
+
export class NodeReadlinePrompter {
|
|
7
|
+
rl;
|
|
8
|
+
constructor() {
|
|
9
|
+
this.rl = readline.createInterface({
|
|
10
|
+
input: process.stdin,
|
|
11
|
+
output: process.stdout
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
close() {
|
|
15
|
+
this.rl.close();
|
|
16
|
+
}
|
|
17
|
+
async ask(question, defaultValue) {
|
|
18
|
+
const hint = defaultValue !== undefined ? ` [${defaultValue}]` : "";
|
|
19
|
+
const answer = (await this.rl.question(`${question}${hint} `)).trim();
|
|
20
|
+
return answer.length > 0 ? answer : (defaultValue ?? "");
|
|
21
|
+
}
|
|
22
|
+
async confirm(question, defaultValue) {
|
|
23
|
+
const hint = defaultValue === true ? "[Y/n]" : defaultValue === false ? "[y/N]" : "[y/n]";
|
|
24
|
+
const answer = (await this.rl.question(`${question} ${hint} `)).trim().toLowerCase();
|
|
25
|
+
if (answer.length === 0)
|
|
26
|
+
return defaultValue ?? false;
|
|
27
|
+
return answer === "y" || answer === "yes";
|
|
28
|
+
}
|
|
29
|
+
async choose(question, options, defaultIndex) {
|
|
30
|
+
const lines = [question];
|
|
31
|
+
for (let i = 0; i < options.length; i++) {
|
|
32
|
+
const marker = i === defaultIndex ? "*" : " ";
|
|
33
|
+
lines.push(` ${marker} ${i + 1}. ${options[i] ?? ""}`);
|
|
34
|
+
}
|
|
35
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
36
|
+
const defaultHint = defaultIndex !== undefined ? ` [${defaultIndex + 1}]` : "";
|
|
37
|
+
// eslint-disable-next-line no-constant-condition
|
|
38
|
+
while (true) {
|
|
39
|
+
const answer = (await this.rl.question(`Enter number${defaultHint}: `)).trim();
|
|
40
|
+
if (answer.length === 0 && defaultIndex !== undefined) {
|
|
41
|
+
return defaultIndex;
|
|
42
|
+
}
|
|
43
|
+
const n = Number(answer);
|
|
44
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) {
|
|
45
|
+
return n - 1;
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(` Please enter a number between 1 and ${options.length}\n`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async multiSelect(question, options, preSelected) {
|
|
51
|
+
const preSet = new Set(preSelected ?? []);
|
|
52
|
+
const lines = [
|
|
53
|
+
question,
|
|
54
|
+
"(Enter comma-separated numbers, 'all' to select all, or leave empty for none)"
|
|
55
|
+
];
|
|
56
|
+
for (let i = 0; i < options.length; i++) {
|
|
57
|
+
const marker = preSet.has(i) ? "[x]" : "[ ]";
|
|
58
|
+
lines.push(` ${marker} ${i + 1}. ${options[i] ?? ""}`);
|
|
59
|
+
}
|
|
60
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
61
|
+
// eslint-disable-next-line no-constant-condition
|
|
62
|
+
while (true) {
|
|
63
|
+
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);
|
|
79
|
+
}
|
|
80
|
+
if (valid)
|
|
81
|
+
return [...new Set(indices)];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Non-interactive prompter that accepts all defaults.
|
|
87
|
+
* Used when --yes is passed or in tests.
|
|
88
|
+
*/
|
|
89
|
+
export class NonInteractivePrompter {
|
|
90
|
+
ask(_question, defaultValue) {
|
|
91
|
+
return Promise.resolve(defaultValue ?? "");
|
|
92
|
+
}
|
|
93
|
+
confirm(_question, defaultValue) {
|
|
94
|
+
return Promise.resolve(defaultValue ?? true);
|
|
95
|
+
}
|
|
96
|
+
choose(_question, _options, defaultIndex) {
|
|
97
|
+
return Promise.resolve(defaultIndex ?? 0);
|
|
98
|
+
}
|
|
99
|
+
multiSelect(_question, options, preSelected) {
|
|
100
|
+
return Promise.resolve(preSelected ?? options.map((_, i) => i));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const PROVIDER_SPECS = [
|
|
2
|
+
{
|
|
3
|
+
name: "claude",
|
|
4
|
+
command: "claude",
|
|
5
|
+
versionArgs: ["--version"],
|
|
6
|
+
versionExtract: (out) => out.trim().split("\n")[0] ?? out.trim()
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: "codex",
|
|
10
|
+
command: "codex",
|
|
11
|
+
versionArgs: ["--version"],
|
|
12
|
+
versionExtract: (out) => out.trim()
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "gemini",
|
|
16
|
+
command: "gemini",
|
|
17
|
+
versionArgs: ["--version"],
|
|
18
|
+
versionExtract: (out) => out.trim()
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "opencode",
|
|
22
|
+
command: "opencode",
|
|
23
|
+
versionArgs: ["--version"],
|
|
24
|
+
versionExtract: (out) => out.trim()
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "antigravity",
|
|
28
|
+
command: "agy",
|
|
29
|
+
versionArgs: ["--version"],
|
|
30
|
+
versionExtract: (out) => out.trim()
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
export class ProviderDiscoveryService {
|
|
34
|
+
commandRunner;
|
|
35
|
+
constructor(commandRunner) {
|
|
36
|
+
this.commandRunner = commandRunner;
|
|
37
|
+
}
|
|
38
|
+
async discoverAll() {
|
|
39
|
+
return Promise.all(PROVIDER_SPECS.map((spec) => this.checkProvider(spec)));
|
|
40
|
+
}
|
|
41
|
+
async checkProvider(spec) {
|
|
42
|
+
try {
|
|
43
|
+
const result = await this.commandRunner.run(spec.command, spec.versionArgs, {
|
|
44
|
+
timeoutMs: 5_000
|
|
45
|
+
});
|
|
46
|
+
const version = spec.versionExtract?.(result.stdout) ?? result.stdout.trim();
|
|
47
|
+
return {
|
|
48
|
+
name: spec.name,
|
|
49
|
+
command: spec.command,
|
|
50
|
+
available: true,
|
|
51
|
+
...(version.length > 0 ? { version } : {})
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { name: spec.name, command: spec.command, available: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
/** Candidate directories to scan for local git repos. */
|
|
5
|
+
const DEFAULT_LOCAL_SEARCH_DIRS = [
|
|
6
|
+
path.join(os.homedir(), "repos"),
|
|
7
|
+
path.join(os.homedir(), "code"),
|
|
8
|
+
path.join(os.homedir(), "projects"),
|
|
9
|
+
path.join(os.homedir(), "src"),
|
|
10
|
+
path.join(os.homedir(), "work")
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* Directory names to skip when scanning for repos. These directories are
|
|
14
|
+
* either too large to scan usefully or are not likely to contain repos.
|
|
15
|
+
*/
|
|
16
|
+
const SKIP_DIRS = new Set([
|
|
17
|
+
"node_modules",
|
|
18
|
+
".git",
|
|
19
|
+
"dist",
|
|
20
|
+
"build",
|
|
21
|
+
".cache",
|
|
22
|
+
".npm",
|
|
23
|
+
".yarn",
|
|
24
|
+
"vendor",
|
|
25
|
+
"coverage"
|
|
26
|
+
]);
|
|
27
|
+
export class RepoDiscoveryService {
|
|
28
|
+
commandRunner;
|
|
29
|
+
constructor(commandRunner) {
|
|
30
|
+
this.commandRunner = commandRunner;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Fetch repos accessible via the authenticated GitHub CLI account.
|
|
34
|
+
*
|
|
35
|
+
* Discovers the user's personal login and all org memberships via the GitHub
|
|
36
|
+
* API, then queries `gh repo list <owner>` for each. Falls back to a plain
|
|
37
|
+
* `gh repo list` (personal repos only) when the API calls fail.
|
|
38
|
+
*/
|
|
39
|
+
async discoverGitHubRepos(limit = 100) {
|
|
40
|
+
const owners = await this.resolveGitHubOwners();
|
|
41
|
+
if (owners.length === 0) {
|
|
42
|
+
return this.listGitHubRepos(undefined, limit);
|
|
43
|
+
}
|
|
44
|
+
const allRepos = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const owner of owners) {
|
|
47
|
+
for (const repo of await this.listGitHubRepos(owner, limit)) {
|
|
48
|
+
const key = `${repo.owner}/${repo.name}`;
|
|
49
|
+
if (!seen.has(key)) {
|
|
50
|
+
seen.add(key);
|
|
51
|
+
allRepos.push(repo);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return allRepos;
|
|
56
|
+
}
|
|
57
|
+
/** Returns the personal login plus all org logins the user is a member of. */
|
|
58
|
+
async resolveGitHubOwners() {
|
|
59
|
+
const owners = [];
|
|
60
|
+
try {
|
|
61
|
+
const userResult = await this.commandRunner.run("gh", ["api", "user", "--jq", ".login"], { timeoutMs: 10_000 });
|
|
62
|
+
const login = userResult.stdout.trim();
|
|
63
|
+
if (login.length > 0)
|
|
64
|
+
owners.push(login);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// ignore — will fall back to unscoped gh repo list
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const orgsResult = await this.commandRunner.run("gh", ["api", "user/orgs", "--jq", ".[].login"], { timeoutMs: 10_000 });
|
|
71
|
+
const orgLogins = orgsResult.stdout
|
|
72
|
+
.trim()
|
|
73
|
+
.split(/\r?\n/)
|
|
74
|
+
.filter((s) => s.length > 0);
|
|
75
|
+
owners.push(...orgLogins);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// ignore — org repos will just be absent
|
|
79
|
+
}
|
|
80
|
+
return owners;
|
|
81
|
+
}
|
|
82
|
+
async listGitHubRepos(owner, limit) {
|
|
83
|
+
try {
|
|
84
|
+
const args = [
|
|
85
|
+
"repo",
|
|
86
|
+
"list",
|
|
87
|
+
...(owner !== undefined ? [owner] : []),
|
|
88
|
+
"--json",
|
|
89
|
+
"name,owner,defaultBranchRef",
|
|
90
|
+
"--limit",
|
|
91
|
+
String(limit)
|
|
92
|
+
];
|
|
93
|
+
const result = await this.commandRunner.run("gh", args, { timeoutMs: 30_000 });
|
|
94
|
+
const repos = JSON.parse(result.stdout);
|
|
95
|
+
return repos.map((repo) => {
|
|
96
|
+
const defaultBranch = repo.defaultBranchRef?.name;
|
|
97
|
+
return {
|
|
98
|
+
owner: repo.owner.login,
|
|
99
|
+
name: repo.name,
|
|
100
|
+
isLocalClone: false,
|
|
101
|
+
...(defaultBranch !== undefined ? { defaultBranch } : {})
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Scan one or more directories for GitHub-hosted git repositories.
|
|
111
|
+
*
|
|
112
|
+
* Supports two layouts:
|
|
113
|
+
* - flat: `<dir>/<repo>/.git` (e.g. ~/repos/my-repo)
|
|
114
|
+
* - owner/name: `<dir>/<owner>/<repo>/.git` (e.g. ~/code/myorg/my-repo)
|
|
115
|
+
*
|
|
116
|
+
* Hidden directories and heavy directories (node_modules, dist, …) are
|
|
117
|
+
* skipped. Traversal is bounded to depth 2.
|
|
118
|
+
*/
|
|
119
|
+
async discoverLocalRepos(searchDirs) {
|
|
120
|
+
const dirs = searchDirs ?? DEFAULT_LOCAL_SEARCH_DIRS;
|
|
121
|
+
const repos = [];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
for (const dir of dirs) {
|
|
124
|
+
await this.scanDir(dir, repos, seen);
|
|
125
|
+
}
|
|
126
|
+
return repos;
|
|
127
|
+
}
|
|
128
|
+
/** Merge GitHub repo list with local discoveries, adding localPath where found. */
|
|
129
|
+
mergeRepos(githubRepos, localRepos) {
|
|
130
|
+
const repoMap = new Map();
|
|
131
|
+
for (const repo of githubRepos) {
|
|
132
|
+
repoMap.set(`${repo.owner}/${repo.name}`, repo);
|
|
133
|
+
}
|
|
134
|
+
for (const local of localRepos) {
|
|
135
|
+
const key = `${local.owner}/${local.name}`;
|
|
136
|
+
const existing = repoMap.get(key);
|
|
137
|
+
if (existing) {
|
|
138
|
+
repoMap.set(key, {
|
|
139
|
+
...existing,
|
|
140
|
+
isLocalClone: true,
|
|
141
|
+
...(local.localPath !== undefined ? { localPath: local.localPath } : {})
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
repoMap.set(key, local);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Array.from(repoMap.values());
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Scan `dir` at up to depth 2.
|
|
152
|
+
*
|
|
153
|
+
* - Depth 1: `<dir>/<entry>` — try as a repo; if not, recurse into it as a
|
|
154
|
+
* potential owner directory (to handle `<dir>/<owner>/<repo>`).
|
|
155
|
+
* - Depth 2: `<dir>/<owner>/<entry>` — try as a repo only (no further recursion).
|
|
156
|
+
*/
|
|
157
|
+
async scanDir(dir, repos, seen) {
|
|
158
|
+
let entries;
|
|
159
|
+
try {
|
|
160
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (!entry.isDirectory())
|
|
167
|
+
continue;
|
|
168
|
+
if (shouldSkip(entry.name))
|
|
169
|
+
continue;
|
|
170
|
+
const childPath = path.join(dir, entry.name);
|
|
171
|
+
// Try it as a direct repo first.
|
|
172
|
+
const repo = await this.tryReadGitRepo(childPath);
|
|
173
|
+
if (repo !== undefined) {
|
|
174
|
+
addUnique(repo, repos, seen);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Not a repo — treat it as a potential owner directory and scan one level
|
|
178
|
+
// deeper (bounded; we do not recurse further from depth-2 entries).
|
|
179
|
+
let subEntries;
|
|
180
|
+
try {
|
|
181
|
+
subEntries = await fs.readdir(childPath, { withFileTypes: true });
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
for (const sub of subEntries) {
|
|
187
|
+
if (!sub.isDirectory())
|
|
188
|
+
continue;
|
|
189
|
+
if (shouldSkip(sub.name))
|
|
190
|
+
continue;
|
|
191
|
+
const subPath = path.join(childPath, sub.name);
|
|
192
|
+
const subRepo = await this.tryReadGitRepo(subPath);
|
|
193
|
+
if (subRepo !== undefined) {
|
|
194
|
+
addUnique(subRepo, repos, seen);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async tryReadGitRepo(dirPath) {
|
|
200
|
+
try {
|
|
201
|
+
await fs.access(path.join(dirPath, ".git"));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const result = await this.commandRunner.run("git", ["remote", "get-url", "origin"], { cwd: dirPath, timeoutMs: 5_000 });
|
|
208
|
+
const parsed = parseGitHubRemoteUrl(result.stdout.trim());
|
|
209
|
+
if (!parsed)
|
|
210
|
+
return undefined;
|
|
211
|
+
return {
|
|
212
|
+
owner: parsed.owner,
|
|
213
|
+
name: parsed.name,
|
|
214
|
+
localPath: dirPath,
|
|
215
|
+
isLocalClone: true
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function shouldSkip(name) {
|
|
224
|
+
return name.startsWith(".") || SKIP_DIRS.has(name);
|
|
225
|
+
}
|
|
226
|
+
function addUnique(repo, repos, seen) {
|
|
227
|
+
const key = repo.localPath ?? `${repo.owner}/${repo.name}`;
|
|
228
|
+
if (!seen.has(key)) {
|
|
229
|
+
seen.add(key);
|
|
230
|
+
repos.push(repo);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function parseGitHubRemoteUrl(url) {
|
|
234
|
+
// SSH: git@github.com:owner/repo.git
|
|
235
|
+
const ssh = url.match(/git@github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
236
|
+
if (ssh?.[1] && ssh[2]) {
|
|
237
|
+
return { owner: ssh[1], name: ssh[2] };
|
|
238
|
+
}
|
|
239
|
+
// HTTPS: https://github.com/owner/repo or https://github.com/owner/repo.git
|
|
240
|
+
const https = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\s|$)/);
|
|
241
|
+
if (https?.[1] && https[2]) {
|
|
242
|
+
return { owner: https[1], name: https[2] };
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
export { parseGitHubRemoteUrl };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { GitHubAuthChecker } from "../validators/github-auth.js";
|
|
2
|
+
export class CheckGitHubAuthStep {
|
|
3
|
+
commandRunner;
|
|
4
|
+
name = "check-github-auth";
|
|
5
|
+
description = "Validating GitHub authentication";
|
|
6
|
+
constructor(commandRunner) {
|
|
7
|
+
this.commandRunner = commandRunner;
|
|
8
|
+
}
|
|
9
|
+
async run(ctx, _prompter) {
|
|
10
|
+
const checker = new GitHubAuthChecker(this.commandRunner);
|
|
11
|
+
const authResult = await checker.check();
|
|
12
|
+
if (!authResult.authenticated) {
|
|
13
|
+
return {
|
|
14
|
+
status: "requiresUserAction",
|
|
15
|
+
message: "Not authenticated with GitHub",
|
|
16
|
+
issues: authResult.issue !== undefined ? [authResult.issue] : []
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
let username = authResult.username;
|
|
20
|
+
if (username === undefined || username.length === 0) {
|
|
21
|
+
username = await checker.getAuthenticatedUser();
|
|
22
|
+
}
|
|
23
|
+
if (username !== undefined && username.length > 0) {
|
|
24
|
+
ctx.githubUser = username;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
status: "passed",
|
|
28
|
+
message: `Authenticated as ${username ?? "unknown"}`,
|
|
29
|
+
...(username !== undefined ? { details: [`GitHub user: ${username}`] } : {})
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ToolChecker } from "../validators/tool-checker.js";
|
|
2
|
+
export class CheckRequirementsStep {
|
|
3
|
+
commandRunner;
|
|
4
|
+
name = "check-requirements";
|
|
5
|
+
description = "Checking required tools";
|
|
6
|
+
constructor(commandRunner) {
|
|
7
|
+
this.commandRunner = commandRunner;
|
|
8
|
+
}
|
|
9
|
+
async run(ctx, _prompter) {
|
|
10
|
+
const checker = new ToolChecker(this.commandRunner);
|
|
11
|
+
const results = await checker.checkAll();
|
|
12
|
+
const missing = results.filter((r) => !r.available);
|
|
13
|
+
const details = results.map((r) => r.available ? `✓ ${r.name}: ${r.version ?? "found"}` : `✗ ${r.name}: not found`);
|
|
14
|
+
if (missing.length === 0) {
|
|
15
|
+
return {
|
|
16
|
+
status: "passed",
|
|
17
|
+
message: `All ${results.length} required tools found`,
|
|
18
|
+
details
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
for (const r of missing) {
|
|
22
|
+
ctx.warnings.push(`Required tool not found: ${r.name} (${r.command})`);
|
|
23
|
+
}
|
|
24
|
+
const issues = missing
|
|
25
|
+
.map((r) => r.issue)
|
|
26
|
+
.filter((issue) => issue !== undefined);
|
|
27
|
+
return {
|
|
28
|
+
status: "failed",
|
|
29
|
+
message: `${missing.length} required tool(s) missing: ${missing.map((r) => r.name).join(", ")}`,
|
|
30
|
+
details,
|
|
31
|
+
issues
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const STRONG_ROLES = [
|
|
2
|
+
"product",
|
|
3
|
+
"planning",
|
|
4
|
+
"review",
|
|
5
|
+
"discussion",
|
|
6
|
+
"implementation_manager",
|
|
7
|
+
"retrospective"
|
|
8
|
+
];
|
|
9
|
+
const CHEAP_ROLES = ["implementation", "discussion"];
|
|
10
|
+
/** Suggested default model names per provider. */
|
|
11
|
+
const DEFAULT_MODELS = {
|
|
12
|
+
claude: { strong: "claude-sonnet-4-6" },
|
|
13
|
+
codex: { strong: "o3" },
|
|
14
|
+
gemini: { strong: "gemini-3.1-pro-preview", cheap: "gemini-3-flash-preview" },
|
|
15
|
+
opencode: { strong: "anthropic/claude-sonnet-4-6", cheap: "google/gemini-2.0-flash" },
|
|
16
|
+
antigravity: { strong: "Gemini 3.1 Pro (High)", cheap: "Gemini 3.5 Flash (Low)" }
|
|
17
|
+
};
|
|
18
|
+
/** Preference order when choosing defaults for the user. */
|
|
19
|
+
const STRONG_PREFERENCE = ["antigravity", "gemini", "claude", "codex", "opencode"];
|
|
20
|
+
const CHEAP_PREFERENCE = ["antigravity", "gemini", "claude", "codex", "opencode"];
|
|
21
|
+
export class ConfigureModelsStep {
|
|
22
|
+
name = "configure-models";
|
|
23
|
+
description = "Configure model assignments";
|
|
24
|
+
async run(ctx, prompter) {
|
|
25
|
+
const available = ctx.discoveredProviders.filter((p) => p.available);
|
|
26
|
+
if (available.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
status: "skipped",
|
|
29
|
+
message: "Skipped: no providers available to configure"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const providerOptions = available.map((p) => `${p.name}${p.version !== undefined ? ` (${p.version})` : ""}`);
|
|
33
|
+
const providerNames = available.map((p) => p.name);
|
|
34
|
+
const defaultStrongIdx = indexOfFirstMatch(providerNames, STRONG_PREFERENCE);
|
|
35
|
+
const defaultCheapIdx = indexOfFirstMatch(providerNames, CHEAP_PREFERENCE);
|
|
36
|
+
let strongIdx;
|
|
37
|
+
let cheapIdx;
|
|
38
|
+
if (available.length === 1) {
|
|
39
|
+
strongIdx = 0;
|
|
40
|
+
cheapIdx = 0;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
process.stdout.write("\nStrong role tasks: product, planning, review, discussion, implementation_manager, retrospective\n");
|
|
44
|
+
strongIdx = await prompter.choose("Provider for strong tasks:", providerOptions, defaultStrongIdx);
|
|
45
|
+
process.stdout.write("\nCheap role tasks: implementation, discussion\n");
|
|
46
|
+
cheapIdx = await prompter.choose("Provider for cheap tasks:", providerOptions, defaultCheapIdx);
|
|
47
|
+
}
|
|
48
|
+
const strongProvider = available[strongIdx];
|
|
49
|
+
const cheapProvider = available[cheapIdx];
|
|
50
|
+
if (strongProvider === undefined || cheapProvider === undefined) {
|
|
51
|
+
return { status: "failed", message: "Provider selection out of range" };
|
|
52
|
+
}
|
|
53
|
+
const strongDefaults = DEFAULT_MODELS[strongProvider.name];
|
|
54
|
+
const cheapDefaults = DEFAULT_MODELS[cheapProvider.name];
|
|
55
|
+
const strongModel = await prompter.ask(`Model for ${strongProvider.name} (strong):`, strongDefaults.strong);
|
|
56
|
+
const cheapModel = await prompter.ask(`Model for ${cheapProvider.name} (cheap):`, cheapDefaults.cheap ?? cheapDefaults.strong);
|
|
57
|
+
const agents = [
|
|
58
|
+
buildAgent(`${strongProvider.name}-strong`, strongProvider.name, strongProvider.command, "strong", STRONG_ROLES, strongModel || strongDefaults.strong),
|
|
59
|
+
buildAgent(`${cheapProvider.name}-cheap`, cheapProvider.name, cheapProvider.command, "cheap", CHEAP_ROLES, cheapModel || (cheapDefaults.cheap ?? cheapDefaults.strong))
|
|
60
|
+
];
|
|
61
|
+
if (ctx.configDraft === undefined) {
|
|
62
|
+
ctx.configDraft = buildEmptyDraft(ctx);
|
|
63
|
+
}
|
|
64
|
+
ctx.configDraft.agents = agents;
|
|
65
|
+
return {
|
|
66
|
+
status: "passed",
|
|
67
|
+
message: `Configured ${agents.length} agents`,
|
|
68
|
+
details: agents.map((a) => {
|
|
69
|
+
const modelNote = a.model !== undefined ? ` (${a.model})` : "";
|
|
70
|
+
return `${a.id}: ${a.provider}${modelNote} [${a.modelClass}] → ${a.roles.join(", ")}`;
|
|
71
|
+
})
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function indexOfFirstMatch(names, preference) {
|
|
76
|
+
for (const pref of preference) {
|
|
77
|
+
const idx = names.indexOf(pref);
|
|
78
|
+
if (idx >= 0)
|
|
79
|
+
return idx;
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
function buildAgent(id, provider, command, modelClass, roles, model) {
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
provider,
|
|
87
|
+
command,
|
|
88
|
+
modelClass,
|
|
89
|
+
roles,
|
|
90
|
+
enabled: true,
|
|
91
|
+
...(model !== undefined && model.length > 0 ? { model } : {})
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function buildEmptyDraft(ctx) {
|
|
95
|
+
return {
|
|
96
|
+
codeRoot: ctx.codeRoot,
|
|
97
|
+
managementDir: ctx.managementDir,
|
|
98
|
+
outputsDir: ctx.outputsDir,
|
|
99
|
+
logDir: ctx.logDir,
|
|
100
|
+
workspacesDir: ctx.workspacesDir,
|
|
101
|
+
allowedUsers: ctx.githubUser !== undefined ? [ctx.githubUser] : [],
|
|
102
|
+
repos: [],
|
|
103
|
+
agents: []
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, defaultWorkspacesRootForCodeRoot } from "../../utils/paths.js";
|
|
3
|
+
export class ConfigurePathsStep {
|
|
4
|
+
name = "configure-paths";
|
|
5
|
+
description = "Configure directories";
|
|
6
|
+
async run(ctx, prompter) {
|
|
7
|
+
// Use current ctx values as defaults so that CLI-supplied flags are preserved.
|
|
8
|
+
// Only fall back to ~/.backburner/* when the context has no value (shouldn't happen
|
|
9
|
+
// if runInit initialised ctx properly, but is a safety net).
|
|
10
|
+
const defaultManagement = ctx.managementDir || defaultBackburnerManagementRoot();
|
|
11
|
+
const defaultOutputs = ctx.outputsDir || defaultBackburnerOutputsRoot();
|
|
12
|
+
const defaultLog = ctx.logDir || path.join(defaultOutputs, "logs");
|
|
13
|
+
const defaultWorkspaces = ctx.workspacesDir || defaultWorkspacesRootForCodeRoot(ctx.codeRoot);
|
|
14
|
+
const managementDir = await prompter.ask("Management directory (Backburner config and prompt files):", defaultManagement);
|
|
15
|
+
const outputsDir = await prompter.ask("Outputs directory (state, logs):", defaultOutputs);
|
|
16
|
+
const logDir = await prompter.ask("Log directory:", ctx.logDir || path.join(outputsDir || defaultOutputs, "logs"));
|
|
17
|
+
const workspacesDir = await prompter.ask("Local workspaces directory (where Backburner creates git worktrees):", defaultWorkspaces);
|
|
18
|
+
ctx.managementDir = managementDir || defaultManagement;
|
|
19
|
+
ctx.outputsDir = outputsDir || defaultOutputs;
|
|
20
|
+
ctx.logDir = logDir || path.join(ctx.outputsDir, "logs");
|
|
21
|
+
ctx.workspacesDir = workspacesDir || defaultWorkspaces;
|
|
22
|
+
return {
|
|
23
|
+
status: "passed",
|
|
24
|
+
message: "Directories configured",
|
|
25
|
+
details: [
|
|
26
|
+
`Management: ${ctx.managementDir}`,
|
|
27
|
+
`Outputs: ${ctx.outputsDir}`,
|
|
28
|
+
`Logs: ${ctx.logDir}`,
|
|
29
|
+
`Workspaces: ${ctx.workspacesDir}`
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ProviderDiscoveryService } from "../services/provider-discovery.js";
|
|
2
|
+
export class DiscoverProvidersStep {
|
|
3
|
+
commandRunner;
|
|
4
|
+
name = "discover-providers";
|
|
5
|
+
description = "Detecting available AI providers";
|
|
6
|
+
constructor(commandRunner) {
|
|
7
|
+
this.commandRunner = commandRunner;
|
|
8
|
+
}
|
|
9
|
+
async run(ctx, _prompter) {
|
|
10
|
+
const service = new ProviderDiscoveryService(this.commandRunner);
|
|
11
|
+
ctx.discoveredProviders = await service.discoverAll();
|
|
12
|
+
const available = ctx.discoveredProviders.filter((p) => p.available);
|
|
13
|
+
const unavailable = ctx.discoveredProviders.filter((p) => !p.available);
|
|
14
|
+
const details = ctx.discoveredProviders.map((p) => p.available
|
|
15
|
+
? `✓ ${p.name} (${p.command}): ${p.version ?? "found"}`
|
|
16
|
+
: `✗ ${p.name} (${p.command}): not found`);
|
|
17
|
+
if (available.length === 0) {
|
|
18
|
+
ctx.warnings.push("No AI providers found. Install at least one of: antigravity, gemini, claude, codex, opencode");
|
|
19
|
+
return {
|
|
20
|
+
status: "warning",
|
|
21
|
+
message: "No AI providers found",
|
|
22
|
+
details: [
|
|
23
|
+
...details,
|
|
24
|
+
"",
|
|
25
|
+
"Install at least one provider before running agents."
|
|
26
|
+
]
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const unavailableNote = unavailable.length > 0
|
|
30
|
+
? ` (${unavailable.length} not found)`
|
|
31
|
+
: "";
|
|
32
|
+
return {
|
|
33
|
+
status: "passed",
|
|
34
|
+
message: `${available.length} provider${available.length === 1 ? "" : "s"} available: ${available.map((p) => p.name).join(", ")}${unavailableNote}`,
|
|
35
|
+
details
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|