Repository navigation
Fixed task layout, PR bodies hold the details, a deliverables setting; release 0.4.0 #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9471359
3c06a01
e5da9ec
eed76ee
da5faa0
0d224f7
2f0dc48
a06f447
63fbac3
1eb2fb8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| } | ||
| } |
| 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 | ||
| 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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a script or Bash command creates an off-layout artifact matched by the repository's ignore rules—for example, Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not fixing: outputs come from |
||
| 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()) | ||
| 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": [ | ||
| { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a task is created or modified through Bash—such as Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in a06f447, as a Stop hook rather than in |
||
| } | ||
| ] | ||
| } | ||
| ], | ||
| "Stop": [ | ||
| { | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard_task_layout.py\"", | ||
| "timeout": 15 | ||
| } | ||
| ] | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a legitimate topic itself ends in
_vN, writing a human notebook directly beneath it—such asanalysis/cohort_v1/notes.ipynb—setstasktocohort_v1, after whichproblem()rejects the notebook as an invalid task entry. The skill permits notebooks flat underanalysis/<topic>/and imposes no topic-name restriction. Fresh evidence in the current revision is that the revised walk only passes overcohort_v1when a later versioned directory replaces it, so the direct-notebook scenario from the earlier review remains broken.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
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 orscripts/in the directory) only moves the ambiguity, since topics may have READMEs too.