@frontfactory/brigade 1.1.1 → 1.2.0

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/README.md CHANGED
@@ -4,7 +4,11 @@
4
4
  [![Downloads](https://flat.badgen.net/npm/dt/@frontfactory/brigade)](https://www.npmjs.com/package/@frontfactory/brigade)
5
5
  [![License](https://flat.badgen.net/npm/license/@frontfactory/brigade)](https://www.npmjs.com/package/@frontfactory/brigade)
6
6
 
7
- Sets up a Claude Code architecture in any project, new or existing, without fuss.
7
+ Claude Code, organized like a kitchen brigade.
8
+
9
+ Brigade sets up a Claude Code architecture in any web project, new or existing, without fuss: a main session that
10
+ thinks and orchestrates, an `implementer` that writes the code and a `git-operator` that handles git, each limited to
11
+ its mission, with guardrails built in.
8
12
 
9
13
  ```sh
10
14
  npx @frontfactory/brigade init
@@ -14,18 +18,19 @@ npx @frontfactory/brigade init
14
18
 
15
19
  Three roles, each limited to its mission:
16
20
 
17
- | Role | Model (default effort) | Mission |
18
- |---|---|---|
19
- | Main session | Opus in Plan Mode (medium) | Discuss, think, orchestrate, verify, propose, answer. Goes back and forth with the subagents. |
20
- | `implementer` | Sonnet (medium) | Development, nothing else. |
21
- | `git-operator` | Haiku (low) | Git, nothing else, once the work is done. |
21
+ | Role | Model (default effort) | Mission |
22
+ |----------------|----------------------------|-----------------------------------------------------------------------------------------------|
23
+ | Main session | Opus in Plan Mode (medium) | Discuss, think, orchestrate, verify, propose, answer. Goes back and forth with the subagents. |
24
+ | `implementer` | Sonnet (medium) | Development, nothing else. |
25
+ | `git-operator` | Haiku (low) | Git, nothing else, once the work is done. |
22
26
 
23
27
  Plus base rules for work, security and efficiency, adapted to the stack (web for now). The setup is shared with the
24
28
  project and committed with it. Each developer can override their own effort locally with `/brigade-effort`.
25
29
 
26
30
  The rest of the kit, all under `.claude/`:
27
31
 
28
- - Skills: `session-start` (where things stand before the first task), `feature` (branch + state file for multi-step work),
32
+ - Skills: `session-start` (where things stand before the first task), `feature` (branch + state file for multi-step
33
+ work),
29
34
  `audit` (keep an audit file between sessions).
30
35
  - Commands: `/brigade-effort` (your effort per role) and `/brigade-language` (your reply language), both local and
31
36
  not committed.
@@ -35,21 +40,29 @@ The rest of the kit, all under `.claude/`:
35
40
 
36
41
  ## Install
37
42
 
38
- `init` detects the default branch, the package manager, the commit convention and the language, and asks you to
39
- validate each one. `--yes` (or `-y`) accepts everything detected. `--stack <name>` forces the stack instead of
40
- detecting it (only `web` for now).
43
+ `init` detects the default branch, the package manager, the commit convention and the language, then shows what will
44
+ be set up in one card. Choose "Install as is" (the default) or "Customize" to answer every option one by one. An option
45
+ that cannot be detected is asked even in the express path. After "Customize", or when something had to be asked, a recap
46
+ is shown before anything is written: "Everything is right" installs, "Change something" lets you pick the groups to
47
+ redo (branches, package manager, commit format, languages) and shows the recap again. There are two language
48
+ questions: the language of Claude's replies, then the language of code and docs together, or "Different for code and
49
+ docs" to choose each apart. Customize also asks your own reply language, which stays on your machine. Cancelling writes
50
+ nothing. `--yes` (or `-y`) accepts everything detected. `--stack <name>` forces the stack instead of detecting it (only
51
+ `web` for now).
41
52
 
42
53
  Brigade never touches your `CLAUDE.md`: its instructions live in `.claude/rules/brigade.md`, which Claude Code loads
43
54
  at launch like a `CLAUDE.md`.
44
55
 
45
56
  When Brigade is already installed, `init` asks whether to keep the installed version or update to the one you run.
46
- Updating does not repeat the setup: your stored answers are reused and only the options that are new to the manifest
47
- are asked. On the same version it offers to change your options (every question is asked again, files only change if
48
- an answer does) or to leave everything as it is. Without a terminal, it updates.
57
+ Updating does not repeat the setup: your answers stored in `.claude/brigade.json` are reused and only the options the
58
+ new version added are asked (your personal reply language is not one of them). A downgrade does the same and keeps the
59
+ answers the older version does not know. On the same version it offers to change your options (every question is asked
60
+ again, files only change if an answer does) or to leave everything as it is. Without a terminal, it updates.
49
61
 
50
62
  An existing file is never overwritten. If it differs from the kit, a `.proposed` file is written next to it; if it is
51
63
  identical, nothing happens. A file Brigade wrote and nobody touched is updated. `.gitignore` and `.gitattributes` only
52
- get the lines they miss. `.claude/context/decisions.md` and `.claude/context/architecture.md` are written once and then belong to the
64
+ get the lines they miss. `.claude/context/decisions.md` and `.claude/context/architecture.md` are written once and then
65
+ belong to the
53
66
  project: they are never updated nor proposed over.
54
67
 
55
68
  ## Update
@@ -57,20 +70,24 @@ project: they are never updated nor proposed over.
57
70
  Re-run `npx @frontfactory/brigade init`. Kit files you never touched are updated. A file you edited is left as it is
58
71
  and a `<file>.proposed` is written next to it: diff it, merge what you want, then delete the `.proposed`.
59
72
 
60
- An update does not ask your options again. It reuses the answers stored in `.claude/brigade.json` and asks only the
61
- options the new version added (your personal reply language is not one of them). A downgrade does the same and keeps
62
- the answers the older version does not know. To change your options on purpose, run `init` again on the same version
63
- and choose "Change my options".
73
+ Older installs had your options written inside `.claude/rules/brigade.md`. They now live in the generated
74
+ `.claude/rules/brigade-options.md`, and `brigade.md` is pure kit. If you never edited your `brigade.md`, it is updated
75
+ silently. If you did (or the manifest is lost), a `brigade.md.proposed` is written with a note: merge it, or delete
76
+ your edits and re-run.
77
+
78
+ To change your options on purpose, run `init` again on the same version and choose "Change my options" (see Install).
64
79
 
65
80
  Brigade records what it did in `.claude/brigade.json`: its version, your answers and the sha256 of each file it wrote.
66
- That is how it tells an untouched file from an edited one. Do not edit it by hand. Commit it with the rest of `.claude/`:
81
+ That is how it tells an untouched file from an edited one. Do not edit it by hand. Commit it with the rest of
82
+ `.claude/`:
67
83
  the install is shared by the whole team, and anyone can then update it from any machine.
68
84
 
69
85
  ## Troubleshooting
70
86
 
71
87
  - `Unknown stack` or `Unknown option` from `init`: only `--yes` (`-y`) and `--stack <name>` exist, and `web` is the only
72
88
  stack for now. Run `npx @frontfactory/brigade --help` to see the usage.
73
- - `.proposed` files keep coming back: a proposal is rewritten only when the kit changed since the last one. Once you have
89
+ - `.proposed` files keep coming back: a proposal is rewritten only when the kit changed since the last one. Once you
90
+ have
74
91
  merged what you want, delete the `.proposed`.
75
92
  - No terminal (CI, pipe): nothing is asked. `init` behaves like `--yes`: a new install accepts every detected answer, an
76
93
  existing install is updated with its stored answers, and a new option gets its detected or default value. The run
@@ -94,18 +111,23 @@ Only `git-operator` writes to git, enforced by a hook. Writing on the default or
94
111
  `rebase` ask for confirmation; destructive commands are denied. Brigade proposes a commit once a piece of work is
95
112
  finished and never commits without your go.
96
113
 
97
- A feature (work over several steps or sessions) gets its own branch and a state file `.claude/context/features/<branch>.md` (slashes become dashes), committed with the branch, so
114
+ A feature (work over several steps or sessions) gets its own branch and a state file
115
+ `.claude/context/features/<branch>.md` (slashes become dashes), committed with the branch, so
98
116
  the work survives between developers and machines. `CLAUDE.local.md` stays personal.
99
117
 
100
118
  ## Add a stack
101
119
 
102
- Create `templates/<name>/` with the same layout as `templates/web/`, then add one entry in `src/stacks.js`. Placeholders:
120
+ Create `templates/<name>/` with the same layout as `templates/web/`, then add one entry in `src/stacks.js`.
121
+ Placeholders:
103
122
  `__DEFAULT_BRANCH__`, `__BASE_BRANCH__`, `__PACKAGE_MANAGER__`, `__COMMIT_FORMAT__`, `__REPLY_LANGUAGE__`,
104
- `__CODE_LANGUAGE__`, `__DOCS_LANGUAGE__`.
123
+ `__CODE_LANGUAGE__`, `__DOCS_LANGUAGE__`. Do not use them in `.claude/rules/brigade.md`: it stays pure kit, and the
124
+ answers reach it through `.claude/rules/brigade-options.md`, which Brigade generates from the answers (see
125
+ `renderOptions` in `src/install.js`).
105
126
 
106
127
  ## Develop
107
128
 
108
- `npm test` runs the tests (`node --test`, no dependency). Commits follow Conventional Commits, checked by commitlint. Node >= 24 is required.
129
+ `npm test` runs the tests (`node --test`, no dependency). Commits follow Conventional Commits, checked by commitlint.
130
+ Node >= 24 is required.
109
131
 
110
132
  Releases are automatic: on a push to `main` or `beta`, once the tests pass, the CI runs semantic-release, which
111
133
  derives the version from the commits, updates the changelog and publishes `@frontfactory/brigade` to npm.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frontfactory/brigade",
3
- "version": "1.1.1",
4
- "description": "Sets up a Claude Code architecture in any web project: a main session, a Sonnet implementer, a Haiku git-operator and a git guard.",
3
+ "version": "1.2.0",
4
+ "description": "Claude Code, organized like a kitchen brigade.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "prepare": "husky",
package/src/cli.js CHANGED
@@ -1,5 +1,7 @@
1
- import { CONVENTIONS } from './conventions.js';
1
+ import { parseArgs } from 'node:util';
2
+
2
3
  import { runInit } from './init.js';
4
+ import { recapRows } from './options.js';
3
5
  import { STACKS } from './stacks.js';
4
6
  import { VERSION } from './version.js';
5
7
 
@@ -22,41 +24,51 @@ const WORDS = {
22
24
  };
23
25
 
24
26
  function parse(args) {
25
- const options = { yes: false, stack: undefined };
26
- for (let i = 0; i < args.length; i++) {
27
- if (args[i] === '--yes' || args[i] === '-y') options.yes = true;
28
- else if (args[i] === '--stack') {
29
- const value = args[++i];
30
- if (!value || value.startsWith('-')) throw new Error(`--stack needs a value (${Object.keys(STACKS).join(', ')})`);
31
- options.stack = value;
27
+ let values;
28
+ try {
29
+ ({ values } = parseArgs({ args, options: { yes: { type: 'boolean', short: 'y' }, stack: { type: 'string' } } }));
30
+ } catch (error) {
31
+ const arg = /'([^']+)'/.exec(error.message)?.[1];
32
+ if (error.code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE' && arg?.startsWith('--stack')) {
33
+ throw new Error(`--stack needs a value (${Object.keys(STACKS).join(', ')})`);
32
34
  }
33
- else throw new Error(`Unknown option: ${args[i]}`);
35
+ if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION' || error.code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL') {
36
+ throw new Error(`Unknown option: ${arg}`);
37
+ }
38
+ throw error;
34
39
  }
40
+ const options = { yes: values.yes ?? false, stack: values.stack };
41
+ if (options.stack === '') throw new Error(`--stack needs a value (${Object.keys(STACKS).join(', ')})`);
35
42
  if (options.stack && !STACKS[options.stack]) throw new Error(`Unknown stack: ${options.stack}`);
36
43
  return options;
37
44
  }
38
45
 
46
+ // Plain-text rows, the label padded to a common column.
47
+ const plain = (rows) => rows.map(([label, value]) => `${label.padEnd(17)}${value}`).join('\n');
48
+
39
49
  function summary({ version, answers, report, ignored, repo, personal, local, asked = [] }) {
40
- const lines = report.map(({ path, status }) => `${WORDS[status].padEnd(28)} ${path}${status === 'proposed' ? '.proposed' : ''}`);
50
+ const files = report.map(({ path, status }) => ({
51
+ done: status === 'created' || status === 'updated',
52
+ label: `${path}${status === 'proposed' ? '.proposed' : ''}`,
53
+ detail: WORDS[status],
54
+ plain: `${WORDS[status].padEnd(28)} ${path}${status === 'proposed' ? '.proposed' : ''}`
55
+ }));
41
56
  for (const { path, status, added } of ignored) {
42
- if (status === 'updated') lines.push(`${'lines added'.padEnd(28)} ${path} (${added.join(', ')})`);
57
+ if (status === 'updated') files.push({ done: true, label: path, detail: `lines added (${added.join(', ')})`, plain: `${'lines added'.padEnd(28)} ${path} (${added.join(', ')})` });
43
58
  }
44
59
 
45
- const settings = [
46
- `Brigade ${version}`,
47
- `Default branch ${answers.defaultBranch}`,
48
- `Reference branch ${answers.baseBranch}`,
49
- `Package manager ${answers.packageManager}`,
50
- `Commit format ${CONVENTIONS[answers.commitConvention]?.label ?? 'Custom'}`,
51
- `Replies ${answers.replyLanguage} (project default)`,
52
- `Code ${answers.codeLanguage}`,
53
- `Documentation ${answers.docsLanguage}`,
54
- `Replies for you ${personal.language && local?.status === 'written' ? `${personal.language} (your machine only)` : 'project default'}`
60
+ const notes = report.filter(({ note }) => note).map(({ path, note }) => `${path}: ${note}`);
61
+ const rows = [
62
+ ['Brigade', version],
63
+ ...recapRows(answers),
64
+ ['Replies for you', personal.language && local?.status === 'written' ? `${personal.language} (your machine only)` : 'project default']
55
65
  ];
56
66
  const added = asked.flat().map((key) => `${key}: ${answers[key]}`).join(', ');
57
- return { files: lines.join('\n'), settings: settings.join('\n'), added, kept: report.some(({ status }) => status === 'proposed'), repo };
67
+ return { files, notes, rows, added, kept: report.some(({ status }) => status === 'proposed'), repo };
58
68
  }
59
69
 
70
+ const plainFiles = (files, notes) => [...files.map(({ plain }) => plain), ...(notes.length ? ['', ...notes] : [])].join('\n');
71
+
60
72
  function print(result, interactive) {
61
73
  const { prompter } = result;
62
74
  if (result.kept) {
@@ -65,20 +77,26 @@ function print(result, interactive) {
65
77
  return;
66
78
  }
67
79
 
68
- const { files, settings, added, kept, repo } = summary(result);
80
+ const { files, notes, rows, added, kept, repo } = summary(result);
69
81
 
70
82
  if (!interactive) {
71
83
  const news = added ? `\n\nNew options, default used: ${added}` : '';
72
- console.log(`Brigade installed (stack: ${result.stack}).\n\n${files}\n\n${settings}${news}\n\nCommit .claude/ including .claude/brigade.json so the install is shared with your team and can be updated from any machine.`);
84
+ console.log(`Brigade installed (stack: ${result.stack}).\n\n${plainFiles(files, notes)}\n\n${plain(rows)}${news}\n\nCommit .claude/ including .claude/brigade.json so the install is shared with your team and can be updated from any machine.`);
73
85
  return;
74
86
  }
75
87
 
76
- prompter.note(files, 'Files');
77
- prompter.note(settings, 'Your setup');
88
+ prompter.step(3, 3, 'Installation');
89
+ prompter.files(files);
90
+ for (const note of notes) prompter.info(note);
91
+ prompter.card('Your setup', rows);
78
92
  if (added) prompter.info(`New options set up: ${added}`);
79
93
  if (kept) prompter.warn('Some files of yours differ from the kit: they were kept. Merge the .proposed version if you want it.');
80
94
  if (!repo) prompter.warn('Remember to run `git init` before your first commit.');
81
- prompter.outro('Done. Commit `.claude/` with `.claude/brigade.json` to share the install with your team: anyone can then update it from any machine. Run `claude` here, use Plan Mode (Shift+Tab) to discuss, and /brigade-language and /brigade-effort for your own settings.');
95
+ prompter.done('Done. Brigade is ready.', [
96
+ 'Commit `.claude/` (with `brigade.json`) to share the install.',
97
+ 'Run `claude` here, Shift+Tab for Plan Mode.',
98
+ '/brigade-language and /brigade-effort set your own preferences.'
99
+ ]);
82
100
  }
83
101
 
84
102
  const isHelp = (arg) => arg === '--help' || arg === '-h';
package/src/init.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isGitRepo } from './detect.js';
2
- import { appendMissingLines, hasKitFiles, installFiles, MANIFEST, readManifest, writeLocalSettings, writeManifest } from './install.js';
2
+ import { appendMissingLines, hasKitFiles, installFiles, MANIFEST, readManifest, writeLocalSettings, writeManifest, writeOptionsFile } from './install.js';
3
3
  import { askOptions } from './options.js';
4
4
  import { createPrompter } from './prompt.js';
5
5
  import { DEFAULT_STACK, STACKS, detectStack } from './stacks.js';
@@ -38,7 +38,7 @@ export async function runInit({ cwd, yes = false, stack, stackDir, version = VER
38
38
 
39
39
  prompter ??= createPrompter({ interactive: Boolean(interactive) });
40
40
  const repo = isGitRepo(cwd);
41
- prompter.intro(`Brigade ${version}`);
41
+ prompter.banner(version);
42
42
  prompter.info(`Stack: ${name}${stack ? '' : ' (the only one available for now)'}.`);
43
43
 
44
44
  const installed = previous?.version;
@@ -59,6 +59,7 @@ export async function runInit({ cwd, yes = false, stack, stackDir, version = VER
59
59
  const { answers, personal, asked } = await askOptions({ cwd, prompter, previous: previous?.answers ?? {}, full: !update });
60
60
 
61
61
  const { report, hashes } = installFiles({ cwd, stackDir: dir, answers, previous });
62
+ report.push(writeOptionsFile({ cwd, answers }));
62
63
  writeManifest({ cwd, version, stack: name, answers, hashes });
63
64
  const ignored = appendMissingLines(cwd);
64
65
  const local = personal.language ? writeLocalSettings(cwd, { language: personal.language }) : null;
package/src/install.js CHANGED
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, st
3
3
  import { dirname, join, relative } from 'node:path';
4
4
 
5
5
  export const MANIFEST = '.claude/brigade.json';
6
+ export const OPTIONS_FILE = '.claude/rules/brigade-options.md';
6
7
 
7
8
  // Lines Brigade appends to the project files when missing. Existing lines are never touched or duplicated.
8
9
  const APPENDS = {
@@ -10,6 +11,9 @@ const APPENDS = {
10
11
  '.gitattributes': ['.claude/hooks/* text eol=lf']
11
12
  };
12
13
 
14
+ const RULES_PATH = '.claude/rules/brigade.md';
15
+ const MIGRATION_NOTE = "The kit's brigade.md no longer holds your options: they moved to .claude/rules/brigade-options.md. Merge brigade.md.proposed into your file, or delete your file's edits and re-run to take the new one.";
16
+
13
17
  // Written once, then the team's own: never updated, never proposed over.
14
18
  const WRITE_ONCE = new Set(['.claude/context/decisions.md', '.claude/context/architecture.md']);
15
19
 
@@ -98,7 +102,9 @@ export function installFiles({ cwd, stackDir, answers, previous }) {
98
102
  const same = existsSync(proposed) && sha(readFileSync(proposed, 'utf8')) === wanted;
99
103
  if (!same) write(proposed);
100
104
  if (previous?.files?.[path]) hashes[path] = previous.files[path];
101
- report.push({ path, status: 'proposed' });
105
+ const entry = { path, status: 'proposed' };
106
+ if (path === RULES_PATH && !readFileSync(target, 'utf8').includes('brigade-options.md')) entry.note = MIGRATION_NOTE;
107
+ report.push(entry);
102
108
  }
103
109
  }
104
110
 
@@ -112,6 +118,35 @@ export function installFiles({ cwd, stackDir, answers, previous }) {
112
118
  return { report, hashes };
113
119
  }
114
120
 
121
+ // The project's answers in plain English. Not a kit file: generated from the answers at every install, always overwritten.
122
+ export function renderOptions(answers) {
123
+ return `# Brigade options
124
+
125
+ <!-- Generated by Brigade from .claude/brigade.json. Do not edit: changes are overwritten at the next install or update. -->
126
+
127
+ Options of this project, referred to by \`.claude/rules/brigade.md\`.
128
+
129
+ - Default branch: \`${answers.defaultBranch}\`.
130
+ - Reference branch of the work: \`${answers.baseBranch}\`.
131
+ - Package manager: \`${answers.packageManager}\`.
132
+ - Commit format: ${answers.commitFormat}
133
+ - Code, comments and commit messages language: ${answers.codeLanguage}.
134
+ - Documentation language: ${answers.docsLanguage}.
135
+ - Reply language, project default: ${answers.replyLanguage}.
136
+ `;
137
+ }
138
+
139
+ export function writeOptionsFile({ cwd, answers }) {
140
+ const file = join(cwd, OPTIONS_FILE);
141
+ const content = renderOptions(answers);
142
+ const exists = existsSync(file);
143
+ if (exists && sha(readFileSync(file, 'utf8')) === sha(content)) return { path: OPTIONS_FILE, status: 'unchanged' };
144
+
145
+ mkdirSync(dirname(file), { recursive: true });
146
+ writeFileSync(file, content);
147
+ return { path: OPTIONS_FILE, status: exists ? 'updated' : 'created' };
148
+ }
149
+
115
150
  const normalize = (line) => line.trim().replace(/^\//, '');
116
151
 
117
152
  export function appendMissingLines(cwd) {
package/src/options.js CHANGED
@@ -9,29 +9,41 @@ import {
9
9
 
10
10
  const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
11
11
  const LANGUAGES = ['English', 'French'];
12
+ const ENGLISH = LANGUAGES[0];
13
+ const DIFFERENT = '__different__';
14
+ const LANGUAGE_KEYS = ['replyLanguage', 'codeLanguage', 'docsLanguage'];
12
15
 
13
16
  const languages = (current) => [...new Set([current, ...LANGUAGES].filter(Boolean))].map((value) => ({ value, label: value }));
14
17
 
15
18
  // The options of the kit, in the order they are asked. `keys` are the names stored in the manifest `answers`: a group
16
19
  // is missing when any of its keys is. `ask(ctx)` returns the values of its keys. Every answer is detected first, then
17
- // validated by the dev; a previous answer wins over detection.
18
- // ctx: { cwd, prompter, previous (stored answers), answers (decided so far), full (asked from scratch), personal }
20
+ // validated by the dev; a previous answer wins over detection. `detect(ctx)` returns the same values without asking
21
+ // (express install), or null when they cannot be told.
22
+ // ctx: { cwd, prompter, previous (stored answers), current (answers being edited), answers (decided so far), full (asked from scratch), personal }
19
23
  export const OPTIONS = [
20
24
  {
21
25
  keys: ['defaultBranch'],
22
- ask: async ({ cwd, prompter: { validate, text }, previous }) => ({
26
+ detect: ({ cwd, previous }) => {
27
+ const defaultBranch = previous.defaultBranch || detectDefaultBranch(cwd);
28
+ return defaultBranch ? { defaultBranch } : null;
29
+ },
30
+ ask: async ({ cwd, prompter: { validate, text }, previous, current }) => ({
23
31
  defaultBranch: await validate(
24
32
  'Default branch of the repository',
25
33
  previous.defaultBranch || detectDefaultBranch(cwd),
26
- () => text('Default branch of the repository', 'main')
34
+ () => text('Default branch of the repository', current.defaultBranch || 'main')
27
35
  )
28
36
  })
29
37
  },
30
38
  {
31
39
  keys: ['baseBranch'],
32
- ask: async ({ cwd, prompter: { choose }, previous, answers }) => {
40
+ detect: ({ previous, answers }) => {
41
+ const baseBranch = previous.baseBranch || answers.defaultBranch;
42
+ return baseBranch ? { baseBranch } : null;
43
+ },
44
+ ask: async ({ cwd, prompter: { choose }, previous, current, answers }) => {
33
45
  const { defaultBranch } = answers;
34
- const baseCandidates = [previous.baseBranch, defaultBranch, localBranches(cwd).includes('develop') && 'develop'].filter(Boolean);
46
+ const baseCandidates = [current.baseBranch, previous.baseBranch, defaultBranch, localBranches(cwd).includes('develop') && 'develop'].filter(Boolean);
35
47
  return {
36
48
  baseBranch: await choose(
37
49
  'Reference branch of the work',
@@ -46,9 +58,13 @@ export const OPTIONS = [
46
58
  },
47
59
  {
48
60
  keys: ['packageManager'],
49
- ask: async ({ cwd, prompter: { validate, choose }, previous }) => {
61
+ detect: ({ cwd, previous }) => {
62
+ const packageManager = previous.packageManager || detectPackageManager(cwd).value;
63
+ return packageManager ? { packageManager } : null;
64
+ },
65
+ ask: async ({ cwd, prompter: { validate, choose }, previous, current }) => {
50
66
  const manager = detectPackageManager(cwd);
51
- const managers = [...new Set([...manager.candidates, ...PACKAGE_MANAGERS])];
67
+ const managers = [...new Set([current.packageManager, ...manager.candidates, ...PACKAGE_MANAGERS].filter(Boolean))];
52
68
  return {
53
69
  packageManager: await validate(
54
70
  'Package manager',
@@ -64,8 +80,13 @@ export const OPTIONS = [
64
80
  },
65
81
  {
66
82
  keys: ['commitConvention', 'commitFormat'],
67
- ask: async ({ cwd, prompter: { validate, choose, text }, previous }) => {
68
- const start = previous.commitConvention || detectCommitConvention(cwd) || 'conventional';
83
+ detect: ({ cwd, previous }) => {
84
+ const commitConvention = previous.commitConvention || detectCommitConvention(cwd) || 'conventional';
85
+ if (commitConvention === 'custom') return previous.commitFormat ? { commitConvention, commitFormat: previous.commitFormat } : null;
86
+ return CONVENTIONS[commitConvention] ? { commitConvention, commitFormat: CONVENTIONS[commitConvention].format } : null;
87
+ },
88
+ ask: async ({ cwd, prompter: { validate, choose, text }, previous, current }) => {
89
+ const start = previous.commitConvention || current.commitConvention || detectCommitConvention(cwd) || 'conventional';
69
90
  const commitConvention = await validate(
70
91
  'Commit convention',
71
92
  start,
@@ -74,32 +95,75 @@ export const OPTIONS = [
74
95
  [
75
96
  ...Object.entries(CONVENTIONS).map(([value, { label, example }]) => ({ value, label, hint: example })),
76
97
  { value: 'custom', label: 'Custom', hint: 'you describe it in one sentence' }
77
- ],
98
+ ].sort((a, b) => (b.value === current.commitConvention) - (a.value === current.commitConvention)),
78
99
  { other: false }
79
100
  ),
80
101
  CONVENTIONS[start]?.label
81
102
  );
82
103
  const commitFormat = commitConvention === 'custom'
83
- ? await text('Describe your commit format in one sentence', previous.commitFormat)
104
+ ? await text('Describe your commit format in one sentence', previous.commitFormat || current.commitFormat)
84
105
  : CONVENTIONS[commitConvention].format;
85
106
  return { commitConvention, commitFormat };
86
107
  }
87
108
  },
88
109
  {
89
- keys: ['replyLanguage'],
110
+ keys: LANGUAGE_KEYS,
111
+ // The recommended setup: stored values first, then the language found (none when unknown), English for code and docs.
112
+ detect: ({ previous }) => {
113
+ const replyLanguage = previous.replyLanguage || detectLanguage();
114
+ const codeLanguage = previous.codeLanguage || ENGLISH;
115
+ return { ...(replyLanguage && { replyLanguage }), codeLanguage, docsLanguage: previous.docsLanguage || codeLanguage };
116
+ },
90
117
  ask: async (ctx) => {
91
- const { prompter: { validate, choose }, previous } = ctx;
92
- const replyLanguage = await validate(
93
- "Default language of Claude's replies for this project (each developer can use their own later)",
94
- previous.replyLanguage || detectLanguage(),
95
- () => choose("Default language of Claude's replies for this project", languages())
118
+ const { prompter: { choose }, previous, current } = ctx;
119
+ const known = { ...current, ...previous };
120
+
121
+ // First question: replies. The language found comes first, stored values lead.
122
+ const detected = detectLanguage();
123
+ const replyOptions = languages(known.replyLanguage || detected);
124
+ if (!known.replyLanguage) replyOptions[0].hint = 'recommended';
125
+ const replyLanguage = await choose(
126
+ "Language of Claude's replies for this project (each developer can use their own later)",
127
+ replyOptions,
128
+ { other: false }
96
129
  );
130
+
131
+ // Second question: code and docs together, or apart. A stored split keeps "Different" first.
132
+ const together = known.codeLanguage && known.codeLanguage === known.docsLanguage ? known.codeLanguage : undefined;
133
+ const split = known.codeLanguage && known.docsLanguage && !together;
134
+ const sameChoices = [...new Set([ENGLISH, replyLanguage])].map((value) => ({ value, label: value }));
135
+ if (together && !sameChoices.some(({ value }) => value === together)) sameChoices.push({ value: together, label: together });
136
+ const different = { value: DIFFERENT, label: 'Different for code and docs', hint: 'you choose each language' };
137
+ const first = split ? different : sameChoices.find(({ value }) => value === (together || ENGLISH));
138
+ const codeDocs = await choose(
139
+ 'Language of the code, comments, commit messages and documentation',
140
+ [first, ...[...sameChoices, different].filter((option) => option !== first)],
141
+ { other: false }
142
+ );
143
+
144
+ let languagesChosen;
145
+ if (codeDocs === DIFFERENT) {
146
+ const codeLanguage = await choose(
147
+ 'Language of the code, comments and commit messages (shared by the team)',
148
+ languages(known.codeLanguage)
149
+ );
150
+ const docsLanguage = await choose(
151
+ 'Language of the documentation (shared by the team)',
152
+ languages(known.docsLanguage || codeLanguage)
153
+ );
154
+ languagesChosen = { replyLanguage, codeLanguage, docsLanguage };
155
+ } else {
156
+ languagesChosen = { replyLanguage, codeLanguage: codeDocs, docsLanguage: codeDocs };
157
+ }
158
+
97
159
  // The personal choice lives in settings.local.json, not in the manifest: only asked from scratch.
160
+ delete ctx.personal.language;
98
161
  if (ctx.full) {
162
+ const { replyLanguage } = languagesChosen;
99
163
  const mine = await choose(
100
164
  'And for you? This stays on your machine',
101
165
  [
102
- { value: 'keep', label: `Keep the project language (${replyLanguage || LANGUAGES[0]})` },
166
+ { value: 'keep', label: `Keep the project language (${replyLanguage})` },
103
167
  { value: 'own', label: 'Use another language for me' }
104
168
  ],
105
169
  { other: false }
@@ -111,31 +175,112 @@ export const OPTIONS = [
111
175
  );
112
176
  }
113
177
  }
114
- return { replyLanguage: replyLanguage || LANGUAGES[0] };
115
- }
116
- },
117
- {
118
- keys: ['codeLanguage', 'docsLanguage'],
119
- ask: async ({ prompter: { choose }, previous }) => {
120
- const codeLanguage = await choose(
121
- 'Language of the code, comments and commit messages (shared by the team)',
122
- languages(previous.codeLanguage)
123
- );
124
- const docsLanguage = await choose(
125
- 'Language of the documentation (shared by the team)',
126
- languages(previous.docsLanguage || codeLanguage)
127
- );
128
- return { codeLanguage, docsLanguage };
178
+ return languagesChosen;
129
179
  }
130
180
  }
131
181
  ];
132
182
 
183
+ // The answers as the dev reads them, before and after the files are written. Missing ones are still to be chosen.
184
+ export function recapRows(answers) {
185
+ const show = (value, text = value) => (value === undefined ? 'to choose' : text);
186
+ return [
187
+ ['Default branch', show(answers.defaultBranch)],
188
+ ['Reference branch', show(answers.baseBranch)],
189
+ ['Package manager', show(answers.packageManager)],
190
+ ['Commit format', show(answers.commitConvention, CONVENTIONS[answers.commitConvention]?.label ?? 'Custom')],
191
+ ['Replies', show(answers.replyLanguage, `${answers.replyLanguage} (project default)`)],
192
+ ['Code', show(answers.codeLanguage)],
193
+ ['Documentation', show(answers.docsLanguage)]
194
+ ];
195
+ }
196
+
133
197
  // `previous`: the stored answers. Returns the options (groups of keys) that have no stored value yet.
134
198
  export const missingOptions = (previous = {}) => OPTIONS.filter(({ keys }) => keys.some((key) => previous[key] === undefined));
135
199
 
200
+ // What each option would be without asking. The ones that cannot be told are left out of the state.
201
+ function propose(ctx) {
202
+ const answers = {};
203
+ for (const option of OPTIONS) {
204
+ const found = option.detect({ ...ctx, answers });
205
+ for (const [key, value] of Object.entries(found ?? {})) if (value !== undefined) answers[key] = value;
206
+ }
207
+ return answers;
208
+ }
209
+
210
+ // The groups the dev can re-open from the recap, each covering some options of OPTIONS.
211
+ const GROUPS = [
212
+ { value: 'branches', label: 'Branches', options: [0, 1] },
213
+ { value: 'packageManager', label: 'Package manager', options: [2] },
214
+ { value: 'commit', label: 'Commit format', options: [3] },
215
+ { value: 'languages', label: 'Languages', options: [4] }
216
+ ];
217
+
218
+ // Asks the given options in order, each one seeing the answers decided so far.
219
+ async function askEach(options, ctx) {
220
+ for (const option of options) Object.assign(ctx.answers, await option.ask(ctx));
221
+ }
222
+
223
+ // A first install with a dev to ask. Everything is detected and shown in one card; "Install as is" takes it (the
224
+ // options that cannot be told are still asked), "Customize" asks every option. After a customization, or when
225
+ // something had to be asked, the recap can be edited group by group until it is right. Nothing is written before it
226
+ // returns, so cancelling leaves the project untouched.
227
+ async function askInteractive({ cwd, prompter, previous }) {
228
+ prompter.step(1, 3, 'Detection');
229
+ const personal = {};
230
+ const answers = propose({ cwd, prompter, previous, current: {}, answers: {}, full: true, personal });
231
+ const unresolved = OPTIONS.filter(({ keys }) => keys.some((key) => answers[key] === undefined));
232
+
233
+ prompter.step(2, 3, 'Review');
234
+ prompter.card('Here is what will be set up', recapRows(answers));
235
+ const mode = await prompter.choose(
236
+ 'How do you want to set up Brigade?',
237
+ [
238
+ { value: 'asis', label: 'Install as is', hint: unresolved.length ? 'the options still to choose will be asked' : undefined },
239
+ { value: 'customize', label: 'Customize', hint: 'every option, one by one' }
240
+ ],
241
+ { other: false }
242
+ );
243
+
244
+ let reviewed = false;
245
+ if (mode === 'customize') {
246
+ for (const key of Object.keys(answers)) delete answers[key];
247
+ await askEach(OPTIONS, { cwd, prompter, previous, current: {}, answers, full: true, personal });
248
+ reviewed = true;
249
+ } else if (unresolved.length) {
250
+ await askEach(unresolved, { cwd, prompter, previous, current: {}, answers, full: false, personal });
251
+ reviewed = true;
252
+ } else {
253
+ prompter.info('Installing as detected.');
254
+ }
255
+
256
+ while (reviewed) {
257
+ prompter.card('Your setup', recapRows(answers));
258
+ const verdict = await prompter.choose(
259
+ 'Is the setup right?',
260
+ [
261
+ { value: 'ok', label: 'Everything is right' },
262
+ { value: 'change', label: 'Change something' }
263
+ ],
264
+ { other: false }
265
+ );
266
+ if (verdict === 'ok') break;
267
+
268
+ const chosen = await prompter.multi('What do you want to change?', GROUPS.map(({ value, label }) => ({ value, label })));
269
+ // Re-asked options skip "Is that right?" and start from the current values.
270
+ const editor = { ...prompter, validate: async (_message, _detected, pick) => pick() };
271
+ const current = { ...answers };
272
+ const options = GROUPS.filter(({ value }) => chosen.includes(value)).flatMap(({ options }) => options.map((index) => OPTIONS[index]));
273
+ await askEach(options, { cwd, prompter: editor, previous: {}, current, answers, full: true, personal });
274
+ }
275
+ return { answers: { ...previous, ...answers }, personal, asked: [] };
276
+ }
277
+
136
278
  // Asks the options in order. From scratch (`full`) every option is asked; otherwise only the missing ones and the
137
279
  // others keep their stored answers. Keys the kit does not know (older version) are kept as they are.
280
+ // Without a dev to ask (`prompter.interactive` false) every question resolves to its default.
138
281
  export async function askOptions({ cwd, prompter, previous = {}, full }) {
282
+ if (full && prompter.interactive) return askInteractive({ cwd, prompter, previous });
283
+
139
284
  const missing = new Set(missingOptions(previous));
140
285
  const answers = {};
141
286
  const personal = {};
@@ -143,7 +288,7 @@ export async function askOptions({ cwd, prompter, previous = {}, full }) {
143
288
 
144
289
  for (const option of OPTIONS) {
145
290
  if (full || missing.has(option)) {
146
- Object.assign(answers, await option.ask({ cwd, prompter, previous, answers, full, personal }));
291
+ Object.assign(answers, await option.ask({ cwd, prompter, previous, current: {}, answers, full, personal }));
147
292
  if (!full) asked.push(option.keys);
148
293
  } else {
149
294
  for (const key of option.keys) answers[key] = previous[key];
package/src/prompt.js CHANGED
@@ -1,16 +1,20 @@
1
1
  import * as p from '@clack/prompts';
2
2
 
3
+ import * as ui from './ui.js';
4
+
3
5
  const OTHER = Symbol('other');
4
6
 
5
7
  // Non-interactive (no TTY or `--yes`): every question resolves to its default and nothing is printed.
6
8
  export function createPrompter({ interactive }) {
9
+ const cancelled = () => {
10
+ console.log(`\n${ui.dim('Cancelled. Nothing was written.')}`);
11
+ process.exit(1);
12
+ };
13
+
7
14
  // Nothing is written before the last question, so cancelling leaves the project untouched.
8
15
  const answer = async (pending) => {
9
16
  const value = await pending;
10
- if (p.isCancel(value)) {
11
- p.cancel('Cancelled. Nothing was written.');
12
- process.exit(1);
13
- }
17
+ if (p.isCancel(value)) cancelled();
14
18
  return value;
15
19
  };
16
20
 
@@ -39,17 +43,28 @@ export function createPrompter({ interactive }) {
39
43
  return keep ? detected : pick();
40
44
  }
41
45
 
46
+ // Several values among `options` ([{ value, label, hint? }]), at least one. Nothing is asked without a dev.
47
+ async function multi(message, options) {
48
+ if (!interactive) return [];
49
+ return answer(p.multiselect({ message, options: options.map(({ value, label, hint }) => ({ value, label, hint })), required: true }));
50
+ }
51
+
42
52
  const when = (fn) => (...args) => interactive && fn(...args);
43
53
 
44
54
  return {
45
55
  text,
56
+ interactive,
46
57
  choose,
58
+ multi,
47
59
  validate,
48
- intro: when((title) => p.intro(title)),
49
- info: when((message) => p.log.info(message)),
50
- warn: when((message) => p.log.warn(message)),
51
- note: when((message, title) => p.note(message, title)),
52
- outro: when((message) => p.outro(message)),
60
+ banner: when((version) => console.log(ui.banner(version))),
61
+ step: when((n, total, label) => console.log(ui.step(n, total, label))),
62
+ card: when((title, rows) => console.log(ui.card(title, rows))),
63
+ files: when((items) => console.log(`\n${ui.checklist(items)}`)),
64
+ info: when((message) => console.log(`${ui.dim('·')} ${ui.dim(message)}`)),
65
+ warn: when((message) => console.log(`${ui.accent('!')} ${message}`)),
66
+ outro: when((message) => console.log(`\n${message}`)),
67
+ done: when((message, lines) => console.log(`\n${ui.success('✓')} ${ui.bright(message)}\n${ui.bullets(lines)}\n`)),
53
68
  close: () => {}
54
69
  };
55
70
  }
package/src/ui.js ADDED
@@ -0,0 +1,71 @@
1
+ import { styleText } from 'node:util';
2
+
3
+ // The only place with colors and layout. `styleText` drops the escape codes by itself when the output is not a
4
+ // terminal or when NO_COLOR is set, so every helper degrades to plain text.
5
+ // Palette: amber accent, bright (cream) text, dim secondary, green for success, red for errors only.
6
+ const paint = (format, text) => styleText(format, text);
7
+
8
+ export const accent = (text) => paint(['bold', 'yellow'], text);
9
+ export const bright = (text) => paint('whiteBright', text);
10
+ export const dim = (text) => paint('gray', text);
11
+ export const success = (text) => paint('green', text);
12
+ export const warning = (text) => paint('yellow', text);
13
+ export const danger = (text) => paint('red', text);
14
+
15
+ const TAGLINE = 'Claude Code, organized like a kitchen brigade.';
16
+ const FALLBACK_COLUMNS = 80;
17
+
18
+ // A terminal reporting 0 columns (or none) is treated as 80.
19
+ const columnsOf = (columns = process.stdout.columns) => (columns > 0 ? columns : FALLBACK_COLUMNS);
20
+
21
+ const truncate = (text, max) => (text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`);
22
+
23
+ // Compact welcome header: letter-spaced wordmark, dimmed version, a rule, the tagline.
24
+ export function banner(version) {
25
+ const wordmark = [...'BRIGADE'].join(' ');
26
+ return [
27
+ '',
28
+ ` ${accent(wordmark)} ${dim(version)}`,
29
+ ` ${dim('─'.repeat(TAGLINE.length))}`,
30
+ ` ${dim(TAGLINE)}`,
31
+ ''
32
+ ].join('\n');
33
+ }
34
+
35
+ // A section label such as "1/3 Detection".
36
+ export function step(n, total, label) {
37
+ return `\n${accent(`${n}/${total}`)} ${bright(label)}`;
38
+ }
39
+
40
+ // An aligned key/value card with rounded borders, fitted to its content and capped to the terminal width.
41
+ // `rows`: [[label, value]].
42
+ export function card(title, rows, { columns } = {}) {
43
+ const keyWidth = Math.max(0, ...rows.map(([key]) => key.length));
44
+ const content = Math.max(title.length + 2, ...rows.map(([, value]) => keyWidth + 2 + value.length));
45
+ const inner = Math.max(12, Math.min(content, columnsOf(columns) - 4));
46
+ const valueWidth = Math.max(0, inner - keyWidth - 2);
47
+
48
+ const name = truncate(title, inner - 2);
49
+ const top = `${dim('╭─')} ${accent(name)} ${dim(`${'─'.repeat(inner - name.length - 2)}─╮`)}`;
50
+ const body = rows.map(([key, value]) => {
51
+ const shown = truncate(value, valueWidth);
52
+ const pad = ' '.repeat(Math.max(0, inner - keyWidth - 2 - shown.length));
53
+ return `${dim('│')} ${dim(key.padEnd(keyWidth))} ${bright(shown)}${pad} ${dim('│')}`;
54
+ });
55
+ const bottom = dim(`╰${'─'.repeat(inner + 2)}╯`);
56
+ return ['', top, ...body, bottom].join('\n');
57
+ }
58
+
59
+ // A tidy list. `items`: [{ done, label, detail? }]. Done items get a green check, the others are dimmed.
60
+ export function checklist(items) {
61
+ const width = Math.max(0, ...items.map(({ label }) => label.length));
62
+ return items.map(({ done, label, detail = '' }) => {
63
+ const gap = ' '.repeat(width - label.length);
64
+ return done
65
+ ? ` ${success('✓')} ${bright(label)}${gap} ${dim(detail)}`
66
+ : ` ${dim('-')} ${dim(label)}${gap} ${dim(detail)}`;
67
+ }).join('\n');
68
+ }
69
+
70
+ // Short lines with accent bullets.
71
+ export const bullets = (lines) => lines.map((line) => ` ${accent('›')} ${bright(line)}`).join('\n');
@@ -1,6 +1,6 @@
1
1
  # Brigade
2
2
 
3
- Rules of the Brigade setup of this web project. Work is split between three roles.
3
+ Rules of the Brigade setup of this web project. Work is split between three roles. The options of this project (branches, package manager, commit format, languages) are in `.claude/rules/brigade-options.md`, generated by Brigade.
4
4
 
5
5
  ## Roles
6
6
 
@@ -12,19 +12,19 @@ A brief to a subagent states the goal, the branch, the relevant files, the const
12
12
 
13
13
  ## Git
14
14
 
15
- - Default branch: `__DEFAULT_BRANCH__`. Reference branch of the work: `__BASE_BRANCH__`. Both are protected: writing on them needs the user's confirmation.
15
+ - The default branch and the reference branch of the work are protected: writing on them needs the user's confirmation. Their names are in `.claude/rules/brigade-options.md`.
16
16
  - Check the current branch before any code.
17
17
  - A feature (several steps, sessions or files) = its own branch + a state file. Small work (a fix, a few files, one sitting) has neither: it ends with a commit on the current branch. Before any code, judge which it is and follow the skill `feature`: propose, wait for the user's answer, then `git-operator` creates the branch (with the user's go).
18
18
  - When the work is finished and its open questions are settled, propose the commit in one sentence. Never call `git-operator` without the user's go. The user can ask for a commit at any moment.
19
19
  - The feature file `.claude/context/features/<branch-with-slashes-as-dashes>.md` is shared and travels with the branch. `implementer` updates it at important steps; mention it in the `git-operator` brief so it goes in the same commit.
20
20
  - Pushing needs the user's confirmation. At the end of a session, or on pause/handoff, if commits are not pushed, propose the push in one sentence: without a push nothing survives.
21
- - Commit format, for `git-operator`: __COMMIT_FORMAT__
21
+ - Commit format, for `git-operator`: the one in `.claude/rules/brigade-options.md`.
22
22
 
23
23
  ## Rules
24
24
 
25
25
  - At the start of a session, read `.claude/context/decisions.md` (team decisions, shared).
26
26
  - Keep changes small and focused.
27
- - Use the project's package manager: `__PACKAGE_MANAGER__`.
27
+ - Use the project's package manager, given in `.claude/rules/brigade-options.md`.
28
28
  - Run lint, build or tests only when the user asks or the task needs them. Do not assume they exist.
29
29
  - Never read or write secrets (`.env*`, keys, tokens).
30
30
  - Say so before adding a dependency.
@@ -33,9 +33,8 @@ A brief to a subagent states the goal, the branch, the relevant files, the const
33
33
 
34
34
  ## Languages
35
35
 
36
- - Code, comments and commit messages: __CODE_LANGUAGE__.
37
- - Documentation: __DOCS_LANGUAGE__.
38
- - Replies: set by the `language` setting. The project default is __REPLY_LANGUAGE__; each developer can use their own with `/brigade-language`.
36
+ - Code, comments, commit messages and documentation: the languages in `.claude/rules/brigade-options.md`.
37
+ - Replies: set by the `language` setting. The project default is in `.claude/rules/brigade-options.md`; each developer can use their own with `/brigade-language`.
39
38
 
40
39
  ## Local effort
41
40