diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a3bccc6..117afbd 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,11 +1,19 @@ { "name": "analysis-workflow", - "version": "0.3.0", + "version": "0.4.0", "description": "Conventions for analysis repos where humans (notebooks) and coding agents (scripts) work side by side: one task per session, worktree and directory; worktree-safe output paths; additive write-back to AnnData zarr working objects. Ships a skill, guard hooks and the analysis_workflow Python package.", "author": { "name": "quadbio" }, "homepage": "https://github.com/quadbio/analysis-workflow", "license": "MIT", - "keywords": ["single-cell", "spatial-transcriptomics", "anndata", "zarr", "git-worktree", "pixi", "scverse"] + "keywords": ["single-cell", "spatial-transcriptomics", "anndata", "zarr", "git-worktree", "pixi", "scverse"], + "userConfig": { + "deliverables": { + "type": "string", + "title": "Default deliverables", + "description": "What a task delivers unless you choose otherwise at its start, e.g. \"a figure\" or \"a figure and a report\". Blank: the agent asks.", + "default": "a figure" + } + } } diff --git a/README.md b/README.md index 8ea035a..2a98c5a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ shared AnnData zarr *working object* per dataset that grows by additive write-ba | Part | What it does | | --- | --- | | `skills/analysis-workflow/` | The conventions: repo model, human and agent lanes, task lifecycle, `data/` layout, working objects, environments. Loaded on demand. | -| `hooks/` | Always-on core rules and the main checkout's environment at session start; blocks worktree-unsafe output paths and `pixi install` in a worktree. | +| `hooks/` | Always-on core rules and the main checkout's environment at session start; blocks worktree-unsafe output paths, `pixi install` in a worktree, and task directories off the template. | | `src/analysis_workflow/` | `task_paths`, `main_checkout`, `commit_adata`, `keys_on_disk`, `analyze_anndata_size`, and `analysis-workflow check layout`. | It assumes analysis repos made from [analysis_template](https://github.com/quadbio/analysis_template) @@ -25,10 +25,12 @@ claude --worktree ``` 1. The agent works on that worktree's branch, in a new task directory `analysis//_vN/`; a later - answer to the same question gets the next version, in a new session. -2. It asks you which working object is current, then works in scripts; outputs go to the task directory. + answer to the same question gets the next version, in a new session. Earlier task directories are never edited. +2. It asks you which working object is current and agrees what the task delivers (by default your `deliverables` + setting), then works in scripts; outputs go to the task directory. 3. Results reach a shared working object only after you sign off the dry run. -4. It opens a PR. Once it is merged and its batch jobs have finished, leave with `/exit` and remove the worktree. +4. It opens a PR whose body holds the details; the task README summarizes each PR. Once it is merged and its batch + jobs have finished, leave with `/exit` and remove the worktree. Notebooks stay with you, in the main checkout. A task that changes the environment locks, installs and tests it in a temporary checkout beside the main one; after its merge you pull and run `pixi install` there. With no @@ -38,26 +40,16 @@ analysis repo yet, ask Claude to start an analysis project: the skill sets one u **The plugin**, once per machine, from the [quadbio marketplace](https://github.com/quadbio/claude-plugins). The skill is then available everywhere; the hooks and session-start rules act only in repos that enable the plugin in -their committed `.claude/settings.json`, as [analysis_template](https://github.com/quadbio/analysis_template) does: - -```json -{ - "extraKnownMarketplaces": { - "quadbio": { "source": { "source": "github", "repo": "quadbio/claude-plugins" } } - }, - "enabledPlugins": { "analysis-workflow@quadbio": true }, - "permissions": { "additionalDirectories": ["../-lock"] } -} -``` - -`` is the main checkout's directory name: a task changing the environment locks it in `../-lock`. +their committed `.claude/settings.json`. Every repo made from +[analysis_template](https://github.com/quadbio/analysis_template) does, and depends on the package in its +`pixi.toml` (its lock pins the commit; `pixi update` moves it). To adopt an existing repo, ask Claude to adopt the +conventions. -**The package**, in the analysis repo's `pixi.toml` (its lock pins the commit; `pixi update` moves it): +**Settings**, saved with `/plugin configure analysis-workflow@quadbio`: -```toml -[pypi-dependencies] -analysis-workflow = { git = "https://github.com/quadbio/analysis-workflow" } -``` +| Setting | Default | Meaning | +| --- | --- | --- | +| `deliverables` | `a figure` | What a task delivers unless you choose otherwise at its start; recorded in the task README. Blank: the agent asks. | ## Develop diff --git a/hooks/guard_task_layout.py b/hooks/guard_task_layout.py new file mode 100644 index 0000000..760d135 --- /dev/null +++ b/hooks/guard_task_layout.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Keep every task directory in the template's layout. + +A task directory (`analysis//.../_vN/`) holds at its top only what the task template holds +(`skills/analysis-workflow/assets/task_template/`), the output directories of `task_paths` and a task-local +environment. Its README's title is the directory's name, and its other headings are the template README's, all of +them and in order. Both are read from their sources, the template and `paths.py`, so a change there is a change here. + +PreToolUse (Edit|Write|NotebookEdit): checks what this call would leave; exit 2 blocks it. +Stop: checks the task directories this branch touches, committed or not, which also catches files made from the +shell; exit 2 keeps the agent working, once (`stop_hook_active`). +Stderr goes to the agent. Standard library only: it runs on whatever `python3` is on PATH. +""" + +from __future__ import annotations + +import ast +import json +import re +import subprocess +import sys +from pathlib import Path + +from _repo import adopted, checkouts + +PLUGIN = Path(__file__).parents[1] +TEMPLATE = PLUGIN / "skills" / "analysis-workflow" / "assets" / "task_template" +TASK_DIR = re.compile(r".+_v\d+$") +HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +FENCE = re.compile(r"^\s*(```|~~~)") +#: A task-local environment, for a benchmark's candidates (the skill's Environments). +TASK_ENVIRONMENT = {"pixi.toml", "pixi.lock"} + + +def output_dirs() -> set[str]: + """The task output directories, read from the package's `paths.py` without running it. + + The package targets a newer Python than this hook, and may not be installed. + """ + names = ("TRACKED_TASK_DIRS", "UNTRACKED_TASK_DIRS") + tree = ast.parse((PLUGIN / "src" / "analysis_workflow" / "paths.py").read_text()) + found = { + target.id: ast.literal_eval(node.value) + for node in tree.body + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Name) and target.id in names + } + return {d for name in names for d in found[name]} + + +def allowed() -> set[str]: + """What the top of a task directory may hold.""" + return {p.name for p in TEMPLATE.iterdir()} | output_dirs() | TASK_ENVIRONMENT + + +def headings(text: str) -> list[tuple[int, str]]: + """(level, text) of each Markdown heading outside fenced code blocks.""" + found, fenced = [], False + for line in text.splitlines(): + if FENCE.match(line): + fenced = not fenced + elif not fenced and (m := HEADING.match(line)): + found.append((len(m.group(1)), m.group(2))) + return found + + +def task_dir(path: Path, analysis: Path, entries: set[str]) -> Path | None: + """The task directory ``path`` is in, below the checkout's `analysis/`. + + Walking down, the last directory named `_vN` before the path enters one of a task's own ``entries``: a + topic named like a task (`cohort_v1/demo_v1/`) is passed over, a versioned folder inside a task's outputs + (`demo_v1/outputs/model_v1/`) is not taken for one. + """ + if not path.is_relative_to(analysis): + return None + below, task = path.relative_to(analysis).parts[:-1], None + for i, part in enumerate(below): + if task is not None and part in entries: + break + if TASK_DIR.match(part): + task = i + return None if task is None else analysis.joinpath(*below[: task + 1]) + + +def problem(path: Path, task: Path, entries: set[str], text: str | None) -> str | None: + """What is off-layout about ``path`` in ``task``; ``text`` is the README's, when ``path`` is the README.""" + top = path.relative_to(task).parts[0] + if top not in entries: + return f"`{top}` is not part of a task directory, whose top holds only {', '.join(sorted(entries))}." + if path != task / "README.md" or text is None: + return None + expected = [(1, task.name), *((n, h) for n, h in headings((TEMPLATE / "README.md").read_text()) if n > 1)] + if headings(text) == expected: + return None + show = "\n".join(f" {'#' * n} {h}" for n, h in expected) + return f"the README must have exactly the template's headings, titled with the directory's name:\n{show}" + + +def after(tool_input: dict, path: Path) -> str | None: + """The file's text once this Write or Edit is applied; None if it cannot be known.""" + if "content" in tool_input: + return str(tool_input["content"]) + try: + text = path.read_text() + except OSError: + return None + old, new = str(tool_input.get("old_string", "")), str(tool_input.get("new_string", "")) + return text.replace(old, new) if tool_input.get("replace_all") else text.replace(old, new, 1) + + +def check_write(tool_input: dict) -> list[str]: + """The problems a file tool call would leave.""" + path = Path(tool_input.get("file_path") or tool_input.get("notebook_path") or "") + if "analysis" not in path.parts: + return [] + path = path.resolve() + found = checkouts(path.parent) + if not found or not adopted(found[0]): + return [] + entries = allowed() + task = task_dir(path, found[0].resolve() / "analysis", entries) + why = task and problem(path, task, entries, after(tool_input, path)) + return [f"in {task.name}/, {why}"] if why else [] + + +def git(top: Path, *args: str) -> str: + """A git command's output in the checkout ``top``; empty if it fails.""" + return subprocess.run(["git", *args], cwd=top, capture_output=True, text=True, timeout=10).stdout + + +def branch_changes(top: Path) -> list[Path]: + """Paths under `analysis/` this branch changed, committed or not, deleted ones included, and untracked files.""" + base = next((b for ref in ("origin/HEAD", "origin/main", "main") if (b := git(top, "merge-base", "HEAD", ref))), "") + names = git(top, "diff", "--name-only", "--no-renames", "-z", base.strip() or "HEAD", "--", "analysis").split("\0") + names += git(top, "ls-files", "--others", "--exclude-standard", "-z", "--", "analysis").split("\0") + return [top / name for name in dict.fromkeys(names) if name] + + +def check_changes(cwd: Path) -> list[str]: + """The problems in the task directories this branch touches, in the checkout at ``cwd``.""" + found = checkouts(cwd) + if not found or not adopted(found[0]): + return [] + top = found[0].resolve() + entries, problems, tasks = allowed(), [], {} + for path in branch_changes(top): + if task := task_dir(path, top / "analysis", entries): + tasks[task] = None + if path.exists() and (why := problem(path, task, entries, None)): + problems.append(f"in {task.name}/, {why}") + for task in (t for t in tasks if t.is_dir()): # a task renamed away is gone, not broken + readme = task / "README.md" + why = problem(readme, task, entries, readme.read_text()) if readme.exists() else "no README.md." + if why: + problems.append(f"in {task.name}/, {why}") + return list(dict.fromkeys(problems)) + + +def main() -> int: + """Read the hook payload from stdin; return the exit code.""" + payload = json.load(sys.stdin) + if payload.get("hook_event_name") == "Stop": + if payload.get("stop_hook_active"): + return 0 + problems = check_changes(Path(payload.get("cwd") or ".")) + else: + problems = check_write(payload.get("tool_input", {})) + if not problems: + return 0 + for why in problems: + print(f"Off the task layout: {why}", file=sys.stderr) + print("See the analysis-workflow skill (Tasks) and its task template.", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/hooks.json b/hooks/hooks.json index 9fb85c7..de577a7 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "analysis-workflow, in repos that enable it: always-on core rules, plus guards against worktree-unsafe output paths and pixi installs.", + "description": "analysis-workflow, in repos that enable it: always-on core rules, plus guards against worktree-unsafe output paths, pixi installs and task directories off the template.", "hooks": { "SessionStart": [ { @@ -30,6 +30,22 @@ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard_task_paths.py\"", "timeout": 10 + }, + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard_task_layout.py\"", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard_task_layout.py\"", + "timeout": 15 } ] } diff --git a/hooks/session_context.md b/hooks/session_context.md index ff5a43e..c7e41b5 100644 --- a/hooks/session_context.md +++ b/hooks/session_context.md @@ -2,10 +2,11 @@ This repository follows the analysis-workflow conventions. Load the `analysis-wo analysis code, outputs or data. Always: 1. One task = one session = one git worktree = one directory `analysis//.../_vN/`. A new task - directory needs a new session, which the human starts with `claude --worktree ` in the main checkout. + directory needs a new session, started with `claude --worktree ` in the main checkout. + Analysis only moves forward: earlier task directories are never edited, fixed or updated. 2. Every output path comes from `task_paths(__file__)` (package `analysis_workflow`); never a bare relative path. -3. Ask the human which working object (and which label keys) is current; never infer it from a script or the - newest file name. +3. Ask the human which working object (and which label keys) is current, never inferring it from a script or the + newest file name; and agree what the task delivers (a default, if set, is listed below), recorded in its README. 4. Shared objects grow by addition only (`commit_adata`), and only after sign-off on the dry run. 5. Agents never write to `data//results/` or the central `figures/`; those are the human lane. 6. A worktree, and every batch job submitted from it, runs on the main checkout's environment; only the human diff --git a/hooks/session_start.py b/hooks/session_start.py index d382e2c..ab1d6ea 100644 --- a/hooks/session_start.py +++ b/hooks/session_start.py @@ -69,6 +69,8 @@ def main() -> int: print(f"\nEvery Bash command runs on `{variables['CONDA_PREFIX']}`: run `python …` directly.") else: print(f"\nNo environment is active: run `pixi run --manifest-path {main_checkout}/pixi.toml --as-is …`.") + if deliverables := os.environ.get("CLAUDE_PLUGIN_OPTION_DELIVERABLES", "").strip(): + print(f"Default deliverables, from the plugin settings: {deliverables}.") return 0 diff --git a/skills/analysis-workflow/SKILL.md b/skills/analysis-workflow/SKILL.md index 5b6e9c2..3fa8dc3 100644 --- a/skills/analysis-workflow/SKILL.md +++ b/skills/analysis-workflow/SKILL.md @@ -18,14 +18,14 @@ Steps: `references/repos.md`. Elsewhere: - the repo's `AGENTS.md`: its datasets, where its working objects live, its environments. It wins over examples here; -- compute and job submission: the environment's own docs or skill; -- **every scientific plot: the `sci-figures` skill**; cell-type annotation: `cell-type-annotation`; +- figures, analysis methods, and the compute environment with its job submission: their own docs or skills. + Nothing here covers them, and these conventions hold on any machine; - why the storage rules are what they are: `references/storage.md`. ## Where code goes -- Science code that several tasks need goes to a code repo, plotting code included. Figure code is copied only - between versions of one task. The analysis repo's own package holds `FilePaths`, dataset versioning and a CLI. +- Science code that several tasks need goes to a code repo; a new version of a task copies what it reuses from the + one it follows. The analysis repo's own package holds `FilePaths`, dataset versioning and a CLI. - A task that changes a code repo does so in a worktree of that repo, added from the same session: `git -C worktree add .claude/worktrees/ -b `. - Look before writing: existing packages (scverse, then its ecosystem), sibling code repos, earlier task directories. @@ -36,49 +36,53 @@ Elsewhere: ## Two lanes Humans work in notebooks, flat under `analysis//`, writing to `data//results/` and -`figures//`; notebook outputs are committed. Agents work in task directories, with scripts, and never write -into the human lane. +`figures//`; notebook outputs are committed. Agents work in task directories, with scripts. ## Tasks **Start** -1. One task = one session = one git worktree = one branch = one directory `analysis//.../_vN/`, merged - through one or more PRs. A new task directory needs a new session, which the human starts with - `claude --worktree ` in the main checkout; a later answer to the same question is `_v2`. -2. Copy `assets/task_template/`. -3. Ask which working object and which label keys are current, and record them under Inputs. Never infer them from a - script or the newest file name. +1. A task is one branch, merged through one or more PRs; a later answer to the same question is the next version, + in a new session. Only task directories end in `_vN`; a topic never does. +2. Copy `assets/task_template/` into the task directory. +3. Settle what session rule 3 asks, the working object with its label keys and the deliverables, and record both in + the README. Deliverables are any of a report, a figure, an HTML page, just results in `outputs/` and a + write-back; start from the default the session start lists, if any, which the human may change for this task. 4. Rename freely until the first write-back or the merge; then the name is frozen, because keys carry it. **Run** -- Every output path comes from `PATHS = task_paths(__file__)` in `_common.py`; `PATHS.ensure()` in the writer. - A bare relative path written from a worktree dies with the worktree. +- `PATHS = task_paths(__file__)` lives in `scripts/_common.py`; call `PATHS.ensure()` in the writer. | dir | tracked | lives in | holds | | --- | --- | --- | --- | | `results/` | yes | this checkout | small evidence tables; past ~1 MB they belong in `outputs/` | - | `reports/` | yes | this checkout | the findings report | - | `figures/` | no | main checkout | `fig1_.pdf` … in reading order | + | `reports/` | yes | this checkout | a report, if one is a deliverable | + | `figures/` | no | main checkout | figures | | `outputs/` | no | main checkout | data artifacts | | `logs/` | no | main checkout | batch-job logs, passed as an absolute path | - Another task's untracked outputs: `task_paths().outputs`. Read the main checkout by absolute path; never `cd` into it. -- Batch-job scripts sit in `scripts/` beside the code they run. They get the main checkout as `$MAIN` from the - submit command, never hardcoded, run `pixi run --manifest-path "$MAIN/pixi.toml" --as-is`, and log to `PATHS.logs`. How - to submit them is the cluster's skill. -- Large single-cell computations (neighbors, UMAP, clustering on ≳100k cells) use rapids-singlecell over scanpy, in - a GPU job. -- A figure that prints a number may carry `scripts/verify.py` re-deriving it. A code package's invariants belong in - that package's tests. +- Scripts go in `scripts/`: the steps as `NN_.py`, numbered in run order, and batch-job scripts beside the + code they run. A batch job gets the main checkout as `$MAIN` from the submit command, never hardcoded, and logs to + `PATHS.logs`; how to submit it is the compute environment's concern. +- A deliverable that states a number may carry `scripts/verify.py` re-deriving it. A code package's invariants + belong in that package's tests. + +**Record.** The task README, in the task template's layout, is the overview, kept current. The deliverables hold the +findings; the README links to them rather than restating them. The details of each PR go in its body, laid out by +the repo's `.github/pull_request_template.md`; the README gives each PR, and each issue the session opened in any +repo, a short summary. **Finish** -1. Report, and the README: Inputs, Outputs, Write-back, Running, Notes. +1. The agreed deliverables, and the README brought up to date, its jobs counted with the compute environment's + tools. 2. Write back: dry run, sign-off on that diff, then `yes=True`. 3. An object other tasks will load is promoted to `data//processed/` at sign-off. Ask which subdirectory; record the path. 4. `analysis-workflow check layout`, then push. -5. The worktree is removed only once no batch job submitted from it is still queued or running. +5. Open the PR, its body from the PR template; then add it with a short summary to the README and push again. The + body is edited until the merge, never after. +6. The worktree is removed only once no batch job submitted from it is still queued or running. ## Data @@ -102,11 +106,10 @@ into the human lane. ## Environments - The root `pixi.toml` is shared; reusability decides what goes in. A benchmark's candidates go task-local. -- A worktree runs on the main checkout's environment; only the human installs it, in the main checkout, one - session at a time. The session-start hook activates it for every Bash command, subagents' included: run - `python …` directly. Batch jobs, and anything run outside a session, use - `pixi run --manifest-path
/pixi.toml --as-is …`; without `--as-is`, pixi relocks or installs into main - whenever its manifest or lock is ahead of the environment. +- The human installs the main checkout's environment one session at a time. The session-start hook activates it + for every Bash command, subagents' included: run `python …` directly. Anything run outside a session uses + `pixi run --manifest-path
/pixi.toml --as-is …`, as batch jobs do; without `--as-is`, pixi relocks or + installs into main whenever its manifest or lock is ahead of the environment. - A task that changes `pixi.toml` locks, installs and tests it in a temporary checkout beside the main one, where the relative path dependencies resolve; main's environment stays untouched. Commit `pixi.toml`, then `git worktree add --detach
-lock HEAD` (`
` is the main checkout's path), and pass @@ -125,5 +128,5 @@ into the human lane. ## Review -Each repo keeps a `REVIEW_GUIDE.md` for reviewers that cannot load this plugin; its generic half is -[the template's](https://github.com/quadbio/analysis_template/blob/main/%7B%7Bcookiecutter.project_name%7D%7D/REVIEW_GUIDE.md). +A repo's PR template and the generic half of its `REVIEW_GUIDE.md`, for reviewers that cannot load this plugin, +come from [the analysis template](https://github.com/quadbio/analysis_template/tree/main/%7B%7Bcookiecutter.project_name%7D%7D). diff --git a/skills/analysis-workflow/assets/task_template/README.md b/skills/analysis-workflow/assets/task_template/README.md index 9ef6947..dc58104 100644 --- a/skills/analysis-workflow/assets/task_template/README.md +++ b/skills/analysis-workflow/assets/task_template/README.md @@ -1,47 +1,60 @@ -# +# **Date:** -One or two sentences on what this task set out to answer. + + + + +## Deliverables + + + +| deliverable | where | +| --- | --- | +| | `figures/` (gitignored, in the main checkout) | +| | Write-back, below | ## Inputs -Name the actual files, not concepts: this is what makes the task re-runnable. + - Working object: `data//processed/.../.zarr`, and the label keys used - Other artifacts consumed: `analysis//outputs/`, `data//processed/` -## Outputs - -| what | where | -| --- | --- | -| evidence tables | `results/` (tracked) | -| report | `reports/` (tracked) | -| figures | `figures/` (gitignored, in the main checkout) | -| data artifacts | `outputs/` (gitignored, in the main checkout) | - ## Write-back -What went into the working object, and under which keys. The version in this directory's name appears in every -key, so a key can be traced back here by grepping the task READMEs. + - `obs["_"]`: one line on what it holds -- `obsm["X__"]`: likewise - -Anything promoted to `data//processed/` at sign-off is recorded here with its final path. ## Running -From the task directory, on the main checkout's environment. Batch jobs get `MAIN=
` at submit and -log to `PATHS.logs`: + ```bash PY="pixi run --manifest-path
/pixi.toml --as-is python" -$PY scripts/.py +$PY 01_.py LOGS=$($PY -c "from _common import PATHS; print(PATHS.ensure().logs)") ``` -## Notes +## Jobs + + + +| queue | launched | completed | failed | cancelled | +| --- | --- | --- | --- | --- | +| | | | | | + +## PRs and issues + + -Decisions a reader would otherwise have to reverse-engineer: what was tried and rejected, which parameters are -load-bearing, what is still provisional. +- `#`: one or two lines on what it did diff --git a/skills/analysis-workflow/assets/task_template/_common.py b/skills/analysis-workflow/assets/task_template/scripts/_common.py similarity index 100% rename from skills/analysis-workflow/assets/task_template/_common.py rename to skills/analysis-workflow/assets/task_template/scripts/_common.py diff --git a/skills/analysis-workflow/references/repos.md b/skills/analysis-workflow/references/repos.md index 48d47a9..515249b 100644 --- a/skills/analysis-workflow/references/repos.md +++ b/skills/analysis-workflow/references/repos.md @@ -5,35 +5,33 @@ | template | `https://github.com/quadbio/analysis_template` | `https://github.com/scverse/cookiecutter-scverse` | | managed with | pixi | uv | -`uvx cruft update` pulls template changes later, including the generic half of `REVIEW_GUIDE.md`. +The analysis template's README says how to create a project from it and update it later. ## Creating a project -Ask the human for the project name, a one-line description, the author, and where the repos go (default: beside -their other repos). Then: +Ask the human for the answers to the template's prompts (its `cookiecutter.json`) and where the repos go (default: +beside their other repos). Then: -1. Analysis repo: `uvx cruft create