breakaway 1.0.2-main.6 → 1.0.2-main.8
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/package.json +1 -1
- package/scripts/tasks/cli.js +40 -1
- package/scripts/tasks/message-wait.mjs +3 -3
- package/scripts/tasks/proxy.js +90 -0
- package/scripts/tasks/session-hook.mjs +25 -18
- package/scripts/tasks.mjs +38 -2
- package/src/cli-version.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.0.2-main.
|
|
3
|
+
"version": "1.0.2-main.8",
|
|
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,6 +7,7 @@ import { CLI_PACKAGE } from './init.js';
|
|
|
7
7
|
/** The subcommands each command knows. Without one, each lists or shows (horizon needs close). */
|
|
8
8
|
export const SUBCOMMANDS = {
|
|
9
9
|
agents: ['next', 'start', 'refine'],
|
|
10
|
+
github: ['fix', 'review'],
|
|
10
11
|
repos: ['add', 'init', 'modify', 'remove', 'setup'],
|
|
11
12
|
routines: ['add', 'modify', 'run', 'trigger', 'revoke', 'pause', 'resume'],
|
|
12
13
|
horizon: ['close'],
|
|
@@ -18,7 +19,6 @@ export const NO_ARGUMENTS = new Set([
|
|
|
18
19
|
'list',
|
|
19
20
|
'next',
|
|
20
21
|
'activity',
|
|
21
|
-
'github',
|
|
22
22
|
'health',
|
|
23
23
|
'connections',
|
|
24
24
|
'export',
|
|
@@ -74,6 +74,45 @@ export function githubRequest(repo, { sync = false } = {}) {
|
|
|
74
74
|
return ['GET', repo ? `github?repo=${encodeURIComponent(repo)}` : 'github', undefined];
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** What `github fix` accepts for --problem: the same three the pull request page offers. */
|
|
78
|
+
export const FIX_PROBLEMS = ['conflicts', 'failing', 'review'];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The request behind `npx breakaway github fix <n>` and `github review <n>` (BRK-81): the pull request page's "Fix with an
|
|
82
|
+
* agent" and "Safe to merge?" buttons (`POST github/pulls/<n>/fix` and `/review`). It names the checkout's repository
|
|
83
|
+
* like `github` does. Returns an error message instead when the number or `problem` can't be right.
|
|
84
|
+
* @param {'fix' | 'review'} action
|
|
85
|
+
* @param {string | number | undefined} number
|
|
86
|
+
* @param {{ repo?: string | null, problem?: string, note?: string }} [options]
|
|
87
|
+
* @returns {{ error?: string, request?: [string, string, Record<string, string>] }}
|
|
88
|
+
*/
|
|
89
|
+
export function pullAgentRequest(action, number, { repo = null, problem, note } = {}) {
|
|
90
|
+
const n = String(number ?? '').replace(/^#/u, '');
|
|
91
|
+
if (!/^[1-9]\d{0,8}$/u.test(n)) return { error: `say which pull request: npx breakaway github ${action} <number>` };
|
|
92
|
+
if (problem !== undefined && action !== 'fix') return { error: '--problem is for github fix' };
|
|
93
|
+
if (problem !== undefined && !FIX_PROBLEMS.includes(problem))
|
|
94
|
+
return { error: `--problem is ${FIX_PROBLEMS.join(', ')}` };
|
|
95
|
+
const body = {
|
|
96
|
+
...(repo ? { repo } : {}),
|
|
97
|
+
...(problem ? { problem } : {}),
|
|
98
|
+
...(typeof note === 'string' && note.trim() ? { note } : {}),
|
|
99
|
+
};
|
|
100
|
+
return { request: ['POST', `github/pulls/${n}/${action}`, body] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What the CLI says about an answer to those requests: which task and agent took the pull request, or who already has it.
|
|
105
|
+
* @param {'fix' | 'review'} action
|
|
106
|
+
* @param {string | number} number
|
|
107
|
+
* @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string } | null, already?: string | null }} answer
|
|
108
|
+
*/
|
|
109
|
+
export function pullAgentSummary(action, number, { task, run, already }) {
|
|
110
|
+
const id = task.wid ?? task.short;
|
|
111
|
+
if (!run) return `${id} already has it: ${already}.`;
|
|
112
|
+
const what = action === 'fix' ? `fixing #${number}` : `testing #${number}`;
|
|
113
|
+
return `Started ${run.agent ? `${run.agent}, ` : 'an agent '}${what} on ${id}${run.url ? `: ${run.url}` : ''}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
77
116
|
/**
|
|
78
117
|
* The task an idea becomes (`npx breakaway idea`): its first line, kept short, as the title, and the whole text as its
|
|
79
118
|
* description. Like `add`, it lands in the repository of the checkout it was written in (BRK-71): without one, the
|
|
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
19
19
|
import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
|
|
20
|
-
import {
|
|
20
|
+
import { sessionRequest } from './proxy.js';
|
|
21
21
|
import { waitForMessages } from './session-messages.js';
|
|
22
22
|
|
|
23
23
|
async function main() {
|
|
@@ -43,9 +43,9 @@ async function main() {
|
|
|
43
43
|
const { base, headers } = boardConfig(root);
|
|
44
44
|
if (!base) return;
|
|
45
45
|
const url = `${base}/api/tasks/${encodeURIComponent(claim.uuid)}/messages/waiting?agent=${encodeURIComponent(claim.agent)}`;
|
|
46
|
-
|
|
46
|
+
// Through curl in a cloud session, so it works on whichever Node runs hooks there (BRK-86).
|
|
47
47
|
const ask = async () => {
|
|
48
|
-
const res = await
|
|
48
|
+
const res = await sessionRequest(url, { headers, timeoutMs: 10_000 });
|
|
49
49
|
return res.ok ? res.json() : null;
|
|
50
50
|
};
|
|
51
51
|
|
package/scripts/tasks/proxy.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* routeThroughSessionProxy() sends fetch through the proxy and trusts the system's CA store too, which
|
|
8
8
|
* holds the proxy's certificate. Without a proxy in the environment it does nothing.
|
|
9
9
|
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
12
|
import http from 'node:http';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
11
15
|
import tls from 'node:tls';
|
|
12
16
|
|
|
13
17
|
export function sessionProxy(env = process.env) {
|
|
@@ -25,3 +29,89 @@ export function routeThroughSessionProxy(env = process.env) {
|
|
|
25
29
|
}
|
|
26
30
|
return true;
|
|
27
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Runs `command` with `args`, `input` on stdin, and resolves with stdout; rejects with stderr when it fails. */
|
|
34
|
+
function runWithInput(command, args, input) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
37
|
+
let out = '';
|
|
38
|
+
let err = '';
|
|
39
|
+
child.stdout.on('data', (d) => {
|
|
40
|
+
out += d;
|
|
41
|
+
});
|
|
42
|
+
child.stderr.on('data', (d) => {
|
|
43
|
+
err += d;
|
|
44
|
+
});
|
|
45
|
+
child.on('error', reject);
|
|
46
|
+
child.on('close', (code) =>
|
|
47
|
+
code === 0 ? resolve(out) : reject(Object.assign(new Error(err.trim() || `${command} exited ${code}`), { code })),
|
|
48
|
+
);
|
|
49
|
+
child.stdin.end(input ?? '');
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A hook's request to the board (BRK-86). Claude Code runs hooks with the image's default Node, which in a cloud session
|
|
55
|
+
* can be too old to send fetch through the session proxy (Node 20 has neither http.setGlobalProxyFromEnv nor the CA
|
|
56
|
+
* calls above), and the sandbox refuses a request that goes around it. So with a proxy the request goes through curl,
|
|
57
|
+
* which reads HTTPS_PROXY and the system's certificates on any Node; without curl, or without a proxy, through fetch.
|
|
58
|
+
* Answers like fetch's response: { ok, status, json() }.
|
|
59
|
+
* @param {string} url
|
|
60
|
+
* @param {{ method?: string, headers?: Record<string, string>, body?: string, timeoutMs?: number }} [request]
|
|
61
|
+
*/
|
|
62
|
+
export async function sessionRequest(
|
|
63
|
+
url,
|
|
64
|
+
{ method = 'GET', headers = {}, body, timeoutMs = 4000 } = {},
|
|
65
|
+
{ env = process.env, run = runWithInput, fetch = globalThis.fetch } = {},
|
|
66
|
+
) {
|
|
67
|
+
if (sessionProxy(env)) {
|
|
68
|
+
const args = ['-sS', '--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))), '-X', method];
|
|
69
|
+
for (const [name, value] of Object.entries(headers)) args.push('-H', `${name}: ${value}`);
|
|
70
|
+
if (body !== undefined) args.push('--data-binary', '@-');
|
|
71
|
+
args.push('-w', '\n%{http_code}', url);
|
|
72
|
+
try {
|
|
73
|
+
const out = await run('curl', args, body);
|
|
74
|
+
const at = out.lastIndexOf('\n');
|
|
75
|
+
const status = Number(out.slice(at + 1));
|
|
76
|
+
const text = out.slice(0, Math.max(0, at));
|
|
77
|
+
return { ok: status >= 200 && status < 300, status, json: async () => (text.trim() ? JSON.parse(text) : null) };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error;
|
|
80
|
+
// No curl here: fetch, through the proxy if this Node can.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
routeThroughSessionProxy(env);
|
|
84
|
+
return fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Where a hook notes why it couldn't post for a task (BRK-86): the system's temp folder, never the repository, where a
|
|
89
|
+
* cloud session's Stop check would take a new file as work to commit. The CLI shows it on its next command here.
|
|
90
|
+
*/
|
|
91
|
+
const failurePath = (uuid, dir) => join(dir, `breakaway-hook-${String(uuid).replace(/[^\w-]/gu, '')}.json`);
|
|
92
|
+
|
|
93
|
+
/** Records that the hook couldn't post for task `uuid`, and why. */
|
|
94
|
+
export function noteHookFailure(uuid, reason, dir = tmpdir(), at = new Date()) {
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(
|
|
97
|
+
failurePath(uuid, dir),
|
|
98
|
+
`${JSON.stringify({ at: at.toISOString(), reason: String(reason).slice(0, 300) })}\n`,
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
/* nowhere to note it */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Clears that note, after a post that went through. */
|
|
106
|
+
export function clearHookFailure(uuid, dir = tmpdir()) {
|
|
107
|
+
rmSync(failurePath(uuid, dir), { force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The hook's last failure for task `uuid`, or null. */
|
|
111
|
+
export function hookFailure(uuid, dir = tmpdir()) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(failurePath(uuid, dir), 'utf8'));
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* additionalContext so Claude gets them on its next turn (docs/specs/IDEA-15-message-a-running-agent.md).
|
|
8
8
|
*
|
|
9
9
|
* Quiet by design: without a claimed task (.task-session, written by `tasks claim`), with
|
|
10
|
-
* BREAKAWAY_SESSION_LOG=off (or SAMEWAVE_TASKS_SESSION_LOG=off), or on any error, it does nothing and exits 0.
|
|
10
|
+
* BREAKAWAY_SESSION_LOG=off (or SAMEWAVE_TASKS_SESSION_LOG=off), or on any error, it does nothing and exits 0. A post
|
|
11
|
+
* that fails leaves its reason in the temp folder, which the CLI's next command here shows (BRK-86).
|
|
11
12
|
*/
|
|
12
13
|
import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
|
|
13
|
-
import {
|
|
14
|
+
import { clearHookFailure, noteHookFailure, sessionRequest } from './proxy.js';
|
|
14
15
|
import { entryFor } from './session-log.js';
|
|
15
16
|
import { CONTEXT_EVENTS, messageOutput } from './session-messages.js';
|
|
16
17
|
|
|
@@ -26,22 +27,28 @@ async function main() {
|
|
|
26
27
|
if (!entry) return;
|
|
27
28
|
|
|
28
29
|
const { base, headers } = boardConfig(root);
|
|
29
|
-
if (!base) return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
if (!base) return noteHookFailure(claim.uuid, 'no board address: set BREAKAWAY_URL');
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
// Through curl in a cloud session, so it works on whichever Node runs hooks there (BRK-86).
|
|
34
|
+
res = await sessionRequest(`${base}/api/tasks/${encodeURIComponent(claim.uuid)}/session`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
agent: claim.agent,
|
|
39
|
+
session: hook.session_id ?? null,
|
|
40
|
+
remote: process.env.CLAUDE_CODE_REMOTE === 'true',
|
|
41
|
+
entries: [entry],
|
|
42
|
+
// A Stop hook's output can't reach Claude, so it leaves the messages for the next event.
|
|
43
|
+
messages: CONTEXT_EVENTS.has(hook.hook_event_name),
|
|
44
|
+
}),
|
|
45
|
+
timeoutMs: 4000,
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return noteHookFailure(claim.uuid, /** @type {Error} */ (error)?.message ?? String(error));
|
|
49
|
+
}
|
|
50
|
+
if (!res.ok) return noteHookFailure(claim.uuid, `HTTP ${res.status}`);
|
|
51
|
+
clearHookFailure(claim.uuid);
|
|
45
52
|
const output = messageOutput(await res.json(), hook.hook_event_name);
|
|
46
53
|
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
47
54
|
}
|
package/scripts/tasks.mjs
CHANGED
|
@@ -40,11 +40,18 @@ import {
|
|
|
40
40
|
} from './tasks/structure.js';
|
|
41
41
|
import { looksLikeSecret } from '../src/ping.js';
|
|
42
42
|
import { promptPathOf } from '../src/repos.js';
|
|
43
|
-
import { sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
|
|
43
|
+
import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
|
|
44
44
|
import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
|
|
45
45
|
import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
|
|
46
46
|
import { CLI_PACKAGE, PROMPT_SECTIONS, initPlan, machineTaskrc, promptSections } from './tasks/init.js';
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
githubRequest,
|
|
49
|
+
ideaTask,
|
|
50
|
+
pullAgentRequest,
|
|
51
|
+
pullAgentSummary,
|
|
52
|
+
staleCliWarning,
|
|
53
|
+
unknownSubcommand,
|
|
54
|
+
} from './tasks/cli.js';
|
|
48
55
|
import { CLI_VERSION } from '../src/cli-version.js';
|
|
49
56
|
import { LEGACY, parseInstall, secretName } from '../src/install.js';
|
|
50
57
|
import {
|
|
@@ -143,6 +150,8 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
143
150
|
routines saved prompts the owner runs with a button, and their caps
|
|
144
151
|
routines run <slug> run one now: makes a RUN task and starts an agent on it [--note <text>]
|
|
145
152
|
horizon close close now: finished tasks go to the archive, next becomes now, later becomes next [--dry-run]
|
|
153
|
+
github fix <n> start an agent on a pull request's conflicts, failing checks, or review comments (owner) [--problem conflicts|failing|review] [--note <text>] [--repo <slug>]
|
|
154
|
+
github review <n> start an agent that tests a Dependabot pull request, as Safe to merge? does (owner) [--note <text>] [--repo <slug>]
|
|
146
155
|
github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
|
|
147
156
|
hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
|
|
148
157
|
health the server's state
|
|
@@ -517,6 +526,20 @@ async function startSessionLog(t) {
|
|
|
517
526
|
);
|
|
518
527
|
}
|
|
519
528
|
|
|
529
|
+
/** The session hook couldn't post this checkout's live output: say so where the agent and the owner see it (BRK-86). */
|
|
530
|
+
function warnHookFailure() {
|
|
531
|
+
try {
|
|
532
|
+
const claim = JSON.parse(readFileSync(join(REPO, '.task-session'), 'utf8'));
|
|
533
|
+
const failed = claim?.uuid && hookFailure(claim.uuid);
|
|
534
|
+
if (failed)
|
|
535
|
+
console.error(
|
|
536
|
+
`tasks: the session hook couldn't post ${claim.wid ?? 'this task'}'s live output (${failed.reason}, ${failed.at.slice(0, 16).replace('T', ' ')} UTC), so the board shows none. docs/tasks.md#cloud-agents says what to check.`,
|
|
537
|
+
);
|
|
538
|
+
} catch {
|
|
539
|
+
/* no claim here */
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
520
543
|
function unmarkSession(t) {
|
|
521
544
|
try {
|
|
522
545
|
const path = join(REPO, '.task-session');
|
|
@@ -1188,6 +1211,18 @@ const commands = {
|
|
|
1188
1211
|
);
|
|
1189
1212
|
},
|
|
1190
1213
|
async github() {
|
|
1214
|
+
const action = args[0];
|
|
1215
|
+
if (action === 'fix' || action === 'review') {
|
|
1216
|
+
const built = pullAgentRequest(action, args[1], {
|
|
1217
|
+
repo: (await checkoutRepo()).slug,
|
|
1218
|
+
problem: opts.problem,
|
|
1219
|
+
note: opts.note,
|
|
1220
|
+
});
|
|
1221
|
+
if (built.error || !built.request) fail(built.error ?? 'bad request');
|
|
1222
|
+
const answer = await call(...built.request);
|
|
1223
|
+
print(answer, (a) => pullAgentSummary(action, args[1].replace(/^#/u, ''), a));
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1191
1226
|
const g = await call(...githubRequest((await checkoutRepo()).slug, { sync: Boolean(opts.sync) }));
|
|
1192
1227
|
print(g, (d) => {
|
|
1193
1228
|
if (!d.connected) return "GitHub isn't connected yet: open the GitHub view on the board (docs/tasks.md#github).";
|
|
@@ -1938,5 +1973,6 @@ if (opts.help || command === 'help') {
|
|
|
1938
1973
|
} else if (unknownSubcommand(command, args[0])) {
|
|
1939
1974
|
fail(unknownSubcommand(command, args[0]));
|
|
1940
1975
|
} else {
|
|
1976
|
+
if (command !== 'hook') warnHookFailure();
|
|
1941
1977
|
await commands[command]();
|
|
1942
1978
|
}
|
package/src/cli-version.js
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* bump CLI_VERSION and set CLI_FINGERPRINT to the value it prints. Constants only: the board's Worker imports it,
|
|
6
6
|
* so it lives in the board's package (CLD-135) and the CLI imports it from here.
|
|
7
7
|
*/
|
|
8
|
-
export const CLI_VERSION =
|
|
9
|
-
export const CLI_FINGERPRINT = '
|
|
8
|
+
export const CLI_VERSION = 40;
|
|
9
|
+
export const CLI_FINGERPRINT = '37624c98bbeb5108';
|