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),