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