From 4e502ba4947b541c915efb85d2cb6fb1dad4a9ed Mon Sep 17 00:00:00 2001 From: "K. Hodges" Date: Sun, 17 May 2026 10:06:18 -0700 Subject: [PATCH] Examples, readme logo fix --- README.md | 5 +- .../agents/fake_code_writer.py | 95 +++++++++++++++ examples/quickstart-lisp/nightshift.yaml | 22 +++- tests/test_pipeline.py | 109 ++++++++++++++++++ 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 examples/quickstart-lisp/agents/fake_code_writer.py diff --git a/README.md b/README.md index 54ec1a2..94d3331 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # NightShift -![NightShift logo](docs/images/logo.png) +

+ +

+ Auditable local-first AI coding pipelines. diff --git a/examples/quickstart-lisp/agents/fake_code_writer.py b/examples/quickstart-lisp/agents/fake_code_writer.py new file mode 100644 index 0000000..4bf0bb6 --- /dev/null +++ b/examples/quickstart-lisp/agents/fake_code_writer.py @@ -0,0 +1,95 @@ +"""Fake code writer for the NightShift end-to-end quickstart.""" + +from __future__ import annotations + +from pathlib import Path +import difflib + + +FILES = { + "lisp.py": '''"""Tiny Lisp parser used by the NightShift quickstart.""" + + +def tokenize(source): + spaced = source.replace("(", " ( ").replace(")", " ) ") + return spaced.split() + + +def parse(source): + tokens = tokenize(source) + if not tokens: + raise ValueError("empty expression") + expression = _parse_tokens(tokens) + if tokens: + raise ValueError("unexpected trailing tokens") + return expression + + +def _parse_tokens(tokens): + if not tokens: + raise ValueError("unexpected end of input") + token = tokens.pop(0) + if token == "(": + values = [] + while tokens and tokens[0] != ")": + values.append(_parse_tokens(tokens)) + if not tokens: + raise ValueError("unbalanced parentheses") + tokens.pop(0) + return values + if token == ")": + raise ValueError("unexpected closing parenthesis") + return _atom(token) + + +def _atom(token): + try: + return int(token) + except ValueError: + return token +''', + "tests/test_lisp.py": """import unittest + +from lisp import parse + + +class ParserTests(unittest.TestCase): + def test_parses_numbers(self): + self.assertEqual(parse("42"), 42) + + def test_parses_symbols(self): + self.assertEqual(parse("answer"), "answer") + + def test_parses_nested_lists(self): + self.assertEqual(parse("(+ 1 (* 2 3))"), ["+", 1, ["*", 2, 3]]) + + def test_rejects_unbalanced_parentheses(self): + with self.assertRaises(ValueError): + parse("(+ 1 2") + + +if __name__ == "__main__": + unittest.main() +""", +} + + +def main() -> None: + chunks: list[str] = [] + for relative_path, desired in FILES.items(): + path = Path(relative_path) + current = path.read_text(encoding="utf-8") if path.exists() else "" + chunks.append(f"diff --git a/{relative_path} b/{relative_path}\n") + chunks.extend( + difflib.unified_diff( + current.splitlines(keepends=True), + desired.splitlines(keepends=True), + fromfile=f"a/{relative_path}", + tofile=f"b/{relative_path}", + ) + ) + print("".join(chunks), end="") + + +if __name__ == "__main__": + main() diff --git a/examples/quickstart-lisp/nightshift.yaml b/examples/quickstart-lisp/nightshift.yaml index 13b30e6..0f81813 100644 --- a/examples/quickstart-lisp/nightshift.yaml +++ b/examples/quickstart-lisp/nightshift.yaml @@ -27,7 +27,7 @@ agents: implementer: backend: command - command: echo + command: python agents/fake_code_writer.py system_prompt: agents/implementer.md reviewer: @@ -45,9 +45,24 @@ pipeline: output: plan.md - id: implement - type: agent + type: code_writer agent: implementer - output: implementation-log.md + output: proposed.patch + + - id: normalize + type: patch_normalizer + output: normalized.patch + + - id: validate_patch + type: patch_validator + output: patch-validation.md + max_files: 4 + max_lines: 400 + + - id: apply_patch + type: patch_apply + mode: apply + output: patch-apply-output.txt - id: test type: command @@ -56,6 +71,7 @@ pipeline: output: test-output.txt shell: true timeout_seconds: 60 + on_fail: implement - id: review type: agent_review diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 4a151ac..1dbd17b 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,4 +1,5 @@ from pathlib import Path +from dataclasses import replace import tempfile import unittest @@ -404,6 +405,114 @@ Acceptance Criteria: self.assertEqual(result.status, "failed") self.assertIn("forbidden path", result.reason) + def test_patch_apply_stage_applies_patch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_common_files(root) + (root / "app.py").write_text("old\n", encoding="utf-8") + (root / "fake_writer.py").write_text( + "\n".join( + [ + "print('diff --git a/app.py b/app.py')", + "print('--- a/app.py')", + "print('+++ b/app.py')", + "print('@@ -1 +1 @@')", + "print('-old')", + "print('+new')", + ] + ), + encoding="utf-8", + ) + stages = ( + StageConfig(id="write", type="code_writer", agent="writer"), + StageConfig(id="normalize", type="patch_normalizer"), + StageConfig(id="validate", type="patch_validator"), + StageConfig(id="apply", type="patch_apply", mode="apply"), + ) + config = make_config(root, stages) + config.agents["writer"] = AgentConfig( + id="writer", + backend="command", + command="python fake_writer.py", + system_prompt=Path("planner.md"), + ) + runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run")) + + result = runner.run_task(parse_tasks(TASK_MD)[0]) + + task_dir = root / ".nightshift" / "runs" / "test-run" / "tasks" / "TASK-001" + self.assertEqual(result.status, "complete") + self.assertEqual((root / "app.py").read_text(encoding="utf-8"), "new\n") + self.assertTrue((task_dir / "applied.patch").exists()) + self.assertTrue((task_dir / "patch-apply-output.txt").exists()) + self.assertTrue((task_dir / "git-status-before-patch-apply.txt").exists()) + self.assertTrue((task_dir / "git-status-after-patch-apply.txt").exists()) + + def test_test_failure_repairs_with_second_patch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_common_files(root) + (root / "app.py").write_text("old\n", encoding="utf-8") + (root / "fake_writer.py").write_text( + "\n".join( + [ + "from pathlib import Path", + "current = Path('app.py').read_text()", + "old, new = ('bad', 'new') if current == 'bad\\n' else ('old', 'bad')", + "print('diff --git a/app.py b/app.py')", + "print('--- a/app.py')", + "print('+++ b/app.py')", + "print('@@ -1 +1 @@')", + "print('-' + old)", + "print('+' + new)", + ] + ), + encoding="utf-8", + ) + stages = ( + StageConfig(id="write", type="code_writer", agent="writer"), + StageConfig(id="normalize", type="patch_normalizer"), + StageConfig(id="validate", type="patch_validator"), + StageConfig(id="apply", type="patch_apply", mode="apply"), + StageConfig( + id="test", + type="command", + commands=('python -c "from pathlib import Path; raise SystemExit(0 if Path(\'app.py\').read_text() == \'new\\\\n\' else 1)"',), + output="test-output.txt", + on_fail="write", + ), + ) + config = make_config( + root, + stages, + max_retries=1, + ) + config = replace( + config, + safety=SafetyConfig( + require_clean_worktree=False, + scoped_paths=(".",), + allowed_commands=('python -c "from pathlib import Path; raise SystemExit(0 if Path(\'app.py\').read_text() == \'new\\\\n\' else 1)"',), + forbidden_commands=("rm -rf",), + ), + ) + config.agents["writer"] = AgentConfig( + id="writer", + backend="command", + command="python fake_writer.py", + system_prompt=Path("planner.md"), + ) + runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run")) + + result = runner.run_task(parse_tasks(TASK_MD)[0]) + + task_dir = root / ".nightshift" / "runs" / "test-run" / "tasks" / "TASK-001" + self.assertEqual(result.status, "complete") + self.assertEqual(result.retry_count, 1) + self.assertEqual((root / "app.py").read_text(encoding="utf-8"), "new\n") + self.assertTrue((task_dir / "repair-1.patch").exists()) + self.assertTrue((task_dir / "repair-summary-1.md").exists()) + def _write_common_files(root: Path) -> None: (root / "nightshift.yaml").write_text("project:\n name: test\n", encoding="utf-8")