breakaway 1.4.0-main.2 → 1.4.0-main.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.4.0-main.2",
3
+ "version": "1.4.0-main.20",
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",
@@ -21,13 +21,16 @@
21
21
  "!scripts/install/*.test.js",
22
22
  "src/cli-version.js",
23
23
  "src/decision.js",
24
+ "src/init.js",
24
25
  "src/install.js",
25
26
  "src/model.js",
27
+ "src/packages.js",
26
28
  "src/ping.js",
27
29
  "src/promote.js",
28
30
  "src/prompt.js",
29
31
  "src/redact.js",
30
32
  "src/repos.js",
33
+ "src/specs.js",
31
34
  "src/versions.js",
32
35
  "prompts/*.md",
33
36
  "taskrc",
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Writes src/board-files.json (BRK-132): the board's files repos init reads (boardSources in src/init.js), by path,
4
+ * so the Worker renders an empty repository's first commit from the same files as the CLI. scripts/tasks/init.test.js
5
+ * fails when it's behind them; run `node scripts/board-files.mjs` after changing one.
6
+ */
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+ import { boardSources } from '../src/init.js';
9
+
10
+ const ROOT = new URL('../', import.meta.url);
11
+ const read = (path) => readFileSync(new URL(path, ROOT), 'utf8');
12
+
13
+ export const boardFiles = () => Object.fromEntries(boardSources(read).map((path) => [path, read(path)]));
14
+
15
+ if (import.meta.url === `file://${process.argv[1]}`) {
16
+ writeFileSync(new URL('src/board-files.json', ROOT), `${JSON.stringify(boardFiles(), null, 2)}\n`);
17
+ console.log('Wrote src/board-files.json.');
18
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The steps a repository's Deploy, Promote, Roll back, and Release workflows run before they act
4
+ * (scripts/lib/deploy-plan.js). Each prints key=value lines for $GITHUB_OUTPUT and its reason on stderr.
5
+ * node scripts/deploy-plan.mjs checks --sha <commit> --checks '["CI"]'
6
+ * ready=true when every check workflow passed on the commit
7
+ * node scripts/deploy-plan.mjs plan --staging <worker> --sha <commit> --checks '["CI"]' [--branch main] [--paths <file>]
8
+ * deploy=true when staging should deploy the commit, and from=<the commit staging ran before>
9
+ * node scripts/deploy-plan.mjs live --environment <worker> sha=<the commit it runs>
10
+ * node scripts/deploy-plan.mjs sha-of --environment <worker> --version <id> sha=<the commit that version came from>
11
+ * node scripts/deploy-plan.mjs current < deployments.json version=<what wrangler deployments list runs>
12
+ * node scripts/deploy-plan.mjs uploaded < wrangler-output.ndjson version=<what wrangler just uploaded>
13
+ * node scripts/deploy-plan.mjs missing < wrangler-error.txt exits 1 unless the Worker doesn't exist yet
14
+ * Reads GITHUB_TOKEN and GITHUB_REPOSITORY (or --repo owner/name). Copied into a repository by `repos init`.
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import { readFileSync } from 'node:fs';
18
+ import { parseArgs } from 'node:util';
19
+ import {
20
+ checksPassed,
21
+ currentVersionId,
22
+ deployPatterns,
23
+ liveSha,
24
+ planDeploy,
25
+ shaOfVersion,
26
+ uploadedVersionId,
27
+ workerMissing,
28
+ } from './lib/deploy-plan.js';
29
+ import { USER_AGENT, deploymentsOf } from './lib/deployments.js';
30
+
31
+ const { positionals, values: o } = parseArgs({
32
+ allowPositionals: true,
33
+ options: {
34
+ repo: { type: 'string' },
35
+ sha: { type: 'string' },
36
+ checks: { type: 'string' },
37
+ staging: { type: 'string' },
38
+ branch: { type: 'string', default: 'main' },
39
+ paths: { type: 'string' },
40
+ environment: { type: 'string' },
41
+ version: { type: 'string' },
42
+ },
43
+ });
44
+ const token = process.env.GITHUB_TOKEN;
45
+ const repo = o.repo ?? process.env.GITHUB_REPOSITORY;
46
+ const stdin = () => readFileSync(0, 'utf8');
47
+
48
+ /** @returns {Promise<any>} */
49
+ async function get(path) {
50
+ const res = await fetch(`https://api.github.com/repos/${repo}${path}`, {
51
+ headers: {
52
+ accept: 'application/vnd.github+json',
53
+ authorization: `Bearer ${token}`,
54
+ 'user-agent': USER_AGENT,
55
+ 'x-github-api-version': '2022-11-28',
56
+ },
57
+ });
58
+ if (!res.ok) throw new Error(`GitHub answered ${res.status} for ${path}: ${(await res.text()).slice(0, 200)}`);
59
+ return res.json();
60
+ }
61
+
62
+ async function checks() {
63
+ if (!/^[0-9a-f]{40}$/u.test(o.sha ?? '')) throw new Error('Give the full commit: --sha <commit>.');
64
+ let names;
65
+ try {
66
+ names = JSON.parse(o.checks ?? '');
67
+ } catch {
68
+ names = null;
69
+ }
70
+ if (!Array.isArray(names) || !names.length) throw new Error('Name the check workflows as JSON: --checks \'["CI"]\'.');
71
+ const { workflow_runs: runs } = await get(`/actions/runs?head_sha=${o.sha}&per_page=100`);
72
+ return checksPassed(runs, names, o.sha);
73
+ }
74
+
75
+ /** The files changed between two commits, or null when git can't say (the older one isn't in this checkout). */
76
+ function changedFiles(from, to) {
77
+ try {
78
+ return execFileSync('git', ['diff', '--name-only', from, to], { encoding: 'utf8' }).split('\n').filter(Boolean);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ try {
85
+ const [command] = positionals;
86
+ if (command === 'checks') {
87
+ const result = await checks();
88
+ console.error(result.reason);
89
+ console.log(`ready=${result.ready}`);
90
+ } else if (command === 'plan') {
91
+ if (!o.staging) throw new Error('Name the staging Worker: --staging <worker>.');
92
+ const ready = await checks();
93
+ const [tip, staging] = ready.ready
94
+ ? await Promise.all([
95
+ get(`/commits/${encodeURIComponent(o.branch)}`),
96
+ deploymentsOf({ token, repo, environment: o.staging }),
97
+ ])
98
+ : [null, []];
99
+ const from = staging.find((d) => d.task === 'deploy' && d.state === 'success')?.sha;
100
+ const patterns = o.paths ? deployPatterns(JSON.parse(readFileSync(o.paths, 'utf8'))) : [];
101
+ const plan = planDeploy({
102
+ sha: o.sha,
103
+ tip: tip?.sha ?? null,
104
+ checks: ready,
105
+ staging,
106
+ files: from ? changedFiles(from, o.sha) : null,
107
+ patterns,
108
+ });
109
+ console.error(plan.reason);
110
+ console.log(`deploy=${plan.deploy}\nfrom=${plan.from}`);
111
+ } else if (command === 'live' || command === 'sha-of') {
112
+ if (!o.environment) throw new Error('Name the Worker: --environment <worker>.');
113
+ const list = await deploymentsOf({ token, repo, environment: o.environment });
114
+ console.log(`sha=${(command === 'live' ? liveSha(list) : shaOfVersion(list, o.version)) ?? ''}`);
115
+ } else if (command === 'current') {
116
+ console.log(`version=${currentVersionId(JSON.parse(stdin() || '[]')) ?? ''}`);
117
+ } else if (command === 'uploaded') {
118
+ const version = uploadedVersionId(stdin());
119
+ if (!version) throw new Error("wrangler's output names no version it uploaded.");
120
+ console.log(`version=${version}`);
121
+ } else if (command === 'missing') {
122
+ if (!workerMissing(stdin()))
123
+ throw new Error(
124
+ "Couldn't list the Worker's deployments, so there would be nothing to roll back to. Check that CLOUDFLARE_ACCOUNT_ID is your account's ID and that CLOUDFLARE_API_TOKEN can read and edit the Worker.",
125
+ );
126
+ console.log('missing=true');
127
+ } else {
128
+ throw new Error(
129
+ 'Usage: deploy-plan.mjs checks | plan | live | sha-of | current | uploaded | missing (see the file)',
130
+ );
131
+ }
132
+ } catch (error) {
133
+ console.error(error.message);
134
+ process.exit(1);
135
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What a repository's Deploy, Promote, Roll back, and Release workflows decide before they act
3
+ * (docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md, section 2): whether every check passed on the commit,
4
+ * whether staging needs it, and which Worker version is which. Pure over GitHub's and wrangler's answers, so the
5
+ * tests run without either. Copied into a repository by `repos init`, with scripts/deploy-plan.mjs.
6
+ */
7
+ import { candidate, productionSha, versionOf } from './promote.js';
8
+
9
+ /**
10
+ * Whether every check workflow passed on `sha`. `runs` are GitHub's workflow runs for the commit
11
+ * (`{ id, name, head_sha, status, conclusion }`); each check counts by its latest run.
12
+ * @param {Array<{ id: number, name: string, head_sha: string, status: string, conclusion: string | null }>} runs
13
+ * @param {string[]} checks the check workflows' names
14
+ * @param {string} sha
15
+ * @returns {{ ready: boolean, reason: string }}
16
+ */
17
+ export function checksPassed(runs, checks, sha) {
18
+ const latest = new Map();
19
+ for (const run of runs ?? []) {
20
+ if (run.head_sha !== sha || !checks.includes(run.name)) continue;
21
+ if (!latest.has(run.name) || latest.get(run.name).id < run.id) latest.set(run.name, run);
22
+ }
23
+ const short = sha.slice(0, 7);
24
+ const missing = checks.filter((name) => !latest.has(name));
25
+ if (missing.length) return { ready: false, reason: `${missing.join(', ')} hasn't run on ${short} yet.` };
26
+ const failed = checks.filter(
27
+ (name) => latest.get(name).status === 'completed' && latest.get(name).conclusion !== 'success',
28
+ );
29
+ if (failed.length) return { ready: false, reason: `${failed.join(', ')} didn't pass on ${short}.` };
30
+ const running = checks.filter((name) => latest.get(name).status !== 'completed');
31
+ if (running.length)
32
+ return { ready: false, reason: `${running.join(', ')} is still running on ${short}; its own run deploys it.` };
33
+ return { ready: true, reason: `${checks.join(', ')} passed on ${short}.` };
34
+ }
35
+
36
+ /**
37
+ * Whether staging should deploy `sha`. `tip` is the branch's latest commit, `staging` the staging Worker's
38
+ * Deployments (newest first, as deploymentsOf reads them), `files` the paths changed since staging's last successful
39
+ * deploy (null when they can't be known), and `patterns` the deploy paths' regular expressions.
40
+ * @returns {{ deploy: boolean, from: string, reason: string }}
41
+ */
42
+ export function planDeploy({ sha, tip, checks, staging, files, patterns }) {
43
+ const short = sha.slice(0, 7);
44
+ const no = (reason, from = '') => ({ deploy: false, from, reason });
45
+ if (!checks.ready) return no(checks.reason);
46
+ if (tip && tip !== sha)
47
+ return no(`${short} isn't the branch's latest commit any more: ${tip.slice(0, 7)} deploys next, with it.`);
48
+ const last = candidate(staging ?? []);
49
+ const from = last?.sha ?? '';
50
+ if (from === sha) return no(`Staging already runs ${short}.`, from);
51
+ if (!from) return { deploy: true, from, reason: `Staging has no deploy yet, so ${short} goes first.` };
52
+ if (files && patterns?.length && !files.some((file) => patterns.some((pattern) => pattern.test(file))))
53
+ return no(`Nothing since ${from.slice(0, 7)} touches the deploy paths, so staging stays as it is.`, from);
54
+ return { deploy: true, from, reason: `${short} goes to staging, after ${from.slice(0, 7)}.` };
55
+ }
56
+
57
+ /** The deploy paths file (`{ worker: "regex" }`) as regular expressions; a pattern that doesn't compile is an error. */
58
+ export function deployPatterns(json) {
59
+ if (!json || typeof json !== 'object' || Array.isArray(json))
60
+ throw new Error('The deploy paths file is an object of Worker names and regular expressions.');
61
+ return Object.entries(json).map(([worker, pattern]) => {
62
+ try {
63
+ return new RegExp(String(pattern), 'u');
64
+ } catch {
65
+ throw new Error(`The deploy path for ${worker} isn't a regular expression: ${pattern}`);
66
+ }
67
+ });
68
+ }
69
+
70
+ /** The version a Worker runs, from `wrangler deployments list --json`: what a rollback goes back to. */
71
+ export function currentVersionId(deployments) {
72
+ const list = Array.isArray(deployments) ? deployments : [];
73
+ const newest = [...list].sort((a, b) => String(b.created_on ?? '').localeCompare(String(a.created_on ?? '')))[0];
74
+ const best = [...(newest?.versions ?? [])].sort((a, b) => (b.percentage ?? 0) - (a.percentage ?? 0))[0];
75
+ return best?.version_id ?? null;
76
+ }
77
+
78
+ /** The version wrangler just uploaded or deployed, from its output file (WRANGLER_OUTPUT_FILE_PATH, one JSON a line). */
79
+ export function uploadedVersionId(ndjson) {
80
+ const entries = String(ndjson ?? '')
81
+ .split('\n')
82
+ .filter((line) => line.trim())
83
+ .flatMap((line) => {
84
+ try {
85
+ return [JSON.parse(line)];
86
+ } catch {
87
+ return [];
88
+ }
89
+ });
90
+ return entries.filter((e) => ['version-upload', 'deploy'].includes(e.type) && e.version_id).pop()?.version_id ?? null;
91
+ }
92
+
93
+ /** Whether wrangler's output from a failed `deployments list` says the Worker doesn't exist yet (Cloudflare error 10007). */
94
+ export const workerMissing = (output) =>
95
+ /\[code: 10007\]|worker does not exist on your account/iu.test(String(output ?? ''));
96
+
97
+ /** The commit an environment runs now: its latest successful deploy or rollback. */
98
+ export const liveSha = (deployments) => productionSha(deployments ?? []);
99
+
100
+ /** The commit a Worker version was deployed from, by the Deployments that recorded it. */
101
+ export const shaOfVersion = (deployments, version) =>
102
+ (deployments ?? []).find((d) => d.state === 'success' && versionOf(d.description) === version)?.sha ?? null;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The version numbers of a repository's npm releases (docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md, section
3
+ * 2b), worked out the way breakaway's own Release workflow does (scripts/release/lib.js uses these): every merge
4
+ * stages `X.Y.Z-main.N` on next, and the owner promotes one to a stable `X.Y.Z` on latest. The tags remember the
5
+ * numbers: `<prefix>X.Y.Z-main.N` for each pre-release and `<prefix>X.Y.Z` for each stable, where the prefix is `v`,
6
+ * or `<package>@` in a repository whose production deploys already tag `v…`. Pure, so it's tested without git.
7
+ * Copied into a repository by `repos init`, with scripts/package-release.mjs.
8
+ */
9
+
10
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/u;
11
+ const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
12
+ const prereleasePattern = (prefix) => new RegExp(`^${escapeRegex(prefix)}(\\d+\\.\\d+\\.\\d+)-main\\.(\\d+)$`, 'u');
13
+ const stablePattern = (prefix) => new RegExp(`^${escapeRegex(prefix)}(\\d+\\.\\d+\\.\\d+)$`, 'u');
14
+
15
+ /** @param {string} v @returns {[number, number, number]} */
16
+ function parts(v) {
17
+ const m = SEMVER.exec(v);
18
+ if (!m) throw new Error(`"${v}" isn't a version like 1.4.0.`);
19
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
20
+ }
21
+
22
+ /** Negative, zero, or positive, like a sort comparator, for two X.Y.Z versions. */
23
+ export function compareVersions(a, b) {
24
+ const [x, y] = [parts(a), parts(b)];
25
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
26
+ }
27
+
28
+ /** The tags' prefix: `v`, or `<package>@` when the repository's production deploys already tag `v…`. */
29
+ export const tagPrefix = (name, { workers = false } = {}) => (workers ? `${name}@` : 'v');
30
+
31
+ /**
32
+ * The next pre-release. `current` is package.json's version, the release being worked toward; once its stable tag
33
+ * exists the work is toward the next patch, so a pre-release never sorts below a stable.
34
+ * @param {string} current
35
+ * @param {string[]} tags every tag in the repository
36
+ * @param {string} [prefix]
37
+ * @returns {{ version: string, base: string, tag: string }}
38
+ */
39
+ export function nextPrerelease(current, tags, prefix = 'v') {
40
+ parts(current);
41
+ const stable = stablePattern(prefix);
42
+ const prerelease = prereleasePattern(prefix);
43
+ const stables = tags.map((t) => stable.exec(t)?.[1]).filter(Boolean);
44
+ let base = current;
45
+ for (const s of stables) {
46
+ if (compareVersions(s, base) >= 0) {
47
+ const [maj, min, pat] = parts(s);
48
+ base = `${maj}.${min}.${pat + 1}`;
49
+ }
50
+ }
51
+ const last = tags
52
+ .map((t) => prerelease.exec(t))
53
+ .filter((m) => m?.[1] === base)
54
+ .reduce((n, m) => Math.max(n, Number(m[2])), 0);
55
+ const version = `${base}-main.${last + 1}`;
56
+ return { version, base, tag: `${prefix}${version}` };
57
+ }
58
+
59
+ /** The pre-release among `tags` (the tags on one commit), if one was already staged from it. */
60
+ export function prereleaseAmong(tags, prefix = 'v') {
61
+ const prerelease = prereleasePattern(prefix);
62
+ const tag = tags.find((t) => prerelease.test(t));
63
+ return tag ? { tag, version: tag.slice(prefix.length) } : null;
64
+ }
65
+
66
+ /** The stable version a pre-release tag (`v1.4.0-main.37`) is promoted to. */
67
+ export function stableOf(prereleaseTag, prefix = 'v') {
68
+ const m = prereleasePattern(prefix).exec(prereleaseTag);
69
+ if (!m) throw new Error(`"${prereleaseTag}" isn't a pre-release tag like ${prefix}1.4.0-main.37.`);
70
+ return m[1];
71
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Release workflow's version numbers for a repository's npm package (scripts/lib/package-release.js). Prints
4
+ * key=value lines for $GITHUB_OUTPUT.
5
+ * node scripts/package-release.mjs prerelease --dir <path> --prefix <tag prefix> [--sha <commit>]
6
+ * the next pre-release's version and tag, from <path>/package.json and the repository's tags; with --sha,
7
+ * exists=true when that commit already has a pre-release (a second run for one merge stages nothing)
8
+ * node scripts/package-release.mjs stable <pre-release tag> --prefix <tag prefix>
9
+ * the stable version and tag it becomes, and the commit it was staged from; stops if that stable exists
10
+ * Reads the tags with git, so run it in a checkout with its tags (fetch-depth: 0). Copied by `repos init`.
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { parseArgs } from 'node:util';
16
+ import { nextPrerelease, prereleaseAmong, stableOf } from './lib/package-release.js';
17
+
18
+ const { positionals, values: o } = parseArgs({
19
+ allowPositionals: true,
20
+ options: { dir: { type: 'string', default: '.' }, prefix: { type: 'string', default: 'v' }, sha: { type: 'string' } },
21
+ });
22
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).split('\n').filter(Boolean);
23
+
24
+ try {
25
+ const [command, tag] = positionals;
26
+ if (command === 'prerelease') {
27
+ const current = JSON.parse(readFileSync(join(o.dir, 'package.json'), 'utf8')).version;
28
+ const staged = o.sha ? prereleaseAmong(git('tag', '--points-at', o.sha), o.prefix) : null;
29
+ if (staged) {
30
+ console.error(`${o.sha.slice(0, 7)} already has a pre-release, ${staged.tag}, so this run stages nothing.`);
31
+ console.log(`version=${staged.version}\ntag=${staged.tag}\nexists=true`);
32
+ } else {
33
+ const next = nextPrerelease(current, git('tag', '-l'), o.prefix);
34
+ console.log(`version=${next.version}\ntag=${next.tag}\nexists=false`);
35
+ }
36
+ } else if (command === 'stable' && tag) {
37
+ const version = stableOf(tag, o.prefix);
38
+ const tags = git('tag', '-l');
39
+ if (!tags.includes(tag)) throw new Error(`There is no pre-release ${tag}. Pick one the Release workflow staged.`);
40
+ if (tags.includes(`${o.prefix}${version}`))
41
+ throw new Error(`${o.prefix}${version} is already released. Pick a newer pre-release.`);
42
+ const [commit] = git('rev-list', '-n', '1', `refs/tags/${tag}`);
43
+ console.log(`version=${version}\ntag=${o.prefix}${version}\ncommit=${commit}`);
44
+ } else {
45
+ throw new Error(
46
+ 'Usage: package-release.mjs prerelease --dir <path> --prefix <p> [--sha <commit>] | stable <tag> --prefix <p>',
47
+ );
48
+ }
49
+ } catch (error) {
50
+ console.error(error.message);
51
+ process.exit(1);
52
+ }
@@ -14,6 +14,7 @@ export const SUBCOMMANDS = {
14
14
  horizon: ['close'],
15
15
  hook: ['session', 'wait'],
16
16
  peloton: ['checkin', 'step', 'reply'],
17
+ specs: ['list', 'show'],
17
18
  };
18
19
 
19
20
  /** Commands that take nothing after their name, so a word there is a mistake (an old copy's missing subcommand, say). */
@@ -104,6 +105,21 @@ export function pullAgentRequest(action, number, { repo = null, problem, note, f
104
105
  return { request: ['POST', `github/pulls/${n}/${action}`, body] };
105
106
  }
106
107
 
108
+ /**
109
+ * `npx breakaway github release <pre-release>` (BRK-103): the owner releases a package's pre-release as its stable, as
110
+ * Release on the GitHub page does. The board starts the repository's release.yml stable job, and npm waits for the
111
+ * owner's 2FA; it refuses an agent, so the request always says who asks.
112
+ * @param {string | undefined} version the pre-release, like 1.4.0-main.5 (or its tag)
113
+ * @param {{ repo?: string | null, by?: string }} [options]
114
+ * @returns {{ error?: string, request?: [string, string, Record<string, string>] }}
115
+ */
116
+ export function packageReleaseRequest(version, { repo = null, by } = {}) {
117
+ const v = String(version ?? '').trim();
118
+ if (!/^(?:\S+@|v)?\d+\.\d+\.\d+-main\.\d+$/u.test(v))
119
+ return { error: 'say which pre-release: npx breakaway github release <version>, like 1.4.0-main.5' };
120
+ return { request: ['POST', 'github/release', { version: v, ...(repo ? { repo } : {}), ...(by ? { by } : {}) }] };
121
+ }
122
+
107
123
  export const REVIEW_VERDICTS = ['ready', 'follow-up', 'changes'];
108
124
 
109
125
  /**
@@ -148,17 +164,27 @@ export function forceFields(force, by) {
148
164
  * With `decision` (`agents new --decision <ID> ["<note>"]`, BRK-110) the board writes the prompt from that answered
149
165
  * decision, in the decision's repository, and the text is the owner's note under it. With `next`
150
166
  * (`agents new --next minor|major ["<note>"]`, BRK-100) it writes the prompt that sets the repository's next version.
167
+ * With `spec` (`agents new --spec <path> "<what should change>"`, BRK-121) it writes the prompt that refines that spec
168
+ * and the tasks that link it, and the text, required, is what should change.
151
169
  * @param {string} prompt
152
- * @param {{ repo?: string | null, force?: boolean, by?: string, decision?: string | null, next?: string | null }} [options]
170
+ * @param {{ repo?: string | null, force?: boolean, by?: string, decision?: string | null, next?: string | null, spec?: string | null }} [options]
153
171
  */
154
- export function generalAgentRequest(prompt, { repo = null, force = false, by, decision = null, next = null } = {}) {
172
+ export function generalAgentRequest(
173
+ prompt,
174
+ { repo = null, force = false, by, decision = null, next = null, spec = null } = {},
175
+ ) {
155
176
  const text = String(prompt ?? '').trim();
156
- if (decision && next) return { error: 'start one from --decision or --next, not both' };
177
+ if ([decision, next, spec].filter(Boolean).length > 1)
178
+ return { error: 'start one from --decision, --spec, or --next: only one of them' };
157
179
  if (next && !['minor', 'major'].includes(next))
158
180
  return { error: 'patches count by themselves: --next minor or --next major' };
181
+ if (spec && !text)
182
+ return {
183
+ error: 'say what should change in the spec: npx breakaway agents new --spec <path> "<what should change>"',
184
+ };
159
185
  if (!text && !decision && !next)
160
186
  return { error: 'say what the agent should do: npx breakaway agents new "Tidy the docs" [--image <file>]' };
161
- const board = decision ? { decision } : next ? { next } : null;
187
+ const board = decision ? { decision } : next ? { next } : spec ? { spec: specPath(spec) } : null;
162
188
  const body = {
163
189
  ...(board ? { ...board, ...(text ? { note: text } : {}) } : { prompt: text }),
164
190
  ...(repo ? { repo } : {}),
@@ -170,18 +196,54 @@ export function generalAgentRequest(prompt, { repo = null, force = false, by, de
170
196
 
171
197
  /**
172
198
  * What the CLI says about a general agent's answer: the task and that it started, or why it waits (and whether Force
173
- * start could skip that), or, from a decision or for the next version (`next`), the open one that already has it.
199
+ * start could skip that), or, from a decision, for the next version (`next`), or on a spec (`spec`), the open one
200
+ * that already has it.
174
201
  * @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string } | null, waiting?: string | null, forceable?: boolean, already?: string | null }} answer
175
- * @param {{ next?: string | null }} [options]
202
+ * @param {{ next?: string | null, spec?: unknown }} [options]
176
203
  */
177
- export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null } = {}) {
204
+ export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null, spec = null } = {}) {
178
205
  const id = task.wid ?? task.short;
179
- if (!run && already)
180
- return `${id} already ${next ? 'prepares the next version' : 'refines from these answers'}: ${already}.`;
206
+ if (!run && already) {
207
+ const what = next ? 'prepares the next version' : spec ? 'refines this spec' : 'refines from these answers';
208
+ return `${id} already ${what}: ${already}.`;
209
+ }
181
210
  if (run) return `Started ${run.agent ? `${run.agent} ` : 'an agent '}on ${id}${run.url ? `: ${run.url}` : ''}`;
182
211
  return `Saved ${id}, waiting to start: ${waiting ?? 'no room yet'}.${forceable ? ` Start it now past the board's limits: npx breakaway agents start ${id} --force` : ''}`;
183
212
  }
184
213
 
214
+ /** A spec's path as the board reads it: no leading `./`, no doubled or trailing slashes. */
215
+ const specPath = (path) =>
216
+ String(path ?? '')
217
+ .trim()
218
+ .replace(/^(\.\/)+/u, '')
219
+ .split('/')
220
+ .filter((part) => part && part !== '.')
221
+ .join('/');
222
+
223
+ /**
224
+ * The request behind `npx breakaway specs [list]` (BRK-121): the specs of the checkout's repository, or the one `--repo`
225
+ * names; without either, the board answers with its default repository's.
226
+ * @param {string | null} repo
227
+ * @returns {[string, string, undefined]}
228
+ */
229
+ export function specsRequest(repo) {
230
+ return ['GET', repo ? `specs?repo=${encodeURIComponent(repo)}` : 'specs', undefined];
231
+ }
232
+
233
+ /**
234
+ * The request behind `npx breakaway specs show <path>` (BRK-121): one spec, by its path in the repository. The board
235
+ * refuses a path outside the specs directory; one that climbs out with `..` is refused here first.
236
+ * @param {string | undefined} path
237
+ * @param {string | null} repo
238
+ */
239
+ export function specRequest(path, repo) {
240
+ const clean = specPath(path);
241
+ if (!clean) return { error: 'say which spec: npx breakaway specs show <path>, like docs/specs/BRK-1-thing.md' };
242
+ if (clean.split('/').includes('..')) return { error: `${clean.slice(0, 200)} climbs out of the repository` };
243
+ const query = repo ? `?repo=${encodeURIComponent(repo)}` : '';
244
+ return { request: ['GET', `specs/${clean.split('/').map(encodeURIComponent).join('/')}${query}`, undefined] };
245
+ }
246
+
185
247
  /**
186
248
  * What the CLI says about an answer to those requests: which task and agent took the pull request, or who already has it.
187
249
  * @param {'fix' | 'review'} action
@@ -409,3 +471,64 @@ export function chaseSummary(slug, { dryRun, chase, started = [], wouldStart = [
409
471
  }
410
472
  return [first, '', ...chaseLines(chase, slug)].join('\n');
411
473
  }
474
+
475
+ /** A spec's tasks in a few words: "3 tasks, 2 open", or "no tasks". */
476
+ const specTaskCount = (tasks = []) => {
477
+ if (!tasks.length) return 'no tasks';
478
+ const open = tasks.filter((t) => t.status === 'pending').length;
479
+ return `${plural(tasks.length, 'task')}, ${open} open`;
480
+ };
481
+
482
+ /**
483
+ * What `npx breakaway specs` prints: the repository's specs newest first, each with its work ID, status, title, and its
484
+ * tasks' count; with none, where specs go and how to point the board at another directory.
485
+ * @param {{ slug: string, dir: string, missing?: boolean, readme?: { path: string } | null, specs: any[] }} answer
486
+ */
487
+ export function specListLines({ slug, dir, missing, readme, specs }) {
488
+ if (!specs.length)
489
+ return [
490
+ `No specs in ${dir} yet${missing ? `: ${slug} has no ${dir} on its default branch` : ''}.`,
491
+ 'A spec is a Markdown file in that directory, merged like any change.',
492
+ `If ${slug} keeps its specs somewhere else, the owner sets it with npx breakaway repos modify ${slug} --specs <dir>.`,
493
+ ];
494
+ const intro = readme ? ` (its introduction is ${readme.path})` : '';
495
+ const out = [`${slug}: ${plural(specs.length, 'spec')} in ${dir}${intro}`, ''];
496
+ for (const s of specs) {
497
+ const extra = s.tooLarge ? ', over 1 MB: read it on GitHub' : '';
498
+ out.push(
499
+ ` ${(s.wid ?? '').padEnd(9)} ${(s.status ?? '-').padEnd(10)} ${s.title} (${specTaskCount(s.tasks)}${extra})`,
500
+ );
501
+ }
502
+ out.push('', `Read one: npx breakaway specs show <path>, like ${specs[0].path}`);
503
+ return out;
504
+ }
505
+
506
+ /**
507
+ * What `npx breakaway specs show <path>` prints: the spec's title and path, status, the commit that last changed it,
508
+ * its GitHub link, its Markdown (or, over 1 MB, a pointer to GitHub), and the tasks that link it.
509
+ * @param {any} spec
510
+ */
511
+ export function specLines(spec) {
512
+ const out = [`${spec.title} (${spec.path})`, ''];
513
+ const row = (k, v) => v && out.push(` ${k.padEnd(11)} ${v}`);
514
+ row('Status', spec.status);
515
+ const c = spec.commit;
516
+ if (c)
517
+ row(
518
+ 'Changed',
519
+ `${c.date ? `${String(c.date).slice(0, 16).replace('T', ' ')} ` : ''}in ${String(c.sha).slice(0, 7)}${c.message ? `: ${c.message}` : ''}`,
520
+ );
521
+ row('GitHub', spec.url);
522
+ out.push('');
523
+ if (spec.tooLarge || spec.text === null || spec.text === undefined)
524
+ out.push('Over 1 MB, too large to show here: read it on GitHub.');
525
+ else out.push(String(spec.text).replace(/\s+$/u, ''));
526
+ const tasks = spec.tasks ?? [];
527
+ out.push('');
528
+ if (tasks.length) {
529
+ out.push(` Tasks (${tasks.length}, ${tasks.filter((t) => t.status === 'pending').length} open)`);
530
+ for (const t of tasks) out.push(` ${idOf(t).padEnd(9)} ${t.status.padEnd(9)} ${t.description}`);
531
+ } else out.push(` No task links it yet: npx breakaway modify <ref> --spec ${spec.path}`);
532
+ out.push('', `Refine it: npx breakaway agents new --spec ${spec.path} "<what should change>"`);
533
+ return out;
534
+ }