breakaway 1.4.0-main.2 → 1.4.0-main.21
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 +4 -1
- package/scripts/board-files.mjs +18 -0
- package/scripts/deploy-plan.mjs +135 -0
- package/scripts/lib/deploy-plan.js +102 -0
- package/scripts/lib/package-release.js +71 -0
- package/scripts/package-release.mjs +52 -0
- package/scripts/tasks/cli.js +132 -9
- package/scripts/tasks/init.js +3 -562
- package/scripts/tasks/pipeline.js +747 -0
- package/scripts/tasks.mjs +73 -8
- package/src/cli-version.js +2 -2
- package/src/init.js +590 -0
- package/src/packages.js +56 -0
- package/src/prompt.js +1 -1
- package/src/repos.js +28 -9
- package/src/specs.js +96 -0
- package/template/pipeline/deploy.yml +178 -0
- package/template/pipeline/promote.yml +203 -0
- package/template/pipeline/release.yml +212 -0
- package/template/pipeline/rollback.yml +98 -0
package/src/init.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `npx breakaway repos init <slug>` adds to a registered repository (CLD-191): the least a board-started
|
|
3
|
+
* agent needs to claim and work a task there, and Taskwarrior set up the way the board has it. Nothing here
|
|
4
|
+
* overwrites a file the repository already has: it's skipped and said, and `.gitignore` only gets the lines
|
|
5
|
+
* it lacks. Pure (the files come through `read` and `readTarget`), so it's tested without git or a disk.
|
|
6
|
+
* It's the one renderer for both ways the files arrive (BRK-132): the CLI's clone, commit, and push are in
|
|
7
|
+
* scripts/tasks.mjs, and the board's first commit to an empty repository, through its GitHub App, is in
|
|
8
|
+
* src/store-init.js, which reads the board's files from src/board-files.json (BOARD_SOURCES).
|
|
9
|
+
*/
|
|
10
|
+
import { SKILL, areaList, routinePrompt } from './prompt.js';
|
|
11
|
+
import { promptPathOf } from './repos.js';
|
|
12
|
+
|
|
13
|
+
/** This machine's folder for the board, as .taskrc names it, where a caller doesn't say (scripts/tasks/settings.js). */
|
|
14
|
+
const DEFAULT_DIR = '~/.config/breakaway';
|
|
15
|
+
|
|
16
|
+
export { routinePrompt };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The CLI on npm (BRK-7), as a repository runs it: `npx breakaway`, pinned to the major (BRK-47), so a breaking change
|
|
20
|
+
* never reaches a repository by itself. It was the `next` channel until the first stable release.
|
|
21
|
+
*/
|
|
22
|
+
export const CLI_PACKAGE = 'breakaway@1';
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export const CLI_ENTRIES = ['scripts/tasks.mjs', 'scripts/tasks/session-hook.mjs', 'scripts/tasks/message-wait.mjs'];
|
|
28
|
+
/**
|
|
29
|
+
* While breakaway was private, nothing reached npm (BRK-57), so repos init copied the two hooks and what they import
|
|
30
|
+
* under HOOKS_DIR and settings.json ran them from there (BRK-64). breakaway is public and on npm now, so the hooks run
|
|
31
|
+
* through npx again (BRK-69), and `repos init --update` removes a copy an older repos init left.
|
|
32
|
+
*/
|
|
33
|
+
export const HOOKS_FROM_COPY = false;
|
|
34
|
+
/** 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'];
|
|
36
|
+
export const HOOKS_DIR = 'tools/tasks/cli/';
|
|
37
|
+
/**
|
|
38
|
+
* The release helpers a repository's Deploy, Promote, Roll back, and Release workflows run (BRK-45), copied with what
|
|
39
|
+
* they import: the deploy helpers, and the package's version numbers (BRK-90, `npx breakaway pipeline init`).
|
|
40
|
+
*/
|
|
41
|
+
export const RELEASE_ENTRIES = [
|
|
42
|
+
'scripts/record-deployment.mjs',
|
|
43
|
+
'scripts/promote-check.mjs',
|
|
44
|
+
'scripts/release-notes.mjs',
|
|
45
|
+
'scripts/check-migrations.mjs',
|
|
46
|
+
'scripts/release-artifact.mjs',
|
|
47
|
+
'scripts/deploy-plan.mjs',
|
|
48
|
+
'scripts/package-release.mjs',
|
|
49
|
+
];
|
|
50
|
+
/** Copied unchanged: the board's shared core and stub, and the Taskwarrior settings the new .taskrc includes. */
|
|
51
|
+
const COPIED = ['prompts/core.md', 'prompts/stub.md'];
|
|
52
|
+
/** Copied with a change for the repository, or made from the board's: their source is versioned too. */
|
|
53
|
+
const ADAPTED = ['taskrc', 'scripts/task', SKILL];
|
|
54
|
+
/**
|
|
55
|
+
* Where the board's files (the prompts, the shared taskrc) go in another repository. In the board's own checkout they
|
|
56
|
+
* sit at the root, so the paths `read` takes are the board's; what is written keeps this folder.
|
|
57
|
+
*/
|
|
58
|
+
const TARGET_DIR = 'tools/tasks/';
|
|
59
|
+
/**
|
|
60
|
+
* The record of what repos init wrote into a repository (BRK-79): `--update` replaces a copied file only when it's
|
|
61
|
+
* listed here, or sits in TARGET_DIR, breakaway's own folder. Anything else at a path it copies to is the repository's.
|
|
62
|
+
*/
|
|
63
|
+
export const MANIFEST = `${TARGET_DIR}copied.json`;
|
|
64
|
+
const GITIGNORE = ['.task/', '.task-session', '.env'];
|
|
65
|
+
/** Pinned to LF so the shell scripts run on a checkout with core.autocrlf=true (BRK-41). */
|
|
66
|
+
const GITATTRIBUTES = ['scripts/task text eol=lf', '.envrc text eol=lf'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The board's own files initPlan reads (repository paths, sorted): the prompt template, what it copies as it is or
|
|
70
|
+
* adapted, and the release helpers with what they import. src/board-files.json holds them for the Worker, and
|
|
71
|
+
* scripts/tasks/init.test.js fails when it falls behind (`node scripts/board-files.mjs` writes it again).
|
|
72
|
+
*/
|
|
73
|
+
export function boardSources(read) {
|
|
74
|
+
return [...new Set(['prompts/repository.md', ...COPIED, ...ADAPTED, ...importClosure(RELEASE_ENTRIES, read)])].sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The first commit's message for a repository set up from scratch: its title and body, the CLI's and the board's alike. */
|
|
78
|
+
export function initCommitMessage(slug, { by = `npx breakaway repos init ${slug}` } = {}) {
|
|
79
|
+
return {
|
|
80
|
+
title: "Set up the task board's agent files",
|
|
81
|
+
body: `What a board-started agent needs to claim and work a task here: the agent prompt, the board's core, the session hooks (they run the CLI through npx), the tasks skill, AGENTS.md, and Taskwarrior with direnv. Added by ${by}.`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `a/b/../c` → `a/c`, for import paths (no node:path here). */
|
|
86
|
+
function normalize(path) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const part of path.split('/')) {
|
|
89
|
+
if (part === '..') out.pop();
|
|
90
|
+
else if (part !== '.' && part !== '') out.push(part);
|
|
91
|
+
}
|
|
92
|
+
return out.join('/');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const RELATIVE_IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"](\.{1,2}\/[^'"]+)['"]/gu;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every file the CLI needs, from `entries` and the relative imports they lead to (repository paths, sorted).
|
|
99
|
+
* `read(path)` gives a file's text. Tests are never followed, and only JavaScript is read for imports.
|
|
100
|
+
*/
|
|
101
|
+
export function importClosure(entries, read) {
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const queue = [...entries];
|
|
104
|
+
while (queue.length) {
|
|
105
|
+
const path = queue.shift();
|
|
106
|
+
if (seen.has(path)) continue;
|
|
107
|
+
seen.add(path);
|
|
108
|
+
// A JSON module (src/board-files.json) holds other files' text, not imports of its own.
|
|
109
|
+
if (!/\.m?js$/u.test(path)) continue;
|
|
110
|
+
const dir = path.split('/').slice(0, -1).join('/');
|
|
111
|
+
for (const m of read(path).matchAll(RELATIVE_IMPORT)) {
|
|
112
|
+
const next = normalize(`${dir}/${m[1]}`);
|
|
113
|
+
if (!/\.test\.js$/u.test(next) && !seen.has(next)) queue.push(next);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...seen].sort();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every file CLI_VERSION in src/cli-version.js versions (repository paths, sorted): what repos init copies, as it is
|
|
121
|
+
* or adapted, and the CLI's own files, which the npm package carries and an old copy still holds. cli-version.js
|
|
122
|
+
* itself is left out, so the fingerprint can live in it.
|
|
123
|
+
*/
|
|
124
|
+
export function copiedSources(read) {
|
|
125
|
+
return [
|
|
126
|
+
...new Set([...importClosure(CLI_ENTRIES, read), ...importClosure(RELEASE_ENTRIES, read), ...COPIED, ...ADAPTED]),
|
|
127
|
+
]
|
|
128
|
+
.filter((path) => path !== 'src/cli-version.js')
|
|
129
|
+
.sort();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The files an old copy of the CLI holds that npx replaces (repository paths, sorted): the CLI, its hooks, and what
|
|
134
|
+
* only they import. The release helpers' files stay copied, so they are not here.
|
|
135
|
+
*/
|
|
136
|
+
export function cliCopySources(read) {
|
|
137
|
+
const kept = new Set(importClosure(RELEASE_ENTRIES, read));
|
|
138
|
+
// scripts/install/ (BRK-9) is the npm package's own: no old copy ever held it.
|
|
139
|
+
return importClosure(CLI_ENTRIES, read).filter(
|
|
140
|
+
(path) => !kept.has(path) && path !== 'src/cli-version.js' && !path.startsWith('scripts/install/'),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The command a session hook runs: from the copy under HOOKS_DIR while breakaway is private, else through npx (BRK-7). */
|
|
145
|
+
export function hookCommand(name, { fromCopy = HOOKS_FROM_COPY, pkg = CLI_PACKAGE } = {}) {
|
|
146
|
+
if (!fromCopy) return `npx --yes ${pkg} hook ${name}`;
|
|
147
|
+
const entry = HOOK_ENTRIES.find((path) => path.includes(name === 'wait' ? 'message-wait' : 'session-hook'));
|
|
148
|
+
return `node "$CLAUDE_PROJECT_DIR/${HOOKS_DIR}${entry}"`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The session hooks .claude/settings.json runs (see HOOKS_FROM_COPY). */
|
|
152
|
+
export function sessionHooks(pkg = CLI_PACKAGE) {
|
|
153
|
+
const hook = (name, extra = {}) => ({
|
|
154
|
+
type: 'command',
|
|
155
|
+
command: hookCommand(name, { pkg }),
|
|
156
|
+
async: true,
|
|
157
|
+
...extra,
|
|
158
|
+
});
|
|
159
|
+
const session = [{ hooks: [hook('session')] }];
|
|
160
|
+
return {
|
|
161
|
+
SessionStart: session,
|
|
162
|
+
UserPromptSubmit: session,
|
|
163
|
+
PostToolUse: session,
|
|
164
|
+
Stop: [{ hooks: [hook('session'), hook('wait', { asyncRewake: true, timeout: 300 })] }],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* settings.json with the session hooks' commands set to the current one, whichever form they had: the copy's, or npx
|
|
170
|
+
* with an earlier channel or version of the package (`breakaway@next` before BRK-47). The rest is untouched.
|
|
171
|
+
*/
|
|
172
|
+
export function rewireHooks(text) {
|
|
173
|
+
let out = text;
|
|
174
|
+
for (const name of ['session', 'wait'])
|
|
175
|
+
for (const fromCopy of [true, false]) {
|
|
176
|
+
const was = JSON.stringify(hookCommand(name, { fromCopy })).slice(1, -1);
|
|
177
|
+
const now = JSON.stringify(hookCommand(name)).slice(1, -1);
|
|
178
|
+
out = out.split(was).join(now);
|
|
179
|
+
}
|
|
180
|
+
const current = (_, name) => JSON.stringify(hookCommand(name === 'message-wait' ? 'wait' : name)).slice(1, -1);
|
|
181
|
+
return (
|
|
182
|
+
out
|
|
183
|
+
.replace(/npx --yes breakaway(?:@[^\s"\\]+)? hook (session|wait)\b/gu, current)
|
|
184
|
+
// The old CLI copy's hook scripts, from before BRK-7, quoted or not (BRK-79): its copy is about to go.
|
|
185
|
+
.replace(
|
|
186
|
+
/node (\\")?(?:\$CLAUDE_PROJECT_DIR\/)?(?:tools\/tasks\/cli\/)?scripts\/tasks\/(session-hook|message-wait)\.mjs\1/gu,
|
|
187
|
+
(_, _q, name) => current(_, name === 'session-hook' ? 'session' : name),
|
|
188
|
+
)
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The paths a repository's AGENTS.md says came from the board, in its "Copied files." bullet as any version of repos
|
|
194
|
+
* init wrote it; a folder ends in `/`. None when AGENTS.md is the repository's own (BRK-79).
|
|
195
|
+
*/
|
|
196
|
+
export function declaredCopies(agents) {
|
|
197
|
+
const line = String(agents ?? '')
|
|
198
|
+
.split('\n')
|
|
199
|
+
.find((l) => /^- \*\*Copied files\.\*\*/u.test(l));
|
|
200
|
+
if (!line) return [];
|
|
201
|
+
return [...line.split(/ come from \[/u)[0].matchAll(/`([^`]+)`/gu)].map((m) => m[1]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A short SHA-256 of `paths` and their text: changes whenever one of them does. */
|
|
205
|
+
export async function fingerprint(paths, read) {
|
|
206
|
+
const text = paths.map((path) => `${path}\0${read(path)}\0`).join('');
|
|
207
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
208
|
+
return [...new Uint8Array(hash)]
|
|
209
|
+
.slice(0, 8)
|
|
210
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
211
|
+
.join('');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The Taskwarrior report and context for repository `slug` (IDEA-14): `task <slug>` lists its open work, and
|
|
216
|
+
* `task context <slug>` narrows everything to it and puts new tasks in it. For the shared taskrc.
|
|
217
|
+
* The default repository's pair also counts tasks without a `repo` (that is what empty means), and has no write
|
|
218
|
+
* filter: a new task with no `repo` is already its own.
|
|
219
|
+
*/
|
|
220
|
+
export function taskrcLines(slug, isDefault = false) {
|
|
221
|
+
const filter = isDefault ? `(repo: or repo:${slug})` : `repo:${slug}`;
|
|
222
|
+
return [
|
|
223
|
+
`report.${slug}.description=${slug}'s open work, best first`,
|
|
224
|
+
`report.${slug}.columns=id,wid,priority,horizon,project,tags,claim,depends.indicator,description.count`,
|
|
225
|
+
`report.${slug}.labels=ID,Work,P,Horizon,Area,Tags,Claimed by,D,Description`,
|
|
226
|
+
`report.${slug}.filter=status:pending -WAITING ${filter}`,
|
|
227
|
+
`report.${slug}.sort=urgency-`,
|
|
228
|
+
`context.${slug}.read=${filter}`,
|
|
229
|
+
...(isDefault ? [] : [`context.${slug}.write=repo:${slug}`]),
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `taskrc` with repository `slug`'s report and context added, or as it is when it has them. */
|
|
234
|
+
export function withRepoInTaskrc(taskrc, slug, isDefault = false) {
|
|
235
|
+
const text = String(taskrc);
|
|
236
|
+
if (text.includes(`context.${slug}.read=`)) return text;
|
|
237
|
+
return `${text.replace(/\n*$/u, '\n')}${taskrcLines(slug, isDefault).join('\n')}\n`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const MACHINE_MARK = '# Each repository on the board: its report and context.';
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* This machine's taskrc in the board's folder (CLD-193): its sync credentials as they are, then a report and
|
|
244
|
+
* context for each repository in `slugs` that the shared taskrc (`shared`) doesn't already have, so
|
|
245
|
+
* `task <slug>` and `task context <slug>` work in any checkout without a commit. `defaultSlug`'s pair also
|
|
246
|
+
* counts tasks without a `repo`. `npx breakaway setup` writes it; repos add, init, and remove refresh it.
|
|
247
|
+
*/
|
|
248
|
+
export function machineTaskrc(current, slugs, shared = '', defaultSlug = null) {
|
|
249
|
+
const mark = String(current ?? '').indexOf(MACHINE_MARK);
|
|
250
|
+
const own = (mark === -1 ? String(current ?? '') : String(current).slice(0, mark)).replace(/\n*$/u, '\n');
|
|
251
|
+
const missing = [...new Set(slugs)].filter((slug) => !String(shared).includes(`context.${slug}.read=`));
|
|
252
|
+
if (!missing.length) return own;
|
|
253
|
+
return `${own}${MACHINE_MARK} Refreshed by npx breakaway setup and repos add, init, and remove.\n${missing.flatMap((slug) => taskrcLines(slug, slug === defaultSlug)).join('\n')}\n`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The agent prompt's sections the core refers to by name (CLD-196), in the template's order: the heading, the
|
|
258
|
+
* repos init flag that sets it, what init asks, and the answer it takes when there's none. A default never names
|
|
259
|
+
* a rule the repository may not have: it says to read AGENTS.md, which the owner fills in next.
|
|
260
|
+
*/
|
|
261
|
+
export const PROMPT_SECTIONS = [
|
|
262
|
+
{
|
|
263
|
+
heading: 'Building',
|
|
264
|
+
flag: 'building',
|
|
265
|
+
ask: 'How is work done here (tests first, a style guide, anything about personal data)?',
|
|
266
|
+
default:
|
|
267
|
+
'Do the work the way `AGENTS.md` says. Keep each change small and to the task, and add tests for new logic where the repository has them.',
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
heading: 'Checks',
|
|
271
|
+
flag: 'checks',
|
|
272
|
+
ask: 'Which commands must pass before handing over (like `npm test` and `npm run build`)?',
|
|
273
|
+
default:
|
|
274
|
+
'There are no tests or build yet. Run any check `AGENTS.md` names, and say in the pull request what you checked by hand.',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
heading: 'Pull requests',
|
|
278
|
+
flag: 'pull-requests',
|
|
279
|
+
ask: 'How is a pull request opened (a template, anything its description must hold)?',
|
|
280
|
+
default:
|
|
281
|
+
'Use the repository’s pull request template if it has one. Otherwise say what changed and why, how you checked it, and what the owner has to do after merging.',
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
heading: 'Direction',
|
|
285
|
+
flag: 'direction',
|
|
286
|
+
ask: 'Where are the principles, settled decisions, and plans, and where do specs go?',
|
|
287
|
+
default: '`AGENTS.md` and the task itself. Specs go in `docs/specs/`, named after the task’s work ID.',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
heading: 'Dependency updates',
|
|
291
|
+
flag: 'dependency-updates',
|
|
292
|
+
ask: 'Any extra checks for a Dependabot review, and does merging deploy anything?',
|
|
293
|
+
default: 'No extra checks beyond **Checks**. Merging deploys nothing.',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
heading: 'Never share',
|
|
297
|
+
flag: 'never-share',
|
|
298
|
+
ask: 'What must never go into a task, comment, or pull request?',
|
|
299
|
+
default: 'Personal data of the project’s users, and any secret, token, or key.',
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The answer for each section from `given` (flag → text, as repos init's options hold them), else its default:
|
|
305
|
+
* `{ sections: heading → text, defaulted: headings that took the default }`.
|
|
306
|
+
*/
|
|
307
|
+
export function promptSections(given = {}) {
|
|
308
|
+
const sections = {};
|
|
309
|
+
const defaulted = [];
|
|
310
|
+
for (const s of PROMPT_SECTIONS) {
|
|
311
|
+
const text = String(given[s.flag] ?? '').trim();
|
|
312
|
+
sections[s.heading] = text || s.default;
|
|
313
|
+
if (!text) defaulted.push(s.heading);
|
|
314
|
+
}
|
|
315
|
+
return { sections, defaulted };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The `tasks` skill for another repository: links into the board's docs point at them on GitHub. */
|
|
319
|
+
export function skillFor(text, board, repo = null) {
|
|
320
|
+
const linked = String(text)
|
|
321
|
+
.replace(/\]\((?:\.\.\/)+((?:docs|tools)\/[^)]+)\)/gu, `](https://github.com/${board}/blob/main/$1)`)
|
|
322
|
+
.replace(/\]\(((?:\.\.\/)+)prompts\//gu, ']($1tools/tasks/prompts/');
|
|
323
|
+
if (!repo) return linked;
|
|
324
|
+
// The skill is breakaway's own: in another repository it names that repository's work, areas, and prompt (BRK-79).
|
|
325
|
+
const prompt = promptPathOf(repo);
|
|
326
|
+
return linked
|
|
327
|
+
.replace(/breakaway's work is on the board/gu, "This repository's work is on the board")
|
|
328
|
+
.replace(/breakaway's areas: [^.\n]+\./gu, `This repository's areas: ${areaList(repo)}.`)
|
|
329
|
+
.replace(
|
|
330
|
+
/\[`prompts\/breakaway\.md`\]\(((?:\.\.\/)+)tools\/tasks\/prompts\/breakaway\.md\)/gu,
|
|
331
|
+
(_, up) => `[\`${prompt}\`](${up}${prompt})`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A starter AGENTS.md: how this repository works with the board. The owner adds how to build here. */
|
|
336
|
+
export function agentsMd(repo, board, dir = DEFAULT_DIR) {
|
|
337
|
+
return `# Agent instructions
|
|
338
|
+
|
|
339
|
+
<!-- Started by \`npx breakaway repos init\` (breakaway's task board). Add how to build here: setup, tests, style, and anything agents must never do. -->
|
|
340
|
+
|
|
341
|
+
- **Work lives on the task board.** This repository's tasks are in the areas ${areaList(repo)}. Use the \`tasks\` skill (\`${SKILL}\`) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is breakaway's, written for this repository's areas and prompt: where it names breakaway's own files or rules, the board's part applies and the rest doesn't.
|
|
342
|
+
- **Agents started by the board** follow [\`${promptPathOf(repo)}\`](${promptPathOf(repo)}), which starts with the board's core, \`tools/tasks/prompts/core.md\`.
|
|
343
|
+
- **Copied files.** \`tools/tasks/\`, the release helpers in \`scripts/\`, and \`${SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`${MANIFEST}\` lists every file it copied, and \`repos init --update\` replaces only those: a file it doesn't list is this repository's own, even at a path breakaway copies to. \`.claude/settings.json\` holds the session hooks that show a cloud agent's output on its task, and they run through \`npx\`, so this repository carries no copy of the CLI.
|
|
344
|
+
- **Taskwarrior** (optional): \`scripts/task\`, or plain \`task\` with direnv after \`direnv allow\`, uses the board with this checkout's own \`.task/\` database, in the \`${repo.slug}\` context. \`npx breakaway setup\` connects the machine once.
|
|
345
|
+
- **Changes reach \`${repo.defaultBranch || 'main'}\` through pull requests**, which the owner merges. Never merge, force-push, or rewrite \`${repo.defaultBranch || 'main'}\`.
|
|
346
|
+
- **Never put a secret or token** in a file, task, comment, or pull request. The board's token lives in \`${dir}/tasks.env\` (or \`$BREAKAWAY_HOME/tasks.env\`) or the cloud environment's credentials, never in this repository.
|
|
347
|
+
`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const ENVRC = (
|
|
351
|
+
slug,
|
|
352
|
+
) => `# direnv: plain \`task\` in this checkout uses the task board, in ${slug}'s context. Run \`direnv allow\` once.
|
|
353
|
+
export TASKRC="$PWD/.taskrc"
|
|
354
|
+
export TASKDATA="$PWD/.task"
|
|
355
|
+
`;
|
|
356
|
+
|
|
357
|
+
const TASKRC = (
|
|
358
|
+
slug,
|
|
359
|
+
url,
|
|
360
|
+
dir,
|
|
361
|
+
) => `# The task board's Taskwarrior config for ${slug}. Use it through scripts/task or direnv (.envrc),
|
|
362
|
+
# which also point TASKDATA at this checkout's own .task/ database.
|
|
363
|
+
include tools/tasks/taskrc
|
|
364
|
+
# The board this checkout uses; the CLI reads it too when BREAKAWAY_URL isn't set (docs/tasks.md#another-install).
|
|
365
|
+
sync.server.url=${url}
|
|
366
|
+
# Plain \`task\` shows ${slug}'s work, and new tasks land in it; \`task context none\` shows every repository.
|
|
367
|
+
context=${slug}
|
|
368
|
+
default.command=${slug}
|
|
369
|
+
|
|
370
|
+
# sync.server.client_id and sync.encryption_secret, written by \`npx breakaway setup\` (in the board's checkout).
|
|
371
|
+
include ${dir}/taskrc
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The plan for repository `repo` (a registry row): `files` to write (`{path, content, mode?}` or a symlink
|
|
376
|
+
* `{path, link}`; `changed` when it replaces the repository's copy), `skipped` paths it already has,
|
|
377
|
+
* `removals` files of an old CLI copy to delete (with `update` only), `current` copied files that are already the same, `notes` the owner should read, and `todo` for what's left
|
|
378
|
+
* to fill in. `read(path)` reads this checkout (the board's); `readTarget(path)` reads the new repository's,
|
|
379
|
+
* null when the file isn't there. `board` is the board's own repository, owner/name; `url` is the board's
|
|
380
|
+
* address and `configDir` this machine's folder for it as .taskrc names it (`~/.config/…`), both from the CLI.
|
|
381
|
+
*
|
|
382
|
+
* With `update` (repos init --update), the files copied from the board (the CLI, the release helpers, the core and stub, the skill,
|
|
383
|
+
* the shared taskrc, scripts/task) replace the repository's copies when they differ; the repository's own
|
|
384
|
+
* (its agent prompt, AGENTS.md, .taskrc, .envrc, package.json, .claude/settings.json) are still never touched.
|
|
385
|
+
*/
|
|
386
|
+
export function initPlan({
|
|
387
|
+
repo,
|
|
388
|
+
board,
|
|
389
|
+
read,
|
|
390
|
+
readTarget,
|
|
391
|
+
url,
|
|
392
|
+
configDir = DEFAULT_DIR,
|
|
393
|
+
update = false,
|
|
394
|
+
sections = {},
|
|
395
|
+
defaulted = [],
|
|
396
|
+
}) {
|
|
397
|
+
const files = [];
|
|
398
|
+
const skipped = [];
|
|
399
|
+
const current = [];
|
|
400
|
+
const notes = [];
|
|
401
|
+
const todo = [];
|
|
402
|
+
const add = (path, content, extra = {}) => {
|
|
403
|
+
if (readTarget(path) !== null) skipped.push(path);
|
|
404
|
+
else files.push({ path, content, ...extra });
|
|
405
|
+
};
|
|
406
|
+
// What an earlier run recorded writing (BRK-79): null for a repository set up before the record existed.
|
|
407
|
+
const recorded = (() => {
|
|
408
|
+
try {
|
|
409
|
+
const files = JSON.parse(readTarget(MANIFEST) ?? 'null')?.files;
|
|
410
|
+
return Array.isArray(files) ? new Set(files) : null;
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
})();
|
|
415
|
+
// Set up before the record: the AGENTS.md repos init wrote says the copied files came from the board, and a
|
|
416
|
+
// repository's own AGENTS.md doesn't, so only then are files at the copied paths breakaway's.
|
|
417
|
+
const declared = recorded === null ? declaredCopies(readTarget('AGENTS.md')) : [];
|
|
418
|
+
/** Whether repos init wrote `path` here: in breakaway's own folder, on the record, or in AGENTS.md's copied files. */
|
|
419
|
+
const owns = (path) =>
|
|
420
|
+
path.startsWith(TARGET_DIR) ||
|
|
421
|
+
Boolean(recorded?.has(path)) ||
|
|
422
|
+
declared.some((d) => (d.endsWith('/') ? path.startsWith(d) : path === d));
|
|
423
|
+
const ours = new Set();
|
|
424
|
+
const theirs = [];
|
|
425
|
+
/**
|
|
426
|
+
* A file copied from the board: added when it's missing, and with `update`, replaced when it differs, but only when
|
|
427
|
+
* repos init wrote it (the record lists it, or it's in breakaway's own folder). A repository's own file at a path
|
|
428
|
+
* breakaway copies to is left alone (BRK-79).
|
|
429
|
+
*/
|
|
430
|
+
const copy = (path, content, extra = {}) => {
|
|
431
|
+
const there = readTarget(path);
|
|
432
|
+
if (there === null) {
|
|
433
|
+
files.push({ path, content, ...extra });
|
|
434
|
+
ours.add(path);
|
|
435
|
+
} else if (there === content) {
|
|
436
|
+
(update ? current : skipped).push(path);
|
|
437
|
+
ours.add(path);
|
|
438
|
+
} else if (!update) skipped.push(path);
|
|
439
|
+
else if (owns(path)) {
|
|
440
|
+
files.push({ path, content, ...extra, changed: true });
|
|
441
|
+
ours.add(path);
|
|
442
|
+
} else {
|
|
443
|
+
skipped.push(path);
|
|
444
|
+
theirs.push(path);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const prompt = promptPathOf(repo);
|
|
449
|
+
add(prompt, routinePrompt(read('prompts/repository.md'), repo, sections));
|
|
450
|
+
if (!skipped.includes(prompt)) {
|
|
451
|
+
const open = PROMPT_SECTIONS.map((s) => s.heading).filter((h) => !String(sections[h] ?? '').trim());
|
|
452
|
+
if (open.length) todo.push(`${prompt}: fill in each <…> (${open.join(', ')})`);
|
|
453
|
+
const took = defaulted.filter((h) => !open.includes(h));
|
|
454
|
+
if (took.length) todo.push(`${prompt}: check the sections that took the default (${took.join(', ')})`);
|
|
455
|
+
}
|
|
456
|
+
for (const path of COPIED) copy(TARGET_DIR + path, read(path));
|
|
457
|
+
copy(`${TARGET_DIR}taskrc`, withRepoInTaskrc(read('taskrc'), repo.slug, Boolean(repo.isDefault)));
|
|
458
|
+
// A release helper the repository keeps as its own doesn't bring the files breakaway's version imports (BRK-79).
|
|
459
|
+
const kept = (entry) => {
|
|
460
|
+
const there = readTarget(entry);
|
|
461
|
+
return there === null || there === read(entry) || (update && owns(entry));
|
|
462
|
+
};
|
|
463
|
+
const needed = new Set(importClosure(RELEASE_ENTRIES.filter(kept), read));
|
|
464
|
+
for (const path of importClosure(RELEASE_ENTRIES, read))
|
|
465
|
+
if (needed.has(path) || readTarget(path) !== null) copy(path, read(path));
|
|
466
|
+
if (HOOKS_FROM_COPY) for (const path of importClosure(HOOK_ENTRIES, read)) copy(HOOKS_DIR + path, read(path));
|
|
467
|
+
// An old copy of the CLI is replaced by npx: with update, its files go. The src/ files it imported may be the
|
|
468
|
+
// repository's own by now, so those are only named.
|
|
469
|
+
const old = update && !HOOKS_FROM_COPY ? cliCopySources(read).filter((path) => readTarget(path) !== null) : [];
|
|
470
|
+
const removals = old.filter((path) => path.startsWith('scripts/'));
|
|
471
|
+
// The hook copy of BRK-64 goes once the hooks run through npx again.
|
|
472
|
+
if (update && !HOOKS_FROM_COPY)
|
|
473
|
+
for (const path of importClosure(HOOK_ENTRIES, read))
|
|
474
|
+
if (readTarget(HOOKS_DIR + path) !== null) removals.push(HOOKS_DIR + path);
|
|
475
|
+
const leftover = old.filter((path) => !path.startsWith('scripts/'));
|
|
476
|
+
if (leftover.length)
|
|
477
|
+
notes.push(
|
|
478
|
+
`${leftover.join(', ')} came with the old copy of the CLI. Nothing here needs them now: delete the ones this repository doesn't use itself.`,
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
add('.claude/settings.json', `${JSON.stringify({ hooks: sessionHooks() }, null, 2)}\n`);
|
|
482
|
+
if (skipped.includes('.claude/settings.json')) {
|
|
483
|
+
const there = readTarget('.claude/settings.json');
|
|
484
|
+
const rewired = update ? rewireHooks(there) : there;
|
|
485
|
+
if (rewired !== there) {
|
|
486
|
+
skipped.splice(skipped.indexOf('.claude/settings.json'), 1);
|
|
487
|
+
files.push({ path: '.claude/settings.json', content: rewired, changed: true });
|
|
488
|
+
} else if (
|
|
489
|
+
!there.includes(hookCommand('session')) &&
|
|
490
|
+
!(HOOKS_FROM_COPY && there.includes('scripts/tasks/session-hook.mjs'))
|
|
491
|
+
)
|
|
492
|
+
notes.push(
|
|
493
|
+
`.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.'}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (readTarget('.claude/skills') === null) files.push({ path: '.claude/skills', link: '../.agents/skills' });
|
|
497
|
+
copy(SKILL, skillFor(read(SKILL), board, repo));
|
|
498
|
+
add('AGENTS.md', agentsMd(repo, board, configDir));
|
|
499
|
+
if (!skipped.includes('AGENTS.md')) todo.push('AGENTS.md: add how to build in this repository');
|
|
500
|
+
|
|
501
|
+
const pkg = readTarget('package.json');
|
|
502
|
+
if (pkg === null) {
|
|
503
|
+
files.push({
|
|
504
|
+
path: 'package.json',
|
|
505
|
+
content: `${JSON.stringify({ name: repo.slug, private: true, type: 'module' }, null, 2)}\n`,
|
|
506
|
+
});
|
|
507
|
+
} else {
|
|
508
|
+
skipped.push('package.json');
|
|
509
|
+
let type = null;
|
|
510
|
+
try {
|
|
511
|
+
type = JSON.parse(pkg).type ?? null;
|
|
512
|
+
} catch {
|
|
513
|
+
/* said below */
|
|
514
|
+
}
|
|
515
|
+
if (type !== 'module')
|
|
516
|
+
notes.push(
|
|
517
|
+
'package.json has no "type": "module", which the CLI\'s .js files need: add it, or run the CLI from a folder with its own package.json that has it.',
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
add('.envrc', ENVRC(repo.slug));
|
|
522
|
+
add('.taskrc', TASKRC(repo.slug, url, configDir));
|
|
523
|
+
copy('scripts/task', read('scripts/task'), { mode: 0o755 });
|
|
524
|
+
|
|
525
|
+
const ignore = readTarget('.gitignore');
|
|
526
|
+
const have = new Set(
|
|
527
|
+
String(ignore ?? '')
|
|
528
|
+
.split('\n')
|
|
529
|
+
.map((l) => l.trim()),
|
|
530
|
+
);
|
|
531
|
+
const missing = GITIGNORE.filter((l) => !have.has(l));
|
|
532
|
+
if (missing.length) {
|
|
533
|
+
const before = ignore === null ? '' : ignore.replace(/\n*$/u, '\n');
|
|
534
|
+
files.push({ path: '.gitignore', content: `${before}${missing.join('\n')}\n`, append: ignore !== null });
|
|
535
|
+
}
|
|
536
|
+
const attributes = readTarget('.gitattributes');
|
|
537
|
+
const haveAttributes = new Set(
|
|
538
|
+
String(attributes ?? '')
|
|
539
|
+
.split('\n')
|
|
540
|
+
.map((l) => l.trim()),
|
|
541
|
+
);
|
|
542
|
+
const missingAttributes = GITATTRIBUTES.filter((l) => !haveAttributes.has(l));
|
|
543
|
+
if (missingAttributes.length) {
|
|
544
|
+
const before = attributes === null ? '' : attributes.replace(/\n*$/u, '\n');
|
|
545
|
+
files.push({
|
|
546
|
+
path: '.gitattributes',
|
|
547
|
+
content: `${before}${missingAttributes.join('\n')}\n`,
|
|
548
|
+
append: attributes !== null,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
// A file the repository still runs stays, with the old copy it belongs to (BRK-79): package.json, or settings the
|
|
552
|
+
// rewiring above couldn't move to npx.
|
|
553
|
+
const settingsNow =
|
|
554
|
+
files.find((f) => f.path === '.claude/settings.json')?.content ?? readTarget('.claude/settings.json');
|
|
555
|
+
const uses = [
|
|
556
|
+
['package.json', readTarget('package.json')],
|
|
557
|
+
['.claude/settings.json', settingsNow],
|
|
558
|
+
];
|
|
559
|
+
for (const group of [(p) => p.startsWith('scripts/'), (p) => p.startsWith(HOOKS_DIR)]) {
|
|
560
|
+
const inGroup = removals.filter(group);
|
|
561
|
+
const used = uses.flatMap(([file, text]) =>
|
|
562
|
+
inGroup.filter((p) => String(text ?? '').includes(p)).map((p) => `${file} still runs ${p}`),
|
|
563
|
+
);
|
|
564
|
+
if (!used.length) continue;
|
|
565
|
+
for (const p of inGroup) removals.splice(removals.indexOf(p), 1);
|
|
566
|
+
notes.push(
|
|
567
|
+
`${used.join('; ')}, so its copy stays: switch it to npx breakaway (\`${hookCommand('session')}\` for the hooks), then run --update again to remove the copy.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (theirs.length)
|
|
571
|
+
notes.push(
|
|
572
|
+
`${theirs.join(', ')} ${theirs.length > 1 ? 'are' : 'is'} at a path breakaway copies to, but repos init has no record of writing ${theirs.length > 1 ? 'them' : 'it'}, so ${theirs.length > 1 ? 'they are left as they are' : 'it is left as it is'}. If one is breakaway's older copy, delete it and run --update again.`,
|
|
573
|
+
);
|
|
574
|
+
// The record of what is breakaway's here; a run without --update leaves an existing one alone.
|
|
575
|
+
const record = `${JSON.stringify(
|
|
576
|
+
{
|
|
577
|
+
about: `Files repos init copied from ${board} and keeps in step with it (npx breakaway repos init <slug> --update). A file not listed is this repository's own, even at a path breakaway copies to.`,
|
|
578
|
+
from: board,
|
|
579
|
+
files: [...ours].sort(),
|
|
580
|
+
},
|
|
581
|
+
null,
|
|
582
|
+
2,
|
|
583
|
+
)}\n`;
|
|
584
|
+
const recordThere = readTarget(MANIFEST);
|
|
585
|
+
if (recordThere === null) files.push({ path: MANIFEST, content: record });
|
|
586
|
+
else if (recordThere === record) current.push(MANIFEST);
|
|
587
|
+
else if (update) files.push({ path: MANIFEST, content: record, changed: true });
|
|
588
|
+
else skipped.push(MANIFEST);
|
|
589
|
+
return { files, removals, skipped, current, notes, todo };
|
|
590
|
+
}
|
package/src/packages.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Packages feed's pure half (BRK-101, docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md, section 2b): what a
|
|
3
|
+
* workflow run's annotations say it staged on npm, and what npm's public registry answers about it. Read-only: the
|
|
4
|
+
* board never publishes or approves a package; approving a staged version needs the owner's 2FA on npm.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** npm's public registry. The npmjs.com pages answer 403 to anything but a browser, so the board reads this. */
|
|
8
|
+
export const REGISTRY = 'https://registry.npmjs.org';
|
|
9
|
+
|
|
10
|
+
/** How staging works and how a person approves a staged version (with 2FA): npm's own docs. */
|
|
11
|
+
export const STAGING_DOCS = 'https://docs.npmjs.com/staged-publishing';
|
|
12
|
+
|
|
13
|
+
/** The package's page on npm; its Staged Packages tab is where the owner approves a staged version. */
|
|
14
|
+
export const packageUrl = (name, version = null) =>
|
|
15
|
+
`https://www.npmjs.com/package/${name}${version ? `/v/${version}` : ''}`;
|
|
16
|
+
|
|
17
|
+
const NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/u;
|
|
18
|
+
/** Whether `name` is a name npm would take for a package, scoped or not. */
|
|
19
|
+
export const isPackageName = (name) => typeof name === 'string' && name.length <= 214 && NAME.test(name);
|
|
20
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
21
|
+
const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The line the release flow prints when it stages a version (`::notice title=Staged on npm::…`), word for word:
|
|
25
|
+
* `<package>@<version> goes live on <dist-tag> once the owner approves it with 2FA: …`. The package may be scoped.
|
|
26
|
+
*/
|
|
27
|
+
const STAGED = /(?<=^|\s)((?:@[^\s@/]+\/)?[^\s@/]+)@(\S+) goes live on (\S+) once the owner approves it\b/gu;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The versions an annotation's message says were staged: `{ name, version, tag }` each, only names, versions, and
|
|
31
|
+
* dist-tags npm would accept, so nothing else from a run reaches a URL or the board.
|
|
32
|
+
*/
|
|
33
|
+
export function stagedIn(message) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const m of String(message ?? '').matchAll(STAGED)) {
|
|
36
|
+
const [, name, version, tag] = m;
|
|
37
|
+
if (isPackageName(name) && VERSION.test(version) && TAG.test(tag)) out.push({ name, version, tag });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A package name as the registry's path has it: a scoped one's slash is escaped. */
|
|
43
|
+
export const registryName = (name) => name.replace('/', '%2f');
|
|
44
|
+
|
|
45
|
+
/** The registry's URL for a package's metadata, or for one version of it. */
|
|
46
|
+
export const registryUrl = (name, version = null, base = REGISTRY) =>
|
|
47
|
+
`${base.replace(/\/$/u, '')}/${registryName(name)}${version ? `/${encodeURIComponent(version)}` : ''}`;
|
|
48
|
+
|
|
49
|
+
/** A package's dist-tags from its metadata, only the ones that name a version. */
|
|
50
|
+
export function distTags(metadata) {
|
|
51
|
+
const tags = metadata?.['dist-tags'];
|
|
52
|
+
if (!tags || typeof tags !== 'object') return {};
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
Object.entries(tags).filter(([tag, version]) => TAG.test(tag) && VERSION.test(String(version))),
|
|
55
|
+
);
|
|
56
|
+
}
|
package/src/prompt.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A repository's agent prompt, made from the board's template (prompts/repository.md). `repos init` writes it
|
|
3
|
-
* into the repository (
|
|
3
|
+
* into the repository (src/init.js), and the board's tests check the wizard against it, so it lives
|
|
4
4
|
* in the board's package (CLD-135). Pure.
|
|
5
5
|
*/
|
|
6
6
|
|