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 +1 -1
- package/scripts/tasks/structure.js +2 -0
- package/src/ping.js +43 -3
- package/src/specs.js +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.5.0-main.
|
|
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))
|
|
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)
|
|
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);
|