@pennixrv/trellis 0.6.44 → 0.7.0-beta.10
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/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/mem.d.ts.map +1 -1
- package/dist/commands/mem.js +6 -13
- package/dist/commands/mem.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts +17 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +222 -0
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/claude.d.ts.map +1 -1
- package/dist/configurators/claude.js.map +1 -1
- package/dist/configurators/dsh.d.ts +16 -19
- package/dist/configurators/dsh.d.ts.map +1 -1
- package/dist/configurators/dsh.js +38 -26
- package/dist/configurators/dsh.js.map +1 -1
- package/dist/configurators/gemini.d.ts.map +1 -1
- package/dist/configurators/gemini.js.map +1 -1
- package/dist/configurators/opencode.d.ts.map +1 -1
- package/dist/configurators/opencode.js +4 -0
- package/dist/configurators/opencode.js.map +1 -1
- package/dist/migrations/manifests/0.6.17.json +2 -2
- package/dist/migrations/manifests/0.6.45.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.10.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.4.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.4.pennix.1.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.5.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.6.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.7.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.8.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.9.json +9 -0
- package/dist/templates/claude/hooks/statusline.py +7 -0
- package/dist/templates/claude/settings.json +22 -0
- package/dist/templates/codex/hooks/session-start.py +20 -1
- package/dist/templates/codex/hooks.json +25 -0
- package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +32 -3
- package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +1 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +2 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +3 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +5 -4
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +1 -0
- package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +3 -2
- package/dist/templates/common/commands/continue.md +4 -2
- package/dist/templates/common/skills/brainstorm.md +14 -6
- package/dist/templates/copilot/hooks/session-start.py +20 -1
- package/dist/templates/copilot/prompts/brainstorm.prompt.md +16 -6
- package/dist/templates/dsh/DSH.md +61 -37
- package/dist/templates/dsh/agents/trellis-check.md +99 -0
- package/dist/templates/dsh/agents/trellis-implement.md +106 -0
- package/dist/templates/dsh/agents/trellis-research.md +134 -0
- package/dist/templates/dsh/index.d.ts +12 -11
- package/dist/templates/dsh/index.d.ts.map +1 -1
- package/dist/templates/dsh/index.js +14 -12
- package/dist/templates/dsh/index.js.map +1 -1
- package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
- package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
- package/dist/templates/pi/extensions/trellis/index.ts.txt +164 -10
- package/dist/templates/shared-hooks/index.d.ts +8 -2
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +13 -1
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -3
- package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
- package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
- package/dist/templates/shared-hooks/session-start.py +22 -1
- package/dist/templates/trellis/agents/subnode.md +11 -4
- package/dist/templates/trellis/config.yaml +49 -0
- package/dist/templates/trellis/index.d.ts +3 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +6 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/active_task.py +26 -4
- package/dist/templates/trellis/scripts/common/cli_adapter.py +38 -6
- package/dist/templates/trellis/scripts/common/config.py +18 -0
- package/dist/templates/trellis/scripts/common/context_projection.py +2 -2
- package/dist/templates/trellis/scripts/common/git_context.py +34 -2
- package/dist/templates/trellis/scripts/common/paths.py +28 -0
- package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
- package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
- package/dist/templates/trellis/scripts/common/task_store.py +88 -1
- package/dist/templates/trellis/scripts/common/trellis_config.py +46 -7
- package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
- package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
- package/dist/templates/trellis/scripts/subnode_artifact.py +153 -15
- package/dist/templates/trellis/scripts/task.py +160 -0
- package/dist/templates/trellis/workflow.md +49 -34
- package/dist/types/ai-tools.d.ts.map +1 -1
- package/dist/types/ai-tools.js +26 -13
- package/dist/types/ai-tools.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Per-task workflow selection.
|
|
4
|
+
|
|
5
|
+
Resolves which workflow markdown file consumers should read. A task may pin
|
|
6
|
+
a workflow variant by storing `"workflow": "<id>"` in its task.json; the
|
|
7
|
+
variant body lives at `.trellis/workflows/<id>.md` (user-managed library).
|
|
8
|
+
|
|
9
|
+
Resolution precedence (single source of truth for all consumers), highest
|
|
10
|
+
to lowest — each layer resolves an id to `.trellis/workflows/<id>.md` and
|
|
11
|
+
falls through when unset, invalid, or pointing at a missing file:
|
|
12
|
+
1. Per-task pin - active task's task.json `workflow` (session-bound,
|
|
13
|
+
explicit; a bad id or missing file warns once on stderr, then falls
|
|
14
|
+
through rather than aborting).
|
|
15
|
+
2. Personal - `.developer` `workflow=<id>` (gitignored, per-developer;
|
|
16
|
+
outranks the team default). Silent on miss.
|
|
17
|
+
3. Team default - config.yaml `default_workflow` (git-tracked, shared).
|
|
18
|
+
Silent on miss.
|
|
19
|
+
4. Global - `.trellis/workflow.md`.
|
|
20
|
+
With neither a per-task pin nor the personal/team keys set, this is identical
|
|
21
|
+
to reading the global `.trellis/workflow.md`. Never raises.
|
|
22
|
+
|
|
23
|
+
Provides:
|
|
24
|
+
workflow_md_for_task - Full precedence for an already-resolved task dir
|
|
25
|
+
resolve_workflow_md - Session-aware wrapper via the active task resolver
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from .paths import DIR_WORKFLOW, FILE_TASK_JSON
|
|
36
|
+
|
|
37
|
+
# Workflow variant library directory under .trellis/ (plural on purpose:
|
|
38
|
+
# `.trellis/workflow/` is reserved by the YAML-manifest migration).
|
|
39
|
+
DIR_WORKFLOWS = "workflows"
|
|
40
|
+
|
|
41
|
+
# Workflow ids must be plain slugs; anything else (path separators, dots)
|
|
42
|
+
# is rejected so a task.json value can never escape .trellis/workflows/.
|
|
43
|
+
WORKFLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _global_workflow_md(repo_root: Path) -> Path:
|
|
47
|
+
return repo_root / DIR_WORKFLOW / "workflow.md"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _library_variant(repo_root: Path, workflow_id: str | None) -> Path | None:
|
|
51
|
+
"""Map a workflow id to its library file if valid and present, else None.
|
|
52
|
+
|
|
53
|
+
Shared by every layer (per-task pin, personal, team). An id with path
|
|
54
|
+
separators/dots/blanks, or one whose `.trellis/workflows/<id>.md` file does
|
|
55
|
+
not exist, returns None so the caller falls through. Never raises.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(workflow_id, str) or not workflow_id:
|
|
58
|
+
return None
|
|
59
|
+
if not WORKFLOW_ID_RE.match(workflow_id):
|
|
60
|
+
return None
|
|
61
|
+
variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
|
|
62
|
+
return variant if variant.is_file() else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _developer_workflow_id(repo_root: Path) -> str | None:
|
|
66
|
+
"""Personal override id from the gitignored .developer file (fail-open)."""
|
|
67
|
+
try:
|
|
68
|
+
from .paths import get_developer_workflow
|
|
69
|
+
|
|
70
|
+
return get_developer_workflow(repo_root)
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _config_default_id(repo_root: Path) -> str | None:
|
|
76
|
+
"""Team-shared default id from config.yaml `default_workflow` (fail-open)."""
|
|
77
|
+
try:
|
|
78
|
+
from .config import get_default_workflow
|
|
79
|
+
|
|
80
|
+
return get_default_workflow(repo_root)
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _default_workflow_md(repo_root: Path) -> Path:
|
|
86
|
+
"""Resolve the non-per-task default: personal -> team -> global.
|
|
87
|
+
|
|
88
|
+
Personal (`.developer` `workflow=`) outranks the team-shared
|
|
89
|
+
(config.yaml `default_workflow`) layer; both fall through to the global
|
|
90
|
+
`.trellis/workflow.md` when unset, invalid, or naming a missing file. These
|
|
91
|
+
layers are silent on miss (they are defaults, not an explicit per-task
|
|
92
|
+
choice — a per-turn warning would be noise).
|
|
93
|
+
"""
|
|
94
|
+
for get_id in (_developer_workflow_id, _config_default_id):
|
|
95
|
+
variant = _library_variant(repo_root, get_id(repo_root))
|
|
96
|
+
if variant is not None:
|
|
97
|
+
return variant
|
|
98
|
+
return _global_workflow_md(repo_root)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _task_pin_variant(repo_root: Path, task_dir: Path | None) -> Path | None:
|
|
102
|
+
"""Return the per-task pinned variant path, or None to fall through.
|
|
103
|
+
|
|
104
|
+
Emits a stderr warning on an invalid id or a missing variant file (an
|
|
105
|
+
explicit per-task choice that cannot be honored), then returns None so
|
|
106
|
+
resolution continues with the personal/team defaults. Never raises.
|
|
107
|
+
"""
|
|
108
|
+
if task_dir is None:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8"))
|
|
113
|
+
if not isinstance(raw, dict):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
workflow_id = raw.get("workflow")
|
|
117
|
+
if not isinstance(workflow_id, str) or not workflow_id:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
if not WORKFLOW_ID_RE.match(workflow_id):
|
|
121
|
+
print(
|
|
122
|
+
f"Warning: task '{task_dir.name}' has invalid workflow id "
|
|
123
|
+
f"{workflow_id!r}; using default workflow resolution",
|
|
124
|
+
file=sys.stderr,
|
|
125
|
+
)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
variant = _library_variant(repo_root, workflow_id)
|
|
129
|
+
if variant is not None:
|
|
130
|
+
return variant
|
|
131
|
+
|
|
132
|
+
print(
|
|
133
|
+
f"Warning: task '{task_dir.name}' selects workflow '{workflow_id}' but "
|
|
134
|
+
f"{DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md is missing; "
|
|
135
|
+
f"using default workflow resolution",
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
return None
|
|
139
|
+
except Exception:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path:
|
|
144
|
+
"""Return the workflow.md path for an already-resolved task dir (or None).
|
|
145
|
+
|
|
146
|
+
Applies the full precedence documented in the module docstring:
|
|
147
|
+
per-task pin -> personal (.developer) -> team (config.yaml) -> global.
|
|
148
|
+
Never raises; any failure falls through toward the global workflow path.
|
|
149
|
+
"""
|
|
150
|
+
pin = _task_pin_variant(repo_root, task_dir)
|
|
151
|
+
if pin is not None:
|
|
152
|
+
return pin
|
|
153
|
+
return _default_workflow_md(repo_root)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_workflow_md(
|
|
157
|
+
repo_root: Path,
|
|
158
|
+
input_data: dict | None = None,
|
|
159
|
+
platform: str | None = None,
|
|
160
|
+
) -> Path:
|
|
161
|
+
"""Resolve the session-aware active task, then apply the resolution rule.
|
|
162
|
+
|
|
163
|
+
``input_data`` is the raw hook payload (session/conversation identity);
|
|
164
|
+
CLI callers may omit it — the active-task resolver then falls back to
|
|
165
|
+
environment context. Never raises; any failure resolves to the global
|
|
166
|
+
`.trellis/workflow.md`.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
from .active_task import resolve_active_task, resolve_task_ref
|
|
170
|
+
|
|
171
|
+
active = resolve_active_task(repo_root, input_data, platform)
|
|
172
|
+
task_dir: Path | None = None
|
|
173
|
+
if active.task_path:
|
|
174
|
+
task_dir = resolve_task_ref(active.task_path, repo_root)
|
|
175
|
+
return workflow_md_for_task(repo_root, task_dir)
|
|
176
|
+
except Exception:
|
|
177
|
+
return _global_workflow_md(repo_root)
|
|
@@ -22,10 +22,12 @@ from common.task_utils import is_within_tasks_dir, resolve_task_dir
|
|
|
22
22
|
from common.tasks import load_task
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
SCHEMA_VERSION =
|
|
25
|
+
SCHEMA_VERSION = 2
|
|
26
26
|
MAX_DRAFT_BYTES = 64 * 1024
|
|
27
27
|
MAX_REPORT_BYTES = 128 * 1024
|
|
28
|
+
MAX_WORKLOG_BYTES = 128 * 1024
|
|
28
29
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
30
|
+
CHECKPOINT_RE = re.compile(r"^<!-- trellis-checkpoint: (?P<payload>\{.*\}) -->$", re.MULTILINE)
|
|
29
31
|
SECRET_PATTERNS = (
|
|
30
32
|
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
31
33
|
re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"),
|
|
@@ -305,7 +307,7 @@ def _validate_brief_file(
|
|
|
305
307
|
return brief, raw, brief_path, node_dir, task_dir
|
|
306
308
|
|
|
307
309
|
|
|
308
|
-
def _validate_evidence(value: Any) -> set[
|
|
310
|
+
def _validate_evidence(value: Any) -> set[str]:
|
|
309
311
|
if not isinstance(value, list):
|
|
310
312
|
_fail("report.evidence must be a list")
|
|
311
313
|
identities: set[tuple[str, str]] = set()
|
|
@@ -319,13 +321,100 @@ def _validate_evidence(value: Any) -> set[tuple[str, str]]:
|
|
|
319
321
|
if identity in identities:
|
|
320
322
|
_fail("report.evidence contains a duplicate evidence identity")
|
|
321
323
|
identities.add(identity)
|
|
322
|
-
return identities
|
|
324
|
+
return {evidence_id for evidence_id, _locator in identities}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _validate_id_list(value: Any, field: str) -> list[str]:
|
|
328
|
+
if not isinstance(value, list):
|
|
329
|
+
_fail(f"{field} must be a list")
|
|
330
|
+
result = []
|
|
331
|
+
for index, item in enumerate(value):
|
|
332
|
+
result.append(_require_text(item, f"{field}[{index}]", max_len=128))
|
|
333
|
+
if len(result) != len(set(result)):
|
|
334
|
+
_fail(f"{field} must not contain duplicates")
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _validate_typed_notes(value: Any, field: str) -> None:
|
|
339
|
+
if not isinstance(value, list):
|
|
340
|
+
_fail(f"report.{field} must be a list")
|
|
341
|
+
for index, item in enumerate(value):
|
|
342
|
+
if not isinstance(item, dict):
|
|
343
|
+
_fail(f"report.{field}[{index}] must be an object")
|
|
344
|
+
_require_id(item.get("id"), f"report.{field}[{index}].id")
|
|
345
|
+
_require_text(item.get("type"), f"report.{field}[{index}].type", max_len=64)
|
|
346
|
+
_require_text(item.get("detail"), f"report.{field}[{index}].detail")
|
|
347
|
+
_validate_id_list(item.get("evidence_ids", []), f"report.{field}[{index}].evidence_ids")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _validate_checkpoint(node_dir: Path, evidence_ids: set[str], scope: list[str]) -> list[str]:
|
|
351
|
+
worklog_path = node_dir / "worklog.md"
|
|
352
|
+
if worklog_path.is_symlink():
|
|
353
|
+
_fail(f"refusing symlinked worklog: {worklog_path}")
|
|
354
|
+
try:
|
|
355
|
+
raw = worklog_path.read_bytes()
|
|
356
|
+
except FileNotFoundError:
|
|
357
|
+
_fail(f"worklog does not exist: {worklog_path}")
|
|
358
|
+
except OSError as exc:
|
|
359
|
+
_fail(f"could not read worklog {worklog_path}: {exc}")
|
|
360
|
+
if len(raw) > MAX_WORKLOG_BYTES:
|
|
361
|
+
_fail(f"worklog exceeds the {MAX_WORKLOG_BYTES} byte limit: {worklog_path}")
|
|
362
|
+
_reject_obvious_secrets(raw, "worklog")
|
|
363
|
+
try:
|
|
364
|
+
text = raw.decode("utf-8")
|
|
365
|
+
except UnicodeDecodeError:
|
|
366
|
+
_fail(f"worklog is not UTF-8: {worklog_path}")
|
|
367
|
+
matches = list(CHECKPOINT_RE.finditer(text))
|
|
368
|
+
if not matches:
|
|
369
|
+
return ["missing_worklog_checkpoint"]
|
|
370
|
+
concerns: list[str] = []
|
|
371
|
+
checkpoint_ids: set[str] = set()
|
|
372
|
+
for match in matches:
|
|
373
|
+
try:
|
|
374
|
+
checkpoint = json.loads(match.group("payload"))
|
|
375
|
+
except json.JSONDecodeError:
|
|
376
|
+
concerns.append("malformed_worklog_checkpoint")
|
|
377
|
+
continue
|
|
378
|
+
if not isinstance(checkpoint, dict):
|
|
379
|
+
concerns.append("malformed_worklog_checkpoint")
|
|
380
|
+
continue
|
|
381
|
+
try:
|
|
382
|
+
checkpoint_id = _require_id(checkpoint.get("id"), "worklog checkpoint.id")
|
|
383
|
+
if checkpoint_id in checkpoint_ids:
|
|
384
|
+
concerns.append("duplicate_worklog_checkpoint")
|
|
385
|
+
checkpoint_ids.add(checkpoint_id)
|
|
386
|
+
covered_scope = _require_text_list(
|
|
387
|
+
checkpoint.get("covered_scope"),
|
|
388
|
+
"worklog checkpoint.covered_scope",
|
|
389
|
+
allow_empty=True,
|
|
390
|
+
)
|
|
391
|
+
for item in covered_scope:
|
|
392
|
+
if item not in scope:
|
|
393
|
+
concerns.append("checkpoint_scope_outside_brief")
|
|
394
|
+
checkpoint_evidence = _validate_id_list(
|
|
395
|
+
checkpoint.get("evidence_ids"), "worklog checkpoint.evidence_ids"
|
|
396
|
+
)
|
|
397
|
+
if any(item not in evidence_ids for item in checkpoint_evidence):
|
|
398
|
+
concerns.append("checkpoint_evidence_unresolved")
|
|
399
|
+
_require_text(
|
|
400
|
+
checkpoint.get("conclusion_or_blocker"),
|
|
401
|
+
"worklog checkpoint.conclusion_or_blocker",
|
|
402
|
+
)
|
|
403
|
+
_require_text_list(
|
|
404
|
+
checkpoint.get("unknowns"),
|
|
405
|
+
"worklog checkpoint.unknowns",
|
|
406
|
+
allow_empty=True,
|
|
407
|
+
)
|
|
408
|
+
_require_text(checkpoint.get("safe_resume_point"), "worklog checkpoint.safe_resume_point")
|
|
409
|
+
except ArtifactError:
|
|
410
|
+
concerns.append("malformed_worklog_checkpoint")
|
|
411
|
+
return sorted(set(concerns))
|
|
323
412
|
|
|
324
413
|
|
|
325
414
|
def _validate_report_data(
|
|
326
415
|
report: dict[str, Any],
|
|
327
416
|
brief: dict[str, Any],
|
|
328
|
-
) -> set[
|
|
417
|
+
) -> tuple[set[str], list[str]]:
|
|
329
418
|
if report.get("schema_version") != SCHEMA_VERSION:
|
|
330
419
|
_fail(f"report.schema_version must be {SCHEMA_VERSION}")
|
|
331
420
|
for field in ("task_id", "work_id", "subnode_id", "role_id"):
|
|
@@ -339,11 +428,52 @@ def _validate_report_data(
|
|
|
339
428
|
if status not in {"complete", "blocked", "incomplete", "error"}:
|
|
340
429
|
_fail("report.status must be complete, blocked, incomplete, or error")
|
|
341
430
|
evidence = _validate_evidence(report.get("evidence"))
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
431
|
+
assessment = report.get("scope_assessment")
|
|
432
|
+
if not isinstance(assessment, list) or len(assessment) != len(brief["scope"]):
|
|
433
|
+
_fail("report.scope_assessment must contain one item for every brief scope item")
|
|
434
|
+
concerns: list[str] = []
|
|
435
|
+
for index, item in enumerate(assessment):
|
|
436
|
+
if not isinstance(item, dict):
|
|
437
|
+
_fail(f"report.scope_assessment[{index}] must be an object")
|
|
438
|
+
if item.get("scope") != brief["scope"][index]:
|
|
439
|
+
_fail(f"report.scope_assessment[{index}].scope does not match brief.scope")
|
|
440
|
+
if item.get("status") not in {"covered", "inconclusive", "not-started"}:
|
|
441
|
+
_fail(f"report.scope_assessment[{index}].status is invalid")
|
|
442
|
+
_require_text(item.get("conclusion"), f"report.scope_assessment[{index}].conclusion")
|
|
443
|
+
assessment_evidence = _validate_id_list(
|
|
444
|
+
item.get("evidence_ids"), f"report.scope_assessment[{index}].evidence_ids"
|
|
445
|
+
)
|
|
446
|
+
if any(value not in evidence for value in assessment_evidence):
|
|
447
|
+
concerns.append("scope_assessment_evidence_unresolved")
|
|
448
|
+
if item["status"] != "covered":
|
|
449
|
+
concerns.append("incomplete_scope_coverage")
|
|
450
|
+
if item["status"] == "covered" and not assessment_evidence:
|
|
451
|
+
concerns.append("covered_scope_without_evidence")
|
|
452
|
+
findings = report.get("findings")
|
|
453
|
+
if not isinstance(findings, list):
|
|
454
|
+
_fail("report.findings must be a list")
|
|
455
|
+
finding_ids: set[str] = set()
|
|
456
|
+
for index, item in enumerate(findings):
|
|
457
|
+
if not isinstance(item, dict):
|
|
458
|
+
_fail(f"report.findings[{index}] must be an object")
|
|
459
|
+
finding_id = _require_id(item.get("id"), f"report.findings[{index}].id")
|
|
460
|
+
if finding_id in finding_ids:
|
|
461
|
+
_fail("report.findings contains duplicate ids")
|
|
462
|
+
finding_ids.add(finding_id)
|
|
463
|
+
_require_text(item.get("conclusion"), f"report.findings[{index}].conclusion")
|
|
464
|
+
finding_evidence = _validate_id_list(
|
|
465
|
+
item.get("evidence_ids"), f"report.findings[{index}].evidence_ids"
|
|
466
|
+
)
|
|
467
|
+
if not finding_evidence:
|
|
468
|
+
concerns.append("finding_without_evidence")
|
|
469
|
+
if any(value not in evidence for value in finding_evidence):
|
|
470
|
+
concerns.append("finding_evidence_unresolved")
|
|
471
|
+
_validate_typed_notes(report.get("uncertainties"), "uncertainties")
|
|
472
|
+
_validate_typed_notes(report.get("corrections"), "corrections")
|
|
345
473
|
if status == "complete" and not evidence:
|
|
346
474
|
_fail("a complete report requires at least one evidence item")
|
|
475
|
+
if status == "complete" and not findings:
|
|
476
|
+
_fail("a complete report requires at least one finding")
|
|
347
477
|
if status != "complete":
|
|
348
478
|
_require_text_list(
|
|
349
479
|
report.get("completed_scope"),
|
|
@@ -351,13 +481,13 @@ def _validate_report_data(
|
|
|
351
481
|
allow_empty=True,
|
|
352
482
|
)
|
|
353
483
|
_require_text(report.get("blocker"), "report.blocker")
|
|
354
|
-
return evidence
|
|
484
|
+
return evidence, sorted(set(concerns))
|
|
355
485
|
|
|
356
486
|
|
|
357
487
|
def _validate_report_file(
|
|
358
488
|
value: str,
|
|
359
489
|
repo_root: Path,
|
|
360
|
-
) -> tuple[dict[str, Any], set[
|
|
490
|
+
) -> tuple[dict[str, Any], set[str], dict[str, Any], Path, list[str]]:
|
|
361
491
|
report_path, node_dir, _task_dir = _resolve_artifact_file(value, "report.json", repo_root)
|
|
362
492
|
brief_path = node_dir / "brief.json"
|
|
363
493
|
brief, _brief_raw, _brief_path, _brief_node, _brief_task = _validate_brief_file(
|
|
@@ -368,8 +498,9 @@ def _validate_report_file(
|
|
|
368
498
|
_fail("brief.report_path does not point to the report being validated")
|
|
369
499
|
report, raw = _read_json_object(report_path, MAX_REPORT_BYTES, "report")
|
|
370
500
|
_reject_obvious_secrets(raw, "report")
|
|
371
|
-
evidence = _validate_report_data(report, brief)
|
|
372
|
-
|
|
501
|
+
evidence, concerns = _validate_report_data(report, brief)
|
|
502
|
+
concerns.extend(_validate_checkpoint(node_dir, evidence, brief["scope"]))
|
|
503
|
+
return report, evidence, brief, node_dir, sorted(set(concerns))
|
|
373
504
|
|
|
374
505
|
|
|
375
506
|
def _validate_disposition_data(
|
|
@@ -466,13 +597,20 @@ def _init(args: argparse.Namespace) -> None:
|
|
|
466
597
|
|
|
467
598
|
def _validate(args: argparse.Namespace) -> None:
|
|
468
599
|
repo_root = get_repo_root()
|
|
469
|
-
report, _evidence, _brief, _node_dir_value = _validate_report_file(args.report, repo_root)
|
|
600
|
+
report, _evidence, _brief, _node_dir_value, concerns = _validate_report_file(args.report, repo_root)
|
|
601
|
+
if concerns:
|
|
602
|
+
print(json.dumps({
|
|
603
|
+
"status": "review_concern",
|
|
604
|
+
"subnode_id": report["subnode_id"],
|
|
605
|
+
"concerns": concerns,
|
|
606
|
+
}))
|
|
607
|
+
return
|
|
470
608
|
print(f"Validated pending-review report: {report['subnode_id']}")
|
|
471
609
|
|
|
472
610
|
|
|
473
611
|
def _disposition(args: argparse.Namespace) -> None:
|
|
474
612
|
repo_root = get_repo_root()
|
|
475
|
-
report, _evidence, brief, node_dir = _validate_report_file(args.report, repo_root)
|
|
613
|
+
report, _evidence, brief, node_dir, _concerns = _validate_report_file(args.report, repo_root)
|
|
476
614
|
disposition_path = node_dir / "disposition.json"
|
|
477
615
|
if disposition_path.exists() or disposition_path.is_symlink():
|
|
478
616
|
_fail(f"disposition already exists and cannot be replaced: {disposition_path}")
|
|
@@ -514,10 +652,10 @@ def _validate_counter(args: argparse.Namespace) -> None:
|
|
|
514
652
|
primary_dir = repo_root / primary_dir
|
|
515
653
|
if not counter_dir.is_absolute():
|
|
516
654
|
counter_dir = repo_root / counter_dir
|
|
517
|
-
primary_report, primary_evidence, primary_brief, primary_node = _validate_report_file(
|
|
655
|
+
primary_report, primary_evidence, primary_brief, primary_node, _primary_concerns = _validate_report_file(
|
|
518
656
|
str(primary_dir / "report.json"), repo_root
|
|
519
657
|
)
|
|
520
|
-
counter_report, counter_evidence, counter_brief, counter_node = _validate_report_file(
|
|
658
|
+
counter_report, counter_evidence, counter_brief, counter_node, _counter_concerns = _validate_report_file(
|
|
521
659
|
str(counter_dir / "report.json"), repo_root
|
|
522
660
|
)
|
|
523
661
|
if primary_node == counter_node:
|
|
@@ -9,9 +9,11 @@ Usage:
|
|
|
9
9
|
python3 task.py validate <dir> # Validate jsonl files
|
|
10
10
|
python3 task.py list-context <dir> # List jsonl entries
|
|
11
11
|
python3 task.py start <dir> # Set active task, record current branch
|
|
12
|
+
python3 task.py replan <dir> "<reason>" # Return an in-progress task to planning
|
|
12
13
|
python3 task.py current [--source] [--json] # Show active task
|
|
13
14
|
python3 task.py ownership <operation> ... # Manage formal handoff task ownership
|
|
14
15
|
python3 task.py finish # Clear active task
|
|
16
|
+
python3 task.py workflow <id>|--clear # Set/clear per-task workflow selection
|
|
15
17
|
python3 task.py set-branch <dir> <branch> # Set git branch
|
|
16
18
|
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
|
17
19
|
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
|
@@ -28,7 +30,9 @@ from __future__ import annotations
|
|
|
28
30
|
|
|
29
31
|
import argparse
|
|
30
32
|
import json
|
|
33
|
+
import os
|
|
31
34
|
import sys
|
|
35
|
+
from datetime import datetime, timezone
|
|
32
36
|
from pathlib import Path
|
|
33
37
|
|
|
34
38
|
from common.log import Colors, colored
|
|
@@ -51,11 +55,13 @@ from common.active_task import (
|
|
|
51
55
|
from common.git import current_branch_name
|
|
52
56
|
from common.io import (
|
|
53
57
|
describe_json_read_failure,
|
|
58
|
+
read_json,
|
|
54
59
|
read_json_checked,
|
|
55
60
|
write_json,
|
|
56
61
|
)
|
|
57
62
|
from common.task_utils import resolve_task_dir, run_task_hooks
|
|
58
63
|
from common.tasks import iter_active_tasks, children_progress
|
|
64
|
+
from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task
|
|
59
65
|
|
|
60
66
|
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
|
61
67
|
from common.task_store import (
|
|
@@ -298,6 +304,74 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
298
304
|
return 1
|
|
299
305
|
|
|
300
306
|
|
|
307
|
+
def cmd_replan(args: argparse.Namespace) -> int:
|
|
308
|
+
"""Return an in-progress task to planning without losing its binding."""
|
|
309
|
+
repo_root = get_repo_root()
|
|
310
|
+
full_path = resolve_task_dir(args.dir, repo_root)
|
|
311
|
+
if full_path is None or not full_path.is_dir():
|
|
312
|
+
print(colored(f"Error: Task not found: {args.dir}", Colors.RED), file=sys.stderr)
|
|
313
|
+
return 1
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
assert_task_mutation_allowed(repo_root, full_path)
|
|
317
|
+
except (OwnershipError, OSError) as exc:
|
|
318
|
+
print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
|
|
319
|
+
return 2
|
|
320
|
+
|
|
321
|
+
reason = " ".join(args.reason).strip()
|
|
322
|
+
if not reason:
|
|
323
|
+
print(colored("Error: replan reason must not be empty", Colors.RED), file=sys.stderr)
|
|
324
|
+
return 1
|
|
325
|
+
|
|
326
|
+
task_json_path = full_path / FILE_TASK_JSON
|
|
327
|
+
data, read_reason = read_json_checked(task_json_path)
|
|
328
|
+
if data is None:
|
|
329
|
+
problem, hint = describe_json_read_failure(task_json_path, read_reason)
|
|
330
|
+
print(colored(f"Error: {problem}", Colors.RED), file=sys.stderr)
|
|
331
|
+
print(hint, file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
if data.get("status") != "in_progress":
|
|
334
|
+
print(
|
|
335
|
+
colored(
|
|
336
|
+
f"Error: replan requires status=in_progress (found {data.get('status')!r})",
|
|
337
|
+
Colors.RED,
|
|
338
|
+
),
|
|
339
|
+
file=sys.stderr,
|
|
340
|
+
)
|
|
341
|
+
return 1
|
|
342
|
+
|
|
343
|
+
event = {
|
|
344
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
345
|
+
"task": full_path.relative_to(repo_root).as_posix(),
|
|
346
|
+
"from_status": "in_progress",
|
|
347
|
+
"reason": reason,
|
|
348
|
+
"branch": data.get("branch"),
|
|
349
|
+
}
|
|
350
|
+
context_key = resolve_context_key()
|
|
351
|
+
if context_key:
|
|
352
|
+
event["session"] = context_key
|
|
353
|
+
|
|
354
|
+
replans_path = full_path / "replans.jsonl"
|
|
355
|
+
try:
|
|
356
|
+
with replans_path.open("a", encoding="utf-8") as handle:
|
|
357
|
+
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
358
|
+
handle.flush()
|
|
359
|
+
os.fsync(handle.fileno())
|
|
360
|
+
except OSError as exc:
|
|
361
|
+
print(colored(f"Error: could not record replan: {exc}", Colors.RED), file=sys.stderr)
|
|
362
|
+
return 1
|
|
363
|
+
|
|
364
|
+
data["status"] = "planning"
|
|
365
|
+
if not write_json(task_json_path, data):
|
|
366
|
+
print(colored("Error: replan event recorded but task status was not changed", Colors.RED), file=sys.stderr)
|
|
367
|
+
return 1
|
|
368
|
+
|
|
369
|
+
print(colored(f"✓ Task returned to planning: {full_path.relative_to(repo_root)}", Colors.GREEN))
|
|
370
|
+
print("Reason:", reason)
|
|
371
|
+
run_task_hooks("after_replan", task_json_path, repo_root)
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
|
|
301
375
|
def cmd_finish(args: argparse.Namespace) -> int:
|
|
302
376
|
"""Clear active task."""
|
|
303
377
|
repo_root = get_repo_root()
|
|
@@ -483,6 +557,72 @@ def cmd_ownership(args: argparse.Namespace) -> int:
|
|
|
483
557
|
return 2
|
|
484
558
|
|
|
485
559
|
|
|
560
|
+
# =============================================================================
|
|
561
|
+
# Command: workflow
|
|
562
|
+
# =============================================================================
|
|
563
|
+
|
|
564
|
+
def cmd_workflow(args: argparse.Namespace) -> int:
|
|
565
|
+
"""Set or clear the workflow selection on the current session's active task."""
|
|
566
|
+
repo_root = get_repo_root()
|
|
567
|
+
|
|
568
|
+
if args.clear and args.id:
|
|
569
|
+
print(colored("Error: pass either <id> or --clear, not both", Colors.RED))
|
|
570
|
+
return 1
|
|
571
|
+
if not args.clear and not args.id:
|
|
572
|
+
print(colored("Error: workflow id required (or --clear)", Colors.RED))
|
|
573
|
+
print("Usage: python3 task.py workflow <id> | --clear")
|
|
574
|
+
return 1
|
|
575
|
+
|
|
576
|
+
active = resolve_active_task(repo_root)
|
|
577
|
+
if not active.task_path:
|
|
578
|
+
print(colored("Error: No current task set", Colors.RED))
|
|
579
|
+
print("Hint: run task.py start <dir> first")
|
|
580
|
+
return 1
|
|
581
|
+
|
|
582
|
+
task_dir = repo_root / active.task_path
|
|
583
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
584
|
+
if not task_json_path.is_file():
|
|
585
|
+
print(colored(f"Error: task.json not found at {task_dir}", Colors.RED))
|
|
586
|
+
return 1
|
|
587
|
+
|
|
588
|
+
data = read_json(task_json_path)
|
|
589
|
+
if not data:
|
|
590
|
+
print(colored(f"Error: failed to read {task_json_path}", Colors.RED))
|
|
591
|
+
return 1
|
|
592
|
+
|
|
593
|
+
if args.clear:
|
|
594
|
+
if data.pop("workflow", None) is None:
|
|
595
|
+
print(colored("No workflow selection set on this task", Colors.YELLOW))
|
|
596
|
+
else:
|
|
597
|
+
if not write_json(task_json_path, data):
|
|
598
|
+
print(colored("Error: failed to update task.json", Colors.RED))
|
|
599
|
+
return 1
|
|
600
|
+
print(colored("✓ Workflow selection cleared", Colors.GREEN))
|
|
601
|
+
else:
|
|
602
|
+
workflow_id = args.id
|
|
603
|
+
if not WORKFLOW_ID_RE.match(workflow_id):
|
|
604
|
+
print(colored(
|
|
605
|
+
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
|
|
606
|
+
Colors.RED,
|
|
607
|
+
))
|
|
608
|
+
return 1
|
|
609
|
+
data["workflow"] = workflow_id
|
|
610
|
+
if not write_json(task_json_path, data):
|
|
611
|
+
print(colored("Error: failed to update task.json", Colors.RED))
|
|
612
|
+
return 1
|
|
613
|
+
print(colored(f"✓ Workflow set to: {workflow_id}", Colors.GREEN))
|
|
614
|
+
|
|
615
|
+
# workflow_md_for_task warns on stderr itself when the selected variant
|
|
616
|
+
# file is missing (it can be saved later via `trellis workflow --save`).
|
|
617
|
+
effective = workflow_md_for_task(repo_root, task_dir)
|
|
618
|
+
try:
|
|
619
|
+
effective_display = effective.relative_to(repo_root).as_posix()
|
|
620
|
+
except ValueError:
|
|
621
|
+
effective_display = str(effective)
|
|
622
|
+
print(f"Effective workflow: {effective_display}")
|
|
623
|
+
return 0
|
|
624
|
+
|
|
625
|
+
|
|
486
626
|
# =============================================================================
|
|
487
627
|
# Command: list
|
|
488
628
|
# =============================================================================
|
|
@@ -665,12 +805,15 @@ Usage:
|
|
|
665
805
|
python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package
|
|
666
806
|
python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent
|
|
667
807
|
python3 task.py create <title> --description <desc> --no-start Create without making it active in this session
|
|
808
|
+
python3 task.py create <title> --description <desc> --workflow <id> Create task pinned to a workflow variant
|
|
668
809
|
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
|
669
810
|
python3 task.py validate <dir> Validate jsonl files
|
|
670
811
|
python3 task.py list-context <dir> List jsonl entries
|
|
671
812
|
python3 task.py start <dir> Set active task; records the checked-out branch when unset
|
|
672
813
|
python3 task.py current [--source] Show active task
|
|
673
814
|
python3 task.py finish Clear active task
|
|
815
|
+
python3 task.py workflow <id> Select workflow variant for active task
|
|
816
|
+
python3 task.py workflow --clear Clear selection (use default resolution)
|
|
674
817
|
python3 task.py set-branch <dir> <branch> Set git branch
|
|
675
818
|
python3 task.py set-base-branch <dir> <branch> Set PR target branch
|
|
676
819
|
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
|
@@ -794,6 +937,10 @@ def main() -> int:
|
|
|
794
937
|
action="store_true",
|
|
795
938
|
help="Create the task without making it active in this session",
|
|
796
939
|
)
|
|
940
|
+
p_create.add_argument(
|
|
941
|
+
"--workflow",
|
|
942
|
+
help="Workflow variant id for this task (.trellis/workflows/<id>.md)",
|
|
943
|
+
)
|
|
797
944
|
p_create.add_argument(
|
|
798
945
|
"--force",
|
|
799
946
|
action="store_true",
|
|
@@ -824,6 +971,11 @@ def main() -> int:
|
|
|
824
971
|
help="Start even when implement.jsonl / check.jsonl have no curated entries",
|
|
825
972
|
)
|
|
826
973
|
|
|
974
|
+
# replan
|
|
975
|
+
p_replan = subparsers.add_parser("replan", help="Return an in-progress task to planning")
|
|
976
|
+
p_replan.add_argument("dir", help="Task directory")
|
|
977
|
+
p_replan.add_argument("reason", nargs="+", help="Material reason for returning to planning")
|
|
978
|
+
|
|
827
979
|
# current
|
|
828
980
|
p_current = subparsers.add_parser("current", help="Show active task")
|
|
829
981
|
p_current.add_argument("--source", action="store_true",
|
|
@@ -889,6 +1041,12 @@ def main() -> int:
|
|
|
889
1041
|
# finish
|
|
890
1042
|
subparsers.add_parser("finish", help="Clear active task")
|
|
891
1043
|
|
|
1044
|
+
# workflow
|
|
1045
|
+
p_workflow = subparsers.add_parser("workflow", help="Set/clear per-task workflow selection")
|
|
1046
|
+
p_workflow.add_argument("id", nargs="?", help="Workflow id (.trellis/workflows/<id>.md)")
|
|
1047
|
+
p_workflow.add_argument("--clear", action="store_true",
|
|
1048
|
+
help="Remove the workflow selection (use default resolution)")
|
|
1049
|
+
|
|
892
1050
|
# set-branch
|
|
893
1051
|
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
|
894
1052
|
p_branch.add_argument("dir", help="Task directory")
|
|
@@ -965,10 +1123,12 @@ def main() -> int:
|
|
|
965
1123
|
"validate": cmd_validate,
|
|
966
1124
|
"list-context": cmd_list_context,
|
|
967
1125
|
"start": cmd_start,
|
|
1126
|
+
"replan": cmd_replan,
|
|
968
1127
|
"current": cmd_current,
|
|
969
1128
|
"continuity": cmd_continuity,
|
|
970
1129
|
"ownership": cmd_ownership,
|
|
971
1130
|
"finish": cmd_finish,
|
|
1131
|
+
"workflow": cmd_workflow,
|
|
972
1132
|
"set-branch": cmd_set_branch,
|
|
973
1133
|
"set-base-branch": cmd_set_base_branch,
|
|
974
1134
|
"set-scope": cmd_set_scope,
|