Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"name": "flow",
"description": "Stack-agnostic AI dev workflow \u2014 twelve slash commands, a focused reviewer agent, and hooks that layer with your project's .flow/ config",
"source": "./plugins/flow",
"version": "0.6.0",
"version": "0.7.0",
"category": "workflow",
"keywords": [
"workflow",
Expand Down
2 changes: 1 addition & 1 deletion plugins/flow/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flow",
"version": "0.6.0",
"version": "0.7.0",
"description": "Reusable AI dev workflow for Claude Code: orchestrated agents, session lifecycle, deep reviews, and project-aware slash commands.",
"author": {
"name": "kaiomp"
Expand Down
4 changes: 3 additions & 1 deletion plugins/flow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ This creates `.flow/config.md` (project-level config) and `CLAUDE.md` (project m

| Command | Purpose |
| -------------------- | ----------------------------------------------------------------------- |
| `/flow:init` | One-time setup. Drops config and memory templates into the project. |
| `/flow:init` | One-time setup. Drops config and memory templates into the project, and fills in the stack commands itself — detected from your manifests, otherwise inferred from the repo and verified to run. |
| `/flow:uninstall` | Remove what `init` created, so you can start clean or re-init. Dry-run by default. |
| `/flow:continue` | Resume an in-progress session from `.flow/session-progress.md`, or start a new one if none exists. |
| `/flow:questions` | Work the open-question queue in `.flow/questions.md` — answer a round, list it, reopen or retire an entry. |
Expand Down Expand Up @@ -76,6 +76,8 @@ Custom agents communicate through files in `.flow/session/` (e.g. `investigation

Hooks and helper scripts read these values. Nothing is hardcoded — `flow` adapts to your stack. See `references/config-template.md` for the full annotated template.

`/flow:init` fills the `*_cmd` fields in for you. It reads them straight off a manifest where there is one (`package.json` scripts, `go.mod`, `Cargo.toml`, `pyproject.toml`, …); where there isn't — a script-style repo with just a `requirements.txt` and a venv, say — the init agent infers each command from what is actually in the repo, runs it to confirm it works, and writes the verified value into `config.md`. You are never handed a blank field to fill in by hand. A field stays blank only when nothing in the repo supports a command there, which the consuming hooks read as "skip that step".

### `config.md` is private by default

`.flow/config.md` is how the same globally-installed plugin adapts to each repo's stack, but flow's default `private_globs` includes `.claude`, so `/flow:pause` will not stage it. Treat it as personal machine setup: sync it outside git if you want it on your other machines.
Expand Down
5 changes: 3 additions & 2 deletions plugins/flow/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ asks before dropping session state), then `/flow:init` again.

## 2. Check `.flow/config.md`

Init fills in what it detected, so most projects only need a glance. A blank value means
"skip that step" — fill it in by hand if detection missed something:
Init fills these in — from your manifest where there is one, otherwise inferred from the
repo and verified to run — so most projects only need a glance. A blank value means "skip
that step": nothing in the repo supported a command there.

```
workflow_mode: solo
Expand Down
5 changes: 5 additions & 0 deletions plugins/flow/commands/health.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ Run each check and collect the result. Print a table at the end.
- Does it parse? (frontmatter or key:value lines as defined in the template)
- Are required fields present: `workflow_mode`, `pm_backend`, `dev_cmd`, `lint_cmd`, `build_cmd`, `test_cmd`?

A blank `*_cmd` means "skip that step", which is legitimate for a project that has no such
command. Report it, but do not tell the user to fill it in by hand — if the repo evidently
supports one (a venv, a test file, a `Makefile` target), infer it, verify it runs, and write
it into `.flow/config.md` yourself, exactly as `/flow:init` Step 4 does.

### 2. CLAUDE.md freshness

- Does `$CLAUDE_PROJECT_DIR/CLAUDE.md` exist?
Expand Down
53 changes: 44 additions & 9 deletions plugins/flow/commands/init.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
description: Bootstrap a new project with .flow/config.md and CLAUDE.md tailored to your stack.
allowed-tools: Bash, Read, AskUserQuestion
allowed-tools: Bash, Read, Edit, Glob, AskUserQuestion
---
# /flow:init

Bootstrap a project so the rest of `/flow:*` works. This drives `scripts/init.js`, which
writes `.flow/config.md` and `CLAUDE.md` and auto-detects your stack commands from files
already on disk (`package.json` scripts, `go.mod`, `Cargo.toml`, `pyproject.toml`, …). You
only need to supply what the script cannot infer.
already on disk (`package.json` scripts, `go.mod`, `Cargo.toml`, `pyproject.toml`, …). What
no manifest evidences, **you** work out from the repo in Step 4 — the user is never asked to
fill in a command by hand.

## Step 1: Ask the user

Expand All @@ -19,7 +20,8 @@ Use `AskUserQuestion`, batched where possible:
what `git remote get-url origin` parses to.
4. If `linear`: ask for `pm_linear_team` (the team key, e.g. `ENG`).

Do not ask about dev/lint/typecheck/build/test/format commands — the script detects those.
Do not ask about dev/lint/typecheck/build/test/format commands — the script detects what the
manifests evidence, and Step 4 has you infer the rest from the repo yourself.

For the project name, don't ask by default — the script infers it from `package.json`'s
`name` field, falling back to the directory basename. Only ask via `AskUserQuestion` if
Expand Down Expand Up @@ -58,15 +60,48 @@ first, then `/flow:init` again.
collaborators should get the same stack setup. Only `.flow/local.md` is machine-private
(`private_globs` covers it), so machine-specific values belong there, not in `config.md`.

## Step 4: Report
## Step 4: Fill in the stack commands the script left blank

Relay the script's output verbatim — which files were `created` / `left untouched`, and
which stack commands were detected.
The script only writes a command a manifest file evidences, so a script-style repo (a
`requirements.txt` and a venv, a `Makefile`, a bare `main.py`) comes back with blank
`*_cmd` fields. Those blanks are **yours to close, not the user's**. If the script's
closing lines name any, work each one out from the repo before you report anything.

## Step 5: Verify the setup
Look for the evidence a manifest did not carry (`Glob`, then `Read` what looks relevant):

- **Interpreter** — `.venv/bin/python`, `venv/bin/python`, `.venv/bin/pytest`. Prefer the
project's own interpreter over a bare `pytest`/`python` that may not be on `PATH`.
- **Tests** — `test_*.py`, `*_test.py`, `tests/`, `tox.ini`, `noxfile.py`, `*_test.go`.
- **Dev entrypoint** — `manage.py` (`python manage.py runserver`), `app.py`, `main.py`,
`wsgi.py`/`asgi.py`, a `docker-compose.yml` service.
- **Task runner** — a `Makefile` / `justfile` / `Taskfile.yml` target wins over a
hand-rolled command: prefer `make test` over re-deriving what `make test` already runs.
- **Lint/format** — a `.flake8`, `setup.cfg`, `.pre-commit-config.yaml`, or a linter
pinned in `requirements*.txt`.

Then, for each command you inferred:

1. **Run it** with `Bash` before writing it down. For `test_cmd`/`lint_cmd`/`build_cmd`
run it outright; for `dev_cmd`, `--help` or a few-second start is proof enough — never
leave a server running.
2. If it fails, fix it or drop it. Never write a command you have not seen work.
3. Write the verified value into `$CLAUDE_PROJECT_DIR/.flow/config.md` with `Edit`,
replacing the blank line in place: `- test_cmd:` → `- test_cmd: .venv/bin/python -m pytest`.

> Leave a field blank only when nothing in the repo supports one — a library with no dev
> server has no `dev_cmd`, and that blank is correct. Say which ones you left blank and
> why. Never end a step by telling the user to edit `.flow/config.md` themselves.

## Step 5: Report

Relay the script's output — which files were `created` / `left untouched`, and which stack
commands were detected. Then add what you inferred in Step 4: each command, the evidence it
came from, and that you verified it runs.

## Step 6: Verify the setup

Run `/flow:health` straight away rather than suggesting it. Init is exactly the point where
a wrong answer is cheapest to fix, and health is read-only.

Report only what health flags. If everything passes, one line is enough — do not reprint the
whole table on top of the Step 4 report.
whole table on top of the Step 5 report.
2 changes: 1 addition & 1 deletion plugins/flow/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flow",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"scripts": {
"test": "node --test scripts/*.test.js hooks/*.test.js"
Expand Down
5 changes: 3 additions & 2 deletions plugins/flow/references/config-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ Examples (replace with your own — these are illustrative, not defaults):
- e.g. lint_cmd: `<your-linter>`
- e.g. test_cmd: `<your-test-runner>`
<!-- template-only:end -->
> `/flow:init` fills these in from your manifest. A blank value means "skip that step" —
> add one by hand if the detection missed it.
> `/flow:init` fills these in: from your manifest where one exists, and otherwise the init
> agent infers the command from the repo and verifies it runs before writing it here. A
> blank value means "skip that step" — nothing in the repo supported one.

---

Expand Down
38 changes: 36 additions & 2 deletions plugins/flow/scripts/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,37 @@ function renderClaudeMdTemplate(text, opts) {
return out;
}

/**
* The closing line(s) of an init run, addressed to whoever can still act.
*
* `detected` is null when `.flow/config.md` already existed — nothing was
* detected this run and there is nothing to fill in. Otherwise any `*_cmd`
* no manifest evidenced comes back blank, and closing that gap is the init
* *agent's* job, not the user's: the agent can read the repo (a venv, a
* runnable `test_*.py`, a `Makefile` target), infer the command, and verify
* it runs — all in the step it is already in. Handing that back to the user
* as "edit config.md yourself" is homework, so this message never does.
*/
function doneMessage(detected) {
if (!detected) return '[flow init] Done.\n';

const blanks = STACK_CMD_KEYS.filter((key) => !detected[key]);
if (blanks.length === 0) {
return '[flow init] Done. Every stack command was detected from a manifest.\n';
}

const headline = blanks.length === STACK_CMD_KEYS.length
? '[flow init] Done. No stack commands were detected from manifests.\n'
: `[flow init] Done. Not detected from manifests: ${blanks.join(', ')}.\n`;

return headline +
'[flow init] Next (agent, not the user): inspect the repo for the evidence a\n' +
' manifest did not carry — a venv, a runnable test_*.py, a dev entrypoint, a\n' +
' Makefile/justfile target — then infer each blank *_cmd, verify it actually\n' +
' runs, and write the verified value into .flow/config.md. Leave a field blank\n' +
' only when nothing in the repo supports one.\n';
}

function report(label, result) {
if (result.missingSource) {
process.stdout.write(` ! ${label}: source template missing (${result.path})\n`);
Expand Down Expand Up @@ -421,8 +452,10 @@ function main() {
report('.flow/config.md', configResult);
// Only fill in a freshly-created config.md — an existing one may already
// hold the user's own edits, which copyIfMissing correctly left alone.
// Stays null on a re-run: nothing was detected, so nothing is owed.
let detected = null;
if (configResult.created) {
const detected = detectStackCommands(target);
detected = detectStackCommands(target);
let text = fs.readFileSync(configDest, 'utf8');
text = applyStackCommands(text, detected);
text = applyPmFields(text, args);
Expand Down Expand Up @@ -473,7 +506,7 @@ function main() {
report('issues/', ensureDir(issuesDir));
}

process.stdout.write('\n[flow init] Done. Next: edit .flow/config.md to fill in your project commands.\n');
process.stdout.write(`\n${doneMessage(detected)}`);
return 0;
}

Expand All @@ -483,6 +516,7 @@ if (require.main === module) {

module.exports = {
main, parseArgs, copyIfMissing, ensureDir, appendSection, stripTemplateOnly, readPmBackend,
doneMessage,
detectStackCommands, applyStackCommands, applyPmFields, pmPrefix,
detectProjectName, detectLanguageRuntime, detectFramework, renderClaudeMdTemplate,
VALID_WORKFLOW_MODES, VALID_PM_BACKENDS,
Expand Down
127 changes: 126 additions & 1 deletion plugins/flow/scripts/init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const os = require('os');
const path = require('path');

const {
copyIfMissing, ensureDir, appendSection, parseArgs, main,
copyIfMissing, ensureDir, appendSection, parseArgs, main, doneMessage,
detectStackCommands, applyStackCommands, applyPmFields, pmPrefix,
detectProjectName, detectLanguageRuntime, detectFramework, renderClaudeMdTemplate,
} = require('./init');
Expand All @@ -28,6 +28,39 @@ function runInit(target, extraArgs = []) {
}
}

// main() reports through process.stdout; the assertions about *what it tells whom*
// need that text, so swap the sink for the duration of the (synchronous) call.
function captureStdout(fn) {
const original = process.stdout.write;
let captured = '';
process.stdout.write = (chunk) => {
captured += chunk;
return true;
};
try {
fn();
} finally {
process.stdout.write = original;
}
return captured;
}

function runInitCapturing(target, extraArgs = []) {
let code;
const output = captureStdout(() => {
code = runInit(target, extraArgs);
});
return { code, output };
}

function blankDetection(overrides = {}) {
const detected = {
dev_cmd: '', lint_cmd: '', typecheck_cmd: '',
build_cmd: '', test_cmd: '', format_cmd: '',
};
return Object.assign(detected, overrides);
}

test('parseArgs: target defaults to cwd and both flag spellings resolve to absolute', () => {
assert.equal(parseArgs([]).target, process.cwd());
assert.equal(parseArgs(['--target', '/tmp/foo']).target, path.resolve('/tmp/foo'));
Expand Down Expand Up @@ -695,3 +728,95 @@ test('main: re-running with new PM flags never touches an already-filled-in conf

assert.equal(fs.readFileSync(configDest, 'utf8'), 'USER EDITED CONFIG', 'existing user config must never be rewritten by PM flags either');
});


// --- The closing message never hands the user homework the agent can do (issue #20) ---

test('doneMessage: nothing detected — directs the AGENT to infer the commands, not the user', () => {
const message = doneMessage(blankDetection());

assert.match(message, /No stack commands were detected/);
assert.match(message, /agent, not the user/,
'the work is addressed to whoever can actually do it');
assert.match(message, /verify/i, 'an inferred command is worthless unverified');
assert.doesNotMatch(message, /edit \.flow\/config\.md to fill in/,
'the old "edit it yourself" punt must be gone');
assert.doesNotMatch(message, /by hand/);
});

test('doneMessage: partial detection — names exactly the keys still blank', () => {
const message = doneMessage(blankDetection({ lint_cmd: 'ruff check .', test_cmd: 'pytest' }));

assert.match(message, /Not detected from manifests: dev_cmd, typecheck_cmd, build_cmd, format_cmd/);
assert.doesNotMatch(message, /lint_cmd/, 'a detected key is not outstanding work');
assert.doesNotMatch(message, /test_cmd/);
assert.match(message, /agent, not the user/);
});

test('doneMessage: everything detected — plain done, no follow-up work', () => {
const message = doneMessage(blankDetection({
dev_cmd: 'pnpm dev', lint_cmd: 'pnpm lint', typecheck_cmd: 'pnpm typecheck',
build_cmd: 'pnpm build', test_cmd: 'pnpm test', format_cmd: 'pnpm format',
}));

assert.match(message, /Every stack command was detected/);
assert.doesNotMatch(message, /agent, not the user/, 'nothing is outstanding, so ask for nothing');
});

test('doneMessage: config.md already existed — nothing was detected, so nothing is owed', () => {
const message = doneMessage(null);

assert.equal(message, '[flow init] Done.\n');
});

test('main: script-style Python repo — output tells the agent to fill the blanks in, never the user', () => {
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-init-'));
// requirements.txt + a venv + a runnable test file: plenty for an agent to work
// from, but no manifest the script itself reads. This is the issue's repro.
fs.writeFileSync(path.join(target, 'requirements.txt'), 'requests\n');
fs.mkdirSync(path.join(target, 'venv', 'bin'), { recursive: true });
fs.writeFileSync(path.join(target, 'test_thing.py'), 'def test_thing():\n assert True\n');

const { code, output } = runInitCapturing(target, ['--pm-backend', 'local']);

assert.equal(code, 0);
assert.match(output, /No stack commands were detected/);
assert.match(output, /agent, not the user/);
assert.doesNotMatch(output, /edit \.flow\/config\.md to fill in/,
'the user must never be handed the config file as homework');
assert.doesNotMatch(output, /by hand/);

// The detection invariant itself is unchanged: blanks stay blank on disk.
const configText = fs.readFileSync(path.join(target, '.flow', 'config.md'), 'utf8');
assert.match(configText, /^- test_cmd:$/m);
assert.doesNotMatch(configText, /\{COMMAND_TO/);
});

test('main: fully-detected project keeps the plain detected-command output', () => {
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-init-'));
fs.writeFileSync(path.join(target, 'pnpm-lock.yaml'), '');
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
scripts: {
dev: 'vite', lint: 'eslint .', typecheck: 'tsc --noEmit',
build: 'vite build', test: 'vitest run', format: 'prettier --write .',
},
}));

const { output } = runInitCapturing(target);

assert.match(output, /detected: lint_cmd = pnpm lint/, 'the per-key detection lines are unchanged');
assert.match(output, /Every stack command was detected/);
assert.doesNotMatch(output, /agent, not the user/, 'nothing was left blank, so ask for nothing');
});

test('main: re-run over an existing config.md asks for no follow-up work', () => {
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-init-'));
runInit(target);

const { output } = runInitCapturing(target);

assert.match(output, /\[flow init\] Done\./);
assert.doesNotMatch(output, /agent, not the user/,
're-running detects nothing, and an existing config is the user\'s own — leave it be');
assert.doesNotMatch(output, /No stack commands were detected/);
});
Loading