@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,147 @@
|
|
|
1
|
+
import { Task } from "../tasks/task.js";
|
|
2
|
+
export class GitHubTaskLifecycleNotifier {
|
|
3
|
+
gitHubGateway;
|
|
4
|
+
logger;
|
|
5
|
+
reposById;
|
|
6
|
+
constructor(gitHubGateway, repos, logger) {
|
|
7
|
+
this.gitHubGateway = gitHubGateway;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.reposById = new Map(repos.map((repo) => [repo.id, repo]));
|
|
10
|
+
}
|
|
11
|
+
async notifyDispatch(taskRecord, { isRetry }) {
|
|
12
|
+
const task = Task.from(taskRecord);
|
|
13
|
+
const repo = this.reposById.get(taskRecord.repoId);
|
|
14
|
+
if (!repo) {
|
|
15
|
+
this.logger?.warn(`Could not resolve repo ${taskRecord.repoId} for task lifecycle feedback.`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
// Best-effort reaction
|
|
19
|
+
try {
|
|
20
|
+
const eyesSourceNumber = task.getSourceEntityNumber();
|
|
21
|
+
const eyesCommentId = task.getSourceCommentId();
|
|
22
|
+
const eyesCommentKind = task.getSourceCommentKind();
|
|
23
|
+
await this.gitHubGateway.addReaction({
|
|
24
|
+
owner: repo.owner,
|
|
25
|
+
name: repo.name,
|
|
26
|
+
...(eyesSourceNumber !== undefined ? { issueNumber: eyesSourceNumber } : {}),
|
|
27
|
+
...(eyesCommentId !== undefined ? { commentId: eyesCommentId } : {}),
|
|
28
|
+
...(eyesCommentKind !== undefined ? { commentKind: eyesCommentKind } : {}),
|
|
29
|
+
content: "eyes"
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
this.logger?.warn(`Failed to add reaction for task ${taskRecord.id}: ${error}`);
|
|
34
|
+
}
|
|
35
|
+
// Best-effort comment
|
|
36
|
+
try {
|
|
37
|
+
const threadNumber = task.owningThreadNumber();
|
|
38
|
+
if (threadNumber !== undefined) {
|
|
39
|
+
const suffix = isRetry ? " (this is a re-run)" : "";
|
|
40
|
+
const body = `🤖 I noticed this task and I'm starting work on it: **${taskRecord.summary}**${suffix}`;
|
|
41
|
+
await this.gitHubGateway.postIssueComment({
|
|
42
|
+
owner: repo.owner,
|
|
43
|
+
name: repo.name,
|
|
44
|
+
issueNumber: threadNumber,
|
|
45
|
+
body
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
this.logger?.warn(`Failed to post dispatch lifecycle feedback for task ${taskRecord.id}: ${error}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async notifyCompletionStatus(taskRecord, status) {
|
|
54
|
+
const task = Task.from(taskRecord);
|
|
55
|
+
const repo = this.reposById.get(taskRecord.repoId);
|
|
56
|
+
if (!repo)
|
|
57
|
+
return;
|
|
58
|
+
try {
|
|
59
|
+
const sourceNumber = task.getSourceEntityNumber();
|
|
60
|
+
const commentId = task.getSourceCommentId();
|
|
61
|
+
const commentKind = task.getSourceCommentKind();
|
|
62
|
+
await this.gitHubGateway.addReaction({
|
|
63
|
+
owner: repo.owner,
|
|
64
|
+
name: repo.name,
|
|
65
|
+
...(sourceNumber !== undefined ? { issueNumber: sourceNumber } : {}),
|
|
66
|
+
...(commentId !== undefined ? { commentId: commentId } : {}),
|
|
67
|
+
...(commentKind !== undefined ? { commentKind } : {}),
|
|
68
|
+
content: status === "completed" ? "+1" : "-1"
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
this.logger?.warn(`Failed to post completion reaction for task ${taskRecord.id}: ${error}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async notifyQueued(taskRecord, providers, resetTime) {
|
|
76
|
+
const task = Task.from(taskRecord);
|
|
77
|
+
const repo = this.reposById.get(taskRecord.repoId);
|
|
78
|
+
if (!repo) {
|
|
79
|
+
this.logger?.warn(`Could not resolve repo ${taskRecord.repoId} for task lifecycle feedback.`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const providerList = Array.isArray(providers) ? providers : [providers];
|
|
83
|
+
const providersStr = providerList.map(p => `\`${p}\``).join(", ");
|
|
84
|
+
try {
|
|
85
|
+
const threadNumber = task.owningThreadNumber();
|
|
86
|
+
if (threadNumber !== undefined) {
|
|
87
|
+
const resetMsg = resetTime ? ` until ${resetTime}` : "";
|
|
88
|
+
const providerText = providerList.length > 1 ? "providers" : "provider";
|
|
89
|
+
const body = `🤖 I've queued this task because the AI ${providerText} (${providersStr}) has hit its session/rate limit${resetMsg}. It will resume automatically once the limit resets.`;
|
|
90
|
+
await this.gitHubGateway.postIssueComment({
|
|
91
|
+
owner: repo.owner,
|
|
92
|
+
name: repo.name,
|
|
93
|
+
issueNumber: threadNumber,
|
|
94
|
+
body
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
this.logger?.warn(`Failed to post queued lifecycle feedback for task ${taskRecord.id}: ${error}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async notifyResumed(taskRecord) {
|
|
103
|
+
const task = Task.from(taskRecord);
|
|
104
|
+
const repo = this.reposById.get(taskRecord.repoId);
|
|
105
|
+
if (!repo) {
|
|
106
|
+
this.logger?.warn(`Could not resolve repo ${taskRecord.repoId} for task lifecycle feedback.`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const threadNumber = task.owningThreadNumber();
|
|
111
|
+
if (threadNumber !== undefined) {
|
|
112
|
+
const body = `🤖 Resuming task: **${taskRecord.summary}**`;
|
|
113
|
+
await this.gitHubGateway.postIssueComment({
|
|
114
|
+
owner: repo.owner,
|
|
115
|
+
name: repo.name,
|
|
116
|
+
issueNumber: threadNumber,
|
|
117
|
+
body
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.logger?.warn(`Failed to post resumed lifecycle feedback for task ${taskRecord.id}: ${error}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async notifyCanceled(taskRecord) {
|
|
126
|
+
const task = Task.from(taskRecord);
|
|
127
|
+
const repo = this.reposById.get(taskRecord.repoId);
|
|
128
|
+
if (!repo) {
|
|
129
|
+
this.logger?.warn(`Could not resolve repo ${taskRecord.repoId} for task lifecycle feedback.`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const threadNumber = task.owningThreadNumber();
|
|
134
|
+
if (threadNumber !== undefined) {
|
|
135
|
+
await this.gitHubGateway.postIssueComment({
|
|
136
|
+
owner: repo.owner,
|
|
137
|
+
name: repo.name,
|
|
138
|
+
issueNumber: threadNumber,
|
|
139
|
+
body: "🤖 Task was canceled or the PR/issue was closed while queued."
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
this.logger?.warn(`Failed to post canceled lifecycle feedback for task ${taskRecord.id}: ${error}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export function detectProviderLimit(provider, exitCode, stdout, stderr) {
|
|
2
|
+
const combined = (stdout + "\n" + stderr).toLowerCase();
|
|
3
|
+
let isLimited = false;
|
|
4
|
+
let limitType = undefined;
|
|
5
|
+
if (exitCode === 429) {
|
|
6
|
+
isLimited = true;
|
|
7
|
+
limitType = "session";
|
|
8
|
+
}
|
|
9
|
+
if (provider === "claude") {
|
|
10
|
+
if (combined.includes("rate limit exceeded") || combined.includes("quota exceeded") || combined.includes("too many requests") || exitCode === 429) {
|
|
11
|
+
isLimited = true;
|
|
12
|
+
}
|
|
13
|
+
if (combined.includes("5-hour limit") || combined.includes("five-hour limit") || combined.includes("session limit")) {
|
|
14
|
+
isLimited = true;
|
|
15
|
+
limitType = "session";
|
|
16
|
+
}
|
|
17
|
+
if (combined.includes("weekly limit")) {
|
|
18
|
+
isLimited = true;
|
|
19
|
+
limitType = "weekly";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (provider === "codex") {
|
|
23
|
+
if (/rate limit|quota exceeded|billing limit/i.test(combined) || combined.includes("budget exceeded") || combined.includes("thread rate limit") || combined.includes("too many requests") || exitCode === 429) {
|
|
24
|
+
isLimited = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (provider === "gemini") {
|
|
28
|
+
if (combined.includes("quota_exhausted") ||
|
|
29
|
+
combined.includes("resource_exhausted") ||
|
|
30
|
+
combined.includes("model_capacity_exhausted") ||
|
|
31
|
+
combined.includes("quota exhausted") ||
|
|
32
|
+
combined.includes("rate limit") ||
|
|
33
|
+
combined.includes("limit exceeded") ||
|
|
34
|
+
combined.includes("too many requests") ||
|
|
35
|
+
exitCode === 429) {
|
|
36
|
+
isLimited = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (provider === "opencode") {
|
|
40
|
+
if (/rate limit|exhausted|quota/i.test(combined) || combined.includes("limit exceeded") || combined.includes("too many requests") || exitCode === 429) {
|
|
41
|
+
isLimited = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (provider === "antigravity") {
|
|
45
|
+
if (/rate limit|usage cap|quota|session limit/i.test(combined) || combined.includes("limit exceeded") || combined.includes("too many requests") || exitCode === 429) {
|
|
46
|
+
isLimited = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (isLimited) {
|
|
50
|
+
const now = new Date();
|
|
51
|
+
let resetTime;
|
|
52
|
+
if (limitType === "weekly") {
|
|
53
|
+
resetTime = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
54
|
+
}
|
|
55
|
+
else if (limitType === "session" || combined.includes("5-hour limit")) {
|
|
56
|
+
limitType = "session";
|
|
57
|
+
resetTime = new Date(now.getTime() + 5 * 60 * 60 * 1000);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
resetTime = new Date(now.getTime() + 1 * 60 * 60 * 1000);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
provider,
|
|
64
|
+
isLimited: true,
|
|
65
|
+
limitType,
|
|
66
|
+
rawError: stderr.trim() || stdout.trim() || `Exit code ${exitCode}`,
|
|
67
|
+
detectedAt: now.toISOString(),
|
|
68
|
+
resetTime: resetTime.toISOString()
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
export function mapSignalToRecord(signal, nowIso) {
|
|
74
|
+
return {
|
|
75
|
+
provider: signal.provider,
|
|
76
|
+
status: signal.isLimited ? "limited" : "available",
|
|
77
|
+
limitType: signal.limitType,
|
|
78
|
+
rawError: signal.rawError,
|
|
79
|
+
detectedAt: signal.detectedAt,
|
|
80
|
+
resetTime: signal.resetTime,
|
|
81
|
+
updatedAt: nowIso
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { writeJsonFileAtomic } from "../utils/json.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds OpenCode CLI arguments.
|
|
4
|
+
* Prompt is passed as the last positional argument.
|
|
5
|
+
*/
|
|
6
|
+
export function buildOpenCodeCliArgs(options) {
|
|
7
|
+
return [
|
|
8
|
+
"run",
|
|
9
|
+
"--model",
|
|
10
|
+
options.model,
|
|
11
|
+
"--format",
|
|
12
|
+
"json",
|
|
13
|
+
"--dir",
|
|
14
|
+
options.repoPath,
|
|
15
|
+
options.promptText
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Writes the per-run OpenCode config JSON that wires the broker MCP server.
|
|
20
|
+
*/
|
|
21
|
+
export async function writeOpenCodeConfig(configPath, options) {
|
|
22
|
+
const config = options.mcp ? buildMcpConfig(options.mcp) : {};
|
|
23
|
+
await writeJsonFileAtomic(configPath, config);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Normalizes an MCP tool name for OpenCode.
|
|
27
|
+
* OpenCode replaces characters outside [a-zA-Z0-9_-] with _.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeOpenCodeMcpToolName(serverName, toolName) {
|
|
30
|
+
const sanitizedTool = toolName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
31
|
+
return `${serverName}_${sanitizedTool}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Builds the MCP configuration block.
|
|
35
|
+
*/
|
|
36
|
+
function buildMcpConfig(mcp) {
|
|
37
|
+
const tools = {
|
|
38
|
+
[`${mcp.serverName}_*`]: false
|
|
39
|
+
};
|
|
40
|
+
for (const tool of mcp.toolAllowList) {
|
|
41
|
+
tools[normalizeOpenCodeMcpToolName(mcp.serverName, tool)] = true;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
mcp: {
|
|
45
|
+
[mcp.serverName]: {
|
|
46
|
+
type: "remote",
|
|
47
|
+
url: mcp.serverUrl
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
tools
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns the env var that points OpenCode at the generated config.
|
|
55
|
+
*/
|
|
56
|
+
export function getOpenCodeEnvOverrides(configPath) {
|
|
57
|
+
return { OPENCODE_CONFIG: configPath };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Normalizes OpenCode --format json output into assistant text.
|
|
61
|
+
* Skips non-JSON lines and accumulates content from text/message events.
|
|
62
|
+
*/
|
|
63
|
+
export function extractOpenCodeAssistantText(streamText) {
|
|
64
|
+
if (!streamText.trim()) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
let assistantText = "";
|
|
68
|
+
let sawAssistantMessage = false;
|
|
69
|
+
let fallbackResult;
|
|
70
|
+
for (const line of streamText.split(/\r?\n/)) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let event;
|
|
76
|
+
try {
|
|
77
|
+
event = JSON.parse(trimmed);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!event || typeof event !== "object") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Primary: type "text" with part.text (observed in implementation)
|
|
86
|
+
if (event.type === "text" && event.part && typeof event.part.text === "string") {
|
|
87
|
+
assistantText += event.part.text;
|
|
88
|
+
sawAssistantMessage = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Secondary: type "message" with content (standard provider event)
|
|
92
|
+
if (event.type === "message" && event.role === "assistant" && typeof event.content === "string") {
|
|
93
|
+
assistantText += event.content;
|
|
94
|
+
sawAssistantMessage = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Fallback: type "result" or "output"
|
|
98
|
+
if (!sawAssistantMessage && !fallbackResult) {
|
|
99
|
+
if (event.type === "result" || event.type === "output") {
|
|
100
|
+
if (typeof event.text === "string") {
|
|
101
|
+
fallbackResult = event.text;
|
|
102
|
+
}
|
|
103
|
+
else if (event.part && typeof event.part.text === "string") {
|
|
104
|
+
fallbackResult = event.part.text;
|
|
105
|
+
}
|
|
106
|
+
else if (typeof event.content === "string") {
|
|
107
|
+
fallbackResult = event.content;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (sawAssistantMessage && assistantText.trim()) {
|
|
113
|
+
return assistantText;
|
|
114
|
+
}
|
|
115
|
+
return fallbackResult;
|
|
116
|
+
}
|
|
117
|
+
export function buildGlobalOpenCodeLaunchInstructions(input) {
|
|
118
|
+
return [
|
|
119
|
+
"# Global OpenCode Instructions",
|
|
120
|
+
"",
|
|
121
|
+
"These instructions apply across repositories for this orchestrated run.",
|
|
122
|
+
"",
|
|
123
|
+
"## MCP Server",
|
|
124
|
+
`Use the local MCP server \`${input.mcp.serverName}\` for managed GitHub operations.`,
|
|
125
|
+
"- Invoke broker tools instead of using \`gh\` directly.",
|
|
126
|
+
"",
|
|
127
|
+
"Enabled broker tools for this run:",
|
|
128
|
+
...input.mcp.toolAllowList.map((tool) => `- \`${normalizeOpenCodeMcpToolName(input.mcp.serverName, tool)}\``),
|
|
129
|
+
"",
|
|
130
|
+
"Any GitHub-authored message you post must begin with \`🤖\`."
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
export function prependGlobalOpenCodeLaunchInstructions(promptText, options) {
|
|
134
|
+
if (!options.mcp) {
|
|
135
|
+
return promptText;
|
|
136
|
+
}
|
|
137
|
+
return `${buildGlobalOpenCodeLaunchInstructions({ mcp: options.mcp })}\n\n${promptText}`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writeJsonFileAtomic } from "../utils/json.js";
|
|
4
|
+
import { buildOpenCodeCliArgs, extractOpenCodeAssistantText, getOpenCodeEnvOverrides, prependGlobalOpenCodeLaunchInstructions, writeOpenCodeConfig } from "./opencode-launch.js";
|
|
5
|
+
import { buildExecutionId, extractAndExportPlanBreakdownResult, extractAndExportWaveAssessmentResult, extractExitCode, extractReviewResult, extractStderr, extractStdout, extractTriageResult, detectProviderLimit } from "./extraction.js";
|
|
6
|
+
import { buildPromptPacket, renderPrompt } from "./prompt.js";
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
8
|
+
export class OpenCodeCliAgent {
|
|
9
|
+
options;
|
|
10
|
+
now;
|
|
11
|
+
timeoutMs;
|
|
12
|
+
logger;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
16
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
17
|
+
this.logger = options.logger;
|
|
18
|
+
}
|
|
19
|
+
async dispatch(task, context, agentConfig, _sessionDecision, promptContent) {
|
|
20
|
+
const promptPacket = buildPromptPacket(task, context);
|
|
21
|
+
const promptText = prependGlobalOpenCodeLaunchInstructions(renderPrompt(task, promptPacket, {
|
|
22
|
+
providerIdentity: "OpenCode CLI",
|
|
23
|
+
customConstraints: promptContent?.content
|
|
24
|
+
}), { ...(this.options.githubBroker ? { mcp: this.options.githubBroker } : {}) });
|
|
25
|
+
const startedAt = this.now();
|
|
26
|
+
const executionId = buildExecutionId(task.id, startedAt);
|
|
27
|
+
const artifactDir = path.join(this.options.logDir, "artifacts", executionId);
|
|
28
|
+
const promptPacketPath = path.join(artifactDir, "prompt-packet.json");
|
|
29
|
+
const promptArtifactPath = path.join(artifactDir, "prompt.md");
|
|
30
|
+
const stdoutArtifactPath = path.join(artifactDir, "stdout.txt");
|
|
31
|
+
const stderrArtifactPath = path.join(artifactDir, "stderr.txt");
|
|
32
|
+
const streamArtifactPath = path.join(artifactDir, "stdout-events.jsonl");
|
|
33
|
+
const settingsArtifactPath = path.join(artifactDir, "opencode-config.json");
|
|
34
|
+
await mkdir(artifactDir, { recursive: true });
|
|
35
|
+
await writeOpenCodeConfig(settingsArtifactPath, {
|
|
36
|
+
...(this.options.githubBroker ? { mcp: this.options.githubBroker } : {})
|
|
37
|
+
});
|
|
38
|
+
await Promise.all([
|
|
39
|
+
writeJsonFileAtomic(promptPacketPath, promptPacket),
|
|
40
|
+
writeFile(promptArtifactPath, promptText, "utf8")
|
|
41
|
+
]);
|
|
42
|
+
const envOverrides = getOpenCodeEnvOverrides(settingsArtifactPath);
|
|
43
|
+
const cwd = context.localRepoPath ?? process.cwd();
|
|
44
|
+
this.logger?.info(JSON.stringify({
|
|
45
|
+
component: "opencode-launch",
|
|
46
|
+
taskKind: task.kind,
|
|
47
|
+
targetRepoPath: cwd,
|
|
48
|
+
configPath: settingsArtifactPath,
|
|
49
|
+
mcpServerUrl: this.options.githubBroker?.serverUrl
|
|
50
|
+
}));
|
|
51
|
+
const args = buildOpenCodeCliArgs({
|
|
52
|
+
model: agentConfig.model, // Schema ensures model is present for opencode
|
|
53
|
+
repoPath: cwd,
|
|
54
|
+
promptText
|
|
55
|
+
});
|
|
56
|
+
let rawStdout = "";
|
|
57
|
+
let rawStderr = "";
|
|
58
|
+
let exitCode = 0;
|
|
59
|
+
try {
|
|
60
|
+
const result = await this.options.commandRunner.run(agentConfig.command, args, {
|
|
61
|
+
cwd,
|
|
62
|
+
env: { ...process.env, ...envOverrides },
|
|
63
|
+
timeoutMs: this.timeoutMs
|
|
64
|
+
});
|
|
65
|
+
rawStdout = result.stdout;
|
|
66
|
+
rawStderr = result.stderr;
|
|
67
|
+
exitCode = result.exitCode;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
exitCode = extractExitCode(error);
|
|
71
|
+
rawStdout = extractStdout(error);
|
|
72
|
+
rawStderr = extractStderr(error);
|
|
73
|
+
}
|
|
74
|
+
const normalizedStdout = extractOpenCodeAssistantText(rawStdout) ?? rawStdout;
|
|
75
|
+
const endedAt = this.now();
|
|
76
|
+
await Promise.all([
|
|
77
|
+
writeFile(streamArtifactPath, rawStdout, "utf8"),
|
|
78
|
+
writeFile(stdoutArtifactPath, normalizedStdout, "utf8"),
|
|
79
|
+
writeFile(stderrArtifactPath, rawStderr, "utf8")
|
|
80
|
+
]);
|
|
81
|
+
const triageResult = extractTriageResult(task, normalizedStdout);
|
|
82
|
+
const reviewResult = extractReviewResult(task, normalizedStdout);
|
|
83
|
+
const planBreakdownExtraction = extractAndExportPlanBreakdownResult(task, normalizedStdout, this.options.planBreakdownSessionStore);
|
|
84
|
+
const waveAssessmentExtraction = extractAndExportWaveAssessmentResult(task, normalizedStdout, this.options.waveAssessmentSessionStore);
|
|
85
|
+
let taskStatus = exitCode === 0 ? "completed" : "failed";
|
|
86
|
+
let errorMessage = exitCode === 0 ? undefined : rawStderr.trim() || "OpenCode CLI exited with a non-zero status.";
|
|
87
|
+
if (task.kind === "review_pr" && exitCode === 0 && !reviewResult) {
|
|
88
|
+
taskStatus = "failed";
|
|
89
|
+
errorMessage = "Agent output did not contain a valid structured review result.";
|
|
90
|
+
}
|
|
91
|
+
if ((task.kind === "plan_breakdown" || task.kind === "wave_plan_replanning") && exitCode === 0) {
|
|
92
|
+
if (!planBreakdownExtraction.ok) {
|
|
93
|
+
taskStatus = "failed";
|
|
94
|
+
errorMessage = planBreakdownExtraction.message;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (task.kind === "wave_plan_assessment" && exitCode === 0) {
|
|
98
|
+
if (!waveAssessmentExtraction.ok) {
|
|
99
|
+
taskStatus = "failed";
|
|
100
|
+
errorMessage = waveAssessmentExtraction.message;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const execution = {
|
|
104
|
+
id: executionId,
|
|
105
|
+
taskId: task.id,
|
|
106
|
+
taskKind: task.kind,
|
|
107
|
+
taskStatus,
|
|
108
|
+
repoId: task.repoId,
|
|
109
|
+
repoSlug: task.repoSlug,
|
|
110
|
+
agentId: agentConfig.id,
|
|
111
|
+
agentProvider: "opencode",
|
|
112
|
+
agentModel: agentConfig.model,
|
|
113
|
+
command: {
|
|
114
|
+
executable: agentConfig.command,
|
|
115
|
+
args,
|
|
116
|
+
cwd,
|
|
117
|
+
timeoutMs: this.timeoutMs
|
|
118
|
+
},
|
|
119
|
+
promptArtifactPath,
|
|
120
|
+
stdoutArtifactPath,
|
|
121
|
+
stderrArtifactPath,
|
|
122
|
+
streamArtifactPath,
|
|
123
|
+
promptPacketPath,
|
|
124
|
+
settingsArtifactPath,
|
|
125
|
+
startedAt,
|
|
126
|
+
endedAt,
|
|
127
|
+
exitCode,
|
|
128
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
129
|
+
...(triageResult || reviewResult || planBreakdownExtraction.ok || waveAssessmentExtraction.ok
|
|
130
|
+
? {
|
|
131
|
+
result: {
|
|
132
|
+
...(triageResult ? { triage: triageResult } : {}),
|
|
133
|
+
...(reviewResult ? { review: reviewResult } : {}),
|
|
134
|
+
...(planBreakdownExtraction.ok
|
|
135
|
+
? {
|
|
136
|
+
planBreakdown: planBreakdownExtraction.planBreakdown,
|
|
137
|
+
planBreakdownSessionId: planBreakdownExtraction.sessionId
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
...(waveAssessmentExtraction.ok
|
|
141
|
+
? {
|
|
142
|
+
waveAssessment: waveAssessmentExtraction.waveAssessment
|
|
143
|
+
}
|
|
144
|
+
: {})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
: {})
|
|
148
|
+
};
|
|
149
|
+
const providerLimitSignal = detectProviderLimit("opencode", exitCode, rawStdout, rawStderr);
|
|
150
|
+
return {
|
|
151
|
+
taskStatus: execution.taskStatus,
|
|
152
|
+
execution,
|
|
153
|
+
...(providerLimitSignal ? { providerLimitSignal } : {})
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Task } from "../tasks/task.js";
|
|
2
|
+
export class GitHubPlanBreakdownPublisher {
|
|
3
|
+
gitHubGateway;
|
|
4
|
+
logger;
|
|
5
|
+
reposById;
|
|
6
|
+
constructor(gitHubGateway, repos, logger) {
|
|
7
|
+
this.gitHubGateway = gitHubGateway;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.reposById = new Map(repos.map((repo) => [repo.id, repo]));
|
|
10
|
+
}
|
|
11
|
+
async publishBreakdown(task, execution) {
|
|
12
|
+
if (task.kind !== "plan_breakdown" || execution.taskStatus !== "completed") {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const breakdown = execution.result?.planBreakdown;
|
|
16
|
+
if (!breakdown) {
|
|
17
|
+
this.logger?.warn(`Completed plan_breakdown task ${task.id} did not return a structured breakdown result.`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const repo = this.reposById.get(task.repoId);
|
|
21
|
+
const targetNumber = Task.from(task).owningThreadNumber();
|
|
22
|
+
if (!repo || targetNumber === undefined) {
|
|
23
|
+
this.logger?.warn(`Could not resolve repo or target number for plan_breakdown task ${task.id}.`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
await this.gitHubGateway.postIssueComment({
|
|
27
|
+
owner: repo.owner,
|
|
28
|
+
name: repo.name,
|
|
29
|
+
issueNumber: targetNumber,
|
|
30
|
+
body: renderPlanBreakdownComment(breakdown)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function renderPlanBreakdownComment(result) {
|
|
35
|
+
const lines = ["🤖 Breakdown recommendation", ""];
|
|
36
|
+
lines.push("## Summary", "", result.summary, "");
|
|
37
|
+
lines.push("## Dependency graph", "");
|
|
38
|
+
lines.push("| Task ID | Title | Depends on | Blocks | Parallel with | Risk |");
|
|
39
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
40
|
+
for (const task of result.tasks) {
|
|
41
|
+
const row = [
|
|
42
|
+
task.id,
|
|
43
|
+
escapeTableCell(task.title),
|
|
44
|
+
formatInlineList(task.dependsOn),
|
|
45
|
+
formatInlineList(task.blocks),
|
|
46
|
+
formatInlineList(task.canRunInParallelWith),
|
|
47
|
+
task.risk
|
|
48
|
+
];
|
|
49
|
+
lines.push(`| ${row.join(" | ")} |`);
|
|
50
|
+
}
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push("## Execution waves", "");
|
|
53
|
+
for (const wave of result.waves) {
|
|
54
|
+
lines.push(`### Wave ${wave.id}`, "", wave.rationale, "");
|
|
55
|
+
for (const taskId of wave.taskIds) {
|
|
56
|
+
const task = result.tasks.find((t) => t.id === taskId);
|
|
57
|
+
if (task) {
|
|
58
|
+
lines.push(`- ${task.id}: ${task.title}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
lines.push("");
|
|
62
|
+
}
|
|
63
|
+
lines.push("## Recommended first wave", "", result.recommendedFirstWaveId, "");
|
|
64
|
+
lines.push("## Staged implementation packets", "");
|
|
65
|
+
for (const task of result.tasks) {
|
|
66
|
+
lines.push(`### ${task.id}: ${task.title}`, "", task.description, "");
|
|
67
|
+
lines.push(`- **Depends on:** ${formatInlineList(task.dependsOn)}`);
|
|
68
|
+
lines.push(`- **Blocks:** ${formatInlineList(task.blocks)}`);
|
|
69
|
+
lines.push(`- **Parallel with:** ${formatInlineList(task.canRunInParallelWith)}`);
|
|
70
|
+
lines.push(`- **Risk:** ${task.risk}`);
|
|
71
|
+
lines.push(`- **Files likely involved:** ${formatInlineList(task.filesLikelyInvolved)}`);
|
|
72
|
+
lines.push("- **Acceptance criteria:**");
|
|
73
|
+
lines.push(formatBulletList(task.acceptanceCriteria));
|
|
74
|
+
lines.push("- **Verification:**");
|
|
75
|
+
lines.push(formatBulletList(task.verification));
|
|
76
|
+
lines.push("");
|
|
77
|
+
}
|
|
78
|
+
lines.push("## Graph concerns / uncertainty", "");
|
|
79
|
+
lines.push(formatBulletList(result.graphConcerns));
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push("## Human decisions needed", "");
|
|
82
|
+
lines.push(formatBulletList(result.humanDecisionsNeeded));
|
|
83
|
+
lines.push("");
|
|
84
|
+
lines.push("## Suggested next action", "", result.suggestedNextAction);
|
|
85
|
+
return lines.join("\n").trim();
|
|
86
|
+
}
|
|
87
|
+
function formatInlineList(values) {
|
|
88
|
+
return values.length > 0 ? values.join(", ") : "-";
|
|
89
|
+
}
|
|
90
|
+
function formatBulletList(values, emptyText = "None") {
|
|
91
|
+
if (values.length === 0)
|
|
92
|
+
return ` - ${emptyText}`;
|
|
93
|
+
return values.map((v) => ` - ${v}`).join("\n");
|
|
94
|
+
}
|
|
95
|
+
function escapeTableCell(value) {
|
|
96
|
+
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
97
|
+
}
|