@pennixrv/trellis 0.6.45 → 0.7.0-beta.11

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 (117) 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/channel/adapters/codex.d.ts +2 -1
  5. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  6. package/dist/commands/channel/adapters/codex.js +10 -7
  7. package/dist/commands/channel/adapters/codex.js.map +1 -1
  8. package/dist/commands/channel/adapters/index.d.ts +2 -0
  9. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  10. package/dist/commands/channel/adapters/index.js +1 -1
  11. package/dist/commands/channel/adapters/index.js.map +1 -1
  12. package/dist/commands/channel/index.d.ts.map +1 -1
  13. package/dist/commands/channel/index.js +6 -0
  14. package/dist/commands/channel/index.js.map +1 -1
  15. package/dist/commands/channel/profiles.d.ts +26 -0
  16. package/dist/commands/channel/profiles.d.ts.map +1 -0
  17. package/dist/commands/channel/profiles.js +186 -0
  18. package/dist/commands/channel/profiles.js.map +1 -0
  19. package/dist/commands/channel/spawn.d.ts +3 -0
  20. package/dist/commands/channel/spawn.d.ts.map +1 -1
  21. package/dist/commands/channel/spawn.js +22 -3
  22. package/dist/commands/channel/spawn.js.map +1 -1
  23. package/dist/commands/channel/supervisor.d.ts +8 -0
  24. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  25. package/dist/commands/channel/supervisor.js +19 -0
  26. package/dist/commands/channel/supervisor.js.map +1 -1
  27. package/dist/commands/mem.d.ts.map +1 -1
  28. package/dist/commands/mem.js +6 -13
  29. package/dist/commands/mem.js.map +1 -1
  30. package/dist/commands/workflow.d.ts +17 -0
  31. package/dist/commands/workflow.d.ts.map +1 -1
  32. package/dist/commands/workflow.js +222 -0
  33. package/dist/commands/workflow.js.map +1 -1
  34. package/dist/configurators/claude.d.ts.map +1 -1
  35. package/dist/configurators/claude.js.map +1 -1
  36. package/dist/configurators/dsh.d.ts +16 -19
  37. package/dist/configurators/dsh.d.ts.map +1 -1
  38. package/dist/configurators/dsh.js +38 -26
  39. package/dist/configurators/dsh.js.map +1 -1
  40. package/dist/configurators/gemini.d.ts.map +1 -1
  41. package/dist/configurators/gemini.js.map +1 -1
  42. package/dist/configurators/opencode.d.ts.map +1 -1
  43. package/dist/configurators/opencode.js +4 -0
  44. package/dist/configurators/opencode.js.map +1 -1
  45. package/dist/migrations/manifests/0.6.17.json +2 -2
  46. package/dist/migrations/manifests/0.7.0-beta.10.json +9 -0
  47. package/dist/migrations/manifests/0.7.0-beta.11.json +9 -0
  48. package/dist/migrations/manifests/0.7.0-beta.4.json +9 -0
  49. package/dist/migrations/manifests/0.7.0-beta.4.pennix.1.json +9 -0
  50. package/dist/migrations/manifests/0.7.0-beta.5.json +9 -0
  51. package/dist/migrations/manifests/0.7.0-beta.6.json +9 -0
  52. package/dist/migrations/manifests/0.7.0-beta.7.json +9 -0
  53. package/dist/migrations/manifests/0.7.0-beta.8.json +9 -0
  54. package/dist/migrations/manifests/0.7.0-beta.9.json +9 -0
  55. package/dist/templates/claude/hooks/statusline.py +7 -0
  56. package/dist/templates/claude/settings.json +22 -0
  57. package/dist/templates/codex/hooks/session-start.py +20 -1
  58. package/dist/templates/codex/hooks.json +25 -0
  59. package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +32 -3
  60. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
  61. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +1 -0
  62. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +2 -1
  63. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +3 -2
  64. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
  65. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +5 -4
  66. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +1 -0
  67. package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +2 -2
  68. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +3 -2
  69. package/dist/templates/common/commands/continue.md +4 -2
  70. package/dist/templates/common/skills/brainstorm.md +14 -6
  71. package/dist/templates/copilot/hooks/session-start.py +20 -1
  72. package/dist/templates/copilot/prompts/brainstorm.prompt.md +16 -6
  73. package/dist/templates/dsh/DSH.md +61 -37
  74. package/dist/templates/dsh/agents/trellis-check.md +99 -0
  75. package/dist/templates/dsh/agents/trellis-implement.md +106 -0
  76. package/dist/templates/dsh/agents/trellis-research.md +134 -0
  77. package/dist/templates/dsh/index.d.ts +12 -11
  78. package/dist/templates/dsh/index.d.ts.map +1 -1
  79. package/dist/templates/dsh/index.js +14 -12
  80. package/dist/templates/dsh/index.js.map +1 -1
  81. package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
  82. package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
  83. package/dist/templates/pi/extensions/trellis/index.ts.txt +164 -10
  84. package/dist/templates/shared-hooks/index.d.ts +8 -2
  85. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  86. package/dist/templates/shared-hooks/index.js +13 -1
  87. package/dist/templates/shared-hooks/index.js.map +1 -1
  88. package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -3
  89. package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
  90. package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
  91. package/dist/templates/shared-hooks/session-start.py +22 -1
  92. package/dist/templates/trellis/agents/subnode-profiles.json +14 -0
  93. package/dist/templates/trellis/agents/subnode.md +11 -4
  94. package/dist/templates/trellis/config.yaml +49 -0
  95. package/dist/templates/trellis/index.d.ts +4 -0
  96. package/dist/templates/trellis/index.d.ts.map +1 -1
  97. package/dist/templates/trellis/index.js +8 -0
  98. package/dist/templates/trellis/index.js.map +1 -1
  99. package/dist/templates/trellis/scripts/common/active_task.py +26 -4
  100. package/dist/templates/trellis/scripts/common/cli_adapter.py +38 -6
  101. package/dist/templates/trellis/scripts/common/config.py +18 -0
  102. package/dist/templates/trellis/scripts/common/context_projection.py +2 -2
  103. package/dist/templates/trellis/scripts/common/git_context.py +34 -2
  104. package/dist/templates/trellis/scripts/common/paths.py +28 -0
  105. package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
  106. package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
  107. package/dist/templates/trellis/scripts/common/task_store.py +88 -1
  108. package/dist/templates/trellis/scripts/common/trellis_config.py +46 -7
  109. package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
  110. package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
  111. package/dist/templates/trellis/scripts/subnode_artifact.py +416 -15
  112. package/dist/templates/trellis/scripts/task.py +160 -0
  113. package/dist/templates/trellis/workflow.md +49 -34
  114. package/dist/types/ai-tools.d.ts.map +1 -1
  115. package/dist/types/ai-tools.js +26 -13
  116. package/dist/types/ai-tools.js.map +1 -1
  117. package/package.json +2 -2
@@ -9,6 +9,7 @@ dispatches workers, makes the acceptance judgment, or mutates task state.
9
9
  from __future__ import annotations
10
10
 
11
11
  import argparse
12
+ import hashlib
12
13
  import json
13
14
  import re
14
15
  import sys
@@ -22,10 +23,13 @@ from common.task_utils import is_within_tasks_dir, resolve_task_dir
22
23
  from common.tasks import load_task
23
24
 
24
25
 
25
- SCHEMA_VERSION = 1
26
+ SCHEMA_VERSION = 2
26
27
  MAX_DRAFT_BYTES = 64 * 1024
27
28
  MAX_REPORT_BYTES = 128 * 1024
29
+ MAX_WORKLOG_BYTES = 128 * 1024
30
+ MAX_QUEUE_BYTES = 128 * 1024
28
31
  ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
32
+ CHECKPOINT_RE = re.compile(r"^<!-- trellis-checkpoint: (?P<payload>\{.*\}) -->$", re.MULTILINE)
29
33
  SECRET_PATTERNS = (
30
34
  re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
31
35
  re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"),
@@ -152,6 +156,98 @@ def _relative_to_repo(path: Path, repo_root: Path) -> str:
152
156
  _fail(f"path is outside repository: {path}")
153
157
 
154
158
 
159
+ def _queue_paths(task_dir: Path, work_id: str) -> tuple[Path, Path]:
160
+ work_dir = task_dir / "subnodes" / work_id
161
+ _assert_no_subpath_symlinks(task_dir, work_dir)
162
+ if not work_dir.exists() or not work_dir.is_dir():
163
+ _fail(f"queue work directory does not exist: {work_dir}")
164
+ return work_dir / "queue.json", work_dir / "queue-abandoned.json"
165
+
166
+
167
+ def _queue_context(args: argparse.Namespace) -> tuple[Path, str, Path, Path, Path]:
168
+ repo_root = get_repo_root()
169
+ task_dir = resolve_task_dir(args.task, repo_root)
170
+ if task_dir is None or not is_within_tasks_dir(task_dir, repo_root):
171
+ _fail("--task must identify a direct active task directory")
172
+ if task_dir.is_symlink():
173
+ _fail(f"refusing symlinked task directory: {task_dir}")
174
+ work_id = _require_id(args.work_id, "--work-id")
175
+ task_id = _task_id(task_dir)
176
+ queue_path, abandoned_path = _queue_paths(task_dir, work_id)
177
+ for path in (queue_path, abandoned_path):
178
+ if path.is_symlink():
179
+ _fail(f"refusing symlinked queue artifact: {path}")
180
+ return repo_root, task_id, task_dir, queue_path, abandoned_path
181
+
182
+
183
+ def _write_json_exclusive(path: Path, value: dict[str, Any], label: str) -> None:
184
+ if path.is_symlink():
185
+ _fail(f"refusing symlinked {label}: {path}")
186
+ try:
187
+ with path.open("x", encoding="utf-8") as stream:
188
+ json.dump(value, stream, indent=2, ensure_ascii=False)
189
+ stream.write("\n")
190
+ except FileExistsError:
191
+ _fail(f"{label} already exists and cannot be replaced: {path}")
192
+ except OSError as exc:
193
+ _fail(f"could not write {label} {path}: {exc}")
194
+
195
+
196
+ def _validate_queue_file(
197
+ queue_path: Path,
198
+ task_dir: Path,
199
+ task_id: str,
200
+ work_id: str,
201
+ repo_root: Path,
202
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
203
+ queue, _raw = _read_json_object(queue_path, MAX_QUEUE_BYTES, "queue")
204
+ if queue.get("schema_version") != 1:
205
+ _fail("queue.schema_version must be 1")
206
+ if queue.get("task_id") != task_id:
207
+ _fail("queue.task_id does not match the task")
208
+ if queue.get("work_id") != work_id:
209
+ _fail("queue.work_id does not match the requested work")
210
+ channel = queue.get("channel_ref")
211
+ if not isinstance(channel, dict):
212
+ _fail("queue.channel_ref must be an object")
213
+ channel_name = _require_text(channel.get("name"), "queue.channel_ref.name", max_len=128)
214
+ channel_scope = _require_text(channel.get("scope"), "queue.channel_ref.scope", max_len=32)
215
+ if channel_scope not in {"project", "global"}:
216
+ _fail("queue.channel_ref.scope must be project or global")
217
+ items = queue.get("items")
218
+ if not isinstance(items, list) or not items:
219
+ _fail("queue.items must be a non-empty list")
220
+ seen: set[str] = set()
221
+ validated: list[dict[str, Any]] = []
222
+ for index, item in enumerate(items):
223
+ if not isinstance(item, dict):
224
+ _fail(f"queue.items[{index}] must be an object")
225
+ subnode_id = _require_id(item.get("subnode_id"), f"queue.items[{index}].subnode_id")
226
+ if subnode_id in seen:
227
+ _fail("queue.items contains duplicate subnode_id values")
228
+ seen.add(subnode_id)
229
+ brief_path = _require_text(item.get("brief_path"), f"queue.items[{index}].brief_path")
230
+ brief, raw, brief_file, node_dir, brief_task_dir = _validate_brief_file(
231
+ brief_path, repo_root
232
+ )
233
+ if brief_task_dir != task_dir:
234
+ _fail(f"queue item {subnode_id} brief belongs to another task")
235
+ if brief.get("work_id") != work_id or brief.get("subnode_id") != subnode_id:
236
+ _fail(f"queue item {subnode_id} brief identity does not match")
237
+ expected_brief_path = _relative_to_repo(node_dir / "brief.json", repo_root)
238
+ if _relative_to_repo(brief_file, repo_root) != expected_brief_path:
239
+ _fail(f"queue item {subnode_id} brief_path is not canonical")
240
+ if brief.get("channel_ref", {}).get("name") != channel_name:
241
+ _fail(f"queue item {subnode_id} channel name does not match the queue")
242
+ if brief.get("channel_ref", {}).get("scope") != channel_scope:
243
+ _fail(f"queue item {subnode_id} channel scope does not match the queue")
244
+ digest = hashlib.sha256(raw).hexdigest()
245
+ if item.get("brief_digest") != digest:
246
+ _fail(f"queue item {subnode_id} brief digest does not match")
247
+ validated.append({"subnode_id": subnode_id, "brief_path": brief_path, "brief_digest": digest})
248
+ return queue, validated
249
+
250
+
155
251
  def _validate_snapshot(value: Any) -> None:
156
252
  if not isinstance(value, list) or not value:
157
253
  _fail("source_snapshot must be a non-empty list")
@@ -305,7 +401,7 @@ def _validate_brief_file(
305
401
  return brief, raw, brief_path, node_dir, task_dir
306
402
 
307
403
 
308
- def _validate_evidence(value: Any) -> set[tuple[str, str]]:
404
+ def _validate_evidence(value: Any) -> set[str]:
309
405
  if not isinstance(value, list):
310
406
  _fail("report.evidence must be a list")
311
407
  identities: set[tuple[str, str]] = set()
@@ -319,13 +415,100 @@ def _validate_evidence(value: Any) -> set[tuple[str, str]]:
319
415
  if identity in identities:
320
416
  _fail("report.evidence contains a duplicate evidence identity")
321
417
  identities.add(identity)
322
- return identities
418
+ return {evidence_id for evidence_id, _locator in identities}
419
+
420
+
421
+ def _validate_id_list(value: Any, field: str) -> list[str]:
422
+ if not isinstance(value, list):
423
+ _fail(f"{field} must be a list")
424
+ result = []
425
+ for index, item in enumerate(value):
426
+ result.append(_require_text(item, f"{field}[{index}]", max_len=128))
427
+ if len(result) != len(set(result)):
428
+ _fail(f"{field} must not contain duplicates")
429
+ return result
430
+
431
+
432
+ def _validate_typed_notes(value: Any, field: str) -> None:
433
+ if not isinstance(value, list):
434
+ _fail(f"report.{field} must be a list")
435
+ for index, item in enumerate(value):
436
+ if not isinstance(item, dict):
437
+ _fail(f"report.{field}[{index}] must be an object")
438
+ _require_id(item.get("id"), f"report.{field}[{index}].id")
439
+ _require_text(item.get("type"), f"report.{field}[{index}].type", max_len=64)
440
+ _require_text(item.get("detail"), f"report.{field}[{index}].detail")
441
+ _validate_id_list(item.get("evidence_ids", []), f"report.{field}[{index}].evidence_ids")
442
+
443
+
444
+ def _validate_checkpoint(node_dir: Path, evidence_ids: set[str], scope: list[str]) -> list[str]:
445
+ worklog_path = node_dir / "worklog.md"
446
+ if worklog_path.is_symlink():
447
+ _fail(f"refusing symlinked worklog: {worklog_path}")
448
+ try:
449
+ raw = worklog_path.read_bytes()
450
+ except FileNotFoundError:
451
+ _fail(f"worklog does not exist: {worklog_path}")
452
+ except OSError as exc:
453
+ _fail(f"could not read worklog {worklog_path}: {exc}")
454
+ if len(raw) > MAX_WORKLOG_BYTES:
455
+ _fail(f"worklog exceeds the {MAX_WORKLOG_BYTES} byte limit: {worklog_path}")
456
+ _reject_obvious_secrets(raw, "worklog")
457
+ try:
458
+ text = raw.decode("utf-8")
459
+ except UnicodeDecodeError:
460
+ _fail(f"worklog is not UTF-8: {worklog_path}")
461
+ matches = list(CHECKPOINT_RE.finditer(text))
462
+ if not matches:
463
+ return ["missing_worklog_checkpoint"]
464
+ concerns: list[str] = []
465
+ checkpoint_ids: set[str] = set()
466
+ for match in matches:
467
+ try:
468
+ checkpoint = json.loads(match.group("payload"))
469
+ except json.JSONDecodeError:
470
+ concerns.append("malformed_worklog_checkpoint")
471
+ continue
472
+ if not isinstance(checkpoint, dict):
473
+ concerns.append("malformed_worklog_checkpoint")
474
+ continue
475
+ try:
476
+ checkpoint_id = _require_id(checkpoint.get("id"), "worklog checkpoint.id")
477
+ if checkpoint_id in checkpoint_ids:
478
+ concerns.append("duplicate_worklog_checkpoint")
479
+ checkpoint_ids.add(checkpoint_id)
480
+ covered_scope = _require_text_list(
481
+ checkpoint.get("covered_scope"),
482
+ "worklog checkpoint.covered_scope",
483
+ allow_empty=True,
484
+ )
485
+ for item in covered_scope:
486
+ if item not in scope:
487
+ concerns.append("checkpoint_scope_outside_brief")
488
+ checkpoint_evidence = _validate_id_list(
489
+ checkpoint.get("evidence_ids"), "worklog checkpoint.evidence_ids"
490
+ )
491
+ if any(item not in evidence_ids for item in checkpoint_evidence):
492
+ concerns.append("checkpoint_evidence_unresolved")
493
+ _require_text(
494
+ checkpoint.get("conclusion_or_blocker"),
495
+ "worklog checkpoint.conclusion_or_blocker",
496
+ )
497
+ _require_text_list(
498
+ checkpoint.get("unknowns"),
499
+ "worklog checkpoint.unknowns",
500
+ allow_empty=True,
501
+ )
502
+ _require_text(checkpoint.get("safe_resume_point"), "worklog checkpoint.safe_resume_point")
503
+ except ArtifactError:
504
+ concerns.append("malformed_worklog_checkpoint")
505
+ return sorted(set(concerns))
323
506
 
324
507
 
325
508
  def _validate_report_data(
326
509
  report: dict[str, Any],
327
510
  brief: dict[str, Any],
328
- ) -> set[tuple[str, str]]:
511
+ ) -> tuple[set[str], list[str]]:
329
512
  if report.get("schema_version") != SCHEMA_VERSION:
330
513
  _fail(f"report.schema_version must be {SCHEMA_VERSION}")
331
514
  for field in ("task_id", "work_id", "subnode_id", "role_id"):
@@ -339,11 +522,52 @@ def _validate_report_data(
339
522
  if status not in {"complete", "blocked", "incomplete", "error"}:
340
523
  _fail("report.status must be complete, blocked, incomplete, or error")
341
524
  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")
525
+ assessment = report.get("scope_assessment")
526
+ if not isinstance(assessment, list) or len(assessment) != len(brief["scope"]):
527
+ _fail("report.scope_assessment must contain one item for every brief scope item")
528
+ concerns: list[str] = []
529
+ for index, item in enumerate(assessment):
530
+ if not isinstance(item, dict):
531
+ _fail(f"report.scope_assessment[{index}] must be an object")
532
+ if item.get("scope") != brief["scope"][index]:
533
+ _fail(f"report.scope_assessment[{index}].scope does not match brief.scope")
534
+ if item.get("status") not in {"covered", "inconclusive", "not-started"}:
535
+ _fail(f"report.scope_assessment[{index}].status is invalid")
536
+ _require_text(item.get("conclusion"), f"report.scope_assessment[{index}].conclusion")
537
+ assessment_evidence = _validate_id_list(
538
+ item.get("evidence_ids"), f"report.scope_assessment[{index}].evidence_ids"
539
+ )
540
+ if any(value not in evidence for value in assessment_evidence):
541
+ concerns.append("scope_assessment_evidence_unresolved")
542
+ if item["status"] != "covered":
543
+ concerns.append("incomplete_scope_coverage")
544
+ if item["status"] == "covered" and not assessment_evidence:
545
+ concerns.append("covered_scope_without_evidence")
546
+ findings = report.get("findings")
547
+ if not isinstance(findings, list):
548
+ _fail("report.findings must be a list")
549
+ finding_ids: set[str] = set()
550
+ for index, item in enumerate(findings):
551
+ if not isinstance(item, dict):
552
+ _fail(f"report.findings[{index}] must be an object")
553
+ finding_id = _require_id(item.get("id"), f"report.findings[{index}].id")
554
+ if finding_id in finding_ids:
555
+ _fail("report.findings contains duplicate ids")
556
+ finding_ids.add(finding_id)
557
+ _require_text(item.get("conclusion"), f"report.findings[{index}].conclusion")
558
+ finding_evidence = _validate_id_list(
559
+ item.get("evidence_ids"), f"report.findings[{index}].evidence_ids"
560
+ )
561
+ if not finding_evidence:
562
+ concerns.append("finding_without_evidence")
563
+ if any(value not in evidence for value in finding_evidence):
564
+ concerns.append("finding_evidence_unresolved")
565
+ _validate_typed_notes(report.get("uncertainties"), "uncertainties")
566
+ _validate_typed_notes(report.get("corrections"), "corrections")
345
567
  if status == "complete" and not evidence:
346
568
  _fail("a complete report requires at least one evidence item")
569
+ if status == "complete" and not findings:
570
+ _fail("a complete report requires at least one finding")
347
571
  if status != "complete":
348
572
  _require_text_list(
349
573
  report.get("completed_scope"),
@@ -351,13 +575,13 @@ def _validate_report_data(
351
575
  allow_empty=True,
352
576
  )
353
577
  _require_text(report.get("blocker"), "report.blocker")
354
- return evidence
578
+ return evidence, sorted(set(concerns))
355
579
 
356
580
 
357
581
  def _validate_report_file(
358
582
  value: str,
359
583
  repo_root: Path,
360
- ) -> tuple[dict[str, Any], set[tuple[str, str]], dict[str, Any], Path]:
584
+ ) -> tuple[dict[str, Any], set[str], dict[str, Any], Path, list[str]]:
361
585
  report_path, node_dir, _task_dir = _resolve_artifact_file(value, "report.json", repo_root)
362
586
  brief_path = node_dir / "brief.json"
363
587
  brief, _brief_raw, _brief_path, _brief_node, _brief_task = _validate_brief_file(
@@ -368,8 +592,9 @@ def _validate_report_file(
368
592
  _fail("brief.report_path does not point to the report being validated")
369
593
  report, raw = _read_json_object(report_path, MAX_REPORT_BYTES, "report")
370
594
  _reject_obvious_secrets(raw, "report")
371
- evidence = _validate_report_data(report, brief)
372
- return report, evidence, brief, node_dir
595
+ evidence, concerns = _validate_report_data(report, brief)
596
+ concerns.extend(_validate_checkpoint(node_dir, evidence, brief["scope"]))
597
+ return report, evidence, brief, node_dir, sorted(set(concerns))
373
598
 
374
599
 
375
600
  def _validate_disposition_data(
@@ -464,15 +689,161 @@ def _init(args: argparse.Namespace) -> None:
464
689
  }))
465
690
 
466
691
 
692
+ def _queue_init(args: argparse.Namespace) -> None:
693
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
694
+ if queue_path.exists() or abandoned_path.exists():
695
+ _fail("queue already exists or has been abandoned")
696
+ if not args.brief:
697
+ _fail("queue init requires at least one --brief")
698
+ work_id = _require_id(args.work_id, "--work-id")
699
+ channel_name = _require_text(args.channel_name, "--channel-name", max_len=128)
700
+ channel_scope = _require_text(args.channel_scope, "--channel-scope", max_len=32)
701
+ if channel_scope not in {"project", "global"}:
702
+ _fail("--channel-scope must be project or global")
703
+ items: list[dict[str, str]] = []
704
+ seen: set[str] = set()
705
+ for brief_arg in args.brief:
706
+ brief, raw, brief_path, node_dir, brief_task_dir = _validate_brief_file(
707
+ brief_arg, repo_root
708
+ )
709
+ subnode_id = _require_id(brief.get("subnode_id"), "brief.subnode_id")
710
+ if brief_task_dir != task_dir:
711
+ _fail(f"queue item {subnode_id} brief belongs to another task")
712
+ if brief.get("work_id") != work_id:
713
+ _fail(f"queue item {subnode_id} brief belongs to another work")
714
+ if subnode_id in seen:
715
+ _fail("queue init contains duplicate subnode_id values")
716
+ if brief.get("channel_ref", {}).get("name") != channel_name:
717
+ _fail(f"queue item {subnode_id} channel name does not match")
718
+ if brief.get("channel_ref", {}).get("scope") != channel_scope:
719
+ _fail(f"queue item {subnode_id} channel scope does not match")
720
+ expected_path = _relative_to_repo(node_dir / "brief.json", repo_root)
721
+ if _relative_to_repo(brief_path, repo_root) != expected_path:
722
+ _fail(f"queue item {subnode_id} brief path is not canonical")
723
+ seen.add(subnode_id)
724
+ items.append({
725
+ "subnode_id": subnode_id,
726
+ "brief_path": expected_path,
727
+ "brief_digest": hashlib.sha256(raw).hexdigest(),
728
+ })
729
+ queue = {
730
+ "schema_version": 1,
731
+ "task_id": task_id,
732
+ "work_id": work_id,
733
+ "channel_ref": {"name": channel_name, "scope": channel_scope},
734
+ "created_at": datetime.now(timezone.utc).isoformat(),
735
+ "items": items,
736
+ }
737
+ _write_json_exclusive(queue_path, queue, "queue")
738
+ print(json.dumps({"queue": _relative_to_repo(queue_path, repo_root), "items": items}))
739
+
740
+
741
+ def _queue_validate(args: argparse.Namespace) -> None:
742
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
743
+ _queue, items = _validate_queue_file(
744
+ queue_path, task_dir, task_id, args.work_id, repo_root
745
+ )
746
+ print(json.dumps({
747
+ "status": "abandoned" if abandoned_path.exists() else "valid",
748
+ "queue": _relative_to_repo(queue_path, repo_root),
749
+ "task_id": task_id,
750
+ "work_id": args.work_id,
751
+ "items": items,
752
+ "item_count": len(items),
753
+ }))
754
+
755
+
756
+ def _queue_claim(args: argparse.Namespace) -> None:
757
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
758
+ if abandoned_path.exists():
759
+ _fail("queue has been abandoned; no further dispatch claim is allowed")
760
+ _queue, items = _validate_queue_file(
761
+ queue_path, task_dir, task_id, args.work_id, repo_root
762
+ )
763
+ subnode_id = _require_id(args.subnode_id, "--subnode-id")
764
+ item = next((candidate for candidate in items if candidate["subnode_id"] == subnode_id), None)
765
+ if item is None:
766
+ _fail(f"subnode is not listed in the queue: {subnode_id}")
767
+ claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
768
+ _assert_no_subpath_symlinks(task_dir, claim_path)
769
+ claim = {
770
+ "schema_version": 1,
771
+ "task_id": task_id,
772
+ "work_id": args.work_id,
773
+ "subnode_id": subnode_id,
774
+ "brief_digest": item["brief_digest"],
775
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
776
+ }
777
+ _write_json_exclusive(claim_path, claim, "dispatch claim")
778
+ print(json.dumps({
779
+ "claim": _relative_to_repo(claim_path, repo_root),
780
+ "subnode_id": subnode_id,
781
+ "brief_digest": item["brief_digest"],
782
+ }))
783
+
784
+
785
+ def _queue_abandon(args: argparse.Namespace) -> None:
786
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
787
+ _queue, items = _validate_queue_file(
788
+ queue_path, task_dir, task_id, args.work_id, repo_root
789
+ )
790
+ item_ids = {item["subnode_id"] for item in items}
791
+ claimed_ids: set[str] = set()
792
+ for item in items:
793
+ subnode_id = item["subnode_id"]
794
+ claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
795
+ _assert_no_subpath_symlinks(task_dir, claim_path)
796
+ if not claim_path.exists():
797
+ continue
798
+ claim, _raw = _read_json_object(claim_path, MAX_DRAFT_BYTES, "dispatch claim")
799
+ if (
800
+ claim.get("schema_version") != 1
801
+ or claim.get("task_id") != task_id
802
+ or claim.get("work_id") != args.work_id
803
+ or claim.get("subnode_id") != subnode_id
804
+ or claim.get("brief_digest") != item["brief_digest"]
805
+ ):
806
+ _fail(f"dispatch claim does not match queue item {subnode_id}")
807
+ claimed_ids.add(subnode_id)
808
+ dispatched = [_require_id(value, "--dispatched") for value in (args.dispatched or [])]
809
+ pending = [_require_id(value, "--pending") for value in (args.pending or [])]
810
+ if len(set(dispatched)) != len(dispatched) or len(set(pending)) != len(pending):
811
+ _fail("queue abandonment lists must not contain duplicates")
812
+ if not set(dispatched).issubset(item_ids) or not set(pending).issubset(item_ids):
813
+ _fail("queue abandonment lists must contain only queued subnode IDs")
814
+ if set(dispatched) & set(pending):
815
+ _fail("a subnode cannot be both dispatched and pending")
816
+ if set(dispatched) != claimed_ids or set(pending) != item_ids - claimed_ids:
817
+ _fail("queue abandonment lists must exactly match dispatch claims and remaining queued items")
818
+ abandoned = {
819
+ "schema_version": 1,
820
+ "task_id": task_id,
821
+ "work_id": args.work_id,
822
+ "reason": _require_text(args.reason, "--reason"),
823
+ "dispatched": dispatched,
824
+ "pending": pending,
825
+ "abandoned_at": datetime.now(timezone.utc).isoformat(),
826
+ }
827
+ _write_json_exclusive(abandoned_path, abandoned, "queue abandonment")
828
+ print(json.dumps({"status": "abandoned", "queue": _relative_to_repo(abandoned_path, repo_root)}))
829
+
830
+
467
831
  def _validate(args: argparse.Namespace) -> None:
468
832
  repo_root = get_repo_root()
469
- report, _evidence, _brief, _node_dir_value = _validate_report_file(args.report, repo_root)
833
+ report, _evidence, _brief, _node_dir_value, concerns = _validate_report_file(args.report, repo_root)
834
+ if concerns:
835
+ print(json.dumps({
836
+ "status": "review_concern",
837
+ "subnode_id": report["subnode_id"],
838
+ "concerns": concerns,
839
+ }))
840
+ return
470
841
  print(f"Validated pending-review report: {report['subnode_id']}")
471
842
 
472
843
 
473
844
  def _disposition(args: argparse.Namespace) -> None:
474
845
  repo_root = get_repo_root()
475
- report, _evidence, brief, node_dir = _validate_report_file(args.report, repo_root)
846
+ report, _evidence, brief, node_dir, _concerns = _validate_report_file(args.report, repo_root)
476
847
  disposition_path = node_dir / "disposition.json"
477
848
  if disposition_path.exists() or disposition_path.is_symlink():
478
849
  _fail(f"disposition already exists and cannot be replaced: {disposition_path}")
@@ -514,10 +885,10 @@ def _validate_counter(args: argparse.Namespace) -> None:
514
885
  primary_dir = repo_root / primary_dir
515
886
  if not counter_dir.is_absolute():
516
887
  counter_dir = repo_root / counter_dir
517
- primary_report, primary_evidence, primary_brief, primary_node = _validate_report_file(
888
+ primary_report, primary_evidence, primary_brief, primary_node, _primary_concerns = _validate_report_file(
518
889
  str(primary_dir / "report.json"), repo_root
519
890
  )
520
- counter_report, counter_evidence, counter_brief, counter_node = _validate_report_file(
891
+ counter_report, counter_evidence, counter_brief, counter_node, _counter_concerns = _validate_report_file(
521
892
  str(counter_dir / "report.json"), repo_root
522
893
  )
523
894
  if primary_node == counter_node:
@@ -580,6 +951,36 @@ def _parser() -> argparse.ArgumentParser:
580
951
  counter.add_argument("--primary", required=True, help="primary subnode artifact directory")
581
952
  counter.add_argument("--counter", required=True, help="counter subnode artifact directory")
582
953
  counter.set_defaults(handler=_validate_counter)
954
+
955
+ queue = subparsers.add_parser("queue", help="manage one write-once subnode queue")
956
+ queue_parsers = queue.add_subparsers(dest="queue_command", required=True)
957
+
958
+ queue_init = queue_parsers.add_parser("init", help="write one FIFO queue manifest")
959
+ queue_init.add_argument("--task", required=True)
960
+ queue_init.add_argument("--work-id", required=True)
961
+ queue_init.add_argument("--channel-name", required=True)
962
+ queue_init.add_argument("--channel-scope", required=True)
963
+ queue_init.add_argument("--brief", action="append", required=True)
964
+ queue_init.set_defaults(handler=_queue_init)
965
+
966
+ queue_validate = queue_parsers.add_parser("validate", help="validate a queue manifest")
967
+ queue_validate.add_argument("--task", required=True)
968
+ queue_validate.add_argument("--work-id", required=True)
969
+ queue_validate.set_defaults(handler=_queue_validate)
970
+
971
+ queue_claim = queue_parsers.add_parser("claim", help="write one dispatch intent")
972
+ queue_claim.add_argument("--task", required=True)
973
+ queue_claim.add_argument("--work-id", required=True)
974
+ queue_claim.add_argument("--subnode-id", required=True)
975
+ queue_claim.set_defaults(handler=_queue_claim)
976
+
977
+ queue_abandon = queue_parsers.add_parser("abandon", help="stop a queue permanently")
978
+ queue_abandon.add_argument("--task", required=True)
979
+ queue_abandon.add_argument("--work-id", required=True)
980
+ queue_abandon.add_argument("--reason", required=True)
981
+ queue_abandon.add_argument("--dispatched", action="append")
982
+ queue_abandon.add_argument("--pending", action="append")
983
+ queue_abandon.set_defaults(handler=_queue_abandon)
583
984
  return parser
584
985
 
585
986