From 69bc8a54b2e6e7403517d4c811ee7afb14ae057d Mon Sep 17 00:00:00 2001 From: taosu Date: Thu, 30 Jul 2026 21:17:22 +0800 Subject: [PATCH 1/2] revert: keep per-task workflow selection on beta This reverts commit c3596dd95f821ab23f50209bef60a5e6ab9f9569 (#467), which targeted main but belongs to the v0.7 beta release line. --- .claude/hooks/inject-workflow-state.py | 50 ++---- .claude/hooks/session-start.py | 23 +-- .codex/hooks/inject-workflow-state.py | 44 +---- .codex/hooks/session-start.py | 21 +-- .cursor/hooks/session-start.py | 23 +-- .opencode/plugins/inject-workflow-state.js | 126 ++++---------- .trellis/scripts/common/task_store.py | 29 ---- .trellis/scripts/common/types.py | 1 - .trellis/scripts/common/workflow_phase.py | 5 +- .trellis/scripts/common/workflow_selection.py | 110 ------------ .trellis/scripts/task.py | 86 --------- .../spec/cli/backend/commands-workflow.md | 91 +--------- .../cli/backend/workflow-state-contract.md | 70 +------- .../tasks/07-23-per-task-workflow/check.jsonl | 6 - .../tasks/07-23-per-task-workflow/design.md | 137 --------------- .../07-23-per-task-workflow/implement.jsonl | 8 - .../07-23-per-task-workflow/implement.md | 88 ---------- .trellis/tasks/07-23-per-task-workflow/prd.md | 90 ---------- .../tasks/07-23-per-task-workflow/task.json | 26 --- .trellis/workspace/tommy/index.md | 40 ----- .trellis/workspace/tommy/journal-1.md | 7 - packages/cli/src/cli/index.ts | 5 - packages/cli/src/commands/workflow.ts | 150 ---------------- .../templates/codex/hooks/session-start.py | 21 +-- .../templates/copilot/hooks/session-start.py | 21 +-- .../opencode/plugins/inject-workflow-state.js | 126 ++++---------- .../shared-hooks/inject-workflow-state.py | 50 ++---- .../templates/shared-hooks/session-start.py | 23 +-- packages/cli/src/templates/trellis/index.ts | 4 - .../trellis/scripts/common/task_store.py | 29 ---- .../templates/trellis/scripts/common/types.py | 1 - .../trellis/scripts/common/workflow_phase.py | 5 +- .../scripts/common/workflow_selection.py | 110 ------------ .../cli/src/templates/trellis/scripts/task.py | 86 --------- .../commands/workflow.integration.test.ts | 164 ------------------ ...ct-workflow-state-kiro.integration.test.ts | 107 +----------- packages/cli/test/templates/opencode.test.ts | 72 +------- packages/core/src/task/records.ts | 8 +- packages/core/src/task/schema.ts | 26 +-- packages/core/test/task/records.test.ts | 24 --- packages/core/test/task/schema.test.ts | 22 --- 41 files changed, 123 insertions(+), 2012 deletions(-) delete mode 100755 .trellis/scripts/common/workflow_selection.py delete mode 100644 .trellis/tasks/07-23-per-task-workflow/check.jsonl delete mode 100644 .trellis/tasks/07-23-per-task-workflow/design.md delete mode 100644 .trellis/tasks/07-23-per-task-workflow/implement.jsonl delete mode 100644 .trellis/tasks/07-23-per-task-workflow/implement.md delete mode 100644 .trellis/tasks/07-23-per-task-workflow/prd.md delete mode 100644 .trellis/tasks/07-23-per-task-workflow/task.json delete mode 100644 .trellis/workspace/tommy/index.md delete mode 100644 .trellis/workspace/tommy/journal-1.md delete mode 100644 packages/cli/src/templates/trellis/scripts/common/workflow_selection.py diff --git a/.claude/hooks/inject-workflow-state.py b/.claude/hooks/inject-workflow-state.py index 21f17d78f..0c2fbea56 100755 --- a/.claude/hooks/inject-workflow-state.py +++ b/.claude/hooks/inject-workflow-state.py @@ -10,10 +10,8 @@ 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 the resolved workflow file's -[workflow-state:STATUS] tag blocks — the active task may select a -per-task variant (`.trellis/workflows/.md` via task.json `workflow`), -otherwise the global workflow.md is the single source of +Breadcrumb text is pulled exclusively from workflow.md +[workflow-state:STATUS] tag blocks — workflow.md is the single source of truth. There are no fallback dicts in this script: when workflow.md is 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) @@ -141,11 +139,9 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st if not active.task_path: return None - 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 + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir if active.stale: return task_dir.name, f"stale_{active.source_type}", active.source @@ -175,40 +171,16 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st re.DOTALL, ) -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. +def load_breadcrumbs(root: Path) -> dict[str, str]: + """Parse workflow.md for [workflow-state:STATUS] blocks. - Returns {status: body_text}. The workflow file is the single source of + Returns {status: body_text}. workflow.md is the single source of truth — there are no fallback dicts in this script. Missing tags - (or a missing/unreadable workflow file) fall back to a generic line + (or a missing/unreadable workflow.md) 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 = _resolve_workflow_md(root, input_data) + workflow = root / ".trellis" / "workflow.md" if not workflow.is_file(): return {} try: @@ -385,7 +357,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, data) + templates = load_breadcrumbs(root) platform = _detect_platform(data) task = get_active_task(root, data) if task is None: diff --git a/.claude/hooks/session-start.py b/.claude/hooks/session-start.py index 584482143..bfc5282fb 100755 --- a/.claude/hooks/session-start.py +++ b/.claude/hooks/session-start.py @@ -699,27 +699,6 @@ 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) @@ -803,7 +782,7 @@ def main(): output.write("\n\n\n") output.write("\n") - output.write(_build_workflow_overview(_resolve_workflow_md(project_dir, hook_input))) + output.write(_build_workflow_overview(trellis_dir / "workflow.md")) output.write("\n\n\n") output.write("\n") diff --git a/.codex/hooks/inject-workflow-state.py b/.codex/hooks/inject-workflow-state.py index a803a42a9..0c2fbea56 100755 --- a/.codex/hooks/inject-workflow-state.py +++ b/.codex/hooks/inject-workflow-state.py @@ -139,11 +139,9 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st if not active.task_path: return None - 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 + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir if active.stale: return task_dir.name, f"stale_{active.source_type}", active.source @@ -173,40 +171,16 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st re.DOTALL, ) -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. +def load_breadcrumbs(root: Path) -> dict[str, str]: + """Parse workflow.md for [workflow-state:STATUS] blocks. - Returns {status: body_text}. The workflow file is the single source of + Returns {status: body_text}. workflow.md is the single source of truth — there are no fallback dicts in this script. Missing tags - (or a missing/unreadable workflow file) fall back to a generic line + (or a missing/unreadable workflow.md) 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 = _resolve_workflow_md(root, input_data) + workflow = root / ".trellis" / "workflow.md" if not workflow.is_file(): return {} try: @@ -383,7 +357,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, data) + templates = load_breadcrumbs(root) platform = _detect_platform(data) task = get_active_task(root, data) if task is None: diff --git a/.codex/hooks/session-start.py b/.codex/hooks/session-start.py index d5ec43875..d1dec97c7 100755 --- a/.codex/hooks/session-start.py +++ b/.codex/hooks/session-start.py @@ -446,25 +446,6 @@ 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) @@ -518,7 +499,7 @@ def main() -> None: output.write("\n\n\n") output.write("\n") - output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input))) + output.write(_build_workflow_toc(trellis_dir / "workflow.md")) output.write("\n\n\n") output.write("\n") diff --git a/.cursor/hooks/session-start.py b/.cursor/hooks/session-start.py index 584482143..bfc5282fb 100755 --- a/.cursor/hooks/session-start.py +++ b/.cursor/hooks/session-start.py @@ -699,27 +699,6 @@ 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) @@ -803,7 +782,7 @@ def main(): output.write("\n\n\n") output.write("\n") - output.write(_build_workflow_overview(_resolve_workflow_md(project_dir, hook_input))) + output.write(_build_workflow_overview(trellis_dir / "workflow.md")) output.write("\n\n\n") output.write("\n") diff --git a/.opencode/plugins/inject-workflow-state.js b/.opencode/plugins/inject-workflow-state.js index f6d5fcace..888fb5991 100644 --- a/.opencode/plugins/inject-workflow-state.js +++ b/.opencode/plugins/inject-workflow-state.js @@ -1,4 +1,4 @@ -/* global process, console */ +/* global process */ /** * Trellis Workflow State Injection Plugin * @@ -8,11 +8,7 @@ * 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. When the active task's - * task.json selects a workflow variant ("workflow": ""), the tag - * blocks are read from .trellis/workflows/.md instead (missing - * variant file → one stderr warning + fallback to workflow.md; see - * resolveWorkflowMd). There are no fallback + * workflow.md is the single source of truth. 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) @@ -27,7 +23,7 @@ * - task.json malformed or missing status */ -import { existsSync, readFileSync, statSync } from "fs" +import { existsSync, readFileSync } from "fs" import { join } from "path" import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-context.js" @@ -35,88 +31,17 @@ import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-cont // (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 and read active task state once for the current turn. - */ -function resolveActiveTaskState(ctx, platformInput = null) { - const active = ctx.getActiveTask(platformInput) - const taskRef = active.taskPath - if (!taskRef) return null - const taskDir = ctx.resolveTaskDir(taskRef) - if (active.stale || !taskDir || !existsSync(taskDir)) { - return { taskRef, source: active.source, stale: true, data: null } - } - const taskJsonPath = join(taskDir, "task.json") - if (!existsSync(taskJsonPath)) { - return { taskRef, source: active.source, stale: false, data: null } - } - try { - return { - taskRef, - source: active.source, - stale: false, - data: JSON.parse(readFileSync(taskJsonPath, "utf-8")), - } - } catch { - return { taskRef, source: active.source, stale: false, data: null } - } -} - -/** - * 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/.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(directory, state) { - const globalPath = join(directory, ".trellis", "workflow.md") - const data = state?.data - if (state?.stale || !data || typeof data !== "object") return globalPath - if (!Object.prototype.hasOwnProperty.call(data, "workflow")) return globalPath - - const workflowId = data.workflow - if (typeof workflowId !== "string" || !WORKFLOW_ID_RE.test(workflowId)) { - console.error( - `Warning: active task has invalid workflow id ${JSON.stringify(workflowId)}; using .trellis/workflow.md`, - ) - return globalPath - } - - 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 the resolved workflow markdown for [workflow-state:STATUS] blocks. + * Parse workflow.md for [workflow-state:STATUS] blocks. * - * 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 + * 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 * buildBreadcrumb so users see the broken state and fix workflow.md * rather than the plugin silently masking it. */ -function loadBreadcrumbs(workflowPath) { +function loadBreadcrumbs(directory) { + const workflowPath = join(directory, ".trellis", "workflow.md") if (!existsSync(workflowPath)) return {} let content try { @@ -136,17 +61,25 @@ function loadBreadcrumbs(workflowPath) { /** * Get (taskId, status) from active task, or null if no active task. */ -function getActiveTask(state) { - if (!state) return null - if (state.stale) { - return { id: state.taskRef.split("/").pop(), status: "stale", source: state.source } +function getActiveTask(ctx, platformInput = null) { + const active = ctx.getActiveTask(platformInput) + const taskRef = active.taskPath + if (!taskRef) return null + const taskDir = ctx.resolveTaskDir(taskRef) + if (active.stale || !taskDir || !existsSync(taskDir)) { + return { id: taskRef.split("/").pop(), status: "stale", source: active.source } + } + const taskJsonPath = join(taskDir, "task.json") + if (!existsSync(taskJsonPath)) return null + try { + const data = JSON.parse(readFileSync(taskJsonPath, "utf-8")) + const status = typeof data.status === "string" ? data.status : "" + if (!status) return null + const id = data.id || taskRef.split("/").pop() + return { id, status, source: active.source } + } catch { + return null } - const data = state.data - if (!data || typeof data !== "object") return null - const status = typeof data.status === "string" ? data.status : "" - if (!status) return null - const id = data.id || state.taskRef.split("/").pop() - return { id, status, source: state.source } } /** @@ -191,9 +124,8 @@ export default async ({ directory }) => { if (!ctx.isTrellisProject()) { return } - const state = resolveActiveTaskState(ctx, input) - const templates = loadBreadcrumbs(resolveWorkflowMd(directory, state)) - const task = getActiveTask(state) + const templates = loadBreadcrumbs(directory) + const task = getActiveTask(ctx, input) const breadcrumb = task ? buildBreadcrumb(task.id, task.status, templates, task.source) : buildBreadcrumb(null, "no_task", templates) diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py index cd606c64d..2cd978998 100755 --- a/.trellis/scripts/common/task_store.py +++ b/.trellis/scripts/common/task_store.py @@ -56,7 +56,6 @@ resolve_task_dir, run_task_hooks, ) -from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE # ============================================================================= @@ -255,30 +254,6 @@ def cmd_create(args: argparse.Namespace) -> int: # Inferred: default_package → None (no task.json yet for create) package = resolve_package(repo_root=repo_root) - # Validate --workflow (CLI source: fail-fast on invalid id; a missing - # library file only warns — it may be saved later via `trellis workflow --save`) - workflow_id: str | None = getattr(args, "workflow", None) - if workflow_id: - if not WORKFLOW_ID_RE.fullmatch(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; " - "the global workflow.md is used until it is saved (trellis workflow --save).", - Colors.YELLOW, - ), - file=sys.stderr, - ) - # Default assignee to current developer assignee = args.assignee if not assignee: @@ -410,10 +385,6 @@ def cmd_create(args: argparse.Namespace) -> int: "notes": "", "meta": meta, } - # Optional per-task workflow selection: key present only when opted in, - # so tasks without a selection keep today's task.json shape byte-for-byte. - if workflow_id: - task_data["workflow"] = workflow_id write_json(task_json_path, task_data) diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py index adf76376d..5802e1012 100644 --- a/.trellis/scripts/common/types.py +++ b/.trellis/scripts/common/types.py @@ -49,7 +49,6 @@ class TaskData(TypedDict, total=False): relatedFiles: list[str] notes: str meta: dict - workflow: str # ============================================================================= diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py index 858c021e7..9e1c619c2 100755 --- a/.trellis/scripts/common/workflow_phase.py +++ b/.trellis/scripts/common/workflow_phase.py @@ -22,12 +22,11 @@ import re -from . import workflow_selection -from .paths import get_repo_root +from .paths import DIR_WORKFLOW, get_repo_root def _workflow_md_path(): - return workflow_selection.resolve_workflow_md(get_repo_root()) + return get_repo_root() / DIR_WORKFLOW / "workflow.md" # Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" _MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") diff --git a/.trellis/scripts/common/workflow_selection.py b/.trellis/scripts/common/workflow_selection.py deleted file mode 100755 index 646b350a9..000000000 --- a/.trellis/scripts/common/workflow_selection.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -""" -Per-task workflow selection. - -Resolves which workflow markdown file consumers should read. A task may pin -a workflow variant by storing `"workflow": ""` in its task.json; the -variant body lives at `.trellis/workflows/.md` (user-managed library). - -Resolution rule (single source of truth for all consumers): - - Active task's task.json has a non-empty string `workflow` field whose - id matches `[A-Za-z0-9_-]+` AND `.trellis/workflows/.md` is a - file -> that variant path. - - Selection present but id invalid or file missing -> one warning line - on stderr (stdout is hook JSON), fall back to `.trellis/workflow.md`. - - No task / no field / anything unreadable -> `.trellis/workflow.md`. - - Never raises. - -Provides: - workflow_md_for_task - Resolution rule 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 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 per-task resolution rule documented in the module docstring. - Never raises; any failure falls back to the global workflow path. - """ - fallback = _global_workflow_md(repo_root) - if task_dir is None: - return fallback - - try: - raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8")) - if not isinstance(raw, dict): - return fallback - - if "workflow" not in raw: - return fallback - - workflow_id = raw["workflow"] - if not isinstance(workflow_id, str) or not WORKFLOW_ID_RE.fullmatch( - workflow_id - ): - print( - f"Warning: task '{task_dir.name}' has invalid workflow id " - f"{workflow_id!r}; using {DIR_WORKFLOW}/workflow.md", - file=sys.stderr, - ) - return fallback - - variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md" - if variant.is_file(): - 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 {DIR_WORKFLOW}/workflow.md", - file=sys.stderr, - ) - return fallback - except Exception: - return fallback - - -def resolve_workflow_md( - repo_root: Path, - input_data: dict | None = None, - platform: str | None = None, -) -> Path: - """Resolve the session-aware active task, then apply the resolution rule. - - ``input_data`` is the raw hook payload (session/conversation identity); - CLI callers may omit it — the active-task resolver then falls back to - environment context. Never raises; any failure resolves to the global - `.trellis/workflow.md`. - """ - try: - from .active_task import resolve_active_task, resolve_task_ref - - active = resolve_active_task(repo_root, input_data, platform) - task_dir: Path | None = None - if active.task_path: - task_dir = resolve_task_ref(active.task_path, repo_root) - return workflow_md_for_task(repo_root, task_dir) - except Exception: - return _global_workflow_md(repo_root) diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py index f6c005f3c..7e82eacbe 100755 --- a/.trellis/scripts/task.py +++ b/.trellis/scripts/task.py @@ -11,7 +11,6 @@ python3 task.py start # Set active task python3 task.py current [--source] [--json] # Show active task python3 task.py finish # Clear active task - python3 task.py workflow |--clear # Set/clear per-task workflow selection python3 task.py set-branch # Set git branch python3 task.py set-base-branch # Set PR target branch python3 task.py set-scope # Set scope for PR title @@ -43,13 +42,11 @@ clear_active_task, resolve_active_task, resolve_context_key, - resolve_task_ref, set_active_task, ) from common.io import read_json, write_json from common.task_utils import resolve_task_dir, run_task_hooks from common.tasks import iter_active_tasks, children_progress -from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task # Import command handlers from split modules (also re-exports for plan.py compatibility) from common.task_store import ( @@ -207,75 +204,6 @@ def cmd_current(args: argparse.Namespace) -> int: return 1 -# ============================================================================= -# Command: workflow -# ============================================================================= - -def cmd_workflow(args: argparse.Namespace) -> int: - """Set or clear the workflow selection on the current session's active task.""" - repo_root = get_repo_root() - - if args.clear and args.id: - print(colored("Error: pass either or --clear, not both", Colors.RED)) - return 1 - if not args.clear and not args.id: - print(colored("Error: workflow id required (or --clear)", Colors.RED)) - print("Usage: python3 task.py workflow | --clear") - return 1 - - active = resolve_active_task(repo_root) - if not active.task_path: - print(colored("Error: No current task set", Colors.RED)) - print("Hint: run task.py start first") - return 1 - - task_dir = resolve_task_ref(active.task_path, repo_root) - if task_dir is None: - print(colored(f"Error: invalid task path: {active.task_path}", Colors.RED)) - return 1 - 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.fullmatch(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 # ============================================================================= @@ -454,15 +382,12 @@ def show_usage() -> None: python3 task.py create --package <pkg> Create task for a specific package python3 task.py create <title> --parent <dir> Create task as child of parent python3 task.py create <title> --no-start Create without making it active in this session - python3 task.py create <title> --workflow <id> Create task pinned to a workflow variant python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl python3 task.py validate <dir> Validate jsonl files python3 task.py list-context <dir> List jsonl entries python3 task.py start <dir> Set active task python3 task.py current [--source] Show active task python3 task.py finish Clear active task - python3 task.py workflow <id> Select workflow variant for active task - python3 task.py workflow --clear Clear selection (use global workflow.md) python3 task.py set-branch <dir> <branch> Set git branch python3 task.py set-base-branch <dir> <branch> Set PR target branch python3 task.py set-scope <dir> <scope> Set scope for PR title @@ -565,10 +490,6 @@ def main() -> int: action="store_true", help="Create the task without making it active in this session", ) - p_create.add_argument( - "--workflow", - help="Workflow variant id for this task (.trellis/workflows/<id>.md)", - ) # add-context p_add = subparsers.add_parser("add-context", help="Add context entry") @@ -599,12 +520,6 @@ def main() -> int: # finish subparsers.add_parser("finish", help="Clear active task") - # workflow - p_workflow = subparsers.add_parser("workflow", help="Set/clear per-task workflow selection") - p_workflow.add_argument("id", nargs="?", help="Workflow id (.trellis/workflows/<id>.md)") - p_workflow.add_argument("--clear", action="store_true", - help="Remove the workflow selection (use global workflow.md)") - # set-branch p_branch = subparsers.add_parser("set-branch", help="Set git branch") p_branch.add_argument("dir", help="Task directory") @@ -665,7 +580,6 @@ def main() -> int: "start": cmd_start, "current": cmd_current, "finish": cmd_finish, - "workflow": cmd_workflow, "set-branch": cmd_set_branch, "set-base-branch": cmd_set_base_branch, "set-scope": cmd_set_scope, diff --git a/.trellis/spec/cli/backend/commands-workflow.md b/.trellis/spec/cli/backend/commands-workflow.md index 34da87768..1f9104080 100644 --- a/.trellis/spec/cli/backend/commands-workflow.md +++ b/.trellis/spec/cli/backend/commands-workflow.md @@ -2,10 +2,7 @@ `trellis workflow` lists and switches the project's active `.trellis/workflow.md` template. It is the only command that deliberately replaces an existing -workflow variant in-place after init. `--save <id>` instead populates the -per-task variant library (`.trellis/workflows/<id>.md`) without touching the -active workflow; how consumers resolve a task's selected variant is specified -in `workflow-state-contract.md`. +workflow variant in-place after init. ## Scenario: workflow marketplace templates and switcher @@ -36,10 +33,6 @@ trellis workflow --marketplace <source> --template <id> trellis workflow --template <id> --force trellis workflow --template <id> --create-new -trellis workflow --save <id> -trellis workflow --marketplace <source> --save <id> -trellis workflow --save <id> --force - trellis init --workflow <id> trellis init --workflow-source <source> --workflow <id> ``` @@ -120,39 +113,7 @@ Ownership contract: `removeHash`. - Do not add `workflow.variant` or any other long-lived config field to make `trellis update` chase a selected variant. Switching is an explicit project - action. (Per-task selection via `task.json.workflow` is task-scoped state, - not config — it does not violate this rule.) -- `--save <id>` writes only `.trellis/workflows/<id>.md`. It never touches - `.trellis/workflow.md` or `.trellis/.template-hashes.json` — no - `updateHashes`, no `removeHash`, whether the resolved template is native or - marketplace. - -Library ownership contract (`.trellis/workflows/`): - -- Everything under `.trellis/workflows/` is user-managed local content: never - hash-tracked, never touched by `trellis update` (the directory is never in - update's desired-file map, same as `tasks/`). Refreshing a saved variant - means re-running `--save <id> --force`. -- Library ids must match `^[A-Za-z0-9_-]+$` — the same charset the per-task - resolvers accept (`common/workflow_selection.py`), so every saved id is - resolvable and a task.json value can never escape `.trellis/workflows/`. -- `--save` composes with `-m/--marketplace <source>` exactly like - `--template`, and never composes with the active-workflow write modes - (`--template`, `--create-new`). -- `--list` additionally prints a `Library (.trellis/workflows/):` section - listing the `.md` ids found on disk (sorted); the section is omitted when - the directory is absent or empty. - -Marker validation contract (`--save` only; warn, never block): - -- After writing the library file, `--save` checks the saved content for the - runtime parser markers and prints a single stderr warning listing whatever - is missing: `## Phase Index`, at least one `#### X.Y` step heading, and the - six native `[workflow-state:*]` blocks (`no_task`, `planning`, - `planning-inline`, `in_progress`, `in_progress-inline`, `completed`). -- The file is still written and the command exits 0 — consumers degrade to - generic breadcrumbs / partial phase detail where markers are absent. -- Warnings go to stderr; stdout stays reserved for command output. + action. Runtime parser contract: @@ -160,10 +121,8 @@ Runtime parser contract: `#### X.Y` step headings, platform marker syntax, and all required `[workflow-state:*]` blocks. - SessionStart, per-turn workflow-state hooks, `trellis-start`, and - `get_context.py --mode phase` read the resolved workflow file (the active - task's `.trellis/workflows/<id>.md` when selected, else the current - `.trellis/workflow.md` — resolution order in `workflow-state-contract.md`); - do not duplicate variant-specific behavior in hook scripts or skills. + `get_context.py --mode phase` read the current `.trellis/workflow.md`; do not + duplicate variant-specific behavior in hook scripts or skills. Native source-of-truth contract: @@ -186,29 +145,17 @@ Native source-of-truth contract: | `init --workflow missing-id` | Reject; do not print and return success | | `init --workflow tdd` | Write marketplace content and remove `.trellis/workflow.md` hash | | `trellis update` after switching to non-native | Treat workflow as modified/user-managed; never silently restore native | -| `--save <id>` where id fails `[A-Za-z0-9_-]+` | Exit 1 with invalid-id error before any resolve/fetch | -| `--save <id>` combined with `--template` or `--create-new` | Exit 1; the library write never composes with active-workflow modes | -| `--save <id>` and `.trellis/workflows/<id>.md` exists | Exit 1 with guidance to re-run with `--force`; `--force` overwrites the library file only | -| `--save missing-id` | `WorkflowResolveError` surfaced as command error; nothing written | -| `--save` of a template missing parser markers | Write the file, exit 0, one stderr warning listing the missing markers | -| `trellis update` with saved library files present | Leave `.trellis/workflows/` untouched | ### 5. Good/Base/Bad Cases - Good: `trellis workflow --template tdd` replaces a pristine native workflow, removes the workflow hash, and later `trellis update --skip-all` leaves TDD content in place. -- Good: `trellis workflow --save tdd` writes `.trellis/workflows/tdd.md` while - `.trellis/workflow.md` and `.template-hashes.json` stay byte-unchanged, and - a later `trellis update` leaves the library file alone. - Base: `trellis init --workflow native` writes bundled native workflow and keeps `.trellis/workflow.md` hash-tracked. - Bad: `trellis workflow --template tdd` writes TDD content and records the TDD hash. The next `trellis update` sees a pristine file and overwrites it with native workflow. -- Bad: `--save` removes the `.trellis/workflow.md` hash (or records one for the - library file). The command mutated the hash contract of a file it never - wrote, or turned user-managed library content into a Trellis-owned template. ### 6. Tests Required @@ -237,15 +184,6 @@ Integration tests: - Real `marketplace/workflows/tdd/workflow.md` planning breadcrumbs include the TDD gates: observable behavior slices, public interface under test, and mock boundaries. -- `--save tdd` writes the library file; `.trellis/workflow.md` and - `.template-hashes.json` are byte-unchanged. -- `--save` on an existing library file fails without `--force` and overwrites - with it. -- `--save` of a variant missing `[workflow-state:*]` blocks warns on stderr and - still writes the file; `--save native` emits no marker warning. -- `--save` combined with `--template` or `--create-new` fails. -- `--list` shows saved library ids in a `Library` section. -- `trellis update` leaves saved library files intact. Runtime parsing validation: @@ -300,24 +238,3 @@ if (explicitTemplate || !isInteractive()) { ``` Only the no-argument interactive picker may prompt for conflict resolution. - -#### Wrong: --save - -```typescript -// Treats a library save like an active-workflow switch. -fs.writeFileSync(".trellis/workflows/tdd.md", finalContent); -removeHash(cwd, PATHS.WORKFLOW_GUIDE_FILE); -``` - -This mutates the hash contract of `.trellis/workflow.md`, a file the `--save` -path never wrote. - -#### Correct: --save - -```typescript -fs.writeFileSync(".trellis/workflows/tdd.md", finalContent); -// No updateHashes / removeHash: the library is user-managed by definition. -``` - -Hash absence is what keeps `trellis update` away from the library — never add -library paths to `.template-hashes.json`. diff --git a/.trellis/spec/cli/backend/workflow-state-contract.md b/.trellis/spec/cli/backend/workflow-state-contract.md index 648414bc5..849d104a2 100644 --- a/.trellis/spec/cli/backend/workflow-state-contract.md +++ b/.trellis/spec/cli/backend/workflow-state-contract.md @@ -21,16 +21,14 @@ main session will silently skip them. Prior bugs around planning gates and Phase 3.4 commit reminders hit exactly this failure mode. This document is the source of truth for the runtime mechanics. The user-facing -breadcrumb body lives in `.trellis/workflow.md` — or in the active task's -selected variant file (see "Per-task workflow resolution" below); this spec -covers everything **around** it (parsers, writers, lifecycle, reachability). +breadcrumb body lives in `.trellis/workflow.md`; this spec covers everything +**around** it (parsers, writers, lifecycle, reachability). --- ## Marker syntax -Each breadcrumb body lives in a managed block of the global workflow or the -active task's selected variant: +Each breadcrumb body lives in a managed block of `.trellis/workflow.md`: ``` [workflow-state:STATUS] @@ -75,9 +73,8 @@ Both regexes MUST use the `\1` backreference variant — `[workflow-state:([A-Za per-session active task. If absent → status is the pseudo `no_task`. If the pointer is stale (task dir deleted) → status is `stale_<source_type>`. 4. Otherwise it reads `task.json.status` from the resolved task directory. -5. It resolves the workflow file (per-task resolution order below: the - active task's `.trellis/workflows/<id>.md` when selected, else - `.trellis/workflow.md`) and parses every `[workflow-state:STATUS]` block. +5. It opens `.trellis/workflow.md` and parses every `[workflow-state:STATUS]` + block. 6. Codex may map `planning` / `in_progress` to `planning-inline` / `in_progress-inline` based on `codex.dispatch_mode`; all other platforms use the plain status. @@ -113,58 +110,10 @@ Both regexes MUST use the `\1` backreference variant — `[workflow-state:([A-Za --- -## Per-task workflow resolution - -`.trellis/workflow.md` is the **global** workflow. A task may pin a variant -by storing `"workflow": "<id>"` in its task.json (writers: `task.py create ---workflow <id>`, `task.py workflow <id>` / `--clear`); variant bodies live -in the user-managed library `.trellis/workflows/<id>.md`, populated by -`trellis workflow --save <id>` (see `commands-workflow.md`). Every runtime -consumer of workflow markdown resolves the path with the same rule, whose -single source is `.trellis/scripts/common/workflow_selection.py`: - -1. Active task's task.json has a non-empty string `workflow` field matching - `^[A-Za-z0-9_-]+$` AND `.trellis/workflows/<id>.md` is a file → use that - path. -2. Selection present but id invalid or variant file missing → one warning - line on **stderr** (never stdout — stdout is hook JSON), fall back to - `.trellis/workflow.md`. -3. No active task / no `workflow` field / anything unreadable → - `.trellis/workflow.md`, silently. - -The resolver never raises. The hooks additionally wrap the -`common.workflow_selection` import itself in try/except and fall back to the -global path (older installed projects may not ship the module; hooks must -never crash the session). - -Consumers that resolve per-task: - -| Consumer | Serves | -|---|---| -| `shared-hooks/session-start.py` (`_resolve_workflow_md`) | SessionStart Phase Index (`<trellis-workflow>` block) | -| `shared-hooks/inject-workflow-state.py` (`load_breadcrumbs`) | per-turn `[workflow-state:*]` breadcrumb bodies | -| `scripts/common/workflow_phase.py` (`get_context.py --mode phase`) | phase/step detail bodies | -| `opencode/plugins/inject-workflow-state.js` (`resolveWorkflowMd`) | per-turn breadcrumbs (JS port; mirrors the Python rule for the same inputs) | -| `codex/hooks/session-start.py` + `copilot/hooks/session-start.py` (`_resolve_workflow_md`) | platform-specific SessionStart Phase Index TOC | - -Known degradation: the Pi and OMP extensions keep injecting the global -`.trellis/workflow.md` regardless of task selection (their workflow reads -live inside monolithic TS extensions); per-task parity there is a tracked -follow-up. - -Absent a `workflow` field, every consumer takes the fallback branch -immediately — output is byte-identical to a project without the feature. -Variant files must satisfy the same parser contract as `workflow.md` (marker -syntax above, `## Phase Index`, `#### X.Y` step headings, platform markers); -`trellis workflow --save` warns at save time when markers are missing. - ---- - ## Source of truth -The global workflow or the active task's selected variant is **the only editable -source** for breadcrumb body text. The hook scripts (`.py` and `.js`) contain -only the parser, no fallback text. +`workflow.md` is **the only editable source** for breadcrumb body text. The +hook scripts (`.py` and `.js`) contain only the parser, no fallback text. **Why no fallback dicts**: prior to v0.5.0-beta.20, both hook scripts shipped a `_FALLBACK_BREADCRUMBS` / `FALLBACK_BREADCRUMBS` dict mirroring the @@ -175,8 +124,7 @@ tag is absent, the hook degrades to the generic line — visible to the user as an obvious bug they can fix, rather than being silently masked. To customize breadcrumb wording, edit the `[workflow-state:STATUS]` block in -`.trellis/workflow.md` (or in the task's selected variant file). No script -change required. +`.trellis/workflow.md`. No script change required. ### Update boundary @@ -344,8 +292,6 @@ nested Trellis sub-agents. ## Mandatory triggers (must update this spec when changing) - Marker syntax (regex / charset) -- Per-task resolution rule change (`workflow_selection` resolution order, id - charset, or the per-task consumer list above) - Hook script structural change (parser, output envelope, what reads `task.json.status`) - `workflow.md` update semantics in `trellis update` diff --git a/.trellis/tasks/07-23-per-task-workflow/check.jsonl b/.trellis/tasks/07-23-per-task-workflow/check.jsonl deleted file mode 100644 index 54c46fe27..000000000 --- a/.trellis/tasks/07-23-per-task-workflow/check.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"file": ".trellis/tasks/07-23-per-task-workflow/prd.md", "reason": "Acceptance criteria to verify against"} -{"file": ".trellis/tasks/07-23-per-task-workflow/design.md", "reason": "Contracts the implementation must match"} -{"file": ".trellis/spec/cli/backend/commands-workflow.md", "reason": "Verify --save respects hash-ownership contract"} -{"file": ".trellis/spec/cli/backend/workflow-state-contract.md", "reason": "Verify consumers preserve breadcrumb parser contract"} -{"file": ".trellis/spec/cli/backend/quality-guidelines.md", "reason": "House quality bar for the diff review"} -{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Verify new tests follow conventions"} diff --git a/.trellis/tasks/07-23-per-task-workflow/design.md b/.trellis/tasks/07-23-per-task-workflow/design.md deleted file mode 100644 index 24b07fbd7..000000000 --- a/.trellis/tasks/07-23-per-task-workflow/design.md +++ /dev/null @@ -1,137 +0,0 @@ -# Design: Per-task dynamic workflow selection - -## Architecture summary - -One new shared Python module owns the resolution rule; every consumer swaps its -hardcoded `.trellis/workflow.md` path for a call into it. A new `--save` mode on the -existing `trellis workflow` command populates the variant library. Selection state is a -single optional `task.json` field. - -``` -task.json { "workflow": "tdd" } .trellis/workflows/tdd.md (library, user-managed) - │ │ - └──> common/workflow_selection.py ───────┘ - resolve_workflow_md() → Path(.trellis/workflows/tdd.md) - └ fallback: .trellis/workflow.md - consumers: - shared-hooks/session-start.py (SessionStart Phase Index) - shared-hooks/inject-workflow-state.py (per-turn breadcrumbs) - scripts/common/workflow_phase.py (get_context.py --mode phase) - opencode/plugins/inject-workflow-state.js (JS port) -``` - -## Contracts - -### 1. `common/workflow_selection.py` (new, in `templates/trellis/scripts/common/`) - -```python -def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path: - """Resolution rule, given an already-resolved task dir (or None).""" - -def resolve_workflow_md(repo_root: Path, input_data: dict | None = None, - platform: str | None = None) -> Path: - """Convenience: resolve the session-aware active task via - common.active_task.resolve_active_task, then apply workflow_md_for_task.""" -``` - -Rule (both functions): -- task.json readable AND has non-empty string field `workflow` (id validated - `[A-Za-z0-9_-]+` to prevent path traversal) AND - `repo_root/.trellis/workflows/<id>.md` is a file → return that path. -- Selection present but file missing/invalid → one-line warning to **stderr** - (never stdout — stdout is hook JSON), return global path. -- No task / no field / anything unreadable → `repo_root/.trellis/workflow.md`. -- Never raises. - -Why a scripts/common module and not hook-local code: both hooks already -`sys.path.insert(.trellis/scripts)` and import `common.active_task` / -`common.config` (inject-workflow-state.py:139-145, session-start.py:269-271), and -`workflow_phase.py` lives in the same package — one module keeps the rule single-source. - -### 2. Consumer patches (minimal, identical shape) - -- `session-start.py` `main()`: `_build_workflow_overview(trellis_dir / "workflow.md")` - → `_build_workflow_overview(_resolve_workflow_md(...))` using the same sys.path - import pattern; falls back to the global path if the import itself fails - (hooks must never die on a missing scripts tree). -- `inject-workflow-state.py` `load_breadcrumbs(root)`: path built at line 195 → - resolver call. Signature gains the already-available `input_data` so active-task - resolution matches the status lookup (same session key). -- `workflow_phase.py` `_workflow_md_path()`: `get_repo_root()/DIR_WORKFLOW/"workflow.md"` - → `workflow_selection.resolve_workflow_md(get_repo_root())` (CLI path: active task - comes from env/runtime pointer via resolve_active_task's existing fallbacks). -- `opencode/plugins/inject-workflow-state.js`: JS port of the same rule (read active - task's task.json, check `workflows/<id>.md` existence, fallback). Mirrors the - Python behavior for the same inputs. - -### 3. `task.py` selection surface - -- `task.py create --workflow <id>`: stores `"workflow": "<id>"` in task.json. - Unknown-id warning (no `.trellis/workflows/<id>.md` yet) — warn, don't block. -- `task.py workflow <id>` / `task.py workflow --clear`: set/remove on the current - session's active task; prints resolved effective workflow path after change. -- Field is optional; absent = global workflow. Archive carries it away naturally. - -### 4. `trellis workflow --save <id>` (TS CLI) - -- Reuses `resolveWorkflowTemplate(id, source)` + `replacePythonCommandLiterals` - (same pipeline as `--template`, commands/workflow.ts:170). -- Writes `.trellis/workflows/<id>.md`; creates the dir on demand. -- **Never** touches `.trellis/workflow.md` or `.template-hashes.json` — the library - is user-managed by definition (same ownership stance as non-native global: - hash absence = user content; here we simply never add hashes). -- Existing file: overwrite requires `--force`, else error (no interactive prompt in - MVP — the library is low-risk, `--force` is enough). -- Marker validation (warn, never block): `## Phase Index` present, ≥1 `#### X.Y` - step heading, all six `[workflow-state:*]` statuses used by the native template. - Shared with nothing (new small helper in workflow.ts; the contract source is - `.trellis/spec/cli/backend/workflow-state-contract.md`). -- `--list` additionally prints a `Library (.trellis/workflows/)` section with ids - found on disk. -- `--save` composes with `-m/--marketplace <source>` exactly like `--template`. - -### 5. `trellis update` interaction - -`update` builds its desired-file map from template sources; `.trellis/workflows/` is -never in that map, so update leaves it alone (same as `tasks/`). No code change; a -regression test asserts update does not delete/modify a library file. - -## Data flow (session start, task with `workflow: tdd`) - -1. Hook resolves active task (existing) → task_dir. -2. `workflow_md_for_task` reads task.json once, validates id, stats - `workflows/tdd.md` → path. -3. `_build_workflow_overview(path)` extracts that file's Phase Index. -4. Per-turn hook does the same for `[workflow-state:*]` blocks; `--mode phase` - serves step bodies from the same file. All three re-read per invocation — - switching tasks switches workflow next turn with no cache invalidation - (property inherited from the existing design). - -## Compatibility / rollout - -- No `workflow` field anywhere ⇒ every consumer takes the fallback branch - immediately ⇒ byte-identical outputs (AC-verified). -- Old task.json files: field absent — safe. New field ignored by older Trellis - versions (task.json readers ignore unknown keys) — forward-safe. -- Variant files must satisfy the same parser contract as workflow.md - (## Phase Index, #### X.Y, [workflow-state:*], platform markers) — enforced - softly at `--save` time, and marketplace workflow templates already comply. -- Dogfood mirrors: identical patches applied to live `.claude/hooks/*.py`, - `.trellis/scripts/common/*.py`, `.trellis/scripts/task.py`, - `.opencode/plugins/inject-workflow-state.js` (surgical patch, not wholesale - resync — live session-start.py has unrelated local drift we must not clobber). - -## Known collisions / degradations (documented, accepted) - -- PR #337 (workflow YAML manifest) would relocate workflow bodies; our resolver is - one function, trivially re-pointable during that migration. Dir name `workflows/` - (plural) avoids its `workflow/` namespace. -- Pi / OMP extensions keep injecting the global workflow this iteration (their - reads sit inside monolithic TS extensions); follow-up noted in PR description. -- 05-15 non-goal "no long-lived workflow.variant config": respected — selection is - task-scoped, not config; global file + hash contract untouched. - -## Rollback - -Single revert of the PR restores prior behavior; tasks carrying a `workflow` field -degrade to the global workflow (field simply unread), no data migration either way. diff --git a/.trellis/tasks/07-23-per-task-workflow/implement.jsonl b/.trellis/tasks/07-23-per-task-workflow/implement.jsonl deleted file mode 100644 index 8a12a9925..000000000 --- a/.trellis/tasks/07-23-per-task-workflow/implement.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"file": ".trellis/tasks/07-23-per-task-workflow/prd.md", "reason": "Requirements and acceptance criteria"} -{"file": ".trellis/tasks/07-23-per-task-workflow/design.md", "reason": "Resolution rule contract, consumer patch sites, --save semantics"} -{"file": ".trellis/spec/cli/backend/commands-workflow.md", "reason": "Existing trellis workflow command spec: hash-ownership contract --save must NOT violate"} -{"file": ".trellis/spec/cli/backend/workflow-state-contract.md", "reason": "[workflow-state:*] parser contract every consumer and the marker validation must preserve"} -{"file": ".trellis/spec/cli/backend/script-conventions.md", "reason": "Python script/hook conventions for .trellis/scripts and shared-hooks changes"} -{"file": ".trellis/spec/cli/backend/error-handling.md", "reason": "Error/warning conventions: hooks never die, warnings to stderr"} -{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Test conventions for workflow.integration.test.ts additions"} -{"file": ".trellis/spec/guides/cross-platform-thinking-guide.md", "reason": "Template-vs-live-dogfood mirroring and per-platform parity rules"} diff --git a/.trellis/tasks/07-23-per-task-workflow/implement.md b/.trellis/tasks/07-23-per-task-workflow/implement.md deleted file mode 100644 index a66e1ee8c..000000000 --- a/.trellis/tasks/07-23-per-task-workflow/implement.md +++ /dev/null @@ -1,88 +0,0 @@ -# Implementation Plan: Per-task dynamic workflow selection - -Baseline note: clean main has 5 pre-existing test failures (2× trellis.test.ts -marketplace workflow mirror, 1× template-fetcher ref classification, 2× regression -gitignore-trellis). "Green" below means no NEW failures beyond these. - -## Stage A — Python core (templates) - -- [x] A1. New `packages/cli/src/templates/trellis/scripts/common/workflow_selection.py` - implementing `workflow_md_for_task` + `resolve_workflow_md` per design.md §1. -- [x] A2. `templates/trellis/scripts/task.py`: `create --workflow <id>` flag + - `workflow` subcommand (`<id>` / `--clear`) per design.md §3. -- [x] A3. `templates/trellis/scripts/common/workflow_phase.py`: - `_workflow_md_path()` → resolver call. - -Validation: `pnpm lint:py` (basedpyright); manual smoke: -`python3 .trellis/scripts/task.py workflow tdd && python3 .trellis/scripts/get_context.py --mode phase | head`. - -## Stage B — Hook consumers (templates) - -- [x] B1. `templates/shared-hooks/session-start.py`: resolve workflow path via - workflow_selection (sys.path import pattern; fallback to global on any failure). -- [x] B2. `templates/shared-hooks/inject-workflow-state.py`: `load_breadcrumbs` - resolves per-task path (thread input_data through). - -Validation: run each hook with fabricated stdin JSON against a temp project fixture -(with and without `workflow` field) — assert content switches / stays identical. - -## Stage C — TS CLI - -- [x] C1. `packages/cli/src/commands/workflow.ts`: `--save <id>` (+`--force` - overwrite gate), library section in `--list`, marker validation warnings - per design.md §4. Register flags in `src/cli/index.ts`. -- [x] C2. Tests in `packages/cli/test/commands/workflow.integration.test.ts`: - save happy path (no workflow.md/hash mutation), save-existing requires - --force, marker warnings, list shows library. -- [x] C3. Regression guard: `trellis update` leaves `.trellis/workflows/*` intact - (extend existing update test file where update fixtures live). - -Validation: `pnpm --filter @mindfoldhq/trellis test -- workflow` + `pnpm typecheck`. - -## Stage D — OpenCode JS port - -- [x] D1. `templates/opencode/plugins/inject-workflow-state.js`: same resolution rule. - -Validation: node-level smoke (require the plugin's resolver in isolation if -structured for it; otherwise fixture-driven manual run) + existing opencode tests. - -## Stage E — Dogfood mirrors (after A–D verified) - -- [x] E1. Live `.trellis/scripts/common/workflow_selection.py` (copy of A1), - `.trellis/scripts/common/workflow_phase.py` (A3 patch), - `.trellis/scripts/task.py` (A2 patch). -- [x] E2. Live `.claude/hooks/session-start.py` + `.claude/hooks/inject-workflow-state.py`: - apply B1/B2 as surgical patches (do NOT wholesale-copy; live files carry - unrelated local drift). -- [x] E3. Live `.opencode/plugins/inject-workflow-state.js`: D1 patch. - -Validation: run live session-start hook with echo-JSON stdin; confirm output -unchanged (no active workflow selection in this repo's tasks yet). - -## Stage F — Specs & docs - -- [x] F1. `.trellis/spec/cli/backend/commands-workflow.md`: `--save`, library - ownership rule, marker validation. -- [x] F2. `.trellis/spec/cli/backend/workflow-state-contract.md`: per-task - resolution order ahead of the global file. - -## Stage G — Full check gate - -- [x] G1. `pnpm lint && pnpm lint:py && pnpm typecheck && pnpm test` — green - (vs baseline). -- [x] G2. End-to-end manual scenario in a scratch project: init → save tdd → - create task --workflow tdd → session-start output serves tdd Phase Index; - clear → native output returns. - -## Review gates - -- After Stage C and before Stage E: self-review diff for surgical-changes - discipline (no adjacent refactors). -- After G: trellis-check quality pass over the full diff. - -## Rollback points - -- Each stage is an isolated commit candidate; revert order E→A safe at any point - (consumers fall back to global path when the module is absent only in the sense - of try/except import guards in hooks — workflow_phase imports directly, so A1+A3 - must revert together). diff --git a/.trellis/tasks/07-23-per-task-workflow/prd.md b/.trellis/tasks/07-23-per-task-workflow/prd.md deleted file mode 100644 index f33238357..000000000 --- a/.trellis/tasks/07-23-per-task-workflow/prd.md +++ /dev/null @@ -1,90 +0,0 @@ -# PRD: Per-task dynamic workflow selection - -## Problem - -Project-level workflow switching already shipped (05-15-workflow-marketplace-feature-flag: -`trellis workflow` command, marketplace `type:"workflow"` entries, `trellis init --workflow`). -But a project still has exactly **one** `.trellis/workflow.md`, and every runtime consumer -hardcodes that single path: - -- SessionStart Phase Index extraction (`shared-hooks/session-start.py` `_build_workflow_overview`) -- Per-turn breadcrumbs (`shared-hooks/inject-workflow-state.py`, `root/.trellis/workflow.md`) -- Step detail (`get_context.py --mode phase` → `common/workflow_phase.py` `_workflow_md_path()`) -- OpenCode JS port (`opencode/plugins/inject-workflow-state.js`) - -So all tasks and all sessions in a project share the same workflow. Teams want, e.g., the -TDD workflow for feature tasks, native for quick fixes, channel-driven for parallel efforts — -today switching flips the workflow **globally for everyone**, mid-flight tasks included. - -"Dynamic workflow switching" = the workflow a session sees follows the **active task's** -choice, injected automatically at session start (and per-turn / on-demand), without the -user or other tasks being affected. - -## Chosen dimension: per-task - -Selection is stored on the task (`task.json`), because: - -- Every runtime consumer already resolves the session-aware active task (for status/breadcrumbs); - resolving its workflow choice adds one field read to an existing lookup. -- Task lifecycle bounds the state: archived task ⇒ selection leaves the active set with it. - This respects the 05-15 non-goal of "no long-lived `workflow.variant` config key" — - nothing global records a variant; the global file and its hash contract are untouched. -- Per-session or per-prompt switching stays out of scope (a session inherits its task's choice). - -## Requirements - -1. **Workflow library**: variants live as `.trellis/workflows/<id>.md` (new directory, - plural — deliberately not colliding with PR #337's proposed `.trellis/workflow/`). - Files there are user-managed: never hash-tracked, never touched by `trellis update` - (same ownership rule as a non-native `.trellis/workflow.md`). -2. **Populate the library**: `trellis workflow --save <id>` resolves a template - (native or marketplace, honoring `--marketplace <source>`) and writes - `.trellis/workflows/<id>.md` **without** touching `.trellis/workflow.md` or - `.trellis/.template-hashes.json`. `--list` additionally lists library entries. -3. **Per-task selection**: `task.py create --workflow <id>` stores `"workflow": "<id>"` - in `task.json`; `task.py workflow <id>` sets/changes it on the current task; - `task.py workflow --clear` removes it. Setting a workflow id that has no library file - warns but is allowed (file may be saved later). -4. **Resolution rule** (all consumers, identical): active task has `workflow: <id>` - AND `.trellis/workflows/<id>.md` exists → use it; otherwise → `.trellis/workflow.md`. - Missing variant file degrades with a stderr warning, never breaks injection. -5. **Consumers updated**: session-start.py, inject-workflow-state.py, workflow_phase.py - (`--mode phase`), and the OpenCode `inject-workflow-state.js` port all resolve - per-task. Template AND live dogfood copies (`.claude/hooks/`, `.trellis/scripts/`) - get the same patch. -6. **No behavior change without opt-in**: absent `workflow` field ⇒ byte-identical - output to today on every consumer. -7. **Contract validation**: `--save` warns (never blocks) when the saved variant is - missing required parser markers: `## Phase Index`, at least one level-4 (`####`) step heading, - and the six `[workflow-state:*]` blocks. -8. **Spec updates**: `commands-workflow.md` (new `--save` + library ownership), - `workflow-state-contract.md` (per-task resolution order). - -## Non-goals - -- No change to `trellis workflow --template` (global switch) semantics or the - hash-ownership contract for `.trellis/workflow.md`. -- No YAML-manifest workflow format (PR #337's territory; this design stays on - monolithic markdown and does not conflict with that migration). -- No per-session/per-prompt switching; no auto-selection by task type. -- Pi / OMP extension parity: their workflow reads live inside large TS extensions; - they keep injecting the global workflow this iteration. Documented degradation + - follow-up noted in PR description. (OpenCode IS covered — it is a direct port of - inject-workflow-state.) -- No `trellis workflow --save` refresh/update semantics (re-run `--save` to refresh). - -## Acceptance Criteria - -- [ ] `trellis workflow --save tdd` creates `.trellis/workflows/tdd.md`; `workflow.md` - and `.template-hashes.json` byte-unchanged; `--list` shows the library entry. -- [ ] `task.py create --workflow tdd` writes the field; `task.py workflow --clear` removes it. -- [ ] With active task selecting `tdd`: SessionStart `<trellis-workflow>` block, per-turn - `<workflow-state>` breadcrumb, and `get_context.py --mode phase --step 1.1` all - serve content from `workflows/tdd.md`. -- [ ] Same commands with no `workflow` field produce byte-identical output to current main. -- [ ] Task selects `tdd` but `workflows/tdd.md` missing: stderr warning + global fallback, - exit code unchanged. -- [ ] `--save` of a file missing `[workflow-state:*]` blocks prints a marker warning. -- [ ] `pnpm lint && pnpm typecheck && pnpm test` pass with no new failures vs the recorded - baseline (5 pre-existing failures on clean main: 2× trellis.test.ts marketplace - workflow mirror, 1× template-fetcher ref classification, 2× regression gitignore-trellis). diff --git a/.trellis/tasks/07-23-per-task-workflow/task.json b/.trellis/tasks/07-23-per-task-workflow/task.json deleted file mode 100644 index 42dbaecbf..000000000 --- a/.trellis/tasks/07-23-per-task-workflow/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "per-task-workflow", - "name": "per-task-workflow", - "title": "Per-task dynamic workflow selection", - "description": "Allow each task to select a workflow variant; runtime injection paths resolve the active task's workflow instead of the single global .trellis/workflow.md", - "status": "in_progress", - "dev_type": null, - "scope": null, - "package": "cli", - "priority": "P1", - "creator": "tommy", - "assignee": "tommy", - "createdAt": "2026-07-23", - "completedAt": null, - "branch": null, - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/workspace/tommy/index.md b/.trellis/workspace/tommy/index.md deleted file mode 100644 index 27ddd668f..000000000 --- a/.trellis/workspace/tommy/index.md +++ /dev/null @@ -1,40 +0,0 @@ -# Workspace Index - tommy - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~0 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | Branch | -|---|------|-------|---------|--------| -<!-- @@@/auto:session-history --> - ---- - -## Notes - -- Sessions are appended to journal files -- New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions diff --git a/.trellis/workspace/tommy/journal-1.md b/.trellis/workspace/tommy/journal-1.md deleted file mode 100644 index 446db9f09..000000000 --- a/.trellis/workspace/tommy/journal-1.md +++ /dev/null @@ -1,7 +0,0 @@ -# Journal - tommy (Part 1) - -> AI development session journal -> Started: 2026-07-23 - ---- - diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index e9b6bd282..d87046671 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -278,10 +278,6 @@ program "-n, --create-new", "Write .trellis/workflow.md.new instead of replacing the active workflow", ) - .option( - "-s, --save <id>", - "Save a template to the per-task library (.trellis/workflows/<id>.md) without touching workflow.md", - ) .action(async (options: Record<string, unknown>) => { try { await runWorkflowCommand({ @@ -290,7 +286,6 @@ program list: options.list as boolean | undefined, force: options.force as boolean | undefined, createNew: options.createNew as boolean | undefined, - save: options.save as string | undefined, }); } catch (error) { if (error instanceof WorkflowCommandError) { diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index ce6944f39..39c744353 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -16,11 +16,6 @@ * * - `--create-new`: never touches `.trellis/workflow.md`; writes * `.trellis/workflow.md.new` and leaves the hash file alone. - * - * - `--save <id>`: resolves a template through the same pipeline but writes it - * to the per-task variant library (`.trellis/workflows/<id>.md`) instead of - * the active workflow. Library files are user-managed by definition: this - * path never touches `.trellis/workflow.md` or `.template-hashes.json`. */ import fs from "node:fs"; @@ -52,31 +47,12 @@ export interface WorkflowCommandOptions { list?: boolean; force?: boolean; createNew?: boolean; - save?: string; } -/** - * Per-task workflow variant library. Files here are user-managed: never - * hash-tracked, never touched by `trellis update` (same ownership stance as a - * non-native `.trellis/workflow.md`). - */ -const WORKFLOWS_LIB_REL = `${DIR_NAMES.WORKFLOW}/workflows`; - -/** - * Library id charset — must match the per-task resolution rule in the runtime - * consumers (`common/workflow_selection.py`) so a saved id is resolvable and - * cannot traverse paths. - */ -const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/; - function workflowFilePath(cwd: string): string { return path.join(cwd, PATHS.WORKFLOW_GUIDE_FILE); } -function workflowsLibraryDir(cwd: string): string { - return path.join(cwd, WORKFLOWS_LIB_REL); -} - function isInteractive(): boolean { return Boolean(process.stdin.isTTY); } @@ -96,26 +72,6 @@ function printListing(templates: WorkflowTemplateListing[]): void { console.log(""); } -/** - * `--list` addition: ids already saved to the per-task variant library on - * disk. Skipped entirely when the directory is absent or empty. - */ -function printLibraryListing(cwd: string): void { - const libDir = workflowsLibraryDir(cwd); - if (!fs.existsSync(libDir)) return; - const ids = fs - .readdirSync(libDir) - .filter((f) => f.endsWith(".md")) - .map((f) => f.slice(0, -".md".length)) - .sort(); - if (ids.length === 0) return; - console.log(chalk.cyan(`Library (${WORKFLOWS_LIB_REL}/):\n`)); - for (const id of ids) { - console.log(` ${chalk.green(id)}`); - } - console.log(""); -} - /** * Decide whether the existing workflow.md is byte-identical to the resolved * template (treat as "safe to overwrite"), pristine (matches tracked hash — @@ -271,99 +227,6 @@ async function writeWorkflow( applyHashContract(cwd, template.id); } -/** - * The six breadcrumb statuses used by the bundled native template. Kept in - * sync with `.trellis/spec/cli/backend/workflow-state-contract.md` (status - * writer + reachability tables) — update both together. - */ -const REQUIRED_WORKFLOW_STATE_IDS = [ - "no_task", - "planning", - "planning-inline", - "in_progress", - "in_progress-inline", - "completed", -]; - -/** - * Soft parser-contract validation for saved library variants: warn (never - * block) when runtime markers that SessionStart / per-turn hooks and - * `get_context.py --mode phase` rely on are missing. Warnings go to stderr — - * stdout stays reserved for command output. - */ -function warnAboutMissingMarkers(content: string, relPath: string): void { - const problems: string[] = []; - if (!content.includes("## Phase Index")) { - problems.push('missing "## Phase Index" section'); - } - if (!/^#### \d+\.\d+/m.test(content)) { - problems.push('no "#### X.Y" step heading'); - } - const missingStates = REQUIRED_WORKFLOW_STATE_IDS.filter( - (id) => !content.includes(`[workflow-state:${id}]`), - ); - if (missingStates.length > 0) { - problems.push( - `missing [workflow-state:*] blocks: ${missingStates.join(", ")}`, - ); - } - if (problems.length === 0) return; - process.stderr.write( - chalk.yellow( - `\n⚠ ${relPath} is missing runtime parser markers:\n` + - problems.map((p) => ` - ${p}\n`).join("") + - " Consumers degrade to generic breadcrumbs / partial phase detail where markers are absent.\n", - ), - ); -} - -/** - * `--save <id>`: resolve through the same template pipeline as `--template` - * and write to the per-task variant library. Never touches - * `.trellis/workflow.md` or `.template-hashes.json` — library files are - * user-managed by definition. - */ -async function saveWorkflowToLibrary( - cwd: string, - id: string, - options: WorkflowCommandOptions, -): Promise<void> { - if (!WORKFLOW_ID_RE.test(id)) { - throw new WorkflowCommandError( - `Invalid workflow id "${id}". Library ids must match [A-Za-z0-9_-]+ so per-task resolution can find the saved file.`, - ); - } - - let template: ResolvedWorkflowTemplate; - try { - template = await resolveWorkflowTemplate(id, { - source: options.marketplace, - }); - } catch (err) { - if (err instanceof WorkflowResolveError) { - throw new WorkflowCommandError(err.message); - } - throw err; - } - - const libDir = workflowsLibraryDir(cwd); - const destPath = path.join(libDir, `${id}.md`); - const destRel = `${WORKFLOWS_LIB_REL}/${id}.md`; - if (fs.existsSync(destPath) && !options.force) { - throw new WorkflowCommandError( - `${destRel} already exists. Re-run with --force to overwrite.`, - ); - } - if (!fs.existsSync(libDir)) { - fs.mkdirSync(libDir, { recursive: true }); - } - const finalContent = replacePythonCommandLiterals(template.content); - fs.writeFileSync(destPath, finalContent, "utf-8"); - console.log(chalk.green(` ✓ Saved "${template.id}" to ${destRel}`)); - - warnAboutMissingMarkers(finalContent, destRel); -} - /** * Distinct error class so `cli/index.ts` can format these as user errors * without dumping stack traces. @@ -391,25 +254,12 @@ export async function runWorkflowCommand( source: options.marketplace, }); printListing(templates); - printLibraryListing(cwd); if (errorMessage) { console.log(chalk.yellow(`⚠ ${errorMessage}`)); } return; } - // `--save <id>` populates the per-task variant library; it never composes - // with the active-workflow write modes. - if (options.save !== undefined) { - if (options.template || options.createNew) { - throw new WorkflowCommandError( - "--save cannot be combined with --template or --create-new.", - ); - } - await saveWorkflowToLibrary(cwd, options.save, options); - return; - } - // Resolve template id (non-interactive flag or interactive picker). let templateId = options.template; if (!templateId) { diff --git a/packages/cli/src/templates/codex/hooks/session-start.py b/packages/cli/src/templates/codex/hooks/session-start.py index 572513249..ca5608f3d 100644 --- a/packages/cli/src/templates/codex/hooks/session-start.py +++ b/packages/cli/src/templates/codex/hooks/session-start.py @@ -452,25 +452,6 @@ 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) @@ -524,7 +505,7 @@ def main() -> None: output.write("\n</current-state>\n\n") output.write("<trellis-workflow>\n") - output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input))) + output.write(_build_workflow_toc(trellis_dir / "workflow.md")) output.write("\n</trellis-workflow>\n\n") output.write("<guidelines>\n") diff --git a/packages/cli/src/templates/copilot/hooks/session-start.py b/packages/cli/src/templates/copilot/hooks/session-start.py index 2d73d87a4..dc84e43aa 100644 --- a/packages/cli/src/templates/copilot/hooks/session-start.py +++ b/packages/cli/src/templates/copilot/hooks/session-start.py @@ -451,25 +451,6 @@ 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="copilot") - 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) @@ -521,7 +502,7 @@ def main() -> None: output.write("\n</current-state>\n\n") output.write("<trellis-workflow>\n") - output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input))) + output.write(_build_workflow_toc(trellis_dir / "workflow.md")) output.write("\n</trellis-workflow>\n\n") output.write("<guidelines>\n") diff --git a/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js b/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js index 3990e9060..8e9e234e0 100644 --- a/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js +++ b/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js @@ -1,4 +1,4 @@ -/* global process, console */ +/* global process */ /** * Trellis Workflow State Injection Plugin * @@ -8,11 +8,7 @@ * <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. 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 + * workflow.md is the single source of truth. 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) @@ -27,7 +23,7 @@ * - task.json malformed or missing status */ -import { existsSync, readFileSync, statSync } from "fs" +import { existsSync, readFileSync } from "fs" import { join } from "path" import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-context.js" @@ -111,88 +107,17 @@ function promptHasSkipKeyword(text, keyword) { return pattern.test(text) } -// 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 and read active task state once for the current turn. - */ -function resolveActiveTaskState(ctx, platformInput = null) { - const active = ctx.getActiveTask(platformInput) - const taskRef = active.taskPath - if (!taskRef) return null - const taskDir = ctx.resolveTaskDir(taskRef) - if (active.stale || !taskDir || !existsSync(taskDir)) { - return { taskRef, source: active.source, stale: true, data: null } - } - const taskJsonPath = join(taskDir, "task.json") - if (!existsSync(taskJsonPath)) { - return { taskRef, source: active.source, stale: false, data: null } - } - try { - return { - taskRef, - source: active.source, - stale: false, - data: JSON.parse(readFileSync(taskJsonPath, "utf-8")), - } - } catch { - return { taskRef, source: active.source, stale: false, data: null } - } -} - -/** - * Resolve which workflow markdown file feeds this turn's breadcrumbs. + * Parse workflow.md for [workflow-state:STATUS] blocks. * - * 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(directory, state) { - const globalPath = join(directory, ".trellis", "workflow.md") - const data = state?.data - if (state?.stale || !data || typeof data !== "object") return globalPath - if (!Object.prototype.hasOwnProperty.call(data, "workflow")) return globalPath - - const workflowId = data.workflow - if (typeof workflowId !== "string" || !WORKFLOW_ID_RE.test(workflowId)) { - console.error( - `Warning: active task has invalid workflow id ${JSON.stringify(workflowId)}; using .trellis/workflow.md`, - ) - return globalPath - } - - 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 the resolved workflow markdown for [workflow-state:STATUS] blocks. - * - * 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 + * 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 * buildBreadcrumb so users see the broken state and fix workflow.md * rather than the plugin silently masking it. */ -function loadBreadcrumbs(workflowPath) { +function loadBreadcrumbs(directory) { + const workflowPath = join(directory, ".trellis", "workflow.md") if (!existsSync(workflowPath)) return {} let content try { @@ -212,17 +137,25 @@ function loadBreadcrumbs(workflowPath) { /** * Get (taskId, status) from active task, or null if no active task. */ -function getActiveTask(state) { - if (!state) return null - if (state.stale) { - return { id: state.taskRef.split("/").pop(), status: "stale", source: state.source } +function getActiveTask(ctx, platformInput = null) { + const active = ctx.getActiveTask(platformInput) + const taskRef = active.taskPath + if (!taskRef) return null + const taskDir = ctx.resolveTaskDir(taskRef) + if (active.stale || !taskDir || !existsSync(taskDir)) { + return { id: taskRef.split("/").pop(), status: "stale", source: active.source } + } + const taskJsonPath = join(taskDir, "task.json") + if (!existsSync(taskJsonPath)) return null + try { + const data = JSON.parse(readFileSync(taskJsonPath, "utf-8")) + const status = typeof data.status === "string" ? data.status : "" + if (!status) return null + const id = data.id || taskRef.split("/").pop() + return { id, status, source: active.source } + } catch { + return null } - const data = state.data - if (!data || typeof data !== "object") return null - const status = typeof data.status === "string" ? data.status : "" - if (!status) return null - const id = data.id || state.taskRef.split("/").pop() - return { id, status, source: state.source } } /** @@ -281,9 +214,8 @@ export default async ({ directory }) => { return } - const state = resolveActiveTaskState(ctx, input) - const templates = loadBreadcrumbs(resolveWorkflowMd(directory, state)) - const task = getActiveTask(state) + const templates = loadBreadcrumbs(directory) + const task = getActiveTask(ctx, input) const breadcrumb = task ? buildBreadcrumb(task.id, task.status, templates, task.source) : buildBreadcrumb(null, "no_task", templates) diff --git a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py index 964eb55cf..ab8e276c1 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -10,10 +10,8 @@ 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 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 +Breadcrumb text is pulled exclusively from workflow.md +[workflow-state:STATUS] tag blocks — workflow.md is the single source of truth. There are no fallback dicts in this script: when workflow.md is 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) @@ -153,11 +151,9 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st if not active.task_path: return None - 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 + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir if active.stale: return task_dir.name, f"stale_{active.source_type}", active.source @@ -187,40 +183,16 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st re.DOTALL, ) -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. +def load_breadcrumbs(root: Path) -> dict[str, str]: + """Parse workflow.md for [workflow-state:STATUS] blocks. - Returns {status: body_text}. The workflow file is the single source of + Returns {status: body_text}. workflow.md is the single source of truth — there are no fallback dicts in this script. Missing tags - (or a missing/unreadable workflow file) fall back to a generic line + (or a missing/unreadable workflow.md) 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 = _resolve_workflow_md(root, input_data) + workflow = root / ".trellis" / "workflow.md" if not workflow.is_file(): return {} try: @@ -439,7 +411,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, data) + templates = load_breadcrumbs(root) platform = _detect_platform(data) task = get_active_task(root, data) if task is None: diff --git a/packages/cli/src/templates/shared-hooks/session-start.py b/packages/cli/src/templates/shared-hooks/session-start.py index 2a45df6d3..a18e57bf8 100644 --- a/packages/cli/src/templates/shared-hooks/session-start.py +++ b/packages/cli/src/templates/shared-hooks/session-start.py @@ -715,27 +715,6 @@ 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) @@ -821,7 +800,7 @@ def main(): output.write("\n</current-state>\n\n") output.write("<trellis-workflow>\n") - output.write(_build_workflow_overview(_resolve_workflow_md(project_dir, hook_input))) + output.write(_build_workflow_overview(trellis_dir / "workflow.md")) output.write("\n</trellis-workflow>\n\n") output.write("<guidelines>\n") diff --git a/packages/cli/src/templates/trellis/index.ts b/packages/cli/src/templates/trellis/index.ts index d6c77c63a..25d72f840 100644 --- a/packages/cli/src/templates/trellis/index.ts +++ b/packages/cli/src/templates/trellis/index.ts @@ -59,9 +59,6 @@ export const commonPackagesContext = readTemplate( export const commonWorkflowPhase = readTemplate( "scripts/common/workflow_phase.py", ); -export const commonWorkflowSelection = readTemplate( - "scripts/common/workflow_selection.py", -); export const commonTrellisConfig = readTemplate( "scripts/common/trellis_config.py", ); @@ -116,7 +113,6 @@ export function getAllScripts(): Map<string, string> { scripts.set("common/session_context.py", commonSessionContext); scripts.set("common/packages_context.py", commonPackagesContext); scripts.set("common/workflow_phase.py", commonWorkflowPhase); - scripts.set("common/workflow_selection.py", commonWorkflowSelection); scripts.set("common/trellis_config.py", commonTrellisConfig); scripts.set("common/safe_commit.py", commonSafeCommit); diff --git a/packages/cli/src/templates/trellis/scripts/common/task_store.py b/packages/cli/src/templates/trellis/scripts/common/task_store.py index cd606c64d..2cd978998 100644 --- a/packages/cli/src/templates/trellis/scripts/common/task_store.py +++ b/packages/cli/src/templates/trellis/scripts/common/task_store.py @@ -56,7 +56,6 @@ resolve_task_dir, run_task_hooks, ) -from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE # ============================================================================= @@ -255,30 +254,6 @@ def cmd_create(args: argparse.Namespace) -> int: # Inferred: default_package → None (no task.json yet for create) package = resolve_package(repo_root=repo_root) - # Validate --workflow (CLI source: fail-fast on invalid id; a missing - # library file only warns — it may be saved later via `trellis workflow --save`) - workflow_id: str | None = getattr(args, "workflow", None) - if workflow_id: - if not WORKFLOW_ID_RE.fullmatch(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; " - "the global workflow.md is used until it is saved (trellis workflow --save).", - Colors.YELLOW, - ), - file=sys.stderr, - ) - # Default assignee to current developer assignee = args.assignee if not assignee: @@ -410,10 +385,6 @@ def cmd_create(args: argparse.Namespace) -> int: "notes": "", "meta": meta, } - # Optional per-task workflow selection: key present only when opted in, - # so tasks without a selection keep today's task.json shape byte-for-byte. - if workflow_id: - task_data["workflow"] = workflow_id write_json(task_json_path, task_data) diff --git a/packages/cli/src/templates/trellis/scripts/common/types.py b/packages/cli/src/templates/trellis/scripts/common/types.py index adf76376d..5802e1012 100644 --- a/packages/cli/src/templates/trellis/scripts/common/types.py +++ b/packages/cli/src/templates/trellis/scripts/common/types.py @@ -49,7 +49,6 @@ class TaskData(TypedDict, total=False): relatedFiles: list[str] notes: str meta: dict - workflow: str # ============================================================================= diff --git a/packages/cli/src/templates/trellis/scripts/common/workflow_phase.py b/packages/cli/src/templates/trellis/scripts/common/workflow_phase.py index 858c021e7..9e1c619c2 100644 --- a/packages/cli/src/templates/trellis/scripts/common/workflow_phase.py +++ b/packages/cli/src/templates/trellis/scripts/common/workflow_phase.py @@ -22,12 +22,11 @@ import re -from . import workflow_selection -from .paths import get_repo_root +from .paths import DIR_WORKFLOW, get_repo_root def _workflow_md_path(): - return workflow_selection.resolve_workflow_md(get_repo_root()) + return get_repo_root() / DIR_WORKFLOW / "workflow.md" # Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" _MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") diff --git a/packages/cli/src/templates/trellis/scripts/common/workflow_selection.py b/packages/cli/src/templates/trellis/scripts/common/workflow_selection.py deleted file mode 100644 index 646b350a9..000000000 --- a/packages/cli/src/templates/trellis/scripts/common/workflow_selection.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/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 rule (single source of truth for all 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 -> that variant path. - - Selection present but id invalid or file missing -> one warning line - on stderr (stdout is hook JSON), fall back to `.trellis/workflow.md`. - - No task / no field / anything unreadable -> `.trellis/workflow.md`. - - Never raises. - -Provides: - workflow_md_for_task - Resolution rule 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 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 per-task resolution rule documented in the module docstring. - Never raises; any failure falls back to the global workflow path. - """ - fallback = _global_workflow_md(repo_root) - if task_dir is None: - return fallback - - try: - raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8")) - if not isinstance(raw, dict): - return fallback - - if "workflow" not in raw: - return fallback - - workflow_id = raw["workflow"] - if not isinstance(workflow_id, str) or not WORKFLOW_ID_RE.fullmatch( - workflow_id - ): - print( - f"Warning: task '{task_dir.name}' has invalid workflow id " - f"{workflow_id!r}; using {DIR_WORKFLOW}/workflow.md", - file=sys.stderr, - ) - return fallback - - variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md" - if variant.is_file(): - 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 {DIR_WORKFLOW}/workflow.md", - file=sys.stderr, - ) - return fallback - except Exception: - return fallback - - -def resolve_workflow_md( - repo_root: Path, - input_data: dict | None = None, - platform: str | None = None, -) -> Path: - """Resolve the session-aware active task, then apply the resolution rule. - - ``input_data`` is the raw hook payload (session/conversation identity); - CLI callers may omit it — the active-task resolver then falls back to - environment context. Never raises; any failure resolves to the global - `.trellis/workflow.md`. - """ - try: - from .active_task import resolve_active_task, resolve_task_ref - - active = resolve_active_task(repo_root, input_data, platform) - task_dir: Path | None = None - if active.task_path: - task_dir = resolve_task_ref(active.task_path, repo_root) - return workflow_md_for_task(repo_root, task_dir) - except Exception: - return _global_workflow_md(repo_root) diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index f6c005f3c..7e82eacbe 100755 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -11,7 +11,6 @@ python3 task.py start <dir> # Set active task python3 task.py current [--source] [--json] # Show active task python3 task.py finish # Clear active task - python3 task.py workflow <id>|--clear # Set/clear per-task workflow selection python3 task.py set-branch <dir> <branch> # Set git branch python3 task.py set-base-branch <dir> <branch> # Set PR target branch python3 task.py set-scope <dir> <scope> # Set scope for PR title @@ -43,13 +42,11 @@ clear_active_task, resolve_active_task, resolve_context_key, - resolve_task_ref, set_active_task, ) from common.io import read_json, write_json from common.task_utils import resolve_task_dir, run_task_hooks from common.tasks import iter_active_tasks, children_progress -from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task # Import command handlers from split modules (also re-exports for plan.py compatibility) from common.task_store import ( @@ -207,75 +204,6 @@ def cmd_current(args: argparse.Namespace) -> int: return 1 -# ============================================================================= -# Command: workflow -# ============================================================================= - -def cmd_workflow(args: argparse.Namespace) -> int: - """Set or clear the workflow selection on the current session's active task.""" - repo_root = get_repo_root() - - if args.clear and args.id: - print(colored("Error: pass either <id> or --clear, not both", Colors.RED)) - return 1 - if not args.clear and not args.id: - print(colored("Error: workflow id required (or --clear)", Colors.RED)) - print("Usage: python3 task.py workflow <id> | --clear") - return 1 - - active = resolve_active_task(repo_root) - if not active.task_path: - print(colored("Error: No current task set", Colors.RED)) - print("Hint: run task.py start <dir> first") - return 1 - - task_dir = resolve_task_ref(active.task_path, repo_root) - if task_dir is None: - print(colored(f"Error: invalid task path: {active.task_path}", Colors.RED)) - return 1 - 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.fullmatch(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 # ============================================================================= @@ -454,15 +382,12 @@ def show_usage() -> None: python3 task.py create <title> --package <pkg> Create task for a specific package python3 task.py create <title> --parent <dir> Create task as child of parent python3 task.py create <title> --no-start Create without making it active in this session - python3 task.py create <title> --workflow <id> Create task pinned to a workflow variant python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl python3 task.py validate <dir> Validate jsonl files python3 task.py list-context <dir> List jsonl entries python3 task.py start <dir> Set active task python3 task.py current [--source] Show active task python3 task.py finish Clear active task - python3 task.py workflow <id> Select workflow variant for active task - python3 task.py workflow --clear Clear selection (use global workflow.md) python3 task.py set-branch <dir> <branch> Set git branch python3 task.py set-base-branch <dir> <branch> Set PR target branch python3 task.py set-scope <dir> <scope> Set scope for PR title @@ -565,10 +490,6 @@ def main() -> int: action="store_true", help="Create the task without making it active in this session", ) - p_create.add_argument( - "--workflow", - help="Workflow variant id for this task (.trellis/workflows/<id>.md)", - ) # add-context p_add = subparsers.add_parser("add-context", help="Add context entry") @@ -599,12 +520,6 @@ def main() -> int: # finish subparsers.add_parser("finish", help="Clear active task") - # workflow - p_workflow = subparsers.add_parser("workflow", help="Set/clear per-task workflow selection") - p_workflow.add_argument("id", nargs="?", help="Workflow id (.trellis/workflows/<id>.md)") - p_workflow.add_argument("--clear", action="store_true", - help="Remove the workflow selection (use global workflow.md)") - # set-branch p_branch = subparsers.add_parser("set-branch", help="Set git branch") p_branch.add_argument("dir", help="Task directory") @@ -665,7 +580,6 @@ def main() -> int: "start": cmd_start, "current": cmd_current, "finish": cmd_finish, - "workflow": cmd_workflow, "set-branch": cmd_set_branch, "set-base-branch": cmd_set_base_branch, "set-scope": cmd_set_scope, diff --git a/packages/cli/test/commands/workflow.integration.test.ts b/packages/cli/test/commands/workflow.integration.test.ts index fb5452b70..1d98fe029 100644 --- a/packages/cli/test/commands/workflow.integration.test.ts +++ b/packages/cli/test/commands/workflow.integration.test.ts @@ -9,10 +9,6 @@ * - `trellis update` after switch to tdd does NOT silently restore native. * - Non-interactive modified workflow.md fails without --force / --create-new. * - `--create-new` writes `.new` and leaves workflow.md + hash untouched. - * - `--save <id>`: writes the per-task library file (.trellis/workflows/<id>.md) - * without touching workflow.md or the hash file; --force overwrite gate; - * marker warnings on stderr; `--list` Library section; `trellis update` - * leaves library files intact. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -285,166 +281,6 @@ describe("trellis workflow integration", () => { expect(loadHashes(tmpDir)[PATHS.WORKFLOW_GUIDE_FILE]).toBe(originalHash); }); - /** - * Capture process.stderr.write output (marker warnings) without printing. - * Restored by `vi.restoreAllMocks()` in afterEach. - */ - function captureStderr(): { text: () => string } { - const chunks: string[] = []; - vi.spyOn(process.stderr, "write").mockImplementation( - (chunk: unknown): boolean => { - chunks.push(String(chunk)); - return true; - }, - ); - return { text: () => chunks.join("") }; - } - - it("--save tdd writes the library file; workflow.md and .template-hashes.json stay byte-unchanged", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - - const wfPath = path.join(tmpDir, PATHS.WORKFLOW_GUIDE_FILE); - const hashesPath = path.join(tmpDir, ".trellis", ".template-hashes.json"); - const wfBefore = fs.readFileSync(wfPath, "utf-8"); - const hashesBefore = fs.readFileSync(hashesPath, "utf-8"); - - captureStderr(); - await runWorkflowCommand({ save: "tdd" }); - - const libPath = path.join(tmpDir, ".trellis", "workflows", "tdd.md"); - expect(fs.readFileSync(libPath, "utf-8")).toBe( - replacePythonCommandLiterals(TDD_CONTENT), - ); - expect(fs.readFileSync(wfPath, "utf-8")).toBe(wfBefore); - expect(fs.readFileSync(hashesPath, "utf-8")).toBe(hashesBefore); - }); - - it("--save on an existing library file errors without --force and overwrites with it", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - - const libPath = path.join(tmpDir, ".trellis", "workflows", "tdd.md"); - fs.mkdirSync(path.dirname(libPath), { recursive: true }); - fs.writeFileSync(libPath, "# my locally-tuned tdd variant", "utf-8"); - - captureStderr(); - await expect(runWorkflowCommand({ save: "tdd" })).rejects.toThrow( - /already exists.*--force/, - ); - expect(fs.readFileSync(libPath, "utf-8")).toBe( - "# my locally-tuned tdd variant", - ); - - await runWorkflowCommand({ save: "tdd", force: true }); - expect(fs.readFileSync(libPath, "utf-8")).toBe( - replacePythonCommandLiterals(TDD_CONTENT), - ); - }); - - it("--save warns on stderr for a variant missing workflow-state blocks but still writes the file", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - - // TDD_CONTENT carries only [workflow-state:in_progress] and no #### X.Y - // heading — the other five statuses must be reported as missing. - const stderr = captureStderr(); - await runWorkflowCommand({ save: "tdd" }); - - const text = stderr.text(); - expect(text).toContain("missing runtime parser markers"); - expect(text).toContain("missing [workflow-state:*] blocks"); - expect(text).toContain("no_task"); - expect(text).toContain("completed"); - expect(text).toContain('no "#### X.Y" step heading'); - // Warn, never block: the file is written regardless. - expect( - fs.existsSync(path.join(tmpDir, ".trellis", "workflows", "tdd.md")), - ).toBe(true); - }); - - it("--save native emits no marker warning (all parser markers present)", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - - const stderr = captureStderr(); - await runWorkflowCommand({ save: "native" }); - - expect(stderr.text()).not.toContain("missing runtime parser markers"); - expect( - fs.readFileSync( - path.join(tmpDir, ".trellis", "workflows", "native.md"), - "utf-8", - ), - ).toBe(replacePythonCommandLiterals(workflowMdTemplate)); - }); - - it("--save cannot be combined with --template or --create-new", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - - await expect( - runWorkflowCommand({ save: "tdd", template: "tdd" }), - ).rejects.toThrow(/--save cannot be combined/); - await expect( - runWorkflowCommand({ save: "tdd", createNew: true }), - ).rejects.toThrow(/--save cannot be combined/); - expect( - fs.existsSync(path.join(tmpDir, ".trellis", "workflows", "tdd.md")), - ).toBe(false); - }); - - it("--save with an invalid (path-escaping) id fails before any resolve/fetch", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - - const fetchMock = vi.mocked(globalThis.fetch); - fetchMock.mockClear(); - await expect( - runWorkflowCommand({ save: "../evil" }), - ).rejects.toThrow(/Invalid workflow id/); - // Rejected before the template pipeline: no marketplace fetch, no write. - expect(fetchMock).not.toHaveBeenCalled(); - expect(fs.existsSync(path.join(tmpDir, ".trellis", "evil.md"))).toBe(false); - }); - - it("--list shows saved library ids in a Library section", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - captureStderr(); - await runWorkflowCommand({ save: "tdd" }); - - vi.mocked(console.log).mockClear(); - await runWorkflowCommand({ list: true }); - - const logged = vi - .mocked(console.log) - .mock.calls.map((call) => call.map(String).join(" ")) - .join("\n"); - expect(logged).toContain("Library (.trellis/workflows/)"); - // Assert "tdd" inside the Library section specifically — the template - // listing above it also mentions tdd. - const librarySection = logged.slice( - logged.indexOf("Library (.trellis/workflows/)"), - ); - expect(librarySection).toContain("tdd"); - }); - - it("trellis update leaves saved library files intact", async () => { - stubMarketplaceFetch(); - await init({ yes: true }); - captureStderr(); - await runWorkflowCommand({ save: "tdd" }); - - const libPath = path.join(tmpDir, ".trellis", "workflows", "tdd.md"); - const before = fs.readFileSync(libPath, "utf-8"); - - await update({ skipAll: true }); - - expect(fs.existsSync(libPath)).toBe(true); - expect(fs.readFileSync(libPath, "utf-8")).toBe(before); - }); - it("trellis update after switching to tdd does not silently restore native workflow", async () => { stubMarketplaceFetch(); await init({ yes: true }); diff --git a/packages/cli/test/scripts/inject-workflow-state-kiro.integration.test.ts b/packages/cli/test/scripts/inject-workflow-state-kiro.integration.test.ts index 0d6174fcc..4420ca04e 100644 --- a/packages/cli/test/scripts/inject-workflow-state-kiro.integration.test.ts +++ b/packages/cli/test/scripts/inject-workflow-state-kiro.integration.test.ts @@ -58,57 +58,11 @@ function setupRepo(tmp: string): void { ); } -function runTaskWorkflow(tmp: string, ...args: string[]) { - return spawnSync( - "python3", - [path.join(tmp, ".trellis", "scripts", "task.py"), "workflow", ...args], - { - cwd: tmp, - encoding: "utf-8", - env: { ...process.env, TRELLIS_CONTEXT_ID: "kiro_test-session" }, - }, - ); -} - -function setupSelectedWorkflow(tmp: string): void { - const taskDir = path.join(tmp, ".trellis", "tasks", "demo-task"); - fs.mkdirSync(taskDir, { recursive: true }); - fs.writeFileSync( - path.join(taskDir, "task.json"), - JSON.stringify({ - id: "demo-task", - status: "in_progress", - }), - ); - fs.mkdirSync(path.join(tmp, ".trellis", "workflows"), { recursive: true }); - fs.writeFileSync( - path.join(tmp, ".trellis", "workflows", "tdd.md"), - [ - "# TDD Workflow", - "", - "## Phase Index", - "TDD_SELECTED_PHASE_INDEX", - "", - "[workflow-state:in_progress]", - "TDD_SELECTED_BREADCRUMB", - "[/workflow-state:in_progress]", - "", - "## Phase 1: Plan", - ].join("\n"), - ); - const sessionsDir = path.join(tmp, ".trellis", ".runtime", "sessions"); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.writeFileSync( - path.join(sessionsDir, "kiro_test-session.json"), - JSON.stringify({ current_task: "demo-task" }), - ); -} - function runHook( tmp: string, script: string, platformEnvVar: string, -): { stdout: string; stderr: string; status: number | null } { +): { stdout: string; status: number | null } { const r = spawnSync( "python3", [path.join(SHARED_HOOKS, script)], @@ -124,7 +78,7 @@ function runHook( env: { ...process.env, [platformEnvVar]: tmp }, }, ); - return { stdout: r.stdout, stderr: r.stderr, status: r.status }; + return { stdout: r.stdout, status: r.status }; } const describeFn = hasPython() ? describe : describe.skip; @@ -196,61 +150,4 @@ describeFn("Kiro hook output branch", () => { "<session-context>", ); }); - - it("uses the active task's selected workflow in per-turn and session-start hooks", () => { - setupSelectedWorkflow(tmp); - const selected = runTaskWorkflow(tmp, "tdd"); - expect(selected.status).toBe(0); - - const perTurn = runHook( - tmp, - "inject-workflow-state.py", - "KIRO_PROJECT_DIR", - ); - expect(perTurn.status).toBe(0); - expect(perTurn.stdout).toContain("TDD_SELECTED_BREADCRUMB"); - - const sessionStart = runHook(tmp, "session-start.py", "KIRO_PROJECT_DIR"); - expect(sessionStart.status).toBe(0); - expect(sessionStart.stdout).toContain("TDD_SELECTED_PHASE_INDEX"); - - const cleared = runTaskWorkflow(tmp, "--clear"); - expect(cleared.status).toBe(0); - const task = JSON.parse( - fs.readFileSync( - path.join(tmp, ".trellis", "tasks", "demo-task", "task.json"), - "utf-8", - ), - ) as Record<string, unknown>; - expect(task.workflow).toBeUndefined(); - - expect(runTaskWorkflow(tmp, "tdd\n").status).toBe(1); - }); - - it("warns once for each explicitly present invalid workflow value", () => { - setupSelectedWorkflow(tmp); - const taskJson = path.join( - tmp, - ".trellis", - "tasks", - "demo-task", - "task.json", - ); - - for (const workflow of ["", null, 42]) { - fs.writeFileSync( - taskJson, - JSON.stringify({ id: "demo-task", status: "in_progress", workflow }), - ); - const result = runHook( - tmp, - "inject-workflow-state.py", - "KIRO_PROJECT_DIR", - ); - const warnings = result.stderr.trim().split(/\r?\n/).filter(Boolean); - expect(result.status).toBe(0); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("invalid workflow id"); - } - }); }); diff --git a/packages/cli/test/templates/opencode.test.ts b/packages/cli/test/templates/opencode.test.ts index 3bbf4ed8f..d52b90dfd 100644 --- a/packages/cli/test/templates/opencode.test.ts +++ b/packages/cli/test/templates/opencode.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { contextCollector, isTrellisSubagent, @@ -751,7 +751,6 @@ describe("opencode chat.message subagent skip (issue #264)", () => { rmSync(dir, { recursive: true, force: true }); contextCollector.clear("subagent-session"); contextCollector.clear("main-session"); - vi.restoreAllMocks(); }); it("session-start.js early-returns when input.agent is a trellis sub-agent", async () => { @@ -817,75 +816,6 @@ describe("opencode chat.message subagent skip (issue #264)", () => { expect(parts[0].text).toContain("user prompt"); }); - it("inject-workflow-state.js uses the active task's selected workflow", async () => { - const taskDir = join(dir, ".trellis", "tasks", "demo-task"); - mkdirSync(join(dir, ".trellis", "workflows"), { recursive: true }); - writeFileSync( - join(taskDir, "task.json"), - JSON.stringify({ - id: "demo-task", - status: "in_progress", - workflow: "tdd", - }), - ); - writeFileSync( - join(dir, ".trellis", "workflows", "tdd.md"), - [ - "[workflow-state:in_progress]", - "OPENCODE_SELECTED_WORKFLOW", - "[/workflow-state:in_progress]", - ].join("\n"), - ); - writeSessionFile( - dir, - "opencode_main-session", - ".trellis/tasks/demo-task", - ); - const hooks = (await injectWorkflowStatePlugin({ - directory: dir, - })) as ChatMessageHooks; - const getActiveTask = vi.spyOn(TrellisContext.prototype, "getActiveTask"); - const parts: ChatMessagePart[] = [{ type: "text", text: "user prompt" }]; - - await hooks["chat.message"]( - { sessionID: "main-session", agent: "build" }, - { parts }, - ); - - expect(parts[0].text).toContain("OPENCODE_SELECTED_WORKFLOW"); - expect(getActiveTask).toHaveBeenCalledTimes(1); - }); - - it("inject-workflow-state.js warns once for explicitly invalid workflow values", async () => { - const taskDir = join(dir, ".trellis", "tasks", "demo-task"); - writeSessionFile( - dir, - "opencode_main-session", - ".trellis/tasks/demo-task", - ); - const hooks = (await injectWorkflowStatePlugin({ - directory: dir, - })) as ChatMessageHooks; - const error = vi - .spyOn(console, "error") - .mockImplementation(() => undefined); - - for (const workflow of ["", null, 42]) { - writeFileSync( - join(taskDir, "task.json"), - JSON.stringify({ id: "demo-task", status: "in_progress", workflow }), - ); - const parts: ChatMessagePart[] = [{ type: "text", text: "user prompt" }]; - await hooks["chat.message"]( - { sessionID: "main-session", agent: "build" }, - { parts }, - ); - expect(error).toHaveBeenCalledTimes(1); - expect(String(error.mock.calls[0]?.[0])).toContain("invalid workflow id"); - error.mockClear(); - } - }); - it("inject-workflow-state.js skips injection when the prompt contains the default skip keyword", async () => { const hooks = (await injectWorkflowStatePlugin({ directory: dir, diff --git a/packages/core/src/task/records.ts b/packages/core/src/task/records.ts index 087a5d334..ebb77a706 100644 --- a/packages/core/src/task/records.ts +++ b/packages/core/src/task/records.ts @@ -30,9 +30,8 @@ export interface WriteTaskRecordOptions { /** * Read a task.json file and return a canonicalized record. * - * Unknown fields on disk that are not part of the 24 required fields or - * supported optional fields are NOT returned — `loadTaskRecord` is the - * structured public API. + * Unknown fields on disk that are not part of the canonical 24-field + * shape are NOT returned — `loadTaskRecord` is the structured public API. * To preserve unknown fields across a load/write cycle, callers should * use {@link writeTaskRecord}, which merges canonical updates on top of * the on-disk JSON object instead of overwriting it. @@ -76,9 +75,6 @@ export function writeTaskRecord(options: WriteTaskRecordOptions): void { for (const field of TASK_RECORD_FIELD_ORDER) { out[field] = recordBag[field]; } - if (record.workflow !== undefined) { - out.workflow = record.workflow; - } if (existing) { for (const key of Object.keys(existing)) { if (!(key in out)) { diff --git a/packages/core/src/task/schema.ts b/packages/core/src/task/schema.ts index 6336e1da4..df4848073 100644 --- a/packages/core/src/task/schema.ts +++ b/packages/core/src/task/schema.ts @@ -2,8 +2,9 @@ * Canonical task.json shape — single source of truth for Trellis tasks. * * The runtime Python writer is `.trellis/scripts/common/task_store.py` - * (`cmd_create`). The 24 required fields and field order below mirror that - * writer exactly; optional feature fields remain absent unless selected. + * (`cmd_create`). The 24-field shape and field order below mirror that + * writer exactly so every TS and Python entry point produces structurally + * identical task.json files. * * Downstream consumers (CLI bootstrap, migration tooling, external Node * services) should depend on this type instead of redefining their own @@ -34,7 +35,6 @@ export interface TrellisTaskRecord { relatedFiles: string[]; notes: string; meta: Record<string, unknown>; - workflow?: string; } /** @@ -102,8 +102,6 @@ const STRING_ARRAY_FIELDS: ReadonlySet<TaskRecordField> = new Set([ "relatedFiles", ]); -const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/; - /** * Lightweight runtime schema for {@link TrellisTaskRecord}. Zero-dep on * purpose — `taskRecordSchema.parse(input)` returns a canonicalized @@ -148,16 +146,6 @@ function parseTaskRecord(input: unknown): TrellisTaskRecord { const value = (input as Record<string, unknown>)[field]; assignField(out, field, value); } - if ("workflow" in input) { - const workflow = input.workflow; - if (typeof workflow !== "string") { - throw new Error("task.workflow must be a string"); - } - if (!WORKFLOW_ID_RE.test(workflow)) { - throw new Error("task.workflow must match [A-Za-z0-9_-]+"); - } - out.workflow = workflow; - } return out; } @@ -203,10 +191,10 @@ function assignField( /** * Produce a fully-populated canonical-shape {@link TrellisTaskRecord}. * - * All 24 required fields are present in canonical order. Optional fields - * supplied through `overrides` follow them. Callers supply per-task values - * (id, name, title, assignee, createdAt, etc.) and leave null-default fields - * untouched unless they have a real value. + * All 24 fields are present in canonical order. `overrides` shallow-merges + * over the defaults — callers supply per-task values (id, name, title, + * assignee, createdAt, etc.) and leave null-default fields untouched + * unless they have a real value. */ export function emptyTaskRecord( overrides: Partial<TrellisTaskRecord> = {}, diff --git a/packages/core/test/task/records.test.ts b/packages/core/test/task/records.test.ts index f62081479..42632e092 100644 --- a/packages/core/test/task/records.test.ts +++ b/packages/core/test/task/records.test.ts @@ -54,30 +54,6 @@ describe("loadTaskRecord / writeTaskRecord", () => { expect(loaded).toEqual(record); }); - it("round-trips and preserves an optional workflow selection", () => { - const dir = path.join(tmp, "05-13-workflow"); - writeTaskRecord({ - taskDir: dir, - record: emptyTaskRecord({ - id: "workflow", - name: "workflow", - title: "Workflow", - workflow: "tdd", - }), - }); - expect(loadTaskRecord({ taskDir: dir }).workflow).toBe("tdd"); - - writeTaskRecord({ - taskDir: dir, - record: emptyTaskRecord({ - id: "workflow", - name: "workflow", - title: "Updated", - }), - }); - expect(loadTaskRecord({ taskDir: dir }).workflow).toBe("tdd"); - }); - it("loadTaskRecord rejects incomplete on-disk records instead of defaulting fields", () => { const dir = path.join(tmp, "05-13-incomplete"); fs.mkdirSync(dir, { recursive: true }); diff --git a/packages/core/test/task/schema.test.ts b/packages/core/test/task/schema.test.ts index 052d1e1e6..11e6f99d4 100644 --- a/packages/core/test/task/schema.test.ts +++ b/packages/core/test/task/schema.test.ts @@ -71,28 +71,6 @@ describe("taskRecordSchema", () => { expect(parsed).not.toBe(input); }); - it("parses the optional workflow selection", () => { - const parsed = taskRecordSchema.parse({ - ...emptyTaskRecord(), - workflow: "tdd", - }); - expect(parsed.workflow).toBe("tdd"); - expect(() => - taskRecordSchema.parse({ - ...emptyTaskRecord(), - workflow: 42, - }), - ).toThrow(/task.workflow must be a string/); - for (const workflow of ["", "tdd workflow", "../tdd"]) { - expect(() => - taskRecordSchema.parse({ - ...emptyTaskRecord(), - workflow, - }), - ).toThrow(/task.workflow must match \[A-Za-z0-9_-\]\+/); - } - }); - it("rejects non-object inputs", () => { expect(() => taskRecordSchema.parse("nope")).toThrow(/must be a JSON object/); expect(() => taskRecordSchema.parse(null)).toThrow(); From 4f9b067f6f9f3229410212e2cbe512414c7571ec Mon Sep 17 00:00:00 2001 From: taosu <taosu@mindfold.ai> Date: Thu, 30 Jul 2026 21:32:13 +0800 Subject: [PATCH 2/2] docs: make workflow hash example verifiable --- .trellis/spec/cli/backend/commands-workflow.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trellis/spec/cli/backend/commands-workflow.md b/.trellis/spec/cli/backend/commands-workflow.md index 1f9104080..6587dfc0d 100644 --- a/.trellis/spec/cli/backend/commands-workflow.md +++ b/.trellis/spec/cli/backend/commands-workflow.md @@ -149,8 +149,8 @@ Native source-of-truth contract: ### 5. Good/Base/Bad Cases - Good: `trellis workflow --template tdd` replaces a pristine native workflow, - removes the workflow hash, and later `trellis update --skip-all` leaves TDD - content in place. + removes the workflow hash, and later `trellis update` leaves TDD content in + place. - Base: `trellis init --workflow native` writes bundled native workflow and keeps `.trellis/workflow.md` hash-tracked. - Bad: `trellis workflow --template tdd` writes TDD content and records the TDD