Develop #32

Merged
sakibcc merged 2 commits from develop into main 2026-09-07 13:25:58 +08:00
23 changed files with 2525 additions and 130 deletions
Showing only changes of commit e567e5f717 - Show all commits
@@ -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(<root>, <workflowSkills>, 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 `collect<Platform>Templates()` in `packages/cli/src/configurators/<platform>.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(<skillsRoot>, <workflowSkills>, <bundledSkills>)` folds them into the platform's `Map<filePath, content>` under `<skillsRoot>/<skill>/<relativePath>`.
| Platform | Bundled skill root | Notes |
| --- | --- | --- |
| Claude Code | `.claude/skills/<skill>/` | `configureClaude` |
| Cursor | `.cursor/skills/<skill>/` | `configureCursor` |
| Codex | `.agents/skills/<skill>/` | `configureCodex` writes the shared `.agents/skills/` root, which Gemini CLI 0.40+ also reads |
| Gemini CLI | `.agents/skills/<skill>/` | Same shared root as Codex; the two configurators are required to produce byte-identical output |
| Kiro | `.kiro/skills/<skill>/` | `configureKiro` (skills-based platform — no commands) |
| Qoder | `.qoder/skills/<skill>/` | `configureQoder` |
| Codebuddy | `.codebuddy/skills/<skill>/` | `configureCodebuddy` |
| Copilot | `.github/skills/<skill>/` | `configureCopilot` |
| Droid | `.factory/skills/<skill>/` | `configureDroid` |
| Antigravity | `.agent/skills/<skill>/` | `configureAntigravity` |
| Devin | `.devin/skills/<skill>/` | `configureDevin` |
| Kilo | `.kilocode/skills/<skill>/` | `configureKilo` |
| ZCode | `.zcode/skills/<skill>/` | `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/<skill>/` |
| Cursor | `.cursor/skills/<skill>/` |
| OpenCode | `.opencode/skills/<skill>/` |
| Codex | `.agents/skills/<skill>/` |
| Gemini CLI | `.agents/skills/<skill>/` |
| Pi | `.agents/skills/<skill>/` |
| Kimi | `.agents/skills/<skill>/` |
| Kilo | `.kilocode/skills/<skill>/` |
| Kiro | `.kiro/skills/<skill>/` |
| Antigravity | `.agent/skills/<skill>/` |
| Devin | `.devin/skills/<skill>/` |
| Qoder | `.qoder/skills/<skill>/` |
| Codebuddy | `.codebuddy/skills/<skill>/` |
| Copilot | `.github/skills/<skill>/` |
| Droid | `.factory/skills/<skill>/` |
| Reasonix | `.reasonix/skills/<skill>/` |
| ZCode | `.zcode/skills/<skill>/` |
| Trae | `.trae/skills/<skill>/` |
| OMP | `.omp/skills/<skill>/` |
| Grok | `.grok/skills/<skill>/` |
| Snow | `.snow/skills/<skill>/` |
1. `configureX(cwd)` writes files during `trellis init`.
2. `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) returns a `Map<filePath, content>` 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, collect<Platform>Templates())`. For 18 of the 21 platforms the registry entry in `configurators/index.ts` is literally `fromTemplates(collect<Platform>Templates)`, which *is* that composition. Claude Code, Codex and ZCode spell out a `configure` of their own, each for work a `Map<path, content>` 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 `<skill>/<relativePath>` 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<filePath, content>` for the update / hash pipeline.
- `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns workflow skills and bundled skill files together as a `Map<filePath, content>` 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 `collect<Platform>Templates()` — 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: `collect<Platform>Templates()` 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
@@ -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 --<platfor
| `session-start.py` | Generates session-start context. |
| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. |
| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. |
| `inject-spec-context.py` | Matches path-scoped specs and manages budgeted delivery state. |
| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. |
Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event.
+25
View File
@@ -1,5 +1,17 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "^(?:clear|compact)$",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
"timeout": 15
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
@@ -22,6 +34,19 @@
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
"timeout": 15,
"additionalContextLimit": 0
}
]
}
]
}
}
+844
View File
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Path-Scoped Spec Context Injection Hook (ticket-refresh model)
When the agent touches a file, the specs that govern that file are surfaced
right then — small, relevant, budgeted — instead of everything up front or
nothing at all. Spec .md files under .trellis/spec/ declare which code paths
they govern via YAML frontmatter (`paths:` glob list); the matching engine
lives in .trellis/scripts/common/spec_match.py and the decision engine in
.trellis/scripts/common/spec_inject.py. This file is the IO shell: stdin,
config, identity, state files, locking, GC, one print.
Triggers:
* Claude Code PostToolUse (matcher "Read|Edit|Write|MultiEdit") receives one
structured file path after the tool runs.
* Codex PreToolUse (matcher "Edit|Write") receives an ``apply_patch`` command.
Every patch header is matched before the patch runs. When a FULL spec is
emitted, the patch is denied once so the model can read the injected rules
and retry; ticket-only reminders do not block.
* OpenCode ``tool.execute.before`` adapts ``write``, ``edit``, and
``apply_patch`` into the same PreToolUse payload. FULL delivery uses the same
deny-once decision before the JS plugin surfaces context as a tool error.
Behavior — per matched spec, per event (recency-decay aware):
h = sha256(spec bytes)
last = newest emission recorded for (identity, spec) # stateless → None
if stateless: emit TICKET # bounded cost, always
elif last is None: emit FULL # first time this session
elif last.sha256 != h: emit FULL # spec changed → re-teach
elif reset since last: emit FULL # the text is gone, re-teach
elif within window: silent # fixed window; no state append
else: emit TICKET # refresh attention cheaply
A FULL block inlines the (budgeted) spec body with a sha256 attr; a TICKET is
a short reminder pointing back at the spec. Both append a state record; silent
hits do not (fixed window, not sliding — continuous editing is exactly when
drift is worst).
Identity (misfire asymmetry: a collision that MISSES an injection is
unacceptable; drift that OVER-injects is fine): the session/window key is
delegated to common.active_task.resolve_context_key — the shared resolver
every other hook uses — called payload-first, environment-inclusive second,
so two live sessions can never collapse onto one exported env value. A
`+a-<agent_id>` 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}/<project16>/<identity>.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 <spec-index> 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 <spec-context>
# 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 `<base>/<project16>/<identity>[.<pid>].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 `.<pid>`
# 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-<agent_id>`
# 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 (``<base>/<project16>/<shard>.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)
+13 -5
View File
@@ -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
+52 -16
View File
@@ -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/<id>.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 collect<Platform>Templates() 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_<their-session-id>`. 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:
Regular → Executable
+20 -1
View File
@@ -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</current-state>\n\n")
output.write("<trellis-workflow>\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</trellis-workflow>\n\n")
output.write("<guidelines>\n")
+61 -4
View File
@@ -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<string, string> = {};
for (const m of wf.matchAll(WF_RE)) {
+22 -18
View File
@@ -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"
}
}
+1 -1
View File
@@ -1 +1 @@
0.6.12
0.7.0-beta.3
+47
View File
@@ -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/<id>.md` (populate it with
# `trellis workflow --save <id>`). 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=<id>` 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 `<spec-ticket>`
# 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
#-------------------------------------------------------------------------------
+112 -43
View File
@@ -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
# `<PLATFORM>_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/<ws>/<id>). 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
+18
View File
@@ -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/<id>.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=<id>``, 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
+34 -2
View File
@@ -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()
+28
View File
@@ -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=<id>`` 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.
+26 -8
View File
@@ -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("")
+439
View File
@@ -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'<spec-context file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
f"{body}\n"
f"</spec-context>"
)
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'<spec-ticket file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
f"{body}\n"
f"</spec-ticket>"
)
def _index_block(lines: Sequence[str]) -> str:
return "<spec-index>\n" + "\n".join(lines) + "\n</spec-index>"
# =============================================================================
# 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
+395
View File
@@ -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 (``- <glob>`` 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 []
+57 -4
View File
@@ -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
+30
View File
@@ -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)
+3 -2
View File
@@ -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*$")
+177
View File
@@ -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": "<id>"` in its task.json; the
variant body lives at `.trellis/workflows/<id>.md` (user-managed library).
Resolution precedence (single source of truth for all consumers), highest
to lowest — each layer resolves an id to `.trellis/workflows/<id>.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=<id>` (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/<id>.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)
+82
View File
@@ -11,6 +11,7 @@ Usage:
python3 task.py start <dir> # Set active task
python3 task.py current [--source] [--json] # Show active task
python3 task.py finish # Clear active task
python3 task.py workflow <id>|--clear # Set/clear per-task workflow selection
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
@@ -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 <id> 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 <id> | --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 <dir> 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 <title> --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,