@pennixrv/trellis 0.7.1 → 0.7.2-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/ablate.d.ts.map +1 -1
- package/dist/commands/ablate.js +2 -0
- package/dist/commands/ablate.js.map +1 -1
- package/dist/commands/backup.d.ts +4 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +75 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +705 -681
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mem.d.ts.map +1 -1
- package/dist/commands/mem.js +49 -7
- package/dist/commands/mem.js.map +1 -1
- package/dist/commands/task-progress.d.ts +1 -0
- package/dist/commands/task-progress.d.ts.map +1 -1
- package/dist/commands/task-progress.js +67 -25
- package/dist/commands/task-progress.js.map +1 -1
- package/dist/commands/teamwork/adapters/acp.d.ts +20 -0
- package/dist/commands/teamwork/adapters/acp.d.ts.map +1 -0
- package/dist/commands/teamwork/adapters/acp.js +268 -0
- package/dist/commands/teamwork/adapters/acp.js.map +1 -0
- package/dist/commands/teamwork/adapters/claude.d.ts +1 -0
- package/dist/commands/teamwork/adapters/claude.d.ts.map +1 -1
- package/dist/commands/teamwork/adapters/claude.js +5 -0
- package/dist/commands/teamwork/adapters/claude.js.map +1 -1
- package/dist/commands/teamwork/adapters/codex.d.ts +3 -2
- package/dist/commands/teamwork/adapters/codex.d.ts.map +1 -1
- package/dist/commands/teamwork/adapters/codex.js +14 -15
- package/dist/commands/teamwork/adapters/codex.js.map +1 -1
- package/dist/commands/teamwork/adapters/index.d.ts +23 -1
- package/dist/commands/teamwork/adapters/index.d.ts.map +1 -1
- package/dist/commands/teamwork/adapters/index.js +163 -24
- package/dist/commands/teamwork/adapters/index.js.map +1 -1
- package/dist/commands/teamwork/agent-loader.d.ts +2 -1
- package/dist/commands/teamwork/agent-loader.d.ts.map +1 -1
- package/dist/commands/teamwork/agent-loader.js +2 -1
- package/dist/commands/teamwork/agent-loader.js.map +1 -1
- package/dist/commands/teamwork/artifact.d.ts +42 -14
- package/dist/commands/teamwork/artifact.d.ts.map +1 -1
- package/dist/commands/teamwork/artifact.js +188 -85
- package/dist/commands/teamwork/artifact.js.map +1 -1
- package/dist/commands/teamwork/batch-reservation.d.ts +16 -0
- package/dist/commands/teamwork/batch-reservation.d.ts.map +1 -0
- package/dist/commands/teamwork/batch-reservation.js +43 -0
- package/dist/commands/teamwork/batch-reservation.js.map +1 -0
- package/dist/commands/teamwork/batch.d.ts +23 -0
- package/dist/commands/teamwork/batch.d.ts.map +1 -0
- package/dist/commands/teamwork/batch.js +415 -0
- package/dist/commands/teamwork/batch.js.map +1 -0
- package/dist/commands/teamwork/context-loader.d.ts +1 -1
- package/dist/commands/teamwork/context-loader.d.ts.map +1 -1
- package/dist/commands/teamwork/context-loader.js +19 -6
- package/dist/commands/teamwork/context-loader.js.map +1 -1
- package/dist/commands/teamwork/coordination.d.ts +88 -0
- package/dist/commands/teamwork/coordination.d.ts.map +1 -0
- package/dist/commands/teamwork/coordination.js +153 -0
- package/dist/commands/teamwork/coordination.js.map +1 -0
- package/dist/commands/teamwork/coordinator.d.ts +33 -6
- package/dist/commands/teamwork/coordinator.d.ts.map +1 -1
- package/dist/commands/teamwork/coordinator.js +948 -342
- package/dist/commands/teamwork/coordinator.js.map +1 -1
- package/dist/commands/teamwork/create.d.ts +1 -0
- package/dist/commands/teamwork/create.d.ts.map +1 -1
- package/dist/commands/teamwork/create.js +2 -1
- package/dist/commands/teamwork/create.js.map +1 -1
- package/dist/commands/teamwork/dev-parse-trace.d.ts +2 -1
- package/dist/commands/teamwork/dev-parse-trace.d.ts.map +1 -1
- package/dist/commands/teamwork/dev-parse-trace.js +2 -0
- package/dist/commands/teamwork/dev-parse-trace.js.map +1 -1
- package/dist/commands/teamwork/foreground-wait.d.ts +6 -0
- package/dist/commands/teamwork/foreground-wait.d.ts.map +1 -0
- package/dist/commands/teamwork/foreground-wait.js +65 -0
- package/dist/commands/teamwork/foreground-wait.js.map +1 -0
- package/dist/commands/teamwork/guard.d.ts +2 -2
- package/dist/commands/teamwork/guard.d.ts.map +1 -1
- package/dist/commands/teamwork/guard.js +25 -7
- package/dist/commands/teamwork/guard.js.map +1 -1
- package/dist/commands/teamwork/index.d.ts.map +1 -1
- package/dist/commands/teamwork/index.js +163 -10
- package/dist/commands/teamwork/index.js.map +1 -1
- package/dist/commands/teamwork/kill.d.ts +2 -0
- package/dist/commands/teamwork/kill.d.ts.map +1 -1
- package/dist/commands/teamwork/kill.js +79 -38
- package/dist/commands/teamwork/kill.js.map +1 -1
- package/dist/commands/teamwork/messages.d.ts.map +1 -1
- package/dist/commands/teamwork/messages.js +3 -0
- package/dist/commands/teamwork/messages.js.map +1 -1
- package/dist/commands/teamwork/plan.d.ts +22 -0
- package/dist/commands/teamwork/plan.d.ts.map +1 -0
- package/dist/commands/teamwork/plan.js +288 -0
- package/dist/commands/teamwork/plan.js.map +1 -0
- package/dist/commands/teamwork/process-identity.d.ts +3 -0
- package/dist/commands/teamwork/process-identity.d.ts.map +1 -0
- package/dist/commands/teamwork/process-identity.js +24 -0
- package/dist/commands/teamwork/process-identity.js.map +1 -0
- package/dist/commands/teamwork/profiles.d.ts +43 -6
- package/dist/commands/teamwork/profiles.d.ts.map +1 -1
- package/dist/commands/teamwork/profiles.js +138 -110
- package/dist/commands/teamwork/profiles.js.map +1 -1
- package/dist/commands/teamwork/prompt.d.ts +4 -0
- package/dist/commands/teamwork/prompt.d.ts.map +1 -0
- package/dist/commands/teamwork/prompt.js +33 -0
- package/dist/commands/teamwork/prompt.js.map +1 -0
- package/dist/commands/teamwork/result.d.ts +55 -0
- package/dist/commands/teamwork/result.d.ts.map +1 -0
- package/dist/commands/teamwork/result.js +94 -0
- package/dist/commands/teamwork/result.js.map +1 -0
- package/dist/commands/teamwork/rm.d.ts.map +1 -1
- package/dist/commands/teamwork/rm.js +64 -64
- package/dist/commands/teamwork/rm.js.map +1 -1
- package/dist/commands/teamwork/run.d.ts +5 -0
- package/dist/commands/teamwork/run.d.ts.map +1 -1
- package/dist/commands/teamwork/run.js +120 -24
- package/dist/commands/teamwork/run.js.map +1 -1
- package/dist/commands/teamwork/runtime.d.ts +9 -0
- package/dist/commands/teamwork/runtime.d.ts.map +1 -0
- package/dist/commands/teamwork/runtime.js +401 -0
- package/dist/commands/teamwork/runtime.js.map +1 -0
- package/dist/commands/teamwork/send.d.ts +1 -0
- package/dist/commands/teamwork/send.d.ts.map +1 -1
- package/dist/commands/teamwork/send.js +2 -1
- package/dist/commands/teamwork/send.js.map +1 -1
- package/dist/commands/teamwork/spawn.d.ts +10 -3
- package/dist/commands/teamwork/spawn.d.ts.map +1 -1
- package/dist/commands/teamwork/spawn.js +42 -27
- package/dist/commands/teamwork/spawn.js.map +1 -1
- package/dist/commands/teamwork/store/lock.js +28 -4
- package/dist/commands/teamwork/store/lock.js.map +1 -1
- package/dist/commands/teamwork/supervisor/inbox.d.ts +1 -0
- package/dist/commands/teamwork/supervisor/inbox.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor/inbox.js +28 -9
- package/dist/commands/teamwork/supervisor/inbox.js.map +1 -1
- package/dist/commands/teamwork/supervisor/stdout.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor/stdout.js +2 -3
- package/dist/commands/teamwork/supervisor/stdout.js.map +1 -1
- package/dist/commands/teamwork/supervisor/warning.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor/warning.js +2 -3
- package/dist/commands/teamwork/supervisor/warning.js.map +1 -1
- package/dist/commands/teamwork/supervisor.d.ts +5 -0
- package/dist/commands/teamwork/supervisor.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor.js +21 -12
- package/dist/commands/teamwork/supervisor.js.map +1 -1
- package/dist/commands/teamwork/teammate-adapters.d.ts +0 -5
- package/dist/commands/teamwork/teammate-adapters.d.ts.map +1 -1
- package/dist/commands/teamwork/teammate-adapters.js +9 -56
- package/dist/commands/teamwork/teammate-adapters.js.map +1 -1
- package/dist/commands/teamwork/teammate-config.d.ts +5 -1
- package/dist/commands/teamwork/teammate-config.d.ts.map +1 -1
- package/dist/commands/teamwork/teammate-config.js +62 -19
- package/dist/commands/teamwork/teammate-config.js.map +1 -1
- package/dist/commands/teamwork/teammate-launch.d.ts +15 -6
- package/dist/commands/teamwork/teammate-launch.d.ts.map +1 -1
- package/dist/commands/teamwork/teammate-launch.js +129 -22
- package/dist/commands/teamwork/teammate-launch.js.map +1 -1
- package/dist/commands/teamwork/wait.d.ts +2 -0
- package/dist/commands/teamwork/wait.d.ts.map +1 -1
- package/dist/commands/teamwork/wait.js +44 -3
- package/dist/commands/teamwork/wait.js.map +1 -1
- package/dist/commands/uninstall.d.ts.map +1 -1
- package/dist/commands/uninstall.js +92 -84
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +635 -592
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +94 -89
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/shared.js +2 -2
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +14 -32
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/constants/paths.d.ts +3 -10
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +4 -13
- package/dist/constants/paths.js.map +1 -1
- package/dist/migrations/manifests/0.7.1-beta.0.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.1.json +186 -0
- package/dist/migrations/manifests/0.7.1-beta.10.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.11.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.12.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.13.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.14.json +11 -0
- package/dist/migrations/manifests/0.7.1-beta.15.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.16.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.17.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.18.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.19.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.2.json +522 -0
- package/dist/migrations/manifests/0.7.1-beta.3.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.4.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.5.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.6.json +10 -0
- package/dist/migrations/manifests/0.7.1-beta.7.json +8 -0
- package/dist/migrations/manifests/0.7.1-beta.8.json +481 -0
- package/dist/migrations/manifests/0.7.1-beta.9.json +191 -0
- package/dist/migrations/manifests/0.7.2-beta.0.json +11 -0
- package/dist/migrations/manifests/0.7.2-beta.1.json +11 -0
- package/dist/migrations/manifests/0.7.2-beta.2.json +11 -0
- package/dist/templates/claude/hooks/statusline.py +9 -4
- package/dist/templates/codex/hooks/session-start.py +14 -11
- package/dist/templates/codex/hooks.json +10 -0
- package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/workflow.md +2 -0
- package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/workflow.md +2 -0
- package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +5 -5
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +3 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-teamwork.md +14 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +8 -2
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/workflow.md +8 -0
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/mcp-setup.md +4 -2
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-task-planning.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-task-implementation/workflow.md +6 -0
- package/dist/templates/common/bundled-skills/trellis-teamwork/SKILL.md +18 -3
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/coordination-modes.md +34 -3
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-configuration-matrix.md +37 -26
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +128 -14
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +152 -20
- package/dist/templates/common/commands/start.md +2 -2
- package/dist/templates/common/skills/before-dev.md +1 -4
- package/dist/templates/common/skills/break-loop.md +7 -18
- package/dist/templates/common/skills/check.md +7 -11
- package/dist/templates/common/skills/{brainstorm.md → request-intake.md} +5 -3
- package/dist/templates/common/skills/update-spec.md +5 -7
- package/dist/templates/copilot/hooks/session-start.py +14 -11
- package/dist/templates/copilot/index.d.ts +1 -6
- package/dist/templates/copilot/index.d.ts.map +1 -1
- package/dist/templates/copilot/index.js +1 -14
- package/dist/templates/copilot/index.js.map +1 -1
- package/dist/templates/extract.js +4 -0
- package/dist/templates/extract.js.map +1 -1
- package/dist/templates/markdown/spec/backend/index.md.txt +4 -35
- package/dist/templates/markdown/spec/frontend/index.md.txt +4 -36
- package/dist/templates/omp/extensions/trellis/index.ts.txt +2 -2
- package/dist/templates/opencode/lib/session-utils.js +301 -209
- package/dist/templates/pi/extensions/trellis/index.ts.txt +17 -1
- package/dist/templates/shared-hooks/guard-teamwork-wait.py +58 -0
- package/dist/templates/shared-hooks/index.d.ts +1 -1
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +1 -0
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-workflow-state.py +8 -3
- package/dist/templates/shared-hooks/session-start.py +16 -12
- package/dist/templates/snow/SNOW.md +1 -1
- package/dist/templates/trellis/agents/delegated-worktree-primary-agent.md +9 -0
- package/dist/templates/trellis/agents/profiles/architecture.md +9 -0
- package/dist/templates/trellis/agents/profiles/diagnosis.md +9 -0
- package/dist/templates/trellis/agents/profiles/exploration.md +9 -0
- package/dist/templates/trellis/agents/profiles/research.md +9 -0
- package/dist/templates/trellis/agents/profiles/review.md +9 -0
- package/dist/templates/trellis/agents/profiles/verification.md +9 -0
- package/dist/templates/trellis/agents/teammate-profiles.json +56 -4
- package/dist/templates/trellis/agents/teammate.md +4 -52
- package/dist/templates/trellis/config.yaml +2 -5
- package/dist/templates/trellis/index.d.ts +4 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +22 -4
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +1 -1
- package/dist/templates/trellis/scripts/common/active_task.py +3 -47
- package/dist/templates/trellis/scripts/common/config.py +5 -0
- package/dist/templates/trellis/scripts/common/paths.py +9 -25
- package/dist/templates/trellis/scripts/common/safe_commit.py +8 -8
- package/dist/templates/trellis/scripts/common/task_context.py +11 -51
- package/dist/templates/trellis/scripts/common/task_continuity.py +191 -60
- package/dist/templates/trellis/scripts/common/task_layout.py +80 -0
- package/dist/templates/trellis/scripts/common/task_move.py +345 -0
- package/dist/templates/trellis/scripts/common/task_store.py +51 -57
- package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
- package/dist/templates/trellis/scripts/common/task_workflow.py +395 -49
- package/dist/templates/trellis/scripts/common/tasks.py +6 -5
- package/dist/templates/trellis/scripts/hooks/linear_sync.py +13 -3
- package/dist/templates/trellis/scripts/task.py +76 -24
- package/dist/templates/trellis/teamwork.json +10 -0
- package/dist/templates/trellis/workflow.md +11 -2
- package/dist/utils/ablation-store.d.ts +1 -1
- package/dist/utils/ablation-store.d.ts.map +1 -1
- package/dist/utils/ablation-store.js +58 -24
- package/dist/utils/ablation-store.js.map +1 -1
- package/dist/utils/asset-backup.d.ts +138 -0
- package/dist/utils/asset-backup.d.ts.map +1 -0
- package/dist/utils/asset-backup.js +525 -0
- package/dist/utils/asset-backup.js.map +1 -0
- package/dist/utils/atomic-write.d.ts +1 -1
- package/dist/utils/atomic-write.d.ts.map +1 -1
- package/dist/utils/atomic-write.js +6 -3
- package/dist/utils/atomic-write.js.map +1 -1
- package/dist/utils/manifest-prune.d.ts +4 -1
- package/dist/utils/manifest-prune.d.ts.map +1 -1
- package/dist/utils/manifest-prune.js +17 -6
- package/dist/utils/manifest-prune.js.map +1 -1
- package/dist/utils/template-fetcher.d.ts +4 -19
- package/dist/utils/template-fetcher.d.ts.map +1 -1
- package/dist/utils/template-fetcher.js +9 -45
- package/dist/utils/template-fetcher.js.map +1 -1
- package/dist/utils/template-hash.d.ts +5 -4
- package/dist/utils/template-hash.d.ts.map +1 -1
- package/dist/utils/template-hash.js +24 -17
- package/dist/utils/template-hash.js.map +1 -1
- package/package.json +3 -2
- package/dist/templates/copilot/prompts/before-dev.prompt.md +0 -39
- package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -13
- package/dist/templates/copilot/prompts/break-loop.prompt.md +0 -129
- package/dist/templates/copilot/prompts/check-cross-layer.prompt.md +0 -170
- package/dist/templates/copilot/prompts/check.prompt.md +0 -110
- package/dist/templates/copilot/prompts/create-command.prompt.md +0 -116
- package/dist/templates/copilot/prompts/finish-work.prompt.md +0 -22
- package/dist/templates/copilot/prompts/integrate-skill.prompt.md +0 -223
- package/dist/templates/copilot/prompts/onboard.prompt.md +0 -362
- package/dist/templates/copilot/prompts/record-session.prompt.md +0 -58
- package/dist/templates/copilot/prompts/start.prompt.md +0 -65
- package/dist/templates/copilot/prompts/update-spec.prompt.md +0 -358
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import contextlib
|
|
7
|
+
import copy
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
import hashlib
|
|
9
10
|
import json
|
|
@@ -61,11 +62,14 @@ def _task_dir(value: str) -> Path:
|
|
|
61
62
|
return task_dir
|
|
62
63
|
|
|
63
64
|
|
|
64
|
-
def _load(task_dir: Path) -> tuple[dict, dict]:
|
|
65
|
+
def _load(task_dir: Path, *, initialize_meta: bool = False) -> tuple[dict, dict]:
|
|
65
66
|
task_file = task_dir / FILE_TASK_JSON
|
|
66
67
|
data, reason = read_json_checked(task_file)
|
|
67
68
|
if data is None:
|
|
68
69
|
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"cannot read {task_file}: {reason}")
|
|
70
|
+
# Explicit initialization may adopt legacy records; malformed metadata still fails.
|
|
71
|
+
if initialize_meta and "meta" not in data:
|
|
72
|
+
data["meta"] = {}
|
|
69
73
|
meta = data.get("meta")
|
|
70
74
|
if not isinstance(meta, dict):
|
|
71
75
|
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task.meta must be an object")
|
|
@@ -106,6 +110,28 @@ def _state(meta: dict) -> dict:
|
|
|
106
110
|
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "route dependency admission has an invalid shape")
|
|
107
111
|
if state.get("current_phase_id") is not None and state["current_phase_id"] not in state["phase_ids"]:
|
|
108
112
|
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "current phase is not in the task route")
|
|
113
|
+
skip_records = state.get("skip_records")
|
|
114
|
+
if not isinstance(skip_records, dict):
|
|
115
|
+
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task phase skip records have an invalid shape")
|
|
116
|
+
for phase_id, skip in skip_records.items():
|
|
117
|
+
if (
|
|
118
|
+
phase_id not in PHASE_ID_SET
|
|
119
|
+
or not isinstance(skip, dict)
|
|
120
|
+
or not isinstance(skip.get("reason"), str)
|
|
121
|
+
or not skip["reason"].strip()
|
|
122
|
+
or not isinstance(skip.get("evidence_refs"), list)
|
|
123
|
+
or not skip["evidence_refs"]
|
|
124
|
+
or any(not isinstance(value, str) or not value.strip() for value in skip["evidence_refs"])
|
|
125
|
+
or skip.get("task_route_revision") != state["task_route_revision"]
|
|
126
|
+
or not ROUTE_REVISION.fullmatch(str(skip.get("input_revision", "")))
|
|
127
|
+
or not isinstance(skip.get("artifact_revision"), str)
|
|
128
|
+
or not re.fullmatch(r"[a-f0-9]{64}", skip["artifact_revision"])
|
|
129
|
+
or (
|
|
130
|
+
phase_id in state["phases"]
|
|
131
|
+
and skip["artifact_revision"] != state["phases"][phase_id].get("artifact_revision")
|
|
132
|
+
)
|
|
133
|
+
):
|
|
134
|
+
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"task phase skip record is invalid: {phase_id}")
|
|
109
135
|
for phase_id in state["phase_ids"]:
|
|
110
136
|
record = state["phases"].get(phase_id)
|
|
111
137
|
if not isinstance(record, dict) or record.get("status") not in {"not_started", "active", "waiting_user", "blocked", "accepted", "skipped"}:
|
|
@@ -130,6 +156,10 @@ def implementation_start_ready(data: dict, task_dir: Path) -> bool:
|
|
|
130
156
|
phase_ids = state.get("phase_ids")
|
|
131
157
|
if not isinstance(phase_ids, list) or PHASE_IDS[3] not in phase_ids:
|
|
132
158
|
return False
|
|
159
|
+
phases = state.get("phases")
|
|
160
|
+
implementation = phases.get(PHASE_IDS[3]) if isinstance(phases, dict) else None
|
|
161
|
+
if not isinstance(implementation, dict) or implementation.get("status") != "active":
|
|
162
|
+
return False
|
|
133
163
|
if state.get("task_kind") not in {"implementation", "combined"}:
|
|
134
164
|
return False
|
|
135
165
|
authorization = state.get("implementation_authorization")
|
|
@@ -145,6 +175,7 @@ def implementation_start_ready(data: dict, task_dir: Path) -> bool:
|
|
|
145
175
|
):
|
|
146
176
|
return False
|
|
147
177
|
_require_current_requirements_confirmation(task_dir, state)
|
|
178
|
+
_require_current_accepted_artifacts(task_dir, state)
|
|
148
179
|
if PHASE_IDS[2] not in phase_ids:
|
|
149
180
|
skip_records = state.get("skip_records")
|
|
150
181
|
skipped_design = skip_records.get(PHASE_IDS[2]) if isinstance(skip_records, dict) else None
|
|
@@ -176,7 +207,8 @@ def _save(task_file: Path, data: dict) -> None:
|
|
|
176
207
|
|
|
177
208
|
@contextlib.contextmanager
|
|
178
209
|
def _task_lock(task_dir: Path):
|
|
179
|
-
lock_file =
|
|
210
|
+
lock_file = get_repo_root() / ".trellis/.runtime/task-phase" / f"{task_dir.name}.lock"
|
|
211
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
180
212
|
with lock_file.open("a+b") as handle:
|
|
181
213
|
if os.name == "nt":
|
|
182
214
|
import msvcrt
|
|
@@ -223,28 +255,39 @@ def _phase(state: dict, phase_id: str | None = None) -> tuple[str, dict]:
|
|
|
223
255
|
return current, record
|
|
224
256
|
|
|
225
257
|
|
|
226
|
-
def
|
|
227
|
-
if PHASE_IDS[0] not in state["phase_ids"]:
|
|
228
|
-
return
|
|
258
|
+
def _is_withdrawal_route(state: dict) -> bool:
|
|
229
259
|
requirements = state["phases"].get(PHASE_IDS[0], {})
|
|
230
260
|
withdrawal = state.get("return_record")
|
|
231
261
|
skip_records = state.get("skip_records")
|
|
232
262
|
skipped_requirements = skip_records.get(PHASE_IDS[0]) if isinstance(skip_records, dict) else None
|
|
233
|
-
|
|
263
|
+
return (
|
|
234
264
|
state["phase_ids"] == [PHASE_IDS[0], PHASE_IDS[4]]
|
|
235
|
-
and state.get("current_phase_id")
|
|
265
|
+
and state.get("current_phase_id") in {PHASE_IDS[4], None}
|
|
236
266
|
and isinstance(requirements, dict)
|
|
237
|
-
and
|
|
238
|
-
|
|
239
|
-
|
|
267
|
+
and (
|
|
268
|
+
requirements.get("status") == "accepted"
|
|
269
|
+
or (
|
|
270
|
+
requirements.get("status") == "skipped"
|
|
271
|
+
and isinstance(skipped_requirements, dict)
|
|
272
|
+
and skipped_requirements.get("task_route_revision") == state.get("task_route_revision")
|
|
273
|
+
)
|
|
274
|
+
)
|
|
240
275
|
and isinstance(withdrawal, dict)
|
|
241
276
|
and isinstance(withdrawal.get("from"), str)
|
|
242
277
|
and withdrawal["from"] in PHASE_ID_SET
|
|
243
278
|
and withdrawal.get("to") == PHASE_IDS[4]
|
|
279
|
+
and withdrawal.get("return_kind") == "withdrawal"
|
|
244
280
|
and withdrawal.get("task_route_revision") == state.get("task_route_revision")
|
|
245
281
|
and isinstance(withdrawal.get("evidence_refs"), list)
|
|
246
282
|
and bool(withdrawal["evidence_refs"])
|
|
247
|
-
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _require_current_requirements_confirmation(task_dir: Path, state: dict) -> None:
|
|
287
|
+
if PHASE_IDS[0] not in state["phase_ids"]:
|
|
288
|
+
return
|
|
289
|
+
requirements = state["phases"].get(PHASE_IDS[0], {})
|
|
290
|
+
if requirements.get("status") == "skipped" and _is_withdrawal_route(state):
|
|
248
291
|
return
|
|
249
292
|
acceptance = requirements.get("acceptance") if isinstance(requirements, dict) else None
|
|
250
293
|
if (
|
|
@@ -280,6 +323,7 @@ def _invalidate_from(state: dict, first_phase: str, keep_first: bool = False) ->
|
|
|
280
323
|
for phase_id in state["phase_ids"]:
|
|
281
324
|
if PHASE_IDS.index(phase_id) < first_index:
|
|
282
325
|
continue
|
|
326
|
+
state["skip_records"].pop(phase_id, None)
|
|
283
327
|
record = state["phases"][phase_id]
|
|
284
328
|
if keep_first and phase_id == first_phase:
|
|
285
329
|
record["status"] = "active"
|
|
@@ -327,6 +371,24 @@ def _artifact_digest(task_dir: Path, refs: list[str]) -> str:
|
|
|
327
371
|
return digest.hexdigest()
|
|
328
372
|
|
|
329
373
|
|
|
374
|
+
def _require_current_accepted_artifacts(task_dir: Path, state: dict) -> None:
|
|
375
|
+
for phase_id in state["phase_ids"]:
|
|
376
|
+
record = state["phases"][phase_id]
|
|
377
|
+
if record.get("status") != "accepted":
|
|
378
|
+
continue
|
|
379
|
+
refs = record.get("artifact_refs")
|
|
380
|
+
if not isinstance(refs, list) or any(not isinstance(ref, str) for ref in refs):
|
|
381
|
+
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"accepted artifact references are invalid: {phase_id}")
|
|
382
|
+
if _artifact_digest(task_dir, refs) != record.get("artifact_revision"):
|
|
383
|
+
raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", f"accepted artifacts changed for {phase_id}; return to the affected phase and review the current candidate again")
|
|
384
|
+
authorization = state.get("implementation_authorization")
|
|
385
|
+
if authorization is not None:
|
|
386
|
+
if not isinstance(authorization, dict) or not isinstance(authorization.get("contract_ref"), str):
|
|
387
|
+
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "implementation authorization has no contract reference")
|
|
388
|
+
if _artifact_digest(task_dir, [authorization["contract_ref"]]) != authorization.get("contract_revision"):
|
|
389
|
+
raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "implementation contract changed after authorization; review and authorize its current content before continuing")
|
|
390
|
+
|
|
391
|
+
|
|
330
392
|
def _validate_route(value: str | None) -> list[str]:
|
|
331
393
|
raw = _required_text(value, "--phase-ids")
|
|
332
394
|
phases = raw.split(",")
|
|
@@ -350,6 +412,8 @@ def _validate_classification(task_kind: str | None, complexity: str | None, proj
|
|
|
350
412
|
raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "--project-context must be new or existing")
|
|
351
413
|
if task_kind == "analysis" and PHASE_IDS[3] in phases:
|
|
352
414
|
raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "analysis tasks cannot include the Task Implementation and Test Verification Phase")
|
|
415
|
+
if task_kind in {"implementation", "combined"} and PHASE_IDS[3] not in phases:
|
|
416
|
+
raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "implementation and combined tasks must include the Task Implementation and Test Verification Phase")
|
|
353
417
|
|
|
354
418
|
|
|
355
419
|
def _increment(value: str, allow_zero: bool = False) -> str:
|
|
@@ -402,7 +466,13 @@ def _init(args: argparse.Namespace, task_dir: Path, data: dict, meta: dict) -> i
|
|
|
402
466
|
"implementation_authorization": None,
|
|
403
467
|
"closeout_disposition": None,
|
|
404
468
|
"skip_records": {
|
|
405
|
-
phase: {
|
|
469
|
+
phase: {
|
|
470
|
+
"reason": route_reason,
|
|
471
|
+
"evidence_refs": route_evidence,
|
|
472
|
+
"task_route_revision": "1",
|
|
473
|
+
"input_revision": input_revision,
|
|
474
|
+
"artifact_revision": _phase_record(input_revision)["artifact_revision"],
|
|
475
|
+
}
|
|
406
476
|
for phase in omitted
|
|
407
477
|
},
|
|
408
478
|
"return_record": None,
|
|
@@ -417,6 +487,31 @@ def _show(state: dict) -> int:
|
|
|
417
487
|
return 0
|
|
418
488
|
|
|
419
489
|
|
|
490
|
+
def _require_acceptance_coverage(task_dir: Path, contract_ref: str) -> None:
|
|
491
|
+
_artifact_digest(task_dir, [contract_ref])
|
|
492
|
+
lines = (task_dir / contract_ref).read_text(encoding="utf-8").splitlines()
|
|
493
|
+
heading = next((index for index, line in enumerate(lines) if line.strip() == "## 验收覆盖"), None)
|
|
494
|
+
headers = ["接受条件", "工作包", "Owner", "验证入口", "交付证据", "停止条件"]
|
|
495
|
+
if heading is not None:
|
|
496
|
+
section = []
|
|
497
|
+
for line in lines[heading + 1:]:
|
|
498
|
+
if line.startswith("#"):
|
|
499
|
+
break
|
|
500
|
+
if line.strip():
|
|
501
|
+
section.append(line.strip())
|
|
502
|
+
rows = [line.strip("|").split("|") for line in section if line.startswith("|") and line.endswith("|")]
|
|
503
|
+
rows = [[cell.strip() for cell in row] for row in rows]
|
|
504
|
+
if (
|
|
505
|
+
len(rows) >= 3
|
|
506
|
+
and rows[0] == headers
|
|
507
|
+
and len(rows[1]) == len(headers)
|
|
508
|
+
and all(re.fullmatch(r":?-{3,}:?", cell) for cell in rows[1])
|
|
509
|
+
and all(len(row) == len(headers) and all(row) for row in rows[2:])
|
|
510
|
+
):
|
|
511
|
+
return
|
|
512
|
+
raise TaskWorkflowError("TASK_PHASE_COVERAGE_REQUIRED", "implementation contract requires ## 验收覆盖 with nonempty rows: " + " | ".join(headers))
|
|
513
|
+
|
|
514
|
+
|
|
420
515
|
def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -> int:
|
|
421
516
|
action = args.phase_action
|
|
422
517
|
child_dispositions: dict[str, str] = {}
|
|
@@ -427,14 +522,21 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
427
522
|
raise TaskWorkflowError("TASK_PHASE_TASK_TERMINAL", f"task is already {data['status']}")
|
|
428
523
|
|
|
429
524
|
_expected_route(state, args.expected_route_revision)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
525
|
+
reviewed_delivery = (
|
|
526
|
+
state.get("current_phase_id") is None
|
|
527
|
+
and action in {"return", "route"}
|
|
528
|
+
and state["phases"].get(PHASE_IDS[4], {}).get("status") == "accepted"
|
|
529
|
+
)
|
|
530
|
+
if reviewed_delivery:
|
|
531
|
+
current, record = PHASE_IDS[4], state["phases"][PHASE_IDS[4]]
|
|
532
|
+
else:
|
|
533
|
+
current, record = _phase(state)
|
|
534
|
+
accepted_design_return = action == "return" and current == PHASE_IDS[2] and record.get("status") == "accepted"
|
|
433
535
|
returning_to_requirements = action == "return" and args.target_phase == PHASE_IDS[0]
|
|
434
536
|
rerouting_to_requirements = action == "route" and args.target_phase == PHASE_IDS[0]
|
|
435
537
|
if current != PHASE_IDS[0] and not returning_to_requirements and not rerouting_to_requirements:
|
|
436
538
|
_require_current_requirements_confirmation(task_dir, state)
|
|
437
|
-
if action not in {"show", "resume", "route", "authorize", "admission"} and record.get("status") != "active":
|
|
539
|
+
if action not in {"show", "resume", "route", "authorize", "admission"} and record.get("status") != "active" and not reviewed_delivery and not accepted_design_return:
|
|
438
540
|
raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "only the active phase can be changed")
|
|
439
541
|
|
|
440
542
|
if action == "artifact":
|
|
@@ -478,9 +580,16 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
478
580
|
if current == PHASE_IDS[0]:
|
|
479
581
|
raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "The Goal Exploration and Requirements Confirmation Phase must reach minimum alignment and acceptance and cannot be skipped")
|
|
480
582
|
record.update({"status": "skipped", "reason": reason, "evidence_refs": evidence})
|
|
481
|
-
state["skip_records"][current] = {
|
|
583
|
+
state["skip_records"][current] = {
|
|
584
|
+
"reason": reason,
|
|
585
|
+
"evidence_refs": evidence,
|
|
586
|
+
"task_route_revision": state["task_route_revision"],
|
|
587
|
+
"input_revision": state["input_revision"],
|
|
588
|
+
"artifact_revision": record["artifact_revision"],
|
|
589
|
+
}
|
|
482
590
|
_advance(state, current)
|
|
483
591
|
elif action == "accept":
|
|
592
|
+
_require_current_accepted_artifacts(task_dir, state)
|
|
484
593
|
artifact_revision = _required_text(args.artifact_revision, "--artifact-revision")
|
|
485
594
|
current_revision = _artifact_digest(task_dir, record["artifact_refs"])
|
|
486
595
|
if artifact_revision != record["artifact_revision"] or current_revision != record["artifact_revision"]:
|
|
@@ -495,6 +604,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
495
604
|
raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "Solution Design and Convergence Phase must remain active for pending_user_decision or deferred; accept implementation_contract, analysis_conclusion, or no_go")
|
|
496
605
|
if result_ref not in record["artifact_refs"]:
|
|
497
606
|
raise TaskWorkflowError("TASK_PHASE_RESULT_NOT_BOUND", "--result-ref must be included in the current phase artifact refs")
|
|
607
|
+
if current == PHASE_IDS[2] and outcome == "implementation_contract":
|
|
608
|
+
_require_acceptance_coverage(task_dir, result_ref)
|
|
498
609
|
if current == PHASE_IDS[3]:
|
|
499
610
|
authorization = state.get("implementation_authorization")
|
|
500
611
|
if not isinstance(authorization, dict) or authorization.get("task_route_revision") != state["task_route_revision"]:
|
|
@@ -505,8 +616,7 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
505
616
|
if outcome != args.closeout_disposition:
|
|
506
617
|
raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "Delivery Review and On-Site Closeout Phase outcome must match --closeout-disposition")
|
|
507
618
|
if args.closeout_disposition == "cancelled":
|
|
508
|
-
|
|
509
|
-
if not isinstance(withdrawal, dict) or withdrawal.get("to") != PHASE_IDS[4] or not withdrawal.get("evidence_refs"):
|
|
619
|
+
if not _is_withdrawal_route(state):
|
|
510
620
|
raise TaskWorkflowError("TASK_PHASE_USER_CONFIRMATION_REQUIRED", "cancellation closeout requires a user-confirmed route to Delivery Review and On-Site Closeout Phase")
|
|
511
621
|
child_dispositions = _validate_child_dispositions(
|
|
512
622
|
task_dir, data, args.closeout_disposition, args.child_disposition
|
|
@@ -532,14 +642,38 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
532
642
|
_advance(state, current)
|
|
533
643
|
elif action == "return":
|
|
534
644
|
target = _required_text(args.target_phase, "--target-phase")
|
|
535
|
-
|
|
645
|
+
return_kind = args.return_kind
|
|
646
|
+
execution_rework = return_kind == "execution_rework"
|
|
647
|
+
if target not in state["phase_ids"] or (
|
|
648
|
+
state["phase_ids"].index(target) >= state["phase_ids"].index(current)
|
|
649
|
+
and not ((reviewed_delivery or accepted_design_return or execution_rework) and target == current)
|
|
650
|
+
):
|
|
536
651
|
raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "return target must be an earlier phase in the current route")
|
|
537
652
|
reason = _required_text(args.reason, "--reason")
|
|
538
653
|
evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
|
|
654
|
+
authorization = state.get("implementation_authorization")
|
|
655
|
+
admission = state.get("route_dependency_admission")
|
|
656
|
+
if execution_rework:
|
|
657
|
+
if current not in PHASE_IDS[3:] or target not in PHASE_IDS[3:] or PHASE_IDS[3] not in state["phase_ids"]:
|
|
658
|
+
raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "execution_rework is limited to implementation and delivery phases")
|
|
659
|
+
candidate = copy.deepcopy(data)
|
|
660
|
+
candidate_state = candidate["meta"]["task_workflow"]
|
|
661
|
+
_invalidate_from(candidate_state, target, keep_first=True)
|
|
662
|
+
candidate_state["implementation_authorization"] = authorization
|
|
663
|
+
_require_current_accepted_artifacts(task_dir, candidate_state)
|
|
664
|
+
candidate_state["current_phase_id"] = PHASE_IDS[3]
|
|
665
|
+
candidate_state["phases"][PHASE_IDS[3]]["status"] = "active"
|
|
666
|
+
if not implementation_start_ready(candidate, task_dir):
|
|
667
|
+
raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_AUTHORIZED", "execution_rework requires the unchanged authorized implementation contract")
|
|
668
|
+
if admission is not None and (admission.get("admission_state") != "ready" or admission.get("task_route_revision") != state["task_route_revision"]):
|
|
669
|
+
raise TaskWorkflowError("TASK_PHASE_ADMISSION_REQUIRED", "execution_rework requires current ready dependency admission")
|
|
539
670
|
state["input_revision"] = _increment(state["input_revision"])
|
|
540
671
|
_invalidate_from(state, target, keep_first=True)
|
|
672
|
+
if execution_rework:
|
|
673
|
+
state["implementation_authorization"] = authorization
|
|
674
|
+
state["route_dependency_admission"] = admission
|
|
541
675
|
state["phases"][target]["input_revision"] = state["input_revision"]
|
|
542
|
-
state["return_record"] = {"from": current, "to": target, "reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
|
|
676
|
+
state["return_record"] = {"from": current, "to": target, "return_kind": return_kind, "authorization_retained": execution_rework, "reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
|
|
543
677
|
elif action == "route":
|
|
544
678
|
phases = _validate_route(args.phase_ids)
|
|
545
679
|
task_kind = args.task_kind if args.task_kind is not None else state["task_kind"]
|
|
@@ -563,8 +697,9 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
563
697
|
target == PHASE_IDS[4]
|
|
564
698
|
and phases == [PHASE_IDS[0], PHASE_IDS[4]]
|
|
565
699
|
and bool(evidence)
|
|
700
|
+
and args.return_kind == "withdrawal"
|
|
566
701
|
)
|
|
567
|
-
if not changed and not classification_changed and target == current:
|
|
702
|
+
if not changed and not classification_changed and target == current and not reviewed_delivery:
|
|
568
703
|
raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "the updated route and target are unchanged")
|
|
569
704
|
earliest_change = min((PHASE_IDS.index(phase) for phase in changed), default=PHASE_IDS.index(target))
|
|
570
705
|
if target not in PHASE_ID_SET or (PHASE_IDS.index(target) > earliest_change and not withdrawal_route):
|
|
@@ -604,9 +739,25 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
604
739
|
for phase_id, phase_record in state["phases"].items():
|
|
605
740
|
if phase_id not in old_phases:
|
|
606
741
|
phase_record["input_revision"] = state["input_revision"]
|
|
607
|
-
state["skip_records"] = {
|
|
742
|
+
state["skip_records"] = {
|
|
743
|
+
phase_id: {
|
|
744
|
+
**skip,
|
|
745
|
+
"task_route_revision": state["task_route_revision"],
|
|
746
|
+
"input_revision": state["input_revision"],
|
|
747
|
+
"artifact_revision": state["phases"][phase_id]["artifact_revision"],
|
|
748
|
+
}
|
|
749
|
+
for phase_id, skip in state["skip_records"].items()
|
|
750
|
+
if phase_id in phases and state["phases"][phase_id].get("status") == "skipped"
|
|
751
|
+
}
|
|
608
752
|
for phase_id in omitted:
|
|
609
|
-
state["
|
|
753
|
+
phase_record = state["phases"].get(phase_id, old_phases.get(phase_id, _phase_record(state["input_revision"])))
|
|
754
|
+
state["skip_records"][phase_id] = {
|
|
755
|
+
"reason": reason,
|
|
756
|
+
"evidence_refs": evidence,
|
|
757
|
+
"task_route_revision": state["task_route_revision"],
|
|
758
|
+
"input_revision": state["input_revision"],
|
|
759
|
+
"artifact_revision": phase_record["artifact_revision"],
|
|
760
|
+
}
|
|
610
761
|
if withdrawal_route and state["phases"][PHASE_IDS[0]].get("status") != "accepted":
|
|
611
762
|
state["phases"][PHASE_IDS[0]].update({
|
|
612
763
|
"status": "skipped",
|
|
@@ -620,6 +771,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
620
771
|
"reason": reason,
|
|
621
772
|
"evidence_refs": evidence,
|
|
622
773
|
"task_route_revision": state["task_route_revision"],
|
|
774
|
+
"input_revision": state["input_revision"],
|
|
775
|
+
"artifact_revision": state["phases"][PHASE_IDS[0]]["artifact_revision"],
|
|
623
776
|
}
|
|
624
777
|
state["implementation_authorization"] = None
|
|
625
778
|
state["closeout_disposition"] = None
|
|
@@ -627,6 +780,7 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
627
780
|
state["return_record"] = {
|
|
628
781
|
"from": current,
|
|
629
782
|
"to": target,
|
|
783
|
+
**({"return_kind": "withdrawal"} if withdrawal_route else {}),
|
|
630
784
|
"reason": reason,
|
|
631
785
|
"evidence_refs": evidence,
|
|
632
786
|
"task_route_revision": state["task_route_revision"],
|
|
@@ -647,7 +801,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
|
|
|
647
801
|
raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "Solution Design and Convergence Phase implementation contract changed after acceptance; refresh and accept the current artifact before authorization")
|
|
648
802
|
elif contract_ref not in state.get("skip_records", {}).get(PHASE_IDS[2], {}).get("evidence_refs", []):
|
|
649
803
|
raise TaskWorkflowError("TASK_PHASE_CONTRACT_NOT_ACCEPTED", "when Solution Design and Convergence Phase is omitted, the implementation contract must be among its route evidence")
|
|
650
|
-
|
|
804
|
+
_require_acceptance_coverage(task_dir, contract_ref)
|
|
805
|
+
state["implementation_authorization"] = {"contract_ref": contract_ref, "contract_revision": _artifact_digest(task_dir, [contract_ref]), "approval_ref": approval_ref, "task_route_revision": state["task_route_revision"]}
|
|
651
806
|
if current == PHASE_IDS[2]:
|
|
652
807
|
_advance(state, current)
|
|
653
808
|
else:
|
|
@@ -662,7 +817,7 @@ def cmd_phase(args: argparse.Namespace) -> int:
|
|
|
662
817
|
try:
|
|
663
818
|
task_dir = _task_dir(args.task_dir)
|
|
664
819
|
with _task_lock(task_dir):
|
|
665
|
-
data, meta = _load(task_dir)
|
|
820
|
+
data, meta = _load(task_dir, initialize_meta=args.phase_action == "init")
|
|
666
821
|
if args.phase_action == "init":
|
|
667
822
|
return _init(args, task_dir, data, meta)
|
|
668
823
|
return _update(args, task_dir, data, _state(meta))
|
|
@@ -707,6 +862,56 @@ def _paseo_list(args: list[str], cwd: Path, code: str) -> list[dict]:
|
|
|
707
862
|
return value
|
|
708
863
|
|
|
709
864
|
|
|
865
|
+
def _delegation_binding(repo_root: Path, child_dir: Path, child_data: dict, result_path: str) -> dict:
|
|
866
|
+
if not isinstance(result_path, str) or Path(result_path).is_absolute() or ".." in Path(result_path).parts or not result_path.startswith(".trellis/delegated-results/"):
|
|
867
|
+
raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", "result path must stay under .trellis/delegated-results/")
|
|
868
|
+
try:
|
|
869
|
+
contract_path = (child_dir / "prd.md").resolve(strict=True)
|
|
870
|
+
contract_path.relative_to(child_dir.resolve())
|
|
871
|
+
role_path = (repo_root / ".trellis/agents/delegated-worktree-primary-agent.md").resolve(strict=True)
|
|
872
|
+
role_path.relative_to((repo_root / ".trellis/agents").resolve())
|
|
873
|
+
if not contract_path.is_file() or not role_path.is_file() or contract_path.stat().st_size + role_path.stat().st_size > 1048576:
|
|
874
|
+
raise ValueError("delegated contract and role must be regular files within the size budget")
|
|
875
|
+
contract = contract_path.read_bytes()
|
|
876
|
+
role = role_path.read_bytes()
|
|
877
|
+
except (OSError, ValueError) as error:
|
|
878
|
+
raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", str(error)) from error
|
|
879
|
+
if not contract.strip() or not role.strip():
|
|
880
|
+
raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", "child PRD and delegated role must be nonempty")
|
|
881
|
+
return {
|
|
882
|
+
"contract_sha256": hashlib.sha256(contract).hexdigest(),
|
|
883
|
+
"role_sha256": hashlib.sha256(role).hexdigest(),
|
|
884
|
+
"branch": _required_text(child_data.get("branch"), "child branch"),
|
|
885
|
+
"base_commit": _git_output(["rev-parse", "--verify", "--end-of-options", f"{_required_text(child_data.get('base_branch'), 'child base_branch')}^{{commit}}"], repo_root),
|
|
886
|
+
"result_path": result_path,
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def cmd_delegate_bind(args: argparse.Namespace) -> int:
|
|
891
|
+
try:
|
|
892
|
+
repo_root = get_repo_root().resolve()
|
|
893
|
+
child_dir = _task_dir(args.child_task)
|
|
894
|
+
child, _ = _load(child_dir)
|
|
895
|
+
if not isinstance(child.get("parent"), str) or not child["parent"].strip():
|
|
896
|
+
raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "child task has no parent link")
|
|
897
|
+
parent_dir = _task_dir(child["parent"])
|
|
898
|
+
parent, meta = _load(parent_dir)
|
|
899
|
+
if child_dir.name not in parent.get("children", []) or not implementation_start_ready(parent, parent_dir):
|
|
900
|
+
raise TaskWorkflowError("TASK_DELEGATE_IMPLEMENTATION_NOT_AUTHORIZED", "bind only a linked child within current parent implementation authorization")
|
|
901
|
+
state = _state(meta)
|
|
902
|
+
authorization = state["implementation_authorization"]
|
|
903
|
+
result_path = args.result_path or f".trellis/delegated-results/{child_dir.name}.md"
|
|
904
|
+
binding = _delegation_binding(repo_root, child_dir, child, result_path)
|
|
905
|
+
binding.update({"authorization_ref": authorization["approval_ref"], "parent_contract_revision": authorization["contract_revision"], "task_route_revision": state["task_route_revision"]})
|
|
906
|
+
meta.setdefault("delegation_authorizations", {})[child_dir.name] = binding
|
|
907
|
+
_save(parent_dir / FILE_TASK_JSON, parent)
|
|
908
|
+
print(json.dumps(binding, ensure_ascii=False))
|
|
909
|
+
return 0
|
|
910
|
+
except TaskWorkflowError as error:
|
|
911
|
+
print(f"{error.code}: {error}", file=sys.stderr)
|
|
912
|
+
return 1
|
|
913
|
+
|
|
914
|
+
|
|
710
915
|
def cmd_delegate_start(args: argparse.Namespace) -> int:
|
|
711
916
|
try:
|
|
712
917
|
repo_root = get_repo_root().resolve()
|
|
@@ -722,6 +927,11 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
|
|
|
722
927
|
child_data, child_meta = _load(child_dir)
|
|
723
928
|
if child_data.get("parent") != parent_dir.name or child_dir.name not in parent_data.get("children", []):
|
|
724
929
|
raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "parent and child task links do not match")
|
|
930
|
+
from .task_continuity import assert_task_mutation_allowed
|
|
931
|
+
|
|
932
|
+
assert_task_mutation_allowed(repo_root, parent_dir, parent_data)
|
|
933
|
+
if child_data.get("status") not in {"planning", "in_progress"}:
|
|
934
|
+
raise TaskWorkflowError("TASK_DELEGATE_CHILD_TERMINAL", "only a planning or in-progress child task can be delegated")
|
|
725
935
|
if parent_data.get("status") != "in_progress" or not implementation_start_ready(parent_data, parent_dir):
|
|
726
936
|
raise TaskWorkflowError("TASK_DELEGATE_IMPLEMENTATION_NOT_AUTHORIZED", "parent task is not at an authorized Task Implementation and Test Verification Phase entry")
|
|
727
937
|
|
|
@@ -775,6 +985,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
|
|
|
775
985
|
raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", f"could not run Paseo: {error}") from error
|
|
776
986
|
if version.returncode != 0:
|
|
777
987
|
raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", (version.stderr or version.stdout).strip() or "Paseo CLI preflight failed")
|
|
988
|
+
_paseo_list(["agent", "ls", "--all", "--global", "--background", "--json"], repo_root, "TASK_DELEGATE_PASEO_UNAVAILABLE")
|
|
778
989
|
role_path = repo_root / ".trellis" / "agents" / "delegated-worktree-primary-agent.md"
|
|
779
990
|
try:
|
|
780
991
|
role = role_path.read_text(encoding="utf-8")
|
|
@@ -785,6 +996,16 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
|
|
|
785
996
|
if not isinstance(authorization, dict) or not isinstance(authorization.get("approval_ref"), str):
|
|
786
997
|
raise TaskWorkflowError("TASK_DELEGATE_AUTHORIZATION_INVALID", "current implementation authorization is invalid")
|
|
787
998
|
contract_digest = hashlib.sha256(contract_bytes).hexdigest()
|
|
999
|
+
bindings = parent_meta.get("delegation_authorizations", {})
|
|
1000
|
+
binding = bindings.get(child_dir.name) if isinstance(bindings, dict) else None
|
|
1001
|
+
if not isinstance(binding, dict):
|
|
1002
|
+
raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_NOT_BOUND", "freeze this child through delegate-bind before dispatch")
|
|
1003
|
+
actual = _delegation_binding(repo_root, child_dir, child_data, binding.get("result_path", ""))
|
|
1004
|
+
if any(binding.get(key) != value for key, value in actual.items()) or actual["contract_sha256"] != contract_digest or actual["role_sha256"] != hashlib.sha256(role.encode("utf-8")).hexdigest() or binding.get("authorization_ref") != authorization["approval_ref"] or binding.get("parent_contract_revision") != authorization["contract_revision"] or binding.get("task_route_revision") != state["task_route_revision"]:
|
|
1005
|
+
raise TaskWorkflowError("TASK_DELEGATE_STALE_CONTRACT", "child PRD, role, Git base, output path, or parent authorization changed; review and bind the child again")
|
|
1006
|
+
prompt_bytes = len((role + contract).encode("utf-8"))
|
|
1007
|
+
if prompt_bytes > (24000 if os.name == "nt" else 96000):
|
|
1008
|
+
raise TaskWorkflowError("TASK_DELEGATE_PROMPT_TOO_LARGE", "delegated prompt exceeds the native Paseo argv budget; narrow the contract before creating resources")
|
|
788
1009
|
previous = child_meta.get("delegated_worktree_execution_record")
|
|
789
1010
|
reuse_workspace = False
|
|
790
1011
|
workspace_id = None
|
|
@@ -808,7 +1029,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
|
|
|
808
1029
|
previous_worktree = Path(previous_worktree_path).expanduser().resolve()
|
|
809
1030
|
agents = _paseo_list([
|
|
810
1031
|
"agent", "ls", "--all", "--global", "--background", "--json",
|
|
811
|
-
"--label", f"trellis.task_id={child_dir.name}",
|
|
1032
|
+
"--label", f"trellis.task_id={previous.get('label_task_id', child_dir.name)}",
|
|
812
1033
|
], repo_root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
|
|
813
1034
|
for agent in agents:
|
|
814
1035
|
agent_id = agent.get("id")
|
|
@@ -859,9 +1080,14 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
|
|
|
859
1080
|
"workspace_id": workspace_id,
|
|
860
1081
|
"worktree_path": str(worktree) if worktree else None,
|
|
861
1082
|
"agent_id": None,
|
|
1083
|
+
"label_task_id": child_dir.name,
|
|
1084
|
+
"role_sha256": actual["role_sha256"],
|
|
1085
|
+
"result_path": actual["result_path"],
|
|
862
1086
|
"status": "starting",
|
|
863
1087
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
864
1088
|
}
|
|
1089
|
+
if previous is not None:
|
|
1090
|
+
child_meta.setdefault("delegated_worktree_execution_history", []).append(previous)
|
|
865
1091
|
child_meta["delegated_worktree_execution_record"] = record
|
|
866
1092
|
_save(child_dir / FILE_TASK_JSON, child_data)
|
|
867
1093
|
|
|
@@ -902,6 +1128,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
|
|
|
902
1128
|
"the parent authorization and Git baseline are execution metadata. Do not accept task phases or change task lifecycle state.\n\n"
|
|
903
1129
|
f"Parent task: {parent_dir.name}\nParent route revision: {state['task_route_revision']}\n"
|
|
904
1130
|
f"Authorized contract: prd.md\nBranch: {branch}\nBase commit: {base_commit}\n\n"
|
|
1131
|
+
f"Assigned result artifact: {actual['result_path']}\n"
|
|
905
1132
|
f"{role}\n\n## Child task prd.md\n{contract}"
|
|
906
1133
|
)
|
|
907
1134
|
agent = _paseo_json([
|
|
@@ -939,6 +1166,7 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
|
|
|
939
1166
|
readiness_error = task_closeout_readiness(data, "cancelled")
|
|
940
1167
|
if readiness_error:
|
|
941
1168
|
raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
|
|
1169
|
+
_require_current_accepted_artifacts(task_dir, _state(meta))
|
|
942
1170
|
_validate_child_dispositions(task_dir, data, "cancelled", None, from_acceptance=True)
|
|
943
1171
|
_assert_delegated_resources_closed(task_dir, data)
|
|
944
1172
|
meta["task_cancellation"] = {
|
|
@@ -947,7 +1175,8 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
|
|
|
947
1175
|
}
|
|
948
1176
|
data["status"] = "cancelled"
|
|
949
1177
|
data["completedAt"] = None
|
|
950
|
-
|
|
1178
|
+
from .task_move import relocate_task
|
|
1179
|
+
task_dir = relocate_task(get_repo_root(), task_dir, data)
|
|
951
1180
|
from .active_task import clear_task_from_sessions
|
|
952
1181
|
from .task_continuity import clear_task_runtime_records
|
|
953
1182
|
|
|
@@ -961,6 +1190,56 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
|
|
|
961
1190
|
return 1
|
|
962
1191
|
|
|
963
1192
|
|
|
1193
|
+
def cmd_reopen_task(args: argparse.Namespace) -> int:
|
|
1194
|
+
try:
|
|
1195
|
+
task_dir = _task_dir(args.task_dir)
|
|
1196
|
+
with _task_lock(task_dir):
|
|
1197
|
+
data, meta = _load(task_dir)
|
|
1198
|
+
previous_status = data.get("status")
|
|
1199
|
+
if previous_status not in {"completed", "cancelled"}:
|
|
1200
|
+
raise TaskWorkflowError("TASK_PHASE_REOPEN_INVALID_STATUS", "only completed or cancelled tasks can be reopened")
|
|
1201
|
+
reason = _required_text(args.reason, "--reason")
|
|
1202
|
+
evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
|
|
1203
|
+
_assert_delegated_resources_closed(task_dir, data)
|
|
1204
|
+
recoveries = meta.setdefault("task_lifecycle_recoveries", [])
|
|
1205
|
+
if not isinstance(recoveries, list) or any(not isinstance(record, dict) for record in recoveries):
|
|
1206
|
+
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task lifecycle recovery history is malformed")
|
|
1207
|
+
recovery = {
|
|
1208
|
+
"from_status": previous_status,
|
|
1209
|
+
"to_status": "planning",
|
|
1210
|
+
"reason": reason,
|
|
1211
|
+
"evidence_refs": evidence,
|
|
1212
|
+
"reopened_at": datetime.now(timezone.utc).isoformat(),
|
|
1213
|
+
"terminal_completed_at": data.get("completedAt"),
|
|
1214
|
+
"terminal_cancellation": copy.deepcopy(meta.get("task_cancellation")),
|
|
1215
|
+
}
|
|
1216
|
+
state = meta.get("task_workflow")
|
|
1217
|
+
if state is not None:
|
|
1218
|
+
state = _state(meta)
|
|
1219
|
+
state["implementation_authorization"] = None
|
|
1220
|
+
state["route_dependency_admission"] = None
|
|
1221
|
+
state["closeout_disposition"] = None
|
|
1222
|
+
recoveries.append(recovery)
|
|
1223
|
+
data["status"] = "planning"
|
|
1224
|
+
data["completedAt"] = None
|
|
1225
|
+
from .task_move import relocate_task
|
|
1226
|
+
from .active_task import clear_task_from_sessions
|
|
1227
|
+
from .task_continuity import clear_task_runtime_records
|
|
1228
|
+
|
|
1229
|
+
repo_root = get_repo_root()
|
|
1230
|
+
# Relocation rewrites runtime paths; terminal runtime must be discarded first.
|
|
1231
|
+
for runtime_dir in (task_dir, repo_root / ".trellis/tasks/planning" / task_dir.name):
|
|
1232
|
+
clear_task_from_sessions(str(runtime_dir), repo_root)
|
|
1233
|
+
if not clear_task_runtime_records(repo_root, runtime_dir.relative_to(repo_root).as_posix()):
|
|
1234
|
+
raise TaskWorkflowError("TASK_PHASE_RUNTIME_CLEANUP_FAILED", "could not discard terminal task runtime; task remains terminal")
|
|
1235
|
+
task_dir = relocate_task(repo_root, task_dir, data)
|
|
1236
|
+
print(json.dumps({"status": "planning", "task_lifecycle_recovery": recovery, "runtime_records_cleared": True}, ensure_ascii=False))
|
|
1237
|
+
return 0
|
|
1238
|
+
except TaskWorkflowError as error:
|
|
1239
|
+
print(f"{error.code}: {error}", file=sys.stderr)
|
|
1240
|
+
return 1
|
|
1241
|
+
|
|
1242
|
+
|
|
964
1243
|
def task_closeout_readiness(data: dict, disposition: str) -> str | None:
|
|
965
1244
|
if "meta" in data and not isinstance(data["meta"], dict):
|
|
966
1245
|
return "task metadata is malformed"
|
|
@@ -982,8 +1261,7 @@ def task_closeout_readiness(data: dict, disposition: str) -> str | None:
|
|
|
982
1261
|
if state.get("closeout_disposition") != disposition:
|
|
983
1262
|
return f"Delivery Review and On-Site Closeout Phase disposition is {state.get('closeout_disposition')}; expected {disposition}"
|
|
984
1263
|
if disposition == "cancelled":
|
|
985
|
-
|
|
986
|
-
if not isinstance(withdrawal, dict) or withdrawal.get("to") != PHASE_IDS[4] or not withdrawal.get("evidence_refs"):
|
|
1264
|
+
if not _is_withdrawal_route(state):
|
|
987
1265
|
return "cancellation requires a user-confirmed route to Delivery Review and On-Site Closeout Phase"
|
|
988
1266
|
return None
|
|
989
1267
|
|
|
@@ -1033,27 +1311,96 @@ def _validate_child_dispositions(
|
|
|
1033
1311
|
return choices
|
|
1034
1312
|
|
|
1035
1313
|
|
|
1314
|
+
def cmd_delegate_reconcile(args: argparse.Namespace) -> int:
|
|
1315
|
+
try:
|
|
1316
|
+
root = get_repo_root().resolve()
|
|
1317
|
+
task_dir = _task_dir(args.child_task)
|
|
1318
|
+
data, meta = _load(task_dir)
|
|
1319
|
+
record = meta.get("delegated_worktree_execution_record")
|
|
1320
|
+
if not isinstance(record, dict):
|
|
1321
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "no valid delegated execution mapping")
|
|
1322
|
+
if record.get("status") == "retired" and isinstance(record.get("closure_evidence"), dict):
|
|
1323
|
+
print(json.dumps(record, ensure_ascii=False))
|
|
1324
|
+
return 0
|
|
1325
|
+
label = record.get("label_task_id", task_dir.name)
|
|
1326
|
+
agents = _paseo_list(["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={label}"], root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
|
|
1327
|
+
if any(agent.get("status") != "closed" for agent in agents):
|
|
1328
|
+
raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "a delegated Paseo agent is still active or unknown")
|
|
1329
|
+
agent_id = record.get("agent_id")
|
|
1330
|
+
if agent_id and not any(agent.get("id") == agent_id for agent in agents):
|
|
1331
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "recorded agent is not available; preserve its mapping until native closure evidence is available")
|
|
1332
|
+
worktree_path = record.get("worktree_path")
|
|
1333
|
+
workspace_id = record.get("workspace_id")
|
|
1334
|
+
workspaces = _paseo_list(["workspace", "ls", "--background", "--json"], root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
|
|
1335
|
+
workspace = next((row for row in workspaces if row.get("workspaceId") == workspace_id), None) if workspace_id else None
|
|
1336
|
+
if workspace and (not isinstance(worktree_path, str) or Path(workspace.get("cwd", "")).resolve() != Path(worktree_path).resolve()):
|
|
1337
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "workspace mapping does not match")
|
|
1338
|
+
head = None
|
|
1339
|
+
if isinstance(worktree_path, str) and Path(worktree_path).is_dir():
|
|
1340
|
+
worktree = Path(worktree_path).resolve()
|
|
1341
|
+
common = Path(_git_output(["rev-parse", "--git-common-dir"], worktree))
|
|
1342
|
+
common = (common if common.is_absolute() else worktree / common).resolve()
|
|
1343
|
+
expected_common = Path(_git_output(["rev-parse", "--git-common-dir"], root))
|
|
1344
|
+
expected_common = (expected_common if expected_common.is_absolute() else root / expected_common).resolve()
|
|
1345
|
+
if common != expected_common or _git_output(["branch", "--show-current"], worktree) != record.get("branch"):
|
|
1346
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree does not belong to the recorded repository and branch")
|
|
1347
|
+
dirty = _git_output(["status", "--porcelain", "--untracked-files=all"], worktree)
|
|
1348
|
+
allowed = "?? " + record.get("result_path", "")
|
|
1349
|
+
if any(line != allowed for line in dirty.splitlines()):
|
|
1350
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree contains unintegrated changes; preserve and integrate them before retiring")
|
|
1351
|
+
head = _git_output(["rev-parse", "HEAD"], worktree)
|
|
1352
|
+
if run_git(["merge-base", "--is-ancestor", head, "HEAD"], cwd=root)[0] != 0:
|
|
1353
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree commits are not integrated into the coordinator checkout")
|
|
1354
|
+
elif workspace:
|
|
1355
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "workspace still exists but its worktree cannot be inspected")
|
|
1356
|
+
elif not agent_id:
|
|
1357
|
+
branch = record.get("branch")
|
|
1358
|
+
if not isinstance(branch, str) or run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=root)[0] != 1:
|
|
1359
|
+
raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "incomplete mapping still has an unresolved branch")
|
|
1360
|
+
evidence = {"agent_ids": [agent.get("id") for agent in agents], "workspace_id": workspace_id, "head": head, "checked_at": datetime.now(timezone.utc).isoformat()}
|
|
1361
|
+
if not args.execute:
|
|
1362
|
+
print(json.dumps({"status": "ready", "evidence": evidence}, ensure_ascii=False))
|
|
1363
|
+
return 0
|
|
1364
|
+
reason = _required_text(args.reason, "--reason")
|
|
1365
|
+
approval_ref = _required_text(args.approval_ref, "--approval-ref")
|
|
1366
|
+
record.update({"status": "retired", "closure_evidence": {**evidence, "reason": reason, "approval_ref": approval_ref}})
|
|
1367
|
+
_save(task_dir / FILE_TASK_JSON, data)
|
|
1368
|
+
print(json.dumps(record, ensure_ascii=False))
|
|
1369
|
+
return 0
|
|
1370
|
+
except TaskWorkflowError as error:
|
|
1371
|
+
print(f"{error.code}: {error}", file=sys.stderr)
|
|
1372
|
+
return 1
|
|
1373
|
+
|
|
1374
|
+
|
|
1036
1375
|
def _assert_delegated_resources_closed(task_dir: Path, data: dict) -> None:
|
|
1037
|
-
repo_root = get_repo_root()
|
|
1038
1376
|
children = data.get("children", [])
|
|
1039
1377
|
if not isinstance(children, list):
|
|
1040
1378
|
raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "linked child task list is malformed")
|
|
1041
1379
|
for task_id in [task_dir.name, *children]:
|
|
1042
1380
|
_, child_meta = _load(_task_dir(task_id))
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
)
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1381
|
+
_assert_delegated_record_closed(task_id, child_meta.get("delegated_worktree_execution_record"))
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _assert_delegated_record_closed(task_id: str, record) -> None:
|
|
1385
|
+
if record is None:
|
|
1386
|
+
return
|
|
1387
|
+
if isinstance(record, dict) and record.get("status") == "retired" and isinstance(record.get("closure_evidence"), dict):
|
|
1388
|
+
return
|
|
1389
|
+
agent_id = record.get("agent_id") if isinstance(record, dict) else None
|
|
1390
|
+
if not isinstance(agent_id, str) or not agent_id.strip():
|
|
1391
|
+
raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated execution status is unknown for {task_id}; preserve the current state")
|
|
1392
|
+
agents = _paseo_list(
|
|
1393
|
+
["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={record.get('label_task_id', task_id)}"],
|
|
1394
|
+
get_repo_root(),
|
|
1395
|
+
"TASK_PHASE_RESOURCE_ACTIVE",
|
|
1396
|
+
)
|
|
1397
|
+
agent = next((item for item in agents if item.get("id") == agent_id), None)
|
|
1398
|
+
if agent is None or agent.get("status") != "closed":
|
|
1399
|
+
raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated Paseo agent for {task_id} is not confirmed closed; preserve the current state")
|
|
1400
|
+
worktree_path = record.get("worktree_path")
|
|
1401
|
+
if isinstance(worktree_path, str) and Path(worktree_path).is_dir():
|
|
1402
|
+
if _git_output(["status", "--porcelain", "--untracked-files=all", "--", ".trellis/tasks", ".trellis/.runtime"], Path(worktree_path)):
|
|
1403
|
+
raise TaskWorkflowError("TASK_DELEGATE_TASK_FACTS_CHANGED", "delegated worktree changed protected task facts; preserve evidence and exclude those changes before integration")
|
|
1057
1404
|
|
|
1058
1405
|
|
|
1059
1406
|
def cmd_complete_task(args: argparse.Namespace) -> int:
|
|
@@ -1068,9 +1415,7 @@ def cmd_complete_task(args: argparse.Namespace) -> int:
|
|
|
1068
1415
|
disposition = state.get("closeout_disposition")
|
|
1069
1416
|
if disposition not in {"delivery_ready", "no_go"}:
|
|
1070
1417
|
raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", "Delivery Review and On-Site Closeout Phase must accept delivery_ready or no_go before complete")
|
|
1071
|
-
|
|
1072
|
-
if _artifact_digest(task_dir, closeout.get("artifact_refs", [])) != closeout.get("artifact_revision"):
|
|
1073
|
-
raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "Delivery Review and On-Site Closeout Phase artifacts changed after acceptance; review the current delivery candidate again")
|
|
1418
|
+
_require_current_accepted_artifacts(task_dir, state)
|
|
1074
1419
|
readiness_error = task_closeout_readiness(data, disposition)
|
|
1075
1420
|
if readiness_error:
|
|
1076
1421
|
raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
|
|
@@ -1081,7 +1426,8 @@ def cmd_complete_task(args: argparse.Namespace) -> int:
|
|
|
1081
1426
|
raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_STARTED", "implementation tasks must enter in_progress before complete")
|
|
1082
1427
|
data["status"] = "completed"
|
|
1083
1428
|
data["completedAt"] = datetime.now().strftime("%Y-%m-%d")
|
|
1084
|
-
|
|
1429
|
+
from .task_move import relocate_task
|
|
1430
|
+
task_dir = relocate_task(get_repo_root(), task_dir, data)
|
|
1085
1431
|
from .active_task import clear_task_from_sessions
|
|
1086
1432
|
from .task_continuity import clear_task_runtime_records
|
|
1087
1433
|
|