@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.
Files changed (39) hide show
  1. package/dist/commands/channel/adapters/codex.d.ts +14 -0
  2. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  3. package/dist/commands/channel/adapters/codex.js +37 -0
  4. package/dist/commands/channel/adapters/codex.js.map +1 -1
  5. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  6. package/dist/commands/channel/adapters/index.js +49 -13
  7. package/dist/commands/channel/adapters/index.js.map +1 -1
  8. package/dist/commands/update.d.ts.map +1 -1
  9. package/dist/commands/update.js +7 -2
  10. package/dist/commands/update.js.map +1 -1
  11. package/dist/configurators/claude.d.ts +17 -0
  12. package/dist/configurators/claude.d.ts.map +1 -1
  13. package/dist/configurators/claude.js +61 -2
  14. package/dist/configurators/claude.js.map +1 -1
  15. package/dist/migrations/manifests/0.6.18.json +9 -0
  16. package/dist/templates/common/bundled-skills/trellis-channel/SKILL.md +2 -0
  17. package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +143 -0
  18. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/bundled-skills.md +1 -1
  19. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/generated-files.md +33 -0
  20. package/dist/templates/shared-hooks/inject-workflow-state.py +17 -16
  21. package/dist/templates/trellis/agents/subnode.md +63 -0
  22. package/dist/templates/trellis/config.yaml +9 -9
  23. package/dist/templates/trellis/index.d.ts +3 -0
  24. package/dist/templates/trellis/index.d.ts.map +1 -1
  25. package/dist/templates/trellis/index.js +6 -0
  26. package/dist/templates/trellis/index.js.map +1 -1
  27. package/dist/templates/trellis/scripts/common/config.py +5 -4
  28. package/dist/templates/trellis/scripts/common/task_store.py +3 -3
  29. package/dist/templates/trellis/scripts/common/workflow_phase.py +1 -1
  30. package/dist/templates/trellis/scripts/subnode_artifact.py +463 -0
  31. package/dist/templates/trellis/scripts/workspace_note.py +126 -0
  32. package/dist/templates/trellis/workflow.md +5 -5
  33. package/dist/utils/manifest-prune.d.ts.map +1 -1
  34. package/dist/utils/manifest-prune.js +11 -2
  35. package/dist/utils/manifest-prune.js.map +1 -1
  36. package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
  37. package/dist/utils/uninstall-scrubbers.js +3 -3
  38. package/dist/utils/uninstall-scrubbers.js.map +1 -1
  39. 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 codex.dispatch_mode=inline.
211
- Codex-only opt-in alternate to [workflow-state:planning]. The main agent
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
- Main-session default: dispatch implement/check sub-agents. 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.
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 opt-in alternate to
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;AAWH,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;AAoDD,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"}
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)) {