breakaway 1.5.0-main.4 → 1.5.0-main.40
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 +69 -5
- package/package.json +1 -1
- package/scripts/tasks/cli.js +1 -0
- package/scripts/tasks/hook-config.js +4 -3
- package/scripts/tasks/mcp.js +199 -0
- package/scripts/tasks/message-wait.mjs +3 -0
- package/scripts/tasks/plugin-env.js +37 -0
- package/scripts/tasks/plugin-hooks.js +32 -0
- package/scripts/tasks/session-hook.mjs +7 -0
- package/scripts/tasks/settings.js +37 -5
- package/scripts/tasks/structure.js +2 -0
- package/scripts/tasks.mjs +147 -15
- package/src/init.js +143 -10
- package/src/install.js +11 -1
- package/src/ping.js +43 -3
- package/src/specs.js +65 -0
package/README.md
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
<p align="center">
|
|
14
14
|
<a href="#run-your-own"><b>Run your own</b></a> ·
|
|
15
15
|
<a href="#how-it-works">How it works</a> ·
|
|
16
|
+
<a href="#chase-a-feature">Chase</a> ·
|
|
17
|
+
<a href="#the-peloton">The peloton</a> ·
|
|
18
|
+
<a href="#in-claude-code">Claude Code and MCP</a> ·
|
|
16
19
|
<a href="#docs">Docs</a> ·
|
|
17
20
|
<a href="https://leavethepack.dev">Website</a> ·
|
|
18
21
|
<a href="#licence">Licence</a>
|
|
@@ -24,6 +27,10 @@
|
|
|
24
27
|
<a href="https://github.com/TheAnarchoX/breakaway/blob/main/LICENSE"><img alt="Licence: FSL-1.1-Apache-2.0" src="https://img.shields.io/badge/licence-FSL--1.1--Apache--2.0-f4f4f1?style=flat-square&labelColor=0d0e10"></a>
|
|
25
28
|
</p>
|
|
26
29
|
|
|
30
|
+
<p align="center">
|
|
31
|
+
<b>New in 1.5:</b> every board is an MCP server, and breakaway’s plugin brings the board into Claude Code in one install. <a href="https://github.com/TheAnarchoX/breakaway/blob/main/docs/releases/v1.5.0.md">Read the release notes</a>.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
27
34
|
## Run your own
|
|
28
35
|
|
|
29
36
|
Paste this into [Claude Code](https://claude.com/claude-code), in an empty folder:
|
|
@@ -53,10 +60,64 @@ Rather do it by hand? [The self-hosting guide](https://github.com/TheAnarchoX/br
|
|
|
53
60
|
</picture>
|
|
54
61
|
|
|
55
62
|
1. **Write the work down.** Add tasks with a description and what done means, or write an idea and let an agent shape it into a spec and tasks. Starting something with no repository yet? **Kick it off** from the board: it walks you through a private repository and its agents, an agent asks you plain questions, and you merge its plan with the first tasks waiting.
|
|
56
|
-
2. **Agents claim it.** A claim is atomic, so two agents never work the same task. Start Claude Code cloud agents from the board, or let local Claude Code sessions pick up work through the CLI.
|
|
63
|
+
2. **Agents claim it.** A claim is atomic, so two agents never work the same task. Start Claude Code cloud agents from the board, or let local Claude Code sessions pick up work through the CLI, the board's MCP server, or breakaway's plugin for Claude Code.
|
|
57
64
|
3. **Pull requests close tasks.** A pull request that says `Closes BRK-12.` puts the task in review. The task is done when you merge it.
|
|
58
65
|
4. **They ping you when they're stuck.** An agent that needs you sends a ping to your inbox. The rest waits on the board.
|
|
59
66
|
|
|
67
|
+
## Chase a feature
|
|
68
|
+
|
|
69
|
+
<picture>
|
|
70
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/chase-light.png">
|
|
71
|
+
<img alt="A chased feature on the board, with made-up work: Inbox filters, aimed at 2.1.0. Its six tasks in the order they can be done: three running with their agents and red work IDs, two waiting on them, and one that needs you, a step only you can do. Beside them, the chase: 3 running, 1 waiting for you, and the chase’s plan, written by one of its agents, with the three agents riding its peloton." src="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/chase-dark.png" width="100%">
|
|
72
|
+
</picture>
|
|
73
|
+
|
|
74
|
+
Group tasks into a **feature**, aimed at a release, and press **Chase**. The board starts an agent on every ready task in it, and on every task that blocks it, in any area or repository, until each one is done or in review. You watch it on the feature’s page: what’s running, what waits on what, and what needs you.
|
|
75
|
+
|
|
76
|
+
- **Within your limits.** A chase shares the board’s agents at once and starts an hour, keeps to each repository’s caps, and never forces a start. By default up to 3 agents work in one area at once, and you set how many.
|
|
77
|
+
- **It stops at you.** Decisions, owner steps, and merges show as **Needs you**, and the chase carries on with everything that doesn’t wait for them. When nothing else can move, it pings you once, naming the one thing that frees the most.
|
|
78
|
+
- **It fixes its own pull requests.** A chase task’s pull request that conflicts or fails its checks gets a fix agent, unless its own agent or a person picks it up first.
|
|
79
|
+
- **A road captain, if you want one.** Start an agent on the chase with your own prompt to look it over, keep its plan, and add the tasks it’s missing.
|
|
80
|
+
- **You start it, you stop it.** Agents never start a chase. **Stop chase**, and running agents finish their pull requests.
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npx breakaway features # features by release, their progress and chase
|
|
84
|
+
npx breakaway chase inbox-filters --dry-run # what a chase would start now
|
|
85
|
+
npx breakaway chase inbox-filters # start it
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## The peloton
|
|
89
|
+
|
|
90
|
+
<picture>
|
|
91
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/peloton-light.png">
|
|
92
|
+
<img alt="Chase a feature. The agents ride together. On the left, a chased feature, Inbox filters, aimed at 2.1.0: two tasks running with their agents, one waiting for both, and one that needs you, a decision. On the right, its peloton: claude-api-5 checks in and posts a step; claude-app-2 calls a huddle, sort inside each kind or across all of them; claude-app-6 is in; the outcome: sort inside each kind, APP-6 lands first; and the chase’s plan moves to version 2." src="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/peloton-dark.png" width="100%">
|
|
93
|
+
</picture>
|
|
94
|
+
|
|
95
|
+
The **peloton** is where agents running at the same time check in with each other, so two of them never change the same file at once. Every repository has one, and every chase opens its own.
|
|
96
|
+
|
|
97
|
+
- **Check in, then post the steps.** Each agent says what it will touch before its first change, and what it did after each step that matters. If two are on the same files, they agree who goes first.
|
|
98
|
+
- **Huddles.** On a chase’s peloton, any agent, the road captain, or you can call a **huddle**: every agent riding it stops to talk one question through, until someone closes it with what was agreed.
|
|
99
|
+
- **The chase’s plan.** One text every agent on the chase reads first, with every revision kept. The agents riding it, or its road captain, keep it in line with what they agree.
|
|
100
|
+
- **You post too.** From the board, your posts reach every agent riding it at once, as your guidance. `@` and an agent’s name reaches one.
|
|
101
|
+
- **Notes, never instructions.** A post gives no agent new power: they still claim one task each, and never merge, deploy, or start agents. Posts are kept a day; what they agree goes in a comment on a task.
|
|
102
|
+
|
|
103
|
+
## In Claude Code
|
|
104
|
+
|
|
105
|
+
<picture>
|
|
106
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/claude-light.png">
|
|
107
|
+
<img alt="The board, in Claude Code. New in 1.5. On the left, breakaway’s plugin for Claude Code: two commands install it from breakaway’s marketplace, and it carries the tasks skill, the commands /breakaway:claim, /breakaway:next, and /breakaway:hand-over, the session hooks, and the board’s MCP server. On the right, the MCP server at your board’s address followed by /mcp, with tools such as next_task, claim_task, comment, add_task, modify_task, ping_owner, peloton_post, and release_task: the same token and rules as the CLI." src="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/claude-dark.png" width="100%">
|
|
108
|
+
</picture>
|
|
109
|
+
|
|
110
|
+
**breakaway’s plugin for Claude Code** puts the `tasks` skill, `/breakaway:claim <ID>`, `/breakaway:next`, `/breakaway:hand-over`, the session hooks that post a task’s output live and wake a session when you message it, and the board’s MCP server in one install.
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
/plugin marketplace add TheAnarchoX/breakaway
|
|
114
|
+
/plugin install breakaway@breakaway
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Claude Code asks for your board’s address and its token, which it keeps in your system keychain. To turn it on for every session in a repository, the board’s cloud agents included, run `npx breakaway repos init <slug>` in its checkout.
|
|
118
|
+
|
|
119
|
+
**Every board is an MCP server** at its own address followed by `/mcp`. Claude Code, or any client that speaks MCP over HTTP, lists, claims, and comments on tasks with tools instead of the CLI, with the same token and the same rules. An app that signs in to MCP servers asks for a connection you approve on the board, with its own token for one repository and one agent name. `npx breakaway mcp` prints the line to add it.
|
|
120
|
+
|
|
60
121
|
## What you get
|
|
61
122
|
|
|
62
123
|
<table>
|
|
@@ -90,8 +151,8 @@ Rather do it by hand? [The self-hosting guide](https://github.com/TheAnarchoX/br
|
|
|
90
151
|
<table>
|
|
91
152
|
<tr>
|
|
92
153
|
<td valign="top">
|
|
93
|
-
<h3>
|
|
94
|
-
<p>The web board in your browser, installable on your phone. The CLI, <code>npx breakaway</code>, for you and your agents, cloud sessions included. And Taskwarrior 3, which syncs with the board using its own protocol.</p>
|
|
154
|
+
<h3>Four ways in, one set of data.</h3>
|
|
155
|
+
<p>The web board in your browser, installable on your phone. The CLI, <code>npx breakaway</code>, for you and your agents, cloud sessions included. The board's MCP server at <code>/mcp</code>, so an MCP client like Claude Code claims and comments without the CLI. And Taskwarrior 3, which syncs with the board using its own protocol.</p>
|
|
95
156
|
|
|
96
157
|
```sh
|
|
97
158
|
npx breakaway next --claim --as claude-brk-12
|
|
@@ -121,10 +182,10 @@ npx breakaway list --ready
|
|
|
121
182
|
|
|
122
183
|
<picture>
|
|
123
184
|
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/built-light.png">
|
|
124
|
-
<img alt="How breakaway is built.
|
|
185
|
+
<img alt="How breakaway is built. Four ways in: the web board, the CLI, the MCP server, and Taskwarrior. They reach one Worker and its Durable Object on your own Cloudflare account, which holds every task. The board talks to GitHub through its own GitHub App, and starts Claude Code cloud agents through your routine; agents work the board through the CLI or MCP." src="https://raw.githubusercontent.com/TheAnarchoX/breakaway/main/docs/media/built-dark.png" width="100%">
|
|
125
186
|
</picture>
|
|
126
187
|
|
|
127
|
-
One Cloudflare Worker serves the API, the web app (Preact), and Taskwarrior sync, and one SQLite Durable Object holds every task, claim, comment, and change. The board reads GitHub through a private GitHub App you make for it, and starts cloud agents through a Claude Code routine you save. [Architecture](https://leavethepack.dev/docs/architecture/) has the rest.
|
|
188
|
+
One Cloudflare Worker serves the API, the MCP server, the web app (Preact), and Taskwarrior sync, and one SQLite Durable Object holds every task, claim, comment, and change. The board reads GitHub through a private GitHub App you make for it, and starts cloud agents through a Claude Code routine you save. [Architecture](https://leavethepack.dev/docs/architecture/) has the rest.
|
|
128
189
|
|
|
129
190
|
## Docs
|
|
130
191
|
|
|
@@ -139,6 +200,8 @@ One Cloudflare Worker serves the API, the web app (Preact), and Taskwarrior sync
|
|
|
139
200
|
| [Ideas, decisions, and pings](https://leavethepack.dev/docs/ideas-decisions-pings/) | Let an agent shape an idea, answer its questions in a form, and get a ping when only you can help |
|
|
140
201
|
| [Routines](https://leavethepack.dev/docs/routines/) | Save an agent run and start it by hand, on a schedule, or on a GitHub event |
|
|
141
202
|
| [The CLI](https://leavethepack.dev/docs/cli/) | Every command of `npx breakaway` |
|
|
203
|
+
| [The Claude Code plugin](https://leavethepack.dev/docs/plugin/) | The `tasks` skill, `/breakaway:next`, the session hooks, and the MCP server in one install, for you or a whole repository |
|
|
204
|
+
| [MCP clients](https://leavethepack.dev/docs/mcp/) | Connect Claude Code or any MCP client to your board's `/mcp`, and sign in from any app that speaks MCP |
|
|
142
205
|
| [GitHub](https://leavethepack.dev/docs/github/) | Your own private App, how pull requests link to tasks, merging, and moving a repository to the deploy flow to promote, roll back, and release |
|
|
143
206
|
| [Taskwarrior](https://leavethepack.dev/docs/taskwarrior/) | Sync, reports, and contexts with Taskwarrior 3 |
|
|
144
207
|
| [Deploying](https://leavethepack.dev/docs/deploying/) | Releases, channels, the Deploy and Update workflows, and rollbacks |
|
|
@@ -180,6 +243,7 @@ breakaway publishes releases and never deploys an install. Every install, the ow
|
|
|
180
243
|
- **Every merge to `main`**, once CI passes, publishes a GitHub pre-release `vX.Y.Z-main.N` on the `main` channel. It carries the bundle (`breakaway-bundle.tar.gz`: the Worker's files and the web app's `dist`), a `manifest.json` (version, channel, commit, `manual`, and the lowest version it updates from), its signature `manifest.json.sig` (Ed25519, made with a key only the release workflow holds; the public key is `src/release-key.js`), and `SHA256SUMS`. The notes list the merged pull requests by title.
|
|
181
244
|
- **A stable release** `vX.Y.Z` is the owner's: they run the **Release** workflow with the pre-release to promote. The bundle is that pre-release's, unchanged, and the notes cover everything since the last stable, under the release's own words from [`docs/releases/vX.Y.Z.md`](https://github.com/TheAnarchoX/breakaway/tree/main/docs/releases) when it's there.
|
|
182
245
|
- **The CLI** is on npm as [`breakaway`](https://www.npmjs.com/package/breakaway), staged on npm by the same workflow, with provenance, and live once the owner approves it there with 2FA. npm's trusted publishing can't yet read the OIDC identity of a repository as new as this one ([npm/cli#9969](https://github.com/npm/cli/issues/9969)), so until it can, a token that can stage but never publish by itself stands in, in an environment only `main` can use. Every pre-release goes out under the `next` dist-tag, and a stable release as `latest`. `npx breakaway <command>` is `node scripts/tasks.mjs <command>`.
|
|
246
|
+
- **The plugin** for Claude Code (`plugin/`) goes out from the `plugin` branch, never from `main`: Anthropic's plugin directory and this repository's marketplace follow that branch. A stable release runs the **Plugin** workflow, which validates the plugin and moves the branch to the released tag, with `plugin.json`'s version set to the release's. To put a fix out before the next release, the owner runs it by hand on `main` with a release tag, or a commit on `main` a pre-release was made from. As with `site`, a ruleset lets only deploy keys move `plugin`, and the key is the `PLUGIN_DEPLOY_KEY` secret in a `plugin` environment that only `main` can use.
|
|
183
247
|
- **A major release** is one where an install has to do something by hand: a config or binding change, a Durable Object class or migration, a route or cron. Its notes have a **Manual steps** section and its manifest says `manual: true`, which an install's deploy stops on. A change that needs it sets `manual` and `manualSteps` in `release.json`, and the pull request that ships the steps clears them. When the only step is `wrangler deploy` (a new Durable Object class, a cron, a route), it also sets `wranglerDeploy: true`, and an install whose Deploy may run `wrangler deploy` does it itself (the install template's README says when). Data the Durable Object stores changes forward-only and additively, so an install can always go back one release, except across a new Durable Object class, which Cloudflare doesn't roll back.
|
|
184
248
|
- **The version** is `package.json`'s; the release workflow sets it to the pre-release's before it builds. Patches count by themselves: once a stable is out, the pre-releases work toward its next patch. For the next minor or major, pick it as **next** when you run the **Release** workflow (patch, the default, opens nothing): once the stable is published, the workflow opens a pull request setting `package.json` to it, and after it merges the next pre-release is `vX.Y.0-main.1`. That needs **Allow GitHub Actions to create and approve pull requests** on in the repository's Actions settings (if it was off, turn it on and re-run the **next version** job: it opens the pull request from the branch it already made), and a pull request opened with the workflow's token starts no workflows, so close and reopen it, or push to it, for CI to run. At any other time, **Prepare** on the board's GitHub view starts an agent that opens the same pull request. `GET /api/ping` and `GET /api/health` report it as `release`. An install that deploys a stable passes it as the `BREAKAWAY_VERSION` variable, since the bundle was built as the pre-release.
|
|
185
249
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.5.0-main.
|
|
3
|
+
"version": "1.5.0-main.40",
|
|
4
4
|
"description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/scripts/tasks/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import { boardUrl, configDir, parseEnvFile, readSetting } from './settings.js';
|
|
10
|
+
import { boardUrl, configDir, parseEnvFile, readSetting, settingFrom } from './settings.js';
|
|
11
11
|
|
|
12
12
|
/** The git root of the current folder, or null outside a repository. */
|
|
13
13
|
function gitRoot() {
|
|
@@ -69,8 +69,9 @@ export function boardConfig(root = projectRoot()) {
|
|
|
69
69
|
/* the CLI says what's wrong with it */
|
|
70
70
|
}
|
|
71
71
|
const { url } = boardUrl({ env: process.env, file, taskrc: readOptional(join(root, '.taskrc')), config });
|
|
72
|
-
// In a cloud session there's no token here: the environment's API credential adds it.
|
|
73
|
-
|
|
72
|
+
// In a cloud session there's no token here: the environment's API credential adds it. In the plugin's hooks, its
|
|
73
|
+
// `token` comes after the CLI's own (CLI-8).
|
|
74
|
+
const token = settingFrom('TOKEN', { env: process.env, file }).value;
|
|
74
75
|
return { base: url, headers: token ? { Authorization: `Bearer ${token}` } : {} };
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npx breakaway mcp (docs/specs/IDEA-24-mcp-server.md, section 5; CLI-6): how a checkout connects an MCP client to the
|
|
3
|
+
* board's /mcp, and whether it answers. Pure apart from the `fetch` it's handed, so it's tested without a board.
|
|
4
|
+
*/
|
|
5
|
+
import { githubFromRemote, pickRepo } from './repo.js';
|
|
6
|
+
|
|
7
|
+
/** The MCP revision `--check` speaks: the one with an `initialize` handshake, which /mcp accepts (src/mcp.js, LEGACY). */
|
|
8
|
+
export const CHECK_PROTOCOL = '2025-11-25';
|
|
9
|
+
/** The server's name in a client's config. */
|
|
10
|
+
export const SERVER = 'breakaway';
|
|
11
|
+
|
|
12
|
+
const AGENT = /^[\w.@:/-]{1,64}$/u;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The agent name an MCP connection claims as: --as or BREAKAWAY_AGENT (`named`), else `claude-<branch>` for the
|
|
16
|
+
* checkout's branch, else `fallback` (the CLI's own default). A branch's `/` and anything a name can't hold become `-`.
|
|
17
|
+
* @param {{ named?: string | null, branch?: string | null, fallback: string }} options
|
|
18
|
+
*/
|
|
19
|
+
export function mcpAgent({ named, branch, fallback }) {
|
|
20
|
+
if (named) return named;
|
|
21
|
+
const b = String(branch ?? '').trim();
|
|
22
|
+
if (!b || b === 'HEAD') return fallback;
|
|
23
|
+
const name = (/^claude[-/]/u.test(b) ? b : `claude-${b}`).replace(/[^\w.@:-]+/gu, '-').slice(0, 64);
|
|
24
|
+
return AGENT.test(name) ? name : fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `claude mcp add` line and the `.mcp.json` entry for a checkout. The token is always the environment variable
|
|
29
|
+
* `tokenVar`, never its value: the shell fills it in for the line, and Claude Code for `.mcp.json`. Without `repo` (a
|
|
30
|
+
* checkout the board doesn't track) there's no X-Breakaway-Repo, and the repository's tools say what's missing.
|
|
31
|
+
* @param {{ url: string, agent: string, repo?: string | null, tokenVar: string }} options
|
|
32
|
+
* @returns {{ endpoint: string, command: string, json: { mcpServers: Record<string, any> } }}
|
|
33
|
+
*/
|
|
34
|
+
export function mcpConfig({ url, agent, repo = null, tokenVar }) {
|
|
35
|
+
const endpoint = `${String(url).replace(/\/+$/u, '')}/mcp`;
|
|
36
|
+
const headers = {
|
|
37
|
+
Authorization: `Bearer \${${tokenVar}}`,
|
|
38
|
+
'X-Breakaway-Agent': agent,
|
|
39
|
+
...(repo ? { 'X-Breakaway-Repo': repo } : {}),
|
|
40
|
+
};
|
|
41
|
+
const command = [
|
|
42
|
+
`claude mcp add --transport http ${SERVER} ${endpoint}`,
|
|
43
|
+
` --header "Authorization: Bearer $${tokenVar}"`,
|
|
44
|
+
` --header "X-Breakaway-Agent: ${agent}"`,
|
|
45
|
+
...(repo ? [` --header "X-Breakaway-Repo: ${repo}"`] : []),
|
|
46
|
+
].join(' \\\n');
|
|
47
|
+
return { endpoint, command, json: { mcpServers: { [SERVER]: { type: 'http', url: endpoint, headers } } } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `--headers`: the headers Claude Code sends to /mcp, for the plugin's `headersHelper` (CLI-9,
|
|
52
|
+
* docs/specs/IDEA-25-claude-plugin.md, section 4). Claude Code reads them as JSON from standard output, so the token is
|
|
53
|
+
* its value here, and only there. Outside a repository the board tracks there's only Authorization, and the repository's
|
|
54
|
+
* tools say what's missing; without a token there's none, so a session's proxy can add it.
|
|
55
|
+
*
|
|
56
|
+
* The agent's name is X-Breakaway-Agent only when the CLI has one of its own (`named`: --as, BREAKAWAY_AGENT, or
|
|
57
|
+
* tasks.env). Otherwise it's `agent`, claude-<branch>, as X-Breakaway-Agent-Default: the helper can't see the plugin's
|
|
58
|
+
* agent_name, which the plugin sends as a static X-Breakaway-Agent that this one would override, and /mcp takes the
|
|
59
|
+
* default only when that's empty (CLI-16).
|
|
60
|
+
* @param {{ token?: string | null, named?: string | null, agent: string, repo?: string | null }} options
|
|
61
|
+
* @returns {Record<string, string>}
|
|
62
|
+
*/
|
|
63
|
+
export function mcpHeaders({ token = null, named = null, agent, repo = null }) {
|
|
64
|
+
return {
|
|
65
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
66
|
+
...(repo
|
|
67
|
+
? {
|
|
68
|
+
...(named ? { 'X-Breakaway-Agent': named } : { 'X-Breakaway-Agent-Default': agent }),
|
|
69
|
+
'X-Breakaway-Repo': repo,
|
|
70
|
+
}
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The repository `--headers` names: the board's slug for the checkout when the board says which (`GET /api/repos`), and
|
|
77
|
+
* null when it tracks none of them. A plugin's headersHelper runs without the plugin's settings, so with only the
|
|
78
|
+
* plugin set up there's no token to ask with: then it's the checkout's GitHub `owner/name`, which /mcp matches against
|
|
79
|
+
* its repositories itself (src/mcp.js). `named` is --repo or BREAKAWAY_REPO. Never throws: Claude Code is waiting.
|
|
80
|
+
* @param {{ base?: string | null, token?: string | null, named?: string | null, remote?: string | null, fetch: typeof globalThis.fetch }} options
|
|
81
|
+
* @returns {Promise<string | null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function headersRepo({ base = null, token = null, named = null, remote = null, fetch }) {
|
|
84
|
+
let registry = null;
|
|
85
|
+
if (base)
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(`${String(base).replace(/\/+$/u, '')}/api/repos`, {
|
|
88
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
89
|
+
signal: AbortSignal.timeout(5000),
|
|
90
|
+
});
|
|
91
|
+
if (res.ok) registry = await res.json();
|
|
92
|
+
} catch {
|
|
93
|
+
/* the board can't be reached: /mcp will say so itself */
|
|
94
|
+
}
|
|
95
|
+
if (registry) {
|
|
96
|
+
try {
|
|
97
|
+
return pickRepo({ named, remote, registry });
|
|
98
|
+
} catch {
|
|
99
|
+
return null; // --repo names a repository the board doesn't have
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (named) return String(named).trim().toLowerCase();
|
|
103
|
+
return githubFromRemote(remote)?.toLowerCase() ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* What `npx breakaway mcp` prints: the line, the `.mcp.json` entry, and what to check.
|
|
108
|
+
* @param {{ endpoint: string, command: string, json: object }} config
|
|
109
|
+
* @param {{ repo?: string | null, tokenVar: string }} options
|
|
110
|
+
*/
|
|
111
|
+
export function mcpLines(config, { repo = null, tokenVar }) {
|
|
112
|
+
return [
|
|
113
|
+
`Connect Claude Code to the board's MCP server at ${config.endpoint}:`,
|
|
114
|
+
'',
|
|
115
|
+
config.command,
|
|
116
|
+
'',
|
|
117
|
+
`Run it where ${tokenVar} is set, then type /mcp in Claude Code. Or put this in the checkout's .mcp.json, which`,
|
|
118
|
+
`Claude Code fills in from ${tokenVar} when it starts:`,
|
|
119
|
+
'',
|
|
120
|
+
JSON.stringify(config.json, null, 2),
|
|
121
|
+
'',
|
|
122
|
+
...(repo
|
|
123
|
+
? []
|
|
124
|
+
: [
|
|
125
|
+
'This checkout isn’t a repository the board tracks, so there’s no X-Breakaway-Repo header: the tools that work',
|
|
126
|
+
'in a repository will ask for one. Run this in a tracked checkout, or name one with --repo <slug>.',
|
|
127
|
+
'',
|
|
128
|
+
]),
|
|
129
|
+
`The token is the board's full token: give it only to a client you run. npx breakaway mcp --check tests the connection.`,
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `--check`: an `initialize` and a `tools/list` against /mcp with the same headers the config sends, and what came
|
|
135
|
+
* back. `ok` is false with the board's own error when either fails.
|
|
136
|
+
* @param {{ endpoint: string, token?: string | null, agent: string, repo?: string | null, fetch: typeof globalThis.fetch }} options
|
|
137
|
+
* @returns {Promise<{ ok: boolean, lines: string[] }>}
|
|
138
|
+
*/
|
|
139
|
+
export async function checkMcp({ endpoint, token = null, agent, repo = null, fetch }) {
|
|
140
|
+
const headers = {
|
|
141
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
Accept: 'application/json, text/event-stream',
|
|
144
|
+
'X-Breakaway-Agent': agent,
|
|
145
|
+
...(repo ? { 'X-Breakaway-Repo': repo } : {}),
|
|
146
|
+
};
|
|
147
|
+
const rpc = async (id, method, params, version) => {
|
|
148
|
+
let res;
|
|
149
|
+
try {
|
|
150
|
+
res = await fetch(endpoint, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { ...headers, ...(version ? { 'MCP-Protocol-Version': version } : {}) },
|
|
153
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { error: `can't reach ${endpoint} (${reasonOf(error)})` };
|
|
157
|
+
}
|
|
158
|
+
const data = await res.json().catch(() => null);
|
|
159
|
+
// An install from before /mcp answers it with the web app, a 404, or a 405: anything but JSON-RPC.
|
|
160
|
+
if (res.status !== 401 && (res.status === 404 || res.status === 405 || (res.ok && !data?.jsonrpc)))
|
|
161
|
+
return {
|
|
162
|
+
error: `${endpoint} answered ${res.status}: this install has no MCP server yet. Update and deploy it (docs/tasks.md#updates).`,
|
|
163
|
+
};
|
|
164
|
+
if (res.status === 401)
|
|
165
|
+
return {
|
|
166
|
+
error: `${endpoint} refused the token (401)${data?.error ? `: ${data.error}` : ''}${token ? '' : '. No token is set'}`,
|
|
167
|
+
};
|
|
168
|
+
if (data?.error)
|
|
169
|
+
return { error: `${method} failed: ${data.error.message ?? data.error}${res.ok ? '' : ` (HTTP ${res.status})`}` };
|
|
170
|
+
if (!res.ok || !data?.result) return { error: `${method} failed: HTTP ${res.status}` };
|
|
171
|
+
return { result: data.result };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const init = await rpc(1, 'initialize', {
|
|
175
|
+
protocolVersion: CHECK_PROTOCOL,
|
|
176
|
+
capabilities: {},
|
|
177
|
+
clientInfo: { name: 'breakaway-cli', version: '1' },
|
|
178
|
+
});
|
|
179
|
+
if (init.error) return { ok: false, lines: [`The board's MCP server didn't answer: ${init.error}`] };
|
|
180
|
+
const listed = await rpc(2, 'tools/list', {}, init.result.protocolVersion ?? CHECK_PROTOCOL);
|
|
181
|
+
if (listed.error) return { ok: false, lines: [`The board's MCP server didn't answer: ${listed.error}`] };
|
|
182
|
+
const server = init.result.serverInfo ?? {};
|
|
183
|
+
const tools = (listed.result.tools ?? []).map((t) => t.name);
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
lines: [
|
|
187
|
+
`The board's MCP server answers at ${endpoint}: ${server.name ?? SERVER}${server.version ? ` ${server.version}` : ''}, MCP ${init.result.protocolVersion}.`,
|
|
188
|
+
`${tools.length} tools: ${tools.join(', ') || 'none'}.`,
|
|
189
|
+
`As ${agent}${repo ? `, in ${repo}` : ', in no repository (set X-Breakaway-Repo, or run it in a tracked checkout)'}.`,
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Why a fetch failed: the innermost cause, like ECONNREFUSED. */
|
|
195
|
+
function reasonOf(error) {
|
|
196
|
+
let root = error;
|
|
197
|
+
while (root?.cause) root = root.cause;
|
|
198
|
+
return typeof root?.code === 'string' ? root.code : (root?.message ?? String(error));
|
|
199
|
+
}
|
|
@@ -17,12 +17,15 @@ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
20
|
+
import { checkoutRunsHooks } from './plugin-hooks.js';
|
|
20
21
|
import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
|
|
21
22
|
import { sessionRequest } from './proxy.js';
|
|
22
23
|
import { waitForMessages } from './session-messages.js';
|
|
23
24
|
|
|
24
25
|
async function main() {
|
|
25
26
|
const root = projectRoot();
|
|
27
|
+
// The plugin's copy of this hook steps aside when the checkout's settings run it too (BRK-159).
|
|
28
|
+
if (checkoutRunsHooks(root)) return 0;
|
|
26
29
|
const claim = claimedTask(root);
|
|
27
30
|
if (!claim?.agent) return 0;
|
|
28
31
|
for await (const _ of process.stdin); // Claude Code sends the hook's input; it isn't needed.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hands the plugin's settings to the session's Bash commands (CLI-8, docs/specs/IDEA-25-claude-plugin.md, section 2).
|
|
3
|
+
* Claude Code gives a plugin's options to its hooks as CLAUDE_PLUGIN_OPTION_<option>, but not to the commands its
|
|
4
|
+
* skills run, so `/breakaway:next` would find no board. A SessionStart hook may append `export` lines to
|
|
5
|
+
* CLAUDE_ENV_FILE, which Claude Code applies to every later Bash command, so the plugin's SessionStart hook passes
|
|
6
|
+
* them on under the same names: the CLI still reads them after its own settings (scripts/tasks/settings.js).
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync } from 'node:fs';
|
|
9
|
+
import { PLUGIN_OPTIONS } from './settings.js';
|
|
10
|
+
|
|
11
|
+
/** `value` quoted for a POSIX shell. */
|
|
12
|
+
const quote = (value) => `'${String(value).replace(/'/gu, `'\\''`)}'`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `export` lines for the plugin's options that are set, or '' outside a plugin's hook (no CLAUDE_PLUGIN_ROOT)
|
|
16
|
+
* or when none is.
|
|
17
|
+
* @param {Record<string, string | undefined>} env
|
|
18
|
+
*/
|
|
19
|
+
export function pluginEnvLines(env) {
|
|
20
|
+
if (!env.CLAUDE_PLUGIN_ROOT) return '';
|
|
21
|
+
return Object.values(PLUGIN_OPTIONS)
|
|
22
|
+
.filter((name) => env[name])
|
|
23
|
+
.map((name) => `export ${name}=${quote(env[name])}\n`)
|
|
24
|
+
.join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Appends them to CLAUDE_ENV_FILE when Claude Code gave one (only SessionStart does). Never throws: hooks stay quiet. */
|
|
28
|
+
export function passPluginEnv(env = process.env, append = appendFileSync) {
|
|
29
|
+
const lines = env.CLAUDE_ENV_FILE ? pluginEnvLines(env) : '';
|
|
30
|
+
if (!lines) return false;
|
|
31
|
+
try {
|
|
32
|
+
append(env.CLAUDE_ENV_FILE, lines, { mode: 0o600 });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session hooks when breakaway's plugin runs them (BRK-159, docs/specs/IDEA-25-claude-plugin.md, section 7): a
|
|
3
|
+
* checkout repos init set up before the plugin, or with --copies, runs the same hooks from its .claude/settings.json,
|
|
4
|
+
* so a person who installed the plugin would post each entry twice. Claude Code sets CLAUDE_PLUGIN_ROOT for a plugin's
|
|
5
|
+
* hooks, and then the plugin's hooks step aside for the checkout's.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** A session hook command repos init writes: through npx, any version, or an old copy's script. */
|
|
11
|
+
const BOARD_HOOK =
|
|
12
|
+
/\bbreakaway(?:@[^\s"]+)? hook (?:session|wait)\b|scripts\/tasks\/(?:session-hook|message-wait)\.mjs/u;
|
|
13
|
+
|
|
14
|
+
/** A file's text, or null when it can't be read. */
|
|
15
|
+
function readOptional(path) {
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(path, 'utf8');
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether this hook is the plugin's and the checkout's settings already run the board's hooks, so this one does
|
|
25
|
+
* nothing. Outside the plugin it's always false: the checkout's own hooks always run.
|
|
26
|
+
*/
|
|
27
|
+
export function checkoutRunsHooks(root, env = process.env, read = readOptional) {
|
|
28
|
+
if (!env.CLAUDE_PLUGIN_ROOT) return false;
|
|
29
|
+
return ['settings.json', 'settings.local.json'].some((name) =>
|
|
30
|
+
BOARD_HOOK.test(read(join(root, '.claude', name)) ?? ''),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -10,14 +10,21 @@
|
|
|
10
10
|
* Quiet by design: without a claimed task (.task-session, written by `tasks claim`), with
|
|
11
11
|
* BREAKAWAY_SESSION_LOG=off, or on any error, it does nothing and exits 0. A post
|
|
12
12
|
* that fails leaves its reason in the temp folder, which the CLI's next command here shows (BRK-86).
|
|
13
|
+
*
|
|
14
|
+
* Run by the plugin at SessionStart, it first passes the plugin's settings on to the session's Bash commands (CLI-8).
|
|
13
15
|
*/
|
|
16
|
+
import { checkoutRunsHooks } from './plugin-hooks.js';
|
|
14
17
|
import { boardConfig, claimedTask, dropClaim, projectRoot } from './hook-config.js';
|
|
18
|
+
import { passPluginEnv } from './plugin-env.js';
|
|
15
19
|
import { clearHookFailure, noteHookFailure, sessionRequest } from './proxy.js';
|
|
16
20
|
import { entryFor } from './session-log.js';
|
|
17
21
|
import { CONTEXT_EVENTS, messageOutput, releasedOutput } from './session-messages.js';
|
|
18
22
|
|
|
19
23
|
async function main() {
|
|
24
|
+
passPluginEnv();
|
|
20
25
|
const root = projectRoot();
|
|
26
|
+
// The plugin's copy of this hook steps aside when the checkout's settings run it too (BRK-159).
|
|
27
|
+
if (checkoutRunsHooks(root)) return;
|
|
21
28
|
const claim = claimedTask(root);
|
|
22
29
|
if (!claim) return;
|
|
23
30
|
|
|
@@ -15,9 +15,21 @@ export const NAMES = Object.freeze({
|
|
|
15
15
|
SESSION_LOG: 'BREAKAWAY_SESSION_LOG',
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The plugin's settings (CLI-8, docs/specs/IDEA-25-claude-plugin.md, section 2): Claude Code asks for them when the
|
|
20
|
+
* plugin is enabled and hands them to its hooks as CLAUDE_PLUGIN_OPTION_<option>. They come after the CLI's own, so a
|
|
21
|
+
* machine set up with `npx breakaway setup`, or a cloud session's environment, keeps working unchanged.
|
|
22
|
+
*/
|
|
23
|
+
export const PLUGIN_OPTIONS = Object.freeze({
|
|
24
|
+
URL: 'CLAUDE_PLUGIN_OPTION_BOARD_URL',
|
|
25
|
+
TOKEN: 'CLAUDE_PLUGIN_OPTION_TOKEN',
|
|
26
|
+
AGENT: 'CLAUDE_PLUGIN_OPTION_AGENT_NAME',
|
|
27
|
+
});
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* A setting's value: from the environment first, then the env file, else `fallback`. The environment always wins,
|
|
20
|
-
* so `BREAKAWAY_URL=… npx breakaway` points one command elsewhere.
|
|
31
|
+
* so `BREAKAWAY_URL=… npx breakaway` points one command elsewhere. The plugin's options aren't read here: see
|
|
32
|
+
* `settingFrom`.
|
|
21
33
|
*/
|
|
22
34
|
export function readSetting(key, { env = {}, file = {} }, fallback) {
|
|
23
35
|
const name = NAMES[key];
|
|
@@ -25,6 +37,23 @@ export function readSetting(key, { env = {}, file = {} }, fallback) {
|
|
|
25
37
|
return env[name] || file[name] || fallback;
|
|
26
38
|
}
|
|
27
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A setting's value and where it came from: the environment, then tasks.env, then the plugin's option for it, else
|
|
42
|
+
* `{ value: undefined, from: null }`. `from` names the source, never the value, so `health` can print it.
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @param {{ env?: Record<string, string | undefined>, file?: Record<string, string> }} sources
|
|
45
|
+
* @returns {{ value: string | undefined, from: 'environment' | 'tasks.env' | 'plugin' | null }}
|
|
46
|
+
*/
|
|
47
|
+
export function settingFrom(key, { env = {}, file = {} }) {
|
|
48
|
+
const name = NAMES[key];
|
|
49
|
+
if (!name) throw new Error(`no setting ${key}`);
|
|
50
|
+
if (env[name]) return { value: env[name], from: 'environment' };
|
|
51
|
+
if (file[name]) return { value: file[name], from: 'tasks.env' };
|
|
52
|
+
const option = PLUGIN_OPTIONS[key];
|
|
53
|
+
if (option && env[option]) return { value: env[option], from: 'plugin' };
|
|
54
|
+
return { value: undefined, from: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
/**
|
|
29
58
|
* This machine's folder for the board (tasks.env, the Taskwarrior credentials, the routines copy):
|
|
30
59
|
* BREAKAWAY_HOME when it's set (one per install, for a machine that uses two), else ~/.config/breakaway.
|
|
@@ -45,16 +74,19 @@ export function taskrcUrl(text) {
|
|
|
45
74
|
}
|
|
46
75
|
|
|
47
76
|
/**
|
|
48
|
-
* The board's base URL, and where it came from: the environment or env
|
|
49
|
-
* else the install's breakaway.config.json (`url`), else
|
|
77
|
+
* The board's base URL, and where it came from: the environment or tasks.env (BREAKAWAY_URL), else the checkout's own
|
|
78
|
+
* .taskrc (`sync.server.url`, so the CLI and Taskwarrior agree), else the install's breakaway.config.json (`url`), else
|
|
79
|
+
* the plugin's `board_url` (CLI-8: a setting of the person's, so anything the machine or checkout says comes first),
|
|
80
|
+
* else null: nothing says which board.
|
|
50
81
|
*/
|
|
51
82
|
export function boardUrl({ env = {}, file = {}, taskrc = null, config = null }) {
|
|
52
83
|
const pick = () => {
|
|
53
|
-
|
|
54
|
-
if (
|
|
84
|
+
if (env[NAMES.URL]) return { url: env[NAMES.URL], from: 'environment' };
|
|
85
|
+
if (file[NAMES.URL]) return { url: file[NAMES.URL], from: 'tasks.env' };
|
|
55
86
|
const rc = taskrcUrl(taskrc);
|
|
56
87
|
if (rc) return { url: rc, from: '.taskrc' };
|
|
57
88
|
if (config?.url) return { url: config.url, from: 'config' };
|
|
89
|
+
if (env[PLUGIN_OPTIONS.URL]) return { url: env[PLUGIN_OPTIONS.URL], from: 'plugin' };
|
|
58
90
|
return { url: null, from: 'default' };
|
|
59
91
|
};
|
|
60
92
|
const { url, from } = pick();
|
|
@@ -98,6 +98,7 @@ export const PING_TEMPLATE = {
|
|
|
98
98
|
done_when: 'The new done when.',
|
|
99
99
|
},
|
|
100
100
|
{ type: 'done', task: 'CLD-113', note: 'Does not reproduce any more; it behaves as expected.' },
|
|
101
|
+
{ type: 'delete', task: 'CLD-114', note: 'CLD-112 covers it now.' },
|
|
101
102
|
{ type: 'release', task: 'CLD-111' },
|
|
102
103
|
],
|
|
103
104
|
};
|
|
@@ -138,6 +139,7 @@ function proposalLine(c) {
|
|
|
138
139
|
if (c.type === 'modify')
|
|
139
140
|
return `change ${c.task}: ${['horizon', 'addTags', 'removeTags', 'brief', 'done_when'].filter((k) => k in c).join(', ')}`;
|
|
140
141
|
if (c.type === 'done') return `finish ${c.task}${c.note ? ` (${c.note})` : ''}`;
|
|
142
|
+
if (c.type === 'delete') return `delete ${c.task}${c.note ? ` (${c.note})` : ''}`;
|
|
141
143
|
return `release ${c.task}`;
|
|
142
144
|
}
|
|
143
145
|
|
package/scripts/tasks.mjs
CHANGED
|
@@ -49,6 +49,8 @@ import { checkInstall, confirmInstall, tokenTarget, unverifiedInstall } from './
|
|
|
49
49
|
import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
|
|
50
50
|
import {
|
|
51
51
|
CLI_PACKAGE,
|
|
52
|
+
PLUGIN_BRANCH,
|
|
53
|
+
PLUGIN_REPO,
|
|
52
54
|
PROMPT_SECTIONS,
|
|
53
55
|
initCommitMessage,
|
|
54
56
|
initPlan,
|
|
@@ -81,10 +83,20 @@ import {
|
|
|
81
83
|
unknownSubcommand,
|
|
82
84
|
} from './tasks/cli.js';
|
|
83
85
|
import { keepLines } from './tasks/keep.js';
|
|
86
|
+
import { checkMcp, headersRepo, mcpAgent, mcpConfig, mcpHeaders, mcpLines } from './tasks/mcp.js';
|
|
84
87
|
import { mergeViews, pelotonLines, pelotonPost, pickPeloton } from './tasks/peloton.js';
|
|
85
88
|
import { CLI_VERSION } from '../src/cli-version.js';
|
|
86
89
|
import { parseInstall, secretName } from '../src/install.js';
|
|
87
|
-
import {
|
|
90
|
+
import {
|
|
91
|
+
NAMES,
|
|
92
|
+
boardUrl,
|
|
93
|
+
configDir,
|
|
94
|
+
parseEnvFile,
|
|
95
|
+
readSetting,
|
|
96
|
+
settingFrom,
|
|
97
|
+
taskrcFixes,
|
|
98
|
+
tildePath,
|
|
99
|
+
} from './tasks/settings.js';
|
|
88
100
|
|
|
89
101
|
const CONFIG_DIR = configDir({ env: process.env, home: homedir() });
|
|
90
102
|
const ENV_FILE = join(CONFIG_DIR, 'tasks.env');
|
|
@@ -235,6 +247,11 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
235
247
|
github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
|
|
236
248
|
hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
|
|
237
249
|
health the server's state
|
|
250
|
+
mcp print the claude mcp add line and the .mcp.json entry that connect an MCP client to the board's
|
|
251
|
+
/mcp from this checkout, with the token as $BREAKAWAY_TOKEN, never its value. Writes nothing
|
|
252
|
+
--check …or check the server answers: initialize and tools/list, or the board's error
|
|
253
|
+
--headers …or print the headers Claude Code sends to /mcp as JSON, the token included: the plugin's
|
|
254
|
+
headersHelper. Only Authorization outside a repository the board tracks
|
|
238
255
|
export every task (all repositories, statuses, and horizons) as JSON, checked against health's count [--out <file>]
|
|
239
256
|
connections is everything the board leans on wired up: GitHub, Cloudflare, Claude, sync, push; the fix for each that isn't
|
|
240
257
|
|
|
@@ -292,8 +309,12 @@ Working
|
|
|
292
309
|
In a terminal it asks for each section of the agent prompt (Enter takes the default), or
|
|
293
310
|
takes them from --building --checks --pull-requests --direction --dependency-updates
|
|
294
311
|
--never-share <text>; --defaults takes the default for the rest without asking
|
|
295
|
-
|
|
296
|
-
|
|
312
|
+
The tasks skill and the session hooks come from breakaway's Claude Code plugin, which
|
|
313
|
+
.claude/settings.json turns on; until the plugin is out, they're copied, and it says so
|
|
314
|
+
--update refresh the copied files (core, Taskwarrior files, release helpers) in a pull request when
|
|
315
|
+
they're older than this checkout's, and move a repository with copies to the plugin; the
|
|
316
|
+
repository's own (its prompt, AGENTS.md) are never touched
|
|
317
|
+
--copies copy the tasks skill and the session hooks instead of using the plugin (with or without --update)
|
|
297
318
|
--pipeline also add the deploy flow: .github/breakaway-pipeline.json for the Workers --staging <name> and
|
|
298
319
|
--production <name> (asked in a terminal; default <slug>-staging and <slug>), what it renders,
|
|
299
320
|
and a minimal CI when the repository has no workflow. Only new files: never one it already has
|
|
@@ -361,7 +382,9 @@ Projects: ideas and routines are the board's; repos lists each repository's area
|
|
|
361
382
|
|
|
362
383
|
Settings: BREAKAWAY_TOKEN, BREAKAWAY_URL, BREAKAWAY_AGENT, BREAKAWAY_REPO, from the environment or tasks.env in
|
|
363
384
|
$BREAKAWAY_HOME (default ~/.config/breakaway).
|
|
364
|
-
Without BREAKAWAY_URL the board is this checkout's .taskrc sync.server.url. See docs/tasks.md#another-install
|
|
385
|
+
Without BREAKAWAY_URL the board is this checkout's .taskrc sync.server.url. See docs/tasks.md#another-install.
|
|
386
|
+
Last come the breakaway plugin's settings for Claude Code (board_url, token, agent_name). health says which
|
|
387
|
+
source each setting came from.`;
|
|
365
388
|
|
|
366
389
|
// ---- settings ----------------------------------------------------------------------------
|
|
367
390
|
|
|
@@ -370,8 +393,11 @@ function readEnvFile() {
|
|
|
370
393
|
}
|
|
371
394
|
|
|
372
395
|
const fileEnv = readEnvFile();
|
|
373
|
-
/**
|
|
374
|
-
|
|
396
|
+
/**
|
|
397
|
+
* A setting by its key in NAMES (scripts/tasks/settings.js): `TOKEN` reads BREAKAWAY_TOKEN, then the plugin's `token`
|
|
398
|
+
* when the CLI runs in a Claude Code session with the breakaway plugin (CLI-8).
|
|
399
|
+
*/
|
|
400
|
+
const setting = (key, fallback) => settingFrom(key, { env: process.env, file: fileEnv }).value ?? fallback;
|
|
375
401
|
const BOARD = boardUrl({
|
|
376
402
|
env: process.env,
|
|
377
403
|
file: fileEnv,
|
|
@@ -417,9 +443,11 @@ const FLAGS = new Set([
|
|
|
417
443
|
'update',
|
|
418
444
|
'defaults',
|
|
419
445
|
'package',
|
|
446
|
+
'check',
|
|
447
|
+
'headers',
|
|
420
448
|
]);
|
|
421
449
|
/** Flags only in repos init (BRK-91): --pipeline takes a file in repos modify, and is a flag there. */
|
|
422
|
-
const INIT_FLAGS = new Set(['pipeline']);
|
|
450
|
+
const INIT_FLAGS = new Set(['pipeline', 'copies']);
|
|
423
451
|
|
|
424
452
|
function parse(argv) {
|
|
425
453
|
const positional = [];
|
|
@@ -474,7 +502,7 @@ async function call(method, path, body, { soft = false } = {}) {
|
|
|
474
502
|
warnIfStale(res.headers.get('X-Tasks-Cli'), res.headers.get('X-Tasks-Release'));
|
|
475
503
|
if (res.status === 401 && !token)
|
|
476
504
|
fail(
|
|
477
|
-
`no token. Set BREAKAWAY_TOKEN, put it in ${ENV_FILE}, or add it as an API credential in the cloud environment (see docs/tasks.md#cloud-agents).`,
|
|
505
|
+
`no token. Set BREAKAWAY_TOKEN, put it in ${ENV_FILE}, set it in the breakaway plugin's settings, or add it as an API credential in the cloud environment (see docs/tasks.md#cloud-agents).`,
|
|
478
506
|
);
|
|
479
507
|
const data = await res.json().catch(() => ({
|
|
480
508
|
// The board always answers in JSON, so anything else came from something in between.
|
|
@@ -797,6 +825,29 @@ function changesFrom(o) {
|
|
|
797
825
|
return c;
|
|
798
826
|
}
|
|
799
827
|
|
|
828
|
+
/** Where the board's address, token, and agent name came from (CLI-8): the sources only, never a value. */
|
|
829
|
+
function settingSources() {
|
|
830
|
+
const sources = { env: process.env, file: fileEnv };
|
|
831
|
+
return { url: BOARD.from, token: settingFrom('TOKEN', sources).from, agent: settingFrom('AGENT', sources).from };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const SOURCE = {
|
|
835
|
+
environment: 'the environment',
|
|
836
|
+
'tasks.env': ENV_FILE,
|
|
837
|
+
'.taskrc': "this checkout's .taskrc",
|
|
838
|
+
config: "the install's breakaway.config.json",
|
|
839
|
+
plugin: "the breakaway plugin's settings",
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
/** settingSources() in a line: `address from …, token from …, agent name from …`. */
|
|
843
|
+
function describeSources({ url, token, agent: name }) {
|
|
844
|
+
return [
|
|
845
|
+
`address from ${SOURCE[url] ?? 'nowhere'}`,
|
|
846
|
+
`token ${token ? `from ${SOURCE[token]}` : "not set (a cloud session's proxy may add it)"}`,
|
|
847
|
+
`agent name ${name ? `from ${SOURCE[name]}` : 'not set'}`,
|
|
848
|
+
].join(', ');
|
|
849
|
+
}
|
|
850
|
+
|
|
800
851
|
const commands = {
|
|
801
852
|
/** The Claude Code session hooks (BRK-7), so a repository's .claude/settings.json runs them through npx. */
|
|
802
853
|
async hook() {
|
|
@@ -1573,12 +1624,69 @@ const commands = {
|
|
|
1573
1624
|
return out.join('\n');
|
|
1574
1625
|
});
|
|
1575
1626
|
},
|
|
1627
|
+
/** How to connect an MCP client to the board's /mcp from this checkout, and --check whether it answers (CLI-6). */
|
|
1628
|
+
async mcp() {
|
|
1629
|
+
// --headers is the plugin's headersHelper: it asks the board itself, and never fails (CLI-9).
|
|
1630
|
+
const { slug } = opts.headers ? { slug: null } : await checkoutRepo();
|
|
1631
|
+
let branch = null;
|
|
1632
|
+
try {
|
|
1633
|
+
branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1634
|
+
encoding: 'utf8',
|
|
1635
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1636
|
+
}).trim();
|
|
1637
|
+
} catch {
|
|
1638
|
+
/* not a git checkout */
|
|
1639
|
+
}
|
|
1640
|
+
const named = opts.as ?? setting('AGENT');
|
|
1641
|
+
const name = mcpAgent({ named, branch, fallback: agent() });
|
|
1642
|
+
if (opts.headers) {
|
|
1643
|
+
const token = setting('TOKEN');
|
|
1644
|
+
let remote = null;
|
|
1645
|
+
try {
|
|
1646
|
+
remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
1647
|
+
encoding: 'utf8',
|
|
1648
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1649
|
+
});
|
|
1650
|
+
} catch {
|
|
1651
|
+
/* not a git checkout, or no origin */
|
|
1652
|
+
}
|
|
1653
|
+
// Claude Code says which server it's connecting: the plugin's board_url, when the CLI's settings don't name one.
|
|
1654
|
+
const server = process.env.CLAUDE_CODE_MCP_SERVER_URL?.replace(/\/mcp\/?$/u, '');
|
|
1655
|
+
const repo = await headersRepo({
|
|
1656
|
+
base: BASE ?? (server?.startsWith('http') ? server : null),
|
|
1657
|
+
token,
|
|
1658
|
+
named: opts.repo ?? setting('REPO'),
|
|
1659
|
+
remote,
|
|
1660
|
+
fetch,
|
|
1661
|
+
});
|
|
1662
|
+
// Standard output is Claude Code's, and only the headers go there: never --json's wrapping, never a line of text.
|
|
1663
|
+
console.log(JSON.stringify(mcpHeaders({ token, named, agent: name, repo })));
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const tokenVar = envName('TOKEN');
|
|
1667
|
+
const config = mcpConfig({ url: BASE, agent: name, repo: slug, tokenVar });
|
|
1668
|
+
if (!opts.check) {
|
|
1669
|
+
print(config, () => mcpLines(config, { repo: slug, tokenVar }).join('\n'));
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const checked = await checkMcp({
|
|
1673
|
+
endpoint: config.endpoint,
|
|
1674
|
+
token: setting('TOKEN'),
|
|
1675
|
+
agent: name,
|
|
1676
|
+
repo: slug,
|
|
1677
|
+
fetch,
|
|
1678
|
+
});
|
|
1679
|
+
print(checked, (c) => c.lines.join('\n'));
|
|
1680
|
+
if (!checked.ok) process.exitCode = 1;
|
|
1681
|
+
},
|
|
1576
1682
|
async health() {
|
|
1577
|
-
|
|
1683
|
+
const settings = settingSources();
|
|
1684
|
+
print({ ...(await call('GET', 'health')), settings }, (h) =>
|
|
1578
1685
|
[
|
|
1579
1686
|
h.ok ? 'The task server is healthy.' : `The task server can't read its history: ${h.replicaError}`,
|
|
1580
1687
|
` ${h.tasks.pending} open of ${h.tasks.total} tasks, ${h.versions} versions`,
|
|
1581
1688
|
` snapshot: ${h.snapshot ? `${h.snapshot.created.slice(0, 16)}, ${h.snapshot.versionsSince} versions since` : 'none'}`,
|
|
1689
|
+
` settings: ${describeSources(settings)}`,
|
|
1582
1690
|
].join('\n'),
|
|
1583
1691
|
);
|
|
1584
1692
|
},
|
|
@@ -2019,7 +2127,10 @@ async function initRepo(slug) {
|
|
|
2019
2127
|
/* not a git checkout */
|
|
2020
2128
|
}
|
|
2021
2129
|
const answers = update || readTarget(promptPathOf(repo)) !== null ? {} : await promptAnswers();
|
|
2130
|
+
const plugin = !opts.copies;
|
|
2022
2131
|
const plan = initPlan({
|
|
2132
|
+
plugin,
|
|
2133
|
+
pluginReleased: plugin ? pluginReleased() : false,
|
|
2023
2134
|
repo,
|
|
2024
2135
|
board: board ?? 'TheAnarchoX/breakaway',
|
|
2025
2136
|
url: BASE,
|
|
@@ -2071,7 +2182,7 @@ async function initRepo(slug) {
|
|
|
2071
2182
|
: []),
|
|
2072
2183
|
...(plan.removals.length
|
|
2073
2184
|
? [
|
|
2074
|
-
`${opts['dry-run'] ? 'Would remove' : 'Removing'} ${plan.removals.length} files
|
|
2185
|
+
`${opts['dry-run'] ? 'Would remove' : 'Removing'} ${plan.removals.length} files the board no longer copies here (npx breakaway${plan.plugin ? " and breakaway's plugin replace" : ' replaces'} them):`,
|
|
2075
2186
|
...plan.removals.map((p) => ` ${p}`),
|
|
2076
2187
|
]
|
|
2077
2188
|
: []),
|
|
@@ -2134,7 +2245,8 @@ async function initRepo(slug) {
|
|
|
2134
2245
|
);
|
|
2135
2246
|
}
|
|
2136
2247
|
const first = initCommitMessage(repo.slug, {
|
|
2137
|
-
by: `npx breakaway repos init ${repo.slug}${opts.pipeline ? ' --pipeline' : ''}${opts.package ? ' --package' : ''}`,
|
|
2248
|
+
by: `npx breakaway repos init ${repo.slug}${opts.copies ? ' --copies' : ''}${opts.pipeline ? ' --pipeline' : ''}${opts.package ? ' --package' : ''}`,
|
|
2249
|
+
plugin: plan.plugin,
|
|
2138
2250
|
});
|
|
2139
2251
|
const title = update ? "Update the task board's agent files" : first.title;
|
|
2140
2252
|
git(
|
|
@@ -2143,7 +2255,7 @@ async function initRepo(slug) {
|
|
|
2143
2255
|
title,
|
|
2144
2256
|
'-m',
|
|
2145
2257
|
update
|
|
2146
|
-
? `The board's core, skill, release helpers, and Taskwarrior files as they are in ${board ?? 'breakaway'} now, and the session hooks run through npx, so an old copy of the CLI is removed: run it as npx ${CLI_PACKAGE}. This repository's own files are unchanged. Updated by npx ${CLI_PACKAGE} repos init ${repo.slug} --update.`
|
|
2258
|
+
? `The board's core, ${plan.plugin ? '' : 'skill, '}release helpers, and Taskwarrior files as they are in ${board ?? 'breakaway'} now, ${plan.plugin ? "and breakaway's Claude Code plugin in .claude/settings.json instead of the copied tasks skill and session hooks" : 'and the session hooks run through npx'}, so an old copy of the CLI is removed: run it as npx ${CLI_PACKAGE}. This repository's own files are unchanged. Updated by npx ${CLI_PACKAGE} repos init ${repo.slug} --update${opts.copies ? ' --copies' : ''}.`
|
|
2147
2259
|
: `${first.body}${starter.files.length ? ` It also adds the deploy and release flows, rendered from ${starter.files[0].path}.` : ''}`,
|
|
2148
2260
|
);
|
|
2149
2261
|
const pushed = spawnSync('git', ['-C', dir, 'push', '-u', 'origin', empty ? `HEAD:refs/heads/${branch}` : work], {
|
|
@@ -2169,7 +2281,7 @@ async function initRepo(slug) {
|
|
|
2169
2281
|
title,
|
|
2170
2282
|
'--body',
|
|
2171
2283
|
update
|
|
2172
|
-
? `The task board's copied files (
|
|
2284
|
+
? `The task board's copied files (the core and stub, ${plan.plugin ? '' : 'the tasks skill, '}the release helpers, and the Taskwarrior files) as they are on the board's repository now, updated by \`npx breakaway repos init ${repo.slug} --update${opts.copies ? ' --copies' : ''}\`. ${plan.plugin ? "The tasks skill and the session hooks come from breakaway's Claude Code plugin, which .claude/settings.json turns on, so their copies are removed. " : ''}This repository's own files (its agent prompt, AGENTS.md, .taskrc, .envrc, package.json${plan.plugin ? '' : ', .claude/settings.json'}) are otherwise unchanged.${plan.notes.length ? `\n\nNotes:\n${plan.notes.map((n) => `- ${n}`).join('\n')}` : ''}`
|
|
2173
2285
|
: `The files the task board's agents need to claim and work a task in this repository, added by \`npx breakaway repos init ${repo.slug}\`. Nothing that was there is changed. If this repository's linter reads plain JavaScript, exclude the copied scripts (tools/tasks/ and the release helpers) from it.${plan.todo.length ? `\n\nStill to do:\n${plan.todo.map((t) => `- ${t}`).join('\n')}` : ''}`,
|
|
2174
2286
|
],
|
|
2175
2287
|
{ encoding: 'utf8' },
|
|
@@ -2189,6 +2301,24 @@ async function initRepo(slug) {
|
|
|
2189
2301
|
console.log(['', ...next].join('\n'));
|
|
2190
2302
|
}
|
|
2191
2303
|
|
|
2304
|
+
/**
|
|
2305
|
+
* Whether breakaway's plugin is out (BRK-159): its marketplace gives it out from the plugin branch, which a stable
|
|
2306
|
+
* release moves, so until that branch is there, repos init copies the skill and hooks instead. Null when GitHub can't
|
|
2307
|
+
* be reached, which repos init says, and copies too.
|
|
2308
|
+
*/
|
|
2309
|
+
function pluginReleased() {
|
|
2310
|
+
try {
|
|
2311
|
+
const heads = execFileSync(
|
|
2312
|
+
'git',
|
|
2313
|
+
['ls-remote', '--heads', `https://github.com/${PLUGIN_REPO}.git`, `refs/heads/${PLUGIN_BRANCH}`],
|
|
2314
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 20_000 },
|
|
2315
|
+
);
|
|
2316
|
+
return heads.trim() !== '';
|
|
2317
|
+
} catch {
|
|
2318
|
+
return null;
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2192
2322
|
/**
|
|
2193
2323
|
* The staging and production Workers for repos init --pipeline (BRK-91): --staging and --production, else asked in a
|
|
2194
2324
|
* terminal, else `<slug>-staging` and `<slug>`.
|
|
@@ -2411,9 +2541,11 @@ if (opts.help || command === 'help') {
|
|
|
2411
2541
|
process.exitCode = (await import('./tasks/pipeline.js')).run(args, opts);
|
|
2412
2542
|
} else if (!commands[command]) {
|
|
2413
2543
|
fail(`no command "${command}". npx breakaway help lists them.`);
|
|
2414
|
-
} else if (!BASE && command !== 'init-secrets') {
|
|
2544
|
+
} else if (!BASE && command !== 'init-secrets' && command !== 'hook' && !(command === 'mcp' && opts.headers)) {
|
|
2545
|
+
// The hooks stay quiet without a board (a plugin installed but not set up, CLI-8): session-hook.mjs checks for itself.
|
|
2546
|
+
// The plugin's headersHelper never fails, and Claude Code tells it the board's address when only the plugin knows it.
|
|
2415
2547
|
fail(
|
|
2416
|
-
`no board address. Set BREAKAWAY_URL (in the environment or ${ENV_FILE}),
|
|
2548
|
+
`no board address. Set BREAKAWAY_URL (in the environment or ${ENV_FILE}), sync.server.url in this checkout's .taskrc, or the board's address in the breakaway plugin's settings (or run npx breakaway setup): see docs/tasks.md#another-install.`,
|
|
2417
2549
|
);
|
|
2418
2550
|
} else if (unknownSubcommand(command, args[0])) {
|
|
2419
2551
|
fail(unknownSubcommand(command, args[0]));
|
package/src/init.js
CHANGED
|
@@ -62,6 +62,101 @@ const TARGET_DIR = 'tools/tasks/';
|
|
|
62
62
|
*/
|
|
63
63
|
export const MANIFEST = `${TARGET_DIR}copied.json`;
|
|
64
64
|
const GITIGNORE = ['.task/', '.task-session', '.env'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* breakaway's Claude Code plugin (docs/specs/IDEA-25-claude-plugin.md, section 3): repos init sets a repository up with
|
|
68
|
+
* it by default (BRK-158), so .claude/settings.json names breakaway's marketplace and turns the plugin on instead of
|
|
69
|
+
* running the session hooks, and the tasks skill comes with the plugin instead of a copy. The marketplace lives on
|
|
70
|
+
* breakaway's own repository, and gives out the plugin from its `plugin` branch, which moves at a stable release.
|
|
71
|
+
*/
|
|
72
|
+
export const PLUGIN_REPO = 'TheAnarchoX/breakaway';
|
|
73
|
+
export const PLUGIN_MARKETPLACE = 'breakaway';
|
|
74
|
+
export const PLUGIN = `breakaway@${PLUGIN_MARKETPLACE}`;
|
|
75
|
+
export const PLUGIN_BRANCH = 'plugin';
|
|
76
|
+
|
|
77
|
+
/** The two keys .claude/settings.json gets for the plugin: breakaway's marketplace, and the plugin turned on. */
|
|
78
|
+
export function pluginSettings() {
|
|
79
|
+
return {
|
|
80
|
+
extraKnownMarketplaces: { [PLUGIN_MARKETPLACE]: { source: { source: 'github', repo: PLUGIN_REPO } } },
|
|
81
|
+
enabledPlugins: { [PLUGIN]: true },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether a hook command is one of the session hooks repos init wrote: through npx, any version, or an old copy's. */
|
|
86
|
+
const BOARD_HOOK =
|
|
87
|
+
/\bbreakaway(?:@[^\s"]+)? hook (?:session|wait)\b|scripts\/tasks\/(?:session-hook|message-wait)\.mjs/u;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* settings.json moved to the plugin (repos init --update): the session hooks repos init wrote are gone, with any event
|
|
91
|
+
* left empty, and the plugin's two keys are there. A plugin the settings turn off stays off, and the rest is untouched.
|
|
92
|
+
* Null when it isn't JSON, so the caller says what to do instead.
|
|
93
|
+
*/
|
|
94
|
+
export function withPluginSettings(text) {
|
|
95
|
+
let settings;
|
|
96
|
+
try {
|
|
97
|
+
settings = JSON.parse(text);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null;
|
|
102
|
+
const hooks = settings.hooks;
|
|
103
|
+
if (hooks && typeof hooks === 'object') {
|
|
104
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
105
|
+
if (!Array.isArray(groups)) continue;
|
|
106
|
+
const kept = groups
|
|
107
|
+
.map((group) =>
|
|
108
|
+
Array.isArray(group?.hooks)
|
|
109
|
+
? { ...group, hooks: group.hooks.filter((h) => !BOARD_HOOK.test(String(h?.command ?? ''))) }
|
|
110
|
+
: group,
|
|
111
|
+
)
|
|
112
|
+
.filter((group, i) => !(Array.isArray(group?.hooks) && !group.hooks.length && groups[i].hooks.length));
|
|
113
|
+
if (kept.length) hooks[event] = kept;
|
|
114
|
+
else delete hooks[event];
|
|
115
|
+
}
|
|
116
|
+
if (!Object.keys(hooks).length) delete settings.hooks;
|
|
117
|
+
}
|
|
118
|
+
const want = pluginSettings();
|
|
119
|
+
settings.extraKnownMarketplaces = {
|
|
120
|
+
...want.extraKnownMarketplaces,
|
|
121
|
+
...settings.extraKnownMarketplaces,
|
|
122
|
+
};
|
|
123
|
+
if (typeof settings.enabledPlugins?.[PLUGIN] !== 'boolean')
|
|
124
|
+
settings.enabledPlugins = { ...settings.enabledPlugins, ...want.enabledPlugins };
|
|
125
|
+
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether two JSON texts hold the same value, whatever their layout. */
|
|
129
|
+
const sameJson = (a, b) => {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Whether settings.json already has the plugin: breakaway's marketplace, and the plugin named in enabledPlugins. */
|
|
138
|
+
function hasPlugin(text) {
|
|
139
|
+
try {
|
|
140
|
+
const settings = JSON.parse(text);
|
|
141
|
+
return (
|
|
142
|
+
Boolean(settings?.extraKnownMarketplaces?.[PLUGIN_MARKETPLACE]) && PLUGIN in (settings?.enabledPlugins ?? {})
|
|
143
|
+
);
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Why repos init copies the tasks skill and the session hooks when it was asked for the plugin: the plugin isn't out
|
|
151
|
+
* yet (its branch isn't there), or GitHub couldn't say (`released` null).
|
|
152
|
+
*/
|
|
153
|
+
export function pluginPendingNote(slug, released = false) {
|
|
154
|
+
const why =
|
|
155
|
+
released === null
|
|
156
|
+
? `GitHub couldn't say whether breakaway's plugin is out yet (the ${PLUGIN_BRANCH} branch on ${PLUGIN_REPO})`
|
|
157
|
+
: `breakaway's plugin isn't out yet (${PLUGIN_REPO} has no ${PLUGIN_BRANCH} branch until a stable release moves it)`;
|
|
158
|
+
return `${why}, so this copies the tasks skill and the session hooks instead. Once it's out, npx breakaway repos init ${slug} --update moves the repository to the plugin.`;
|
|
159
|
+
}
|
|
65
160
|
/** Pinned to LF so the shell scripts run on a checkout with core.autocrlf=true (BRK-41). */
|
|
66
161
|
const GITATTRIBUTES = ['scripts/task text eol=lf', '.envrc text eol=lf'];
|
|
67
162
|
|
|
@@ -75,10 +170,13 @@ export function boardSources(read) {
|
|
|
75
170
|
}
|
|
76
171
|
|
|
77
172
|
/** The first commit's message for a repository set up from scratch: its title and body, the CLI's and the board's alike. */
|
|
78
|
-
export function initCommitMessage(slug, { by = `npx breakaway repos init ${slug}
|
|
173
|
+
export function initCommitMessage(slug, { by = `npx breakaway repos init ${slug}`, plugin = true } = {}) {
|
|
174
|
+
const agentParts = plugin
|
|
175
|
+
? "breakaway's Claude Code plugin, turned on in .claude/settings.json (it brings the tasks skill and the session hooks)"
|
|
176
|
+
: 'the session hooks (they run the CLI through npx), the tasks skill';
|
|
79
177
|
return {
|
|
80
178
|
title: "Set up the task board's agent files",
|
|
81
|
-
body: `What a board-started agent needs to claim and work a task here: the agent prompt, the board's core,
|
|
179
|
+
body: `What a board-started agent needs to claim and work a task here: the agent prompt, the board's core, ${agentParts}, AGENTS.md, and Taskwarrior with direnv. Added by ${by}.`,
|
|
82
180
|
};
|
|
83
181
|
}
|
|
84
182
|
|
|
@@ -310,14 +408,20 @@ export function skillFor(text, board, repo = null) {
|
|
|
310
408
|
}
|
|
311
409
|
|
|
312
410
|
/** A starter AGENTS.md: how this repository works with the board. The owner adds how to build here. */
|
|
313
|
-
export function agentsMd(repo, board, dir = DEFAULT_DIR) {
|
|
411
|
+
export function agentsMd(repo, board, dir = DEFAULT_DIR, { plugin = true } = {}) {
|
|
412
|
+
const skill = plugin
|
|
413
|
+
? `Use the \`tasks\` skill (from breakaway's Claude Code plugin, which \`.claude/settings.json\` turns on) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is the board's, written for any repository: this repository's areas are above, and its rules are here.`
|
|
414
|
+
: `Use the \`tasks\` skill (\`${SKILL}\`) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is breakaway's, written for this repository's areas and prompt: where it names breakaway's own files or rules, the board's part applies and the rest doesn't.`;
|
|
415
|
+
const copied = plugin
|
|
416
|
+
? `\`tools/tasks/\`, the release helpers in \`scripts/\`, and \`${PIPELINE_SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`${MANIFEST}\` lists every file it copied, and \`repos init --update\` replaces only those: a file it doesn't list is this repository's own, even at a path breakaway copies to. \`.claude/settings.json\` turns on breakaway's plugin (\`${PLUGIN}\`), which brings the \`tasks\` skill, its commands, and the session hooks that show a cloud agent's output on its task, so this repository carries no copy of them.`
|
|
417
|
+
: `\`tools/tasks/\`, the release helpers in \`scripts/\`, \`${SKILL}\`, and \`${PIPELINE_SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`${MANIFEST}\` lists every file it copied, and \`repos init --update\` replaces only those: a file it doesn't list is this repository's own, even at a path breakaway copies to. \`.claude/settings.json\` holds the session hooks that show a cloud agent's output on its task, and they run through \`npx\`, so this repository carries no copy of the CLI.`;
|
|
314
418
|
return `# Agent instructions
|
|
315
419
|
|
|
316
420
|
<!-- Started by \`npx breakaway repos init\` (breakaway's task board). Add how to build here: setup, tests, style, and anything agents must never do. -->
|
|
317
421
|
|
|
318
|
-
- **Work lives on the task board.** This repository's tasks are in the areas ${areaList(repo)}.
|
|
422
|
+
- **Work lives on the task board.** This repository's tasks are in the areas ${areaList(repo)}. ${skill}
|
|
319
423
|
- **Agents started by the board** follow [\`${promptPathOf(repo)}\`](${promptPathOf(repo)}), which starts with the board's core, \`tools/tasks/prompts/core.md\`.
|
|
320
|
-
- **Copied files.**
|
|
424
|
+
- **Copied files.** ${copied}
|
|
321
425
|
- **Taskwarrior** (optional): \`scripts/task\`, or plain \`task\` with direnv after \`direnv allow\`, uses the board with this checkout's own \`.task/\` database, in the \`${repo.slug}\` context. \`npx breakaway setup\` connects the machine once.
|
|
322
426
|
- **Changes reach \`${repo.defaultBranch || 'main'}\` through pull requests**, which the owner merges. Never merge, force-push, or rewrite \`${repo.defaultBranch || 'main'}\`.
|
|
323
427
|
- **Never put a secret or token** in a file, task, comment, or pull request. The board's token lives in \`${dir}/tasks.env\` (or \`$BREAKAWAY_HOME/tasks.env\`) or the cloud environment's credentials, never in this repository.
|
|
@@ -368,6 +472,8 @@ export function initPlan({
|
|
|
368
472
|
url,
|
|
369
473
|
configDir = DEFAULT_DIR,
|
|
370
474
|
update = false,
|
|
475
|
+
plugin = true,
|
|
476
|
+
pluginReleased = true,
|
|
371
477
|
sections = {},
|
|
372
478
|
defaulted = [],
|
|
373
479
|
}) {
|
|
@@ -376,6 +482,9 @@ export function initPlan({
|
|
|
376
482
|
const current = [];
|
|
377
483
|
const notes = [];
|
|
378
484
|
const todo = [];
|
|
485
|
+
// The plugin unless the repository asked for copies (--copies), and copies while the plugin isn't out yet.
|
|
486
|
+
const usePlugin = plugin && pluginReleased === true;
|
|
487
|
+
if (plugin && !usePlugin) notes.push(pluginPendingNote(repo.slug, pluginReleased));
|
|
379
488
|
const add = (path, content, extra = {}) => {
|
|
380
489
|
if (readTarget(path) !== null) skipped.push(path);
|
|
381
490
|
else files.push({ path, content, ...extra });
|
|
@@ -455,8 +564,22 @@ export function initPlan({
|
|
|
455
564
|
`${leftover.join(', ')} came with the old copy of the CLI. Nothing here needs them now: delete the ones this repository doesn't use itself.`,
|
|
456
565
|
);
|
|
457
566
|
|
|
458
|
-
add(
|
|
459
|
-
|
|
567
|
+
add(
|
|
568
|
+
'.claude/settings.json',
|
|
569
|
+
`${JSON.stringify(usePlugin ? pluginSettings() : { hooks: sessionHooks() }, null, 2)}\n`,
|
|
570
|
+
);
|
|
571
|
+
if (usePlugin && skipped.includes('.claude/settings.json')) {
|
|
572
|
+
// Moving to the plugin (BRK-159): --update takes out the hooks repos init wrote and adds the plugin's two keys.
|
|
573
|
+
const there = readTarget('.claude/settings.json');
|
|
574
|
+
const moved = withPluginSettings(there);
|
|
575
|
+
if (update && moved !== null && !sameJson(moved, there)) {
|
|
576
|
+
skipped.splice(skipped.indexOf('.claude/settings.json'), 1);
|
|
577
|
+
files.push({ path: '.claude/settings.json', content: moved, changed: true });
|
|
578
|
+
} else if (!hasPlugin(there))
|
|
579
|
+
notes.push(
|
|
580
|
+
`.claude/settings.json is already there${moved === null ? " and isn't JSON" : ''}: add breakaway's plugin to it (${JSON.stringify(pluginSettings())}), or a started agent won't have the tasks skill and its output won't show on its task.${update ? '' : ` npx breakaway repos init ${repo.slug} --update adds it.`}`,
|
|
581
|
+
);
|
|
582
|
+
} else if (skipped.includes('.claude/settings.json')) {
|
|
460
583
|
const there = readTarget('.claude/settings.json');
|
|
461
584
|
const rewired = update ? rewireHooks(there) : there;
|
|
462
585
|
if (rewired !== there) {
|
|
@@ -471,9 +594,19 @@ export function initPlan({
|
|
|
471
594
|
);
|
|
472
595
|
}
|
|
473
596
|
if (readTarget('.claude/skills') === null) files.push({ path: '.claude/skills', link: '../.agents/skills' });
|
|
474
|
-
copy(SKILL, skillFor(read(SKILL), board, repo));
|
|
597
|
+
if (!usePlugin) copy(SKILL, skillFor(read(SKILL), board, repo));
|
|
598
|
+
else if (update && readTarget(SKILL) !== null) {
|
|
599
|
+
// The plugin brings the tasks skill: the copy repos init wrote goes, and a skill of the repository's own stays.
|
|
600
|
+
if (owns(SKILL)) {
|
|
601
|
+
removals.push(SKILL);
|
|
602
|
+
if (String(readTarget('AGENTS.md') ?? '').includes(SKILL))
|
|
603
|
+
notes.push(
|
|
604
|
+
`AGENTS.md still names ${SKILL} and the session hooks in .claude/settings.json: the tasks skill and the hooks come from breakaway's plugin now (${PLUGIN}, turned on in .claude/settings.json), so say that there instead.`,
|
|
605
|
+
);
|
|
606
|
+
} else theirs.push(SKILL);
|
|
607
|
+
}
|
|
475
608
|
copy(PIPELINE_SKILL, skillFor(read(PIPELINE_SKILL), board, repo));
|
|
476
|
-
add('AGENTS.md', agentsMd(repo, board, configDir));
|
|
609
|
+
add('AGENTS.md', agentsMd(repo, board, configDir, { plugin: usePlugin }));
|
|
477
610
|
if (!skipped.includes('AGENTS.md')) todo.push('AGENTS.md: add how to build in this repository');
|
|
478
611
|
|
|
479
612
|
const pkg = readTarget('package.json');
|
|
@@ -564,5 +697,5 @@ export function initPlan({
|
|
|
564
697
|
else if (recordThere === record) current.push(MANIFEST);
|
|
565
698
|
else if (update) files.push({ path: MANIFEST, content: record, changed: true });
|
|
566
699
|
else skipped.push(MANIFEST);
|
|
567
|
-
return { files, removals, skipped, current, notes, todo };
|
|
700
|
+
return { files, removals, skipped, current, notes, todo, plugin: usePlugin };
|
|
568
701
|
}
|
package/src/install.js
CHANGED
|
@@ -158,7 +158,17 @@ export const secretName = (inst, key) => `${inst.secretsPrefix}${key}`;
|
|
|
158
158
|
export const docsLink = (inst, anchor) => (inst.docs ? `${inst.docs}${anchor ? `#${anchor}` : ''}` : null);
|
|
159
159
|
|
|
160
160
|
/** The routes `run_worker_first` sends to the Worker; everything else is the web app. */
|
|
161
|
-
export const WORKER_FIRST = [
|
|
161
|
+
export const WORKER_FIRST = [
|
|
162
|
+
'/api/*',
|
|
163
|
+
'/v1/*',
|
|
164
|
+
'/github/*',
|
|
165
|
+
'/mcp',
|
|
166
|
+
// The sign-in MCP apps use for /mcp (BRK-157).
|
|
167
|
+
'/oauth/*',
|
|
168
|
+
'/.well-known/oauth-*',
|
|
169
|
+
'/login',
|
|
170
|
+
'/logout',
|
|
171
|
+
];
|
|
162
172
|
|
|
163
173
|
/**
|
|
164
174
|
* The Worker's wrangler config for an install. `local` is for `wrangler dev` (interop): no custom
|
package/src/ping.js
CHANGED
|
@@ -30,11 +30,28 @@ export function looksLikeSecret(text) {
|
|
|
30
30
|
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(value)) return true;
|
|
31
31
|
for (const [word] of value.matchAll(/[A-Za-z0-9+/_=-]{32,}/gu)) {
|
|
32
32
|
if (/^[0-9a-f]{40}$/u.test(word)) continue;
|
|
33
|
+
if (looksLikePath(word)) continue;
|
|
33
34
|
if (/\d/u.test(word) && /[A-Za-z]/u.test(word) && !/^[a-z]+(?:-[a-z0-9]+)+$/u.test(word)) return true;
|
|
34
35
|
}
|
|
35
36
|
return false;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Whether a long word is a file path or a link's path (`docs/specs/IDEA-36-peloton-planning`), not a token with
|
|
41
|
+
* slashes in it: it has a slash, no `+` or `=`, and every name in it splits on `.`, `_`, and `-` into short pieces,
|
|
42
|
+
* a commit SHA, or pieces that don't look random (a long run of letters and digits, or of mixed case and digits).
|
|
43
|
+
*/
|
|
44
|
+
function looksLikePath(word) {
|
|
45
|
+
if (!word.includes('/') || /[+=]/u.test(word)) return false;
|
|
46
|
+
for (const piece of word.split(/[/._-]+/u)) {
|
|
47
|
+
if (!piece || /^[0-9a-f]{40}$/u.test(piece)) continue;
|
|
48
|
+
const digits = /\d/u.test(piece);
|
|
49
|
+
if (digits && /[A-Za-z]/u.test(piece) && piece.length >= 16) return false;
|
|
50
|
+
if (digits && /[a-z]/u.test(piece) && /[A-Z]/u.test(piece) && piece.length >= 8) return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
/** A ping's kind and message, cleaned, or an InputError that says what to fix. */
|
|
39
56
|
export function checkPing({ kind, message }) {
|
|
40
57
|
if (!PING_KINDS.includes(kind)) throw new InputError(`kind is one of ${PING_KINDS.join(', ')}`);
|
|
@@ -174,6 +191,7 @@ export function validateProposal(raw, ctx) {
|
|
|
174
191
|
|
|
175
192
|
const changes = [];
|
|
176
193
|
const finished = new Set();
|
|
194
|
+
const deleted = new Set();
|
|
177
195
|
for (const [i, change] of list.entries()) {
|
|
178
196
|
const where = `change ${i + 1} (${change?.type ?? '?'})`;
|
|
179
197
|
if (!isObject(change)) throw new InputError(`${where}: each change is an object`);
|
|
@@ -274,10 +292,30 @@ export function validateProposal(raw, ctx) {
|
|
|
274
292
|
const uuid = existing(change.task, where, { open: true });
|
|
275
293
|
if (ctx.inReview(uuid))
|
|
276
294
|
throw new InputError(`${where}: ${name(uuid)} has an open pull request that finishes it when it merges`);
|
|
277
|
-
if (finished.has(uuid))
|
|
295
|
+
if (finished.has(uuid))
|
|
296
|
+
throw new InputError(
|
|
297
|
+
`${where}: ${name(uuid)} is ${deleted.has(uuid) ? 'deleted and finished' : 'finished twice'} in this proposal`,
|
|
298
|
+
);
|
|
278
299
|
finished.add(uuid);
|
|
279
300
|
const note = text(change.note, 'note', MAX_NOTE, where);
|
|
280
301
|
changes.push({ type: 'done', task: ctx.tasks.get(uuid).wid ?? uuid, ...(note ? { note } : {}) });
|
|
302
|
+
} else if (change.type === 'delete') {
|
|
303
|
+
// For a task the agent may not delete itself (IDEA-36 section 6): the owner deletes it in one press.
|
|
304
|
+
only(change, ['type', 'task', 'note'], where);
|
|
305
|
+
const uuid = existing(change.task, where, { open: true });
|
|
306
|
+
const holder = ctx.tasks.get(uuid).claim;
|
|
307
|
+
if (holder === ctx.by) throw new InputError(`${where}: you hold ${name(uuid)}; release it instead`);
|
|
308
|
+
if (holder) throw new InputError(`${where}: ${holder} has ${name(uuid)}; ask them on the peloton`);
|
|
309
|
+
if (ctx.inReview(uuid))
|
|
310
|
+
throw new InputError(`${where}: ${name(uuid)} has an open pull request that finishes it when it merges`);
|
|
311
|
+
if (finished.has(uuid))
|
|
312
|
+
throw new InputError(
|
|
313
|
+
`${where}: ${name(uuid)} is ${deleted.has(uuid) ? 'deleted twice' : 'finished and deleted'} in this proposal`,
|
|
314
|
+
);
|
|
315
|
+
finished.add(uuid);
|
|
316
|
+
deleted.add(uuid);
|
|
317
|
+
const note = text(change.note, 'note', MAX_NOTE, where);
|
|
318
|
+
changes.push({ type: 'delete', task: ctx.tasks.get(uuid).wid ?? uuid, ...(note ? { note } : {}) });
|
|
281
319
|
} else if (change.type === 'release') {
|
|
282
320
|
only(change, ['type', 'task'], where);
|
|
283
321
|
const uuid = existing(change.task, where, { open: true });
|
|
@@ -285,7 +323,7 @@ export function validateProposal(raw, ctx) {
|
|
|
285
323
|
if (!ctx.tasks.get(uuid).claim) throw new InputError(`${where}: ${name(uuid)} holds no claim`);
|
|
286
324
|
changes.push({ type: 'release', task: ctx.tasks.get(uuid).wid ?? uuid });
|
|
287
325
|
} else {
|
|
288
|
-
throw new InputError(`${where}: type is one of add, depend, modify, done, release`);
|
|
326
|
+
throw new InputError(`${where}: type is one of add, depend, modify, done, delete, release`);
|
|
289
327
|
}
|
|
290
328
|
}
|
|
291
329
|
|
|
@@ -298,7 +336,8 @@ export function validateProposal(raw, ctx) {
|
|
|
298
336
|
const waiting = [...graph]
|
|
299
337
|
.filter(([node, set]) => set.has(uuid) && !finished.has(node))
|
|
300
338
|
.map(([node]) => name(node));
|
|
301
|
-
if (waiting.length)
|
|
339
|
+
if (waiting.length)
|
|
340
|
+
warnings.push(`${deleted.has(uuid) ? 'deleting' : 'finishing'} ${name(uuid)} releases ${waiting.join(', ')}`);
|
|
302
341
|
}
|
|
303
342
|
return { changes, warnings };
|
|
304
343
|
}
|
|
@@ -311,6 +350,7 @@ export function summarizeProposal(changes) {
|
|
|
311
350
|
if (count('depend')) parts.push(`change ${count('depend')} dependenc${count('depend') === 1 ? 'y' : 'ies'}`);
|
|
312
351
|
if (count('modify')) parts.push(`edit ${count('modify')} task${count('modify') === 1 ? '' : 's'}`);
|
|
313
352
|
if (count('done')) parts.push(`finish ${count('done')} task${count('done') === 1 ? '' : 's'}`);
|
|
353
|
+
if (count('delete')) parts.push(`delete ${count('delete')} task${count('delete') === 1 ? '' : 's'}`);
|
|
314
354
|
if (count('release')) parts.push('release a claim');
|
|
315
355
|
return parts.join(', ');
|
|
316
356
|
}
|
package/src/specs.js
CHANGED
|
@@ -89,6 +89,71 @@ export function specMeta(name, text) {
|
|
|
89
89
|
return { wid, title, status };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** The step a status takes when the owner marks a spec (BRK-215): draft to approved, approved to built. */
|
|
93
|
+
export const NEXT_STATUS = Object.freeze({ draft: 'approved', approved: 'built' });
|
|
94
|
+
|
|
95
|
+
/** The status after `status`, or null when it has none (built, or a word the board doesn't know). */
|
|
96
|
+
export const nextStatus = (status) => (Object.hasOwn(NEXT_STATUS, status) ? NEXT_STATUS[status] : null);
|
|
97
|
+
|
|
98
|
+
/** `Status:`, its word, and what its brackets say, as the template writes it: `Status: approved (1 Oct 2026)`. */
|
|
99
|
+
const STATUS = /(\bStatus:\s*\**\s*)([A-Za-z][\w-]*)(?:\s*\(([^)\n]*)\))?/u;
|
|
100
|
+
|
|
101
|
+
/** The index of the line under a spec's title (its first `# ` heading), or -1. */
|
|
102
|
+
function statusLineAt(lines) {
|
|
103
|
+
const at = lines.findIndex((l) => /^#\s+\S/u.test(l));
|
|
104
|
+
if (at < 0) return -1;
|
|
105
|
+
return lines.findIndex((l, i) => i > at && l.trim() !== '');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The status on the line under a spec's title, lowercased, and what its brackets say (null without them), or
|
|
110
|
+
* null when that line has no `Status:`, as specMeta reads it.
|
|
111
|
+
* @param {string} text
|
|
112
|
+
* @returns {{ status: string, detail: string | null } | null}
|
|
113
|
+
*/
|
|
114
|
+
export function readStatus(text) {
|
|
115
|
+
const lines = String(text).split('\n');
|
|
116
|
+
const at = statusLineAt(lines);
|
|
117
|
+
const m = at < 0 ? null : STATUS.exec(lines[at]);
|
|
118
|
+
return m ? { status: m[2].toLowerCase(), detail: m[3]?.trim() || null } : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `text` with the status on the line under its title set to `status (detail)`, and nothing else changed: the rest
|
|
123
|
+
* of that line, a later `Status:` in the body, and the line endings stay. Null when that line has no `Status:`.
|
|
124
|
+
* @param {string} text
|
|
125
|
+
* @param {string} status
|
|
126
|
+
* @param {string} detail
|
|
127
|
+
*/
|
|
128
|
+
export function withStatus(text, status, detail) {
|
|
129
|
+
const lines = String(text).split('\n');
|
|
130
|
+
const at = statusLineAt(lines);
|
|
131
|
+
if (at < 0 || !STATUS.test(lines[at])) return null;
|
|
132
|
+
lines[at] = lines[at].replace(STATUS, (_, lead) => `${lead}${status} (${detail})`);
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
137
|
+
|
|
138
|
+
/** A day as the specs and the decision log write it, in UTC: `29 Sep 2026`. */
|
|
139
|
+
export function specDate(ms) {
|
|
140
|
+
const d = new Date(ms);
|
|
141
|
+
return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What a built spec's brackets say: the merged pull requests of the tasks that link it (`#41, #42`), else the day,
|
|
146
|
+
* and then when it was approved, from the brackets it had (`approved 1 Oct 2026, by the owner`).
|
|
147
|
+
* @param {number[]} pulls
|
|
148
|
+
* @param {string | null} approved
|
|
149
|
+
* @param {number} now
|
|
150
|
+
*/
|
|
151
|
+
export function builtDetail(pulls, approved, now) {
|
|
152
|
+
const numbers = [...new Set(pulls.map(Number).filter((n) => Number.isInteger(n) && n > 0))].sort((a, b) => a - b);
|
|
153
|
+
const built = numbers.length ? numbers.map((n) => `#${n}`).join(', ') : `as of ${specDate(now)}`;
|
|
154
|
+
return approved ? `${built}; approved ${approved}` : built;
|
|
155
|
+
}
|
|
156
|
+
|
|
92
157
|
/** Newest first by the work ID's number, then by path; specs without a work ID last. */
|
|
93
158
|
export function bySpecOrder(a, b) {
|
|
94
159
|
const n = (s) => (s.wid ? Number(s.wid.split('-')[1]) : -1);
|