diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8876aa2..ac789a6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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", diff --git a/plugins/flow/.claude-plugin/plugin.json b/plugins/flow/.claude-plugin/plugin.json index a657fa9..fc14925 100644 --- a/plugins/flow/.claude-plugin/plugin.json +++ b/plugins/flow/.claude-plugin/plugin.json @@ -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" diff --git a/plugins/flow/README.md b/plugins/flow/README.md index 794542c..78741af 100644 --- a/plugins/flow/README.md +++ b/plugins/flow/README.md @@ -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. | @@ -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. diff --git a/plugins/flow/SETUP.md b/plugins/flow/SETUP.md index fca3db1..ffd92eb 100644 --- a/plugins/flow/SETUP.md +++ b/plugins/flow/SETUP.md @@ -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 diff --git a/plugins/flow/commands/health.md b/plugins/flow/commands/health.md index 7a20c58..3284c77 100644 --- a/plugins/flow/commands/health.md +++ b/plugins/flow/commands/health.md @@ -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? diff --git a/plugins/flow/commands/init.md b/plugins/flow/commands/init.md index 0836d41..d8e8f5c 100644 --- a/plugins/flow/commands/init.md +++ b/plugins/flow/commands/init.md @@ -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 @@ -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 @@ -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. diff --git a/plugins/flow/package.json b/plugins/flow/package.json index 9adfcf9..dc51d83 100644 --- a/plugins/flow/package.json +++ b/plugins/flow/package.json @@ -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" diff --git a/plugins/flow/references/config-template.md b/plugins/flow/references/config-template.md index 5cedc14..a18d3ed 100644 --- a/plugins/flow/references/config-template.md +++ b/plugins/flow/references/config-template.md @@ -71,8 +71,9 @@ Examples (replace with your own — these are illustrative, not defaults): - e.g. lint_cmd: `` - e.g. test_cmd: `` -> `/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. --- diff --git a/plugins/flow/scripts/init.js b/plugins/flow/scripts/init.js index 21a08ae..386aca5 100644 --- a/plugins/flow/scripts/init.js +++ b/plugins/flow/scripts/init.js @@ -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`); @@ -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); @@ -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; } @@ -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, diff --git a/plugins/flow/scripts/init.test.js b/plugins/flow/scripts/init.test.js index 8209442..ad6a05c 100644 --- a/plugins/flow/scripts/init.test.js +++ b/plugins/flow/scripts/init.test.js @@ -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'); @@ -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')); @@ -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/); +});