diff --git a/README.md b/README.md
index 54ec1a2..94d3331 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
# NightShift
-
+
+
+
+
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")