diff --git a/AGENTS.md b/AGENTS.md index 6e9cd0f..6ce902e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,10 +155,22 @@ the two settings that change what bench *is* — claim-on-move and syncing through origin/main — stay invisible to anyone who has not read `core/.env.example`. So the first run asks, and writes the file: **solo or team** (team turns `BOARD_COMMIT_MOVES` and `BOARD_SYNC` on together, and -the question says what that costs), **which agent adapter** (enumerated -from the adapter directories, so a project's own `local/adapters/` entry -is offered), and **what command runs this project's tests** -(`BOARD_AGENT_COMMANDS` — the one a headless agent cannot work around). +the question says what that costs) and **which agent adapter** +(enumerated from the adapter directories, so a project's own +`local/adapters/` entry is offered). + +It does not ask what runs the project's tests. That was a third question +once, and it wanted an answer about a repo the person may have just +cloned, thirty seconds in and before anything had explained why the +board needed one. It is detected instead — `package.json` → `npm test`, +`Cargo.toml` → `cargo test`, `go.mod` → `go test ./...`, a +`pyproject.toml`/`setup.py`/`tests/` → `python3 -m unittest` — and a +project matching none of them gets `BOARD_AGENT_COMMANDS` empty, which +is honest: an agent then runs no project commands until someone fills it +in. A wrong guess costs nothing an empty value would not, since a prefix +that matches nothing denies exactly the same way. An existing value is +never overwritten, so `--setup` cannot undo a hand-edit. + Bare Enter takes the default, Ctrl-D skips the rest, and what lands is `core/.env.example` with the answers substituted into their lines: every other key, every comment, so the written file is where the project reads diff --git a/README.md b/README.md index 0b3ffd3..978edc3 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,13 @@ mkdir .task-manager && curl -L \ No token, no clone: releases are curated artifacts that never contained bench's own cards or settings, so the board starts empty by construction. -The first run asks three questions it cannot answer for you — solo or -team, which agent adapter, what command runs your tests — and writes -`manager/local/.env` from the documented example, so every other setting -is discoverable in your own copy. Bare Enter takes the default +The first run asks the two questions it cannot answer for you — solo or +team, and which agent adapter — and writes `manager/local/.env` from the +documented example, so every other setting is discoverable in your own +copy. What runs your tests is read off the project rather than asked +(`package.json` → `npm test`, and so on); nothing recognisable leaves +`BOARD_AGENT_COMMANDS` empty, and that is the key to set before an agent +can run them. Bare Enter takes the default throughout; with no terminal (CI, a script) it asks nothing and `install.py --setup` asks later. diff --git a/install.py b/install.py index ec1a480..e8eb7a9 100755 --- a/install.py +++ b/install.py @@ -124,9 +124,10 @@ def first_boot_clean(dry_run: bool) -> None: # ── First-run settings ──────────────────────────────────────────────── # # Everything not asked about is written at its documented default, so the -# answers are only the ones no default can be right about: how this -# project works (solo or team), which agent runs its headless jobs, and -# what command runs its tests. +# answers are only the ones no default can be right about and nothing +# can be read off the project: how this project works (solo or team) +# and which agent runs its headless jobs. The test command used to be a +# third question; it is detected instead — see TEST_COMMANDS. ENV_EXAMPLE = CORE / ".env.example" ENV_FILE = LOCAL / ".env" @@ -137,10 +138,33 @@ TEAM_NOTE = """\ merges, and a local main that only ever advances through the board. Solo — today's default — does none of it.""" -COMMANDS_NOTE = """\ - Headless agents may only run the command prefixes named here, so a test - runner missing from the list is a test the work agent cannot run. - Comma-separate several.""" +# What runs this project's tests, read off the project rather than asked. +# It was a question once, and it was the wrong one to put to someone +# thirty seconds into their first run: it wants an answer about a project +# they may have just cloned, before anything has explained why the board +# needs it. The file that names a project's ecosystem usually names its +# test runner too, so the first match wins and no match writes nothing. +# +# A wrong guess costs nothing it did not already cost: the prefix simply +# never matches, and the agent is denied exactly as it would be with the +# key empty. What it must never do is guess something *broader* than the +# truth — every entry here is one runner, not a shell. +TEST_COMMANDS = [ + ("package.json", "npm test"), + ("Cargo.toml", "cargo test"), + ("go.mod", "go test ./..."), + ("pyproject.toml", "python3 -m unittest"), + ("setup.py", "python3 -m unittest"), + ("tests", "python3 -m unittest"), +] + + +def detect_test_command(root: Path) -> str: + """The project's test runner, or "" when nothing here names one.""" + for marker, command in TEST_COMMANDS: + if (root / marker).exists(): + return command + return "" class _Skipped(Exception): @@ -217,9 +241,25 @@ def _ask(question: str, default: str, note: str = "") -> str: return answer or default -def ask_questions(current: dict[str, str], answers: dict[str, str]) -> None: +def ask_questions(current: dict[str, str], answers: dict[str, str], + held: dict[str, str] | None = None) -> None: """Fill `answers` in place — in place because a Ctrl-D part-way through - keeps what was already answered.""" + keeps what was already answered. + + `current` is the example's values under the existing file's, which is + what a question should offer as its default. `held` is only what this + project itself has said — empty on a first run — because "keep what is + already there" must not mean "keep the example's default". + """ + held = held or {} + # Detected, not asked — and settled before the first question, so a + # Ctrl-D part-way through still leaves the project's own runner rather + # than the example's Python one. A value this project has already set + # wins: --setup must not undo a hand-edit. The example's default is + # not such a value, which is why this reads `held` and not `current`. + answers["BOARD_AGENT_COMMANDS"] = ( + held.get("BOARD_AGENT_COMMANDS") or detect_test_command(PROJECT)) + team = env_on(current.get("BOARD_SYNC", "")) or env_on( current.get("BOARD_COMMIT_MOVES", "")) while True: @@ -246,10 +286,6 @@ def ask_questions(current: dict[str, str], answers: dict[str, str]) -> None: print(f" no such adapter here — one of: {', '.join(allowed)}.") answers["BOARD_AGENT_ADAPTER"] = reply - answers["BOARD_AGENT_COMMANDS"] = _ask( - "what command runs this project's tests?", - current.get("BOARD_AGENT_COMMANDS", "python3 -m unittest"), - note=COMMANDS_NOTE) def setup(dry_run: bool, forced: bool) -> None: @@ -261,9 +297,12 @@ def setup(dry_run: bool, forced: bool) -> None: return verb = "rewrite" if exists else "write" if dry_run: - print(f"would ask: solo or team, which agent adapter, the test " - f"command — and {verb} {_rel(ENV_FILE)} from " - f"{_rel(ENV_EXAMPLE)}.\nDry run — nothing written.\n") + found = detect_test_command(PROJECT) + print(f"would ask: solo or team, which agent adapter — and " + f"{verb} {_rel(ENV_FILE)} from {_rel(ENV_EXAMPLE)}, with " + f"BOARD_AGENT_COMMANDS=" + f"{found or '(nothing detected)'}.\n" + f"Dry run — nothing written.\n") return if not sys.stdin.isatty(): if exists: @@ -290,7 +329,8 @@ def setup(dry_run: bool, forced: bool) -> None: answers: dict[str, str] = {} try: - ask_questions(current, answers) + ask_questions(current, answers, + held=env_values(base) if exists else {}) except _Skipped: print(" skipped — the rest stay at their documented defaults.") except KeyboardInterrupt: diff --git a/site/templates/home.html b/site/templates/home.html index 18cd618..89d3d4d 100644 --- a/site/templates/home.html +++ b/site/templates/home.html @@ -91,7 +91,6 @@ $nav solo or team? [solo]: which agent adapter? [claude]: - what command runs this project's tests? [python3 -m unittest]: diff --git a/tests/test_install_first_boot.py b/tests/test_install_first_boot.py index b9675f8..add310d 100644 --- a/tests/test_install_first_boot.py +++ b/tests/test_install_first_boot.py @@ -204,17 +204,47 @@ class FirstRunSettings(unittest.TestCase): self.env_file.read_text(encoding="utf-8")) def test_bare_enter_everywhere_writes_the_shipped_defaults(self): - """Every question defaulted → the file is the example verbatim, so - the board behaves exactly as it does with no .env at all.""" - out = run_install_tty(self.tm, ["", "", ""]) + """Every question defaulted → the file is the example, save the one + key that is read off the project rather than asked about. A scratch + host names no test runner, so that key lands empty.""" + out = run_install_tty(self.tm, ["", ""]) self.assertIn("solo or team?", out) self.assertIn("which agent adapter?", out) - self.assertIn("what command runs this project's tests?", out) - self.assertEqual(self.env_file.read_text(encoding="utf-8"), - self.example) + self.assertEqual( + self.env_file.read_text(encoding="utf-8"), + self.example.replace("BOARD_AGENT_COMMANDS=python3 -m unittest", + "BOARD_AGENT_COMMANDS=")) + + def test_the_test_command_is_never_asked_for(self): + """It was a third question once. It wanted an answer about a repo + the person may have just cloned, before anything had explained why + the board needed one.""" + out = run_install_tty(self.tm, ["", ""]) + self.assertNotIn("runs this project's tests", out) + + def test_the_test_command_is_read_off_the_project(self): + (self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8") + run_install_tty(self.tm, ["", ""]) + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test") + + def test_a_project_naming_no_runner_gets_an_empty_allowlist(self): + """Empty is honest: the agent runs no project commands until + someone fills it in. The example's Python default would be quietly + wrong in most repos, and wrong is worse than absent here.""" + run_install_tty(self.tm, ["", ""]) + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "") + + def test_the_first_marker_wins(self): + """A repo with both a package.json and a tests/ directory is a JS + project with tests, not a Python one.""" + (self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8") + (self.tm.parent / "tests").mkdir(exist_ok=True) + run_install_tty(self.tm, ["", ""]) + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test") def test_answers_are_substituted_into_the_whole_example(self): - out = run_install_tty(self.tm, ["team", "claude", "npm test"]) + (self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8") + out = run_install_tty(self.tm, ["team", "claude"]) written = self.env_file.read_text(encoding="utf-8") self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "1") self.assertEqual(self.values()["BOARD_SYNC"], "1") @@ -229,21 +259,25 @@ class FirstRunSettings(unittest.TestCase): self.assertIn("Wrote .task-manager/manager/local/.env", out) def test_solo_leaves_both_team_settings_empty(self): - run_install_tty(self.tm, ["solo", "", ""]) + run_install_tty(self.tm, ["solo", ""]) self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "") self.assertEqual(self.values()["BOARD_SYNC"], "") def test_an_invalid_answer_is_asked_again(self): - out = run_install_tty(self.tm, ["both", "team", "", ""]) + out = run_install_tty(self.tm, ["both", "team", ""]) self.assertIn("answer solo or team.", out) self.assertEqual(self.values()["BOARD_SYNC"], "1") def test_ctrl_d_skips_the_rest_and_writes_the_defaults(self): + """The detected command is settled before the first question, so a + Ctrl-D part-way through still leaves the project's own runner — + not the example's Python one, which is what a later detection + would have been skipped past.""" + (self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8") out = run_install_tty(self.tm, ["team", CTRL_D]) self.assertIn("skipped", out) self.assertEqual(self.values()["BOARD_SYNC"], "1") - self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], - "python3 -m unittest") + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test") def test_the_adapter_question_enumerates_the_directories(self): """Adapters are listed from disk, so a project's own local one is @@ -255,12 +289,12 @@ class FirstRunSettings(unittest.TestCase): mine = self.tm / "manager" / "local" / "adapters" / "mine" mine.mkdir(parents=True) (mine / "run").write_text("#!/bin/sh\n", encoding="utf-8") - out = run_install_tty(self.tm, ["", "mine", ""]) + out = run_install_tty(self.tm, ["", "mine"]) self.assertIn("here: claude, opencode, mine.", out) self.assertEqual(self.values()["BOARD_AGENT_ADAPTER"], "mine") def test_an_existing_env_is_never_touched_and_the_run_stays_quiet(self): - run_install_tty(self.tm, ["team", "", ""]) + run_install_tty(self.tm, ["team", ""]) written = self.env_file.read_text(encoding="utf-8") result = run_install(self.tm) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) @@ -297,13 +331,15 @@ class FirstRunSettings(unittest.TestCase): .replace("BOARD_AGENT_COMMANDS=python3 -m unittest", "BOARD_AGENT_COMMANDS=make test"), encoding="utf-8") - out = run_install_tty(self.tm, ["", "", ""], "--setup") + out = run_install_tty(self.tm, ["", ""], "--setup") self.assertIn("[team]", out) # the current file's answer… - self.assertIn("[make test]", out) # …offered as the default self.assertEqual(self.values()["BOARD_SYNC"], "1") self.assertEqual(self.values()["BOARD_PORT"], "26099") + # …and the hand-edited command survives, detection notwithstanding: + # an existing answer is never second-guessed by a marker file. + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "make test") - out = run_install_tty(self.tm, ["solo", "", ""], "--setup") + out = run_install_tty(self.tm, ["solo", ""], "--setup") self.assertEqual(self.values()["BOARD_SYNC"], "") self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "") self.assertEqual(self.values()["BOARD_PORT"], "26099") @@ -320,7 +356,7 @@ class FirstRunSettings(unittest.TestCase): def test_first_boot_both_clears_the_cards_and_writes_the_env(self): """The order is load-bearing: .env is one of the two things the first-boot guard reads, so writing it early would skip the clean.""" - out = run_install_tty(self.tm, ["", "", ""]) + out = run_install_tty(self.tm, ["", ""]) self.assertIn("removed tasks/backlog/00-shipped-card.md", out) self.assertEqual(shipped_files(self.tm), []) self.assertTrue(self.env_file.is_file()) diff --git a/tests/test_site_landing.py b/tests/test_site_landing.py index 6a452c7..7143fa5 100644 --- a/tests/test_site_landing.py +++ b/tests/test_site_landing.py @@ -48,9 +48,6 @@ CONFIG = REPO / "manager" / "core" / "config.py" TRANSCRIPT = [ ("solo or team?", "solo or team?", INSTALL), ("which agent adapter?", "which agent adapter?", INSTALL), - ("what command runs this project's tests?", - "what command runs this project's tests?", INSTALL), - ("python3 -m unittest", "python3 -m unittest", INSTALL), ("Task board for", "Task board for", BOARD), ("http://127.0.0.1:", "http://127.0.0.1:", BOARD), ("26071", "26071", CONFIG),