@pennixrv/trellis 0.6.17 → 0.6.19
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/commands/channel/adapters/codex.d.ts +14 -0
- package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
- package/dist/commands/channel/adapters/codex.js +37 -0
- package/dist/commands/channel/adapters/codex.js.map +1 -1
- package/dist/commands/channel/adapters/index.d.ts.map +1 -1
- package/dist/commands/channel/adapters/index.js +49 -13
- package/dist/commands/channel/adapters/index.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +7 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/claude.d.ts +17 -0
- package/dist/configurators/claude.d.ts.map +1 -1
- package/dist/configurators/claude.js +61 -2
- package/dist/configurators/claude.js.map +1 -1
- package/dist/migrations/manifests/0.6.18.json +9 -0
- package/dist/templates/common/bundled-skills/trellis-channel/SKILL.md +2 -0
- package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +143 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/bundled-skills.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/generated-files.md +33 -0
- package/dist/templates/shared-hooks/inject-workflow-state.py +17 -16
- package/dist/templates/trellis/agents/subnode.md +63 -0
- package/dist/templates/trellis/config.yaml +9 -9
- 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/config.py +5 -4
- package/dist/templates/trellis/scripts/common/task_store.py +3 -3
- package/dist/templates/trellis/scripts/common/workflow_phase.py +1 -1
- package/dist/templates/trellis/scripts/subnode_artifact.py +463 -0
- package/dist/templates/trellis/scripts/workspace_note.py +126 -0
- package/dist/templates/trellis/workflow.md +5 -5
- package/dist/utils/manifest-prune.d.ts.map +1 -1
- package/dist/utils/manifest-prune.js +11 -2
- package/dist/utils/manifest-prune.js.map +1 -1
- package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
- package/dist/utils/uninstall-scrubbers.js +3 -3
- package/dist/utils/uninstall-scrubbers.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create and validate durable artifacts for one bounded Trellis subnode.
|
|
3
|
+
|
|
4
|
+
The coordinator creates an immutable brief before dispatching a subnode. The
|
|
5
|
+
subnode appends its worklog and writes its own final report. This helper never
|
|
6
|
+
dispatches workers, decides acceptance, or mutates task state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, NoReturn
|
|
18
|
+
|
|
19
|
+
from common.io import write_text_atomic
|
|
20
|
+
from common.paths import get_repo_root, get_tasks_dir
|
|
21
|
+
from common.task_utils import is_within_tasks_dir, resolve_task_dir
|
|
22
|
+
from common.tasks import load_task
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SCHEMA_VERSION = 1
|
|
26
|
+
MAX_DRAFT_BYTES = 64 * 1024
|
|
27
|
+
MAX_REPORT_BYTES = 128 * 1024
|
|
28
|
+
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
29
|
+
SECRET_PATTERNS = (
|
|
30
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
31
|
+
re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"),
|
|
32
|
+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ArtifactError(Exception):
|
|
37
|
+
"""A user-correctable artifact validation failure."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fail(message: str) -> NoReturn:
|
|
41
|
+
raise ArtifactError(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_json_object(path: Path, max_bytes: int, label: str) -> tuple[dict[str, Any], bytes]:
|
|
45
|
+
"""Read one bounded, non-symlink JSON object."""
|
|
46
|
+
if path.is_symlink():
|
|
47
|
+
_fail(f"refusing symlinked {label}: {path}")
|
|
48
|
+
try:
|
|
49
|
+
size = path.stat().st_size
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
_fail(f"could not stat {label} {path}: {exc}")
|
|
52
|
+
if size > max_bytes:
|
|
53
|
+
_fail(f"{label} exceeds the {max_bytes} byte limit: {path}")
|
|
54
|
+
try:
|
|
55
|
+
raw = path.read_bytes()
|
|
56
|
+
data = json.loads(raw.decode("utf-8"))
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
_fail(f"{label} does not exist: {path}")
|
|
59
|
+
except UnicodeDecodeError:
|
|
60
|
+
_fail(f"{label} is not UTF-8: {path}")
|
|
61
|
+
except json.JSONDecodeError as exc:
|
|
62
|
+
_fail(f"{label} is not valid JSON: {path}: {exc.msg}")
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
_fail(f"could not read {label} {path}: {exc}")
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
_fail(f"{label} must contain a JSON object: {path}")
|
|
67
|
+
return data, raw
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _require_text(value: Any, field: str, *, max_len: int = 4096) -> str:
|
|
71
|
+
if not isinstance(value, str) or not value.strip():
|
|
72
|
+
_fail(f"{field} must be a non-empty string")
|
|
73
|
+
if len(value) > max_len or "\x00" in value:
|
|
74
|
+
_fail(f"{field} is not a bounded text value")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _require_text_list(value: Any, field: str, *, allow_empty: bool = False) -> list[str]:
|
|
79
|
+
if not isinstance(value, list) or (not value and not allow_empty):
|
|
80
|
+
_fail(f"{field} must be {'a list' if allow_empty else 'a non-empty list'}")
|
|
81
|
+
result: list[str] = []
|
|
82
|
+
for index, item in enumerate(value):
|
|
83
|
+
result.append(_require_text(item, f"{field}[{index}]"))
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _require_id(value: Any, field: str) -> str:
|
|
88
|
+
text = _require_text(value, field, max_len=64)
|
|
89
|
+
if not ID_RE.fullmatch(text):
|
|
90
|
+
_fail(f"{field} must match {ID_RE.pattern}")
|
|
91
|
+
return text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _reject_obvious_secrets(raw: bytes, label: str) -> None:
|
|
95
|
+
text = raw.decode("utf-8", errors="replace")
|
|
96
|
+
for pattern in SECRET_PATTERNS:
|
|
97
|
+
if pattern.search(text):
|
|
98
|
+
_fail(f"{label} appears to contain a credential; remove it before writing the artifact")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _task_id(task_dir: Path) -> str:
|
|
102
|
+
task = load_task(task_dir)
|
|
103
|
+
if task is None:
|
|
104
|
+
_fail(f"task.json is missing or invalid: {task_dir}")
|
|
105
|
+
task_id = task.raw.get("id") or task.dir_name
|
|
106
|
+
if not isinstance(task_id, str) or not task_id.strip():
|
|
107
|
+
_fail(f"task has no usable id: {task_dir}")
|
|
108
|
+
if task.status not in {"planning", "in_progress"}:
|
|
109
|
+
_fail(f"task is not active (planning or in_progress): {task_dir}")
|
|
110
|
+
return task_id
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _task_lexical_from_resolved(task_resolved: Path, repo_root: Path) -> Path:
|
|
114
|
+
tasks_lexical = get_tasks_dir(repo_root)
|
|
115
|
+
try:
|
|
116
|
+
relative = task_resolved.relative_to(tasks_lexical.resolve())
|
|
117
|
+
except ValueError:
|
|
118
|
+
_fail(f"task is outside the Trellis task directory: {task_resolved}")
|
|
119
|
+
return tasks_lexical / relative
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _assert_no_subpath_symlinks(task_dir: Path, target: Path) -> None:
|
|
123
|
+
"""Reject symlinks below a task while permitting a symlinked .trellis root."""
|
|
124
|
+
if task_dir.is_symlink():
|
|
125
|
+
_fail(f"refusing symlinked task directory: {task_dir}")
|
|
126
|
+
try:
|
|
127
|
+
parts = target.relative_to(task_dir).parts
|
|
128
|
+
except ValueError:
|
|
129
|
+
_fail(f"artifact path is outside its task: {target}")
|
|
130
|
+
current = task_dir
|
|
131
|
+
for part in parts:
|
|
132
|
+
current = current / part
|
|
133
|
+
if current.is_symlink():
|
|
134
|
+
_fail(f"refusing symlinked artifact path component: {current}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _node_dir(task_dir: Path, work_id: str, subnode_id: str) -> Path:
|
|
138
|
+
return task_dir / "subnodes" / work_id / subnode_id
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _relative_to_repo(path: Path, repo_root: Path) -> str:
|
|
142
|
+
try:
|
|
143
|
+
return path.relative_to(repo_root).as_posix()
|
|
144
|
+
except ValueError:
|
|
145
|
+
_fail(f"path is outside repository: {path}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_snapshot(value: Any) -> None:
|
|
149
|
+
if not isinstance(value, list) or not value:
|
|
150
|
+
_fail("source_snapshot must be a non-empty list")
|
|
151
|
+
for index, item in enumerate(value):
|
|
152
|
+
if not isinstance(item, dict):
|
|
153
|
+
_fail(f"source_snapshot[{index}] must be an object")
|
|
154
|
+
_require_text(item.get("locator"), f"source_snapshot[{index}].locator")
|
|
155
|
+
_require_text(
|
|
156
|
+
item.get("revision_or_digest"),
|
|
157
|
+
f"source_snapshot[{index}].revision_or_digest",
|
|
158
|
+
)
|
|
159
|
+
_require_text(item.get("observed_at"), f"source_snapshot[{index}].observed_at")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _validate_channel_ref(value: Any) -> None:
|
|
163
|
+
if not isinstance(value, dict):
|
|
164
|
+
_fail("channel_ref must be an object")
|
|
165
|
+
_require_text(value.get("name"), "channel_ref.name", max_len=128)
|
|
166
|
+
scope = _require_text(value.get("scope"), "channel_ref.scope", max_len=32)
|
|
167
|
+
if scope not in {"project", "global"}:
|
|
168
|
+
_fail("channel_ref.scope must be project or global")
|
|
169
|
+
_require_text(value.get("worker_handle"), "channel_ref.worker_handle", max_len=128)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_brief_data(
|
|
173
|
+
brief: dict[str, Any],
|
|
174
|
+
task_dir: Path,
|
|
175
|
+
node_dir: Path,
|
|
176
|
+
repo_root: Path,
|
|
177
|
+
) -> None:
|
|
178
|
+
expected_task_id = _task_id(task_dir)
|
|
179
|
+
if brief.get("schema_version") != SCHEMA_VERSION:
|
|
180
|
+
_fail(f"brief.schema_version must be {SCHEMA_VERSION}")
|
|
181
|
+
if brief.get("task_id") != expected_task_id:
|
|
182
|
+
_fail("brief.task_id does not match task.json")
|
|
183
|
+
work_id = _require_id(brief.get("work_id"), "brief.work_id")
|
|
184
|
+
subnode_id = _require_id(brief.get("subnode_id"), "brief.subnode_id")
|
|
185
|
+
if node_dir != _node_dir(task_dir, work_id, subnode_id):
|
|
186
|
+
_fail("brief identity does not match its artifact directory")
|
|
187
|
+
if brief.get("role_id") != "subnode":
|
|
188
|
+
_fail("brief.role_id must be subnode")
|
|
189
|
+
_require_text(brief.get("question"), "brief.question")
|
|
190
|
+
_require_text(brief.get("independence_reason"), "brief.independence_reason")
|
|
191
|
+
_require_text_list(brief.get("scope"), "brief.scope")
|
|
192
|
+
_require_text_list(brief.get("protected_targets"), "brief.protected_targets", allow_empty=True)
|
|
193
|
+
_require_text(brief.get("lens"), "brief.lens")
|
|
194
|
+
_require_text(brief.get("evidence_method"), "brief.evidence_method")
|
|
195
|
+
_validate_snapshot(brief.get("source_snapshot"))
|
|
196
|
+
_require_text_list(brief.get("dependencies"), "brief.dependencies", allow_empty=True)
|
|
197
|
+
_require_text_list(brief.get("stop_conditions"), "brief.stop_conditions")
|
|
198
|
+
_require_text(brief.get("deadline"), "brief.deadline", max_len=128)
|
|
199
|
+
_validate_channel_ref(brief.get("channel_ref"))
|
|
200
|
+
if "retry_of" not in brief:
|
|
201
|
+
_fail("brief.retry_of must be present and null")
|
|
202
|
+
if brief["retry_of"] is not None:
|
|
203
|
+
_fail("brief.retry_of must be null; subnodes are not retry workers")
|
|
204
|
+
if "counter_of" not in brief:
|
|
205
|
+
_fail("brief.counter_of must be present; use null when this is not counterwork")
|
|
206
|
+
counter_of = brief["counter_of"]
|
|
207
|
+
if counter_of is not None:
|
|
208
|
+
counter_id = _require_id(counter_of, "brief.counter_of")
|
|
209
|
+
if counter_id == subnode_id:
|
|
210
|
+
_fail("brief.counter_of must name a different subnode")
|
|
211
|
+
expected_report = _relative_to_repo(node_dir / "report.json", repo_root)
|
|
212
|
+
if brief.get("report_path") != expected_report:
|
|
213
|
+
_fail("brief.report_path does not match its immutable artifact directory")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _resolve_artifact_file(
|
|
217
|
+
value: str,
|
|
218
|
+
expected_name: str,
|
|
219
|
+
repo_root: Path,
|
|
220
|
+
) -> tuple[Path, Path, Path]:
|
|
221
|
+
"""Return lexical artifact, node directory, and task directory after checks."""
|
|
222
|
+
supplied = Path(value)
|
|
223
|
+
lexical = supplied if supplied.is_absolute() else repo_root / supplied
|
|
224
|
+
if lexical.name != expected_name:
|
|
225
|
+
_fail(f"expected a {expected_name} path, received: {value}")
|
|
226
|
+
if lexical.is_symlink():
|
|
227
|
+
_fail(f"refusing symlinked artifact: {lexical}")
|
|
228
|
+
try:
|
|
229
|
+
resolved = lexical.resolve(strict=True)
|
|
230
|
+
except (OSError, RuntimeError) as exc:
|
|
231
|
+
_fail(f"could not resolve artifact {lexical}: {exc}")
|
|
232
|
+
node_resolved = resolved.parent
|
|
233
|
+
work_resolved = node_resolved.parent
|
|
234
|
+
subnodes_resolved = work_resolved.parent
|
|
235
|
+
task_resolved = subnodes_resolved.parent
|
|
236
|
+
if subnodes_resolved.name != "subnodes":
|
|
237
|
+
_fail(f"artifact is not under a task subnodes directory: {lexical}")
|
|
238
|
+
if not is_within_tasks_dir(task_resolved, repo_root):
|
|
239
|
+
_fail(f"artifact task is not a direct active task: {lexical}")
|
|
240
|
+
task_dir = _task_lexical_from_resolved(task_resolved, repo_root)
|
|
241
|
+
expected = _node_dir(task_dir, work_resolved.name, node_resolved.name) / expected_name
|
|
242
|
+
try:
|
|
243
|
+
same_path = lexical.resolve() == expected.resolve()
|
|
244
|
+
except (OSError, RuntimeError) as exc:
|
|
245
|
+
_fail(f"could not verify artifact containment: {exc}")
|
|
246
|
+
if not same_path:
|
|
247
|
+
_fail(f"artifact path does not match its task-owned location: {lexical}")
|
|
248
|
+
_assert_no_subpath_symlinks(task_dir, expected)
|
|
249
|
+
return expected, expected.parent, task_dir
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _validate_brief_file(
|
|
253
|
+
value: str,
|
|
254
|
+
repo_root: Path,
|
|
255
|
+
) -> tuple[dict[str, Any], bytes, Path, Path, Path]:
|
|
256
|
+
brief_path, node_dir, task_dir = _resolve_artifact_file(value, "brief.json", repo_root)
|
|
257
|
+
brief, raw = _read_json_object(brief_path, MAX_DRAFT_BYTES, "brief")
|
|
258
|
+
_reject_obvious_secrets(raw, "brief")
|
|
259
|
+
_validate_brief_data(brief, task_dir, node_dir, repo_root)
|
|
260
|
+
return brief, raw, brief_path, node_dir, task_dir
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _validate_evidence(value: Any) -> set[tuple[str, str]]:
|
|
264
|
+
if not isinstance(value, list):
|
|
265
|
+
_fail("report.evidence must be a list")
|
|
266
|
+
identities: set[tuple[str, str]] = set()
|
|
267
|
+
for index, item in enumerate(value):
|
|
268
|
+
if not isinstance(item, dict):
|
|
269
|
+
_fail(f"report.evidence[{index}] must be an object")
|
|
270
|
+
evidence_id = _require_text(item.get("id"), f"report.evidence[{index}].id", max_len=128)
|
|
271
|
+
locator = _require_text(item.get("locator"), f"report.evidence[{index}].locator")
|
|
272
|
+
_require_text(item.get("summary"), f"report.evidence[{index}].summary")
|
|
273
|
+
identity = (evidence_id, locator)
|
|
274
|
+
if identity in identities:
|
|
275
|
+
_fail("report.evidence contains a duplicate evidence identity")
|
|
276
|
+
identities.add(identity)
|
|
277
|
+
return identities
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _validate_report_data(
|
|
281
|
+
report: dict[str, Any],
|
|
282
|
+
brief: dict[str, Any],
|
|
283
|
+
brief_raw: bytes,
|
|
284
|
+
) -> set[tuple[str, str]]:
|
|
285
|
+
if report.get("schema_version") != SCHEMA_VERSION:
|
|
286
|
+
_fail(f"report.schema_version must be {SCHEMA_VERSION}")
|
|
287
|
+
for field in ("task_id", "work_id", "subnode_id", "role_id"):
|
|
288
|
+
if report.get(field) != brief.get(field):
|
|
289
|
+
_fail(f"report.{field} does not match brief.{field}")
|
|
290
|
+
digest = hashlib.sha256(brief_raw).hexdigest()
|
|
291
|
+
if report.get("brief_digest") != digest:
|
|
292
|
+
_fail("report.brief_digest does not match immutable brief.json")
|
|
293
|
+
if report.get("scope") != brief.get("scope"):
|
|
294
|
+
_fail("report.scope does not match brief.scope")
|
|
295
|
+
if report.get("lens") != brief.get("lens"):
|
|
296
|
+
_fail("report.lens does not match brief.lens")
|
|
297
|
+
status = report.get("status")
|
|
298
|
+
if status not in {"complete", "blocked", "incomplete", "error"}:
|
|
299
|
+
_fail("report.status must be complete, blocked, incomplete, or error")
|
|
300
|
+
evidence = _validate_evidence(report.get("evidence"))
|
|
301
|
+
for field in ("findings", "uncertainties", "corrections"):
|
|
302
|
+
if not isinstance(report.get(field), list):
|
|
303
|
+
_fail(f"report.{field} must be a list")
|
|
304
|
+
if status == "complete" and not evidence:
|
|
305
|
+
_fail("a complete report requires at least one evidence item")
|
|
306
|
+
if status != "complete":
|
|
307
|
+
_require_text_list(report.get("completed_scope"), "report.completed_scope")
|
|
308
|
+
_require_text(report.get("blocker"), "report.blocker")
|
|
309
|
+
return evidence
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _validate_report_file(
|
|
313
|
+
value: str,
|
|
314
|
+
repo_root: Path,
|
|
315
|
+
) -> tuple[dict[str, Any], set[tuple[str, str]], dict[str, Any], Path]:
|
|
316
|
+
report_path, node_dir, _task_dir = _resolve_artifact_file(value, "report.json", repo_root)
|
|
317
|
+
brief_path = node_dir / "brief.json"
|
|
318
|
+
brief, brief_raw, _brief_path, _brief_node, _brief_task = _validate_brief_file(
|
|
319
|
+
str(brief_path), repo_root
|
|
320
|
+
)
|
|
321
|
+
expected_report = _relative_to_repo(report_path, repo_root)
|
|
322
|
+
if brief.get("report_path") != expected_report:
|
|
323
|
+
_fail("brief.report_path does not point to the report being validated")
|
|
324
|
+
report, raw = _read_json_object(report_path, MAX_REPORT_BYTES, "report")
|
|
325
|
+
_reject_obvious_secrets(raw, "report")
|
|
326
|
+
evidence = _validate_report_data(report, brief, brief_raw)
|
|
327
|
+
return report, evidence, brief, node_dir
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _init(args: argparse.Namespace) -> None:
|
|
331
|
+
repo_root = get_repo_root()
|
|
332
|
+
task_dir = resolve_task_dir(args.task, repo_root)
|
|
333
|
+
if task_dir is None or not is_within_tasks_dir(task_dir, repo_root):
|
|
334
|
+
_fail("--task must identify a direct active task directory")
|
|
335
|
+
if task_dir.is_symlink():
|
|
336
|
+
_fail(f"refusing symlinked task directory: {task_dir}")
|
|
337
|
+
work_id = _require_id(args.work_id, "--work-id")
|
|
338
|
+
subnode_id = _require_id(args.subnode_id, "--subnode-id")
|
|
339
|
+
node_dir = _node_dir(task_dir, work_id, subnode_id)
|
|
340
|
+
_assert_no_subpath_symlinks(task_dir, node_dir)
|
|
341
|
+
if node_dir.exists() or node_dir.is_symlink():
|
|
342
|
+
_fail(f"subnode artifacts already exist: {node_dir}")
|
|
343
|
+
draft_path = Path(args.draft)
|
|
344
|
+
if not draft_path.is_absolute():
|
|
345
|
+
draft_path = repo_root / draft_path
|
|
346
|
+
draft, raw = _read_json_object(draft_path, MAX_DRAFT_BYTES, "brief draft")
|
|
347
|
+
_reject_obvious_secrets(raw, "brief draft")
|
|
348
|
+
expected_task_id = _task_id(task_dir)
|
|
349
|
+
brief = dict(draft)
|
|
350
|
+
brief["schema_version"] = SCHEMA_VERSION
|
|
351
|
+
brief["task_id"] = expected_task_id
|
|
352
|
+
brief["work_id"] = work_id
|
|
353
|
+
brief["subnode_id"] = subnode_id
|
|
354
|
+
brief["role_id"] = "subnode"
|
|
355
|
+
brief["report_path"] = _relative_to_repo(node_dir / "report.json", repo_root)
|
|
356
|
+
_validate_brief_data(brief, task_dir, node_dir, repo_root)
|
|
357
|
+
for directory in (task_dir / "subnodes", task_dir / "subnodes" / work_id, node_dir):
|
|
358
|
+
if directory.is_symlink():
|
|
359
|
+
_fail(f"refusing symlinked artifact directory: {directory}")
|
|
360
|
+
if directory.exists() and not directory.is_dir():
|
|
361
|
+
_fail(f"artifact path component is not a directory: {directory}")
|
|
362
|
+
try:
|
|
363
|
+
directory.mkdir(exist_ok=True)
|
|
364
|
+
except OSError as exc:
|
|
365
|
+
_fail(f"could not create artifact directory {directory}: {exc}")
|
|
366
|
+
brief_path = node_dir / "brief.json"
|
|
367
|
+
worklog_path = node_dir / "worklog.md"
|
|
368
|
+
brief_text = json.dumps(brief, indent=2, ensure_ascii=False) + "\n"
|
|
369
|
+
worklog_text = (
|
|
370
|
+
"# Subnode Worklog\n\n"
|
|
371
|
+
f"- Task: `{brief['task_id']}`\n"
|
|
372
|
+
f"- Work: `{work_id}`\n"
|
|
373
|
+
f"- Subnode: `{subnode_id}`\n\n"
|
|
374
|
+
"Append substantive observations, corrections, blockers, and completion notes below.\n"
|
|
375
|
+
)
|
|
376
|
+
if not write_text_atomic(brief_path, brief_text):
|
|
377
|
+
_fail(f"could not write immutable brief: {brief_path}")
|
|
378
|
+
if not write_text_atomic(worklog_path, worklog_text):
|
|
379
|
+
_fail(f"could not write subnode worklog: {worklog_path}")
|
|
380
|
+
print(json.dumps({
|
|
381
|
+
"brief": _relative_to_repo(brief_path, repo_root),
|
|
382
|
+
"worklog": _relative_to_repo(worklog_path, repo_root),
|
|
383
|
+
"report": brief["report_path"],
|
|
384
|
+
}))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _validate(args: argparse.Namespace) -> None:
|
|
388
|
+
repo_root = get_repo_root()
|
|
389
|
+
report, _evidence, _brief, _node_dir_value = _validate_report_file(args.report, repo_root)
|
|
390
|
+
print(f"Validated pending-review report: {report['subnode_id']}")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _validate_counter(args: argparse.Namespace) -> None:
|
|
394
|
+
repo_root = get_repo_root()
|
|
395
|
+
primary_dir = Path(args.primary)
|
|
396
|
+
counter_dir = Path(args.counter)
|
|
397
|
+
if not primary_dir.is_absolute():
|
|
398
|
+
primary_dir = repo_root / primary_dir
|
|
399
|
+
if not counter_dir.is_absolute():
|
|
400
|
+
counter_dir = repo_root / counter_dir
|
|
401
|
+
primary_report, primary_evidence, primary_brief, primary_node = _validate_report_file(
|
|
402
|
+
str(primary_dir / "report.json"), repo_root
|
|
403
|
+
)
|
|
404
|
+
counter_report, counter_evidence, counter_brief, counter_node = _validate_report_file(
|
|
405
|
+
str(counter_dir / "report.json"), repo_root
|
|
406
|
+
)
|
|
407
|
+
if primary_node == counter_node:
|
|
408
|
+
_fail("counter validation requires two different subnode directories")
|
|
409
|
+
if primary_brief["task_id"] != counter_brief["task_id"]:
|
|
410
|
+
_fail("counter subnode must belong to the same task")
|
|
411
|
+
if primary_brief["work_id"] != counter_brief["work_id"]:
|
|
412
|
+
_fail("counter subnode must belong to the same work_id")
|
|
413
|
+
if counter_brief.get("counter_of") != primary_brief["subnode_id"]:
|
|
414
|
+
_fail("counter brief.counter_of must identify the primary subnode")
|
|
415
|
+
if primary_brief["lens"] == counter_brief["lens"]:
|
|
416
|
+
_fail("counter subnode must use a different lens")
|
|
417
|
+
if primary_report["status"] != "complete" or counter_report["status"] != "complete":
|
|
418
|
+
_fail("counter comparison requires two complete reports")
|
|
419
|
+
if primary_evidence & counter_evidence:
|
|
420
|
+
_fail("counter report must use evidence identities independent of the primary report")
|
|
421
|
+
print(
|
|
422
|
+
"Validated independent counter reports: "
|
|
423
|
+
f"{primary_brief['subnode_id']} and {counter_brief['subnode_id']}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _parser() -> argparse.ArgumentParser:
|
|
428
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
429
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
430
|
+
|
|
431
|
+
init = subparsers.add_parser("init", help="create immutable brief and empty worklog")
|
|
432
|
+
init.add_argument("--task", required=True, help="direct active Trellis task directory")
|
|
433
|
+
init.add_argument("--work-id", required=True)
|
|
434
|
+
init.add_argument("--subnode-id", required=True)
|
|
435
|
+
init.add_argument("--draft", required=True, help="coordinator-owned brief draft JSON")
|
|
436
|
+
init.set_defaults(handler=_init)
|
|
437
|
+
|
|
438
|
+
validate = subparsers.add_parser("validate", help="validate a pending-review report")
|
|
439
|
+
validate.add_argument("--report", required=True)
|
|
440
|
+
validate.set_defaults(handler=_validate)
|
|
441
|
+
|
|
442
|
+
counter = subparsers.add_parser(
|
|
443
|
+
"validate-counter",
|
|
444
|
+
help="validate two completed, independent subnode directories",
|
|
445
|
+
)
|
|
446
|
+
counter.add_argument("--primary", required=True, help="primary subnode artifact directory")
|
|
447
|
+
counter.add_argument("--counter", required=True, help="counter subnode artifact directory")
|
|
448
|
+
counter.set_defaults(handler=_validate_counter)
|
|
449
|
+
return parser
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main() -> int:
|
|
453
|
+
args = _parser().parse_args()
|
|
454
|
+
try:
|
|
455
|
+
args.handler(args)
|
|
456
|
+
except ArtifactError as exc:
|
|
457
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
458
|
+
return 1
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
if __name__ == "__main__":
|
|
463
|
+
sys.exit(main())
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Append one bounded coordinator note to the current developer workspace."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import NoReturn
|
|
13
|
+
|
|
14
|
+
from common.paths import DIR_WORKFLOW, DIR_WORKSPACE, get_developer, get_repo_root, get_workspace_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ALLOWED_KINDS = {"observation", "decision", "open-question", "blocker"}
|
|
18
|
+
MAX_SUMMARY_LENGTH = 600
|
|
19
|
+
MAX_SOURCES = 5
|
|
20
|
+
MAX_SOURCE_LENGTH = 1024
|
|
21
|
+
SECRET_PATTERNS = (
|
|
22
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
23
|
+
re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"),
|
|
24
|
+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorkspaceNoteError(Exception):
|
|
29
|
+
"""A user-correctable workspace-note failure."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fail(message: str) -> NoReturn:
|
|
33
|
+
raise WorkspaceNoteError(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_text(value: str, field: str, max_length: int) -> str:
|
|
37
|
+
if not value.strip() or len(value) > max_length:
|
|
38
|
+
_fail(f"{field} must be a non-empty string of at most {max_length} characters")
|
|
39
|
+
if "\n" in value or "\r" in value or "\x00" in value:
|
|
40
|
+
_fail(f"{field} must be a single line without control characters")
|
|
41
|
+
for pattern in SECRET_PATTERNS:
|
|
42
|
+
if pattern.search(value):
|
|
43
|
+
_fail(f"{field} appears to contain a credential")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _workspace_path(repo_root: Path) -> Path:
|
|
48
|
+
developer = get_developer(repo_root)
|
|
49
|
+
workspace_dir = get_workspace_dir(repo_root)
|
|
50
|
+
if not developer or workspace_dir is None:
|
|
51
|
+
_fail("developer is not initialized; run init_developer.py first")
|
|
52
|
+
workspace_root = repo_root / DIR_WORKFLOW / DIR_WORKSPACE
|
|
53
|
+
if workspace_dir.is_symlink():
|
|
54
|
+
_fail(f"refusing symlinked developer workspace: {workspace_dir}")
|
|
55
|
+
try:
|
|
56
|
+
workspace_root_resolved = workspace_root.resolve(strict=True)
|
|
57
|
+
workspace_resolved = workspace_dir.resolve(strict=True)
|
|
58
|
+
except (OSError, RuntimeError) as exc:
|
|
59
|
+
_fail(f"could not resolve developer workspace: {exc}")
|
|
60
|
+
if workspace_resolved.parent != workspace_root_resolved:
|
|
61
|
+
_fail("developer workspace is not a direct workspace child")
|
|
62
|
+
if workspace_dir.name != developer:
|
|
63
|
+
_fail("developer identity does not name its workspace directory")
|
|
64
|
+
return workspace_dir / "working-notes.md"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _append_record(path: Path, record: str) -> None:
|
|
68
|
+
if path.is_symlink():
|
|
69
|
+
_fail(f"refusing symlinked working notes file: {path}")
|
|
70
|
+
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
71
|
+
if hasattr(os, "O_BINARY"):
|
|
72
|
+
flags |= os.O_BINARY
|
|
73
|
+
try:
|
|
74
|
+
descriptor = os.open(path, flags, 0o600)
|
|
75
|
+
try:
|
|
76
|
+
data = record.encode("utf-8")
|
|
77
|
+
written = os.write(descriptor, data)
|
|
78
|
+
if written != len(data):
|
|
79
|
+
_fail("could not append the complete workspace note")
|
|
80
|
+
os.fsync(descriptor)
|
|
81
|
+
finally:
|
|
82
|
+
os.close(descriptor)
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
_fail(f"could not append workspace note {path}: {exc}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _main(args: argparse.Namespace) -> None:
|
|
88
|
+
if args.kind not in ALLOWED_KINDS:
|
|
89
|
+
_fail(f"--kind must be one of: {', '.join(sorted(ALLOWED_KINDS))}")
|
|
90
|
+
summary = _validate_text(args.summary, "--summary", MAX_SUMMARY_LENGTH)
|
|
91
|
+
if len(args.source) > MAX_SOURCES:
|
|
92
|
+
_fail(f"at most {MAX_SOURCES} --source values are allowed")
|
|
93
|
+
sources = [
|
|
94
|
+
_validate_text(value, "--source", MAX_SOURCE_LENGTH)
|
|
95
|
+
for value in args.source
|
|
96
|
+
]
|
|
97
|
+
repo_root = get_repo_root()
|
|
98
|
+
path = _workspace_path(repo_root)
|
|
99
|
+
timestamp = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
100
|
+
lines = [f"## {timestamp} | {args.kind}", "", summary]
|
|
101
|
+
if sources:
|
|
102
|
+
lines.extend(["", "Sources:"])
|
|
103
|
+
lines.extend(f"- {source}" for source in sources)
|
|
104
|
+
_append_record(path, "\n".join(lines) + "\n\n")
|
|
105
|
+
print(path.relative_to(repo_root).as_posix())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parser() -> argparse.ArgumentParser:
|
|
109
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
110
|
+
parser.add_argument("--kind", required=True, choices=sorted(ALLOWED_KINDS))
|
|
111
|
+
parser.add_argument("--summary", required=True)
|
|
112
|
+
parser.add_argument("--source", action="append", default=[])
|
|
113
|
+
return parser
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> int:
|
|
117
|
+
try:
|
|
118
|
+
_main(_parser().parse_args())
|
|
119
|
+
except WorkspaceNoteError as exc:
|
|
120
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|
|
@@ -207,8 +207,8 @@ Multi-deliverable scope: consider a parent task plus independently verifiable ch
|
|
|
207
207
|
Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start.
|
|
208
208
|
[/workflow-state:planning]
|
|
209
209
|
|
|
210
|
-
<!-- Per-turn breadcrumb: shown throughout Phase 1 when
|
|
211
|
-
Codex-only
|
|
210
|
+
<!-- Per-turn breadcrumb: shown throughout Phase 1 when Codex uses its default
|
|
211
|
+
codex.dispatch_mode=inline. Codex-only alternate to [workflow-state:planning]. The main agent
|
|
212
212
|
edits code directly in Phase 2, so jsonl curation is skipped —
|
|
213
213
|
the inline workflow loads `trellis-before-dev` instead of injecting JSONL
|
|
214
214
|
into a sub-agent. -->
|
|
@@ -236,12 +236,12 @@ Sub-agent dispatch protocol applies to all platforms and all sub-agents, includi
|
|
|
236
236
|
[workflow-state:in_progress]
|
|
237
237
|
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
|
|
238
238
|
Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`.
|
|
239
|
-
|
|
239
|
+
Use this block only when `codex.dispatch_mode: auto` is explicitly selected. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only.
|
|
240
240
|
Dispatch prompt starts with `Active task: <task path from task.py current>`. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`.
|
|
241
241
|
[/workflow-state:in_progress]
|
|
242
242
|
|
|
243
|
-
<!-- Per-turn breadcrumb: shown while status='in_progress' when
|
|
244
|
-
codex.dispatch_mode=inline. Codex-only
|
|
243
|
+
<!-- Per-turn breadcrumb: shown while status='in_progress' when Codex uses its
|
|
244
|
+
default codex.dispatch_mode=inline. Codex-only alternate to
|
|
245
245
|
[workflow-state:in_progress]. The main session edits code directly
|
|
246
246
|
instead of dispatching sub-agents. -->
|
|
247
247
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest-prune.d.ts","sourceRoot":"","sources":["../../src/utils/manifest-prune.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;
|
|
1
|
+
{"version":3,"file":"manifest-prune.d.ts","sourceRoot":"","sources":["../../src/utils/manifest-prune.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAeH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,MAAM,WAAW,WAAW;IAC1B,0DAA0D;IAC1D,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,6EAA6E;IAC7E,MAAM,EAAE,cAAc,CAAC;CACxB;AAkED,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,mBAAmB,EAAE,SAAS,MAAM,EAAE,EACtC,MAAM,EAAE,cAAc,EACtB,OAAO,GAAE,YAAiB,GACzB,WAAW,CAmCb"}
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import fs from "node:fs";
|
|
34
34
|
import path from "node:path";
|
|
35
|
+
import { CLAUDE_STATUSLINE_PATH, isClaudeStatuslineManaged, } from "../configurators/claude.js";
|
|
35
36
|
import { collectPlatformTemplates } from "../configurators/index.js";
|
|
36
37
|
import { FILE_NAMES } from "../constants/paths.js";
|
|
37
38
|
import { getAllMigrations } from "../migrations/index.js";
|
|
@@ -45,7 +46,7 @@ import { TRELLIS_BLOCK_END, TRELLIS_BLOCK_START } from "./managed-paths.js";
|
|
|
45
46
|
* - every migration manifest's from/to path (preserve so legitimate
|
|
46
47
|
* pending migrations can find their source/target)
|
|
47
48
|
*/
|
|
48
|
-
function buildKnownKeys(configuredPlatforms) {
|
|
49
|
+
function buildKnownKeys(cwd, configuredPlatforms, hashes) {
|
|
49
50
|
const known = new Set();
|
|
50
51
|
for (const id of configuredPlatforms) {
|
|
51
52
|
const templates = collectPlatformTemplates(id);
|
|
@@ -55,6 +56,14 @@ function buildKnownKeys(configuredPlatforms) {
|
|
|
55
56
|
known.add(toPosix(key));
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
// Claude's statusline is an opt-in file intentionally absent from the
|
|
60
|
+
// parameterless collector. Keep it only when the manifest already proves
|
|
61
|
+
// ownership, or the legacy recovery check proves the file is byte-identical
|
|
62
|
+
// to Trellis' template. A settings field alone never adopts custom content.
|
|
63
|
+
if (configuredPlatforms.includes("claude-code") &&
|
|
64
|
+
isClaudeStatuslineManaged(cwd, hashes)) {
|
|
65
|
+
known.add(CLAUDE_STATUSLINE_PATH);
|
|
66
|
+
}
|
|
58
67
|
// Preserve any path referenced by a migration: legitimate pending
|
|
59
68
|
// rename/delete operations need to resolve their `from` (and the target's
|
|
60
69
|
// hash record for `to`) even if the current registry doesn't list it.
|
|
@@ -100,7 +109,7 @@ function shouldKeepAgentsMd(cwd) {
|
|
|
100
109
|
*/
|
|
101
110
|
export function pruneOrphanManifestKeys(cwd, configuredPlatforms, hashes, options = {}) {
|
|
102
111
|
const persist = options.persist ?? true;
|
|
103
|
-
const known = buildKnownKeys(configuredPlatforms);
|
|
112
|
+
const known = buildKnownKeys(cwd, configuredPlatforms, hashes);
|
|
104
113
|
const pruned = [];
|
|
105
114
|
const kept = {};
|
|
106
115
|
for (const [rawKey, value] of Object.entries(hashes)) {
|