Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
36 changes: 14 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -25,10 +25,12 @@ claude --worktree <task>
```

1. The agent works on that worktree's branch, in a new task directory `analysis/<topic>/<task>_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
Expand All @@ -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": ["../<repo>-lock"] }
}
```

`<repo>` is the main checkout's directory name: a task changing the environment locks it in `../<repo>-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

Expand Down
178 changes: 178 additions & 0 deletions hooks/guard_task_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Keep every task directory in the template's layout.

A task directory (`analysis/<topic>/.../<name>_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 `<name>_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
Comment on lines +81 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not classify a lone versioned topic as a task

When a legitimate topic itself ends in _vN, writing a human notebook directly beneath it—such as analysis/cohort_v1/notes.ipynb—sets task to cohort_v1, after which problem() rejects the notebook as an invalid task entry. The skill permits notebooks flat under analysis/<topic>/ and imposes no topic-name restriction. Fresh evidence in the current revision is that the revised walk only passes over cohort_v1 when a later versioned directory replaces it, so the direct-notebook scenario from the earlier review remains broken.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by the convention instead of the hook, in 1eb2fb8: only task directories end in _vN, a topic never does. A marker heuristic (a README or scripts/ in the directory) only moves the ambiguity, since topics may have READMEs too.

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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include ignored artifacts in the stop scan

When a script or Bash command creates an off-layout artifact matched by the repository's ignore rules—for example, analysis/topic/demo_v1/model.zarr, since adoption explicitly adds *.zarr—this command omits it because --exclude-standard applies the standard Git exclusions. The preceding diff also cannot see an untracked path, so the Stop hook returns success and leaves the invalid task layout undetected. Fresh evidence after the earlier shell-change fix is that its untracked-file backstop explicitly excludes ignored files; scan ignored paths as well while allowing their contents only beneath the permitted output directories.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing: outputs come from task_paths and a hook blocks bare output paths, so an ignored artifact at a task's top is already prevented where it is written; ignored files never reach the PR; and scanning ignored paths would also walk a task-local .pixi environment. The cost is more than the case is worth.

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())
18 changes: 17 additions & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down Expand Up @@ -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
Comment on lines +35 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate layout changes made outside file tools

When a task is created or modified through Bash—such as cp, mkdir, sed, or an analysis script—the new layout guard never runs because it is registered only for Edit|Write|NotebookEdit. This leaves arbitrary top-level files, missing READMEs, and invalid headings undetected; the finishing command does not provide a backstop because src/analysis_workflow/cli.py::layout still checks only stray, orphaned, and oversized outputs and can report Clean. Add a repository-wide layout validation to the finish check or otherwise cover non-file-tool changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a06f447, as a Stop hook rather than in check layout: the installed package doesn't ship the plugin's task template, so it couldn't check headings. When a turn ends, the same guard checks the task directories with uncommitted changes (git status), which catches files made with cp, mkdir or a script, and earlier tasks are never scanned. It keeps the agent working once (stop_hook_active); tested.

}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard_task_layout.py\"",
"timeout": 15
}
]
}
Expand Down
7 changes: 4 additions & 3 deletions hooks/session_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<topic>/.../<name>_vN/`. A new task
directory needs a new session, which the human starts with `claude --worktree <name>` in the main checkout.
directory needs a new session, started with `claude --worktree <name>` 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/<dataset>/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
Expand Down
2 changes: 2 additions & 0 deletions hooks/session_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading