breakaway 1.4.0-main.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.4.0-main.5",
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 (opts['agents-max'] !== undefined || opts['agents-hourly'] !== undefined || opts.prompt !== undefined) {
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;
@@ -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 = 63;
8
- export const CLI_FINGERPRINT = '4e27258ce14d0ac7';
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
+ }