@dsdenes/devboard 0.1.560 → 0.1.562
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/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/implement-next-epic [ref]` — read one open epic created by `/epic-next`
|
|
3
|
+
* and start it as an omp goal in the current session.
|
|
4
|
+
*
|
|
5
|
+
* The server gives every implementation lane exactly one epic reference, so a
|
|
6
|
+
* lane never selects a different epic. With no reference the command accepts
|
|
7
|
+
* the single open epic and refuses (listing the URLs) when several are open.
|
|
8
|
+
*
|
|
9
|
+
* Goal mode's `goal` tool is not part of the default tool set. A file-based
|
|
10
|
+
* slash command cannot add it, so this command is registered by a small
|
|
11
|
+
* project-local extension instead: it selects the epic, prepares the
|
|
12
|
+
* worktree, activates the `goal` tool, and hands the objective to the model
|
|
13
|
+
* with an instruction to create the goal before implementing.
|
|
14
|
+
*
|
|
15
|
+
* Harness rules: this file lives under `.devboard/`; the only host-side file
|
|
16
|
+
* is the thin wrapper at `.omp/extensions/implement-next-epic.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import {
|
|
21
|
+
applyAuthToProcess,
|
|
22
|
+
isAuthFailure,
|
|
23
|
+
resolveForgeAuth,
|
|
24
|
+
type Exec,
|
|
25
|
+
} from "../lib/forge-auth.ts";
|
|
26
|
+
|
|
27
|
+
interface ExecResult {
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
code: number;
|
|
31
|
+
killed?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ExecOptions {
|
|
35
|
+
cwd?: string;
|
|
36
|
+
timeout?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ExtensionAPI {
|
|
40
|
+
registerCommand(
|
|
41
|
+
name: string,
|
|
42
|
+
options: {
|
|
43
|
+
description?: string;
|
|
44
|
+
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
|
45
|
+
},
|
|
46
|
+
): void;
|
|
47
|
+
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
|
48
|
+
getActiveTools(): string[];
|
|
49
|
+
getAllTools(): Array<{ name: string }>;
|
|
50
|
+
setActiveTools(names: string[]): Promise<void>;
|
|
51
|
+
sendUserMessage(content: string): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ModeChangeEntry {
|
|
55
|
+
type: string;
|
|
56
|
+
mode?: string;
|
|
57
|
+
data?: Record<string, unknown> | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ExtensionCommandContext {
|
|
61
|
+
cwd: string;
|
|
62
|
+
ui: {
|
|
63
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
64
|
+
};
|
|
65
|
+
sessionManager: {
|
|
66
|
+
getBranch(): ModeChangeEntry[];
|
|
67
|
+
};
|
|
68
|
+
waitForIdle?(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RemoteInfo {
|
|
72
|
+
host: string;
|
|
73
|
+
project: string;
|
|
74
|
+
forge: "github" | "gitlab";
|
|
75
|
+
/** The authenticated account selected for this repository. */
|
|
76
|
+
login?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface EpicSummary {
|
|
80
|
+
ref: number;
|
|
81
|
+
title: string;
|
|
82
|
+
url: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Epic extends EpicSummary {
|
|
86
|
+
body: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RunResult {
|
|
90
|
+
ok: boolean;
|
|
91
|
+
message: string;
|
|
92
|
+
epic?: Epic;
|
|
93
|
+
objective?: string;
|
|
94
|
+
worktree?: string;
|
|
95
|
+
branch?: string;
|
|
96
|
+
prompt?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface Failure {
|
|
100
|
+
error: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type Outcome<T> = T | Failure;
|
|
104
|
+
|
|
105
|
+
function isFailure<T>(value: Outcome<T>): value is Failure {
|
|
106
|
+
return typeof value === "object" && value !== null && "error" in value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect an existing goal from the session's persisted mode changes.
|
|
111
|
+
*
|
|
112
|
+
* Active tools are not goal state: `goal` is an ordinary active tool whenever
|
|
113
|
+
* goal mode is enabled, even when no goal exists. The newest `mode_change` on
|
|
114
|
+
* the active branch is authoritative — `goal`/`goal_paused` with a goal record
|
|
115
|
+
* means a goal exists; `none` (or no mode change) means none.
|
|
116
|
+
*/
|
|
117
|
+
export function goalModeFromBranch(
|
|
118
|
+
entries: readonly ModeChangeEntry[],
|
|
119
|
+
): "active" | "paused" | null {
|
|
120
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
121
|
+
const entry = entries[index];
|
|
122
|
+
if (entry.type !== "mode_change") continue;
|
|
123
|
+
if (entry.mode !== "goal" && entry.mode !== "goal_paused") return null;
|
|
124
|
+
if (!entry.data?.goal) return null;
|
|
125
|
+
return entry.mode === "goal_paused" ? "paused" : "active";
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function run(
|
|
131
|
+
pi: ExtensionAPI,
|
|
132
|
+
command: string,
|
|
133
|
+
args: string[],
|
|
134
|
+
cwd: string,
|
|
135
|
+
): Promise<ExecResult> {
|
|
136
|
+
try {
|
|
137
|
+
return await pi.exec(command, args, { cwd, timeout: 120_000 });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return {
|
|
140
|
+
stdout: "",
|
|
141
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
142
|
+
code: -1,
|
|
143
|
+
killed: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function firstLine(value: string): string {
|
|
149
|
+
return value.trim().split("\n")[0]?.trim() ?? "";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseJsonArray(stdout: string): unknown[] | null {
|
|
153
|
+
try {
|
|
154
|
+
const value: unknown = JSON.parse(stdout);
|
|
155
|
+
return Array.isArray(value) ? value : null;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseJsonObject(stdout: string): Record<string, unknown> | null {
|
|
162
|
+
try {
|
|
163
|
+
const value: unknown = JSON.parse(stdout);
|
|
164
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
165
|
+
? (value as Record<string, unknown>)
|
|
166
|
+
: null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Parse the three remote shapes accepted by `/epic-next`:
|
|
174
|
+
* `https://host/path`, `ssh://user@host[:port]/path`, `user@host:path`.
|
|
175
|
+
*/
|
|
176
|
+
export function parseRemote(url: string): { host: string; project: string } | null {
|
|
177
|
+
const value = url.trim();
|
|
178
|
+
if (!value) return null;
|
|
179
|
+
|
|
180
|
+
let host = "";
|
|
181
|
+
let project = "";
|
|
182
|
+
|
|
183
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = new URL(value);
|
|
186
|
+
host = parsed.hostname;
|
|
187
|
+
project = parsed.pathname;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(value);
|
|
193
|
+
if (!scp) return null;
|
|
194
|
+
host = scp[1];
|
|
195
|
+
project = scp[2];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
project = project
|
|
199
|
+
.replace(/^\/+/, "")
|
|
200
|
+
.replace(/\.git$/i, "")
|
|
201
|
+
.replace(/\/+$/, "");
|
|
202
|
+
|
|
203
|
+
if (!host || !project) return null;
|
|
204
|
+
return { host, project };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run one auth probe with an explicit environment. `pi.exec` cannot set env,
|
|
209
|
+
* so credential validation cannot use it; every probe starts its own child and
|
|
210
|
+
* the selected token only ever travels in that child's environment.
|
|
211
|
+
*/
|
|
212
|
+
export async function execWithEnvironment(
|
|
213
|
+
command: string,
|
|
214
|
+
args: string[],
|
|
215
|
+
options: { cwd: string; env?: Record<string, string> },
|
|
216
|
+
): Promise<ExecResult> {
|
|
217
|
+
try {
|
|
218
|
+
const child = Bun.spawn({
|
|
219
|
+
cmd: [command, ...args],
|
|
220
|
+
cwd: options.cwd,
|
|
221
|
+
env: options.env,
|
|
222
|
+
stdin: "ignore",
|
|
223
|
+
stdout: "pipe",
|
|
224
|
+
stderr: "pipe",
|
|
225
|
+
});
|
|
226
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
227
|
+
new Response(child.stdout).text(),
|
|
228
|
+
new Response(child.stderr).text(),
|
|
229
|
+
child.exited,
|
|
230
|
+
]);
|
|
231
|
+
return { stdout, stderr, code, killed: false };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return {
|
|
234
|
+
stdout: "",
|
|
235
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
236
|
+
code: -1,
|
|
237
|
+
killed: false,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function detectForge(
|
|
243
|
+
remote: { host: string; project: string },
|
|
244
|
+
cwd: string,
|
|
245
|
+
authExec: Exec = execWithEnvironment,
|
|
246
|
+
): Promise<Outcome<RemoteInfo>> {
|
|
247
|
+
const host = remote.host;
|
|
248
|
+
const lower = host.toLowerCase();
|
|
249
|
+
const resolveFor = (forge: "github" | "gitlab") =>
|
|
250
|
+
resolveForgeAuth(authExec, {
|
|
251
|
+
forge,
|
|
252
|
+
host,
|
|
253
|
+
project: remote.project,
|
|
254
|
+
cwd,
|
|
255
|
+
required: "write",
|
|
256
|
+
env: process.env,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (host === "github.com") {
|
|
260
|
+
const github = await resolveFor("github");
|
|
261
|
+
if (isAuthFailure(github)) return { error: github.error };
|
|
262
|
+
applyAuthToProcess(github);
|
|
263
|
+
return { ...remote, forge: "github", login: github.login };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (lower.includes("gitlab")) {
|
|
267
|
+
const gitlab = await resolveFor("gitlab");
|
|
268
|
+
if (isAuthFailure(gitlab)) return { error: gitlab.error };
|
|
269
|
+
applyAuthToProcess(gitlab);
|
|
270
|
+
return { ...remote, forge: "gitlab", login: gitlab.login };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Self-hosted: use whichever forge CLI is authenticated for this exact host.
|
|
274
|
+
// Both configured means the forge cannot be told apart.
|
|
275
|
+
const gitlab = await resolveFor("gitlab");
|
|
276
|
+
const github = await resolveFor("github");
|
|
277
|
+
if (!isAuthFailure(gitlab) && !isAuthFailure(github)) {
|
|
278
|
+
return {
|
|
279
|
+
error: `both gh and glab are configured for ${host}; cannot tell which forge serves the remote`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (!isAuthFailure(gitlab)) {
|
|
283
|
+
applyAuthToProcess(gitlab);
|
|
284
|
+
return { ...remote, forge: "gitlab", login: gitlab.login };
|
|
285
|
+
}
|
|
286
|
+
if (!isAuthFailure(github)) {
|
|
287
|
+
applyAuthToProcess(github);
|
|
288
|
+
return { ...remote, forge: "github", login: github.login };
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
error: [
|
|
292
|
+
`no authenticated gh or glab account has write access to ${remote.project} on ${host}`,
|
|
293
|
+
`gh: ${github.error}`,
|
|
294
|
+
`glab: ${gitlab.error}`,
|
|
295
|
+
].join("\n"),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizeSummary(forge: "github" | "gitlab", raw: unknown): EpicSummary | null {
|
|
300
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
301
|
+
const record = raw as Record<string, unknown>;
|
|
302
|
+
const ref = forge === "github" ? record.number : (record.iid ?? record.number);
|
|
303
|
+
const url = forge === "github" ? record.url : (record.web_url ?? record.url);
|
|
304
|
+
const title = record.title;
|
|
305
|
+
if (typeof ref !== "number" || typeof title !== "string" || typeof url !== "string") {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
return { ref, title, url };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function listOpenEpics(
|
|
312
|
+
pi: ExtensionAPI,
|
|
313
|
+
info: RemoteInfo,
|
|
314
|
+
cwd: string,
|
|
315
|
+
): Promise<Outcome<{ epics: EpicSummary[]; matchedByLabel: boolean }>> {
|
|
316
|
+
if (info.forge === "github") {
|
|
317
|
+
const labelsResult = await run(
|
|
318
|
+
pi,
|
|
319
|
+
"gh",
|
|
320
|
+
["label", "list", "--search", "epic", "--limit", "100", "--json", "name"],
|
|
321
|
+
cwd,
|
|
322
|
+
);
|
|
323
|
+
if (labelsResult.code !== 0) {
|
|
324
|
+
return { error: `gh label list failed: ${firstLine(labelsResult.stderr) || "unknown error"}` };
|
|
325
|
+
}
|
|
326
|
+
const labels = parseJsonArray(labelsResult.stdout);
|
|
327
|
+
if (!labels) return { error: "gh label list returned invalid JSON" };
|
|
328
|
+
const label = labels.find((raw) => {
|
|
329
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
330
|
+
const name = (raw as Record<string, unknown>).name;
|
|
331
|
+
return typeof name === "string" && name.toLowerCase() === "epic";
|
|
332
|
+
}) as { name: string } | undefined;
|
|
333
|
+
|
|
334
|
+
const result = label
|
|
335
|
+
? await run(
|
|
336
|
+
pi,
|
|
337
|
+
"gh",
|
|
338
|
+
[
|
|
339
|
+
"issue",
|
|
340
|
+
"list",
|
|
341
|
+
"--label",
|
|
342
|
+
label.name,
|
|
343
|
+
"--state",
|
|
344
|
+
"open",
|
|
345
|
+
"--json",
|
|
346
|
+
"number,title,url",
|
|
347
|
+
"--limit",
|
|
348
|
+
"100",
|
|
349
|
+
],
|
|
350
|
+
cwd,
|
|
351
|
+
)
|
|
352
|
+
: await run(
|
|
353
|
+
pi,
|
|
354
|
+
"gh",
|
|
355
|
+
[
|
|
356
|
+
"search",
|
|
357
|
+
"issues",
|
|
358
|
+
"--repo",
|
|
359
|
+
info.project,
|
|
360
|
+
"--match",
|
|
361
|
+
"body",
|
|
362
|
+
"--state",
|
|
363
|
+
"open",
|
|
364
|
+
"devboard-epic",
|
|
365
|
+
"--json",
|
|
366
|
+
"number,title,url",
|
|
367
|
+
"--limit",
|
|
368
|
+
"100",
|
|
369
|
+
],
|
|
370
|
+
cwd,
|
|
371
|
+
);
|
|
372
|
+
if (result.code !== 0) {
|
|
373
|
+
return { error: `gh issue listing failed: ${firstLine(result.stderr) || "unknown error"}` };
|
|
374
|
+
}
|
|
375
|
+
const rows = parseJsonArray(result.stdout);
|
|
376
|
+
if (!rows) return { error: "gh issue listing returned invalid JSON" };
|
|
377
|
+
return {
|
|
378
|
+
epics: rows.map((row) => normalizeSummary("github", row)).filter((row): row is EpicSummary => row !== null),
|
|
379
|
+
matchedByLabel: Boolean(label),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const labelsResult = await run(pi, "glab", ["label", "list", "--output", "json"], cwd);
|
|
384
|
+
if (labelsResult.code !== 0) {
|
|
385
|
+
return { error: `glab label list failed: ${firstLine(labelsResult.stderr) || "unknown error"}` };
|
|
386
|
+
}
|
|
387
|
+
const labels = parseJsonArray(labelsResult.stdout);
|
|
388
|
+
if (!labels) return { error: "glab label list returned invalid JSON" };
|
|
389
|
+
const label = labels.find((raw) => {
|
|
390
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
391
|
+
const name = (raw as Record<string, unknown>).name;
|
|
392
|
+
return typeof name === "string" && name.toLowerCase() === "epic";
|
|
393
|
+
}) as { name: string } | undefined;
|
|
394
|
+
|
|
395
|
+
const result = label
|
|
396
|
+
? await run(pi, "glab", ["issue", "list", "--label", label.name, "--output", "json"], cwd)
|
|
397
|
+
: await run(
|
|
398
|
+
pi,
|
|
399
|
+
"glab",
|
|
400
|
+
[
|
|
401
|
+
"issue",
|
|
402
|
+
"list",
|
|
403
|
+
"--search",
|
|
404
|
+
"devboard-epic",
|
|
405
|
+
"--in",
|
|
406
|
+
"description",
|
|
407
|
+
"--output",
|
|
408
|
+
"json",
|
|
409
|
+
],
|
|
410
|
+
cwd,
|
|
411
|
+
);
|
|
412
|
+
if (result.code !== 0) {
|
|
413
|
+
return { error: `glab issue listing failed: ${firstLine(result.stderr) || "unknown error"}` };
|
|
414
|
+
}
|
|
415
|
+
const rows = parseJsonArray(result.stdout);
|
|
416
|
+
if (!rows) return { error: "glab issue listing returned invalid JSON" };
|
|
417
|
+
return {
|
|
418
|
+
epics: rows.map((row) => normalizeSummary("gitlab", row)).filter((row): row is EpicSummary => row !== null),
|
|
419
|
+
matchedByLabel: Boolean(label),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function fetchEpic(
|
|
424
|
+
pi: ExtensionAPI,
|
|
425
|
+
info: RemoteInfo,
|
|
426
|
+
ref: number,
|
|
427
|
+
cwd: string,
|
|
428
|
+
): Promise<Outcome<Epic>> {
|
|
429
|
+
const result =
|
|
430
|
+
info.forge === "github"
|
|
431
|
+
? await run(
|
|
432
|
+
pi,
|
|
433
|
+
"gh",
|
|
434
|
+
["issue", "view", String(ref), "--json", "number,title,body,url"],
|
|
435
|
+
cwd,
|
|
436
|
+
)
|
|
437
|
+
: await run(pi, "glab", ["issue", "view", String(ref), "--output", "json"], cwd);
|
|
438
|
+
|
|
439
|
+
if (result.code !== 0) {
|
|
440
|
+
return {
|
|
441
|
+
error: `${info.forge} issue view failed: ${firstLine(result.stderr) || "unknown error"}`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const raw = parseJsonObject(result.stdout);
|
|
446
|
+
if (!raw) return { error: `${info.forge} issue view returned invalid JSON` };
|
|
447
|
+
|
|
448
|
+
const summary = normalizeSummary(info.forge, raw);
|
|
449
|
+
const body = info.forge === "github" ? raw.body : (raw.description ?? raw.body);
|
|
450
|
+
if (!summary || typeof body !== "string") {
|
|
451
|
+
return { error: `${info.forge} issue view returned an unexpected shape` };
|
|
452
|
+
}
|
|
453
|
+
return { ...summary, body };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const REQUIRED_SECTIONS = [
|
|
457
|
+
"Objective",
|
|
458
|
+
"Success criteria",
|
|
459
|
+
"Verification",
|
|
460
|
+
"Boundaries",
|
|
461
|
+
] as const;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Keep the epic's title and the sections a goal needs to execute and verify.
|
|
465
|
+
* The tracker marker and other HTML comments are dropped. When the body has
|
|
466
|
+
* none of the expected sections, keep the whole (comment-stripped) body.
|
|
467
|
+
*/
|
|
468
|
+
export function distillObjective(title: string, body: string): string {
|
|
469
|
+
const clean = body.replace(/\r\n/g, "\n").replace(/<!--[\s\S]*?-->/g, "");
|
|
470
|
+
const lines = clean.split("\n");
|
|
471
|
+
const sections: Record<string, string[]> = {};
|
|
472
|
+
let current: string | null = null;
|
|
473
|
+
|
|
474
|
+
for (const line of lines) {
|
|
475
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
476
|
+
if (heading) {
|
|
477
|
+
const name = heading[1].trim();
|
|
478
|
+
current =
|
|
479
|
+
REQUIRED_SECTIONS.find((candidate) => candidate.toLowerCase() === name.toLowerCase()) ?? null;
|
|
480
|
+
if (current) sections[current] = [];
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (current) sections[current]?.push(line);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const parts: string[] = [title.trim()];
|
|
487
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
488
|
+
const content = sections[section]?.join("\n").trim();
|
|
489
|
+
if (content) parts.push(`## ${section}\n${content}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (parts.length === 1) {
|
|
493
|
+
const fallback = clean.trim();
|
|
494
|
+
if (fallback) parts.push(fallback);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return parts.filter(Boolean).join("\n\n").trim();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function slugify(title: string): string {
|
|
501
|
+
const words = title
|
|
502
|
+
.toLowerCase()
|
|
503
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
504
|
+
.trim()
|
|
505
|
+
.split(/\s+/)
|
|
506
|
+
.filter(Boolean)
|
|
507
|
+
.slice(0, 6);
|
|
508
|
+
const slug = words.join("-").slice(0, 48).replace(/-+$/, "");
|
|
509
|
+
return slug || "epic";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function planWorktree(
|
|
513
|
+
pi: ExtensionAPI,
|
|
514
|
+
repo: string,
|
|
515
|
+
slug: string,
|
|
516
|
+
): Promise<Outcome<{ path: string; branch: string }>> {
|
|
517
|
+
const worktrees = await run(pi, "git", ["worktree", "list", "--porcelain"], repo);
|
|
518
|
+
if (worktrees.code !== 0) {
|
|
519
|
+
return { error: `git worktree list failed: ${firstLine(worktrees.stderr) || "unknown error"}` };
|
|
520
|
+
}
|
|
521
|
+
const existingPaths = new Set(
|
|
522
|
+
worktrees.stdout
|
|
523
|
+
.split("\n")
|
|
524
|
+
.filter((line) => line.startsWith("worktree "))
|
|
525
|
+
.map((line) => line.slice("worktree ".length).trim()),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
for (let index = 1; index <= 100; index += 1) {
|
|
529
|
+
const candidate = index === 1 ? slug : `${slug}-${index}`;
|
|
530
|
+
const path = resolve(repo, ".worktree", candidate);
|
|
531
|
+
const branch = `devboard/${candidate}`;
|
|
532
|
+
if (existingPaths.has(path)) continue;
|
|
533
|
+
const branchExists = await run(
|
|
534
|
+
pi,
|
|
535
|
+
"git",
|
|
536
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
537
|
+
repo,
|
|
538
|
+
);
|
|
539
|
+
if (branchExists.code === 0) continue;
|
|
540
|
+
return { path, branch };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { error: `no free worktree name for slug "${slug}"` };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function createWorktree(
|
|
547
|
+
pi: ExtensionAPI,
|
|
548
|
+
repo: string,
|
|
549
|
+
path: string,
|
|
550
|
+
branch: string,
|
|
551
|
+
): Promise<Outcome<{ path: string; branch: string }>> {
|
|
552
|
+
const main = await run(pi, "git", ["rev-parse", "--verify", "main"], repo);
|
|
553
|
+
if (main.code !== 0) {
|
|
554
|
+
return { error: "main branch not found; cannot create the epic worktree" };
|
|
555
|
+
}
|
|
556
|
+
const result = await run(
|
|
557
|
+
pi,
|
|
558
|
+
"git",
|
|
559
|
+
["worktree", "add", path, "-b", branch, "main"],
|
|
560
|
+
repo,
|
|
561
|
+
);
|
|
562
|
+
if (result.code !== 0) {
|
|
563
|
+
return {
|
|
564
|
+
error: `git worktree add failed: ${firstLine(result.stderr) || firstLine(result.stdout) || "unknown error"}`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
return { path, branch };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function shellArgument(value: string): string {
|
|
571
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
|
|
572
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The exact wrapper every tracker command in the lane prompt must use. */
|
|
576
|
+
export function forgeAuthCommand(repo: string, info: RemoteInfo): string {
|
|
577
|
+
const script = resolve(repo, ".devboard", "scripts", "forge-auth.ts");
|
|
578
|
+
return [
|
|
579
|
+
"bun",
|
|
580
|
+
shellArgument(script),
|
|
581
|
+
"exec",
|
|
582
|
+
"--host",
|
|
583
|
+
shellArgument(info.host),
|
|
584
|
+
"--project",
|
|
585
|
+
shellArgument(info.project),
|
|
586
|
+
"--forge",
|
|
587
|
+
info.forge,
|
|
588
|
+
"--required",
|
|
589
|
+
"write",
|
|
590
|
+
"--",
|
|
591
|
+
].join(" ");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function buildGoalPrompt(
|
|
595
|
+
epic: Epic,
|
|
596
|
+
objective: string,
|
|
597
|
+
repo: string,
|
|
598
|
+
worktree: string,
|
|
599
|
+
branch: string,
|
|
600
|
+
forge: RemoteInfo["forge"],
|
|
601
|
+
authCommand: string,
|
|
602
|
+
): string {
|
|
603
|
+
return `Implement the next epic from the tracker. This run is fully autonomous.
|
|
604
|
+
|
|
605
|
+
Autonomy contract:
|
|
606
|
+
- Do not ask the user anything. Never call the \`ask\` tool.
|
|
607
|
+
- Do not wait for confirmation, approval, sign-off, or review. Never request permission to proceed.
|
|
608
|
+
- Do not stop for a plan, a choice, or a progress check. Never offer the user options.
|
|
609
|
+
- Decide from the epic, the repository, its tests, its documentation, and its ADRs. Where those are silent, choose the smallest reversible option, record the assumption in the final report, and continue.
|
|
610
|
+
- Continue until every success criterion is verified, the branch is merged, the worktree is removed, and the epic is closed.
|
|
611
|
+
|
|
612
|
+
Epic: ${epic.title}
|
|
613
|
+
URL: ${epic.url}
|
|
614
|
+
Repository: ${repo}
|
|
615
|
+
Worktree: ${worktree}
|
|
616
|
+
Branch: ${branch}
|
|
617
|
+
|
|
618
|
+
Before doing any work:
|
|
619
|
+
1. Call the \`goal\` tool with {"op":"get"}.
|
|
620
|
+
2. If it returns any goal (active, paused, budget-limited, complete, or dropped), end the run immediately with that conflict as the result. Do not drop, resume, or replace it, and do not ask the user what to do.
|
|
621
|
+
3. Otherwise call the \`goal\` tool with {"op":"create","objective": <the objective below>}. Pass the objective exactly as written.
|
|
622
|
+
|
|
623
|
+
Objective:
|
|
624
|
+
<<<OBJECTIVE
|
|
625
|
+
${objective}
|
|
626
|
+
OBJECTIVE>>>
|
|
627
|
+
|
|
628
|
+
After the goal tool succeeds, implement the epic end to end:
|
|
629
|
+
- Work only inside the worktree path above. Never edit, write, or run mutating commands in ${repo}; that checkout is for the final merge only.
|
|
630
|
+
- Use absolute paths under the worktree for every tool, and pass the worktree as \`cwd\` to \`bash\`.
|
|
631
|
+
- Read the epic URL for the full body, including any detail omitted from the objective.
|
|
632
|
+
- Follow every success criterion and verification scenario. Perform each check through the product's user-visible surface in the worktree, observe the actual result, and use the repository's documented build and test commands only as supporting evidence.
|
|
633
|
+
- Never end a turn while a background job you started is still running: when a \`wait\` returns a
|
|
634
|
+
still-running snapshot, call \`wait\` again. Leave the turn only once the work is finished or
|
|
635
|
+
the exact blocker is known.
|
|
636
|
+
- Keep the goal active until every criterion is verified. Call the \`goal\` tool with {"op":"complete"} only then.
|
|
637
|
+
- When complete, merge ${branch} into main from ${repo}. First fetch the latest main and integrate
|
|
638
|
+
it into ${branch}; then merge. If the merge fails, fetch the latest main again, integrate it,
|
|
639
|
+
and retry. Never force-push. After the merge succeeds, push main to origin and remove the
|
|
640
|
+
worktree and branch, per AGENTS.md. If the push is rejected, fetch and integrate the new main
|
|
641
|
+
and push again.
|
|
642
|
+
- Never call \`gh\` or \`glab\` directly. Route every tracker command through the harness auth wrapper, which selects the authenticated account with write access to this repository and overrides any stale ambient token:
|
|
643
|
+
\`${authCommand} gh issue close <n>\`
|
|
644
|
+
- When complete, close the epic issue ${epic.ref} in ${repo} with \`${authCommand} ${forge === "github" ? "gh" : "glab"} issue close ${epic.ref}\`. Close it only after the merge succeeded, main was pushed, and the worktree was removed. If closing fails, report that failure once as the run's blocker.
|
|
645
|
+
- If a hard blocker makes completion impossible (a missing prerequisite that cannot be created, an unavailable external dependency, or an operation that would destroy uncommitted user work), end the run and report the exact blocker once on a line beginning \`DEVBOARD-BLOCKED:\`. Do not ask for permission or guidance.`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function notify(ctx: ExtensionCommandContext, message: string, type: "info" | "warning" | "error"): void {
|
|
649
|
+
try {
|
|
650
|
+
ctx.ui.notify(`implement-next-epic: ${message}`, type);
|
|
651
|
+
} catch {
|
|
652
|
+
// Notification is best-effort; the command still returns its result.
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function fail(
|
|
657
|
+
ctx: ExtensionCommandContext,
|
|
658
|
+
message: string,
|
|
659
|
+
): Promise<RunResult> {
|
|
660
|
+
notify(ctx, message, "error");
|
|
661
|
+
return { ok: false, message };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export async function runImplementNextEpic(
|
|
665
|
+
pi: ExtensionAPI,
|
|
666
|
+
ctx: ExtensionCommandContext,
|
|
667
|
+
args = "",
|
|
668
|
+
authExec: Exec = execWithEnvironment,
|
|
669
|
+
): Promise<RunResult> {
|
|
670
|
+
const requested = args.trim();
|
|
671
|
+
if (requested.length > 0 && !/^\d+$/.test(requested)) {
|
|
672
|
+
return fail(
|
|
673
|
+
ctx,
|
|
674
|
+
"takes an optional epic reference; with no reference it implements the single open epic",
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
const reference = requested.length > 0 ? Number(requested) : undefined;
|
|
678
|
+
|
|
679
|
+
const top = await run(pi, "git", ["rev-parse", "--show-toplevel"], ctx.cwd);
|
|
680
|
+
if (top.code !== 0) {
|
|
681
|
+
return fail(ctx, `not a git repository: ${firstLine(top.stderr) || "git rev-parse failed"}`);
|
|
682
|
+
}
|
|
683
|
+
const repo = top.stdout.trim();
|
|
684
|
+
|
|
685
|
+
const goalMode = goalModeFromBranch(ctx.sessionManager.getBranch());
|
|
686
|
+
if (goalMode) {
|
|
687
|
+
return fail(
|
|
688
|
+
ctx,
|
|
689
|
+
`a goal is already ${goalMode}; finish or drop it before starting another epic`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const origin = await run(pi, "git", ["remote", "get-url", "origin"], repo);
|
|
694
|
+
if (origin.code !== 0 || !origin.stdout.trim()) {
|
|
695
|
+
return fail(ctx, "origin remote is not configured; /epic-next cannot have filed an epic");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const remote = parseRemote(origin.stdout);
|
|
699
|
+
if (!remote) {
|
|
700
|
+
return fail(ctx, `cannot parse origin remote: ${origin.stdout.trim()}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const info = await detectForge(remote, repo, authExec);
|
|
704
|
+
if (isFailure(info)) return fail(ctx, info.error);
|
|
705
|
+
|
|
706
|
+
const listed = await listOpenEpics(pi, info, repo);
|
|
707
|
+
if (isFailure(listed)) return fail(ctx, listed.error);
|
|
708
|
+
|
|
709
|
+
let epics = listed.epics;
|
|
710
|
+
if (!listed.matchedByLabel) {
|
|
711
|
+
// The marker search is a candidate fetch; confirm the marker in the body.
|
|
712
|
+
const confirmed: EpicSummary[] = [];
|
|
713
|
+
for (const candidate of epics) {
|
|
714
|
+
const full = await fetchEpic(pi, info, candidate.ref, repo);
|
|
715
|
+
if (isFailure(full)) return fail(ctx, full.error);
|
|
716
|
+
if (full.body.includes("<!-- devboard-epic -->")) confirmed.push(candidate);
|
|
717
|
+
}
|
|
718
|
+
epics = confirmed;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (epics.length === 0) {
|
|
722
|
+
return fail(ctx, "no open epic found; run /epic-next first");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
let selected: EpicSummary | undefined;
|
|
726
|
+
if (reference !== undefined) {
|
|
727
|
+
selected = epics.find((epic) => epic.ref === reference);
|
|
728
|
+
if (selected === undefined) {
|
|
729
|
+
return fail(
|
|
730
|
+
ctx,
|
|
731
|
+
`epic ${reference} is not open, found ${epics.length}:\n${epics
|
|
732
|
+
.map((epic) => `${epic.url}`)
|
|
733
|
+
.join("\n")}`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
if (epics.length > 1) {
|
|
738
|
+
return fail(
|
|
739
|
+
ctx,
|
|
740
|
+
`expected exactly one open epic, found ${epics.length}:\n${epics
|
|
741
|
+
.map((epic) => `${epic.url}`)
|
|
742
|
+
.join("\n")}`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
selected = epics[0];
|
|
746
|
+
}
|
|
747
|
+
if (selected === undefined) {
|
|
748
|
+
return fail(ctx, "no open epic found; run /epic-next first");
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const fetched = await fetchEpic(pi, info, selected.ref, repo);
|
|
752
|
+
if (isFailure(fetched)) return fail(ctx, fetched.error);
|
|
753
|
+
const epic = fetched;
|
|
754
|
+
|
|
755
|
+
const objective = distillObjective(epic.title, epic.body);
|
|
756
|
+
if (!objective) {
|
|
757
|
+
return fail(ctx, `epic ${epic.url} has no objective text`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const slug = slugify(epic.title);
|
|
761
|
+
const planned = await planWorktree(pi, repo, slug);
|
|
762
|
+
if (isFailure(planned)) return fail(ctx, planned.error);
|
|
763
|
+
|
|
764
|
+
const worktree = await createWorktree(pi, repo, planned.path, planned.branch);
|
|
765
|
+
if (isFailure(worktree)) return fail(ctx, worktree.error);
|
|
766
|
+
|
|
767
|
+
const active = pi.getActiveTools();
|
|
768
|
+
try {
|
|
769
|
+
await pi.setActiveTools([...active, "goal"]);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
return fail(
|
|
772
|
+
ctx,
|
|
773
|
+
`could not activate the goal tool: ${error instanceof Error ? error.message : String(error)}`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const goalAvailable =
|
|
778
|
+
pi.getActiveTools().includes("goal") && pi.getAllTools().some((tool) => tool.name === "goal");
|
|
779
|
+
if (!goalAvailable) {
|
|
780
|
+
return fail(ctx, "goal mode is unavailable; enable goal.enabled in omp settings");
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
await ctx.waitForIdle?.();
|
|
784
|
+
|
|
785
|
+
const prompt = buildGoalPrompt(
|
|
786
|
+
epic,
|
|
787
|
+
objective,
|
|
788
|
+
repo,
|
|
789
|
+
worktree.path,
|
|
790
|
+
worktree.branch,
|
|
791
|
+
info.forge,
|
|
792
|
+
forgeAuthCommand(repo, info),
|
|
793
|
+
);
|
|
794
|
+
try {
|
|
795
|
+
pi.sendUserMessage(prompt);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
return fail(
|
|
798
|
+
ctx,
|
|
799
|
+
`could not send the goal prompt: ${error instanceof Error ? error.message : String(error)}. Worktree ${worktree.path} was created.`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
notify(
|
|
804
|
+
ctx,
|
|
805
|
+
`queued goal for ${epic.url}; worktree ${worktree.path} on ${worktree.branch}`,
|
|
806
|
+
"info",
|
|
807
|
+
);
|
|
808
|
+
return {
|
|
809
|
+
ok: true,
|
|
810
|
+
message: `queued goal for ${epic.url}`,
|
|
811
|
+
epic,
|
|
812
|
+
objective,
|
|
813
|
+
worktree: worktree.path,
|
|
814
|
+
branch: worktree.branch,
|
|
815
|
+
prompt,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export default function implementNextEpicExtension(pi: ExtensionAPI): void {
|
|
820
|
+
pi.registerCommand("implement-next-epic", {
|
|
821
|
+
description:
|
|
822
|
+
"Implement the open epic given by reference, or the single open epic created by /epic-next",
|
|
823
|
+
handler: async (args, ctx) => {
|
|
824
|
+
try {
|
|
825
|
+
await runImplementNextEpic(pi, ctx, args);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
notify(
|
|
828
|
+
ctx,
|
|
829
|
+
`unexpected failure: ${error instanceof Error ? error.message : String(error)}`,
|
|
830
|
+
"error",
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
}
|