From 50c501e639fda492a91f5f651f488f93d7084163 Mon Sep 17 00:00:00 2001 From: Viktor Cherniavskyi <1274162+ejct@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:14:07 +0300 Subject: [PATCH] Release LoomLoom v0.3.0-alpha.2 --- CHANGELOG.md | 17 +++- README.md | 156 +++++++++++++++++++++++------------- docs/QUICKSTART.md | 126 +++++++++++++++++------------ install.py | 62 ++++++++++++++ loomloom.yaml | 10 +-- scripts/init_project.py | 144 +++++++++++++++++++++++++++++++++ scripts/validate_package.py | 18 ++++- tests/test_init_project.py | 62 ++++++++++++++ tests/test_installer_e2e.py | 51 ++++++++++++ 9 files changed, 532 insertions(+), 114 deletions(-) create mode 100644 install.py create mode 100644 scripts/init_project.py create mode 100644 tests/test_init_project.py create mode 100644 tests/test_installer_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a12ad..f06d302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,22 @@ Public distribution changes are recorded here. Internal research/proposal iterations are intentionally not mapped one-to-one to public release versions. -## v0.3.0-alpha.1 — release candidate +## v0.3.0-alpha.2 — released -First intended public LoomLoom distribution. +First-use revision over `v0.3.0-alpha.1`: + +- README organized around user problems and outcomes; +- one-command setup with an agent-first alternative; +- project initializer for exact release pinning and Bootstrap skill setup; +- same-release setup is idempotent; +- Quickstart focused on real-task execution and troubleshooting; +- initializer tests, tagged-source installer E2E, and distribution validation. + +The Development Kernel and current policy/profile semantics are unchanged. + +## v0.3.0-alpha.1 — released + +First public LoomLoom Open Alpha distribution. Includes: diff --git a/README.md b/README.md index df27f95..4bd8a27 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,134 @@ # LoomLoom -**LoomLoom is an experimental control protocol for agentic software development.** It helps coding agents work autonomously inside explicit authority while keeping evidence, acceptance, and project state tied to exactly identified subjects. +**Keep coding agents inside the task — and make their work verifiable.** -Current distribution: **v0.3.0-alpha.1 — Open Alpha**. +Coding agents are fast, but consequential work becomes hard to trust when scope drifts, “tests pass” is detached from the exact code that was tested, important state lives only in chat, or the next session has to reconstruct what happened from scratch. -LoomLoom is not a coding agent, a replacement for CI, or a project-management system. It sits above replaceable agent runtimes and gives consequential agent work a small shared control model: +LoomLoom adds a lightweight control layer around the coding agent you already use. It makes scope and decision boundaries explicit, ties verification evidence to the exact code it came from, and leaves enough durable project state for the next agent to continue correctly. -`INTENT → CONTRACT → AUTHORITY → CANDIDATE → OBSERVATION → EVIDENCE → VERDICT → ACCEPTANCE → INTEGRATION → RECONCILIATION` +Current distribution: **v0.3.0-alpha.2 — Open Alpha**. -## Why +## Start in one command -Coding agents can generate and modify software quickly. The difficult part is keeping clear answers to questions such as: +From the root of an existing project on macOS or Linux: -- What was the agent actually authorized to change? -- What exact candidate did a test result describe? -- Does a successful run prove the intended claim, or only a narrower surrogate? -- Who is allowed to accept or promote the result? -- Can a fresh agent continue from durable project state without hidden chat history? +```sh +curl -fsSL https://raw.githubusercontent.com/ejct/LoomLoom/v0.3.0-alpha.2/install.py -o /tmp/loomloom-install.py && python3 /tmp/loomloom-install.py . && rm -f /tmp/loomloom-install.py +``` -LoomLoom makes those boundaries explicit without requiring one specific agent, IDE, CI provider, or repository layout. +The installer resolves the immutable release tag to its exact Git commit, installs the Bootstrap skill into `.agents/skills/loomloom-bootstrap/`, and records that exact identity in `.loomloom/loomloom.lock`. It refuses to silently replace a different pin or overwrite a different existing skill. -## Current alpha +Then give your coding agent the task you actually care about: -The current public alpha contains: +> Use LoomLoom for this task: fix the cache invalidation bug and verify the result. -- Development Kernel v0.1 public specification; -- Bootstrap v0.1 and an Agent Skill; -- exact project pinning and portable package identity; -- Agent Operating Behavior v0.1; -- Browser Extension Development Profile v0.1; -- Context Receipt and selective-context routing; -- validators, fixtures, and tests used by the Bootstrap distribution. +You should not need to read LoomLoom documentation, create JSON by hand, or copy files yourself before trying it. -This is an **early open alpha**, not a stable API. Later 0.x releases may change workflow materialization and distribution behavior as dogfood evidence accumulates. The five Kernel invariants and exact-subject/evidence discipline are the current semantic baseline, but no 0.x compatibility promise should be inferred beyond the versioning policy. +### Or let the agent set it up -## Try it +Give this to your coding agent from the project root: -Start with [`docs/QUICKSTART.md`](docs/QUICKSTART.md). +> Set up LoomLoom v0.3.0-alpha.2 from https://github.com/ejct/LoomLoom/tree/v0.3.0-alpha.2, using that release’s installer. Pin the exact tagged commit, then use LoomLoom for this task: ****. -If your agent supports repository Agent Skills, the distribution includes: +If your runtime automatically discovers repository Agent Skills, subsequent tasks can stay short. If it does not, tell it to read `.agents/skills/loomloom-bootstrap/SKILL.md` first. -`.agents/skills/loomloom-bootstrap/SKILL.md` +## The problems LoomLoom is for -Otherwise use [`START_HERE.md`](START_HERE.md). +### “The agent fixed it” — but also changed three unrelated things -A consuming project should pin the exact LoomLoom release/commit in `.loomloom/loomloom.lock` and must not silently upgrade to branch HEAD. +LoomLoom resolves the task boundary and authority before consequential work, so capability does not silently become permission. -## Public authority +### “Tests pass” — but what exactly did they prove? -For a released LoomLoom version, the **exact Git tag and the repository contents at that tag are the public distribution authority**. The exact Git commit identifies the released subject. +Evidence is tied to an exactly identified candidate. A unit test, page probe, CI run, real-profile check, or production observation can support only the claim it actually exercised. -Important surfaces: +### The task spans sessions and the next agent starts from folklore -- [`docs/DEVELOPMENT_KERNEL.md`](docs/DEVELOPMENT_KERNEL.md) — public Development Kernel v0.1 specification; -- [`policy/agent-operating-behavior.md`](policy/agent-operating-behavior.md) — accepted operating-policy projection bundled in this distribution; -- [`profiles/browser-extension-development.md`](profiles/browser-extension-development.md) — accepted browser-extension profile projection; -- [`START_HERE.md`](START_HERE.md) — authority-aware entry point; -- [`loomloom.yaml`](loomloom.yaml) — machine-readable distribution metadata; -- [`VERSIONING.md`](VERSIONING.md) — release and component versioning; -- [`CHANGELOG.md`](CHANGELOG.md) — public release lineage. +LoomLoom makes the current authoritative state, exact LoomLoom pin, material context, and unresolved constraints recoverable without requiring hidden chat history. + +### The agent keeps handing routine verification back to you + +Within explicit authority, LoomLoom tells agents to close safe edit → verify → inspect → correct loops themselves and to try lower-interference automation before declaring a routine step human-only. + +## What changes in practice + +| Without LoomLoom | With LoomLoom | +| --- | --- | +| “Fix this” can expand into whatever the agent thinks is useful | Scope and authority are resolved before consequential work | +| “Tests pass” is a sentence in chat | Evidence refers to the exact candidate it observed | +| CI success, merge, and acceptance blur together | Observation, evaluation, acceptance, and integration stay distinct | +| Important state disappears with the session | Material current state can survive into the next session | +| Every new agent reloads everything “just in case” | Context is loaded selectively when it can change the task or claim | + +LoomLoom does not replace your coding agent, Git, CI, issue tracker, tests, or project documentation. It uses those existing surfaces where they already carry the needed truth. + +## A normal LoomLoom task + +You ask for a real change. The agent should then: + +1. resolve the project’s exact LoomLoom pin and material project authority; +2. emit a concise Context Receipt when authority-sensitive work needs it; +3. implement only within the resolved task boundary; +4. verify the exact candidate with evidence proportionate to the claim; +5. ask for a decision only when a real authority boundary requires one; +6. reconcile the smallest durable state needed for correct continuation. + +The underlying control model is: + +`INTENT → CONTRACT → AUTHORITY → CANDIDATE → OBSERVATION → EVIDENCE → VERDICT → ACCEPTANCE → INTEGRATION → RECONCILIATION` + +You do **not** need to learn that model before trying LoomLoom. + +## Good first tasks + +LoomLoom is most useful when an agent’s mistake, overreach, or unverifiable success claim would cost real time: + +- a retained feature or multi-file refactor; +- a bug fix with meaningful regression evidence; +- a migration or dependency change; +- browser-extension work where page behavior and extension-runtime behavior differ; +- a task that will cross sessions or agents; +- work where “done” must be distinguishable from “accepted” or “integrated.” + +For a throwaway prototype where you do not care about scope, reproducibility, or future continuation, LoomLoom may be unnecessary overhead. -Internal research, experiments, audit history, and future governance work may exist outside the public release. They are **not required to consume this released version and do not override the tagged public subject**. +## What setup adds -## Five invariants +```text +.loomloom/ + loomloom.lock -1. **Authority** — No capability creates authority by itself. -2. **Identity** — Evidence always refers to an exactly identified subject. -3. **Observation** — Producer self-report is not independent evidence. -4. **Promotion** — No success signal automatically raises the authority state of an artifact. -5. **Context** — A result cannot be treated as reproducible when its material execution context is unknown. +.agents/ + skills/ + loomloom-bootstrap/ +``` -See [`docs/DEVELOPMENT_KERNEL.md`](docs/DEVELOPMENT_KERNEL.md) for K1–K12, rigor levels, topology, and lifecycle semantics. +The lock binds the project to one exact LoomLoom commit. The installed skill is a navigator and operating projection; it does not create authority by itself. -## Maturity boundaries +Running setup again for the same release is safe and idempotent. Moving to a different release is an explicit project change, never a silent upgrade. -The alpha does **not** claim: +## Open Alpha boundaries +This is an early Open Alpha. The current release does **not** claim: + +- a stable 1.0 API or compatibility promise; +- broad cross-runtime validation; - a finished generic Kernel implementation; -- universal runtime adapters; -- stable multi-runtime compatibility; -- mandatory multi-agent workflows; -- complete autonomous acceptance/promotion; -- that a page probe equals extension-runtime or release/store evidence; -- that runtime/session success implies project acceptance. - -LoomLoom deliberately keeps runtime-native session, trace, sandbox, approval, persistence, and subagent mechanics outside the Kernel unless cross-project evidence later justifies a shared abstraction. +- automatic acceptance or promotion of agent work; +- that one kind of test proves behavior it did not exercise. + +## Go deeper when you need to + +You should not need these documents to get started. They exist for deeper operation and verification: + +- [`docs/QUICKSTART.md`](docs/QUICKSTART.md) — setup behavior, troubleshooting, and manual details; +- [`docs/DEVELOPMENT_KERNEL.md`](docs/DEVELOPMENT_KERNEL.md) — K1–K12 and the full public control model; +- [`policy/agent-operating-behavior.md`](policy/agent-operating-behavior.md) — agent operating policy projection; +- [`profiles/browser-extension-development.md`](profiles/browser-extension-development.md) — browser-extension evidence and test profile; +- [`VERSIONING.md`](VERSIONING.md) — release and compatibility model; +- [`CHANGELOG.md`](CHANGELOG.md) — public release lineage. + +For a released LoomLoom version, the exact Git tag/commit and repository contents at that tag are the public distribution authority. ## Author diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 94fee31..71058f4 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,82 +1,66 @@ # LoomLoom Quickstart -This is the smallest path for trying LoomLoom on an existing software project. +Start from the root of an existing project. You do not need to create LoomLoom files by hand. -## 1. Choose an exact LoomLoom release +## 1. Set up LoomLoom -Use an immutable LoomLoom release/tag. Do not use branch HEAD as a substitute for a release. +On macOS or Linux: -For this release candidate the intended public version is: - -`v0.3.0-alpha.1` - -When the release is tagged, use the exact commit shown by the release/tag as the binding identity. - -## 2. Pin LoomLoom in the project - -Create `.loomloom/loomloom.lock` using `schemas/loomloom-lock.schema.json` and the example in `fixtures/loomloom.lock.example`. +```sh +curl -fsSL https://raw.githubusercontent.com/ejct/LoomLoom/v0.3.0-alpha.2/install.py -o /tmp/loomloom-install.py && python3 /tmp/loomloom-install.py . && rm -f /tmp/loomloom-install.py +``` -Conceptually: +Setup uses the released tag, resolves its exact 40-character commit, and leaves these project-local surfaces: -```json -{ - "schema_version": 1, - "repository": "ejct/LoomLoom", - "commit": "", - "distribution": "github_release", - "release": "v0.3.0-alpha.1" -} +```text +.loomloom/loomloom.lock +.agents/skills/loomloom-bootstrap/ ``` -The commit is binding. The release name is descriptive provenance and never overrides the commit. - -## 3. Give the agent the Bootstrap +It is deliberately fail-closed: -If the runtime supports repository Agent Skills, copy or expose the exact release copy of: +- an existing different LoomLoom pin is not silently replaced; +- an existing different Bootstrap skill is not overwritten; +- rerunning setup for the same exact release is idempotent. -`.agents/skills/loomloom-bootstrap/` +If you prefer, hand the setup to your coding agent instead: -and invoke the `loomloom-bootstrap` skill. +> Set up LoomLoom v0.3.0-alpha.2 from https://github.com/ejct/LoomLoom/tree/v0.3.0-alpha.2, using that release’s installer. Pin the exact tagged commit, then use LoomLoom for this task: ****. -Otherwise give the agent the exact released LoomLoom repository/tag and instruct it to follow `START_HERE.md`. +## 2. Give the agent the real task -A useful first instruction is: +If your runtime discovers repository Agent Skills automatically: -> Bootstrap this project with the exact pinned LoomLoom release, emit a LoomLoom Context Receipt, then continue the requested task within the resolved authority and evidence boundaries. +> Use LoomLoom for this task: ****. -## 4. Expect a Context Receipt +Otherwise tell it to read `.agents/skills/loomloom-bootstrap/SKILL.md` first. -Before authority-sensitive implementation/evaluation work, the agent should resolve and report material state such as: +Before consequential work, the agent should be able to report: -- LoomLoom identity and exact commit; +- exact LoomLoom identity: `ejct/LoomLoom@<40-char commit>`; - project/task identity; -- material sources actually read; -- authority constraints; -- rigor/topology when material; -- conflicts or missing material context; +- material authority constraints; +- material sources it actually read; +- evidence expectations when they affect the task; - the next bounded action. +This is the LoomLoom Context Receipt. It is a compact receipt for material execution context, not a transcript and not a universal evidence log. + `READY` means work can proceed. `INCOMPLETE` means useful bounded work may continue with limited claims. `NEEDS_DECISION` means a material authority conflict blocks the requested authority-sensitive action. -The receipt may exist only in the active session when no later actor needs it. When durable continuation/review materially depends on it, reuse a project-native current-state/documentation owner if one exists; otherwise persist the fallback at `.loomloom/context-receipt.yaml`. Do not create duplicate state merely to satisfy LoomLoom. - -See `bootstrap/CONTEXT_RECEIPT.md`. - -## 5. Run one real bounded task +## 3. Run a real task -Start with an existing task where evidence matters: a retained feature, a bug fix, a migration, or another change that you would normally ask a coding agent to implement and verify. - -LoomLoom does not require every K1–K12 primitive to become a separate file. Use the project’s existing issue/spec/test/documentation surfaces when they already express the needed semantics. +Good first tasks are work you would already trust to a coding agent but where a wrong scope or weak success claim would cost time: a bug fix, retained feature, migration, multi-file refactor, browser-extension repair, or work that will cross sessions. The practical loop is: `intent/contract → authorized implementation → exact candidate → observations/evidence → evaluation → explicit acceptance when required → reconciliation` -At task close, reconcile only the durable state needed for a later actor to understand current truth and the next valid action. Verification evidence should normally remain in project-native surfaces such as tests, CI results, build output, version-control state, issue/spec records, or existing evidence documentation. LoomLoom v0.3 does not require a universal post-task evidence schema, evidence receipt, journal, or telemetry file. +LoomLoom should reuse the project’s own issue/spec/test/CI/documentation surfaces when they already express the needed truth. It should not manufacture extra files merely to materialize every control concept. -## 6. Keep claims proportional to evidence +## 4. Expect evidence proportional to the claim Examples: @@ -86,8 +70,50 @@ Examples: - CI success does not accept a candidate; - a merge does not prove post-integration correctness. -## 7. Upgrade explicitly +Verification evidence normally stays in project-native surfaces such as tests, CI results, build output, version-control state, issue/spec records, or existing evidence documentation. + +## 5. Let continuation state stay small + +A Context Receipt may exist only in the active session when nobody later needs it. When durable continuation or review materially depends on it, reuse an adequate project-native current-state/documentation owner if one exists; otherwise `.loomloom/context-receipt.yaml` is the fallback. + +Do not create duplicate state just to satisfy LoomLoom. + +## What setup actually pins + +`.loomloom/loomloom.lock` records the exact LoomLoom source identity. Conceptually: + +```json +{ + "schema_version": 1, + "repository": "ejct/LoomLoom", + "commit": "", + "distribution": "github_release", + "release": "v0.3.0-alpha.2" +} +``` + +The commit is binding. The human-readable release name is provenance and never overrides the exact commit. + +## Troubleshooting + +### The project already has a different LoomLoom pin + +Treat this as an explicit upgrade decision. Do not replace the lock automatically. Review compatibility, then change the project pin deliberately. + +### The Bootstrap skill already exists and differs + +Do not overwrite it blindly. Determine whether it is a local customization, another LoomLoom version, or unrelated project material. + +### The runtime does not auto-discover Agent Skills + +Tell the agent to read `.agents/skills/loomloom-bootstrap/SKILL.md` explicitly. The skill is the compact navigator; deeper LoomLoom sources are loaded only when they can materially affect the task or claim. + +### The agent cannot access GitHub + +Supply the exact tagged LoomLoom release/package through a permitted local or connected source. Preserve the same exact identity; do not substitute mutable branch HEAD. + +## Upgrade explicitly -When a newer LoomLoom version exists, do not silently follow it. Update the project pin as an explicit project change and re-evaluate any material compatibility differences. +When a newer LoomLoom version exists, upgrading is a project change. Resolve the new exact release commit, replace the installed Bootstrap projection only with explicit intent, update the project lock, and re-evaluate material compatibility differences. -See `VERSIONING.md`. +For deeper semantics, see `docs/DEVELOPMENT_KERNEL.md`. For versioning, see `VERSIONING.md`. diff --git a/install.py b/install.py new file mode 100644 index 0000000..2b1cefd --- /dev/null +++ b/install.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +DEFAULT_VERSION = "v0.3.0-alpha.2" +DEFAULT_REPOSITORY = "ejct/LoomLoom" + + +def run(command, **kwargs): + try: + return subprocess.run(command, check=True, text=True, **kwargs) + except FileNotFoundError as exc: + raise RuntimeError(f"required command not found: {command[0]}") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"command failed ({exc.returncode}): {' '.join(command)}") from exc + + +def main(): + version = os.environ.get("LOOMLOOM_VERSION", DEFAULT_VERSION) + repository = os.environ.get("LOOMLOOM_REPOSITORY", DEFAULT_REPOSITORY) + project = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() + repo_url = os.environ.get("LOOMLOOM_GIT_URL", f"https://github.com/{repository}.git") + + if not project.is_dir(): + raise RuntimeError(f"project directory does not exist: {project}") + + with tempfile.TemporaryDirectory(prefix="loomloom-") as temp_dir: + checkout = Path(temp_dir) / "LoomLoom" + run(["git", "clone", "--quiet", "--depth", "1", "--branch", version, repo_url, str(checkout)]) + + tag_check = subprocess.run( + ["git", "-C", str(checkout), "show-ref", "--verify", "--quiet", f"refs/tags/{version}"], + text=True, + ) + if tag_check.returncode != 0: + raise RuntimeError(f"{version} is not an immutable release tag") + + commit = subprocess.check_output( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + text=True, + ).strip() + + run([ + sys.executable, + str(checkout / "scripts/init_project.py"), + "--project", str(project), + "--source", str(checkout), + "--repository", repository, + "--commit", commit, + "--release", version, + ]) + + +if __name__ == "__main__": + try: + main() + except RuntimeError as exc: + print(f"LoomLoom setup failed: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/loomloom.yaml b/loomloom.yaml index 6cbce17..3f96aba 100644 --- a/loomloom.yaml +++ b/loomloom.yaml @@ -1,11 +1,11 @@ project: LoomLoom artifact: distribution -version: 0.3.0-alpha.1 +version: 0.3.0-alpha.2 channel: alpha -status: release_candidate +status: released metadata_role: static_distribution_metadata repository: ejct/LoomLoom -release_branch: release/v0.3.0-alpha.1 +release_branch: release/v0.3.0-alpha.2 components: development_kernel: 0.1 @@ -49,5 +49,5 @@ release: public_kernel: docs/DEVELOPMENT_KERNEL.md notes: - - "This branch is a release candidate until an exact subject is verified and explicitly accepted for publication." - - "Do not treat branch HEAD, test success, or release packaging as acceptance by itself." + - "This distribution is released as an immutable tag; exact Git identity comes from that tag/commit." + - "Do not treat branch HEAD, test success, or release packaging as acceptance by itself for future releases." diff --git a/scripts/init_project.py b/scripts/init_project.py new file mode 100644 index 0000000..2b74c65 --- /dev/null +++ b/scripts/init_project.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import shutil +import sys +from pathlib import Path + +DEFAULT_REPOSITORY = "ejct/LoomLoom" +LOCK_REL = Path(".loomloom/loomloom.lock") +SKILL_REL = Path(".agents/skills/loomloom-bootstrap") + + +class InitError(RuntimeError): + pass + + +def _exact_commit(value: str) -> str: + if not re.fullmatch(r"[0-9a-f]{40}", value or ""): + raise InitError("commit must be an exact 40-character lowercase Git SHA") + return value + + +def _load_lock(path: Path): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise InitError(f"existing LoomLoom lock is invalid JSON: {path}: {exc}") from exc + if not isinstance(payload, dict): + raise InitError(f"existing LoomLoom lock must be a JSON object: {path}") + return payload + + +def _tree_snapshot(root: Path): + if not root.is_dir(): + return None + snapshot = {} + for path in sorted(p for p in root.rglob("*") if p.is_file()): + snapshot[path.relative_to(root).as_posix()] = path.read_bytes() + return snapshot + + +def _desired_lock(repository: str, commit: str, release: str | None): + payload = { + "schema_version": 1, + "repository": repository, + "commit": _exact_commit(commit), + "distribution": "github_release" if release else "git_commit", + } + if release: + payload["release"] = release + return payload + + +def initialize_project( + project: Path, + source: Path, + repository: str = DEFAULT_REPOSITORY, + commit: str = "", + release: str | None = None, +): + project = project.resolve() + source = source.resolve() + if not project.is_dir(): + raise InitError(f"project directory does not exist: {project}") + if not re.fullmatch(r"[^/\s]+/[^/\s]+", repository or ""): + raise InitError("repository must use owner/name form") + + desired_lock = _desired_lock(repository, commit, release) + source_skill = source / SKILL_REL + if not source_skill.is_dir(): + raise InitError(f"source distribution is missing {SKILL_REL}") + + lock_path = project / LOCK_REL + skill_path = project / SKILL_REL + + existing_lock = _load_lock(lock_path) if lock_path.exists() else None + if existing_lock is not None: + existing_repo = existing_lock.get("repository") + existing_commit = existing_lock.get("commit") + if existing_repo != repository or existing_commit != commit: + raise InitError( + "project already has a different LoomLoom pin; refusing a silent upgrade " + f"({existing_repo}@{existing_commit} != {repository}@{commit})" + ) + + source_snapshot = _tree_snapshot(source_skill) + dest_snapshot = _tree_snapshot(skill_path) + if dest_snapshot is not None and dest_snapshot != source_snapshot: + raise InitError( + f"{SKILL_REL} already exists and differs from this LoomLoom release; " + "refusing to overwrite local or differently-versioned skill files" + ) + + lock_state = "unchanged" + if existing_lock != desired_lock: + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.write_text(json.dumps(desired_lock, indent=2) + "\n", encoding="utf-8") + lock_state = "created" if existing_lock is None else "updated" + + skill_state = "unchanged" + if dest_snapshot is None: + skill_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_skill, skill_path) + skill_state = "installed" + + return { + "project": str(project), + "pinned_identity": f"{repository}@{commit}", + "release": release, + "lock": lock_state, + "skill": skill_state, + } + + +def build_parser(): + parser = argparse.ArgumentParser(description="Install a pinned LoomLoom Bootstrap skill and lock into an existing project.") + parser.add_argument("--project", default=".", help="target project directory") + parser.add_argument("--source", required=True, help="LoomLoom release checkout") + parser.add_argument("--repository", default=DEFAULT_REPOSITORY, help="LoomLoom repository in owner/name form") + parser.add_argument("--commit", required=True, help="exact 40-character release commit") + parser.add_argument("--release", help="release/tag provenance") + return parser + + +def main(argv=None): + args = build_parser().parse_args(argv) + try: + result = initialize_project(Path(args.project), Path(args.source), args.repository, args.commit, args.release) + except InitError as exc: + print(f"LoomLoom setup failed: {exc}", file=sys.stderr) + return 2 + + release = f" ({result['release']})" if result["release"] else "" + print(f"LoomLoom ready{release}.") + print(f"Pinned: {result['pinned_identity']}") + print(f"Lock: {LOCK_REL} [{result['lock']}]") + print(f"Skill: {SKILL_REL} [{result['skill']}]") + print("Next: ask your coding agent: 'Use LoomLoom for this task: .'") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_package.py b/scripts/validate_package.py index f0ffc5d..5704581 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -8,6 +8,7 @@ "START_HERE.md", "AGENTS.md", "loomloom.yaml", + "install.py", "bootstrap/BOOTSTRAP.md", "bootstrap/CONTEXT_RECEIPT.md", "bootstrap/ROUTING.md", @@ -15,8 +16,11 @@ "bootstrap/PACKAGE_IDENTITY.md", "schemas/loomloom-lock.schema.json", "fixtures/loomloom.lock.example", + "scripts/init_project.py", "scripts/validate_loomloom_pin.py", "scripts/package_identity.py", + "tests/test_init_project.py", + "tests/test_installer_e2e.py", "tests/test_lock_validation.py", "tests/test_package_identity.py", ".agents/skills/loomloom-bootstrap/SKILL.md", @@ -113,6 +117,18 @@ except Exception as e: errors.append(f"lock example JSON invalid: {e}") +metadata = ROOT / "loomloom.yaml" +installer = ROOT / "install.py" +if metadata.is_file() and installer.is_file(): + version_match = re.search(r"^version:\s*([^\s]+)$", metadata.read_text(encoding="utf-8"), flags=re.M) + installer_match = re.search(r'^DEFAULT_VERSION\s*=\s*"v([^"]+)"$', installer.read_text(encoding="utf-8"), flags=re.M) + if not version_match: + errors.append("loomloom.yaml: missing distribution version") + elif not installer_match: + errors.append("install.py: missing DEFAULT_VERSION") + elif version_match.group(1) != installer_match.group(1): + errors.append("distribution metadata / installer version drift") + if errors: print("FAIL") for e in errors: @@ -120,4 +136,4 @@ sys.exit(1) print("PASS") -print("LoomLoom Bootstrap candidate package structure is internally valid.") +print("LoomLoom distribution package structure is internally valid.") diff --git a/tests/test_init_project.py b/tests/test_init_project.py new file mode 100644 index 0000000..0765ce2 --- /dev/null +++ b/tests/test_init_project.py @@ -0,0 +1,62 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "init_project.py" +spec = importlib.util.spec_from_file_location("init_project", SCRIPT) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class InitProjectTests(unittest.TestCase): + COMMIT = "0123456789abcdef0123456789abcdef01234567" + + def setUp(self): + self.source_tmp = tempfile.TemporaryDirectory() + self.project_tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.source_tmp.cleanup) + self.addCleanup(self.project_tmp.cleanup) + self.source = Path(self.source_tmp.name) + self.project = Path(self.project_tmp.name) + skill = self.source / ".agents/skills/loomloom-bootstrap" + (skill / "references").mkdir(parents=True) + (skill / "SKILL.md").write_text("skill\n", encoding="utf-8") + (skill / "references/BOOTSTRAP.md").write_text("bootstrap\n", encoding="utf-8") + + def test_install_and_idempotency(self): + args = dict(commit=self.COMMIT, release="v0.3.0-alpha.2") + first = module.initialize_project(self.project, self.source, **args) + second = module.initialize_project(self.project, self.source, **args) + lock = json.loads((self.project / ".loomloom/loomloom.lock").read_text()) + self.assertEqual(lock["commit"], self.COMMIT) + self.assertEqual(lock["distribution"], "github_release") + self.assertEqual(first["skill"], "installed") + self.assertEqual(second["skill"], "unchanged") + + def test_existing_other_pin_is_rejected(self): + lock_path = self.project / ".loomloom/loomloom.lock" + lock_path.parent.mkdir(parents=True) + lock_path.write_text(json.dumps({ + "schema_version": 1, + "repository": "ejct/LoomLoom", + "commit": "89abcdef0123456789abcdef0123456789abcdef", + "distribution": "github_release", + "release": "v0.3.0-alpha.1" + }), encoding="utf-8") + with self.assertRaises(module.InitError): + module.initialize_project(self.project, self.source, commit=self.COMMIT, release="v0.3.0-alpha.2") + self.assertFalse((self.project / ".agents/skills/loomloom-bootstrap").exists()) + + def test_existing_different_skill_is_rejected(self): + skill = self.project / ".agents/skills/loomloom-bootstrap" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("different\n", encoding="utf-8") + with self.assertRaises(module.InitError): + module.initialize_project(self.project, self.source, commit=self.COMMIT, release="v0.3.0-alpha.2") + self.assertFalse((self.project / ".loomloom/loomloom.lock").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installer_e2e.py b/tests/test_installer_e2e.py new file mode 100644 index 0000000..878ce17 --- /dev/null +++ b/tests/test_installer_e2e.py @@ -0,0 +1,51 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INSTALLER = ROOT / "install.py" +VERSION = "v0.3.0-alpha.2" + + +class InstallerE2ETests(unittest.TestCase): + def test_tagged_source_installs_exact_pin_and_skill(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + source = temp / "source" + project = temp / "project" + project.mkdir() + + subprocess.run(["git", "clone", "--quiet", str(ROOT), str(source)], check=True) + subprocess.run(["git", "-C", str(source), "tag", VERSION], check=True) + expected = subprocess.check_output( + ["git", "-C", str(source), "rev-parse", VERSION], + text=True, + ).strip() + + env = os.environ.copy() + env["LOOMLOOM_GIT_URL"] = source.as_uri() + env["LOOMLOOM_VERSION"] = VERSION + env["LOOMLOOM_REPOSITORY"] = "ejct/LoomLoom" + + completed = subprocess.run( + [sys.executable, str(INSTALLER), str(project)], + env=env, + text=True, + capture_output=True, + check=True, + ) + + lock = json.loads((project / ".loomloom/loomloom.lock").read_text(encoding="utf-8")) + self.assertEqual(lock["commit"], expected) + self.assertEqual(lock["release"], VERSION) + self.assertEqual(lock["distribution"], "github_release") + self.assertTrue((project / ".agents/skills/loomloom-bootstrap/SKILL.md").is_file()) + self.assertIn(f"Pinned: ejct/LoomLoom@{expected}", completed.stdout) + + +if __name__ == "__main__": + unittest.main()