Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions .claude/hooks/inject-workflow-state.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context).

Silent exit 0 cases (no output):
Silent exit 0 case (no output):
- No .trellis/ directory found (not a Trellis project)
- task.json malformed or missing status

When a session points at a task directory whose task.json is missing, malformed,
or missing a usable status, the hook emits a task_error breadcrumb instead of
misreporting the session as having no active task.
"""
from __future__ import annotations

Expand Down Expand Up @@ -155,8 +158,16 @@ def _resolve_active_task(root: Path, input_data: dict):
return resolve_active_task(root, input_data, platform=_detect_platform(input_data))


def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]:
"""Return (task_id, status, source) from the current active task."""
def get_active_task(
root: Path, input_data: dict
) -> tuple[str, str, str] | None:
"""Return active task data, a task-record error, or no task pointer.

``(task_id, "task_error", source)`` is distinct from ``None``: a session
pointer can exist even when its task record is missing or unreadable, and
that state needs a diagnostic breadcrumb rather than the normal ``no_task``
prompt.
"""
active = _resolve_active_task(root, input_data)
if not active.task_path:
return None
Expand All @@ -169,16 +180,18 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st

task_json = task_dir / "task.json"
if not task_json.is_file():
return None
return task_dir.name, "task_error", active.source
try:
data = json.loads(task_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
return task_dir.name, "task_error", active.source
if not isinstance(data, dict):
return task_dir.name, "task_error", active.source

task_id = data.get("id") or task_dir.name
status = data.get("status", "")
if not isinstance(status, str) or not status:
return None
return task_dir.name, "task_error", active.source
return task_id, status, active.source


Expand Down
27 changes: 20 additions & 7 deletions .codex/hooks/inject-workflow-state.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context).

Silent exit 0 cases (no output):
Silent exit 0 case (no output):
- No .trellis/ directory found (not a Trellis project)
- task.json malformed or missing status

When a session points at a task directory whose task.json is missing, malformed,
or missing a usable status, the hook emits a task_error breadcrumb instead of
misreporting the session as having no active task.
"""
from __future__ import annotations

Expand Down Expand Up @@ -155,8 +158,16 @@ def _resolve_active_task(root: Path, input_data: dict):
return resolve_active_task(root, input_data, platform=_detect_platform(input_data))


def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]:
"""Return (task_id, status, source) from the current active task."""
def get_active_task(
root: Path, input_data: dict
) -> tuple[str, str, str] | None:
"""Return active task data, a task-record error, or no task pointer.

``(task_id, "task_error", source)`` is distinct from ``None``: a session
pointer can exist even when its task record is missing or unreadable, and
that state needs a diagnostic breadcrumb rather than the normal ``no_task``
prompt.
"""
active = _resolve_active_task(root, input_data)
if not active.task_path:
return None
Expand All @@ -169,16 +180,18 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st

task_json = task_dir / "task.json"
if not task_json.is_file():
return None
return task_dir.name, "task_error", active.source
try:
data = json.loads(task_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
return task_dir.name, "task_error", active.source
if not isinstance(data, dict):
return task_dir.name, "task_error", active.source

task_id = data.get("id") or task_dir.name
status = data.get("status", "")
if not isinstance(status, str) or not status:
return None
return task_dir.name, "task_error", active.source
return task_id, status, active.source


Expand Down
6 changes: 5 additions & 1 deletion .trellis/spec/cli/backend/workflow-state-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ Both regexes MUST use the `\1` backreference variant — `[workflow-state:([A-Za
3. It calls `common.active_task.resolve_active_task()` to look up the
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.
4. Otherwise it reads `task.json.status` from the resolved task directory. If
the task directory exists but `task.json` is missing, malformed, or has no
usable status, the hook emits the `task_error` pseudo-status and keeps the
task directory name in the breadcrumb header.
5. It opens `.trellis/workflow.md` and parses every `[workflow-state:STATUS]`
block.
6. Codex may map `planning` / `in_progress` to `planning-inline` /
Expand Down Expand Up @@ -293,6 +296,7 @@ Which breadcrumbs actually fire in normal flow:
| Status | Reachability | Notes |
|--------|--------------|-------|
| `no_task` | ✅ reachable | Pseudo-status; emitted when `resolve_active_task()` returns no pointer. |
| `task_error` | ✅ reachable | Pseudo-status; emitted when a session task pointer resolves to a directory whose `task.json` cannot be read or has no usable `status`. |
| `planning` | ✅ reachable | After `cmd_create` (which now auto-sets the session pointer when available) and before `cmd_start`. `planning-inline` is the Codex inline-mode breadcrumb body for the same task status. |
| `in_progress` | ✅ reachable | After `cmd_start`, until `cmd_archive`. `in_progress-inline` is the Codex inline-mode breadcrumb body for the same task status. |
| `completed` | ❌ DEAD in normal flow | `cmd_archive` writes `status="completed"` and immediately moves the task dir to `archive/`. The session-pointer cleanup in `clear_task_from_sessions` runs before the move, so the resolver loses the pointer in the same call. The block body in workflow.md is preserved for a future status-transition redesign (e.g. an explicit `in_progress → completed` command) but no current code path produces it. |
Expand Down
2 changes: 1 addition & 1 deletion marketplace
27 changes: 20 additions & 7 deletions packages/cli/src/templates/shared-hooks/inject-workflow-state.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context).

Silent exit 0 cases (no output):
Silent exit 0 case (no output):
- No .trellis/ directory found (not a Trellis project)
- task.json malformed or missing status

When a session points at a task directory whose task.json is missing, malformed,
or missing a usable status, the hook emits a task_error breadcrumb instead of
misreporting the session as having no active task.
"""
from __future__ import annotations

Expand Down Expand Up @@ -155,8 +158,16 @@ def _resolve_active_task(root: Path, input_data: dict):
return resolve_active_task(root, input_data, platform=_detect_platform(input_data))


def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]:
"""Return (task_id, status, source) from the current active task."""
def get_active_task(
root: Path, input_data: dict
) -> tuple[str, str, str] | None:
"""Return active task data, a task-record error, or no task pointer.

``(task_id, "task_error", source)`` is distinct from ``None``: a session
pointer can exist even when its task record is missing or unreadable, and
that state needs a diagnostic breadcrumb rather than the normal ``no_task``
prompt.
"""
active = _resolve_active_task(root, input_data)
if not active.task_path:
return None
Expand All @@ -169,16 +180,18 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st

task_json = task_dir / "task.json"
if not task_json.is_file():
return None
return task_dir.name, "task_error", active.source
try:
data = json.loads(task_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
return task_dir.name, "task_error", active.source
if not isinstance(data, dict):
return task_dir.name, "task_error", active.source

task_id = data.get("id") or task_dir.name
status = data.get("status", "")
if not isinstance(status, str) or not status:
return None
return task_dir.name, "task_error", active.source
return task_id, status, active.source


Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/templates/trellis/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed

TAG ↔ PHASE scoping:
[workflow-state:no_task] → no active task; before Phase 1
[workflow-state:task_error] → active task record is unreadable; repair it before continuing
[workflow-state:planning] → all of Phase 1 (status='planning')
[workflow-state:planning-inline] → Codex inline variant of Phase 1
[workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
Expand Down Expand Up @@ -179,6 +180,14 @@ Simple conversation / small task: ask only whether this turn should create a Tre
Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split.
[/workflow-state:no_task]

<!-- Per-turn breadcrumb: shown when the active task record cannot be read. -->

[workflow-state:task_error]
The active task record could not be read. Do not create or activate another task.
Inspect the task directory named above and repair its task.json. It must be a valid JSON object with a non-empty status.
Preserve existing task fields and artifacts. If the correct status cannot be determined safely, ask the user before reconstructing the record.
[/workflow-state:task_error]

### Phase 1: Plan
- 1.0 Create task `[required · once]` (only after task-creation consent)
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
Expand Down Expand Up @@ -660,6 +669,7 @@ All tag blocks live in the `## Phase Index` section above, immediately after eac
| Scope | Corresponding tag |
|---|---|
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
| Active task record unreadable | `[workflow-state:task_error]` (repair the existing task before continuing) |
| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
| Codex inline Phase 1 | `[workflow-state:planning-inline]` |
| Phase 2 + Phase 3.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/test/regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5468,6 +5468,77 @@ print(json.dumps({
);
});

it("reports task_error when task.json is malformed", () => {
setupTaskRepo();
writeSessionContext("session_workflow-a", ".trellis/tasks/issue-106");
writeWorkflowStateHook();
writeWorkflowMd(
"[workflow-state:no_task]\n" +
"No active task.\n" +
"[/workflow-state:no_task]\n" +
"[workflow-state:task_error]\n" +
"Repair the active task record before continuing.\n" +
"[/workflow-state:task_error]\n",
);
writeProjectFile(
path.join(".trellis", "tasks", "issue-106", "task.json"),
"{not-json\n",
);

const output = runInjectWorkflowState();
const parsed = JSON.parse(output) as {
hookSpecificOutput: { additionalContext: string };
};
const context = parsed.hookSpecificOutput.additionalContext;
expect(context).toContain("Task: issue-106 (task_error)");
expect(context).toContain("Repair the active task record before continuing.");
expect(context).not.toContain("Status: no_task");
});

it("reports task_error when task.json has no usable status", () => {
setupTaskRepo();
writeSessionContext("session_workflow-a", ".trellis/tasks/issue-106");
writeWorkflowStateHook();
writeWorkflowMd(
"[workflow-state:no_task]\nNo active task.\n[/workflow-state:no_task]\n",
);
writeProjectFile(
path.join(".trellis", "tasks", "issue-106", "task.json"),
JSON.stringify({ title: "Missing status" }),
);

const output = runInjectWorkflowState();
const parsed = JSON.parse(output) as {
hookSpecificOutput: { additionalContext: string };
};
const context = parsed.hookSpecificOutput.additionalContext;
expect(context).toContain("Task: issue-106 (task_error)");
expect(context).toContain("Refer to workflow.md for current step.");
expect(context).not.toContain("Status: no_task");
});

it("reports task_error when task.json is not an object", () => {
setupTaskRepo();
writeSessionContext("session_workflow-a", ".trellis/tasks/issue-106");
writeWorkflowStateHook();
writeWorkflowMd(
"[workflow-state:task_error]\nRepair the active task record before continuing.\n[/workflow-state:task_error]\n",
);
writeProjectFile(
path.join(".trellis", "tasks", "issue-106", "task.json"),
"[]",
);

const output = runInjectWorkflowState();
const parsed = JSON.parse(output) as {
hookSpecificOutput: { additionalContext: string };
};
const context = parsed.hookSpecificOutput.additionalContext;
expect(context).toContain("Task: issue-106 (task_error)");
expect(context).toContain("Repair the active task record before continuing.");
expect(context).not.toContain("Status: no_task");
});

it("[#240] Codex workflow-state output starts with codex mode, not generic sub-agent notice", () => {
setupTaskRepo();
writeProjectFile(
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/test/templates/trellis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,13 @@ describe("trellis template constants", () => {
}
});

it("workflow.md ships a task_error breadcrumb that repairs the existing task", () => {
const taskError = workflowStateBreadcrumb("task_error");
expect(taskError).toContain("Do not create or activate another task");
expect(taskError).toContain("repair its task.json");
expect(taskError).toContain("valid JSON object with a non-empty status");
});

it("gitignoreTemplate contains ignore patterns", () => {
expect(gitignoreTemplate).toContain(".developer");
expect(gitignoreTemplate).toContain("__pycache__");
Expand Down
Loading