Merge pull request 'Develop' (#32) from develop into main
Deploy Production / deploy (push) Successful in 29s
Deploy Production / deploy (push) Successful in 29s
Reviewed-on: sakibcc/zhixing-system#32
This commit was merged in pull request #32.
This commit is contained in:
@@ -31,30 +31,42 @@ The list is discovered at runtime, so adding a new directory under `bundled-skil
|
|||||||
|
|
||||||
## Where Bundled Skills Land Per Platform
|
## 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 |
|
All 21 platforms receive the full bundled-skill set:
|
||||||
| --- | --- | --- |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
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`.
|
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.
|
||||||
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))`.
|
|
||||||
|
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)
|
## 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`
|
2. `packages/cli/src/configurators/shared.ts`
|
||||||
- `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders.
|
- `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 workflow skills and bundled skill files together as a `Map<filePath, content>` rooted at `skillsRoot`.
|
||||||
- `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map<filePath, content>` for the update / hash pipeline.
|
- `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
|
## 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.
|
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
|
## Operating Rules
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Common files:
|
|||||||
| Claude Code | `.claude/settings.json` |
|
| Claude Code | `.claude/settings.json` |
|
||||||
| Cursor | `.cursor/hooks.json` |
|
| Cursor | `.cursor/hooks.json` |
|
||||||
| Codex | `.codex/hooks.json`, `.codex/config.toml` |
|
| 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 |
|
| Kiro | `.kiro/hooks/` + platform config |
|
||||||
| Gemini CLI | `.gemini/settings.json` |
|
| Gemini CLI | `.gemini/settings.json` |
|
||||||
| Qoder | `.qoder/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. |
|
| `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-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-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. |
|
| `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.
|
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.
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
{
|
{
|
||||||
"hooks": {
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "^(?:clear|compact)$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"UserPromptSubmit": [
|
"UserPromptSubmit": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+844
@@ -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)
|
||||||
Regular → Executable
+13
-5
@@ -215,11 +215,12 @@ def truncate_utf8(data: bytes, cap: int) -> bytes:
|
|||||||
seq_len = 4
|
seq_len = 4
|
||||||
else:
|
else:
|
||||||
seq_len = 1
|
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):
|
if (i - 1) + seq_len > len(truncated):
|
||||||
i -= 1
|
return truncated[: i - 1]
|
||||||
|
|
||||||
return truncated[:i]
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
class _Budget:
|
class _Budget:
|
||||||
@@ -877,8 +878,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None:
|
|||||||
if not subagent_type or not parent_session_id:
|
if not subagent_type or not parent_session_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
cwd = _string_value(input_data.get("cwd")) or os.getcwd()
|
# Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4)
|
||||||
repo_root = find_repo_root(cwd)
|
# 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:
|
if not repo_root:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+52
-16
@@ -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
|
CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed
|
||||||
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
|
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
|
||||||
legacy name. ``_detect_platform`` picks the right value at runtime.
|
legacy name. ``_detect_platform`` picks the right value at runtime.
|
||||||
Breadcrumb text is pulled exclusively from workflow.md
|
Breadcrumb text is pulled exclusively from the resolved workflow file's
|
||||||
[workflow-state:STATUS] tag blocks — workflow.md is the single source of
|
[workflow-state:STATUS] tag blocks — the active task may select a
|
||||||
truth. There are no fallback dicts in this script: when workflow.md is
|
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
|
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)
|
"Refer to workflow.md for current step." line so users see (and fix)
|
||||||
the broken state instead of the hook silently masking it.
|
the broken state instead of the hook silently masking it.
|
||||||
|
|
||||||
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
|
Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
|
||||||
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
|
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``
|
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
|
||||||
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
|
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
|
||||||
(Kiro adds hook stdout directly to the conversation context). Written to
|
(Kiro adds hook stdout directly to the conversation context).
|
||||||
each platform's hooks directory via writeSharedHooks() at init time.
|
|
||||||
|
|
||||||
Silent exit 0 cases (no output):
|
Silent exit 0 cases (no output):
|
||||||
- No .trellis/ directory found (not a Trellis project)
|
- 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:
|
def _detect_platform(input_data: dict) -> str | None:
|
||||||
if isinstance(input_data.get("cursor_version"), str):
|
if isinstance(input_data.get("cursor_version"), str):
|
||||||
return "cursor"
|
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 = {
|
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",
|
"ZCODE_PROJECT_DIR": "zcode",
|
||||||
"CLAUDE_PROJECT_DIR": "claude",
|
|
||||||
"CURSOR_PROJECT_DIR": "cursor",
|
"CURSOR_PROJECT_DIR": "cursor",
|
||||||
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
||||||
"FACTORY_PROJECT_DIR": "droid",
|
"FACTORY_PROJECT_DIR": "droid",
|
||||||
@@ -108,6 +118,8 @@ def _detect_platform(input_data: dict) -> str | None:
|
|||||||
"KIRO_PROJECT_DIR": "kiro",
|
"KIRO_PROJECT_DIR": "kiro",
|
||||||
"COPILOT_PROJECT_DIR": "copilot",
|
"COPILOT_PROJECT_DIR": "copilot",
|
||||||
"TRAE_PROJECT_DIR": "trae",
|
"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():
|
for env_name, platform in env_map.items():
|
||||||
if os.environ.get(env_name):
|
if os.environ.get(env_name):
|
||||||
@@ -183,16 +195,40 @@ _TAG_RE = re.compile(
|
|||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_breadcrumbs(root: Path) -> dict[str, str]:
|
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
|
||||||
"""Parse workflow.md for [workflow-state:STATUS] blocks.
|
"""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
|
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
|
in build_breadcrumb so users see the broken state and fix
|
||||||
workflow.md, rather than the hook silently masking the issue.
|
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():
|
if not workflow.is_file():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
@@ -411,7 +447,7 @@ def main() -> int:
|
|||||||
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
|
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
|
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)
|
platform = _detect_platform(data)
|
||||||
task = get_active_task(root, data)
|
task = get_active_task(root, data)
|
||||||
if task is None:
|
if task is None:
|
||||||
|
|||||||
Regular → Executable
+20
-1
@@ -452,6 +452,25 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
|
|||||||
return re.sub(r"\n{3,}", "\n\n", stripped).strip()
|
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:
|
def _build_workflow_toc(workflow_path: Path) -> str:
|
||||||
"""Inject only the compact Phase Index summary for SessionStart."""
|
"""Inject only the compact Phase Index summary for SessionStart."""
|
||||||
content = read_file(workflow_path)
|
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("\n</current-state>\n\n")
|
||||||
|
|
||||||
output.write("<trellis-workflow>\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("\n</trellis-workflow>\n\n")
|
||||||
|
|
||||||
output.write("<guidelines>\n")
|
output.write("<guidelines>\n")
|
||||||
|
|||||||
@@ -772,10 +772,11 @@ function truncateUtf8(buf: Buffer, cap: number): Buffer {
|
|||||||
if ((lead & 0xe0) === 0xc0) seqLen = 2;
|
if ((lead & 0xe0) === 0xc0) seqLen = 2;
|
||||||
else if ((lead & 0xf0) === 0xe0) seqLen = 3;
|
else if ((lead & 0xf0) === 0xe0) seqLen = 3;
|
||||||
else if ((lead & 0xf8) === 0xf0) seqLen = 4;
|
else if ((lead & 0xf8) === 0xf0) seqLen = 4;
|
||||||
// Drop the lead byte too if its full sequence didn't fit.
|
// Cut before the lead byte when its full sequence didn't fit;
|
||||||
if (i - 1 + seqLen > cap) i--;
|
// 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 {
|
function stripInlineComment(value: string): string {
|
||||||
@@ -1069,10 +1070,66 @@ function readTaskDir(root: string, key: string | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Workflow State Breadcrumb ─────────────────────────────────────────
|
// ── 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 =
|
const WF_RE =
|
||||||
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
|
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
|
||||||
function workflowBreadcrumb(root: string, key: string | null): string {
|
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 "";
|
if (!wf) return "";
|
||||||
const templates: Record<string, string> = {};
|
const templates: Record<string, string> = {};
|
||||||
for (const m of wf.matchAll(WF_RE)) {
|
for (const m of wf.matchAll(WF_RE)) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
".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/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||||
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
".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/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
".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/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||||
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
".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/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/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
||||||
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
|
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
|
||||||
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
".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-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
|
||||||
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
||||||
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
||||||
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
|
".codex/hooks/session-start.py": "91fbbd30ac974c3cd2b4db152aa38ac176a9c7e3d062cbaa1acacf07152b2592",
|
||||||
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609",
|
".codex/hooks/inject-subagent-context.py": "db413933ff30e1503f37f1d292fb421b1ced3f39f1890351a6621e499ec16b2c",
|
||||||
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
|
".codex/hooks/inject-workflow-state.py": "cda5888c29671035e7d2e033a0fcc631a9428a0543fb2dd071285b30c6ca4e23",
|
||||||
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
".codex/hooks.json": "c16c9af7f6010bf4fabb64fd4bd497201d34583b96c665ea1d5739eed48b3fb5",
|
||||||
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
||||||
".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d",
|
".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d",
|
||||||
".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c",
|
".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c",
|
||||||
@@ -61,40 +61,44 @@
|
|||||||
".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496",
|
".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496",
|
||||||
".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2",
|
".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2",
|
||||||
".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a",
|
".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a",
|
||||||
".pi/extensions/trellis/index.ts": "b4bfd740d517462f943aaff920dd39371d3e6bdc3823dfa8c2752314c57eaf9b",
|
".pi/extensions/trellis/index.ts": "770290e675fabfe0dd0889771876607927d36cb9ef68586552e6e2d56ca345fc",
|
||||||
".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c",
|
".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c",
|
||||||
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
|
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
|
||||||
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||||
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||||
".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa",
|
".trellis/config.yaml": "eaba56c36fb07483fcbc96d74c4da0ab33b9739fb9ff10ea4cc1e1cd32bf23b2",
|
||||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||||
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
|
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
|
||||||
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
|
".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/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/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
|
||||||
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
|
".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/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
|
||||||
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
||||||
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
|
".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/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
|
||||||
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
|
".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
|
||||||
".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d",
|
".trellis/scripts/common/task_context.py": "be5fa407f99c2400075194dbaa8b6ec996ce8aac2122d191bb9cb11e479a9476",
|
||||||
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
".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/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871",
|
||||||
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
|
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
|
||||||
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
|
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
|
||||||
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
".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_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
|
||||||
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
||||||
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
||||||
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
||||||
".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4",
|
".trellis/scripts/task.py": "7790d9510311d55ed1f66c71b9ce0bafc8871f1b675c1251dcf6a1f4e823d2b3",
|
||||||
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76"
|
".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
@@ -1 +1 @@
|
|||||||
0.6.12
|
0.7.0-beta.3
|
||||||
@@ -76,6 +76,21 @@ max_journal_lines: 2000
|
|||||||
# Default package used when --package is not specified.
|
# Default package used when --package is not specified.
|
||||||
# default_package: frontend
|
# 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
|
# Channel worker OOM guard
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
@@ -145,6 +160,38 @@ channel:
|
|||||||
# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md)
|
# 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
|
# 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
|
# Per-turn prompt injection
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ DIR_WORKFLOW = ".trellis"
|
|||||||
DIR_TASKS = "tasks"
|
DIR_TASKS = "tasks"
|
||||||
DIR_RUNTIME = ".runtime"
|
DIR_RUNTIME = ".runtime"
|
||||||
DIR_SESSIONS = "sessions"
|
DIR_SESSIONS = "sessions"
|
||||||
DIR_CURSOR_SHELL = "cursor-shell"
|
DIR_SHELL_TICKETS = "shell-tickets"
|
||||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
# 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"}
|
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
||||||
|
|
||||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||||
@@ -50,35 +57,75 @@ _KNOWN_PLATFORMS = {
|
|||||||
"snow",
|
"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, ...]], ...] = (
|
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
|
||||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
|
||||||
("cursor", ("CURSOR_SESSION_ID",)),
|
# was removed here — verified absent from that same live environment.
|
||||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
("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",)),
|
("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",)),
|
("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",)),
|
("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")),
|
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
|
||||||
("trae", ("TRAE_SESSION_ID",)),
|
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
|
||||||
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID).
|
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
|
||||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this
|
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
|
||||||
# only fires when the resolver already detected "zcode" — no collision with
|
# 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.
|
# the claude entry above.
|
||||||
("zcode", ("CLAUDE_SESSION_ID",)),
|
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
|
||||||
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
|
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
|
||||||
# TRELLIS_CONTEXT_ID remains the preferred override when present.
|
# 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",)),
|
("snow", ("SNOW_SESSION_ID",)),
|
||||||
)
|
)
|
||||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
_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")),
|
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||||
)
|
)
|
||||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
|
||||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
# scripts; empty in the agent's own shell env.
|
||||||
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
("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",)),
|
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
||||||
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
||||||
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
||||||
@@ -90,11 +137,15 @@ _ENV_PLATFORM_ALIASES = {
|
|||||||
"factory-ai": "droid",
|
"factory-ai": "droid",
|
||||||
"github-copilot": "copilot",
|
"github-copilot": "copilot",
|
||||||
}
|
}
|
||||||
# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode,
|
# ZCode intentionally reuses Claude's session env var name. Hooks know the host
|
||||||
# while later shell commands see only the shared env name and resolve it through
|
# is ZCode, while later shell commands see only the shared env name and resolve
|
||||||
# the Claude entry. Canonicalize both paths to one runtime filename.
|
# it through the claude entry. Canonicalize both paths to one runtime filename.
|
||||||
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
||||||
"zcode": "claude",
|
"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, ...]], ...],
|
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
||||||
platform_name: str | None,
|
platform_name: str | None,
|
||||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
) -> 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:
|
if not platform_name:
|
||||||
return env_keys
|
return env_keys
|
||||||
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
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
|
current = current.parent
|
||||||
|
|
||||||
|
|
||||||
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
|
||||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
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:
|
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")
|
created_at = ticket.get("created_at_epoch")
|
||||||
if isinstance(created_at, (int, float)):
|
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
|
return True
|
||||||
_remove_file(ticket_path)
|
_remove_file(ticket_path)
|
||||||
return False
|
return False
|
||||||
@@ -352,13 +413,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _matching_cursor_ticket_context_key(
|
def _matching_ticket_context_key(
|
||||||
ticket_path: Path,
|
ticket_path: Path,
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
now: float,
|
now: float,
|
||||||
) -> str | None:
|
) -> 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)
|
ticket = _read_json(ticket_path)
|
||||||
if ticket is None or ticket.get("platform") != "cursor":
|
if ticket is None:
|
||||||
return None
|
return None
|
||||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||||
return None
|
return None
|
||||||
@@ -369,27 +435,28 @@ def _matching_cursor_ticket_context_key(
|
|||||||
return _string_value(ticket.get("context_key"))
|
return _string_value(ticket.get("context_key"))
|
||||||
|
|
||||||
|
|
||||||
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
def _lookup_shell_ticket_context_key() -> str | None:
|
||||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
"""Resolve session identity from a short-lived shell ticket.
|
||||||
|
|
||||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
No researched platform exports its session id into a shell child, but every
|
||||||
export it into the shell command environment. The Cursor hook writes a
|
hook-capable one hands that id to a hook. So the hook that runs just before
|
||||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
a shell command writes a ticket, and this reads it back. A ticket counts
|
||||||
the current `task.py` subcommand matches and exactly one fresh context key
|
only when it is fresh, was written for this repo, and matches the `task.py`
|
||||||
matches, which avoids cross-window pointer contamination.
|
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()
|
repo_root = _find_repo_root_from_cwd()
|
||||||
if repo_root is None:
|
if repo_root is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
|
||||||
if not ticket_dir.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
candidates: set[str] = set()
|
candidates: set[str] = set()
|
||||||
|
for ticket_dir in _shell_ticket_dirs(repo_root):
|
||||||
|
if not ticket_dir.is_dir():
|
||||||
|
continue
|
||||||
for ticket_path in ticket_dir.glob("*.json"):
|
for ticket_path in ticket_dir.glob("*.json"):
|
||||||
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
|
||||||
if context_key:
|
if context_key:
|
||||||
candidates.add(context_key)
|
candidates.add(context_key)
|
||||||
|
|
||||||
@@ -435,8 +502,10 @@ def resolve_context_key(
|
|||||||
if env_context_key:
|
if env_context_key:
|
||||||
return env_context_key
|
return env_context_key
|
||||||
|
|
||||||
if allow_environment_context and platform_name in (None, "session", "cursor"):
|
# Last in the chain on purpose: a platform that genuinely exports identity
|
||||||
return _lookup_cursor_shell_ticket_context_key()
|
# 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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,24 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
|||||||
return "inline"
|
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_FILE_BYTES = 32768
|
||||||
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
|
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
|
||||||
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from .packages_context import (
|
|||||||
get_context_packages_text,
|
get_context_packages_text,
|
||||||
get_context_packages_json,
|
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 .trellis_config import read_trellis_config
|
||||||
from .workflow_phase import (
|
from .workflow_phase import (
|
||||||
filter_platform,
|
filter_platform,
|
||||||
@@ -57,9 +59,9 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mode",
|
"--mode",
|
||||||
"-m",
|
"-m",
|
||||||
choices=["default", "record", "packages", "phase"],
|
choices=["default", "record", "packages", "phase", "spec"],
|
||||||
default="default",
|
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(
|
parser.add_argument(
|
||||||
"--step",
|
"--step",
|
||||||
@@ -69,6 +71,10 @@ def main() -> None:
|
|||||||
"--platform",
|
"--platform",
|
||||||
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -95,6 +101,32 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
content = filter_platform(content, effective)
|
content = filter_platform(content, effective)
|
||||||
print(content, end="")
|
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:
|
else:
|
||||||
if args.json:
|
if args.json:
|
||||||
output_json()
|
output_json()
|
||||||
|
|||||||
@@ -94,6 +94,34 @@ def get_developer(repo_root: Path | None = None) -> str | None:
|
|||||||
return 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:
|
def check_developer(repo_root: Path | None = None) -> bool:
|
||||||
"""Check if developer is initialized.
|
"""Check if developer is initialized.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Provides:
|
|||||||
get_context_text_record - Text for record mode
|
get_context_text_record - Text for record mode
|
||||||
output_json - Print JSON
|
output_json - Print JSON
|
||||||
output_text - Print text
|
output_text - Print text
|
||||||
|
get_update_hint - Once-per-session "update available" line
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -417,7 +418,15 @@ def _compare_versions(left: str, right: str) -> int | None:
|
|||||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||||
|
|
||||||
|
|
||||||
def _update_marker_path(repo_root: Path) -> Path:
|
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()
|
context_key = resolve_context_key()
|
||||||
if not context_key:
|
if not context_key:
|
||||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||||
@@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
def _mark_update_check_attempted(
|
||||||
marker_path = _update_marker_path(repo_root)
|
repo_root: Path,
|
||||||
|
context_key: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
marker_path = _update_marker_path(repo_root, context_key)
|
||||||
if marker_path.exists():
|
if marker_path.exists():
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _get_update_hint(repo_root: Path) -> str | None:
|
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
|
||||||
marker_path = _update_marker_path(repo_root)
|
"""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():
|
if marker_path.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
|
|||||||
if not latest_version:
|
if not latest_version:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_mark_update_check_attempted(repo_root)
|
_mark_update_check_attempted(repo_root, context_key)
|
||||||
comparison = _compare_versions(current_version, latest_version)
|
comparison = _compare_versions(current_version, latest_version)
|
||||||
if comparison is None or comparison >= 0:
|
if comparison is None or comparison >= 0:
|
||||||
return None
|
return None
|
||||||
@@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None:
|
|||||||
"""
|
"""
|
||||||
if repo_root is None:
|
if repo_root is None:
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
update_hint = _get_update_hint(repo_root)
|
update_hint = get_update_hint(repo_root)
|
||||||
if update_hint:
|
if update_hint:
|
||||||
print(update_hint)
|
print(update_hint)
|
||||||
print("")
|
print("")
|
||||||
|
|||||||
Executable
+439
@@ -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
|
||||||
Executable
+395
@@ -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 []
|
||||||
@@ -25,7 +25,7 @@ from .config import get_context_injection_limits
|
|||||||
from .git import branch_exists_locally
|
from .git import branch_exists_locally
|
||||||
from .io import read_json
|
from .io import read_json
|
||||||
from .log import Colors, colored
|
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
|
from .task_utils import resolve_task_dir
|
||||||
|
|
||||||
# Extensions that look like code rather than spec/research docs. Entries with
|
# 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
|
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:
|
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
|
||||||
"""Validate a single JSONL file.
|
"""Validate a single JSONL file.
|
||||||
|
|
||||||
@@ -220,14 +273,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
real_entries += 1
|
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 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)}")
|
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
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)}")
|
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from .task_utils import (
|
|||||||
resolve_task_dir,
|
resolve_task_dir,
|
||||||
run_task_hooks,
|
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)
|
# Inferred: default_package → None (no task.json yet for create)
|
||||||
package = resolve_package(repo_root=repo_root)
|
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
|
# Default assignee to current developer
|
||||||
assignee = args.assignee
|
assignee = args.assignee
|
||||||
if not assignee:
|
if not assignee:
|
||||||
@@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|||||||
"notes": "",
|
"notes": "",
|
||||||
"meta": meta,
|
"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)
|
write_json(task_json_path, task_data)
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .paths import DIR_WORKFLOW, get_repo_root
|
from . import workflow_selection
|
||||||
|
from .paths import get_repo_root
|
||||||
|
|
||||||
|
|
||||||
def _workflow_md_path():
|
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]"
|
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||||
|
|||||||
+177
@@ -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)
|
||||||
@@ -11,6 +11,7 @@ Usage:
|
|||||||
python3 task.py start <dir> # Set active task
|
python3 task.py start <dir> # Set active task
|
||||||
python3 task.py current [--source] [--json] # Show active task
|
python3 task.py current [--source] [--json] # Show active task
|
||||||
python3 task.py finish # Clear 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-branch <dir> <branch> # Set git branch
|
||||||
python3 task.py set-base-branch <dir> <branch> # Set PR target 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
|
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.io import read_json, write_json
|
||||||
from common.task_utils import resolve_task_dir, run_task_hooks
|
from common.task_utils import resolve_task_dir, run_task_hooks
|
||||||
from common.tasks import iter_active_tasks, children_progress
|
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)
|
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
||||||
from common.task_store import (
|
from common.task_store import (
|
||||||
@@ -204,6 +206,72 @@ def cmd_current(args: argparse.Namespace) -> int:
|
|||||||
return 1
|
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
|
# 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> --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> --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> --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 add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||||||
python3 task.py validate <dir> Validate jsonl files
|
python3 task.py validate <dir> Validate jsonl files
|
||||||
python3 task.py list-context <dir> List jsonl entries
|
python3 task.py list-context <dir> List jsonl entries
|
||||||
python3 task.py start <dir> Set active task
|
python3 task.py start <dir> Set active task
|
||||||
python3 task.py current [--source] Show active task
|
python3 task.py current [--source] Show active task
|
||||||
python3 task.py finish Clear 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-branch <dir> <branch> Set git branch
|
||||||
python3 task.py set-base-branch <dir> <branch> Set PR target 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
|
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||||||
@@ -490,6 +561,10 @@ def main() -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Create the task without making it active in this session",
|
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
|
# add-context
|
||||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||||
@@ -520,6 +595,12 @@ def main() -> int:
|
|||||||
# finish
|
# finish
|
||||||
subparsers.add_parser("finish", help="Clear active task")
|
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
|
# set-branch
|
||||||
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||||||
p_branch.add_argument("dir", help="Task directory")
|
p_branch.add_argument("dir", help="Task directory")
|
||||||
@@ -580,6 +661,7 @@ def main() -> int:
|
|||||||
"start": cmd_start,
|
"start": cmd_start,
|
||||||
"current": cmd_current,
|
"current": cmd_current,
|
||||||
"finish": cmd_finish,
|
"finish": cmd_finish,
|
||||||
|
"workflow": cmd_workflow,
|
||||||
"set-branch": cmd_set_branch,
|
"set-branch": cmd_set_branch,
|
||||||
"set-base-branch": cmd_set_base_branch,
|
"set-base-branch": cmd_set_base_branch,
|
||||||
"set-scope": cmd_set_scope,
|
"set-scope": cmd_set_scope,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useState } from "react"
|
import { lazy, Suspense, useState, type ReactNode } from "react"
|
||||||
import { Button } from "@/shared/ui/button"
|
import { Button } from "@/shared/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -47,55 +47,91 @@ export function RadarDetailDialog({ row }: { row: RadarRankingRow }) {
|
|||||||
{row.sector_name}
|
{row.sector_name}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-[90dvh] grid-cols-1 overflow-y-auto sm:max-w-4xl">
|
<DialogContent className="max-h-[90dvh] grid-cols-1 overflow-y-auto sm:max-w-4xl">
|
||||||
<DialogTitle>{row.sector_name}</DialogTitle>
|
{open ? <RadarDetail row={row} /> : null}
|
||||||
<DialogDescription>
|
|
||||||
{row.sector_type === "concept" ? "概念" : "行业"} · {row.sector_code}{" "}
|
|
||||||
· {row.trade_date}
|
|
||||||
</DialogDescription>
|
|
||||||
{open ? (
|
|
||||||
<RadarDetail
|
|
||||||
initialMetric={row.metric_kind}
|
|
||||||
query={{
|
|
||||||
sectorType: row.sector_type,
|
|
||||||
sectorCode: row.sector_code,
|
|
||||||
tradeDate: row.trade_date,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarDetail({
|
function RadarDetail({ row }: { row: RadarRankingRow }) {
|
||||||
query,
|
const query: RadarSectorQuery = {
|
||||||
initialMetric,
|
sectorType: row.sector_type,
|
||||||
}: {
|
sectorCode: row.sector_code,
|
||||||
query: RadarSectorQuery
|
tradeDate: row.trade_date,
|
||||||
initialMetric: RadarMetricKind
|
}
|
||||||
}) {
|
|
||||||
const detail = useSectorRadarDetail(query)
|
const detail = useSectorRadarDetail(query)
|
||||||
if (detail.isPending) return <p role="status">正在加载板块详情…</p>
|
if (detail.isPending)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RadarDetailHeader row={row} />
|
||||||
|
<p role="status">正在加载板块详情…</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
if (detail.isError)
|
if (detail.isError)
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<RadarDetailHeader row={row} />
|
||||||
<div role="alert">
|
<div role="alert">
|
||||||
板块详情加载失败。
|
板块详情加载失败。
|
||||||
<Button onClick={() => void detail.refetch()} variant="outline">
|
<Button onClick={() => void detail.refetch()} variant="outline">
|
||||||
重试详情
|
重试详情
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
if (!detail.data || detail.data.status === "no_data")
|
if (!detail.data || detail.data.status === "no_data")
|
||||||
return <p>暂无板块详情数据。</p>
|
return (
|
||||||
return <RadarDetailBody data={detail.data} initialMetric={initialMetric} />
|
<>
|
||||||
|
<RadarDetailHeader row={row} />
|
||||||
|
<p>暂无板块详情数据。</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<RadarDetailBody
|
||||||
|
data={detail.data}
|
||||||
|
initialMetric={row.metric_kind}
|
||||||
|
row={row}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadarDetailHeader({
|
||||||
|
row,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
row: RadarRankingRow
|
||||||
|
actions?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="板块详情标题栏"
|
||||||
|
className="flex min-w-0 flex-col gap-2 pr-8 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<DialogTitle className="shrink-0">{row.sector_name}</DialogTitle>
|
||||||
|
<DialogDescription className="whitespace-nowrap">
|
||||||
|
{row.sector_type === "concept" ? "概念" : "行业"} · {row.sector_code}{" "}
|
||||||
|
· {row.trade_date}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-2 sm:ml-auto">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarDetailBody({
|
function RadarDetailBody({
|
||||||
data,
|
data,
|
||||||
initialMetric,
|
initialMetric,
|
||||||
|
row,
|
||||||
}: {
|
}: {
|
||||||
data: RadarDetailResponse
|
data: RadarDetailResponse
|
||||||
initialMetric: RadarMetricKind
|
initialMetric: RadarMetricKind
|
||||||
|
row: RadarRankingRow
|
||||||
}) {
|
}) {
|
||||||
const [metric, setMetric] = useState<RadarMetricKind>(initialMetric)
|
const [metric, setMetric] = useState<RadarMetricKind>(initialMetric)
|
||||||
const [memberMetric, setMemberMetric] =
|
const [memberMetric, setMemberMetric] =
|
||||||
@@ -130,7 +166,16 @@ function RadarDetailBody({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 space-y-5">
|
<div className="min-w-0 space-y-5">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="space-y-3">
|
||||||
|
<RadarDetailHeader
|
||||||
|
row={row}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{exportStatus ? (
|
||||||
|
<p role="status" className="text-xs text-muted-foreground">
|
||||||
|
{exportStatus}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -147,9 +192,10 @@ function RadarDetailBody({
|
|||||||
>
|
>
|
||||||
导出成员
|
导出成员
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
{exportStatus ? <p role="status">{exportStatus}</p> : null}
|
}
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-2 rounded-md bg-muted/50 p-3">
|
/>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 rounded-md bg-muted/50 px-3 py-2">
|
||||||
<p>实际交易日:{data.trade_date ?? "—"}</p>
|
<p>实际交易日:{data.trade_date ?? "—"}</p>
|
||||||
<p>
|
<p>
|
||||||
当日涨跌幅:
|
当日涨跌幅:
|
||||||
@@ -164,6 +210,7 @@ function RadarDetailBody({
|
|||||||
: "暂无数据"}
|
: "暂无数据"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<section aria-label="三指标排名" className="space-y-3">
|
<section aria-label="三指标排名" className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{(["swing", "ratio", "amount"] as const).map((key) => (
|
{(["swing", "ratio", "amount"] as const).map((key) => (
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ describe("radar detail interactions", () => {
|
|||||||
expect(detailQuery).not.toHaveBeenCalled()
|
expect(detailQuery).not.toHaveBeenCalled()
|
||||||
fireEvent.click(screen.getByRole("button", { name: "机器人" }))
|
fireEvent.click(screen.getByRole("button", { name: "机器人" }))
|
||||||
const dialog = await screen.findByRole("dialog")
|
const dialog = await screen.findByRole("dialog")
|
||||||
|
const compactHeader = within(dialog).getByRole("group", {
|
||||||
|
name: "板块详情标题栏",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
within(compactHeader).getByRole("heading", { name: "机器人" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(compactHeader).getByText(/概念 · BK01 · 2026-08-28/),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(compactHeader).getByRole("button", { name: "复制成员" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(compactHeader).getByRole("button", { name: "导出成员" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
expect(detailQuery).toHaveBeenCalledWith({
|
expect(detailQuery).toHaveBeenCalledWith({
|
||||||
sectorType: "concept",
|
sectorType: "concept",
|
||||||
sectorCode: "BK01",
|
sectorCode: "BK01",
|
||||||
|
|||||||
Reference in New Issue
Block a user