breakaway 1.4.0-main.6 → 1.4.0-main.7
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.mjs +11 -2
- package/src/cli-version.js +2 -2
- package/src/repos.js +2 -0
- package/src/specs.js +96 -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.7",
|
|
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",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"src/prompt.js",
|
|
29
29
|
"src/redact.js",
|
|
30
30
|
"src/repos.js",
|
|
31
|
+
"src/specs.js",
|
|
31
32
|
"src/versions.js",
|
|
32
33
|
"prompts/*.md",
|
|
33
34
|
"taskrc",
|
package/scripts/tasks.mjs
CHANGED
|
@@ -236,6 +236,7 @@ Working
|
|
|
236
236
|
repos modify <slug> change one (owner): --area <project:PREFIX> adds an area, --remove-area <project> drops one with no tasks, --name, --branch, --github,
|
|
237
237
|
--agents-max <n|none> and --agents-hourly <n|none> cap its agents under the board's shared limits,
|
|
238
238
|
--prompt <path|none> says where its agent prompt is in its checkout (default tools/tasks/routine-prompt.md)
|
|
239
|
+
--specs <dir|none> says where its specs are (default docs/specs)
|
|
239
240
|
--pipeline <file.json|none> sets its deploy pipeline ({"workers": {"staging", "production"}, "workflows": {...}, "deployPaths"}) or clears it
|
|
240
241
|
features add <slug> new feature: its tasks join by carrying <slug> as a tag [--title <text>]
|
|
241
242
|
[--brief <text> | --brief-file <path>] [--release <x.y.z>] (agents add one without a release)
|
|
@@ -935,9 +936,14 @@ const commands = {
|
|
|
935
936
|
if (sub === 'modify') {
|
|
936
937
|
const slug = need(args[1], 'repository');
|
|
937
938
|
const change = fields();
|
|
939
|
+
const routineFlags =
|
|
940
|
+
opts['agents-max'] !== undefined || opts['agents-hourly'] !== undefined || opts.prompt !== undefined;
|
|
941
|
+
const current =
|
|
942
|
+
routineFlags || opts.specs !== undefined
|
|
943
|
+
? (await call('GET', 'repos')).repos.find((r) => r.slug === slug.toLowerCase())
|
|
944
|
+
: null;
|
|
938
945
|
// Its caps on agents, under the board's shared limits, and where its agent prompt is (CLD-127): kept with the rest of its routine settings.
|
|
939
|
-
if (
|
|
940
|
-
const current = (await call('GET', 'repos')).repos.find((r) => r.slug === slug.toLowerCase());
|
|
946
|
+
if (routineFlags) {
|
|
941
947
|
const routine = { ...current?.routine };
|
|
942
948
|
for (const [flag, key] of [
|
|
943
949
|
['agents-max', 'max'],
|
|
@@ -948,6 +954,9 @@ const commands = {
|
|
|
948
954
|
if (opts.prompt !== undefined) routine.prompt = opts.prompt === 'none' ? null : String(opts.prompt);
|
|
949
955
|
change.routine = routine;
|
|
950
956
|
}
|
|
957
|
+
// Where its specs are (IDEA-31), kept with the rest of its settings; none goes back to docs/specs.
|
|
958
|
+
if (opts.specs !== undefined)
|
|
959
|
+
change.settings = { ...current?.settings, specs: opts.specs === 'none' ? null : String(opts.specs) };
|
|
951
960
|
// Its deploy pipeline (BRK-44): a JSON file, or none to clear it; the board refuses one it couldn't use, with the reason.
|
|
952
961
|
if (opts.pipeline !== undefined) {
|
|
953
962
|
if (opts.pipeline === 'none') change.pipeline = null;
|
package/src/cli-version.js
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* and how to update it. scripts/tasks/version.test.js fails when the copied files change and this doesn't:
|
|
5
5
|
* so it lives in the board's package (CLD-135) and the CLI imports it from here.
|
|
6
6
|
*/
|
|
7
|
-
export const CLI_VERSION =
|
|
8
|
-
export const CLI_FINGERPRINT = '
|
|
7
|
+
export const CLI_VERSION = 64;
|
|
8
|
+
export const CLI_FINGERPRINT = '7c834da9bbef354a';
|
package/src/repos.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* repository, and a prefix to exactly one area, so a work ID means one task across the install.
|
|
11
11
|
*/
|
|
12
12
|
import { AREA_NAMES, InputError, PROJECTS } from './model.js';
|
|
13
|
+
import { checkSettings } from './specs.js';
|
|
13
14
|
|
|
14
15
|
/** The slug a task without `repo` falls back to while the registry has no default: nothing is registered, so no task is. */
|
|
15
16
|
export const NO_REPO = 'default';
|
|
@@ -292,6 +293,7 @@ export function checkRepo(
|
|
|
292
293
|
for (const key of JSON_FIELDS) if (key in input) row[key] = jsonField(input[key], key);
|
|
293
294
|
if ('pipeline' in input) row.pipeline = checkPipeline(row.pipeline);
|
|
294
295
|
if ('routine' in input) row.routine = checkRoutine(row.routine, caps);
|
|
296
|
+
if ('settings' in input) row.settings = checkSettings(row.settings);
|
|
295
297
|
|
|
296
298
|
const adding = [...list(input.areas), ...list(input.addAreas)].map(parseArea);
|
|
297
299
|
for (const project of list(input.removeAreas).map((p) => String(p).trim().toLowerCase())) {
|
package/src/specs.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A repository's specs (docs/specs/IDEA-31-specs-view.md, sections 1 and 2): where they are, and what the board
|
|
3
|
+
* reads from each file. Pure, so it's tested without the Durable Object; reading them through GitHub is in
|
|
4
|
+
* store-specs.js. The board never stores a spec: it reads the files on the default branch when asked.
|
|
5
|
+
*/
|
|
6
|
+
import { InputError } from './model.js';
|
|
7
|
+
|
|
8
|
+
/** Where a repository keeps its specs when its settings don't say: where breakaway and the template put them. */
|
|
9
|
+
export const DEFAULT_SPECS_DIR = 'docs/specs';
|
|
10
|
+
/** The largest spec the board reads; a bigger one is a link to GitHub. */
|
|
11
|
+
export const SPEC_MAX_BYTES = 1_048_576;
|
|
12
|
+
const SPECS_DIR = /^(?!\/)(?!.*\.\.)[\w./-]{1,200}$/u;
|
|
13
|
+
const SPEC_NAME = /^[\w.-]{1,200}\.md$/u;
|
|
14
|
+
const WID = /^([A-Z]{2,8}-\d+)(?:[-.]|$)/u;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A repository's specs directory as the owner sets it (`settings.specs`): a relative directory, with no `..`,
|
|
18
|
+
* of at most 200 characters, checked like `routine.prompt`. Empty or null means the default, so it's left out.
|
|
19
|
+
* @param {unknown} value
|
|
20
|
+
* @returns {string | null}
|
|
21
|
+
*/
|
|
22
|
+
export function checkSpecsDir(value) {
|
|
23
|
+
if (value === null || value === undefined || value === '') return null;
|
|
24
|
+
const dir = String(value).trim().replace(/^\.\//u, '').replace(/\/+$/u, '');
|
|
25
|
+
if (!SPECS_DIR.test(dir) || dir.split('/').some((part) => part === '' || part === '.'))
|
|
26
|
+
throw new InputError('settings.specs is a directory in the repository, like docs/specs');
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A repository's `settings`, checked: its specs directory (`specs`) when it has one; the rest is kept as it is.
|
|
32
|
+
* @param {Record<string, any> | null} settings
|
|
33
|
+
*/
|
|
34
|
+
export function checkSettings(settings) {
|
|
35
|
+
if (!settings) return null;
|
|
36
|
+
const out = { ...settings };
|
|
37
|
+
const specs = checkSpecsDir(out.specs);
|
|
38
|
+
if (specs) out.specs = specs;
|
|
39
|
+
else delete out.specs;
|
|
40
|
+
return Object.keys(out).length ? out : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The directory, in the repository, its specs are in. */
|
|
44
|
+
export const specsDirOf = (repo) => repo?.settings?.specs || DEFAULT_SPECS_DIR;
|
|
45
|
+
|
|
46
|
+
/** A path as a task's `spec` field or a request may give it: no leading `./` or `/`. */
|
|
47
|
+
export const normalPath = (path) =>
|
|
48
|
+
String(path ?? '')
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^(?:\.\/|\/)+/u, '');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether `path` is a file the board reads as one of the repository's specs: a Markdown file directly in `dir`
|
|
54
|
+
* (its README.md too). Subdirectories, other files, and anything outside it are refused.
|
|
55
|
+
* @param {string} dir
|
|
56
|
+
* @param {string} path
|
|
57
|
+
*/
|
|
58
|
+
export function inSpecsDir(dir, path) {
|
|
59
|
+
const p = normalPath(path);
|
|
60
|
+
if (!p.startsWith(`${dir}/`)) return false;
|
|
61
|
+
const name = p.slice(dir.length + 1);
|
|
62
|
+
return SPEC_NAME.test(name) && !name.startsWith('.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether a file in the directory is a spec, not its introduction. */
|
|
66
|
+
export const isSpecFile = (name) => SPEC_NAME.test(name) && !name.startsWith('.') && name.toLowerCase() !== 'readme.md';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the list shows for one spec: its title (the first `# ` heading, else the file name), its status (the
|
|
70
|
+
* first word after `Status:` on the line under the heading, as the template has it, else null), and the work ID
|
|
71
|
+
* its file name starts with, if any. `text` is null for a file the board didn't read (too large).
|
|
72
|
+
* @param {string} name the file name
|
|
73
|
+
* @param {string | null} text
|
|
74
|
+
*/
|
|
75
|
+
export function specMeta(name, text) {
|
|
76
|
+
const wid = WID.exec(name)?.[1] ?? null;
|
|
77
|
+
const fallback = name.replace(/\.md$/u, '');
|
|
78
|
+
if (text === null || text === undefined) return { wid, title: fallback, status: null };
|
|
79
|
+
const lines = String(text).split(/\r?\n/u);
|
|
80
|
+
const at = lines.findIndex((l) => /^#\s+\S/u.test(l));
|
|
81
|
+
if (at < 0) return { wid, title: fallback, status: null };
|
|
82
|
+
const title =
|
|
83
|
+
lines[at]
|
|
84
|
+
.replace(/^#\s+/u, '')
|
|
85
|
+
.replace(/\s+#+\s*$/u, '')
|
|
86
|
+
.trim() || fallback;
|
|
87
|
+
const under = lines.slice(at + 1).find((l) => l.trim() !== '') ?? '';
|
|
88
|
+
const status = /\bStatus:\s*\**\s*([A-Za-z][\w-]*)/u.exec(under)?.[1]?.toLowerCase() ?? null;
|
|
89
|
+
return { wid, title, status };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Newest first by the work ID's number, then by path; specs without a work ID last. */
|
|
93
|
+
export function bySpecOrder(a, b) {
|
|
94
|
+
const n = (s) => (s.wid ? Number(s.wid.split('-')[1]) : -1);
|
|
95
|
+
return n(b) - n(a) || a.path.localeCompare(b.path);
|
|
96
|
+
}
|