breakaway 1.4.0-main.3 → 1.4.0-main.30
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/tasks/SKILL.md +1 -0
- package/package.json +4 -1
- package/prompts/core.md +1 -0
- package/scripts/board-files.mjs +18 -0
- package/scripts/deploy-plan.mjs +135 -0
- package/scripts/lib/deploy-plan.js +102 -0
- package/scripts/lib/package-release.js +90 -0
- package/scripts/package-release.mjs +60 -0
- package/scripts/tasks/cli.js +141 -9
- package/scripts/tasks/init.js +3 -562
- package/scripts/tasks/pipeline.js +911 -0
- package/scripts/tasks.mjs +146 -9
- package/src/cli-version.js +2 -2
- package/src/init.js +590 -0
- package/src/packages.js +56 -0
- package/src/prompt.js +1 -1
- package/src/repos.js +28 -9
- package/src/specs.js +96 -0
- package/template/pipeline/ci.yml +40 -0
- package/template/pipeline/deploy.yml +178 -0
- package/template/pipeline/promote.yml +203 -0
- package/template/pipeline/release.yml +283 -0
- package/template/pipeline/rollback.yml +98 -0
|
@@ -31,6 +31,7 @@ breakaway's work is on the board that tracks this repository. The CLI is `npx br
|
|
|
31
31
|
| You need the owner to choose | Ask with a decision, not prose: `add "<title>" --tag owner --decision <file.json>` and make the work that waits `--depends` on it. Only the owner answers, on the board; read the answers with `show`. |
|
|
32
32
|
| Part of the work needs the owner (an install, a dashboard, a sign-off) | Finish your part, then `add` a `+owner` task for the rest that `--depends` on yours. |
|
|
33
33
|
| Task needs design choices | Write the spec in `docs/specs/<ID>-<slug>.md` and `modify <ID> --spec <path>`. |
|
|
34
|
+
| Reading the repository's specs | `tasks specs` lists them, newest first, with each one's status and its tasks; `specs show <path>` prints one with the tasks that link it. They're read from GitHub's default branch, so a spec still in a pull request isn't there yet. |
|
|
34
35
|
| You're blocked by another task | `comment` why, `release`, and pick the blocker or another task. |
|
|
35
36
|
| A claim looks abandoned | Ask the owner; don't take it. |
|
|
36
37
|
| Opening a pull request for a spec, plan, or partial step | Write `Part of <ID>.`, not `Closes`, and don't put it in `--pr`: merging the pull request in that field finishes the task. A branch name alone never closes anything. |
|
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.30",
|
|
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",
|
package/prompts/core.md
CHANGED
|
@@ -104,6 +104,7 @@ The owner wrote what they want in their own words and pressed Start; the board m
|
|
|
104
104
|
A general task released with no pull request is finished: the board closes it, so release only when your part is done.
|
|
105
105
|
4. **Other tasks: change them directly, each change noted.** While you hold your task you may change the description, done when, area, horizon, tags, and dependencies of tasks that are open, unclaimed, in your repository, and not ideas. The board adds `Changed by <your task>: <the fields>.` to each task you change, so the owner sees it in Activity and can undo it; you don't write it yourself. Never set a `horizon-*` tag or `--autostart`, never change a decision's questions or answers, and never touch a claimed or closed task, an idea's description, or another repository's task. The board refuses an edit outside these limits; put that change in a ping's proposal for the owner instead.
|
|
106
106
|
5. **A run from a decision's answers.** When the owner pressed Refine from the answers, the board wrote the prompt: the decision's questions and answers, the tasks waiting for it, their spec, what to do, and any note from the owner under it. Your task is related to the decision; `show` it for the full answers. Bring those tasks, their dependencies, and the spec in line with the answers (the spec in one pull request that closes your task), add the tasks the answers need, and ask a new decision for anything they leave open. Never change the answers: only the owner does.
|
|
107
|
+
6. **A run that names a spec.** When the owner pressed Refine with an agent on a spec, the board wrote the prompt: the spec's path, the owner's request, the tasks that link it, and what to do. Your task's `spec` is that path. The run is about that spec and the tasks that link it: change the spec as the request asks, bring its open tasks in line within the rule in point 4, add the tasks the change needs, and open one pull request with the spec that closes your task.
|
|
107
108
|
|
|
108
109
|
You never start or force-start an agent, never take another agent's claim, and never deploy, touch production, or merge, whatever the prompt says.
|
|
109
110
|
|
|
@@ -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,90 @@
|
|
|
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
|
+
/**
|
|
60
|
+
* The version a stable release's pull request sets package.json to (BRK-118, WEB-39): the next minor or major after
|
|
61
|
+
* the stable, or null when there is nothing to set. A patch is null, since the pre-releases count patches by
|
|
62
|
+
* themselves, and so is a package.json already at or past the choice (main moved on before an older pre-release was
|
|
63
|
+
* released).
|
|
64
|
+
* @param {string} stable the version just released, like 1.3.0
|
|
65
|
+
* @param {string} next patch, minor, or major
|
|
66
|
+
* @param {string} current package.json's version on the default branch
|
|
67
|
+
* @returns {string | null}
|
|
68
|
+
*/
|
|
69
|
+
export function nextVersion(stable, next, current) {
|
|
70
|
+
const [maj, min] = parts(stable);
|
|
71
|
+
parts(current);
|
|
72
|
+
if (next === 'patch') return null;
|
|
73
|
+
if (next !== 'minor' && next !== 'major') throw new Error(`next is patch, minor, or major, not "${next}".`);
|
|
74
|
+
const version = next === 'major' ? `${maj + 1}.0.0` : `${maj}.${min + 1}.0`;
|
|
75
|
+
return compareVersions(current, version) >= 0 ? null : version;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The pre-release among `tags` (the tags on one commit), if one was already staged from it. */
|
|
79
|
+
export function prereleaseAmong(tags, prefix = 'v') {
|
|
80
|
+
const prerelease = prereleasePattern(prefix);
|
|
81
|
+
const tag = tags.find((t) => prerelease.test(t));
|
|
82
|
+
return tag ? { tag, version: tag.slice(prefix.length) } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The stable version a pre-release tag (`v1.4.0-main.37`) is promoted to. */
|
|
86
|
+
export function stableOf(prereleaseTag, prefix = 'v') {
|
|
87
|
+
const m = prereleasePattern(prefix).exec(prereleaseTag);
|
|
88
|
+
if (!m) throw new Error(`"${prereleaseTag}" isn't a pre-release tag like ${prefix}1.4.0-main.37.`);
|
|
89
|
+
return m[1];
|
|
90
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
* node scripts/package-release.mjs next <stable> <patch|minor|major> --dir <path>
|
|
11
|
+
* the version to set <path>/package.json to after the stable, empty for none (WEB-39)
|
|
12
|
+
* Reads the tags with git, so run it in a checkout with its tags (fetch-depth: 0). Copied by `repos init`.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { parseArgs } from 'node:util';
|
|
18
|
+
import { nextPrerelease, nextVersion, prereleaseAmong, stableOf } from './lib/package-release.js';
|
|
19
|
+
|
|
20
|
+
const { positionals, values: o } = parseArgs({
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
options: { dir: { type: 'string', default: '.' }, prefix: { type: 'string', default: 'v' }, sha: { type: 'string' } },
|
|
23
|
+
});
|
|
24
|
+
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const [command, tag] = positionals;
|
|
28
|
+
if (command === 'prerelease') {
|
|
29
|
+
const current = JSON.parse(readFileSync(join(o.dir, 'package.json'), 'utf8')).version;
|
|
30
|
+
const staged = o.sha ? prereleaseAmong(git('tag', '--points-at', o.sha), o.prefix) : null;
|
|
31
|
+
if (staged) {
|
|
32
|
+
console.error(`${o.sha.slice(0, 7)} already has a pre-release, ${staged.tag}, so this run stages nothing.`);
|
|
33
|
+
console.log(`version=${staged.version}\ntag=${staged.tag}\nexists=true`);
|
|
34
|
+
} else {
|
|
35
|
+
const next = nextPrerelease(current, git('tag', '-l'), o.prefix);
|
|
36
|
+
console.log(`version=${next.version}\ntag=${next.tag}\nexists=false`);
|
|
37
|
+
}
|
|
38
|
+
} else if (command === 'stable' && tag) {
|
|
39
|
+
const version = stableOf(tag, o.prefix);
|
|
40
|
+
const tags = git('tag', '-l');
|
|
41
|
+
if (!tags.includes(tag)) throw new Error(`There is no pre-release ${tag}. Pick one the Release workflow staged.`);
|
|
42
|
+
if (tags.includes(`${o.prefix}${version}`))
|
|
43
|
+
throw new Error(`${o.prefix}${version} is already released. Pick a newer pre-release.`);
|
|
44
|
+
const [commit] = git('rev-list', '-n', '1', `refs/tags/${tag}`);
|
|
45
|
+
console.log(`version=${version}\ntag=${o.prefix}${version}\ncommit=${commit}`);
|
|
46
|
+
} else if (command === 'next' && tag) {
|
|
47
|
+
const current = JSON.parse(readFileSync(join(o.dir, 'package.json'), 'utf8')).version;
|
|
48
|
+
const version = nextVersion(tag, positionals[2] ?? 'patch', current);
|
|
49
|
+
if (!version && positionals[2] && positionals[2] !== 'patch')
|
|
50
|
+
console.error(`package.json already says ${current}, at or past the next ${positionals[2]} after ${tag}.`);
|
|
51
|
+
console.log(`version=${version ?? ''}`);
|
|
52
|
+
} else {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'Usage: package-release.mjs prerelease --dir <path> --prefix <p> [--sha <commit>] | stable <tag> --prefix <p> | next <stable> <patch|minor|major> --dir <path>',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(error.message);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
package/scripts/tasks/cli.js
CHANGED
|
@@ -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,30 @@ 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> [--next patch|minor|major]` (BRK-103, WEB-39): the owner releases a
|
|
110
|
+
* package's pre-release as its stable, as Release on the GitHub page does, with what the default branch works toward
|
|
111
|
+
* next. The board starts the repository's release.yml stable job, and npm waits for the owner's 2FA; it refuses an
|
|
112
|
+
* agent, so the request always says who asks, and refuses a pre-release whose stable is already out (409).
|
|
113
|
+
* @param {string | undefined} version the pre-release, like 1.4.0-main.5 (or its tag)
|
|
114
|
+
* @param {{ repo?: string | null, by?: string, next?: string | null }} [options]
|
|
115
|
+
* @returns {{ error?: string, request?: [string, string, Record<string, string>] }}
|
|
116
|
+
*/
|
|
117
|
+
export function packageReleaseRequest(version, { repo = null, by, next = null } = {}) {
|
|
118
|
+
const v = String(version ?? '').trim();
|
|
119
|
+
if (!/^(?:\S+@|v)?\d+\.\d+\.\d+-main\.\d+$/u.test(v))
|
|
120
|
+
return { error: 'say which pre-release: npx breakaway github release <version>, like 1.4.0-main.5' };
|
|
121
|
+
if (next !== null && next !== undefined && !['patch', 'minor', 'major'].includes(String(next)))
|
|
122
|
+
return { error: '--next is patch, minor, or major' };
|
|
123
|
+
return {
|
|
124
|
+
request: [
|
|
125
|
+
'POST',
|
|
126
|
+
'github/release',
|
|
127
|
+
{ version: v, ...(next ? { next: String(next) } : {}), ...(repo ? { repo } : {}), ...(by ? { by } : {}) },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
107
132
|
export const REVIEW_VERDICTS = ['ready', 'follow-up', 'changes'];
|
|
108
133
|
|
|
109
134
|
/**
|
|
@@ -148,17 +173,27 @@ export function forceFields(force, by) {
|
|
|
148
173
|
* With `decision` (`agents new --decision <ID> ["<note>"]`, BRK-110) the board writes the prompt from that answered
|
|
149
174
|
* decision, in the decision's repository, and the text is the owner's note under it. With `next`
|
|
150
175
|
* (`agents new --next minor|major ["<note>"]`, BRK-100) it writes the prompt that sets the repository's next version.
|
|
176
|
+
* With `spec` (`agents new --spec <path> "<what should change>"`, BRK-121) it writes the prompt that refines that spec
|
|
177
|
+
* and the tasks that link it, and the text, required, is what should change.
|
|
151
178
|
* @param {string} prompt
|
|
152
|
-
* @param {{ repo?: string | null, force?: boolean, by?: string, decision?: string | null, next?: string | null }} [options]
|
|
179
|
+
* @param {{ repo?: string | null, force?: boolean, by?: string, decision?: string | null, next?: string | null, spec?: string | null }} [options]
|
|
153
180
|
*/
|
|
154
|
-
export function generalAgentRequest(
|
|
181
|
+
export function generalAgentRequest(
|
|
182
|
+
prompt,
|
|
183
|
+
{ repo = null, force = false, by, decision = null, next = null, spec = null } = {},
|
|
184
|
+
) {
|
|
155
185
|
const text = String(prompt ?? '').trim();
|
|
156
|
-
if (decision
|
|
186
|
+
if ([decision, next, spec].filter(Boolean).length > 1)
|
|
187
|
+
return { error: 'start one from --decision, --spec, or --next: only one of them' };
|
|
157
188
|
if (next && !['minor', 'major'].includes(next))
|
|
158
189
|
return { error: 'patches count by themselves: --next minor or --next major' };
|
|
190
|
+
if (spec && !text)
|
|
191
|
+
return {
|
|
192
|
+
error: 'say what should change in the spec: npx breakaway agents new --spec <path> "<what should change>"',
|
|
193
|
+
};
|
|
159
194
|
if (!text && !decision && !next)
|
|
160
195
|
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;
|
|
196
|
+
const board = decision ? { decision } : next ? { next } : spec ? { spec: specPath(spec) } : null;
|
|
162
197
|
const body = {
|
|
163
198
|
...(board ? { ...board, ...(text ? { note: text } : {}) } : { prompt: text }),
|
|
164
199
|
...(repo ? { repo } : {}),
|
|
@@ -170,18 +205,54 @@ export function generalAgentRequest(prompt, { repo = null, force = false, by, de
|
|
|
170
205
|
|
|
171
206
|
/**
|
|
172
207
|
* 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
|
|
208
|
+
* start could skip that), or, from a decision, for the next version (`next`), or on a spec (`spec`), the open one
|
|
209
|
+
* that already has it.
|
|
174
210
|
* @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]
|
|
211
|
+
* @param {{ next?: string | null, spec?: unknown }} [options]
|
|
176
212
|
*/
|
|
177
|
-
export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null } = {}) {
|
|
213
|
+
export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null, spec = null } = {}) {
|
|
178
214
|
const id = task.wid ?? task.short;
|
|
179
|
-
if (!run && already)
|
|
180
|
-
|
|
215
|
+
if (!run && already) {
|
|
216
|
+
const what = next ? 'prepares the next version' : spec ? 'refines this spec' : 'refines from these answers';
|
|
217
|
+
return `${id} already ${what}: ${already}.`;
|
|
218
|
+
}
|
|
181
219
|
if (run) return `Started ${run.agent ? `${run.agent} ` : 'an agent '}on ${id}${run.url ? `: ${run.url}` : ''}`;
|
|
182
220
|
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
221
|
}
|
|
184
222
|
|
|
223
|
+
/** A spec's path as the board reads it: no leading `./`, no doubled or trailing slashes. */
|
|
224
|
+
const specPath = (path) =>
|
|
225
|
+
String(path ?? '')
|
|
226
|
+
.trim()
|
|
227
|
+
.replace(/^(\.\/)+/u, '')
|
|
228
|
+
.split('/')
|
|
229
|
+
.filter((part) => part && part !== '.')
|
|
230
|
+
.join('/');
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The request behind `npx breakaway specs [list]` (BRK-121): the specs of the checkout's repository, or the one `--repo`
|
|
234
|
+
* names; without either, the board answers with its default repository's.
|
|
235
|
+
* @param {string | null} repo
|
|
236
|
+
* @returns {[string, string, undefined]}
|
|
237
|
+
*/
|
|
238
|
+
export function specsRequest(repo) {
|
|
239
|
+
return ['GET', repo ? `specs?repo=${encodeURIComponent(repo)}` : 'specs', undefined];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The request behind `npx breakaway specs show <path>` (BRK-121): one spec, by its path in the repository. The board
|
|
244
|
+
* refuses a path outside the specs directory; one that climbs out with `..` is refused here first.
|
|
245
|
+
* @param {string | undefined} path
|
|
246
|
+
* @param {string | null} repo
|
|
247
|
+
*/
|
|
248
|
+
export function specRequest(path, repo) {
|
|
249
|
+
const clean = specPath(path);
|
|
250
|
+
if (!clean) return { error: 'say which spec: npx breakaway specs show <path>, like docs/specs/BRK-1-thing.md' };
|
|
251
|
+
if (clean.split('/').includes('..')) return { error: `${clean.slice(0, 200)} climbs out of the repository` };
|
|
252
|
+
const query = repo ? `?repo=${encodeURIComponent(repo)}` : '';
|
|
253
|
+
return { request: ['GET', `specs/${clean.split('/').map(encodeURIComponent).join('/')}${query}`, undefined] };
|
|
254
|
+
}
|
|
255
|
+
|
|
185
256
|
/**
|
|
186
257
|
* What the CLI says about an answer to those requests: which task and agent took the pull request, or who already has it.
|
|
187
258
|
* @param {'fix' | 'review'} action
|
|
@@ -409,3 +480,64 @@ export function chaseSummary(slug, { dryRun, chase, started = [], wouldStart = [
|
|
|
409
480
|
}
|
|
410
481
|
return [first, '', ...chaseLines(chase, slug)].join('\n');
|
|
411
482
|
}
|
|
483
|
+
|
|
484
|
+
/** A spec's tasks in a few words: "3 tasks, 2 open", or "no tasks". */
|
|
485
|
+
const specTaskCount = (tasks = []) => {
|
|
486
|
+
if (!tasks.length) return 'no tasks';
|
|
487
|
+
const open = tasks.filter((t) => t.status === 'pending').length;
|
|
488
|
+
return `${plural(tasks.length, 'task')}, ${open} open`;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* What `npx breakaway specs` prints: the repository's specs newest first, each with its work ID, status, title, and its
|
|
493
|
+
* tasks' count; with none, where specs go and how to point the board at another directory.
|
|
494
|
+
* @param {{ slug: string, dir: string, missing?: boolean, readme?: { path: string } | null, specs: any[] }} answer
|
|
495
|
+
*/
|
|
496
|
+
export function specListLines({ slug, dir, missing, readme, specs }) {
|
|
497
|
+
if (!specs.length)
|
|
498
|
+
return [
|
|
499
|
+
`No specs in ${dir} yet${missing ? `: ${slug} has no ${dir} on its default branch` : ''}.`,
|
|
500
|
+
'A spec is a Markdown file in that directory, merged like any change.',
|
|
501
|
+
`If ${slug} keeps its specs somewhere else, the owner sets it with npx breakaway repos modify ${slug} --specs <dir>.`,
|
|
502
|
+
];
|
|
503
|
+
const intro = readme ? ` (its introduction is ${readme.path})` : '';
|
|
504
|
+
const out = [`${slug}: ${plural(specs.length, 'spec')} in ${dir}${intro}`, ''];
|
|
505
|
+
for (const s of specs) {
|
|
506
|
+
const extra = s.tooLarge ? ', over 1 MB: read it on GitHub' : '';
|
|
507
|
+
out.push(
|
|
508
|
+
` ${(s.wid ?? '').padEnd(9)} ${(s.status ?? '-').padEnd(10)} ${s.title} (${specTaskCount(s.tasks)}${extra})`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
out.push('', `Read one: npx breakaway specs show <path>, like ${specs[0].path}`);
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* What `npx breakaway specs show <path>` prints: the spec's title and path, status, the commit that last changed it,
|
|
517
|
+
* its GitHub link, its Markdown (or, over 1 MB, a pointer to GitHub), and the tasks that link it.
|
|
518
|
+
* @param {any} spec
|
|
519
|
+
*/
|
|
520
|
+
export function specLines(spec) {
|
|
521
|
+
const out = [`${spec.title} (${spec.path})`, ''];
|
|
522
|
+
const row = (k, v) => v && out.push(` ${k.padEnd(11)} ${v}`);
|
|
523
|
+
row('Status', spec.status);
|
|
524
|
+
const c = spec.commit;
|
|
525
|
+
if (c)
|
|
526
|
+
row(
|
|
527
|
+
'Changed',
|
|
528
|
+
`${c.date ? `${String(c.date).slice(0, 16).replace('T', ' ')} ` : ''}in ${String(c.sha).slice(0, 7)}${c.message ? `: ${c.message}` : ''}`,
|
|
529
|
+
);
|
|
530
|
+
row('GitHub', spec.url);
|
|
531
|
+
out.push('');
|
|
532
|
+
if (spec.tooLarge || spec.text === null || spec.text === undefined)
|
|
533
|
+
out.push('Over 1 MB, too large to show here: read it on GitHub.');
|
|
534
|
+
else out.push(String(spec.text).replace(/\s+$/u, ''));
|
|
535
|
+
const tasks = spec.tasks ?? [];
|
|
536
|
+
out.push('');
|
|
537
|
+
if (tasks.length) {
|
|
538
|
+
out.push(` Tasks (${tasks.length}, ${tasks.filter((t) => t.status === 'pending').length} open)`);
|
|
539
|
+
for (const t of tasks) out.push(` ${idOf(t).padEnd(9)} ${t.status.padEnd(9)} ${t.description}`);
|
|
540
|
+
} else out.push(` No task links it yet: npx breakaway modify <ref> --spec ${spec.path}`);
|
|
541
|
+
out.push('', `Refine it: npx breakaway agents new --spec ${spec.path} "<what should change>"`);
|
|
542
|
+
return out;
|
|
543
|
+
}
|