Adds basic task management tools.

This commit is contained in:
2026-10-08 19:15:08 -07:00
parent 5725de2dac
commit 0c7c20b6e6
34 changed files with 676 additions and 868 deletions

View File

@@ -21,7 +21,7 @@ def tracker(prefix=None, epics=True, number=1):
label: "Team's Release"
description: 'Ship the product.'
""" if epics else ""
anchor_prefix = prefix.lower() if prefix else "ot"
anchor_prefix = prefix.lower() if prefix else "ex"
return f"""---
{prefix_line}projects:
- value: 'shared'
@@ -101,7 +101,7 @@ class TaskScriptsTest(unittest.TestCase):
def test_epic_add_update_clear_and_preserve(self):
self.run_cli("add-task", "New task", "--epic", "Proof of Concept", "--subproject", "engine")
task = self.get_task("OT-002")
task = self.get_task("EX-002")
self.assertEqual((task["epic"], task["epic_label"]), ("poc", "Proof of Concept"))
self.assertEqual(task["subproject"], "engine")
self.assertIn('data-epic="poc"', task["raw_markdown"])
@@ -181,7 +181,7 @@ class TaskScriptsTest(unittest.TestCase):
def test_default_prefix_without_epics_and_environment_file(self):
self.path.write_text(tracker(epics=False))
self.run_cli("update-tasks")
self.assertEqual(self.get_task()["id"], "OT-001")
self.assertEqual(self.get_task()["id"], "EX-001")
self.assertEqual(self.get_task()["epic"], "")
self.assertNotIn("| Epic", self.path.read_text())
env = {**self.env, "TASKS_FILE": str(self.path)}
@@ -190,7 +190,7 @@ class TaskScriptsTest(unittest.TestCase):
env=env, text=True, capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "OT-001")
self.assertEqual(result.stdout.strip(), "EX-001")
def test_invalid_prefix_and_duplicate_numbers(self):
for prefix in ("", "1BAD", "BAD SPACE", "BAD/"):
@@ -216,7 +216,7 @@ class TaskScriptsTest(unittest.TestCase):
self.assertNotIn("<<<<<<<", updated)
self.assertNotIn("=======", updated)
self.assertNotIn(">>>>>>>", updated)
self.assertIn("OT-001", updated)
self.assertIn("EX-001", updated)
self.assertIn("Original", updated)
@@ -226,30 +226,30 @@ class TaskScriptsTest(unittest.TestCase):
env = {**self.env, "PATH": "/usr/bin:/bin"}
output = self.run_cli("get-tasks", "--all", "--sort", "asc", env=env).stdout
self.assertIn("Epic", output)
self.assertLess(output.index("OT-001"), output.index("OT-002"))
self.assertLess(output.index("EX-001"), output.index("EX-002"))
output = self.run_cli(
"get-tasks", "--all", "--accept", "Epic:Team's Release", "--select", "ID,Epic", env=env,
).stdout
self.assertIn("OT-001", output)
self.assertNotIn("OT-002", output)
self.assertIn("EX-001", output)
self.assertNotIn("EX-002", output)
self.assertNotIn("Title", output)
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:Proof of Concept", env=env).stdout
self.assertNotIn("OT-002", output)
self.assertIn("OT-001", output)
self.assertNotIn("EX-002", output)
self.assertIn("EX-001", output)
for value in ("poc", "POC", "Proof of Concept"):
with self.subTest(value=value):
output = self.run_cli("get-tasks", "--accept", f"Epic:{value}", env=env).stdout
self.assertIn("OT-002", output)
self.assertNotIn("OT-001", output)
self.assertIn("EX-002", output)
self.assertNotIn("EX-001", output)
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:poc", env=env).stdout
self.assertNotIn("OT-002", output)
self.assertIn("OT-001", output)
self.assertNotIn("EX-002", output)
self.assertIn("EX-001", output)
output = self.run_cli(
"get-tasks", "--accept", "Epic:poc,release", "--accept", "Status:Triage", env=env,
).stdout
self.assertIn("OT-002", output)
self.assertNotIn("OT-001", output)
self.assertNotIn("OT-001", self.run_cli("get-tasks", "--accept", "Epic:missing", env=env).stdout)
self.assertIn("EX-002", output)
self.assertNotIn("EX-001", output)
self.assertNotIn("EX-001", self.run_cli("get-tasks", "--accept", "Epic:missing", env=env).stdout)
def test_listing_external_filter_table_selects_epic(self):
self.run_cli("update-tasks")
@@ -291,6 +291,117 @@ class TaskScriptsTest(unittest.TestCase):
self.assertIn("- Epic: Proof of Concept (poc)", prompt)
self.assertIn("--epic=poc", prompt)
def test_ready_task_can_be_started(self):
self.run_cli("update-task", "1", "--status", "ready")
self.run_cli("work-on-task", "1", "--start")
self.assertEqual(self.get_task()["status"], "in_progress")
self.run_cli("work-on-task", "1")
def test_hyphenated_in_progress_is_already_started(self):
self.run_cli("update-task", "1", "--status", "in-progress")
before = self.path.read_text()
self.run_cli("work-on-task", "1", "--start")
self.assertEqual(before, self.path.read_text())
def test_repository_sample_and_config_are_consistent(self):
config = json.loads((SCRIPTS.parent / "project-config.json").read_text())
sample = SCRIPTS.parent / "docs" / "tasks.md"
result = subprocess.run(
[sys.executable, str(SCRIPTS / "get-task"), "1", "-f", str(sample), "--json"],
env=self.env, text=True, capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
task = json.loads(result.stdout)
self.assertIsInstance(config["name"], str)
sys.path.insert(0, str(SCRIPTS / "lib"))
try:
import task_context
update = task_context.load_get_task_module().load_update_tasks_module()
finally:
sys.path.pop(0)
frontmatter = update.read_frontmatter(sample.read_text())
project = next(item for item in frontmatter["projects"] if item["value"] == task["project"])
self.assertIn(f"**Project:** {project['label']}", task["raw_markdown"])
self.assertEqual(task["id"].rsplit("-", 1)[0], update.get_task_id_prefix(frontmatter))
copy = Path(self.temp.name) / "sample.md"
copy.write_text(sample.read_text())
result = subprocess.run(
[sys.executable, str(SCRIPTS / "update-tasks"), "-f", str(copy)],
env=self.env, text=True, capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(sample.read_text(), copy.read_text())
def test_environment_preserves_external_tracker(self):
env = {**self.env, "TASKS_FILE": str(self.path)}
result = subprocess.run(
["bash", "-c", 'source "$1" >/dev/null; printf "%s\\n" "$TASKS_FILE"; command -v get-task',
"bash", str(SCRIPTS / "env.sh")],
env=env, text=True, capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), [str(self.path), str(SCRIPTS / "get-task")])
class ProjectConfigTest(unittest.TestCase):
def setUp(self):
sys.path.insert(0, str(SCRIPTS / "lib"))
try:
import task_context
self.context = task_context
finally:
sys.path.pop(0)
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / "project-config.json"
self.config = {
"name": "The Lighthouse Novel",
"description": "A novel about a coastal community.",
"commands": {},
}
self.path.write_text(json.dumps(self.config))
def test_nonsoftware_configuration_in_prompt(self):
task = {
"id": "BOOK-001", "title": "Outline chapter one", "project": "manuscript",
"status": "triage", "type": "task", "description": "Review the narrative arc.",
}
with patch.object(self.context, "load_project_config", return_value=self.context.load_project_config(self.path)):
prompt = self.context.build_workflow_prompt(task, "triaging", "Outline the chapter")
self.assertIn("for The Lighthouse Novel.", prompt)
self.assertIn(self.config["description"], prompt)
self.assertIn("No commands configured", prompt)
self.assertIn("Do not assume this is software", prompt)
self.assertIn("AGENTS.md", prompt)
def test_arbitrary_named_commands_are_guidance(self):
self.config["commands"] = {"word-count": "wc -w manuscript.txt"}
self.path.write_text(json.dumps(self.config))
config = self.context.load_project_config(self.path)
task = {"id": "1", "title": "Review", "project": "manuscript", "status": "triage", "type": "task"}
with patch.object(self.context, "load_project_config", return_value=config):
prompt = self.context.build_workflow_prompt(task, "reviewing", "Review")
self.assertIn("- word-count: `wc -w manuscript.txt`", prompt)
self.assertIn("not automatically executed", prompt)
def test_invalid_configuration_is_reported(self):
invalid_configs = (
[], {}, {**self.config, "name": ""}, {**self.config, "description": None},
{**self.config, "commands": []}, {**self.config, "commands": {"test": None}},
{**self.config, "commands": {"": "echo ok"}},
{**self.config, "commands": {"review": " "}},
)
for config in invalid_configs:
with self.subTest(config=config):
self.path.write_text(json.dumps(config))
with self.assertRaisesRegex(ValueError, "Project configuration"):
self.context.load_project_config(self.path)
self.path.write_text("{")
with self.assertRaisesRegex(ValueError, "Cannot read project configuration"):
self.context.load_project_config(self.path)
with self.assertRaisesRegex(ValueError, "Cannot read project configuration"):
self.context.load_project_config(self.path.parent / "missing.json")
if __name__ == "__main__":
unittest.main()