@blxzer/cursor-trellis 0.2.9 → 0.3.0
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/CHANGELOG.md +64 -0
- package/README.md +37 -12
- package/README.zh-CN.md +41 -16
- package/bin/{trellis.js → cstl.js} +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +30 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/capability-smoke.d.ts +24 -0
- package/dist/commands/capability-smoke.d.ts.map +1 -0
- package/dist/commands/capability-smoke.js +83 -0
- package/dist/commands/capability-smoke.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +94 -34
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/uninstall.d.ts +1 -1
- package/dist/commands/uninstall.js +2 -2
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/commands/update.d.ts +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +79 -34
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/upgrade.js +2 -2
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/configurators/cursor.d.ts +2 -2
- package/dist/configurators/cursor.js +4 -4
- package/dist/configurators/cursor.js.map +1 -1
- package/dist/configurators/index.js +3 -3
- package/dist/configurators/index.js.map +1 -1
- package/dist/configurators/shared.d.ts +10 -10
- package/dist/configurators/shared.d.ts.map +1 -1
- package/dist/configurators/shared.js +26 -26
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/workflow.d.ts +2 -2
- package/dist/migrations/manifests/0.2.10.json +374 -0
- package/dist/migrations/manifests/0.3.0.json +89 -0
- package/dist/templates/common/bundled-skills/{trellis-cursor2plus-setup → cstl-cursor2plus-setup}/SKILL.md +5 -4
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/SKILL.md +75 -75
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/add-project-local-conventions.md +8 -8
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-agents.md +6 -6
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-context-loading.md +7 -7
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-skills-or-commands.md +1 -1
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-spec-structure.md +2 -2
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-task-lifecycle.md +31 -31
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-workflow.md +12 -12
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/overview.md +55 -55
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/context-injection.md +13 -13
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/generated-files.md +83 -83
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/overview.md +2 -2
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/spec-system.md +1 -1
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/task-system.md +22 -22
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workflow.md +5 -5
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workspace-memory.md +3 -3
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/agents.md +11 -11
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/hooks-and-settings.md +1 -1
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/overview.md +4 -4
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/platform-map.md +5 -5
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/skills-and-commands.md +15 -15
- package/dist/templates/common/bundled-skills/{trellis-micro-grill → cstl-micro-grill}/SKILL.md +62 -47
- package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/SKILL.md +44 -44
- package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/references/authoring-rules.md +97 -97
- package/dist/templates/common/bundled-skills/{trellis-skill-creator/references/trellis-skill-locations.md → cstl-skill-creator/references/cstl-skill-locations.md} +52 -52
- package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/references/review-checklist.md +47 -47
- package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/SKILL.md +1 -1
- package/dist/templates/common/commands/continue.md +20 -20
- package/dist/templates/common/commands/start.md +11 -11
- package/dist/templates/common/index.d.ts +1 -1
- package/dist/templates/common/index.d.ts.map +1 -1
- package/dist/templates/common/skills/brainstorm.md +188 -164
- package/dist/templates/common/skills/check.md +1 -1
- package/dist/templates/cursor/agents/{trellis-check.md → cstl-check.md} +8 -8
- package/dist/templates/cursor/agents/{trellis-implement.md → cstl-implement.md} +8 -8
- package/dist/templates/cursor/agents/{trellis-research.md → cstl-research.md} +2 -2
- package/dist/templates/cursor/commands/cursor2plus-setup.md +5 -5
- package/dist/templates/cursor/fixtures/expected-rules.d.ts +1 -1
- package/dist/templates/cursor/fixtures/expected-rules.d.ts.map +1 -1
- package/dist/templates/cursor/fixtures/expected-rules.js +2 -2
- package/dist/templates/cursor/fixtures/expected-rules.js.map +1 -1
- package/dist/templates/cursor/rules/{trellis-subagent-dispatch.mdc → cstl-subagent-dispatch.mdc} +2 -2
- package/dist/templates/cursor/rules/{trellis-triage.mdc → cstl-triage.mdc} +1 -1
- package/dist/templates/markdown/agents.md +4 -4
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +634 -634
- package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +4 -4
- package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +6 -0
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +393 -391
- package/dist/templates/markdown/spec/guides/durable-learning-decision-guide.md.txt +6 -6
- package/dist/templates/markdown/spec/guides/execution-strategy.md.txt +2 -2
- package/dist/templates/markdown/spec/guides/index.md.txt +3 -2
- package/dist/templates/shared-hooks/index.d.ts +1 -1
- package/dist/templates/shared-hooks/index.js +1 -1
- package/dist/templates/shared-hooks/inject-subagent-context.py +3 -3
- package/dist/templates/shared-hooks/inject-workflow-state.py +27 -27
- package/dist/templates/shared-hooks/research-end-retrieval-pack.py +1 -1
- package/dist/templates/shared-hooks/session-start.py +3 -3
- package/dist/templates/trellis/config/execution-strategy-rules.json +1 -1
- package/dist/templates/trellis/config.yaml +4 -4
- package/dist/templates/trellis/local/README.md +12 -10
- package/dist/templates/trellis/local/patch_wpelc8.py +3 -3
- package/dist/templates/trellis/local/subagent-models.json.example +1 -1
- package/dist/templates/trellis/local/trellis-task-models.json.example +3 -3
- package/dist/templates/trellis/local/trellis-task-models.json5.example +5 -5
- package/dist/templates/trellis/local/trellis_task_models_config.py +6 -6
- package/dist/templates/trellis/scripts/common/cli_adapter.py +3 -3
- package/dist/templates/trellis/scripts/common/execution_strategy.py +1 -1
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +702 -702
- package/dist/templates/trellis/scripts/common/session_context.py +1159 -1159
- package/dist/templates/trellis/scripts/common/subagent_dispatch.py +5 -5
- package/dist/templates/trellis/scripts/common/task_gates.py +2056 -2034
- package/dist/templates/trellis/scripts/common/task_store.py +1594 -1594
- package/dist/templates/trellis/workflow.md +805 -805
- package/dist/types/ai-tools.d.ts +1 -1
- package/dist/types/ai-tools.d.ts.map +1 -1
- package/dist/types/ai-tools.js +1 -1
- package/dist/types/ai-tools.js.map +1 -1
- package/dist/utils/project-capabilities.d.ts +14 -3
- package/dist/utils/project-capabilities.d.ts.map +1 -1
- package/dist/utils/project-capabilities.js +122 -12
- package/dist/utils/project-capabilities.js.map +1 -1
- package/dist/utils/readiness.d.ts +10 -0
- package/dist/utils/readiness.d.ts.map +1 -1
- package/dist/utils/readiness.js +6 -3
- package/dist/utils/readiness.js.map +1 -1
- package/package.json +3 -4
- /package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-hooks.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/mcp-setup.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/repository-analysis.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-task-planning.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-writing.md +0 -0
|
@@ -1,1594 +1,1594 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Task CRUD operations.
|
|
4
|
-
|
|
5
|
-
Provides:
|
|
6
|
-
ensure_tasks_dir - Ensure tasks directory exists
|
|
7
|
-
cmd_create - Create a new task
|
|
8
|
-
cmd_archive - Archive completed task
|
|
9
|
-
cmd_set_branch - Set git branch for task
|
|
10
|
-
cmd_set_base_branch - Set PR target branch
|
|
11
|
-
cmd_set_scope - Set scope for PR title
|
|
12
|
-
cmd_add_subtask - Link child task to parent
|
|
13
|
-
cmd_remove_subtask - Unlink child task from parent
|
|
14
|
-
cmd_prepare_child_worktree - Create/register Child git worktree
|
|
15
|
-
cmd_set_child_state - Set Child-reported task-map state
|
|
16
|
-
cmd_integrate_child - Set Parent-controlled Child integration state
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import argparse
|
|
22
|
-
import json
|
|
23
|
-
import re
|
|
24
|
-
import sys
|
|
25
|
-
from datetime import datetime
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
|
|
28
|
-
from .config import (
|
|
29
|
-
get_packages,
|
|
30
|
-
get_session_auto_commit,
|
|
31
|
-
is_monorepo,
|
|
32
|
-
resolve_package,
|
|
33
|
-
validate_package,
|
|
34
|
-
)
|
|
35
|
-
from .cli_environment import format_git_repo_errors, print_environment_repair_hints
|
|
36
|
-
from .git import run_git
|
|
37
|
-
from .io import read_json, write_json
|
|
38
|
-
from .log import Colors, colored
|
|
39
|
-
from .paths import (
|
|
40
|
-
DIR_ARCHIVE,
|
|
41
|
-
DIR_TASKS,
|
|
42
|
-
DIR_WORKFLOW,
|
|
43
|
-
FILE_TASK_JSON,
|
|
44
|
-
generate_task_date_prefix,
|
|
45
|
-
get_developer,
|
|
46
|
-
get_repo_root,
|
|
47
|
-
get_tasks_dir,
|
|
48
|
-
)
|
|
49
|
-
from .safe_commit import (
|
|
50
|
-
print_gitignore_warning,
|
|
51
|
-
safe_archive_paths_to_add,
|
|
52
|
-
safe_git_add,
|
|
53
|
-
)
|
|
54
|
-
from .task_gates import (
|
|
55
|
-
BASELINE_GATE,
|
|
56
|
-
archive_repair_hints,
|
|
57
|
-
build_spec_update_scaffold,
|
|
58
|
-
prepare_archive_evidence,
|
|
59
|
-
validate_archive,
|
|
60
|
-
write_gate_record,
|
|
61
|
-
)
|
|
62
|
-
from .task_map import (
|
|
63
|
-
CHILD_STATES,
|
|
64
|
-
CHILD_REPORT_STATES,
|
|
65
|
-
PARENT_CONTROLLED_STATES,
|
|
66
|
-
ensure_task_map,
|
|
67
|
-
get_child_state,
|
|
68
|
-
record_child_worktree,
|
|
69
|
-
remove_child_from_task_map,
|
|
70
|
-
set_child_state,
|
|
71
|
-
set_parent_child_integration_state,
|
|
72
|
-
validate_parent_child_integration,
|
|
73
|
-
)
|
|
74
|
-
from .tasks import parent_archive_child_followup_hint
|
|
75
|
-
from .task_utils import (
|
|
76
|
-
archive_task_complete,
|
|
77
|
-
find_task_by_name,
|
|
78
|
-
resolve_task_dir,
|
|
79
|
-
run_task_hooks,
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
# =============================================================================
|
|
84
|
-
# Helper Functions
|
|
85
|
-
# =============================================================================
|
|
86
|
-
|
|
87
|
-
def _slugify(title: str) -> str:
|
|
88
|
-
"""Convert title to slug (only works with ASCII)."""
|
|
89
|
-
result = title.lower()
|
|
90
|
-
result = re.sub(r"[^a-z0-9]", "-", result)
|
|
91
|
-
result = re.sub(r"-+", "-", result)
|
|
92
|
-
result = result.strip("-")
|
|
93
|
-
return result
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def ensure_tasks_dir(repo_root: Path) -> Path:
|
|
97
|
-
"""Ensure tasks directory exists."""
|
|
98
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
99
|
-
archive_dir = tasks_dir / "archive"
|
|
100
|
-
|
|
101
|
-
if not tasks_dir.exists():
|
|
102
|
-
tasks_dir.mkdir(parents=True)
|
|
103
|
-
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
|
|
104
|
-
|
|
105
|
-
if not archive_dir.exists():
|
|
106
|
-
archive_dir.mkdir(parents=True)
|
|
107
|
-
|
|
108
|
-
return tasks_dir
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
|
|
112
|
-
"""Find an archived task directory with the exact active-task dir name."""
|
|
113
|
-
archive_dir = tasks_dir / DIR_ARCHIVE
|
|
114
|
-
if not archive_dir.is_dir():
|
|
115
|
-
return None
|
|
116
|
-
|
|
117
|
-
for month_dir in sorted(archive_dir.iterdir()):
|
|
118
|
-
if not month_dir.is_dir():
|
|
119
|
-
continue
|
|
120
|
-
candidate = month_dir / dir_name
|
|
121
|
-
if candidate.is_dir():
|
|
122
|
-
return candidate
|
|
123
|
-
|
|
124
|
-
return None
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _repo_relative_path(path: Path, repo_root: Path) -> str:
|
|
128
|
-
"""Format a path relative to the repo root when possible."""
|
|
129
|
-
try:
|
|
130
|
-
return path.relative_to(repo_root).as_posix()
|
|
131
|
-
except ValueError:
|
|
132
|
-
return str(path)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _short_cli_ref(value: str | None) -> bool:
|
|
136
|
-
"""Return True for a compact single-argument ref/path value."""
|
|
137
|
-
if not value or not isinstance(value, str):
|
|
138
|
-
return False
|
|
139
|
-
if value.startswith("-"):
|
|
140
|
-
return False
|
|
141
|
-
if len(value) > 240:
|
|
142
|
-
return False
|
|
143
|
-
return "\n" not in value and "\r" not in value
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _validate_git_repo(repo_root: Path) -> list[str]:
|
|
147
|
-
rc, out, err = run_git(["rev-parse", "--is-inside-work-tree"], cwd=repo_root)
|
|
148
|
-
if rc != 0 or out.strip() != "true":
|
|
149
|
-
detail = err.strip() or out.strip() or "not a Git worktree"
|
|
150
|
-
return format_git_repo_errors([f"Git repository required: {detail}"])
|
|
151
|
-
return []
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _validate_branch_name(repo_root: Path, branch: str) -> list[str]:
|
|
155
|
-
if not _short_cli_ref(branch):
|
|
156
|
-
return ["branch must be a short git branch name"]
|
|
157
|
-
rc, _, err = run_git(["check-ref-format", "--branch", branch], cwd=repo_root)
|
|
158
|
-
if rc != 0:
|
|
159
|
-
return [f"invalid branch name: {branch} ({err.strip() or 'git check-ref-format failed'})"]
|
|
160
|
-
return []
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _git_commit_exists(repo_root: Path, ref: str | None) -> bool:
|
|
164
|
-
if not _short_cli_ref(ref):
|
|
165
|
-
return False
|
|
166
|
-
rc, _, _ = run_git(["rev-parse", "--verify", f"{ref}^{{commit}}"], cwd=repo_root)
|
|
167
|
-
return rc == 0
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def _git_branch_exists(repo_root: Path, branch: str) -> bool:
|
|
171
|
-
rc, _, _ = run_git(
|
|
172
|
-
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
173
|
-
cwd=repo_root,
|
|
174
|
-
)
|
|
175
|
-
return rc == 0
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _default_child_worktree_path(repo_root: Path, child_dir: Path) -> Path:
|
|
179
|
-
return repo_root / DIR_WORKFLOW / "worktrees" / child_dir.name
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _resolve_child_worktree_path(repo_root: Path, child_dir: Path, raw_path: str | None) -> tuple[Path | None, str | None]:
|
|
183
|
-
path = Path(raw_path) if raw_path else _default_child_worktree_path(repo_root, child_dir)
|
|
184
|
-
if not path.is_absolute():
|
|
185
|
-
path = repo_root / path
|
|
186
|
-
resolved = path.resolve()
|
|
187
|
-
worktree_root = (repo_root / DIR_WORKFLOW / "worktrees").resolve()
|
|
188
|
-
try:
|
|
189
|
-
resolved.relative_to(worktree_root)
|
|
190
|
-
except ValueError:
|
|
191
|
-
return None, f"worktree path must stay under {_repo_relative_path(worktree_root, repo_root)}"
|
|
192
|
-
return resolved, None
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _non_trellis_dirty_paths(repo_root: Path) -> list[str]:
|
|
196
|
-
rc, out, err = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
197
|
-
if rc != 0:
|
|
198
|
-
return [err.strip() or "git status failed"]
|
|
199
|
-
|
|
200
|
-
dirty: list[str] = []
|
|
201
|
-
for raw_line in out.splitlines():
|
|
202
|
-
path = raw_line[3:].strip()
|
|
203
|
-
if " -> " in path:
|
|
204
|
-
path = path.split(" -> ", 1)[1].strip()
|
|
205
|
-
path = path.replace("\\", "/").strip('"')
|
|
206
|
-
if path and path != ".trellis" and not path.startswith(".trellis/"):
|
|
207
|
-
dirty.append(path)
|
|
208
|
-
return dirty
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _validate_merge_execution(repo_root: Path, state: str, ref: str | None) -> list[str]:
|
|
212
|
-
errors = _validate_git_repo(repo_root)
|
|
213
|
-
if state != "integrated":
|
|
214
|
-
errors.append("--execute-merge is only valid with state `integrated`")
|
|
215
|
-
if not _short_cli_ref(ref):
|
|
216
|
-
errors.append("--execute-merge requires --ref")
|
|
217
|
-
elif not _git_commit_exists(repo_root, ref):
|
|
218
|
-
errors.append(f"merge ref does not resolve to a commit: {ref}")
|
|
219
|
-
|
|
220
|
-
dirty = _non_trellis_dirty_paths(repo_root)
|
|
221
|
-
if dirty:
|
|
222
|
-
errors.append(
|
|
223
|
-
"non-Trellis working tree changes block merge execution: "
|
|
224
|
-
+ ", ".join(dirty[:8])
|
|
225
|
-
)
|
|
226
|
-
return errors
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
# =============================================================================
|
|
230
|
-
# Sub-agent platform detection + JSONL seeding
|
|
231
|
-
# =============================================================================
|
|
232
|
-
|
|
233
|
-
# Config directories of platforms that consume implement.jsonl / check.jsonl.
|
|
234
|
-
# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
|
|
235
|
-
# platforms listed in workflow.md's "agent-capable" Skill Routing block
|
|
236
|
-
# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
|
|
237
|
-
# Windsurf are NOT in this list: they do not consume JSONL.
|
|
238
|
-
_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
|
|
239
|
-
".claude",
|
|
240
|
-
".cursor",
|
|
241
|
-
".codex",
|
|
242
|
-
".kiro",
|
|
243
|
-
".gemini",
|
|
244
|
-
".opencode",
|
|
245
|
-
".qoder",
|
|
246
|
-
".codebuddy",
|
|
247
|
-
".factory", # Factory Droid
|
|
248
|
-
".github/copilot",
|
|
249
|
-
".pi", # Pi Agent
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
_SEED_EXAMPLE = (
|
|
253
|
-
"Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
|
|
254
|
-
"Put spec/research files only — no code paths. "
|
|
255
|
-
"Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. "
|
|
256
|
-
"Delete this line once real entries are added."
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def _has_subagent_platform(repo_root: Path) -> bool:
|
|
261
|
-
"""Return True if any sub-agent-capable platform is configured.
|
|
262
|
-
|
|
263
|
-
Detected by probing well-known config directories at the repo root. Used
|
|
264
|
-
only to decide whether ``task.py create`` should seed empty
|
|
265
|
-
``implement.jsonl`` / ``check.jsonl`` files.
|
|
266
|
-
"""
|
|
267
|
-
for config_dir in _SUBAGENT_CONFIG_DIRS:
|
|
268
|
-
if (repo_root / config_dir).is_dir():
|
|
269
|
-
return True
|
|
270
|
-
return False
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _write_seed_jsonl(path: Path) -> None:
|
|
274
|
-
"""Write a one-line seed JSONL file with a self-describing ``_example``.
|
|
275
|
-
|
|
276
|
-
The seed row has no ``file`` field, so downstream consumers (hooks +
|
|
277
|
-
preludes) that iterate entries via ``item.get("file")`` naturally skip
|
|
278
|
-
it. The row exists purely as an in-file prompt for the AI curator.
|
|
279
|
-
"""
|
|
280
|
-
seed = {"_example": _SEED_EXAMPLE}
|
|
281
|
-
path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def _default_prd_content(title: str, description: str | None = None) -> str:
|
|
285
|
-
"""Return the default PRD skeleton created with every task."""
|
|
286
|
-
goal = (description or "").strip() or "TBD."
|
|
287
|
-
heading = title.strip() or "Untitled task"
|
|
288
|
-
return f"""# {heading}
|
|
289
|
-
|
|
290
|
-
## Goal
|
|
291
|
-
|
|
292
|
-
{goal}
|
|
293
|
-
|
|
294
|
-
## Requirements
|
|
295
|
-
|
|
296
|
-
- TBD
|
|
297
|
-
|
|
298
|
-
## Acceptance Criteria
|
|
299
|
-
|
|
300
|
-
- [ ] TBD
|
|
301
|
-
|
|
302
|
-
## Notes
|
|
303
|
-
|
|
304
|
-
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
|
305
|
-
- Lightweight tasks can remain PRD-only.
|
|
306
|
-
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start-execution --check`.
|
|
307
|
-
"""
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
# =============================================================================
|
|
311
|
-
# Command: create
|
|
312
|
-
# =============================================================================
|
|
313
|
-
|
|
314
|
-
def cmd_create(args: argparse.Namespace) -> int:
|
|
315
|
-
"""Create a new task."""
|
|
316
|
-
repo_root = get_repo_root()
|
|
317
|
-
|
|
318
|
-
if not args.title:
|
|
319
|
-
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
|
320
|
-
return 1
|
|
321
|
-
|
|
322
|
-
# Validate --package (CLI source: fail-fast)
|
|
323
|
-
package: str | None = getattr(args, "package", None)
|
|
324
|
-
if not is_monorepo(repo_root):
|
|
325
|
-
# Single-repo: ignore --package, no package prefix
|
|
326
|
-
if package:
|
|
327
|
-
print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
|
|
328
|
-
package = None
|
|
329
|
-
elif package:
|
|
330
|
-
if not validate_package(package, repo_root):
|
|
331
|
-
packages = get_packages(repo_root)
|
|
332
|
-
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
|
333
|
-
print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr)
|
|
334
|
-
return 1
|
|
335
|
-
else:
|
|
336
|
-
# Inferred: default_package → None (no task.json yet for create)
|
|
337
|
-
package = resolve_package(repo_root=repo_root)
|
|
338
|
-
|
|
339
|
-
# Default assignee to current developer
|
|
340
|
-
assignee = args.assignee
|
|
341
|
-
if not assignee:
|
|
342
|
-
assignee = get_developer(repo_root)
|
|
343
|
-
if not assignee:
|
|
344
|
-
print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
|
|
345
|
-
return 1
|
|
346
|
-
|
|
347
|
-
ensure_tasks_dir(repo_root)
|
|
348
|
-
|
|
349
|
-
# Get current developer as creator
|
|
350
|
-
creator = get_developer(repo_root) or assignee
|
|
351
|
-
|
|
352
|
-
# Generate slug if not provided
|
|
353
|
-
slug = args.slug or _slugify(args.title)
|
|
354
|
-
if not slug:
|
|
355
|
-
print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
|
|
356
|
-
return 1
|
|
357
|
-
|
|
358
|
-
# Create task directory with MM-DD-slug format
|
|
359
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
360
|
-
date_prefix = generate_task_date_prefix()
|
|
361
|
-
dir_name = f"{date_prefix}-{slug}"
|
|
362
|
-
task_dir = tasks_dir / dir_name
|
|
363
|
-
task_json_path = task_dir / FILE_TASK_JSON
|
|
364
|
-
|
|
365
|
-
archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
|
|
366
|
-
if archived_task_dir:
|
|
367
|
-
print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr)
|
|
368
|
-
print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
|
|
369
|
-
print("Use a new slug if you intend to create a new task.", file=sys.stderr)
|
|
370
|
-
return 1
|
|
371
|
-
|
|
372
|
-
if task_dir.exists():
|
|
373
|
-
print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
|
|
374
|
-
else:
|
|
375
|
-
task_dir.mkdir(parents=True)
|
|
376
|
-
|
|
377
|
-
today = datetime.now().strftime("%Y-%m-%d")
|
|
378
|
-
|
|
379
|
-
# Record current branch as base_branch (PR target)
|
|
380
|
-
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
381
|
-
current_branch = branch_out.strip() or "main"
|
|
382
|
-
|
|
383
|
-
task_data = {
|
|
384
|
-
"id": slug,
|
|
385
|
-
"name": slug,
|
|
386
|
-
"title": args.title,
|
|
387
|
-
"description": args.description or "",
|
|
388
|
-
"status": "planning",
|
|
389
|
-
"dev_type": None,
|
|
390
|
-
"scope": None,
|
|
391
|
-
"package": package,
|
|
392
|
-
"priority": args.priority,
|
|
393
|
-
"creator": creator,
|
|
394
|
-
"assignee": assignee,
|
|
395
|
-
"createdAt": today,
|
|
396
|
-
"completedAt": None,
|
|
397
|
-
"branch": None,
|
|
398
|
-
"base_branch": current_branch,
|
|
399
|
-
"worktree_path": None,
|
|
400
|
-
"commit": None,
|
|
401
|
-
"pr_url": None,
|
|
402
|
-
"subtasks": [],
|
|
403
|
-
"children": [],
|
|
404
|
-
"parent": None,
|
|
405
|
-
"relatedFiles": [],
|
|
406
|
-
"notes": "",
|
|
407
|
-
"meta": {},
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
write_json(task_json_path, task_data)
|
|
411
|
-
|
|
412
|
-
prd_path = task_dir / "prd.md"
|
|
413
|
-
if not prd_path.exists():
|
|
414
|
-
prd_path.write_text(
|
|
415
|
-
_default_prd_content(args.title, args.description),
|
|
416
|
-
encoding="utf-8",
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
# Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
|
|
420
|
-
# Agent curates real entries during planning when the task needs them.
|
|
421
|
-
# Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they
|
|
422
|
-
# load specs via the
|
|
423
|
-
seeded_jsonl = False
|
|
424
|
-
if _has_subagent_platform(repo_root):
|
|
425
|
-
for jsonl_name in ("implement.jsonl", "check.jsonl"):
|
|
426
|
-
jsonl_path = task_dir / jsonl_name
|
|
427
|
-
if not jsonl_path.exists():
|
|
428
|
-
_write_seed_jsonl(jsonl_path)
|
|
429
|
-
seeded_jsonl = True
|
|
430
|
-
|
|
431
|
-
# Handle --parent: establish bidirectional link
|
|
432
|
-
if args.parent:
|
|
433
|
-
parent_dir = resolve_task_dir(args.parent, repo_root)
|
|
434
|
-
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
435
|
-
if not parent_json_path.is_file():
|
|
436
|
-
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
|
|
437
|
-
else:
|
|
438
|
-
parent_data = read_json(parent_json_path)
|
|
439
|
-
if parent_data:
|
|
440
|
-
# Add child to parent's children list
|
|
441
|
-
parent_children = parent_data.get("children", [])
|
|
442
|
-
if dir_name not in parent_children:
|
|
443
|
-
parent_children.append(dir_name)
|
|
444
|
-
parent_data["children"] = parent_children
|
|
445
|
-
write_json(parent_json_path, parent_data)
|
|
446
|
-
|
|
447
|
-
# Set parent in child's task.json
|
|
448
|
-
task_data["parent"] = parent_dir.name
|
|
449
|
-
write_json(task_json_path, task_data)
|
|
450
|
-
ensure_task_map(
|
|
451
|
-
parent_dir,
|
|
452
|
-
parent_data,
|
|
453
|
-
list(parent_data.get("children", [])),
|
|
454
|
-
f"Linked Child `{dir_name}`.",
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
458
|
-
|
|
459
|
-
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
|
|
460
|
-
print("", file=sys.stderr)
|
|
461
|
-
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
|
462
|
-
print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
|
|
463
|
-
print(f" - Select it when ready: python ./.trellis/scripts/task.py select {DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}", file=sys.stderr)
|
|
464
|
-
print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
|
|
465
|
-
print(" - Complex task: add design.md and implement.md before task.py start-execution --check", file=sys.stderr)
|
|
466
|
-
if seeded_jsonl:
|
|
467
|
-
print(
|
|
468
|
-
" - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
|
|
469
|
-
file=sys.stderr,
|
|
470
|
-
)
|
|
471
|
-
print(" - Use /
|
|
472
|
-
print("", file=sys.stderr)
|
|
473
|
-
|
|
474
|
-
# Output relative path for script chaining
|
|
475
|
-
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
|
476
|
-
|
|
477
|
-
run_task_hooks("after_create", task_json_path, repo_root)
|
|
478
|
-
return 0
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
# =============================================================================
|
|
482
|
-
# Command: archive / prepare-archive-evidence
|
|
483
|
-
# =============================================================================
|
|
484
|
-
|
|
485
|
-
def cmd_prepare_archive_evidence(args: argparse.Namespace) -> int:
|
|
486
|
-
"""Append missing archive evidence sections to verify.md (non-destructive)."""
|
|
487
|
-
repo_root = get_repo_root()
|
|
488
|
-
task_name = args.name
|
|
489
|
-
if not task_name:
|
|
490
|
-
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
|
491
|
-
return 1
|
|
492
|
-
|
|
493
|
-
task_dir = resolve_task_dir(task_name, repo_root)
|
|
494
|
-
if not task_dir or not task_dir.is_dir():
|
|
495
|
-
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
|
496
|
-
return 1
|
|
497
|
-
|
|
498
|
-
task_json_path = task_dir / FILE_TASK_JSON
|
|
499
|
-
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
500
|
-
dry_run = getattr(args, "dry_run", False)
|
|
501
|
-
|
|
502
|
-
changed, messages = prepare_archive_evidence(
|
|
503
|
-
task_dir, task_data, dry_run=dry_run
|
|
504
|
-
)
|
|
505
|
-
for msg in messages:
|
|
506
|
-
print(msg)
|
|
507
|
-
if not changed and messages and messages[0].startswith("task.json"):
|
|
508
|
-
return 1
|
|
509
|
-
|
|
510
|
-
guard = validate_archive(task_dir, task_data)
|
|
511
|
-
if guard.ok:
|
|
512
|
-
print(colored("Archive check: PASS (after prepare)", Colors.GREEN))
|
|
513
|
-
return 0
|
|
514
|
-
print(colored("Archive check: still blocked", Colors.YELLOW))
|
|
515
|
-
for item in guard.errors:
|
|
516
|
-
print(f" - {item}")
|
|
517
|
-
if task_data is not None:
|
|
518
|
-
hints = archive_repair_hints(guard.errors, task_dir, task_data, guard)
|
|
519
|
-
if hints:
|
|
520
|
-
print(colored("Next steps:", Colors.BLUE))
|
|
521
|
-
for hint in hints:
|
|
522
|
-
print(f" - {hint}")
|
|
523
|
-
return 0 if changed else 1
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
def cmd_prepare_learning_scaffold(args: argparse.Namespace) -> int:
|
|
527
|
-
"""Print spec-update scaffolding for a task (stdout only; does not edit specs)."""
|
|
528
|
-
repo_root = get_repo_root()
|
|
529
|
-
task_name = args.name
|
|
530
|
-
if not task_name:
|
|
531
|
-
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
|
532
|
-
return 1
|
|
533
|
-
|
|
534
|
-
task_dir = resolve_task_dir(task_name, repo_root)
|
|
535
|
-
if not task_dir or not task_dir.is_dir():
|
|
536
|
-
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
|
537
|
-
return 1
|
|
538
|
-
|
|
539
|
-
task_json_path = task_dir / FILE_TASK_JSON
|
|
540
|
-
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
541
|
-
if task_data is None:
|
|
542
|
-
print(colored("Error: task.json missing or invalid", Colors.RED), file=sys.stderr)
|
|
543
|
-
return 1
|
|
544
|
-
|
|
545
|
-
trigger = getattr(args, "trigger", None)
|
|
546
|
-
print(build_spec_update_scaffold(repo_root, task_dir, task_data, trigger=trigger))
|
|
547
|
-
return 0
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
def _integrated_children_still_active(
|
|
551
|
-
parent_dir: Path,
|
|
552
|
-
child_names: list[str],
|
|
553
|
-
tasks_dir: Path,
|
|
554
|
-
) -> list[tuple[str, Path]]:
|
|
555
|
-
"""Return (name, dir) for integrated children that remain in the active set."""
|
|
556
|
-
pending: list[tuple[str, Path]] = []
|
|
557
|
-
for child_name in child_names:
|
|
558
|
-
if not isinstance(child_name, str):
|
|
559
|
-
continue
|
|
560
|
-
if get_child_state(parent_dir, child_name) != "integrated":
|
|
561
|
-
continue
|
|
562
|
-
child_dir = find_task_by_name(child_name, tasks_dir)
|
|
563
|
-
if child_dir and child_dir.is_dir():
|
|
564
|
-
pending.append((child_name, child_dir))
|
|
565
|
-
return pending
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
def _archive_one_task(
|
|
569
|
-
task_dir: Path,
|
|
570
|
-
repo_root: Path,
|
|
571
|
-
tasks_dir: Path,
|
|
572
|
-
*,
|
|
573
|
-
no_commit: bool,
|
|
574
|
-
) -> tuple[bool, list[str], str | None]:
|
|
575
|
-
"""Archive a single task directory after validate_archive passed.
|
|
576
|
-
|
|
577
|
-
Returns (success, modified_child_names, archived_relative_path_or_none).
|
|
578
|
-
"""
|
|
579
|
-
dir_name = task_dir.name
|
|
580
|
-
task_json_path = task_dir / FILE_TASK_JSON
|
|
581
|
-
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
582
|
-
guard = validate_archive(task_dir, task_data)
|
|
583
|
-
if not guard.ok:
|
|
584
|
-
return False, [], None
|
|
585
|
-
|
|
586
|
-
today = datetime.now().strftime("%Y-%m-%d")
|
|
587
|
-
modified_children: list[str] = []
|
|
588
|
-
if task_data:
|
|
589
|
-
data = task_data
|
|
590
|
-
if guard.baseline_record:
|
|
591
|
-
write_gate_record(data, "full-task-complete", BASELINE_GATE, guard.baseline_record)
|
|
592
|
-
data["status"] = "completed"
|
|
593
|
-
data["completedAt"] = today
|
|
594
|
-
write_json(task_json_path, data)
|
|
595
|
-
|
|
596
|
-
task_children = data.get("children", [])
|
|
597
|
-
if task_children:
|
|
598
|
-
for child_name in task_children:
|
|
599
|
-
child_dir_path = find_task_by_name(child_name, tasks_dir)
|
|
600
|
-
if child_dir_path:
|
|
601
|
-
child_json = child_dir_path / FILE_TASK_JSON
|
|
602
|
-
if child_json.is_file():
|
|
603
|
-
child_data = read_json(child_json)
|
|
604
|
-
if child_data:
|
|
605
|
-
child_data["parent"] = None
|
|
606
|
-
write_json(child_json, child_data)
|
|
607
|
-
modified_children.append(child_dir_path.name)
|
|
608
|
-
|
|
609
|
-
from .active_task import clear_task_from_sessions
|
|
610
|
-
|
|
611
|
-
clear_task_from_sessions(str(task_dir), repo_root)
|
|
612
|
-
result = archive_task_complete(task_dir, repo_root)
|
|
613
|
-
if "archived_to" not in result:
|
|
614
|
-
return False, modified_children, None
|
|
615
|
-
|
|
616
|
-
archive_dest = Path(result["archived_to"])
|
|
617
|
-
year_month = archive_dest.parent.name
|
|
618
|
-
print(
|
|
619
|
-
colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN),
|
|
620
|
-
file=sys.stderr,
|
|
621
|
-
)
|
|
622
|
-
|
|
623
|
-
if not no_commit:
|
|
624
|
-
if not _auto_commit_archive(dir_name, repo_root, modified_children):
|
|
625
|
-
print(
|
|
626
|
-
colored(
|
|
627
|
-
"Archive moved on disk, but git auto-commit did not complete. "
|
|
628
|
-
"Resolve `git status` before continuing.",
|
|
629
|
-
Colors.RED,
|
|
630
|
-
),
|
|
631
|
-
file=sys.stderr,
|
|
632
|
-
)
|
|
633
|
-
return False, modified_children, None
|
|
634
|
-
|
|
635
|
-
rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}"
|
|
636
|
-
archived_json = archive_dest / FILE_TASK_JSON
|
|
637
|
-
run_task_hooks("after_archive", archived_json, repo_root)
|
|
638
|
-
return True, modified_children, rel
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def cmd_archive(args: argparse.Namespace) -> int:
|
|
642
|
-
"""Archive completed task."""
|
|
643
|
-
repo_root = get_repo_root()
|
|
644
|
-
task_name = args.name
|
|
645
|
-
|
|
646
|
-
if not task_name:
|
|
647
|
-
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
|
648
|
-
return 1
|
|
649
|
-
|
|
650
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
651
|
-
|
|
652
|
-
# Resolve task directory (supports task name, relative path, or absolute path)
|
|
653
|
-
task_dir = resolve_task_dir(task_name, repo_root)
|
|
654
|
-
|
|
655
|
-
if not task_dir or not task_dir.is_dir():
|
|
656
|
-
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
|
657
|
-
print("Active tasks:", file=sys.stderr)
|
|
658
|
-
# Import lazily to avoid circular dependency
|
|
659
|
-
from .tasks import iter_active_tasks
|
|
660
|
-
for t in iter_active_tasks(tasks_dir):
|
|
661
|
-
print(f" - {t.dir_name}/", file=sys.stderr)
|
|
662
|
-
return 1
|
|
663
|
-
|
|
664
|
-
dir_name = task_dir.name
|
|
665
|
-
task_json_path = task_dir / FILE_TASK_JSON
|
|
666
|
-
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
667
|
-
|
|
668
|
-
guard = validate_archive(task_dir, task_data)
|
|
669
|
-
task_children_raw = (
|
|
670
|
-
task_data.get("children", []) if isinstance(task_data, dict) else []
|
|
671
|
-
)
|
|
672
|
-
structural_children = [
|
|
673
|
-
name for name in task_children_raw if isinstance(name, str)
|
|
674
|
-
]
|
|
675
|
-
cascade = getattr(args, "archive_integrated_children", False)
|
|
676
|
-
pending_integrated = _integrated_children_still_active(
|
|
677
|
-
task_dir, structural_children, tasks_dir
|
|
678
|
-
)
|
|
679
|
-
|
|
680
|
-
if getattr(args, "check", False):
|
|
681
|
-
if not guard.ok:
|
|
682
|
-
print(colored("Archive check: FAIL", Colors.RED))
|
|
683
|
-
for item in guard.errors:
|
|
684
|
-
print(f" - {item}")
|
|
685
|
-
if task_data is not None:
|
|
686
|
-
hints = archive_repair_hints(
|
|
687
|
-
guard.errors, task_dir, task_data, guard
|
|
688
|
-
)
|
|
689
|
-
if hints:
|
|
690
|
-
print(colored("Next steps:", Colors.BLUE))
|
|
691
|
-
for hint in hints:
|
|
692
|
-
print(f" - {hint}")
|
|
693
|
-
return 1
|
|
694
|
-
if cascade and pending_integrated:
|
|
695
|
-
for child_name, child_dir in pending_integrated:
|
|
696
|
-
child_data = read_json(child_dir / FILE_TASK_JSON)
|
|
697
|
-
child_guard = validate_archive(child_dir, child_data)
|
|
698
|
-
if not child_guard.ok:
|
|
699
|
-
print(colored("Archive check: FAIL", Colors.RED))
|
|
700
|
-
print(
|
|
701
|
-
f" - integrated child {child_name} not ready to archive:"
|
|
702
|
-
)
|
|
703
|
-
for item in child_guard.errors:
|
|
704
|
-
print(f" - {item}")
|
|
705
|
-
if child_data is not None:
|
|
706
|
-
hints = archive_repair_hints(
|
|
707
|
-
child_guard.errors, child_dir, child_data, child_guard
|
|
708
|
-
)
|
|
709
|
-
if hints:
|
|
710
|
-
print(colored("Next steps:", Colors.BLUE))
|
|
711
|
-
for hint in hints:
|
|
712
|
-
print(f" - {hint}")
|
|
713
|
-
return 1
|
|
714
|
-
print(colored("Archive check: PASS", Colors.GREEN))
|
|
715
|
-
print(f"Contract fingerprint: {guard.contract_fingerprint}")
|
|
716
|
-
if guard.required_gates:
|
|
717
|
-
print(f"Required completion gates: {', '.join(guard.required_gates)}")
|
|
718
|
-
if pending_integrated and not cascade:
|
|
719
|
-
hint = parent_archive_child_followup_hint(
|
|
720
|
-
task_dir, structural_children, tasks_dir
|
|
721
|
-
)
|
|
722
|
-
if hint:
|
|
723
|
-
print(hint)
|
|
724
|
-
if cascade and pending_integrated:
|
|
725
|
-
names = ", ".join(n for n, _ in pending_integrated)
|
|
726
|
-
print(
|
|
727
|
-
f"Cascade: {len(pending_integrated)} integrated child dir(s) "
|
|
728
|
-
f"would archive with parent: {names}"
|
|
729
|
-
)
|
|
730
|
-
return 0
|
|
731
|
-
|
|
732
|
-
if not guard.ok:
|
|
733
|
-
print(colored("Error: cannot archive task; completion check failed.", Colors.RED), file=sys.stderr)
|
|
734
|
-
for item in guard.errors:
|
|
735
|
-
print(f" - {item}", file=sys.stderr)
|
|
736
|
-
if task_data is not None:
|
|
737
|
-
hints = archive_repair_hints(guard.errors, task_dir, task_data, guard)
|
|
738
|
-
if hints:
|
|
739
|
-
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
|
740
|
-
for hint in hints:
|
|
741
|
-
print(f" - {hint}", file=sys.stderr)
|
|
742
|
-
print("Run `task.py archive <task> --check` for a non-mutating preflight.", file=sys.stderr)
|
|
743
|
-
return 1
|
|
744
|
-
|
|
745
|
-
no_commit = getattr(args, "no_commit", False)
|
|
746
|
-
if cascade and pending_integrated:
|
|
747
|
-
for child_name, child_dir in pending_integrated:
|
|
748
|
-
child_data = read_json(child_dir / FILE_TASK_JSON)
|
|
749
|
-
child_guard = validate_archive(child_dir, child_data)
|
|
750
|
-
if not child_guard.ok:
|
|
751
|
-
print(
|
|
752
|
-
colored(
|
|
753
|
-
f"Error: integrated child {child_name} failed archive check.",
|
|
754
|
-
Colors.RED,
|
|
755
|
-
),
|
|
756
|
-
file=sys.stderr,
|
|
757
|
-
)
|
|
758
|
-
for item in child_guard.errors:
|
|
759
|
-
print(f" - {item}", file=sys.stderr)
|
|
760
|
-
return 1
|
|
761
|
-
for child_name, child_dir in pending_integrated:
|
|
762
|
-
ok, _, _ = _archive_one_task(
|
|
763
|
-
child_dir, repo_root, tasks_dir, no_commit=no_commit
|
|
764
|
-
)
|
|
765
|
-
if not ok:
|
|
766
|
-
print(
|
|
767
|
-
colored(
|
|
768
|
-
f"Error: failed to archive integrated child {child_name}.",
|
|
769
|
-
Colors.RED,
|
|
770
|
-
),
|
|
771
|
-
file=sys.stderr,
|
|
772
|
-
)
|
|
773
|
-
return 1
|
|
774
|
-
|
|
775
|
-
manual_child_hint = None
|
|
776
|
-
if pending_integrated and not cascade:
|
|
777
|
-
manual_child_hint = parent_archive_child_followup_hint(
|
|
778
|
-
task_dir, structural_children, tasks_dir
|
|
779
|
-
)
|
|
780
|
-
|
|
781
|
-
ok, _, rel_path = _archive_one_task(
|
|
782
|
-
task_dir, repo_root, tasks_dir, no_commit=no_commit
|
|
783
|
-
)
|
|
784
|
-
if not ok or not rel_path:
|
|
785
|
-
return 1
|
|
786
|
-
|
|
787
|
-
if manual_child_hint:
|
|
788
|
-
print(colored("Note:", Colors.YELLOW), file=sys.stderr)
|
|
789
|
-
print(manual_child_hint, file=sys.stderr)
|
|
790
|
-
|
|
791
|
-
print(rel_path)
|
|
792
|
-
return 0
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
def _auto_commit_archive(
|
|
796
|
-
task_name: str,
|
|
797
|
-
repo_root: Path,
|
|
798
|
-
modified_children: list[str] | None = None,
|
|
799
|
-
) -> bool:
|
|
800
|
-
"""Stage Trellis-owned task paths and commit after archive.
|
|
801
|
-
|
|
802
|
-
Scoped narrowly to the archived task's source + destination paths
|
|
803
|
-
plus any child task dirs whose ``task.json`` was edited (parent →
|
|
804
|
-
children relationship update). Dirty changes in OTHER active task
|
|
805
|
-
dirs are NOT bundled into the archive commit.
|
|
806
|
-
|
|
807
|
-
If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
|
|
808
|
-
retry with ``git add -f``. The warning explicitly forbids
|
|
809
|
-
``git add -f .trellis/`` (which would fan out to caches/backups)
|
|
810
|
-
and points users at ``session_auto_commit: false``.
|
|
811
|
-
|
|
812
|
-
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
|
|
813
|
-
set to ``false``, this function returns immediately without
|
|
814
|
-
touching git (the archive directory move on disk is unaffected).
|
|
815
|
-
"""
|
|
816
|
-
if not get_session_auto_commit(repo_root):
|
|
817
|
-
print(
|
|
818
|
-
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
|
819
|
-
file=sys.stderr,
|
|
820
|
-
)
|
|
821
|
-
return True
|
|
822
|
-
|
|
823
|
-
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
|
|
824
|
-
rc, tracked_out, _ = run_git(
|
|
825
|
-
["ls-files", "--", source_rel],
|
|
826
|
-
cwd=repo_root,
|
|
827
|
-
)
|
|
828
|
-
source_was_tracked = rc == 0 and bool(tracked_out.strip())
|
|
829
|
-
|
|
830
|
-
paths = safe_archive_paths_to_add(
|
|
831
|
-
repo_root, task_name=task_name, modified_children=modified_children
|
|
832
|
-
)
|
|
833
|
-
if not paths:
|
|
834
|
-
print("[OK] No task changes to commit.", file=sys.stderr)
|
|
835
|
-
return True
|
|
836
|
-
|
|
837
|
-
success, _, err = safe_git_add(paths, repo_root)
|
|
838
|
-
if not success:
|
|
839
|
-
if err and "ignored by" in err.lower():
|
|
840
|
-
print_gitignore_warning(paths)
|
|
841
|
-
else:
|
|
842
|
-
print(
|
|
843
|
-
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
|
844
|
-
file=sys.stderr,
|
|
845
|
-
)
|
|
846
|
-
return not source_was_tracked
|
|
847
|
-
|
|
848
|
-
# Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
|
|
849
|
-
# `git add` (no -A) which only stages additions/modifications. The
|
|
850
|
-
# source task directory was moved away by `shutil.move`, so its files
|
|
851
|
-
# need an explicit `git rm --cached` to stage the deletions in this
|
|
852
|
-
# same commit — otherwise they sit as uncommitted "phantom deletes"
|
|
853
|
-
# against HEAD until something later picks them up.
|
|
854
|
-
#
|
|
855
|
-
# `--ignore-unmatch` makes this a no-op when the task was never tracked
|
|
856
|
-
# (e.g. archiving a task that lived only in working tree).
|
|
857
|
-
run_git(
|
|
858
|
-
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
|
|
859
|
-
cwd=repo_root,
|
|
860
|
-
)
|
|
861
|
-
|
|
862
|
-
rc, _, _ = run_git(
|
|
863
|
-
["diff", "--cached", "--quiet", "--", *paths, source_rel],
|
|
864
|
-
cwd=repo_root,
|
|
865
|
-
)
|
|
866
|
-
if rc == 0:
|
|
867
|
-
print("[OK] No task changes to commit.", file=sys.stderr)
|
|
868
|
-
return True
|
|
869
|
-
|
|
870
|
-
commit_msg = f"chore(task): archive {task_name}"
|
|
871
|
-
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
|
872
|
-
if rc == 0:
|
|
873
|
-
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
|
874
|
-
return True
|
|
875
|
-
else:
|
|
876
|
-
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
|
877
|
-
return not source_was_tracked
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
# =============================================================================
|
|
881
|
-
# Command: add-subtask
|
|
882
|
-
# =============================================================================
|
|
883
|
-
|
|
884
|
-
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
|
885
|
-
"""Link a child task to a parent task."""
|
|
886
|
-
repo_root = get_repo_root()
|
|
887
|
-
|
|
888
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
889
|
-
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
890
|
-
|
|
891
|
-
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
892
|
-
child_json_path = child_dir / FILE_TASK_JSON
|
|
893
|
-
|
|
894
|
-
if not parent_json_path.is_file():
|
|
895
|
-
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
896
|
-
return 1
|
|
897
|
-
|
|
898
|
-
if not child_json_path.is_file():
|
|
899
|
-
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
900
|
-
return 1
|
|
901
|
-
|
|
902
|
-
parent_data = read_json(parent_json_path)
|
|
903
|
-
child_data = read_json(child_json_path)
|
|
904
|
-
|
|
905
|
-
if not parent_data or not child_data:
|
|
906
|
-
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
907
|
-
return 1
|
|
908
|
-
|
|
909
|
-
# Check if child already has a parent
|
|
910
|
-
existing_parent = child_data.get("parent")
|
|
911
|
-
if existing_parent:
|
|
912
|
-
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
|
|
913
|
-
return 1
|
|
914
|
-
|
|
915
|
-
# Add child to parent's children list
|
|
916
|
-
parent_children = parent_data.get("children", [])
|
|
917
|
-
child_dir_name = child_dir.name
|
|
918
|
-
if child_dir_name not in parent_children:
|
|
919
|
-
parent_children.append(child_dir_name)
|
|
920
|
-
parent_data["children"] = parent_children
|
|
921
|
-
|
|
922
|
-
# Set parent in child's task.json
|
|
923
|
-
child_data["parent"] = parent_dir.name
|
|
924
|
-
|
|
925
|
-
# Write both
|
|
926
|
-
write_json(parent_json_path, parent_data)
|
|
927
|
-
write_json(child_json_path, child_data)
|
|
928
|
-
ensure_task_map(
|
|
929
|
-
parent_dir,
|
|
930
|
-
parent_data,
|
|
931
|
-
list(parent_data.get("children", [])),
|
|
932
|
-
f"Linked Child `{child_dir_name}`.",
|
|
933
|
-
)
|
|
934
|
-
|
|
935
|
-
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
936
|
-
return 0
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
# =============================================================================
|
|
940
|
-
# Command: remove-subtask
|
|
941
|
-
# =============================================================================
|
|
942
|
-
|
|
943
|
-
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
944
|
-
"""Unlink a child task from a parent task."""
|
|
945
|
-
repo_root = get_repo_root()
|
|
946
|
-
|
|
947
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
948
|
-
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
949
|
-
|
|
950
|
-
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
951
|
-
child_json_path = child_dir / FILE_TASK_JSON
|
|
952
|
-
|
|
953
|
-
if not parent_json_path.is_file():
|
|
954
|
-
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
955
|
-
return 1
|
|
956
|
-
|
|
957
|
-
if not child_json_path.is_file():
|
|
958
|
-
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
959
|
-
return 1
|
|
960
|
-
|
|
961
|
-
parent_data = read_json(parent_json_path)
|
|
962
|
-
child_data = read_json(child_json_path)
|
|
963
|
-
|
|
964
|
-
if not parent_data or not child_data:
|
|
965
|
-
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
966
|
-
return 1
|
|
967
|
-
|
|
968
|
-
# Remove child from parent's children list
|
|
969
|
-
parent_children = parent_data.get("children", [])
|
|
970
|
-
child_dir_name = child_dir.name
|
|
971
|
-
if child_dir_name in parent_children:
|
|
972
|
-
parent_children.remove(child_dir_name)
|
|
973
|
-
parent_data["children"] = parent_children
|
|
974
|
-
|
|
975
|
-
# Clear parent in child's task.json
|
|
976
|
-
child_data["parent"] = None
|
|
977
|
-
|
|
978
|
-
# Write both
|
|
979
|
-
write_json(parent_json_path, parent_data)
|
|
980
|
-
write_json(child_json_path, child_data)
|
|
981
|
-
remove_child_from_task_map(parent_dir, parent_data, child_dir_name)
|
|
982
|
-
|
|
983
|
-
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
984
|
-
return 0
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
# =============================================================================
|
|
988
|
-
# Command: prepare-child-worktree
|
|
989
|
-
# =============================================================================
|
|
990
|
-
|
|
991
|
-
def cmd_prepare_child_worktree(args: argparse.Namespace) -> int:
|
|
992
|
-
"""Create and register a Git worktree for a Child task."""
|
|
993
|
-
repo_root = get_repo_root()
|
|
994
|
-
|
|
995
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
996
|
-
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
997
|
-
|
|
998
|
-
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
999
|
-
child_json_path = child_dir / FILE_TASK_JSON
|
|
1000
|
-
|
|
1001
|
-
if not parent_json_path.is_file():
|
|
1002
|
-
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
1003
|
-
return 1
|
|
1004
|
-
if not child_json_path.is_file():
|
|
1005
|
-
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
1006
|
-
return 1
|
|
1007
|
-
|
|
1008
|
-
parent_data = read_json(parent_json_path)
|
|
1009
|
-
child_data = read_json(child_json_path)
|
|
1010
|
-
if not parent_data or not child_data:
|
|
1011
|
-
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1012
|
-
return 1
|
|
1013
|
-
|
|
1014
|
-
if child_data.get("parent") != parent_dir.name:
|
|
1015
|
-
print(colored(f"Error: Child is not linked to parent: {child_dir.name}", Colors.RED), file=sys.stderr)
|
|
1016
|
-
return 1
|
|
1017
|
-
|
|
1018
|
-
branch = args.branch
|
|
1019
|
-
base_ref = getattr(args, "base", None) or child_data.get("base_branch") or parent_data.get("base_branch") or "HEAD"
|
|
1020
|
-
worktree_path, path_error = _resolve_child_worktree_path(
|
|
1021
|
-
repo_root,
|
|
1022
|
-
child_dir,
|
|
1023
|
-
getattr(args, "path", None),
|
|
1024
|
-
)
|
|
1025
|
-
|
|
1026
|
-
errors: list[str] = []
|
|
1027
|
-
errors.extend(_validate_git_repo(repo_root))
|
|
1028
|
-
errors.extend(_validate_branch_name(repo_root, branch))
|
|
1029
|
-
if path_error:
|
|
1030
|
-
errors.append(path_error)
|
|
1031
|
-
if worktree_path and worktree_path.exists():
|
|
1032
|
-
errors.append(f"worktree path already exists: {_repo_relative_path(worktree_path, repo_root)}")
|
|
1033
|
-
if not _git_commit_exists(repo_root, base_ref):
|
|
1034
|
-
errors.append(f"base ref does not resolve to a commit: {base_ref}")
|
|
1035
|
-
|
|
1036
|
-
if errors:
|
|
1037
|
-
print("Prepare-child-worktree check: FAIL" if getattr(args, "check", False) else colored("Error: cannot prepare child worktree.", Colors.RED), file=sys.stderr)
|
|
1038
|
-
for item in errors:
|
|
1039
|
-
print(f" - {item}", file=sys.stderr)
|
|
1040
|
-
print_environment_repair_hints(errors)
|
|
1041
|
-
return 1
|
|
1042
|
-
|
|
1043
|
-
assert worktree_path is not None
|
|
1044
|
-
worktree_rel = _repo_relative_path(worktree_path, repo_root)
|
|
1045
|
-
if getattr(args, "check", False):
|
|
1046
|
-
print("Prepare-child-worktree check: PASS")
|
|
1047
|
-
print(f"Branch: {branch}")
|
|
1048
|
-
print(f"Base: {base_ref}")
|
|
1049
|
-
print(f"Path: {worktree_rel}")
|
|
1050
|
-
print("No files changed.")
|
|
1051
|
-
return 0
|
|
1052
|
-
|
|
1053
|
-
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1054
|
-
if _git_branch_exists(repo_root, branch):
|
|
1055
|
-
git_args = ["worktree", "add", str(worktree_path), branch]
|
|
1056
|
-
else:
|
|
1057
|
-
git_args = ["worktree", "add", "-b", branch, str(worktree_path), base_ref]
|
|
1058
|
-
rc, _, err = run_git(git_args, cwd=repo_root)
|
|
1059
|
-
if rc != 0:
|
|
1060
|
-
print(colored("Error: git worktree add failed.", Colors.RED), file=sys.stderr)
|
|
1061
|
-
print(err.strip() or "unknown git error", file=sys.stderr)
|
|
1062
|
-
return 1
|
|
1063
|
-
|
|
1064
|
-
child_data["branch"] = branch
|
|
1065
|
-
child_data["worktree_path"] = worktree_rel
|
|
1066
|
-
write_json(child_json_path, child_data)
|
|
1067
|
-
|
|
1068
|
-
ok, map_errors = record_child_worktree(
|
|
1069
|
-
parent_dir,
|
|
1070
|
-
parent_data,
|
|
1071
|
-
child_dir.name,
|
|
1072
|
-
branch,
|
|
1073
|
-
worktree_rel,
|
|
1074
|
-
base_ref,
|
|
1075
|
-
)
|
|
1076
|
-
if not ok:
|
|
1077
|
-
print(colored("Error: worktree created, but Parent task-map update failed.", Colors.RED), file=sys.stderr)
|
|
1078
|
-
for item in map_errors:
|
|
1079
|
-
print(f" - {item}", file=sys.stderr)
|
|
1080
|
-
return 1
|
|
1081
|
-
|
|
1082
|
-
print(colored(f"✓ Child worktree prepared: {child_dir.name}", Colors.GREEN))
|
|
1083
|
-
print(f"Branch: {branch}")
|
|
1084
|
-
print(f"Path: {worktree_rel}")
|
|
1085
|
-
print(f"Parent map: {_repo_relative_path(parent_dir / 'task-map.md', repo_root)}")
|
|
1086
|
-
return 0
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
# =============================================================================
|
|
1090
|
-
# Command: set-child-state
|
|
1091
|
-
# =============================================================================
|
|
1092
|
-
|
|
1093
|
-
def cmd_set_child_state(args: argparse.Namespace) -> int:
|
|
1094
|
-
"""Set a Child Worker-reported state in the Parent task-map.md."""
|
|
1095
|
-
repo_root = get_repo_root()
|
|
1096
|
-
|
|
1097
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1098
|
-
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1099
|
-
state = args.state
|
|
1100
|
-
|
|
1101
|
-
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
1102
|
-
child_json_path = child_dir / FILE_TASK_JSON
|
|
1103
|
-
|
|
1104
|
-
if not parent_json_path.is_file():
|
|
1105
|
-
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
1106
|
-
return 1
|
|
1107
|
-
if not child_json_path.is_file():
|
|
1108
|
-
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
1109
|
-
return 1
|
|
1110
|
-
|
|
1111
|
-
parent_data = read_json(parent_json_path)
|
|
1112
|
-
child_data = read_json(child_json_path)
|
|
1113
|
-
if not parent_data or not child_data:
|
|
1114
|
-
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1115
|
-
return 1
|
|
1116
|
-
|
|
1117
|
-
if child_data.get("parent") != parent_dir.name:
|
|
1118
|
-
print(colored(f"Error: Child is not linked to parent: {child_dir.name}", Colors.RED), file=sys.stderr)
|
|
1119
|
-
return 1
|
|
1120
|
-
|
|
1121
|
-
if state in CHILD_STATES and not getattr(args, "evidence", None):
|
|
1122
|
-
print(colored("Error: --evidence is required when setting child state", Colors.RED), file=sys.stderr)
|
|
1123
|
-
return 1
|
|
1124
|
-
if state in PARENT_CONTROLLED_STATES:
|
|
1125
|
-
print(
|
|
1126
|
-
colored(
|
|
1127
|
-
"Error: Parent-controlled Child states require `task.py integrate-child`.",
|
|
1128
|
-
Colors.RED,
|
|
1129
|
-
),
|
|
1130
|
-
file=sys.stderr,
|
|
1131
|
-
)
|
|
1132
|
-
print(
|
|
1133
|
-
"Allowed set-child-state values: "
|
|
1134
|
-
+ ", ".join(sorted(CHILD_REPORT_STATES)),
|
|
1135
|
-
file=sys.stderr,
|
|
1136
|
-
)
|
|
1137
|
-
return 1
|
|
1138
|
-
|
|
1139
|
-
ok, errors = set_child_state(
|
|
1140
|
-
parent_dir,
|
|
1141
|
-
parent_data,
|
|
1142
|
-
child_dir.name,
|
|
1143
|
-
state,
|
|
1144
|
-
args.evidence,
|
|
1145
|
-
getattr(args, "reason", None),
|
|
1146
|
-
)
|
|
1147
|
-
if not ok:
|
|
1148
|
-
print(colored("Error: cannot set child state.", Colors.RED), file=sys.stderr)
|
|
1149
|
-
for item in errors:
|
|
1150
|
-
print(f" - {item}", file=sys.stderr)
|
|
1151
|
-
return 1
|
|
1152
|
-
|
|
1153
|
-
print(colored(f"✓ Child state updated: {child_dir.name} -> {state}", Colors.GREEN))
|
|
1154
|
-
print(f"Parent map: {_repo_relative_path(parent_dir / 'task-map.md', repo_root)}")
|
|
1155
|
-
return 0
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
# =============================================================================
|
|
1159
|
-
# Command: integrate-child
|
|
1160
|
-
# =============================================================================
|
|
1161
|
-
|
|
1162
|
-
def cmd_integrate_child(args: argparse.Namespace) -> int:
|
|
1163
|
-
"""Set a Parent-controlled Child integration state."""
|
|
1164
|
-
repo_root = get_repo_root()
|
|
1165
|
-
|
|
1166
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1167
|
-
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1168
|
-
state = args.state
|
|
1169
|
-
|
|
1170
|
-
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
1171
|
-
child_json_path = child_dir / FILE_TASK_JSON
|
|
1172
|
-
|
|
1173
|
-
if not parent_json_path.is_file():
|
|
1174
|
-
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
1175
|
-
return 1
|
|
1176
|
-
if not child_json_path.is_file():
|
|
1177
|
-
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
1178
|
-
return 1
|
|
1179
|
-
|
|
1180
|
-
parent_data = read_json(parent_json_path)
|
|
1181
|
-
child_data = read_json(child_json_path)
|
|
1182
|
-
if not parent_data or not child_data:
|
|
1183
|
-
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1184
|
-
return 1
|
|
1185
|
-
|
|
1186
|
-
if state not in PARENT_CONTROLLED_STATES:
|
|
1187
|
-
print(
|
|
1188
|
-
colored("Error: state is not Parent-controlled.", Colors.RED),
|
|
1189
|
-
file=sys.stderr,
|
|
1190
|
-
)
|
|
1191
|
-
print(
|
|
1192
|
-
"Allowed integrate-child values: "
|
|
1193
|
-
+ ", ".join(sorted(PARENT_CONTROLLED_STATES)),
|
|
1194
|
-
file=sys.stderr,
|
|
1195
|
-
)
|
|
1196
|
-
return 1
|
|
1197
|
-
|
|
1198
|
-
evidence = args.evidence
|
|
1199
|
-
ref = getattr(args, "ref", None)
|
|
1200
|
-
reason = getattr(args, "reason", None)
|
|
1201
|
-
execute_merge = getattr(args, "execute_merge", False)
|
|
1202
|
-
if getattr(args, "check", False):
|
|
1203
|
-
errors = validate_parent_child_integration(
|
|
1204
|
-
parent_dir,
|
|
1205
|
-
parent_data,
|
|
1206
|
-
child_dir,
|
|
1207
|
-
child_data,
|
|
1208
|
-
state,
|
|
1209
|
-
evidence,
|
|
1210
|
-
ref,
|
|
1211
|
-
reason,
|
|
1212
|
-
)
|
|
1213
|
-
if execute_merge:
|
|
1214
|
-
errors.extend(_validate_merge_execution(repo_root, state, ref))
|
|
1215
|
-
if errors:
|
|
1216
|
-
print("Integrate-child check: FAIL")
|
|
1217
|
-
for item in errors:
|
|
1218
|
-
print(f" - {item}")
|
|
1219
|
-
print_environment_repair_hints(errors, stream=sys.stdout)
|
|
1220
|
-
return 1
|
|
1221
|
-
print("Integrate-child check: PASS")
|
|
1222
|
-
if execute_merge:
|
|
1223
|
-
print("Merge execution check: PASS")
|
|
1224
|
-
print("No files changed.")
|
|
1225
|
-
return 0
|
|
1226
|
-
|
|
1227
|
-
merge_ref = None
|
|
1228
|
-
if execute_merge:
|
|
1229
|
-
merge_errors = _validate_merge_execution(repo_root, state, ref)
|
|
1230
|
-
if merge_errors:
|
|
1231
|
-
print(colored("Error: cannot execute child merge.", Colors.RED), file=sys.stderr)
|
|
1232
|
-
for item in merge_errors:
|
|
1233
|
-
print(f" - {item}", file=sys.stderr)
|
|
1234
|
-
print_environment_repair_hints(merge_errors)
|
|
1235
|
-
return 1
|
|
1236
|
-
|
|
1237
|
-
rc, _, err = run_git(["merge", "--no-ff", "--no-commit", ref], cwd=repo_root)
|
|
1238
|
-
if rc != 0:
|
|
1239
|
-
print(colored("Error: git merge failed; Parent task-map was not advanced to integrated.", Colors.RED), file=sys.stderr)
|
|
1240
|
-
if err.strip():
|
|
1241
|
-
print(err.strip(), file=sys.stderr)
|
|
1242
|
-
print_environment_repair_hints([err.strip()])
|
|
1243
|
-
print("Resolve the merge manually, abort it with `git merge --abort`, or record a `changes` / `cancelled` Parent decision.", file=sys.stderr)
|
|
1244
|
-
return 1
|
|
1245
|
-
merge_ref = ref
|
|
1246
|
-
|
|
1247
|
-
ok, errors = set_parent_child_integration_state(
|
|
1248
|
-
parent_dir,
|
|
1249
|
-
parent_data,
|
|
1250
|
-
child_dir,
|
|
1251
|
-
child_data,
|
|
1252
|
-
state,
|
|
1253
|
-
evidence,
|
|
1254
|
-
ref,
|
|
1255
|
-
reason,
|
|
1256
|
-
merge_ref,
|
|
1257
|
-
)
|
|
1258
|
-
if not ok:
|
|
1259
|
-
print(colored("Error: cannot integrate child.", Colors.RED), file=sys.stderr)
|
|
1260
|
-
for item in errors:
|
|
1261
|
-
print(f" - {item}", file=sys.stderr)
|
|
1262
|
-
return 1
|
|
1263
|
-
|
|
1264
|
-
print(colored(f"✓ Child integration updated: {child_dir.name} -> {state}", Colors.GREEN))
|
|
1265
|
-
if merge_ref:
|
|
1266
|
-
print(f"Merge executed: git merge --no-ff --no-commit {merge_ref}")
|
|
1267
|
-
print(f"Parent map: {_repo_relative_path(parent_dir / 'task-map.md', repo_root)}")
|
|
1268
|
-
return 0
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
# =============================================================================
|
|
1272
|
-
# Command: generate-child-prompt / parent-status / review-child
|
|
1273
|
-
# =============================================================================
|
|
1274
|
-
|
|
1275
|
-
def cmd_suggest_execution_strategy(args: argparse.Namespace) -> int:
|
|
1276
|
-
"""Suggest execution_mode and isolation for implement.md contract."""
|
|
1277
|
-
import json
|
|
1278
|
-
|
|
1279
|
-
from .execution_strategy import (
|
|
1280
|
-
format_contract_yaml_block,
|
|
1281
|
-
suggest_execution_strategy,
|
|
1282
|
-
)
|
|
1283
|
-
from .task_utils import resolve_task_dir
|
|
1284
|
-
|
|
1285
|
-
repo_root = get_repo_root()
|
|
1286
|
-
task_dir = resolve_task_dir(args.task_dir, repo_root)
|
|
1287
|
-
task_json_path = task_dir / FILE_TASK_JSON
|
|
1288
|
-
if not task_json_path.is_file():
|
|
1289
|
-
print(colored("Error: task.json not found", Colors.RED), file=sys.stderr)
|
|
1290
|
-
return 1
|
|
1291
|
-
task_data = read_json(task_json_path)
|
|
1292
|
-
if not isinstance(task_data, dict):
|
|
1293
|
-
print(colored("Error: invalid task.json", Colors.RED), file=sys.stderr)
|
|
1294
|
-
return 1
|
|
1295
|
-
|
|
1296
|
-
suggestion = suggest_execution_strategy(repo_root, task_dir, task_data)
|
|
1297
|
-
if getattr(args, "json", False):
|
|
1298
|
-
print(json.dumps(suggestion.to_dict(), indent=2, ensure_ascii=False))
|
|
1299
|
-
return 0
|
|
1300
|
-
print(format_contract_yaml_block(suggestion))
|
|
1301
|
-
return 0
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
def cmd_generate_dispatch_prompt(args: argparse.Namespace) -> int:
|
|
1305
|
-
"""Build a full Task dispatch prompt (Agent-facing CLI Layer 2)."""
|
|
1306
|
-
from .subagent_dispatch import build_dispatch_prompt
|
|
1307
|
-
|
|
1308
|
-
repo_root = get_repo_root()
|
|
1309
|
-
task_dir = resolve_task_dir(args.task_dir, repo_root)
|
|
1310
|
-
role = args.role
|
|
1311
|
-
scope = getattr(args, "scope", None)
|
|
1312
|
-
finish = bool(getattr(args, "finish", False))
|
|
1313
|
-
max_chars = getattr(args, "max_chars", None)
|
|
1314
|
-
|
|
1315
|
-
prompt, warnings, errors = build_dispatch_prompt(
|
|
1316
|
-
repo_root,
|
|
1317
|
-
task_dir,
|
|
1318
|
-
role,
|
|
1319
|
-
scope=scope,
|
|
1320
|
-
finish=finish,
|
|
1321
|
-
max_chars=max_chars,
|
|
1322
|
-
)
|
|
1323
|
-
for item in warnings:
|
|
1324
|
-
print(f"[generate-dispatch-prompt] WARN: {item}", file=sys.stderr)
|
|
1325
|
-
if errors:
|
|
1326
|
-
for item in errors:
|
|
1327
|
-
print(f"[generate-dispatch-prompt] Error: {item}", file=sys.stderr)
|
|
1328
|
-
return 1
|
|
1329
|
-
if prompt is None:
|
|
1330
|
-
print(colored("Error: could not build dispatch prompt", Colors.RED), file=sys.stderr)
|
|
1331
|
-
return 1
|
|
1332
|
-
print(prompt)
|
|
1333
|
-
return 0
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
def cmd_generate_child_prompt(args: argparse.Namespace) -> int:
|
|
1337
|
-
"""Generate a child implementation prompt for parent orchestration."""
|
|
1338
|
-
from .parent_orchestration import build_child_prompt
|
|
1339
|
-
|
|
1340
|
-
repo_root = get_repo_root()
|
|
1341
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1342
|
-
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1343
|
-
mode = getattr(args, "mode", "inline") or "inline"
|
|
1344
|
-
if mode not in ("inline", "subagent"):
|
|
1345
|
-
print(colored("Error: --mode must be inline or subagent", Colors.RED), file=sys.stderr)
|
|
1346
|
-
return 1
|
|
1347
|
-
|
|
1348
|
-
prompt, errors = build_child_prompt(
|
|
1349
|
-
parent_dir,
|
|
1350
|
-
child_dir,
|
|
1351
|
-
include_artifacts=getattr(args, "include_artifacts", False),
|
|
1352
|
-
mode=mode,
|
|
1353
|
-
)
|
|
1354
|
-
if errors:
|
|
1355
|
-
for item in errors:
|
|
1356
|
-
print(f" - {item}", file=sys.stderr)
|
|
1357
|
-
return 1
|
|
1358
|
-
if prompt is None:
|
|
1359
|
-
print(colored("Error: could not generate child prompt", Colors.RED), file=sys.stderr)
|
|
1360
|
-
return 1
|
|
1361
|
-
|
|
1362
|
-
out_path = getattr(args, "output", None)
|
|
1363
|
-
if out_path:
|
|
1364
|
-
path = Path(out_path)
|
|
1365
|
-
if not path.is_absolute():
|
|
1366
|
-
path = repo_root / path
|
|
1367
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1368
|
-
path.write_text(prompt + "\n", encoding="utf-8")
|
|
1369
|
-
print(colored(f"✓ Child prompt written: {_repo_relative_path(path, repo_root)}", Colors.GREEN))
|
|
1370
|
-
else:
|
|
1371
|
-
print(prompt)
|
|
1372
|
-
return 0
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
def cmd_parent_status(args: argparse.Namespace) -> int:
|
|
1376
|
-
"""Show parent task-map orchestration status."""
|
|
1377
|
-
from .parent_orchestration import build_parent_status
|
|
1378
|
-
|
|
1379
|
-
repo_root = get_repo_root()
|
|
1380
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1381
|
-
print(build_parent_status(parent_dir))
|
|
1382
|
-
return 0
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
def cmd_review_child(args: argparse.Namespace) -> int:
|
|
1386
|
-
"""Review child handoff and optionally advance parent integration states."""
|
|
1387
|
-
from .parent_orchestration import (
|
|
1388
|
-
append_parent_review_notes,
|
|
1389
|
-
build_review_report,
|
|
1390
|
-
write_review_artifact,
|
|
1391
|
-
)
|
|
1392
|
-
from .task_map import set_parent_child_integration_state
|
|
1393
|
-
|
|
1394
|
-
repo_root = get_repo_root()
|
|
1395
|
-
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1396
|
-
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1397
|
-
|
|
1398
|
-
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
1399
|
-
child_json_path = child_dir / FILE_TASK_JSON
|
|
1400
|
-
if not parent_json_path.is_file() or not child_json_path.is_file():
|
|
1401
|
-
print(colored("Error: parent or child task.json missing", Colors.RED), file=sys.stderr)
|
|
1402
|
-
return 1
|
|
1403
|
-
|
|
1404
|
-
parent_data = read_json(parent_json_path)
|
|
1405
|
-
child_data = read_json(child_json_path)
|
|
1406
|
-
if not parent_data or not child_data:
|
|
1407
|
-
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1408
|
-
return 1
|
|
1409
|
-
|
|
1410
|
-
decision = getattr(args, "decision", None)
|
|
1411
|
-
ref = getattr(args, "ref", None)
|
|
1412
|
-
reason = getattr(args, "reason", None)
|
|
1413
|
-
notes = getattr(args, "notes", None)
|
|
1414
|
-
check_only = getattr(args, "check", False) or not decision
|
|
1415
|
-
|
|
1416
|
-
report, errors, actions = build_review_report(
|
|
1417
|
-
parent_dir,
|
|
1418
|
-
child_dir,
|
|
1419
|
-
parent_data,
|
|
1420
|
-
child_data,
|
|
1421
|
-
decision=decision if not check_only else None,
|
|
1422
|
-
ref=ref,
|
|
1423
|
-
reason=reason,
|
|
1424
|
-
notes=notes,
|
|
1425
|
-
)
|
|
1426
|
-
|
|
1427
|
-
if getattr(args, "write_artifact", False):
|
|
1428
|
-
artifact = write_review_artifact(parent_dir, child_dir.name, report)
|
|
1429
|
-
print(colored(f"✓ Review artifact: {_repo_relative_path(artifact, repo_root)}", Colors.GREEN))
|
|
1430
|
-
|
|
1431
|
-
if check_only:
|
|
1432
|
-
if errors:
|
|
1433
|
-
print("Review-child check: FAIL")
|
|
1434
|
-
for item in errors:
|
|
1435
|
-
print(f" - {item}")
|
|
1436
|
-
print("")
|
|
1437
|
-
print(report)
|
|
1438
|
-
return 1
|
|
1439
|
-
print("Review-child check: PASS")
|
|
1440
|
-
print("")
|
|
1441
|
-
print(report)
|
|
1442
|
-
return 0
|
|
1443
|
-
|
|
1444
|
-
if errors:
|
|
1445
|
-
print(colored("Error: review decision blocked by validation.", Colors.RED), file=sys.stderr)
|
|
1446
|
-
for item in errors:
|
|
1447
|
-
print(f" - {item}", file=sys.stderr)
|
|
1448
|
-
return 1
|
|
1449
|
-
|
|
1450
|
-
evidence_default = "handoff.md"
|
|
1451
|
-
if actions.get("integrate_sequence"):
|
|
1452
|
-
for step in actions["integrate_sequence"]:
|
|
1453
|
-
ok, step_errors = set_parent_child_integration_state(
|
|
1454
|
-
parent_dir,
|
|
1455
|
-
parent_data,
|
|
1456
|
-
child_dir,
|
|
1457
|
-
child_data,
|
|
1458
|
-
step["state"],
|
|
1459
|
-
step.get("evidence", evidence_default),
|
|
1460
|
-
step.get("ref", ref),
|
|
1461
|
-
step.get("reason", reason),
|
|
1462
|
-
)
|
|
1463
|
-
if not ok:
|
|
1464
|
-
print(colored("Error: integrate-child step failed.", Colors.RED), file=sys.stderr)
|
|
1465
|
-
for item in step_errors:
|
|
1466
|
-
print(f" - {item}", file=sys.stderr)
|
|
1467
|
-
return 1
|
|
1468
|
-
print(colored(f"✓ Child integration updated: {child_dir.name} -> {step['state']}", Colors.GREEN))
|
|
1469
|
-
elif actions.get("integrate"):
|
|
1470
|
-
step = actions["integrate"]
|
|
1471
|
-
ok, step_errors = set_parent_child_integration_state(
|
|
1472
|
-
parent_dir,
|
|
1473
|
-
parent_data,
|
|
1474
|
-
child_dir,
|
|
1475
|
-
child_data,
|
|
1476
|
-
step["state"],
|
|
1477
|
-
step.get("evidence", evidence_default),
|
|
1478
|
-
step.get("ref", ref),
|
|
1479
|
-
step.get("reason", reason),
|
|
1480
|
-
)
|
|
1481
|
-
if not ok:
|
|
1482
|
-
print(colored("Error: integrate-child failed.", Colors.RED), file=sys.stderr)
|
|
1483
|
-
for item in step_errors:
|
|
1484
|
-
print(f" - {item}", file=sys.stderr)
|
|
1485
|
-
return 1
|
|
1486
|
-
print(colored(f"✓ Child integration updated: {child_dir.name} -> {step['state']}", Colors.GREEN))
|
|
1487
|
-
|
|
1488
|
-
if not getattr(args, "no_append_parent_verify", False):
|
|
1489
|
-
append_parent_review_notes(parent_dir, child_dir.name, report)
|
|
1490
|
-
print(colored("✓ Appended review notes to parent verify.md", Colors.GREEN))
|
|
1491
|
-
|
|
1492
|
-
for gate in actions.get("gates", []):
|
|
1493
|
-
if gate.get("optional") and gate.get("hint"):
|
|
1494
|
-
print(f"Optional reviewer gate: {gate['hint']}")
|
|
1495
|
-
|
|
1496
|
-
print(report)
|
|
1497
|
-
return 0
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
# =============================================================================
|
|
1501
|
-
# Command: set-branch
|
|
1502
|
-
# =============================================================================
|
|
1503
|
-
|
|
1504
|
-
def cmd_set_branch(args: argparse.Namespace) -> int:
|
|
1505
|
-
"""Set git branch for task."""
|
|
1506
|
-
repo_root = get_repo_root()
|
|
1507
|
-
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
1508
|
-
branch = args.branch
|
|
1509
|
-
|
|
1510
|
-
if not branch:
|
|
1511
|
-
print(colored("Error: Missing arguments", Colors.RED))
|
|
1512
|
-
print("Usage: python task.py set-branch <task-dir> <branch-name>")
|
|
1513
|
-
return 1
|
|
1514
|
-
|
|
1515
|
-
task_json = target_dir / FILE_TASK_JSON
|
|
1516
|
-
if not task_json.is_file():
|
|
1517
|
-
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
|
1518
|
-
return 1
|
|
1519
|
-
|
|
1520
|
-
data = read_json(task_json)
|
|
1521
|
-
if not data:
|
|
1522
|
-
return 1
|
|
1523
|
-
|
|
1524
|
-
data["branch"] = branch
|
|
1525
|
-
write_json(task_json, data)
|
|
1526
|
-
|
|
1527
|
-
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
|
1528
|
-
return 0
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
# =============================================================================
|
|
1532
|
-
# Command: set-base-branch
|
|
1533
|
-
# =============================================================================
|
|
1534
|
-
|
|
1535
|
-
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
|
1536
|
-
"""Set the base branch (PR target) for task."""
|
|
1537
|
-
repo_root = get_repo_root()
|
|
1538
|
-
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
1539
|
-
base_branch = args.base_branch
|
|
1540
|
-
|
|
1541
|
-
if not base_branch:
|
|
1542
|
-
print(colored("Error: Missing arguments", Colors.RED))
|
|
1543
|
-
print("Usage: python task.py set-base-branch <task-dir> <base-branch>")
|
|
1544
|
-
print("Example: python task.py set-base-branch <dir> develop")
|
|
1545
|
-
print()
|
|
1546
|
-
print("This sets the target branch for PR (the branch your feature will merge into).")
|
|
1547
|
-
return 1
|
|
1548
|
-
|
|
1549
|
-
task_json = target_dir / FILE_TASK_JSON
|
|
1550
|
-
if not task_json.is_file():
|
|
1551
|
-
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
|
1552
|
-
return 1
|
|
1553
|
-
|
|
1554
|
-
data = read_json(task_json)
|
|
1555
|
-
if not data:
|
|
1556
|
-
return 1
|
|
1557
|
-
|
|
1558
|
-
data["base_branch"] = base_branch
|
|
1559
|
-
write_json(task_json, data)
|
|
1560
|
-
|
|
1561
|
-
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
|
1562
|
-
print(f" PR will target: {base_branch}")
|
|
1563
|
-
return 0
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
# =============================================================================
|
|
1567
|
-
# Command: set-scope
|
|
1568
|
-
# =============================================================================
|
|
1569
|
-
|
|
1570
|
-
def cmd_set_scope(args: argparse.Namespace) -> int:
|
|
1571
|
-
"""Set scope for PR title."""
|
|
1572
|
-
repo_root = get_repo_root()
|
|
1573
|
-
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
1574
|
-
scope = args.scope
|
|
1575
|
-
|
|
1576
|
-
if not scope:
|
|
1577
|
-
print(colored("Error: Missing arguments", Colors.RED))
|
|
1578
|
-
print("Usage: python task.py set-scope <task-dir> <scope>")
|
|
1579
|
-
return 1
|
|
1580
|
-
|
|
1581
|
-
task_json = target_dir / FILE_TASK_JSON
|
|
1582
|
-
if not task_json.is_file():
|
|
1583
|
-
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
|
1584
|
-
return 1
|
|
1585
|
-
|
|
1586
|
-
data = read_json(task_json)
|
|
1587
|
-
if not data:
|
|
1588
|
-
return 1
|
|
1589
|
-
|
|
1590
|
-
data["scope"] = scope
|
|
1591
|
-
write_json(task_json, data)
|
|
1592
|
-
|
|
1593
|
-
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
|
1594
|
-
return 0
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Task CRUD operations.
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
ensure_tasks_dir - Ensure tasks directory exists
|
|
7
|
+
cmd_create - Create a new task
|
|
8
|
+
cmd_archive - Archive completed task
|
|
9
|
+
cmd_set_branch - Set git branch for task
|
|
10
|
+
cmd_set_base_branch - Set PR target branch
|
|
11
|
+
cmd_set_scope - Set scope for PR title
|
|
12
|
+
cmd_add_subtask - Link child task to parent
|
|
13
|
+
cmd_remove_subtask - Unlink child task from parent
|
|
14
|
+
cmd_prepare_child_worktree - Create/register Child git worktree
|
|
15
|
+
cmd_set_child_state - Set Child-reported task-map state
|
|
16
|
+
cmd_integrate_child - Set Parent-controlled Child integration state
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .config import (
|
|
29
|
+
get_packages,
|
|
30
|
+
get_session_auto_commit,
|
|
31
|
+
is_monorepo,
|
|
32
|
+
resolve_package,
|
|
33
|
+
validate_package,
|
|
34
|
+
)
|
|
35
|
+
from .cli_environment import format_git_repo_errors, print_environment_repair_hints
|
|
36
|
+
from .git import run_git
|
|
37
|
+
from .io import read_json, write_json
|
|
38
|
+
from .log import Colors, colored
|
|
39
|
+
from .paths import (
|
|
40
|
+
DIR_ARCHIVE,
|
|
41
|
+
DIR_TASKS,
|
|
42
|
+
DIR_WORKFLOW,
|
|
43
|
+
FILE_TASK_JSON,
|
|
44
|
+
generate_task_date_prefix,
|
|
45
|
+
get_developer,
|
|
46
|
+
get_repo_root,
|
|
47
|
+
get_tasks_dir,
|
|
48
|
+
)
|
|
49
|
+
from .safe_commit import (
|
|
50
|
+
print_gitignore_warning,
|
|
51
|
+
safe_archive_paths_to_add,
|
|
52
|
+
safe_git_add,
|
|
53
|
+
)
|
|
54
|
+
from .task_gates import (
|
|
55
|
+
BASELINE_GATE,
|
|
56
|
+
archive_repair_hints,
|
|
57
|
+
build_spec_update_scaffold,
|
|
58
|
+
prepare_archive_evidence,
|
|
59
|
+
validate_archive,
|
|
60
|
+
write_gate_record,
|
|
61
|
+
)
|
|
62
|
+
from .task_map import (
|
|
63
|
+
CHILD_STATES,
|
|
64
|
+
CHILD_REPORT_STATES,
|
|
65
|
+
PARENT_CONTROLLED_STATES,
|
|
66
|
+
ensure_task_map,
|
|
67
|
+
get_child_state,
|
|
68
|
+
record_child_worktree,
|
|
69
|
+
remove_child_from_task_map,
|
|
70
|
+
set_child_state,
|
|
71
|
+
set_parent_child_integration_state,
|
|
72
|
+
validate_parent_child_integration,
|
|
73
|
+
)
|
|
74
|
+
from .tasks import parent_archive_child_followup_hint
|
|
75
|
+
from .task_utils import (
|
|
76
|
+
archive_task_complete,
|
|
77
|
+
find_task_by_name,
|
|
78
|
+
resolve_task_dir,
|
|
79
|
+
run_task_hooks,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# =============================================================================
|
|
84
|
+
# Helper Functions
|
|
85
|
+
# =============================================================================
|
|
86
|
+
|
|
87
|
+
def _slugify(title: str) -> str:
|
|
88
|
+
"""Convert title to slug (only works with ASCII)."""
|
|
89
|
+
result = title.lower()
|
|
90
|
+
result = re.sub(r"[^a-z0-9]", "-", result)
|
|
91
|
+
result = re.sub(r"-+", "-", result)
|
|
92
|
+
result = result.strip("-")
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ensure_tasks_dir(repo_root: Path) -> Path:
|
|
97
|
+
"""Ensure tasks directory exists."""
|
|
98
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
99
|
+
archive_dir = tasks_dir / "archive"
|
|
100
|
+
|
|
101
|
+
if not tasks_dir.exists():
|
|
102
|
+
tasks_dir.mkdir(parents=True)
|
|
103
|
+
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
|
|
104
|
+
|
|
105
|
+
if not archive_dir.exists():
|
|
106
|
+
archive_dir.mkdir(parents=True)
|
|
107
|
+
|
|
108
|
+
return tasks_dir
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
|
|
112
|
+
"""Find an archived task directory with the exact active-task dir name."""
|
|
113
|
+
archive_dir = tasks_dir / DIR_ARCHIVE
|
|
114
|
+
if not archive_dir.is_dir():
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
for month_dir in sorted(archive_dir.iterdir()):
|
|
118
|
+
if not month_dir.is_dir():
|
|
119
|
+
continue
|
|
120
|
+
candidate = month_dir / dir_name
|
|
121
|
+
if candidate.is_dir():
|
|
122
|
+
return candidate
|
|
123
|
+
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _repo_relative_path(path: Path, repo_root: Path) -> str:
|
|
128
|
+
"""Format a path relative to the repo root when possible."""
|
|
129
|
+
try:
|
|
130
|
+
return path.relative_to(repo_root).as_posix()
|
|
131
|
+
except ValueError:
|
|
132
|
+
return str(path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _short_cli_ref(value: str | None) -> bool:
|
|
136
|
+
"""Return True for a compact single-argument ref/path value."""
|
|
137
|
+
if not value or not isinstance(value, str):
|
|
138
|
+
return False
|
|
139
|
+
if value.startswith("-"):
|
|
140
|
+
return False
|
|
141
|
+
if len(value) > 240:
|
|
142
|
+
return False
|
|
143
|
+
return "\n" not in value and "\r" not in value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_git_repo(repo_root: Path) -> list[str]:
|
|
147
|
+
rc, out, err = run_git(["rev-parse", "--is-inside-work-tree"], cwd=repo_root)
|
|
148
|
+
if rc != 0 or out.strip() != "true":
|
|
149
|
+
detail = err.strip() or out.strip() or "not a Git worktree"
|
|
150
|
+
return format_git_repo_errors([f"Git repository required: {detail}"])
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_branch_name(repo_root: Path, branch: str) -> list[str]:
|
|
155
|
+
if not _short_cli_ref(branch):
|
|
156
|
+
return ["branch must be a short git branch name"]
|
|
157
|
+
rc, _, err = run_git(["check-ref-format", "--branch", branch], cwd=repo_root)
|
|
158
|
+
if rc != 0:
|
|
159
|
+
return [f"invalid branch name: {branch} ({err.strip() or 'git check-ref-format failed'})"]
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _git_commit_exists(repo_root: Path, ref: str | None) -> bool:
|
|
164
|
+
if not _short_cli_ref(ref):
|
|
165
|
+
return False
|
|
166
|
+
rc, _, _ = run_git(["rev-parse", "--verify", f"{ref}^{{commit}}"], cwd=repo_root)
|
|
167
|
+
return rc == 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _git_branch_exists(repo_root: Path, branch: str) -> bool:
|
|
171
|
+
rc, _, _ = run_git(
|
|
172
|
+
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
173
|
+
cwd=repo_root,
|
|
174
|
+
)
|
|
175
|
+
return rc == 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _default_child_worktree_path(repo_root: Path, child_dir: Path) -> Path:
|
|
179
|
+
return repo_root / DIR_WORKFLOW / "worktrees" / child_dir.name
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_child_worktree_path(repo_root: Path, child_dir: Path, raw_path: str | None) -> tuple[Path | None, str | None]:
|
|
183
|
+
path = Path(raw_path) if raw_path else _default_child_worktree_path(repo_root, child_dir)
|
|
184
|
+
if not path.is_absolute():
|
|
185
|
+
path = repo_root / path
|
|
186
|
+
resolved = path.resolve()
|
|
187
|
+
worktree_root = (repo_root / DIR_WORKFLOW / "worktrees").resolve()
|
|
188
|
+
try:
|
|
189
|
+
resolved.relative_to(worktree_root)
|
|
190
|
+
except ValueError:
|
|
191
|
+
return None, f"worktree path must stay under {_repo_relative_path(worktree_root, repo_root)}"
|
|
192
|
+
return resolved, None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _non_trellis_dirty_paths(repo_root: Path) -> list[str]:
|
|
196
|
+
rc, out, err = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
197
|
+
if rc != 0:
|
|
198
|
+
return [err.strip() or "git status failed"]
|
|
199
|
+
|
|
200
|
+
dirty: list[str] = []
|
|
201
|
+
for raw_line in out.splitlines():
|
|
202
|
+
path = raw_line[3:].strip()
|
|
203
|
+
if " -> " in path:
|
|
204
|
+
path = path.split(" -> ", 1)[1].strip()
|
|
205
|
+
path = path.replace("\\", "/").strip('"')
|
|
206
|
+
if path and path != ".trellis" and not path.startswith(".trellis/"):
|
|
207
|
+
dirty.append(path)
|
|
208
|
+
return dirty
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _validate_merge_execution(repo_root: Path, state: str, ref: str | None) -> list[str]:
|
|
212
|
+
errors = _validate_git_repo(repo_root)
|
|
213
|
+
if state != "integrated":
|
|
214
|
+
errors.append("--execute-merge is only valid with state `integrated`")
|
|
215
|
+
if not _short_cli_ref(ref):
|
|
216
|
+
errors.append("--execute-merge requires --ref")
|
|
217
|
+
elif not _git_commit_exists(repo_root, ref):
|
|
218
|
+
errors.append(f"merge ref does not resolve to a commit: {ref}")
|
|
219
|
+
|
|
220
|
+
dirty = _non_trellis_dirty_paths(repo_root)
|
|
221
|
+
if dirty:
|
|
222
|
+
errors.append(
|
|
223
|
+
"non-Trellis working tree changes block merge execution: "
|
|
224
|
+
+ ", ".join(dirty[:8])
|
|
225
|
+
)
|
|
226
|
+
return errors
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# =============================================================================
|
|
230
|
+
# Sub-agent platform detection + JSONL seeding
|
|
231
|
+
# =============================================================================
|
|
232
|
+
|
|
233
|
+
# Config directories of platforms that consume implement.jsonl / check.jsonl.
|
|
234
|
+
# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
|
|
235
|
+
# platforms listed in workflow.md's "agent-capable" Skill Routing block
|
|
236
|
+
# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
|
|
237
|
+
# Windsurf are NOT in this list: they do not consume JSONL.
|
|
238
|
+
_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
|
|
239
|
+
".claude",
|
|
240
|
+
".cursor",
|
|
241
|
+
".codex",
|
|
242
|
+
".kiro",
|
|
243
|
+
".gemini",
|
|
244
|
+
".opencode",
|
|
245
|
+
".qoder",
|
|
246
|
+
".codebuddy",
|
|
247
|
+
".factory", # Factory Droid
|
|
248
|
+
".github/copilot",
|
|
249
|
+
".pi", # Pi Agent
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
_SEED_EXAMPLE = (
|
|
253
|
+
"Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
|
|
254
|
+
"Put spec/research files only — no code paths. "
|
|
255
|
+
"Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. "
|
|
256
|
+
"Delete this line once real entries are added."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _has_subagent_platform(repo_root: Path) -> bool:
|
|
261
|
+
"""Return True if any sub-agent-capable platform is configured.
|
|
262
|
+
|
|
263
|
+
Detected by probing well-known config directories at the repo root. Used
|
|
264
|
+
only to decide whether ``task.py create`` should seed empty
|
|
265
|
+
``implement.jsonl`` / ``check.jsonl`` files.
|
|
266
|
+
"""
|
|
267
|
+
for config_dir in _SUBAGENT_CONFIG_DIRS:
|
|
268
|
+
if (repo_root / config_dir).is_dir():
|
|
269
|
+
return True
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _write_seed_jsonl(path: Path) -> None:
|
|
274
|
+
"""Write a one-line seed JSONL file with a self-describing ``_example``.
|
|
275
|
+
|
|
276
|
+
The seed row has no ``file`` field, so downstream consumers (hooks +
|
|
277
|
+
preludes) that iterate entries via ``item.get("file")`` naturally skip
|
|
278
|
+
it. The row exists purely as an in-file prompt for the AI curator.
|
|
279
|
+
"""
|
|
280
|
+
seed = {"_example": _SEED_EXAMPLE}
|
|
281
|
+
path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _default_prd_content(title: str, description: str | None = None) -> str:
|
|
285
|
+
"""Return the default PRD skeleton created with every task."""
|
|
286
|
+
goal = (description or "").strip() or "TBD."
|
|
287
|
+
heading = title.strip() or "Untitled task"
|
|
288
|
+
return f"""# {heading}
|
|
289
|
+
|
|
290
|
+
## Goal
|
|
291
|
+
|
|
292
|
+
{goal}
|
|
293
|
+
|
|
294
|
+
## Requirements
|
|
295
|
+
|
|
296
|
+
- TBD
|
|
297
|
+
|
|
298
|
+
## Acceptance Criteria
|
|
299
|
+
|
|
300
|
+
- [ ] TBD
|
|
301
|
+
|
|
302
|
+
## Notes
|
|
303
|
+
|
|
304
|
+
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
|
305
|
+
- Lightweight tasks can remain PRD-only.
|
|
306
|
+
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start-execution --check`.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# =============================================================================
|
|
311
|
+
# Command: create
|
|
312
|
+
# =============================================================================
|
|
313
|
+
|
|
314
|
+
def cmd_create(args: argparse.Namespace) -> int:
|
|
315
|
+
"""Create a new task."""
|
|
316
|
+
repo_root = get_repo_root()
|
|
317
|
+
|
|
318
|
+
if not args.title:
|
|
319
|
+
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
# Validate --package (CLI source: fail-fast)
|
|
323
|
+
package: str | None = getattr(args, "package", None)
|
|
324
|
+
if not is_monorepo(repo_root):
|
|
325
|
+
# Single-repo: ignore --package, no package prefix
|
|
326
|
+
if package:
|
|
327
|
+
print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
|
|
328
|
+
package = None
|
|
329
|
+
elif package:
|
|
330
|
+
if not validate_package(package, repo_root):
|
|
331
|
+
packages = get_packages(repo_root)
|
|
332
|
+
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
|
333
|
+
print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr)
|
|
334
|
+
return 1
|
|
335
|
+
else:
|
|
336
|
+
# Inferred: default_package → None (no task.json yet for create)
|
|
337
|
+
package = resolve_package(repo_root=repo_root)
|
|
338
|
+
|
|
339
|
+
# Default assignee to current developer
|
|
340
|
+
assignee = args.assignee
|
|
341
|
+
if not assignee:
|
|
342
|
+
assignee = get_developer(repo_root)
|
|
343
|
+
if not assignee:
|
|
344
|
+
print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
|
|
345
|
+
return 1
|
|
346
|
+
|
|
347
|
+
ensure_tasks_dir(repo_root)
|
|
348
|
+
|
|
349
|
+
# Get current developer as creator
|
|
350
|
+
creator = get_developer(repo_root) or assignee
|
|
351
|
+
|
|
352
|
+
# Generate slug if not provided
|
|
353
|
+
slug = args.slug or _slugify(args.title)
|
|
354
|
+
if not slug:
|
|
355
|
+
print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
|
|
356
|
+
return 1
|
|
357
|
+
|
|
358
|
+
# Create task directory with MM-DD-slug format
|
|
359
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
360
|
+
date_prefix = generate_task_date_prefix()
|
|
361
|
+
dir_name = f"{date_prefix}-{slug}"
|
|
362
|
+
task_dir = tasks_dir / dir_name
|
|
363
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
364
|
+
|
|
365
|
+
archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
|
|
366
|
+
if archived_task_dir:
|
|
367
|
+
print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr)
|
|
368
|
+
print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
|
|
369
|
+
print("Use a new slug if you intend to create a new task.", file=sys.stderr)
|
|
370
|
+
return 1
|
|
371
|
+
|
|
372
|
+
if task_dir.exists():
|
|
373
|
+
print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
|
|
374
|
+
else:
|
|
375
|
+
task_dir.mkdir(parents=True)
|
|
376
|
+
|
|
377
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
378
|
+
|
|
379
|
+
# Record current branch as base_branch (PR target)
|
|
380
|
+
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
381
|
+
current_branch = branch_out.strip() or "main"
|
|
382
|
+
|
|
383
|
+
task_data = {
|
|
384
|
+
"id": slug,
|
|
385
|
+
"name": slug,
|
|
386
|
+
"title": args.title,
|
|
387
|
+
"description": args.description or "",
|
|
388
|
+
"status": "planning",
|
|
389
|
+
"dev_type": None,
|
|
390
|
+
"scope": None,
|
|
391
|
+
"package": package,
|
|
392
|
+
"priority": args.priority,
|
|
393
|
+
"creator": creator,
|
|
394
|
+
"assignee": assignee,
|
|
395
|
+
"createdAt": today,
|
|
396
|
+
"completedAt": None,
|
|
397
|
+
"branch": None,
|
|
398
|
+
"base_branch": current_branch,
|
|
399
|
+
"worktree_path": None,
|
|
400
|
+
"commit": None,
|
|
401
|
+
"pr_url": None,
|
|
402
|
+
"subtasks": [],
|
|
403
|
+
"children": [],
|
|
404
|
+
"parent": None,
|
|
405
|
+
"relatedFiles": [],
|
|
406
|
+
"notes": "",
|
|
407
|
+
"meta": {},
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
write_json(task_json_path, task_data)
|
|
411
|
+
|
|
412
|
+
prd_path = task_dir / "prd.md"
|
|
413
|
+
if not prd_path.exists():
|
|
414
|
+
prd_path.write_text(
|
|
415
|
+
_default_prd_content(args.title, args.description),
|
|
416
|
+
encoding="utf-8",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
|
|
420
|
+
# Agent curates real entries during planning when the task needs them.
|
|
421
|
+
# Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they
|
|
422
|
+
# load specs via the cstl-before-dev skill instead of JSONL.
|
|
423
|
+
seeded_jsonl = False
|
|
424
|
+
if _has_subagent_platform(repo_root):
|
|
425
|
+
for jsonl_name in ("implement.jsonl", "check.jsonl"):
|
|
426
|
+
jsonl_path = task_dir / jsonl_name
|
|
427
|
+
if not jsonl_path.exists():
|
|
428
|
+
_write_seed_jsonl(jsonl_path)
|
|
429
|
+
seeded_jsonl = True
|
|
430
|
+
|
|
431
|
+
# Handle --parent: establish bidirectional link
|
|
432
|
+
if args.parent:
|
|
433
|
+
parent_dir = resolve_task_dir(args.parent, repo_root)
|
|
434
|
+
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
435
|
+
if not parent_json_path.is_file():
|
|
436
|
+
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
|
|
437
|
+
else:
|
|
438
|
+
parent_data = read_json(parent_json_path)
|
|
439
|
+
if parent_data:
|
|
440
|
+
# Add child to parent's children list
|
|
441
|
+
parent_children = parent_data.get("children", [])
|
|
442
|
+
if dir_name not in parent_children:
|
|
443
|
+
parent_children.append(dir_name)
|
|
444
|
+
parent_data["children"] = parent_children
|
|
445
|
+
write_json(parent_json_path, parent_data)
|
|
446
|
+
|
|
447
|
+
# Set parent in child's task.json
|
|
448
|
+
task_data["parent"] = parent_dir.name
|
|
449
|
+
write_json(task_json_path, task_data)
|
|
450
|
+
ensure_task_map(
|
|
451
|
+
parent_dir,
|
|
452
|
+
parent_data,
|
|
453
|
+
list(parent_data.get("children", [])),
|
|
454
|
+
f"Linked Child `{dir_name}`.",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
458
|
+
|
|
459
|
+
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
|
|
460
|
+
print("", file=sys.stderr)
|
|
461
|
+
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
|
462
|
+
print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
|
|
463
|
+
print(f" - Select it when ready: python ./.trellis/scripts/task.py select {DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}", file=sys.stderr)
|
|
464
|
+
print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
|
|
465
|
+
print(" - Complex task: add design.md and implement.md before task.py start-execution --check", file=sys.stderr)
|
|
466
|
+
if seeded_jsonl:
|
|
467
|
+
print(
|
|
468
|
+
" - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
|
|
469
|
+
file=sys.stderr,
|
|
470
|
+
)
|
|
471
|
+
print(" - Use /cstl:continue or phase context to decide the next step", file=sys.stderr)
|
|
472
|
+
print("", file=sys.stderr)
|
|
473
|
+
|
|
474
|
+
# Output relative path for script chaining
|
|
475
|
+
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
|
476
|
+
|
|
477
|
+
run_task_hooks("after_create", task_json_path, repo_root)
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# =============================================================================
|
|
482
|
+
# Command: archive / prepare-archive-evidence
|
|
483
|
+
# =============================================================================
|
|
484
|
+
|
|
485
|
+
def cmd_prepare_archive_evidence(args: argparse.Namespace) -> int:
|
|
486
|
+
"""Append missing archive evidence sections to verify.md (non-destructive)."""
|
|
487
|
+
repo_root = get_repo_root()
|
|
488
|
+
task_name = args.name
|
|
489
|
+
if not task_name:
|
|
490
|
+
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
|
491
|
+
return 1
|
|
492
|
+
|
|
493
|
+
task_dir = resolve_task_dir(task_name, repo_root)
|
|
494
|
+
if not task_dir or not task_dir.is_dir():
|
|
495
|
+
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
|
496
|
+
return 1
|
|
497
|
+
|
|
498
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
499
|
+
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
500
|
+
dry_run = getattr(args, "dry_run", False)
|
|
501
|
+
|
|
502
|
+
changed, messages = prepare_archive_evidence(
|
|
503
|
+
task_dir, task_data, dry_run=dry_run
|
|
504
|
+
)
|
|
505
|
+
for msg in messages:
|
|
506
|
+
print(msg)
|
|
507
|
+
if not changed and messages and messages[0].startswith("task.json"):
|
|
508
|
+
return 1
|
|
509
|
+
|
|
510
|
+
guard = validate_archive(task_dir, task_data)
|
|
511
|
+
if guard.ok:
|
|
512
|
+
print(colored("Archive check: PASS (after prepare)", Colors.GREEN))
|
|
513
|
+
return 0
|
|
514
|
+
print(colored("Archive check: still blocked", Colors.YELLOW))
|
|
515
|
+
for item in guard.errors:
|
|
516
|
+
print(f" - {item}")
|
|
517
|
+
if task_data is not None:
|
|
518
|
+
hints = archive_repair_hints(guard.errors, task_dir, task_data, guard)
|
|
519
|
+
if hints:
|
|
520
|
+
print(colored("Next steps:", Colors.BLUE))
|
|
521
|
+
for hint in hints:
|
|
522
|
+
print(f" - {hint}")
|
|
523
|
+
return 0 if changed else 1
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def cmd_prepare_learning_scaffold(args: argparse.Namespace) -> int:
|
|
527
|
+
"""Print spec-update scaffolding for a task (stdout only; does not edit specs)."""
|
|
528
|
+
repo_root = get_repo_root()
|
|
529
|
+
task_name = args.name
|
|
530
|
+
if not task_name:
|
|
531
|
+
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
|
532
|
+
return 1
|
|
533
|
+
|
|
534
|
+
task_dir = resolve_task_dir(task_name, repo_root)
|
|
535
|
+
if not task_dir or not task_dir.is_dir():
|
|
536
|
+
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
|
537
|
+
return 1
|
|
538
|
+
|
|
539
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
540
|
+
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
541
|
+
if task_data is None:
|
|
542
|
+
print(colored("Error: task.json missing or invalid", Colors.RED), file=sys.stderr)
|
|
543
|
+
return 1
|
|
544
|
+
|
|
545
|
+
trigger = getattr(args, "trigger", None)
|
|
546
|
+
print(build_spec_update_scaffold(repo_root, task_dir, task_data, trigger=trigger))
|
|
547
|
+
return 0
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _integrated_children_still_active(
|
|
551
|
+
parent_dir: Path,
|
|
552
|
+
child_names: list[str],
|
|
553
|
+
tasks_dir: Path,
|
|
554
|
+
) -> list[tuple[str, Path]]:
|
|
555
|
+
"""Return (name, dir) for integrated children that remain in the active set."""
|
|
556
|
+
pending: list[tuple[str, Path]] = []
|
|
557
|
+
for child_name in child_names:
|
|
558
|
+
if not isinstance(child_name, str):
|
|
559
|
+
continue
|
|
560
|
+
if get_child_state(parent_dir, child_name) != "integrated":
|
|
561
|
+
continue
|
|
562
|
+
child_dir = find_task_by_name(child_name, tasks_dir)
|
|
563
|
+
if child_dir and child_dir.is_dir():
|
|
564
|
+
pending.append((child_name, child_dir))
|
|
565
|
+
return pending
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _archive_one_task(
|
|
569
|
+
task_dir: Path,
|
|
570
|
+
repo_root: Path,
|
|
571
|
+
tasks_dir: Path,
|
|
572
|
+
*,
|
|
573
|
+
no_commit: bool,
|
|
574
|
+
) -> tuple[bool, list[str], str | None]:
|
|
575
|
+
"""Archive a single task directory after validate_archive passed.
|
|
576
|
+
|
|
577
|
+
Returns (success, modified_child_names, archived_relative_path_or_none).
|
|
578
|
+
"""
|
|
579
|
+
dir_name = task_dir.name
|
|
580
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
581
|
+
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
582
|
+
guard = validate_archive(task_dir, task_data)
|
|
583
|
+
if not guard.ok:
|
|
584
|
+
return False, [], None
|
|
585
|
+
|
|
586
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
587
|
+
modified_children: list[str] = []
|
|
588
|
+
if task_data:
|
|
589
|
+
data = task_data
|
|
590
|
+
if guard.baseline_record:
|
|
591
|
+
write_gate_record(data, "full-task-complete", BASELINE_GATE, guard.baseline_record)
|
|
592
|
+
data["status"] = "completed"
|
|
593
|
+
data["completedAt"] = today
|
|
594
|
+
write_json(task_json_path, data)
|
|
595
|
+
|
|
596
|
+
task_children = data.get("children", [])
|
|
597
|
+
if task_children:
|
|
598
|
+
for child_name in task_children:
|
|
599
|
+
child_dir_path = find_task_by_name(child_name, tasks_dir)
|
|
600
|
+
if child_dir_path:
|
|
601
|
+
child_json = child_dir_path / FILE_TASK_JSON
|
|
602
|
+
if child_json.is_file():
|
|
603
|
+
child_data = read_json(child_json)
|
|
604
|
+
if child_data:
|
|
605
|
+
child_data["parent"] = None
|
|
606
|
+
write_json(child_json, child_data)
|
|
607
|
+
modified_children.append(child_dir_path.name)
|
|
608
|
+
|
|
609
|
+
from .active_task import clear_task_from_sessions
|
|
610
|
+
|
|
611
|
+
clear_task_from_sessions(str(task_dir), repo_root)
|
|
612
|
+
result = archive_task_complete(task_dir, repo_root)
|
|
613
|
+
if "archived_to" not in result:
|
|
614
|
+
return False, modified_children, None
|
|
615
|
+
|
|
616
|
+
archive_dest = Path(result["archived_to"])
|
|
617
|
+
year_month = archive_dest.parent.name
|
|
618
|
+
print(
|
|
619
|
+
colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN),
|
|
620
|
+
file=sys.stderr,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if not no_commit:
|
|
624
|
+
if not _auto_commit_archive(dir_name, repo_root, modified_children):
|
|
625
|
+
print(
|
|
626
|
+
colored(
|
|
627
|
+
"Archive moved on disk, but git auto-commit did not complete. "
|
|
628
|
+
"Resolve `git status` before continuing.",
|
|
629
|
+
Colors.RED,
|
|
630
|
+
),
|
|
631
|
+
file=sys.stderr,
|
|
632
|
+
)
|
|
633
|
+
return False, modified_children, None
|
|
634
|
+
|
|
635
|
+
rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}"
|
|
636
|
+
archived_json = archive_dest / FILE_TASK_JSON
|
|
637
|
+
run_task_hooks("after_archive", archived_json, repo_root)
|
|
638
|
+
return True, modified_children, rel
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def cmd_archive(args: argparse.Namespace) -> int:
|
|
642
|
+
"""Archive completed task."""
|
|
643
|
+
repo_root = get_repo_root()
|
|
644
|
+
task_name = args.name
|
|
645
|
+
|
|
646
|
+
if not task_name:
|
|
647
|
+
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
|
648
|
+
return 1
|
|
649
|
+
|
|
650
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
651
|
+
|
|
652
|
+
# Resolve task directory (supports task name, relative path, or absolute path)
|
|
653
|
+
task_dir = resolve_task_dir(task_name, repo_root)
|
|
654
|
+
|
|
655
|
+
if not task_dir or not task_dir.is_dir():
|
|
656
|
+
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
|
657
|
+
print("Active tasks:", file=sys.stderr)
|
|
658
|
+
# Import lazily to avoid circular dependency
|
|
659
|
+
from .tasks import iter_active_tasks
|
|
660
|
+
for t in iter_active_tasks(tasks_dir):
|
|
661
|
+
print(f" - {t.dir_name}/", file=sys.stderr)
|
|
662
|
+
return 1
|
|
663
|
+
|
|
664
|
+
dir_name = task_dir.name
|
|
665
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
666
|
+
task_data = read_json(task_json_path) if task_json_path.is_file() else None
|
|
667
|
+
|
|
668
|
+
guard = validate_archive(task_dir, task_data)
|
|
669
|
+
task_children_raw = (
|
|
670
|
+
task_data.get("children", []) if isinstance(task_data, dict) else []
|
|
671
|
+
)
|
|
672
|
+
structural_children = [
|
|
673
|
+
name for name in task_children_raw if isinstance(name, str)
|
|
674
|
+
]
|
|
675
|
+
cascade = getattr(args, "archive_integrated_children", False)
|
|
676
|
+
pending_integrated = _integrated_children_still_active(
|
|
677
|
+
task_dir, structural_children, tasks_dir
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
if getattr(args, "check", False):
|
|
681
|
+
if not guard.ok:
|
|
682
|
+
print(colored("Archive check: FAIL", Colors.RED))
|
|
683
|
+
for item in guard.errors:
|
|
684
|
+
print(f" - {item}")
|
|
685
|
+
if task_data is not None:
|
|
686
|
+
hints = archive_repair_hints(
|
|
687
|
+
guard.errors, task_dir, task_data, guard
|
|
688
|
+
)
|
|
689
|
+
if hints:
|
|
690
|
+
print(colored("Next steps:", Colors.BLUE))
|
|
691
|
+
for hint in hints:
|
|
692
|
+
print(f" - {hint}")
|
|
693
|
+
return 1
|
|
694
|
+
if cascade and pending_integrated:
|
|
695
|
+
for child_name, child_dir in pending_integrated:
|
|
696
|
+
child_data = read_json(child_dir / FILE_TASK_JSON)
|
|
697
|
+
child_guard = validate_archive(child_dir, child_data)
|
|
698
|
+
if not child_guard.ok:
|
|
699
|
+
print(colored("Archive check: FAIL", Colors.RED))
|
|
700
|
+
print(
|
|
701
|
+
f" - integrated child {child_name} not ready to archive:"
|
|
702
|
+
)
|
|
703
|
+
for item in child_guard.errors:
|
|
704
|
+
print(f" - {item}")
|
|
705
|
+
if child_data is not None:
|
|
706
|
+
hints = archive_repair_hints(
|
|
707
|
+
child_guard.errors, child_dir, child_data, child_guard
|
|
708
|
+
)
|
|
709
|
+
if hints:
|
|
710
|
+
print(colored("Next steps:", Colors.BLUE))
|
|
711
|
+
for hint in hints:
|
|
712
|
+
print(f" - {hint}")
|
|
713
|
+
return 1
|
|
714
|
+
print(colored("Archive check: PASS", Colors.GREEN))
|
|
715
|
+
print(f"Contract fingerprint: {guard.contract_fingerprint}")
|
|
716
|
+
if guard.required_gates:
|
|
717
|
+
print(f"Required completion gates: {', '.join(guard.required_gates)}")
|
|
718
|
+
if pending_integrated and not cascade:
|
|
719
|
+
hint = parent_archive_child_followup_hint(
|
|
720
|
+
task_dir, structural_children, tasks_dir
|
|
721
|
+
)
|
|
722
|
+
if hint:
|
|
723
|
+
print(hint)
|
|
724
|
+
if cascade and pending_integrated:
|
|
725
|
+
names = ", ".join(n for n, _ in pending_integrated)
|
|
726
|
+
print(
|
|
727
|
+
f"Cascade: {len(pending_integrated)} integrated child dir(s) "
|
|
728
|
+
f"would archive with parent: {names}"
|
|
729
|
+
)
|
|
730
|
+
return 0
|
|
731
|
+
|
|
732
|
+
if not guard.ok:
|
|
733
|
+
print(colored("Error: cannot archive task; completion check failed.", Colors.RED), file=sys.stderr)
|
|
734
|
+
for item in guard.errors:
|
|
735
|
+
print(f" - {item}", file=sys.stderr)
|
|
736
|
+
if task_data is not None:
|
|
737
|
+
hints = archive_repair_hints(guard.errors, task_dir, task_data, guard)
|
|
738
|
+
if hints:
|
|
739
|
+
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
|
740
|
+
for hint in hints:
|
|
741
|
+
print(f" - {hint}", file=sys.stderr)
|
|
742
|
+
print("Run `task.py archive <task> --check` for a non-mutating preflight.", file=sys.stderr)
|
|
743
|
+
return 1
|
|
744
|
+
|
|
745
|
+
no_commit = getattr(args, "no_commit", False)
|
|
746
|
+
if cascade and pending_integrated:
|
|
747
|
+
for child_name, child_dir in pending_integrated:
|
|
748
|
+
child_data = read_json(child_dir / FILE_TASK_JSON)
|
|
749
|
+
child_guard = validate_archive(child_dir, child_data)
|
|
750
|
+
if not child_guard.ok:
|
|
751
|
+
print(
|
|
752
|
+
colored(
|
|
753
|
+
f"Error: integrated child {child_name} failed archive check.",
|
|
754
|
+
Colors.RED,
|
|
755
|
+
),
|
|
756
|
+
file=sys.stderr,
|
|
757
|
+
)
|
|
758
|
+
for item in child_guard.errors:
|
|
759
|
+
print(f" - {item}", file=sys.stderr)
|
|
760
|
+
return 1
|
|
761
|
+
for child_name, child_dir in pending_integrated:
|
|
762
|
+
ok, _, _ = _archive_one_task(
|
|
763
|
+
child_dir, repo_root, tasks_dir, no_commit=no_commit
|
|
764
|
+
)
|
|
765
|
+
if not ok:
|
|
766
|
+
print(
|
|
767
|
+
colored(
|
|
768
|
+
f"Error: failed to archive integrated child {child_name}.",
|
|
769
|
+
Colors.RED,
|
|
770
|
+
),
|
|
771
|
+
file=sys.stderr,
|
|
772
|
+
)
|
|
773
|
+
return 1
|
|
774
|
+
|
|
775
|
+
manual_child_hint = None
|
|
776
|
+
if pending_integrated and not cascade:
|
|
777
|
+
manual_child_hint = parent_archive_child_followup_hint(
|
|
778
|
+
task_dir, structural_children, tasks_dir
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
ok, _, rel_path = _archive_one_task(
|
|
782
|
+
task_dir, repo_root, tasks_dir, no_commit=no_commit
|
|
783
|
+
)
|
|
784
|
+
if not ok or not rel_path:
|
|
785
|
+
return 1
|
|
786
|
+
|
|
787
|
+
if manual_child_hint:
|
|
788
|
+
print(colored("Note:", Colors.YELLOW), file=sys.stderr)
|
|
789
|
+
print(manual_child_hint, file=sys.stderr)
|
|
790
|
+
|
|
791
|
+
print(rel_path)
|
|
792
|
+
return 0
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _auto_commit_archive(
|
|
796
|
+
task_name: str,
|
|
797
|
+
repo_root: Path,
|
|
798
|
+
modified_children: list[str] | None = None,
|
|
799
|
+
) -> bool:
|
|
800
|
+
"""Stage Trellis-owned task paths and commit after archive.
|
|
801
|
+
|
|
802
|
+
Scoped narrowly to the archived task's source + destination paths
|
|
803
|
+
plus any child task dirs whose ``task.json`` was edited (parent →
|
|
804
|
+
children relationship update). Dirty changes in OTHER active task
|
|
805
|
+
dirs are NOT bundled into the archive commit.
|
|
806
|
+
|
|
807
|
+
If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
|
|
808
|
+
retry with ``git add -f``. The warning explicitly forbids
|
|
809
|
+
``git add -f .trellis/`` (which would fan out to caches/backups)
|
|
810
|
+
and points users at ``session_auto_commit: false``.
|
|
811
|
+
|
|
812
|
+
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
|
|
813
|
+
set to ``false``, this function returns immediately without
|
|
814
|
+
touching git (the archive directory move on disk is unaffected).
|
|
815
|
+
"""
|
|
816
|
+
if not get_session_auto_commit(repo_root):
|
|
817
|
+
print(
|
|
818
|
+
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
|
819
|
+
file=sys.stderr,
|
|
820
|
+
)
|
|
821
|
+
return True
|
|
822
|
+
|
|
823
|
+
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
|
|
824
|
+
rc, tracked_out, _ = run_git(
|
|
825
|
+
["ls-files", "--", source_rel],
|
|
826
|
+
cwd=repo_root,
|
|
827
|
+
)
|
|
828
|
+
source_was_tracked = rc == 0 and bool(tracked_out.strip())
|
|
829
|
+
|
|
830
|
+
paths = safe_archive_paths_to_add(
|
|
831
|
+
repo_root, task_name=task_name, modified_children=modified_children
|
|
832
|
+
)
|
|
833
|
+
if not paths:
|
|
834
|
+
print("[OK] No task changes to commit.", file=sys.stderr)
|
|
835
|
+
return True
|
|
836
|
+
|
|
837
|
+
success, _, err = safe_git_add(paths, repo_root)
|
|
838
|
+
if not success:
|
|
839
|
+
if err and "ignored by" in err.lower():
|
|
840
|
+
print_gitignore_warning(paths)
|
|
841
|
+
else:
|
|
842
|
+
print(
|
|
843
|
+
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
|
844
|
+
file=sys.stderr,
|
|
845
|
+
)
|
|
846
|
+
return not source_was_tracked
|
|
847
|
+
|
|
848
|
+
# Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
|
|
849
|
+
# `git add` (no -A) which only stages additions/modifications. The
|
|
850
|
+
# source task directory was moved away by `shutil.move`, so its files
|
|
851
|
+
# need an explicit `git rm --cached` to stage the deletions in this
|
|
852
|
+
# same commit — otherwise they sit as uncommitted "phantom deletes"
|
|
853
|
+
# against HEAD until something later picks them up.
|
|
854
|
+
#
|
|
855
|
+
# `--ignore-unmatch` makes this a no-op when the task was never tracked
|
|
856
|
+
# (e.g. archiving a task that lived only in working tree).
|
|
857
|
+
run_git(
|
|
858
|
+
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
|
|
859
|
+
cwd=repo_root,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
rc, _, _ = run_git(
|
|
863
|
+
["diff", "--cached", "--quiet", "--", *paths, source_rel],
|
|
864
|
+
cwd=repo_root,
|
|
865
|
+
)
|
|
866
|
+
if rc == 0:
|
|
867
|
+
print("[OK] No task changes to commit.", file=sys.stderr)
|
|
868
|
+
return True
|
|
869
|
+
|
|
870
|
+
commit_msg = f"chore(task): archive {task_name}"
|
|
871
|
+
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
|
872
|
+
if rc == 0:
|
|
873
|
+
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
|
874
|
+
return True
|
|
875
|
+
else:
|
|
876
|
+
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
|
877
|
+
return not source_was_tracked
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# =============================================================================
|
|
881
|
+
# Command: add-subtask
|
|
882
|
+
# =============================================================================
|
|
883
|
+
|
|
884
|
+
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
|
885
|
+
"""Link a child task to a parent task."""
|
|
886
|
+
repo_root = get_repo_root()
|
|
887
|
+
|
|
888
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
889
|
+
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
890
|
+
|
|
891
|
+
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
892
|
+
child_json_path = child_dir / FILE_TASK_JSON
|
|
893
|
+
|
|
894
|
+
if not parent_json_path.is_file():
|
|
895
|
+
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
896
|
+
return 1
|
|
897
|
+
|
|
898
|
+
if not child_json_path.is_file():
|
|
899
|
+
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
900
|
+
return 1
|
|
901
|
+
|
|
902
|
+
parent_data = read_json(parent_json_path)
|
|
903
|
+
child_data = read_json(child_json_path)
|
|
904
|
+
|
|
905
|
+
if not parent_data or not child_data:
|
|
906
|
+
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
907
|
+
return 1
|
|
908
|
+
|
|
909
|
+
# Check if child already has a parent
|
|
910
|
+
existing_parent = child_data.get("parent")
|
|
911
|
+
if existing_parent:
|
|
912
|
+
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
|
|
913
|
+
return 1
|
|
914
|
+
|
|
915
|
+
# Add child to parent's children list
|
|
916
|
+
parent_children = parent_data.get("children", [])
|
|
917
|
+
child_dir_name = child_dir.name
|
|
918
|
+
if child_dir_name not in parent_children:
|
|
919
|
+
parent_children.append(child_dir_name)
|
|
920
|
+
parent_data["children"] = parent_children
|
|
921
|
+
|
|
922
|
+
# Set parent in child's task.json
|
|
923
|
+
child_data["parent"] = parent_dir.name
|
|
924
|
+
|
|
925
|
+
# Write both
|
|
926
|
+
write_json(parent_json_path, parent_data)
|
|
927
|
+
write_json(child_json_path, child_data)
|
|
928
|
+
ensure_task_map(
|
|
929
|
+
parent_dir,
|
|
930
|
+
parent_data,
|
|
931
|
+
list(parent_data.get("children", [])),
|
|
932
|
+
f"Linked Child `{child_dir_name}`.",
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
936
|
+
return 0
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
# =============================================================================
|
|
940
|
+
# Command: remove-subtask
|
|
941
|
+
# =============================================================================
|
|
942
|
+
|
|
943
|
+
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
944
|
+
"""Unlink a child task from a parent task."""
|
|
945
|
+
repo_root = get_repo_root()
|
|
946
|
+
|
|
947
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
948
|
+
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
949
|
+
|
|
950
|
+
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
951
|
+
child_json_path = child_dir / FILE_TASK_JSON
|
|
952
|
+
|
|
953
|
+
if not parent_json_path.is_file():
|
|
954
|
+
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
955
|
+
return 1
|
|
956
|
+
|
|
957
|
+
if not child_json_path.is_file():
|
|
958
|
+
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
959
|
+
return 1
|
|
960
|
+
|
|
961
|
+
parent_data = read_json(parent_json_path)
|
|
962
|
+
child_data = read_json(child_json_path)
|
|
963
|
+
|
|
964
|
+
if not parent_data or not child_data:
|
|
965
|
+
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
966
|
+
return 1
|
|
967
|
+
|
|
968
|
+
# Remove child from parent's children list
|
|
969
|
+
parent_children = parent_data.get("children", [])
|
|
970
|
+
child_dir_name = child_dir.name
|
|
971
|
+
if child_dir_name in parent_children:
|
|
972
|
+
parent_children.remove(child_dir_name)
|
|
973
|
+
parent_data["children"] = parent_children
|
|
974
|
+
|
|
975
|
+
# Clear parent in child's task.json
|
|
976
|
+
child_data["parent"] = None
|
|
977
|
+
|
|
978
|
+
# Write both
|
|
979
|
+
write_json(parent_json_path, parent_data)
|
|
980
|
+
write_json(child_json_path, child_data)
|
|
981
|
+
remove_child_from_task_map(parent_dir, parent_data, child_dir_name)
|
|
982
|
+
|
|
983
|
+
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
984
|
+
return 0
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# =============================================================================
|
|
988
|
+
# Command: prepare-child-worktree
|
|
989
|
+
# =============================================================================
|
|
990
|
+
|
|
991
|
+
def cmd_prepare_child_worktree(args: argparse.Namespace) -> int:
|
|
992
|
+
"""Create and register a Git worktree for a Child task."""
|
|
993
|
+
repo_root = get_repo_root()
|
|
994
|
+
|
|
995
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
996
|
+
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
997
|
+
|
|
998
|
+
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
999
|
+
child_json_path = child_dir / FILE_TASK_JSON
|
|
1000
|
+
|
|
1001
|
+
if not parent_json_path.is_file():
|
|
1002
|
+
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
1003
|
+
return 1
|
|
1004
|
+
if not child_json_path.is_file():
|
|
1005
|
+
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
1006
|
+
return 1
|
|
1007
|
+
|
|
1008
|
+
parent_data = read_json(parent_json_path)
|
|
1009
|
+
child_data = read_json(child_json_path)
|
|
1010
|
+
if not parent_data or not child_data:
|
|
1011
|
+
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1012
|
+
return 1
|
|
1013
|
+
|
|
1014
|
+
if child_data.get("parent") != parent_dir.name:
|
|
1015
|
+
print(colored(f"Error: Child is not linked to parent: {child_dir.name}", Colors.RED), file=sys.stderr)
|
|
1016
|
+
return 1
|
|
1017
|
+
|
|
1018
|
+
branch = args.branch
|
|
1019
|
+
base_ref = getattr(args, "base", None) or child_data.get("base_branch") or parent_data.get("base_branch") or "HEAD"
|
|
1020
|
+
worktree_path, path_error = _resolve_child_worktree_path(
|
|
1021
|
+
repo_root,
|
|
1022
|
+
child_dir,
|
|
1023
|
+
getattr(args, "path", None),
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
errors: list[str] = []
|
|
1027
|
+
errors.extend(_validate_git_repo(repo_root))
|
|
1028
|
+
errors.extend(_validate_branch_name(repo_root, branch))
|
|
1029
|
+
if path_error:
|
|
1030
|
+
errors.append(path_error)
|
|
1031
|
+
if worktree_path and worktree_path.exists():
|
|
1032
|
+
errors.append(f"worktree path already exists: {_repo_relative_path(worktree_path, repo_root)}")
|
|
1033
|
+
if not _git_commit_exists(repo_root, base_ref):
|
|
1034
|
+
errors.append(f"base ref does not resolve to a commit: {base_ref}")
|
|
1035
|
+
|
|
1036
|
+
if errors:
|
|
1037
|
+
print("Prepare-child-worktree check: FAIL" if getattr(args, "check", False) else colored("Error: cannot prepare child worktree.", Colors.RED), file=sys.stderr)
|
|
1038
|
+
for item in errors:
|
|
1039
|
+
print(f" - {item}", file=sys.stderr)
|
|
1040
|
+
print_environment_repair_hints(errors)
|
|
1041
|
+
return 1
|
|
1042
|
+
|
|
1043
|
+
assert worktree_path is not None
|
|
1044
|
+
worktree_rel = _repo_relative_path(worktree_path, repo_root)
|
|
1045
|
+
if getattr(args, "check", False):
|
|
1046
|
+
print("Prepare-child-worktree check: PASS")
|
|
1047
|
+
print(f"Branch: {branch}")
|
|
1048
|
+
print(f"Base: {base_ref}")
|
|
1049
|
+
print(f"Path: {worktree_rel}")
|
|
1050
|
+
print("No files changed.")
|
|
1051
|
+
return 0
|
|
1052
|
+
|
|
1053
|
+
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1054
|
+
if _git_branch_exists(repo_root, branch):
|
|
1055
|
+
git_args = ["worktree", "add", str(worktree_path), branch]
|
|
1056
|
+
else:
|
|
1057
|
+
git_args = ["worktree", "add", "-b", branch, str(worktree_path), base_ref]
|
|
1058
|
+
rc, _, err = run_git(git_args, cwd=repo_root)
|
|
1059
|
+
if rc != 0:
|
|
1060
|
+
print(colored("Error: git worktree add failed.", Colors.RED), file=sys.stderr)
|
|
1061
|
+
print(err.strip() or "unknown git error", file=sys.stderr)
|
|
1062
|
+
return 1
|
|
1063
|
+
|
|
1064
|
+
child_data["branch"] = branch
|
|
1065
|
+
child_data["worktree_path"] = worktree_rel
|
|
1066
|
+
write_json(child_json_path, child_data)
|
|
1067
|
+
|
|
1068
|
+
ok, map_errors = record_child_worktree(
|
|
1069
|
+
parent_dir,
|
|
1070
|
+
parent_data,
|
|
1071
|
+
child_dir.name,
|
|
1072
|
+
branch,
|
|
1073
|
+
worktree_rel,
|
|
1074
|
+
base_ref,
|
|
1075
|
+
)
|
|
1076
|
+
if not ok:
|
|
1077
|
+
print(colored("Error: worktree created, but Parent task-map update failed.", Colors.RED), file=sys.stderr)
|
|
1078
|
+
for item in map_errors:
|
|
1079
|
+
print(f" - {item}", file=sys.stderr)
|
|
1080
|
+
return 1
|
|
1081
|
+
|
|
1082
|
+
print(colored(f"✓ Child worktree prepared: {child_dir.name}", Colors.GREEN))
|
|
1083
|
+
print(f"Branch: {branch}")
|
|
1084
|
+
print(f"Path: {worktree_rel}")
|
|
1085
|
+
print(f"Parent map: {_repo_relative_path(parent_dir / 'task-map.md', repo_root)}")
|
|
1086
|
+
return 0
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
# =============================================================================
|
|
1090
|
+
# Command: set-child-state
|
|
1091
|
+
# =============================================================================
|
|
1092
|
+
|
|
1093
|
+
def cmd_set_child_state(args: argparse.Namespace) -> int:
|
|
1094
|
+
"""Set a Child Worker-reported state in the Parent task-map.md."""
|
|
1095
|
+
repo_root = get_repo_root()
|
|
1096
|
+
|
|
1097
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1098
|
+
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1099
|
+
state = args.state
|
|
1100
|
+
|
|
1101
|
+
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
1102
|
+
child_json_path = child_dir / FILE_TASK_JSON
|
|
1103
|
+
|
|
1104
|
+
if not parent_json_path.is_file():
|
|
1105
|
+
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
1106
|
+
return 1
|
|
1107
|
+
if not child_json_path.is_file():
|
|
1108
|
+
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
1109
|
+
return 1
|
|
1110
|
+
|
|
1111
|
+
parent_data = read_json(parent_json_path)
|
|
1112
|
+
child_data = read_json(child_json_path)
|
|
1113
|
+
if not parent_data or not child_data:
|
|
1114
|
+
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1115
|
+
return 1
|
|
1116
|
+
|
|
1117
|
+
if child_data.get("parent") != parent_dir.name:
|
|
1118
|
+
print(colored(f"Error: Child is not linked to parent: {child_dir.name}", Colors.RED), file=sys.stderr)
|
|
1119
|
+
return 1
|
|
1120
|
+
|
|
1121
|
+
if state in CHILD_STATES and not getattr(args, "evidence", None):
|
|
1122
|
+
print(colored("Error: --evidence is required when setting child state", Colors.RED), file=sys.stderr)
|
|
1123
|
+
return 1
|
|
1124
|
+
if state in PARENT_CONTROLLED_STATES:
|
|
1125
|
+
print(
|
|
1126
|
+
colored(
|
|
1127
|
+
"Error: Parent-controlled Child states require `task.py integrate-child`.",
|
|
1128
|
+
Colors.RED,
|
|
1129
|
+
),
|
|
1130
|
+
file=sys.stderr,
|
|
1131
|
+
)
|
|
1132
|
+
print(
|
|
1133
|
+
"Allowed set-child-state values: "
|
|
1134
|
+
+ ", ".join(sorted(CHILD_REPORT_STATES)),
|
|
1135
|
+
file=sys.stderr,
|
|
1136
|
+
)
|
|
1137
|
+
return 1
|
|
1138
|
+
|
|
1139
|
+
ok, errors = set_child_state(
|
|
1140
|
+
parent_dir,
|
|
1141
|
+
parent_data,
|
|
1142
|
+
child_dir.name,
|
|
1143
|
+
state,
|
|
1144
|
+
args.evidence,
|
|
1145
|
+
getattr(args, "reason", None),
|
|
1146
|
+
)
|
|
1147
|
+
if not ok:
|
|
1148
|
+
print(colored("Error: cannot set child state.", Colors.RED), file=sys.stderr)
|
|
1149
|
+
for item in errors:
|
|
1150
|
+
print(f" - {item}", file=sys.stderr)
|
|
1151
|
+
return 1
|
|
1152
|
+
|
|
1153
|
+
print(colored(f"✓ Child state updated: {child_dir.name} -> {state}", Colors.GREEN))
|
|
1154
|
+
print(f"Parent map: {_repo_relative_path(parent_dir / 'task-map.md', repo_root)}")
|
|
1155
|
+
return 0
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
# =============================================================================
|
|
1159
|
+
# Command: integrate-child
|
|
1160
|
+
# =============================================================================
|
|
1161
|
+
|
|
1162
|
+
def cmd_integrate_child(args: argparse.Namespace) -> int:
|
|
1163
|
+
"""Set a Parent-controlled Child integration state."""
|
|
1164
|
+
repo_root = get_repo_root()
|
|
1165
|
+
|
|
1166
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1167
|
+
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1168
|
+
state = args.state
|
|
1169
|
+
|
|
1170
|
+
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
1171
|
+
child_json_path = child_dir / FILE_TASK_JSON
|
|
1172
|
+
|
|
1173
|
+
if not parent_json_path.is_file():
|
|
1174
|
+
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
1175
|
+
return 1
|
|
1176
|
+
if not child_json_path.is_file():
|
|
1177
|
+
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
1178
|
+
return 1
|
|
1179
|
+
|
|
1180
|
+
parent_data = read_json(parent_json_path)
|
|
1181
|
+
child_data = read_json(child_json_path)
|
|
1182
|
+
if not parent_data or not child_data:
|
|
1183
|
+
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1184
|
+
return 1
|
|
1185
|
+
|
|
1186
|
+
if state not in PARENT_CONTROLLED_STATES:
|
|
1187
|
+
print(
|
|
1188
|
+
colored("Error: state is not Parent-controlled.", Colors.RED),
|
|
1189
|
+
file=sys.stderr,
|
|
1190
|
+
)
|
|
1191
|
+
print(
|
|
1192
|
+
"Allowed integrate-child values: "
|
|
1193
|
+
+ ", ".join(sorted(PARENT_CONTROLLED_STATES)),
|
|
1194
|
+
file=sys.stderr,
|
|
1195
|
+
)
|
|
1196
|
+
return 1
|
|
1197
|
+
|
|
1198
|
+
evidence = args.evidence
|
|
1199
|
+
ref = getattr(args, "ref", None)
|
|
1200
|
+
reason = getattr(args, "reason", None)
|
|
1201
|
+
execute_merge = getattr(args, "execute_merge", False)
|
|
1202
|
+
if getattr(args, "check", False):
|
|
1203
|
+
errors = validate_parent_child_integration(
|
|
1204
|
+
parent_dir,
|
|
1205
|
+
parent_data,
|
|
1206
|
+
child_dir,
|
|
1207
|
+
child_data,
|
|
1208
|
+
state,
|
|
1209
|
+
evidence,
|
|
1210
|
+
ref,
|
|
1211
|
+
reason,
|
|
1212
|
+
)
|
|
1213
|
+
if execute_merge:
|
|
1214
|
+
errors.extend(_validate_merge_execution(repo_root, state, ref))
|
|
1215
|
+
if errors:
|
|
1216
|
+
print("Integrate-child check: FAIL")
|
|
1217
|
+
for item in errors:
|
|
1218
|
+
print(f" - {item}")
|
|
1219
|
+
print_environment_repair_hints(errors, stream=sys.stdout)
|
|
1220
|
+
return 1
|
|
1221
|
+
print("Integrate-child check: PASS")
|
|
1222
|
+
if execute_merge:
|
|
1223
|
+
print("Merge execution check: PASS")
|
|
1224
|
+
print("No files changed.")
|
|
1225
|
+
return 0
|
|
1226
|
+
|
|
1227
|
+
merge_ref = None
|
|
1228
|
+
if execute_merge:
|
|
1229
|
+
merge_errors = _validate_merge_execution(repo_root, state, ref)
|
|
1230
|
+
if merge_errors:
|
|
1231
|
+
print(colored("Error: cannot execute child merge.", Colors.RED), file=sys.stderr)
|
|
1232
|
+
for item in merge_errors:
|
|
1233
|
+
print(f" - {item}", file=sys.stderr)
|
|
1234
|
+
print_environment_repair_hints(merge_errors)
|
|
1235
|
+
return 1
|
|
1236
|
+
|
|
1237
|
+
rc, _, err = run_git(["merge", "--no-ff", "--no-commit", ref], cwd=repo_root)
|
|
1238
|
+
if rc != 0:
|
|
1239
|
+
print(colored("Error: git merge failed; Parent task-map was not advanced to integrated.", Colors.RED), file=sys.stderr)
|
|
1240
|
+
if err.strip():
|
|
1241
|
+
print(err.strip(), file=sys.stderr)
|
|
1242
|
+
print_environment_repair_hints([err.strip()])
|
|
1243
|
+
print("Resolve the merge manually, abort it with `git merge --abort`, or record a `changes` / `cancelled` Parent decision.", file=sys.stderr)
|
|
1244
|
+
return 1
|
|
1245
|
+
merge_ref = ref
|
|
1246
|
+
|
|
1247
|
+
ok, errors = set_parent_child_integration_state(
|
|
1248
|
+
parent_dir,
|
|
1249
|
+
parent_data,
|
|
1250
|
+
child_dir,
|
|
1251
|
+
child_data,
|
|
1252
|
+
state,
|
|
1253
|
+
evidence,
|
|
1254
|
+
ref,
|
|
1255
|
+
reason,
|
|
1256
|
+
merge_ref,
|
|
1257
|
+
)
|
|
1258
|
+
if not ok:
|
|
1259
|
+
print(colored("Error: cannot integrate child.", Colors.RED), file=sys.stderr)
|
|
1260
|
+
for item in errors:
|
|
1261
|
+
print(f" - {item}", file=sys.stderr)
|
|
1262
|
+
return 1
|
|
1263
|
+
|
|
1264
|
+
print(colored(f"✓ Child integration updated: {child_dir.name} -> {state}", Colors.GREEN))
|
|
1265
|
+
if merge_ref:
|
|
1266
|
+
print(f"Merge executed: git merge --no-ff --no-commit {merge_ref}")
|
|
1267
|
+
print(f"Parent map: {_repo_relative_path(parent_dir / 'task-map.md', repo_root)}")
|
|
1268
|
+
return 0
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
# =============================================================================
|
|
1272
|
+
# Command: generate-child-prompt / parent-status / review-child
|
|
1273
|
+
# =============================================================================
|
|
1274
|
+
|
|
1275
|
+
def cmd_suggest_execution_strategy(args: argparse.Namespace) -> int:
|
|
1276
|
+
"""Suggest execution_mode and isolation for implement.md contract."""
|
|
1277
|
+
import json
|
|
1278
|
+
|
|
1279
|
+
from .execution_strategy import (
|
|
1280
|
+
format_contract_yaml_block,
|
|
1281
|
+
suggest_execution_strategy,
|
|
1282
|
+
)
|
|
1283
|
+
from .task_utils import resolve_task_dir
|
|
1284
|
+
|
|
1285
|
+
repo_root = get_repo_root()
|
|
1286
|
+
task_dir = resolve_task_dir(args.task_dir, repo_root)
|
|
1287
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
1288
|
+
if not task_json_path.is_file():
|
|
1289
|
+
print(colored("Error: task.json not found", Colors.RED), file=sys.stderr)
|
|
1290
|
+
return 1
|
|
1291
|
+
task_data = read_json(task_json_path)
|
|
1292
|
+
if not isinstance(task_data, dict):
|
|
1293
|
+
print(colored("Error: invalid task.json", Colors.RED), file=sys.stderr)
|
|
1294
|
+
return 1
|
|
1295
|
+
|
|
1296
|
+
suggestion = suggest_execution_strategy(repo_root, task_dir, task_data)
|
|
1297
|
+
if getattr(args, "json", False):
|
|
1298
|
+
print(json.dumps(suggestion.to_dict(), indent=2, ensure_ascii=False))
|
|
1299
|
+
return 0
|
|
1300
|
+
print(format_contract_yaml_block(suggestion))
|
|
1301
|
+
return 0
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def cmd_generate_dispatch_prompt(args: argparse.Namespace) -> int:
|
|
1305
|
+
"""Build a full Task dispatch prompt (Agent-facing CLI Layer 2)."""
|
|
1306
|
+
from .subagent_dispatch import build_dispatch_prompt
|
|
1307
|
+
|
|
1308
|
+
repo_root = get_repo_root()
|
|
1309
|
+
task_dir = resolve_task_dir(args.task_dir, repo_root)
|
|
1310
|
+
role = args.role
|
|
1311
|
+
scope = getattr(args, "scope", None)
|
|
1312
|
+
finish = bool(getattr(args, "finish", False))
|
|
1313
|
+
max_chars = getattr(args, "max_chars", None)
|
|
1314
|
+
|
|
1315
|
+
prompt, warnings, errors = build_dispatch_prompt(
|
|
1316
|
+
repo_root,
|
|
1317
|
+
task_dir,
|
|
1318
|
+
role,
|
|
1319
|
+
scope=scope,
|
|
1320
|
+
finish=finish,
|
|
1321
|
+
max_chars=max_chars,
|
|
1322
|
+
)
|
|
1323
|
+
for item in warnings:
|
|
1324
|
+
print(f"[generate-dispatch-prompt] WARN: {item}", file=sys.stderr)
|
|
1325
|
+
if errors:
|
|
1326
|
+
for item in errors:
|
|
1327
|
+
print(f"[generate-dispatch-prompt] Error: {item}", file=sys.stderr)
|
|
1328
|
+
return 1
|
|
1329
|
+
if prompt is None:
|
|
1330
|
+
print(colored("Error: could not build dispatch prompt", Colors.RED), file=sys.stderr)
|
|
1331
|
+
return 1
|
|
1332
|
+
print(prompt)
|
|
1333
|
+
return 0
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def cmd_generate_child_prompt(args: argparse.Namespace) -> int:
|
|
1337
|
+
"""Generate a child implementation prompt for parent orchestration."""
|
|
1338
|
+
from .parent_orchestration import build_child_prompt
|
|
1339
|
+
|
|
1340
|
+
repo_root = get_repo_root()
|
|
1341
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1342
|
+
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1343
|
+
mode = getattr(args, "mode", "inline") or "inline"
|
|
1344
|
+
if mode not in ("inline", "subagent"):
|
|
1345
|
+
print(colored("Error: --mode must be inline or subagent", Colors.RED), file=sys.stderr)
|
|
1346
|
+
return 1
|
|
1347
|
+
|
|
1348
|
+
prompt, errors = build_child_prompt(
|
|
1349
|
+
parent_dir,
|
|
1350
|
+
child_dir,
|
|
1351
|
+
include_artifacts=getattr(args, "include_artifacts", False),
|
|
1352
|
+
mode=mode,
|
|
1353
|
+
)
|
|
1354
|
+
if errors:
|
|
1355
|
+
for item in errors:
|
|
1356
|
+
print(f" - {item}", file=sys.stderr)
|
|
1357
|
+
return 1
|
|
1358
|
+
if prompt is None:
|
|
1359
|
+
print(colored("Error: could not generate child prompt", Colors.RED), file=sys.stderr)
|
|
1360
|
+
return 1
|
|
1361
|
+
|
|
1362
|
+
out_path = getattr(args, "output", None)
|
|
1363
|
+
if out_path:
|
|
1364
|
+
path = Path(out_path)
|
|
1365
|
+
if not path.is_absolute():
|
|
1366
|
+
path = repo_root / path
|
|
1367
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1368
|
+
path.write_text(prompt + "\n", encoding="utf-8")
|
|
1369
|
+
print(colored(f"✓ Child prompt written: {_repo_relative_path(path, repo_root)}", Colors.GREEN))
|
|
1370
|
+
else:
|
|
1371
|
+
print(prompt)
|
|
1372
|
+
return 0
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def cmd_parent_status(args: argparse.Namespace) -> int:
|
|
1376
|
+
"""Show parent task-map orchestration status."""
|
|
1377
|
+
from .parent_orchestration import build_parent_status
|
|
1378
|
+
|
|
1379
|
+
repo_root = get_repo_root()
|
|
1380
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1381
|
+
print(build_parent_status(parent_dir))
|
|
1382
|
+
return 0
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def cmd_review_child(args: argparse.Namespace) -> int:
|
|
1386
|
+
"""Review child handoff and optionally advance parent integration states."""
|
|
1387
|
+
from .parent_orchestration import (
|
|
1388
|
+
append_parent_review_notes,
|
|
1389
|
+
build_review_report,
|
|
1390
|
+
write_review_artifact,
|
|
1391
|
+
)
|
|
1392
|
+
from .task_map import set_parent_child_integration_state
|
|
1393
|
+
|
|
1394
|
+
repo_root = get_repo_root()
|
|
1395
|
+
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
1396
|
+
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
1397
|
+
|
|
1398
|
+
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
1399
|
+
child_json_path = child_dir / FILE_TASK_JSON
|
|
1400
|
+
if not parent_json_path.is_file() or not child_json_path.is_file():
|
|
1401
|
+
print(colored("Error: parent or child task.json missing", Colors.RED), file=sys.stderr)
|
|
1402
|
+
return 1
|
|
1403
|
+
|
|
1404
|
+
parent_data = read_json(parent_json_path)
|
|
1405
|
+
child_data = read_json(child_json_path)
|
|
1406
|
+
if not parent_data or not child_data:
|
|
1407
|
+
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
|
1408
|
+
return 1
|
|
1409
|
+
|
|
1410
|
+
decision = getattr(args, "decision", None)
|
|
1411
|
+
ref = getattr(args, "ref", None)
|
|
1412
|
+
reason = getattr(args, "reason", None)
|
|
1413
|
+
notes = getattr(args, "notes", None)
|
|
1414
|
+
check_only = getattr(args, "check", False) or not decision
|
|
1415
|
+
|
|
1416
|
+
report, errors, actions = build_review_report(
|
|
1417
|
+
parent_dir,
|
|
1418
|
+
child_dir,
|
|
1419
|
+
parent_data,
|
|
1420
|
+
child_data,
|
|
1421
|
+
decision=decision if not check_only else None,
|
|
1422
|
+
ref=ref,
|
|
1423
|
+
reason=reason,
|
|
1424
|
+
notes=notes,
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
if getattr(args, "write_artifact", False):
|
|
1428
|
+
artifact = write_review_artifact(parent_dir, child_dir.name, report)
|
|
1429
|
+
print(colored(f"✓ Review artifact: {_repo_relative_path(artifact, repo_root)}", Colors.GREEN))
|
|
1430
|
+
|
|
1431
|
+
if check_only:
|
|
1432
|
+
if errors:
|
|
1433
|
+
print("Review-child check: FAIL")
|
|
1434
|
+
for item in errors:
|
|
1435
|
+
print(f" - {item}")
|
|
1436
|
+
print("")
|
|
1437
|
+
print(report)
|
|
1438
|
+
return 1
|
|
1439
|
+
print("Review-child check: PASS")
|
|
1440
|
+
print("")
|
|
1441
|
+
print(report)
|
|
1442
|
+
return 0
|
|
1443
|
+
|
|
1444
|
+
if errors:
|
|
1445
|
+
print(colored("Error: review decision blocked by validation.", Colors.RED), file=sys.stderr)
|
|
1446
|
+
for item in errors:
|
|
1447
|
+
print(f" - {item}", file=sys.stderr)
|
|
1448
|
+
return 1
|
|
1449
|
+
|
|
1450
|
+
evidence_default = "handoff.md"
|
|
1451
|
+
if actions.get("integrate_sequence"):
|
|
1452
|
+
for step in actions["integrate_sequence"]:
|
|
1453
|
+
ok, step_errors = set_parent_child_integration_state(
|
|
1454
|
+
parent_dir,
|
|
1455
|
+
parent_data,
|
|
1456
|
+
child_dir,
|
|
1457
|
+
child_data,
|
|
1458
|
+
step["state"],
|
|
1459
|
+
step.get("evidence", evidence_default),
|
|
1460
|
+
step.get("ref", ref),
|
|
1461
|
+
step.get("reason", reason),
|
|
1462
|
+
)
|
|
1463
|
+
if not ok:
|
|
1464
|
+
print(colored("Error: integrate-child step failed.", Colors.RED), file=sys.stderr)
|
|
1465
|
+
for item in step_errors:
|
|
1466
|
+
print(f" - {item}", file=sys.stderr)
|
|
1467
|
+
return 1
|
|
1468
|
+
print(colored(f"✓ Child integration updated: {child_dir.name} -> {step['state']}", Colors.GREEN))
|
|
1469
|
+
elif actions.get("integrate"):
|
|
1470
|
+
step = actions["integrate"]
|
|
1471
|
+
ok, step_errors = set_parent_child_integration_state(
|
|
1472
|
+
parent_dir,
|
|
1473
|
+
parent_data,
|
|
1474
|
+
child_dir,
|
|
1475
|
+
child_data,
|
|
1476
|
+
step["state"],
|
|
1477
|
+
step.get("evidence", evidence_default),
|
|
1478
|
+
step.get("ref", ref),
|
|
1479
|
+
step.get("reason", reason),
|
|
1480
|
+
)
|
|
1481
|
+
if not ok:
|
|
1482
|
+
print(colored("Error: integrate-child failed.", Colors.RED), file=sys.stderr)
|
|
1483
|
+
for item in step_errors:
|
|
1484
|
+
print(f" - {item}", file=sys.stderr)
|
|
1485
|
+
return 1
|
|
1486
|
+
print(colored(f"✓ Child integration updated: {child_dir.name} -> {step['state']}", Colors.GREEN))
|
|
1487
|
+
|
|
1488
|
+
if not getattr(args, "no_append_parent_verify", False):
|
|
1489
|
+
append_parent_review_notes(parent_dir, child_dir.name, report)
|
|
1490
|
+
print(colored("✓ Appended review notes to parent verify.md", Colors.GREEN))
|
|
1491
|
+
|
|
1492
|
+
for gate in actions.get("gates", []):
|
|
1493
|
+
if gate.get("optional") and gate.get("hint"):
|
|
1494
|
+
print(f"Optional reviewer gate: {gate['hint']}")
|
|
1495
|
+
|
|
1496
|
+
print(report)
|
|
1497
|
+
return 0
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
# =============================================================================
|
|
1501
|
+
# Command: set-branch
|
|
1502
|
+
# =============================================================================
|
|
1503
|
+
|
|
1504
|
+
def cmd_set_branch(args: argparse.Namespace) -> int:
|
|
1505
|
+
"""Set git branch for task."""
|
|
1506
|
+
repo_root = get_repo_root()
|
|
1507
|
+
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
1508
|
+
branch = args.branch
|
|
1509
|
+
|
|
1510
|
+
if not branch:
|
|
1511
|
+
print(colored("Error: Missing arguments", Colors.RED))
|
|
1512
|
+
print("Usage: python task.py set-branch <task-dir> <branch-name>")
|
|
1513
|
+
return 1
|
|
1514
|
+
|
|
1515
|
+
task_json = target_dir / FILE_TASK_JSON
|
|
1516
|
+
if not task_json.is_file():
|
|
1517
|
+
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
|
1518
|
+
return 1
|
|
1519
|
+
|
|
1520
|
+
data = read_json(task_json)
|
|
1521
|
+
if not data:
|
|
1522
|
+
return 1
|
|
1523
|
+
|
|
1524
|
+
data["branch"] = branch
|
|
1525
|
+
write_json(task_json, data)
|
|
1526
|
+
|
|
1527
|
+
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
|
1528
|
+
return 0
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
# =============================================================================
|
|
1532
|
+
# Command: set-base-branch
|
|
1533
|
+
# =============================================================================
|
|
1534
|
+
|
|
1535
|
+
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
|
1536
|
+
"""Set the base branch (PR target) for task."""
|
|
1537
|
+
repo_root = get_repo_root()
|
|
1538
|
+
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
1539
|
+
base_branch = args.base_branch
|
|
1540
|
+
|
|
1541
|
+
if not base_branch:
|
|
1542
|
+
print(colored("Error: Missing arguments", Colors.RED))
|
|
1543
|
+
print("Usage: python task.py set-base-branch <task-dir> <base-branch>")
|
|
1544
|
+
print("Example: python task.py set-base-branch <dir> develop")
|
|
1545
|
+
print()
|
|
1546
|
+
print("This sets the target branch for PR (the branch your feature will merge into).")
|
|
1547
|
+
return 1
|
|
1548
|
+
|
|
1549
|
+
task_json = target_dir / FILE_TASK_JSON
|
|
1550
|
+
if not task_json.is_file():
|
|
1551
|
+
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
|
1552
|
+
return 1
|
|
1553
|
+
|
|
1554
|
+
data = read_json(task_json)
|
|
1555
|
+
if not data:
|
|
1556
|
+
return 1
|
|
1557
|
+
|
|
1558
|
+
data["base_branch"] = base_branch
|
|
1559
|
+
write_json(task_json, data)
|
|
1560
|
+
|
|
1561
|
+
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
|
1562
|
+
print(f" PR will target: {base_branch}")
|
|
1563
|
+
return 0
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
# =============================================================================
|
|
1567
|
+
# Command: set-scope
|
|
1568
|
+
# =============================================================================
|
|
1569
|
+
|
|
1570
|
+
def cmd_set_scope(args: argparse.Namespace) -> int:
|
|
1571
|
+
"""Set scope for PR title."""
|
|
1572
|
+
repo_root = get_repo_root()
|
|
1573
|
+
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
1574
|
+
scope = args.scope
|
|
1575
|
+
|
|
1576
|
+
if not scope:
|
|
1577
|
+
print(colored("Error: Missing arguments", Colors.RED))
|
|
1578
|
+
print("Usage: python task.py set-scope <task-dir> <scope>")
|
|
1579
|
+
return 1
|
|
1580
|
+
|
|
1581
|
+
task_json = target_dir / FILE_TASK_JSON
|
|
1582
|
+
if not task_json.is_file():
|
|
1583
|
+
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
|
1584
|
+
return 1
|
|
1585
|
+
|
|
1586
|
+
data = read_json(task_json)
|
|
1587
|
+
if not data:
|
|
1588
|
+
return 1
|
|
1589
|
+
|
|
1590
|
+
data["scope"] = scope
|
|
1591
|
+
write_json(task_json, data)
|
|
1592
|
+
|
|
1593
|
+
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
|
1594
|
+
return 0
|