From e567e5f717fe51ac086eda18085b571366da8378 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Mon, 7 Sep 2026 11:23:15 +0800 Subject: [PATCH] feat(trellis): enhance bundled skills and workflow integration - Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths. - Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions. - Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context. - Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence. - Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability. --- .../local-architecture/bundled-skills.md | 62 +- .../platform-files/hooks-and-settings.md | 3 +- .codex/hooks.json | 25 + .codex/hooks/inject-spec-context.py | 844 ++++++++++++++++++ .codex/hooks/inject-subagent-context.py | 18 +- .codex/hooks/inject-workflow-state.py | 68 +- .codex/hooks/session-start.py | 21 +- .pi/extensions/trellis/index.ts | 65 +- .trellis/.template-hashes.json | 40 +- .trellis/.version | 2 +- .trellis/config.yaml | 47 + .trellis/scripts/common/active_task.py | 155 +++- .trellis/scripts/common/config.py | 18 + .trellis/scripts/common/git_context.py | 36 +- .trellis/scripts/common/paths.py | 28 + .trellis/scripts/common/session_context.py | 34 +- .trellis/scripts/common/spec_inject.py | 439 +++++++++ .trellis/scripts/common/spec_match.py | 395 ++++++++ .trellis/scripts/common/task_context.py | 61 +- .trellis/scripts/common/task_store.py | 30 + .trellis/scripts/common/workflow_phase.py | 5 +- .trellis/scripts/common/workflow_selection.py | 177 ++++ .trellis/scripts/task.py | 82 ++ 23 files changed, 2525 insertions(+), 130 deletions(-) create mode 100755 .codex/hooks/inject-spec-context.py mode change 100644 => 100755 .codex/hooks/inject-subagent-context.py mode change 100644 => 100755 .codex/hooks/inject-workflow-state.py mode change 100644 => 100755 .codex/hooks/session-start.py create mode 100755 .trellis/scripts/common/spec_inject.py create mode 100755 .trellis/scripts/common/spec_match.py create mode 100755 .trellis/scripts/common/workflow_selection.py diff --git a/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md b/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md index ae28870..d8f9763 100644 --- a/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md +++ b/.agents/skills/trellis-meta/references/local-architecture/bundled-skills.md @@ -31,30 +31,42 @@ The list is discovered at runtime, so adding a new directory under `bundled-skil ## Where Bundled Skills Land Per Platform -Each platform configurator calls `writeSkills(, , resolveBundledSkills(ctx))` during `trellis init`. `resolveBundledSkills` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries. `writeSkills` then mirrors them under the platform's skill root. +A platform's whole file set — commands, workflow skills, agents, hooks, bundled skills — is described exactly once, by `collectTemplates()` in `packages/cli/src/configurators/.ts`. For bundled skills that description is two calls: `resolveBundledSkills(ctx)` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries; `collectSkillTemplates(, , )` folds them into the platform's `Map` under `//`. -| Platform | Bundled skill root | Notes | -| --- | --- | --- | -| Claude Code | `.claude/skills//` | `configureClaude` | -| Cursor | `.cursor/skills//` | `configureCursor` | -| Codex | `.agents/skills//` | `configureCodex` writes the shared `.agents/skills/` root, which Gemini CLI 0.40+ also reads | -| Gemini CLI | `.agents/skills//` | Same shared root as Codex; the two configurators are required to produce byte-identical output | -| Kiro | `.kiro/skills//` | `configureKiro` (skills-based platform — no commands) | -| Qoder | `.qoder/skills//` | `configureQoder` | -| Codebuddy | `.codebuddy/skills//` | `configureCodebuddy` | -| Copilot | `.github/skills//` | `configureCopilot` | -| Droid | `.factory/skills//` | `configureDroid` | -| Antigravity | `.agent/skills//` | `configureAntigravity` | -| Devin | `.devin/skills//` | `configureDevin` | -| Kilo | `.kilocode/skills//` | `configureKilo` | -| ZCode | `.zcode/skills//` | `configureZcode` | -| OpenCode | (handled by `collectOpenCodeTemplates`) | Uses the same `resolveBundledSkills(ctx)` output | -| Pi, Reasonix | (their own collectors) | Same `resolveBundledSkills(ctx)` output | +All 21 platforms receive the full bundled-skill set: -Two paths exercise the same data: +| Platform | Bundled skill root | +| --- | --- | +| Claude Code | `.claude/skills//` | +| Cursor | `.cursor/skills//` | +| OpenCode | `.opencode/skills//` | +| Codex | `.agents/skills//` | +| Gemini CLI | `.agents/skills//` | +| Pi | `.agents/skills//` | +| Kimi | `.agents/skills//` | +| Kilo | `.kilocode/skills//` | +| Kiro | `.kiro/skills//` | +| Antigravity | `.agent/skills//` | +| Devin | `.devin/skills//` | +| Qoder | `.qoder/skills//` | +| Codebuddy | `.codebuddy/skills//` | +| Copilot | `.github/skills//` | +| Droid | `.factory/skills//` | +| Reasonix | `.reasonix/skills//` | +| ZCode | `.zcode/skills//` | +| Trae | `.trae/skills//` | +| OMP | `.omp/skills//` | +| Grok | `.grok/skills//` | +| Snow | `.snow/skills//` | -1. `configureX(cwd)` writes files during `trellis init`. -2. `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) returns a `Map` that `trellis update` uses to detect drift and to populate `.trellis/.template-hashes.json`. Both must produce byte-identical output, so they both call `resolveBundledSkills(ctx)` and `collectSkillTemplates(root, …, resolveBundledSkills(ctx))`. +Codex, Gemini CLI, Pi and Kimi share the `.agents/skills/` root (the upstream Agent Skills workspace alias). Their collectors are required to emit byte-identical content for every file more than one of them writes there. + +One description, two consumers: + +1. `trellis init` → `configurePlatform(platformId, cwd)` → `writeTemplateMap(cwd, collectTemplates())`. For 18 of the 21 platforms the registry entry in `configurators/index.ts` is literally `fromTemplates(collectTemplates)`, which *is* that composition. Claude Code, Codex and ZCode spell out a `configure` of their own, each for work a `Map` cannot express (an opt-in `--with-statusline` flag, an intentionally empty `.codex/skills/` directory, a one-shot console notice) — none of them restates the file list. +2. `trellis update` → `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) → the same map, used to detect drift and to populate `.trellis/.template-hashes.json`. + +Because both consumers read the one description, init and update cannot disagree about which files a bundled skill produces. ## Dispatch Wiring (Code Path) @@ -67,10 +79,10 @@ The mechanism that auto-dispatches bundled skills to platform skill roots lives 2. `packages/cli/src/configurators/shared.ts` - `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `/` paths and resolved placeholders. - - `writeSkills(skillsRoot, workflowSkills, bundledSkills)` writes both workflow skills and bundled skill files under `skillsRoot`. - - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map` for the update / hash pipeline. + - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns workflow skills and bundled skill files together as a `Map` rooted at `skillsRoot`. + - `writeTemplateMap(cwd, files)` is the single writer that puts a collected map on disk. -Every platform configurator that supports skills imports both helpers (see `claude.ts`, `cursor.ts`, `codex.ts`, `gemini.ts`, `kiro.ts`, `qoder.ts`, `codebuddy.ts`, `copilot.ts`, `droid.ts`, `antigravity.ts`, `devin.ts`, `kilo.ts`). The `index.ts` `PLATFORM_FUNCTIONS` registry also calls `resolveBundledSkills(ctx)` inside each `collectTemplates` closure so `trellis update` tracking stays consistent. +Every platform that supports skills reaches those two helpers from its own `collectTemplates()` — either directly (`claude.ts`, `codex.ts`, `copilot.ts`, `gemini.ts`, `grok.ts`, `kimi.ts`, `kiro.ts`, `omp.ts`, `opencode.ts`, `pi.ts`, `reasonix.ts`, `snow.ts`, `zcode.ts`) or through `collectBothTemplates(ctx, cmdPath, skillRoot)` in `shared.ts`, which makes the same two calls on behalf of platforms that have both a commands directory and a skills root (`antigravity.ts`, `codebuddy.ts`, `cursor.ts`, `devin.ts`, `droid.ts`, `kilo.ts`, `qoder.ts`, `trae.ts`). ## Adding a New Bundled Skill @@ -137,7 +149,7 @@ There is no per-project opt-out flag for bundled skills. Two options: 2. **Pin a Trellis version that did not ship the skill.** The bundled-skill set is determined at build time, so installing an older release of the CLI is the only way to permanently exclude a skill that the current release ships. -A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional in every configurator. Adding such a flag would require changing `PLATFORM_FUNCTIONS` in `configurators/index.ts` and every `configureX` function. +A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional: `collectTemplates()` takes no arguments, so there is nowhere for a flag to enter. Adding one would mean changing that signature across all 21 platforms plus `collectPlatformTemplates` in `configurators/index.ts`. ## Operating Rules diff --git a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md index a2ff389..533997e 100644 --- a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md +++ b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -19,7 +19,7 @@ Common files: | Claude Code | `.claude/settings.json` | | Cursor | `.cursor/hooks.json` | | Codex | `.codex/hooks.json`, `.codex/config.toml` | -| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | +| OpenCode | `.opencode/package.json`, `.opencode/plugins/*`, `.opencode/hooks/inject-spec-context.py` | | Kiro | `.kiro/hooks/` + platform config | | Gemini CLI | `.gemini/settings.json` | | Qoder | `.qoder/settings.json` | @@ -40,6 +40,7 @@ Whether these files exist in a project depends on which `trellis init --` suffix (appended after each part is sanitized) keeps a +subagent's state separate from its parent's. When the resolver is unavailable +(older installed scripts tree), a minimal payload-only ladder (session keys, +then transcript hash) keeps the hook working. No key from any source, or an +unwritable state dir → stateless: no state IO at all, every hit is a TICKET +(circuit breaker — never a FULL re-emission loop). + +State: user-global, out of the repo, one append-only JSONL file per identity +under ${TRELLIS_SPEC_STATE_DIR:-~/.trellis/spec-inject}//.jsonl. +SessionStart(clear|compact) appends an opaque reset marker to the base session +shard; parent and subagent emission histories stay separate but observe that +shared marker. A best-effort fcntl lock is held across read→decide→append; +where fcntl is unavailable (Windows) the worst case is a duplicate injection. +A once-per-hour GC prunes conforming shards older than 48 h. + +Budget (config.yaml `spec_injection:`): per-spec cap `max_spec_chars` +(default 9400) with code-point truncation + in-body notice; per-event cap +`max_total_chars` (default 9500 — below Claude Code's documented +additionalContext ceiling, and enforced directly for Codex). Once the total +budget is exhausted, remaining FULL bodies degrade to one block; +tickets are counted last and dropped (with a stderr warning) only if even they +do not fit. + +Refresh window (config.yaml `spec_injection:`): `refresh_window_seconds` +(default 2700; `0` = never refresh unchanged content solely because time +passed). + +Fail-open on errors: non-matching events, malformed paths, no matches, or any +internal error → exit 0 with no stdout (stderr warnings allowed). The only +deliberate block is a Codex patch that just received a FULL governing spec. +""" +from __future__ import annotations + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import hashlib +import json +import os +import re +import sys +import time +import uuid +from pathlib import Path + +# IMPORTANT: Force UTF-8 on Windows for the streams this hook uses. +# stdin carries the payload (non-ASCII file paths), stdout carries the spec +# bodies, stderr carries warnings that quote both; without this the default +# ANSI codepage raises UnicodeDecodeError / UnicodeEncodeError. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass # Optional Windows stream setup; keep hook startup non-fatal. + + +# ============================================================================= +# Constants +# ============================================================================= + +DIR_WORKFLOW = ".trellis" +DIR_SPEC = "spec" + +# Tools whose events trigger spec matching (Claude Code tool names). Touching a +# file — even a Read — counts; the miss path stays a fast exit. Overridable via +# config `spec_injection.tools` (e.g. to drop "Read"). +DEFAULT_EDIT_TOOLS = ("Read", "Edit", "Write", "MultiEdit") + +# Budget defaults sized against Claude Code's documented 10,000-CHARACTER +# additionalContext ceiling — stay under with margin. The +# wrapper (~152 chars with typical rel paths) counts against the total, so a +# spec lands whole only up to ~9348 chars; at the per-spec cap the block is +# derived-truncated further to fit the event ceiling. `0` = unlimited. +DEFAULT_MAX_SPEC_CHARS = 9400 +DEFAULT_MAX_TOTAL_CHARS = 9500 + +# Refresh-window default. `0` = never refresh solely because time passed. +DEFAULT_REFRESH_WINDOW_SECONDS = 2700 + +# State-file base dir (overridable for tests / hermeticity) and GC policy. +STATE_ENV_DIR = "TRELLIS_SPEC_STATE_DIR" +STATE_DEFAULT_DIR = "~/.trellis/spec-inject" +GC_MARKER = ".last-gc" +GC_INTERVAL_SECONDS = 60 * 60 # GC runs at most once per hour +STATE_MAX_AGE_SECONDS = 48 * 60 * 60 # shards older than this are pruned + +# GC scope: exactly `//[.].jsonl`, never a +# recursive walk — a hostile or mistyped TRELLIS_SPEC_STATE_DIR must not turn +# this hook into an unlink loop over someone's files. The optional `.` +# alternative covers shards written by the pre-lock layout. +GC_PROJECT_DIR_RE = re.compile(r"^[0-9a-f]{16}$") +# `+` is part of the identity charset: subagent shards use the `+a-` +# suffix (contract amendment 2 — without it those shards were never pruned). +GC_SHARD_NAME_RE = re.compile(r"^[A-Za-z0-9_+-]+(\.[0-9]+)?\.jsonl$") +PATCH_PATH_RE = re.compile( + r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$" +) + +def _warn(message: str) -> None: + print(f"[inject-spec-context] WARN: {message}", file=sys.stderr) + + +def _patch_paths(command: str) -> list[str]: + """Return file paths from the shared apply_patch grammar.""" + paths: list[str] = [] + for line in command.splitlines(): + match = PATCH_PATH_RE.fullmatch(line) + if match: + path = match.group(1).strip() + if path and path not in paths: + paths.append(path) + return paths + + +def _agent_id(payload: dict) -> str: + """The subagent id carried by the event, or "" for a main-session event.""" + raw = payload.get("agent_id") + return raw.strip() if isinstance(raw, str) else "" + + +def find_trellis_root(start: Path) -> Path | None: + """Walk up from start to find the directory containing .trellis/. + + Handles CWD drift: subdirectory launches, monorepo packages, etc. + Returns None if no .trellis/ found (silent no-op). + """ + cur = start.resolve() + while cur != cur.parent: + if (cur / DIR_WORKFLOW).is_dir(): + return cur + cur = cur.parent + return None + + +def _scripts_dir_on_path(root: Path) -> None: + scripts_dir = root / DIR_WORKFLOW / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + + +# ============================================================================= +# Config (.trellis/config.yaml `spec_injection:` section) +# ============================================================================= + + +def _read_trellis_config(root: Path) -> dict: + """Load .trellis/config.yaml via the bundled trellis_config helper. + + The helper lives in .trellis/scripts/common; the hook lives outside the + scripts tree, so we extend sys.path before importing. + """ + _scripts_dir_on_path(root) + try: + from common.trellis_config import read_trellis_config # type: ignore[import-not-found] + except Exception: + return {} + try: + return read_trellis_config(root) + except Exception: + return {} + + +def _parse_tools(raw: object) -> tuple[str, ...] | None: + """Parse `spec_injection.tools` into a tuple of tool names. + + Two grammars, because the bundled YAML reader hands the value over in two + shapes: a block list (``- Edit`` items) arrives as a list, a flow sequence + (``tools: [Edit, Write]``) arrives as the raw string. ``[]`` in either + shape is a deliberate "never trigger" and is respected. Returns None for a + value that is neither (the caller warns and keeps the defaults). + """ + if isinstance(raw, list): + return tuple(t.strip() for t in raw if isinstance(t, str) and t.strip()) + if isinstance(raw, str): + text = raw.strip() + if text.startswith("[") and text.endswith("]"): + items = (part.strip().strip("\"'").strip() for part in text[1:-1].split(",")) + return tuple(item for item in items if item) + return None + + +def get_spec_injection_settings( + root: Path, +) -> tuple[bool, int, int, int, tuple[str, ...]]: + """Return (enabled, max_spec_chars, max_total_chars, + refresh_window_seconds, tools). + + Reads the ``spec_injection:`` section of ``.trellis/config.yaml``: + + spec_injection: + enabled: true + max_spec_chars: 9400 + max_total_chars: 9500 + refresh_window_seconds: 2700 + tools: + - Read + - Edit + - Write + - MultiEdit + + Missing keys use their defaults; ``0`` disables the corresponding limit + (``max_spec_chars: 0`` = inline the whole body, ``max_total_chars: 0`` = + no per-event ceiling) or refresh (window keys). ``tools`` also accepts a + flow sequence (``tools: [Edit, Write]``), and ``tools: []`` disables every + trigger. Invalid values fall back to the default for that key with a + stderr warning; tool names outside the known set warn once. + """ + enabled = True + tools = DEFAULT_EDIT_TOOLS + numbers = { + "max_spec_chars": DEFAULT_MAX_SPEC_CHARS, + "max_total_chars": DEFAULT_MAX_TOTAL_CHARS, + "refresh_window_seconds": DEFAULT_REFRESH_WINDOW_SECONDS, + } + + config = _read_trellis_config(root) + section = config.get("spec_injection") if isinstance(config, dict) else None + if isinstance(section, dict): + raw_enabled = section.get("enabled", True) + if isinstance(raw_enabled, bool): + enabled = raw_enabled + else: + s = str(raw_enabled).strip().lower() + if s in ("false", "no", "0", "off"): + enabled = False + elif s not in ("true", "yes", "1", "on"): + _warn( + f"invalid spec_injection.enabled value: {raw_enabled!r}; " + f"using true (default)" + ) + + # int() coercion stays local to this hook by decision (audit round, + # 2026-07-25): widening the shared common/config.py helpers has more + # blast radius than this small duplication costs. + for key, default_value in list(numbers.items()): + if key not in section: + continue + raw = section[key] + try: + value = int(raw) + except (TypeError, ValueError): + value = -1 + if value < 0: + _warn( + f"invalid spec_injection.{key} value: {raw!r}; " + f"using default {default_value}" + ) + continue + numbers[key] = value + + if "tools" in section: + parsed_tools = _parse_tools(section["tools"]) + if parsed_tools is None: + _warn( + f"invalid spec_injection.tools value: {section['tools']!r}; " + f"using default {list(DEFAULT_EDIT_TOOLS)}" + ) + else: + tools = parsed_tools + unknown = [t for t in tools if t not in DEFAULT_EDIT_TOOLS] + if unknown: + _warn( + f"unknown spec_injection.tools entries {unknown} — " + f"they will never match; known tools: " + f"{list(DEFAULT_EDIT_TOOLS)}" + ) + + return ( + enabled, + numbers["max_spec_chars"], + numbers["max_total_chars"], + numbers["refresh_window_seconds"], + tools, + ) + + +# ============================================================================= +# Identity ladder +# ============================================================================= + + +def _sanitize(raw: str) -> str: + """Map a session/agent id to a filename-safe, collision-free token. + + A readable head (the first 80 characters, every character outside + ``[A-Za-z0-9_-]`` replaced one-for-one by ``-``) plus, whenever anything + was replaced or the id was longer than 80 characters, ``-`` and 8 hex of + sha256(raw). The suffix is what makes the mapping injective: without it, + "a/b" and "a:b" — or two ids sharing an 80-character prefix — would fold + onto one state file, and a collision that MISSES an injection is the + unacceptable failure. Output stays inside the GC name class. + """ + raw = raw.strip() + head = raw[:80] + safe = re.sub(r"[^A-Za-z0-9_-]", "-", head) + if safe != head or len(raw) > 80: + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8] + return f"{safe}-{digest}" + return safe + + +def _shared_context_key(root: Path, payload: dict) -> str | None: + """Session/window key from the shared resolver every other hook uses. + + ``common.active_task.resolve_context_key`` is the single source of truth + for session identity: payload keys in all casings (``session_id`` / + ``sessionId`` / ``sessionID``, conversation and transcript variants), + nested payload shapes, the explicit ``TRELLIS_CONTEXT_ID`` override, + per-platform env fallbacks, and Cursor shell tickets — plus the platform + fixes accumulated behind them. Payload identity is preferred over + environment context so two live sessions can never collapse onto one + exported env value (collision → missed injection is the unacceptable + direction); the environment pass still runs when the payload carries + nothing. + """ + try: + _scripts_dir_on_path(root) + from common.active_task import resolve_context_key # type: ignore[import-not-found] + + key = resolve_context_key(payload, allow_environment_context=False) + if key: + return key + return resolve_context_key(payload) + except Exception: + return None + + +def resolve_base_identity(root: Path, payload: dict) -> tuple[str, bool]: + """Return the base session identity for reset and refresh state. + + When the shared resolver is unavailable (older installed scripts tree), a + minimal payload-only ladder keeps the hook working. ``stateless=True`` + means no state IO is possible. + """ + identity_payload = payload + if payload.get("hook_event_name") == "SessionStart": + # In this event `source` means startup/clear/compact, not platform. + # The shared resolver also accepts a generic `source` platform hint, + # so remove the lifecycle field to keep the same session identity as + # later PostToolUse events. + identity_payload = dict(payload) + identity_payload.pop("source", None) + key = _shared_context_key(root, identity_payload) + + if not key: + # Minimal payload-only fallback for scripts trees that predate + # resolve_context_key. Mirrors its payload lookup order. + for k in ("session_id", "sessionId", "sessionID"): + value = payload.get(k) + if isinstance(value, str) and value.strip(): + key = "s-" + value.strip() + break + if not key: + transcript = payload.get("transcript_path") + if isinstance(transcript, str) and transcript.strip(): + digest = hashlib.sha256( + transcript.strip().encode("utf-8") + ).hexdigest() + key = "t-" + digest[:16] + + if not key: + return "", True + + return _sanitize(key), False + + +# ============================================================================= +# State (one append-only JSONL file per identity, locked, user-global) +# ============================================================================= + + +def _state_base_dir() -> Path: + override = os.environ.get(STATE_ENV_DIR) + if override and override.strip(): + return Path(override.strip()) + return Path(os.path.expanduser(STATE_DEFAULT_DIR)) + + +def _project_id(root: Path) -> str: + return hashlib.sha256(os.path.realpath(str(root)).encode("utf-8")).hexdigest()[:16] + + +def _maybe_gc(base_dir: Path) -> None: + """Prune conforming shards older than 48 h, at most once per hour. + + Scope is exact-depth (``//.jsonl``) and name-gated; + foreign files and directories are never touched. Containment is enforced + against symlinks on both levels — a symlinked project dir or shard is + skipped outright, and every unlink candidate must still be under the + resolved base after realpath — so a planted link cannot walk this GC out + of its own tree. Best-effort, errors ignored. + """ + try: + base_real = os.path.realpath(str(base_dir)) + marker = base_dir / GC_MARKER + now = time.time() + try: + age = now - marker.stat().st_mtime + except OSError: + age = None + if age is not None and age < GC_INTERVAL_SECONDS: + return + try: + base_dir.mkdir(parents=True, exist_ok=True) + marker.touch() + except OSError: + return + try: + project_dirs = list(base_dir.iterdir()) + except OSError: + return + for project_dir in project_dirs: + if not GC_PROJECT_DIR_RE.match(project_dir.name): + continue + try: + if project_dir.is_symlink() or not project_dir.is_dir(): + continue + shards = list(project_dir.iterdir()) + except OSError: + continue + for shard in shards: + if not GC_SHARD_NAME_RE.match(shard.name): + continue + try: + if shard.is_symlink() or not shard.is_file(): + continue + shard_real = os.path.realpath(str(shard)) + if not shard_real.startswith(base_real + os.sep): + continue + if now - shard.stat().st_mtime > STATE_MAX_AGE_SECONDS: + shard.unlink() + except OSError: + continue + except Exception: + pass + + +def open_shard(shard_path: Path) -> int | None: + """Open (creating) the identity's shard for read+append. + + Doubles as the writability probe: a failure here trips the circuit breaker + and the event runs stateless (ticket-only), which is bounded, instead of + re-emitting full specs on every event forever. + """ + try: + shard_path.parent.mkdir(parents=True, exist_ok=True) + except OSError: + _warn(f"state dir {shard_path.parent} unusable — running stateless") + return None + try: + return os.open( + str(shard_path), + os.O_RDWR | os.O_CREAT | os.O_APPEND, + 0o644, + ) + except OSError: + _warn(f"state shard {shard_path} unusable — running stateless") + return None + + +def lock_shard(fd: int) -> None: + """Best-effort exclusive lock held across read→decide→append. + + Closes the duplicate-injection race between concurrent hook processes on + POSIX. No fcntl (Windows) or an unsupported filesystem → no lock; the + worst case is a duplicate injection, never a lost one. + """ + try: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_EX) + except Exception: + pass + + +def unlock_shard(fd: int) -> None: + try: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_UN) + except Exception: + pass + + +def load_state( + fd: int, + state_version: int, +) -> tuple[dict[str, dict], str | None] | None: + """Read the shard through the already-open fd; newest record per spec wins + (``ts`` decides, and on an exact tie the later line in the file does — + appends are ordered, and two records one float apart must not resolve to + the older one). The latest reset marker is returned separately. Malformed + lines and foreign schema versions are skipped silently; read failures + return None so the caller uses stateless ticket mode.""" + result: dict[str, dict] = {} + latest_reset: str | None = None + try: + os.lseek(fd, 0, os.SEEK_SET) + chunks: list[bytes] = [] + while True: + chunk = os.read(fd, 1 << 20) + if not chunk: + break + chunks.append(chunk) + except OSError: + return None + + text = b"".join(chunks).decode("utf-8", errors="replace") + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + if not isinstance(record, dict): + continue + if record.get("v") != state_version: + continue + spec = record.get("spec") + reset = record.get("reset") + if not isinstance(spec, str): + if isinstance(reset, str) and reset: + latest_reset = reset + continue + ts = record.get("ts") + if not isinstance(ts, (int, float)): + continue + previous = result.get(spec) + if previous is None or ts >= previous.get("ts", float("-inf")): + result[spec] = record + return result, latest_reset + + +def append_records(fd: int, records: list[dict]) -> bool: + """Append records as JSONL (O_APPEND) and report whether all bytes landed.""" + if not records: + return True + try: + blob = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records) + encoded = blob.encode("utf-8") + if os.write(fd, encoded) != len(encoded): + _warn("could not write complete state shard — state may be incomplete") + return False + return True + except OSError: + _warn("could not write state shard — state may be incomplete") + return False + + +# ============================================================================= +# Entry +# ============================================================================= + + +def main() -> int: + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return 0 + + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError, UnicodeDecodeError): + return 0 + if not isinstance(input_data, dict): + return 0 + + cwd = input_data.get("cwd") or os.getcwd() + root = find_trellis_root(Path(cwd)) + if root is None: + return 0 + # Bail out before any spec scan when the project has no spec directory. + if not (root / DIR_WORKFLOW / DIR_SPEC).is_dir(): + return 0 + + ( + enabled, + max_spec_chars, + max_total_chars, + win_seconds, + tools, + ) = get_spec_injection_settings(root) + if not enabled: + return 0 + + _scripts_dir_on_path(root) + try: + from common.spec_inject import STATE_VERSION # type: ignore[import-not-found] + except Exception: + return 0 + + if input_data.get("hook_event_name") == "SessionStart": + if input_data.get("source") not in ("clear", "compact"): + return 0 + base_identity, stateless = resolve_base_identity(root, input_data) + if stateless: + _warn("SessionStart reset has no stable session identity") + return 0 + base_dir = _state_base_dir() + _maybe_gc(base_dir) + reset_path = base_dir / _project_id(root) / f"{base_identity}.jsonl" + reset_fd = open_shard(reset_path) + if reset_fd is None: + return 0 + try: + lock_shard(reset_fd) + append_records( + reset_fd, + [{"v": STATE_VERSION, "reset": uuid.uuid4().hex, "ts": time.time()}], + ) + finally: + unlock_shard(reset_fd) + try: + os.close(reset_fd) + except OSError: + pass + return 0 + + event_name = input_data.get("hook_event_name") + tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "") + if not isinstance(tool_name, str) or not tool_name: + return 0 + is_pre_tool_use = event_name == "PreToolUse" + is_patch_tool = event_name == "PreToolUse" and tool_name == "apply_patch" + logical_tool = "Edit" if is_patch_tool else tool_name + # An empty `tools` list is the documented "disable every trigger" switch. + if not tools or logical_tool not in tools: + return 0 + + # snake_case is Claude Code's shape; camelCase keeps parity with the + # sibling hooks that already accept both (other platforms emit toolInput). + tool_input = input_data.get("tool_input") + if not isinstance(tool_input, dict): + tool_input = input_data.get("toolInput") + if not isinstance(tool_input, dict): + return 0 + try: + from common.spec_match import ( # type: ignore[import-not-found] + match_specs_for_file, + normalize_repo_relative, + ) + from common.spec_inject import ( # type: ignore[import-not-found] + assemble_payload, + ) + except Exception: + return 0 # matching/decision engine unavailable — degrade to nothing + + if is_patch_tool: + command = tool_input.get("command") + if not isinstance(command, str): + return 0 + raw_paths = _patch_paths(command) + else: + file_path = tool_input.get("file_path") + if not isinstance(file_path, str) or not file_path.strip(): + return 0 + raw_paths = [file_path.strip()] + + file_paths: list[str] = [] + for raw_path in raw_paths: + normalized = normalize_repo_relative(root, raw_path) + if normalized is not None and normalized not in file_paths: + file_paths.append(normalized) + if not file_paths: + return 0 + + matches = [] + match_files: dict[str, str] = {} + for file_path in file_paths: + for match in match_specs_for_file(root, file_path): + if match.rel_path in match_files: + continue + matches.append(match) + match_files[match.rel_path] = file_path + if not matches: + return 0 + + base_identity, stateless = resolve_base_identity(root, input_data) + identity = base_identity + agent = _agent_id(input_data) + if agent: + identity += "+a-" + _sanitize(agent) + + state_records: dict[str, dict] = {} + clock = {"reset": None, "ts": time.time()} + fd: int | None = None + + if not stateless: + base_dir = _state_base_dir() + _maybe_gc(base_dir) + project_dir = base_dir / _project_id(root) + base_fd = open_shard(project_dir / f"{base_identity}.jsonl") + if base_fd is None: + # Circuit breaker: unwritable state → ticket-only for this event. + stateless = True + else: + lock_shard(base_fd) + base_snapshot = load_state(base_fd, STATE_VERSION) + if base_snapshot is None: + stateless = True + unlock_shard(base_fd) + os.close(base_fd) + else: + base_records, reset_id = base_snapshot + if identity == base_identity: + fd = base_fd + state_records = base_records + else: + unlock_shard(base_fd) + os.close(base_fd) + fd = open_shard(project_dir / f"{identity}.jsonl") + if fd is None: + stateless = True + else: + lock_shard(fd) + snapshot = load_state(fd, STATE_VERSION) + if snapshot is None: + stateless = True + unlock_shard(fd) + os.close(fd) + fd = None + else: + state_records, _ = snapshot + + if not stateless: + clock = { + "reset": reset_id, + "ts": time.time(), + } + + edited_rel = match_files[matches[0].rel_path] + records_persisted = True + try: + payload, records = assemble_payload( + edited_rel, + matches, + stateless, + state_records, + clock, + max_spec_chars, + max_total_chars, + win_seconds, + match_files=match_files, + ) + if fd is not None and records: + records_persisted = append_records(fd, records) + finally: + if fd is not None: + unlock_shard(fd) + try: + os.close(fd) + except OSError: + pass + + if not payload: + return 0 + + hook_specific_output = { + "hookEventName": "PreToolUse" if is_pre_tool_use else "PostToolUse", + "additionalContext": payload, + } + if ( + is_pre_tool_use + and records_persisted + and any(record.get("mode") == "full" for record in records) + ): + hook_specific_output.update( + { + "permissionDecision": "deny", + "permissionDecisionReason": ( + "Trellis injected governing specs. Review them, then retry " + "this tool call." + ), + } + ) + output = {"hookSpecificOutput": hook_specific_output} + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + # Hook failures must never break the tool result or the session. + sys.exit(0) diff --git a/.codex/hooks/inject-subagent-context.py b/.codex/hooks/inject-subagent-context.py old mode 100644 new mode 100755 index cfe7b15..a30a834 --- a/.codex/hooks/inject-subagent-context.py +++ b/.codex/hooks/inject-subagent-context.py @@ -215,11 +215,12 @@ def truncate_utf8(data: bytes, cap: int) -> bytes: seq_len = 4 else: seq_len = 1 - # Drop the lead byte too if its full sequence didn't fit. + # Cut before the lead byte when its full sequence didn't fit; + # otherwise the trailing sequence is complete — keep it whole. if (i - 1) + seq_len > len(truncated): - i -= 1 + return truncated[: i - 1] - return truncated[:i] + return truncated class _Budget: @@ -877,8 +878,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None: if not subagent_type or not parent_session_id: return - cwd = _string_value(input_data.get("cwd")) or os.getcwd() - repo_root = find_repo_root(cwd) + # Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4) + # report "/" for every hook event. See inject-workflow-state.py. + repo_root = None + for candidate in (_string_value(input_data.get("cwd")), os.getcwd()): + if not candidate: + continue + repo_root = find_repo_root(candidate) + if repo_root: + break if not repo_root: return diff --git a/.codex/hooks/inject-workflow-state.py b/.codex/hooks/inject-workflow-state.py old mode 100644 new mode 100755 index ab8e276..d546356 --- a/.codex/hooks/inject-workflow-state.py +++ b/.codex/hooks/inject-workflow-state.py @@ -10,19 +10,24 @@ The emitted ``hookEventName`` field is platform-aware: most hosts expect CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed its per-turn event to ``BeforeAgent`` and its schema validator rejects the legacy name. ``_detect_platform`` picks the right value at runtime. -Breadcrumb text is pulled exclusively from workflow.md -[workflow-state:STATUS] tag blocks — workflow.md is the single source of -truth. There are no fallback dicts in this script: when workflow.md is +Breadcrumb text is pulled exclusively from the resolved workflow file's +[workflow-state:STATUS] tag blocks — the active task may select a +per-task variant (`.trellis/workflows/.md` via task.json `workflow`), +otherwise personal, team, and global defaults are resolved in order. +There are no fallback dicts in this script: when the resolved workflow is missing or a tag is absent, the breadcrumb degrades to a generic "Refer to workflow.md for current step." line so users see (and fix) the broken state instead of the hook silently masking it. -Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder, -CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI +Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM +in templates/shared-hooks/index.ts — currently Claude, Codex, Gemini, +Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae and ZCode. That table is the +source of truth; each listed platform's collectTemplates() pulls +this file into its template map through collectSharedHooks(), and a single +writer puts that map on disk at init time. Kiro wires this via the CLI custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook`` ``promptSubmit`` event; its output branch emits a plain-text breadcrumb -(Kiro adds hook stdout directly to the conversation context). Written to -each platform's hooks directory via writeSharedHooks() at init time. +(Kiro adds hook stdout directly to the conversation context). Silent exit 0 cases (no output): - No .trellis/ directory found (not a Trellis project) @@ -95,11 +100,16 @@ def find_trellis_root(start: Path) -> Optional[Path]: def _detect_platform(input_data: dict) -> str | None: if isinstance(input_data.get("cursor_version"), str): return "cursor" + # CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set + # alongside their own variable — CodeBuddy, ZCode and Trae all do. It must + # therefore be checked LAST, or every one of them is detected as claude and + # the context key becomes `claude_`. That key does not + # match the session file `task.py start` wrote under the host's real name, + # so every turn reports no_task while the pointer exists on disk. + # Observed on CodeBuddy IDE 4.10.4: session file `codebuddy_ae54840e….json` + # alongside marker `update-check-claude_ae54840e….marker`, same id. env_map = { - # ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check - # ZCODE first so ZCode sessions aren't misdetected as claude. "ZCODE_PROJECT_DIR": "zcode", - "CLAUDE_PROJECT_DIR": "claude", "CURSOR_PROJECT_DIR": "cursor", "CODEBUDDY_PROJECT_DIR": "codebuddy", "FACTORY_PROJECT_DIR": "droid", @@ -108,6 +118,8 @@ def _detect_platform(input_data: dict) -> str | None: "KIRO_PROJECT_DIR": "kiro", "COPILOT_PROJECT_DIR": "copilot", "TRAE_PROJECT_DIR": "trae", + # Last: the shared alias, only meaningful once no vendor key matched. + "CLAUDE_PROJECT_DIR": "claude", } for env_name, platform in env_map.items(): if os.environ.get(env_name): @@ -183,16 +195,40 @@ _TAG_RE = re.compile( re.DOTALL, ) -def load_breadcrumbs(root: Path) -> dict[str, str]: - """Parse workflow.md for [workflow-state:STATUS] blocks. +def _resolve_workflow_md(root: Path, input_data: dict) -> Path: + """Resolve the active task's workflow file, falling back to the global one. - Returns {status: body_text}. workflow.md is the single source of + The per-task resolution rule lives in common.workflow_selection inside + .trellis/scripts. Older installed projects may not ship that module, and + hooks must never crash the session — ANY failure (import error, old + scripts tree, resolver bug) falls back to the global workflow.md. + """ + try: + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found] + + return resolve_workflow_md( + root, input_data, platform=_detect_platform(input_data) + ) + except Exception: + return root / ".trellis" / "workflow.md" + + +def load_breadcrumbs(root: Path, input_data: dict) -> dict[str, str]: + """Parse the resolved workflow file for [workflow-state:STATUS] blocks. + + Returns {status: body_text}. The workflow file is the single source of truth — there are no fallback dicts in this script. Missing tags - (or a missing/unreadable workflow.md) fall back to a generic line + (or a missing/unreadable workflow file) fall back to a generic line in build_breadcrumb so users see the broken state and fix workflow.md, rather than the hook silently masking the issue. + The active task's per-task workflow selection (task.json `workflow` + field) is honored via _resolve_workflow_md; without a selection this + reads the global .trellis/workflow.md exactly as before. """ - workflow = root / ".trellis" / "workflow.md" + workflow = _resolve_workflow_md(root, input_data) if not workflow.is_file(): return {} try: @@ -411,7 +447,7 @@ def main() -> int: if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)): return 0 # user opted out of the per-turn breadcrumb for this turn - templates = load_breadcrumbs(root) + templates = load_breadcrumbs(root, data) platform = _detect_platform(data) task = get_active_task(root, data) if task is None: diff --git a/.codex/hooks/session-start.py b/.codex/hooks/session-start.py old mode 100644 new mode 100755 index ca5608f..5725132 --- a/.codex/hooks/session-start.py +++ b/.codex/hooks/session-start.py @@ -452,6 +452,25 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str: return re.sub(r"\n{3,}", "\n\n", stripped).strip() +def _resolve_workflow_md(root: Path, input_data: dict) -> Path: + """Resolve the active task's workflow file, falling back to the global one. + + The per-task resolution rule lives in common.workflow_selection inside + .trellis/scripts. Older installed projects may not ship that module, and + hooks must never crash the session — ANY failure (import error, old + scripts tree, resolver bug) falls back to the global workflow.md. + """ + try: + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found] + + return resolve_workflow_md(root, input_data, platform="codex") + except Exception: + return root / ".trellis" / "workflow.md" + + def _build_workflow_toc(workflow_path: Path) -> str: """Inject only the compact Phase Index summary for SessionStart.""" content = read_file(workflow_path) @@ -505,7 +524,7 @@ Trellis compact SessionStart context. Use it to orient the session; load details output.write("\n\n\n") output.write("\n") - output.write(_build_workflow_toc(trellis_dir / "workflow.md")) + output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input))) output.write("\n\n\n") output.write("\n") diff --git a/.pi/extensions/trellis/index.ts b/.pi/extensions/trellis/index.ts index f14c487..379ca7f 100644 --- a/.pi/extensions/trellis/index.ts +++ b/.pi/extensions/trellis/index.ts @@ -772,10 +772,11 @@ function truncateUtf8(buf: Buffer, cap: number): Buffer { if ((lead & 0xe0) === 0xc0) seqLen = 2; else if ((lead & 0xf0) === 0xe0) seqLen = 3; else if ((lead & 0xf8) === 0xf0) seqLen = 4; - // Drop the lead byte too if its full sequence didn't fit. - if (i - 1 + seqLen > cap) i--; + // Cut before the lead byte when its full sequence didn't fit; + // otherwise the trailing sequence is complete — keep it whole. + if (i - 1 + seqLen > cap) return buf.subarray(0, i - 1); } - return buf.subarray(0, i); + return buf.subarray(0, cap); } function stripInlineComment(value: string): string { @@ -1069,10 +1070,66 @@ function readTaskDir(root: string, key: string | null): string | null { } // ── Workflow State Breadcrumb ───────────────────────────────────────── +const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/; +const DEFAULT_WORKFLOW_RE = + /^default_workflow:\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/m; + +function workflowVariant(root: string, workflowId: string): string { + if (!WORKFLOW_ID_RE.test(workflowId)) return ""; + const path = join(root, ".trellis", "workflows", `${workflowId}.md`); + return exists(path) ? path : ""; +} + +function developerWorkflowId(root: string): string { + for (const line of readText(join(root, ".trellis", ".developer")).split( + /\r?\n/, + )) { + if (line.startsWith("workflow=")) + return line.slice("workflow=".length).trim(); + } + return ""; +} + +function configDefaultWorkflowId(root: string): string { + return ( + readText(join(root, ".trellis", "config.yaml")).match( + DEFAULT_WORKFLOW_RE, + )?.[2] ?? "" + ); +} + +/** Mirrors common/workflow_selection.py without spawning another Python + * process on every Pi turn. Invalid or missing selections fall through. */ +function resolveWorkflowMd(root: string, key: string | null): string { + const taskDir = readTaskDir(root, key); + let workflowId = ""; + if (taskDir) { + try { + const task = JSON.parse( + readText(join(taskDir, "task.json")), + ) as JsonObject; + workflowId = typeof task.workflow === "string" ? task.workflow : ""; + } catch {} + } + if (workflowId) { + const pinned = workflowVariant(root, workflowId); + if (pinned) return pinned; + console.error( + `Warning: active task selects workflow ${JSON.stringify(workflowId)} but .trellis/workflows/ has no matching file; using default workflow resolution`, + ); + } + + const personal = workflowVariant(root, developerWorkflowId(root)); + if (personal) return personal; + const team = workflowVariant(root, configDefaultWorkflowId(root)); + if (team) return team; + return join(root, ".trellis", "workflow.md"); +} + const WF_RE = /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g; function workflowBreadcrumb(root: string, key: string | null): string { - const wf = readText(join(root, ".trellis", "workflow.md")); + const wf = readText(resolveWorkflowMd(root, key)); if (!wf) return ""; const templates: Record = {}; for (const m of wf.matchAll(WF_RE)) { diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json index dfecd69..8eeaa48 100644 --- a/.trellis/.template-hashes.json +++ b/.trellis/.template-hashes.json @@ -24,7 +24,7 @@ ".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d", ".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519", ".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4", - ".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37", + ".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389", ".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084", ".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6", ".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9", @@ -34,7 +34,7 @@ ".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", ".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd", ".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245", - ".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319", + ".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "cb7c1c7dd976004e4981181ab9462f8eedc4e9c014721419bd666d6608f4dd72", ".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4", ".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca", ".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3", @@ -50,10 +50,10 @@ ".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308", ".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188", ".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4", - ".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677", - ".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609", - ".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde", - ".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02", + ".codex/hooks/session-start.py": "91fbbd30ac974c3cd2b4db152aa38ac176a9c7e3d062cbaa1acacf07152b2592", + ".codex/hooks/inject-subagent-context.py": "db413933ff30e1503f37f1d292fb421b1ced3f39f1890351a6621e499ec16b2c", + ".codex/hooks/inject-workflow-state.py": "cda5888c29671035e7d2e033a0fcc631a9428a0543fb2dd071285b30c6ca4e23", + ".codex/hooks.json": "c16c9af7f6010bf4fabb64fd4bd497201d34583b96c665ea1d5739eed48b3fb5", ".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb", ".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d", ".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c", @@ -61,40 +61,44 @@ ".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496", ".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2", ".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a", - ".pi/extensions/trellis/index.ts": "b4bfd740d517462f943aaff920dd39371d3e6bdc3823dfa8c2752314c57eaf9b", + ".pi/extensions/trellis/index.ts": "770290e675fabfe0dd0889771876607927d36cb9ef68586552e6e2d56ca345fc", ".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c", "AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682", ".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175", ".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87", - ".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa", + ".trellis/config.yaml": "eaba56c36fb07483fcbc96d74c4da0ab33b9739fb9ff10ea4cc1e1cd32bf23b2", ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", ".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881", ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", - ".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119", + ".trellis/scripts/common/active_task.py": "28a81f8828538fb70a15c88edd90eda9d685adbde8862f67f630bce5b27d9832", ".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307", - ".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca", + ".trellis/scripts/common/config.py": "43a22c4e88a06d6316d1bcd4730731bdecc22ae782e9f217b25ea53aa85cd416", ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", ".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2", - ".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d", + ".trellis/scripts/common/git_context.py": "be0c68d4b566319484cf95a3ca6d6dee18e00f040cecadea6a98f10dc445f967", ".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb", ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", - ".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", + ".trellis/scripts/common/paths.py": "5f66eb073c296a8a920048c1c53d8236783ccb998c8c25a68a540534d30abd02", ".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446", - ".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017", - ".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d", + ".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5", + ".trellis/scripts/common/task_context.py": "be5fa407f99c2400075194dbaa8b6ec996ce8aac2122d191bb9cb11e479a9476", ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", - ".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6", + ".trellis/scripts/common/task_store.py": "793b9e7863fade04b0d84c7668bacb4229c435f0fcdba008bd333421ec3e17b3", ".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871", ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", ".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf", ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", - ".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8", + ".trellis/scripts/common/workflow_phase.py": "c3d00011a4d8c3d958ec57cea50b97f4170dce7c28381ad3915dae0870167d6e", ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", ".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79", ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", - ".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4", - ".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76" + ".trellis/scripts/task.py": "7790d9510311d55ed1f66c71b9ce0bafc8871f1b675c1251dcf6a1f4e823d2b3", + ".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76", + ".trellis/scripts/common/workflow_selection.py": "b136d6ae41aac95f4d5f425b641b62b7002f7cff2d26aa44b056cef9a2242753", + ".trellis/scripts/common/spec_match.py": "baf3b17b15c279475f99699ac6e039bc68285d5dbfbda28f279aa8346cb0c59a", + ".trellis/scripts/common/spec_inject.py": "9987d213eed3d2ec1b2c16adcad8b10a21d3b68dcce838f85c42d83c56e69e0b", + ".codex/hooks/inject-spec-context.py": "4df3945be18163afe066ec3061d8b95a687f676e3a80dbf0aada4dec8c339f90" } } \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version index e9acb99..d290713 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.6.12 \ No newline at end of file +0.7.0-beta.3 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml index 4eaf288..1808cec 100644 --- a/.trellis/config.yaml +++ b/.trellis/config.yaml @@ -76,6 +76,21 @@ max_journal_lines: 2000 # Default package used when --package is not specified. # default_package: frontend +#------------------------------------------------------------------------------- +# Default workflow +#------------------------------------------------------------------------------- +# Team-shared default workflow for tasks that do not pin one. The id names a +# variant file in `.trellis/workflows/.md` (populate it with +# `trellis workflow --save `). This value is committed, so the whole team +# shares the same default. A per-developer override lives in the gitignored +# `.developer` file as a `workflow=` line and takes precedence over this. +# +# Resolution precedence: per-task (task.json `workflow`) > personal +# (`.developer` `workflow=`) > this `default_workflow` > global +# `.trellis/workflow.md`. +# +# default_workflow: native + #------------------------------------------------------------------------------- # Channel worker OOM guard #------------------------------------------------------------------------------- @@ -145,6 +160,38 @@ channel: # max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md) # max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines +#------------------------------------------------------------------------------- +# Path-scoped spec injection +#------------------------------------------------------------------------------- +# When the agent touches a file (Read/Edit/Write/MultiEdit), spec .md files +# under .trellis/spec/ whose frontmatter `paths:` globs match the touched path +# are surfaced into the session right then. The first time a spec matches it is +# injected in full; while its content is unchanged and the refresh window has +# not elapsed it stays silent; once the window elapses a short `` +# reminder is emitted to counter recency decay. Editing the spec itself — or a +# SessionStart after /clear or /compact — re-injects the full text. +# Oversized specs are truncated with a notice; once the per-event payload cap +# is reached, remaining full bodies degrade to index lines (path + description) +# instead of being inlined. +# +# Character values: the ceiling this budget respects is Claude Code's +# documented 10,000-CHARACTER additionalContext limit, so the caps count +# characters too (byte caps made CJK specs pay 3x for the same text). +# `0` disables the corresponding limit. +# The refresh window uses wall-clock seconds. `0` disables time-based reminders; +# SessionStart resets after /clear or /compact still force a full re-injection. +# +# spec_injection: +# enabled: true # false disables injection entirely +# max_spec_chars: 9400 # per matched spec file +# max_total_chars: 9500 # whole per-event payload; overflow degrades to index lines +# refresh_window_seconds: 2700 # touches past this interval re-emit a ticket +# tools: # tool events that trigger injection +# - Read +# - Edit +# - Write +# - MultiEdit + #------------------------------------------------------------------------------- # Per-turn prompt injection #------------------------------------------------------------------------------- diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py index 0eec6df..702b864 100755 --- a/.trellis/scripts/common/active_task.py +++ b/.trellis/scripts/common/active_task.py @@ -23,8 +23,15 @@ DIR_WORKFLOW = ".trellis" DIR_TASKS = "tasks" DIR_RUNTIME = ".runtime" DIR_SESSIONS = "sessions" -DIR_CURSOR_SHELL = "cursor-shell" -CURSOR_SHELL_TICKET_TTL_SECONDS = 30 +DIR_SHELL_TICKETS = "shell-tickets" +# Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that +# was mid-command across an upgrade does not silently degrade; never written. +# Tickets are 30-second ephemera, so the old directory ages out by itself — +# there is nothing to migrate, only a glob on a directory that is normally +# absent. The alternative (ignore it) would land its one lost command on the +# platform that works today. +DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell" +SHELL_TICKET_TTL_SECONDS = 30 TASK_SESSION_COMMANDS = {"start", "current", "finish"} _SESSION_KEYS = ("session_id", "sessionId", "sessionID") @@ -50,35 +57,75 @@ _KNOWN_PLATFORMS = { "snow", } +# Every name below records how it was checked. Do NOT add a name by analogy +# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21 +# declared names had never existed anywhere — they were pattern-guessed from a +# `_SESSION_ID` shape no vendor agreed to, and the uniformity was the +# only "evidence" behind them. A platform with no verified name belongs in no +# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge. _ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")), - ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")), - ("cursor", ("CURSOR_SESSION_ID",)), - ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")), + # REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash + # child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID + # was removed here — verified absent from that same live environment. + ("claude", ("CLAUDE_CODE_SESSION_ID",)), + # REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0 + # into shell children, absent from the parent env; openai/codex#19937). + # CODEX_SESSION_ID was removed — absent from a live `codex exec` env. + ("codex", ("CODEX_THREAD_ID",)), + # REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's + # hookRunner.ts. Its shell tool builds the child env in + # shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so + # this never reaches a bash child — it resolves only inside a hook process. ("gemini", ("GEMINI_SESSION_ID",)), - ("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")), + # REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/ + # extensions/hooks documents it as injected during hook execution by the + # Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma. ("qoder", ("QODER_SESSION_ID",)), - ("codebuddy", ("CODEBUDDY_SESSION_ID",)), + # UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace + # dtctl, oh-my-agent and gastown all key agent detection on it and one notes + # it is "set in both interactive and --no-interactive". Kept because that is + # absence of evidence, not evidence of absence. To settle: run + # `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro. ("kiro", ("KIRO_SESSION_ID",)), + # UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/ + # hooks-reference and from the CLI programmatic reference. To settle: run + # `copilot help environment` (the authoritative list per those docs) — not + # runnable here, the CLI is not installed and copilot-cli ships no source. ("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")), - ("pi", ("PI_SESSION_ID", "PI_SESSIONID")), - ("trae", ("TRAE_SESSION_ID",)), - # ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID). - # Platform-scoped lookup (_iter_env_keys filters by platform name), so this - # only fires when the resolver already detected "zcode" — no collision with + # REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not + # installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT + # / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously + # declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the + # name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first, + # keep the historical name as a fallback: if neither exists nothing changes. + # Platform-scoped lookup (_iter_env_keys filters by platform name), so the + # entry only fires once the resolver detected "zcode" — no collision with # the claude entry above. - ("zcode", ("CLAUDE_SESSION_ID",)), - # Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children. - # TRELLIS_CONTEXT_ID remains the preferred override when present. + ("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")), + # REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts + # exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names + # Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred + # override — Snow sets that too. ("snow", ("SNOW_SESSION_ID",)), ) _ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + # REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value + # matches ~/.cursor/chats//). The Cursor *IDE* is unverified — a + # 2026-05 forum request for it drew no staff reply. The invented + # CURSOR_SESSION_ID was removed from the session table: empty in a live + # cursor-agent shell. Cursor's other path is the shell ticket below + # (_lookup_shell_ticket_context_key), which is not Cursor-specific. ("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")), ) _ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("claude", ("CLAUDE_TRANSCRIPT_PATH",)), - ("codex", ("CODEX_TRANSCRIPT_PATH",)), + # REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook + # scripts; empty in the agent's own shell env. ("cursor", ("CURSOR_TRANSCRIPT_PATH",)), + # UNVERIFIED — never researched. The 2026-08-05 audit covered the session + # table only, so do not infer these are real *or* fake from that work + # (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were* + # checked: absent from docs and from live envs). To settle each: run + # `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call. ("gemini", ("GEMINI_TRANSCRIPT_PATH",)), ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")), ("qoder", ("QODER_TRANSCRIPT_PATH",)), @@ -90,11 +137,15 @@ _ENV_PLATFORM_ALIASES = { "factory-ai": "droid", "github-copilot": "copilot", } -# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode, -# while later shell commands see only the shared env name and resolve it through -# the Claude entry. Canonicalize both paths to one runtime filename. +# ZCode intentionally reuses Claude's session env var name. Hooks know the host +# is ZCode, while later shell commands see only the shared env name and resolve +# it through the claude entry. Canonicalize both paths to one runtime filename. _CONTEXT_KEY_PLATFORM_ALIASES = { "zcode": "claude", + # Factory Droid's config directory is `.factory/`, so a hook that names its + # platform after the directory it was installed in reports "factory". Its + # sibling hooks report "droid". One runtime filename either way. + "factory": "droid", } @@ -222,6 +273,12 @@ def _iter_env_keys( env_keys: tuple[tuple[str, tuple[str, ...]], ...], platform_name: str | None, ) -> tuple[tuple[str, tuple[str, ...]], ...]: + """Narrow an env-key table to one platform, or return all of it. + + A platform with no entry yields an empty tuple, and the caller's `for` loop + simply does not run. That is the normal case, not an error: platforms with + no verified env var name are deliberately absent from these tables. + """ if not platform_name: return env_keys matched = tuple((name, keys) for name, keys in env_keys if name == platform_name) @@ -275,8 +332,12 @@ def _find_repo_root_from_cwd() -> Path | None: current = current.parent -def _cursor_shell_ticket_dir(repo_root: Path) -> Path: - return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL +def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]: + runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME + return ( + runtime_dir / DIR_SHELL_TICKETS, + runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS, + ) def _remove_file(path: Path) -> bool: @@ -334,7 +395,7 @@ def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> b created_at = ticket.get("created_at_epoch") if isinstance(created_at, (int, float)): - if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS: + if now - created_at <= SHELL_TICKET_TTL_SECONDS: return True _remove_file(ticket_path) return False @@ -352,13 +413,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool: return True -def _matching_cursor_ticket_context_key( +def _matching_ticket_context_key( ticket_path: Path, repo_root: Path, now: float, ) -> str | None: + """Accept a ticket on its merits, never on which platform wrote it. + + The `platform` field a ticket carries is debugging metadata; gating on it + was what kept this bridge invisible to every platform but Cursor. + """ ticket = _read_json(ticket_path) - if ticket is None or ticket.get("platform") != "cursor": + if ticket is None: return None if not _ticket_is_fresh(ticket, ticket_path, now): return None @@ -369,29 +435,30 @@ def _matching_cursor_ticket_context_key( return _string_value(ticket.get("context_key")) -def _lookup_cursor_shell_ticket_context_key() -> str | None: - """Resolve Cursor conversation identity from a short-lived shell ticket. +def _lookup_shell_ticket_context_key() -> str | None: + """Resolve session identity from a short-lived shell ticket. - Cursor exposes `conversation_id` to `beforeShellExecution`, but does not - export it into the shell command environment. The Cursor hook writes a - short-lived ticket just before `task.py` runs. We accept a ticket only when - the current `task.py` subcommand matches and exactly one fresh context key - matches, which avoids cross-window pointer contamination. + No researched platform exports its session id into a shell child, but every + hook-capable one hands that id to a hook. So the hook that runs just before + a shell command writes a ticket, and this reads it back. A ticket counts + only when it is fresh, was written for this repo, and matches the `task.py` + subcommand now running — and only when exactly one fresh context key + matches. Two concurrent windows therefore both degrade rather than one + inheriting the other's pointer. """ repo_root = _find_repo_root_from_cwd() if repo_root is None: return None - ticket_dir = _cursor_shell_ticket_dir(repo_root) - if not ticket_dir.is_dir(): - return None - now = time.time() candidates: set[str] = set() - for ticket_path in ticket_dir.glob("*.json"): - context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now) - if context_key: - candidates.add(context_key) + for ticket_dir in _shell_ticket_dirs(repo_root): + if not ticket_dir.is_dir(): + continue + for ticket_path in ticket_dir.glob("*.json"): + context_key = _matching_ticket_context_key(ticket_path, repo_root, now) + if context_key: + candidates.add(context_key) if len(candidates) == 1: return next(iter(candidates)) @@ -435,8 +502,10 @@ def resolve_context_key( if env_context_key: return env_context_key - if allow_environment_context and platform_name in (None, "session", "cursor"): - return _lookup_cursor_shell_ticket_context_key() + # Last in the chain on purpose: a platform that genuinely exports identity + # into the shell outranks a ticket, and no platform name gates the lookup. + if allow_environment_context: + return _lookup_shell_ticket_context_key() return None diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py index 99f79b2..69453ea 100755 --- a/.trellis/scripts/common/config.py +++ b/.trellis/scripts/common/config.py @@ -281,6 +281,24 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str: return "inline" +def get_default_workflow(repo_root: Path | None = None) -> str | None: + """Return the team-shared default workflow id from config.yaml. + + Reads the top-level ``default_workflow`` key — a slug naming a variant in + ``.trellis/workflows/.md``. Returns None when unset or blank. This is + the git-tracked, team-shared default; a per-developer override lives in the + gitignored ``.developer`` file (``workflow=``, see + ``paths.get_developer_workflow``) and outranks it. Fail-open: a missing or + non-string value simply means "no team default" — no warning (this is read + on every turn by hooks and must not add per-turn noise). + """ + config = _load_config(repo_root) + raw = config.get("default_workflow") + if not isinstance(raw, str): + return None + return raw.strip() or None + + DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768 DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536 DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072 diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py index 23fc6ec..fb608c4 100755 --- a/.trellis/scripts/common/git_context.py +++ b/.trellis/scripts/common/git_context.py @@ -27,6 +27,8 @@ from .packages_context import ( get_context_packages_text, get_context_packages_json, ) +from .paths import get_repo_root +from .spec_match import match_specs_for_file from .trellis_config import read_trellis_config from .workflow_phase import ( filter_platform, @@ -57,9 +59,9 @@ def main() -> None: parser.add_argument( "--mode", "-m", - choices=["default", "record", "packages", "phase"], + choices=["default", "record", "packages", "phase", "spec"], default="default", - help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)", + help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction), spec (specs governing a file)", ) parser.add_argument( "--step", @@ -69,6 +71,10 @@ def main() -> None: "--platform", help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.", ) + parser.add_argument( + "--file", + help="File path (absolute or repo-relative) for --mode spec. Lists spec files whose frontmatter paths match it.", + ) args = parser.parse_args() @@ -95,6 +101,32 @@ def main() -> None: ) content = filter_platform(content, effective) print(content, end="") + elif args.mode == "spec": + if not args.file: + parser.error("--file is required with --mode spec") + matches = match_specs_for_file(get_repo_root(), args.file) + if args.json: + print( + json.dumps( + { + "file": args.file, + "matches": [ + { + "path": match.rel_path, + "description": match.description, + } + for match in matches + ], + }, + indent=2, + ensure_ascii=False, + ) + ) + elif matches: + for match in matches: + print(f"{match.rel_path} — {match.description or '(no description)'}") + else: + print(f"No spec files declare paths matching {args.file}.") else: if args.json: output_json() diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py index 1c5a58e..ff3b78d 100755 --- a/.trellis/scripts/common/paths.py +++ b/.trellis/scripts/common/paths.py @@ -94,6 +94,34 @@ def get_developer(repo_root: Path | None = None) -> str | None: return None +def get_developer_workflow(repo_root: Path | None = None) -> str | None: + """Get the personal workflow override from the .developer file. + + Reads an optional ``workflow=`` line from the gitignored ``.developer`` + file — the per-developer, git-excluded override that outranks the + team-shared ``default_workflow`` in config.yaml. Returns None when the file + or the line is absent/blank. Never raises. Additive to ``get_developer``: + the ``name=`` reader ignores this line and vice versa. + """ + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + + if not dev_file.is_file(): + return None + + try: + content = dev_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if line.startswith("workflow="): + return line.split("=", 1)[1].strip() or None + except (OSError, IOError): + pass + + return None + + def check_developer(repo_root: Path | None = None) -> bool: """Check if developer is initialized. diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py index 8f1fd1c..1e3fe1f 100755 --- a/.trellis/scripts/common/session_context.py +++ b/.trellis/scripts/common/session_context.py @@ -9,6 +9,7 @@ Provides: get_context_text_record - Text for record mode output_json - Print JSON output_text - Print text + get_update_hint - Once-per-session "update available" line """ from __future__ import annotations @@ -417,8 +418,16 @@ def _compare_versions(left: str, right: str) -> int | None: return _compare_prerelease(left_prerelease, right_prerelease) -def _update_marker_path(repo_root: Path) -> Path: - context_key = resolve_context_key() +def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path: + """Path of the once-per-session marker that throttles the update check. + + `context_key` lets a caller that already resolved session identity pass it + in — the SessionStart hook reads the session id from hook stdin, which is + more reliable than this function's environment-only fallback chain. Shell + entry points leave it None and keep the previous behavior. + """ + if not context_key: + context_key = resolve_context_key() if not context_key: terminal_key = os.environ.get("TERM_SESSION_ID", "").strip() context_key = terminal_key or f"ppid-{os.getppid()}" @@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path: ) -def _mark_update_check_attempted(repo_root: Path) -> bool: - marker_path = _update_marker_path(repo_root) +def _mark_update_check_attempted( + repo_root: Path, + context_key: str | None = None, +) -> bool: + marker_path = _update_marker_path(repo_root, context_key) if marker_path.exists(): return False try: @@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool: return True -def _get_update_hint(repo_root: Path) -> str | None: - marker_path = _update_marker_path(repo_root) +def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None: + """Return the "update available" line for this session, at most once. + + Public because the SessionStart hook imports it: the text-mode CLI path + (`get_context.py`) used to be the only caller, so hook-driven platforms — + Claude Code included — never saw the reminder at all. + """ + marker_path = _update_marker_path(repo_root, context_key) if marker_path.exists(): return None @@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None: if not latest_version: return None - _mark_update_check_attempted(repo_root) + _mark_update_check_attempted(repo_root, context_key) comparison = _compare_versions(current_version, latest_version) if comparison is None or comparison >= 0: return None @@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None: """ if repo_root is None: repo_root = get_repo_root() - update_hint = _get_update_hint(repo_root) + update_hint = get_update_hint(repo_root) if update_hint: print(update_hint) print("") diff --git a/.trellis/scripts/common/spec_inject.py b/.trellis/scripts/common/spec_inject.py new file mode 100755 index 0000000..b70a21c --- /dev/null +++ b/.trellis/scripts/common/spec_inject.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Decision logic for path-scoped spec injection (ticket-refresh model). + +Pure logic only: the per-spec decision engine, block rendering and budgeted +payload assembly. Importing this module has no side effects; every piece of IO +orchestration (stdin, config, identity, state files, locking, GC) lives in the +platform hook that calls it. Unit tests import this module directly. + +Clock + The periodic refresh window uses epoch seconds. Context resets are + explicit lifecycle events: the hook records an opaque reset identifier, + and a mismatch with the last emission re-teaches the spec in full. + +Budget + All caps are in characters, because the platform's ``additionalContext`` + ceiling is 10,000 *characters* (counting bytes made CJK specs pay 3x). + Truncation slices code points, which can never split a multi-byte + sequence. The per-event cap is enforced on the assembled payload string — + wrappers, ``\\n\\n`` separators, index block and tickets all counted — so + nothing is ever appended unchecked. +""" + +from __future__ import annotations + +import hashlib +import sys +from typing import Any, Sequence + +from .spec_match import SpecMatch + +# Bound on the size of a spec file we are willing to read and hash. A spec +# larger than this degrades to an index line (warned) — an inlined body that +# big could never fit the budget anyway, and the read+hash would be unbounded. +MAX_SPEC_SOURCE_BYTES = 10 * 1024 * 1024 + +# Upper bound on the room reserved for named index lines while FULL blocks are +# still being packed. Beyond it the reserve falls back to the summary line +# alone: a big fan-out must not starve the specs that can still be taught. +INDEX_RESERVE_MAX_CHARS = 900 + +# State record schema version. Records with any other version are ignored +# (safe direction: an ignored record re-injects rather than stays silent). +STATE_VERSION = 2 + +def _warn(message: str) -> None: + print(f"[WARN] spec_inject: {message}", file=sys.stderr) + + +# ============================================================================= +# Clock +# ============================================================================= + + +def within_window( + clock: dict[str, Any], + last: dict[str, Any], + win_seconds: int, +) -> bool: + """True when the last emission is still inside the refresh window (→ stay + silent). + + A window of ``0`` means never refresh (infinite window → always True). + Missing timestamps or a negative delta are past-window (False → refresh), + the safe side of the misfire asymmetry. + """ + cur_ts = clock.get("ts") + last_ts = last.get("ts") + if isinstance(cur_ts, (int, float)) and isinstance(last_ts, (int, float)): + if win_seconds == 0: + return True + delta = cur_ts - last_ts + return 0 <= delta < win_seconds + + return False + + +def decide( + stateless: bool, + last: dict[str, Any] | None, + sha256_hex: str, + clock: dict[str, Any], + win_seconds: int, +) -> str: + """Return one of ``"full"`` | ``"ticket"`` | ``"silent"`` for a spec. + + Order is contractual: statelessness first (bounded cost, no state to + consult), then first sight, content change, context reset, the refresh + window, and finally completeness. A ticket says "you were shown this spec", + which is a lie when the recorded FULL was truncated, so an incomplete + record is re-taught in full instead. + """ + if stateless: + return "ticket" + if last is None: + return "full" + if last.get("sha256") != sha256_hex: + return "full" + if clock.get("reset") != last.get("reset"): + return "full" + + if within_window(clock, last, win_seconds): + return "silent" + + # "complete" is optional and absent means a whole body was shown. + if last.get("complete") is False: + return "full" + return "ticket" + + +# ============================================================================= +# Rendering +# ============================================================================= + + +def truncate_chars(text: str, cap: int) -> str: + """Slice ``text`` to at most ``cap`` code points. ``cap <= 0`` = no limit.""" + if cap <= 0 or len(text) <= cap: + return text + return text[:cap] + + +def truncation_notice(rel_path: str, cap: int) -> str: + return ( + f"\n[Trellis: truncated at {cap} characters — " + f"read {rel_path} for the full content]" + ) + + +def render_full(edited_rel: str, spec_rel: str, sha12: str, body: str) -> str: + return ( + f'\n' + f"{body}\n" + f"" + ) + + +def render_ticket( + edited_rel: str, + spec_rel: str, + sha12: str, + stateless: bool, +) -> str: + """Render a ticket block. + + ``stateless=True`` covers both the no-identity and circuit-breaker paths: + there is no record of a prior emission, so the wording must not claim one. + """ + if stateless: + body = ( + "This spec governs the file you just touched. If you have not read it in\n" + f"this session, Read {spec_rel} before continuing." + ) + else: + body = ( + "You were shown this spec earlier in this session and its content is unchanged.\n" + "It still governs edits to matching files. If you no longer remember it, Read\n" + f"{spec_rel} before continuing." + ) + return ( + f'\n' + f"{body}\n" + f"" + ) + + +def _index_block(lines: Sequence[str]) -> str: + return "\n" + "\n".join(lines) + "\n" + + +# ============================================================================= +# State records +# ============================================================================= + + +def make_record( + rel_path: str, + sha256_hex: str, + mode: str, + clock: dict[str, Any], + complete: bool = True, +) -> dict[str, Any]: + """Build a state record. ``complete=False`` marks a FULL whose body was + truncated below the whole spec — an absent flag means whole.""" + record: dict[str, Any] = { + "v": STATE_VERSION, + "spec": rel_path, + "sha256": sha256_hex, + "mode": mode, + "ts": clock.get("ts"), + } + if isinstance(clock.get("reset"), str): + record["reset"] = clock["reset"] + if not complete: + record["complete"] = False + return record + + +# ============================================================================= +# Payload assembly +# ============================================================================= + + +def _derive_fitting_full( + edited_rel: str, + spec_rel: str, + sha12: str, + text: str, + max_spec_chars: int, + fits, +) -> tuple[str, bool] | None: + """Largest truncated FULL block that fits the remaining total budget. + + Binary search over the body cap: the rendered block's length is monotone + non-decreasing in the cap, so the largest cap whose block still ``fits`` + is found in ~log2(len(text)) renders (this also absorbs the digit-length + wobble of the notice text, which a closed-form estimate cannot). + + The search ceiling is ``max_spec_chars`` when set and the whole body when + it is ``0`` (unlimited) — with a ceiling of 1, as an unguarded + ``max(1, 0)`` would give, nothing but a one-character spec could ever be + derived. Returns ``(block, complete)`` — ``complete`` is True only when + the winning cap covered the whole body — or None when no non-empty prefix + fits (the caller degrades to an index line). + """ + def candidate_for(cap: int) -> str: + body = truncate_chars(text, cap) + if len(body) < len(text): + body += truncation_notice(spec_rel, cap) + return render_full(edited_rel, spec_rel, sha12, body) + + ceiling = len(text) if max_spec_chars <= 0 else min(max_spec_chars, len(text)) + lo, hi = 1, max(1, ceiling) + best: tuple[str, bool] | None = None + while lo <= hi: + mid = (lo + hi) // 2 + candidate = candidate_for(mid) + if fits(candidate): + best = (candidate, mid >= len(text)) + lo = mid + 1 + else: + hi = mid - 1 + return best + + +def _index_line(match: SpecMatch) -> str: + return f"- {match.rel_path} — {match.description or 'no description'}" + + +def assemble_payload( + edited_rel: str, + matches: Sequence[SpecMatch], + stateless: bool, + state_records: dict[str, dict[str, Any]], + clock: dict[str, Any], + max_spec_chars: int, + max_total_chars: int, + win_seconds: int, + match_files: dict[str, str] | None = None, +) -> tuple[str, list[dict[str, Any]]]: + """Assemble the additionalContext payload from the matched specs. + + Returns ``(payload, records)`` where ``records`` are the state lines to + append for the emissions that actually made it into the payload (silent + hits and budget-dropped emissions record nothing — they stay eligible). + + Every candidate block is measured against the *assembled* payload string + (``"\\n\\n".join(...)``), so the per-event character ceiling holds for the + exact string that is emitted. + + ``match_files`` maps a governing spec to the first matching file in a + multi-file tool call. Single-file callers omit it and retain the original + ``edited_rel`` behavior. + """ + blocks: list[str] = [] + + def file_for(match: SpecMatch) -> str: + return (match_files or {}).get(match.rel_path, edited_rel) + + def fits(candidate: str, reserve: int = 0) -> bool: + """Does ``candidate`` fit the per-event ceiling, keeping ``reserve`` + characters free for what still has to be appended after it?""" + if max_total_chars <= 0: + return True + return len("\n\n".join([*blocks, candidate])) + reserve <= max_total_chars + + # Reserve while candidates are still pending: the index lines those + # candidates would actually need (true strings, not estimates) plus the + # summary line — so a derived-cap FULL cannot eat the budget and starve + # the specs behind it (measured: 10-spec fan-out at max_total_chars 3000 + # emitted one 3000-char FULL and dropped the other nine silently). The + # named part is only guaranteed within INDEX_RESERVE_MAX_CHARS; beyond + # that the reserve falls back to the summary line alone. + _all_index_lines = [_index_line(m) for m in matches] + _summary_upper = ( + f"- (+{len(matches)} more governing specs over budget — run " + f"python3 ./.trellis/scripts/get_context.py --mode spec " + f"--file {edited_rel} to list them)" + ) + _summary_reserve = len("\n\n" + _index_block([_summary_upper])) + + def reserve_for(pending: Sequence[str]) -> int: + if not pending: + return 0 # Nothing can follow this block — nothing to reserve. + named = len("\n\n" + _index_block([*pending, _summary_upper])) + if named > INDEX_RESERVE_MAX_CHARS: + return _summary_reserve + return named + + index_lines: list[str] = [] + ticket_pending: list[tuple[str, str, str]] = [] # (file, spec, sha256) + records: list[dict[str, Any]] = [] + + for match_idx, match in enumerate(matches): + try: + size = match.spec_path.stat().st_size + except OSError: + size = 0 + if size > MAX_SPEC_SOURCE_BYTES: + # Too big to read+hash, let alone inline: name it and move on. + _warn( + f"{match.rel_path} is {size} bytes (over " + f"{MAX_SPEC_SOURCE_BYTES}) — degraded to an index line" + ) + index_lines.append(_index_line(match)) + continue + + try: + data = match.spec_path.read_bytes() + except OSError: + _warn(f"cannot read {match.rel_path} — skipped") + continue + + sha256_hex = hashlib.sha256(data).hexdigest() + sha12 = sha256_hex[:12] + last = None if stateless else state_records.get(match.rel_path) + decision = decide(stateless, last, sha256_hex, clock, win_seconds) + + if decision == "silent": + continue + + if decision == "ticket": + # Deferred: tickets are counted against the budget last. + ticket_pending.append((file_for(match), match.rel_path, sha256_hex)) + continue + + pending = [*index_lines, *_all_index_lines[match_idx + 1 :]] + reserve = reserve_for(pending) + _fits = (lambda c: fits(c, reserve)) + + text = data.decode("utf-8", errors="replace") + body = truncate_chars(text, max_spec_chars) + complete = len(body) >= len(text) + if not complete: + body += truncation_notice(match.rel_path, max_spec_chars) + matching_file = file_for(match) + block = render_full(matching_file, match.rel_path, sha12, body) + if not _fits(block): + # Contract amendment 1: before degrading, truncate FURTHER to the + # largest body prefix that fits the remaining total budget + # (wrapper + notice counted). Without this, the frozen defaults + # made the truncation path unreachable (body cap + notice + + # wrapper > total cap) and long specs fell straight to an index + # line — the rejected index-only mode by another route. + derived = _derive_fitting_full( + matching_file, + match.rel_path, + sha12, + text, + max_spec_chars, + _fits, + ) + if derived is not None: + derived_block, derived_complete = derived + blocks.append(derived_block) + records.append( + make_record( + match.rel_path, sha256_hex, "full", clock, derived_complete + ) + ) + continue + # No usable prefix fits — degrade to an index line, never drop + # silently. Not recorded: stays eligible for a later event. + index_lines.append(_index_line(match)) + continue + blocks.append(block) + records.append( + make_record(match.rel_path, sha256_hex, "full", clock, complete) + ) + + if index_lines: + # The index block is budget-bounded too: lines that do not fit collapse + # into one summary line (count + how to list them via pull mode) so the + # ceiling is honored without silently dropping a governing spec. + chosen: list[str] = [] + dropped = 0 + for line in index_lines: + if fits(_index_block([*chosen, line])): + chosen.append(line) + else: + dropped += 1 + if dropped: + # Contract amendment 3: the summary must actually be reachable. + # Greedy packing rarely leaves a summary-sized gap, so pop chosen + # lines (re-counting them as dropped) until the summary fits — + # only an absurdly small total budget can drop it entirely. + while True: + noun = "spec" if dropped == 1 else "specs" + summary = ( + f"- (+{dropped} more governing {noun} over budget — run " + f"python3 ./.trellis/scripts/get_context.py --mode spec " + f"--file {edited_rel} to list them)" + ) + if fits(_index_block([*chosen, summary])): + chosen.append(summary) + break + if not chosen: + _warn( + f"spec index summary for {edited_rel} dropped — " + f"per-event budget exhausted" + ) + break + chosen.pop() + dropped += 1 + if chosen: + blocks.append(_index_block(chosen)) + + for matching_file, spec_rel, sha256_hex in ticket_pending: + ticket = render_ticket( + matching_file, spec_rel, sha256_hex[:12], stateless + ) + if not fits(ticket): + _warn(f"ticket for {spec_rel} dropped — per-event budget exhausted") + continue + blocks.append(ticket) + records.append(make_record(spec_rel, sha256_hex, "ticket", clock)) + + return "\n\n".join(blocks), records diff --git a/.trellis/scripts/common/spec_match.py b/.trellis/scripts/common/spec_match.py new file mode 100755 index 0000000..b5620a1 --- /dev/null +++ b/.trellis/scripts/common/spec_match.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +Path-scoped spec matching for on-demand spec injection. + +Spec files under `.trellis/spec/**/*.md` MAY start with a YAML-like +frontmatter block declaring which repo paths they govern: + + --- + name: commands-workflow + description: workflow command conventions + paths: + - packages/cli/src/commands/workflow.ts + - packages/cli/src/utils/workflow-resolver.ts + --- + +The parser is hand-rolled (house pattern, modeled on +``trellis_config.parse_simple_yaml`` — no YAML dependency) and reads only a +bounded head of each file (16 KiB / 200 lines, whichever ends first). Only +files whose first line is exactly ``---`` are considered. ``name:`` / +``description:`` single-line strings are recognized (description is reused in +index lines). ``paths:`` accepts both a block list (``- `` items) and a +flow sequence (``paths: [a, b]``). + +The parser is deliberately tolerant — a spec is prose that happens to carry a +routing hint, not a config file. Unknown keys, unrecognized line shapes and +stray ``- item`` lines are ignored; block scalars (``key: >`` / ``key: |``) +consume their more-indented continuation lines, so a SKILL.md-style +``description: >`` paragraph does not disqualify the file. An opening ``---`` +with no recognized key before the closing marker is not frontmatter at all +(a Markdown horizontal rule opening the prose) and is ignored silently. Two +things are errors, and both warn + skip the whole file rather than route on a +half-read block: a malformed ``paths:`` (a scalar where a list belongs — that +key is the one thing the rest of the pipeline depends on), and a frontmatter +block that is still open when the head bound is reached. + +Glob grammar (repo-relative, POSIX separators): + +- ``*`` matches within a single path segment (never crosses ``/``) +- ``?`` matches exactly one character within a segment +- ``**`` as a whole segment matches zero or more segments +- a trailing ``/`` is sugar for ``/**`` +- ``**`` embedded in a segment with other characters degrades to ``*`` + +Validation rejects only what is unsafe or meaningless: empty globs, a leading +``/`` (globs are repo-relative), ``..`` segments, backslashes (POSIX +separators only) and control characters. Everything else is legal — real +repositories carry ``@scope`` packages, ``[slug]`` routes, ``(marketing)`` +groups and non-ASCII directories, and the translation escapes literals +character by character. An invalid glob is skipped with a stderr warning; the +rest of the file's globs still apply. + +Translation examples (glob → matches / non-matches): + + packages/cli/src/commands/update.ts + matches only that exact file + packages/cli/src/commands/*.ts + matches packages/cli/src/commands/update.ts + not packages/cli/src/commands/channel/spawn.ts + packages/cli/src/templates/** + matches packages/cli/src/templates/trellis/index.ts (any depth) + not packages/cli/src/templates (the directory itself) + packages/**/index.ts + matches packages/index.ts and packages/cli/src/index.ts + src/util?.py + matches src/utils.py, not src/util.py or src/utilXY.py + packages/cli/ + same as packages/cli/** + +Provides: + SpecMatch - frozen match record (spec_path, rel_path, description) + match_specs_for_file - map an edited file to the specs that govern it + normalize_repo_relative - the canonical repo-relative path normalization + parse_spec_frontmatter - parse the optional frontmatter head block + glob_to_regex - deterministic glob → compiled regex translation +""" + +from __future__ import annotations + +import re +import sys +import unicodedata +from dataclasses import dataclass +from pathlib import Path + +from .paths import DIR_SPEC, DIR_WORKFLOW +from .trellis_config import _strip_inline_comment, _unquote + +# Bounded head-read limits for frontmatter scanning (design contract). +HEAD_MAX_BYTES = 16384 +HEAD_MAX_LINES = 200 + +# Recognized frontmatter keys. An opening `---` block that declares none of +# them is prose under a horizontal rule, not frontmatter. +_KNOWN_KEYS = ("paths", "name", "description") + +_GLOB_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") +_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(.*)$") +# YAML block-scalar introducers; the value lives in the indented lines below. +_BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+") + +# macOS and Windows filesystems are case-insensitive: the very same file can +# be handed to us in a case the glob author never wrote. Match case-insensitively +# there — over-injecting a spec is the safe side of the asymmetry. +_CASE_INSENSITIVE_FS = sys.platform == "darwin" or sys.platform.startswith("win") +_GLOB_FLAGS = re.IGNORECASE if _CASE_INSENSITIVE_FS else 0 + + +@dataclass(frozen=True) +class SpecFrontmatter: + """Parsed frontmatter head. ``paths`` is None when the key is absent.""" + + paths: tuple[str, ...] | None + name: str | None + description: str | None + + +@dataclass(frozen=True) +class SpecMatch: + spec_path: Path + """Absolute path to the spec file.""" + rel_path: str + """Repo-relative POSIX path, for display.""" + description: str | None + """Frontmatter ``description:`` value, if declared.""" + + +def _warn(message: str) -> None: + print(f"[WARN] spec_match: {message}", file=sys.stderr) + + +def _parse_flow_sequence(value: str) -> list[str]: + """Split a YAML flow sequence body (``[a, b]``) into unquoted items. + + Commas separate; each item is trimmed and unquoted. Empty items (a + trailing comma, ``[]``) collapse away. + """ + inner = value[1:-1] + items = (_unquote(part.strip()).strip() for part in inner.split(",")) + return [item for item in items if item] + + +def _read_head(path: Path) -> str: + """Read at most HEAD_MAX_BYTES from the file, decoded as UTF-8.""" + with open(path, "rb") as f: + data = f.read(HEAD_MAX_BYTES) + return data.decode("utf-8", errors="replace") + + +def parse_spec_frontmatter(head_text: str) -> SpecFrontmatter | None: + """Parse the optional frontmatter block from a spec file's head. + + Returns None when the file has no frontmatter: either the first line is not + ``---``, or the block declares no recognized key before its closing marker + (a horizontal rule opening a prose file — silent, not an error). + + Raises ValueError on a malformed ``paths:`` key (a scalar where a list + belongs) and on a block that is still open when the head bound + (HEAD_MAX_LINES / HEAD_MAX_BYTES) is reached — routing on a half-read + frontmatter would be worse than skipping the file loudly. Every other line + shape is tolerated and ignored. + """ + lines = head_text.splitlines()[:HEAD_MAX_LINES] + if not lines: + return None + first = lines[0].lstrip("\ufeff") # tolerate a UTF-8 BOM + if first != "---": + return None + + paths: list[str] | None = None + name: str | None = None + description: str | None = None + pending_key: str | None = None + block_indent: int | None = None + saw_known_key = False + closed = False + + for line in lines[1:]: + stripped = line.strip() + indent = len(line) - len(line.lstrip()) + + if block_indent is not None: + # Inside a block scalar: everything more indented (and blank lines) + # is its value. A dedent ends the block; that line still counts. + if not stripped or indent > block_indent: + continue + block_indent = None + + if stripped == "---": + closed = True + break + if not stripped or stripped.startswith("#"): + continue + + if stripped == "-" or stripped.startswith("- "): + if pending_key == "paths" and paths is not None: + item = _unquote(_strip_inline_comment(stripped[1:].strip()).strip()) + paths.append(item) + # List items outside `paths:` are tolerated and ignored. + continue + + key_match = _KEY_RE.match(stripped) + if key_match is None: + continue # Unrecognized line shape — tolerated and ignored. + + key = key_match.group(1) + saw_known_key = saw_known_key or key in _KNOWN_KEYS + raw_value = key_match.group(2).strip() + if raw_value in _BLOCK_SCALARS: + if key == "paths": + raise ValueError("'paths' must be a list of globs") + pending_key = None + block_indent = indent + continue + + value = _unquote(_strip_inline_comment(raw_value).strip()) + if value: + pending_key = None + if key == "paths": + if not (value.startswith("[") and value.endswith("]")): + raise ValueError("'paths' must be a list of globs") + paths = _parse_flow_sequence(value) + elif key == "name": + name = value + elif key == "description": + description = value + # Unknown scalar keys are tolerated and ignored. + else: + pending_key = key + if key == "paths": + paths = [] + + if not saw_known_key: + # An opening `---` with no recognized key is a horizontal rule, not a + # frontmatter block. Silent by design: prose files are not malformed. + return None + if not closed: + raise ValueError( + f"frontmatter block never closed within the head bound " + f"({HEAD_MAX_BYTES} bytes / {HEAD_MAX_LINES} lines)" + ) + + return SpecFrontmatter( + paths=tuple(paths) if paths is not None else None, + name=name, + description=description, + ) + + +def validate_glob(glob: str) -> str | None: + """Return an error message for an invalid glob, or None when valid. + + Deny-list, not allow-list: only what is unsafe or meaningless is rejected + (see module docstring). Everything else — ``@scope``, ``[slug]``, + ``(marketing)``, non-ASCII directory names — is a legal path in a real + repository and translates fine. + """ + if not glob: + return "empty glob" + if glob.startswith("/"): + return "absolute paths are not allowed (globs are repo-relative)" + if ".." in glob.split("/"): + return "'..' segments are not allowed" + if "\\" in glob: + return "backslashes are not allowed (globs use POSIX '/' separators)" + if _GLOB_CONTROL_RE.search(glob): + return "contains control characters" + return None + + +def glob_to_regex(glob: str) -> re.Pattern[str]: + """Translate a validated glob to a compiled full-match regex. + + Deterministic, segment-based translation (see module docstring for the + grammar and examples): ``**`` as a whole segment spans zero or more + segments; ``*`` becomes ``[^/]*``; ``?`` becomes ``[^/]``; everything + else is escaped literally. A trailing ``/`` is expanded to ``/**`` first. + On case-insensitive filesystems (macOS, Windows) the pattern compiles with + ``re.IGNORECASE`` — see ``_CASE_INSENSITIVE_FS``. + """ + if glob.endswith("/"): + glob += "**" + segments = glob.split("/") + parts: list[str] = [] + for i, seg in enumerate(segments): + is_last = i == len(segments) - 1 + if seg == "**": + # Last: consume the rest of the path (at least the separator + # boundary is already emitted by the previous segment). Not last: + # zero or more whole segments including their separators. + parts.append(".*" if is_last else r"(?:[^/]+/)*") + continue + piece = "".join( + "[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch) + for ch in seg + ) + parts.append(piece if is_last else piece + "/") + return re.compile("^" + "".join(parts) + "$", _GLOB_FLAGS) + + +def normalize_repo_relative(repo_root: Path, file_path: str | Path) -> str | None: + """Canonical repo-relative POSIX path — the one normalization in the + pipeline, used both for matching and for display. + + Root and file are fully resolved (``strict=False``, so a file that no + longer exists still normalizes): symlinked repo roots, macOS's + ``/tmp`` → ``/private/tmp`` and ``..`` segments cannot make one file look + like two different paths. The result is NFC-normalized (macOS hands out + NFD filenames). Relative inputs are taken as repo-relative. Returns None + when the file resolves outside the repo. + """ + try: + root = Path(repo_root).resolve(strict=False) + candidate = Path(file_path) + if not candidate.is_absolute(): + text = str(file_path).replace("\\", "/") + while text.startswith("./"): + text = text[2:] + if not text: + return None + candidate = root / text + rel = candidate.resolve(strict=False).relative_to(root).as_posix() + except (OSError, ValueError): + return None + return unicodedata.normalize("NFC", rel) + + +def match_specs_for_file(repo_root: Path, file_path: str | Path) -> list[SpecMatch]: + """Return specs whose frontmatter ``paths:`` globs match file_path. + + ``file_path`` may be absolute or repo-relative. More specific matching + globs are returned first; ``rel_path`` is the deterministic tie-break. + Scans ``.trellis/spec/**/*.md`` with bounded head-reads only. Never raises; + unreadable or malformed spec files are skipped with a stderr warning. + """ + try: + repo_root = Path(repo_root).resolve() + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + if not spec_dir.is_dir(): + return [] + rel = normalize_repo_relative(repo_root, file_path) + if rel is None: + return [] + + matches: list[SpecMatch] = [] + specificity: dict[str, tuple[int, int, int, int]] = {} + for spec_file in spec_dir.rglob("*.md"): + spec_rel = spec_file.relative_to(repo_root).as_posix() + try: + head = _read_head(spec_file) + except OSError as exc: + _warn(f"cannot read {spec_rel}: {exc}") + continue + try: + frontmatter = parse_spec_frontmatter(head) + except ValueError as exc: + _warn(f"malformed frontmatter in {spec_rel}: {exc}") + continue + if frontmatter is None or not frontmatter.paths: + continue + for glob in frontmatter.paths: + error = validate_glob(glob) + if error is not None: + _warn(f"invalid glob {glob!r} in {spec_rel}: {error}") + continue + if glob_to_regex(glob).match(rel): + scored_glob = glob + "**" if glob.endswith("/") else glob + wildcard_count = scored_glob.count("*") + scored_glob.count("?") + segments = scored_glob.split("/") + literal_segments = sum( + "*" not in segment and "?" not in segment + for segment in segments + ) + specificity[spec_rel] = ( + 0 if wildcard_count == 0 else 1, + -literal_segments, + wildcard_count, + -(len(scored_glob) - wildcard_count), + ) + matches.append( + SpecMatch( + spec_path=spec_file, + rel_path=spec_rel, + description=frontmatter.description, + ) + ) + break + + # Payload assembly spends its budget in this order. Exact and narrowly + # scoped matches must therefore outrank broad tree globs; alphabetic + # order is only a deterministic tie-break. + matches.sort(key=lambda m: (*specificity[m.rel_path], m.rel_path)) + return matches + except Exception as exc: # Never raise — callers are hooks/context tools. + _warn(f"spec scan failed: {exc}") + return [] diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py index 1c7a126..14994e9 100755 --- a/.trellis/scripts/common/task_context.py +++ b/.trellis/scripts/common/task_context.py @@ -25,7 +25,7 @@ from .config import get_context_injection_limits from .git import branch_exists_locally from .io import read_json from .log import Colors, colored -from .paths import FILE_TASK_JSON, get_repo_root +from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root from .task_utils import resolve_task_dir # Extensions that look like code rather than spec/research docs. Entries with @@ -170,6 +170,59 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool: return False +def _resolve_context_entry_path( + file_path: str, repo_root: Path, task_dir: Path | None +) -> Path | None: + """Resolve a JSONL entry, binding archived self-references to the archive copy. + + Exact historical self-references are remapped only for archived tasks. + ``None`` means the remapped path traversed or resolved outside that archive. + """ + repo_path = repo_root / file_path + if task_dir is None: + return repo_path + + try: + task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts + except ValueError: + return repo_path + + archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE) + if len(task_parts) != 5 or task_parts[:3] != archive_prefix: + return repo_path + + year_month = task_parts[3] + if ( + len(year_month) != 7 + or year_month[4] != "-" + or not year_month[:4].isdigit() + or not year_month[5:].isdigit() + ): + return repo_path + + historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}" + posix_path = file_path.replace("\\", "/") + if posix_path == historical_root: + relative_parts: tuple[str, ...] = () + elif posix_path.startswith(f"{historical_root}/"): + relative_path = posix_path[len(historical_root) + 1 :] + if relative_path.endswith("/"): + relative_path = relative_path[:-1] + relative_parts = tuple(relative_path.split("/")) if relative_path else () + if any(part in ("", ".", "..") for part in relative_parts): + return None + else: + return repo_path + + try: + archive_root = task_dir.resolve() + resolved_path = task_dir.joinpath(*relative_parts).resolve() + resolved_path.relative_to(archive_root) + except (OSError, RuntimeError, ValueError): + return None + return resolved_path + + def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int: """Validate a single JSONL file. @@ -220,14 +273,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N continue real_entries += 1 - full_path = repo_root / file_path + full_path = _resolve_context_entry_path(file_path, repo_root, task_dir) if entry_type == "directory": - if not full_path.is_dir(): + if full_path is None or not full_path.is_dir(): print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") errors += 1 continue - if not full_path.is_file(): + if full_path is None or not full_path.is_file(): print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") errors += 1 continue diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py index 2cd9789..53834c0 100755 --- a/.trellis/scripts/common/task_store.py +++ b/.trellis/scripts/common/task_store.py @@ -56,6 +56,7 @@ from .task_utils import ( resolve_task_dir, run_task_hooks, ) +from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE # ============================================================================= @@ -254,6 +255,31 @@ def cmd_create(args: argparse.Namespace) -> int: # Inferred: default_package → None (no task.json yet for create) package = resolve_package(repo_root=repo_root) + # Validate --workflow (CLI source: fail-fast on invalid id; a missing + # library file only warns — it may be saved later via `trellis workflow --save`) + workflow_id: str | None = getattr(args, "workflow", None) + if workflow_id: + if not WORKFLOW_ID_RE.match(workflow_id): + print( + colored( + f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + workflow_md = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md" + if not workflow_md.is_file(): + print( + colored( + f"Warning: {DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md does not exist yet; " + "default workflow resolution is used until it is saved " + "(trellis workflow --save).", + Colors.YELLOW, + ), + file=sys.stderr, + ) + # Default assignee to current developer assignee = args.assignee if not assignee: @@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int: "notes": "", "meta": meta, } + # Optional per-task workflow selection: key present only when opted in, + # so tasks without a selection keep today's task.json shape byte-for-byte. + if workflow_id: + task_data["workflow"] = workflow_id write_json(task_json_path, task_data) diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py index 9e1c619..858c021 100755 --- a/.trellis/scripts/common/workflow_phase.py +++ b/.trellis/scripts/common/workflow_phase.py @@ -22,11 +22,12 @@ from __future__ import annotations import re -from .paths import DIR_WORKFLOW, get_repo_root +from . import workflow_selection +from .paths import get_repo_root def _workflow_md_path(): - return get_repo_root() / DIR_WORKFLOW / "workflow.md" + return workflow_selection.resolve_workflow_md(get_repo_root()) # Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" _MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") diff --git a/.trellis/scripts/common/workflow_selection.py b/.trellis/scripts/common/workflow_selection.py new file mode 100755 index 0000000..99fdc5d --- /dev/null +++ b/.trellis/scripts/common/workflow_selection.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Per-task workflow selection. + +Resolves which workflow markdown file consumers should read. A task may pin +a workflow variant by storing `"workflow": ""` in its task.json; the +variant body lives at `.trellis/workflows/.md` (user-managed library). + +Resolution precedence (single source of truth for all consumers), highest +to lowest — each layer resolves an id to `.trellis/workflows/.md` and +falls through when unset, invalid, or pointing at a missing file: + 1. Per-task pin - active task's task.json `workflow` (session-bound, + explicit; a bad id or missing file warns once on stderr, then falls + through rather than aborting). + 2. Personal - `.developer` `workflow=` (gitignored, per-developer; + outranks the team default). Silent on miss. + 3. Team default - config.yaml `default_workflow` (git-tracked, shared). + Silent on miss. + 4. Global - `.trellis/workflow.md`. +With neither a per-task pin nor the personal/team keys set, this is identical +to reading the global `.trellis/workflow.md`. Never raises. + +Provides: + workflow_md_for_task - Full precedence for an already-resolved task dir + resolve_workflow_md - Session-aware wrapper via the active task resolver +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +from .paths import DIR_WORKFLOW, FILE_TASK_JSON + +# Workflow variant library directory under .trellis/ (plural on purpose: +# `.trellis/workflow/` is reserved by the YAML-manifest migration). +DIR_WORKFLOWS = "workflows" + +# Workflow ids must be plain slugs; anything else (path separators, dots) +# is rejected so a task.json value can never escape .trellis/workflows/. +WORKFLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _global_workflow_md(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / "workflow.md" + + +def _library_variant(repo_root: Path, workflow_id: str | None) -> Path | None: + """Map a workflow id to its library file if valid and present, else None. + + Shared by every layer (per-task pin, personal, team). An id with path + separators/dots/blanks, or one whose `.trellis/workflows/.md` file does + not exist, returns None so the caller falls through. Never raises. + """ + if not isinstance(workflow_id, str) or not workflow_id: + return None + if not WORKFLOW_ID_RE.match(workflow_id): + return None + variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md" + return variant if variant.is_file() else None + + +def _developer_workflow_id(repo_root: Path) -> str | None: + """Personal override id from the gitignored .developer file (fail-open).""" + try: + from .paths import get_developer_workflow + + return get_developer_workflow(repo_root) + except Exception: + return None + + +def _config_default_id(repo_root: Path) -> str | None: + """Team-shared default id from config.yaml `default_workflow` (fail-open).""" + try: + from .config import get_default_workflow + + return get_default_workflow(repo_root) + except Exception: + return None + + +def _default_workflow_md(repo_root: Path) -> Path: + """Resolve the non-per-task default: personal -> team -> global. + + Personal (`.developer` `workflow=`) outranks the team-shared + (config.yaml `default_workflow`) layer; both fall through to the global + `.trellis/workflow.md` when unset, invalid, or naming a missing file. These + layers are silent on miss (they are defaults, not an explicit per-task + choice — a per-turn warning would be noise). + """ + for get_id in (_developer_workflow_id, _config_default_id): + variant = _library_variant(repo_root, get_id(repo_root)) + if variant is not None: + return variant + return _global_workflow_md(repo_root) + + +def _task_pin_variant(repo_root: Path, task_dir: Path | None) -> Path | None: + """Return the per-task pinned variant path, or None to fall through. + + Emits a stderr warning on an invalid id or a missing variant file (an + explicit per-task choice that cannot be honored), then returns None so + resolution continues with the personal/team defaults. Never raises. + """ + if task_dir is None: + return None + + try: + raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8")) + if not isinstance(raw, dict): + return None + + workflow_id = raw.get("workflow") + if not isinstance(workflow_id, str) or not workflow_id: + return None + + if not WORKFLOW_ID_RE.match(workflow_id): + print( + f"Warning: task '{task_dir.name}' has invalid workflow id " + f"{workflow_id!r}; using default workflow resolution", + file=sys.stderr, + ) + return None + + variant = _library_variant(repo_root, workflow_id) + if variant is not None: + return variant + + print( + f"Warning: task '{task_dir.name}' selects workflow '{workflow_id}' but " + f"{DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md is missing; " + f"using default workflow resolution", + file=sys.stderr, + ) + return None + except Exception: + return None + + +def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path: + """Return the workflow.md path for an already-resolved task dir (or None). + + Applies the full precedence documented in the module docstring: + per-task pin -> personal (.developer) -> team (config.yaml) -> global. + Never raises; any failure falls through toward the global workflow path. + """ + pin = _task_pin_variant(repo_root, task_dir) + if pin is not None: + return pin + return _default_workflow_md(repo_root) + + +def resolve_workflow_md( + repo_root: Path, + input_data: dict | None = None, + platform: str | None = None, +) -> Path: + """Resolve the session-aware active task, then apply the resolution rule. + + ``input_data`` is the raw hook payload (session/conversation identity); + CLI callers may omit it — the active-task resolver then falls back to + environment context. Never raises; any failure resolves to the global + `.trellis/workflow.md`. + """ + try: + from .active_task import resolve_active_task, resolve_task_ref + + active = resolve_active_task(repo_root, input_data, platform) + task_dir: Path | None = None + if active.task_path: + task_dir = resolve_task_ref(active.task_path, repo_root) + return workflow_md_for_task(repo_root, task_dir) + except Exception: + return _global_workflow_md(repo_root) diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py index 7e82eac..b818655 100755 --- a/.trellis/scripts/task.py +++ b/.trellis/scripts/task.py @@ -11,6 +11,7 @@ Usage: python3 task.py start # Set active task python3 task.py current [--source] [--json] # Show active task python3 task.py finish # Clear active task + python3 task.py workflow |--clear # Set/clear per-task workflow selection python3 task.py set-branch # Set git branch python3 task.py set-base-branch # Set PR target branch python3 task.py set-scope # Set scope for PR title @@ -47,6 +48,7 @@ from common.active_task import ( from common.io import read_json, write_json from common.task_utils import resolve_task_dir, run_task_hooks from common.tasks import iter_active_tasks, children_progress +from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task # Import command handlers from split modules (also re-exports for plan.py compatibility) from common.task_store import ( @@ -204,6 +206,72 @@ def cmd_current(args: argparse.Namespace) -> int: return 1 +# ============================================================================= +# Command: workflow +# ============================================================================= + +def cmd_workflow(args: argparse.Namespace) -> int: + """Set or clear the workflow selection on the current session's active task.""" + repo_root = get_repo_root() + + if args.clear and args.id: + print(colored("Error: pass either or --clear, not both", Colors.RED)) + return 1 + if not args.clear and not args.id: + print(colored("Error: workflow id required (or --clear)", Colors.RED)) + print("Usage: python3 task.py workflow | --clear") + return 1 + + active = resolve_active_task(repo_root) + if not active.task_path: + print(colored("Error: No current task set", Colors.RED)) + print("Hint: run task.py start first") + return 1 + + task_dir = repo_root / active.task_path + task_json_path = task_dir / FILE_TASK_JSON + if not task_json_path.is_file(): + print(colored(f"Error: task.json not found at {task_dir}", Colors.RED)) + return 1 + + data = read_json(task_json_path) + if not data: + print(colored(f"Error: failed to read {task_json_path}", Colors.RED)) + return 1 + + if args.clear: + if data.pop("workflow", None) is None: + print(colored("No workflow selection set on this task", Colors.YELLOW)) + else: + if not write_json(task_json_path, data): + print(colored("Error: failed to update task.json", Colors.RED)) + return 1 + print(colored("✓ Workflow selection cleared", Colors.GREEN)) + else: + workflow_id = args.id + if not WORKFLOW_ID_RE.match(workflow_id): + print(colored( + f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')", + Colors.RED, + )) + return 1 + data["workflow"] = workflow_id + if not write_json(task_json_path, data): + print(colored("Error: failed to update task.json", Colors.RED)) + return 1 + print(colored(f"✓ Workflow set to: {workflow_id}", Colors.GREEN)) + + # workflow_md_for_task warns on stderr itself when the selected variant + # file is missing (it can be saved later via `trellis workflow --save`). + effective = workflow_md_for_task(repo_root, task_dir) + try: + effective_display = effective.relative_to(repo_root).as_posix() + except ValueError: + effective_display = str(effective) + print(f"Effective workflow: {effective_display}") + return 0 + + # ============================================================================= # Command: list # ============================================================================= @@ -382,12 +450,15 @@ Usage: python3 task.py create --package <pkg> Create task for a specific package python3 task.py create <title> --parent <dir> Create task as child of parent python3 task.py create <title> --no-start Create without making it active in this session + python3 task.py create <title> --workflow <id> Create task pinned to a workflow variant python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl python3 task.py validate <dir> Validate jsonl files python3 task.py list-context <dir> List jsonl entries python3 task.py start <dir> Set active task python3 task.py current [--source] Show active task python3 task.py finish Clear active task + python3 task.py workflow <id> Select workflow variant for active task + python3 task.py workflow --clear Clear selection (use default resolution) python3 task.py set-branch <dir> <branch> Set git branch python3 task.py set-base-branch <dir> <branch> Set PR target branch python3 task.py set-scope <dir> <scope> Set scope for PR title @@ -490,6 +561,10 @@ def main() -> int: action="store_true", help="Create the task without making it active in this session", ) + p_create.add_argument( + "--workflow", + help="Workflow variant id for this task (.trellis/workflows/<id>.md)", + ) # add-context p_add = subparsers.add_parser("add-context", help="Add context entry") @@ -520,6 +595,12 @@ def main() -> int: # finish subparsers.add_parser("finish", help="Clear active task") + # workflow + p_workflow = subparsers.add_parser("workflow", help="Set/clear per-task workflow selection") + p_workflow.add_argument("id", nargs="?", help="Workflow id (.trellis/workflows/<id>.md)") + p_workflow.add_argument("--clear", action="store_true", + help="Remove the workflow selection (use default resolution)") + # set-branch p_branch = subparsers.add_parser("set-branch", help="Set git branch") p_branch.add_argument("dir", help="Task directory") @@ -580,6 +661,7 @@ def main() -> int: "start": cmd_start, "current": cmd_current, "finish": cmd_finish, + "workflow": cmd_workflow, "set-branch": cmd_set_branch, "set-base-branch": cmd_set_base_branch, "set-scope": cmd_set_scope,