breakaway 1.4.0-main.13 → 1.4.0-main.15

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.13",
3
+ "version": "1.4.0-main.15",
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",
@@ -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). */
@@ -163,17 +164,27 @@ export function forceFields(force, by) {
163
164
  * With `decision` (`agents new --decision <ID> ["<note>"]`, BRK-110) the board writes the prompt from that answered
164
165
  * decision, in the decision's repository, and the text is the owner's note under it. With `next`
165
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.
166
169
  * @param {string} prompt
167
- * @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]
168
171
  */
169
- 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
+ ) {
170
176
  const text = String(prompt ?? '').trim();
171
- 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' };
172
179
  if (next && !['minor', 'major'].includes(next))
173
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
+ };
174
185
  if (!text && !decision && !next)
175
186
  return { error: 'say what the agent should do: npx breakaway agents new "Tidy the docs" [--image <file>]' };
176
- const board = decision ? { decision } : next ? { next } : null;
187
+ const board = decision ? { decision } : next ? { next } : spec ? { spec: specPath(spec) } : null;
177
188
  const body = {
178
189
  ...(board ? { ...board, ...(text ? { note: text } : {}) } : { prompt: text }),
179
190
  ...(repo ? { repo } : {}),
@@ -185,18 +196,54 @@ export function generalAgentRequest(prompt, { repo = null, force = false, by, de
185
196
 
186
197
  /**
187
198
  * What the CLI says about a general agent's answer: the task and that it started, or why it waits (and whether Force
188
- * 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.
189
201
  * @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string } | null, waiting?: string | null, forceable?: boolean, already?: string | null }} answer
190
- * @param {{ next?: string | null }} [options]
202
+ * @param {{ next?: string | null, spec?: unknown }} [options]
191
203
  */
192
- export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null } = {}) {
204
+ export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null, spec = null } = {}) {
193
205
  const id = task.wid ?? task.short;
194
- if (!run && already)
195
- 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
+ }
196
210
  if (run) return `Started ${run.agent ? `${run.agent} ` : 'an agent '}on ${id}${run.url ? `: ${run.url}` : ''}`;
197
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` : ''}`;
198
212
  }
199
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
+
200
247
  /**
201
248
  * What the CLI says about an answer to those requests: which task and agent took the pull request, or who already has it.
202
249
  * @param {'fix' | 'review'} action
@@ -424,3 +471,64 @@ export function chaseSummary(slug, { dryRun, chase, started = [], wouldStart = [
424
471
  }
425
472
  return [first, '', ...chaseLines(chase, slug)].join('\n');
426
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
+ }
package/scripts/tasks.mjs CHANGED
@@ -54,6 +54,10 @@ import {
54
54
  generalAgentRequest,
55
55
  generalAgentSummary,
56
56
  githubRequest,
57
+ specLines,
58
+ specListLines,
59
+ specRequest,
60
+ specsRequest,
57
61
  ideaTask,
58
62
  packageReleaseRequest,
59
63
  pullAgentRequest,
@@ -148,6 +152,8 @@ Reading (list, next, claim, and add work in this checkout's repos
148
152
  agents new "<prompt>" start an agent from a prompt: it makes its own task [--image <file>]… [--repo <slug>] [--force] (owner)
149
153
  agents new --decision <ref> ["<note>"] start an agent that brings the work waiting for an answered decision in line with its answers; the board writes its prompt [--force] (owner)
150
154
  agents new --next minor|major ["<note>"] start an agent that sets package.json to the next minor or major release; the board writes its prompt [--repo <slug>] [--force] (owner)
155
+ agents new --spec <path> "<what should change>" start an agent that changes a spec as you ask and brings the tasks that
156
+ link it in line; the board writes its prompt [--repo <slug>] [--force] (owner)
151
157
  agents start <ref> start a Claude cloud agent on a task [--note <text>] [--force]
152
158
  agents refine <ref> start an agent that improves a task, not builds it --note <what to look at or change> [--force]
153
159
  agents plan [<plan>] your Claude plan and what it allows; pro, max5, or max20 picks one (owner) and sets the limits to its defaults
@@ -168,6 +174,8 @@ Reading (list, next, claim, and add work in this checkout's repos
168
174
  (owner) [--note <text>] [--repo <slug>] [--force]
169
175
  github release <pre-release> release a package's pre-release (1.4.0-main.5) as its stable on latest, as Release on the
170
176
  GitHub page does: the board starts release.yml's stable job, and npm waits for your 2FA (owner) [--repo <slug>]
177
+ specs the repository's specs, newest first: each one's status and its tasks [--repo <slug>]
178
+ specs show <path> one spec: its status, last change, Markdown, and the tasks that link it [--repo <slug>]
171
179
  github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
172
180
  hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
173
181
  health the server's state
@@ -803,11 +811,13 @@ const commands = {
803
811
  if (sub === 'new') {
804
812
  const decision = typeof opts.decision === 'string' ? opts.decision : null;
805
813
  const next = typeof opts.next === 'string' ? opts.next : null;
814
+ const spec = typeof opts.spec === 'string' ? opts.spec : null;
806
815
  const built = generalAgentRequest(args.slice(1).join(' '), {
807
816
  // From a decision, the board runs it in the decision's repository unless --repo says otherwise.
808
817
  repo: opts.repo ?? (decision ? null : (await checkoutRepo()).slug),
809
818
  decision,
810
819
  next,
820
+ spec,
811
821
  force: Boolean(opts.force),
812
822
  by: opts.as ?? setting('AGENT'),
813
823
  });
@@ -820,7 +830,7 @@ const commands = {
820
830
  const image = await upload(ref(answer.task), file);
821
831
  if (!opts.json) console.log(`Attached ${image.name} (${Math.ceil(image.size / 1024)} KB).`);
822
832
  }
823
- print(answer, (d) => generalAgentSummary(d, { next }));
833
+ print(answer, (d) => generalAgentSummary(d, { next, spec }));
824
834
  return;
825
835
  }
826
836
  if (sub === 'start') {
@@ -1099,6 +1109,17 @@ const commands = {
1099
1109
  ].join('\n'),
1100
1110
  );
1101
1111
  },
1112
+ async specs() {
1113
+ // A repository's specs (IDEA-31), read from its default branch on GitHub: the checkout's unless --repo names another.
1114
+ const { slug } = await checkoutRepo();
1115
+ if (args[0] === 'show') {
1116
+ const built = specRequest(args.slice(1).join(' ') || undefined, slug);
1117
+ if (built.error || !built.request) fail(built.error ?? 'bad request');
1118
+ print(await call(...built.request), (d) => specLines(d).join('\n'));
1119
+ return;
1120
+ }
1121
+ print(await call(...specsRequest(slug)), (d) => specListLines(d).join('\n'));
1122
+ },
1102
1123
  async features() {
1103
1124
  const sub = args[0];
1104
1125
  const body = () =>
@@ -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 = 67;
8
- export const CLI_FINGERPRINT = 'f7fcc49bb15b11ba';
7
+ export const CLI_VERSION = 68;
8
+ export const CLI_FINGERPRINT = 'd62907097b579845';