dos-kernel 0.22.0__py3-none-win_amd64.whl
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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
dos/preflight.py
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"""One-shot preflight bundler for `/fanout-true-headless-multi-agent`.
|
|
2
|
+
|
|
3
|
+
Replaces Steps 1.5 + 1.6 + 1.6.5 + 1.7 + 1.8 of the SKILL — four separate
|
|
4
|
+
Bash subcommands and ~5 paragraphs of prose decision logic — with one Bash
|
|
5
|
+
call returning a compact JSON blob the orchestrator branches on.
|
|
6
|
+
|
|
7
|
+
Audit motivating this (session 8ac5898a, 2026-05-19): the fanout SKILL.md
|
|
8
|
+
was 819 lines and the orchestrator was burning context re-deriving the same
|
|
9
|
+
preflight verdicts (packet staleness, in-flight collision, wave grouping,
|
|
10
|
+
register conflict gate) that already-shipped helpers
|
|
11
|
+
(`check_phase_shipped.py`, `fanout_state.py`, `fanout_archive_lock.py`)
|
|
12
|
+
produce mechanically. The skill described their behavior in prose rather
|
|
13
|
+
than just calling them.
|
|
14
|
+
|
|
15
|
+
Full audit + recipe: docs/_audits/skill-context-bundling-2026-05-11.md
|
|
16
|
+
docs/_audits/fanout-context-audit-2026-05-19.md (this audit).
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python scripts/fanout_preflight_context.py <packet-path>
|
|
20
|
+
python scripts/fanout_preflight_context.py <packet-path> --pretty
|
|
21
|
+
|
|
22
|
+
Output schema (top-level keys):
|
|
23
|
+
schema_version int — bump on breaking change
|
|
24
|
+
generated_at str — ISO-8601 UTC
|
|
25
|
+
packet dict — {path, last_sha, drift_commits, schema,
|
|
26
|
+
packet_schema, expected_packet_schema,
|
|
27
|
+
schema_drift, schema_drift_reason}
|
|
28
|
+
schema_drift=True (OC4) means the packet's
|
|
29
|
+
header schema token is absent/mismatched —
|
|
30
|
+
the orchestrator must NOT silently launch.
|
|
31
|
+
picks list — [{n, plan, phase, phase_chain, gates_on,
|
|
32
|
+
files (truncated), prompt_text_len,
|
|
33
|
+
shipped, in_flight_collision, verdict,
|
|
34
|
+
drop_reason}, ...]
|
|
35
|
+
verdict ∈ {go, shipped, collision,
|
|
36
|
+
unknown}; drop_reason set when
|
|
37
|
+
verdict != go.
|
|
38
|
+
waves list — [[pick_n, ...], ...] partition by gates_on
|
|
39
|
+
drop_list list — verdict != go picks, with reason
|
|
40
|
+
live_count int — count of go-verdict picks
|
|
41
|
+
dirty_tree dict — {start_sha, modified_files, untracked_count,
|
|
42
|
+
truncated_at}
|
|
43
|
+
archive_lock dict — {state, prev_owner?, prev_age_s?}
|
|
44
|
+
in_flight_overlap_phases list — phase-ids in_flight (filtered to picks)
|
|
45
|
+
|
|
46
|
+
The helper is read-only — never mutates files or registry. The SKILL still
|
|
47
|
+
calls `fanout_state.py register` (write) at Step 1.8 after consuming this.
|
|
48
|
+
"""
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import argparse
|
|
52
|
+
import datetime as dt
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import subprocess
|
|
57
|
+
import sys
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
from dos import config as _config
|
|
61
|
+
from dos.packet_sidecar import SIDECAR_SCHEMA
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _workspace_root() -> Path:
|
|
65
|
+
"""The served workspace (where git, plan docs, and run-dirs live).
|
|
66
|
+
|
|
67
|
+
Honors `DISPATCH_WORKSPACE` for a test/fixture redirect, then the active
|
|
68
|
+
config. (The job preflight test repoints the workspace at a tmp dir; under
|
|
69
|
+
the separation refactor it does so via this env var instead of monkeypatching
|
|
70
|
+
a module constant that no longer exists.)
|
|
71
|
+
"""
|
|
72
|
+
env = os.environ.get("DISPATCH_WORKSPACE")
|
|
73
|
+
if env:
|
|
74
|
+
return Path(env)
|
|
75
|
+
return _config.active().paths.root
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _next_up_dir() -> Path:
|
|
79
|
+
env = os.environ.get("DISPATCH_NEXT_UP_DIR")
|
|
80
|
+
if env:
|
|
81
|
+
return Path(env)
|
|
82
|
+
return _config.active().paths.next_packets
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
SCHEMA_VERSION = 2 # +verdict_envelope / refuse (FQ-410)
|
|
86
|
+
DIRTY_TREE_CAP = 50 # don't bloat output with a 5k-file untracked list
|
|
87
|
+
FILES_CAP_PER_PICK = 20 # paths > 20 truncated; full list lives in sidecar
|
|
88
|
+
|
|
89
|
+
# The launchable-verdict set + the envelope-refusal judgement now live in ONE
|
|
90
|
+
# place, `wedge_reason` (`LAUNCHABLE_VERDICTS` / `envelope_is_refusal`), shared
|
|
91
|
+
# with `decisions`; `_envelope_refusal` below delegates there.
|
|
92
|
+
|
|
93
|
+
# OC4 (2026-05-19) — the packet-schema token the /next-up renderer
|
|
94
|
+
# (`scripts/next_up_render.py:PACKET_SCHEMA`) stamps in the packet's markdown
|
|
95
|
+
# header. This preflight reads the packet's marker and compares: a missing or
|
|
96
|
+
# mismatched token means the /next-up that wrote the packet is out of contract
|
|
97
|
+
# with this /fanout, so the orchestrator must NOT silently launch against a
|
|
98
|
+
# drifted packet. Keep this in lockstep with the renderer's constant — the two
|
|
99
|
+
# being equal IS the handoff contract. ⚓ feedback_mechanical_contract_over_prose.
|
|
100
|
+
EXPECTED_PACKET_SCHEMA = "next-up-packet-v1"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _feature_flags_view() -> dict:
|
|
104
|
+
"""Surface the operator-mutable dispatch feature flags relevant to /fanout.
|
|
105
|
+
|
|
106
|
+
Reads via the canonical accessors in `next_up_context` (the same read-path
|
|
107
|
+
/next-up uses) so there is ONE loader for execution-state.yaml's
|
|
108
|
+
`feature_flags:` block — no second parser here. Returns the resolved model
|
|
109
|
+
for the `fanout.child` grandchild section (env+yaml honored) plus the raw
|
|
110
|
+
overrides map, so the orchestrator can pass the right `--model` at the
|
|
111
|
+
grandchild launch step without importing the registry itself. Defensive:
|
|
112
|
+
any import/resolution failure degrades to an empty/quiet view rather than
|
|
113
|
+
breaking the whole preflight bundle.
|
|
114
|
+
"""
|
|
115
|
+
view: dict[str, object] = {}
|
|
116
|
+
try:
|
|
117
|
+
sys.path.insert(0, str(_workspace_root() / "scripts"))
|
|
118
|
+
import next_up_context as _nuc # noqa: E402
|
|
119
|
+
view["lane_leasing"] = _nuc.lane_leasing_enabled()
|
|
120
|
+
view["focus_auto"] = _nuc.focus_auto_enabled()
|
|
121
|
+
view["model_overrides"] = _nuc.feature_flags().get("models") or {}
|
|
122
|
+
except Exception:
|
|
123
|
+
view.setdefault("model_overrides", {})
|
|
124
|
+
try:
|
|
125
|
+
import model_registry as _mr # noqa: E402
|
|
126
|
+
view["fanout_child_model"] = _mr.resolve_model("fanout.child")
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
return view
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run(cmd: list[str], *, timeout: int = 30) -> tuple[int, str, str]:
|
|
133
|
+
"""Run a subprocess, return (exit, stdout, stderr). Never raises."""
|
|
134
|
+
try:
|
|
135
|
+
p = subprocess.run(
|
|
136
|
+
cmd,
|
|
137
|
+
cwd=str(_workspace_root()),
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
encoding="utf-8",
|
|
141
|
+
errors="replace",
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
)
|
|
144
|
+
return p.returncode, p.stdout, p.stderr
|
|
145
|
+
except FileNotFoundError as e:
|
|
146
|
+
return 127, "", f"FileNotFoundError: {e}"
|
|
147
|
+
except subprocess.TimeoutExpired:
|
|
148
|
+
return 124, "", f"timeout after {timeout}s"
|
|
149
|
+
except Exception as e: # pragma: no cover — defensive
|
|
150
|
+
return 1, "", f"{type(e).__name__}: {e}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _python() -> str:
|
|
154
|
+
"""Return the venv python interpreter, with PowerShell-style fallback."""
|
|
155
|
+
root = _workspace_root()
|
|
156
|
+
candidates = [
|
|
157
|
+
root / ".venv" / "Scripts" / "python.exe",
|
|
158
|
+
root / ".venv" / "bin" / "python",
|
|
159
|
+
]
|
|
160
|
+
for c in candidates:
|
|
161
|
+
if c.exists():
|
|
162
|
+
return str(c)
|
|
163
|
+
return sys.executable # fall back to the running interpreter
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# The three states the `.prompts.json` prompt sidecar can be in, reported on
|
|
167
|
+
# the loader's `sidecar_status` field. FQ-420: the markdown fallback used to
|
|
168
|
+
# collapse `absent` and `corrupt` into a bare `source="markdown"`, hiding the
|
|
169
|
+
# fact that the renderer DROPPED the prompt bodies — the operator saw only the
|
|
170
|
+
# downstream symptom (`body_empty_picks`), never the root cause. Naming the
|
|
171
|
+
# status lets the refuse gate point straight at the dropped sidecar.
|
|
172
|
+
SIDECAR_PRESENT = "present" # `.prompts.json` existed and parsed — prompts loaded
|
|
173
|
+
SIDECAR_ABSENT = "absent" # `.prompts.json` did not exist — renderer never wrote it
|
|
174
|
+
SIDECAR_CORRUPT = "corrupt" # `.prompts.json` existed but was unreadable / bad JSON
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_packet_sidecar(packet_path: Path) -> dict:
|
|
178
|
+
"""Prefer the `.prompts.json` sidecar over markdown parsing.
|
|
179
|
+
|
|
180
|
+
Returns {schema, picks, source, sidecar_path, sidecar_status}. `source` is
|
|
181
|
+
'sidecar' (prompts loaded from the sidecar), 'markdown' (fell back to header
|
|
182
|
+
parsing), or 'missing' (the packet itself was unreadable). `sidecar_status`
|
|
183
|
+
is the FQ-420 distinguisher — one of SIDECAR_PRESENT / SIDECAR_ABSENT /
|
|
184
|
+
SIDECAR_CORRUPT — so a caller can tell a dropped sidecar (the renderer never
|
|
185
|
+
emitted the prompt bodies) apart from a corrupt one, and from a clean
|
|
186
|
+
markdown packet that genuinely has no sidecar. The skill's Step 2 already
|
|
187
|
+
prefers the sidecar; this preflight does the same.
|
|
188
|
+
"""
|
|
189
|
+
sidecar = packet_path.with_name(packet_path.stem + ".prompts.json")
|
|
190
|
+
sidecar_status = SIDECAR_ABSENT
|
|
191
|
+
if sidecar.exists():
|
|
192
|
+
try:
|
|
193
|
+
with open(sidecar, encoding="utf-8") as f:
|
|
194
|
+
d = json.load(f)
|
|
195
|
+
# Repo-relative for display when under the workspace; fall back to
|
|
196
|
+
# the absolute path otherwise (a sidecar outside the workspace — e.g.
|
|
197
|
+
# a tmp_path fixture — must not crash the loader, the same guard
|
|
198
|
+
# `packet_freshness` applies to the packet path).
|
|
199
|
+
try:
|
|
200
|
+
disp_sidecar = str(sidecar.relative_to(_workspace_root())).replace(os.sep, "/")
|
|
201
|
+
except ValueError:
|
|
202
|
+
disp_sidecar = str(sidecar).replace(os.sep, "/")
|
|
203
|
+
return {
|
|
204
|
+
"schema": d.get("schema", SIDECAR_SCHEMA),
|
|
205
|
+
"picks": d.get("picks", []),
|
|
206
|
+
"source": "sidecar",
|
|
207
|
+
"sidecar_path": disp_sidecar,
|
|
208
|
+
"sidecar_status": SIDECAR_PRESENT,
|
|
209
|
+
}
|
|
210
|
+
except (OSError, json.JSONDecodeError):
|
|
211
|
+
# The sidecar is on disk but unreadable — a corrupt/half-written
|
|
212
|
+
# drop, distinct from one that was never written. Record CORRUPT so
|
|
213
|
+
# the refuse gate names it precisely, then fall through to markdown.
|
|
214
|
+
sidecar_status = SIDECAR_CORRUPT
|
|
215
|
+
# Markdown fallback: parse `### N. <PLAN> <PHASE> — <title>` headers.
|
|
216
|
+
# Conservative — we do not extract prompt_text from markdown here; if the
|
|
217
|
+
# sidecar is missing, the SKILL falls back to its existing markdown path.
|
|
218
|
+
# The picks produced here have empty bodies (`prompt_text=""`, `files=[]`);
|
|
219
|
+
# when the packet DID render picks, that empties them downstream — which is
|
|
220
|
+
# exactly why `sidecar_status` is carried out, so the refuse gate can blame
|
|
221
|
+
# the dropped sidecar rather than the (symptomatically) empty picks.
|
|
222
|
+
picks: list[dict] = []
|
|
223
|
+
try:
|
|
224
|
+
text = packet_path.read_text(encoding="utf-8")
|
|
225
|
+
except OSError:
|
|
226
|
+
return {
|
|
227
|
+
"schema": "unknown", "picks": [], "source": "missing",
|
|
228
|
+
"sidecar_path": None, "sidecar_status": sidecar_status,
|
|
229
|
+
}
|
|
230
|
+
header_re = re.compile(r"^###\s+(\d+)\.\s+([A-Z][A-Za-z0-9]*)\s+(\S+)\s+—\s+(.+)$", re.MULTILINE)
|
|
231
|
+
for m in header_re.finditer(text):
|
|
232
|
+
n, plan, phase, title = m.groups()
|
|
233
|
+
picks.append({
|
|
234
|
+
"n": int(n),
|
|
235
|
+
"plan_id": plan,
|
|
236
|
+
"phase_id": phase,
|
|
237
|
+
"phase_title": title.strip(),
|
|
238
|
+
"phase_chain": [phase],
|
|
239
|
+
"doc_path": None,
|
|
240
|
+
"files": [],
|
|
241
|
+
"reserve_paths": [],
|
|
242
|
+
"gates_on": [],
|
|
243
|
+
"prompt_text": "",
|
|
244
|
+
})
|
|
245
|
+
return {
|
|
246
|
+
"schema": "markdown-fallback",
|
|
247
|
+
"picks": picks,
|
|
248
|
+
"source": "markdown",
|
|
249
|
+
"sidecar_path": None,
|
|
250
|
+
"sidecar_status": sidecar_status,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def packet_freshness(packet_path: Path) -> dict:
|
|
255
|
+
"""Read 'Last commit: `<sha>`' + 'Packet schema: `<token>`' header lines.
|
|
256
|
+
|
|
257
|
+
The `Last commit` sha drives the drift-count diff against HEAD. The OC4
|
|
258
|
+
`Packet schema` token drives the handoff-contract check: a missing or
|
|
259
|
+
mismatched token is reported as `schema_drift: true` so the orchestrator
|
|
260
|
+
can refuse to launch on a packet whose /next-up is out of contract — the
|
|
261
|
+
OC-P4 additive-silent failure mode made loud.
|
|
262
|
+
"""
|
|
263
|
+
last_sha = None
|
|
264
|
+
packet_schema: str | None = None
|
|
265
|
+
try:
|
|
266
|
+
with open(packet_path, encoding="utf-8") as f:
|
|
267
|
+
for i, line in enumerate(f):
|
|
268
|
+
if last_sha is None:
|
|
269
|
+
m = re.search(r"Last commit:\s*`([0-9a-f]{7,40})`", line)
|
|
270
|
+
if m:
|
|
271
|
+
last_sha = m.group(1)
|
|
272
|
+
if packet_schema is None:
|
|
273
|
+
sm = re.search(r"Packet schema:\s*`([^`]+)`", line)
|
|
274
|
+
if sm:
|
|
275
|
+
packet_schema = sm.group(1).strip()
|
|
276
|
+
if last_sha is not None and packet_schema is not None:
|
|
277
|
+
break
|
|
278
|
+
if i > 20: # bounded header scan — don't read the whole packet
|
|
279
|
+
break
|
|
280
|
+
except OSError:
|
|
281
|
+
pass
|
|
282
|
+
# OC4 handoff-contract check. A pre-OC4 packet carries no `Packet schema`
|
|
283
|
+
# line (packet_schema is None) → drift with a "pre-v1" reason; a packet
|
|
284
|
+
# whose token differs from EXPECTED_PACKET_SCHEMA → drift with a mismatch
|
|
285
|
+
# reason. Both block a silent launch.
|
|
286
|
+
if packet_schema is None:
|
|
287
|
+
schema_drift = True
|
|
288
|
+
schema_drift_reason = (
|
|
289
|
+
"packet has no `Packet schema` marker — written by a pre-OC4 "
|
|
290
|
+
"/next-up; re-run /next-up for a versioned packet"
|
|
291
|
+
)
|
|
292
|
+
elif packet_schema != EXPECTED_PACKET_SCHEMA:
|
|
293
|
+
schema_drift = True
|
|
294
|
+
schema_drift_reason = (
|
|
295
|
+
f"packet schema {packet_schema!r} != expected "
|
|
296
|
+
f"{EXPECTED_PACKET_SCHEMA!r} — the /next-up that wrote this packet "
|
|
297
|
+
f"is out of contract with this /fanout"
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
schema_drift = False
|
|
301
|
+
schema_drift_reason = None
|
|
302
|
+
drift = 0
|
|
303
|
+
drift_commits: list[str] = []
|
|
304
|
+
if last_sha:
|
|
305
|
+
rc, out, _ = _run(["git", "log", "--oneline", f"{last_sha}..HEAD"], timeout=15)
|
|
306
|
+
if rc == 0:
|
|
307
|
+
all_drift = [l for l in out.splitlines() if l.strip()]
|
|
308
|
+
drift = len(all_drift)
|
|
309
|
+
# Keep just enough for the SKILL to spot if a recent commit on
|
|
310
|
+
# main shipped something the picks gate on. 10 is plenty.
|
|
311
|
+
drift_commits = all_drift[:10]
|
|
312
|
+
# Repo-relative path for display when the packet lives under the repo;
|
|
313
|
+
# fall back to the absolute path otherwise (a packet outside REPO_ROOT —
|
|
314
|
+
# e.g. a tmp_path fixture — must not crash the read).
|
|
315
|
+
if packet_path.is_absolute():
|
|
316
|
+
try:
|
|
317
|
+
disp_path = str(packet_path.relative_to(_workspace_root())).replace(os.sep, "/")
|
|
318
|
+
except ValueError:
|
|
319
|
+
disp_path = str(packet_path).replace(os.sep, "/")
|
|
320
|
+
else:
|
|
321
|
+
disp_path = str(packet_path)
|
|
322
|
+
return {
|
|
323
|
+
"path": disp_path,
|
|
324
|
+
"last_sha": last_sha,
|
|
325
|
+
"drift_commits": drift_commits,
|
|
326
|
+
"drift_count": drift,
|
|
327
|
+
"packet_schema": packet_schema,
|
|
328
|
+
"expected_packet_schema": EXPECTED_PACKET_SCHEMA,
|
|
329
|
+
"schema_drift": schema_drift,
|
|
330
|
+
"schema_drift_reason": schema_drift_reason,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def packet_shipped_verdict(packet_path: Path) -> dict:
|
|
335
|
+
"""Run `check_phase_shipped.py --check-packet` and parse its table.
|
|
336
|
+
|
|
337
|
+
Output we care about: per-pick verdict (KEEP / DROP) and cited sha if any.
|
|
338
|
+
Exit codes (--check-packet): 0=any shipped, 1=all clean, 2=no coverage,
|
|
339
|
+
3=parse error.
|
|
340
|
+
"""
|
|
341
|
+
rc, out, err = _run(
|
|
342
|
+
[_python(), "-m", "dos.phase_shipped", "--check-packet", str(packet_path)],
|
|
343
|
+
timeout=60,
|
|
344
|
+
)
|
|
345
|
+
# Parse " DROP <SERIES> <PHASE> shipped in <sha>" / " KEEP <SERIES> <PHASE>"
|
|
346
|
+
by_phase: dict[str, dict] = {}
|
|
347
|
+
line_re = re.compile(r"^\s*(KEEP|DROP)\s+([A-Z][A-Za-z0-9]*)\s+(\S+)\s*(?:shipped in\s+([0-9a-f]+))?\s*$")
|
|
348
|
+
for line in out.splitlines():
|
|
349
|
+
m = line_re.match(line)
|
|
350
|
+
if m:
|
|
351
|
+
verdict, plan, phase, sha = m.groups()
|
|
352
|
+
by_phase[f"{plan}/{phase}"] = {
|
|
353
|
+
"verdict": verdict,
|
|
354
|
+
"shipped_sha": sha,
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
"exit_code": rc,
|
|
358
|
+
"by_phase": by_phase,
|
|
359
|
+
"raw_tail": "\n".join(out.splitlines()[-10:]) if out else "",
|
|
360
|
+
"stderr_tail": "\n".join((err or "").splitlines()[-5:]),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# claim_status values that mean the claim is no longer a live block on its
|
|
365
|
+
# (plan, phase) — mirrors next_up_context._DEAD_CLAIM_STATUSES on the job side.
|
|
366
|
+
# A row carrying one of these is terminal even if its legacy `status` field
|
|
367
|
+
# still says in_progress, so it must not count as an in-flight overlap.
|
|
368
|
+
_TERMINAL_CLAIM_STATUSES = frozenset({"done", "stale", "released", "expired"})
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def list_active_filtered(
|
|
372
|
+
pick_phases: set[str], own_packet_basename: str | None = None
|
|
373
|
+
) -> tuple[list[dict], list[str]]:
|
|
374
|
+
"""Call fanout_state list-active and filter to entries overlapping picks.
|
|
375
|
+
|
|
376
|
+
Returns (filtered_rows, overlapping_phase_ids). Full output is ~50KB;
|
|
377
|
+
filtered output is typically <2KB.
|
|
378
|
+
|
|
379
|
+
own_packet_basename: when provided, soft-claim rows whose dispatched_by
|
|
380
|
+
matches this packet are excluded — they are this packet's own freshly-
|
|
381
|
+
written soft-claims, not in-flight work from another packet. Closes the
|
|
382
|
+
self-collision recurrence (#4) where /next-up's pre-write of soft-claims
|
|
383
|
+
wedges the very /fanout it hands off to.
|
|
384
|
+
"""
|
|
385
|
+
rc, out, _ = _run(
|
|
386
|
+
[_python(), "scripts/fanout_state.py", "list-active", "--json"],
|
|
387
|
+
timeout=30,
|
|
388
|
+
)
|
|
389
|
+
if rc != 0 or not out.strip():
|
|
390
|
+
return [], []
|
|
391
|
+
try:
|
|
392
|
+
rows = json.loads(out)
|
|
393
|
+
except json.JSONDecodeError:
|
|
394
|
+
return [], []
|
|
395
|
+
overlap_phases: list[str] = []
|
|
396
|
+
filtered: list[dict] = []
|
|
397
|
+
for r in rows:
|
|
398
|
+
if r.get("status") not in ("in_progress", "stalled", "open"):
|
|
399
|
+
continue
|
|
400
|
+
# FQ-336 (2026-06-05): a claim whose claim_status is terminal
|
|
401
|
+
# (done/stale/released/expired) is NOT a live block on its phase, even
|
|
402
|
+
# while its legacy `status` field still lags at in_progress (the writer
|
|
403
|
+
# flipped claim_status but not status; the terminal-active-work sweep
|
|
404
|
+
# only drains it after a 14-day grace window). Treating it as an in-flight
|
|
405
|
+
# overlap would re-block a phase the /next-up picker already correctly
|
|
406
|
+
# freed (next_up_context._trim_active_work drops the same rows for the
|
|
407
|
+
# picker bundle) — the false-collision twin of the picker false-DRAIN.
|
|
408
|
+
if str(r.get("claim_status") or "").strip().lower() in _TERMINAL_CLAIM_STATUSES:
|
|
409
|
+
continue
|
|
410
|
+
plan = r.get("plan") or ""
|
|
411
|
+
phase = r.get("phase") or ""
|
|
412
|
+
key = f"{plan}/{phase}"
|
|
413
|
+
if key in pick_phases or phase in pick_phases:
|
|
414
|
+
dispatched_by = r.get("dispatched_by") or ""
|
|
415
|
+
if own_packet_basename and dispatched_by == own_packet_basename:
|
|
416
|
+
continue
|
|
417
|
+
overlap_phases.append(phase)
|
|
418
|
+
filtered.append({
|
|
419
|
+
"id": r.get("id"),
|
|
420
|
+
"plan": plan,
|
|
421
|
+
"phase": phase,
|
|
422
|
+
"title": (r.get("title") or "")[:120],
|
|
423
|
+
"dispatched_by": dispatched_by,
|
|
424
|
+
"claim_kind": r.get("claim_kind"),
|
|
425
|
+
"claim_status": r.get("claim_status"),
|
|
426
|
+
"dispatched_at": r.get("dispatched_at"),
|
|
427
|
+
})
|
|
428
|
+
return filtered, overlap_phases
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def dirty_tree_state() -> dict:
|
|
432
|
+
"""Snapshot current working tree state — start_sha + bounded mod/untracked list."""
|
|
433
|
+
rc_sha, sha_out, _ = _run(["git", "rev-parse", "HEAD"], timeout=10)
|
|
434
|
+
start_sha = sha_out.strip()[:12] if rc_sha == 0 else None
|
|
435
|
+
rc_st, st_out, _ = _run(["git", "status", "--short"], timeout=10)
|
|
436
|
+
modified: list[str] = []
|
|
437
|
+
untracked: list[str] = []
|
|
438
|
+
truncated = False
|
|
439
|
+
if rc_st == 0:
|
|
440
|
+
for line in st_out.splitlines():
|
|
441
|
+
if not line.strip():
|
|
442
|
+
continue
|
|
443
|
+
tag = line[:2]
|
|
444
|
+
path = line[3:].strip()
|
|
445
|
+
if tag.startswith("??"):
|
|
446
|
+
untracked.append(path)
|
|
447
|
+
else:
|
|
448
|
+
modified.append(f"{tag} {path}")
|
|
449
|
+
total = len(modified) + len(untracked)
|
|
450
|
+
if total > DIRTY_TREE_CAP:
|
|
451
|
+
truncated = True
|
|
452
|
+
keep = max(1, DIRTY_TREE_CAP // 2)
|
|
453
|
+
modified = modified[:keep]
|
|
454
|
+
untracked = untracked[:DIRTY_TREE_CAP - len(modified)]
|
|
455
|
+
return {
|
|
456
|
+
"start_sha": start_sha,
|
|
457
|
+
"modified": modified,
|
|
458
|
+
"untracked": untracked,
|
|
459
|
+
"untracked_count_full": len([l for l in st_out.splitlines() if l.startswith("??")]) if rc_st == 0 else 0,
|
|
460
|
+
"truncated_at": DIRTY_TREE_CAP if truncated else None,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def archive_lock_state() -> dict:
|
|
465
|
+
"""Probe the Step 9.5 mutex's current state without acquiring it."""
|
|
466
|
+
rc, out, _ = _run(
|
|
467
|
+
[_python(), "-m", "dos.archive_lock", "status"],
|
|
468
|
+
timeout=10,
|
|
469
|
+
)
|
|
470
|
+
state = (out or "").strip().splitlines()[0] if out.strip() else ""
|
|
471
|
+
if state == "free":
|
|
472
|
+
return {"state": "free"}
|
|
473
|
+
# Held shapes: "held <owner> age=<s>s" / "held-stale <owner> age=<s>s"
|
|
474
|
+
m = re.match(r"^(held|held-stale)\s+(\S+)(?:\s+age=(\d+)s)?", state)
|
|
475
|
+
if m:
|
|
476
|
+
return {
|
|
477
|
+
"state": m.group(1),
|
|
478
|
+
"prev_owner": m.group(2),
|
|
479
|
+
"prev_age_s": int(m.group(3)) if m.group(3) else None,
|
|
480
|
+
}
|
|
481
|
+
return {"state": state or "unknown", "raw": out[:200] if out else ""}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def partition_waves(picks_with_verdict: list[dict]) -> list[list[int]]:
|
|
485
|
+
"""Partition live (go-verdict) picks into launch waves by gates_on.
|
|
486
|
+
|
|
487
|
+
Returns [[pick_n, ...], ...] — wave 1 = roots (gates_on empty),
|
|
488
|
+
wave 2 = picks whose gates_on ⊆ wave-1 phases, etc.
|
|
489
|
+
"""
|
|
490
|
+
live = [p for p in picks_with_verdict if p.get("verdict") == "go"]
|
|
491
|
+
if not live:
|
|
492
|
+
return []
|
|
493
|
+
placed: set[str] = set() # phase ids already in a wave
|
|
494
|
+
waves: list[list[int]] = []
|
|
495
|
+
remaining = list(live)
|
|
496
|
+
safety = 10 # cap on wave count — packets >10 waves are pathological
|
|
497
|
+
by_n = {p["n"]: p for p in live}
|
|
498
|
+
while remaining and safety > 0:
|
|
499
|
+
safety -= 1
|
|
500
|
+
wave_ns: list[int] = []
|
|
501
|
+
next_remaining: list[dict] = []
|
|
502
|
+
for p in remaining:
|
|
503
|
+
gates = [g for g in (p.get("gates_on") or []) if g]
|
|
504
|
+
if all(g in placed for g in gates):
|
|
505
|
+
wave_ns.append(p["n"])
|
|
506
|
+
else:
|
|
507
|
+
next_remaining.append(p)
|
|
508
|
+
if not wave_ns:
|
|
509
|
+
# cycle or dangling gate — bail; orchestrator will see remainder
|
|
510
|
+
# in drop_list (the SKILL's existing dangling-edge rule applies).
|
|
511
|
+
break
|
|
512
|
+
for n in wave_ns:
|
|
513
|
+
placed.add(by_n[n]["phase"])
|
|
514
|
+
waves.append(wave_ns)
|
|
515
|
+
remaining = next_remaining
|
|
516
|
+
return waves
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def merge_picks_with_verdicts(
|
|
520
|
+
sidecar_picks: list[dict],
|
|
521
|
+
shipped: dict,
|
|
522
|
+
in_flight_phases: list[str],
|
|
523
|
+
) -> tuple[list[dict], list[dict]]:
|
|
524
|
+
"""Produce the merged picks list + drop_list."""
|
|
525
|
+
picks_out: list[dict] = []
|
|
526
|
+
drops: list[dict] = []
|
|
527
|
+
in_flight_set = set(in_flight_phases)
|
|
528
|
+
shipped_by_phase = shipped.get("by_phase", {})
|
|
529
|
+
for p in sidecar_picks:
|
|
530
|
+
plan = p.get("plan_id") or ""
|
|
531
|
+
phase = p.get("phase_id") or ""
|
|
532
|
+
key = f"{plan}/{phase}"
|
|
533
|
+
files = p.get("files") or []
|
|
534
|
+
files_full_count = len(files)
|
|
535
|
+
if files_full_count > FILES_CAP_PER_PICK:
|
|
536
|
+
files = files[:FILES_CAP_PER_PICK] + [f"… ({files_full_count - FILES_CAP_PER_PICK} more — see sidecar)"]
|
|
537
|
+
|
|
538
|
+
shipped_entry = shipped_by_phase.get(key)
|
|
539
|
+
verdict = "go"
|
|
540
|
+
drop_reason = None
|
|
541
|
+
if shipped_entry and shipped_entry.get("verdict") == "DROP":
|
|
542
|
+
verdict = "shipped"
|
|
543
|
+
drop_reason = f"shipped in {shipped_entry.get('shipped_sha') or '?'}"
|
|
544
|
+
elif phase in in_flight_set:
|
|
545
|
+
verdict = "collision"
|
|
546
|
+
drop_reason = "in-flight in registry (overlap detected)"
|
|
547
|
+
|
|
548
|
+
# OC4 anchor #4 — carry the pick's kind so the fanout/dispatch overlap
|
|
549
|
+
# consumers can branch the same way the renderer's _matrix does: a
|
|
550
|
+
# synthetic findings pick's `files` are routing pointers, not a
|
|
551
|
+
# code-touch footprint. Default `code`; infer `finding` from an explicit
|
|
552
|
+
# field or the FQ- phase-id convention.
|
|
553
|
+
pick_kind = str(p.get("pick_kind") or "").strip().lower()
|
|
554
|
+
if pick_kind not in ("code", "finding"):
|
|
555
|
+
pick_kind = "finding" if (phase.upper().startswith("FQ-") or p.get("is_synthetic")) else "code"
|
|
556
|
+
|
|
557
|
+
row = {
|
|
558
|
+
"n": p.get("n"),
|
|
559
|
+
"plan": plan,
|
|
560
|
+
"phase": phase,
|
|
561
|
+
"phase_chain": p.get("phase_chain") or [phase],
|
|
562
|
+
"phase_title": p.get("phase_title") or "",
|
|
563
|
+
"gates_on": p.get("gates_on") or [],
|
|
564
|
+
"files": files,
|
|
565
|
+
"files_full_count": files_full_count,
|
|
566
|
+
"doc_path": p.get("doc_path"),
|
|
567
|
+
"subagent_type": p.get("subagent_type"),
|
|
568
|
+
"mode": p.get("mode"),
|
|
569
|
+
"pick_kind": pick_kind,
|
|
570
|
+
"prompt_text_len": len(p.get("prompt_text") or ""),
|
|
571
|
+
"verdict": verdict,
|
|
572
|
+
}
|
|
573
|
+
if drop_reason:
|
|
574
|
+
row["drop_reason"] = drop_reason
|
|
575
|
+
drops.append({"n": row["n"], "plan": plan, "phase": phase, "reason": drop_reason})
|
|
576
|
+
picks_out.append(row)
|
|
577
|
+
return picks_out, drops
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def read_verdict_envelope(tag: str) -> dict | None:
|
|
581
|
+
"""Read `output/next-up/.verdict-<tag>.json` if present (FQ-410).
|
|
582
|
+
|
|
583
|
+
The /next-up renderer / WEDGE-emitter writes this envelope for every run —
|
|
584
|
+
LIVE-shaped on a real packet, or `verdict=WEDGE|DRAIN`/`do_not_render` when
|
|
585
|
+
the lane was refused. The preflight was BLIND to it: a packet pre-routed
|
|
586
|
+
`verdict=WEDGE do_not_render=true` still scored `live_count=1 verdict=go`
|
|
587
|
+
for any non-shipped pick, so naively following the Step-1 outcome table
|
|
588
|
+
launched an Opus subprocess against a WEDGEd (often body-empty) packet
|
|
589
|
+
([[feedback_fanout_preflight_blind_to_verdict_envelope]]). Returns the
|
|
590
|
+
parsed dict, or None if the file is absent / unreadable / not an object.
|
|
591
|
+
"""
|
|
592
|
+
path = _next_up_dir() / f".verdict-{tag}.json"
|
|
593
|
+
try:
|
|
594
|
+
if not path.exists():
|
|
595
|
+
return None
|
|
596
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
597
|
+
except (OSError, json.JSONDecodeError):
|
|
598
|
+
return None
|
|
599
|
+
return data if isinstance(data, dict) else None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _envelope_refusal(envelope: dict | None) -> tuple[bool, str | None]:
|
|
603
|
+
"""Decide whether a verdict envelope means REFUSE (do not launch).
|
|
604
|
+
|
|
605
|
+
Thin wrapper over the canonical `wedge_reason.envelope_is_refusal` (the one
|
|
606
|
+
place the envelope-refusal shape is defined, shared with `decisions`); kept as a
|
|
607
|
+
named local so this module's callers read naturally. See that function for the
|
|
608
|
+
rung order.
|
|
609
|
+
"""
|
|
610
|
+
from dos import wedge_reason # noqa: PLC0415
|
|
611
|
+
return wedge_reason.envelope_is_refusal(envelope)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _body_empty_picks(picks: list[dict]) -> list[int]:
|
|
615
|
+
"""Pick-numbers whose body is empty (prompt_text_len 0 AND no files).
|
|
616
|
+
|
|
617
|
+
A body-empty pick (`prompt_text_len==0` and `files==[]`) is a refuse signal
|
|
618
|
+
independent of the verdict envelope — it is a scope-substituted / mis-rendered
|
|
619
|
+
pick with nothing for the subagent to do
|
|
620
|
+
([[feedback_fanout_preflight_blind_to_verdict_envelope]]). Reported so the
|
|
621
|
+
SKILL can refuse rather than launch a no-op Opus subprocess.
|
|
622
|
+
"""
|
|
623
|
+
out: list[int] = []
|
|
624
|
+
for p in picks:
|
|
625
|
+
text_len = p.get("prompt_text_len")
|
|
626
|
+
files = p.get("files") or []
|
|
627
|
+
if (text_len == 0 or text_len is None) and not files and p.get("verdict") == "go":
|
|
628
|
+
n = p.get("n")
|
|
629
|
+
if isinstance(n, int):
|
|
630
|
+
out.append(n)
|
|
631
|
+
return out
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _sidecar_dropped_refusal(
|
|
635
|
+
sidecar_status: str, rendered_pick_count: int
|
|
636
|
+
) -> tuple[bool, str | None]:
|
|
637
|
+
"""Decide whether a missing/corrupt `.prompts.json` sidecar means REFUSE.
|
|
638
|
+
|
|
639
|
+
FQ-420: when `/next-up` returns a packet that HAS picks but drops the prompt
|
|
640
|
+
sidecar, the markdown fallback rehydrates the picks with empty bodies, so
|
|
641
|
+
every `/fanout` refuses on `body_empty_picks` — but that names the symptom,
|
|
642
|
+
not the cause. This is the ROOT signal: a packet that rendered >= 1 pick but
|
|
643
|
+
whose sidecar is `absent` (renderer never wrote it) or `corrupt` (wrote a
|
|
644
|
+
broken one) is a renderer defect that blocks the whole dispatch path. Refuse
|
|
645
|
+
with a reason that points at the dropped sidecar so the operator (and the
|
|
646
|
+
`/unstick` cue → `BlockedReason.BODY_EMPTY_PICKS`) routes the fix at the
|
|
647
|
+
renderer, not the picks.
|
|
648
|
+
|
|
649
|
+
Does NOT refuse when:
|
|
650
|
+
* the sidecar was present (`SIDECAR_PRESENT`) — the normal path; or
|
|
651
|
+
* the packet rendered NO picks (`rendered_pick_count == 0`) — a genuine
|
|
652
|
+
empty DRAIN packet legitimately has no sidecar, and refusing it here
|
|
653
|
+
would mislabel a true drain as a renderer drop.
|
|
654
|
+
|
|
655
|
+
Returns `(refuse, reason)`; `reason` is a short machine-readable string in
|
|
656
|
+
the same shape as `_envelope_refusal`'s.
|
|
657
|
+
"""
|
|
658
|
+
if rendered_pick_count <= 0:
|
|
659
|
+
return (False, None)
|
|
660
|
+
if sidecar_status == SIDECAR_ABSENT:
|
|
661
|
+
return (
|
|
662
|
+
True,
|
|
663
|
+
f"sidecar_dropped:absent rendered_picks={rendered_pick_count} "
|
|
664
|
+
f"(/next-up returned picks but never wrote the .prompts.json prompt "
|
|
665
|
+
f"sidecar — every pick body is empty)",
|
|
666
|
+
)
|
|
667
|
+
if sidecar_status == SIDECAR_CORRUPT:
|
|
668
|
+
return (
|
|
669
|
+
True,
|
|
670
|
+
f"sidecar_dropped:corrupt rendered_picks={rendered_pick_count} "
|
|
671
|
+
f"(.prompts.json exists but is unreadable/bad-JSON — prompt bodies lost)",
|
|
672
|
+
)
|
|
673
|
+
return (False, None)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def build_context(packet_path: Path) -> dict:
|
|
677
|
+
sidecar = load_packet_sidecar(packet_path)
|
|
678
|
+
pick_phase_keys = {
|
|
679
|
+
f"{p.get('plan_id','')}/{p.get('phase_id','')}" for p in sidecar["picks"]
|
|
680
|
+
}
|
|
681
|
+
# Also build a phase-only set for the in-flight phase-id check (the
|
|
682
|
+
# registry's phase ids are unambiguous within a plan).
|
|
683
|
+
pick_phases = {p.get("phase_id", "") for p in sidecar["picks"]}
|
|
684
|
+
freshness = packet_freshness(packet_path)
|
|
685
|
+
shipped = packet_shipped_verdict(packet_path)
|
|
686
|
+
own_packet_basename = packet_path.stem
|
|
687
|
+
in_flight_rows, in_flight_overlap = list_active_filtered(
|
|
688
|
+
pick_phase_keys | pick_phases, own_packet_basename=own_packet_basename
|
|
689
|
+
)
|
|
690
|
+
picks_out, drops = merge_picks_with_verdicts(
|
|
691
|
+
sidecar["picks"], shipped, in_flight_overlap
|
|
692
|
+
)
|
|
693
|
+
waves = partition_waves(picks_out)
|
|
694
|
+
|
|
695
|
+
# FQ-410: read the verdict envelope for this packet's tag (the packet stem)
|
|
696
|
+
# and decide refusal. A WEDGE/DRAIN/do_not_render envelope means the lane was
|
|
697
|
+
# already routed to /replan — the orchestrator must NOT launch regardless of
|
|
698
|
+
# how many picks look live. A body-empty go-pick is a second, independent
|
|
699
|
+
# refuse signal. `refuse` is the single load-bearing bool the SKILL branches
|
|
700
|
+
# on; `refuse_reasons` lists each contributing cause for the operator log.
|
|
701
|
+
tag = packet_path.stem
|
|
702
|
+
verdict_envelope = read_verdict_envelope(tag)
|
|
703
|
+
env_refuse, env_reason = _envelope_refusal(verdict_envelope)
|
|
704
|
+
body_empty = _body_empty_picks(picks_out)
|
|
705
|
+
# FQ-420: a dropped/corrupt prompt sidecar on a packet that rendered picks is
|
|
706
|
+
# the ROOT refuse signal — listed BEFORE body_empty_picks so the operator
|
|
707
|
+
# reads the cause (sidecar gone) above the symptom (empty bodies). Keyed on
|
|
708
|
+
# the count of picks the packet RENDERED (len(sidecar["picks"])), not the
|
|
709
|
+
# merged go-count, so a packet that claimed work but lost its bodies still
|
|
710
|
+
# trips it even if every pick later reads as a drop.
|
|
711
|
+
sidecar_refuse, sidecar_reason = _sidecar_dropped_refusal(
|
|
712
|
+
sidecar.get("sidecar_status", SIDECAR_ABSENT), len(sidecar["picks"])
|
|
713
|
+
)
|
|
714
|
+
refuse_reasons: list[str] = []
|
|
715
|
+
if sidecar_refuse and sidecar_reason:
|
|
716
|
+
refuse_reasons.append(sidecar_reason)
|
|
717
|
+
if env_refuse and env_reason:
|
|
718
|
+
refuse_reasons.append(env_reason)
|
|
719
|
+
if body_empty:
|
|
720
|
+
refuse_reasons.append(
|
|
721
|
+
"body_empty_picks=" + ",".join(str(n) for n in body_empty)
|
|
722
|
+
)
|
|
723
|
+
return {
|
|
724
|
+
"schema_version": SCHEMA_VERSION,
|
|
725
|
+
"generated_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
726
|
+
"packet": {
|
|
727
|
+
"path": freshness["path"],
|
|
728
|
+
"last_sha": freshness["last_sha"],
|
|
729
|
+
"drift_count": freshness["drift_count"],
|
|
730
|
+
"drift_commits": freshness["drift_commits"],
|
|
731
|
+
"schema": sidecar["schema"],
|
|
732
|
+
"source": sidecar["source"],
|
|
733
|
+
"sidecar_path": sidecar["sidecar_path"],
|
|
734
|
+
# FQ-420 — the prompt-sidecar state (present/absent/corrupt). A
|
|
735
|
+
# non-`present` status on a packet with picks is the dropped-sidecar
|
|
736
|
+
# root cause behind a body_empty_picks refuse.
|
|
737
|
+
"sidecar_status": sidecar.get("sidecar_status", SIDECAR_ABSENT),
|
|
738
|
+
# OC4 handoff-contract check (distinct from `schema`, which is the
|
|
739
|
+
# *prompts sidecar* schema). `packet_schema` is the versioned token
|
|
740
|
+
# in the packet markdown header; `schema_drift` is the load-bearing
|
|
741
|
+
# gate the orchestrator branches on at Step 1. `.get` with a
|
|
742
|
+
# conservative default so a patched/legacy `packet_freshness` (e.g.
|
|
743
|
+
# an older test stub) still produces a valid bundle — absent keys
|
|
744
|
+
# default to "treat as drift" (the safe, non-launching outcome).
|
|
745
|
+
"packet_schema": freshness.get("packet_schema"),
|
|
746
|
+
"expected_packet_schema": freshness.get(
|
|
747
|
+
"expected_packet_schema", EXPECTED_PACKET_SCHEMA
|
|
748
|
+
),
|
|
749
|
+
"schema_drift": freshness.get("schema_drift", True),
|
|
750
|
+
"schema_drift_reason": freshness.get("schema_drift_reason"),
|
|
751
|
+
},
|
|
752
|
+
"picks": picks_out,
|
|
753
|
+
"waves": waves,
|
|
754
|
+
"drop_list": drops,
|
|
755
|
+
"live_count": sum(1 for p in picks_out if p.get("verdict") == "go"),
|
|
756
|
+
# FQ-410 — the verdict-envelope refusal gate. `refuse=True` means DO NOT
|
|
757
|
+
# LAUNCH even if live_count>0: the lane was pre-routed WEDGE/DRAIN, or a
|
|
758
|
+
# go-pick is body-empty. The orchestrator's Step-1 outcome table must
|
|
759
|
+
# check `refuse` BEFORE acting on `live_count`.
|
|
760
|
+
"refuse": bool(refuse_reasons),
|
|
761
|
+
"refuse_reasons": refuse_reasons,
|
|
762
|
+
"verdict_envelope": (
|
|
763
|
+
{
|
|
764
|
+
"present": verdict_envelope is not None,
|
|
765
|
+
"verdict": (verdict_envelope or {}).get("verdict"),
|
|
766
|
+
"reason_class": (verdict_envelope or {}).get("reason_class"),
|
|
767
|
+
"do_not_render": (verdict_envelope or {}).get("do_not_render"),
|
|
768
|
+
"blocked": (verdict_envelope or {}).get("blocked"),
|
|
769
|
+
"all_clear": (verdict_envelope or {}).get("all_clear"),
|
|
770
|
+
}
|
|
771
|
+
if verdict_envelope is not None
|
|
772
|
+
else {"present": False}
|
|
773
|
+
),
|
|
774
|
+
"body_empty_picks": body_empty,
|
|
775
|
+
"shipped_check_exit": shipped["exit_code"],
|
|
776
|
+
"in_flight_overlap_phases": in_flight_overlap,
|
|
777
|
+
"in_flight_rows": in_flight_rows,
|
|
778
|
+
"dirty_tree": dirty_tree_state(),
|
|
779
|
+
"archive_lock": archive_lock_state(),
|
|
780
|
+
# Operator-mutable dispatch flags relevant to the grandchild launch
|
|
781
|
+
# (model for `fanout.child`, lane-leasing/focus-auto state). The
|
|
782
|
+
# SKILL.md grandchild-launch step reads `feature_flags.fanout_child_model`
|
|
783
|
+
# for `--model` rather than hardcoding it.
|
|
784
|
+
"feature_flags": _feature_flags_view(),
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def main(argv: list[str] | None = None) -> int:
|
|
789
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
790
|
+
ap.add_argument("packet_path", help="Path to the /next-up packet markdown")
|
|
791
|
+
ap.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
|
|
792
|
+
args = ap.parse_args(argv)
|
|
793
|
+
|
|
794
|
+
packet = Path(args.packet_path)
|
|
795
|
+
if not packet.is_absolute():
|
|
796
|
+
packet = (_workspace_root() / packet).resolve()
|
|
797
|
+
if not packet.exists():
|
|
798
|
+
print(json.dumps({
|
|
799
|
+
"error": "packet-not-found",
|
|
800
|
+
"path": str(packet),
|
|
801
|
+
}), file=sys.stderr)
|
|
802
|
+
return 2
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
ctx = build_context(packet)
|
|
806
|
+
except Exception as e: # pragma: no cover
|
|
807
|
+
print(json.dumps({
|
|
808
|
+
"error": f"build-failed: {type(e).__name__}: {e}",
|
|
809
|
+
}), file=sys.stderr)
|
|
810
|
+
return 1
|
|
811
|
+
|
|
812
|
+
# Ensure UTF-8 on Windows where stdout defaults to cp1252.
|
|
813
|
+
try:
|
|
814
|
+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
|
815
|
+
except Exception:
|
|
816
|
+
pass
|
|
817
|
+
if args.pretty:
|
|
818
|
+
print(json.dumps(ctx, indent=2, ensure_ascii=False))
|
|
819
|
+
else:
|
|
820
|
+
print(json.dumps(ctx, ensure_ascii=False))
|
|
821
|
+
return 0
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
if __name__ == "__main__":
|
|
825
|
+
sys.exit(main())
|