@frontfactory/brigade 0.0.0-stage → 1.0.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/LICENSE.md +21 -0
- package/README.md +73 -2
- package/bin/brigade.js +4 -0
- package/package.json +54 -5
- package/src/cli.js +88 -0
- package/src/conventions.js +22 -0
- package/src/detect.js +80 -0
- package/src/init.js +161 -0
- package/src/install.js +134 -0
- package/src/prompt.js +55 -0
- package/src/stacks.js +20 -0
- package/src/version.js +15 -0
- package/templates/web/.claude/agents/git-operator.md +17 -0
- package/templates/web/.claude/agents/implementer.md +18 -0
- package/templates/web/.claude/commands/brigade-effort.md +11 -0
- package/templates/web/.claude/commands/brigade-language.md +11 -0
- package/templates/web/.claude/context/architecture.md +9 -0
- package/templates/web/.claude/context/audits/_template.md +12 -0
- package/templates/web/.claude/context/decisions.md +6 -0
- package/templates/web/.claude/context/features/_template.md +29 -0
- package/templates/web/.claude/context/local-template.md +14 -0
- package/templates/web/.claude/hooks/git-guard.mjs +104 -0
- package/templates/web/.claude/rules/brigade.md +42 -0
- package/templates/web/.claude/settings.json +51 -0
- package/templates/web/.claude/skills/audit/SKILL.md +14 -0
- package/templates/web/.claude/skills/feature/SKILL.md +15 -0
- package/templates/web/.claude/skills/session-start/SKILL.md +11 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Front Factory
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# brigade
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@frontfactory/brigade)
|
|
4
|
+
[](https://www.npmjs.com/package/@frontfactory/brigade)
|
|
5
|
+
[](https://www.npmjs.com/package/@frontfactory/brigade)
|
|
6
|
+
|
|
7
|
+
Sets up a Claude Code architecture in any project, new or existing, without fuss.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @frontfactory/brigade init
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What you get
|
|
14
|
+
|
|
15
|
+
Three roles, each limited to its mission:
|
|
16
|
+
|
|
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. |
|
|
22
|
+
|
|
23
|
+
Plus base rules for work, security and efficiency, adapted to the stack (web for now). The setup is shared with the
|
|
24
|
+
project and committed with it. Each developer can override their own effort locally with `/brigade-effort`.
|
|
25
|
+
|
|
26
|
+
The rest of the kit, all under `.claude/`:
|
|
27
|
+
|
|
28
|
+
- Skills: `session-start` (where things stand before the first task), `feature` (branch + state file for multi-step work),
|
|
29
|
+
`audit` (keep an audit file between sessions).
|
|
30
|
+
- Commands: `/brigade-effort` (your effort per role) and `/brigade-language` (your reply language), both local and
|
|
31
|
+
not committed.
|
|
32
|
+
- Shared context in `context/`: `decisions.md` (team decisions), `architecture.md` (optional, read on demand), one
|
|
33
|
+
state file per feature in `context/features/`, audits in `context/audits/`.
|
|
34
|
+
- A git guard hook (`hooks/git-guard.mjs`) and the permissions in `settings.json`.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
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).
|
|
41
|
+
|
|
42
|
+
Brigade never touches your `CLAUDE.md`: its instructions live in `.claude/rules/brigade.md`, which Claude Code loads
|
|
43
|
+
at launch like a `CLAUDE.md`.
|
|
44
|
+
|
|
45
|
+
When Brigade is already installed, `init` asks whether to keep the installed version or update to the one you run. On
|
|
46
|
+
the same version it offers to change your options (the questions are asked again, files only change if an answer
|
|
47
|
+
does) or to leave everything as it is. Without a terminal, it updates.
|
|
48
|
+
|
|
49
|
+
An existing file is never overwritten. If it differs from the kit, a `.proposed` file is written next to it; if it is
|
|
50
|
+
identical, nothing happens. A file Brigade wrote and nobody touched is updated. `.gitignore` and `.gitattributes` only
|
|
51
|
+
get the lines they miss. `.claude/context/decisions.md` is written once and then belongs to the team: it is never
|
|
52
|
+
updated nor proposed over.
|
|
53
|
+
|
|
54
|
+
## Git
|
|
55
|
+
|
|
56
|
+
Only `git-operator` writes to git, enforced by a hook. Writing on the default or reference branch, `push`, `merge` and
|
|
57
|
+
`rebase` ask for confirmation; destructive commands are denied. Brigade proposes a commit once a piece of work is
|
|
58
|
+
finished and never commits without your go.
|
|
59
|
+
|
|
60
|
+
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
|
|
61
|
+
the work survives between developers and machines. `CLAUDE.local.md` stays personal.
|
|
62
|
+
|
|
63
|
+
## Add a stack
|
|
64
|
+
|
|
65
|
+
Create `templates/<name>/` with the same layout as `templates/web/`, then add one entry in `src/stacks.js`. Placeholders:
|
|
66
|
+
`__DEFAULT_BRANCH__`, `__BASE_BRANCH__`, `__PACKAGE_MANAGER__`, `__COMMIT_FORMAT__`, `__REPLY_LANGUAGE__`,
|
|
67
|
+
`__CODE_LANGUAGE__`, `__DOCS_LANGUAGE__`.
|
|
68
|
+
|
|
69
|
+
## Develop
|
|
70
|
+
|
|
71
|
+
`npm test` runs the tests (`node --test`, no dependency). Commits follow Conventional Commits, checked by commitlint. Node >= 24 is required.
|
|
72
|
+
|
|
73
|
+
Releases are automatic: on a push to `main` or `beta`, once the tests pass, the CI runs semantic-release, which
|
|
74
|
+
derives the version from the commits, updates the changelog and publishes `@frontfactory/brigade` to npm.
|
package/bin/brigade.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@frontfactory/brigade",
|
|
3
|
+
"version": "1.0.0",
|
|
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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"prepare": "husky",
|
|
8
|
+
"test": "node --test \"test/*.test.js\"",
|
|
9
|
+
"release": "semantic-release"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=24"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"brigade": "bin/brigade.js"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@commitlint/cli": "^21.2.4",
|
|
19
|
+
"@commitlint/config-conventional": "^21.2.4",
|
|
20
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
21
|
+
"@semantic-release/git": "^11.0.1",
|
|
22
|
+
"@semantic-release/github": "^12.0.10",
|
|
23
|
+
"conventional-changelog-conventionalcommits": "^9.3.1",
|
|
24
|
+
"husky": "^9.1.7",
|
|
25
|
+
"semantic-release": "^25.0.9"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@clack/prompts": "^1.8.1"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"claude-code",
|
|
32
|
+
"claude",
|
|
33
|
+
"cli",
|
|
34
|
+
"scaffold",
|
|
35
|
+
"git-hooks"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"homepage": "https://github.com/front-factory/brigade#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/front-factory/brigade/issues"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/front-factory/brigade.git"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"bin",
|
|
48
|
+
"src",
|
|
49
|
+
"templates"
|
|
50
|
+
],
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"provenance": true
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { CONVENTIONS } from './conventions.js';
|
|
2
|
+
import { runInit } from './init.js';
|
|
3
|
+
import { STACKS } from './stacks.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `Usage: brigade init [--yes] [--stack <name>]
|
|
6
|
+
|
|
7
|
+
--yes accept every detected or default answer, ask nothing
|
|
8
|
+
--stack <name> force the stack (${Object.keys(STACKS).join(', ')}); detected otherwise
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
const WORDS = {
|
|
12
|
+
created: 'added',
|
|
13
|
+
updated: 'updated',
|
|
14
|
+
unchanged: 'already up to date',
|
|
15
|
+
kept: 'yours kept',
|
|
16
|
+
proposed: 'yours kept, proposal written'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function parse(args) {
|
|
20
|
+
const options = { yes: false, stack: undefined };
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
if (args[i] === '--yes' || args[i] === '-y') options.yes = true;
|
|
23
|
+
else if (args[i] === '--stack') options.stack = args[++i];
|
|
24
|
+
else throw new Error(`Unknown option: ${args[i]}`);
|
|
25
|
+
}
|
|
26
|
+
if (options.stack && !STACKS[options.stack]) throw new Error(`Unknown stack: ${options.stack}`);
|
|
27
|
+
return options;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function summary({ version, answers, report, ignored, repo, personal, local }) {
|
|
31
|
+
const lines = report.map(({ path, status }) => `${WORDS[status].padEnd(28)} ${path}${status === 'proposed' ? '.proposed' : ''}`);
|
|
32
|
+
for (const { path, status, added } of ignored) {
|
|
33
|
+
if (status === 'updated') lines.push(`${'lines added'.padEnd(28)} ${path} (${added.join(', ')})`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const settings = [
|
|
37
|
+
`Brigade ${version}`,
|
|
38
|
+
`Default branch ${answers.defaultBranch}`,
|
|
39
|
+
`Reference branch ${answers.baseBranch}`,
|
|
40
|
+
`Package manager ${answers.packageManager}`,
|
|
41
|
+
`Commit format ${CONVENTIONS[answers.commitConvention]?.label ?? 'Custom'}`,
|
|
42
|
+
`Replies ${answers.replyLanguage} (project default)`,
|
|
43
|
+
`Code ${answers.codeLanguage}`,
|
|
44
|
+
`Documentation ${answers.docsLanguage}`,
|
|
45
|
+
`Replies for you ${personal.language && local?.status === 'written' ? `${personal.language} (your machine only)` : 'project default'}`
|
|
46
|
+
];
|
|
47
|
+
return { files: lines.join('\n'), settings: settings.join('\n'), kept: report.some(({ status }) => status === 'proposed'), repo };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function print(result, interactive) {
|
|
51
|
+
const { prompter } = result;
|
|
52
|
+
if (result.kept) {
|
|
53
|
+
if (interactive) prompter.outro(`Kept Brigade ${result.kept}. Nothing was written.`);
|
|
54
|
+
else console.log(`Brigade ${result.kept} kept. Nothing was written.`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { files, settings, kept, repo } = summary(result);
|
|
59
|
+
|
|
60
|
+
if (!interactive) {
|
|
61
|
+
console.log(`Brigade installed (stack: ${result.stack}).\n\n${files}\n\n${settings}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
prompter.note(files, 'Files');
|
|
66
|
+
prompter.note(settings, 'Your setup');
|
|
67
|
+
if (kept) prompter.warn('Some files of yours differ from the kit: they were kept. Merge the .proposed version if you want it.');
|
|
68
|
+
if (!repo) prompter.warn('Remember to run `git init` before your first commit.');
|
|
69
|
+
prompter.outro('Done. Run `claude` here, use Plan Mode (Shift+Tab) to discuss, and /brigade-language and /brigade-effort for your own settings.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function main(argv) {
|
|
73
|
+
const [command, ...rest] = argv;
|
|
74
|
+
if (command !== 'init') {
|
|
75
|
+
console.log(HELP);
|
|
76
|
+
return command === undefined || command === '--help' ? 0 : 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const options = parse(rest);
|
|
81
|
+
const interactive = Boolean(process.stdin.isTTY) && !options.yes;
|
|
82
|
+
print(await runInit({ cwd: process.cwd(), interactive, ...options }), interactive);
|
|
83
|
+
return 0;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error(`brigade: ${error.message}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const CONVENTIONS = {
|
|
2
|
+
conventional: {
|
|
3
|
+
label: 'Conventional Commits',
|
|
4
|
+
example: 'feat(auth): add login form',
|
|
5
|
+
format: 'Conventional Commits: `type(scope): description` (`feat`, `fix`, `chore`, `refactor`, `docs`, `test`), imperative mood, 72 characters max.'
|
|
6
|
+
},
|
|
7
|
+
angular: {
|
|
8
|
+
label: 'Angular',
|
|
9
|
+
example: 'fix(core): handle empty list',
|
|
10
|
+
format: 'Angular: `type(scope): description` with the types `build`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `test`, imperative mood, 72 characters max.'
|
|
11
|
+
},
|
|
12
|
+
gitmoji: {
|
|
13
|
+
label: 'Gitmoji',
|
|
14
|
+
example: '✨ Add login form',
|
|
15
|
+
format: 'Gitmoji: `<emoji> Description` (e.g. `✨ Add login form`), imperative mood, 72 characters max.'
|
|
16
|
+
},
|
|
17
|
+
imperative: {
|
|
18
|
+
label: 'Simple imperative',
|
|
19
|
+
example: 'Add login form',
|
|
20
|
+
format: 'A single imperative sentence without prefix (e.g. `Add login form`), 72 characters max.'
|
|
21
|
+
}
|
|
22
|
+
};
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const LOCKFILES = {
|
|
6
|
+
'pnpm-lock.yaml': 'pnpm',
|
|
7
|
+
'yarn.lock': 'yarn',
|
|
8
|
+
'bun.lock': 'bun',
|
|
9
|
+
'bun.lockb': 'bun',
|
|
10
|
+
'package-lock.json': 'npm'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const LANGUAGES = {
|
|
14
|
+
en: 'English',
|
|
15
|
+
fr: 'French',
|
|
16
|
+
es: 'Spanish',
|
|
17
|
+
de: 'German',
|
|
18
|
+
it: 'Italian',
|
|
19
|
+
pt: 'Portuguese'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function git(cwd, ...args) {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJson(file) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isGitRepo(cwd) {
|
|
39
|
+
return git(cwd, 'rev-parse', '--is-inside-work-tree') === 'true';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function localBranches(cwd) {
|
|
43
|
+
return git(cwd, 'for-each-ref', '--format=%(refname:short)', 'refs/heads').split('\n').filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Remote HEAD first, then `init.defaultBranch`, then the current branch. Empty when nothing is found.
|
|
47
|
+
export function detectDefaultBranch(cwd) {
|
|
48
|
+
const remote = git(cwd, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD').replace(/^origin\//, '');
|
|
49
|
+
if (remote) return remote;
|
|
50
|
+
|
|
51
|
+
const configured = git(cwd, 'config', 'init.defaultBranch');
|
|
52
|
+
if (configured) return configured;
|
|
53
|
+
|
|
54
|
+
return git(cwd, 'symbolic-ref', '--short', 'HEAD');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `packageManager` wins; several lockfiles are a leftover, so `value` stays empty and the candidates are listed.
|
|
58
|
+
export function detectPackageManager(cwd) {
|
|
59
|
+
const declared = readJson(join(cwd, 'package.json'))?.packageManager?.split('@')[0];
|
|
60
|
+
if (declared) return { value: declared, candidates: [declared] };
|
|
61
|
+
|
|
62
|
+
const candidates = [...new Set(
|
|
63
|
+
Object.entries(LOCKFILES).filter(([file]) => existsSync(join(cwd, file))).map(([, name]) => name)
|
|
64
|
+
)];
|
|
65
|
+
return { value: candidates.length === 1 ? candidates[0] : '', candidates };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// commitlint counts only when it is a dependency and wired to a `commit-msg` hook.
|
|
69
|
+
export function detectCommitConvention(cwd) {
|
|
70
|
+
const pkg = readJson(join(cwd, 'package.json'));
|
|
71
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
72
|
+
if (!existsSync(join(cwd, '.husky', 'commit-msg'))) return '';
|
|
73
|
+
if (deps['@commitlint/config-angular']) return 'angular';
|
|
74
|
+
return deps['@commitlint/config-conventional'] ? 'conventional' : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function detectLanguage(env = process.env) {
|
|
78
|
+
const code = (env.LC_ALL || env.LC_MESSAGES || env.LANG || '').slice(0, 2).toLowerCase();
|
|
79
|
+
return LANGUAGES[code] ?? '';
|
|
80
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { CONVENTIONS } from './conventions.js';
|
|
2
|
+
import {
|
|
3
|
+
detectCommitConvention,
|
|
4
|
+
detectDefaultBranch,
|
|
5
|
+
detectLanguage,
|
|
6
|
+
detectPackageManager,
|
|
7
|
+
isGitRepo,
|
|
8
|
+
localBranches
|
|
9
|
+
} from './detect.js';
|
|
10
|
+
import { appendMissingLines, installFiles, readManifest, writeLocalSettings, writeManifest } from './install.js';
|
|
11
|
+
import { createPrompter } from './prompt.js';
|
|
12
|
+
import { DEFAULT_STACK, STACKS, detectStack } from './stacks.js';
|
|
13
|
+
import { VERSION, compareVersions } from './version.js';
|
|
14
|
+
|
|
15
|
+
const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
16
|
+
const LANGUAGES = ['English', 'French'];
|
|
17
|
+
|
|
18
|
+
// Every answer is detected first, then validated by the dev. A previous answer wins over detection.
|
|
19
|
+
async function ask({ cwd, prompter, previous = {} }) {
|
|
20
|
+
const { validate, choose, text } = prompter;
|
|
21
|
+
const branches = localBranches(cwd);
|
|
22
|
+
|
|
23
|
+
const defaultBranch = await validate(
|
|
24
|
+
'Default branch of the repository',
|
|
25
|
+
previous.defaultBranch || detectDefaultBranch(cwd),
|
|
26
|
+
() => text('Default branch of the repository', 'main')
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const baseCandidates = [previous.baseBranch, defaultBranch, branches.includes('develop') && 'develop'].filter(Boolean);
|
|
30
|
+
const baseBranch = await choose(
|
|
31
|
+
'Reference branch of the work',
|
|
32
|
+
[...new Set(baseCandidates)].map((value) => ({
|
|
33
|
+
value,
|
|
34
|
+
label: value,
|
|
35
|
+
hint: value === defaultBranch ? 'default branch' : 'feature branches start from it'
|
|
36
|
+
}))
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const manager = detectPackageManager(cwd);
|
|
40
|
+
const managers = [...new Set([...manager.candidates, ...PACKAGE_MANAGERS])];
|
|
41
|
+
const packageManager = await validate(
|
|
42
|
+
'Package manager',
|
|
43
|
+
previous.packageManager || manager.value,
|
|
44
|
+
() => choose(
|
|
45
|
+
'Package manager',
|
|
46
|
+
managers.map((value) => ({ value, label: value, hint: manager.candidates.includes(value) ? 'found in the project' : undefined })),
|
|
47
|
+
{ other: false }
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const conventionKey = await validate(
|
|
52
|
+
'Commit convention',
|
|
53
|
+
previous.commitConvention || detectCommitConvention(cwd) || 'conventional',
|
|
54
|
+
() => choose(
|
|
55
|
+
'Commit convention',
|
|
56
|
+
[
|
|
57
|
+
...Object.entries(CONVENTIONS).map(([value, { label, example }]) => ({ value, label, hint: example })),
|
|
58
|
+
{ value: 'custom', label: 'Custom', hint: 'you describe it in one sentence' }
|
|
59
|
+
],
|
|
60
|
+
{ other: false }
|
|
61
|
+
),
|
|
62
|
+
CONVENTIONS[previous.commitConvention || detectCommitConvention(cwd) || 'conventional']?.label
|
|
63
|
+
);
|
|
64
|
+
const commitFormat = conventionKey === 'custom'
|
|
65
|
+
? await text('Describe your commit format in one sentence', previous.commitFormat)
|
|
66
|
+
: CONVENTIONS[conventionKey].format;
|
|
67
|
+
|
|
68
|
+
const languages = (current) => [...new Set([current, ...LANGUAGES].filter(Boolean))].map((value) => ({ value, label: value }));
|
|
69
|
+
|
|
70
|
+
const replyLanguage = await validate(
|
|
71
|
+
"Default language of Claude's replies for this project (each developer can use their own later)",
|
|
72
|
+
previous.replyLanguage || detectLanguage(),
|
|
73
|
+
() => choose("Default language of Claude's replies for this project", languages())
|
|
74
|
+
);
|
|
75
|
+
const mine = await choose(
|
|
76
|
+
'And for you? This stays on your machine',
|
|
77
|
+
[
|
|
78
|
+
{ value: 'keep', label: `Keep the project language (${replyLanguage || LANGUAGES[0]})` },
|
|
79
|
+
{ value: 'own', label: 'Use another language for me' }
|
|
80
|
+
],
|
|
81
|
+
{ other: false }
|
|
82
|
+
);
|
|
83
|
+
const ownLanguage = mine === 'own'
|
|
84
|
+
? await choose('Your own reply language', languages().filter(({ value }) => value !== replyLanguage))
|
|
85
|
+
: '';
|
|
86
|
+
|
|
87
|
+
const codeLanguage = await choose(
|
|
88
|
+
'Language of the code, comments and commit messages (shared by the team)',
|
|
89
|
+
languages(previous.codeLanguage)
|
|
90
|
+
);
|
|
91
|
+
const docsLanguage = await choose(
|
|
92
|
+
'Language of the documentation (shared by the team)',
|
|
93
|
+
languages(previous.docsLanguage || codeLanguage)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const answers = {
|
|
97
|
+
defaultBranch,
|
|
98
|
+
baseBranch,
|
|
99
|
+
packageManager,
|
|
100
|
+
commitConvention: conventionKey,
|
|
101
|
+
commitFormat,
|
|
102
|
+
replyLanguage: replyLanguage || LANGUAGES[0],
|
|
103
|
+
codeLanguage,
|
|
104
|
+
docsLanguage
|
|
105
|
+
};
|
|
106
|
+
return { answers, personal: { language: ownLanguage } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Asked whenever Brigade is already installed. The first option is the answer when nothing can be asked.
|
|
110
|
+
async function chooseVersion({ prompter, installed, version }) {
|
|
111
|
+
if (installed === version) {
|
|
112
|
+
prompter.info(`Brigade ${installed} is already installed here and up to date.`);
|
|
113
|
+
return prompter.choose(
|
|
114
|
+
'What do you want to do?',
|
|
115
|
+
[
|
|
116
|
+
{ value: 'update', label: 'Change my options', hint: 'the questions are asked again, files only change if an answer does' },
|
|
117
|
+
{ value: 'keep', label: 'Leave it as it is', hint: 'nothing is written' }
|
|
118
|
+
],
|
|
119
|
+
{ other: false }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const newer = compareVersions(version, installed) > 0;
|
|
124
|
+
prompter.info(`Brigade ${installed} is installed here, this is Brigade ${version}.`);
|
|
125
|
+
return prompter.choose(
|
|
126
|
+
newer ? 'A newer version is available' : 'This Brigade is older than the installed one',
|
|
127
|
+
[
|
|
128
|
+
{ value: 'update', label: `${newer ? 'Update' : 'Switch'} to ${version}`, hint: 'new kit files, and the questions are asked again. Files you edited are kept' },
|
|
129
|
+
{ value: 'keep', label: `Keep ${installed}`, hint: 'nothing is written' }
|
|
130
|
+
],
|
|
131
|
+
{ other: false }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function runInit({ cwd, yes = false, stack, stackDir, version = VERSION, interactive = process.stdin.isTTY && !yes, prompter }) {
|
|
136
|
+
const name = stack ?? detectStack(cwd);
|
|
137
|
+
const dir = stackDir ?? (STACKS[name] ?? STACKS[DEFAULT_STACK]).dir;
|
|
138
|
+
const previous = readManifest(cwd);
|
|
139
|
+
|
|
140
|
+
prompter ??= createPrompter({ interactive: Boolean(interactive) });
|
|
141
|
+
const repo = isGitRepo(cwd);
|
|
142
|
+
prompter.intro(`Brigade ${version}`);
|
|
143
|
+
prompter.info(`Stack: ${name}${stack ? '' : ' (the only one available for now)'}.`);
|
|
144
|
+
|
|
145
|
+
const installed = previous?.version;
|
|
146
|
+
if (installed && await chooseVersion({ prompter, installed, version }) === 'keep') {
|
|
147
|
+
return { kept: installed, stack: previous.stack ?? name, answers: previous.answers, report: [], ignored: [], repo, prompter, personal: {}, local: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!repo) {
|
|
151
|
+
prompter.warn('No git repository here. Brigade will not create one: run `git init` when you are ready. The branch names below are kept for then.');
|
|
152
|
+
}
|
|
153
|
+
const { answers, personal } = await ask({ cwd, prompter, previous: previous?.answers });
|
|
154
|
+
|
|
155
|
+
const { report, hashes } = installFiles({ cwd, stackDir: dir, answers, previous });
|
|
156
|
+
writeManifest({ cwd, version, stack: name, answers, hashes });
|
|
157
|
+
const ignored = appendMissingLines(cwd);
|
|
158
|
+
const local = personal.language ? writeLocalSettings(cwd, { language: personal.language }) : null;
|
|
159
|
+
|
|
160
|
+
return { version, stack: name, answers, report, ignored, repo, prompter, personal, local };
|
|
161
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const MANIFEST = '.claude/brigade.json';
|
|
6
|
+
|
|
7
|
+
// Lines Brigade appends to the project files when missing. Existing lines are never touched or duplicated.
|
|
8
|
+
const APPENDS = {
|
|
9
|
+
'.gitignore': ['.claude/settings.local.json', 'CLAUDE.local.md'],
|
|
10
|
+
'.gitattributes': ['.claude/hooks/* text eol=lf']
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Written once, then the team's own: never updated, never proposed over.
|
|
14
|
+
const WRITE_ONCE = new Set(['.claude/context/decisions.md']);
|
|
15
|
+
|
|
16
|
+
const sha = (content) => createHash('sha256').update(content).digest('hex');
|
|
17
|
+
|
|
18
|
+
function listFiles(dir) {
|
|
19
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
20
|
+
const abs = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) return listFiles(abs);
|
|
22
|
+
return entry.name === '.DS_Store' ? [] : [abs];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function render(content, answers) {
|
|
27
|
+
const values = {
|
|
28
|
+
DEFAULT_BRANCH: answers.defaultBranch,
|
|
29
|
+
BASE_BRANCH: answers.baseBranch,
|
|
30
|
+
PACKAGE_MANAGER: answers.packageManager,
|
|
31
|
+
COMMIT_FORMAT: answers.commitFormat,
|
|
32
|
+
REPLY_LANGUAGE: answers.replyLanguage,
|
|
33
|
+
CODE_LANGUAGE: answers.codeLanguage,
|
|
34
|
+
DOCS_LANGUAGE: answers.docsLanguage
|
|
35
|
+
};
|
|
36
|
+
return content.replace(/__([A-Z_]+)__/g, (match, key) => values[key] ?? match);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readManifest(cwd) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(join(cwd, MANIFEST), 'utf8'));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Never overwrites a file that differs from what Brigade last wrote: that one gets a `.proposed` next to it.
|
|
48
|
+
export function installFiles({ cwd, stackDir, answers, previous }) {
|
|
49
|
+
const report = [];
|
|
50
|
+
const hashes = {};
|
|
51
|
+
|
|
52
|
+
for (const source of listFiles(stackDir)) {
|
|
53
|
+
const path = relative(stackDir, source).split('\\').join('/');
|
|
54
|
+
const target = join(cwd, path);
|
|
55
|
+
const content = render(readFileSync(source, 'utf8'), answers);
|
|
56
|
+
const mode = statSync(source).mode & 0o777;
|
|
57
|
+
|
|
58
|
+
const write = (file) => {
|
|
59
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
60
|
+
writeFileSync(file, content, { mode });
|
|
61
|
+
chmodSync(file, mode);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!existsSync(target)) {
|
|
65
|
+
write(target);
|
|
66
|
+
hashes[path] = sha(content);
|
|
67
|
+
report.push({ path, status: 'created' });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const current = sha(readFileSync(target, 'utf8'));
|
|
72
|
+
if (WRITE_ONCE.has(path)) {
|
|
73
|
+
report.push({ path, status: current === sha(content) ? 'unchanged' : 'kept' });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (current === sha(content)) {
|
|
77
|
+
hashes[path] = current;
|
|
78
|
+
report.push({ path, status: 'unchanged' });
|
|
79
|
+
} else if (previous?.files?.[path] === current) {
|
|
80
|
+
write(target);
|
|
81
|
+
hashes[path] = sha(content);
|
|
82
|
+
report.push({ path, status: 'updated' });
|
|
83
|
+
} else {
|
|
84
|
+
const proposed = `${target}.proposed`;
|
|
85
|
+
const same = existsSync(proposed) && sha(readFileSync(proposed, 'utf8')) === sha(content);
|
|
86
|
+
if (!same) write(proposed);
|
|
87
|
+
if (previous?.files?.[path]) hashes[path] = previous.files[path];
|
|
88
|
+
report.push({ path, status: 'proposed' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { report, hashes };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const normalize = (line) => line.trim().replace(/^\//, '');
|
|
96
|
+
|
|
97
|
+
export function appendMissingLines(cwd) {
|
|
98
|
+
return Object.entries(APPENDS).map(([path, lines]) => {
|
|
99
|
+
const file = join(cwd, path);
|
|
100
|
+
const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
101
|
+
const known = new Set(existing.split('\n').map(normalize));
|
|
102
|
+
const missing = lines.filter((line) => !known.has(normalize(line)));
|
|
103
|
+
|
|
104
|
+
if (missing.length === 0) return { path, status: 'unchanged' };
|
|
105
|
+
|
|
106
|
+
const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
107
|
+
const heading = existing === '' ? '' : '\n';
|
|
108
|
+
writeFileSync(file, `${existing}${separator}${heading}## Claude\n${missing.join('\n')}\n`);
|
|
109
|
+
return { path, status: 'updated', added: missing };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function writeManifest({ cwd, version, stack, answers, hashes }) {
|
|
114
|
+
const file = join(cwd, MANIFEST);
|
|
115
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
116
|
+
const files = Object.fromEntries(Object.entries(hashes).sort(([a], [b]) => a.localeCompare(b)));
|
|
117
|
+
writeFileSync(file, `${JSON.stringify({ version, stack, answers, files }, null, 4)}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Personal answers go to the developer's own local settings, never to the shared files. A broken file is left alone.
|
|
121
|
+
export function writeLocalSettings(cwd, patch) {
|
|
122
|
+
const file = join(cwd, '.claude', 'settings.local.json');
|
|
123
|
+
let current = {};
|
|
124
|
+
if (existsSync(file)) {
|
|
125
|
+
try {
|
|
126
|
+
current = JSON.parse(readFileSync(file, 'utf8'));
|
|
127
|
+
} catch {
|
|
128
|
+
return { status: 'skipped' };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
132
|
+
writeFileSync(file, `${JSON.stringify({ ...current, ...patch }, null, 4)}\n`);
|
|
133
|
+
return { status: 'written' };
|
|
134
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
|
|
3
|
+
const OTHER = Symbol('other');
|
|
4
|
+
|
|
5
|
+
// Non-interactive (no TTY or `--yes`): every question resolves to its default and nothing is printed.
|
|
6
|
+
export function createPrompter({ interactive }) {
|
|
7
|
+
// Nothing is written before the last question, so cancelling leaves the project untouched.
|
|
8
|
+
const answer = async (pending) => {
|
|
9
|
+
const value = await pending;
|
|
10
|
+
if (p.isCancel(value)) {
|
|
11
|
+
p.cancel('Cancelled. Nothing was written.');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function text(message, fallback = '') {
|
|
18
|
+
if (!interactive) return fallback;
|
|
19
|
+
return (await answer(p.text({ message, placeholder: fallback, defaultValue: fallback }))).trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// `options`: [{ value, label, hint? }]. The first one is preselected.
|
|
23
|
+
async function choose(message, options, { other = true } = {}) {
|
|
24
|
+
if (!interactive) return options[0].value;
|
|
25
|
+
|
|
26
|
+
const items = options.map(({ value, label, hint }) => ({ value, label, hint }));
|
|
27
|
+
if (other) items.push({ value: OTHER, label: 'Other…' });
|
|
28
|
+
|
|
29
|
+
const value = await answer(p.select({ message, options: items, initialValue: options[0].value }));
|
|
30
|
+
return value === OTHER ? text(`${message} (type it)`) : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A detected value is proposed first; Enter keeps it, "no" goes on to `pick`.
|
|
34
|
+
async function validate(message, detected, pick, shown = detected) {
|
|
35
|
+
if (!detected) return pick();
|
|
36
|
+
if (!interactive) return detected;
|
|
37
|
+
|
|
38
|
+
const keep = await answer(p.confirm({ message: `${message}: ${shown}. Is that right?`, initialValue: true }));
|
|
39
|
+
return keep ? detected : pick();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const when = (fn) => (...args) => interactive && fn(...args);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
text,
|
|
46
|
+
choose,
|
|
47
|
+
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)),
|
|
53
|
+
close: () => {}
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/stacks.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
6
|
+
|
|
7
|
+
// A stack is a folder in `templates/` plus one entry here. `match` tells whether a project looks like it.
|
|
8
|
+
export const STACKS = {
|
|
9
|
+
web: {
|
|
10
|
+
label: 'Web',
|
|
11
|
+
dir: join(TEMPLATES, 'web'),
|
|
12
|
+
match: (cwd) => existsSync(join(cwd, 'package.json'))
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_STACK = 'web';
|
|
17
|
+
|
|
18
|
+
export function detectStack(cwd) {
|
|
19
|
+
return Object.keys(STACKS).find((name) => STACKS[name].match(cwd)) ?? DEFAULT_STACK;
|
|
20
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
4
|
+
|
|
5
|
+
const parts = (version) => version.split('-')[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
6
|
+
|
|
7
|
+
// Positive when `a` is newer than `b`, negative when older, 0 when equal. Pre-release tags are ignored.
|
|
8
|
+
export function compareVersions(a, b) {
|
|
9
|
+
const [left, right] = [parts(a), parts(b)];
|
|
10
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
11
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
12
|
+
if (diff !== 0) return diff;
|
|
13
|
+
}
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: git-operator
|
|
3
|
+
description: Git only, once a piece of work is done and the user has approved the commit. The only role allowed to write to git. Never edits code.
|
|
4
|
+
model: haiku
|
|
5
|
+
effort: low
|
|
6
|
+
tools: Bash, Read, Glob, Grep
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are the git role. You do what the brief asks on git and nothing else. You never edit files.
|
|
10
|
+
|
|
11
|
+
- Protected branches: `__DEFAULT_BRANCH__` and `__BASE_BRANCH__`. Writing on them asks the user for confirmation; do not work around it.
|
|
12
|
+
- **Commits**: compare the changes with the file list in the brief. If an unexpected file changed, or a sensitive file is present (`.env*`, key, token), stop and report it without committing. Never stage `CLAUDE.local.md`. Stage explicit files (never `git add .` or `-A`). If the changes cover several goals, make several atomic commits.
|
|
13
|
+
- **Message**: in __CODE_LANGUAGE__. Format: __COMMIT_FORMAT__ Add a body when the "why" is not obvious.
|
|
14
|
+
- **Branches**: create a branch only when the brief asks, from `__BASE_BRANCH__` after a fetch, named `type/short-description`.
|
|
15
|
+
- `push`, `merge` and `rebase` always ask the user. Destructive commands are denied.
|
|
16
|
+
|
|
17
|
+
Report back: the branch, the commits (hash and message), and anything that was left undone.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: implementer
|
|
3
|
+
description: Development only. Writes and changes code, dependencies and config as briefed by the main session. Use for every code change. Never runs git write commands.
|
|
4
|
+
model: sonnet
|
|
5
|
+
effort: medium
|
|
6
|
+
tools: Read, Edit, Write, Glob, Grep, Bash
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are the development role. You do the work described in the brief and nothing else.
|
|
10
|
+
|
|
11
|
+
- Work on the branch given in the brief. If it is not the current one, stop and say so.
|
|
12
|
+
- Keep changes small and focused on the brief. Match the style of the surrounding code.
|
|
13
|
+
- Use the project's package manager: `__PACKAGE_MANAGER__`.
|
|
14
|
+
- Run lint, build or tests only if the brief asks for it.
|
|
15
|
+
- At important steps, update the feature file `.claude/context/features/<branch-with-slashes-as-dashes>.md` if the work is a feature (see skill `feature`); no file for small work.
|
|
16
|
+
- Never run git write commands (the git guard blocks them). Never read or write secrets.
|
|
17
|
+
|
|
18
|
+
Report back briefly: what you changed (files), what you did not do and why, and any question that needs the user's decision. Do not commit.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Set your own effort for the main session and the subagents (local, not committed)
|
|
3
|
+
allowed-tools: Read, Write, Edit
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Ask the developer, in one question, which role and which level they want (`low`, `medium`, `high`):
|
|
7
|
+
|
|
8
|
+
- **main session**: set `effortLevel` in `.claude/settings.local.json`.
|
|
9
|
+
- **implementer** or **git-operator**: write the line `Effort for <role>: <level>` in `CLAUDE.local.md`, replacing the existing line for that role if there is one.
|
|
10
|
+
|
|
11
|
+
Read the file first and keep everything else in it. Create it if it does not exist. Only change the effort. Tell the developer what was written and that the shared project files were not touched.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Choose the language of Claude's replies for you only (local, not committed)
|
|
3
|
+
allowed-tools: Read, Write, Edit
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The project sets a default reply language in `.claude/settings.json` (key `language`). Read it, then ask the developer one question: keep the project language, or use their own?
|
|
7
|
+
|
|
8
|
+
- **Keep**: remove the `language` key from `.claude/settings.local.json` if it is there.
|
|
9
|
+
- **Their own**: ask which language, then set `language` in `.claude/settings.local.json`.
|
|
10
|
+
|
|
11
|
+
Read the file first and keep every other key. Create it if it does not exist. Only change `language`. Tell the developer what was written and that the shared project files were not touched.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
<!-- Optional. Not loaded by default: read on demand. Keep it short. -->
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
<!-- Main modules, data flow, external services (API, database, auth, CDN) -->
|
|
7
|
+
|
|
8
|
+
## Conventions
|
|
9
|
+
<!-- Naming, folder layout, patterns specific to this project -->
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Audit: <topic>
|
|
2
|
+
|
|
3
|
+
Date: YYYY-MM-DD
|
|
4
|
+
Baseline commit: <hash>
|
|
5
|
+
Scope: <files / folders / question>
|
|
6
|
+
Status: open
|
|
7
|
+
|
|
8
|
+
## Findings
|
|
9
|
+
|
|
10
|
+
| ID | Severity | File | Problem | Status | Notes (commit / reason) |
|
|
11
|
+
|----|----------|------|---------|--------|-------------------------|
|
|
12
|
+
| A1 | high | src/... | ... | open | |
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Team decisions
|
|
2
|
+
|
|
3
|
+
<!-- Shared by everyone. Written once by Brigade at install, then yours: never overwritten. -->
|
|
4
|
+
<!-- One line per decision: what + why. Newest at the bottom. -->
|
|
5
|
+
<!-- To change a decision, edit it in place (git keeps the history). -->
|
|
6
|
+
<!-- Keep this file under ~30 lines: move details and replaced decisions to decisions-archive.md. -->
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Feature: <name> (branch <branch name>)
|
|
2
|
+
|
|
3
|
+
Date: YYYY-MM-DD
|
|
4
|
+
Last commit: <hash>
|
|
5
|
+
Base branch: <branch>
|
|
6
|
+
Parent branch: <feature branch if stacked> (only if any)
|
|
7
|
+
Uncommitted / stash: <WIP description> (only if any)
|
|
8
|
+
|
|
9
|
+
## Goal
|
|
10
|
+
|
|
11
|
+
<one or two sentences>
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
- [x] done step
|
|
16
|
+
- [ ] remaining step
|
|
17
|
+
|
|
18
|
+
## Decisions and open questions
|
|
19
|
+
|
|
20
|
+
-
|
|
21
|
+
|
|
22
|
+
## Notes
|
|
23
|
+
|
|
24
|
+
-
|
|
25
|
+
|
|
26
|
+
## Active audit (only if any)
|
|
27
|
+
|
|
28
|
+
Path: <.claude/context/audits/YYYY-MM-DD-topic.md>
|
|
29
|
+
Open IDs: <A1, A2>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# My preferences (personal, not committed)
|
|
2
|
+
|
|
3
|
+
<!-- Copy to CLAUDE.local.md at the project root (git-ignored). Effort lines for the roles live here too. -->
|
|
4
|
+
<!-- The feature state does NOT live here: it is in .claude/context/features/<branch>.md, shared with the branch. -->
|
|
5
|
+
|
|
6
|
+
## Effort
|
|
7
|
+
Effort for implementer: medium
|
|
8
|
+
Effort for git-operator: low
|
|
9
|
+
|
|
10
|
+
## Preferences
|
|
11
|
+
-
|
|
12
|
+
|
|
13
|
+
## Scratch
|
|
14
|
+
-
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Git guard (PreToolUse on Bash): only `git-operator` writes to git.
|
|
3
|
+
// Reads the hook payload on stdin; `agent_type` is set for subagents and absent for the main session.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const READ = new Set(['status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'ls-files', 'ls-tree', 'grep', 'describe', 'shortlog', 'cat-file']);
|
|
10
|
+
const ASK = new Set(['push', 'merge', 'rebase']);
|
|
11
|
+
const ON_PROTECTED = new Set(['commit', 'cherry-pick', 'revert', 'am', 'pull', 'reset', 'rm']);
|
|
12
|
+
const RANK = { allow: 0, ask: 1, deny: 2 };
|
|
13
|
+
|
|
14
|
+
// Quoted text (commit messages) must not be read as commands.
|
|
15
|
+
const strip = (command) => command.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
|
|
16
|
+
|
|
17
|
+
function gitCommands(command) {
|
|
18
|
+
return strip(command)
|
|
19
|
+
.split(/&&|\|\||;|\||\n/)
|
|
20
|
+
.map((segment) => segment.trim().match(/^(?:\w+=\S+\s+)*git\s+((?:-\S+\s+)*)([\w-]+)\s*(.*)$/))
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map(([, , sub, rest]) => ({ sub, rest }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// `git branch` and `git remote` only read when given no mutating flag.
|
|
26
|
+
function writes({ sub, rest }) {
|
|
27
|
+
if (READ.has(sub)) return false;
|
|
28
|
+
if (sub === 'branch') return /(^|\s)(-[dDmMcC]|--delete|--move|--copy)\b/.test(rest) || /(^|\s)[^-\s]/.test(rest);
|
|
29
|
+
if (sub === 'remote') return !/^(-v|--verbose)?\s*$/.test(rest);
|
|
30
|
+
if (sub === 'config') return !/(^|\s)(--get|--list|-l)\b/.test(rest);
|
|
31
|
+
if (sub === 'stash') return !/^list\b/.test(rest);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function destructive({ sub, rest }) {
|
|
36
|
+
return (sub === 'reset' && /--hard/.test(rest))
|
|
37
|
+
|| (sub === 'push' && /(^|\s)(--force|--force-with-lease|-f)\b/.test(rest))
|
|
38
|
+
|| (sub === 'clean' && /(^|\s)-\w*f/.test(rest))
|
|
39
|
+
|| (sub === 'branch' && /(^|\s)-D\b/.test(rest));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decide({ command, agent, branch, protectedBranches }) {
|
|
43
|
+
let decision = { permissionDecision: 'allow', reason: '' };
|
|
44
|
+
const raise = (permissionDecision, reason) => {
|
|
45
|
+
if (RANK[permissionDecision] > RANK[decision.permissionDecision] || decision.reason === '') decision = { permissionDecision, reason };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const found = gitCommands(command).filter(writes);
|
|
49
|
+
if (found.length === 0) return null;
|
|
50
|
+
|
|
51
|
+
for (const git of found) {
|
|
52
|
+
if (agent !== 'git-operator') {
|
|
53
|
+
raise('deny', 'Only git-operator writes to git: delegate to it, after the user\'s go.');
|
|
54
|
+
} else if (destructive(git)) {
|
|
55
|
+
raise('deny', `git ${git.sub}: destructive command, not allowed.`);
|
|
56
|
+
} else if (ASK.has(git.sub)) {
|
|
57
|
+
raise('ask', `git ${git.sub} always needs the user's confirmation.`);
|
|
58
|
+
} else if (ON_PROTECTED.has(git.sub) && protectedBranches.includes(branch)) {
|
|
59
|
+
raise('ask', `"${branch}" is a protected branch: confirm before writing on it.`);
|
|
60
|
+
} else {
|
|
61
|
+
raise('allow', 'git-operator: git write on an unprotected branch.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return decision;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function currentBranch(cwd) {
|
|
68
|
+
try {
|
|
69
|
+
return execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
70
|
+
} catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function protectedBranches(root) {
|
|
76
|
+
try {
|
|
77
|
+
const { answers } = JSON.parse(readFileSync(join(root, '.claude', 'brigade.json'), 'utf8'));
|
|
78
|
+
return [answers.defaultBranch, answers.baseBranch].filter(Boolean);
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
85
|
+
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
86
|
+
const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd();
|
|
87
|
+
|
|
88
|
+
const decision = decide({
|
|
89
|
+
command: input.tool_input?.command ?? '',
|
|
90
|
+
agent: input.agent_type ?? '',
|
|
91
|
+
branch: currentBranch(input.cwd || root),
|
|
92
|
+
protectedBranches: protectedBranches(root)
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (decision) {
|
|
96
|
+
process.stdout.write(JSON.stringify({
|
|
97
|
+
hookSpecificOutput: {
|
|
98
|
+
hookEventName: 'PreToolUse',
|
|
99
|
+
permissionDecision: decision.permissionDecision,
|
|
100
|
+
permissionDecisionReason: decision.reason
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Brigade
|
|
2
|
+
|
|
3
|
+
Rules of the Brigade setup of this web project. Work is split between three roles.
|
|
4
|
+
|
|
5
|
+
## Roles
|
|
6
|
+
|
|
7
|
+
- **Main session (you)**: discuss, think, plan, orchestrate, verify, propose, answer questions. You do not write code and you do not run git write commands. Delegate development to `implementer`, git to `git-operator`, and go back and forth between them based on their reports.
|
|
8
|
+
- **`implementer`** (Sonnet): development only.
|
|
9
|
+
- **`git-operator`** (Haiku): git only, once the work is done.
|
|
10
|
+
|
|
11
|
+
A brief to a subagent states the goal, the branch, the relevant files, the constraints and what "done" means.
|
|
12
|
+
|
|
13
|
+
## Git
|
|
14
|
+
|
|
15
|
+
- Default branch: `__DEFAULT_BRANCH__`. Reference branch of the work: `__BASE_BRANCH__`. Both are protected: writing on them needs the user's confirmation.
|
|
16
|
+
- Check the current branch before any code.
|
|
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
|
+
- 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
|
+
- 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
|
+
- 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__
|
|
22
|
+
|
|
23
|
+
## Rules
|
|
24
|
+
|
|
25
|
+
- At the start of a session, read `.claude/context/decisions.md` (team decisions, shared).
|
|
26
|
+
- Keep changes small and focused.
|
|
27
|
+
- Use the project's package manager: `__PACKAGE_MANAGER__`.
|
|
28
|
+
- Run lint, build or tests only when the user asks or the task needs them. Do not assume they exist.
|
|
29
|
+
- Never read or write secrets (`.env*`, keys, tokens).
|
|
30
|
+
- Say so before adding a dependency.
|
|
31
|
+
- Validate input on the server side. Never log sensitive data.
|
|
32
|
+
- Do not read `node_modules`, lockfiles or build output.
|
|
33
|
+
|
|
34
|
+
## Languages
|
|
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`.
|
|
39
|
+
|
|
40
|
+
## Local effort
|
|
41
|
+
|
|
42
|
+
If `CLAUDE.local.md` gives an effort for a role (`implementer` or `git-operator`), pass it as the `effort` parameter when you delegate to that role. Change it with `/brigade-effort`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"model": "opusplan",
|
|
3
|
+
"effortLevel": "medium",
|
|
4
|
+
"language": "__REPLY_LANGUAGE__",
|
|
5
|
+
"permissions": {
|
|
6
|
+
"defaultMode": "acceptEdits",
|
|
7
|
+
"allow": [
|
|
8
|
+
"Bash(git status:*)",
|
|
9
|
+
"Bash(git diff:*)",
|
|
10
|
+
"Bash(git log:*)",
|
|
11
|
+
"Bash(git show:*)",
|
|
12
|
+
"Bash(__PACKAGE_MANAGER__ run:*)"
|
|
13
|
+
],
|
|
14
|
+
"ask": [
|
|
15
|
+
"Bash(git push:*)",
|
|
16
|
+
"Bash(git merge:*)",
|
|
17
|
+
"Bash(git rebase:*)"
|
|
18
|
+
],
|
|
19
|
+
"deny": [
|
|
20
|
+
"Bash(git reset --hard:*)",
|
|
21
|
+
"Bash(git push --force:*)",
|
|
22
|
+
"Bash(git push -f:*)",
|
|
23
|
+
"Bash(git clean -f:*)",
|
|
24
|
+
"Bash(git branch -D:*)",
|
|
25
|
+
"Bash(rm -rf /:*)",
|
|
26
|
+
"Bash(rm -rf ~:*)",
|
|
27
|
+
"Read(./.env*)",
|
|
28
|
+
"Read(./**/*.pem)",
|
|
29
|
+
"Read(./**/*.key)",
|
|
30
|
+
"Read(./node_modules/**)",
|
|
31
|
+
"Read(./package-lock.json)",
|
|
32
|
+
"Read(./pnpm-lock.yaml)",
|
|
33
|
+
"Read(./yarn.lock)",
|
|
34
|
+
"Read(./dist/**)",
|
|
35
|
+
"Read(./build/**)"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"hooks": {
|
|
39
|
+
"PreToolUse": [
|
|
40
|
+
{
|
|
41
|
+
"matcher": "Bash",
|
|
42
|
+
"hooks": [
|
|
43
|
+
{
|
|
44
|
+
"type": "command",
|
|
45
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/git-guard.mjs\""
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: audit
|
|
3
|
+
description: "Use after any code audit or review and BEFORE fixing anything, or when resuming one: writes, maintains and hands off the audit file for commit."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
An audit must survive between sessions, machines and developers: it is a committed, shared file.
|
|
7
|
+
|
|
8
|
+
- Right after an audit, BEFORE any fix, have `implementer` write `.claude/context/audits/YYYY-MM-DD-topic.md` from `.claude/context/audits/_template.md`: scope, baseline commit (`git rev-parse --short HEAD`), one table row per finding.
|
|
9
|
+
- Statuses: `open`, `fixed`, `wontfix` (reason), `deferred`. Set a row to `fixed` as soon as its fix is verified, BEFORE the commit, so the audit file goes in the same commit as the fix (no hash: it does not exist yet; `git log -- <file>` finds it).
|
|
10
|
+
- **Commit**: an audit file is never left uncommitted. Audit only (no fix yet): propose the commit to the user, then ask `git-operator` to commit it on the current branch. On the default or reference branch, ask the user which branch to use first. With fixes: include it in the task commit.
|
|
11
|
+
- Audit files are NOT loaded by default: read them on demand. Never delete one; when every row is closed, set `Status: closed` (and commit it).
|
|
12
|
+
- Keep the active audit's path and open IDs in the feature file (`.claude/context/features/<branch-with-slashes-as-dashes>.md`).
|
|
13
|
+
- To resume: work only on `open`/`deferred` rows, and run `git diff <baseline>..HEAD -- <files>` to find findings the code changed since. Re-audit only those.
|
|
14
|
+
- A `wontfix` that sets a team-wide rule also goes in `.claude/context/decisions.md`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feature
|
|
3
|
+
description: "Use on each development request, before any code, to judge whether it is a feature (several steps, sessions or files); also when pausing, handing off, ending a session, resuming or finishing a feature."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
A feature = work with several steps, sessions or files. Only features get a branch and a state file. Small work (a fix, a doc tweak, a few files, one sitting) has no branch requirement, no file, no handoff: it ends with a commit on the current branch (`main` and `develop` still need the user's confirmation).
|
|
7
|
+
|
|
8
|
+
- **Detection**: before any code, judge whether the request is a feature. If not, say nothing and continue. If yes, say in 1-2 lines that it looks like a feature and propose to handle it as one (branch `feat/<name>` + state file). Wait for the answer: yes / no / "small work".
|
|
9
|
+
- **Yes**: `git-operator` creates the branch (with the user's go). Create the file at once from `.claude/context/features/_template.md` with the goal and the planned steps. File name: the branch with slashes as dashes (`feat/login` -> `feat-login.md`), in `.claude/context/features/`.
|
|
10
|
+
- **No / small work**: continue on the current branch, no file, do not ask again for that work. If it grows into a feature, tell the user and re-propose.
|
|
11
|
+
- **During the feature**: `implementer` updates the file at important steps. It goes in the same commit as the work (mention it in the `git-operator` brief).
|
|
12
|
+
- **Pause / handoff / end of session**: make sure the file is up to date (have `implementer` fix it), propose the commit, then propose the push in one sentence: without a push nothing survives. Pushing needs the user's confirmation.
|
|
13
|
+
- **Resume**: `session-start` reads the file and reports it, then runs the resume check: new commits on the base branch (`git log HEAD..<base> --oneline`) and conflict risk, changes to `.claude/context/decisions.md` since the saved date, audit rows still open.
|
|
14
|
+
- **End**: remove the file in the last commit before the branch is merged.
|
|
15
|
+
- The file is versioned with the branch, so it is shared between developers and machines. `CLAUDE.local.md` stays personal.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: session-start
|
|
3
|
+
description: "Use before the first coding task of a session (not for plain questions): checks the git branch, its feature file and remote sync, reports where things stand."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Check the current branch (`git branch --show-current`, `git status -sb`), read `.claude/context/decisions.md`, `CLAUDE.local.md` (if present, personal preferences only) and `.claude/context/features/<branch-with-slashes-as-dashes>.md` (if present).
|
|
7
|
+
|
|
8
|
+
- **Feature file present**: read it and report in 2-3 lines the goal, what is done, the next step. Then run the resume check (see `feature`, "Resume"): new commits on base, `decisions.md` changes since the saved date, open audit rows. Ask whether to continue or start something new, and do not start coding before the user has confirmed.
|
|
9
|
+
- **No file**: just report the branch and the remote sync. No file is created.
|
|
10
|
+
|
|
11
|
+
Also report the sync with the remote: `git status -sb` (ahead/behind) and `git log @{u}..HEAD --oneline` for unpushed commits. If the branch is behind or has no upstream, say so. If commits are unpushed, propose the push in one sentence.
|