breakaway 1.5.0-main.30 → 1.5.0-main.32

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.5.0-main.30",
3
+ "version": "1.5.0-main.32",
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",
@@ -98,6 +98,7 @@ export const PING_TEMPLATE = {
98
98
  done_when: 'The new done when.',
99
99
  },
100
100
  { type: 'done', task: 'CLD-113', note: 'Does not reproduce any more; it behaves as expected.' },
101
+ { type: 'delete', task: 'CLD-114', note: 'CLD-112 covers it now.' },
101
102
  { type: 'release', task: 'CLD-111' },
102
103
  ],
103
104
  };
@@ -138,6 +139,7 @@ function proposalLine(c) {
138
139
  if (c.type === 'modify')
139
140
  return `change ${c.task}: ${['horizon', 'addTags', 'removeTags', 'brief', 'done_when'].filter((k) => k in c).join(', ')}`;
140
141
  if (c.type === 'done') return `finish ${c.task}${c.note ? ` (${c.note})` : ''}`;
142
+ if (c.type === 'delete') return `delete ${c.task}${c.note ? ` (${c.note})` : ''}`;
141
143
  return `release ${c.task}`;
142
144
  }
143
145
 
package/src/ping.js CHANGED
@@ -30,11 +30,28 @@ export function looksLikeSecret(text) {
30
30
  if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(value)) return true;
31
31
  for (const [word] of value.matchAll(/[A-Za-z0-9+/_=-]{32,}/gu)) {
32
32
  if (/^[0-9a-f]{40}$/u.test(word)) continue;
33
+ if (looksLikePath(word)) continue;
33
34
  if (/\d/u.test(word) && /[A-Za-z]/u.test(word) && !/^[a-z]+(?:-[a-z0-9]+)+$/u.test(word)) return true;
34
35
  }
35
36
  return false;
36
37
  }
37
38
 
39
+ /**
40
+ * Whether a long word is a file path or a link's path (`docs/specs/IDEA-36-peloton-planning`), not a token with
41
+ * slashes in it: it has a slash, no `+` or `=`, and every name in it splits on `.`, `_`, and `-` into short pieces,
42
+ * a commit SHA, or pieces that don't look random (a long run of letters and digits, or of mixed case and digits).
43
+ */
44
+ function looksLikePath(word) {
45
+ if (!word.includes('/') || /[+=]/u.test(word)) return false;
46
+ for (const piece of word.split(/[/._-]+/u)) {
47
+ if (!piece || /^[0-9a-f]{40}$/u.test(piece)) continue;
48
+ const digits = /\d/u.test(piece);
49
+ if (digits && /[A-Za-z]/u.test(piece) && piece.length >= 16) return false;
50
+ if (digits && /[a-z]/u.test(piece) && /[A-Z]/u.test(piece) && piece.length >= 8) return false;
51
+ }
52
+ return true;
53
+ }
54
+
38
55
  /** A ping's kind and message, cleaned, or an InputError that says what to fix. */
39
56
  export function checkPing({ kind, message }) {
40
57
  if (!PING_KINDS.includes(kind)) throw new InputError(`kind is one of ${PING_KINDS.join(', ')}`);
@@ -174,6 +191,7 @@ export function validateProposal(raw, ctx) {
174
191
 
175
192
  const changes = [];
176
193
  const finished = new Set();
194
+ const deleted = new Set();
177
195
  for (const [i, change] of list.entries()) {
178
196
  const where = `change ${i + 1} (${change?.type ?? '?'})`;
179
197
  if (!isObject(change)) throw new InputError(`${where}: each change is an object`);
@@ -274,10 +292,30 @@ export function validateProposal(raw, ctx) {
274
292
  const uuid = existing(change.task, where, { open: true });
275
293
  if (ctx.inReview(uuid))
276
294
  throw new InputError(`${where}: ${name(uuid)} has an open pull request that finishes it when it merges`);
277
- if (finished.has(uuid)) throw new InputError(`${where}: ${name(uuid)} is finished twice`);
295
+ if (finished.has(uuid))
296
+ throw new InputError(
297
+ `${where}: ${name(uuid)} is ${deleted.has(uuid) ? 'deleted and finished' : 'finished twice'} in this proposal`,
298
+ );
278
299
  finished.add(uuid);
279
300
  const note = text(change.note, 'note', MAX_NOTE, where);
280
301
  changes.push({ type: 'done', task: ctx.tasks.get(uuid).wid ?? uuid, ...(note ? { note } : {}) });
302
+ } else if (change.type === 'delete') {
303
+ // For a task the agent may not delete itself (IDEA-36 section 6): the owner deletes it in one press.
304
+ only(change, ['type', 'task', 'note'], where);
305
+ const uuid = existing(change.task, where, { open: true });
306
+ const holder = ctx.tasks.get(uuid).claim;
307
+ if (holder === ctx.by) throw new InputError(`${where}: you hold ${name(uuid)}; release it instead`);
308
+ if (holder) throw new InputError(`${where}: ${holder} has ${name(uuid)}; ask them on the peloton`);
309
+ if (ctx.inReview(uuid))
310
+ throw new InputError(`${where}: ${name(uuid)} has an open pull request that finishes it when it merges`);
311
+ if (finished.has(uuid))
312
+ throw new InputError(
313
+ `${where}: ${name(uuid)} is ${deleted.has(uuid) ? 'deleted twice' : 'finished and deleted'} in this proposal`,
314
+ );
315
+ finished.add(uuid);
316
+ deleted.add(uuid);
317
+ const note = text(change.note, 'note', MAX_NOTE, where);
318
+ changes.push({ type: 'delete', task: ctx.tasks.get(uuid).wid ?? uuid, ...(note ? { note } : {}) });
281
319
  } else if (change.type === 'release') {
282
320
  only(change, ['type', 'task'], where);
283
321
  const uuid = existing(change.task, where, { open: true });
@@ -285,7 +323,7 @@ export function validateProposal(raw, ctx) {
285
323
  if (!ctx.tasks.get(uuid).claim) throw new InputError(`${where}: ${name(uuid)} holds no claim`);
286
324
  changes.push({ type: 'release', task: ctx.tasks.get(uuid).wid ?? uuid });
287
325
  } else {
288
- throw new InputError(`${where}: type is one of add, depend, modify, done, release`);
326
+ throw new InputError(`${where}: type is one of add, depend, modify, done, delete, release`);
289
327
  }
290
328
  }
291
329
 
@@ -298,7 +336,8 @@ export function validateProposal(raw, ctx) {
298
336
  const waiting = [...graph]
299
337
  .filter(([node, set]) => set.has(uuid) && !finished.has(node))
300
338
  .map(([node]) => name(node));
301
- if (waiting.length) warnings.push(`finishing ${name(uuid)} releases ${waiting.join(', ')}`);
339
+ if (waiting.length)
340
+ warnings.push(`${deleted.has(uuid) ? 'deleting' : 'finishing'} ${name(uuid)} releases ${waiting.join(', ')}`);
302
341
  }
303
342
  return { changes, warnings };
304
343
  }
@@ -311,6 +350,7 @@ export function summarizeProposal(changes) {
311
350
  if (count('depend')) parts.push(`change ${count('depend')} dependenc${count('depend') === 1 ? 'y' : 'ies'}`);
312
351
  if (count('modify')) parts.push(`edit ${count('modify')} task${count('modify') === 1 ? '' : 's'}`);
313
352
  if (count('done')) parts.push(`finish ${count('done')} task${count('done') === 1 ? '' : 's'}`);
353
+ if (count('delete')) parts.push(`delete ${count('delete')} task${count('delete') === 1 ? '' : 's'}`);
314
354
  if (count('release')) parts.push('release a claim');
315
355
  return parts.join(', ');
316
356
  }
package/src/specs.js CHANGED
@@ -89,6 +89,71 @@ export function specMeta(name, text) {
89
89
  return { wid, title, status };
90
90
  }
91
91
 
92
+ /** The step a status takes when the owner marks a spec (BRK-215): draft to approved, approved to built. */
93
+ export const NEXT_STATUS = Object.freeze({ draft: 'approved', approved: 'built' });
94
+
95
+ /** The status after `status`, or null when it has none (built, or a word the board doesn't know). */
96
+ export const nextStatus = (status) => (Object.hasOwn(NEXT_STATUS, status) ? NEXT_STATUS[status] : null);
97
+
98
+ /** `Status:`, its word, and what its brackets say, as the template writes it: `Status: approved (1 Oct 2026)`. */
99
+ const STATUS = /(\bStatus:\s*\**\s*)([A-Za-z][\w-]*)(?:\s*\(([^)\n]*)\))?/u;
100
+
101
+ /** The index of the line under a spec's title (its first `# ` heading), or -1. */
102
+ function statusLineAt(lines) {
103
+ const at = lines.findIndex((l) => /^#\s+\S/u.test(l));
104
+ if (at < 0) return -1;
105
+ return lines.findIndex((l, i) => i > at && l.trim() !== '');
106
+ }
107
+
108
+ /**
109
+ * The status on the line under a spec's title, lowercased, and what its brackets say (null without them), or
110
+ * null when that line has no `Status:`, as specMeta reads it.
111
+ * @param {string} text
112
+ * @returns {{ status: string, detail: string | null } | null}
113
+ */
114
+ export function readStatus(text) {
115
+ const lines = String(text).split('\n');
116
+ const at = statusLineAt(lines);
117
+ const m = at < 0 ? null : STATUS.exec(lines[at]);
118
+ return m ? { status: m[2].toLowerCase(), detail: m[3]?.trim() || null } : null;
119
+ }
120
+
121
+ /**
122
+ * `text` with the status on the line under its title set to `status (detail)`, and nothing else changed: the rest
123
+ * of that line, a later `Status:` in the body, and the line endings stay. Null when that line has no `Status:`.
124
+ * @param {string} text
125
+ * @param {string} status
126
+ * @param {string} detail
127
+ */
128
+ export function withStatus(text, status, detail) {
129
+ const lines = String(text).split('\n');
130
+ const at = statusLineAt(lines);
131
+ if (at < 0 || !STATUS.test(lines[at])) return null;
132
+ lines[at] = lines[at].replace(STATUS, (_, lead) => `${lead}${status} (${detail})`);
133
+ return lines.join('\n');
134
+ }
135
+
136
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
137
+
138
+ /** A day as the specs and the decision log write it, in UTC: `29 Sep 2026`. */
139
+ export function specDate(ms) {
140
+ const d = new Date(ms);
141
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
142
+ }
143
+
144
+ /**
145
+ * What a built spec's brackets say: the merged pull requests of the tasks that link it (`#41, #42`), else the day,
146
+ * and then when it was approved, from the brackets it had (`approved 1 Oct 2026, by the owner`).
147
+ * @param {number[]} pulls
148
+ * @param {string | null} approved
149
+ * @param {number} now
150
+ */
151
+ export function builtDetail(pulls, approved, now) {
152
+ const numbers = [...new Set(pulls.map(Number).filter((n) => Number.isInteger(n) && n > 0))].sort((a, b) => a - b);
153
+ const built = numbers.length ? numbers.map((n) => `#${n}`).join(', ') : `as of ${specDate(now)}`;
154
+ return approved ? `${built}; approved ${approved}` : built;
155
+ }
156
+
92
157
  /** Newest first by the work ID's number, then by path; specs without a work ID last. */
93
158
  export function bySpecOrder(a, b) {
94
159
  const n = (s) => (s.wid ? Number(s.wid.split('-')[1]) : -1);