Merge pull request 'improve: README, LICENSE, pyproject, tests, sub-question traversal fix' (#21) from improve/v1 into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions

Reviewed-on: https://git.example.com/jarianc/MasterMind/pulls/21
This commit is contained in:
Jarian Cottingham 2026-08-20 22:19:39 -05:00
commit 18978e6e95
9 changed files with 414 additions and 22 deletions

16
.gitignore vendored
View File

@ -1,15 +1,21 @@
# Created by venv; see https://docs.python.org/3/library/venv.html # Python
*/output __pycache__/
*.py[cod]
*.egg-info/
.venv/
build/
dist/
# Virtual environment config (contains personal paths)
pyvenv.cfg
/lib /lib
/include /include
/bin /bin
.ropeproject .ropeproject
# Virtual environment config (contains personal paths)
pyvenv.cfg
# Large binaries (PDFs should not be tracked in git) # Large binaries (PDFs should not be tracked in git)
*.pdf *.pdf
# Output directories # Output directories
output/ output/
*/output

View File

@ -20,7 +20,7 @@ It's important to always start every project in this order. We should write the
* What technologies did that project try to use? * What technologies did that project try to use?
* How many people are using this project? * How many people are using this project?
* How relevant is this project? * How relevant is this project?
* Based on my learnings, can I use code from on of those project to help me? * Based on my learnings, can I use code from one of those projects to help me?
* Is it open source with a license that is friendly to my project? * Is it open source with a license that is friendly to my project?
* What technology is the best for this project? * What technology is the best for this project?
* What frameworks am i considering using and are they commercially friendly? * What frameworks am i considering using and are they commercially friendly?
@ -32,8 +32,8 @@ It's important to always start every project in this order. We should write the
### Questions to answer ### Questions to answer
* Draw the architectural schema for all the classes I plan to create to implement this project * Draw the architectural schema for all the classes I plan to create to implement this project
* Describe each of the classes in details with a list of functions with return values that we plan to create * Describe each of the classes in detail with a list of functions with return values that we plan to create
* What techonologies are required to build it? * What technologies are required to build it?
* What will be the cost of running this given the hardware for the project * What will be the cost of running this given the hardware for the project
* How many users are using this system * How many users are using this system

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jarian Cottingham
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,5 +1,5 @@
## Brief Description ## Brief Description
I have a local deployment of Mellisearch going. It's job is going to be to provide an alternative to Google for a very wide amount of resources on a local internet. The current resources the internet has are the following. I have a local deployment of Meilisearch going. It's job is going to be to provide an alternative to Google for a very wide amount of resources on a local internet. The current resources the internet has are the following.
- Downloaded Youtube videos (Jellyfin Urls) - Downloaded Youtube videos (Jellyfin Urls)
- MKV Backups of Blurays (jellyfin urls) - MKV Backups of Blurays (jellyfin urls)

108
README.md Normal file
View File

@ -0,0 +1,108 @@
# MasterMind
AI-driven project planning. MasterMind feeds a project proposal and a structured
question set through a local LLM (Ollama) in fixed execution phases, producing a
written plan plus per-phase summaries you can review before writing any code.
It is built around the **IDIOT method** — a project execution framework that breaks
any project into five ordered phases:
1. **Investigation** — audience, goals, sub-problems, prior art, tech choice
2. **Design** — architecture, required tech, cost, scale
3. **Implementation** — build the design piece by piece
4. **Optimization** — improve without sacrificing quality
5. **Testing** — user-proposed tests, derived test cases, unit/E2E tests
6. **Follow-up** — suggested next steps
`questions.json` encodes the full question tree for each phase (including nested
sub-questions); `IDIOTMethod.txt` is the human-readable framework document.
## How it works
```
proposal + IDIOT method + system prompt
┌──────────────────────────────┐
│ Investigation (N questions) │ ──► question_001.txt … question_NNN.txt
├──────────────────────────────┤
│ Design (N questions) │ ──► design_summary.txt
├──────────────────────────────┤
│ Implementation … Follow-up │ ──► … _summary.txt
└──────────────────────────────┘
experiment_results.json (per-phase index of summary files)
```
- Each question is sent to the model with the project proposal, the IDIOT method,
and a **sliding context window** (the last 5 accumulated answers) so context
stays bounded on long runs.
- After every phase, a second model call writes a concise per-phase summary.
- All outputs land in a folder named after your project.
## Requirements
- Python 3.10+
- [Ollama](https://ollama.com) running locally (default: `http://localhost:11434`)
- A model pulled locally, e.g.:
```bash
ollama pull gpt-oss:20b
```
## Quickstart
```bash
python3 scripts/mastermind_cli.py \
--project "Meilisearch UI" \
--proposal "ProjectProposals/Meilisearch UI/proposal.txt" \
--idiot IDIOTMethod.txt \
--prompt-file "ProjectProposals/Meilisearch UI/system-prompt.txt" \
--model gpt-oss:20b
```
Outputs appear in `./Meilisearch UI/`.
## Options
| Flag | Default | Description |
| --- | --- | --- |
| `--project NAME` | required | Project name; a folder with this name stores the outputs (alphanumeric, `-`, `_` only) |
| `--proposal PATH` | required | Project proposal document (text) |
| `--idiot PATH` | required | IDIOT method document (text) |
| `--prompt-file PATH` | `system_prompt.txt` | System prompt file |
| `--model NAME` | `gpt-oss:20b` | One of `gpt-oss:20b`, `qwen3:30b`, `devstral:24b`, `llama3.3:70b` |
| `--questions-file PATH` | `questions.json` (repo root) | Question tree JSON |
| `--output-dir PATH` | current directory | Where the project folder is created (must be under the current directory) |
| `--ollama-url URL` | `$OLLAMA_URL` or `http://localhost:11434/api/generate` | Ollama endpoint |
| `--api-key KEY` | `$OLLAMA_API_KEY` | Optional bearer token for Ollama |
| `--timeout SECONDS` | `120` | Per-request timeout |
The question tree supports nested `subQuestions`, which are asked in depth-first
order. Empty questions are skipped.
## Project layout
```
.
├── IDIOTMethod.txt # The IDIOT execution framework
├── questions.json # Phase → question tree
├── ProjectProposals/
│ └── Meilisearch UI/ # Example proposal + system prompt
├── scripts/
│ └── mastermind_cli.py # The CLI
└── tests/
└── test_mastermind_cli.py
```
## Testing
```bash
pip install -e ".[dev]"
pytest tests/ -v
```
## License
MIT — see [LICENSE](LICENSE).

6
conftest.py Normal file
View File

@ -0,0 +1,6 @@
"""Make scripts/ importable for the test suite without installation."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "scripts"))

44
pyproject.toml Normal file
View File

@ -0,0 +1,44 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "mastermind"
version = "1.0.0"
description = "AI-driven project planning: run a proposal through the IDIOT method with a local LLM (Ollama)"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Jarian Cottingham" }]
keywords = ["ollama", "llm", "planning", "cli"]
classifiers = [
"Environment :: Console",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.4"]
[project.scripts]
mastermind = "mastermind_cli:main"
[tool.setuptools]
package-dir = { "" = "scripts" }
py-modules = ["mastermind_cli"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
[tool.ruff.lint.isort]
known-first-party = ["mastermind_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@ -37,6 +37,7 @@ import os
import pathlib import pathlib
import re import re
import sys import sys
import urllib.request
from typing import List from typing import List
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
@ -72,6 +73,23 @@ def validate_no_traversal(path: pathlib.Path, label: str) -> pathlib.Path:
return resolved return resolved
def sanitize_phase_name(name: str) -> str:
"""Reduce a phase name to a safe filename slug. Prevents path traversal."""
slug = re.sub(r"[^a-z0-9_-]+", "_", name.strip().lower()).strip("_")
return slug or "phase"
def iter_question_texts(questions: List[dict]) -> List[str]:
"""Collect question texts recursively, including nested subQuestions."""
texts: List[str] = []
for q in questions or []:
text = (q.get("text") or "").strip()
if text:
texts.append(text)
texts.extend(iter_question_texts(q.get("subQuestions") or []))
return texts
def read_file(path: pathlib.Path) -> str: def read_file(path: pathlib.Path) -> str:
"""Return the contents of a text file.""" """Return the contents of a text file."""
try: try:
@ -142,11 +160,11 @@ def fetch_response(
if api_key: if api_key:
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
req = __import__("urllib.request").request.Request( req = urllib.request.Request(
ollama_url, data=payload, headers=headers, method="POST" ollama_url, data=payload, headers=headers, method="POST"
) )
try: try:
with __import__("urllib.request").request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read().decode("utf-8") data = resp.read().decode("utf-8")
result = json.loads(data) result = json.loads(data)
return result.get("response", "").strip() return result.get("response", "").strip()
@ -285,17 +303,13 @@ def main() -> None:
for phase in questions: for phase in questions:
phase_name = phase.get("phase", "Unnamed Phase") phase_name = phase.get("phase", "Unnamed Phase")
phase_qs = phase.get("questions", []) phase_texts = iter_question_texts(phase.get("questions", []))
logging.info("Phase: %s %d questions", phase_name, len(phase_qs)) logging.info("Phase: %s %d questions", phase_name, len(phase_texts))
# Sliding window: only keep last N summaries to bound context growth # Sliding window: only keep last N summaries to bound context growth
phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:] phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:]
for q in phase_qs: for question_text in phase_texts:
question_text = q.get("text", "")
if not question_text:
continue # skip empty
logging.info("Q%d: %s", counter, question_text) logging.info("Q%d: %s", counter, question_text)
response = fetch_response( response = fetch_response(
full_system_prompt, full_system_prompt,
@ -318,12 +332,10 @@ def main() -> None:
counter += 1 counter += 1
# After finishing the phase, write a short summary file for this phase # After finishing the phase, write a short summary file for this phase
phase_summary_file = ( phase_summary_file = project_dir / f"{sanitize_phase_name(phase_name)}_summary.txt"
project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt"
)
try: try:
summary = fetch_response( summary = fetch_response(
"Make a clear and concise summary that's no more than 500 tokens on the following input", "Summarize the following input concisely, in at most 500 tokens",
"\n\n".join(accumulated_summaries), "\n\n".join(accumulated_summaries),
[], [],
args.model, args.model,

View File

@ -0,0 +1,195 @@
"""Tests for mastermind_cli."""
import contextlib
import io
import json
import sys
from pathlib import Path
import pytest
import mastermind_cli as mm
# --- validation -------------------------------------------------------------
def test_validate_project_name_valid():
assert mm.validate_project_name("MyProject") == "MyProject"
assert mm.validate_project_name("abc-123_x") == "abc-123_x"
@pytest.mark.parametrize("bad", ["", "a b", "a/b", "../evil", "a.b", "p'x", "caf\u00e9"])
def test_validate_project_name_invalid(bad):
with pytest.raises(SystemExit):
mm.validate_project_name(bad)
def test_validate_no_traversal_inside(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert mm.validate_no_traversal(Path("sub/dir"), "x") == tmp_path / "sub" / "dir"
def test_validate_no_traversal_escape(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
with pytest.raises(SystemExit):
mm.validate_no_traversal(Path("../../outside"), "x")
# --- slug + question tree ----------------------------------------------------
def test_sanitize_phase_name():
assert mm.sanitize_phase_name("Investigation") == "investigation"
assert mm.sanitize_phase_name("Follow up") == "follow_up"
assert mm.sanitize_phase_name("../../evil") == "evil"
assert mm.sanitize_phase_name(" ") == "phase"
assert mm.sanitize_phase_name("a b/c") == "a_b_c"
def test_iter_question_texts_nested():
tree = [
{"text": "Q1"},
{
"text": "Q2",
"subQuestions": [
{"text": "Q2a"},
{"text": "", "subQuestions": [{"text": "Q2a-1"}]},
],
},
{"text": ""},
]
assert mm.iter_question_texts(tree) == ["Q1", "Q2", "Q2a", "Q2a-1"]
def test_iter_question_texts_empty():
assert mm.iter_question_texts(None) == []
assert mm.iter_question_texts([]) == []
# --- loading -----------------------------------------------------------------
def test_load_questions_ok(tmp_path):
p = tmp_path / "q.json"
p.write_text('[{"phase": "A", "questions": [{"text": "x"}]}]', encoding="utf-8")
assert mm.load_questions(p) == [{"phase": "A", "questions": [{"text": "x"}]}]
def test_load_questions_invalid(tmp_path):
p = tmp_path / "q.json"
p.write_text("not json", encoding="utf-8")
with pytest.raises(SystemExit):
mm.load_questions(p)
# --- fetch_response -----------------------------------------------------------
class _FakeResp:
def __init__(self, payload: dict):
self.payload = payload
def read(self):
return json.dumps(self.payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, *args):
return False
def _fake_urlopen(exc=None):
captured = {}
def _urlopen(req, timeout=None):
captured["headers"] = {k.lower(): v for k, v in req.headers.items()}
captured["payload"] = json.loads(req.data.decode("utf-8"))
if exc is not None:
raise exc
return _FakeResp({"response": "hello world"})
return _urlopen, captured
def test_fetch_response_success(monkeypatch):
_urlopen, captured = _fake_urlopen()
monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen)
out = mm.fetch_response("SYS", "What?", ["CTX"])
assert out == "hello world"
prompt = captured["payload"]["prompt"]
assert "SYS" in prompt and "What?" in prompt and "CTX" in prompt
assert captured["headers"]["content-type"] == "application/json"
assert "authorization" not in captured["headers"]
def test_fetch_response_api_key(monkeypatch):
_urlopen, captured = _fake_urlopen()
monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen)
mm.fetch_response("S", "Q", [], api_key="sekret")
assert captured["headers"]["authorization"] == "Bearer sekret"
def test_fetch_response_error_exits(monkeypatch):
_urlopen, _ = _fake_urlopen(exc=OSError("boom"))
monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen)
with pytest.raises(SystemExit):
mm.fetch_response("S", "Q", [])
# --- end to end ---------------------------------------------------------------
QUESTIONS = [
{
"phase": "Alpha",
"questions": [
{"text": "Q1"},
{"text": "Q2", "subQuestions": [{"text": "Q2a"}]},
],
},
{"phase": "Beta", "questions": [{"text": "Q3"}]},
]
def test_main_end_to_end(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "prompt.txt").write_text("SYSTEM", encoding="utf-8")
(tmp_path / "proposal.txt").write_text("PROPOSAL", encoding="utf-8")
(tmp_path / "idiot.txt").write_text("IDIOT", encoding="utf-8")
qfile = tmp_path / "qs.json"
qfile.write_text(json.dumps(QUESTIONS), encoding="utf-8")
calls = []
def fake_fetch(system_prompt, user_prompt, context, *args, **kwargs):
calls.append((user_prompt, context))
return f"ANSWER-{user_prompt}"
monkeypatch.setattr(mm, "fetch_response", fake_fetch)
argv = [
"mastermind_cli.py",
"--project", "Proj",
"--proposal", "proposal.txt",
"--idiot", "idiot.txt",
"--prompt-file", "prompt.txt",
"--questions-file", str(qfile),
"--output-dir", "out",
]
monkeypatch.setattr(sys, "argv", argv)
with contextlib.redirect_stderr(io.StringIO()):
mm.main()
proj = tmp_path / "out" / "Proj"
assert (proj / "question_001.txt").read_text(encoding="utf-8") == "ANSWER-Q1"
assert (proj / "question_002.txt").read_text(encoding="utf-8") == "ANSWER-Q2"
assert (proj / "question_003.txt").read_text(encoding="utf-8") == "ANSWER-Q2a"
assert (proj / "question_004.txt").read_text(encoding="utf-8") == "ANSWER-Q3"
assert (proj / "alpha_summary.txt").exists()
assert (proj / "beta_summary.txt").exists()
results = json.loads((proj / "experiment_results.json").read_text(encoding="utf-8"))
assert [r["phase"] for r in results] == ["Alpha", "Beta"]
# 4 questions + 2 phase summaries
assert len(calls) == 6