@pennixrv/trellis 0.7.0-beta.5 → 0.7.0-beta.50

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 (219) hide show
  1. package/dist/cli/index.d.ts.map +1 -1
  2. package/dist/cli/index.js +47 -3
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/commands/backup.d.ts +25 -0
  5. package/dist/commands/backup.d.ts.map +1 -0
  6. package/dist/commands/backup.js +152 -0
  7. package/dist/commands/backup.js.map +1 -0
  8. package/dist/commands/channel/adapters/claude.d.ts.map +1 -1
  9. package/dist/commands/channel/adapters/claude.js +2 -0
  10. package/dist/commands/channel/adapters/claude.js.map +1 -1
  11. package/dist/commands/channel/adapters/codex.d.ts +5 -1
  12. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  13. package/dist/commands/channel/adapters/codex.js +19 -9
  14. package/dist/commands/channel/adapters/codex.js.map +1 -1
  15. package/dist/commands/channel/adapters/index.d.ts +3 -0
  16. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  17. package/dist/commands/channel/adapters/index.js +3 -3
  18. package/dist/commands/channel/adapters/index.js.map +1 -1
  19. package/dist/commands/channel/adapters/types.d.ts +2 -0
  20. package/dist/commands/channel/adapters/types.d.ts.map +1 -1
  21. package/dist/commands/channel/guard.d.ts.map +1 -1
  22. package/dist/commands/channel/guard.js +38 -29
  23. package/dist/commands/channel/guard.js.map +1 -1
  24. package/dist/commands/channel/index.d.ts.map +1 -1
  25. package/dist/commands/channel/index.js +52 -16
  26. package/dist/commands/channel/index.js.map +1 -1
  27. package/dist/commands/channel/kill.d.ts.map +1 -1
  28. package/dist/commands/channel/kill.js +37 -15
  29. package/dist/commands/channel/kill.js.map +1 -1
  30. package/dist/commands/channel/messages.d.ts.map +1 -1
  31. package/dist/commands/channel/messages.js +3 -2
  32. package/dist/commands/channel/messages.js.map +1 -1
  33. package/dist/commands/channel/process-identity.d.ts +3 -0
  34. package/dist/commands/channel/process-identity.d.ts.map +1 -0
  35. package/dist/commands/channel/process-identity.js +24 -0
  36. package/dist/commands/channel/process-identity.js.map +1 -0
  37. package/dist/commands/channel/profiles.d.ts +26 -0
  38. package/dist/commands/channel/profiles.d.ts.map +1 -0
  39. package/dist/commands/channel/profiles.js +186 -0
  40. package/dist/commands/channel/profiles.js.map +1 -0
  41. package/dist/commands/channel/rm.d.ts +0 -1
  42. package/dist/commands/channel/rm.d.ts.map +1 -1
  43. package/dist/commands/channel/rm.js +203 -96
  44. package/dist/commands/channel/rm.js.map +1 -1
  45. package/dist/commands/channel/spawn.d.ts +3 -0
  46. package/dist/commands/channel/spawn.d.ts.map +1 -1
  47. package/dist/commands/channel/spawn.js +43 -15
  48. package/dist/commands/channel/spawn.js.map +1 -1
  49. package/dist/commands/channel/supervisor/idle.d.ts +2 -0
  50. package/dist/commands/channel/supervisor/idle.d.ts.map +1 -1
  51. package/dist/commands/channel/supervisor/idle.js +15 -7
  52. package/dist/commands/channel/supervisor/idle.js.map +1 -1
  53. package/dist/commands/channel/supervisor/inbox.d.ts.map +1 -1
  54. package/dist/commands/channel/supervisor/inbox.js +48 -33
  55. package/dist/commands/channel/supervisor/inbox.js.map +1 -1
  56. package/dist/commands/channel/supervisor/stdout.d.ts.map +1 -1
  57. package/dist/commands/channel/supervisor/stdout.js +6 -3
  58. package/dist/commands/channel/supervisor/stdout.js.map +1 -1
  59. package/dist/commands/channel/supervisor/warning.d.ts.map +1 -1
  60. package/dist/commands/channel/supervisor/warning.js +2 -3
  61. package/dist/commands/channel/supervisor/warning.js.map +1 -1
  62. package/dist/commands/channel/supervisor.d.ts +12 -0
  63. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  64. package/dist/commands/channel/supervisor.js +88 -16
  65. package/dist/commands/channel/supervisor.js.map +1 -1
  66. package/dist/commands/channel/wait.d.ts +3 -1
  67. package/dist/commands/channel/wait.d.ts.map +1 -1
  68. package/dist/commands/channel/wait.js +59 -14
  69. package/dist/commands/channel/wait.js.map +1 -1
  70. package/dist/commands/init.d.ts.map +1 -1
  71. package/dist/commands/init.js +23 -10
  72. package/dist/commands/init.js.map +1 -1
  73. package/dist/commands/update.d.ts.map +1 -1
  74. package/dist/commands/update.js +446 -333
  75. package/dist/commands/update.js.map +1 -1
  76. package/dist/commands/workflow.d.ts +1 -0
  77. package/dist/commands/workflow.d.ts.map +1 -1
  78. package/dist/commands/workflow.js +56 -1
  79. package/dist/commands/workflow.js.map +1 -1
  80. package/dist/configurators/codex.d.ts +6 -0
  81. package/dist/configurators/codex.d.ts.map +1 -1
  82. package/dist/configurators/codex.js +93 -0
  83. package/dist/configurators/codex.js.map +1 -1
  84. package/dist/configurators/shared.js +2 -2
  85. package/dist/configurators/shared.js.map +1 -1
  86. package/dist/constants/paths.d.ts +2 -0
  87. package/dist/constants/paths.d.ts.map +1 -1
  88. package/dist/constants/paths.js +2 -0
  89. package/dist/constants/paths.js.map +1 -1
  90. package/dist/migrations/manifests/0.7.0-beta.10.json +9 -0
  91. package/dist/migrations/manifests/0.7.0-beta.11.json +9 -0
  92. package/dist/migrations/manifests/0.7.0-beta.12.json +9 -0
  93. package/dist/migrations/manifests/0.7.0-beta.13.json +9 -0
  94. package/dist/migrations/manifests/0.7.0-beta.14.json +9 -0
  95. package/dist/migrations/manifests/0.7.0-beta.15.json +9 -0
  96. package/dist/migrations/manifests/0.7.0-beta.16.json +9 -0
  97. package/dist/migrations/manifests/0.7.0-beta.17.json +9 -0
  98. package/dist/migrations/manifests/0.7.0-beta.18.json +9 -0
  99. package/dist/migrations/manifests/0.7.0-beta.19.json +9 -0
  100. package/dist/migrations/manifests/0.7.0-beta.20.json +9 -0
  101. package/dist/migrations/manifests/0.7.0-beta.21.json +17 -0
  102. package/dist/migrations/manifests/0.7.0-beta.22.json +9 -0
  103. package/dist/migrations/manifests/0.7.0-beta.23.json +9 -0
  104. package/dist/migrations/manifests/0.7.0-beta.24.json +9 -0
  105. package/dist/migrations/manifests/0.7.0-beta.25.json +9 -0
  106. package/dist/migrations/manifests/0.7.0-beta.26.json +9 -0
  107. package/dist/migrations/manifests/0.7.0-beta.27.json +9 -0
  108. package/dist/migrations/manifests/0.7.0-beta.28.json +9 -0
  109. package/dist/migrations/manifests/0.7.0-beta.29.json +9 -0
  110. package/dist/migrations/manifests/0.7.0-beta.30.json +9 -0
  111. package/dist/migrations/manifests/0.7.0-beta.31.json +9 -0
  112. package/dist/migrations/manifests/0.7.0-beta.32.json +9 -0
  113. package/dist/migrations/manifests/0.7.0-beta.33.json +9 -0
  114. package/dist/migrations/manifests/0.7.0-beta.34.json +9 -0
  115. package/dist/migrations/manifests/0.7.0-beta.35.json +9 -0
  116. package/dist/migrations/manifests/0.7.0-beta.36.json +9 -0
  117. package/dist/migrations/manifests/0.7.0-beta.37.json +9 -0
  118. package/dist/migrations/manifests/0.7.0-beta.38.json +9 -0
  119. package/dist/migrations/manifests/0.7.0-beta.39.json +9 -0
  120. package/dist/migrations/manifests/0.7.0-beta.40.json +9 -0
  121. package/dist/migrations/manifests/0.7.0-beta.41.json +9 -0
  122. package/dist/migrations/manifests/0.7.0-beta.42.json +9 -0
  123. package/dist/migrations/manifests/0.7.0-beta.43.json +9 -0
  124. package/dist/migrations/manifests/0.7.0-beta.44.json +9 -0
  125. package/dist/migrations/manifests/0.7.0-beta.45.json +9 -0
  126. package/dist/migrations/manifests/0.7.0-beta.46.json +9 -0
  127. package/dist/migrations/manifests/0.7.0-beta.47.json +9 -0
  128. package/dist/migrations/manifests/0.7.0-beta.48.json +9 -0
  129. package/dist/migrations/manifests/0.7.0-beta.49.json +13 -0
  130. package/dist/migrations/manifests/0.7.0-beta.50.json +12 -0
  131. package/dist/migrations/manifests/0.7.0-beta.6.json +9 -0
  132. package/dist/migrations/manifests/0.7.0-beta.7.json +9 -0
  133. package/dist/migrations/manifests/0.7.0-beta.8.json +9 -0
  134. package/dist/migrations/manifests/0.7.0-beta.9.json +9 -0
  135. package/dist/templates/codex/hooks/session-start.py +21 -17
  136. package/dist/templates/common/bundled-skills/trellis-channel/SKILL.md +3 -1
  137. package/dist/templates/common/bundled-skills/trellis-channel/references/command-reference.md +24 -16
  138. package/dist/templates/common/bundled-skills/trellis-channel/references/forum.md +4 -12
  139. package/dist/templates/common/bundled-skills/trellis-channel/references/multi-target-dispatch.md +139 -0
  140. package/dist/templates/common/bundled-skills/trellis-channel/references/progress-debugging.md +9 -5
  141. package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +84 -14
  142. package/dist/templates/common/bundled-skills/trellis-channel/references/workers.md +9 -11
  143. package/dist/templates/common/bundled-skills/trellis-channel/references/workflows.md +25 -14
  144. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +13 -11
  145. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +7 -2
  146. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +6 -1
  147. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +6 -4
  148. package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +42 -3
  149. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +1 -1
  150. package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +10 -2
  151. package/dist/templates/common/commands/continue.md +36 -8
  152. package/dist/templates/common/commands/finish-work.md +17 -2
  153. package/dist/templates/common/commands/start.md +8 -1
  154. package/dist/templates/common/skills/brainstorm.md +49 -22
  155. package/dist/templates/common/skills/break-loop.md +6 -10
  156. package/dist/templates/common/skills/check.md +34 -5
  157. package/dist/templates/copilot/hooks/session-start.py +19 -15
  158. package/dist/templates/copilot/prompts/brainstorm.prompt.md +27 -21
  159. package/dist/templates/markdown/spec/backend/database-guidelines.md.txt +22 -22
  160. package/dist/templates/markdown/spec/backend/directory-structure.md.txt +20 -20
  161. package/dist/templates/markdown/spec/backend/error-handling.md.txt +22 -22
  162. package/dist/templates/markdown/spec/backend/index.md.txt +19 -19
  163. package/dist/templates/markdown/spec/backend/logging-guidelines.md.txt +22 -22
  164. package/dist/templates/markdown/spec/backend/quality-guidelines.md.txt +22 -22
  165. package/dist/templates/markdown/spec/frontend/component-guidelines.md.txt +25 -25
  166. package/dist/templates/markdown/spec/frontend/directory-structure.md.txt +20 -20
  167. package/dist/templates/markdown/spec/frontend/hook-guidelines.md.txt +22 -22
  168. package/dist/templates/markdown/spec/frontend/index.md.txt +20 -20
  169. package/dist/templates/markdown/spec/frontend/quality-guidelines.md.txt +22 -22
  170. package/dist/templates/markdown/spec/frontend/state-management.md.txt +22 -22
  171. package/dist/templates/markdown/spec/frontend/type-safety.md.txt +22 -22
  172. package/dist/templates/markdown/spec/guides/code-reuse-thinking-guide.md.txt +80 -85
  173. package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +164 -209
  174. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +161 -185
  175. package/dist/templates/markdown/spec/guides/index.md.txt +51 -51
  176. package/dist/templates/markdown/workspace-index.md +44 -44
  177. package/dist/templates/opencode/lib/session-utils.js +2 -2
  178. package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -6
  179. package/dist/templates/shared-hooks/inject-workflow-state.py +3 -2
  180. package/dist/templates/shared-hooks/session-start.py +23 -18
  181. package/dist/templates/trellis/agents/subnode-profiles.json +13 -0
  182. package/dist/templates/trellis/agents/subnode.md +11 -5
  183. package/dist/templates/trellis/config.yaml +2 -0
  184. package/dist/templates/trellis/index.d.ts +2 -1
  185. package/dist/templates/trellis/index.d.ts.map +1 -1
  186. package/dist/templates/trellis/index.js +4 -2
  187. package/dist/templates/trellis/index.js.map +1 -1
  188. package/dist/templates/trellis/scripts/add_session.py +35 -32
  189. package/dist/templates/trellis/scripts/common/active_task.py +8 -8
  190. package/dist/templates/trellis/scripts/common/developer.py +18 -18
  191. package/dist/templates/trellis/scripts/common/ownership_record.py +13 -8
  192. package/dist/templates/trellis/scripts/common/safe_commit.py +38 -9
  193. package/dist/templates/trellis/scripts/common/task_planning.py +137 -0
  194. package/dist/templates/trellis/scripts/common/task_store.py +46 -14
  195. package/dist/templates/trellis/scripts/common/workflow_phase.py +4 -3
  196. package/dist/templates/trellis/scripts/subnode_artifact.py +470 -21
  197. package/dist/templates/trellis/scripts/task.py +175 -2
  198. package/dist/templates/trellis/workflow.md +407 -388
  199. package/dist/utils/managed-paths.d.ts +2 -0
  200. package/dist/utils/managed-paths.d.ts.map +1 -1
  201. package/dist/utils/managed-paths.js +24 -2
  202. package/dist/utils/managed-paths.js.map +1 -1
  203. package/dist/utils/template-fetcher.d.ts +1 -0
  204. package/dist/utils/template-fetcher.d.ts.map +1 -1
  205. package/dist/utils/template-fetcher.js +2 -1
  206. package/dist/utils/template-fetcher.js.map +1 -1
  207. package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
  208. package/dist/utils/uninstall-scrubbers.js +5 -0
  209. package/dist/utils/uninstall-scrubbers.js.map +1 -1
  210. package/dist/utils/workflow-provenance.d.ts +24 -0
  211. package/dist/utils/workflow-provenance.d.ts.map +1 -0
  212. package/dist/utils/workflow-provenance.js +71 -0
  213. package/dist/utils/workflow-provenance.js.map +1 -0
  214. package/dist/utils/workflow-resolver.d.ts +9 -2
  215. package/dist/utils/workflow-resolver.d.ts.map +1 -1
  216. package/dist/utils/workflow-resolver.js +33 -7
  217. package/dist/utils/workflow-resolver.js.map +1 -1
  218. package/package.json +2 -2
  219. package/dist/templates/trellis/agents/subnode.env +0 -4
@@ -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,14 +23,19 @@ 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"),
32
36
  re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
37
+ re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]+=*", re.IGNORECASE),
38
+ re.compile(r'''["']?\b(?:password|secret|api[_-]?key|access[_-]?token|authorization)["']?\s*[:=]\s*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s,;}]+)''', re.IGNORECASE),
33
39
  )
34
40
  TERMINAL_LIFECYCLES = {"done", "error", "killed", "crashed"}
35
41
  DISPOSITION_OUTCOMES = {"accepted", "rejected", "deferred"}
@@ -152,6 +158,98 @@ def _relative_to_repo(path: Path, repo_root: Path) -> str:
152
158
  _fail(f"path is outside repository: {path}")
153
159
 
154
160
 
161
+ def _queue_paths(task_dir: Path, work_id: str) -> tuple[Path, Path]:
162
+ work_dir = task_dir / "subnodes" / work_id
163
+ _assert_no_subpath_symlinks(task_dir, work_dir)
164
+ if not work_dir.exists() or not work_dir.is_dir():
165
+ _fail(f"queue work directory does not exist: {work_dir}")
166
+ return work_dir / "queue.json", work_dir / "queue-abandoned.json"
167
+
168
+
169
+ def _queue_context(args: argparse.Namespace) -> tuple[Path, str, Path, Path, Path]:
170
+ repo_root = get_repo_root()
171
+ task_dir = resolve_task_dir(args.task, repo_root)
172
+ if task_dir is None or not is_within_tasks_dir(task_dir, repo_root):
173
+ _fail("--task must identify a direct active task directory")
174
+ if task_dir.is_symlink():
175
+ _fail(f"refusing symlinked task directory: {task_dir}")
176
+ work_id = _require_id(args.work_id, "--work-id")
177
+ task_id = _task_id(task_dir)
178
+ queue_path, abandoned_path = _queue_paths(task_dir, work_id)
179
+ for path in (queue_path, abandoned_path):
180
+ if path.is_symlink():
181
+ _fail(f"refusing symlinked queue artifact: {path}")
182
+ return repo_root, task_id, task_dir, queue_path, abandoned_path
183
+
184
+
185
+ def _write_json_exclusive(path: Path, value: dict[str, Any], label: str) -> None:
186
+ if path.is_symlink():
187
+ _fail(f"refusing symlinked {label}: {path}")
188
+ try:
189
+ with path.open("x", encoding="utf-8") as stream:
190
+ json.dump(value, stream, indent=2, ensure_ascii=False)
191
+ stream.write("\n")
192
+ except FileExistsError:
193
+ _fail(f"{label} already exists and cannot be replaced: {path}")
194
+ except OSError as exc:
195
+ _fail(f"could not write {label} {path}: {exc}")
196
+
197
+
198
+ def _validate_queue_file(
199
+ queue_path: Path,
200
+ task_dir: Path,
201
+ task_id: str,
202
+ work_id: str,
203
+ repo_root: Path,
204
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
205
+ queue, _raw = _read_json_object(queue_path, MAX_QUEUE_BYTES, "queue")
206
+ if queue.get("schema_version") != 1:
207
+ _fail("queue.schema_version must be 1")
208
+ if queue.get("task_id") != task_id:
209
+ _fail("queue.task_id does not match the task")
210
+ if queue.get("work_id") != work_id:
211
+ _fail("queue.work_id does not match the requested work")
212
+ channel = queue.get("channel_ref")
213
+ if not isinstance(channel, dict):
214
+ _fail("queue.channel_ref must be an object")
215
+ channel_name = _require_text(channel.get("name"), "queue.channel_ref.name", max_len=128)
216
+ channel_scope = _require_text(channel.get("scope"), "queue.channel_ref.scope", max_len=32)
217
+ if channel_scope not in {"project", "global"}:
218
+ _fail("queue.channel_ref.scope must be project or global")
219
+ items = queue.get("items")
220
+ if not isinstance(items, list) or not items:
221
+ _fail("queue.items must be a non-empty list")
222
+ seen: set[str] = set()
223
+ validated: list[dict[str, Any]] = []
224
+ for index, item in enumerate(items):
225
+ if not isinstance(item, dict):
226
+ _fail(f"queue.items[{index}] must be an object")
227
+ subnode_id = _require_id(item.get("subnode_id"), f"queue.items[{index}].subnode_id")
228
+ if subnode_id in seen:
229
+ _fail("queue.items contains duplicate subnode_id values")
230
+ seen.add(subnode_id)
231
+ brief_path = _require_text(item.get("brief_path"), f"queue.items[{index}].brief_path")
232
+ brief, raw, brief_file, node_dir, brief_task_dir = _validate_brief_file(
233
+ brief_path, repo_root
234
+ )
235
+ if brief_task_dir != task_dir:
236
+ _fail(f"queue item {subnode_id} brief belongs to another task")
237
+ if brief.get("work_id") != work_id or brief.get("subnode_id") != subnode_id:
238
+ _fail(f"queue item {subnode_id} brief identity does not match")
239
+ expected_brief_path = _relative_to_repo(node_dir / "brief.json", repo_root)
240
+ if _relative_to_repo(brief_file, repo_root) != expected_brief_path:
241
+ _fail(f"queue item {subnode_id} brief_path is not canonical")
242
+ if brief.get("channel_ref", {}).get("name") != channel_name:
243
+ _fail(f"queue item {subnode_id} channel name does not match the queue")
244
+ if brief.get("channel_ref", {}).get("scope") != channel_scope:
245
+ _fail(f"queue item {subnode_id} channel scope does not match the queue")
246
+ digest = hashlib.sha256(raw).hexdigest()
247
+ if item.get("brief_digest") != digest:
248
+ _fail(f"queue item {subnode_id} brief digest does not match")
249
+ validated.append({"subnode_id": subnode_id, "brief_path": brief_path, "brief_digest": digest})
250
+ return queue, validated
251
+
252
+
155
253
  def _validate_snapshot(value: Any) -> None:
156
254
  if not isinstance(value, list) or not value:
157
255
  _fail("source_snapshot must be a non-empty list")
@@ -305,27 +403,121 @@ def _validate_brief_file(
305
403
  return brief, raw, brief_path, node_dir, task_dir
306
404
 
307
405
 
308
- def _validate_evidence(value: Any) -> set[tuple[str, str]]:
406
+ def _validate_evidence(value: Any) -> set[str]:
309
407
  if not isinstance(value, list):
310
408
  _fail("report.evidence must be a list")
311
- identities: set[tuple[str, str]] = set()
409
+ identities: set[str] = set()
312
410
  for index, item in enumerate(value):
313
411
  if not isinstance(item, dict):
314
412
  _fail(f"report.evidence[{index}] must be an object")
315
413
  evidence_id = _require_text(item.get("id"), f"report.evidence[{index}].id", max_len=128)
316
- locator = _require_text(item.get("locator"), f"report.evidence[{index}].locator")
414
+ _require_text(item.get("locator"), f"report.evidence[{index}].locator")
317
415
  _require_text(item.get("summary"), f"report.evidence[{index}].summary")
318
- identity = (evidence_id, locator)
319
- if identity in identities:
416
+ if evidence_id in identities:
320
417
  _fail("report.evidence contains a duplicate evidence identity")
321
- identities.add(identity)
418
+ identities.add(evidence_id)
322
419
  return identities
323
420
 
324
421
 
422
+ def _validate_id_list(value: Any, field: str) -> list[str]:
423
+ if not isinstance(value, list):
424
+ _fail(f"{field} must be a list")
425
+ result = []
426
+ for index, item in enumerate(value):
427
+ result.append(_require_text(item, f"{field}[{index}]", max_len=128))
428
+ if len(result) != len(set(result)):
429
+ _fail(f"{field} must not contain duplicates")
430
+ return result
431
+
432
+
433
+ def _validate_typed_notes(value: Any, field: str, evidence: set[str]) -> list[str]:
434
+ if not isinstance(value, list):
435
+ _fail(f"report.{field} must be a list")
436
+ concerns: list[str] = []
437
+ for index, item in enumerate(value):
438
+ if not isinstance(item, dict):
439
+ _fail(f"report.{field}[{index}] must be an object")
440
+ _require_id(item.get("id"), f"report.{field}[{index}].id")
441
+ _require_text(item.get("type"), f"report.{field}[{index}].type", max_len=64)
442
+ _require_text(item.get("detail"), f"report.{field}[{index}].detail")
443
+ references = _validate_id_list(item.get("evidence_ids", []), f"report.{field}[{index}].evidence_ids")
444
+ if any(reference not in evidence for reference in references):
445
+ concerns.append(f"{field}_evidence_unresolved")
446
+ return concerns
447
+
448
+
449
+ def _validate_checkpoint(node_dir: Path, evidence_ids: set[str], scope: list[str]) -> list[str]:
450
+ worklog_path = node_dir / "worklog.md"
451
+ if worklog_path.is_symlink():
452
+ _fail(f"refusing symlinked worklog: {worklog_path}")
453
+ try:
454
+ raw = worklog_path.read_bytes()
455
+ except FileNotFoundError:
456
+ _fail(f"worklog does not exist: {worklog_path}")
457
+ except OSError as exc:
458
+ _fail(f"could not read worklog {worklog_path}: {exc}")
459
+ if len(raw) > MAX_WORKLOG_BYTES:
460
+ _fail(f"worklog exceeds the {MAX_WORKLOG_BYTES} byte limit: {worklog_path}")
461
+ _reject_obvious_secrets(raw, "worklog")
462
+ try:
463
+ text = raw.decode("utf-8")
464
+ except UnicodeDecodeError:
465
+ _fail(f"worklog is not UTF-8: {worklog_path}")
466
+ matches = list(CHECKPOINT_RE.finditer(text))
467
+ if not matches:
468
+ return ["missing_worklog_checkpoint"]
469
+ concerns: list[str] = []
470
+ checkpoint_ids: set[str] = set()
471
+ checkpoint_scope: set[str] = set()
472
+ for match in matches:
473
+ try:
474
+ checkpoint = json.loads(match.group("payload"))
475
+ except json.JSONDecodeError:
476
+ concerns.append("malformed_worklog_checkpoint")
477
+ continue
478
+ if not isinstance(checkpoint, dict):
479
+ concerns.append("malformed_worklog_checkpoint")
480
+ continue
481
+ try:
482
+ checkpoint_id = _require_id(checkpoint.get("id"), "worklog checkpoint.id")
483
+ if checkpoint_id in checkpoint_ids:
484
+ concerns.append("duplicate_worklog_checkpoint")
485
+ checkpoint_ids.add(checkpoint_id)
486
+ covered_scope = _require_text_list(
487
+ checkpoint.get("covered_scope"),
488
+ "worklog checkpoint.covered_scope",
489
+ allow_empty=True,
490
+ )
491
+ for item in covered_scope:
492
+ if item not in scope:
493
+ concerns.append("checkpoint_scope_outside_brief")
494
+ checkpoint_evidence = _validate_id_list(
495
+ checkpoint.get("evidence_ids"), "worklog checkpoint.evidence_ids"
496
+ )
497
+ if any(item not in evidence_ids for item in checkpoint_evidence):
498
+ concerns.append("checkpoint_evidence_unresolved")
499
+ _require_text(
500
+ checkpoint.get("conclusion_or_blocker"),
501
+ "worklog checkpoint.conclusion_or_blocker",
502
+ )
503
+ _require_text_list(
504
+ checkpoint.get("unknowns"),
505
+ "worklog checkpoint.unknowns",
506
+ allow_empty=True,
507
+ )
508
+ _require_text(checkpoint.get("safe_resume_point"), "worklog checkpoint.safe_resume_point")
509
+ checkpoint_scope.update(covered_scope)
510
+ except ArtifactError:
511
+ concerns.append("malformed_worklog_checkpoint")
512
+ if not set(scope).issubset(checkpoint_scope):
513
+ concerns.append("checkpoint_scope_incomplete")
514
+ return sorted(set(concerns))
515
+
516
+
325
517
  def _validate_report_data(
326
518
  report: dict[str, Any],
327
519
  brief: dict[str, Any],
328
- ) -> set[tuple[str, str]]:
520
+ ) -> tuple[set[str], list[str]]:
329
521
  if report.get("schema_version") != SCHEMA_VERSION:
330
522
  _fail(f"report.schema_version must be {SCHEMA_VERSION}")
331
523
  for field in ("task_id", "work_id", "subnode_id", "role_id"):
@@ -339,25 +531,71 @@ def _validate_report_data(
339
531
  if status not in {"complete", "blocked", "incomplete", "error"}:
340
532
  _fail("report.status must be complete, blocked, incomplete, or error")
341
533
  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")
534
+ assessment = report.get("scope_assessment")
535
+ if not isinstance(assessment, list) or len(assessment) != len(brief["scope"]):
536
+ _fail("report.scope_assessment must contain one item for every brief scope item")
537
+ concerns: list[str] = []
538
+ for index, item in enumerate(assessment):
539
+ if not isinstance(item, dict):
540
+ _fail(f"report.scope_assessment[{index}] must be an object")
541
+ if item.get("scope") != brief["scope"][index]:
542
+ _fail(f"report.scope_assessment[{index}].scope does not match brief.scope")
543
+ if item.get("status") not in {"covered", "inconclusive", "not-started"}:
544
+ _fail(f"report.scope_assessment[{index}].status is invalid")
545
+ _require_text(item.get("conclusion"), f"report.scope_assessment[{index}].conclusion")
546
+ assessment_evidence = _validate_id_list(
547
+ item.get("evidence_ids"), f"report.scope_assessment[{index}].evidence_ids"
548
+ )
549
+ if any(value not in evidence for value in assessment_evidence):
550
+ concerns.append("scope_assessment_evidence_unresolved")
551
+ if item["status"] != "covered":
552
+ concerns.append("incomplete_scope_coverage")
553
+ if item["status"] == "covered" and not assessment_evidence:
554
+ concerns.append("covered_scope_without_evidence")
555
+ findings = report.get("findings")
556
+ if not isinstance(findings, list):
557
+ _fail("report.findings must be a list")
558
+ finding_ids: set[str] = set()
559
+ for index, item in enumerate(findings):
560
+ if not isinstance(item, dict):
561
+ _fail(f"report.findings[{index}] must be an object")
562
+ finding_id = _require_id(item.get("id"), f"report.findings[{index}].id")
563
+ if finding_id in finding_ids:
564
+ _fail("report.findings contains duplicate ids")
565
+ finding_ids.add(finding_id)
566
+ _require_text(item.get("conclusion"), f"report.findings[{index}].conclusion")
567
+ finding_evidence = _validate_id_list(
568
+ item.get("evidence_ids"), f"report.findings[{index}].evidence_ids"
569
+ )
570
+ if not finding_evidence:
571
+ concerns.append("finding_without_evidence")
572
+ if any(value not in evidence for value in finding_evidence):
573
+ concerns.append("finding_evidence_unresolved")
574
+ concerns.extend(_validate_typed_notes(report.get("uncertainties"), "uncertainties", evidence))
575
+ concerns.extend(_validate_typed_notes(report.get("corrections"), "corrections", evidence))
345
576
  if status == "complete" and not evidence:
346
577
  _fail("a complete report requires at least one evidence item")
578
+ if status == "complete" and not findings:
579
+ _fail("a complete report requires at least one finding")
347
580
  if status != "complete":
348
- _require_text_list(
581
+ completed = _require_text_list(
349
582
  report.get("completed_scope"),
350
583
  "report.completed_scope",
351
584
  allow_empty=True,
352
585
  )
586
+ if len(completed) != len(set(completed)) or any(item not in brief["scope"] for item in completed):
587
+ _fail("report.completed_scope must contain unique assigned scope items")
588
+ covered = {item["scope"] for item in assessment if item["status"] == "covered"}
589
+ if set(completed) != covered:
590
+ concerns.append("completed_scope_assessment_mismatch")
353
591
  _require_text(report.get("blocker"), "report.blocker")
354
- return evidence
592
+ return evidence, sorted(set(concerns))
355
593
 
356
594
 
357
595
  def _validate_report_file(
358
596
  value: str,
359
597
  repo_root: Path,
360
- ) -> tuple[dict[str, Any], set[tuple[str, str]], dict[str, Any], Path]:
598
+ ) -> tuple[dict[str, Any], set[str], dict[str, Any], Path, list[str]]:
361
599
  report_path, node_dir, _task_dir = _resolve_artifact_file(value, "report.json", repo_root)
362
600
  brief_path = node_dir / "brief.json"
363
601
  brief, _brief_raw, _brief_path, _brief_node, _brief_task = _validate_brief_file(
@@ -368,8 +606,9 @@ def _validate_report_file(
368
606
  _fail("brief.report_path does not point to the report being validated")
369
607
  report, raw = _read_json_object(report_path, MAX_REPORT_BYTES, "report")
370
608
  _reject_obvious_secrets(raw, "report")
371
- evidence = _validate_report_data(report, brief)
372
- return report, evidence, brief, node_dir
609
+ evidence, concerns = _validate_report_data(report, brief)
610
+ concerns.extend(_validate_checkpoint(node_dir, evidence, brief["scope"]))
611
+ return report, evidence, brief, node_dir, sorted(set(concerns))
373
612
 
374
613
 
375
614
  def _validate_disposition_data(
@@ -407,6 +646,29 @@ def _validate_disposition_data(
407
646
  _require_text(disposition.get("decided_at"), "disposition.decided_at", max_len=128)
408
647
 
409
648
 
649
+ def _validate_unit_plan(brief: dict[str, Any], task_dir: Path, repo_root: Path) -> None:
650
+ """New dispatch admission only; historical report readers remain compatible."""
651
+ plan = brief.get("unit_plan")
652
+ if not isinstance(plan, dict):
653
+ _fail("new evidence unit requires unit_plan")
654
+ reference = _require_text(plan.get("plan_ref"), "unit_plan.plan_ref", max_len=1024)
655
+ locator = reference.partition("#")[0]
656
+ relative = Path(locator)
657
+ if relative.is_absolute() or ".." in relative.parts or "\\" in locator:
658
+ _fail("unit_plan.plan_ref must be a repository-relative task document")
659
+ document = repo_root / relative
660
+ lexical_task = _task_lexical_from_resolved(task_dir, repo_root)
661
+ _assert_no_subpath_symlinks(lexical_task, document)
662
+ if not document.is_file() or document.suffix != ".md":
663
+ _fail("unit_plan.plan_ref must identify a regular document in this task")
664
+ if document.stat().st_size > MAX_REPORT_BYTES:
665
+ _fail("unit_plan.plan_ref document exceeds the byte limit")
666
+ _require_text(document.read_text(encoding="utf-8"), "unit_plan.plan_ref document", max_len=MAX_REPORT_BYTES)
667
+ _require_text(plan.get("sizing_rationale"), "unit_plan.sizing_rationale")
668
+ if len(brief["scope"]) > 1:
669
+ _require_text(plan.get("grouping_rationale"), "unit_plan.grouping_rationale")
670
+
671
+
410
672
  def _init(args: argparse.Namespace) -> None:
411
673
  repo_root = get_repo_root()
412
674
  task_dir = resolve_task_dir(args.task, repo_root)
@@ -434,13 +696,14 @@ def _init(args: argparse.Namespace) -> None:
434
696
  brief["role_id"] = "subnode"
435
697
  brief["report_path"] = _relative_to_repo(node_dir / "report.json", repo_root)
436
698
  _validate_brief_data(brief, task_dir, node_dir, repo_root)
699
+ _validate_unit_plan(brief, task_dir, repo_root)
437
700
  for directory in (task_dir / "subnodes", task_dir / "subnodes" / work_id, node_dir):
438
701
  if directory.is_symlink():
439
702
  _fail(f"refusing symlinked artifact directory: {directory}")
440
703
  if directory.exists() and not directory.is_dir():
441
704
  _fail(f"artifact path component is not a directory: {directory}")
442
705
  try:
443
- directory.mkdir(exist_ok=True)
706
+ directory.mkdir(exist_ok=directory != node_dir)
444
707
  except OSError as exc:
445
708
  _fail(f"could not create artifact directory {directory}: {exc}")
446
709
  brief_path = node_dir / "brief.json"
@@ -464,15 +727,171 @@ def _init(args: argparse.Namespace) -> None:
464
727
  }))
465
728
 
466
729
 
730
+ def _queue_init(args: argparse.Namespace) -> None:
731
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
732
+ if queue_path.exists() or abandoned_path.exists():
733
+ _fail("queue already exists or has been abandoned")
734
+ if not args.brief:
735
+ _fail("queue init requires at least one --brief")
736
+ work_id = _require_id(args.work_id, "--work-id")
737
+ channel_name = _require_text(args.channel_name, "--channel-name", max_len=128)
738
+ channel_scope = _require_text(args.channel_scope, "--channel-scope", max_len=32)
739
+ if channel_scope not in {"project", "global"}:
740
+ _fail("--channel-scope must be project or global")
741
+ items: list[dict[str, str]] = []
742
+ seen: set[str] = set()
743
+ for brief_arg in args.brief:
744
+ brief, raw, brief_path, node_dir, brief_task_dir = _validate_brief_file(
745
+ brief_arg, repo_root
746
+ )
747
+ subnode_id = _require_id(brief.get("subnode_id"), "brief.subnode_id")
748
+ if brief_task_dir != task_dir:
749
+ _fail(f"queue item {subnode_id} brief belongs to another task")
750
+ _validate_unit_plan(brief, task_dir, repo_root)
751
+ if brief.get("work_id") != work_id:
752
+ _fail(f"queue item {subnode_id} brief belongs to another work")
753
+ if subnode_id in seen:
754
+ _fail("queue init contains duplicate subnode_id values")
755
+ if brief.get("channel_ref", {}).get("name") != channel_name:
756
+ _fail(f"queue item {subnode_id} channel name does not match")
757
+ if brief.get("channel_ref", {}).get("scope") != channel_scope:
758
+ _fail(f"queue item {subnode_id} channel scope does not match")
759
+ expected_path = _relative_to_repo(node_dir / "brief.json", repo_root)
760
+ if _relative_to_repo(brief_path, repo_root) != expected_path:
761
+ _fail(f"queue item {subnode_id} brief path is not canonical")
762
+ seen.add(subnode_id)
763
+ items.append({
764
+ "subnode_id": subnode_id,
765
+ "brief_path": expected_path,
766
+ "brief_digest": hashlib.sha256(raw).hexdigest(),
767
+ })
768
+ queue = {
769
+ "schema_version": 1,
770
+ "task_id": task_id,
771
+ "work_id": work_id,
772
+ "channel_ref": {"name": channel_name, "scope": channel_scope},
773
+ "created_at": datetime.now(timezone.utc).isoformat(),
774
+ "items": items,
775
+ }
776
+ _write_json_exclusive(queue_path, queue, "queue")
777
+ print(json.dumps({"queue": _relative_to_repo(queue_path, repo_root), "items": items}))
778
+
779
+
780
+ def _queue_validate(args: argparse.Namespace) -> None:
781
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
782
+ _queue, items = _validate_queue_file(
783
+ queue_path, task_dir, task_id, args.work_id, repo_root
784
+ )
785
+ print(json.dumps({
786
+ "status": "abandoned" if abandoned_path.exists() else "valid",
787
+ "queue": _relative_to_repo(queue_path, repo_root),
788
+ "task_id": task_id,
789
+ "work_id": args.work_id,
790
+ "items": items,
791
+ "item_count": len(items),
792
+ }))
793
+
794
+
795
+ def _queue_claim(args: argparse.Namespace) -> None:
796
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
797
+ if abandoned_path.exists():
798
+ _fail("queue has been abandoned; no further dispatch claim is allowed")
799
+ _queue, items = _validate_queue_file(
800
+ queue_path, task_dir, task_id, args.work_id, repo_root
801
+ )
802
+ subnode_id = _require_id(args.subnode_id, "--subnode-id")
803
+ item = next((candidate for candidate in items if candidate["subnode_id"] == subnode_id), None)
804
+ if item is None:
805
+ _fail(f"subnode is not listed in the queue: {subnode_id}")
806
+ for previous in items:
807
+ if previous is item:
808
+ break
809
+ previous_claim = task_dir / "subnodes" / args.work_id / previous["subnode_id"] / "dispatch-claim.json"
810
+ _assert_no_subpath_symlinks(task_dir, previous_claim)
811
+ if not previous_claim.is_file():
812
+ _fail(f"FIFO requires claiming earlier unit first: {previous['subnode_id']}")
813
+ claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
814
+ _assert_no_subpath_symlinks(task_dir, claim_path)
815
+ claim = {
816
+ "schema_version": 1,
817
+ "task_id": task_id,
818
+ "work_id": args.work_id,
819
+ "subnode_id": subnode_id,
820
+ "brief_digest": item["brief_digest"],
821
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
822
+ }
823
+ _write_json_exclusive(claim_path, claim, "dispatch claim")
824
+ print(json.dumps({
825
+ "claim": _relative_to_repo(claim_path, repo_root),
826
+ "subnode_id": subnode_id,
827
+ "brief_digest": item["brief_digest"],
828
+ }))
829
+
830
+
831
+ def _queue_abandon(args: argparse.Namespace) -> None:
832
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
833
+ _queue, items = _validate_queue_file(
834
+ queue_path, task_dir, task_id, args.work_id, repo_root
835
+ )
836
+ item_ids = {item["subnode_id"] for item in items}
837
+ claimed_ids: set[str] = set()
838
+ for item in items:
839
+ subnode_id = item["subnode_id"]
840
+ claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
841
+ _assert_no_subpath_symlinks(task_dir, claim_path)
842
+ if not claim_path.exists():
843
+ continue
844
+ claim, _raw = _read_json_object(claim_path, MAX_DRAFT_BYTES, "dispatch claim")
845
+ if (
846
+ claim.get("schema_version") != 1
847
+ or claim.get("task_id") != task_id
848
+ or claim.get("work_id") != args.work_id
849
+ or claim.get("subnode_id") != subnode_id
850
+ or claim.get("brief_digest") != item["brief_digest"]
851
+ ):
852
+ _fail(f"dispatch claim does not match queue item {subnode_id}")
853
+ claimed_ids.add(subnode_id)
854
+ dispatched = [_require_id(value, "--dispatched") for value in (args.dispatched or [])]
855
+ pending = [_require_id(value, "--pending") for value in (args.pending or [])]
856
+ if len(set(dispatched)) != len(dispatched) or len(set(pending)) != len(pending):
857
+ _fail("queue abandonment lists must not contain duplicates")
858
+ if not set(dispatched).issubset(item_ids) or not set(pending).issubset(item_ids):
859
+ _fail("queue abandonment lists must contain only queued subnode IDs")
860
+ if set(dispatched) & set(pending):
861
+ _fail("a subnode cannot be both dispatched and pending")
862
+ if set(dispatched) != claimed_ids or set(pending) != item_ids - claimed_ids:
863
+ _fail("queue abandonment lists must exactly match dispatch claims and remaining queued items")
864
+ abandoned = {
865
+ "schema_version": 1,
866
+ "task_id": task_id,
867
+ "work_id": args.work_id,
868
+ "reason": _require_text(args.reason, "--reason"),
869
+ "dispatched": dispatched,
870
+ "pending": pending,
871
+ "abandoned_at": datetime.now(timezone.utc).isoformat(),
872
+ }
873
+ _write_json_exclusive(abandoned_path, abandoned, "queue abandonment")
874
+ print(json.dumps({"status": "abandoned", "queue": _relative_to_repo(abandoned_path, repo_root)}))
875
+
876
+
467
877
  def _validate(args: argparse.Namespace) -> None:
468
878
  repo_root = get_repo_root()
469
- report, _evidence, _brief, _node_dir_value = _validate_report_file(args.report, repo_root)
879
+ report, _evidence, _brief, _node_dir_value, concerns = _validate_report_file(args.report, repo_root)
880
+ if concerns:
881
+ print(json.dumps({
882
+ "status": "review_concern",
883
+ "subnode_id": report["subnode_id"],
884
+ "concerns": concerns,
885
+ }))
886
+ return
470
887
  print(f"Validated pending-review report: {report['subnode_id']}")
471
888
 
472
889
 
473
890
  def _disposition(args: argparse.Namespace) -> None:
474
891
  repo_root = get_repo_root()
475
- report, _evidence, brief, node_dir = _validate_report_file(args.report, repo_root)
892
+ report, _evidence, brief, node_dir, concerns = _validate_report_file(args.report, repo_root)
893
+ if args.outcome == "accepted" and (report["status"] != "complete" or concerns):
894
+ _fail("cannot accept a non-complete report or unresolved validation concerns: " + ", ".join(concerns))
476
895
  disposition_path = node_dir / "disposition.json"
477
896
  if disposition_path.exists() or disposition_path.is_symlink():
478
897
  _fail(f"disposition already exists and cannot be replaced: {disposition_path}")
@@ -514,10 +933,10 @@ def _validate_counter(args: argparse.Namespace) -> None:
514
933
  primary_dir = repo_root / primary_dir
515
934
  if not counter_dir.is_absolute():
516
935
  counter_dir = repo_root / counter_dir
517
- primary_report, primary_evidence, primary_brief, primary_node = _validate_report_file(
936
+ primary_report, primary_evidence, primary_brief, primary_node, _primary_concerns = _validate_report_file(
518
937
  str(primary_dir / "report.json"), repo_root
519
938
  )
520
- counter_report, counter_evidence, counter_brief, counter_node = _validate_report_file(
939
+ counter_report, counter_evidence, counter_brief, counter_node, _counter_concerns = _validate_report_file(
521
940
  str(counter_dir / "report.json"), repo_root
522
941
  )
523
942
  if primary_node == counter_node:
@@ -580,6 +999,36 @@ def _parser() -> argparse.ArgumentParser:
580
999
  counter.add_argument("--primary", required=True, help="primary subnode artifact directory")
581
1000
  counter.add_argument("--counter", required=True, help="counter subnode artifact directory")
582
1001
  counter.set_defaults(handler=_validate_counter)
1002
+
1003
+ queue = subparsers.add_parser("queue", help="manage one write-once subnode queue")
1004
+ queue_parsers = queue.add_subparsers(dest="queue_command", required=True)
1005
+
1006
+ queue_init = queue_parsers.add_parser("init", help="write one FIFO queue manifest")
1007
+ queue_init.add_argument("--task", required=True)
1008
+ queue_init.add_argument("--work-id", required=True)
1009
+ queue_init.add_argument("--channel-name", required=True)
1010
+ queue_init.add_argument("--channel-scope", required=True)
1011
+ queue_init.add_argument("--brief", action="append", required=True)
1012
+ queue_init.set_defaults(handler=_queue_init)
1013
+
1014
+ queue_validate = queue_parsers.add_parser("validate", help="validate a queue manifest")
1015
+ queue_validate.add_argument("--task", required=True)
1016
+ queue_validate.add_argument("--work-id", required=True)
1017
+ queue_validate.set_defaults(handler=_queue_validate)
1018
+
1019
+ queue_claim = queue_parsers.add_parser("claim", help="write one dispatch intent")
1020
+ queue_claim.add_argument("--task", required=True)
1021
+ queue_claim.add_argument("--work-id", required=True)
1022
+ queue_claim.add_argument("--subnode-id", required=True)
1023
+ queue_claim.set_defaults(handler=_queue_claim)
1024
+
1025
+ queue_abandon = queue_parsers.add_parser("abandon", help="stop a queue permanently")
1026
+ queue_abandon.add_argument("--task", required=True)
1027
+ queue_abandon.add_argument("--work-id", required=True)
1028
+ queue_abandon.add_argument("--reason", required=True)
1029
+ queue_abandon.add_argument("--dispatched", action="append")
1030
+ queue_abandon.add_argument("--pending", action="append")
1031
+ queue_abandon.set_defaults(handler=_queue_abandon)
583
1032
  return parser
584
1033
 
585
1034