breakaway 1.4.0-main.53 → 1.4.0-main.55
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 +2 -1
- package/scripts/tasks/platform.js +55 -0
- package/scripts/tasks.mjs +39 -10
- package/src/session-report.js +92 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.4.0-main.
|
|
3
|
+
"version": "1.4.0-main.55",
|
|
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",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"src/prompt.js",
|
|
31
31
|
"src/redact.js",
|
|
32
32
|
"src/repos.js",
|
|
33
|
+
"src/session-report.js",
|
|
33
34
|
"src/specs.js",
|
|
34
35
|
"src/versions.js",
|
|
35
36
|
"prompts/*.md",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The systems the install supports (CLI-3, decided in BRK-140): macOS, Linux, and Windows through WSL. The commands
|
|
3
|
+
* that write a secret run `npx wrangler` without a shell, and `setup` runs a `#!/bin/sh` script, so on Windows
|
|
4
|
+
* itself (PowerShell or cmd) they stop before doing anything. Where wrangler can't run, the reason it gives is what's
|
|
5
|
+
* reported, not a guess. Pure, so it's tested without Windows or wrangler.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const WSL_URL = 'https://learn.microsoft.com/windows/wsl/install';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Null on a supported system; on Windows itself, the message that stops `command`.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} command
|
|
14
|
+
* @param {string} platform `process.platform`
|
|
15
|
+
* @returns {string | null}
|
|
16
|
+
*/
|
|
17
|
+
export function unsupportedSystem(command, platform) {
|
|
18
|
+
if (platform !== 'win32') return null;
|
|
19
|
+
return `npx breakaway ${command} doesn't run on Windows itself, so nothing was changed. Install WSL (${WSL_URL}), then run it in WSL's terminal: the board supports macOS, Linux, and Windows through WSL.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Colours and wrangler's box-drawing around its messages. */
|
|
23
|
+
const NOISE = /\u001b\[[0-9;]*m|[│┌┐└┘─╭╮╰╯]/gu;
|
|
24
|
+
|
|
25
|
+
/** Lines that say nothing about the failure: wrangler's banner and npm's warnings. */
|
|
26
|
+
const CHATTER = /^(\S+\s+)?wrangler \d+\.\d+|^npm (warn|notice)|^Update available/iu;
|
|
27
|
+
|
|
28
|
+
/** @param {string | null | undefined} text */
|
|
29
|
+
const meaningful = (text) =>
|
|
30
|
+
(text ?? '')
|
|
31
|
+
.replace(NOISE, '')
|
|
32
|
+
.split('\n')
|
|
33
|
+
.map((line) => line.trim())
|
|
34
|
+
.filter((line) => line && !CHATTER.test(line));
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Why a `spawnSync('npx', ['wrangler', …])` failed, in one line: npx missing or not runnable, else wrangler's own
|
|
38
|
+
* error (its `✘ [ERROR]` line, or its last line of output), else its exit code.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ error?: (Error & { code?: string }) | undefined, status: number | null, signal?: string | null, stdout?: string | null, stderr?: string | null }} res
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function wranglerFailure(res) {
|
|
44
|
+
if (res.error?.code === 'ENOENT')
|
|
45
|
+
return "npx isn't on this machine's PATH; install Node.js 20 or newer, then run this again";
|
|
46
|
+
if (res.error) return `npx couldn't run (${res.error.message})`;
|
|
47
|
+
const stderr = meaningful(res.stderr);
|
|
48
|
+
const stdout = meaningful(res.stdout);
|
|
49
|
+
const error = [...stderr, ...stdout].find((line) => /^✘\s*\[ERROR\]/u.test(line));
|
|
50
|
+
if (error) return `wrangler said: ${error.replace(/^✘\s*\[ERROR\]\s*/u, '')}`;
|
|
51
|
+
const last = (stderr.length ? stderr : stdout).at(-1);
|
|
52
|
+
if (last) return `wrangler said: ${last}`;
|
|
53
|
+
if (res.signal) return `wrangler stopped (${res.signal})`;
|
|
54
|
+
return `wrangler stopped with exit code ${res.status}`;
|
|
55
|
+
}
|
package/scripts/tasks.mjs
CHANGED
|
@@ -39,10 +39,12 @@ import {
|
|
|
39
39
|
textFields,
|
|
40
40
|
} from './tasks/structure.js';
|
|
41
41
|
import { looksLikeSecret } from '../src/ping.js';
|
|
42
|
+
import { sessionReport, shortHash, stubText } from '../src/session-report.js';
|
|
42
43
|
import { promptPathOf } from '../src/repos.js';
|
|
43
44
|
import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
|
|
44
45
|
import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
|
|
45
46
|
import { appInPlace } from './tasks/github-connect.js';
|
|
47
|
+
import { unsupportedSystem, wranglerFailure } from './tasks/platform.js';
|
|
46
48
|
import { checkInstall } from './tasks/install-check.js';
|
|
47
49
|
import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
|
|
48
50
|
import {
|
|
@@ -142,6 +144,12 @@ async function ensureBoardInstall(command, health = null) {
|
|
|
142
144
|
if (result.warning) console.error(`tasks: ${result.warning}`);
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
/** Stops `command` before it does anything on a system the install doesn't support: Windows outside WSL (CLI-3). */
|
|
148
|
+
function ensureSupportedSystem(command) {
|
|
149
|
+
const message = unsupportedSystem(command, process.platform);
|
|
150
|
+
if (message) fail(message);
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
/** A file in this checkout, or null when it isn't there. */
|
|
146
154
|
function readOptional(path) {
|
|
147
155
|
try {
|
|
@@ -623,6 +631,18 @@ async function startSessionLog(t) {
|
|
|
623
631
|
);
|
|
624
632
|
}
|
|
625
633
|
|
|
634
|
+
/**
|
|
635
|
+
* In a cloud session, what its claim tells the board about its environment, so the routine that started it reads
|
|
636
|
+
* Verified on Connections (BRK-142): whether a name was set, where the token came from, and the hash of this
|
|
637
|
+
* checkout's copy of the stub. Never a value. Null outside a cloud session.
|
|
638
|
+
*/
|
|
639
|
+
async function environmentReport() {
|
|
640
|
+
if (process.env.CLAUDE_CODE_REMOTE !== 'true') return null;
|
|
641
|
+
const path = ['tools/tasks/prompts/stub.md', 'prompts/stub.md'].map((p) => join(REPO, p)).find((p) => existsSync(p));
|
|
642
|
+
const stub = path ? await shortHash(stubText(readFileSync(path, 'utf8'))) : null;
|
|
643
|
+
return sessionReport({ env: process.env, file: fileEnv, named: Boolean(opts.as), stub });
|
|
644
|
+
}
|
|
645
|
+
|
|
626
646
|
/** The session hook couldn't post this checkout's live output: say so where the agent and the owner see it (BRK-86). */
|
|
627
647
|
function warnHookFailure() {
|
|
628
648
|
try {
|
|
@@ -1250,10 +1270,12 @@ const commands = {
|
|
|
1250
1270
|
async claim() {
|
|
1251
1271
|
// The board refuses a task of another repository than the one sent (--all doesn't widen a claim).
|
|
1252
1272
|
const { slug: repo } = await checkoutRepo();
|
|
1273
|
+
const session = await environmentReport();
|
|
1253
1274
|
const { task } = await call('POST', `tasks/${enc(need(args[0], 'task'))}/claim`, {
|
|
1254
1275
|
agent: agent(),
|
|
1255
1276
|
force: Boolean(opts.force),
|
|
1256
1277
|
...(repo ? { repo } : {}),
|
|
1278
|
+
...(session ? { session } : {}),
|
|
1257
1279
|
});
|
|
1258
1280
|
markSession(task);
|
|
1259
1281
|
await startSessionLog(task);
|
|
@@ -1539,6 +1561,8 @@ const commands = {
|
|
|
1539
1561
|
/** Every connection with its state and, for each that isn't working, the fix (IDEA-14). Read only. */
|
|
1540
1562
|
async connections() {
|
|
1541
1563
|
const STATE = { working: 'Working', attention: 'Needs attention', off: 'Not connected' };
|
|
1564
|
+
// A routine's row says whether a session it started has reported back (BRK-142).
|
|
1565
|
+
const READING = { verified: 'Verified', unverified: 'Not verified yet' };
|
|
1542
1566
|
const GROUP = {
|
|
1543
1567
|
repos: 'Repositories',
|
|
1544
1568
|
cloudflare: 'Cloudflare',
|
|
@@ -1568,7 +1592,7 @@ const commands = {
|
|
|
1568
1592
|
out.push('', GROUP[group] ?? group);
|
|
1569
1593
|
}
|
|
1570
1594
|
const when = c.at ? ` (${c.at.slice(0, 16).replace('T', ' ')})` : '';
|
|
1571
|
-
out.push(` ${STATE[c.state].padEnd(16)} ${c.name}: ${c.detail}${when}`);
|
|
1595
|
+
out.push(` ${(READING[c.reading] ?? STATE[c.state] ?? c.state).padEnd(16)} ${c.name}: ${c.detail}${when}`);
|
|
1572
1596
|
if (c.fix) out.push(` ${''.padEnd(16)} Fix: ${c.fix}`);
|
|
1573
1597
|
if (c.fix && c.link) out.push(` ${''.padEnd(16)} ${c.link}`);
|
|
1574
1598
|
}
|
|
@@ -1578,6 +1602,7 @@ const commands = {
|
|
|
1578
1602
|
});
|
|
1579
1603
|
},
|
|
1580
1604
|
async setup() {
|
|
1605
|
+
ensureSupportedSystem('setup');
|
|
1581
1606
|
const clientId = setting('CLIENT_ID');
|
|
1582
1607
|
const secret = setting('SECRET');
|
|
1583
1608
|
if (!clientId || !secret)
|
|
@@ -1620,6 +1645,7 @@ const commands = {
|
|
|
1620
1645
|
* values. The new values are saved before the server switches, so they can't be lost.
|
|
1621
1646
|
*/
|
|
1622
1647
|
async 'rotate-sync'() {
|
|
1648
|
+
ensureSupportedSystem('rotate-sync');
|
|
1623
1649
|
const env = readEnvFile();
|
|
1624
1650
|
if (!readSetting('SECRET', { file: env })) fail(`${ENV_FILE} has no sync secret; this is for the owner's machine.`);
|
|
1625
1651
|
const health = await call('GET', 'health');
|
|
@@ -1654,6 +1680,7 @@ const commands = {
|
|
|
1654
1680
|
},
|
|
1655
1681
|
/** Owner: a new API token. Every browser is signed out; cloud environments need the new one. */
|
|
1656
1682
|
async 'rotate-token'() {
|
|
1683
|
+
ensureSupportedSystem('rotate-token');
|
|
1657
1684
|
await ensureBoardInstall('rotate-token');
|
|
1658
1685
|
const env = readEnvFile();
|
|
1659
1686
|
const token = randomBytes(32).toString('base64url');
|
|
@@ -1686,6 +1713,7 @@ const commands = {
|
|
|
1686
1713
|
* the install's ROUTINES secret (BREAKAWAY_ROUTINES with breakaway's prefix), JSON keyed by slug, merged with this machine's copy of the others.
|
|
1687
1714
|
*/
|
|
1688
1715
|
async 'agents-connect'() {
|
|
1716
|
+
ensureSupportedSystem('agents-connect');
|
|
1689
1717
|
const { repos, default: fallback } = await call('GET', 'repos');
|
|
1690
1718
|
const slug = opts.repo ? String(opts.repo).toLowerCase() : fallback;
|
|
1691
1719
|
if (!repos.some((r) => r.slug === slug)) fail(unknownRepo(slug, repos));
|
|
@@ -1706,13 +1734,11 @@ const commands = {
|
|
|
1706
1734
|
fail("that isn't a routine token; generate one in the routine's API trigger.");
|
|
1707
1735
|
if (!others) {
|
|
1708
1736
|
if (!updateSecretsStore({ ROUTINE_URL: url, ROUTINE_TOKEN: token }))
|
|
1709
|
-
fail("couldn't update the Secrets Store (is
|
|
1737
|
+
fail("couldn't update the Secrets Store (the reason is above), so the routine isn't connected.");
|
|
1710
1738
|
} else {
|
|
1711
1739
|
const next = { ...others, [slug]: { url, token } };
|
|
1712
1740
|
if (!updateSecretsStore({ ROUTINES: JSON.stringify(next) }))
|
|
1713
|
-
fail(
|
|
1714
|
-
`couldn't update the Secrets Store (is wrangler logged in, and does ${secretName(installConfig(), 'ROUTINES')} exist?).`,
|
|
1715
|
-
);
|
|
1741
|
+
fail("couldn't update the Secrets Store (the reason is above), so the routine isn't connected.");
|
|
1716
1742
|
writePrivate(ROUTINES_FILE, `${JSON.stringify(next, null, 2)}\n`);
|
|
1717
1743
|
}
|
|
1718
1744
|
console.log(
|
|
@@ -1726,6 +1752,7 @@ const commands = {
|
|
|
1726
1752
|
* the App's ID, key, and webhook secret, and pipe them into the Secrets Store.
|
|
1727
1753
|
*/
|
|
1728
1754
|
async 'github-connect'() {
|
|
1755
|
+
ensureSupportedSystem('github-connect');
|
|
1729
1756
|
const code = need(args[0], 'code');
|
|
1730
1757
|
// Before the code is traded: it works once, and the keys it gives have to go into the board's own secrets.
|
|
1731
1758
|
await ensureBoardInstall('github-connect');
|
|
@@ -2162,8 +2189,10 @@ async function promptAnswers() {
|
|
|
2162
2189
|
async function removeRepo(slug, signer) {
|
|
2163
2190
|
const { default: fallback } = await call('GET', 'repos');
|
|
2164
2191
|
// Dropping a routine this machine connected rewrites the ROUTINES secret: check before anything changes.
|
|
2165
|
-
if (existsSync(ROUTINES_FILE) && JSON.parse(readFileSync(ROUTINES_FILE, 'utf8'))[slug])
|
|
2192
|
+
if (existsSync(ROUTINES_FILE) && JSON.parse(readFileSync(ROUTINES_FILE, 'utf8'))[slug]) {
|
|
2193
|
+
ensureSupportedSystem('repos remove');
|
|
2166
2194
|
await ensureBoardInstall('repos remove');
|
|
2195
|
+
}
|
|
2167
2196
|
const res = await call('DELETE', `repos/${enc(slug)}`, { by: signer, ...(opts.force ? { force: true } : {}) });
|
|
2168
2197
|
const lines = [
|
|
2169
2198
|
`Took ${res.removed.slug} (${res.removed.github}) off the board. Its tasks stay, readable, and its slug and prefixes (${res.removed.areas.map((a) => a.prefix).join(', ')}) stay its own.`,
|
|
@@ -2184,7 +2213,7 @@ async function removeRepo(slug, signer) {
|
|
|
2184
2213
|
delete next[res.removed.slug];
|
|
2185
2214
|
if (!updateSecretsStore({ ROUTINES: JSON.stringify(next) }))
|
|
2186
2215
|
lines.push(
|
|
2187
|
-
`Couldn't drop its routine from the Secrets Store (
|
|
2216
|
+
`Couldn't drop its routine from the Secrets Store (the reason is above); the board doesn't use it any more.`,
|
|
2188
2217
|
);
|
|
2189
2218
|
else {
|
|
2190
2219
|
writePrivate(ROUTINES_FILE, `${JSON.stringify(next, null, 2)}\n`);
|
|
@@ -2266,7 +2295,7 @@ function updateSecretsStore(values) {
|
|
|
2266
2295
|
{ cwd: REPO, encoding: 'utf8' },
|
|
2267
2296
|
);
|
|
2268
2297
|
if (list.status !== 0) {
|
|
2269
|
-
console.error(`tasks: couldn't list the Secrets Store (
|
|
2298
|
+
console.error(`tasks: couldn't list the Secrets Store: ${wranglerFailure(list)}`);
|
|
2270
2299
|
return false;
|
|
2271
2300
|
}
|
|
2272
2301
|
const ids = Object.fromEntries(
|
|
@@ -2286,7 +2315,7 @@ function updateSecretsStore(values) {
|
|
|
2286
2315
|
{ cwd: REPO, encoding: 'utf8', input: value },
|
|
2287
2316
|
);
|
|
2288
2317
|
if (res.status !== 0) {
|
|
2289
|
-
console.error(`tasks: couldn't update ${name} in the Secrets Store`);
|
|
2318
|
+
console.error(`tasks: couldn't update ${name} in the Secrets Store: ${wranglerFailure(res)}`);
|
|
2290
2319
|
ok = false;
|
|
2291
2320
|
}
|
|
2292
2321
|
}
|
|
@@ -2307,7 +2336,7 @@ function updateWorkerSecrets(install, values) {
|
|
|
2307
2336
|
input: value,
|
|
2308
2337
|
});
|
|
2309
2338
|
if (res.status !== 0) {
|
|
2310
|
-
console.error(`tasks: couldn't set ${binding} on the Worker ${install.worker} (
|
|
2339
|
+
console.error(`tasks: couldn't set ${binding} on the Worker ${install.worker}: ${wranglerFailure(res)}`);
|
|
2311
2340
|
ok = false;
|
|
2312
2341
|
}
|
|
2313
2342
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cloud session's report on its own environment (BRK-142, docs/specs/IDEA-33-onboarding-hardening.md): the CLI
|
|
3
|
+
* sends it with a board-started agent's claim, and the routine that started the session reads Verified on
|
|
4
|
+
* Connections. Only yes/no facts and a hash of a public file are sent, never a value: whether a name was set,
|
|
5
|
+
* where the token came from, and the sha-256 of the checkout's copy of the stub. Pure, so it's tested without a
|
|
6
|
+
* Worker; the CLI builds a report with `sessionReport`, the board checks it with `checkReport`
|
|
7
|
+
* and `judgeStub` when it arrives, and reads it back with `reportProblems`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Where the session's token came from: the cloud environment's API credential, a variable, or a tasks.env file. */
|
|
11
|
+
export const TOKEN_SOURCES = Object.freeze(['credential', 'variable', 'file']);
|
|
12
|
+
|
|
13
|
+
/** The stub's text the way it's hashed on both sides: line endings and trailing space don't count. */
|
|
14
|
+
export const stubText = (text) => String(text).replace(/\r\n/gu, '\n').trim();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The report a cloud session's CLI sends with its claim, or null outside a cloud session. `env` is the environment,
|
|
18
|
+
* `file` the tasks.env settings, `named` whether --as named the agent, and `stub` the hash of the checkout's
|
|
19
|
+
* stub (null when the checkout has none).
|
|
20
|
+
* @param {{ env: Record<string, string | undefined>, file?: Record<string, string>, named?: boolean, stub?: string | null }} input
|
|
21
|
+
*/
|
|
22
|
+
export function sessionReport({ env, file = {}, named = false, stub = null }) {
|
|
23
|
+
if (env.CLAUDE_CODE_REMOTE !== 'true') return null;
|
|
24
|
+
const token = env.BREAKAWAY_TOKEN ? 'variable' : file.BREAKAWAY_TOKEN ? 'file' : 'credential';
|
|
25
|
+
return { token, agent: Boolean(named || env.BREAKAWAY_AGENT || file.BREAKAWAY_AGENT), stub: stub ?? null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The report as the board keeps it, or null when it isn't one: only the known facts, so nothing else a client
|
|
30
|
+
* sends is ever stored.
|
|
31
|
+
* @returns {{ token: string, agent: boolean, stub: string | null } | null}
|
|
32
|
+
*/
|
|
33
|
+
export function checkReport(input) {
|
|
34
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
35
|
+
if (!TOKEN_SOURCES.includes(input.token) || typeof input.agent !== 'boolean') return null;
|
|
36
|
+
const stub = typeof input.stub === 'string' && /^[0-9a-f]{16}$/u.test(input.stub) ? input.stub : null;
|
|
37
|
+
return { token: input.token, agent: input.agent, stub };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The checkout's stub against the board's, both hashes: `same`, `different`, or `missing` (the checkout has none),
|
|
42
|
+
* and null when the board has no copy to compare with, so the stub isn't judged.
|
|
43
|
+
* @returns {'same' | 'different' | 'missing' | null}
|
|
44
|
+
*/
|
|
45
|
+
export function judgeStub(stub, boardStub) {
|
|
46
|
+
if (!boardStub) return null;
|
|
47
|
+
if (!stub) return 'missing';
|
|
48
|
+
return stub === boardStub ? 'same' : 'different';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What's wrong in a kept report, each with its fix: no agent name, the token in a variable or a file instead of
|
|
53
|
+
* the API credential, and a stub that's missing from the checkout or differs from the board's (`stub`, judgeStub's
|
|
54
|
+
* verdict). `slug` names the repository and `host` the board in the fixes.
|
|
55
|
+
* @param {{ token: string, agent: boolean, stub: string | null }} report
|
|
56
|
+
* @param {{ slug: string, host: string }} board
|
|
57
|
+
* @returns {{ what: string, fix: string }[]}
|
|
58
|
+
*/
|
|
59
|
+
export function reportProblems(report, { slug, host }) {
|
|
60
|
+
const out = [];
|
|
61
|
+
if (!report.agent)
|
|
62
|
+
out.push({
|
|
63
|
+
what: 'it had no agent name (BREAKAWAY_AGENT)',
|
|
64
|
+
fix: 'Make the routine’s instructions the stub from the Agents view (Copy stub): it sends each agent to the prompt that sets BREAKAWAY_AGENT to the name the board gave it.',
|
|
65
|
+
});
|
|
66
|
+
if (report.token !== 'credential')
|
|
67
|
+
out.push({
|
|
68
|
+
what:
|
|
69
|
+
report.token === 'variable'
|
|
70
|
+
? 'it read the board’s token from BREAKAWAY_TOKEN, a variable anyone using the environment can see'
|
|
71
|
+
: 'it read the board’s token from a tasks.env file',
|
|
72
|
+
fix: `In the routine’s cloud environment on claude.ai, add the token as an API credential for ${host} and remove BREAKAWAY_TOKEN: the proxy then adds it, and the token never enters the session.`,
|
|
73
|
+
});
|
|
74
|
+
if (report.stub === 'different' || report.stub === 'missing')
|
|
75
|
+
out.push({
|
|
76
|
+
what:
|
|
77
|
+
report.stub === 'different'
|
|
78
|
+
? 'its checkout’s copy of the stub differs from the board’s'
|
|
79
|
+
: 'its checkout has no copy of the stub',
|
|
80
|
+
fix: `Bring ${slug}’s copy of the board’s files up to date (npx breakaway repos init ${slug} --update opens a pull request; in the board’s own repository, update the board to the same release), then paste the stub from the Agents view (Copy stub) as the routine’s instructions.`,
|
|
81
|
+
});
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The first 16 hex digits of the sha-256 of `text`, with Web Crypto (the Worker and Node alike). */
|
|
86
|
+
export async function shortHash(text) {
|
|
87
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
88
|
+
return [...new Uint8Array(digest)]
|
|
89
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
90
|
+
.join('')
|
|
91
|
+
.slice(0, 16);
|
|
92
|
+
}
|