@clipboard-health/groundcrew 3.1.1 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -14
- package/{configExample.ts → crew.config.example.ts} +3 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -0
- package/dist/commands/cleaner.d.ts.map +1 -1
- package/dist/commands/cleaner.js +2 -0
- package/dist/commands/cleanupWorkspace.d.ts.map +1 -1
- package/dist/commands/cleanupWorkspace.js +2 -0
- package/dist/commands/interruptWorkspace.d.ts +8 -0
- package/dist/commands/interruptWorkspace.d.ts.map +1 -0
- package/dist/commands/interruptWorkspace.js +108 -0
- package/dist/commands/resumeWorkspace.d.ts +7 -0
- package/dist/commands/resumeWorkspace.d.ts.map +1 -0
- package/dist/commands/resumeWorkspace.js +163 -0
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +77 -79
- package/dist/commands/ticketDoctor.d.ts +18 -3
- package/dist/commands/ticketDoctor.d.ts.map +1 -1
- package/dist/commands/ticketDoctor.js +77 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lib/agentLaunch.d.ts +29 -0
- package/dist/lib/agentLaunch.d.ts.map +1 -0
- package/dist/lib/agentLaunch.js +53 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +91 -26
- package/dist/lib/runState.d.ts +46 -0
- package/dist/lib/runState.d.ts.map +1 -0
- package/dist/lib/runState.js +137 -0
- package/dist/lib/runStateCleanup.d.ts +4 -0
- package/dist/lib/runStateCleanup.d.ts.map +1 -0
- package/dist/lib/runStateCleanup.js +12 -0
- package/dist/lib/stagedLaunch.d.ts +32 -0
- package/dist/lib/stagedLaunch.d.ts.map +1 -0
- package/dist/lib/stagedLaunch.js +58 -0
- package/dist/lib/workspaces.d.ts +19 -1
- package/dist/lib/workspaces.d.ts.map +1 -1
- package/dist/lib/workspaces.js +29 -9
- package/dist/lib/worktrees.d.ts.map +1 -1
- package/dist/lib/worktrees.js +12 -4
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Eligibility
|
|
|
42
42
|
- **Linear-native.** Polls a project, respects `agent-*` labels, honors blockers.
|
|
43
43
|
- **One worktree per ticket.** Agents work in parallel without stepping on each other.
|
|
44
44
|
- **Local-first sandboxing.** Safehouse on macOS, Docker Sandboxes on Linux, or an explicit `none` escape hatch.
|
|
45
|
-
- **Multi-agent.** Ships with `claude` and `codex`; bring your own CLI by dropping a definition into `config.ts`.
|
|
45
|
+
- **Multi-agent.** Ships with `claude` and `codex`; bring your own CLI by dropping a definition into `crew.config.ts`.
|
|
46
46
|
|
|
47
47
|
## Install
|
|
48
48
|
|
|
@@ -64,11 +64,13 @@ Installs the `crew` binary. `@clipboard-health/clearance` is pulled in transitiv
|
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
66
|
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew"
|
|
67
|
-
cp "$(npm root -g)/@clipboard-health/groundcrew/
|
|
68
|
-
"${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/config.ts"
|
|
69
|
-
$EDITOR "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/config.ts"
|
|
67
|
+
cp "$(npm root -g)/@clipboard-health/groundcrew/crew.config.example.ts" \
|
|
68
|
+
"${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts"
|
|
69
|
+
$EDITOR "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts"
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
Or drop `crew.config.ts` at the root of any repo you run `crew` from — `crew` discovers it via cosmiconfig project-walk. Any of `crew.config.{ts,mjs,js,json}`, `.crewrc{,.json,.ts}`, `.config/crew.config.{ts,json}`, or `.config/crewrc{,.json}` work.
|
|
73
|
+
|
|
72
74
|
Set `linear.projectSlug` (paste the trailing slug of your Linear project URL, e.g. `ai-strategy-5152195762f3`), `workspace.projectDir`, and `workspace.knownRepositories`. Defaults cover everything else.
|
|
73
75
|
|
|
74
76
|
Then clone each repo before the first `crew run` — groundcrew creates per-ticket worktrees from these clones, it does not auto-clone:
|
|
@@ -134,7 +136,7 @@ Net effect: by the time the agent process exists, the values are gone from the e
|
|
|
134
136
|
| `sdx` | Linux / WSL | [Docker Sandboxes](https://docs.docker.com/sandboxes/) (`sbx`) — required when the agent needs `docker`. |
|
|
135
137
|
| `none` | — | Unsandboxed escape hatch. Never picked implicitly; doctor warns when configured. |
|
|
136
138
|
|
|
137
|
-
For `sdx`: each model that runs under it needs a `sandbox: { agent: "<sbx-agent>" }` block in `config.ts`. Groundcrew names sandboxes `groundcrew-<agent>` (e.g. `groundcrew-claude`) and reuses one sandbox per agent across repos and tickets. First-time agent auth happens inside the sandbox the first time it launches. To bootstrap manually instead, run `sbx create --name groundcrew-<agent> <agent> <projectDir>` once.
|
|
139
|
+
For `sdx`: each model that runs under it needs a `sandbox: { agent: "<sbx-agent>" }` block in `crew.config.ts`. Groundcrew names sandboxes `groundcrew-<agent>` (e.g. `groundcrew-claude`) and reuses one sandbox per agent across repos and tickets. First-time agent auth happens inside the sandbox the first time it launches. To bootstrap manually instead, run `sbx create --name groundcrew-<agent> <agent> <projectDir>` once.
|
|
138
140
|
|
|
139
141
|
<details>
|
|
140
142
|
<summary>Safehouse clearance allowlist</summary>
|
|
@@ -167,7 +169,7 @@ Three keys are required; everything else has a default.
|
|
|
167
169
|
| `workspace.projectDir` | Parent dir for cloned repos and sibling ticket worktrees. |
|
|
168
170
|
| `workspace.knownRepositories` | Repos searched for in ticket descriptions to infer where work belongs. |
|
|
169
171
|
|
|
170
|
-
`crew` resolves config as: `GROUNDCREW_CONFIG` if set → `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/config.ts`
|
|
172
|
+
`crew` resolves config as: `GROUNDCREW_CONFIG` if set → project-walk from cwd (cosmiconfig: `crew.config.{ts,mjs,js,json}`, `.crewrc{,.json,.ts}`, `.config/crew.config.{ts,json}`, `.config/crewrc{,.json}`) → `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts` (also accepts legacy `config.ts` for one release). The branch prefix (`<prefix>-<TICKET>`) is derived from `os.userInfo().username` — not configurable.
|
|
171
173
|
|
|
172
174
|
Agent selection uses Linear labels: `agent-claude`, `agent-codex`, `agent-<name>`. `crew run` without `--ticket` only fetches tickets carrying an `agent-*` label — the GraphQL query filters server-side, so unlabeled tickets are never returned by Linear and do not appear on the board. Use `crew run --ticket <TICKET>` to provision an unlabeled ticket on demand (falls back to `models.default`). `agent-any` routes to the model with the most available session capacity. Todo tickets blocked by non-terminal blockers are skipped until their blockers reach a terminal status.
|
|
173
175
|
|
|
@@ -180,7 +182,7 @@ For a personal workflow, keep the prompt next to your local config and load it w
|
|
|
180
182
|
```ts
|
|
181
183
|
import { readFileSync } from "node:fs";
|
|
182
184
|
|
|
183
|
-
export
|
|
185
|
+
export default {
|
|
184
186
|
// ...
|
|
185
187
|
prompts: {
|
|
186
188
|
initial: readFileSync(new URL("./initial-prompt.md", import.meta.url), "utf8"),
|
|
@@ -195,7 +197,7 @@ This keeps package defaults portable while letting your private config reference
|
|
|
195
197
|
|
|
196
198
|
| Key | Default | What it does |
|
|
197
199
|
| --------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
198
|
-
| `linear.projectSlug` | **required** | Linear project URL slug (e.g. `ai-strategy-5152195762f3`). The trailing 12-char hex `slugId` is what's matched against Linear's API; the leading name keeps `config.ts` self-documenting and the lookup survives project renames.
|
|
200
|
+
| `linear.projectSlug` | **required** | Linear project URL slug (e.g. `ai-strategy-5152195762f3`). The trailing 12-char hex `slugId` is what's matched against Linear's API; the leading name keeps `crew.config.ts` self-documenting and the lookup survives project renames. |
|
|
199
201
|
| `linear.statuses.todo` | `"Todo"` | Status name picked up for new work. |
|
|
200
202
|
| `linear.statuses.inProgress` | `"In Progress"` | Status set after a workspace is provisioned; counts toward `maximumInProgress`. |
|
|
201
203
|
| `linear.statuses.done` | `"Done"` | Status that triggers worktree cleanup. |
|
|
@@ -214,7 +216,7 @@ This keeps package defaults portable while letting your private config reference
|
|
|
214
216
|
| `models.definitions.<name>.usage` | optional | If set, codexbar usage is fetched for this model and gated by `sessionLimitPercentage`. Falls back to default when unset, with gating enabled for known models. When `usage.codexbar.source` is omitted, groundcrew uses `oauth` for Codex/Claude on macOS, `auto` for other macOS providers, and `cli` elsewhere. Set to `{ disabled: true }` to disable usage gating. |
|
|
215
217
|
| `models.definitions.<name>.sandbox` | optional | Docker Sandboxes binding for the model. Required at launch when `local.runner` resolves to `sdx`. Fields: `agent` (required sbx agent name), `template`, `kits`, `setupCommand` (override for the inside-sandbox setup script). |
|
|
216
218
|
| `models.definitions.<name>.disabled` | optional | When set to exactly `true`, drops the named shipped default (`claude` or `codex`). Doctor skips probing it; `agent-<name>` labels fall back to `models.default` with a warning. |
|
|
217
|
-
| `prompts.initial` | unattended template | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. Override this from `config.ts` for team-specific statuses, tools, plugins, or review loops.
|
|
219
|
+
| `prompts.initial` | unattended template | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. Override this from `crew.config.ts` for team-specific statuses, tools, plugins, or review loops. |
|
|
218
220
|
| `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"` or `"tmux"` to fail loudly when the chosen backend is missing. |
|
|
219
221
|
| `local.runner` | `"auto"` | Local isolation backend. `"auto"` → `safehouse` on macOS, `sdx` on Linux/WSL. Explicit: `"safehouse"`, `"sdx"`, `"none"`. `"none"` is never picked implicitly. |
|
|
220
222
|
| `logging.file` | XDG state path | Append-mode log file. `log()` / `logEvent()` tee here in addition to stdout. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
|
|
@@ -227,8 +229,8 @@ This keeps package defaults portable while letting your private config reference
|
|
|
227
229
|
Groundcrew ships `claude` and `codex` as default model definitions, additively merged into every resolved config. To stop probing one:
|
|
228
230
|
|
|
229
231
|
```ts
|
|
230
|
-
// config.ts
|
|
231
|
-
export
|
|
232
|
+
// crew.config.ts
|
|
233
|
+
export default {
|
|
232
234
|
// …
|
|
233
235
|
models: {
|
|
234
236
|
default: "claude",
|
|
@@ -314,14 +316,16 @@ crew run # one-shot dispatch
|
|
|
314
316
|
crew run --watch # poll forever
|
|
315
317
|
crew run --ticket <TICKET> # provision one ticket and exit
|
|
316
318
|
crew setup repos [--dry-run] [<repo>...]
|
|
319
|
+
crew interrupt <TICKET> [--reason <text>] # stop the live workspace, keep the worktree
|
|
320
|
+
crew resume <TICKET> # reopen an existing ticket worktree
|
|
317
321
|
crew cleanup <TICKET> # tear down every worktree carrying this ticket
|
|
318
322
|
```
|
|
319
323
|
|
|
320
|
-
`crew doctor --ticket <TICKET>` covers the full per-ticket lifecycle: pre-dispatch eligibility (Todo status, `agent-*` label, model resolution, repository mention, local clone, blockers, model session usage, in-progress capacity) **and** post-dispatch local-state recovery (host worktree, workspace pane, local branch, remote branch, open PR). Verdict precedence
|
|
324
|
+
`crew doctor --ticket <TICKET>` covers the full per-ticket lifecycle: pre-dispatch eligibility (Todo status, `agent-*` label, model resolution, repository mention, local clone, blockers, model session usage, in-progress capacity) **and** post-dispatch local-state recovery (recorded run state, host worktree, workspace pane, local branch, remote branch, open PR). Verdict precedence starts with PR outcomes (`pr-open` > `pr-merged`). Recorded failed launches report before ordinary local recovery, interrupted runs report concrete recoverable git work first when it exists and otherwise report `interrupted`, and ordinary post-dispatch cases report `in-flight` before `recoverable`. If none of those apply, doctor falls through to `unresolvable` > `ineligible` > `would-dispatch` > `lost`. Exits 0 on `would-dispatch`, `pr-open`, or `pr-merged`; any other verdict exits 1. `--watch` and `--ticket` are mutually exclusive. To inspect codexbar session windows directly, run `codexbar usage`.
|
|
321
325
|
|
|
322
326
|
### `crew doctor --ticket <ticket>`
|
|
323
327
|
|
|
324
|
-
Diagnose where a ticket is in its lifecycle and what to do next. Runs the same resolution and eligibility chain as the dispatcher, plus probes
|
|
328
|
+
Diagnose where a ticket is in its lifecycle and what to do next. Runs the same resolution and eligibility chain as the dispatcher, plus probes recorded run state, host worktree, workspace pane, local branch, remote branch, and PR; prints a single verdict with a copy-pasteable recovery step when one applies.
|
|
325
329
|
|
|
326
330
|
Flags:
|
|
327
331
|
|
|
@@ -342,6 +346,13 @@ Resolution
|
|
|
342
346
|
Eligibility
|
|
343
347
|
(skipped — post-dispatch — pre-dispatch checks are irrelevant)
|
|
344
348
|
|
|
349
|
+
Run state
|
|
350
|
+
[ok] Local run state (running)
|
|
351
|
+
[ok] Recorded model (claude)
|
|
352
|
+
[ok] Recorded worktree (/Users/paul/dev/groundcrew-workspaces/herds-social/herds-hrd-442)
|
|
353
|
+
[ok] Recorded branch (paul-hrd-442)
|
|
354
|
+
[ok] Resume count (0)
|
|
355
|
+
|
|
345
356
|
Worktree
|
|
346
357
|
[ok] Host worktree exists (/Users/paul/dev/groundcrew-workspaces/herds-social/herds-hrd-442)
|
|
347
358
|
[--] Working tree clean (0 modified, 1 untracked)
|
|
@@ -372,11 +383,31 @@ The verdict on the last line maps to a recovery action:
|
|
|
372
383
|
| `pr-merged` | Done. |
|
|
373
384
|
| `in-flight` | The ticket is still being worked on; the verdict line names the workspace pane to attach to. |
|
|
374
385
|
| `recoverable` | Run the printed `nextStep` exactly. |
|
|
386
|
+
| `interrupted` | Resume the preserved worktree with `crew resume <ticket>` or inspect it by hand. |
|
|
387
|
+
| `failed-launch` | Fix the launch failure, then run `crew resume <ticket>` or `crew cleanup <ticket>`. |
|
|
375
388
|
| `would-dispatch` | Pre-dispatch checks pass; the orchestrator will pick the ticket up on its next tick. |
|
|
376
389
|
| `ineligible` | A resolution or eligibility check failed; the reason after the colon names the failing check. |
|
|
377
390
|
| `unresolvable` | The Linear ticket couldn't be fetched; the reason after the colon names the error. |
|
|
378
391
|
| `lost` | No trace exists. Re-dispatch via `crew run --ticket <ticket>`. |
|
|
379
392
|
|
|
393
|
+
### `crew interrupt <ticket>`
|
|
394
|
+
|
|
395
|
+
Stop a live workspace pane while preserving the ticket worktree and branch. This is the manual pause button for cases where you need terminal capacity back, want to stop an agent that is going in the wrong direction, or need to inspect the diff before letting another agent continue.
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
crew interrupt HRD-442 --reason "wrong implementation direction"
|
|
399
|
+
crew doctor --ticket HRD-442
|
|
400
|
+
crew resume HRD-442
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The command closes the cmux/tmux workspace when it exists, records local run state under the groundcrew state directory, and never tears down the worktree. If the workspace was already gone but the worktree is still present, interrupt records that fact so doctor can point at the preserved branch instead of reporting a mystery ticket.
|
|
404
|
+
|
|
405
|
+
### `crew resume <ticket>`
|
|
406
|
+
|
|
407
|
+
Reopen an existing ticket worktree with a continuation prompt. Resume never creates a new worktree; if none exists, it fails and leaves re-dispatch to `crew run --ticket <ticket>`.
|
|
408
|
+
|
|
409
|
+
The resume prompt tells the agent to inspect current git status and diff before editing, includes the previous interrupt reason when recorded, and reuses the recorded model, repository, branch, runner, sandbox, and workspace backend. When no run-state file exists but a worktree does, resume falls back to Linear resolution for the model and ticket context.
|
|
410
|
+
|
|
380
411
|
## Troubleshooting
|
|
381
412
|
|
|
382
413
|
First stop for "labeled but not on the board": `crew doctor --ticket <ticket>` lists every check the dispatcher runs and flags the failing one.
|
|
@@ -491,7 +522,7 @@ node --run crew -- doctor
|
|
|
491
522
|
node --run crew:op -- run --watch
|
|
492
523
|
```
|
|
493
524
|
|
|
494
|
-
Both forms
|
|
525
|
+
Both forms discover config via cosmiconfig — project-walk from cwd for `crew.config.ts` and friends, then `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts` (legacy `config.ts` is still accepted for one release). Set `GROUNDCREW_CONFIG` to point elsewhere. The `crew:op` wrapper additionally reads `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/op.env` (1Password env-file with `op://` references resolved at launch).
|
|
495
526
|
|
|
496
527
|
Logs land in `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log` by default (override via `logging.file`). The "Loaded config from …" line at startup tells you which config won.
|
|
497
528
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Config } from "./src/lib/config.js";
|
|
2
2
|
// import { readFileSync } from "node:fs";
|
|
3
3
|
|
|
4
|
-
export
|
|
4
|
+
export default {
|
|
5
5
|
linear: {
|
|
6
6
|
// Project URL slug to scope polling. Copy the trailing segment of
|
|
7
7
|
// your Linear project URL —
|
|
@@ -9,7 +9,7 @@ export const config: Config = {
|
|
|
9
9
|
// — verbatim, for example "ai-strategy-5152195762f3". The 12-char hex
|
|
10
10
|
// tail is the canonical ID groundcrew uses, so the orchestrator stays
|
|
11
11
|
// resilient across project renames and across same-name projects in
|
|
12
|
-
// different teams. The leading name segment keeps
|
|
12
|
+
// different teams. The leading name segment keeps the file
|
|
13
13
|
// self-documenting at a glance.
|
|
14
14
|
projectSlug: "your-project-name-0123456789ab",
|
|
15
15
|
// statuses: { todo: "Todo", inProgress: "In Progress", done: "Done", terminal: ["Done"] },
|
|
@@ -76,4 +76,4 @@ export const config: Config = {
|
|
|
76
76
|
// // evidence with it. Default: `${XDG_STATE_HOME:-~/.local/state}/groundcrew/groundcrew.log`.
|
|
77
77
|
// file: "~/Library/Logs/groundcrew/groundcrew.log",
|
|
78
78
|
// },
|
|
79
|
-
};
|
|
79
|
+
} satisfies Config;
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AA6JA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { cleanupWorkspaceCli } from "./commands/cleanupWorkspace.js";
|
|
3
3
|
import { doctor } from "./commands/doctor.js";
|
|
4
|
+
import { interruptWorkspaceCli } from "./commands/interruptWorkspace.js";
|
|
4
5
|
import { orchestrate } from "./commands/orchestrator.js";
|
|
6
|
+
import { resumeWorkspaceCli } from "./commands/resumeWorkspace.js";
|
|
5
7
|
import { setupReposCli } from "./commands/setupRepos.js";
|
|
6
8
|
import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
|
|
7
9
|
import { errorMessage, readTicketArgument, writeError, writeOutput } from "./lib/util.js";
|
|
@@ -90,6 +92,16 @@ const SUBCOMMANDS = {
|
|
|
90
92
|
usage: "[--force] <ticket>",
|
|
91
93
|
invoke: cleanupWorkspaceCli,
|
|
92
94
|
},
|
|
95
|
+
interrupt: {
|
|
96
|
+
summary: "Stop a live ticket workspace while preserving its worktree",
|
|
97
|
+
usage: "<ticket> [--reason <text>]",
|
|
98
|
+
invoke: interruptWorkspaceCli,
|
|
99
|
+
},
|
|
100
|
+
resume: {
|
|
101
|
+
summary: "Reopen an existing ticket worktree with a continuation prompt",
|
|
102
|
+
usage: "<ticket>",
|
|
103
|
+
invoke: resumeWorkspaceCli,
|
|
104
|
+
},
|
|
93
105
|
setup: {
|
|
94
106
|
summary: "Project-level setup commands (currently: repos)",
|
|
95
107
|
usage: "repos [--dry-run] [<repo>...]",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGvD,OAAO,EAAE,KAAK,aAAa,EAAa,MAAM,qBAAqB,CAAC;AAGpE,UAAU,WAAW;IACnB,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAsDxD"}
|
package/dist/commands/cleaner.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* invocation; stateless across iterations. Mirrors `Dispatcher`.
|
|
5
5
|
*/
|
|
6
6
|
import { isTerminalStatus } from "../lib/boardSource.js";
|
|
7
|
+
import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
|
|
7
8
|
import { log, logEvent } from "../lib/util.js";
|
|
8
9
|
import { worktrees } from "../lib/worktrees.js";
|
|
9
10
|
import { logTeardown, recordTeardownEvents } from "./teardownReporter.js";
|
|
@@ -41,6 +42,7 @@ export function createCleaner(deps) {
|
|
|
41
42
|
const result = signal === undefined
|
|
42
43
|
? await worktrees.teardown(config, stale)
|
|
43
44
|
: await worktrees.teardown(config, stale, { signal });
|
|
45
|
+
recordCleanedUpRuns(config, result.removed);
|
|
44
46
|
logTeardown(result);
|
|
45
47
|
recordTeardownEvents(result);
|
|
46
48
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAMnE,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAwBD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAef;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIvE"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../lib/config.js";
|
|
2
|
+
import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
|
|
2
3
|
import { log } from "../lib/util.js";
|
|
3
4
|
import { worktrees } from "../lib/worktrees.js";
|
|
4
5
|
import { logTeardown } from "./teardownReporter.js";
|
|
@@ -29,6 +30,7 @@ export async function cleanupWorkspace(config, options) {
|
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
32
|
const result = await worktrees.teardown(config, entries, { force });
|
|
33
|
+
recordCleanedUpRuns(config, result.removed);
|
|
32
34
|
logTeardown(result);
|
|
33
35
|
if (result.failures.length > 0) {
|
|
34
36
|
throw result.failures[0]?.error;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ResolvedConfig } from "../lib/config.ts";
|
|
2
|
+
export interface InterruptWorkspaceOptions {
|
|
3
|
+
ticket: string;
|
|
4
|
+
reason?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function interruptWorkspace(config: ResolvedConfig, options: InterruptWorkspaceOptions): Promise<void>;
|
|
7
|
+
export declare function interruptWorkspaceCli(argv: string[]): Promise<void>;
|
|
8
|
+
//# sourceMappingURL=interruptWorkspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interruptWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/interruptWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAMnE,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAuGD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,yBAAyB,GACjC,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGzE"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { loadConfig } from "../lib/config.js";
|
|
2
|
+
import { readRunState, recordRunState } from "../lib/runState.js";
|
|
3
|
+
import { errorMessage, log } from "../lib/util.js";
|
|
4
|
+
import { workspaces } from "../lib/workspaces.js";
|
|
5
|
+
import { worktrees } from "../lib/worktrees.js";
|
|
6
|
+
function parseArguments(argv) {
|
|
7
|
+
let reason;
|
|
8
|
+
const positionals = [];
|
|
9
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
10
|
+
const argument = argv[index];
|
|
11
|
+
/* v8 ignore next @preserve -- loop bounds ensure argv[index] exists; guard satisfies noUncheckedIndexedAccess */
|
|
12
|
+
if (argument === undefined) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (argument === "--reason") {
|
|
16
|
+
const value = argv[index + 1];
|
|
17
|
+
if (value === undefined || value.length === 0 || value.startsWith("-")) {
|
|
18
|
+
throw new Error("crew interrupt --reason: reason text is required");
|
|
19
|
+
}
|
|
20
|
+
reason = value;
|
|
21
|
+
index += 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (argument.startsWith("-")) {
|
|
25
|
+
throw new Error(`Unknown option: ${argument}\nUsage: crew interrupt <ticket> [--reason <text>]`);
|
|
26
|
+
}
|
|
27
|
+
positionals.push(argument);
|
|
28
|
+
}
|
|
29
|
+
const [ticket, ...extras] = positionals;
|
|
30
|
+
if (ticket === undefined || ticket.length === 0 || extras.length > 0) {
|
|
31
|
+
throw new Error("Usage: crew interrupt <ticket> [--reason <text>]");
|
|
32
|
+
}
|
|
33
|
+
return { ticket: ticket.toLowerCase(), ...(reason === undefined ? {} : { reason }) };
|
|
34
|
+
}
|
|
35
|
+
function sourceFromState(state) {
|
|
36
|
+
return {
|
|
37
|
+
ticket: state.ticket,
|
|
38
|
+
repository: state.repository,
|
|
39
|
+
model: state.model,
|
|
40
|
+
worktreeDir: state.worktreeDir,
|
|
41
|
+
branchName: state.branchName,
|
|
42
|
+
workspaceName: state.workspaceName,
|
|
43
|
+
resumeCount: state.resumeCount,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function sourceFromWorktree(config, ticket, entry) {
|
|
47
|
+
return {
|
|
48
|
+
ticket,
|
|
49
|
+
repository: entry.repository,
|
|
50
|
+
model: config.models.default,
|
|
51
|
+
worktreeDir: entry.dir,
|
|
52
|
+
branchName: entry.branchName,
|
|
53
|
+
workspaceName: ticket,
|
|
54
|
+
resumeCount: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function resolveInterruptSource(arguments_) {
|
|
58
|
+
if (arguments_.state !== undefined) {
|
|
59
|
+
return sourceFromState(arguments_.state);
|
|
60
|
+
}
|
|
61
|
+
if (arguments_.entry !== undefined) {
|
|
62
|
+
return sourceFromWorktree(arguments_.config, arguments_.ticket, arguments_.entry);
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`No run state or worktree found for ${arguments_.ticket}; nothing to interrupt.`);
|
|
65
|
+
}
|
|
66
|
+
function interruptDetail(result) {
|
|
67
|
+
if (result.kind === "missing") {
|
|
68
|
+
return "workspace missing";
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function failOnUnavailable(result) {
|
|
73
|
+
if (result.kind !== "unavailable") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const detail = result.error === undefined ? "workspace adapter unavailable" : errorMessage(result.error);
|
|
77
|
+
throw new Error(`Could not interrupt workspace: ${detail}`);
|
|
78
|
+
}
|
|
79
|
+
export async function interruptWorkspace(config, options) {
|
|
80
|
+
const ticket = options.ticket.toLowerCase();
|
|
81
|
+
const state = readRunState(config, ticket);
|
|
82
|
+
const [entry] = worktrees.findByTicket(config, ticket);
|
|
83
|
+
const source = resolveInterruptSource({ config, ticket, state, entry });
|
|
84
|
+
const result = await workspaces.interrupt(config, source.workspaceName);
|
|
85
|
+
failOnUnavailable(result);
|
|
86
|
+
const detail = interruptDetail(result);
|
|
87
|
+
recordRunState({
|
|
88
|
+
config,
|
|
89
|
+
state: {
|
|
90
|
+
ticket,
|
|
91
|
+
repository: source.repository,
|
|
92
|
+
model: source.model,
|
|
93
|
+
worktreeDir: source.worktreeDir,
|
|
94
|
+
branchName: source.branchName,
|
|
95
|
+
workspaceName: source.workspaceName,
|
|
96
|
+
state: "interrupted",
|
|
97
|
+
resumeCount: source.resumeCount,
|
|
98
|
+
...(options.reason === undefined ? {} : { reason: options.reason }),
|
|
99
|
+
...(detail === undefined ? {} : { detail }),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
log(`Interrupted ${ticket}; worktree preserved at ${source.worktreeDir}`);
|
|
103
|
+
log(`Next: crew doctor --ticket ${ticket}`);
|
|
104
|
+
}
|
|
105
|
+
export async function interruptWorkspaceCli(argv) {
|
|
106
|
+
const config = await loadConfig();
|
|
107
|
+
await interruptWorkspace(config, parseArguments(argv));
|
|
108
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ResolvedConfig } from "../lib/config.ts";
|
|
2
|
+
export interface ResumeWorkspaceOptions {
|
|
3
|
+
ticket: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function resumeWorkspace(config: ResolvedConfig, options: ResumeWorkspaceOptions): Promise<void>;
|
|
6
|
+
export declare function resumeWorkspaceCli(argv: string[]): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=resumeWorkspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resumeWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/resumeWorkspace.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAcnE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;CAChB;AA6HD,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6Df;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtE"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { fetchResolvedIssue } from "../lib/boardSource.js";
|
|
2
|
+
import { loadConfig } from "../lib/config.js";
|
|
3
|
+
import { ensureAgentSandbox, openAgentWorkspace, prepareAgentLaunch } from "../lib/agentLaunch.js";
|
|
4
|
+
import { buildLaunchCommand } from "../lib/launchCommand.js";
|
|
5
|
+
import { readRunState, recordRunState } from "../lib/runState.js";
|
|
6
|
+
import { removeStagedPrompt, stageBuildSecrets, stagePromptText, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
|
|
7
|
+
import { errorMessage, getLinearClient, log } from "../lib/util.js";
|
|
8
|
+
import { workspaces } from "../lib/workspaces.js";
|
|
9
|
+
import { worktrees } from "../lib/worktrees.js";
|
|
10
|
+
function parseArguments(argv) {
|
|
11
|
+
const [ticket, ...extras] = argv;
|
|
12
|
+
if (ticket === undefined || ticket.length === 0 || extras.length > 0 || ticket.startsWith("-")) {
|
|
13
|
+
throw new Error("Usage: crew resume <ticket>");
|
|
14
|
+
}
|
|
15
|
+
return { ticket: ticket.toLowerCase() };
|
|
16
|
+
}
|
|
17
|
+
async function fetchTicketDetails(ticket) {
|
|
18
|
+
try {
|
|
19
|
+
const issue = await getLinearClient().issue(ticket.toUpperCase());
|
|
20
|
+
return {
|
|
21
|
+
title: issue.title,
|
|
22
|
+
description: issue.description ?? "",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
log(`Resume Linear detail lookup failed for ${ticket}: ${errorMessage(error)}`);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function contextFromLinear(config, ticket, worktree) {
|
|
31
|
+
const resolved = await fetchResolvedIssue({ client: getLinearClient(), config, ticket });
|
|
32
|
+
return {
|
|
33
|
+
ticket,
|
|
34
|
+
repository: resolved.repository,
|
|
35
|
+
model: resolved.model,
|
|
36
|
+
worktree,
|
|
37
|
+
title: resolved.title,
|
|
38
|
+
description: resolved.description,
|
|
39
|
+
resumeCount: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function contextFromState(ticket, state, worktree) {
|
|
43
|
+
const details = await fetchTicketDetails(ticket);
|
|
44
|
+
return {
|
|
45
|
+
ticket,
|
|
46
|
+
repository: state.repository,
|
|
47
|
+
model: state.model,
|
|
48
|
+
worktree,
|
|
49
|
+
title: details?.title ?? ticket.toUpperCase(),
|
|
50
|
+
description: details?.description ?? "",
|
|
51
|
+
...(state.reason === undefined ? {} : { reason: state.reason }),
|
|
52
|
+
resumeCount: state.resumeCount,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function buildResumeContext(config, ticket) {
|
|
56
|
+
const state = readRunState(config, ticket);
|
|
57
|
+
const entries = worktrees.findByTicket(config, ticket);
|
|
58
|
+
const worktree = state === undefined
|
|
59
|
+
? entries[0]
|
|
60
|
+
: (entries.find((entry) => entry.repository === state.repository) ?? entries[0]);
|
|
61
|
+
if (worktree === undefined) {
|
|
62
|
+
throw new Error(`No worktree found for ${ticket}; cannot resume.`);
|
|
63
|
+
}
|
|
64
|
+
if (state !== undefined) {
|
|
65
|
+
return await contextFromState(ticket, state, worktree);
|
|
66
|
+
}
|
|
67
|
+
return await contextFromLinear(config, ticket, worktree);
|
|
68
|
+
}
|
|
69
|
+
function renderResumePrompt(context) {
|
|
70
|
+
return [
|
|
71
|
+
`You are resuming Groundcrew ticket ${context.ticket} (${context.title}) in an existing worktree.`,
|
|
72
|
+
"",
|
|
73
|
+
"Ticket description:",
|
|
74
|
+
"",
|
|
75
|
+
context.description,
|
|
76
|
+
"",
|
|
77
|
+
"## Continuation context",
|
|
78
|
+
"",
|
|
79
|
+
`- Worktree: ${context.worktree.dir}`,
|
|
80
|
+
`- Branch: ${context.worktree.branchName}`,
|
|
81
|
+
context.reason === undefined
|
|
82
|
+
? "- Previous interrupt reason: none recorded"
|
|
83
|
+
: `- Previous interrupt reason: ${context.reason}`,
|
|
84
|
+
"",
|
|
85
|
+
"Before editing, inspect the current git status and diff. Continue from the work already present in this worktree; do not restart from scratch unless the diff proves that is necessary.",
|
|
86
|
+
"",
|
|
87
|
+
"Run the repository's documented verification before stopping, then leave the branch ready or open a PR when possible.",
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
async function failIfWorkspaceAlreadyLive(config, ticket) {
|
|
91
|
+
const probe = await workspaces.probe(config);
|
|
92
|
+
if (probe.kind === "unavailable") {
|
|
93
|
+
const detail = probe.error === undefined ? "" : `: ${errorMessage(probe.error)}`;
|
|
94
|
+
throw new Error(`Could not verify whether workspace for ${ticket} is already live${detail}. Retry or inspect the workspace backend manually before resuming.`);
|
|
95
|
+
}
|
|
96
|
+
if (probe.names.has(ticket)) {
|
|
97
|
+
throw new Error(`Workspace for ${ticket} is already live; attach to it instead of resuming.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function resumeWorkspace(config, options) {
|
|
101
|
+
const ticket = options.ticket.toLowerCase();
|
|
102
|
+
await failIfWorkspaceAlreadyLive(config, ticket);
|
|
103
|
+
const context = await buildResumeContext(config, ticket);
|
|
104
|
+
const definition = config.models.definitions[context.model];
|
|
105
|
+
if (definition === undefined) {
|
|
106
|
+
throw new Error(`Unknown model: ${context.model}`);
|
|
107
|
+
}
|
|
108
|
+
const { runner, sandboxName } = await prepareAgentLaunch({
|
|
109
|
+
config,
|
|
110
|
+
model: context.model,
|
|
111
|
+
definition,
|
|
112
|
+
purpose: "resumes",
|
|
113
|
+
});
|
|
114
|
+
const stagedPrompt = stagePromptText({
|
|
115
|
+
prefix: "groundcrew-resume",
|
|
116
|
+
ticket,
|
|
117
|
+
text: renderResumePrompt(context),
|
|
118
|
+
});
|
|
119
|
+
const secretsFile = stageBuildSecrets(stagedPrompt.directory);
|
|
120
|
+
await ensureAgentSandbox({ config, definition, sandboxName });
|
|
121
|
+
const launchCommand = buildLaunchCommand({
|
|
122
|
+
definition,
|
|
123
|
+
promptFile: stagedPrompt.file,
|
|
124
|
+
worktreeDir: context.worktree.dir,
|
|
125
|
+
secretsFile,
|
|
126
|
+
runner,
|
|
127
|
+
sandboxName,
|
|
128
|
+
});
|
|
129
|
+
const launchCmd = stageWorkspaceLaunchCommand(stagedPrompt.directory, launchCommand);
|
|
130
|
+
try {
|
|
131
|
+
await openAgentWorkspace({
|
|
132
|
+
config,
|
|
133
|
+
name: ticket,
|
|
134
|
+
cwd: context.worktree.dir,
|
|
135
|
+
command: launchCmd,
|
|
136
|
+
model: context.model,
|
|
137
|
+
color: definition.color,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
removeStagedPrompt(stagedPrompt.directory);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
recordRunState({
|
|
145
|
+
config,
|
|
146
|
+
state: {
|
|
147
|
+
ticket,
|
|
148
|
+
repository: context.repository,
|
|
149
|
+
model: context.model,
|
|
150
|
+
worktreeDir: context.worktree.dir,
|
|
151
|
+
branchName: context.worktree.branchName,
|
|
152
|
+
workspaceName: ticket,
|
|
153
|
+
state: "resumed",
|
|
154
|
+
resumeCount: context.resumeCount + 1,
|
|
155
|
+
...(context.reason === undefined ? {} : { reason: context.reason }),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
log(`Resumed ${ticket} in ${context.worktree.dir} (${context.model})`);
|
|
159
|
+
}
|
|
160
|
+
export async function resumeWorkspaceCli(argv) {
|
|
161
|
+
const config = await loadConfig();
|
|
162
|
+
await resumeWorkspace(config, parseArguments(argv));
|
|
163
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAenE,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAWD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAqBD,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CA6Gf;AAwHD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
|