breakaway 2.0.1 → 2.0.2-main.2

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.
@@ -120,6 +120,12 @@ const BINDING_WORDS = {
120
120
  };
121
121
  const CRON = '^\\S+( \\S+){4}$';
122
122
  const VALUES_ELSEWHERE = 'Set with the Worker’s deploy, never here.';
123
+ /**
124
+ * Where `wrangler deploy` sets a Worker's settings from: its bindings, compatibility date and flags, Workers Logs,
125
+ * placement, and cron triggers are each a key of the wrangler config, and each deploy sets what it says (BRK-313;
126
+ * developers.cloudflare.com/workers/wrangler/configuration/, "Source of truth").
127
+ */
128
+ const DEPLOY_SETS = 'the wrangler config';
123
129
 
124
130
  /**
125
131
  * The settings the console may change, by kind (BRK-262; docs/specs/BRK-258-plan-from-the-board.md): exactly what the
@@ -132,6 +138,7 @@ export const EDITABLE = {
132
138
  fields: [
133
139
  {
134
140
  path: 'compatibilityDate',
141
+ deploy: DEPLOY_SETS,
135
142
  label: 'Compatibility date',
136
143
  type: 'text',
137
144
  pattern: '^\\d{4}-\\d{2}-\\d{2}$',
@@ -139,6 +146,7 @@ export const EDITABLE = {
139
146
  },
140
147
  {
141
148
  path: 'compatibilityFlags',
149
+ deploy: DEPLOY_SETS,
142
150
  label: 'Compatibility flags',
143
151
  type: 'names',
144
152
  pattern: '^[a-z0-9_]+$',
@@ -158,12 +166,14 @@ export const EDITABLE = {
158
166
  },
159
167
  {
160
168
  path: 'observability',
169
+ deploy: DEPLOY_SETS,
161
170
  label: 'Workers Logs',
162
171
  type: 'yesno',
163
172
  help: 'Keep the Worker’s logs on Cloudflare, to read in its dashboard.',
164
173
  },
165
174
  {
166
175
  path: 'placement',
176
+ deploy: DEPLOY_SETS,
167
177
  label: 'Placement',
168
178
  type: 'choice',
169
179
  optional: true,
@@ -172,6 +182,7 @@ export const EDITABLE = {
172
182
  },
173
183
  {
174
184
  path: 'crons',
185
+ deploy: DEPLOY_SETS,
175
186
  label: 'Cron triggers',
176
187
  type: 'names',
177
188
  pattern: CRON,
@@ -179,6 +190,7 @@ export const EDITABLE = {
179
190
  },
180
191
  {
181
192
  path: 'bindings',
193
+ deploy: DEPLOY_SETS,
182
194
  label: 'Bindings',
183
195
  type: 'bindings',
184
196
  targets: BINDABLE,
@@ -9,8 +9,9 @@
9
9
  * one `{ id, kind, name, attrs? }`. `provider` is optional and, when given, must be the environment's. `health` is
10
10
  * optional too: `{ "url": "https://…" }`, the owner's own health address the board GETs on each refresh as an active
11
11
  * check of the environment's front door (BRK-266, src/infra-health.js). Nothing else
12
- * goes at the top level: policy (BRK-181) is `policy.json` and scaling rules (BRK-186) are `scaling.json` in the same
13
- * folder, so neither name is an environment's, and envelope bounds live on the board, never in the repository.
12
+ * goes at the top level: policy (BRK-181) is `policy.json`, scaling rules (BRK-186) are `scaling.json`, and the risky
13
+ * paths (BRK-280) are `risky-paths.json` in the same folder, so none of those names is an environment's, and envelope
14
+ * bounds live on the board, never in the repository.
14
15
  */
15
16
  import { checkHealthField } from './infra-health.js';
16
17
  import { checkDesired } from './infra-provider.js';
@@ -24,7 +25,7 @@ export const desiredPath = (environment) => `${DESIRED_DIR}/${environment}.json`
24
25
  * Files in the folder that aren't an environment's: policy (BRK-181), scaling rules (BRK-186), and the template
25
26
  * short-lived environments are made from (BRK-200).
26
27
  */
27
- export const RESERVED_FILES = ['policy', 'scaling', 'short-lived'];
28
+ export const RESERVED_FILES = ['policy', 'scaling', 'short-lived', 'risky-paths'];
28
29
  /** The only version there is. */
29
30
  export const DESIRED_VERSION = 1;
30
31
  /** Bigger than any one environment needs, and small enough to keep a copy of each in the store. */
@@ -269,6 +269,8 @@ const PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/u;
269
269
  * from `template`)
270
270
  * @property {string} help one line, in plain words
271
271
  * @property {boolean} [optional] whether it can be left unset (null: the platform decides)
272
+ * @property {string} [deploy] where an app's deploy sets it too, in words (like `the wrangler config`): on a resource a
273
+ * repository's deploy flow deploys, the console says the next deploy puts back what that says (BRK-313)
272
274
  * @property {number} [min] a number's least value
273
275
  * @property {number} [max] a number's greatest value
274
276
  * @property {boolean} [integer] a number must be whole
@@ -465,6 +467,7 @@ function checkFields(provider, what, fields, settings) {
465
467
  if (!text(f.label) || !text(f.help)) fail(at, 'has no label or no help');
466
468
  if (!EDITABLE_TYPES.includes(f.type)) fail(at, `has unknown type "${f.type}"`);
467
469
  if (f.optional !== undefined && typeof f.optional !== 'boolean') fail(at, 'optional is not true or false');
470
+ if (f.deploy !== undefined && !text(f.deploy)) fail(at, 'deploy is not words');
468
471
  if (f.pattern !== undefined) {
469
472
  if (f.type !== 'text' && f.type !== 'names') fail(at, 'only text and names take a pattern');
470
473
  checkPattern(at, 'pattern', f.pattern);
package/src/init.js CHANGED
@@ -21,10 +21,15 @@ export { routinePrompt };
21
21
  */
22
22
  export const CLI_PACKAGE = 'breakaway@2';
23
23
  /**
24
- * Where the CLI starts: the command, and the two session hooks. repos init no longer copies them (BRK-7): an old copy
25
- * is replaced by npx, and these still version the CLI and say which files an old copy holds.
24
+ * Where the CLI starts: the command, and the session hooks (the edit hook since IDEA-55). repos init no longer copies
25
+ * them (BRK-7): an old copy is replaced by npx, and these still version the CLI and say which files an old copy holds.
26
26
  */
27
- export const CLI_ENTRIES = ['scripts/tasks.mjs', 'scripts/tasks/session-hook.mjs', 'scripts/tasks/message-wait.mjs'];
27
+ export const CLI_ENTRIES = [
28
+ 'scripts/tasks.mjs',
29
+ 'scripts/tasks/session-hook.mjs',
30
+ 'scripts/tasks/message-wait.mjs',
31
+ 'scripts/tasks/edit-hook.mjs',
32
+ ];
28
33
  /**
29
34
  * While breakaway was private, nothing reached npm (BRK-57), so repos init copied the two hooks and what they import
30
35
  * under HOOKS_DIR and settings.json ran them from there (BRK-64). breakaway is public and on npm now, so the hooks run
@@ -32,7 +37,24 @@ export const CLI_ENTRIES = ['scripts/tasks.mjs', 'scripts/tasks/session-hook.mjs
32
37
  */
33
38
  export const HOOKS_FROM_COPY = false;
34
39
  /** The session hooks' entry files, and where their copy goes: its own folder, so it never touches the repository's src/. */
35
- export const HOOK_ENTRIES = ['scripts/tasks/session-hook.mjs', 'scripts/tasks/message-wait.mjs'];
40
+ export const HOOK_ENTRIES = [
41
+ 'scripts/tasks/session-hook.mjs',
42
+ 'scripts/tasks/message-wait.mjs',
43
+ 'scripts/tasks/edit-hook.mjs',
44
+ ];
45
+ /** Each session hook's name (`npx breakaway hook <name>`) → its entry file. */
46
+ const HOOK_FILES = { session: 'session-hook', wait: 'message-wait', edit: 'edit-hook' };
47
+ /**
48
+ * The edit tools the edit hook runs on (IDEA-55 section 1a), as a PreToolUse matcher: exact tool names split by `|`
49
+ * (source: the matcher table in Claude Code's hooks reference, https://code.claude.com/docs/en/hooks).
50
+ */
51
+ export const EDIT_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
52
+ /**
53
+ * The edit hook's `timeout`, in seconds: the field is in seconds and a command hook's defaults to 600, and a PreToolUse
54
+ * command hook that times out doesn't block the tool call (source: https://code.claude.com/docs/en/hooks, the hook
55
+ * handler fields and PreToolUse). Room for npx to start the CLI and for the hook's own wait on the board (3 s).
56
+ */
57
+ export const EDIT_HOOK_TIMEOUT = 15;
36
58
  export const HOOKS_DIR = 'tools/tasks/cli/';
37
59
  /**
38
60
  * The release helpers a repository's Deploy, Promote, Roll back, and Release workflows run (BRK-45), copied with what
@@ -84,7 +106,7 @@ export function pluginSettings() {
84
106
 
85
107
  /** Whether a hook command is one of the session hooks repos init wrote: through npx, any version, or an old copy's. */
86
108
  const BOARD_HOOK =
87
- /\bbreakaway(?:@[^\s"]+)? hook (?:session|wait)\b|scripts\/tasks\/(?:session-hook|message-wait)\.mjs/u;
109
+ /\bbreakaway(?:@[^\s"]+)? hook (?:session|wait|edit)\b|scripts\/tasks\/(?:session-hook|message-wait|edit-hook)\.mjs/u;
88
110
 
89
111
  /**
90
112
  * settings.json moved to the plugin (repos init --update): the session hooks repos init wrote are gone, with any event
@@ -229,11 +251,18 @@ export function cliCopySources(read) {
229
251
  /** The command a session hook runs: from the copy under HOOKS_DIR while breakaway is private, else through npx (BRK-7). */
230
252
  export function hookCommand(name, { fromCopy = HOOKS_FROM_COPY, pkg = CLI_PACKAGE } = {}) {
231
253
  if (!fromCopy) return `npx --yes ${pkg} hook ${name}`;
232
- const entry = HOOK_ENTRIES.find((path) => path.includes(name === 'wait' ? 'message-wait' : 'session-hook'));
254
+ const file = HOOK_FILES[/** @type {keyof typeof HOOK_FILES} */ (name)] ?? HOOK_FILES.session;
255
+ const entry = HOOK_ENTRIES.find((path) => path.endsWith(`/${file}.mjs`));
233
256
  return `node "$CLAUDE_PROJECT_DIR/${HOOKS_DIR}${entry}"`;
234
257
  }
235
258
 
236
- /** The session hooks .claude/settings.json runs (see HOOKS_FROM_COPY). */
259
+ /**
260
+ * The session hooks .claude/settings.json runs (see HOOKS_FROM_COPY). All run in the background but the edit hook
261
+ * (IDEA-55 section 1a), which claims the file an edit names before the edit runs: a hook with `async: true` runs in
262
+ * the background without blocking, so it couldn't turn an edit away (source: https://code.claude.com/docs/en/hooks,
263
+ * the `async` field). It has a short `timeout` instead (EDIT_HOOK_TIMEOUT), and a board that doesn't answer lets the
264
+ * edit through.
265
+ */
237
266
  export function sessionHooks(pkg = CLI_PACKAGE) {
238
267
  const hook = (name, extra = {}) => ({
239
268
  type: 'command',
@@ -242,9 +271,11 @@ export function sessionHooks(pkg = CLI_PACKAGE) {
242
271
  ...extra,
243
272
  });
244
273
  const session = [{ hooks: [hook('session')] }];
274
+ const edit = { type: 'command', command: hookCommand('edit', { pkg }), timeout: EDIT_HOOK_TIMEOUT };
245
275
  return {
246
276
  SessionStart: session,
247
277
  UserPromptSubmit: session,
278
+ PreToolUse: [{ matcher: EDIT_MATCHER, hooks: [edit] }],
248
279
  PostToolUse: session,
249
280
  Stop: [{ hooks: [hook('session'), hook('wait', { asyncRewake: true, timeout: 300 })] }],
250
281
  };
@@ -256,7 +287,7 @@ export function sessionHooks(pkg = CLI_PACKAGE) {
256
287
  */
257
288
  export function rewireHooks(text) {
258
289
  let out = text;
259
- for (const name of ['session', 'wait'])
290
+ for (const name of ['session', 'wait', 'edit'])
260
291
  for (const fromCopy of [true, false]) {
261
292
  const was = JSON.stringify(hookCommand(name, { fromCopy })).slice(1, -1);
262
293
  const now = JSON.stringify(hookCommand(name)).slice(1, -1);
@@ -265,7 +296,7 @@ export function rewireHooks(text) {
265
296
  const current = (_, name) => JSON.stringify(hookCommand(name === 'message-wait' ? 'wait' : name)).slice(1, -1);
266
297
  return (
267
298
  out
268
- .replace(/npx --yes breakaway(?:@[^\s"\\]+)? hook (session|wait)\b/gu, current)
299
+ .replace(/npx --yes breakaway(?:@[^\s"\\]+)? hook (session|wait|edit)\b/gu, current)
269
300
  // The old CLI copy's hook scripts, from before BRK-7, quoted or not (BRK-79): its copy is about to go.
270
301
  .replace(
271
302
  /node (\\")?(?:\$CLAUDE_PROJECT_DIR\/)?(?:tools\/tasks\/cli\/)?scripts\/tasks\/(session-hook|message-wait)\.mjs\1/gu,
@@ -592,6 +623,11 @@ export function initPlan({
592
623
  notes.push(
593
624
  `.claude/settings.json is already there: set its session hooks to \`${hookCommand('session')}\` (and \`${hookCommand('wait')}\` on Stop), or a started agent's output won't show on its task.${HOOKS_FROM_COPY ? '' : ' Hooks that run scripts/tasks/session-hook.mjs stop working once the old copy is removed.'}`,
594
625
  );
626
+ // Settings from before the edit hook (IDEA-55): agents there don't claim the files they edit until it's added.
627
+ if (!rewired.includes(hookCommand('edit')))
628
+ notes.push(
629
+ `.claude/settings.json has no edit hook: add ${JSON.stringify({ PreToolUse: sessionHooks().PreToolUse })} to its hooks, so agents claim each file before they edit it.`,
630
+ );
595
631
  }
596
632
  if (readTarget('.claude/skills') === null) files.push({ path: '.claude/skills', link: '../.agents/skills' });
597
633
  if (!usePlugin) copy(SKILL, skillFor(read(SKILL), board, repo));
package/src/model.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * plus a `tags` list, `dep_<uuid>` plus a `depends` list, `annotation_<epoch>`, and UDAs
6
6
  * (`wid`, `horizon`, `spec`, `claim`, `pr`) as plain properties. The task structure (IDEA-5): `brief`
7
7
  (the current description), `brief_by`, `done_when`, `rel_<uuid>` plus a `related` list, and `by_<epoch>`
8
- beside `annotation_<epoch>` for a comment's author.
8
+ beside `annotation_<epoch>` for a comment's author. The owner's words (BRK-284): `said_<epoch>` (the quote),
9
+ `said_from_<epoch>` (where it came from), and `said_by_<epoch>` (who put it on the task).
9
10
  */
10
11
 
11
12
  import { keepAnswers, validateQuestions } from './decision.js';
@@ -108,6 +109,34 @@ export function commentsOf(map) {
108
109
  });
109
110
  }
110
111
 
112
+ /** The longest one quote of the owner's words may be, and how many a task keeps (BRK-284). */
113
+ export const MAX_SAID = 2000;
114
+ export const MAX_SAID_COUNT = 20;
115
+ /** Where the owner's words came from: the task on the board, a message, a peloton post, a ping, a decision, or a comment. */
116
+ export const SAID_FROM = ['board', 'message', 'peloton', 'ping', 'decision', 'comment'];
117
+ /** A source, with an optional pointer to it: `peloton #2243`, `decision BRK-12`, `message 2026-10-09`. */
118
+ const SAID_SOURCE = new RegExp(`^(${SAID_FROM.join('|')})(?: [#\\w.:/-]{1,48})?$`, 'u');
119
+ const SAID_KEY = /^said_(\d+)$/u;
120
+
121
+ /**
122
+ * The owner's words on a task (BRK-284), oldest first: `{ id, text, from, by, at }`. `by` is `owner` when the owner put
123
+ * them there on the board, or the agent that quoted them; `id` is what removing one names.
124
+ */
125
+ export function ownerSaidOf(map) {
126
+ return Object.keys(map)
127
+ .map((key) => SAID_KEY.exec(key))
128
+ .filter(Boolean)
129
+ .map((m) => Number(m[1]))
130
+ .sort((a, b) => a - b)
131
+ .map((epoch) => ({
132
+ id: epoch,
133
+ text: map[`said_${epoch}`],
134
+ from: map[`said_from_${epoch}`] ?? 'board',
135
+ by: map[`said_by_${epoch}`] ?? DEFAULT_AUTHOR,
136
+ at: iso(epoch),
137
+ }));
138
+ }
139
+
111
140
  /** A JSON property Taskwarrior may have mangled → its value, or null. */
112
141
  function parseJson(text, shape) {
113
142
  try {
@@ -146,6 +175,7 @@ export function view(uuid, map, all, now = new Date()) {
146
175
  briefBy: map.brief_by ?? null,
147
176
  doneWhen: map.done_when ?? null,
148
177
  related: relatedOf(map),
178
+ ownerSaid: ownerSaidOf(map),
149
179
  status,
150
180
  project: map.project ?? null,
151
181
  priority: map.priority ?? '',
@@ -269,7 +299,8 @@ function setRelated(map, rel) {
269
299
  * changes: {description, project, priority, horizon, spec, pr, claim, wid (string or null),
270
300
  * status, due, wait, scheduled (date text or null), start: true|false, entry, end (epoch),
271
301
  * brief, done_when (text or null), addTags, removeTags, addDepends, removeDepends, addRelated,
272
- * removeRelated (arrays), annotate (text), by (who wrote the annotation or the brief)}
302
+ * removeRelated (arrays), annotate (text), by (who wrote the annotation or the brief),
303
+ * said ({ text, from, by }: the owner's words, quoted), unsay (a quote's id, to remove it)}
273
304
  */
274
305
  export function withChanges(before, changes, now = new Date()) {
275
306
  const map = { ...(before ?? {}) };
@@ -368,6 +399,30 @@ export function withChanges(before, changes, now = new Date()) {
368
399
  map[`annotation_${key}`] = String(changes.annotate);
369
400
  if (changes.by) map[`by_${key}`] = String(changes.by);
370
401
  }
402
+ if (changes.said) {
403
+ const text = String(changes.said.text ?? '').trim();
404
+ const from = String(changes.said.from ?? 'board').trim();
405
+ if (!text) throw new InputError("say what the owner said: the quote can't be empty");
406
+ if (text.length > MAX_SAID) throw new InputError(`a quote can be up to ${MAX_SAID} characters`);
407
+ if (!SAID_SOURCE.test(from))
408
+ throw new InputError(
409
+ `say where the owner said it: ${SAID_FROM.join(', ')}, optionally with a pointer like "peloton #12"`,
410
+ );
411
+ const held = ownerSaidOf(map);
412
+ if (held.some((q) => q.text === text)) throw new InputError('the task already quotes those words');
413
+ if (held.length >= MAX_SAID_COUNT)
414
+ throw new InputError(`a task keeps up to ${MAX_SAID_COUNT} quotes; the owner removes one first`);
415
+ let key = Number(nowSec);
416
+ while (map[`said_${key}`] !== undefined) key += 1;
417
+ map[`said_${key}`] = text;
418
+ map[`said_from_${key}`] = from;
419
+ map[`said_by_${key}`] = String(changes.said.by ?? DEFAULT_AUTHOR);
420
+ }
421
+ if (changes.unsay !== undefined && changes.unsay !== null) {
422
+ const key = String(changes.unsay);
423
+ if (!/^\d+$/u.test(key) || map[`said_${key}`] === undefined) throw new InputError(`the task has no quote ${key}`);
424
+ for (const prop of [`said_${key}`, `said_from_${key}`, `said_by_${key}`]) delete map[prop];
425
+ }
371
426
  if (!map.description) throw new InputError('a task needs a description');
372
427
  map.modified = nowSec;
373
428
  return map;
package/taskrc CHANGED
@@ -38,7 +38,8 @@ uda.alert.label=Alert
38
38
 
39
39
  # The task structure (docs/specs/IDEA-5-task-structure.md): the current description and what done
40
40
  # looks like, in Markdown. Comments are annotations; `brief_by` and `by_<epoch>` (who wrote it) are
41
- # plain properties Taskwarrior keeps without declaring them.
41
+ # plain properties Taskwarrior keeps without declaring them, and so are the owner's words, quoted:
42
+ # `said_<epoch>`, `said_from_<epoch>` (where they said it), and `said_by_<epoch>` (who quoted them).
42
43
  uda.brief.type=string
43
44
  uda.brief.label=Description
44
45
  uda.done_when.type=string