@pennixrv/trellis 0.6.45 → 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.
Files changed (92) hide show
  1. package/dist/cli/index.d.ts.map +1 -1
  2. package/dist/cli/index.js +21 -3
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/commands/mem.d.ts.map +1 -1
  5. package/dist/commands/mem.js +6 -13
  6. package/dist/commands/mem.js.map +1 -1
  7. package/dist/commands/workflow.d.ts +17 -0
  8. package/dist/commands/workflow.d.ts.map +1 -1
  9. package/dist/commands/workflow.js +222 -0
  10. package/dist/commands/workflow.js.map +1 -1
  11. package/dist/configurators/claude.d.ts.map +1 -1
  12. package/dist/configurators/claude.js.map +1 -1
  13. package/dist/configurators/dsh.d.ts +16 -19
  14. package/dist/configurators/dsh.d.ts.map +1 -1
  15. package/dist/configurators/dsh.js +38 -26
  16. package/dist/configurators/dsh.js.map +1 -1
  17. package/dist/configurators/gemini.d.ts.map +1 -1
  18. package/dist/configurators/gemini.js.map +1 -1
  19. package/dist/configurators/opencode.d.ts.map +1 -1
  20. package/dist/configurators/opencode.js +4 -0
  21. package/dist/configurators/opencode.js.map +1 -1
  22. package/dist/migrations/manifests/0.6.17.json +2 -2
  23. package/dist/migrations/manifests/0.7.0-beta.10.json +9 -0
  24. package/dist/migrations/manifests/0.7.0-beta.4.json +9 -0
  25. package/dist/migrations/manifests/0.7.0-beta.4.pennix.1.json +9 -0
  26. package/dist/migrations/manifests/0.7.0-beta.5.json +9 -0
  27. package/dist/migrations/manifests/0.7.0-beta.6.json +9 -0
  28. package/dist/migrations/manifests/0.7.0-beta.7.json +9 -0
  29. package/dist/migrations/manifests/0.7.0-beta.8.json +9 -0
  30. package/dist/migrations/manifests/0.7.0-beta.9.json +9 -0
  31. package/dist/templates/claude/hooks/statusline.py +7 -0
  32. package/dist/templates/claude/settings.json +22 -0
  33. package/dist/templates/codex/hooks/session-start.py +20 -1
  34. package/dist/templates/codex/hooks.json +25 -0
  35. package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +32 -3
  36. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
  37. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +1 -0
  38. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +2 -1
  39. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +3 -2
  40. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
  41. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +5 -4
  42. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +1 -0
  43. package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +2 -2
  44. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +3 -2
  45. package/dist/templates/common/commands/continue.md +4 -2
  46. package/dist/templates/common/skills/brainstorm.md +14 -6
  47. package/dist/templates/copilot/hooks/session-start.py +20 -1
  48. package/dist/templates/copilot/prompts/brainstorm.prompt.md +16 -6
  49. package/dist/templates/dsh/DSH.md +61 -37
  50. package/dist/templates/dsh/agents/trellis-check.md +99 -0
  51. package/dist/templates/dsh/agents/trellis-implement.md +106 -0
  52. package/dist/templates/dsh/agents/trellis-research.md +134 -0
  53. package/dist/templates/dsh/index.d.ts +12 -11
  54. package/dist/templates/dsh/index.d.ts.map +1 -1
  55. package/dist/templates/dsh/index.js +14 -12
  56. package/dist/templates/dsh/index.js.map +1 -1
  57. package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
  58. package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
  59. package/dist/templates/pi/extensions/trellis/index.ts.txt +164 -10
  60. package/dist/templates/shared-hooks/index.d.ts +8 -2
  61. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  62. package/dist/templates/shared-hooks/index.js +13 -1
  63. package/dist/templates/shared-hooks/index.js.map +1 -1
  64. package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -3
  65. package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
  66. package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
  67. package/dist/templates/shared-hooks/session-start.py +22 -1
  68. package/dist/templates/trellis/agents/subnode.md +11 -4
  69. package/dist/templates/trellis/config.yaml +49 -0
  70. package/dist/templates/trellis/index.d.ts +3 -0
  71. package/dist/templates/trellis/index.d.ts.map +1 -1
  72. package/dist/templates/trellis/index.js +6 -0
  73. package/dist/templates/trellis/index.js.map +1 -1
  74. package/dist/templates/trellis/scripts/common/active_task.py +26 -4
  75. package/dist/templates/trellis/scripts/common/cli_adapter.py +38 -6
  76. package/dist/templates/trellis/scripts/common/config.py +18 -0
  77. package/dist/templates/trellis/scripts/common/context_projection.py +2 -2
  78. package/dist/templates/trellis/scripts/common/git_context.py +34 -2
  79. package/dist/templates/trellis/scripts/common/paths.py +28 -0
  80. package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
  81. package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
  82. package/dist/templates/trellis/scripts/common/task_store.py +88 -1
  83. package/dist/templates/trellis/scripts/common/trellis_config.py +46 -7
  84. package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
  85. package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
  86. package/dist/templates/trellis/scripts/subnode_artifact.py +153 -15
  87. package/dist/templates/trellis/scripts/task.py +160 -0
  88. package/dist/templates/trellis/workflow.md +49 -34
  89. package/dist/types/ai-tools.d.ts.map +1 -1
  90. package/dist/types/ai-tools.js +26 -13
  91. package/dist/types/ai-tools.js.map +1 -1
  92. 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 = 1
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[tuple[str, str]]:
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[tuple[str, str]]:
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
- for field in ("findings", "uncertainties", "corrections"):
343
- if not isinstance(report.get(field), list):
344
- _fail(f"report.{field} must be a list")
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[tuple[str, str]], dict[str, Any], Path]:
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
- return report, evidence, brief, node_dir
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,