@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.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +47 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/backup.d.ts +25 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +152 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/channel/adapters/claude.d.ts.map +1 -1
- package/dist/commands/channel/adapters/claude.js +2 -0
- package/dist/commands/channel/adapters/claude.js.map +1 -1
- package/dist/commands/channel/adapters/codex.d.ts +5 -1
- package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
- package/dist/commands/channel/adapters/codex.js +19 -9
- package/dist/commands/channel/adapters/codex.js.map +1 -1
- package/dist/commands/channel/adapters/index.d.ts +3 -0
- package/dist/commands/channel/adapters/index.d.ts.map +1 -1
- package/dist/commands/channel/adapters/index.js +3 -3
- package/dist/commands/channel/adapters/index.js.map +1 -1
- package/dist/commands/channel/adapters/types.d.ts +2 -0
- package/dist/commands/channel/adapters/types.d.ts.map +1 -1
- package/dist/commands/channel/guard.d.ts.map +1 -1
- package/dist/commands/channel/guard.js +38 -29
- package/dist/commands/channel/guard.js.map +1 -1
- package/dist/commands/channel/index.d.ts.map +1 -1
- package/dist/commands/channel/index.js +52 -16
- package/dist/commands/channel/index.js.map +1 -1
- package/dist/commands/channel/kill.d.ts.map +1 -1
- package/dist/commands/channel/kill.js +37 -15
- package/dist/commands/channel/kill.js.map +1 -1
- package/dist/commands/channel/messages.d.ts.map +1 -1
- package/dist/commands/channel/messages.js +3 -2
- package/dist/commands/channel/messages.js.map +1 -1
- package/dist/commands/channel/process-identity.d.ts +3 -0
- package/dist/commands/channel/process-identity.d.ts.map +1 -0
- package/dist/commands/channel/process-identity.js +24 -0
- package/dist/commands/channel/process-identity.js.map +1 -0
- package/dist/commands/channel/profiles.d.ts +26 -0
- package/dist/commands/channel/profiles.d.ts.map +1 -0
- package/dist/commands/channel/profiles.js +186 -0
- package/dist/commands/channel/profiles.js.map +1 -0
- package/dist/commands/channel/rm.d.ts +0 -1
- package/dist/commands/channel/rm.d.ts.map +1 -1
- package/dist/commands/channel/rm.js +203 -96
- package/dist/commands/channel/rm.js.map +1 -1
- package/dist/commands/channel/spawn.d.ts +3 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -1
- package/dist/commands/channel/spawn.js +43 -15
- package/dist/commands/channel/spawn.js.map +1 -1
- package/dist/commands/channel/supervisor/idle.d.ts +2 -0
- package/dist/commands/channel/supervisor/idle.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/idle.js +15 -7
- package/dist/commands/channel/supervisor/idle.js.map +1 -1
- package/dist/commands/channel/supervisor/inbox.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/inbox.js +48 -33
- package/dist/commands/channel/supervisor/inbox.js.map +1 -1
- package/dist/commands/channel/supervisor/stdout.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/stdout.js +6 -3
- package/dist/commands/channel/supervisor/stdout.js.map +1 -1
- package/dist/commands/channel/supervisor/warning.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/warning.js +2 -3
- package/dist/commands/channel/supervisor/warning.js.map +1 -1
- package/dist/commands/channel/supervisor.d.ts +12 -0
- package/dist/commands/channel/supervisor.d.ts.map +1 -1
- package/dist/commands/channel/supervisor.js +88 -16
- package/dist/commands/channel/supervisor.js.map +1 -1
- package/dist/commands/channel/wait.d.ts +3 -1
- package/dist/commands/channel/wait.d.ts.map +1 -1
- package/dist/commands/channel/wait.js +59 -14
- package/dist/commands/channel/wait.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +23 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +446 -333
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts +1 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +56 -1
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/codex.d.ts +6 -0
- package/dist/configurators/codex.d.ts.map +1 -1
- package/dist/configurators/codex.js +93 -0
- package/dist/configurators/codex.js.map +1 -1
- package/dist/configurators/shared.js +2 -2
- package/dist/configurators/shared.js.map +1 -1
- package/dist/constants/paths.d.ts +2 -0
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +2 -0
- package/dist/constants/paths.js.map +1 -1
- package/dist/migrations/manifests/0.7.0-beta.10.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.11.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.12.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.13.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.14.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.15.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.16.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.17.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.18.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.19.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.20.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.21.json +17 -0
- package/dist/migrations/manifests/0.7.0-beta.22.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.23.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.24.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.25.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.26.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.27.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.28.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.29.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.30.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.31.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.32.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.33.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.34.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.35.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.36.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.37.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.38.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.39.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.40.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.41.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.42.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.43.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.44.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.45.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.46.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.47.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.48.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.49.json +13 -0
- package/dist/migrations/manifests/0.7.0-beta.50.json +12 -0
- package/dist/migrations/manifests/0.7.0-beta.6.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.7.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.8.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.9.json +9 -0
- package/dist/templates/codex/hooks/session-start.py +21 -17
- package/dist/templates/common/bundled-skills/trellis-channel/SKILL.md +3 -1
- package/dist/templates/common/bundled-skills/trellis-channel/references/command-reference.md +24 -16
- package/dist/templates/common/bundled-skills/trellis-channel/references/forum.md +4 -12
- package/dist/templates/common/bundled-skills/trellis-channel/references/multi-target-dispatch.md +139 -0
- package/dist/templates/common/bundled-skills/trellis-channel/references/progress-debugging.md +9 -5
- package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +84 -14
- package/dist/templates/common/bundled-skills/trellis-channel/references/workers.md +9 -11
- package/dist/templates/common/bundled-skills/trellis-channel/references/workflows.md +25 -14
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +13 -11
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +7 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +6 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +6 -4
- package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +42 -3
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +10 -2
- package/dist/templates/common/commands/continue.md +36 -8
- package/dist/templates/common/commands/finish-work.md +17 -2
- package/dist/templates/common/commands/start.md +8 -1
- package/dist/templates/common/skills/brainstorm.md +49 -22
- package/dist/templates/common/skills/break-loop.md +6 -10
- package/dist/templates/common/skills/check.md +34 -5
- package/dist/templates/copilot/hooks/session-start.py +19 -15
- package/dist/templates/copilot/prompts/brainstorm.prompt.md +27 -21
- package/dist/templates/markdown/spec/backend/database-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/backend/directory-structure.md.txt +20 -20
- package/dist/templates/markdown/spec/backend/error-handling.md.txt +22 -22
- package/dist/templates/markdown/spec/backend/index.md.txt +19 -19
- package/dist/templates/markdown/spec/backend/logging-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/backend/quality-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/component-guidelines.md.txt +25 -25
- package/dist/templates/markdown/spec/frontend/directory-structure.md.txt +20 -20
- package/dist/templates/markdown/spec/frontend/hook-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/index.md.txt +20 -20
- package/dist/templates/markdown/spec/frontend/quality-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/state-management.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/type-safety.md.txt +22 -22
- package/dist/templates/markdown/spec/guides/code-reuse-thinking-guide.md.txt +80 -85
- package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +164 -209
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +161 -185
- package/dist/templates/markdown/spec/guides/index.md.txt +51 -51
- package/dist/templates/markdown/workspace-index.md +44 -44
- package/dist/templates/opencode/lib/session-utils.js +2 -2
- package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -6
- package/dist/templates/shared-hooks/inject-workflow-state.py +3 -2
- package/dist/templates/shared-hooks/session-start.py +23 -18
- package/dist/templates/trellis/agents/subnode-profiles.json +13 -0
- package/dist/templates/trellis/agents/subnode.md +11 -5
- package/dist/templates/trellis/config.yaml +2 -0
- package/dist/templates/trellis/index.d.ts +2 -1
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +4 -2
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +35 -32
- package/dist/templates/trellis/scripts/common/active_task.py +8 -8
- package/dist/templates/trellis/scripts/common/developer.py +18 -18
- package/dist/templates/trellis/scripts/common/ownership_record.py +13 -8
- package/dist/templates/trellis/scripts/common/safe_commit.py +38 -9
- package/dist/templates/trellis/scripts/common/task_planning.py +137 -0
- package/dist/templates/trellis/scripts/common/task_store.py +46 -14
- package/dist/templates/trellis/scripts/common/workflow_phase.py +4 -3
- package/dist/templates/trellis/scripts/subnode_artifact.py +470 -21
- package/dist/templates/trellis/scripts/task.py +175 -2
- package/dist/templates/trellis/workflow.md +407 -388
- package/dist/utils/managed-paths.d.ts +2 -0
- package/dist/utils/managed-paths.d.ts.map +1 -1
- package/dist/utils/managed-paths.js +24 -2
- package/dist/utils/managed-paths.js.map +1 -1
- package/dist/utils/template-fetcher.d.ts +1 -0
- package/dist/utils/template-fetcher.d.ts.map +1 -1
- package/dist/utils/template-fetcher.js +2 -1
- package/dist/utils/template-fetcher.js.map +1 -1
- package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
- package/dist/utils/uninstall-scrubbers.js +5 -0
- package/dist/utils/uninstall-scrubbers.js.map +1 -1
- package/dist/utils/workflow-provenance.d.ts +24 -0
- package/dist/utils/workflow-provenance.d.ts.map +1 -0
- package/dist/utils/workflow-provenance.js +71 -0
- package/dist/utils/workflow-provenance.js.map +1 -0
- package/dist/utils/workflow-resolver.d.ts +9 -2
- package/dist/utils/workflow-resolver.d.ts.map +1 -1
- package/dist/utils/workflow-resolver.js +33 -7
- package/dist/utils/workflow-resolver.js.map +1 -1
- package/package.json +2 -2
- 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 =
|
|
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[
|
|
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[
|
|
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
|
-
|
|
414
|
+
_require_text(item.get("locator"), f"report.evidence[{index}].locator")
|
|
317
415
|
_require_text(item.get("summary"), f"report.evidence[{index}].summary")
|
|
318
|
-
|
|
319
|
-
if identity in identities:
|
|
416
|
+
if evidence_id in identities:
|
|
320
417
|
_fail("report.evidence contains a duplicate evidence identity")
|
|
321
|
-
identities.add(
|
|
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[
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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[
|
|
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
|
-
|
|
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=
|
|
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
|
|