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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions .claude/hooks/inject-workflow-state.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
legacy name. ``_detect_platform`` picks the right value at runtime.
Breadcrumb text is pulled exclusively from workflow.md
[workflow-state:STATUS] tag blocks — workflow.md is the single source of
Breadcrumb text is pulled exclusively from the resolved workflow file's
[workflow-state:STATUS] tag blocks — the active task may select a
per-task variant (`.trellis/workflows/<id>.md` via task.json `workflow`),
otherwise the global workflow.md is the single source of
truth. There are no fallback dicts in this script: when workflow.md is
missing or a tag is absent, the breadcrumb degrades to a generic
"Refer to workflow.md for current step." line so users see (and fix)
Expand Down Expand Up @@ -139,9 +141,11 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
if not active.task_path:
return None

task_dir = Path(active.task_path)
if not task_dir.is_absolute():
task_dir = root / task_dir
from common.active_task import resolve_task_ref # type: ignore[import-not-found]

task_dir = resolve_task_ref(active.task_path, root)
if task_dir is None:
return None
if active.stale:
return task_dir.name, f"stale_{active.source_type}", active.source

Expand Down Expand Up @@ -171,16 +175,40 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
re.DOTALL,
)

def load_breadcrumbs(root: Path) -> dict[str, str]:
"""Parse workflow.md for [workflow-state:STATUS] blocks.
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.

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}. workflow.md is the single source of
Returns {status: body_text}. The workflow file is the single source of
truth — there are no fallback dicts in this script. Missing tags
(or a missing/unreadable workflow.md) fall back to a generic line
(or a missing/unreadable workflow file) fall back to a generic line
in build_breadcrumb so users see the broken state and fix
workflow.md, rather than the hook silently masking the issue.
The active task's per-task workflow selection (task.json `workflow`
field) is honored via _resolve_workflow_md; without a selection this
reads the global .trellis/workflow.md exactly as before.
"""
workflow = root / ".trellis" / "workflow.md"
workflow = _resolve_workflow_md(root, input_data)
if not workflow.is_file():
return {}
try:
Expand Down Expand Up @@ -357,7 +385,7 @@ def main() -> int:
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
return 0 # user opted out of the per-turn breadcrumb for this turn

templates = load_breadcrumbs(root)
templates = load_breadcrumbs(root, data)
platform = _detect_platform(data)
task = get_active_task(root, data)
if task is None:
Expand Down
23 changes: 22 additions & 1 deletion .claude/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,27 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
return re.sub(r"\n{3,}", "\n\n", stripped).strip()


def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.

The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]

return resolve_workflow_md(
root, input_data, platform=_detect_platform(input_data)
)
except Exception:
return root / ".trellis" / "workflow.md"


def _build_workflow_overview(workflow_path: Path) -> str:
"""Inject only the compact Phase Index summary for SessionStart."""
content = read_file(workflow_path)
Expand Down Expand Up @@ -782,7 +803,7 @@ def main():
output.write("\n</current-state>\n\n")

output.write("<trellis-workflow>\n")
output.write(_build_workflow_overview(trellis_dir / "workflow.md"))
output.write(_build_workflow_overview(_resolve_workflow_md(project_dir, hook_input)))
output.write("\n</trellis-workflow>\n\n")

output.write("<guidelines>\n")
Expand Down
44 changes: 35 additions & 9 deletions .codex/hooks/inject-workflow-state.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,11 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
if not active.task_path:
return None

task_dir = Path(active.task_path)
if not task_dir.is_absolute():
task_dir = root / task_dir
from common.active_task import resolve_task_ref # type: ignore[import-not-found]

task_dir = resolve_task_ref(active.task_path, root)
if task_dir is None:
return None
if active.stale:
return task_dir.name, f"stale_{active.source_type}", active.source

Expand Down Expand Up @@ -171,16 +173,40 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
re.DOTALL,
)

def load_breadcrumbs(root: Path) -> dict[str, str]:
"""Parse workflow.md for [workflow-state:STATUS] blocks.
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.

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}. workflow.md is the single source of
Returns {status: body_text}. The workflow file is the single source of
truth — there are no fallback dicts in this script. Missing tags
(or a missing/unreadable workflow.md) fall back to a generic line
(or a missing/unreadable workflow file) fall back to a generic line
in build_breadcrumb so users see the broken state and fix
workflow.md, rather than the hook silently masking the issue.
The active task's per-task workflow selection (task.json `workflow`
field) is honored via _resolve_workflow_md; without a selection this
reads the global .trellis/workflow.md exactly as before.
"""
workflow = root / ".trellis" / "workflow.md"
workflow = _resolve_workflow_md(root, input_data)
if not workflow.is_file():
return {}
try:
Expand Down Expand Up @@ -357,7 +383,7 @@ def main() -> int:
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
return 0 # user opted out of the per-turn breadcrumb for this turn

templates = load_breadcrumbs(root)
templates = load_breadcrumbs(root, data)
platform = _detect_platform(data)
task = get_active_task(root, data)
if task is None:
Expand Down
21 changes: 20 additions & 1 deletion .codex/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,25 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
return re.sub(r"\n{3,}", "\n\n", stripped).strip()


def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.

The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]

return resolve_workflow_md(root, input_data, platform="codex")
except Exception:
return root / ".trellis" / "workflow.md"


def _build_workflow_toc(workflow_path: Path) -> str:
"""Inject only the compact Phase Index summary for SessionStart."""
content = read_file(workflow_path)
Expand Down Expand Up @@ -499,7 +518,7 @@ def main() -> None:
output.write("\n</current-state>\n\n")

output.write("<trellis-workflow>\n")
output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input)))
output.write("\n</trellis-workflow>\n\n")

output.write("<guidelines>\n")
Expand Down
23 changes: 22 additions & 1 deletion .cursor/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,27 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
return re.sub(r"\n{3,}", "\n\n", stripped).strip()


def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.

The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]

return resolve_workflow_md(
root, input_data, platform=_detect_platform(input_data)
)
except Exception:
return root / ".trellis" / "workflow.md"


def _build_workflow_overview(workflow_path: Path) -> str:
"""Inject only the compact Phase Index summary for SessionStart."""
content = read_file(workflow_path)
Expand Down Expand Up @@ -782,7 +803,7 @@ def main():
output.write("\n</current-state>\n\n")

output.write("<trellis-workflow>\n")
output.write(_build_workflow_overview(trellis_dir / "workflow.md"))
output.write(_build_workflow_overview(_resolve_workflow_md(project_dir, hook_input)))
output.write("\n</trellis-workflow>\n\n")

output.write("<guidelines>\n")
Expand Down
71 changes: 61 additions & 10 deletions .opencode/plugins/inject-workflow-state.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* global process */
/* global process, console */
/**
* Trellis Workflow State Injection Plugin
*
Expand All @@ -8,7 +8,11 @@
* <workflow-state> breadcrumb reminding the main AI what task is
* active and its expected flow. Breadcrumb text is pulled exclusively
* from the project's workflow.md [workflow-state:STATUS] tag blocks —
* workflow.md is the single source of truth. There are no fallback
* workflow.md is the single source of truth. When the active task's
* task.json selects a workflow variant ("workflow": "<id>"), the tag
* blocks are read from .trellis/workflows/<id>.md instead (missing
* variant file → one stderr warning + fallback to workflow.md; see
* resolveWorkflowMd). There are no fallback
* tables in this plugin: when workflow.md is missing or a tag is
* absent, the breadcrumb degrades to a generic
* "Refer to workflow.md for current step." line so users see (and fix)
Expand All @@ -23,25 +27,72 @@
* - task.json malformed or missing status
*/

import { existsSync, readFileSync } from "fs"
import { existsSync, readFileSync, statSync } from "fs"
import { join } from "path"
import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-context.js"

// Supports STATUS values with letters, digits, underscores, hyphens
// (so "in-review" / "blocked-by-team" work alongside "in_progress").
const TAG_RE = /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g

// Per-task workflow selection (mirrors the Python resolver in
// .trellis/scripts/common/workflow_selection.py). Ids are restricted to
// [A-Za-z0-9_-]+ so a task.json value can never traverse outside
// .trellis/workflows/.
const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/

/**
* Resolve which workflow markdown file feeds this turn's breadcrumbs.
*
* Rule (identical across all Trellis consumers):
* - Active task's task.json has a non-empty string "workflow" field whose
* id matches [A-Za-z0-9_-]+ AND .trellis/workflows/<id>.md is a file →
* use that path.
* - Selection present but id invalid / variant file missing → one warning
* line on stderr (never stdout — stdout is hook JSON on other hosts)
* and fall back to the global .trellis/workflow.md.
* - No active task / no "workflow" field / anything unreadable → global
* path, silently. Never throws.
*/
function resolveWorkflowMd(ctx, directory, platformInput) {
const globalPath = join(directory, ".trellis", "workflow.md")
let workflowId = ""
try {
const active = ctx.getActiveTask(platformInput)
if (!active.taskPath || active.stale) return globalPath
const taskDir = ctx.resolveTaskDir(active.taskPath)
if (!taskDir) return globalPath
const data = JSON.parse(readFileSync(join(taskDir, "task.json"), "utf-8"))
if (data && typeof data.workflow === "string") workflowId = data.workflow
} catch {
// No active task / unreadable task.json — not a selection, no warning.
return globalPath
}
if (!workflowId) return globalPath
if (WORKFLOW_ID_RE.test(workflowId)) {
const variantPath = join(directory, ".trellis", "workflows", `${workflowId}.md`)
try {
if (statSync(variantPath).isFile()) return variantPath
} catch {
// ENOENT etc. — treated as a missing variant file; warn below.
}
}
console.error(
`Warning: active task selects workflow ${JSON.stringify(workflowId)} but .trellis/workflows/ has no matching file; using .trellis/workflow.md`,
)
return globalPath
}

/**
* Parse workflow.md for [workflow-state:STATUS] blocks.
* Parse the resolved workflow markdown for [workflow-state:STATUS] blocks.
*
* Returns {status: body}. workflow.md is the single source of truth —
* there are no fallback tables here. Missing tags (or a missing /
* unreadable workflow.md) fall back to a generic line in
* Returns {status: body}. The workflow file is the single source of
* truth — there are no fallback tables here. Missing tags (or a missing /
* unreadable workflow file) fall back to a generic line in
* buildBreadcrumb so users see the broken state and fix workflow.md
* rather than the plugin silently masking it.
*/
function loadBreadcrumbs(directory) {
const workflowPath = join(directory, ".trellis", "workflow.md")
function loadBreadcrumbs(workflowPath) {
if (!existsSync(workflowPath)) return {}
let content
try {
Expand Down Expand Up @@ -124,7 +175,7 @@ export default async ({ directory }) => {
if (!ctx.isTrellisProject()) {
return
}
const templates = loadBreadcrumbs(directory)
const templates = loadBreadcrumbs(resolveWorkflowMd(ctx, directory, input))
const task = getActiveTask(ctx, input)
const breadcrumb = task
? buildBreadcrumb(task.id, task.status, templates, task.source)
Expand Down
Loading
Loading