From 3d976e175700c1c380c5874ed017c984a454bba2 Mon Sep 17 00:00:00 2001 From: postmunnet Date: Sat, 23 May 2026 21:02:53 +0700 Subject: [PATCH] feat: trinity-bootstrap-pack v1.1 + kernel UX batch 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three audited Trinity sessions land together (full audit trail kept in local trinity_v2 retros; public export excludes session/audit state). ## trinity-bootstrap-pack v1.0 + v1.1 — one-command install ```bash # From a local trinity_v2 clone: bash tools/trinity-bootstrap-pack/install.sh ~/code/my-app --project-name my-app # From curl|bash (any machine): curl -fsSL https://raw.githubusercontent.com/postmunnet/trinity-protocol/main/tools/trinity-bootstrap-pack/bootstrap.sh \ | bash -s -- ~/code/my-app --project-name my-app ``` - `install.sh` — bash entry → python core; positional `` or `--target` - `bootstrap.sh` — curl|bash entry; clones/pulls `~/.trinity-kernel` then dispatches - `--with-kernel symlink|copy|none` (default `symlink`): auto-wires `.ai/cli` and `.ai/rituals` from trinity_v2 source into target, copies `.ai/requirements.txt` - Auto-detect 4 modes: greenfield / upgrade-v1 / upgrade-v2 / self (refused without `--allow-self-install`) - Pack contents: minimal `.ai/` skeleton + `CLAUDE.md`/`AGENTS.md`/`GEMINI.md` entrypoint templates (with `{{PROJECT_NAME}}` substitution) + `ai-docs/` - 32 pytest covering detector, installer, kernel_wire, bootstrap_sh, smoke - Receipt: `/.trinity-install-receipt.json` (pack version + kernel-wire info) - README documents three install flavours + flag matrix ## Kernel UX batch 1 — fix the system, don't memorize the gotcha Three repeating frictions resolved at the kernel/wrapper layer: 1. `pyproject.toml` at project root: `[tool.pytest.ini_options]` with `testpaths` makes `python3 -m pytest ` work from any cwd. Removes the need to chain `cd` for pytest invocations. 2. `.ai/cli/commands/vvv.py`: `--answers-file` now auto-detects JSON or YAML. Tries JSON first (backwards-compatible); falls back to `yaml.safe_load`. Error message + help text mention both formats explicitly. 3. `.ai/cli/agent` wrapper: `--session-path active` resolves to the kernel's `current_session` pointer (read from `.ai/state/status.json`) at invocation time. Exits 78 with a clear stderr message if status is unreadable — no Python traceback. Plus: new `.ai/cli/agents/README.md` documents the in-house agent set and the `active` keyword. Locally: 1868 → 1883 kernel pytest green; new bootstrap-pack suite added. ## Other docs sync This export also mirrors local docs work that hadn't been published yet: - `docs/specs/01_TOOL_CONTRACT.md` (TH + EN) — substantial spec expansion - `docs/specs/INDEX.md`, `docs/specs/en/INDEX.md` — index updates - `docs/migration/01_CONTEXT_AND_DECISIONS.md`, `03_COMMIT_PLAN.md` - `README.md`, `docs/VERSION_LINEAGE*.md` - `scripts/export_github.sh` — updated scrub rules --- .ai/cli/agent | 39 +- .ai/cli/agents/README.md | 50 + .ai/cli/commands/vvv.py | 43 +- .ai/cli/tests/test_agent_wrapper_active.py | 95 ++ .ai/cli/tests/test_project_registry.py | 4 +- .ai/cli/tests/test_pyproject_pytest_config.py | 40 + .ai/cli/tests/test_vvv_answers_yaml.py | 88 ++ EXPORT_MANIFEST.md | 2 +- README.md | 28 +- docs/VERSION_LINEAGE.md | 2 +- docs/VERSION_LINEAGE_TH.md | 2 +- docs/benchmarks/token-economy/README.md | 16 +- docs/contracts/browser-cli/AI_AGENT_GUIDE.md | 2 +- docs/migration/01_CONTEXT_AND_DECISIONS.md | 2 +- docs/migration/03_COMMIT_PLAN.md | 2 +- docs/specs/01_TOOL_CONTRACT.md | 1332 ++++++++++++++++- docs/specs/10_UPSTREAM_AUDIT.md | 2 +- docs/specs/11_RELATED_PROJECTS.md | 2 +- docs/specs/INDEX.md | 20 +- docs/specs/en/01_TOOL_CONTRACT.md | 823 +++++++++- docs/specs/en/10_UPSTREAM_AUDIT.md | 2 +- docs/specs/en/11_RELATED_PROJECTS.md | 2 +- docs/specs/en/12_GLOSSARY.md | 2 +- docs/specs/en/INDEX.md | 6 +- docs/specs/en/README.md | 2 +- pyproject.toml | 23 + scripts/export_github.sh | 2 +- tools/trinity-bootstrap-pack/README.md | 130 ++ tools/trinity-bootstrap-pack/bootstrap.sh | 138 ++ tools/trinity-bootstrap-pack/install.sh | 28 + tools/trinity-bootstrap-pack/lib/__init__.py | 0 tools/trinity-bootstrap-pack/lib/detector.py | 72 + tools/trinity-bootstrap-pack/lib/installer.py | 353 +++++ .../trinity-bootstrap-pack/lib/kernel_wire.py | 146 ++ .../lib/pack_manifest.py | 66 + tools/trinity-bootstrap-pack/lib/receipt.py | 57 + .../pack/.ai/graphs/standard.yaml | 18 + .../pack/.ai/policies/safety.yaml | 12 + .../pack/.ai/policies/verifier-rules.yaml | 8 + .../pack/.ai/schemas/.keep | 2 + .../trinity-bootstrap-pack/pack/.ai/ssot.yaml | 25 + .../pack/.ai/tools.yaml | 5 + .../pack/ai-docs/CORE_RULES.md | 10 + .../pack/ai-docs/QUICK_START.md | 20 + .../pack/ai-docs/SHORT_CODES.md | 17 + .../pack/ai-docs/WORKFLOW.md | 19 + .../pack/templates/AGENTS.md.template | 11 + .../pack/templates/CLAUDE.md.template | 60 + .../pack/templates/GEMINI.md.template | 11 + tools/trinity-bootstrap-pack/preflight.sh | 60 + .../trinity-bootstrap-pack/tests/__init__.py | 0 .../tests/test_bootstrap_sh.py | 142 ++ .../tests/test_detector.py | 58 + .../tests/test_installer.py | 120 ++ .../tests/test_kernel_wire.py | 124 ++ .../tests/test_pack_manifest.py | 41 + .../tests/test_smoke.py | 53 + .../trinity-bootstrap-pack/verify-install.sh | 44 + 58 files changed, 4394 insertions(+), 89 deletions(-) create mode 100644 .ai/cli/agents/README.md create mode 100644 .ai/cli/tests/test_agent_wrapper_active.py create mode 100644 .ai/cli/tests/test_pyproject_pytest_config.py create mode 100644 .ai/cli/tests/test_vvv_answers_yaml.py create mode 100644 pyproject.toml create mode 100644 tools/trinity-bootstrap-pack/README.md create mode 100755 tools/trinity-bootstrap-pack/bootstrap.sh create mode 100755 tools/trinity-bootstrap-pack/install.sh create mode 100644 tools/trinity-bootstrap-pack/lib/__init__.py create mode 100644 tools/trinity-bootstrap-pack/lib/detector.py create mode 100644 tools/trinity-bootstrap-pack/lib/installer.py create mode 100644 tools/trinity-bootstrap-pack/lib/kernel_wire.py create mode 100644 tools/trinity-bootstrap-pack/lib/pack_manifest.py create mode 100644 tools/trinity-bootstrap-pack/lib/receipt.py create mode 100644 tools/trinity-bootstrap-pack/pack/.ai/graphs/standard.yaml create mode 100644 tools/trinity-bootstrap-pack/pack/.ai/policies/safety.yaml create mode 100644 tools/trinity-bootstrap-pack/pack/.ai/policies/verifier-rules.yaml create mode 100644 tools/trinity-bootstrap-pack/pack/.ai/schemas/.keep create mode 100644 tools/trinity-bootstrap-pack/pack/.ai/ssot.yaml create mode 100644 tools/trinity-bootstrap-pack/pack/.ai/tools.yaml create mode 100644 tools/trinity-bootstrap-pack/pack/ai-docs/CORE_RULES.md create mode 100644 tools/trinity-bootstrap-pack/pack/ai-docs/QUICK_START.md create mode 100644 tools/trinity-bootstrap-pack/pack/ai-docs/SHORT_CODES.md create mode 100644 tools/trinity-bootstrap-pack/pack/ai-docs/WORKFLOW.md create mode 100644 tools/trinity-bootstrap-pack/pack/templates/AGENTS.md.template create mode 100644 tools/trinity-bootstrap-pack/pack/templates/CLAUDE.md.template create mode 100644 tools/trinity-bootstrap-pack/pack/templates/GEMINI.md.template create mode 100755 tools/trinity-bootstrap-pack/preflight.sh create mode 100644 tools/trinity-bootstrap-pack/tests/__init__.py create mode 100644 tools/trinity-bootstrap-pack/tests/test_bootstrap_sh.py create mode 100644 tools/trinity-bootstrap-pack/tests/test_detector.py create mode 100644 tools/trinity-bootstrap-pack/tests/test_installer.py create mode 100644 tools/trinity-bootstrap-pack/tests/test_kernel_wire.py create mode 100644 tools/trinity-bootstrap-pack/tests/test_pack_manifest.py create mode 100644 tools/trinity-bootstrap-pack/tests/test_smoke.py create mode 100755 tools/trinity-bootstrap-pack/verify-install.sh diff --git a/.ai/cli/agent b/.ai/cli/agent index d7b2d49..1ce3d14 100755 --- a/.ai/cli/agent +++ b/.ai/cli/agent @@ -24,11 +24,16 @@ Usage: Examples: bash .ai/cli/agent clarification_helper draft \ - --session-path .ai/sessions/ "task description" - bash .ai/cli/agent plan_helper draft --session-path .ai/sessions/ + --session-path active "task description" + bash .ai/cli/agent plan_helper draft --session-path active bash .ai/cli/agent executor_helper draft \ --session-path .ai/sessions/ --step-id S1 +Session path resolution: + --session-path active resolves to the kernel's current_session pointer + (read from .ai/state/status.json at invocation time). + --session-path absolute or repo-relative; passed through unchanged. + Advanced (direct module invocation): cd .ai && python3 -m cli.agents. [args] USAGE @@ -72,5 +77,33 @@ if [ ! -d "$AGENTS_DIR/$AGENT_NAME" ] || [ ! -f "$AGENTS_DIR/$AGENT_NAME/__main_ exit 2 fi +# Pre-process: expand `--session-path active` to the kernel's +# current_session pointer (from .ai/state/status.json). Resolution +# happens once at invocation; concurrent `sss` calls may race and +# land on a different session — same semantics as KI-2026-05-16-001. +new_args=() +prev="" +for arg in "$@"; do + if [ "$prev" = "--session-path" ] && [ "$arg" = "active" ]; then + resolved="$(python3 -c ' +import json, sys +from pathlib import Path +status = Path("'"$AI_ROOT"'") / "state" / "status.json" +try: + cur = json.loads(status.read_text())["current_session"] +except Exception as e: + sys.stderr.write(f"agent: failed to resolve --session-path active: {e}\n") + sys.exit(78) +print(cur) +')" + if [ -z "$resolved" ]; then + exit 78 + fi + arg="$resolved" + fi + new_args+=("$arg") + prev="$arg" +done + cd "$AI_ROOT" -exec python3 -m "cli.agents.$AGENT_NAME" "$@" +exec python3 -m "cli.agents.$AGENT_NAME" "${new_args[@]}" diff --git a/.ai/cli/agents/README.md b/.ai/cli/agents/README.md new file mode 100644 index 0000000..ff0d7ea --- /dev/null +++ b/.ai/cli/agents/README.md @@ -0,0 +1,50 @@ +# In-house Trinity Agents + +Trinity ships a small set of proposal-only Python agents at `.ai/cli/agents//`. +They are invoked through the wrapper `.ai/cli/agent` (mirroring `.ai/cli/ai`). + +## Agents + +| Agent | Purpose | +|---|---| +| `session_bootstrap` | Draft session slug from a raw task description (sss) | +| `clarification_helper` | Draft 5 vvv answers from session context | +| `plan_helper` | Draft a plan envelope (nnn) | +| `executor_helper` | Draft a per-step gogogo proposal | +| `retro_writer` | Draft retrospective body (rrr) | +| `presentation_synthesizer` | Draft a close pack summary | + +## Invocation + +```bash +bash .ai/cli/agent [args...] +bash .ai/cli/agent --list # show available agents +bash .ai/cli/agent --help # full usage +``` + +## `--session-path active` (added 2026-05-23) + +The wrapper resolves the literal token `active` to whatever +`.ai/state/status.json::current_session` points at, at invocation time. + +```bash +bash .ai/cli/agent clarification_helper draft \ + --session-path active "task description" + +bash .ai/cli/agent plan_helper draft --session-path active +``` + +If no session is open (`status.json` missing or unreadable) the wrapper +exits **78** with a message on stderr — no Python traceback. + +Resolution is **one-shot**: the wrapper substitutes the path before +dispatching to Python, so a concurrent `sss` in another shell could +race to a different session. Same race-window as KI-2026-05-16-001; +use an explicit session path (or `--session` flag on the kernel command) +when concurrency matters. + +## Boundaries + +Agents are **proposal-only** (Article III). They write to a session's +`THINK/` or `SANDBOX/` only, never to `.ai/policies/`, `.ai/audit/` (mutate), +`.ai/schemas/`, `docs/specs/`, or `docs/constitution/`. diff --git a/.ai/cli/commands/vvv.py b/.ai/cli/commands/vvv.py index 7f3ef66..cd306bb 100644 --- a/.ai/cli/commands/vvv.py +++ b/.ai/cli/commands/vvv.py @@ -55,7 +55,10 @@ def callback( None, "--answer", help="N=text (repeatable, e.g. --answer 1=...)" ), answers_file: Optional[Path] = typer.Option( - None, "--answers-file", help='JSON {"1":"...","2":"..."}' + None, + "--answers-file", + help='JSON or YAML file with answers (e.g. {"1":"...","2":"..."} ' + 'or `1: "..."` per line). Format auto-detected.', ), show: bool = typer.Option( False, "--show", help="Print 5 questions and exit (no writes)" @@ -146,7 +149,8 @@ def _run_inner( _print_questions() console.print( f"[yellow]Missing answers for Q{missing}. Provide via " - f"--answer N=text (repeatable) or --answers-file path.[/yellow]" + f"--answer N=text (repeatable) or --answers-file " + f'(JSON `{{"1":"..."}}` or YAML `1: "..."`).[/yellow]' ) # Emit pack-declared vvv.failed before exiting (Article IX — # evidence trail for failed proposals). @@ -329,8 +333,7 @@ def _parse_answers( ) -> Dict[int, str]: answers: Dict[int, str] = {} if answers_file: - with answers_file.open() as f: - data = json.load(f) + data = _load_answers_file(answers_file) for k, v in data.items(): answers[int(k)] = str(v) for flag in answer_flags: @@ -344,6 +347,38 @@ def _parse_answers( return answers +def _load_answers_file(path: Path) -> Dict: + """Load Q-answers map from a JSON or YAML file. + + Tries JSON first (cheapest, backwards-compatible with prior callers); + falls back to yaml.safe_load on JSONDecodeError. Emits a clear error + that mentions both formats if neither parses. + """ + raw = path.read_text(encoding="utf-8") + try: + return json.loads(raw) + except json.JSONDecodeError as json_err: + try: + import yaml # PyYAML is a kernel runtime dep (see .ai/requirements.txt) + + loaded = yaml.safe_load(raw) + except Exception as yaml_err: + console.print( + f"[red]--answers-file {path}: neither valid JSON nor YAML.[/red]\n" + f" JSON error: {json_err}\n" + f" YAML error: {yaml_err}\n" + f' Expected: JSON `{{"1":"...","2":"..."}}` or YAML `1: "..."`.' + ) + raise typer.Exit(2) + if not isinstance(loaded, dict): + console.print( + f"[red]--answers-file {path}: YAML root must be a mapping " + f'(got {type(loaded).__name__}). Expected `1: "..."`.[/red]' + ) + raise typer.Exit(2) + return loaded + + def _query_past_incidents( project_root: Path, answers: Dict[int, str] ) -> List[Dict]: diff --git a/.ai/cli/tests/test_agent_wrapper_active.py b/.ai/cli/tests/test_agent_wrapper_active.py new file mode 100644 index 0000000..63fa49c --- /dev/null +++ b/.ai/cli/tests/test_agent_wrapper_active.py @@ -0,0 +1,95 @@ +"""Tests for `.ai/cli/agent` wrapper `--session-path active` resolution.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +WRAPPER = REPO_ROOT / ".ai" / "cli" / "agent" +STATUS_JSON = REPO_ROOT / ".ai" / "state" / "status.json" + + +def _read_current_session() -> str: + return json.loads(STATUS_JSON.read_text())["current_session"] + + +def test_wrapper_exists_and_executable() -> None: + assert WRAPPER.is_file() + assert os.access(WRAPPER, os.X_OK) + + +def test_help_mentions_active_keyword() -> None: + proc = subprocess.run( + ["bash", str(WRAPPER), "--help"], capture_output=True, text=True + ) + assert proc.returncode == 0 + assert "active" in proc.stdout + assert "current_session" in proc.stdout + + +def test_active_resolves_to_current_session(tmp_path: Path) -> None: + """Use clarification_helper --help via the wrapper: passing + --session-path active should reach the python module (which prints + the help banner). If resolution were broken, the wrapper would exit + 78 before reaching python. + """ + current = _read_current_session() + assert current # precondition: a session must be open for this test + proc = subprocess.run( + ["bash", str(WRAPPER), "clarification_helper", "draft", + "--session-path", "active", "--help"], + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + ) + # Help banner emitted by the python module; non-zero exit on help is fine + # for argparse (it exits with 0). What matters: wrapper did NOT fail with 78. + assert proc.returncode != 78, ( + f"wrapper exit 78 means active-resolution failed.\n" + f"stdout: {proc.stdout}\nstderr: {proc.stderr}" + ) + + +def test_active_fails_cleanly_when_status_missing(tmp_path: Path, monkeypatch) -> None: + """If .ai/state/status.json is missing, wrapper exits 78 with a message + on stderr — not a confusing python traceback. + """ + # Stage a fake repo with no status.json + fake_repo = tmp_path / "fake_repo" + (fake_repo / ".ai" / "cli" / "agents" / "dummy").mkdir(parents=True) + # Mirror the wrapper into the fake repo + wrapper_copy = fake_repo / ".ai" / "cli" / "agent" + wrapper_copy.write_text(WRAPPER.read_text()) + wrapper_copy.chmod(0o755) + # Create a dummy agent with __main__.py so the wrapper passes its existence check + (fake_repo / ".ai" / "cli" / "agents" / "dummy" / "__main__.py").write_text( + "print('dummy')\n" + ) + + proc = subprocess.run( + ["bash", str(wrapper_copy), "dummy", "--session-path", "active"], + capture_output=True, + text=True, + cwd=str(fake_repo), + ) + assert proc.returncode == 78 + assert "failed to resolve" in proc.stderr or "active" in proc.stderr + + +def test_non_active_session_path_passes_through(tmp_path: Path) -> None: + """A literal session path (not 'active') is forwarded as-is.""" + current = _read_current_session() + proc = subprocess.run( + ["bash", str(WRAPPER), "clarification_helper", "draft", + "--session-path", current, "--help"], + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + ) + assert proc.returncode != 78 diff --git a/.ai/cli/tests/test_project_registry.py b/.ai/cli/tests/test_project_registry.py index 78a52d9..f19fab1 100644 --- a/.ai/cli/tests/test_project_registry.py +++ b/.ai/cli/tests/test_project_registry.py @@ -58,7 +58,7 @@ def test_touch_memory_db_creates_sqlite_file(tmp_path: Path) -> None: def test_default_homes_live_inside_trinity_checkout(tmp_path: Path, monkeypatch) -> None: fake_file = ( tmp_path - / "workspace-root" + / "yai_project" / "trinity_v2" / ".ai" / "cli" @@ -67,7 +67,7 @@ def test_default_homes_live_inside_trinity_checkout(tmp_path: Path, monkeypatch) ) monkeypatch.setattr(registry_mod, "__file__", str(fake_file)) - trinity_root = tmp_path / "workspace-root" / "trinity_v2" + trinity_root = tmp_path / "yai_project" / "trinity_v2" assert default_trinity_home(env={}) == trinity_root / ".trinity" assert default_memory_home(env={}) == trinity_root / ".memory" diff --git a/.ai/cli/tests/test_pyproject_pytest_config.py b/.ai/cli/tests/test_pyproject_pytest_config.py new file mode 100644 index 0000000..0c7c6b1 --- /dev/null +++ b/.ai/cli/tests/test_pyproject_pytest_config.py @@ -0,0 +1,40 @@ +"""Verify project-root pyproject.toml carries pytest config for cwd-agnostic discovery.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +PYPROJECT = REPO_ROOT / "pyproject.toml" + + +def test_pyproject_exists_at_repo_root() -> None: + assert PYPROJECT.is_file() + + +def test_pytest_ini_options_section_present() -> None: + # tomllib is 3.11+; for 3.9/3.10 fall back to a plain text check that + # avoids adding a tomli dependency. + body = PYPROJECT.read_text(encoding="utf-8") + assert "[tool.pytest.ini_options]" in body + assert "testpaths" in body + + +def test_pytest_finds_tests_from_arbitrary_cwd(tmp_path: Path) -> None: + """The whole point of pyproject's rootdir anchor: pytest must find the + project's tests no matter where you invoke it from. + """ + proc = subprocess.run( + ["python3", "-m", "pytest", + str(REPO_ROOT / "tools" / "trinity-bootstrap-pack" / "tests"), + "-q", "--collect-only"], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + assert proc.returncode == 0, f"stdout={proc.stdout}\nstderr={proc.stderr}" + assert "no tests ran" not in proc.stdout.lower() diff --git a/.ai/cli/tests/test_vvv_answers_yaml.py b/.ai/cli/tests/test_vvv_answers_yaml.py new file mode 100644 index 0000000..cb13c4a --- /dev/null +++ b/.ai/cli/tests/test_vvv_answers_yaml.py @@ -0,0 +1,88 @@ +"""Regression + new-feature tests for vvv `--answers-file` parsing. + +Covers: +- JSON file parses (backwards-compatible) +- YAML file parses (new) +- Malformed input emits a typer.Exit(2) with a message that mentions both formats +- Non-mapping YAML root is rejected with a clear message +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import typer + +from cli.commands.vvv import _load_answers_file, _parse_answers + + +def test_json_answers_backwards_compatible(tmp_path: Path) -> None: + p = tmp_path / "answers.json" + p.write_text('{"1": "goal", "2": "scope", "3": "x", "4": "y", "5": "z"}', encoding="utf-8") + data = _load_answers_file(p) + assert data == {"1": "goal", "2": "scope", "3": "x", "4": "y", "5": "z"} + + +def test_yaml_answers_parsed(tmp_path: Path) -> None: + p = tmp_path / "answers.yaml" + p.write_text( + '"1": "goal text"\n' + '"2": "scope text"\n' + '"3": "constraints text"\n' + '"4": "acceptance text"\n' + '"5": "risk text"\n', + encoding="utf-8", + ) + data = _load_answers_file(p) + assert data["1"] == "goal text" + assert data["5"] == "risk text" + + +def test_yaml_block_scalar_parsed(tmp_path: Path) -> None: + p = tmp_path / "answers.yaml" + p.write_text( + '"1": |\n' + " multi-line\n" + " goal\n" + '"2": "scope"\n', + encoding="utf-8", + ) + data = _load_answers_file(p) + assert "multi-line\ngoal" in data["1"] + assert data["2"] == "scope" + + +def test_malformed_input_exits_with_helpful_message(tmp_path: Path, capsys) -> None: + p = tmp_path / "junk.txt" + # Mixed flow markers force both JSON and YAML parsers to fail (YAML is + # otherwise extremely permissive about treating plain text as a scalar). + p.write_text("{[: this } is ] broken: : :\n - unbalanced", encoding="utf-8") + with pytest.raises(typer.Exit) as exc_info: + _load_answers_file(p) + assert exc_info.value.exit_code == 2 + + +def test_yaml_non_mapping_rejected(tmp_path: Path) -> None: + p = tmp_path / "list.yaml" + p.write_text("- a\n- b\n- c\n", encoding="utf-8") + with pytest.raises(typer.Exit) as exc_info: + _load_answers_file(p) + assert exc_info.value.exit_code == 2 + + +def test_parse_answers_merges_flags_and_file(tmp_path: Path) -> None: + p = tmp_path / "answers.yaml" + p.write_text('"1": "from yaml"\n"2": "from yaml"\n', encoding="utf-8") + result = _parse_answers(answer_flags=["3=cli-override", "4=another"], answers_file=p) + assert result[1] == "from yaml" + assert result[2] == "from yaml" + assert result[3] == "cli-override" + assert result[4] == "another" + + +def test_parse_answers_flags_override_file(tmp_path: Path) -> None: + p = tmp_path / "answers.json" + p.write_text('{"1": "from json"}', encoding="utf-8") + result = _parse_answers(answer_flags=["1=cli-wins"], answers_file=p) + assert result[1] == "cli-wins" diff --git a/EXPORT_MANIFEST.md b/EXPORT_MANIFEST.md index fa3bab7..24b69a2 100644 --- a/EXPORT_MANIFEST.md +++ b/EXPORT_MANIFEST.md @@ -1,7 +1,7 @@ # GitHub Export Manifest Source: local trinity_v2 workspace (absolute path intentionally omitted) -Exported: 2026-05-17T14:50:54Z +Exported: 2026-05-23T14:01:28Z This folder is a GitHub-safe source export generated by: diff --git a/README.md b/README.md index e0ef1bc..1ef9027 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ If the agent cannot produce the artifact, the work cannot be promoted. - Architecture generation: Trinity v2 - Runtime release: v0.1.0 -- Tool Contract ABI: v1.0.0 stable; validation/examples tooling v1.0.2 +- Public Tool Contract: v1.0 freeze candidate; working spec is `v1.1.0-draft` - Kernel CLI: verified v0.1.0 runtime included in this repository - Release evidence: [`docs/releases/TRINITY_V0_1_0_RELEASE_EVIDENCE.md`](docs/releases/TRINITY_V0_1_0_RELEASE_EVIDENCE.md) @@ -108,27 +108,6 @@ Clean export without optional sibling tools: 1860 passed, 8 skipped --- -## Tool Ecosystem - -Trinity separates the kernel, public ABI, and tools that implement the ABI. - -| Tool | Role | Status | Contract | Repo | -|---|---|---|---|---| -| Trinity Protocol | Kernel / governance runtime | v0.1.0 stable | consumes Tool Contract | this repo | -| Trinity Tool Contract | Stable ABI for CLI tools | v1.0.0 stable, v1.0.2 examples | v1.0 | [`postmunnet/trinity-tool-contract`](https://github.com/postmunnet/trinity-tool-contract) | -| browser-cli | Browser automation organ | v0.3.0 partial v1 envelope implementation | partial v1.0 | [`postmunnet/browser-cli`](https://github.com/postmunnet/browser-cli) | -| memory-cli | Artifact memory organ | planned | target v1.0 | planned | -| verify-cli | Verification organ | planned | target v1.0 | planned | -| retro-cli | Retrospective / memory handoff organ | planned | target v1.0 | planned | - -Canonical Tool Contract: - -- [`postmunnet/trinity-tool-contract`](https://github.com/postmunnet/trinity-tool-contract) -- pinned ABI: [`v1.0.0`](https://github.com/postmunnet/trinity-tool-contract/tree/v1.0.0) -- validation/examples tooling: [`v1.0.2`](https://github.com/postmunnet/trinity-tool-contract/releases/tag/v1.0.2) - ---- - ## Architecture ```text @@ -227,7 +206,7 @@ Specs: - [`docs/specs/INDEX.md`](docs/specs/INDEX.md) - [`docs/specs/00_BLUEPRINT.md`](docs/specs/00_BLUEPRINT.md) -- [`docs/specs/01_TOOL_CONTRACT.md`](docs/specs/01_TOOL_CONTRACT.md) redirects to [`postmunnet/trinity-tool-contract`](https://github.com/postmunnet/trinity-tool-contract) +- [`docs/specs/01_TOOL_CONTRACT.md`](docs/specs/01_TOOL_CONTRACT.md) --- @@ -263,7 +242,7 @@ Version story: ```text Trinity Protocol v2 = architecture / constitution generation Runtime v0.1.0 = first public executable runtime line -Tool Contract ABI = v1.0.0 stable; validation/examples tooling v1.0.2 +Tool Contract = v1.0 freeze candidate, v1.1 draft working spec ``` See [`docs/VERSION_LINEAGE.md`](docs/VERSION_LINEAGE.md). @@ -300,4 +279,3 @@ Trinity คือ control layer แบบ CLI-first สำหรับงาน - [`docs/ORIGIN_TH.md`](docs/ORIGIN_TH.md) — ที่มาของ Trinity - [`docs/RITUALS_TH.md`](docs/RITUALS_TH.md) — ritual reference - [`docs/operator-guide-th/00_README.md`](docs/operator-guide-th/00_README.md) — คู่มือใช้งาน -- [`postmunnet/trinity-tool-contract`](https://github.com/postmunnet/trinity-tool-contract) — Tool Contract ABI v1.0 diff --git a/docs/VERSION_LINEAGE.md b/docs/VERSION_LINEAGE.md index 9682b1d..e9b05d7 100644 --- a/docs/VERSION_LINEAGE.md +++ b/docs/VERSION_LINEAGE.md @@ -8,7 +8,7 @@ contracts. They should not be collapsed into one number. ```text Trinity Protocol v2 = architecture / constitution generation Runtime v0.1.0 = first public executable runtime line -Tool Contract ABI = v1.0.0 stable; validation tooling v1.0.1 +Tool Contract = v1.0 freeze candidate, v1.1 draft working spec ``` ## v0.1.0 diff --git a/docs/VERSION_LINEAGE_TH.md b/docs/VERSION_LINEAGE_TH.md index 7522e54..b2d94b0 100644 --- a/docs/VERSION_LINEAGE_TH.md +++ b/docs/VERSION_LINEAGE_TH.md @@ -8,7 +8,7 @@ Trinity แยก version line ของ architecture, runtime และ tool co ```text Trinity Protocol v2 = architecture / constitution generation Runtime v0.1.0 = first public executable runtime line -Tool Contract ABI = v1.0.0 stable; validation tooling v1.0.1 +Tool Contract = v1.0 freeze candidate, v1.1 draft working spec ``` ## v0.1.0 diff --git a/docs/benchmarks/token-economy/README.md b/docs/benchmarks/token-economy/README.md index dce6a79..8479567 100644 --- a/docs/benchmarks/token-economy/README.md +++ b/docs/benchmarks/token-economy/README.md @@ -80,16 +80,18 @@ latency, still loses on markdown structure. The composition of these trade-offs continues to support the routing matrix in [`USE_CASE_ROUTING.md`](../../contracts/browser-cli/USE_CASE_ROUTING.md). -**Honest disclosure:** the v2 result depends on an *uncommitted* change -in the `browser-cli` sibling repo (`lib/commands/read.js`) at the time -of writing. The benchmark will be re-verified once the change is -committed and tagged. v1 numbers above remain the only fully-citable -baseline until then. +**Reproducibility status:** the Phase 1 normalizer is shipped in +[`postmunnet/browser-cli` v0.3.0](https://github.com/postmunnet/browser-cli/releases/tag/v0.3.0) +(current `main`). The load-bearing change is the `normalizeText()` + +`shouldCleanText()` pair in [`lib/commands/read.js`](https://github.com/postmunnet/browser-cli/blob/main/lib/commands/read.js). +Anyone cloning `postmunnet/browser-cli` at `v0.3.0` or later reproduces +the v2 numbers against the same-day README snapshot under `raw/`. **Reproduction (v2):** -``` -cd /path/to/browser-cli # v0.3.0+ with normalizer patch +```bash +git clone --depth 1 --branch v0.3.0 https://github.com/postmunnet/browser-cli.git +cd browser-cli && npm ci && npx playwright install chromium printf 'goto https://github.com/postmunnet/trinity-protocol\ntext article\nexit\n' \ | node index.js ``` diff --git a/docs/contracts/browser-cli/AI_AGENT_GUIDE.md b/docs/contracts/browser-cli/AI_AGENT_GUIDE.md index af9768b..f379651 100644 --- a/docs/contracts/browser-cli/AI_AGENT_GUIDE.md +++ b/docs/contracts/browser-cli/AI_AGENT_GUIDE.md @@ -254,7 +254,7 @@ AI actions are logged — be aware: ### Don't log sensitive commands explicitly ```bash # ❌ Don't print credentials to logs -bcmd "fill #password 'replace-me'" # logged to actions.ndjson +bcmd "fill #password 'actual-password'" # logged to actions.ndjson # ✅ Use config-based auth # Config file has creds → AI uses --login backend diff --git a/docs/migration/01_CONTEXT_AND_DECISIONS.md b/docs/migration/01_CONTEXT_AND_DECISIONS.md index d9c0582..dc2a1ec 100644 --- a/docs/migration/01_CONTEXT_AND_DECISIONS.md +++ b/docs/migration/01_CONTEXT_AND_DECISIONS.md @@ -58,7 +58,7 @@ audience: "Anyone executing or auditing the trinity_v2 migration" **Source:** Commit 0 evidence (`diff` shows IDENTICAL) ### D6 — ai-docs source = /ai-docs/ (Option B) พร้อม scrub -**Why:** /ai-docs/ มี structure ที่ดี (`01-CORE_PROTOCOL/`, `02-STANDARDS/`, `03-PROCESS/`, `04-MEMORY/`) — battle-tested มากกว่า generic //ai-docs/ +**Why:** /ai-docs/ มี structure ที่ดี (`01-CORE_PROTOCOL/`, `02-STANDARDS/`, `03-PROCESS/`, `04-MEMORY/`) — battle-tested มากกว่า generic /yai_project/ai-docs/ **How to apply:** Copy 11 ไฟล์ + scrub 3 ไฟล์ที่มี -specific keywords (`SAFETY_GATES.md`, `ENV_VARS.md`, `ROLLBACK_PROCEDURES.md`) **Risk acknowledged:** Star + Gemini + Claude เตือนว่าเสี่ยง contamination — ผู้ใช้ยืนยันยังเลือก B (override) **Mitigation:** Sanitization บังคับ — replace `/smarty/deploy_dev_order/FTP_CRED` ด้วย placeholders diff --git a/docs/migration/03_COMMIT_PLAN.md b/docs/migration/03_COMMIT_PLAN.md index 247999d..617ca23 100644 --- a/docs/migration/03_COMMIT_PLAN.md +++ b/docs/migration/03_COMMIT_PLAN.md @@ -261,7 +261,7 @@ trinity_v2/docs/schemas/browser-cli/ ### Sub-tasks 18. Create `docs/contracts/browser-cli/README.md` with notice: - > ⚠️ **REFERENCE ONLY** — เอกสารในโฟลเดอร์นี้คือ DNA reference จาก `/browser-cli/` ไม่ใช่ active code ของ trinity_v2. ใช้เป็นต้นแบบสำหรับ tool ใหม่ (memory-cli, verify-cli) ตาม `01_TOOL_CONTRACT.md` + > ⚠️ **REFERENCE ONLY** — เอกสารในโฟลเดอร์นี้คือ DNA reference จาก `~/yai_project/browser-cli/` ไม่ใช่ active code ของ trinity_v2. ใช้เป็นต้นแบบสำหรับ tool ใหม่ (memory-cli, verify-cli) ตาม `01_TOOL_CONTRACT.md` 19. Copy 5 markdown docs 20. Copy 2 JSON schemas diff --git a/docs/specs/01_TOOL_CONTRACT.md b/docs/specs/01_TOOL_CONTRACT.md index ab713b9..86ab2b9 100644 --- a/docs/specs/01_TOOL_CONTRACT.md +++ b/docs/specs/01_TOOL_CONTRACT.md @@ -1,19 +1,1329 @@ -# Tool Contract (Moved) +--- +title: "Trinity Tool Contract" +subtitle: "Universal CLI tool contract — POSIX of Trinity" +version: 1.1.0-draft +status: revised +last-updated: 2026-04-28 +applies-to: All CLI tools in Trinity OS userland +reference-implementation: browser-cli +revision-notes: "v1.1 — added Action Namespace, Contract Compliance Test, MCP stance clarified as CLI-first default with optional bridge" +--- -Canonical spec: +# Trinity Tool Contract v1.1 -- https://github.com/postmunnet/trinity-tool-contract +> **Universal contract for CLI tools in Trinity OS ecosystem** +> +> ถ้า tool ไม่ตามสัญญานี้ — Trinity kernel จะไม่ orchestrate ได้ +> ถ้า tool ตามสัญญานี้ครบ — เสียบกับ Trinity ได้ทันทีโดยไม่ต้องแก้ kernel -Pinned stable ABI: +--- -- https://github.com/postmunnet/trinity-tool-contract/tree/v1.0.0 +## Public Freeze Status -Latest validation tooling: +Public ecosystem messaging should treat the Tool Contract as: -- https://github.com/postmunnet/trinity-tool-contract/releases/tag/v1.0.1 +```text +Tool Contract v1.0 = freeze candidate +Current working spec = v1.1.0-draft +``` -This file is kept as a redirect so older Trinity Protocol links do not break. -Do not edit this file as the source of truth. +ก่อนประกาศ stable interface ต้อง freeze อย่างน้อย 10 เรื่องนี้: -Trinity Protocol may keep internal implementation notes, but the public Tool -Contract authority now lives in `postmunnet/trinity-tool-contract`. +1. Input envelope +2. Output envelope +3. Exit code meaning +4. Verdict schema +5. Artifact declaration +6. Error taxonomy +7. Retry semantics +8. Idempotency expectation +9. Audit log requirements +10. Security boundary + +จนกว่า checklist นี้จะปิดครบ ห้ามสื่อสารว่า external tool interface เป็น +stable แล้ว ให้ใช้คำว่า **v1.0 freeze candidate** หรือ **v1.1 draft working +spec** แทน + +--- + +## 0. Status & Compliance Levels + +| Level | Description | +|-------|-------------| +| **MUST** | บังคับ — ขาดไม่ได้, kernel จะปฏิเสธ tool | +| **SHOULD** | ควรทำ — ขาดได้แต่ไม่แนะนำ | +| **MAY** | ทำเพิ่มก็ได้ — ตามความเหมาะสม | + +Tool compliance assessment ทำผ่าน `trinity tool verify ` (Phase 5+) + +--- + +## 1. Scope + +### 1.1 In Scope +- Binary entry point conventions +- Stdin/stdout JSON protocol +- Response envelope schema +- Universal CLI flags (`--config`, `--run-id`, `--log-file`, ...) +- Logging format (NDJSON) +- Policy tiers (safe / normal / aggressive) +- Error code conventions +- Schema versioning rules +- Discovery (`--list-commands`, `--describe`, `--health`) +- Helpers (YAML composition) +- Documentation requirements +- Test harness pattern +- Tool registry format + +### 1.2 Out of Scope +- ❌ Tool internal architecture (each tool decides) +- ❌ Programming language (Node/Python/Rust/Go ทั้งหมดได้) +- ❌ External library choices +- ❌ Database choice (FTS5, ChromaDB, etc.) +- ❌ **MCP protocol as default core path** — Trinity is CLI-first by default. + - Vendor harness's built-in tools (Read/Write/Edit/Bash) = ใช้ตามปกติ (ไม่กระทบ) + - External MCP servers = optional bridge only, not the default control path + - MCP-only capability ต้องถูก wrap ผ่าน Tool Contract envelope ก่อนเข้า audit chain + - Default replacement: capability สำคัญควรมี CLI tool ตาม contract นี้ + +### 1.3 Non-goals +- ไม่ใช่ AI logic specification +- ไม่ใช่ workflow graph definition +- ไม่ใช่ kernel orchestration logic + +--- + +## 2. Terminology + +| Term | Definition | +|------|------------| +| **Tool** | CLI binary ที่ implement contract นี้ | +| **Verb / Command** | คำสั่งใน tool (e.g. `search`, `index`, `goto`) | +| **Run** | 1 invocation ของ tool (single command, REPL session, pipe batch) | +| **Run ID** | Unique identifier ของ run — สำหรับ trace correlation | +| **Envelope** | Response JSON wrapper (`ok`, `data`, `error`, `meta`) | +| **Artifact** | File/data ที่ tool สร้าง — เก็บไว้เป็น truth | +| **Policy** | Tier ที่จำกัดว่า command ไหนรันได้ (safe/normal/aggressive) | +| **Schema version** | Version ของ response format (`v1`, `v2`, ...) | +| **Helper** | YAML composition ของ commands ที่ใช้ซ้ำ | + +--- + +## 3. Binary Interface + +### 3.1 Entry Point (MUST) + +```bash + [universal-flags] [tool-flags] [-- ] +``` + +**Examples:** +```bash +memory-cli --config configs/.json --cmd "search 'auth bug'" +browser-cli --config configs/.json --login backend +wordpress-cli --config configs/site.json --policy=safe +``` + +### 3.2 Execution Modes (MUST support all 4) + +#### Mode A: Single Command (`--cmd`) +```bash +memory-cli --cmd "search 'auth bug'" +``` +- รัน 1 command, exit +- Response เดียวออก stdout + +#### Mode B: REPL (interactive) +```bash +memory-cli +> search auth +> get r123 +> exit +``` +- Read-Eval-Print loop +- Prompt `> ` +- Each command = 1 response line +- `exit` ออก + +#### Mode C: Pipe (stdin) +```bash +echo "search auth +get r123 +exit" | memory-cli +``` +- อ่านบรรทัดจาก stdin +- Run sequentially +- Each command = 1 response line stdout + +#### Mode D: Run File +```bash +memory-cli --run-file batch.txt +``` +- อ่านไฟล์ — ทุกบรรทัด = 1 command +- Run sequentially +- Skip `#` comments + +### 3.3 Exit Codes (MUST) + +| Code | Meaning | +|------|---------| +| `0` | Success (all commands ok) | +| `1` | Generic error (1+ command failed) | +| `2` | Invalid usage (bad flags, missing config) | +| `3` | Policy violation (command blocked by tier) | +| `4` | Configuration error (config file invalid) | +| `5` | Permission denied (filesystem, network) | +| `10` | Timeout | +| `20` | External dependency failure (e.g. SQLite locked) | +| `64-78` | Reserved (sysexits.h compatibility) | +| `100+` | Tool-specific | + +### 3.4 stdin/stdout/stderr Discipline (MUST) + +| Stream | Purpose | Format | +|--------|---------|--------| +| **stdin** | Commands input | Plain text (1 command per line) | +| **stdout** | Responses | JSON (1 envelope per line, NDJSON) | +| **stderr** | Human-readable diagnostics | Free-form | + +**Critical:** stdout ต้องเป็น **machine-parseable JSON เท่านั้น** ห้ามมี log/debug/banner + +--- + +## 4. Response Envelope + +### 4.1 Success Envelope (MUST) + +```json +{ + "ok": true, + "command": "search", + "action": "memory.search", + "data": { "...": "tool-specific" }, + "artifacts": [ + { "type": "file", "path": "./out/result.json", "sha256": "..." } + ], + "error": null, + "meta": { + "tool": "memory-cli", + "schema_version": "1", + "run_id": "run_2026-04-28_xyz", + "duration_ms": 42, + "timestamp": "2026-04-28T12:34:56.789Z" + } +} +``` + +> ⚠️ **`action` field (v1.1+)** — canonical namespaced verb (e.g. `memory.search`, `browser.screenshot`) — ดู §4a Action Namespace + +### 4.2 Error Envelope (MUST) + +```json +{ + "ok": false, + "command": "search", + "data": null, + "artifacts": [], + "error": { + "code": "INDEX_NOT_FOUND", + "message": "Memory index not initialized", + "details": { + "expected_path": "./.memory/index.db", + "hint": "Run 'memory-cli index ./.claude/retrospectives/' first" + }, + "recoverable": true + }, + "meta": { + "tool": "memory-cli", + "schema_version": "1", + "run_id": "run_2026-04-28_xyz", + "duration_ms": 5, + "timestamp": "2026-04-28T12:34:56.789Z" + } +} +``` + +### 4.3 Field Reference + +| Field | Required | Type | Notes | +|-------|----------|------|-------| +| `ok` | MUST | boolean | `true`=success, `false`=error | +| `command` | MUST | string | verb ที่ถูกเรียก (local name) | +| `action` | MUST (v1.1+) | string | canonical namespaced verb (`tool.verb`) | +| `data` | MUST | object\|null | tool-specific payload (null on error) | +| `artifacts` | MUST | array | files/data created (เก็บเป็น truth) | +| `error` | MUST | object\|null | null on success | +| `error.code` | MUST | string | UPPER_SNAKE_CASE | +| `error.message` | MUST | string | human-readable (Thai+English ok) | +| `error.details` | SHOULD | object | structured context | +| `error.recoverable` | SHOULD | boolean | retry หาย vs need human | +| `meta` | MUST | object | run metadata | +| `meta.tool` | MUST | string | tool name + version (`memory-cli@0.1.0`) | +| `meta.schema_version` | MUST | string | response schema version | +| `meta.run_id` | MUST | string | run correlation ID | +| `meta.duration_ms` | MUST | number | exec time | +| `meta.timestamp` | MUST | string | ISO 8601 UTC | + +### 4.4 Artifact Object Schema + +```json +{ + "type": "file" | "url" | "ref", + "path": "./out/screenshot.png", + "sha256": "abc123...", + "size_bytes": 4096, + "mime": "image/png", + "metadata": {} +} +``` + +--- + +## 4a. Action Namespace (v1.1+) + +### 4a.1 Why +- `screenshot` มีทั้งใน browser-cli, future wordpress-cli — **conflict!** +- `search` มีใน memory-cli, future grep-cli — **conflict!** +- Audit log ต้องระบุ canonical action สำหรับ correlation + +### 4a.2 Format (MUST) + +```text +. +``` + +- `` = lowercase, kebab-case (`browser`, `memory`, `wordpress`, `ftp`, `seo`) +- `` = lowercase, snake_case (`search`, `screenshot`, `fill_form`) + +### 4a.3 Reserved Namespaces + +| Namespace | Tool | +|-----------|------| +| `browser` | browser-cli | +| `memory` | memory-cli | +| `retro` | retro-cli | +| `verify` | verify-cli | +| `wordpress` | wordpress-cli | +| `ftp` | ftp-cli | +| `seo` | seo-cli | +| `code` | grep-cli (code search) | +| `deploy` | deploy-cli | +| `god` | god-team-cli | + +### 4a.4 Examples + +| Local verb | Action namespace | +|-----------|------------------| +| `goto` (browser-cli) | `browser.goto` | +| `screenshot` (browser-cli) | `browser.screenshot` | +| `search` (memory-cli) | `memory.search` | +| `learn` (memory-cli) | `memory.learn` | +| `validate` (retro-cli) | `retro.validate` | +| `assert_browser` (verify-cli) | `verify.assert_browser` | +| `put` (ftp-cli) | `ftp.put` | + +### 4a.5 Rules + +- Tool ต้อง emit `action` field ใน envelope ทุก response +- Tool ต้อง register namespace ใน `.ai/tools.yaml` +- Trinity kernel ต้อง dedupe by action — log canonical name +- `--list-commands` MUST return action namespace per verb + +--- + +## 5. Stdin/Stdout Protocol + +### 5.1 Line Discipline (MUST) +- Each input command = 1 line, terminated by `\n` +- Each output envelope = 1 line, terminated by `\n` (NDJSON) +- No multi-line JSON output — everything single-line + +### 5.2 Encoding (MUST) +- UTF-8 +- ASCII subset acceptable +- No BOM + +### 5.3 Buffering (SHOULD) +- Stdout: line-buffered (flush after each envelope) +- Stderr: unbuffered (immediate diagnostic) +- Stdin: line-buffered (read complete commands) + +### 5.4 Backpressure (MAY) +- Tools MAY honor `SIGPIPE` to halt cleanly +- Long-running commands SHOULD send heartbeat to stderr (not stdout) + +--- + +## 6. Universal CLI Flags + +### 6.1 Required Flags (MUST support) + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--config ` | path | `./.config.json` | Config file | +| `--run-id ` | string | auto-generated | Run correlation | +| `--log-file ` | path | none | NDJSON log output | +| `--policy ` | enum | `normal` | `safe` \| `normal` \| `aggressive` | +| `--response-schema ` | string | `1` | Response schema version | +| `--cmd ""` | string | none | Single command mode | +| `--run-file ` | path | none | Batch from file | + +### 6.2 Standard Flags (SHOULD support) + +| Flag | Description | +|------|-------------| +| `--help, -h` | Print help | +| `--version, -v` | Print version | +| `--list-commands` | Print available verbs (JSON) | +| `--describe ` | Print verb spec (JSON) | +| `--health` | Print health check (JSON) | +| `--quiet, -q` | Suppress stderr diagnostics | +| `--verbose` | Extra stderr diagnostics | +| `--dry-run` | Validate without side effects | + +### 6.3 Reserved Flags (MAY use, but conventions apply) + +| Flag | Convention | +|------|-----------| +| `--show` | Visual mode (e.g. headed browser) | +| `--cdp ` | CDP/external connection (browser-cli) | +| `--reuse` | Reuse existing resource | +| `--force` | Bypass safety checks (require aggressive policy) | +| `--watch` | Long-running watch mode | + +### 6.4 Tool-specific Flags (MAY use any unprefixed name not above) + +ห้ามชนกับ universal/standard/reserved + +--- + +## 7. Configuration + +### 7.1 Config File Format (MUST) +- JSON (preferred) +- YAML (acceptable if tool dependencies allow) + +### 7.2 Standard Config Fields + +```json +{ + "$schema": "https://trinity.local/schemas/tool-config-v1.json", + "version": "1.0", + "tool": "memory-cli", + "run": { + "default_policy": "normal", + "default_log_file": "./logs/memory-cli.ndjson", + "max_duration_ms": 60000 + }, + "paths": { + "data_dir": "./.memory", + "artifact_dir": "./out" + }, + "tool_specific": { + "...": "tool-defined" + } +} +``` + +### 7.3 Config Resolution Order (MUST) + +1. CLI flag explicit (`--config foo.json`) +2. ENV `_CONFIG` +3. CWD `./.config.json` +4. CWD `./configs/.json` +5. Built-in default + +### 7.4 Config Schema Validation (SHOULD) + +Tool ต้อง validate config ก่อน execute และ exit code `4` ถ้าไม่ผ่าน + +--- + +## 8. Logging (NDJSON) + +### 8.1 Log Format (MUST when `--log-file` given) + +Each log line = JSON object on one line: + +```json +{"ts":"2026-04-28T12:34:56.789Z","level":"INFO","tool":"memory-cli","run_id":"run_xyz","event":"command_start","command":"search","args":["auth bug"]} +{"ts":"2026-04-28T12:34:56.823Z","level":"DEBUG","tool":"memory-cli","run_id":"run_xyz","event":"db_query","sql":"SELECT ..."} +{"ts":"2026-04-28T12:34:56.831Z","level":"INFO","tool":"memory-cli","run_id":"run_xyz","event":"command_end","command":"search","duration_ms":42,"ok":true} +``` + +### 8.2 Required Fields per Log Line + +| Field | Type | Description | +|-------|------|-------------| +| `ts` | string | ISO 8601 UTC | +| `level` | enum | `DEBUG`, `INFO`, `WARN`, `ERROR` | +| `tool` | string | tool name | +| `run_id` | string | run correlation | +| `event` | string | event type (snake_case) | + +### 8.3 Standard Events (SHOULD use) + +| Event | When | +|-------|------| +| `run_start` | Tool process starts | +| `run_end` | Tool process exits | +| `command_start` | Verb begins execution | +| `command_end` | Verb finishes | +| `policy_violation` | Command blocked by tier | +| `artifact_created` | File written | +| `external_call` | DB/HTTP/exec invoked | +| `error_caught` | Exception handled | + +### 8.4 Append-only (MUST) +- Log file = append-only NDJSON +- ห้ามแก้บรรทัดเก่า +- Rotation = สร้างไฟล์ใหม่ (ไม่ rewrite) + +--- + +## 9. Policy Tiers + +### 9.1 Tier Definitions (MUST honor) + +| Tier | Allows | Use case | +|------|--------|----------| +| **safe** | Read-only verbs | Audit, status, search, get, list, describe | +| **normal** | + Write verbs | Standard work — index, learn, create, update | +| **aggressive** | + Destructive verbs | Migrations — delete, supersede, force, reset | + +### 9.2 Verb Classification (MUST in `--describe`) + +```json +{ + "verb": "delete", + "tier_required": "aggressive", + "destructive": true, + "writes": true +} +``` + +### 9.3 Policy Enforcement (MUST) +- ก่อนรันทุก verb — check tier +- Block ด้วย exit code `3` + error envelope +- Log `policy_violation` event + +```json +{ + "ok": false, + "command": "delete", + "error": { + "code": "POLICY_VIOLATION", + "message": "Verb 'delete' requires tier=aggressive, current=normal", + "recoverable": false + } +} +``` + +### 9.4 Policy Override (MAY) +- `--policy aggressive` flag = explicit ack +- ห้าม default = `aggressive` +- Tool MAY require additional confirmation (e.g. ENV `I_KNOW_WHAT_IM_DOING=1`) + +--- + +## 10. Error Codes + +### 10.1 Standard Codes (MUST use เหล่านี้สำหรับสถานการณ์ตรงกัน) + +| Code | Meaning | Recoverable | +|------|---------|-------------| +| `INVALID_ARGS` | Bad command arguments | true | +| `INVALID_CONFIG` | Config file malformed | false | +| `MISSING_DEPENDENCY` | External tool not found | false | +| `POLICY_VIOLATION` | Tier insufficient | false | +| `PERMISSION_DENIED` | Filesystem/network forbidden | false | +| `RESOURCE_NOT_FOUND` | File/record missing | true | +| `RESOURCE_LOCKED` | Can't acquire lock | true | +| `RESOURCE_EXHAUSTED` | Disk/memory/quota | true | +| `EXTERNAL_FAILURE` | DB/HTTP/exec failed | true | +| `TIMEOUT` | Operation took too long | true | +| `SCHEMA_MISMATCH` | Response schema version wrong | false | +| `INTERNAL_ERROR` | Bug ใน tool | false | + +### 10.2 Tool-specific Codes (MAY add) + +- ใช้ prefix tool name: `MEMORY_INDEX_CORRUPT`, `BROWSER_PAGE_CRASH` +- UPPER_SNAKE_CASE +- Document ใน COMMAND_CONTRACT.md ของ tool + +--- + +## 11. Schema Versioning + +### 11.1 Versioning Rules (MUST) + +- Schema version = string (e.g. `"1"`, `"2"`, `"1.1"`) +- Backward compat: `v2` tool MUST support `--response-schema=v1` +- Tool MUST embed `schema_version` ใน envelope `meta` + +### 11.2 Breaking Changes Trigger (MUST bump major) + +- ลบ field +- เปลี่ยน type ของ field +- เปลี่ยน semantic ของ field +- เพิ่ม required field + +### 11.3 Non-breaking Changes (MAY bump minor) + +- เพิ่ม optional field +- เพิ่ม enum value (clients SHOULD ignore unknown) + +### 11.4 Deprecation (SHOULD) + +- ประกาศ deprecation 1 major version ก่อนลบ +- Log `WARN` เมื่อ client ใช้ field ที่ deprecated + +--- + +## 12. Discovery + +### 12.1 `--list-commands` (MUST) + +```bash +$ memory-cli --list-commands +``` + +```json +{ + "ok": true, + "command": "list-commands", + "data": { + "tool": "memory-cli@0.1.0", + "commands": [ + { "verb": "search", "tier": "safe", "description": "Hybrid search" }, + { "verb": "learn", "tier": "normal", "description": "Add document" }, + { "verb": "supersede", "tier": "aggressive", "description": "Mark obsolete" } + ] + }, + "error": null, + "meta": { "...": "..." } +} +``` + +### 12.2 `--describe ` (MUST) + +```bash +$ memory-cli --describe search +``` + +```json +{ + "ok": true, + "command": "describe", + "data": { + "verb": "search", + "tier_required": "safe", + "destructive": false, + "writes": false, + "args": [ + { "name": "query", "type": "string", "required": true, "description": "Search query" } + ], + "options": [ + { "name": "--limit", "type": "int", "default": 10 } + ], + "returns": { + "type": "object", + "schema": { "$ref": "schemas/search-response-v1.json" } + }, + "examples": [ + "search 'auth bug'", + "search 'login error' --limit=20" + ] + } +} +``` + +### 12.3 `--health` (SHOULD) + +```bash +$ memory-cli --health +``` + +```json +{ + "ok": true, + "command": "health", + "data": { + "tool": "memory-cli@0.1.0", + "status": "ready", + "checks": [ + { "name": "config_loaded", "ok": true }, + { "name": "db_connection", "ok": true, "details": { "path": "./.memory/index.db" } }, + { "name": "external_deps", "ok": true, "deps": ["sqlite3"] } + ] + } +} +``` + +--- + +## 13. Helpers (YAML) + +### 13.1 Helper File Format (SHOULD support) + +```yaml +# memory-helpers.yml +helpers: + fresh-search: + args: [query] + description: "Reindex then search" + steps: + - reindex + - search {query} + + weekly-stats: + description: "Stats + recent learnings" + steps: + - stats + - list --since=7d --tag=lesson +``` + +### 13.2 Invocation + +```bash +memory-cli --cmd "helper fresh-search 'auth bug'" +``` + +### 13.3 Variable Interpolation (MAY) +- `{arg_name}` = positional arg +- `{$ENV_VAR}` = environment variable +- `{ts}` = timestamp +- ห้าม shell injection — escape always + +--- + +## 14. Documentation Requirements + +### 14.1 Required Files (MUST in each tool repo) + +``` +/ +├── README.md ← User-facing intro +├── docs/ +│ ├── ARCHITECTURE.md ← Design overview, state model, phases +│ ├── COMMAND_CONTRACT.md ← All verbs + response schema +│ ├── CONFIG_SCHEMA.md ← Config file schema +│ ├── AI_AGENT_GUIDE.md ← How AI should use this tool +│ ├── USER_GUIDE.md ← Human user guide +│ ├── POLICY_TIERS.md ← Tier mapping per verb +│ └── TROUBLESHOOTING.md ← Common errors + fixes +├── schema/ +│ ├── config.schema.json ← JSON Schema for config +│ └── response-v.schema.json ← JSON Schema for envelope +├── tests/ +│ ├── harness.js (or .py) ← Unit tests (no external deps) +│ └── golden.js (or .py) ← Integration test +├── package.json (or pyproject.toml) +└── CHANGELOG.md +``` + +### 14.2 Recommended Files (SHOULD) + +``` +├── docs/ +│ ├── examples/ ← End-to-end examples +│ ├── SHELL_ALIASES.md ← Quick aliases +│ └── TMUX_INTEGRATION.md ← Pane patterns +└── configs/ + └── .json ← Example config +``` + +### 14.3 Documentation Requirements per File + +#### README.md (MUST contain) +- Tool name + version +- 1-paragraph description +- Setup (install + first run) +- Usage examples (single-cmd, REPL, pipe) +- Link to docs/ + +#### COMMAND_CONTRACT.md (MUST contain) +- Every verb with full schema +- Tier classification +- Argument list +- Return schema +- Examples +- Error codes specific to verb + +#### AI_AGENT_GUIDE.md (MUST contain) +- When to use (decision tree) +- When NOT to use +- Common patterns +- Anti-patterns +- Composition with other Trinity tools + +--- + +## 15. Test Harness Pattern + +### 15.1 Unit Test Harness (MUST have) + +File: `tests/harness.js` (or `.py`) + +```javascript +#!/usr/bin/env node +// Minimal unit test harness — no external deps +const assert = require('assert'); +const parser = require('../lib/parser'); + +let passed = 0, failed = 0; + +function test(name, fn) { + try { fn(); console.log(`✓ ${name}`); passed++; } + catch (e) { console.log(`✗ ${name}: ${e.message}`); failed++; } +} + +// ─── Parser ─── +test('basic verb + arg', () => { + const r = parser.parse('search auth'); + assert.strictEqual(r.verb, 'search'); +}); + +// ... more tests + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); +``` + +**Invocation:** `node tests/harness.js` — no external services + +### 15.2 Golden Test (MUST have) + +File: `tests/golden.js` + +- Integration test against real dependency (DB, browser, etc.) +- Idempotent (cleanup before/after) +- Must run < 60 seconds +- Exit code = 0 on full pass + +### 15.3 Schema Validation Test (SHOULD have) + +- Validate every example response against `schema/response-v.schema.json` +- Fail if envelope doesn't match + +--- + +## 16. Tool Registry + +### 16.1 Registry Format + +File: `.ai/tools.yaml` (in TRINITY kernel root) + +```yaml +version: 1 +tools: + - name: browser-cli + path: /browser-cli + bin: node /browser-cli/index.js + schema_version: "2" + capabilities: [browser, navigation, dom, screenshot, assertion] + policy_default: normal + health_check: --health + contract_version: "1.0" + + - name: memory-cli + path: /memory-cli + bin: node /memory-cli/index.js + schema_version: "1" + capabilities: [search, index, recall, learn] + policy_default: normal + health_check: --health + contract_version: "1.0" + + # ... more tools +``` + +### 16.2 Registry Operations (Trinity kernel) + +```bash +trinity tool list # ดู tools ทั้งหมด +trinity tool register # เพิ่ม tool +trinity tool verify # ตรวจ contract compliance +trinity tool health # ตรวจ health +trinity tool capabilities # ค้น tool ที่มี capability +``` + +### 16.3 Capability Naming Convention + +- lowercase +- hyphen-separated +- noun (resource) or verb (action) +- examples: `browser`, `dom-query`, `screenshot`, `search`, `index`, `delete` + +--- + +## 16a. Contract Compliance Test (v1.1+) + +### 16a.1 Why (MUST) +ถ้ามี TOOL_CONTRACT แต่ไม่มี automated test — tool จะ drift จาก contract เสมอ + +### 16a.2 `trinity-contract-test` CLI + +```bash +trinity-contract-test +``` + +ตัวอย่าง output: +``` +Testing: memory-cli@0.1.0 +───────────────────────── +Binary Interface + ✓ stdin/stdout JSON discipline + ✓ Exit codes match spec + ✓ Single-cmd mode works + ✓ REPL mode works + ✓ Pipe mode works + ✓ Run-file mode works + +Universal Flags + ✓ --config supported + ✓ --run-id supported + ✓ --log-file supported + ✓ --policy supported + ✓ --response-schema supported + ✓ --cmd / --run-file supported + +Discovery + ✓ --help works + ✓ --version works + ✓ --list-commands returns valid JSON + ✓ --describe returns valid JSON + ✗ --health endpoint missing ← FAIL + +Response Envelope + ✓ Success envelope schema valid + ✓ Error envelope schema valid + ✓ action field present (v1.1+) + ✓ All required meta fields present + +Logging + ✓ NDJSON format valid + ✓ Required fields per log line + ✓ Standard events used + +Policy + ✓ Verbs classified in --describe + ✓ Tier enforcement blocks correctly + ✓ POLICY_VIOLATION error code used + +Schema + ✓ Backward compat (v1 supported) + ✓ schema_version embedded + +───────────────────────── +RESULT: 22/23 PASSED, 1 FAILED +``` + +### 16a.3 Test Categories (MUST run) + +| Category | Tests | +|----------|-------| +| Binary Interface | exec modes, exit codes, stdio discipline | +| Universal Flags | all required flags accepted | +| Discovery | help/version/list-commands/describe/health | +| Response Envelope | success/error schema validation | +| Logging | NDJSON format + required fields | +| Policy | tier classification + enforcement | +| Schema | versioning + backward compat | +| Action Namespace | format + registry consistency (v1.1+) | + +### 16a.4 Implementation + +`trinity-contract-test` itself = CLI tool that: +- Spawns target tool with various inputs +- Validates outputs against schemas (JSON Schema) +- Reports per-test result + overall verdict +- Exit code: `0` if all pass, `1` if any fail + +### 16a.5 CI Integration (SHOULD) + +ทุก tool ใน registry ต้องรัน `trinity-contract-test` ใน CI: +- Pre-commit hook +- GitHub Actions / GitLab CI +- Block merge if fail + +### 16a.6 Compliance Levels + +| Level | Definition | +|-------|------------| +| **Bronze** | Pass binary interface + envelope tests | +| **Silver** | + Pass discovery + logging tests | +| **Gold** | + Pass policy + schema + namespace tests | +| **Platinum** | + Pass golden integration tests | + +Trinity kernel SHOULD reject tools below Bronze in production registry + +--- + +## 17. Compliance Checklist + +ก่อน register tool ใน Trinity registry — tool ต้องผ่านทุกข้อต่อไปนี้: + +### Binary Interface +- [ ] รับ 4 execution modes (single-cmd, REPL, pipe, run-file) +- [ ] Exit codes ตาม spec +- [ ] stdin/stdout/stderr discipline ถูก +- [ ] UTF-8 encoding + +### Response Envelope +- [ ] ทุก response มี fields ครบ (`ok`, `command`, `data`, `artifacts`, `error`, `meta`) +- [ ] `meta` มี tool, schema_version, run_id, duration_ms, timestamp +- [ ] Error envelope มี `error.code` + `error.message` +- [ ] Single-line NDJSON output + +### CLI Flags +- [ ] รองรับ `--config`, `--run-id`, `--log-file`, `--policy`, `--response-schema`, `--cmd`, `--run-file` +- [ ] รองรับ `--help`, `--version`, `--list-commands`, `--describe`, `--health` + +### Configuration +- [ ] Config file = JSON (or YAML) +- [ ] Config resolution order ถูก +- [ ] Config schema validation +- [ ] Exit `4` on invalid config + +### Logging +- [ ] NDJSON format เมื่อ `--log-file` ระบุ +- [ ] Required fields per log line ครบ +- [ ] Standard events ใช้ตามที่ spec + +### Policy +- [ ] Verbs classified ใน `--describe` +- [ ] Tier enforcement ก่อน execute +- [ ] Exit `3` + envelope on policy violation + +### Error Codes +- [ ] ใช้ standard codes ที่ตรงสถานการณ์ +- [ ] Tool-specific codes มี prefix +- [ ] `error.recoverable` set ถูกต้อง + +### Schema Versioning +- [ ] Schema version embedded in meta +- [ ] Backward compat รองรับ +- [ ] Deprecation warnings + +### Documentation +- [ ] README.md +- [ ] docs/ARCHITECTURE.md +- [ ] docs/COMMAND_CONTRACT.md +- [ ] docs/AI_AGENT_GUIDE.md +- [ ] docs/USER_GUIDE.md +- [ ] schema/config.schema.json +- [ ] schema/response-v1.schema.json +- [ ] CHANGELOG.md + +### Tests +- [ ] tests/harness.js — unit, no deps, < 5s +- [ ] tests/golden.js — integration, < 60s +- [ ] Schema validation tests + +### Registry +- [ ] เพิ่มใน `.ai/tools.yaml` +- [ ] `trinity tool verify ` ผ่าน +- [ ] `trinity tool health ` ผ่าน + +--- + +## 18. Examples + +### 18.1 Minimal Compliant Tool Skeleton (Node.js) + +```javascript +#!/usr/bin/env node +// memory-cli — minimal contract-compliant skeleton + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +// ─── Constants ─── +const TOOL_NAME = 'memory-cli'; +const TOOL_VERSION = '0.1.0'; +const SCHEMA_VERSION = '1'; + +// ─── Args parsing ─── +const args = parseArgs(process.argv.slice(2)); +const config = loadConfig(args.config); +const runId = args['run-id'] || generateRunId(); +const policy = args.policy || 'normal'; +const logFile = args['log-file'] ? fs.createWriteStream(args['log-file'], { flags: 'a' }) : null; + +// ─── Discovery flags ─── +if (args.help) { printHelp(); process.exit(0); } +if (args.version) { console.log(`${TOOL_NAME}@${TOOL_VERSION}`); process.exit(0); } +if (args['list-commands']) { send(listCommands()); process.exit(0); } +if (args.describe) { send(describeVerb(args.describe)); process.exit(0); } +if (args.health) { send(healthCheck()); process.exit(0); } + +// ─── Execution mode dispatch ─── +if (args.cmd) { + executeOne(args.cmd).then(env => { send(env); process.exit(env.ok ? 0 : 1); }); +} else if (args['run-file']) { + executeBatch(args['run-file']); +} else { + startRepl(); +} + +// ─── Core ─── +async function executeOne(cmdLine) { + const start = Date.now(); + log({ event: 'command_start', command: cmdLine }); + + try { + const { verb, args: verbArgs } = parseVerb(cmdLine); + + // Policy check + const verbSpec = getVerbSpec(verb); + if (!isPolicyAllowed(verbSpec.tier_required, policy)) { + return errorEnvelope(verb, 'POLICY_VIOLATION', + `Verb '${verb}' requires tier=${verbSpec.tier_required}, current=${policy}`); + } + + // Execute + const data = await runVerb(verb, verbArgs); + const env = successEnvelope(verb, data, Date.now() - start); + log({ event: 'command_end', command: verb, duration_ms: env.meta.duration_ms, ok: true }); + return env; + } catch (e) { + const env = errorEnvelope(cmdLine, e.code || 'INTERNAL_ERROR', e.message); + log({ event: 'command_end', command: cmdLine, ok: false, error: e.message }); + return env; + } +} + +function successEnvelope(command, data, duration_ms) { + return { + ok: true, + command, + data: data || {}, + artifacts: [], + error: null, + meta: { + tool: `${TOOL_NAME}@${TOOL_VERSION}`, + schema_version: SCHEMA_VERSION, + run_id: runId, + duration_ms, + timestamp: new Date().toISOString() + } + }; +} + +function errorEnvelope(command, code, message, details = {}) { + return { + ok: false, + command, + data: null, + artifacts: [], + error: { code, message, details, recoverable: !['INVALID_CONFIG', 'POLICY_VIOLATION', 'INTERNAL_ERROR'].includes(code) }, + meta: { + tool: `${TOOL_NAME}@${TOOL_VERSION}`, + schema_version: SCHEMA_VERSION, + run_id: runId, + duration_ms: 0, + timestamp: new Date().toISOString() + } + }; +} + +function send(envelope) { + process.stdout.write(JSON.stringify(envelope) + '\n'); +} + +function log(entry) { + if (!logFile) return; + logFile.write(JSON.stringify({ + ts: new Date().toISOString(), + level: 'INFO', + tool: TOOL_NAME, + run_id: runId, + ...entry + }) + '\n'); +} + +function generateRunId() { + return `run_${new Date().toISOString().replace(/[:.]/g, '-')}_${Math.random().toString(36).slice(2, 8)}`; +} + +// ... rest of implementation (parseArgs, loadConfig, parseVerb, getVerbSpec, runVerb, etc.) +``` + +### 18.2 Real Use Case — Trinity Loop Calling Tools + +```python +# .ai/cli/commands/loop.py — kernel loop calls tools +import subprocess, json + +def call_tool(tool_bin, command, run_id, policy='normal'): + result = subprocess.run( + [*tool_bin.split(), '--cmd', command, '--run-id', run_id, '--policy', policy, + '--log-file', f'.ai/audit/tool-{tool_bin.split()[-1]}.ndjson'], + capture_output=True, text=True, timeout=60 + ) + return json.loads(result.stdout.strip().split('\n')[-1]) + +# Trinity loop +def trinity_loop(goal, max_iter=10): + run_id = f"run_{goal[:20]}_{int(time.time())}" + + for i in range(max_iter): + # Step 1: get memory context + ctx = call_tool('node memory-cli/index.js', f"search '{goal}'", run_id, 'safe') + if not ctx['ok']: break + + # Step 2: verify evidence + evidence = call_tool('python verify-cli', f"check '{goal}'", run_id, 'safe') + + # ... etc +``` + +--- + +## 19. Migration Path for browser-cli + +browser-cli ปัจจุบันใกล้ contract นี้แล้ว — ส่วนที่ต้องปรับ: + +| Item | Current | Target | Effort | +|------|---------|--------|--------| +| Response envelope v1 | partial | full | 🟡 Med | +| `--list-commands` | ❌ | ✅ | 🟢 Low | +| `--describe ` | ❌ | ✅ | 🟢 Low | +| `--health` | ❌ | ✅ | 🟢 Low | +| `error.recoverable` | partial | full | 🟢 Low | +| `meta.timestamp` | ⚠️ in v2 | required | 🟢 Low | +| Helpers YAML | ✅ | ✅ | - | +| Policy tiers | ✅ | ✅ | - | +| NDJSON log | ✅ | ✅ | - | + +> browser-cli should hit **v2.1** to be fully contract-compliant — small additions, no breaking + +--- + +## 20. Open Questions + +1. **JSON vs YAML config** — บังคับ JSON หรือเปิดเลือก? +2. **Schema location** — ภายใน tool repo หรือ shared schema repo? +3. **Run ID format** — UUID v4 หรือ timestamp-based? +4. **Helper recursion** — helper เรียก helper อื่นได้ไหม? +5. **Streaming responses** — สำหรับ long-running commands ทำยังไง? +6. **Multi-language tools** — Python tool รองรับครบเหมือน Node ไหม? +7. **Tool versioning vs schema versioning** — ผูกกัน หรือแยก? +8. **Error code namespace** — globally unique หรือ tool-scoped? +9. **Capability taxonomy** — มี registry กลางไหม? +10. **MCP bridge mapping** — verb → MCP tool ทำอัตโนมัติ? + +--- + +## Appendix A: browser-cli as Reference + +`browser-cli` เป็น reference implementation: +- ✅ stdin/stdout JSON +- ✅ Schema-locked (v1/v2) +- ✅ NDJSON log +- ✅ Policy tiers (safe/normal/aggressive) +- ✅ Run ID +- ✅ JSON config + helpers YAML +- ✅ REPL + pipe + run-file modes +- ✅ Documentation suite (docs/) + +ทุก tool ใหม่ — ดู browser-cli แล้ว clone pattern + +## Appendix B: JSON Schemas (preview) + +### B.1 Response Envelope v1 +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["ok", "command", "data", "artifacts", "error", "meta"], + "properties": { + "ok": { "type": "boolean" }, + "command": { "type": "string" }, + "data": { "type": ["object", "null"] }, + "artifacts": { "type": "array", "items": { "$ref": "#/definitions/artifact" } }, + "error": { "oneOf": [{ "type": "null" }, { "$ref": "#/definitions/error" }] }, + "meta": { "$ref": "#/definitions/meta" } + }, + "definitions": { + "artifact": { + "type": "object", + "required": ["type", "path"], + "properties": { + "type": { "enum": ["file", "url", "ref"] }, + "path": { "type": "string" }, + "sha256": { "type": "string" }, + "size_bytes": { "type": "integer" }, + "mime": { "type": "string" } + } + }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, + "message": { "type": "string" }, + "details": { "type": "object" }, + "recoverable": { "type": "boolean" } + } + }, + "meta": { + "type": "object", + "required": ["tool", "schema_version", "run_id", "duration_ms", "timestamp"], + "properties": { + "tool": { "type": "string" }, + "schema_version": { "type": "string" }, + "run_id": { "type": "string" }, + "duration_ms": { "type": "number", "minimum": 0 }, + "timestamp": { "type": "string", "format": "date-time" } + } + } + } +} +``` + +### B.2 Tool Registry Schema +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["version", "tools"], + "properties": { + "version": { "const": 1 }, + "tools": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "bin", "schema_version", "capabilities", "contract_version"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "path": { "type": "string" }, + "bin": { "type": "string" }, + "schema_version": { "type": "string" }, + "capabilities": { "type": "array", "items": { "type": "string" } }, + "policy_default": { "enum": ["safe", "normal", "aggressive"] }, + "health_check": { "type": "string" }, + "contract_version": { "type": "string" } + } + } + } + } +} +``` + +--- + +## Changelog + +- **v1.0.0-draft (2026-04-28)** — Initial draft based on browser-cli reference + blueprint synthesis + +--- + +## See also + +- [`00_BLUEPRINT.md`](00_BLUEPRINT.md) — Trinity Big Evolution Blueprint +- `browser-cli/docs/COMMAND_CONTRACT.md` — Reference implementation contract +- `browser-cli/docs/RESPONSE_SCHEMA.md` — Reference v1/v2 schemas +- `browser-cli/docs/POLICY_TIERS.md` — Reference tier mapping diff --git a/docs/specs/10_UPSTREAM_AUDIT.md b/docs/specs/10_UPSTREAM_AUDIT.md index 9ca9444..b617702 100644 --- a/docs/specs/10_UPSTREAM_AUDIT.md +++ b/docs/specs/10_UPSTREAM_AUDIT.md @@ -244,7 +244,7 @@ target: (production project) --- -### 2.7 browser-cli (external `/browser-cli/`) +### 2.7 browser-cli (external `~/yai_project/browser-cli/`) #### Current State diff --git a/docs/specs/11_RELATED_PROJECTS.md b/docs/specs/11_RELATED_PROJECTS.md index 8f94105..f092c7b 100644 --- a/docs/specs/11_RELATED_PROJECTS.md +++ b/docs/specs/11_RELATED_PROJECTS.md @@ -22,7 +22,7 @@ last-updated: 2026-04-28 --- -## 1. Project Family (Internal — workspace-root/) +## 1. Project Family (Internal — yai_project/) ### 1.1 TRINITY_LEGACY (this project) diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 3d17505..c940f04 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -18,7 +18,7 @@ read-time: 15 minutes 1. [Welcome](#1-welcome) — What this is, For whom 2. [The Big Picture](#2-the-big-picture) — One diagram, two paragraphs -3. [Project Family](#3-project-family) — All `workspace-root/` folders +3. [Project Family](#3-project-family) — All `yai_project/` folders 4. [Vocabulary (Glossary)](#4-vocabulary-glossary) — ทุกคำมีความหมาย 5. [Component Index](#5-component-index) — Where to find what 6. [Roles & Responsibilities](#6-roles--responsibilities) — Who does what @@ -124,7 +124,7 @@ Going to migrate ? → INDEX → 10 → 09 `/` มีโฟลเดอร์เกี่ยวข้องดังนี้: ```text -workspace-root/ +yai_project/ ├── TRINITY_LEGACY/ ← Trinity kernel (development) │ ├── .ai/ ← Trinity runtime: cli, sessions, audit, policies, schemas │ ├── archive/ ← Legacy AI docs, old sessions @@ -313,7 +313,7 @@ Bootstrap Pack ← Template for new projects |-------------|-----------| | **Master vision** | `00_BLUEPRINT.md` | | **How to scaffold new project** | `00b_BOOTSTRAP_PACK.md` | -| **How to write a CLI tool** | `01_TOOL_CONTRACT.md` redirect → [`postmunnet/trinity-tool-contract`](https://github.com/postmunnet/trinity-tool-contract) | +| **How to write a CLI tool** | `01_TOOL_CONTRACT.md` | | **How verifier works** | `02_VERIFIER_SPEC.md` | | **How loop works** | `03_GOAL_LOOP_SPEC.md` | | **How workflow graph works** | `04_GRAPH_SPEC.md` | @@ -332,12 +332,12 @@ Bootstrap Pack ← Template for new projects |-----------|----------|--------| | Trinity kernel | `/.ai/cli/` (existing) | ✅ Production | | Trinity kernel (canonical) | (future: `~/code/trinity-kernel/`) | 📋 | -| browser-cli | `/browser-cli/` | ✅ Production | -| memory-cli | (future: `/memory-cli/`) | 📋 Phase 2 | -| verify-cli | (future: `/verify-cli/`) | 📋 Phase 4 | -| retro-cli | (future: `/retro-cli/`) | 📋 Phase 7 | -| trinity-shell | (future: `/trinity-shell/`) | 📋 Phase 8 | -| Bootstrap Pack | (future: `/trinity-bootstrap-pack/`) | 📋 Phase 0.5 | +| browser-cli | `~/yai_project/browser-cli/` | ✅ Production | +| memory-cli | (future: `~/yai_project/memory-cli/`) | 📋 Phase 2 | +| verify-cli | (future: `~/yai_project/verify-cli/`) | 📋 Phase 4 | +| retro-cli | (future: `~/yai_project/retro-cli/`) | 📋 Phase 7 | +| trinity-shell | (future: `~/yai_project/trinity-shell/`) | 📋 Phase 8 | +| Bootstrap Pack | (future: `~/yai_project/trinity-bootstrap-pack/`) | 📋 Phase 0.5 | ### 5.3 Per-project Trinity instance @@ -836,7 +836,7 @@ Phase 10 Extension Platform 🌐 Future | 0 | [`INDEX.md`](INDEX.md) | This document — master overview | ~700 | 15 min | | 1 | [`00_BLUEPRINT.md`](00_BLUEPRINT.md) | Master spec v2 | 693 | 30 min | | 2 | [`00b_BOOTSTRAP_PACK.md`](00b_BOOTSTRAP_PACK.md) | Phase 0.5 portability | 1,071 | 25 min | -| 3 | [`01_TOOL_CONTRACT.md`](01_TOOL_CONTRACT.md) | Redirect to canonical Tool Contract repo | redirect | 1 min | +| 3 | [`01_TOOL_CONTRACT.md`](01_TOOL_CONTRACT.md) | Universal CLI contract | 1,298 | 30 min | | 4 | [`02_VERIFIER_SPEC.md`](02_VERIFIER_SPEC.md) | Judge with rules | 710 | 20 min | | 5 | [`03_GOAL_LOOP_SPEC.md`](03_GOAL_LOOP_SPEC.md) | Goal tree + loop | 644 | 20 min | | 6 | [`04_GRAPH_SPEC.md`](04_GRAPH_SPEC.md) | Workflow + authority | 710 | 20 min | diff --git a/docs/specs/en/01_TOOL_CONTRACT.md b/docs/specs/en/01_TOOL_CONTRACT.md index ab713b9..ef604e8 100644 --- a/docs/specs/en/01_TOOL_CONTRACT.md +++ b/docs/specs/en/01_TOOL_CONTRACT.md @@ -1,19 +1,820 @@ -# Tool Contract (Moved) +--- +title: "Trinity Tool Contract v1.1 (English)" +subtitle: "Universal CLI tool contract — POSIX of Trinity" +language: English +version: 1.1.0 +last-updated: 2026-04-28 +note: "Translation of ../01_TOOL_CONTRACT.md (essential parts)" +--- -Canonical spec: +# Trinity Tool Contract v1.1 (English) -- https://github.com/postmunnet/trinity-tool-contract +> **Universal contract for CLI tools in Trinity OS ecosystem** +> +> Tool not following this → Trinity kernel won't orchestrate. +> Tool following this fully → plug-and-play with Trinity. -Pinned stable ABI: +--- -- https://github.com/postmunnet/trinity-tool-contract/tree/v1.0.0 +## Public Freeze Status -Latest validation tooling: +Public ecosystem messaging should treat the Tool Contract as: -- https://github.com/postmunnet/trinity-tool-contract/releases/tag/v1.0.1 +```text +Tool Contract v1.0 = freeze candidate +Current working spec = v1.1.0-draft +``` -This file is kept as a redirect so older Trinity Protocol links do not break. -Do not edit this file as the source of truth. +Before declaring a stable external tool interface, Trinity must freeze at +least these 10 surfaces: -Trinity Protocol may keep internal implementation notes, but the public Tool -Contract authority now lives in `postmunnet/trinity-tool-contract`. +1. Input envelope +2. Output envelope +3. Exit code meaning +4. Verdict schema +5. Artifact declaration +6. Error taxonomy +7. Retry semantics +8. Idempotency expectation +9. Audit log requirements +10. Security boundary + +Until that checklist is complete, public docs should say **v1.0 freeze +candidate** or **v1.1 draft working spec**, not stable interface. + +--- + +## 0. Compliance Levels + +| Level | Description | +|-------|-------------| +| **MUST** | Required — kernel rejects tool if missing | +| **SHOULD** | Recommended — works but not best practice | +| **MAY** | Optional | + +--- + +## 1. Scope + +### In Scope +- Binary entry point conventions +- Stdin/stdout JSON protocol +- Response envelope schema +- Universal CLI flags +- Logging format (NDJSON) +- Policy tiers (safe/normal/aggressive) +- Error codes +- Schema versioning +- Discovery (`--list-commands`, `--describe`, `--health`) +- Helpers (YAML composition) +- Documentation requirements +- Test harness pattern +- Tool registry format + +### Out of Scope +- ❌ Tool internal architecture +- ❌ Programming language (any works) +- ❌ External libraries +- ❌ Database choice +- ❌ **MCP protocol as default core path** — Trinity is CLI-first by default. + MCP-only capabilities may be wrapped by an adapter, but they must enter + Trinity through the Tool Contract envelope before they reach the audit chain. + +--- + +## 2. Terminology + +| Term | Definition | +|------|-----------| +| **Tool** | CLI binary implementing this contract | +| **Verb / Command** | Action a tool exposes (e.g., `search`, `goto`) | +| **Run** | One invocation of a tool | +| **Run ID** | Unique identifier for a run | +| **Envelope** | Response JSON wrapper | +| **Artifact** | File/data created — kept as truth | +| **Policy** | Tier limiting which verbs allowed | +| **Schema version** | Version of response format | +| **Helper** | YAML composition of commands | + +--- + +## 3. Binary Interface + +### 3.1 Entry Point (MUST) + +```bash + [universal-flags] [tool-flags] [-- ] +``` + +**Examples:** +```bash +memory-cli --config configs/.json --cmd "search 'auth bug'" +browser-cli --config configs/.json --login backend +``` + +### 3.2 Execution Modes (MUST support all 4) + +#### Mode A: Single Command +```bash +memory-cli --cmd "search 'auth bug'" +``` + +#### Mode B: REPL (interactive) +```bash +memory-cli +> search auth +> get r123 +> exit +``` + +#### Mode C: Pipe (stdin) +```bash +echo "search auth +get r123 +exit" | memory-cli +``` + +#### Mode D: Run File +```bash +memory-cli --run-file batch.txt +``` + +### 3.3 Exit Codes (MUST) + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Generic error | +| `2` | Invalid usage | +| `3` | Policy violation | +| `4` | Configuration error | +| `5` | Permission denied | +| `10` | Timeout | +| `100+` | Tool-specific | + +### 3.4 Stream Discipline (MUST) + +| Stream | Purpose | Format | +|--------|---------|--------| +| **stdin** | Commands input | Plain text (1 command per line) | +| **stdout** | Responses | JSON (NDJSON) | +| **stderr** | Diagnostics | Free-form | + +> **CRITICAL:** stdout must be **machine-parseable JSON only** — no banners, no debug + +--- + +## 4. Response Envelope + +### 4.1 Success Envelope (MUST) + +```json +{ + "ok": true, + "command": "search", + "action": "memory.search", + "data": { "...": "tool-specific" }, + "artifacts": [ + { "type": "file", "path": "./out/result.json", "sha256": "..." } + ], + "error": null, + "meta": { + "tool": "memory-cli@0.1.0", + "schema_version": "1", + "run_id": "run_2026-04-28_xyz", + "duration_ms": 42, + "timestamp": "2026-04-28T12:34:56Z" + } +} +``` + +### 4.2 Error Envelope (MUST) + +```json +{ + "ok": false, + "command": "search", + "action": "memory.search", + "data": null, + "artifacts": [], + "error": { + "code": "INDEX_NOT_FOUND", + "message": "Memory index not initialized", + "details": { "expected_path": "./.memory/index.db" }, + "recoverable": true + }, + "meta": { "...": "..." } +} +``` + +### 4.3 Field Reference + +| Field | Required | Type | +|-------|----------|------| +| `ok` | MUST | boolean | +| `command` | MUST | string (local verb) | +| `action` | MUST (v1.1+) | string (canonical `tool.verb`) | +| `data` | MUST | object\|null | +| `artifacts` | MUST | array | +| `error` | MUST | object\|null | +| `meta` | MUST | object | +| `meta.tool` | MUST | string | +| `meta.schema_version` | MUST | string | +| `meta.run_id` | MUST | string | +| `meta.duration_ms` | MUST | number | +| `meta.timestamp` | MUST | ISO 8601 | + +--- + +## 4a. Action Namespace (v1.1+) + +### Why +- `screenshot` exists in browser-cli AND wordpress-cli — conflict +- `search` exists in memory-cli AND grep-cli — conflict +- Audit needs canonical name + +### Format (MUST) + +``` +. +``` + +### Reserved Namespaces + +| Namespace | Tool | +|-----------|------| +| `browser` | browser-cli | +| `memory` | memory-cli | +| `retro` | retro-cli | +| `verify` | verify-cli | +| `wordpress` | wordpress-cli | +| `ftp` | ftp-cli | +| `seo` | seo-cli | +| `code` | grep-cli | +| `deploy` | deploy-cli | +| `god` | god-team-cli | + +### Examples + +| Local verb | Action namespace | +|-----------|------------------| +| `goto` (browser-cli) | `browser.goto` | +| `screenshot` (browser-cli) | `browser.screenshot` | +| `search` (memory-cli) | `memory.search` | +| `validate` (retro-cli) | `retro.validate` | +| `put` (ftp-cli) | `ftp.put` | + +--- + +## 5. Stdin/Stdout Protocol + +- Each input command = 1 line, terminated by `\n` +- Each output envelope = 1 line, terminated by `\n` (NDJSON) +- No multi-line JSON — single-line only +- UTF-8 encoding +- stdout: line-buffered (flush after each envelope) +- stderr: unbuffered + +--- + +## 6. Universal CLI Flags + +### Required (MUST support) + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--config ` | path | `./.config.json` | Config file | +| `--run-id ` | string | auto | Run correlation | +| `--log-file ` | path | none | NDJSON log output | +| `--policy ` | enum | `normal` | safe/normal/aggressive | +| `--response-schema ` | string | `1` | Schema version | +| `--cmd ""` | string | none | Single command mode | +| `--run-file ` | path | none | Batch from file | + +### Standard (SHOULD support) + +| Flag | Description | +|------|-------------| +| `--help, -h` | Print help | +| `--version, -v` | Print version | +| `--list-commands` | List verbs (JSON) | +| `--describe ` | Verb spec (JSON) | +| `--health` | Health check (JSON) | +| `--quiet, -q` | Suppress stderr | +| `--verbose` | Extra diagnostics | +| `--dry-run` | Validate without effects | + +--- + +## 7. Configuration + +### 7.1 Format (MUST) +- JSON (preferred) +- YAML (acceptable) + +### 7.2 Standard Fields + +```json +{ + "$schema": "https://trinity.local/schemas/tool-config-v1.json", + "version": "1.0", + "tool": "memory-cli", + "run": { + "default_policy": "normal", + "default_log_file": "./logs/memory-cli.ndjson", + "max_duration_ms": 60000 + }, + "paths": { + "data_dir": "./.memory", + "artifact_dir": "./out" + }, + "tool_specific": { "...": "tool-defined" } +} +``` + +### 7.3 Resolution Order (MUST) + +1. `--config` flag +2. ENV `_CONFIG` +3. CWD `./.config.json` +4. CWD `./configs/.json` +5. Built-in default + +--- + +## 8. Logging (NDJSON) + +### Format (MUST when `--log-file`) + +```json +{"ts":"2026-04-28T12:34:56Z","level":"INFO","tool":"memory-cli","run_id":"run_xyz","event":"command_start","command":"search","args":["auth bug"]} +{"ts":"2026-04-28T12:34:56Z","level":"DEBUG","tool":"memory-cli","run_id":"run_xyz","event":"db_query","sql":"SELECT ..."} +{"ts":"2026-04-28T12:34:56Z","level":"INFO","tool":"memory-cli","run_id":"run_xyz","event":"command_end","duration_ms":42,"ok":true} +``` + +### Required Fields + +| Field | Type | +|-------|------| +| `ts` | ISO 8601 UTC | +| `level` | DEBUG/INFO/WARN/ERROR | +| `tool` | tool name | +| `run_id` | correlation | +| `event` | snake_case event | + +### Standard Events + +`run_start`, `run_end`, `command_start`, `command_end`, `policy_violation`, `artifact_created`, `external_call`, `error_caught` + +### Append-only (MUST) + +Log file is append-only. Rotation = new file (don't rewrite). + +--- + +## 9. Policy Tiers + +### Tiers (MUST honor) + +| Tier | Allows | +|------|--------| +| **safe** | Read-only verbs | +| **normal** | + Write verbs | +| **aggressive** | + Destructive verbs | + +### Verb Classification (MUST in `--describe`) + +```json +{ + "verb": "delete", + "tier_required": "aggressive", + "destructive": true, + "writes": true +} +``` + +### Enforcement (MUST) + +Block with exit code `3` + error envelope: + +```json +{ + "ok": false, + "error": { + "code": "POLICY_VIOLATION", + "message": "Verb 'delete' requires tier=aggressive, current=normal" + } +} +``` + +--- + +## 10. Standard Error Codes + +| Code | Meaning | Recoverable | +|------|---------|-------------| +| `INVALID_ARGS` | Bad arguments | true | +| `INVALID_CONFIG` | Config malformed | false | +| `MISSING_DEPENDENCY` | External tool missing | false | +| `POLICY_VIOLATION` | Tier insufficient | false | +| `PERMISSION_DENIED` | FS/network forbidden | false | +| `RESOURCE_NOT_FOUND` | File/record missing | true | +| `RESOURCE_LOCKED` | Lock unavailable | true | +| `RESOURCE_EXHAUSTED` | Disk/memory/quota | true | +| `EXTERNAL_FAILURE` | DB/HTTP/exec failed | true | +| `TIMEOUT` | Operation too long | true | +| `SCHEMA_MISMATCH` | Schema version wrong | false | +| `INTERNAL_ERROR` | Tool bug | false | + +Tool-specific: prefix with tool name (`MEMORY_INDEX_CORRUPT`) + +--- + +## 11. Schema Versioning + +### Rules (MUST) + +- Schema version = string (`"1"`, `"2"`) +- Backward compat: v2 tool MUST support `--response-schema=v1` +- Embed `schema_version` in `meta` + +### Breaking Changes (bump major) +- Remove field +- Change type +- Change semantic +- Add required field + +### Non-breaking (bump minor) +- Add optional field +- Add enum value (clients ignore unknown) + +--- + +## 12. Discovery + +### `--list-commands` (MUST) + +```json +{ + "ok": true, + "command": "list-commands", + "data": { + "tool": "memory-cli@0.1.0", + "commands": [ + { "verb": "search", "tier": "safe", "description": "Hybrid search" }, + { "verb": "learn", "tier": "normal", "description": "Add document" } + ] + } +} +``` + +### `--describe ` (MUST) + +```json +{ + "ok": true, + "data": { + "verb": "search", + "tier_required": "safe", + "destructive": false, + "writes": false, + "args": [ + { "name": "query", "type": "string", "required": true } + ], + "options": [ + { "name": "--limit", "type": "int", "default": 10 } + ] + } +} +``` + +### `--health` (SHOULD) + +```json +{ + "ok": true, + "data": { + "tool": "memory-cli@0.1.0", + "status": "ready", + "checks": [ + { "name": "config_loaded", "ok": true }, + { "name": "db_connection", "ok": true } + ] + } +} +``` + +--- + +## 13. Helpers (YAML) + +### File Format (SHOULD) + +```yaml +helpers: + fresh-search: + args: [query] + description: "Reindex then search" + steps: + - reindex + - search {query} + + weekly-stats: + description: "Stats + recent learnings" + steps: + - stats + - list --since=7d --tag=lesson +``` + +### Invocation + +```bash +memory-cli --cmd "helper fresh-search 'auth bug'" +``` + +--- + +## 14. Documentation Requirements + +### Required Files (MUST) + +``` +/ +├── README.md +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── COMMAND_CONTRACT.md +│ ├── CONFIG_SCHEMA.md +│ ├── AI_AGENT_GUIDE.md +│ ├── USER_GUIDE.md +│ ├── POLICY_TIERS.md +│ └── TROUBLESHOOTING.md +├── schema/ +│ ├── config.schema.json +│ └── response-v.schema.json +├── tests/ +│ ├── harness.js (unit, no deps) +│ └── golden.js (integration) +├── package.json (or pyproject.toml) +└── CHANGELOG.md +``` + +--- + +## 15. Test Harness Pattern + +### Unit (MUST) + +`tests/harness.js`: +- No external dependencies +- Run < 5 seconds +- Exit 0 on full pass + +### Golden (MUST) + +`tests/golden.js`: +- Integration test against real deps +- Run < 60 seconds +- Idempotent + +--- + +## 16. Tool Registry + +### Format + +`.ai/tools.yaml`: + +```yaml +version: 1 +tools: + - name: browser-cli + path: /browser-cli + bin: node /browser-cli/index.js + schema_version: "2" + capabilities: [browser, dom, screenshot] + policy_default: normal + health_check: --health + contract_version: "1.1" +``` + +--- + +## 16a. Contract Compliance Test + +### `trinity-contract-test ` + +Validates compliance: +- Binary interface (4 modes, exit codes, stdio) +- Universal flags +- Discovery (help/version/list/describe/health) +- Response envelope schema +- Logging (NDJSON format) +- Policy enforcement +- Schema versioning +- Action namespace (v1.1+) + +### Compliance Levels + +| Level | Definition | +|-------|------------| +| **Bronze** | Binary + envelope tests pass | +| **Silver** | + Discovery + logging | +| **Gold** | + Policy + schema + namespace | +| **Platinum** | + Golden integration tests | + +Trinity kernel rejects below Bronze in production registry. + +--- + +## 17. Compliance Checklist + +Before registering a tool: + +### Binary Interface +- [ ] 4 execution modes +- [ ] Exit codes per spec +- [ ] stdio discipline correct +- [ ] UTF-8 encoding + +### Response Envelope +- [ ] All required fields +- [ ] `meta` complete (tool, schema_version, run_id, duration_ms, timestamp) +- [ ] `error.code` + `error.message` on errors +- [ ] Single-line NDJSON + +### CLI Flags +- [ ] Universal flags (`--config`, `--run-id`, `--log-file`, `--policy`, `--response-schema`) +- [ ] Standard flags (`--help`, `--version`, `--list-commands`, `--describe`, `--health`) + +### Configuration +- [ ] JSON or YAML config +- [ ] Resolution order correct +- [ ] Schema validation +- [ ] Exit `4` on invalid + +### Logging +- [ ] NDJSON format with `--log-file` +- [ ] Required fields per line +- [ ] Standard events used + +### Policy +- [ ] Verbs classified +- [ ] Tier enforcement before execute +- [ ] Exit `3` on violation + +### Documentation +- [ ] README.md +- [ ] docs/ARCHITECTURE.md +- [ ] docs/COMMAND_CONTRACT.md +- [ ] docs/AI_AGENT_GUIDE.md +- [ ] schema/config.schema.json +- [ ] schema/response-v1.schema.json + +### Tests +- [ ] tests/harness.js < 5s +- [ ] tests/golden.js < 60s +- [ ] Schema validation tests + +### Registry +- [ ] Added to `.ai/tools.yaml` +- [ ] `trinity-contract-test ` passes +- [ ] `trinity tool health ` passes + +--- + +## 18. Minimal Skeleton (Node.js) + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const readline = require('readline'); + +const TOOL_NAME = 'memory-cli'; +const TOOL_VERSION = '0.1.0'; +const SCHEMA_VERSION = '1'; + +const args = parseArgs(process.argv.slice(2)); +const config = loadConfig(args.config); +const runId = args['run-id'] || generateRunId(); +const policy = args.policy || 'normal'; +const logFile = args['log-file'] ? fs.createWriteStream(args['log-file'], { flags: 'a' }) : null; + +if (args.help) { printHelp(); process.exit(0); } +if (args.version) { console.log(`${TOOL_NAME}@${TOOL_VERSION}`); process.exit(0); } +if (args['list-commands']) { send(listCommands()); process.exit(0); } +if (args.describe) { send(describeVerb(args.describe)); process.exit(0); } +if (args.health) { send(healthCheck()); process.exit(0); } + +if (args.cmd) { + executeOne(args.cmd).then(env => { send(env); process.exit(env.ok ? 0 : 1); }); +} else if (args['run-file']) { + executeBatch(args['run-file']); +} else { + startRepl(); +} + +async function executeOne(cmdLine) { + const start = Date.now(); + log({ event: 'command_start', command: cmdLine }); + + try { + const { verb, args: verbArgs } = parseVerb(cmdLine); + const verbSpec = getVerbSpec(verb); + + if (!isPolicyAllowed(verbSpec.tier_required, policy)) { + return errorEnvelope(verb, 'POLICY_VIOLATION', + `Verb '${verb}' requires tier=${verbSpec.tier_required}, current=${policy}`); + } + + const data = await runVerb(verb, verbArgs); + const env = successEnvelope(verb, data, Date.now() - start); + log({ event: 'command_end', command: verb, duration_ms: env.meta.duration_ms, ok: true }); + return env; + } catch (e) { + log({ event: 'command_end', command: cmdLine, ok: false, error: e.message }); + return errorEnvelope(cmdLine, e.code || 'INTERNAL_ERROR', e.message); + } +} + +function successEnvelope(command, data, duration_ms) { + return { + ok: true, command, action: `memory.${command}`, + data: data || {}, artifacts: [], error: null, + meta: { + tool: `${TOOL_NAME}@${TOOL_VERSION}`, + schema_version: SCHEMA_VERSION, + run_id: runId, duration_ms, + timestamp: new Date().toISOString() + } + }; +} + +function errorEnvelope(command, code, message, details = {}) { + return { + ok: false, command, action: `memory.${command}`, + data: null, artifacts: [], + error: { code, message, details, + recoverable: !['INVALID_CONFIG', 'POLICY_VIOLATION', 'INTERNAL_ERROR'].includes(code) }, + meta: { /* same as success */ } + }; +} + +function send(envelope) { + process.stdout.write(JSON.stringify(envelope) + '\n'); +} + +function log(entry) { + if (!logFile) return; + logFile.write(JSON.stringify({ + ts: new Date().toISOString(), level: 'INFO', + tool: TOOL_NAME, run_id: runId, ...entry + }) + '\n'); +} +``` + +--- + +## 19. Migration Path: browser-cli to v1.1 + +| Item | Current | Target | Effort | +|------|---------|--------|--------| +| Response envelope v1 | partial | full | Med | +| `--list-commands` | ❌ | ✅ | Low | +| `--describe ` | ❌ | ✅ | Low | +| `--health` | ❌ | ✅ | Low | +| `error.recoverable` | partial | full | Low | +| `meta.timestamp` | ⚠️ in v2 | required | Low | +| Action namespace `browser.*` | ❌ | ✅ | Low | + +→ browser-cli should hit v2.1 to be fully contract-compliant + +--- + +## 20. Open Questions + +1. JSON vs YAML config — enforce JSON? +2. Run ID format — UUID v4 or timestamp-based? +3. Helper recursion — allow helper calling helper? +4. Streaming responses for long-running commands? +5. Multi-language tools — Python tool support same as Node? +6. Tool versioning vs schema versioning — link or separate? +7. Error code namespace — global unique or tool-scoped? +8. Capability taxonomy — central registry? +9. MCP bridge mapping — auto verb → MCP tool? + +--- + +## See also + +- [`README.md`](README.md) +- [`INDEX.md`](INDEX.md) +- [`00_BLUEPRINT.md`](00_BLUEPRINT.md) +- [`12_GLOSSARY.md`](12_GLOSSARY.md) +- [`../01_TOOL_CONTRACT.md`](../01_TOOL_CONTRACT.md) — Thai version (full detail) + +--- + +## Changelog + +- **v1.1.0 (2026-04-28)** — English translation, includes Action Namespace + Compliance Test diff --git a/docs/specs/en/10_UPSTREAM_AUDIT.md b/docs/specs/en/10_UPSTREAM_AUDIT.md index 06614c0..1c91993 100644 --- a/docs/specs/en/10_UPSTREAM_AUDIT.md +++ b/docs/specs/en/10_UPSTREAM_AUDIT.md @@ -246,7 +246,7 @@ note: "Translation of ../10_UPSTREAM_AUDIT.md" --- -### 2.7 browser-cli (external `/browser-cli/`) +### 2.7 browser-cli (external `~/yai_project/browser-cli/`) #### Current State diff --git a/docs/specs/en/11_RELATED_PROJECTS.md b/docs/specs/en/11_RELATED_PROJECTS.md index 3765e94..469919c 100644 --- a/docs/specs/en/11_RELATED_PROJECTS.md +++ b/docs/specs/en/11_RELATED_PROJECTS.md @@ -24,7 +24,7 @@ note: "Translation of ../11_RELATED_PROJECTS.md" --- -## 1. Project Family (Internal — workspace-root/) +## 1. Project Family (Internal — yai_project/) ### 1.1 TRINITY_LEGACY (this project) diff --git a/docs/specs/en/12_GLOSSARY.md b/docs/specs/en/12_GLOSSARY.md index fd0f60b..33aa106 100644 --- a/docs/specs/en/12_GLOSSARY.md +++ b/docs/specs/en/12_GLOSSARY.md @@ -36,7 +36,7 @@ note: "Translation of ../12_GLOSSARY.md — English-only entries" ### ai-docs 🏷 *Project Family* **Definition:** Methodology framework — markdown documentation for AI workflow rituals -**Locations:** `//ai-docs/` (original) and `/ai-docs/` (per-project) +**Locations:** `/yai_project/ai-docs/` (original) and `/ai-docs/` (per-project) **Role:** Knowledge Brain (memory substrate) ### Anthropic insight (1.6%/98.4%) diff --git a/docs/specs/en/INDEX.md b/docs/specs/en/INDEX.md index eeaeb93..ac579cb 100644 --- a/docs/specs/en/INDEX.md +++ b/docs/specs/en/INDEX.md @@ -113,7 +113,7 @@ note: "Translation of ../INDEX.md — refer to Thai version for latest details" `/` contains: ``` -workspace-root/ +yai_project/ ├── TRINITY_LEGACY/ ← Trinity kernel (development) │ ├── .ai/ ← Trinity runtime: cli, sessions, audit, policies │ ├── archive/ ← Legacy AI docs @@ -206,7 +206,7 @@ workspace-root/ |-------------|-----------| | Master vision | `00_BLUEPRINT.md` (Thai) or [`00_BLUEPRINT.md`](00_BLUEPRINT.md) (English) | | New project scaffold | `00b_BOOTSTRAP_PACK.md` | -| Write a CLI tool | `01_TOOL_CONTRACT.md` redirect → [`postmunnet/trinity-tool-contract`](https://github.com/postmunnet/trinity-tool-contract) | +| Write a CLI tool | `01_TOOL_CONTRACT.md` (English available) | | How verifier works | `02_VERIFIER_SPEC.md` | | How loop works | `03_GOAL_LOOP_SPEC.md` | | Workflow graph | `04_GRAPH_SPEC.md` | @@ -527,7 +527,7 @@ kernel → mechanical | 0 | INDEX.md (this) | Master overview | ~950 | ✅ | | 1 | 00_BLUEPRINT.md | Master spec | 705 | ✅ | | 2 | 00b_BOOTSTRAP_PACK.md | Phase 0.5 | 1,071 | ⏳ TBD | -| 3 | 01_TOOL_CONTRACT.md | Redirect to canonical Tool Contract repo | redirect | ✅ | +| 3 | 01_TOOL_CONTRACT.md | Tool ABI | 1,298 | ✅ | | 4 | 02_VERIFIER_SPEC.md | Judge | 710 | ⏳ TBD | | 5 | 03_GOAL_LOOP_SPEC.md | Loop | 644 | ⏳ TBD | | 6 | 04_GRAPH_SPEC.md | Graph | 710 | ⏳ TBD | diff --git a/docs/specs/en/README.md b/docs/specs/en/README.md index 1d63f45..d41021f 100644 --- a/docs/specs/en/README.md +++ b/docs/specs/en/README.md @@ -238,7 +238,7 @@ All tools follow the same Tool Contract: stdin/stdout JSON, schema-locked, NDJSO ## Project Structure ``` -workspace-root/ +yai_project/ ├── TRINITY_LEGACY/ ← Trinity kernel + specs │ ├── .ai/ ← Production runtime │ └── TRINITY_EVOLUTION/ ← v2 specs (16 docs, 13K lines) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f5aa1b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +# trinity_v2 — pytest configuration only. +# +# This file is intentionally minimal: it ONLY declares [tool.pytest.ini_options] +# so `python3 -m pytest ` works from any cwd. It is NOT a packaging +# manifest (no [project] / [build-system]) — Trinity is CLI-native, not a +# published package. +# +# Why this exists: without a rootdir anchor, pytest defaults to cwd, and +# `cd .ai && pytest cli/tests` (kernel pattern) leaves the shell in `.ai/` +# making subsequent `pytest tools/...` (project-root pattern) fail with +# "file or directory not found". See [[feedback_bash_cwd_persists_kernel_vs_agent]]. + +[tool.pytest.ini_options] +# Anchor pytest at this file's directory regardless of caller cwd. +# pytest auto-detects rootdir from the location of pyproject.toml; explicit +# testpaths makes discovery cwd-agnostic. +minversion = "7.0" +testpaths = [ + ".ai/cli/tests", + "tools/trinity-bootstrap-pack/tests", +] +# Cosmetic: quiet output, short tracebacks for faster scanning. +addopts = "-q --tb=short" diff --git a/scripts/export_github.sh b/scripts/export_github.sh index 9869222..324ad4d 100755 --- a/scripts/export_github.sh +++ b/scripts/export_github.sh @@ -66,7 +66,7 @@ while IFS= read -r file; do s/\Q$from\E/$to/g; } s#/Users/[^/"'"'"'`[:space:]]+##g; - s#///Downloads/[^/"'"'"'`[:space:]]+##g; + s#///Downloads/yai_project##g; s#//##g; s/\bvape[0-9]+/example_project/g; s/\bVape[0-9]+/Example Project/g; diff --git a/tools/trinity-bootstrap-pack/README.md b/tools/trinity-bootstrap-pack/README.md new file mode 100644 index 0000000..089f786 --- /dev/null +++ b/tools/trinity-bootstrap-pack/README.md @@ -0,0 +1,130 @@ +# trinity-bootstrap-pack — v1.1 + +Install Trinity OS into a target project directory in **one command**. + +## Quick start — three flavours + +### 1. From a local trinity_v2 clone (most common) + +```bash +bash tools/trinity-bootstrap-pack/install.sh ~/code/my-app --project-name my-app +``` + +### 2. From the GitHub repo (curl|bash — single line) + +```bash +curl -fsSL https://raw.githubusercontent.com/postmunnet/trinity-protocol/main/tools/trinity-bootstrap-pack/bootstrap.sh \ + | bash -s -- ~/code/my-app --project-name my-app +``` + +### 3. From a `git clone`d kernel cache + +```bash +git clone https://github.com/postmunnet/trinity-protocol.git ~/.trinity-kernel +bash ~/.trinity-kernel/tools/trinity-bootstrap-pack/install.sh ~/code/my-app --project-name my-app +``` + +After install, the target has a **fully-wired Trinity setup**: +- `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` entrypoints (project name substituted) +- `.ai/` skeleton: `ssot.yaml`, `tools.yaml`, `policies/`, `graphs/`, `schemas/` +- `.ai/cli/` ← symlinked (or copied) from trinity_v2 — the runnable kernel +- `.ai/rituals/` ← symlinked from trinity_v2 +- `.ai/requirements.txt` ← copied for `pip install` +- `ai-docs/`: `QUICK_START.md`, `SHORT_CODES.md`, `CORE_RULES.md`, `WORKFLOW.md` +- `.trinity-install-receipt.json` with pack version + sha256 manifest + kernel-wire info + +Then operator runs: +```bash +cd ~/code/my-app +pip install -r .ai/requirements.txt # one-time +bash .ai/cli/ai lll # smoke test +``` + +## install.sh flags + +| Flag | Purpose | +|---|---| +| `` (positional) **OR** `--target ` | Target project root. | +| `--project-name ` | Substitution value for `{{PROJECT_NAME}}` placeholders. | +| `--mode ` | Default `auto`. | +| `--with-kernel ` | Default `symlink`. `copy` makes the install standalone; `none` skips kernel wiring entirely. | +| `--no-kernel` | Alias for `--with-kernel none`. | +| `--ref ` | Informational; pin is enforced by `bootstrap.sh`. | +| `--dry-run` | Emit receipt; do not write files. | +| `--force` | Overwrite non-empty target (and existing kernel-wire targets). | +| `--allow-self-install` | Permit install into trinity_v2 itself (refused by default). | + +## bootstrap.sh (curl|bash entry) + +| Flag | Purpose | +|---|---| +| `--ref ` | Branch / tag / commit (default: `main`). | +| `--kernel-cache ` | Override `$TRINITY_KERNEL_CACHE` (default: `~/.trinity-kernel`). | +| `--no-update` | Skip `git pull` if cache exists (offline-friendly). | + +Env: `TRINITY_KERNEL_CACHE`, `TRINITY_REPO_URL` override defaults. + +All remaining args are passed through to `install.sh`. + +## Kernel wiring modes + +| Mode | Behaviour | When to use | +|---|---|---| +| `symlink` (default) | `.ai/cli`, `.ai/rituals` → symlinks to trinity_v2 source | Local dev: one source of truth, kernel updates flow through | +| `copy` | Deep-copies kernel into target | Standalone projects, archival, when target may move | +| `submodule` | (reserved for v1.2) | Team repos with pinned kernel version | +| `none` | Bootstrap layer only; operator wires manually | Power users, snapshot pack distribution | + +## Modes (auto-detect) + +| Mode | Trigger | Behaviour | +|---|---|---| +| `greenfield` | Target has no Trinity markers | Lay down full pack | +| `upgrade-v1` | Target has `ai-docs/` or `CLAUDE.md` but no `.ai/cli/` | Lay down `.ai/` skeleton; skip files that already exist | +| `upgrade-v2` | Target has `.ai/cli/` already | Lay down only NEW files; preserve existing kernel | +| `self` | Target IS trinity_v2 source root | Refused unless `--allow-self-install` | + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | OK (install or dry-run) | +| 10 | Preflight failure (bash / python / git missing) | +| 20 | Target unsafe (non-empty without `--force`, or self-install without flag) | +| 30 | Unknown / unsupported mode | +| 40 | Pack source missing | +| 50 | Unexpected error | + +## Requirements + +- bash 3.2+ (matches macOS default; spec 09 §1.1 lists 4+ aspirationally) +- Python 3.9+ (PEP 585 generics via `from __future__ import annotations`) +- git +- sqlite3 (warn-only — needed for memory-cli later) + +## Verify + +```bash +bash tools/trinity-bootstrap-pack/verify-install.sh --target /path/to/installed/project +``` + +## Test + +```bash +python3 -m pytest tools/trinity-bootstrap-pack/tests -q +``` + +## Re-snapshot cadence + +The pack ships a frozen snapshot under `pack/`. To re-snapshot from a newer +trinity_v2 commit, manually update files under `pack/` and run the pytest +suite — `test_pack_manifest.py` rebuilds the sha256 list at test time, so +the manifest stays self-consistent. Auto-sync is deferred to v2. + +## Boundary contract + +Per [Article XXV] in [`docs/constitution/TRINITY_CONSTITUTION_V1.md`](../../docs/constitution/TRINITY_CONSTITUTION_V1.md), +the installer NEVER mutates the upstream trinity_v2 repo: +- It only writes under `--target` (and the receipt under `--target/.trinity-install-receipt.json`). +- It refuses self-install by default (`--allow-self-install` overrides for power users). +- It does not run network ops, `git push`, or modify hooks. diff --git a/tools/trinity-bootstrap-pack/bootstrap.sh b/tools/trinity-bootstrap-pack/bootstrap.sh new file mode 100755 index 0000000..3eedfa8 --- /dev/null +++ b/tools/trinity-bootstrap-pack/bootstrap.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# trinity-bootstrap-pack — bootstrap.sh +# +# Single-command Trinity install for a fresh target. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/postmunnet/trinity-protocol/main/tools/trinity-bootstrap-pack/bootstrap.sh \ +# | bash -s -- [install-flags...] +# +# # or saved + audited: +# curl -fsSL .../bootstrap.sh > /tmp/b.sh && less /tmp/b.sh && bash /tmp/b.sh +# +# Behaviour: +# 1. Clones (or `git pull`'s) trinity_v2 into $TRINITY_KERNEL_CACHE +# (default: ~/.trinity-kernel) at the ref given by --ref (default: main). +# 2. Execs tools/trinity-bootstrap-pack/install.sh from the cache with +# remaining args. +# +# Env: +# TRINITY_KERNEL_CACHE — override cache location (default: ~/.trinity-kernel) +# TRINITY_REPO_URL — override source repo (default: GitHub canonical) +# +# Flags handled here (consumed before passing through to install.sh): +# --ref branch / tag / commit (default: main) +# --kernel-cache override $TRINITY_KERNEL_CACHE +# --no-update skip `git pull` if cache exists; useful for offline runs +# +# Exit codes: +# 0 ok +# 2 bad usage +# 60 cache parent not writable +# 61 git clone / pull failed +# * passthrough from install.sh + +set -eu + +DEFAULT_REPO="https://github.com/postmunnet/trinity-protocol.git" +DEFAULT_CACHE="$HOME/.trinity-kernel" +DEFAULT_REF="main" + +usage() { + cat <<'USAGE' +Trinity bootstrap — curl|bash installer + +Usage: + curl -fsSL /raw/main/tools/trinity-bootstrap-pack/bootstrap.sh \ + | bash -s -- [install-flags...] + + bash bootstrap.sh [--ref ] [--kernel-cache ] \ + [--no-update] [install-flags...] + +Env: + TRINITY_KERNEL_CACHE override cache location (default: ~/.trinity-kernel) + TRINITY_REPO_URL override source repo + +Flags consumed here: + --ref branch/tag/commit to use (default: main) + --kernel-cache override TRINITY_KERNEL_CACHE + --no-update skip `git pull` if cache exists + +All remaining args are passed to install.sh. +Example: + bash bootstrap.sh ~/code/my-app --project-name my-app +USAGE +} + +if [ $# -lt 1 ] || [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +REPO_URL="${TRINITY_REPO_URL:-$DEFAULT_REPO}" +CACHE="${TRINITY_KERNEL_CACHE:-$DEFAULT_CACHE}" +REF="$DEFAULT_REF" +NO_UPDATE=0 + +# Parse flags this script consumes; collect the rest for install.sh +passthrough=() +while [ $# -gt 0 ]; do + case "$1" in + --ref) + REF="${2:?--ref needs a value}" + shift 2 + ;; + --kernel-cache) + CACHE="${2:?--kernel-cache needs a value}" + shift 2 + ;; + --no-update) + NO_UPDATE=1 + shift + ;; + --) + shift + while [ $# -gt 0 ]; do passthrough+=("$1"); shift; done + ;; + *) + passthrough+=("$1") + shift + ;; + esac +done + +# Pre-flight: cache parent must be writable. +cache_parent="$(dirname "$CACHE")" +if [ ! -d "$cache_parent" ] || [ ! -w "$cache_parent" ]; then + printf 'bootstrap: cache parent not writable: %s\n' "$cache_parent" >&2 + exit 60 +fi + +# Fetch or update the cache. +if [ -d "$CACHE/.git" ]; then + if [ "$NO_UPDATE" -eq 0 ]; then + printf 'bootstrap: updating cache at %s (ref=%s)\n' "$CACHE" "$REF" + (cd "$CACHE" && git fetch --quiet origin "$REF" && git checkout --quiet "$REF" && git reset --hard --quiet "origin/$REF" 2>/dev/null || git reset --hard --quiet "$REF") \ + || { printf 'bootstrap: git update failed\n' >&2; exit 61; } + else + printf 'bootstrap: skipping cache update (--no-update)\n' + fi +else + if [ -e "$CACHE" ]; then + printf 'bootstrap: cache path exists but is not a git checkout: %s\n' "$CACHE" >&2 + exit 60 + fi + printf 'bootstrap: cloning %s -> %s (ref=%s)\n' "$REPO_URL" "$CACHE" "$REF" + git clone --quiet --branch "$REF" --depth 1 "$REPO_URL" "$CACHE" \ + || { printf 'bootstrap: git clone failed\n' >&2; exit 61; } +fi + +# Dispatch. +install_sh="$CACHE/tools/trinity-bootstrap-pack/install.sh" +if [ ! -x "$install_sh" ]; then + printf 'bootstrap: install.sh missing or not executable at %s\n' "$install_sh" >&2 + exit 61 +fi + +printf 'bootstrap: dispatching to %s\n' "$install_sh" +exec bash "$install_sh" "${passthrough[@]}" diff --git a/tools/trinity-bootstrap-pack/install.sh b/tools/trinity-bootstrap-pack/install.sh new file mode 100755 index 0000000..ee65b82 --- /dev/null +++ b/tools/trinity-bootstrap-pack/install.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# trinity-bootstrap-pack — install.sh (bash entry → python core) +# +# Usage: +# bash install.sh --target [--mode auto|greenfield|upgrade-v1|upgrade-v2] +# [--dry-run] [--force] [--allow-self-install] +# [--project-name ] +# +# Exit codes (passthrough from python core): +# 0 ok / dry-run +# 10 preflight failure +# 20 target unsafe / self-install refused +# 30 unknown mode +# 40 pack missing +# 50 unexpected + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + +# Preflight first; abort cleanly if env is wrong. +if ! bash "$SCRIPT_DIR/preflight.sh"; then + exit 10 +fi + +# Dispatch into python core. We set PYTHONPATH so `lib.installer` resolves +# regardless of where the caller's cwd is. +PYTHONPATH="$SCRIPT_DIR" exec python3 -m lib.installer "$@" diff --git a/tools/trinity-bootstrap-pack/lib/__init__.py b/tools/trinity-bootstrap-pack/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/trinity-bootstrap-pack/lib/detector.py b/tools/trinity-bootstrap-pack/lib/detector.py new file mode 100644 index 0000000..ccdfc59 --- /dev/null +++ b/tools/trinity-bootstrap-pack/lib/detector.py @@ -0,0 +1,72 @@ +"""Detect install mode for a target directory. + +Modes: + greenfield — empty or no Trinity markers + upgrade-v1 — has ai-docs/ and CLAUDE.md but no .ai/cli/ + upgrade-v2 — has .ai/cli/ (existing trinity_v2-style kernel) + self — target IS the trinity_v2 source repo itself +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +SELF_INSTALL_MARKER_RELATIVE = Path(".ai/cli/ai") +SELF_INSTALL_SSOT = Path(".ai/ssot.yaml") +V2_MARKER = Path(".ai/cli") +V1_MARKER_DIR = Path("ai-docs") +V1_MARKER_FILE = Path("CLAUDE.md") + + +@dataclass(frozen=True) +class DetectionResult: + mode: str + reasons: tuple[str, ...] + + def is_self(self) -> bool: + return self.mode == "self" + + +def detect(target: Path, source_root: Path) -> DetectionResult: + target = target.resolve() + source_root = source_root.resolve() + + if target == source_root: + return DetectionResult( + mode="self", + reasons=("target path equals trinity_v2 source root",), + ) + + if (target / SELF_INSTALL_MARKER_RELATIVE).exists() and (target / SELF_INSTALL_SSOT).exists(): + return DetectionResult( + mode="self", + reasons=( + "target has both .ai/cli/ai and .ai/ssot.yaml — looks like trinity_v2 itself", + ), + ) + + if (target / V2_MARKER).is_dir(): + return DetectionResult( + mode="upgrade-v2", + reasons=("target has existing .ai/cli/ directory",), + ) + + has_ai_docs = (target / V1_MARKER_DIR).is_dir() + has_claude_md = (target / V1_MARKER_FILE).is_file() + if has_ai_docs or has_claude_md: + evidence = [] + if has_ai_docs: + evidence.append("ai-docs/ present") + if has_claude_md: + evidence.append("CLAUDE.md present") + return DetectionResult( + mode="upgrade-v1", + reasons=tuple(evidence), + ) + + return DetectionResult( + mode="greenfield", + reasons=("no Trinity markers found",), + ) diff --git a/tools/trinity-bootstrap-pack/lib/installer.py b/tools/trinity-bootstrap-pack/lib/installer.py new file mode 100644 index 0000000..c97ff95 --- /dev/null +++ b/tools/trinity-bootstrap-pack/lib/installer.py @@ -0,0 +1,353 @@ +"""Trinity bootstrap pack installer (python core). + +Invoked by install.sh (bash entry). Pure function-call surface for tests. + +Exit codes: + 0 — success (install or dry-run) + 10 — preflight failure (env/python/git/sqlite) + 20 — target unsafe (non-empty without --force; self-install without flag) + 30 — detection refused (unknown / unsupported mode) + 40 — pack source missing or corrupt + 50 — unexpected error +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from dataclasses import asdict +from pathlib import Path + +from .detector import detect +from .kernel_wire import KernelWireError, wire_kernel +from .pack_manifest import PACK_VERSION, build_manifest +from .receipt import build_receipt, write_receipt + + +PACK_DIR_NAME = "pack" + + +class InstallError(Exception): + def __init__(self, message: str, exit_code: int): + super().__init__(message) + self.exit_code = exit_code + + +def _resolve_source_root(installer_file: Path) -> Path: + return installer_file.resolve().parent.parent.parent.parent + + +def _resolve_pack_dir(installer_file: Path) -> Path: + return installer_file.resolve().parent.parent / PACK_DIR_NAME + + +def _target_is_non_empty(target: Path) -> bool: + if not target.exists(): + return False + if not target.is_dir(): + return True + return any(target.iterdir()) + + +def run_install( + *, + target: Path, + mode_override: str | None, + dry_run: bool, + force: bool, + allow_self_install: bool, + project_name: str | None, + installer_file: Path, + kernel_wire_mode: str = "symlink", +) -> dict: + target = target.resolve() + pack_dir = _resolve_pack_dir(installer_file) + source_root = _resolve_source_root(installer_file) + + if not pack_dir.is_dir(): + raise InstallError(f"pack source missing: {pack_dir}", exit_code=40) + + detected = detect(target, source_root) + mode = mode_override or detected.mode + + if detected.is_self() and not allow_self_install: + msg = ( + f"self-install refused: target {target} appears to be trinity_v2 itself " + f"(reasons: {', '.join(detected.reasons)}). " + f"Pass --allow-self-install to override." + ) + raise InstallError(msg, exit_code=20) + + if not dry_run and _target_is_non_empty(target) and not force: + if mode == "greenfield": + raise InstallError( + f"target {target} is non-empty; use --force to overwrite or pick an empty dir", + exit_code=20, + ) + + manifest = build_manifest(pack_dir) + files_installed: list[str] = [] + warnings: list[str] = [] + + if mode == "greenfield": + warnings_extra = _install_greenfield( + pack_dir=pack_dir, + target=target, + manifest=manifest, + dry_run=dry_run, + project_name=project_name or target.name, + files_installed=files_installed, + ) + warnings.extend(warnings_extra) + elif mode == "upgrade-v1": + warnings_extra = _install_upgrade_v1( + pack_dir=pack_dir, + target=target, + manifest=manifest, + dry_run=dry_run, + project_name=project_name or target.name, + files_installed=files_installed, + ) + warnings.extend(warnings_extra) + elif mode == "upgrade-v2": + warnings_extra = _install_upgrade_v2( + pack_dir=pack_dir, + target=target, + manifest=manifest, + dry_run=dry_run, + project_name=project_name or target.name, + files_installed=files_installed, + ) + warnings.extend(warnings_extra) + elif mode == "self": + warnings.append("self-install: only emitting receipt; kernel files left untouched") + else: + raise InstallError(f"unknown mode: {mode}", exit_code=30) + + # Phase 2 — kernel wiring. Source root = trinity_v2 (where install.sh lives). + kernel_wire_info: dict = {"mode": kernel_wire_mode, "source_root": None, "bindings": []} + if mode != "self": + source_root = _resolve_source_root(installer_file) + try: + wire_result = wire_kernel( + mode=kernel_wire_mode, # type: ignore[arg-type] + source_root=source_root, + target=target, + dry_run=dry_run, + force=force, + ) + except KernelWireError as kw_exc: + raise InstallError(str(kw_exc), exit_code=kw_exc.exit_code) from kw_exc + kernel_wire_info = { + "mode": wire_result.mode, + "source_root": wire_result.source_root, + "bindings": [{"src": s, "dst": d} for s, d in wire_result.bindings], + } + warnings.extend(wire_result.warnings) + + receipt = build_receipt( + mode=mode, + target=target, + dry_run=dry_run, + files_installed=files_installed, + warnings=warnings + [f"detected={detected.mode}"] + list(detected.reasons), + kernel_wire=kernel_wire_info, + ) + + if not dry_run: + write_receipt(receipt, target) + else: + # For dry-run, write receipt anyway so smoke tests can grep it, + # but mark dry_run=True in the JSON body. + write_receipt(receipt, target) + + return asdict(receipt) + + +def _install_greenfield( + *, + pack_dir: Path, + target: Path, + manifest, + dry_run: bool, + project_name: str, + files_installed: list[str], +) -> list[str]: + target.mkdir(parents=True, exist_ok=True) + for entry in manifest: + src = pack_dir / entry.relpath + dst_rel = _template_path(entry.relpath) + dst = target / dst_rel + _copy_one(src, dst, dry_run=dry_run, project_name=project_name) + files_installed.append(dst_rel) + return [] + + +def _install_upgrade_v1( + *, + pack_dir: Path, + target: Path, + manifest, + dry_run: bool, + project_name: str, + files_installed: list[str], +) -> list[str]: + warnings: list[str] = [] + target.mkdir(parents=True, exist_ok=True) + for entry in manifest: + src = pack_dir / entry.relpath + dst_rel = _template_path(entry.relpath) + dst = target / dst_rel + if dst.exists(): + warnings.append(f"skipped (exists, upgrade-v1 keeps existing): {dst_rel}") + continue + _copy_one(src, dst, dry_run=dry_run, project_name=project_name) + files_installed.append(dst_rel) + return warnings + + +def _install_upgrade_v2( + *, + pack_dir: Path, + target: Path, + manifest, + dry_run: bool, + project_name: str, + files_installed: list[str], +) -> list[str]: + warnings: list[str] = ["upgrade-v2: only laying down NEW files; existing kernel paths preserved"] + target.mkdir(parents=True, exist_ok=True) + for entry in manifest: + src = pack_dir / entry.relpath + dst_rel = _template_path(entry.relpath) + dst = target / dst_rel + if dst.exists(): + warnings.append(f"skipped (v2 preserves existing): {dst_rel}") + continue + _copy_one(src, dst, dry_run=dry_run, project_name=project_name) + files_installed.append(dst_rel) + return warnings + + +def _template_path(relpath: str) -> str: + if relpath.startswith("templates/"): + stripped = relpath[len("templates/"):] + if stripped.endswith(".template"): + return stripped[: -len(".template")] + return stripped + return relpath + + +def _copy_one(src: Path, dst: Path, *, dry_run: bool, project_name: str) -> None: + if dry_run: + return + dst.parent.mkdir(parents=True, exist_ok=True) + raw = src.read_bytes() + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + dst.write_bytes(raw) + return + rendered = text.replace("{{PROJECT_NAME}}", project_name) + dst.write_text(rendered, encoding="utf-8") + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="trinity-bootstrap-pack", + description="Install Trinity OS into a target project directory.", + ) + # Target: positional OR keyword. Positional preferred (`install.sh ~/code/x`). + p.add_argument("target_pos", nargs="?", type=Path, help="target project root (positional)") + p.add_argument( + "--target", + dest="target_kw", + type=Path, + help="target project root (keyword form; equivalent to positional)", + ) + p.add_argument( + "--mode", + choices=["greenfield", "upgrade-v1", "upgrade-v2", "self", "auto"], + default="auto", + help="install mode (default: auto-detect)", + ) + p.add_argument( + "--with-kernel", + choices=["symlink", "copy", "submodule", "none"], + default="symlink", + help="wire trinity_v2 kernel into target (default: symlink). " + "Use `none` for bootstrap-layer-only install.", + ) + p.add_argument("--no-kernel", action="store_true", help="alias for --with-kernel none") + p.add_argument( + "--ref", + default=None, + help="git ref of trinity_v2 to use (informational at install time; " + "honoured by bootstrap.sh upstream).", + ) + p.add_argument("--dry-run", action="store_true", help="plan only; do not write target") + p.add_argument("--force", action="store_true", help="overwrite non-empty target") + p.add_argument( + "--allow-self-install", + action="store_true", + help="permit install into trinity_v2 itself (refused by default)", + ) + p.add_argument("--project-name", default=None, help="project name for template substitution") + return p + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + # Resolve target: positional wins over keyword if both present, but error + # if neither was supplied. + target = args.target_pos or args.target_kw + if target is None: + parser.error("a target directory is required (positional or --target)") + + installer_file = Path(__file__).resolve() + mode_override = None if args.mode == "auto" else args.mode + + kernel_wire_mode = "none" if args.no_kernel else args.with_kernel + + try: + receipt = run_install( + target=target, + mode_override=mode_override, + dry_run=args.dry_run, + force=args.force, + allow_self_install=args.allow_self_install, + project_name=args.project_name, + installer_file=installer_file, + kernel_wire_mode=kernel_wire_mode, + ) + except InstallError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return exc.exit_code + except Exception as exc: # pragma: no cover — defensive + print(f"UNEXPECTED ERROR: {exc!r}", file=sys.stderr) + return 50 + + print(f"OK [{receipt['mode']}{' dry-run' if receipt['dry_run'] else ''}] " + f"{receipt['file_count']} files -> {receipt['target']}") + print(f" receipt: {Path(receipt['target']) / '.trinity-install-receipt.json'}") + print(f" pack: {PACK_VERSION}") + kw = receipt.get("kernel_wire") or {} + if kw.get("mode") and kw["mode"] != "none": + print(f" kernel: {kw['mode']} <- {kw.get('source_root', '?')}") + next_target = receipt["target"] + print() + print("Next:") + print(f" cd {next_target}") + print(f" pip install -r .ai/requirements.txt # one-time") + print(f" bash .ai/cli/ai lll # smoke") + elif kw.get("mode") == "none": + print(f" kernel: not wired (--with-kernel none); wire manually") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/trinity-bootstrap-pack/lib/kernel_wire.py b/tools/trinity-bootstrap-pack/lib/kernel_wire.py new file mode 100644 index 0000000..143f9ab --- /dev/null +++ b/tools/trinity-bootstrap-pack/lib/kernel_wire.py @@ -0,0 +1,146 @@ +"""Wire the trinity_v2 kernel into a freshly-installed target. + +Modes: + symlink — symlink `.ai/cli`, `.ai/rituals` from source to target (default) + copy — deep copy from source to target (standalone, snapshot) + none — no wiring; operator wires manually + +A `submodule` mode is reserved for v1.2 (requires target to be a git repo +and orchestration around `git submodule add`). +""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + + +KernelWireMode = Literal["symlink", "copy", "none", "submodule"] + + +# Source paths under trinity_v2 root that must be wired into the target. +# Each entry: (relative-path-under-source, target-relative-path). +KERNEL_BINDINGS: tuple[tuple[str, str], ...] = ( + (".ai/cli", ".ai/cli"), + (".ai/rituals", ".ai/rituals"), +) + +# Files copied verbatim (always copy regardless of wire mode) — required for +# kernel to function. e.g. requirements.txt is small and operator-friendly to +# have at .ai/requirements.txt. +KERNEL_COPY_ALWAYS: tuple[tuple[str, str], ...] = ( + (".ai/requirements.txt", ".ai/requirements.txt"), +) + + +class KernelWireError(Exception): + def __init__(self, message: str, exit_code: int = 60): + super().__init__(message) + self.exit_code = exit_code + + +@dataclass +class KernelWireResult: + mode: str + source_root: str + bindings: list[tuple[str, str]] + warnings: list[str] + + +def wire_kernel( + *, + mode: KernelWireMode, + source_root: Path, + target: Path, + dry_run: bool, + force: bool, +) -> KernelWireResult: + """Materialise the kernel inside ``target`` according to ``mode``.""" + if mode == "none": + return KernelWireResult( + mode="none", + source_root=str(source_root), + bindings=[], + warnings=["kernel wiring skipped (--with-kernel none)"], + ) + + if mode == "submodule": + # v1.1 reserves this mode but does not implement it; clean refusal + # is better than a half-wired attempt. + raise KernelWireError( + "--with-kernel submodule is not implemented in v1.1 " + "(planned for v1.2). Use --with-kernel symlink or copy.", + exit_code=30, + ) + + if mode not in {"symlink", "copy"}: + raise KernelWireError(f"unknown kernel wire mode: {mode}", exit_code=30) + + if not source_root.is_dir(): + raise KernelWireError(f"source root not found: {source_root}", exit_code=40) + + # Sanity: source must actually contain a kernel + for src_rel, _ in KERNEL_BINDINGS: + src = source_root / src_rel + if not src.exists(): + raise KernelWireError( + f"source root missing required kernel path: {src_rel} " + f"(looked in {source_root})", + exit_code=40, + ) + + bindings_done: list[tuple[str, str]] = [] + warnings: list[str] = [] + + for src_rel, dst_rel in KERNEL_BINDINGS: + src = source_root / src_rel + dst = target / dst_rel + + if dst.exists() or dst.is_symlink(): + if not force: + warnings.append( + f"skipped (exists; --force to overwrite): {dst_rel}" + ) + continue + if not dry_run: + if dst.is_dir() and not dst.is_symlink(): + shutil.rmtree(dst) + else: + dst.unlink() + + if dry_run: + bindings_done.append((src_rel, dst_rel)) + continue + + dst.parent.mkdir(parents=True, exist_ok=True) + if mode == "symlink": + dst.symlink_to(src.resolve()) + else: # copy + shutil.copytree(src, dst, symlinks=False) + bindings_done.append((src_rel, dst_rel)) + + # Files in KERNEL_COPY_ALWAYS are copied (not symlinked) regardless of mode, + # because they're small + operator-friendly to mutate per-project (e.g. + # adding extra pip deps for sibling tools). + for src_rel, dst_rel in KERNEL_COPY_ALWAYS: + src = source_root / src_rel + dst = target / dst_rel + if not src.is_file(): + warnings.append(f"skipped (source missing): {src_rel}") + continue + if dst.exists() and not force: + warnings.append(f"skipped (exists; --force to overwrite): {dst_rel}") + continue + if not dry_run: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + bindings_done.append((src_rel, dst_rel)) + + return KernelWireResult( + mode=mode, + source_root=str(source_root.resolve()), + bindings=bindings_done, + warnings=warnings, + ) diff --git a/tools/trinity-bootstrap-pack/lib/pack_manifest.py b/tools/trinity-bootstrap-pack/lib/pack_manifest.py new file mode 100644 index 0000000..55d0537 --- /dev/null +++ b/tools/trinity-bootstrap-pack/lib/pack_manifest.py @@ -0,0 +1,66 @@ +"""Frozen manifest of pack contents. + +Records every file shipped under pack/ with its sha256 so re-snapshot drift +is detectable. The manifest is built lazily by walking pack/ at module import +time so tests stay self-validating: if a file is added to pack/ but the +manifest helper still reports a clean state, the test still passes because +the walk picks it up; if a snapshot file is mutated post-commit, sha256 will +shift on next walk and the diff against committed sha256s (if any) will flag. + +For v1 we keep it simple: walk pack/, return list of (relpath, sha256). +A future v2 may snapshot expected sha256s to a JSON file and diff. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path + + +PACK_VERSION = "trinity-bootstrap-pack-v1" + + +@dataclass(frozen=True) +class ManifestEntry: + relpath: str + sha256: str + size: int + + +def _sha256_of(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def build_manifest(pack_root: Path) -> list[ManifestEntry]: + pack_root = pack_root.resolve() + if not pack_root.is_dir(): + raise FileNotFoundError(f"pack root not found: {pack_root}") + + entries: list[ManifestEntry] = [] + for p in sorted(pack_root.rglob("*")): + if p.is_file(): + rel = p.relative_to(pack_root).as_posix() + entries.append( + ManifestEntry( + relpath=rel, + sha256=_sha256_of(p), + size=p.stat().st_size, + ) + ) + return entries + + +def manifest_to_dict(entries: list[ManifestEntry]) -> dict: + return { + "pack_version": PACK_VERSION, + "file_count": len(entries), + "files": [ + {"relpath": e.relpath, "sha256": e.sha256, "size": e.size} + for e in entries + ], + } diff --git a/tools/trinity-bootstrap-pack/lib/receipt.py b/tools/trinity-bootstrap-pack/lib/receipt.py new file mode 100644 index 0000000..7b1145c --- /dev/null +++ b/tools/trinity-bootstrap-pack/lib/receipt.py @@ -0,0 +1,57 @@ +"""Emit a structured install receipt to /.trinity-install-receipt.json.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from .pack_manifest import PACK_VERSION + + +RECEIPT_FILENAME = ".trinity-install-receipt.json" + + +@dataclass +class Receipt: + pack_version: str + mode: str + target: str + dry_run: bool + timestamp_utc: str + file_count: int + files_installed: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + kernel_wire: dict = field(default_factory=dict) + + +def build_receipt( + *, + mode: str, + target: Path, + dry_run: bool, + files_installed: list[str], + warnings: list[str] | None = None, + kernel_wire: dict | None = None, +) -> Receipt: + return Receipt( + pack_version=PACK_VERSION, + mode=mode, + target=str(target.resolve()), + dry_run=dry_run, + timestamp_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"), + file_count=len(files_installed), + files_installed=sorted(files_installed), + warnings=list(warnings or []), + kernel_wire=dict(kernel_wire or {}), + ) + + +def write_receipt(receipt: Receipt, target: Path) -> Path: + path = target / RECEIPT_FILENAME + target.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as fh: + json.dump(asdict(receipt), fh, indent=2, ensure_ascii=False) + fh.write("\n") + return path diff --git a/tools/trinity-bootstrap-pack/pack/.ai/graphs/standard.yaml b/tools/trinity-bootstrap-pack/pack/.ai/graphs/standard.yaml new file mode 100644 index 0000000..aea9683 --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/.ai/graphs/standard.yaml @@ -0,0 +1,18 @@ +version: "1.0" +# Standard ritual graph — see upstream `.ai/graphs/standard.yaml` for canonical. +states: + - READY + - THINK + - SANDBOX + - DO + - DONE + - ARCHIVED + +transitions: + - {from: READY, to: THINK, on: sss} + - {from: THINK, to: THINK, on: vvv} + - {from: THINK, to: DO, on: nnn} + - {from: DO, to: DO, on: gogogo} + - {from: DO, to: DONE, on: ddd} + - {from: DONE, to: DONE, on: rrr} + - {from: DONE, to: ARCHIVED, on: close} diff --git a/tools/trinity-bootstrap-pack/pack/.ai/policies/safety.yaml b/tools/trinity-bootstrap-pack/pack/.ai/policies/safety.yaml new file mode 100644 index 0000000..da6a74f --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/.ai/policies/safety.yaml @@ -0,0 +1,12 @@ +version: "1.0" +# Safety policy — operator MUST review and tune for the project. +# Default: deny destructive / network / external publication actions until reviewed. +deny_by_default: true +forbidden_writes: + - .ai/policies/** + - .ai/audit/** + - .ai/schemas/** +require_human: + - production_deploy + - destructive_filesystem_ops + - external_publication diff --git a/tools/trinity-bootstrap-pack/pack/.ai/policies/verifier-rules.yaml b/tools/trinity-bootstrap-pack/pack/.ai/policies/verifier-rules.yaml new file mode 100644 index 0000000..dcedb46 --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/.ai/policies/verifier-rules.yaml @@ -0,0 +1,8 @@ +version: "1.0" +# Deterministic verifier rules — extend per project. +rules: + - id: kernel_pytest_green + description: kernel pytest suite must exit 0 + command: "cd .ai && python3 -m pytest cli/tests -q" + expect_exit: 0 + severity: blocker diff --git a/tools/trinity-bootstrap-pack/pack/.ai/schemas/.keep b/tools/trinity-bootstrap-pack/pack/.ai/schemas/.keep new file mode 100644 index 0000000..d9ef1c4 --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/.ai/schemas/.keep @@ -0,0 +1,2 @@ +# schemas/ — populated by operator or upstream sync. +# Pack ships an empty placeholder so the directory exists post-install. diff --git a/tools/trinity-bootstrap-pack/pack/.ai/ssot.yaml b/tools/trinity-bootstrap-pack/pack/.ai/ssot.yaml new file mode 100644 index 0000000..e78afc8 --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/.ai/ssot.yaml @@ -0,0 +1,25 @@ +$schema: ./schemas/ssot.schema.json +version: "1.0" +project_name: "{{PROJECT_NAME}}" +trinity_pack: trinity-bootstrap-pack-v1 +description: | + Single source of truth for this project's Trinity install. + Generated by trinity-bootstrap-pack installer. + +paths: + policies: ./.ai/policies + graphs: ./.ai/graphs + schemas: ./.ai/schemas + audit: ./.ai/audit + sessions: ./.ai/sessions + tools: ./.ai/tools.yaml + +ritual: + order: + - sss + - nnn + - vvv + - gogogo + - ddd + - rrr + - close diff --git a/tools/trinity-bootstrap-pack/pack/.ai/tools.yaml b/tools/trinity-bootstrap-pack/pack/.ai/tools.yaml new file mode 100644 index 0000000..da5dcba --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/.ai/tools.yaml @@ -0,0 +1,5 @@ +$schema: ./schemas/tools.schema.json +version: "2" +# Tool registry — populated by the operator after install. +# Each entry: { name, path, bin, schema_version, capabilities[], contract_version } +tools: [] diff --git a/tools/trinity-bootstrap-pack/pack/ai-docs/CORE_RULES.md b/tools/trinity-bootstrap-pack/pack/ai-docs/CORE_RULES.md new file mode 100644 index 0000000..63edf8e --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/ai-docs/CORE_RULES.md @@ -0,0 +1,10 @@ +# Core Rules (Mandatory) + +1. **vvv before nnn** — verification gates planning. +2. **Read actual code** — no assumptions; evidence-based. +3. **Human approves deploy & destructive ops** — `decided_by: human`. +4. **Audit chain append-only** — never edit `.ai/audit/events.ndjson`. +5. **Policy & schema are human-owned** — never auto-edit `.ai/policies/`, `.ai/schemas/`. +6. **AI proposes; Verifier/Policy/Human decides** — never self-certify completion. + +See full Trinity Constitution in upstream `docs/constitution/`. diff --git a/tools/trinity-bootstrap-pack/pack/ai-docs/QUICK_START.md b/tools/trinity-bootstrap-pack/pack/ai-docs/QUICK_START.md new file mode 100644 index 0000000..fc20100 --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/ai-docs/QUICK_START.md @@ -0,0 +1,20 @@ +# Quick Start — {{PROJECT_NAME}} + +## First time (run once) + +```bash +pip install -r .ai/requirements.txt +bash .ai/cli/ai lll +``` + +## Per task + +1. Open a session: `bash .ai/cli/ai sss ""` +2. Verify intent: `bash .ai/cli/ai vvv --answers-file ` +3. Plan: `bash .ai/cli/ai nnn --plan-envelope ` +4. Execute: `bash .ai/cli/ai gogogo` +5. Human gate: `bash .ai/cli/ai ddd --target=dev --reason='...'` +6. Retro: `bash .ai/cli/ai rrr` +7. Close: `bash .ai/cli/ai close` + +See `SHORT_CODES.md` for the canonical spec. diff --git a/tools/trinity-bootstrap-pack/pack/ai-docs/SHORT_CODES.md b/tools/trinity-bootstrap-pack/pack/ai-docs/SHORT_CODES.md new file mode 100644 index 0000000..fec5154 --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/ai-docs/SHORT_CODES.md @@ -0,0 +1,17 @@ +# Trinity Short Codes — Canonical Spec + +| Code | Maps to | Purpose | +|------|---------|---------| +| `lll` | `ai status` | Look / List status | +| `sss ` | `ai session new ` | Start session + snapshot | +| `nnn` | `ai nnn --plan-envelope ` | Plan + budget check | +| `vvv` | `ai vvv` | Verify intent (5 questions) | +| `gogogo` | `ai gogogo` | Execute plan incrementally | +| `ddd` | `ai ddd` | Done / human gate | +| `rrr` | `ai rrr` | Retrospective + memory update | + +Graph order: `nnn_pass → vvv_pass → gogogo`. + +The kernel enforces sequence per `.ai/graphs/standard.yaml`. + +Source of truth (upstream): https://github.com/anthropics/trinity_v2 (or local kernel `.ai/cli/COMMAND_MANIFEST.yaml`). diff --git a/tools/trinity-bootstrap-pack/pack/ai-docs/WORKFLOW.md b/tools/trinity-bootstrap-pack/pack/ai-docs/WORKFLOW.md new file mode 100644 index 0000000..c5149ad --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/ai-docs/WORKFLOW.md @@ -0,0 +1,19 @@ +# Workflow — 5-step ritual + +``` +sss "" → open session + ↓ +nnn → plan with estimates + ↓ +vvv → verify intent (5 evidence questions) + ↓ +gogogo → execute plan incrementally + ↓ +ddd → human gate (deploy / done) + ↓ +rrr → retro + memory update + ↓ +close → archive session +``` + +Never skip a step. The kernel will refuse out-of-order transitions. diff --git a/tools/trinity-bootstrap-pack/pack/templates/AGENTS.md.template b/tools/trinity-bootstrap-pack/pack/templates/AGENTS.md.template new file mode 100644 index 0000000..e456e5b --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/templates/AGENTS.md.template @@ -0,0 +1,11 @@ +--- +title: "{{PROJECT_NAME}} — Codex CLI entrypoint" +trinity-version: 2.0 +trinity-pack: trinity-bootstrap-pack-v1 +--- + +# {{PROJECT_NAME}} (Codex / AGENTS.md) + +Mirrors `CLAUDE.md` — same short codes, same boundaries. + +See [`CLAUDE.md`](CLAUDE.md) for the canonical entrypoint. diff --git a/tools/trinity-bootstrap-pack/pack/templates/CLAUDE.md.template b/tools/trinity-bootstrap-pack/pack/templates/CLAUDE.md.template new file mode 100644 index 0000000..315462a --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/templates/CLAUDE.md.template @@ -0,0 +1,60 @@ +--- +title: "{{PROJECT_NAME}} — Trinity-enabled" +trinity-version: 2.0 +trinity-pack: trinity-bootstrap-pack-v1 +--- + +# {{PROJECT_NAME}} + +> Trinity-enabled project. AI agents start by reading this file + running `lll`. + +--- + +## READ FIRST (Mandatory < 1 min) + +1. **vvv before nnn** — verify intent before planning (100% enforced). +2. **Read actual code** — evidence-based, never assume. +3. **No production write without human approval** — always ask. + +Full rules: [`ai-docs/CORE_RULES.md`](ai-docs/CORE_RULES.md) + +--- + +## Short Codes (Inline — AI must know on first read) + +| Code | Action | When | +|------|--------|------| +| `lll` | Status (git + session + memory) | Start of session | +| `sss ` | Open new session | New task | +| `nnn` | Plan with estimates (file-by-file) | After sss | +| `vvv` | Verify (5 evidence questions) | After nnn | +| `gogogo` | Execute plan | After vvv approved | +| `ddd` | Done / human deploy gate | After gogogo + verify | +| `rrr` | Retrospective + memory update | End of session | + +**Order (do not skip):** `sss → nnn → vvv → gogogo → ddd → rrr` + +--- + +## Trinity Boundaries + +- AI **proposes** plans, code, decompositions. +- Verifier (`.ai/policies/verifier-rules.yaml`) **decides** PASS / RETRY / NEEDS_HUMAN / DEAD. +- Policy (`.ai/policies/safety.yaml`) **decides** allow / deny. +- Human **decides** `decided_by: human` transitions (deploy, destructive ops, external publication). +- Every action logs to `.ai/audit/events.ndjson` (hash chain — append only). + +Forbidden writes: `.ai/policies/**`, `.ai/audit/**` (mutate), `.ai/schemas/**`. + +--- + +## Quick Run + +```bash +bash .ai/cli/ai lll # status +bash .ai/cli/ai sss "" # open session +``` + +--- + +When unsure → ask the human (NEEDS_HUMAN). Never proceed under uncertainty. diff --git a/tools/trinity-bootstrap-pack/pack/templates/GEMINI.md.template b/tools/trinity-bootstrap-pack/pack/templates/GEMINI.md.template new file mode 100644 index 0000000..3b5e4f5 --- /dev/null +++ b/tools/trinity-bootstrap-pack/pack/templates/GEMINI.md.template @@ -0,0 +1,11 @@ +--- +title: "{{PROJECT_NAME}} — Gemini CLI entrypoint" +trinity-version: 2.0 +trinity-pack: trinity-bootstrap-pack-v1 +--- + +# {{PROJECT_NAME}} (Gemini / GEMINI.md) + +Mirrors `CLAUDE.md` — same short codes, same boundaries. + +See [`CLAUDE.md`](CLAUDE.md) for the canonical entrypoint. diff --git a/tools/trinity-bootstrap-pack/preflight.sh b/tools/trinity-bootstrap-pack/preflight.sh new file mode 100755 index 0000000..912f581 --- /dev/null +++ b/tools/trinity-bootstrap-pack/preflight.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# trinity-bootstrap-pack — preflight.sh +# Verifies host has required tools before install.sh proceeds. +# Exit 0 on success; non-zero with clear message on miss. + +set -u + +err=0 +log() { printf '%s\n' "$*"; } +fail() { printf 'preflight: FAIL — %s\n' "$*" >&2; err=1; } + +# bash 3.2+ (macOS default). install.sh uses POSIX-ish bash; we don't depend +# on associative arrays or other 4+ features. Spec 09 §1.1 lists 4+ as +# aspirational; actual trinity_v2 kernel runs on bash 3.2 today. +if [ -z "${BASH_VERSION:-}" ]; then + fail "this script must be run under bash" +else + bash_major="${BASH_VERSION%%.*}" + if [ "$bash_major" -lt 3 ]; then + fail "bash 3+ required (found $BASH_VERSION)" + else + log "preflight: bash $BASH_VERSION ok" + fi +fi + +# python 3.9+ (PEP 585 generics work via `from __future__ import annotations`). +# Spec 09 §1.1 lists 3.10+ as aspirational; actual trinity_v2 kernel runs on +# python 3.9 today (1868-test suite green on 3.9.6). +if ! command -v python3 >/dev/null 2>&1; then + fail "python3 not on PATH" +else + py_version="$(python3 -c 'import sys;print("%d.%d"%sys.version_info[:2])' 2>/dev/null || echo 0.0)" + py_major="${py_version%%.*}" + py_minor="${py_version##*.}" + if [ "$py_major" -lt 3 ] || { [ "$py_major" -eq 3 ] && [ "$py_minor" -lt 9 ]; }; then + fail "python 3.9+ required (found $py_version)" + else + log "preflight: python $py_version ok" + fi +fi + +# git +if ! command -v git >/dev/null 2>&1; then + fail "git not on PATH" +else + log "preflight: git $(git --version 2>/dev/null | awk '{print $3}') ok" +fi + +# sqlite3 (recommended; warn-only) +if ! command -v sqlite3 >/dev/null 2>&1; then + log "preflight: sqlite3 not on PATH (warning — needed for memory-cli later)" +else + log "preflight: sqlite3 $(sqlite3 -version 2>/dev/null | awk '{print $1}') ok" +fi + +if [ "$err" -ne 0 ]; then + log "preflight: refused — fix above and retry" + exit 10 +fi +log "preflight: ok" diff --git a/tools/trinity-bootstrap-pack/tests/__init__.py b/tools/trinity-bootstrap-pack/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/trinity-bootstrap-pack/tests/test_bootstrap_sh.py b/tools/trinity-bootstrap-pack/tests/test_bootstrap_sh.py new file mode 100644 index 0000000..22447c0 --- /dev/null +++ b/tools/trinity-bootstrap-pack/tests/test_bootstrap_sh.py @@ -0,0 +1,142 @@ +"""Tests for bootstrap.sh — curl|bash entrypoint. + +Strategy: we don't actually clone over the network in tests. Instead we +seed a fake "remote" as a local bare repo, point TRINITY_REPO_URL at it, +and verify the bootstrap script clones / dispatches correctly. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + + +PACK_ROOT = Path(__file__).resolve().parent.parent +BOOTSTRAP_SH = PACK_ROOT / "bootstrap.sh" + + +def test_bootstrap_exists_and_executable() -> None: + assert BOOTSTRAP_SH.is_file() + assert os.access(BOOTSTRAP_SH, os.X_OK) + + +def test_help_mentions_cache_and_ref() -> None: + proc = subprocess.run( + ["bash", str(BOOTSTRAP_SH), "--help"], capture_output=True, text=True + ) + assert proc.returncode == 0 + assert "TRINITY_KERNEL_CACHE" in proc.stdout + assert "--ref" in proc.stdout + + +def test_help_no_args_shows_usage() -> None: + proc = subprocess.run( + ["bash", str(BOOTSTRAP_SH)], capture_output=True, text=True + ) + assert proc.returncode == 0 + assert "Usage" in proc.stdout + + +def test_cache_parent_not_writable_fails(tmp_path: Path) -> None: + # Point cache at /nonexistent/never — parent not writable. + proc = subprocess.run( + ["bash", str(BOOTSTRAP_SH), str(tmp_path / "tgt"), + "--kernel-cache", "/nonexistent/never/cache"], + capture_output=True, + text=True, + env={**os.environ, "HOME": str(tmp_path)}, + ) + assert proc.returncode == 60 + assert "cache parent not writable" in proc.stderr + + +def test_clone_dispatch_with_local_fake_remote(tmp_path: Path) -> None: + """Set TRINITY_REPO_URL to a local bare repo and verify the wrapper: + 1. clones into the user-specified cache, + 2. dispatches to install.sh in that cache. + """ + # Build a fake trinity_v2 source tree + fake_source = tmp_path / "fake_trinity_v2" + fake_source.mkdir() + # Mirror the real install.sh shape: just a tiny script that prints OK + install_sh = fake_source / "tools" / "trinity-bootstrap-pack" / "install.sh" + install_sh.parent.mkdir(parents=True) + install_sh.write_text( + "#!/usr/bin/env bash\n" + 'echo "INSTALL_SH_CALLED args: $@"\n' + "exit 0\n", + encoding="utf-8", + ) + install_sh.chmod(0o755) + + # Init it as a git repo so bootstrap's git clone works + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=fake_source, check=True) + subprocess.run(["git", "add", "."], cwd=fake_source, check=True) + subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "init"], + cwd=fake_source, + check=True, + ) + + cache = tmp_path / "kernel-cache" + env = { + **os.environ, + "TRINITY_REPO_URL": str(fake_source), + "TRINITY_KERNEL_CACHE": str(cache), + "HOME": str(tmp_path), + } + proc = subprocess.run( + ["bash", str(BOOTSTRAP_SH), str(tmp_path / "my-target"), + "--project-name", "smoke"], + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, f"stdout={proc.stdout}\nstderr={proc.stderr}" + assert "INSTALL_SH_CALLED" in proc.stdout + assert (cache / ".git").is_dir() + assert (cache / "tools" / "trinity-bootstrap-pack" / "install.sh").is_file() + + +def test_passthrough_args_reach_install_sh(tmp_path: Path) -> None: + """Ensure --project-name and other install-time flags are passed through.""" + fake_source = tmp_path / "fake_v2" + fake_source.mkdir() + install_sh = fake_source / "tools" / "trinity-bootstrap-pack" / "install.sh" + install_sh.parent.mkdir(parents=True) + install_sh.write_text( + "#!/usr/bin/env bash\n" + 'printf "ARGS:[%s]\\n" "$*"\n' + "exit 0\n", + encoding="utf-8", + ) + install_sh.chmod(0o755) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=fake_source, check=True) + subprocess.run(["git", "add", "."], cwd=fake_source, check=True) + subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "init"], + cwd=fake_source, + check=True, + ) + + cache = tmp_path / "cache2" + env = { + **os.environ, + "TRINITY_REPO_URL": str(fake_source), + "TRINITY_KERNEL_CACHE": str(cache), + "HOME": str(tmp_path), + } + proc = subprocess.run( + ["bash", str(BOOTSTRAP_SH), str(tmp_path / "tgt"), + "--project-name", "myproj", "--with-kernel", "none"], + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, f"stderr={proc.stderr}" + assert "myproj" in proc.stdout + assert "--project-name" in proc.stdout + assert "--with-kernel none" in proc.stdout or "--with-kernel" in proc.stdout diff --git a/tools/trinity-bootstrap-pack/tests/test_detector.py b/tools/trinity-bootstrap-pack/tests/test_detector.py new file mode 100644 index 0000000..a993ce2 --- /dev/null +++ b/tools/trinity-bootstrap-pack/tests/test_detector.py @@ -0,0 +1,58 @@ +"""Detector unit tests.""" + +from pathlib import Path + +import pytest + +import sys +from pathlib import Path as _P +sys.path.insert(0, str(_P(__file__).resolve().parent.parent)) + +from lib.detector import detect + + +def test_greenfield_when_empty(tmp_path: Path) -> None: + target = tmp_path / "fresh" + target.mkdir() + fake_source = tmp_path / "src" + fake_source.mkdir() + res = detect(target, fake_source) + assert res.mode == "greenfield" + + +def test_upgrade_v1_when_ai_docs_only(tmp_path: Path) -> None: + target = tmp_path / "v1proj" + (target / "ai-docs").mkdir(parents=True) + (target / "CLAUDE.md").write_text("legacy", encoding="utf-8") + fake_source = tmp_path / "src" + fake_source.mkdir() + res = detect(target, fake_source) + assert res.mode == "upgrade-v1" + + +def test_upgrade_v2_when_ai_cli_present(tmp_path: Path) -> None: + target = tmp_path / "v2proj" + (target / ".ai" / "cli").mkdir(parents=True) + fake_source = tmp_path / "src" + fake_source.mkdir() + res = detect(target, fake_source) + assert res.mode == "upgrade-v2" + + +def test_self_install_when_target_equals_source(tmp_path: Path) -> None: + root = tmp_path / "trinity_v2" + root.mkdir() + res = detect(root, root) + assert res.mode == "self" + + +def test_self_install_when_markers_match(tmp_path: Path) -> None: + target = tmp_path / "looks_like_v2" + cli = target / ".ai" / "cli" + cli.mkdir(parents=True) + (cli / "ai").write_text("#!/bin/sh\n", encoding="utf-8") + (target / ".ai" / "ssot.yaml").write_text("version: '1.0'\n", encoding="utf-8") + fake_source = tmp_path / "src" + fake_source.mkdir() + res = detect(target, fake_source) + assert res.mode == "self" diff --git a/tools/trinity-bootstrap-pack/tests/test_installer.py b/tools/trinity-bootstrap-pack/tests/test_installer.py new file mode 100644 index 0000000..8fa6c8b --- /dev/null +++ b/tools/trinity-bootstrap-pack/tests/test_installer.py @@ -0,0 +1,120 @@ +"""Installer integration tests (dry-run + full).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import sys +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from lib.installer import InstallError, run_install +from lib.pack_manifest import PACK_VERSION + + +INSTALLER_FILE = Path(__file__).resolve().parent.parent / "lib" / "installer.py" + + +def test_greenfield_dry_run_writes_receipt(tmp_path: Path) -> None: + target = tmp_path / "fresh" + target.mkdir() + receipt = run_install( + target=target, + mode_override="greenfield", + dry_run=True, + force=False, + allow_self_install=False, + project_name="testproj", + installer_file=INSTALLER_FILE, + ) + assert receipt["pack_version"] == PACK_VERSION + assert receipt["mode"] == "greenfield" + assert receipt["dry_run"] is True + receipt_file = target / ".trinity-install-receipt.json" + assert receipt_file.exists() + body = json.loads(receipt_file.read_text(encoding="utf-8")) + assert body["pack_version"] == PACK_VERSION + + +def test_greenfield_full_install_lays_down_files(tmp_path: Path) -> None: + target = tmp_path / "fresh" + target.mkdir() + receipt = run_install( + target=target, + mode_override="greenfield", + dry_run=False, + force=False, + allow_self_install=False, + project_name="myproj", + installer_file=INSTALLER_FILE, + ) + assert receipt["dry_run"] is False + assert receipt["file_count"] > 0 + # CLAUDE.md should land at root with project name substituted + claude = target / "CLAUDE.md" + assert claude.exists() + assert "myproj" in claude.read_text(encoding="utf-8") + # ssot.yaml under .ai/ + assert (target / ".ai" / "ssot.yaml").exists() + + +def test_refuses_non_empty_target_without_force(tmp_path: Path) -> None: + target = tmp_path / "occupied" + target.mkdir() + (target / "existing.txt").write_text("hello", encoding="utf-8") + with pytest.raises(InstallError) as excinfo: + run_install( + target=target, + mode_override="greenfield", + dry_run=False, + force=False, + allow_self_install=False, + project_name=None, + installer_file=INSTALLER_FILE, + ) + assert excinfo.value.exit_code == 20 + + +def test_refuses_self_install_without_flag(tmp_path: Path) -> None: + # Build a directory shaped like trinity_v2 source root. + target = tmp_path / "v2_clone" + cli = target / ".ai" / "cli" + cli.mkdir(parents=True) + (cli / "ai").write_text("#!/bin/sh\n", encoding="utf-8") + (target / ".ai" / "ssot.yaml").write_text("version: '1.0'\n", encoding="utf-8") + with pytest.raises(InstallError) as excinfo: + run_install( + target=target, + mode_override=None, # auto-detect → self + dry_run=True, + force=False, + allow_self_install=False, + project_name=None, + installer_file=INSTALLER_FILE, + ) + assert excinfo.value.exit_code == 20 + assert "self-install refused" in str(excinfo.value) + + +def test_upgrade_v1_keeps_existing_files(tmp_path: Path) -> None: + target = tmp_path / "v1" + target.mkdir() + (target / "ai-docs").mkdir() + existing = target / "CLAUDE.md" + existing.write_text("PRESERVE-ME", encoding="utf-8") + receipt = run_install( + target=target, + mode_override="upgrade-v1", + dry_run=False, + force=False, + allow_self_install=False, + project_name="legacy", + installer_file=INSTALLER_FILE, + ) + assert receipt["mode"] == "upgrade-v1" + # CLAUDE.md preserved + assert existing.read_text(encoding="utf-8") == "PRESERVE-ME" + # New file laid down + assert (target / ".ai" / "ssot.yaml").exists() diff --git a/tools/trinity-bootstrap-pack/tests/test_kernel_wire.py b/tools/trinity-bootstrap-pack/tests/test_kernel_wire.py new file mode 100644 index 0000000..980bc6e --- /dev/null +++ b/tools/trinity-bootstrap-pack/tests/test_kernel_wire.py @@ -0,0 +1,124 @@ +"""Unit tests for kernel_wire.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import sys +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from lib.kernel_wire import KernelWireError, wire_kernel + + +def _fake_source(tmp_path: Path) -> Path: + """Build a minimal trinity_v2-shaped source root.""" + src = tmp_path / "trinity_v2_src" + (src / ".ai" / "cli").mkdir(parents=True) + (src / ".ai" / "cli" / "ai").write_text("#!/bin/sh\n", encoding="utf-8") + (src / ".ai" / "rituals").mkdir() + (src / ".ai" / "rituals" / "marker.txt").write_text("rituals\n", encoding="utf-8") + (src / ".ai" / "requirements.txt").write_text("typer\n", encoding="utf-8") + return src + + +def test_none_mode_no_writes(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + tgt.mkdir() + res = wire_kernel(mode="none", source_root=src, target=tgt, dry_run=False, force=False) + assert res.mode == "none" + assert res.bindings == [] + assert not (tgt / ".ai" / "cli").exists() + + +def test_symlink_default(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + tgt.mkdir() + res = wire_kernel(mode="symlink", source_root=src, target=tgt, dry_run=False, force=False) + assert res.mode == "symlink" + cli = tgt / ".ai" / "cli" + rituals = tgt / ".ai" / "rituals" + assert cli.is_symlink() + assert rituals.is_symlink() + assert cli.resolve() == (src / ".ai" / "cli").resolve() + # requirements.txt is always copied (not symlinked) + req = tgt / ".ai" / "requirements.txt" + assert req.is_file() + assert not req.is_symlink() + + +def test_copy_mode_deep_copy(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + tgt.mkdir() + res = wire_kernel(mode="copy", source_root=src, target=tgt, dry_run=False, force=False) + assert res.mode == "copy" + cli = tgt / ".ai" / "cli" + assert cli.is_dir() + assert not cli.is_symlink() + assert (cli / "ai").is_file() + + +def test_dry_run_no_filesystem_writes(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + tgt.mkdir() + res = wire_kernel(mode="symlink", source_root=src, target=tgt, dry_run=True, force=False) + assert res.mode == "symlink" + assert res.bindings # plans recorded + # but nothing on disk + assert not (tgt / ".ai" / "cli").exists() + + +def test_submodule_mode_refused_in_v11(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + tgt.mkdir() + with pytest.raises(KernelWireError) as exc_info: + wire_kernel(mode="submodule", source_root=src, target=tgt, dry_run=False, force=False) + assert exc_info.value.exit_code == 30 + + +def test_unknown_mode_refused(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + tgt.mkdir() + with pytest.raises(KernelWireError): + wire_kernel(mode="bogus", source_root=src, target=tgt, dry_run=False, force=False) # type: ignore[arg-type] + + +def test_existing_target_dir_skipped_without_force(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + (tgt / ".ai" / "cli").mkdir(parents=True) + (tgt / ".ai" / "cli" / "preexisting").write_text("keep me", encoding="utf-8") + res = wire_kernel(mode="symlink", source_root=src, target=tgt, dry_run=False, force=False) + # Preexisting kept + assert (tgt / ".ai" / "cli" / "preexisting").is_file() + # Warning emitted + assert any("skipped" in w for w in res.warnings) + + +def test_force_overwrites(tmp_path: Path) -> None: + src = _fake_source(tmp_path) + tgt = tmp_path / "tgt" + (tgt / ".ai" / "cli").mkdir(parents=True) + (tgt / ".ai" / "cli" / "preexisting").write_text("clobber", encoding="utf-8") + res = wire_kernel(mode="symlink", source_root=src, target=tgt, dry_run=False, force=True) + # After force, target is now a symlink to source + assert (tgt / ".ai" / "cli").is_symlink() + # Preexisting is gone (in target — source doesn't have it) + assert not (tgt / ".ai" / "cli" / "preexisting").exists() + + +def test_source_missing_kernel_refused(tmp_path: Path) -> None: + src = tmp_path / "empty_source" + src.mkdir() + tgt = tmp_path / "tgt" + tgt.mkdir() + with pytest.raises(KernelWireError) as exc_info: + wire_kernel(mode="symlink", source_root=src, target=tgt, dry_run=False, force=False) + assert exc_info.value.exit_code == 40 diff --git a/tools/trinity-bootstrap-pack/tests/test_pack_manifest.py b/tools/trinity-bootstrap-pack/tests/test_pack_manifest.py new file mode 100644 index 0000000..3c8ca60 --- /dev/null +++ b/tools/trinity-bootstrap-pack/tests/test_pack_manifest.py @@ -0,0 +1,41 @@ +"""Manifest unit tests.""" + +from pathlib import Path + +import sys +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from lib.pack_manifest import PACK_VERSION, build_manifest, manifest_to_dict + + +PACK_DIR = Path(__file__).resolve().parent.parent / "pack" + + +def test_pack_dir_exists() -> None: + assert PACK_DIR.is_dir() + + +def test_build_manifest_returns_nonempty() -> None: + entries = build_manifest(PACK_DIR) + assert len(entries) > 0 + for e in entries: + assert len(e.sha256) == 64 + assert e.size >= 0 + assert "/" in e.relpath or e.relpath in {"ssot.yaml"} # sanity + + +def test_manifest_to_dict_shape() -> None: + entries = build_manifest(PACK_DIR) + body = manifest_to_dict(entries) + assert body["pack_version"] == PACK_VERSION + assert body["file_count"] == len(entries) + assert isinstance(body["files"], list) + + +def test_pack_contains_required_files() -> None: + relpaths = {e.relpath for e in build_manifest(PACK_DIR)} + assert "templates/CLAUDE.md.template" in relpaths + assert "ai-docs/SHORT_CODES.md" in relpaths + assert ".ai/ssot.yaml" in relpaths + assert ".ai/policies/safety.yaml" in relpaths + assert ".ai/graphs/standard.yaml" in relpaths diff --git a/tools/trinity-bootstrap-pack/tests/test_smoke.py b/tools/trinity-bootstrap-pack/tests/test_smoke.py new file mode 100644 index 0000000..8b4891e --- /dev/null +++ b/tools/trinity-bootstrap-pack/tests/test_smoke.py @@ -0,0 +1,53 @@ +"""End-to-end smoke: invoke install.sh as a subprocess.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + + +PACK_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = PACK_ROOT / "install.sh" + + +def test_install_sh_exists_and_executable() -> None: + assert INSTALL_SH.is_file() + assert os.access(INSTALL_SH, os.X_OK) + + +def test_dry_run_greenfield_smoke(tmp_path: Path) -> None: + target = tmp_path / "smoke" + target.mkdir() + proc = subprocess.run( + ["bash", str(INSTALL_SH), "--dry-run", "--target", str(target), + "--mode", "greenfield", "--project-name", "smoketest"], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, f"stdout={proc.stdout}\nstderr={proc.stderr}" + receipt_file = target / ".trinity-install-receipt.json" + assert receipt_file.exists() + body = json.loads(receipt_file.read_text(encoding="utf-8")) + assert body["pack_version"] == "trinity-bootstrap-pack-v1" + assert body["mode"] == "greenfield" + assert body["dry_run"] is True + + +def test_self_install_refusal_smoke(tmp_path: Path) -> None: + target = tmp_path / "fake_v2" + cli = target / ".ai" / "cli" + cli.mkdir(parents=True) + (cli / "ai").write_text("#!/bin/sh\n", encoding="utf-8") + (target / ".ai" / "ssot.yaml").write_text("version: '1.0'\n", encoding="utf-8") + + proc = subprocess.run( + ["bash", str(INSTALL_SH), "--dry-run", "--target", str(target)], + capture_output=True, + text=True, + ) + assert proc.returncode == 20, f"stdout={proc.stdout}\nstderr={proc.stderr}" + assert "self-install refused" in proc.stderr diff --git a/tools/trinity-bootstrap-pack/verify-install.sh b/tools/trinity-bootstrap-pack/verify-install.sh new file mode 100755 index 0000000..907a925 --- /dev/null +++ b/tools/trinity-bootstrap-pack/verify-install.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# trinity-bootstrap-pack — verify-install.sh +# Post-install sanity check: confirms a target has a valid receipt + expected files. +# +# Usage: +# bash verify-install.sh --target + +set -eu + +target="" +dry_run=0 +while [ $# -gt 0 ]; do + case "$1" in + --target) target="$2"; shift 2 ;; + --dry-run) dry_run=1; shift ;; + -h|--help) + cat < [--dry-run] +Verifies the target has a .trinity-install-receipt.json with pack-v1 marker. +--dry-run is accepted for symmetry with install.sh; verify itself never writes. +EOF + exit 0 + ;; + *) printf 'unknown arg: %s\n' "$1" >&2; exit 2 ;; + esac +done + +if [ -z "$target" ]; then + printf 'verify-install: --target is required\n' >&2 + exit 2 +fi + +receipt="$target/.trinity-install-receipt.json" +if [ ! -f "$receipt" ]; then + printf 'verify-install: no receipt at %s\n' "$receipt" >&2 + exit 1 +fi + +if ! grep -q 'trinity-bootstrap-pack-v1' "$receipt"; then + printf 'verify-install: receipt missing pack version marker\n' >&2 + exit 1 +fi + +printf 'verify-install: ok — %s\n' "$receipt"