feat(trellis): enhance bundled skills and workflow integration
- Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths. - Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions. - Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context. - Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence. - Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability.
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37",
|
||||
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389",
|
||||
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
||||
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||
@@ -34,7 +34,7 @@
|
||||
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245",
|
||||
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319",
|
||||
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "cb7c1c7dd976004e4981181ab9462f8eedc4e9c014721419bd666d6608f4dd72",
|
||||
".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
||||
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
|
||||
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||
@@ -50,10 +50,10 @@
|
||||
".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
|
||||
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
||||
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
||||
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
|
||||
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609",
|
||||
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
|
||||
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
||||
".codex/hooks/session-start.py": "91fbbd30ac974c3cd2b4db152aa38ac176a9c7e3d062cbaa1acacf07152b2592",
|
||||
".codex/hooks/inject-subagent-context.py": "db413933ff30e1503f37f1d292fb421b1ced3f39f1890351a6621e499ec16b2c",
|
||||
".codex/hooks/inject-workflow-state.py": "cda5888c29671035e7d2e033a0fcc631a9428a0543fb2dd071285b30c6ca4e23",
|
||||
".codex/hooks.json": "c16c9af7f6010bf4fabb64fd4bd497201d34583b96c665ea1d5739eed48b3fb5",
|
||||
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
||||
".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d",
|
||||
".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c",
|
||||
@@ -61,40 +61,44 @@
|
||||
".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496",
|
||||
".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2",
|
||||
".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a",
|
||||
".pi/extensions/trellis/index.ts": "b4bfd740d517462f943aaff920dd39371d3e6bdc3823dfa8c2752314c57eaf9b",
|
||||
".pi/extensions/trellis/index.ts": "770290e675fabfe0dd0889771876607927d36cb9ef68586552e6e2d56ca345fc",
|
||||
".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c",
|
||||
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
|
||||
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||
".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa",
|
||||
".trellis/config.yaml": "eaba56c36fb07483fcbc96d74c4da0ab33b9739fb9ff10ea4cc1e1cd32bf23b2",
|
||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
|
||||
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
|
||||
".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119",
|
||||
".trellis/scripts/common/active_task.py": "28a81f8828538fb70a15c88edd90eda9d685adbde8862f67f630bce5b27d9832",
|
||||
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
|
||||
".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca",
|
||||
".trellis/scripts/common/config.py": "43a22c4e88a06d6316d1bcd4730731bdecc22ae782e9f217b25ea53aa85cd416",
|
||||
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
|
||||
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
|
||||
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
|
||||
".trellis/scripts/common/git_context.py": "be0c68d4b566319484cf95a3ca6d6dee18e00f040cecadea6a98f10dc445f967",
|
||||
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
|
||||
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
||||
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
|
||||
".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee",
|
||||
".trellis/scripts/common/paths.py": "5f66eb073c296a8a920048c1c53d8236783ccb998c8c25a68a540534d30abd02",
|
||||
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
|
||||
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
|
||||
".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d",
|
||||
".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
|
||||
".trellis/scripts/common/task_context.py": "be5fa407f99c2400075194dbaa8b6ec996ce8aac2122d191bb9cb11e479a9476",
|
||||
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
||||
".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6",
|
||||
".trellis/scripts/common/task_store.py": "793b9e7863fade04b0d84c7668bacb4229c435f0fcdba008bd333421ec3e17b3",
|
||||
".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871",
|
||||
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
|
||||
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
|
||||
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
||||
".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8",
|
||||
".trellis/scripts/common/workflow_phase.py": "c3d00011a4d8c3d958ec57cea50b97f4170dce7c28381ad3915dae0870167d6e",
|
||||
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
|
||||
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
||||
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
||||
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
||||
".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4",
|
||||
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76"
|
||||
".trellis/scripts/task.py": "7790d9510311d55ed1f66c71b9ce0bafc8871f1b675c1251dcf6a1f4e823d2b3",
|
||||
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76",
|
||||
".trellis/scripts/common/workflow_selection.py": "b136d6ae41aac95f4d5f425b641b62b7002f7cff2d26aa44b056cef9a2242753",
|
||||
".trellis/scripts/common/spec_match.py": "baf3b17b15c279475f99699ac6e039bc68285d5dbfbda28f279aa8346cb0c59a",
|
||||
".trellis/scripts/common/spec_inject.py": "9987d213eed3d2ec1b2c16adcad8b10a21d3b68dcce838f85c42d83c56e69e0b",
|
||||
".codex/hooks/inject-spec-context.py": "4df3945be18163afe066ec3061d8b95a687f676e3a80dbf0aada4dec8c339f90"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.6.12
|
||||
0.7.0-beta.3
|
||||
@@ -76,6 +76,21 @@ max_journal_lines: 2000
|
||||
# Default package used when --package is not specified.
|
||||
# default_package: frontend
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Default workflow
|
||||
#-------------------------------------------------------------------------------
|
||||
# Team-shared default workflow for tasks that do not pin one. The id names a
|
||||
# variant file in `.trellis/workflows/<id>.md` (populate it with
|
||||
# `trellis workflow --save <id>`). This value is committed, so the whole team
|
||||
# shares the same default. A per-developer override lives in the gitignored
|
||||
# `.developer` file as a `workflow=<id>` line and takes precedence over this.
|
||||
#
|
||||
# Resolution precedence: per-task (task.json `workflow`) > personal
|
||||
# (`.developer` `workflow=`) > this `default_workflow` > global
|
||||
# `.trellis/workflow.md`.
|
||||
#
|
||||
# default_workflow: native
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Channel worker OOM guard
|
||||
#-------------------------------------------------------------------------------
|
||||
@@ -145,6 +160,38 @@ channel:
|
||||
# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md)
|
||||
# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Path-scoped spec injection
|
||||
#-------------------------------------------------------------------------------
|
||||
# When the agent touches a file (Read/Edit/Write/MultiEdit), spec .md files
|
||||
# under .trellis/spec/ whose frontmatter `paths:` globs match the touched path
|
||||
# are surfaced into the session right then. The first time a spec matches it is
|
||||
# injected in full; while its content is unchanged and the refresh window has
|
||||
# not elapsed it stays silent; once the window elapses a short `<spec-ticket>`
|
||||
# reminder is emitted to counter recency decay. Editing the spec itself — or a
|
||||
# SessionStart after /clear or /compact — re-injects the full text.
|
||||
# Oversized specs are truncated with a notice; once the per-event payload cap
|
||||
# is reached, remaining full bodies degrade to index lines (path + description)
|
||||
# instead of being inlined.
|
||||
#
|
||||
# Character values: the ceiling this budget respects is Claude Code's
|
||||
# documented 10,000-CHARACTER additionalContext limit, so the caps count
|
||||
# characters too (byte caps made CJK specs pay 3x for the same text).
|
||||
# `0` disables the corresponding limit.
|
||||
# The refresh window uses wall-clock seconds. `0` disables time-based reminders;
|
||||
# SessionStart resets after /clear or /compact still force a full re-injection.
|
||||
#
|
||||
# spec_injection:
|
||||
# enabled: true # false disables injection entirely
|
||||
# max_spec_chars: 9400 # per matched spec file
|
||||
# max_total_chars: 9500 # whole per-event payload; overflow degrades to index lines
|
||||
# refresh_window_seconds: 2700 # touches past this interval re-emit a ticket
|
||||
# tools: # tool events that trigger injection
|
||||
# - Read
|
||||
# - Edit
|
||||
# - Write
|
||||
# - MultiEdit
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Per-turn prompt injection
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
@@ -23,8 +23,15 @@ DIR_WORKFLOW = ".trellis"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_RUNTIME = ".runtime"
|
||||
DIR_SESSIONS = "sessions"
|
||||
DIR_CURSOR_SHELL = "cursor-shell"
|
||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
||||
DIR_SHELL_TICKETS = "shell-tickets"
|
||||
# Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that
|
||||
# was mid-command across an upgrade does not silently degrade; never written.
|
||||
# Tickets are 30-second ephemera, so the old directory ages out by itself —
|
||||
# there is nothing to migrate, only a glob on a directory that is normally
|
||||
# absent. The alternative (ignore it) would land its one lost command on the
|
||||
# platform that works today.
|
||||
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
|
||||
SHELL_TICKET_TTL_SECONDS = 30
|
||||
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
||||
|
||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||
@@ -50,35 +57,75 @@ _KNOWN_PLATFORMS = {
|
||||
"snow",
|
||||
}
|
||||
|
||||
# Every name below records how it was checked. Do NOT add a name by analogy
|
||||
# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21
|
||||
# declared names had never existed anywhere — they were pattern-guessed from a
|
||||
# `<PLATFORM>_SESSION_ID` shape no vendor agreed to, and the uniformity was the
|
||||
# only "evidence" behind them. A platform with no verified name belongs in no
|
||||
# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge.
|
||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
||||
("cursor", ("CURSOR_SESSION_ID",)),
|
||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
||||
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
|
||||
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
|
||||
# was removed here — verified absent from that same live environment.
|
||||
("claude", ("CLAUDE_CODE_SESSION_ID",)),
|
||||
# REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0
|
||||
# into shell children, absent from the parent env; openai/codex#19937).
|
||||
# CODEX_SESSION_ID was removed — absent from a live `codex exec` env.
|
||||
("codex", ("CODEX_THREAD_ID",)),
|
||||
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's
|
||||
# hookRunner.ts. Its shell tool builds the child env in
|
||||
# shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so
|
||||
# this never reaches a bash child — it resolves only inside a hook process.
|
||||
("gemini", ("GEMINI_SESSION_ID",)),
|
||||
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
|
||||
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/
|
||||
# extensions/hooks documents it as injected during hook execution by the
|
||||
# Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma.
|
||||
("qoder", ("QODER_SESSION_ID",)),
|
||||
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
|
||||
# UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace
|
||||
# dtctl, oh-my-agent and gastown all key agent detection on it and one notes
|
||||
# it is "set in both interactive and --no-interactive". Kept because that is
|
||||
# absence of evidence, not evidence of absence. To settle: run
|
||||
# `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro.
|
||||
("kiro", ("KIRO_SESSION_ID",)),
|
||||
# UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/
|
||||
# hooks-reference and from the CLI programmatic reference. To settle: run
|
||||
# `copilot help environment` (the authoritative list per those docs) — not
|
||||
# runnable here, the CLI is not installed and copilot-cli ships no source.
|
||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
||||
("trae", ("TRAE_SESSION_ID",)),
|
||||
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID).
|
||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this
|
||||
# only fires when the resolver already detected "zcode" — no collision with
|
||||
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
|
||||
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
|
||||
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
|
||||
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
|
||||
# name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first,
|
||||
# keep the historical name as a fallback: if neither exists nothing changes.
|
||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so the
|
||||
# entry only fires once the resolver detected "zcode" — no collision with
|
||||
# the claude entry above.
|
||||
("zcode", ("CLAUDE_SESSION_ID",)),
|
||||
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
|
||||
# TRELLIS_CONTEXT_ID remains the preferred override when present.
|
||||
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
|
||||
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
|
||||
# exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names
|
||||
# Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred
|
||||
# override — Snow sets that too.
|
||||
("snow", ("SNOW_SESSION_ID",)),
|
||||
)
|
||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
# REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value
|
||||
# matches ~/.cursor/chats/<ws>/<id>). The Cursor *IDE* is unverified — a
|
||||
# 2026-05 forum request for it drew no staff reply. The invented
|
||||
# CURSOR_SESSION_ID was removed from the session table: empty in a live
|
||||
# cursor-agent shell. Cursor's other path is the shell ticket below
|
||||
# (_lookup_shell_ticket_context_key), which is not Cursor-specific.
|
||||
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||
)
|
||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
||||
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
|
||||
# scripts; empty in the agent's own shell env.
|
||||
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
||||
# UNVERIFIED — never researched. The 2026-08-05 audit covered the session
|
||||
# table only, so do not infer these are real *or* fake from that work
|
||||
# (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were*
|
||||
# checked: absent from docs and from live envs). To settle each: run
|
||||
# `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call.
|
||||
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
||||
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
||||
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
||||
@@ -90,11 +137,15 @@ _ENV_PLATFORM_ALIASES = {
|
||||
"factory-ai": "droid",
|
||||
"github-copilot": "copilot",
|
||||
}
|
||||
# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode,
|
||||
# while later shell commands see only the shared env name and resolve it through
|
||||
# the Claude entry. Canonicalize both paths to one runtime filename.
|
||||
# ZCode intentionally reuses Claude's session env var name. Hooks know the host
|
||||
# is ZCode, while later shell commands see only the shared env name and resolve
|
||||
# it through the claude entry. Canonicalize both paths to one runtime filename.
|
||||
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
||||
"zcode": "claude",
|
||||
# Factory Droid's config directory is `.factory/`, so a hook that names its
|
||||
# platform after the directory it was installed in reports "factory". Its
|
||||
# sibling hooks report "droid". One runtime filename either way.
|
||||
"factory": "droid",
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +273,12 @@ def _iter_env_keys(
|
||||
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
||||
platform_name: str | None,
|
||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||||
"""Narrow an env-key table to one platform, or return all of it.
|
||||
|
||||
A platform with no entry yields an empty tuple, and the caller's `for` loop
|
||||
simply does not run. That is the normal case, not an error: platforms with
|
||||
no verified env var name are deliberately absent from these tables.
|
||||
"""
|
||||
if not platform_name:
|
||||
return env_keys
|
||||
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
||||
@@ -275,8 +332,12 @@ def _find_repo_root_from_cwd() -> Path | None:
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
||||
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
|
||||
runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
|
||||
return (
|
||||
runtime_dir / DIR_SHELL_TICKETS,
|
||||
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
|
||||
)
|
||||
|
||||
|
||||
def _remove_file(path: Path) -> bool:
|
||||
@@ -334,7 +395,7 @@ def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> b
|
||||
|
||||
created_at = ticket.get("created_at_epoch")
|
||||
if isinstance(created_at, (int, float)):
|
||||
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
|
||||
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
|
||||
return True
|
||||
_remove_file(ticket_path)
|
||||
return False
|
||||
@@ -352,13 +413,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _matching_cursor_ticket_context_key(
|
||||
def _matching_ticket_context_key(
|
||||
ticket_path: Path,
|
||||
repo_root: Path,
|
||||
now: float,
|
||||
) -> str | None:
|
||||
"""Accept a ticket on its merits, never on which platform wrote it.
|
||||
|
||||
The `platform` field a ticket carries is debugging metadata; gating on it
|
||||
was what kept this bridge invisible to every platform but Cursor.
|
||||
"""
|
||||
ticket = _read_json(ticket_path)
|
||||
if ticket is None or ticket.get("platform") != "cursor":
|
||||
if ticket is None:
|
||||
return None
|
||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||
return None
|
||||
@@ -369,29 +435,30 @@ def _matching_cursor_ticket_context_key(
|
||||
return _string_value(ticket.get("context_key"))
|
||||
|
||||
|
||||
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
||||
def _lookup_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve session identity from a short-lived shell ticket.
|
||||
|
||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
||||
export it into the shell command environment. The Cursor hook writes a
|
||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
||||
the current `task.py` subcommand matches and exactly one fresh context key
|
||||
matches, which avoids cross-window pointer contamination.
|
||||
No researched platform exports its session id into a shell child, but every
|
||||
hook-capable one hands that id to a hook. So the hook that runs just before
|
||||
a shell command writes a ticket, and this reads it back. A ticket counts
|
||||
only when it is fresh, was written for this repo, and matches the `task.py`
|
||||
subcommand now running — and only when exactly one fresh context key
|
||||
matches. Two concurrent windows therefore both degrade rather than one
|
||||
inheriting the other's pointer.
|
||||
"""
|
||||
repo_root = _find_repo_root_from_cwd()
|
||||
if repo_root is None:
|
||||
return None
|
||||
|
||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
||||
if not ticket_dir.is_dir():
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
candidates: set[str] = set()
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
||||
if context_key:
|
||||
candidates.add(context_key)
|
||||
for ticket_dir in _shell_ticket_dirs(repo_root):
|
||||
if not ticket_dir.is_dir():
|
||||
continue
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
|
||||
if context_key:
|
||||
candidates.add(context_key)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return next(iter(candidates))
|
||||
@@ -435,8 +502,10 @@ def resolve_context_key(
|
||||
if env_context_key:
|
||||
return env_context_key
|
||||
|
||||
if allow_environment_context and platform_name in (None, "session", "cursor"):
|
||||
return _lookup_cursor_shell_ticket_context_key()
|
||||
# Last in the chain on purpose: a platform that genuinely exports identity
|
||||
# into the shell outranks a ticket, and no platform name gates the lookup.
|
||||
if allow_environment_context:
|
||||
return _lookup_shell_ticket_context_key()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -281,6 +281,24 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
||||
return "inline"
|
||||
|
||||
|
||||
def get_default_workflow(repo_root: Path | None = None) -> str | None:
|
||||
"""Return the team-shared default workflow id from config.yaml.
|
||||
|
||||
Reads the top-level ``default_workflow`` key — a slug naming a variant in
|
||||
``.trellis/workflows/<id>.md``. Returns None when unset or blank. This is
|
||||
the git-tracked, team-shared default; a per-developer override lives in the
|
||||
gitignored ``.developer`` file (``workflow=<id>``, see
|
||||
``paths.get_developer_workflow``) and outranks it. Fail-open: a missing or
|
||||
non-string value simply means "no team default" — no warning (this is read
|
||||
on every turn by hooks and must not add per-turn noise).
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
raw = config.get("default_workflow")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
return raw.strip() or None
|
||||
|
||||
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
||||
|
||||
@@ -27,6 +27,8 @@ from .packages_context import (
|
||||
get_context_packages_text,
|
||||
get_context_packages_json,
|
||||
)
|
||||
from .paths import get_repo_root
|
||||
from .spec_match import match_specs_for_file
|
||||
from .trellis_config import read_trellis_config
|
||||
from .workflow_phase import (
|
||||
filter_platform,
|
||||
@@ -57,9 +59,9 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
"-m",
|
||||
choices=["default", "record", "packages", "phase"],
|
||||
choices=["default", "record", "packages", "phase", "spec"],
|
||||
default="default",
|
||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction), spec (specs governing a file)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
@@ -69,6 +71,10 @@ def main() -> None:
|
||||
"--platform",
|
||||
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
help="File path (absolute or repo-relative) for --mode spec. Lists spec files whose frontmatter paths match it.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -95,6 +101,32 @@ def main() -> None:
|
||||
)
|
||||
content = filter_platform(content, effective)
|
||||
print(content, end="")
|
||||
elif args.mode == "spec":
|
||||
if not args.file:
|
||||
parser.error("--file is required with --mode spec")
|
||||
matches = match_specs_for_file(get_repo_root(), args.file)
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"file": args.file,
|
||||
"matches": [
|
||||
{
|
||||
"path": match.rel_path,
|
||||
"description": match.description,
|
||||
}
|
||||
for match in matches
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
elif matches:
|
||||
for match in matches:
|
||||
print(f"{match.rel_path} — {match.description or '(no description)'}")
|
||||
else:
|
||||
print(f"No spec files declare paths matching {args.file}.")
|
||||
else:
|
||||
if args.json:
|
||||
output_json()
|
||||
|
||||
@@ -94,6 +94,34 @@ def get_developer(repo_root: Path | None = None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_developer_workflow(repo_root: Path | None = None) -> str | None:
|
||||
"""Get the personal workflow override from the .developer file.
|
||||
|
||||
Reads an optional ``workflow=<id>`` line from the gitignored ``.developer``
|
||||
file — the per-developer, git-excluded override that outranks the
|
||||
team-shared ``default_workflow`` in config.yaml. Returns None when the file
|
||||
or the line is absent/blank. Never raises. Additive to ``get_developer``:
|
||||
the ``name=`` reader ignores this line and vice versa.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
|
||||
if not dev_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = dev_file.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if line.startswith("workflow="):
|
||||
return line.split("=", 1)[1].strip() or None
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_developer(repo_root: Path | None = None) -> bool:
|
||||
"""Check if developer is initialized.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Provides:
|
||||
get_context_text_record - Text for record mode
|
||||
output_json - Print JSON
|
||||
output_text - Print text
|
||||
get_update_hint - Once-per-session "update available" line
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -417,8 +418,16 @@ def _compare_versions(left: str, right: str) -> int | None:
|
||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||
|
||||
|
||||
def _update_marker_path(repo_root: Path) -> Path:
|
||||
context_key = resolve_context_key()
|
||||
def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path:
|
||||
"""Path of the once-per-session marker that throttles the update check.
|
||||
|
||||
`context_key` lets a caller that already resolved session identity pass it
|
||||
in — the SessionStart hook reads the session id from hook stdin, which is
|
||||
more reliable than this function's environment-only fallback chain. Shell
|
||||
entry points leave it None and keep the previous behavior.
|
||||
"""
|
||||
if not context_key:
|
||||
context_key = resolve_context_key()
|
||||
if not context_key:
|
||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||
context_key = terminal_key or f"ppid-{os.getppid()}"
|
||||
@@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path:
|
||||
)
|
||||
|
||||
|
||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
def _mark_update_check_attempted(
|
||||
repo_root: Path,
|
||||
context_key: str | None = None,
|
||||
) -> bool:
|
||||
marker_path = _update_marker_path(repo_root, context_key)
|
||||
if marker_path.exists():
|
||||
return False
|
||||
try:
|
||||
@@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_update_hint(repo_root: Path) -> str | None:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
|
||||
"""Return the "update available" line for this session, at most once.
|
||||
|
||||
Public because the SessionStart hook imports it: the text-mode CLI path
|
||||
(`get_context.py`) used to be the only caller, so hook-driven platforms —
|
||||
Claude Code included — never saw the reminder at all.
|
||||
"""
|
||||
marker_path = _update_marker_path(repo_root, context_key)
|
||||
if marker_path.exists():
|
||||
return None
|
||||
|
||||
@@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
_mark_update_check_attempted(repo_root)
|
||||
_mark_update_check_attempted(repo_root, context_key)
|
||||
comparison = _compare_versions(current_version, latest_version)
|
||||
if comparison is None or comparison >= 0:
|
||||
return None
|
||||
@@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None:
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
update_hint = _get_update_hint(repo_root)
|
||||
update_hint = get_update_hint(repo_root)
|
||||
if update_hint:
|
||||
print(update_hint)
|
||||
print("")
|
||||
|
||||
Executable
+439
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Decision logic for path-scoped spec injection (ticket-refresh model).
|
||||
|
||||
Pure logic only: the per-spec decision engine, block rendering and budgeted
|
||||
payload assembly. Importing this module has no side effects; every piece of IO
|
||||
orchestration (stdin, config, identity, state files, locking, GC) lives in the
|
||||
platform hook that calls it. Unit tests import this module directly.
|
||||
|
||||
Clock
|
||||
The periodic refresh window uses epoch seconds. Context resets are
|
||||
explicit lifecycle events: the hook records an opaque reset identifier,
|
||||
and a mismatch with the last emission re-teaches the spec in full.
|
||||
|
||||
Budget
|
||||
All caps are in characters, because the platform's ``additionalContext``
|
||||
ceiling is 10,000 *characters* (counting bytes made CJK specs pay 3x).
|
||||
Truncation slices code points, which can never split a multi-byte
|
||||
sequence. The per-event cap is enforced on the assembled payload string —
|
||||
wrappers, ``\\n\\n`` separators, index block and tickets all counted — so
|
||||
nothing is ever appended unchecked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .spec_match import SpecMatch
|
||||
|
||||
# Bound on the size of a spec file we are willing to read and hash. A spec
|
||||
# larger than this degrades to an index line (warned) — an inlined body that
|
||||
# big could never fit the budget anyway, and the read+hash would be unbounded.
|
||||
MAX_SPEC_SOURCE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
# Upper bound on the room reserved for named index lines while FULL blocks are
|
||||
# still being packed. Beyond it the reserve falls back to the summary line
|
||||
# alone: a big fan-out must not starve the specs that can still be taught.
|
||||
INDEX_RESERVE_MAX_CHARS = 900
|
||||
|
||||
# State record schema version. Records with any other version are ignored
|
||||
# (safe direction: an ignored record re-injects rather than stays silent).
|
||||
STATE_VERSION = 2
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
print(f"[WARN] spec_inject: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Clock
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def within_window(
|
||||
clock: dict[str, Any],
|
||||
last: dict[str, Any],
|
||||
win_seconds: int,
|
||||
) -> bool:
|
||||
"""True when the last emission is still inside the refresh window (→ stay
|
||||
silent).
|
||||
|
||||
A window of ``0`` means never refresh (infinite window → always True).
|
||||
Missing timestamps or a negative delta are past-window (False → refresh),
|
||||
the safe side of the misfire asymmetry.
|
||||
"""
|
||||
cur_ts = clock.get("ts")
|
||||
last_ts = last.get("ts")
|
||||
if isinstance(cur_ts, (int, float)) and isinstance(last_ts, (int, float)):
|
||||
if win_seconds == 0:
|
||||
return True
|
||||
delta = cur_ts - last_ts
|
||||
return 0 <= delta < win_seconds
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def decide(
|
||||
stateless: bool,
|
||||
last: dict[str, Any] | None,
|
||||
sha256_hex: str,
|
||||
clock: dict[str, Any],
|
||||
win_seconds: int,
|
||||
) -> str:
|
||||
"""Return one of ``"full"`` | ``"ticket"`` | ``"silent"`` for a spec.
|
||||
|
||||
Order is contractual: statelessness first (bounded cost, no state to
|
||||
consult), then first sight, content change, context reset, the refresh
|
||||
window, and finally completeness. A ticket says "you were shown this spec",
|
||||
which is a lie when the recorded FULL was truncated, so an incomplete
|
||||
record is re-taught in full instead.
|
||||
"""
|
||||
if stateless:
|
||||
return "ticket"
|
||||
if last is None:
|
||||
return "full"
|
||||
if last.get("sha256") != sha256_hex:
|
||||
return "full"
|
||||
if clock.get("reset") != last.get("reset"):
|
||||
return "full"
|
||||
|
||||
if within_window(clock, last, win_seconds):
|
||||
return "silent"
|
||||
|
||||
# "complete" is optional and absent means a whole body was shown.
|
||||
if last.get("complete") is False:
|
||||
return "full"
|
||||
return "ticket"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rendering
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def truncate_chars(text: str, cap: int) -> str:
|
||||
"""Slice ``text`` to at most ``cap`` code points. ``cap <= 0`` = no limit."""
|
||||
if cap <= 0 or len(text) <= cap:
|
||||
return text
|
||||
return text[:cap]
|
||||
|
||||
|
||||
def truncation_notice(rel_path: str, cap: int) -> str:
|
||||
return (
|
||||
f"\n[Trellis: truncated at {cap} characters — "
|
||||
f"read {rel_path} for the full content]"
|
||||
)
|
||||
|
||||
|
||||
def render_full(edited_rel: str, spec_rel: str, sha12: str, body: str) -> str:
|
||||
return (
|
||||
f'<spec-context file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
|
||||
f"{body}\n"
|
||||
f"</spec-context>"
|
||||
)
|
||||
|
||||
|
||||
def render_ticket(
|
||||
edited_rel: str,
|
||||
spec_rel: str,
|
||||
sha12: str,
|
||||
stateless: bool,
|
||||
) -> str:
|
||||
"""Render a ticket block.
|
||||
|
||||
``stateless=True`` covers both the no-identity and circuit-breaker paths:
|
||||
there is no record of a prior emission, so the wording must not claim one.
|
||||
"""
|
||||
if stateless:
|
||||
body = (
|
||||
"This spec governs the file you just touched. If you have not read it in\n"
|
||||
f"this session, Read {spec_rel} before continuing."
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
"You were shown this spec earlier in this session and its content is unchanged.\n"
|
||||
"It still governs edits to matching files. If you no longer remember it, Read\n"
|
||||
f"{spec_rel} before continuing."
|
||||
)
|
||||
return (
|
||||
f'<spec-ticket file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
|
||||
f"{body}\n"
|
||||
f"</spec-ticket>"
|
||||
)
|
||||
|
||||
|
||||
def _index_block(lines: Sequence[str]) -> str:
|
||||
return "<spec-index>\n" + "\n".join(lines) + "\n</spec-index>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# State records
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_record(
|
||||
rel_path: str,
|
||||
sha256_hex: str,
|
||||
mode: str,
|
||||
clock: dict[str, Any],
|
||||
complete: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a state record. ``complete=False`` marks a FULL whose body was
|
||||
truncated below the whole spec — an absent flag means whole."""
|
||||
record: dict[str, Any] = {
|
||||
"v": STATE_VERSION,
|
||||
"spec": rel_path,
|
||||
"sha256": sha256_hex,
|
||||
"mode": mode,
|
||||
"ts": clock.get("ts"),
|
||||
}
|
||||
if isinstance(clock.get("reset"), str):
|
||||
record["reset"] = clock["reset"]
|
||||
if not complete:
|
||||
record["complete"] = False
|
||||
return record
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Payload assembly
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _derive_fitting_full(
|
||||
edited_rel: str,
|
||||
spec_rel: str,
|
||||
sha12: str,
|
||||
text: str,
|
||||
max_spec_chars: int,
|
||||
fits,
|
||||
) -> tuple[str, bool] | None:
|
||||
"""Largest truncated FULL block that fits the remaining total budget.
|
||||
|
||||
Binary search over the body cap: the rendered block's length is monotone
|
||||
non-decreasing in the cap, so the largest cap whose block still ``fits``
|
||||
is found in ~log2(len(text)) renders (this also absorbs the digit-length
|
||||
wobble of the notice text, which a closed-form estimate cannot).
|
||||
|
||||
The search ceiling is ``max_spec_chars`` when set and the whole body when
|
||||
it is ``0`` (unlimited) — with a ceiling of 1, as an unguarded
|
||||
``max(1, 0)`` would give, nothing but a one-character spec could ever be
|
||||
derived. Returns ``(block, complete)`` — ``complete`` is True only when
|
||||
the winning cap covered the whole body — or None when no non-empty prefix
|
||||
fits (the caller degrades to an index line).
|
||||
"""
|
||||
def candidate_for(cap: int) -> str:
|
||||
body = truncate_chars(text, cap)
|
||||
if len(body) < len(text):
|
||||
body += truncation_notice(spec_rel, cap)
|
||||
return render_full(edited_rel, spec_rel, sha12, body)
|
||||
|
||||
ceiling = len(text) if max_spec_chars <= 0 else min(max_spec_chars, len(text))
|
||||
lo, hi = 1, max(1, ceiling)
|
||||
best: tuple[str, bool] | None = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
candidate = candidate_for(mid)
|
||||
if fits(candidate):
|
||||
best = (candidate, mid >= len(text))
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
return best
|
||||
|
||||
|
||||
def _index_line(match: SpecMatch) -> str:
|
||||
return f"- {match.rel_path} — {match.description or 'no description'}"
|
||||
|
||||
|
||||
def assemble_payload(
|
||||
edited_rel: str,
|
||||
matches: Sequence[SpecMatch],
|
||||
stateless: bool,
|
||||
state_records: dict[str, dict[str, Any]],
|
||||
clock: dict[str, Any],
|
||||
max_spec_chars: int,
|
||||
max_total_chars: int,
|
||||
win_seconds: int,
|
||||
match_files: dict[str, str] | None = None,
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Assemble the additionalContext payload from the matched specs.
|
||||
|
||||
Returns ``(payload, records)`` where ``records`` are the state lines to
|
||||
append for the emissions that actually made it into the payload (silent
|
||||
hits and budget-dropped emissions record nothing — they stay eligible).
|
||||
|
||||
Every candidate block is measured against the *assembled* payload string
|
||||
(``"\\n\\n".join(...)``), so the per-event character ceiling holds for the
|
||||
exact string that is emitted.
|
||||
|
||||
``match_files`` maps a governing spec to the first matching file in a
|
||||
multi-file tool call. Single-file callers omit it and retain the original
|
||||
``edited_rel`` behavior.
|
||||
"""
|
||||
blocks: list[str] = []
|
||||
|
||||
def file_for(match: SpecMatch) -> str:
|
||||
return (match_files or {}).get(match.rel_path, edited_rel)
|
||||
|
||||
def fits(candidate: str, reserve: int = 0) -> bool:
|
||||
"""Does ``candidate`` fit the per-event ceiling, keeping ``reserve``
|
||||
characters free for what still has to be appended after it?"""
|
||||
if max_total_chars <= 0:
|
||||
return True
|
||||
return len("\n\n".join([*blocks, candidate])) + reserve <= max_total_chars
|
||||
|
||||
# Reserve while candidates are still pending: the index lines those
|
||||
# candidates would actually need (true strings, not estimates) plus the
|
||||
# summary line — so a derived-cap FULL cannot eat the budget and starve
|
||||
# the specs behind it (measured: 10-spec fan-out at max_total_chars 3000
|
||||
# emitted one 3000-char FULL and dropped the other nine silently). The
|
||||
# named part is only guaranteed within INDEX_RESERVE_MAX_CHARS; beyond
|
||||
# that the reserve falls back to the summary line alone.
|
||||
_all_index_lines = [_index_line(m) for m in matches]
|
||||
_summary_upper = (
|
||||
f"- (+{len(matches)} more governing specs over budget — run "
|
||||
f"python3 ./.trellis/scripts/get_context.py --mode spec "
|
||||
f"--file {edited_rel} to list them)"
|
||||
)
|
||||
_summary_reserve = len("\n\n" + _index_block([_summary_upper]))
|
||||
|
||||
def reserve_for(pending: Sequence[str]) -> int:
|
||||
if not pending:
|
||||
return 0 # Nothing can follow this block — nothing to reserve.
|
||||
named = len("\n\n" + _index_block([*pending, _summary_upper]))
|
||||
if named > INDEX_RESERVE_MAX_CHARS:
|
||||
return _summary_reserve
|
||||
return named
|
||||
|
||||
index_lines: list[str] = []
|
||||
ticket_pending: list[tuple[str, str, str]] = [] # (file, spec, sha256)
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
for match_idx, match in enumerate(matches):
|
||||
try:
|
||||
size = match.spec_path.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
if size > MAX_SPEC_SOURCE_BYTES:
|
||||
# Too big to read+hash, let alone inline: name it and move on.
|
||||
_warn(
|
||||
f"{match.rel_path} is {size} bytes (over "
|
||||
f"{MAX_SPEC_SOURCE_BYTES}) — degraded to an index line"
|
||||
)
|
||||
index_lines.append(_index_line(match))
|
||||
continue
|
||||
|
||||
try:
|
||||
data = match.spec_path.read_bytes()
|
||||
except OSError:
|
||||
_warn(f"cannot read {match.rel_path} — skipped")
|
||||
continue
|
||||
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
sha12 = sha256_hex[:12]
|
||||
last = None if stateless else state_records.get(match.rel_path)
|
||||
decision = decide(stateless, last, sha256_hex, clock, win_seconds)
|
||||
|
||||
if decision == "silent":
|
||||
continue
|
||||
|
||||
if decision == "ticket":
|
||||
# Deferred: tickets are counted against the budget last.
|
||||
ticket_pending.append((file_for(match), match.rel_path, sha256_hex))
|
||||
continue
|
||||
|
||||
pending = [*index_lines, *_all_index_lines[match_idx + 1 :]]
|
||||
reserve = reserve_for(pending)
|
||||
_fits = (lambda c: fits(c, reserve))
|
||||
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
body = truncate_chars(text, max_spec_chars)
|
||||
complete = len(body) >= len(text)
|
||||
if not complete:
|
||||
body += truncation_notice(match.rel_path, max_spec_chars)
|
||||
matching_file = file_for(match)
|
||||
block = render_full(matching_file, match.rel_path, sha12, body)
|
||||
if not _fits(block):
|
||||
# Contract amendment 1: before degrading, truncate FURTHER to the
|
||||
# largest body prefix that fits the remaining total budget
|
||||
# (wrapper + notice counted). Without this, the frozen defaults
|
||||
# made the truncation path unreachable (body cap + notice +
|
||||
# wrapper > total cap) and long specs fell straight to an index
|
||||
# line — the rejected index-only mode by another route.
|
||||
derived = _derive_fitting_full(
|
||||
matching_file,
|
||||
match.rel_path,
|
||||
sha12,
|
||||
text,
|
||||
max_spec_chars,
|
||||
_fits,
|
||||
)
|
||||
if derived is not None:
|
||||
derived_block, derived_complete = derived
|
||||
blocks.append(derived_block)
|
||||
records.append(
|
||||
make_record(
|
||||
match.rel_path, sha256_hex, "full", clock, derived_complete
|
||||
)
|
||||
)
|
||||
continue
|
||||
# No usable prefix fits — degrade to an index line, never drop
|
||||
# silently. Not recorded: stays eligible for a later event.
|
||||
index_lines.append(_index_line(match))
|
||||
continue
|
||||
blocks.append(block)
|
||||
records.append(
|
||||
make_record(match.rel_path, sha256_hex, "full", clock, complete)
|
||||
)
|
||||
|
||||
if index_lines:
|
||||
# The index block is budget-bounded too: lines that do not fit collapse
|
||||
# into one summary line (count + how to list them via pull mode) so the
|
||||
# ceiling is honored without silently dropping a governing spec.
|
||||
chosen: list[str] = []
|
||||
dropped = 0
|
||||
for line in index_lines:
|
||||
if fits(_index_block([*chosen, line])):
|
||||
chosen.append(line)
|
||||
else:
|
||||
dropped += 1
|
||||
if dropped:
|
||||
# Contract amendment 3: the summary must actually be reachable.
|
||||
# Greedy packing rarely leaves a summary-sized gap, so pop chosen
|
||||
# lines (re-counting them as dropped) until the summary fits —
|
||||
# only an absurdly small total budget can drop it entirely.
|
||||
while True:
|
||||
noun = "spec" if dropped == 1 else "specs"
|
||||
summary = (
|
||||
f"- (+{dropped} more governing {noun} over budget — run "
|
||||
f"python3 ./.trellis/scripts/get_context.py --mode spec "
|
||||
f"--file {edited_rel} to list them)"
|
||||
)
|
||||
if fits(_index_block([*chosen, summary])):
|
||||
chosen.append(summary)
|
||||
break
|
||||
if not chosen:
|
||||
_warn(
|
||||
f"spec index summary for {edited_rel} dropped — "
|
||||
f"per-event budget exhausted"
|
||||
)
|
||||
break
|
||||
chosen.pop()
|
||||
dropped += 1
|
||||
if chosen:
|
||||
blocks.append(_index_block(chosen))
|
||||
|
||||
for matching_file, spec_rel, sha256_hex in ticket_pending:
|
||||
ticket = render_ticket(
|
||||
matching_file, spec_rel, sha256_hex[:12], stateless
|
||||
)
|
||||
if not fits(ticket):
|
||||
_warn(f"ticket for {spec_rel} dropped — per-event budget exhausted")
|
||||
continue
|
||||
blocks.append(ticket)
|
||||
records.append(make_record(spec_rel, sha256_hex, "ticket", clock))
|
||||
|
||||
return "\n\n".join(blocks), records
|
||||
Executable
+395
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Path-scoped spec matching for on-demand spec injection.
|
||||
|
||||
Spec files under `.trellis/spec/**/*.md` MAY start with a YAML-like
|
||||
frontmatter block declaring which repo paths they govern:
|
||||
|
||||
---
|
||||
name: commands-workflow
|
||||
description: workflow command conventions
|
||||
paths:
|
||||
- packages/cli/src/commands/workflow.ts
|
||||
- packages/cli/src/utils/workflow-resolver.ts
|
||||
---
|
||||
|
||||
The parser is hand-rolled (house pattern, modeled on
|
||||
``trellis_config.parse_simple_yaml`` — no YAML dependency) and reads only a
|
||||
bounded head of each file (16 KiB / 200 lines, whichever ends first). Only
|
||||
files whose first line is exactly ``---`` are considered. ``name:`` /
|
||||
``description:`` single-line strings are recognized (description is reused in
|
||||
index lines). ``paths:`` accepts both a block list (``- <glob>`` items) and a
|
||||
flow sequence (``paths: [a, b]``).
|
||||
|
||||
The parser is deliberately tolerant — a spec is prose that happens to carry a
|
||||
routing hint, not a config file. Unknown keys, unrecognized line shapes and
|
||||
stray ``- item`` lines are ignored; block scalars (``key: >`` / ``key: |``)
|
||||
consume their more-indented continuation lines, so a SKILL.md-style
|
||||
``description: >`` paragraph does not disqualify the file. An opening ``---``
|
||||
with no recognized key before the closing marker is not frontmatter at all
|
||||
(a Markdown horizontal rule opening the prose) and is ignored silently. Two
|
||||
things are errors, and both warn + skip the whole file rather than route on a
|
||||
half-read block: a malformed ``paths:`` (a scalar where a list belongs — that
|
||||
key is the one thing the rest of the pipeline depends on), and a frontmatter
|
||||
block that is still open when the head bound is reached.
|
||||
|
||||
Glob grammar (repo-relative, POSIX separators):
|
||||
|
||||
- ``*`` matches within a single path segment (never crosses ``/``)
|
||||
- ``?`` matches exactly one character within a segment
|
||||
- ``**`` as a whole segment matches zero or more segments
|
||||
- a trailing ``/`` is sugar for ``/**``
|
||||
- ``**`` embedded in a segment with other characters degrades to ``*``
|
||||
|
||||
Validation rejects only what is unsafe or meaningless: empty globs, a leading
|
||||
``/`` (globs are repo-relative), ``..`` segments, backslashes (POSIX
|
||||
separators only) and control characters. Everything else is legal — real
|
||||
repositories carry ``@scope`` packages, ``[slug]`` routes, ``(marketing)``
|
||||
groups and non-ASCII directories, and the translation escapes literals
|
||||
character by character. An invalid glob is skipped with a stderr warning; the
|
||||
rest of the file's globs still apply.
|
||||
|
||||
Translation examples (glob → matches / non-matches):
|
||||
|
||||
packages/cli/src/commands/update.ts
|
||||
matches only that exact file
|
||||
packages/cli/src/commands/*.ts
|
||||
matches packages/cli/src/commands/update.ts
|
||||
not packages/cli/src/commands/channel/spawn.ts
|
||||
packages/cli/src/templates/**
|
||||
matches packages/cli/src/templates/trellis/index.ts (any depth)
|
||||
not packages/cli/src/templates (the directory itself)
|
||||
packages/**/index.ts
|
||||
matches packages/index.ts and packages/cli/src/index.ts
|
||||
src/util?.py
|
||||
matches src/utils.py, not src/util.py or src/utilXY.py
|
||||
packages/cli/
|
||||
same as packages/cli/**
|
||||
|
||||
Provides:
|
||||
SpecMatch - frozen match record (spec_path, rel_path, description)
|
||||
match_specs_for_file - map an edited file to the specs that govern it
|
||||
normalize_repo_relative - the canonical repo-relative path normalization
|
||||
parse_spec_frontmatter - parse the optional frontmatter head block
|
||||
glob_to_regex - deterministic glob → compiled regex translation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import DIR_SPEC, DIR_WORKFLOW
|
||||
from .trellis_config import _strip_inline_comment, _unquote
|
||||
|
||||
# Bounded head-read limits for frontmatter scanning (design contract).
|
||||
HEAD_MAX_BYTES = 16384
|
||||
HEAD_MAX_LINES = 200
|
||||
|
||||
# Recognized frontmatter keys. An opening `---` block that declares none of
|
||||
# them is prose under a horizontal rule, not frontmatter.
|
||||
_KNOWN_KEYS = ("paths", "name", "description")
|
||||
|
||||
_GLOB_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(.*)$")
|
||||
# YAML block-scalar introducers; the value lives in the indented lines below.
|
||||
_BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+")
|
||||
|
||||
# macOS and Windows filesystems are case-insensitive: the very same file can
|
||||
# be handed to us in a case the glob author never wrote. Match case-insensitively
|
||||
# there — over-injecting a spec is the safe side of the asymmetry.
|
||||
_CASE_INSENSITIVE_FS = sys.platform == "darwin" or sys.platform.startswith("win")
|
||||
_GLOB_FLAGS = re.IGNORECASE if _CASE_INSENSITIVE_FS else 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecFrontmatter:
|
||||
"""Parsed frontmatter head. ``paths`` is None when the key is absent."""
|
||||
|
||||
paths: tuple[str, ...] | None
|
||||
name: str | None
|
||||
description: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecMatch:
|
||||
spec_path: Path
|
||||
"""Absolute path to the spec file."""
|
||||
rel_path: str
|
||||
"""Repo-relative POSIX path, for display."""
|
||||
description: str | None
|
||||
"""Frontmatter ``description:`` value, if declared."""
|
||||
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
print(f"[WARN] spec_match: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _parse_flow_sequence(value: str) -> list[str]:
|
||||
"""Split a YAML flow sequence body (``[a, b]``) into unquoted items.
|
||||
|
||||
Commas separate; each item is trimmed and unquoted. Empty items (a
|
||||
trailing comma, ``[]``) collapse away.
|
||||
"""
|
||||
inner = value[1:-1]
|
||||
items = (_unquote(part.strip()).strip() for part in inner.split(","))
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def _read_head(path: Path) -> str:
|
||||
"""Read at most HEAD_MAX_BYTES from the file, decoded as UTF-8."""
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(HEAD_MAX_BYTES)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def parse_spec_frontmatter(head_text: str) -> SpecFrontmatter | None:
|
||||
"""Parse the optional frontmatter block from a spec file's head.
|
||||
|
||||
Returns None when the file has no frontmatter: either the first line is not
|
||||
``---``, or the block declares no recognized key before its closing marker
|
||||
(a horizontal rule opening a prose file — silent, not an error).
|
||||
|
||||
Raises ValueError on a malformed ``paths:`` key (a scalar where a list
|
||||
belongs) and on a block that is still open when the head bound
|
||||
(HEAD_MAX_LINES / HEAD_MAX_BYTES) is reached — routing on a half-read
|
||||
frontmatter would be worse than skipping the file loudly. Every other line
|
||||
shape is tolerated and ignored.
|
||||
"""
|
||||
lines = head_text.splitlines()[:HEAD_MAX_LINES]
|
||||
if not lines:
|
||||
return None
|
||||
first = lines[0].lstrip("\ufeff") # tolerate a UTF-8 BOM
|
||||
if first != "---":
|
||||
return None
|
||||
|
||||
paths: list[str] | None = None
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
pending_key: str | None = None
|
||||
block_indent: int | None = None
|
||||
saw_known_key = False
|
||||
closed = False
|
||||
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
if block_indent is not None:
|
||||
# Inside a block scalar: everything more indented (and blank lines)
|
||||
# is its value. A dedent ends the block; that line still counts.
|
||||
if not stripped or indent > block_indent:
|
||||
continue
|
||||
block_indent = None
|
||||
|
||||
if stripped == "---":
|
||||
closed = True
|
||||
break
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
if stripped == "-" or stripped.startswith("- "):
|
||||
if pending_key == "paths" and paths is not None:
|
||||
item = _unquote(_strip_inline_comment(stripped[1:].strip()).strip())
|
||||
paths.append(item)
|
||||
# List items outside `paths:` are tolerated and ignored.
|
||||
continue
|
||||
|
||||
key_match = _KEY_RE.match(stripped)
|
||||
if key_match is None:
|
||||
continue # Unrecognized line shape — tolerated and ignored.
|
||||
|
||||
key = key_match.group(1)
|
||||
saw_known_key = saw_known_key or key in _KNOWN_KEYS
|
||||
raw_value = key_match.group(2).strip()
|
||||
if raw_value in _BLOCK_SCALARS:
|
||||
if key == "paths":
|
||||
raise ValueError("'paths' must be a list of globs")
|
||||
pending_key = None
|
||||
block_indent = indent
|
||||
continue
|
||||
|
||||
value = _unquote(_strip_inline_comment(raw_value).strip())
|
||||
if value:
|
||||
pending_key = None
|
||||
if key == "paths":
|
||||
if not (value.startswith("[") and value.endswith("]")):
|
||||
raise ValueError("'paths' must be a list of globs")
|
||||
paths = _parse_flow_sequence(value)
|
||||
elif key == "name":
|
||||
name = value
|
||||
elif key == "description":
|
||||
description = value
|
||||
# Unknown scalar keys are tolerated and ignored.
|
||||
else:
|
||||
pending_key = key
|
||||
if key == "paths":
|
||||
paths = []
|
||||
|
||||
if not saw_known_key:
|
||||
# An opening `---` with no recognized key is a horizontal rule, not a
|
||||
# frontmatter block. Silent by design: prose files are not malformed.
|
||||
return None
|
||||
if not closed:
|
||||
raise ValueError(
|
||||
f"frontmatter block never closed within the head bound "
|
||||
f"({HEAD_MAX_BYTES} bytes / {HEAD_MAX_LINES} lines)"
|
||||
)
|
||||
|
||||
return SpecFrontmatter(
|
||||
paths=tuple(paths) if paths is not None else None,
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def validate_glob(glob: str) -> str | None:
|
||||
"""Return an error message for an invalid glob, or None when valid.
|
||||
|
||||
Deny-list, not allow-list: only what is unsafe or meaningless is rejected
|
||||
(see module docstring). Everything else — ``@scope``, ``[slug]``,
|
||||
``(marketing)``, non-ASCII directory names — is a legal path in a real
|
||||
repository and translates fine.
|
||||
"""
|
||||
if not glob:
|
||||
return "empty glob"
|
||||
if glob.startswith("/"):
|
||||
return "absolute paths are not allowed (globs are repo-relative)"
|
||||
if ".." in glob.split("/"):
|
||||
return "'..' segments are not allowed"
|
||||
if "\\" in glob:
|
||||
return "backslashes are not allowed (globs use POSIX '/' separators)"
|
||||
if _GLOB_CONTROL_RE.search(glob):
|
||||
return "contains control characters"
|
||||
return None
|
||||
|
||||
|
||||
def glob_to_regex(glob: str) -> re.Pattern[str]:
|
||||
"""Translate a validated glob to a compiled full-match regex.
|
||||
|
||||
Deterministic, segment-based translation (see module docstring for the
|
||||
grammar and examples): ``**`` as a whole segment spans zero or more
|
||||
segments; ``*`` becomes ``[^/]*``; ``?`` becomes ``[^/]``; everything
|
||||
else is escaped literally. A trailing ``/`` is expanded to ``/**`` first.
|
||||
On case-insensitive filesystems (macOS, Windows) the pattern compiles with
|
||||
``re.IGNORECASE`` — see ``_CASE_INSENSITIVE_FS``.
|
||||
"""
|
||||
if glob.endswith("/"):
|
||||
glob += "**"
|
||||
segments = glob.split("/")
|
||||
parts: list[str] = []
|
||||
for i, seg in enumerate(segments):
|
||||
is_last = i == len(segments) - 1
|
||||
if seg == "**":
|
||||
# Last: consume the rest of the path (at least the separator
|
||||
# boundary is already emitted by the previous segment). Not last:
|
||||
# zero or more whole segments including their separators.
|
||||
parts.append(".*" if is_last else r"(?:[^/]+/)*")
|
||||
continue
|
||||
piece = "".join(
|
||||
"[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch)
|
||||
for ch in seg
|
||||
)
|
||||
parts.append(piece if is_last else piece + "/")
|
||||
return re.compile("^" + "".join(parts) + "$", _GLOB_FLAGS)
|
||||
|
||||
|
||||
def normalize_repo_relative(repo_root: Path, file_path: str | Path) -> str | None:
|
||||
"""Canonical repo-relative POSIX path — the one normalization in the
|
||||
pipeline, used both for matching and for display.
|
||||
|
||||
Root and file are fully resolved (``strict=False``, so a file that no
|
||||
longer exists still normalizes): symlinked repo roots, macOS's
|
||||
``/tmp`` → ``/private/tmp`` and ``..`` segments cannot make one file look
|
||||
like two different paths. The result is NFC-normalized (macOS hands out
|
||||
NFD filenames). Relative inputs are taken as repo-relative. Returns None
|
||||
when the file resolves outside the repo.
|
||||
"""
|
||||
try:
|
||||
root = Path(repo_root).resolve(strict=False)
|
||||
candidate = Path(file_path)
|
||||
if not candidate.is_absolute():
|
||||
text = str(file_path).replace("\\", "/")
|
||||
while text.startswith("./"):
|
||||
text = text[2:]
|
||||
if not text:
|
||||
return None
|
||||
candidate = root / text
|
||||
rel = candidate.resolve(strict=False).relative_to(root).as_posix()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return unicodedata.normalize("NFC", rel)
|
||||
|
||||
|
||||
def match_specs_for_file(repo_root: Path, file_path: str | Path) -> list[SpecMatch]:
|
||||
"""Return specs whose frontmatter ``paths:`` globs match file_path.
|
||||
|
||||
``file_path`` may be absolute or repo-relative. More specific matching
|
||||
globs are returned first; ``rel_path`` is the deterministic tie-break.
|
||||
Scans ``.trellis/spec/**/*.md`` with bounded head-reads only. Never raises;
|
||||
unreadable or malformed spec files are skipped with a stderr warning.
|
||||
"""
|
||||
try:
|
||||
repo_root = Path(repo_root).resolve()
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
if not spec_dir.is_dir():
|
||||
return []
|
||||
rel = normalize_repo_relative(repo_root, file_path)
|
||||
if rel is None:
|
||||
return []
|
||||
|
||||
matches: list[SpecMatch] = []
|
||||
specificity: dict[str, tuple[int, int, int, int]] = {}
|
||||
for spec_file in spec_dir.rglob("*.md"):
|
||||
spec_rel = spec_file.relative_to(repo_root).as_posix()
|
||||
try:
|
||||
head = _read_head(spec_file)
|
||||
except OSError as exc:
|
||||
_warn(f"cannot read {spec_rel}: {exc}")
|
||||
continue
|
||||
try:
|
||||
frontmatter = parse_spec_frontmatter(head)
|
||||
except ValueError as exc:
|
||||
_warn(f"malformed frontmatter in {spec_rel}: {exc}")
|
||||
continue
|
||||
if frontmatter is None or not frontmatter.paths:
|
||||
continue
|
||||
for glob in frontmatter.paths:
|
||||
error = validate_glob(glob)
|
||||
if error is not None:
|
||||
_warn(f"invalid glob {glob!r} in {spec_rel}: {error}")
|
||||
continue
|
||||
if glob_to_regex(glob).match(rel):
|
||||
scored_glob = glob + "**" if glob.endswith("/") else glob
|
||||
wildcard_count = scored_glob.count("*") + scored_glob.count("?")
|
||||
segments = scored_glob.split("/")
|
||||
literal_segments = sum(
|
||||
"*" not in segment and "?" not in segment
|
||||
for segment in segments
|
||||
)
|
||||
specificity[spec_rel] = (
|
||||
0 if wildcard_count == 0 else 1,
|
||||
-literal_segments,
|
||||
wildcard_count,
|
||||
-(len(scored_glob) - wildcard_count),
|
||||
)
|
||||
matches.append(
|
||||
SpecMatch(
|
||||
spec_path=spec_file,
|
||||
rel_path=spec_rel,
|
||||
description=frontmatter.description,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
# Payload assembly spends its budget in this order. Exact and narrowly
|
||||
# scoped matches must therefore outrank broad tree globs; alphabetic
|
||||
# order is only a deterministic tie-break.
|
||||
matches.sort(key=lambda m: (*specificity[m.rel_path], m.rel_path))
|
||||
return matches
|
||||
except Exception as exc: # Never raise — callers are hooks/context tools.
|
||||
_warn(f"spec scan failed: {exc}")
|
||||
return []
|
||||
@@ -25,7 +25,7 @@ from .config import get_context_injection_limits
|
||||
from .git import branch_exists_locally
|
||||
from .io import read_json
|
||||
from .log import Colors, colored
|
||||
from .paths import FILE_TASK_JSON, get_repo_root
|
||||
from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
|
||||
from .task_utils import resolve_task_dir
|
||||
|
||||
# Extensions that look like code rather than spec/research docs. Entries with
|
||||
@@ -170,6 +170,59 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_context_entry_path(
|
||||
file_path: str, repo_root: Path, task_dir: Path | None
|
||||
) -> Path | None:
|
||||
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
|
||||
|
||||
Exact historical self-references are remapped only for archived tasks.
|
||||
``None`` means the remapped path traversed or resolved outside that archive.
|
||||
"""
|
||||
repo_path = repo_root / file_path
|
||||
if task_dir is None:
|
||||
return repo_path
|
||||
|
||||
try:
|
||||
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
|
||||
except ValueError:
|
||||
return repo_path
|
||||
|
||||
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
|
||||
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
|
||||
return repo_path
|
||||
|
||||
year_month = task_parts[3]
|
||||
if (
|
||||
len(year_month) != 7
|
||||
or year_month[4] != "-"
|
||||
or not year_month[:4].isdigit()
|
||||
or not year_month[5:].isdigit()
|
||||
):
|
||||
return repo_path
|
||||
|
||||
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
|
||||
posix_path = file_path.replace("\\", "/")
|
||||
if posix_path == historical_root:
|
||||
relative_parts: tuple[str, ...] = ()
|
||||
elif posix_path.startswith(f"{historical_root}/"):
|
||||
relative_path = posix_path[len(historical_root) + 1 :]
|
||||
if relative_path.endswith("/"):
|
||||
relative_path = relative_path[:-1]
|
||||
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
|
||||
if any(part in ("", ".", "..") for part in relative_parts):
|
||||
return None
|
||||
else:
|
||||
return repo_path
|
||||
|
||||
try:
|
||||
archive_root = task_dir.resolve()
|
||||
resolved_path = task_dir.joinpath(*relative_parts).resolve()
|
||||
resolved_path.relative_to(archive_root)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return resolved_path
|
||||
|
||||
|
||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
|
||||
"""Validate a single JSONL file.
|
||||
|
||||
@@ -220,14 +273,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
||||
continue
|
||||
|
||||
real_entries += 1
|
||||
full_path = repo_root / file_path
|
||||
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
|
||||
if entry_type == "directory":
|
||||
if not full_path.is_dir():
|
||||
if full_path is None or not full_path.is_dir():
|
||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not full_path.is_file():
|
||||
if full_path is None or not full_path.is_file():
|
||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
@@ -56,6 +56,7 @@ from .task_utils import (
|
||||
resolve_task_dir,
|
||||
run_task_hooks,
|
||||
)
|
||||
from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -254,6 +255,31 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
# Inferred: default_package → None (no task.json yet for create)
|
||||
package = resolve_package(repo_root=repo_root)
|
||||
|
||||
# Validate --workflow (CLI source: fail-fast on invalid id; a missing
|
||||
# library file only warns — it may be saved later via `trellis workflow --save`)
|
||||
workflow_id: str | None = getattr(args, "workflow", None)
|
||||
if workflow_id:
|
||||
if not WORKFLOW_ID_RE.match(workflow_id):
|
||||
print(
|
||||
colored(
|
||||
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
workflow_md = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
|
||||
if not workflow_md.is_file():
|
||||
print(
|
||||
colored(
|
||||
f"Warning: {DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md does not exist yet; "
|
||||
"default workflow resolution is used until it is saved "
|
||||
"(trellis workflow --save).",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Default assignee to current developer
|
||||
assignee = args.assignee
|
||||
if not assignee:
|
||||
@@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
"notes": "",
|
||||
"meta": meta,
|
||||
}
|
||||
# Optional per-task workflow selection: key present only when opted in,
|
||||
# so tasks without a selection keep today's task.json shape byte-for-byte.
|
||||
if workflow_id:
|
||||
task_data["workflow"] = workflow_id
|
||||
|
||||
write_json(task_json_path, task_data)
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
from . import workflow_selection
|
||||
from .paths import get_repo_root
|
||||
|
||||
|
||||
def _workflow_md_path():
|
||||
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
||||
return workflow_selection.resolve_workflow_md(get_repo_root())
|
||||
|
||||
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Per-task workflow selection.
|
||||
|
||||
Resolves which workflow markdown file consumers should read. A task may pin
|
||||
a workflow variant by storing `"workflow": "<id>"` in its task.json; the
|
||||
variant body lives at `.trellis/workflows/<id>.md` (user-managed library).
|
||||
|
||||
Resolution precedence (single source of truth for all consumers), highest
|
||||
to lowest — each layer resolves an id to `.trellis/workflows/<id>.md` and
|
||||
falls through when unset, invalid, or pointing at a missing file:
|
||||
1. Per-task pin - active task's task.json `workflow` (session-bound,
|
||||
explicit; a bad id or missing file warns once on stderr, then falls
|
||||
through rather than aborting).
|
||||
2. Personal - `.developer` `workflow=<id>` (gitignored, per-developer;
|
||||
outranks the team default). Silent on miss.
|
||||
3. Team default - config.yaml `default_workflow` (git-tracked, shared).
|
||||
Silent on miss.
|
||||
4. Global - `.trellis/workflow.md`.
|
||||
With neither a per-task pin nor the personal/team keys set, this is identical
|
||||
to reading the global `.trellis/workflow.md`. Never raises.
|
||||
|
||||
Provides:
|
||||
workflow_md_for_task - Full precedence for an already-resolved task dir
|
||||
resolve_workflow_md - Session-aware wrapper via the active task resolver
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import DIR_WORKFLOW, FILE_TASK_JSON
|
||||
|
||||
# Workflow variant library directory under .trellis/ (plural on purpose:
|
||||
# `.trellis/workflow/` is reserved by the YAML-manifest migration).
|
||||
DIR_WORKFLOWS = "workflows"
|
||||
|
||||
# Workflow ids must be plain slugs; anything else (path separators, dots)
|
||||
# is rejected so a task.json value can never escape .trellis/workflows/.
|
||||
WORKFLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
def _global_workflow_md(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / "workflow.md"
|
||||
|
||||
|
||||
def _library_variant(repo_root: Path, workflow_id: str | None) -> Path | None:
|
||||
"""Map a workflow id to its library file if valid and present, else None.
|
||||
|
||||
Shared by every layer (per-task pin, personal, team). An id with path
|
||||
separators/dots/blanks, or one whose `.trellis/workflows/<id>.md` file does
|
||||
not exist, returns None so the caller falls through. Never raises.
|
||||
"""
|
||||
if not isinstance(workflow_id, str) or not workflow_id:
|
||||
return None
|
||||
if not WORKFLOW_ID_RE.match(workflow_id):
|
||||
return None
|
||||
variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
|
||||
return variant if variant.is_file() else None
|
||||
|
||||
|
||||
def _developer_workflow_id(repo_root: Path) -> str | None:
|
||||
"""Personal override id from the gitignored .developer file (fail-open)."""
|
||||
try:
|
||||
from .paths import get_developer_workflow
|
||||
|
||||
return get_developer_workflow(repo_root)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _config_default_id(repo_root: Path) -> str | None:
|
||||
"""Team-shared default id from config.yaml `default_workflow` (fail-open)."""
|
||||
try:
|
||||
from .config import get_default_workflow
|
||||
|
||||
return get_default_workflow(repo_root)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _default_workflow_md(repo_root: Path) -> Path:
|
||||
"""Resolve the non-per-task default: personal -> team -> global.
|
||||
|
||||
Personal (`.developer` `workflow=`) outranks the team-shared
|
||||
(config.yaml `default_workflow`) layer; both fall through to the global
|
||||
`.trellis/workflow.md` when unset, invalid, or naming a missing file. These
|
||||
layers are silent on miss (they are defaults, not an explicit per-task
|
||||
choice — a per-turn warning would be noise).
|
||||
"""
|
||||
for get_id in (_developer_workflow_id, _config_default_id):
|
||||
variant = _library_variant(repo_root, get_id(repo_root))
|
||||
if variant is not None:
|
||||
return variant
|
||||
return _global_workflow_md(repo_root)
|
||||
|
||||
|
||||
def _task_pin_variant(repo_root: Path, task_dir: Path | None) -> Path | None:
|
||||
"""Return the per-task pinned variant path, or None to fall through.
|
||||
|
||||
Emits a stderr warning on an invalid id or a missing variant file (an
|
||||
explicit per-task choice that cannot be honored), then returns None so
|
||||
resolution continues with the personal/team defaults. Never raises.
|
||||
"""
|
||||
if task_dir is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
workflow_id = raw.get("workflow")
|
||||
if not isinstance(workflow_id, str) or not workflow_id:
|
||||
return None
|
||||
|
||||
if not WORKFLOW_ID_RE.match(workflow_id):
|
||||
print(
|
||||
f"Warning: task '{task_dir.name}' has invalid workflow id "
|
||||
f"{workflow_id!r}; using default workflow resolution",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
variant = _library_variant(repo_root, workflow_id)
|
||||
if variant is not None:
|
||||
return variant
|
||||
|
||||
print(
|
||||
f"Warning: task '{task_dir.name}' selects workflow '{workflow_id}' but "
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md is missing; "
|
||||
f"using default workflow resolution",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path:
|
||||
"""Return the workflow.md path for an already-resolved task dir (or None).
|
||||
|
||||
Applies the full precedence documented in the module docstring:
|
||||
per-task pin -> personal (.developer) -> team (config.yaml) -> global.
|
||||
Never raises; any failure falls through toward the global workflow path.
|
||||
"""
|
||||
pin = _task_pin_variant(repo_root, task_dir)
|
||||
if pin is not None:
|
||||
return pin
|
||||
return _default_workflow_md(repo_root)
|
||||
|
||||
|
||||
def resolve_workflow_md(
|
||||
repo_root: Path,
|
||||
input_data: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> Path:
|
||||
"""Resolve the session-aware active task, then apply the resolution rule.
|
||||
|
||||
``input_data`` is the raw hook payload (session/conversation identity);
|
||||
CLI callers may omit it — the active-task resolver then falls back to
|
||||
environment context. Never raises; any failure resolves to the global
|
||||
`.trellis/workflow.md`.
|
||||
"""
|
||||
try:
|
||||
from .active_task import resolve_active_task, resolve_task_ref
|
||||
|
||||
active = resolve_active_task(repo_root, input_data, platform)
|
||||
task_dir: Path | None = None
|
||||
if active.task_path:
|
||||
task_dir = resolve_task_ref(active.task_path, repo_root)
|
||||
return workflow_md_for_task(repo_root, task_dir)
|
||||
except Exception:
|
||||
return _global_workflow_md(repo_root)
|
||||
@@ -11,6 +11,7 @@ Usage:
|
||||
python3 task.py start <dir> # Set active task
|
||||
python3 task.py current [--source] [--json] # Show active task
|
||||
python3 task.py finish # Clear active task
|
||||
python3 task.py workflow <id>|--clear # Set/clear per-task workflow selection
|
||||
python3 task.py set-branch <dir> <branch> # Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
||||
@@ -47,6 +48,7 @@ from common.active_task import (
|
||||
from common.io import read_json, write_json
|
||||
from common.task_utils import resolve_task_dir, run_task_hooks
|
||||
from common.tasks import iter_active_tasks, children_progress
|
||||
from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task
|
||||
|
||||
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
||||
from common.task_store import (
|
||||
@@ -204,6 +206,72 @@ def cmd_current(args: argparse.Namespace) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: workflow
|
||||
# =============================================================================
|
||||
|
||||
def cmd_workflow(args: argparse.Namespace) -> int:
|
||||
"""Set or clear the workflow selection on the current session's active task."""
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if args.clear and args.id:
|
||||
print(colored("Error: pass either <id> or --clear, not both", Colors.RED))
|
||||
return 1
|
||||
if not args.clear and not args.id:
|
||||
print(colored("Error: workflow id required (or --clear)", Colors.RED))
|
||||
print("Usage: python3 task.py workflow <id> | --clear")
|
||||
return 1
|
||||
|
||||
active = resolve_active_task(repo_root)
|
||||
if not active.task_path:
|
||||
print(colored("Error: No current task set", Colors.RED))
|
||||
print("Hint: run task.py start <dir> first")
|
||||
return 1
|
||||
|
||||
task_dir = repo_root / active.task_path
|
||||
task_json_path = task_dir / FILE_TASK_JSON
|
||||
if not task_json_path.is_file():
|
||||
print(colored(f"Error: task.json not found at {task_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json_path)
|
||||
if not data:
|
||||
print(colored(f"Error: failed to read {task_json_path}", Colors.RED))
|
||||
return 1
|
||||
|
||||
if args.clear:
|
||||
if data.pop("workflow", None) is None:
|
||||
print(colored("No workflow selection set on this task", Colors.YELLOW))
|
||||
else:
|
||||
if not write_json(task_json_path, data):
|
||||
print(colored("Error: failed to update task.json", Colors.RED))
|
||||
return 1
|
||||
print(colored("✓ Workflow selection cleared", Colors.GREEN))
|
||||
else:
|
||||
workflow_id = args.id
|
||||
if not WORKFLOW_ID_RE.match(workflow_id):
|
||||
print(colored(
|
||||
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
|
||||
Colors.RED,
|
||||
))
|
||||
return 1
|
||||
data["workflow"] = workflow_id
|
||||
if not write_json(task_json_path, data):
|
||||
print(colored("Error: failed to update task.json", Colors.RED))
|
||||
return 1
|
||||
print(colored(f"✓ Workflow set to: {workflow_id}", Colors.GREEN))
|
||||
|
||||
# workflow_md_for_task warns on stderr itself when the selected variant
|
||||
# file is missing (it can be saved later via `trellis workflow --save`).
|
||||
effective = workflow_md_for_task(repo_root, task_dir)
|
||||
try:
|
||||
effective_display = effective.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
effective_display = str(effective)
|
||||
print(f"Effective workflow: {effective_display}")
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list
|
||||
# =============================================================================
|
||||
@@ -382,12 +450,15 @@ Usage:
|
||||
python3 task.py create <title> --package <pkg> Create task for a specific package
|
||||
python3 task.py create <title> --parent <dir> Create task as child of parent
|
||||
python3 task.py create <title> --no-start Create without making it active in this session
|
||||
python3 task.py create <title> --workflow <id> Create task pinned to a workflow variant
|
||||
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||||
python3 task.py validate <dir> Validate jsonl files
|
||||
python3 task.py list-context <dir> List jsonl entries
|
||||
python3 task.py start <dir> Set active task
|
||||
python3 task.py current [--source] Show active task
|
||||
python3 task.py finish Clear active task
|
||||
python3 task.py workflow <id> Select workflow variant for active task
|
||||
python3 task.py workflow --clear Clear selection (use default resolution)
|
||||
python3 task.py set-branch <dir> <branch> Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||||
@@ -490,6 +561,10 @@ def main() -> int:
|
||||
action="store_true",
|
||||
help="Create the task without making it active in this session",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"--workflow",
|
||||
help="Workflow variant id for this task (.trellis/workflows/<id>.md)",
|
||||
)
|
||||
|
||||
# add-context
|
||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||
@@ -520,6 +595,12 @@ def main() -> int:
|
||||
# finish
|
||||
subparsers.add_parser("finish", help="Clear active task")
|
||||
|
||||
# workflow
|
||||
p_workflow = subparsers.add_parser("workflow", help="Set/clear per-task workflow selection")
|
||||
p_workflow.add_argument("id", nargs="?", help="Workflow id (.trellis/workflows/<id>.md)")
|
||||
p_workflow.add_argument("--clear", action="store_true",
|
||||
help="Remove the workflow selection (use default resolution)")
|
||||
|
||||
# set-branch
|
||||
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||||
p_branch.add_argument("dir", help="Task directory")
|
||||
@@ -580,6 +661,7 @@ def main() -> int:
|
||||
"start": cmd_start,
|
||||
"current": cmd_current,
|
||||
"finish": cmd_finish,
|
||||
"workflow": cmd_workflow,
|
||||
"set-branch": cmd_set_branch,
|
||||
"set-base-branch": cmd_set_base_branch,
|
||||
"set-scope": cmd_set_scope,
|
||||
|
||||
Reference in New Issue
Block a user