breakaway 2.0.2-main.1 → 2.0.2-main.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/pipeline/SKILL.md +1 -1
- package/.agents/skills/tasks/SKILL.md +8 -5
- package/README.md +5 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/prompts/add-repository.md +2 -2
- package/prompts/core.md +30 -17
- package/prompts/install.md +1 -1
- package/scripts/tasks/cli.js +11 -2
- package/scripts/tasks/edit-hook.mjs +54 -0
- package/scripts/tasks/footprint-hook.js +188 -0
- package/scripts/tasks/hook-config.js +3 -3
- package/scripts/tasks/mcp.js +12 -9
- package/scripts/tasks/peloton.js +23 -2
- package/scripts/tasks/plugin-hooks.js +5 -4
- package/scripts/tasks/session-hook.mjs +30 -1
- package/scripts/tasks/session-messages.js +6 -3
- package/scripts/tasks/settings.js +26 -0
- package/scripts/tasks/structure.js +4 -4
- package/scripts/tasks.mjs +172 -28
- package/src/decision.js +13 -7
- package/src/footprint-text.js +93 -0
- package/src/init.js +45 -9
- package/src/model.js +109 -6
- package/src/ping.js +52 -7
- package/src/redact.js +3 -0
- package/taskrc +21 -10
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the session hooks do about footprints (docs/specs/IDEA-55-footprints.md, sections 1a and 1b): the edit hook
|
|
3
|
+
* claims the file an edit names before it runs, and the session hook reports the agent's dirty paths. Pure, apart from
|
|
4
|
+
* the notes kept in the system's temp folder (never the repository, where a cloud session's Stop check would take a new
|
|
5
|
+
* file as work to commit), so it's tested without git or a board.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { EDIT_MATCHER } from '../../src/init.js';
|
|
11
|
+
|
|
12
|
+
const EDIT_TOOLS = new Set(EDIT_MATCHER.split('|'));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How long the edit hook waits for the board before it lets the edit through. Claude Code's own `timeout` on the hook
|
|
16
|
+
* (EDIT_HOOK_TIMEOUT in src/init.js) is the backstop: a PreToolUse command hook that times out doesn't block the tool
|
|
17
|
+
* call (source: https://code.claude.com/docs/en/hooks, "A timed-out command, http, or mcp_tool hook doesn't block the
|
|
18
|
+
* tool call").
|
|
19
|
+
*/
|
|
20
|
+
export const EDIT_CLAIM_MS = 3000;
|
|
21
|
+
/** Dirty paths go to the board at most this often (section 1b). */
|
|
22
|
+
export const DIRTY_EVERY_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
/** Whether a hook event is an edit tool's. */
|
|
25
|
+
export function isEdit(hook) {
|
|
26
|
+
return EDIT_TOOLS.has(hook?.tool_name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The repository path an edit names, or null when it names none or one outside the checkout (or inside `.git/`).
|
|
31
|
+
* Claude Code hands the file tools' `file_path` over absolute, with backslashes on Windows (source:
|
|
32
|
+
* https://code.claude.com/docs/en/hooks, "For the file tools Write, Edit, and Read, tool_input.file_path is always
|
|
33
|
+
* absolute"); NotebookEdit names `notebook_path`.
|
|
34
|
+
* @param {any} hook the hook's input
|
|
35
|
+
* @param {string} root the checkout's root
|
|
36
|
+
*/
|
|
37
|
+
export function editedPath(hook, root) {
|
|
38
|
+
const raw = hook?.tool_input?.file_path ?? hook?.tool_input?.notebook_path;
|
|
39
|
+
if (typeof raw !== 'string' || !raw.trim()) return null;
|
|
40
|
+
const path = raw.replaceAll('\\', '/');
|
|
41
|
+
const base = String(root ?? '')
|
|
42
|
+
.replaceAll('\\', '/')
|
|
43
|
+
.replace(/\/+$/u, '');
|
|
44
|
+
let rel;
|
|
45
|
+
if (/^(?:\/|[A-Za-z]:\/)/u.test(path)) {
|
|
46
|
+
if (!base || !path.startsWith(`${base}/`)) return null;
|
|
47
|
+
rel = path.slice(base.length + 1);
|
|
48
|
+
} else rel = path;
|
|
49
|
+
const parts = rel.split('/').filter((p) => p && p !== '.');
|
|
50
|
+
if (!parts.length || parts.includes('..') || parts[0] === '.git') return null;
|
|
51
|
+
return parts.join('/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What the edit hook prints for the board's answer to its claim, or null to stay quiet and let the edit go ahead.
|
|
56
|
+
* Granted or already the task's: quiet. Refused (409): the edit is denied, with the board's reason, which Claude reads
|
|
57
|
+
* (`permissionDecision: "deny"` shows `permissionDecisionReason` to Claude; source: https://code.claude.com/docs/en/hooks,
|
|
58
|
+
* PreToolUse decision control). Anything else: the edit goes ahead, and Claude hears why it isn't claimed
|
|
59
|
+
* (`additionalContext`, same source), because a board that's down never stops work.
|
|
60
|
+
* @param {{ status: number, data?: any } | { error: string }} answer
|
|
61
|
+
* @param {string} path
|
|
62
|
+
* @param {{ wid?: string | null }} claim
|
|
63
|
+
*/
|
|
64
|
+
export function editOutput(answer, path, claim) {
|
|
65
|
+
if ('status' in answer && answer.status >= 200 && answer.status < 300) return null;
|
|
66
|
+
if ('status' in answer && answer.status === 409) {
|
|
67
|
+
const reason =
|
|
68
|
+
typeof answer.data?.error === 'string' && answer.data.error.trim()
|
|
69
|
+
? answer.data.error.trim()
|
|
70
|
+
: `\`${path}\` is claimed by another task. Back off: change other files, ask its holder on the peloton, or comment why and release your task.`;
|
|
71
|
+
return {
|
|
72
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const why =
|
|
76
|
+
'error' in answer
|
|
77
|
+
? answer.error
|
|
78
|
+
: typeof answer.data?.error === 'string'
|
|
79
|
+
? `HTTP ${answer.status}: ${answer.data.error}`
|
|
80
|
+
: `HTTP ${answer.status}`;
|
|
81
|
+
return {
|
|
82
|
+
hookSpecificOutput: {
|
|
83
|
+
hookEventName: 'PreToolUse',
|
|
84
|
+
additionalContext: `The board didn't grant a claim on \`${path}\` (${String(why).slice(0, 200)}), so this edit went ahead unclaimed. Claims are advisory: carry on, and once the board answers, claim it with npx breakaway paths ${claim?.wid ?? '<your task>'} --claim ${path}.`,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `git status --porcelain -z` → the paths it names: a rename's or copy's new path and its old one, an untracked file,
|
|
91
|
+
* and a deleted one alike.
|
|
92
|
+
* @param {string} out
|
|
93
|
+
*/
|
|
94
|
+
export function porcelainPaths(out) {
|
|
95
|
+
const parts = String(out ?? '').split('\0');
|
|
96
|
+
/** @type {string[]} */
|
|
97
|
+
const paths = [];
|
|
98
|
+
for (let i = 0; i < parts.length; i++) {
|
|
99
|
+
const entry = parts[i];
|
|
100
|
+
if (entry.length < 4) continue;
|
|
101
|
+
paths.push(entry.slice(3));
|
|
102
|
+
// -z puts a rename's or a copy's old path in the next entry, on its own.
|
|
103
|
+
if (/[RC]/u.test(entry.slice(0, 2)) && parts[i + 1]) paths.push(parts[++i]);
|
|
104
|
+
}
|
|
105
|
+
return paths;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The agent's dirty paths (section 1b): what `git status` lists and what differs from the default branch's merge base,
|
|
110
|
+
* paths only, never contents, sorted. `git(args)` runs git in the checkout and answers its output, or null when it
|
|
111
|
+
* fails. Null when git can't say at all (not a checkout).
|
|
112
|
+
* @param {(args: string[]) => string | null} git
|
|
113
|
+
*/
|
|
114
|
+
export function dirtyPaths(git) {
|
|
115
|
+
const status = git(['status', '--porcelain', '-z', '--untracked-files=all']);
|
|
116
|
+
if (status === null) return null;
|
|
117
|
+
const paths = new Set(porcelainPaths(status));
|
|
118
|
+
const head = git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])?.trim();
|
|
119
|
+
for (const branch of [head, 'origin/main', 'origin/master'].filter(Boolean)) {
|
|
120
|
+
const base = git(['merge-base', 'HEAD', /** @type {string} */ (branch)])?.trim();
|
|
121
|
+
if (!base) continue;
|
|
122
|
+
const diff = git(['diff', '--name-only', '-z', base]);
|
|
123
|
+
for (const path of String(diff ?? '').split('\0')) if (path) paths.add(path);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
return [...paths].sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const notePath = (kind, uuid, dir) => join(dir, `breakaway-${kind}-${String(uuid).replace(/[^\w-]/gu, '')}`);
|
|
130
|
+
|
|
131
|
+
/** Whether the session hook should report dirty paths now: an edit tool's call or a stop, at most once a minute. */
|
|
132
|
+
export function dirtyDue(hook, uuid, { dir = tmpdir(), now = Date.now() } = {}) {
|
|
133
|
+
if (!isEdit(hook) && hook?.hook_event_name !== 'Stop') return false;
|
|
134
|
+
let last = 0;
|
|
135
|
+
try {
|
|
136
|
+
last = Number(readFileSync(notePath('dirty', uuid, dir), 'utf8')) || 0;
|
|
137
|
+
} catch {
|
|
138
|
+
/* never sent */
|
|
139
|
+
}
|
|
140
|
+
if (now - last < DIRTY_EVERY_MS) return false;
|
|
141
|
+
try {
|
|
142
|
+
writeFileSync(notePath('dirty', uuid, dir), String(now));
|
|
143
|
+
} catch {
|
|
144
|
+
/* nowhere to note it: it sends again next time */
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The board's answer about the dirty paths → what Claude hears: each conflict the board flagged for the first time
|
|
151
|
+
* (section 1b), or '' when there's none.
|
|
152
|
+
* @param {{ conflicts?: { path: string, task: string, agent: string, pattern: string, new?: boolean }[] } | undefined} footprint
|
|
153
|
+
*/
|
|
154
|
+
export function conflictText(footprint) {
|
|
155
|
+
const fresh = (footprint?.conflicts ?? []).filter((c) => c?.new);
|
|
156
|
+
return fresh
|
|
157
|
+
.map(
|
|
158
|
+
(c) =>
|
|
159
|
+
`You changed \`${c.path}\`, which ${c.agent} claims on ${c.task} (\`${c.pattern}\`). Agree on the peloton who goes first (@${c.agent}); by default the second one backs off and leaves the file to its holder.`,
|
|
160
|
+
)
|
|
161
|
+
.join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A note the session hook couldn't hand Claude yet (a Stop's output can't carry context): kept for the task until its
|
|
166
|
+
* next event that can. `keepNote` adds to it; `takeNote` answers it and clears it.
|
|
167
|
+
*/
|
|
168
|
+
export function keepNote(uuid, text, dir = tmpdir()) {
|
|
169
|
+
if (!text) return;
|
|
170
|
+
const before = takeNote(uuid, dir);
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(notePath('note', uuid, dir), [before, text].filter(Boolean).join('\n').slice(-4000));
|
|
173
|
+
} catch {
|
|
174
|
+
/* nowhere to keep it */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The kept note for task `uuid`, cleared, or ''. */
|
|
179
|
+
export function takeNote(uuid, dir = tmpdir()) {
|
|
180
|
+
const path = notePath('note', uuid, dir);
|
|
181
|
+
try {
|
|
182
|
+
const text = readFileSync(path, 'utf8');
|
|
183
|
+
rmSync(path, { force: true });
|
|
184
|
+
return text;
|
|
185
|
+
} catch {
|
|
186
|
+
return '';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -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, settingFrom } from './settings.js';
|
|
10
|
+
import { authHeaders, 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() {
|
|
@@ -70,9 +70,9 @@ export function boardConfig(root = projectRoot()) {
|
|
|
70
70
|
}
|
|
71
71
|
const { url } = boardUrl({ env: process.env, file, taskrc: readOptional(join(root, '.taskrc')), config });
|
|
72
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).
|
|
73
|
+
// `token` comes after the CLI's own (CLI-8). A lent run's key goes in its own header (BRK-324).
|
|
74
74
|
const token = settingFrom('TOKEN', { env: process.env, file }).value;
|
|
75
|
-
return { base: url, headers: token
|
|
75
|
+
return { base: url, headers: authHeaders(token, settingFrom('RUN_KEY', { env: process.env, file }).value) };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** The board says this checkout's task isn't claimed by its agent any more: drop the marker so later calls post nothing (BRK-87). */
|
package/scripts/tasks/mcp.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* board's /mcp, and whether it answers. Pure apart from the `fetch` it's handed, so it's tested without a board.
|
|
4
4
|
*/
|
|
5
5
|
import { githubFromRemote, pickRepo } from './repo.js';
|
|
6
|
+
import { authHeaders } from './settings.js';
|
|
6
7
|
|
|
7
8
|
/** The MCP revision `--check` speaks: the one with an `initialize` handshake, which /mcp accepts (src/mcp.js, LEGACY). */
|
|
8
9
|
export const CHECK_PROTOCOL = '2025-11-25';
|
|
@@ -57,12 +58,14 @@ export function mcpConfig({ url, agent, repo = null, tokenVar }) {
|
|
|
57
58
|
* tasks.env). Otherwise it's `agent`, claude-<branch>, as X-Breakaway-Agent-Default: the helper can't see the plugin's
|
|
58
59
|
* agent_name, which the plugin sends as a static X-Breakaway-Agent that this one would override, and /mcp takes the
|
|
59
60
|
* default only when that's empty (CLI-16).
|
|
60
|
-
*
|
|
61
|
+
*
|
|
62
|
+
* A lent run's key (`runKey`, BRK-324) goes in its own header, which a cloud session's proxy leaves alone.
|
|
63
|
+
* @param {{ token?: string | null, runKey?: string | null, named?: string | null, agent: string, repo?: string | null }} options
|
|
61
64
|
* @returns {Record<string, string>}
|
|
62
65
|
*/
|
|
63
|
-
export function mcpHeaders({ token = null, named = null, agent, repo = null }) {
|
|
66
|
+
export function mcpHeaders({ token = null, runKey = null, named = null, agent, repo = null }) {
|
|
64
67
|
return {
|
|
65
|
-
...(token
|
|
68
|
+
...authHeaders(token, runKey),
|
|
66
69
|
...(repo
|
|
67
70
|
? {
|
|
68
71
|
...(named ? { 'X-Breakaway-Agent': named } : { 'X-Breakaway-Agent-Default': agent }),
|
|
@@ -77,15 +80,15 @@ export function mcpHeaders({ token = null, named = null, agent, repo = null }) {
|
|
|
77
80
|
* null when it tracks none of them. A plugin's headersHelper runs without the plugin's settings, so with only the
|
|
78
81
|
* plugin set up there's no token to ask with: then it's the checkout's GitHub `owner/name`, which /mcp matches against
|
|
79
82
|
* 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
|
|
83
|
+
* @param {{ base?: string | null, token?: string | null, runKey?: string | null, named?: string | null, remote?: string | null, fetch: typeof globalThis.fetch }} options
|
|
81
84
|
* @returns {Promise<string | null>}
|
|
82
85
|
*/
|
|
83
|
-
export async function headersRepo({ base = null, token = null, named = null, remote = null, fetch }) {
|
|
86
|
+
export async function headersRepo({ base = null, token = null, runKey = null, named = null, remote = null, fetch }) {
|
|
84
87
|
let registry = null;
|
|
85
88
|
if (base)
|
|
86
89
|
try {
|
|
87
90
|
const res = await fetch(`${String(base).replace(/\/+$/u, '')}/api/repos`, {
|
|
88
|
-
headers: token
|
|
91
|
+
headers: authHeaders(token, runKey),
|
|
89
92
|
signal: AbortSignal.timeout(5000),
|
|
90
93
|
});
|
|
91
94
|
if (res.ok) registry = await res.json();
|
|
@@ -133,12 +136,12 @@ export function mcpLines(config, { repo = null, tokenVar }) {
|
|
|
133
136
|
/**
|
|
134
137
|
* `--check`: an `initialize` and a `tools/list` against /mcp with the same headers the config sends, and what came
|
|
135
138
|
* 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
|
|
139
|
+
* @param {{ endpoint: string, token?: string | null, runKey?: string | null, agent: string, repo?: string | null, fetch: typeof globalThis.fetch }} options
|
|
137
140
|
* @returns {Promise<{ ok: boolean, lines: string[] }>}
|
|
138
141
|
*/
|
|
139
|
-
export async function checkMcp({ endpoint, token = null, agent, repo = null, fetch }) {
|
|
142
|
+
export async function checkMcp({ endpoint, token = null, runKey = null, agent, repo = null, fetch }) {
|
|
140
143
|
const headers = {
|
|
141
|
-
...(token
|
|
144
|
+
...authHeaders(token, runKey),
|
|
142
145
|
'Content-Type': 'application/json',
|
|
143
146
|
Accept: 'application/json, text/event-stream',
|
|
144
147
|
'X-Breakaway-Agent': agent,
|
package/scripts/tasks/peloton.js
CHANGED
|
@@ -37,7 +37,9 @@ const usable = (p) => typeof p?.id === 'number' && typeof p?.text === 'string' &
|
|
|
37
37
|
const fromOwner = (p) => p.agent === 'owner' && !p.task;
|
|
38
38
|
const author = (p) => {
|
|
39
39
|
if (fromOwner(p)) return 'the owner';
|
|
40
|
-
|
|
40
|
+
if (p.agent === 'board' && !p.task) return 'the board';
|
|
41
|
+
// A person the owner invited posts from the board with no task (BRK-303): named, never taken for the owner.
|
|
42
|
+
return p.task ? `${p.agent} on ${p.task}` : `${p.agent} via the board`;
|
|
41
43
|
};
|
|
42
44
|
const noPeloton = (agent) =>
|
|
43
45
|
`${agent || 'this agent'} rides no peloton: claim your task first (npx breakaway claim <task>), then check in`;
|
|
@@ -240,6 +242,23 @@ export function planLines(plan, { indent = '' } = {}) {
|
|
|
240
242
|
return [head, ...body];
|
|
241
243
|
}
|
|
242
244
|
|
|
245
|
+
/** Patterns shown of one rider's footprint before "and n more". */
|
|
246
|
+
const SHOWN_PATTERNS = 6;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What a rider is changing, after its name (IDEA-55 section 4): its footprint, and which of the reader's own paths
|
|
250
|
+
* overlap it. '' when the board doesn't know.
|
|
251
|
+
* @param {{ changing?: string[], overlaps?: string[] }} rider
|
|
252
|
+
*/
|
|
253
|
+
export function riderFootprint(rider) {
|
|
254
|
+
const changing = rider?.changing ?? [];
|
|
255
|
+
if (!changing.length) return '';
|
|
256
|
+
const more = changing.length - SHOWN_PATTERNS;
|
|
257
|
+
const list = `${changing.slice(0, SHOWN_PATTERNS).join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
|
|
258
|
+
const overlaps = rider.overlaps?.length ? `; overlaps yours: ${rider.overlaps.join(', ')}` : '';
|
|
259
|
+
return `: changing ${list}${overlaps}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
243
262
|
/**
|
|
244
263
|
* The agent's views → what `npx breakaway peloton` prints: each peloton's riders, its open huddle and its plan (a
|
|
245
264
|
* chase's), then its new posts and the newest SHOWN_POSTS (every one with `all`), new ones starred.
|
|
@@ -258,7 +277,9 @@ export function pelotonLines(views, { agent, all = false } = {}) {
|
|
|
258
277
|
const lines = [`${v.peloton} (you ride it on ${v.task}): ${state}`];
|
|
259
278
|
for (const r of roster) {
|
|
260
279
|
const since = sentAt(r.since);
|
|
261
|
-
lines.push(
|
|
280
|
+
lines.push(
|
|
281
|
+
` ${r.agent}${r.task ? ` on ${r.task}` : ''}${since ? `, since ${since}` : ''}${riderFootprint(r)}`,
|
|
282
|
+
);
|
|
262
283
|
}
|
|
263
284
|
if (v.huddle) lines.push(...huddleLines(v.huddle));
|
|
264
285
|
if (v.plan) lines.push(...planLines(v.plan, { indent: ' ' }));
|
|
@@ -10,6 +10,8 @@ import { join } from 'node:path';
|
|
|
10
10
|
/** A session hook command repos init writes: through npx, any version, or an old copy's script. */
|
|
11
11
|
const BOARD_HOOK =
|
|
12
12
|
/\bbreakaway(?:@[^\s"]+)? hook (?:session|wait)\b|scripts\/tasks\/(?:session-hook|message-wait)\.mjs/u;
|
|
13
|
+
/** The edit hook (IDEA-55): settings from before it run the other hooks but not this one. */
|
|
14
|
+
const EDIT_HOOK = /\bbreakaway(?:@[^\s"]+)? hook edit\b|scripts\/tasks\/edit-hook\.mjs/u;
|
|
13
15
|
|
|
14
16
|
/** A file's text, or null when it can't be read. */
|
|
15
17
|
function readOptional(path) {
|
|
@@ -24,9 +26,8 @@ function readOptional(path) {
|
|
|
24
26
|
* Whether this hook is the plugin's and the checkout's settings already run the board's hooks, so this one does
|
|
25
27
|
* nothing. Outside the plugin it's always false: the checkout's own hooks always run.
|
|
26
28
|
*/
|
|
27
|
-
export function checkoutRunsHooks(root, env = process.env, read = readOptional) {
|
|
29
|
+
export function checkoutRunsHooks(root, env = process.env, read = readOptional, hook = 'session') {
|
|
28
30
|
if (!env.CLAUDE_PLUGIN_ROOT) return false;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
);
|
|
31
|
+
const runs = hook === 'edit' ? EDIT_HOOK : BOARD_HOOK;
|
|
32
|
+
return ['settings.json', 'settings.local.json'].some((name) => runs.test(read(join(root, '.claude', name)) ?? ''));
|
|
32
33
|
}
|
|
@@ -13,9 +13,15 @@
|
|
|
13
13
|
* that fails leaves its reason in the temp folder, which the CLI's next command here shows (BRK-86).
|
|
14
14
|
*
|
|
15
15
|
* Run by the plugin at SessionStart, it first passes the plugin's settings on to the session's Bash commands (CLI-8).
|
|
16
|
+
*
|
|
17
|
+
* After an edit tool's call and at a stop, at most once a minute, the post carries the agent's dirty paths (paths
|
|
18
|
+
* only, never contents), and the board answers with what they conflict with, which Claude hears on its next turn
|
|
19
|
+
* (docs/specs/IDEA-55-footprints.md, section 1b).
|
|
16
20
|
*/
|
|
17
21
|
import { checkoutRunsHooks } from './plugin-hooks.js';
|
|
18
22
|
import { boardConfig, claimedTask, dropClaim, projectRoot } from './hook-config.js';
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { conflictText, dirtyDue, dirtyPaths, keepNote, takeNote } from './footprint-hook.js';
|
|
19
25
|
import { passPluginEnv } from './plugin-env.js';
|
|
20
26
|
import { clearHookFailure, noteHookFailure, sessionRequest } from './proxy.js';
|
|
21
27
|
import { entryFor } from './session-log.js';
|
|
@@ -37,6 +43,7 @@ async function main() {
|
|
|
37
43
|
|
|
38
44
|
const { base, headers } = boardConfig(root);
|
|
39
45
|
if (!base) return noteHookFailure(claim.uuid, 'no board address: set BREAKAWAY_URL');
|
|
46
|
+
const dirty = claim.agent && dirtyDue(hook, claim.uuid) ? dirtyPaths(git(root)) : null;
|
|
40
47
|
let res;
|
|
41
48
|
try {
|
|
42
49
|
// Through curl in a cloud session, so it works on whichever Node runs hooks there (BRK-86).
|
|
@@ -50,6 +57,7 @@ async function main() {
|
|
|
50
57
|
entries: [entry],
|
|
51
58
|
// A Stop hook's output can't reach Claude, so it leaves the messages for the next event.
|
|
52
59
|
messages: CONTEXT_EVENTS.has(hook.hook_event_name),
|
|
60
|
+
...(dirty ? { dirty } : {}),
|
|
53
61
|
}),
|
|
54
62
|
timeoutMs: 4000,
|
|
55
63
|
});
|
|
@@ -65,10 +73,31 @@ async function main() {
|
|
|
65
73
|
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
66
74
|
return;
|
|
67
75
|
}
|
|
68
|
-
|
|
76
|
+
// What the dirty paths conflict with, and what an earlier event couldn't say, go with the messages.
|
|
77
|
+
const conflicts = conflictText(answer?.footprint);
|
|
78
|
+
if (!CONTEXT_EVENTS.has(hook.hook_event_name)) return keepNote(claim.uuid, conflicts);
|
|
79
|
+
const note = [takeNote(claim.uuid), conflicts].filter(Boolean).join('\n');
|
|
80
|
+
const output = messageOutput(answer, hook.hook_event_name, note);
|
|
69
81
|
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
/** Runs git in the checkout: its output, or null when it fails. */
|
|
85
|
+
function git(root) {
|
|
86
|
+
return (/** @type {string[]} */ args) => {
|
|
87
|
+
try {
|
|
88
|
+
return execFileSync('git', args, {
|
|
89
|
+
cwd: root,
|
|
90
|
+
encoding: 'utf8',
|
|
91
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
92
|
+
timeout: 2000,
|
|
93
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
main()
|
|
73
102
|
.catch(() => {})
|
|
74
103
|
.finally(() => process.exit(0));
|
|
@@ -20,10 +20,11 @@ export function sentAt(sent) {
|
|
|
20
20
|
/**
|
|
21
21
|
* The board's answer to the session post, and the hook's event → the JSON the hook prints, or
|
|
22
22
|
* null when there's nothing to say (no messages or posts, an event that can't carry them, a bad answer).
|
|
23
|
+
* `note` is the hook's own word after them: a conflict on a changed path (IDEA-55 section 1b).
|
|
23
24
|
*/
|
|
24
|
-
export function messageOutput(answer, event) {
|
|
25
|
+
export function messageOutput(answer, event, note = '') {
|
|
25
26
|
if (!CONTEXT_EVENTS.has(event)) return null;
|
|
26
|
-
const text = waitingText(answer);
|
|
27
|
+
const text = [waitingText(answer), note].filter(Boolean).join('\n\n');
|
|
27
28
|
if (!text) return null;
|
|
28
29
|
return { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
|
|
29
30
|
}
|
|
@@ -42,7 +43,9 @@ export function messageText(answer) {
|
|
|
42
43
|
.filter((m) => typeof m?.text === 'string' && m.text.trim())
|
|
43
44
|
.map((m) => {
|
|
44
45
|
const at = sentAt(m.sent);
|
|
45
|
-
|
|
46
|
+
// A person's message names them (BRK-303): only the owner's reads "from the owner".
|
|
47
|
+
const from = typeof m.from === 'string' && m.from && m.from !== 'owner' ? m.from : 'the owner';
|
|
48
|
+
return `Message from ${from} (via the board${at ? `, ${at}` : ''}): ${m.text.trim()}`;
|
|
46
49
|
})
|
|
47
50
|
.join('\n\n');
|
|
48
51
|
}
|
|
@@ -13,6 +13,7 @@ export const NAMES = Object.freeze({
|
|
|
13
13
|
SECRET: 'BREAKAWAY_SECRET',
|
|
14
14
|
SYNC_KEY: 'BREAKAWAY_SYNC_KEY',
|
|
15
15
|
SESSION_LOG: 'BREAKAWAY_SESSION_LOG',
|
|
16
|
+
RUN_KEY: 'BREAKAWAY_RUN_KEY',
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -26,6 +27,31 @@ export const PLUGIN_OPTIONS = Object.freeze({
|
|
|
26
27
|
AGENT: 'CLAUDE_PLUGIN_OPTION_AGENT_NAME',
|
|
27
28
|
});
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A lent run's key (BRK-324), from the `Run key:` line of the run's payload: the RUN_KEY setting, which `run-key`
|
|
32
|
+
* saves in tasks.env so the hooks, which don't share the agent's shell, send it too. It travels in its own header,
|
|
33
|
+
* which a cloud session's proxy leaves alone (it replaces Authorization), and the board takes it over any token beside
|
|
34
|
+
* it.
|
|
35
|
+
*/
|
|
36
|
+
export const RUN_KEY_HEADER = 'X-Breakaway-Run-Key';
|
|
37
|
+
/** A run key's shape, as the board makes them. */
|
|
38
|
+
export const RUN_KEY = /^bkr_[0-9a-f]{64}$/u;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The headers that say who a request to the board is: the token as Authorization when there is one, and a lent run's
|
|
42
|
+
* key in its own header when there is one (BRK-324).
|
|
43
|
+
* @param {string | null | undefined} token
|
|
44
|
+
* @param {string | null | undefined} [runKey]
|
|
45
|
+
* @returns {Record<string, string>}
|
|
46
|
+
*/
|
|
47
|
+
export function authHeaders(token, runKey = null) {
|
|
48
|
+
const key = String(runKey ?? '').trim();
|
|
49
|
+
return {
|
|
50
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
51
|
+
...(key ? { [RUN_KEY_HEADER]: key } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
/**
|
|
30
56
|
* A setting's value: from the environment first, then the env file, else `fallback`. The environment always wins,
|
|
31
57
|
* so `BREAKAWAY_URL=… npx breakaway` points one command elsewhere. The plugin's options aren't read here: see
|
|
@@ -82,7 +82,8 @@ export const PING_TEMPLATE = {
|
|
|
82
82
|
title: 'Create the Sentry project',
|
|
83
83
|
project: 'ops',
|
|
84
84
|
horizon: 'now',
|
|
85
|
-
|
|
85
|
+
who: 'person',
|
|
86
|
+
assignee: 'owner',
|
|
86
87
|
brief: 'What has to happen and why.',
|
|
87
88
|
done_when: 'What has to be true to call it done.',
|
|
88
89
|
depends: [],
|
|
@@ -93,8 +94,7 @@ export const PING_TEMPLATE = {
|
|
|
93
94
|
type: 'modify',
|
|
94
95
|
task: 'CLD-112',
|
|
95
96
|
horizon: 'next',
|
|
96
|
-
|
|
97
|
-
removeTags: ['agent'],
|
|
97
|
+
who: 'person',
|
|
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.' },
|
|
@@ -161,7 +161,7 @@ export function ownerSaidLines(t) {
|
|
|
161
161
|
.map((l) => ` > ${l}`.trimEnd()),
|
|
162
162
|
);
|
|
163
163
|
out.push(
|
|
164
|
-
` (${q.from}, ${q.by === 'owner' ? 'from the owner' : `quoted by ${q.by}`}${q.at ? `, ${String(q.at).slice(0, 10)}` : ''})`,
|
|
164
|
+
` (${q.from}, ${q.by === 'owner' ? 'from the owner' : q.from === 'board' ? `${q.by}’s own words` : `quoted by ${q.by}`}${q.at ? `, ${String(q.at).slice(0, 10)}` : ''})`,
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
return out;
|