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.
Files changed (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. dos_mcp/server.py +779 -0
dos/gh4_coverage.py ADDED
@@ -0,0 +1,179 @@
1
+ """GH4 commit-coverage predicates — pure ship-stamp / claim-footprint matching.
2
+
3
+ Lifted from the job userland's ``scripts/fanout_state.py`` (MQ3X P1, docs/62).
4
+ These three predicates answer two distrust questions the GH4 post-commit
5
+ auto-stamp asks before believing "this commit shipped (plan, phase)":
6
+
7
+ * ``claim_covered`` — does any committed path fall inside the claim's
8
+ declared footprint? (explicit glob / explicit
9
+ files / fanout-archive bundle / plan-doc edit)
10
+ * ``coverage_is_plandoc_only`` — was the claim covered ONLY by a plan-doc edit
11
+ (branch 3b) and none of the stronger footprints?
12
+ The surface of the CRS3 false-stamp (2026-06-02):
13
+ a sibling phase's ship edits the SHARED plan doc
14
+ and would auto-stamp a phase whose deliverable
15
+ was never touched → the picker drops a live phase
16
+ → the lane wedges.
17
+ * ``subject_matches_stamp`` — does the commit SUBJECT look like the expected
18
+ ship-stamp for this plan?
19
+
20
+ This module is a ``dos`` Layer-1 leaf in the ``scope`` / ``sibling_scan`` mold:
21
+ **pure** ``(entry, committed_paths, subject)`` → ``bool``, zero fs / git / clock /
22
+ config. All I/O — running ``git show --name-only``, resolving the plan-doc path,
23
+ reading ``STATE_PATH`` — happens in the CALLER (the job adapter's
24
+ ``_gh4_scan_claims_after_commit``), which gathers the evidence then calls these.
25
+ That is what lets the whole predicate family be replay-tested on frozen fixtures.
26
+
27
+ Two impure GH4 siblings (``subject_is_prelaunch_staging_only``,
28
+ ``plandoc_only_lacks_deliverable``) deliberately stay job-side: they transitively
29
+ reach ``STATE_PATH`` via ``_gh4_claim_plan_files → _resolve_phase_plan_files →
30
+ _resolve_plan_doc_path`` (the boundary litmus in docs/62 §0). Only these three
31
+ leaf-pure predicates lift.
32
+
33
+ Function names drop the ``_gh4_`` prefix (the module namespace carries it); the
34
+ job shim re-exports them under their old ``_gh4_*`` names so every existing
35
+ caller resolves unchanged.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import fnmatch
40
+ import re
41
+
42
+ # A ``dispatched_by: fanout-<TS>`` tag — the claim was dispatched by a fanout run
43
+ # whose archive bundle lives under ``docs/_fanout_runs/<TS>/``.
44
+ FANOUT_TS_RE = re.compile(r"^fanout-(\d{8}T\d{6}Z)$", re.IGNORECASE)
45
+
46
+ # Commit-subject shapes accepted as a generic ship-stamp (matched against the
47
+ # SUBJECT line only). Permissive on purpose — a false-positive auto-transition is
48
+ # safer than over-routing to the warn queue (a mistaken transition surfaces as a
49
+ # stamp-drift row on the next pre-screen; over-routing buries real signal under
50
+ # noise). The plan-token check is tightened with the actual plan id at match time.
51
+ STAMP_PATTERNS_GENERIC = (
52
+ re.compile(r"^chore\(working-tree\)", re.IGNORECASE),
53
+ re.compile(r"^docs/fanout:", re.IGNORECASE),
54
+ re.compile(r"^docs/dispatch:", re.IGNORECASE),
55
+ re.compile(r"^docs/_fanout_runs", re.IGNORECASE),
56
+ )
57
+
58
+
59
+ def claim_covered(entry: dict, committed: list[str],
60
+ plan_doc_path: str | None) -> bool:
61
+ """Return True iff any committed path is inside the claim's footprint.
62
+
63
+ Resolution order:
64
+ 1. Explicit ``path_glob`` on the entry (str): fnmatch any committed path.
65
+ 2. Explicit ``files`` on the entry (list[str]): exact match or prefix match
66
+ (treat a trailing ``/`` as a directory prefix).
67
+ 3. Fallback (the common case — schema rows today carry NEITHER):
68
+ a) ``dispatched_by: fanout-<TS>`` AND any committed path is under
69
+ ``docs/_fanout_runs/<TS>/`` (archive-bundled ship).
70
+ b) ``plan_doc_path`` resolves and any committed path equals it (a
71
+ plan-doc edit covering this plan).
72
+ """
73
+ if not committed:
74
+ return False
75
+ norm_committed = [p.replace("\\", "/") for p in committed if p]
76
+
77
+ # 1) explicit path_glob
78
+ glob = entry.get("path_glob")
79
+ if isinstance(glob, str) and glob.strip():
80
+ pat = glob.strip().replace("\\", "/")
81
+ for p in norm_committed:
82
+ if fnmatch.fnmatch(p, pat):
83
+ return True
84
+
85
+ # 2) explicit files
86
+ files = entry.get("files")
87
+ if isinstance(files, list) and files:
88
+ explicit = {str(f).replace("\\", "/") for f in files if f}
89
+ for p in norm_committed:
90
+ if p in explicit:
91
+ return True
92
+ for f in explicit:
93
+ if f.endswith("/") and p.startswith(f):
94
+ return True
95
+
96
+ # 3a) fanout-archive bundled commit
97
+ by = (entry.get("dispatched_by") or "").strip()
98
+ m = FANOUT_TS_RE.match(by)
99
+ if m:
100
+ ts = m.group(1)
101
+ prefix = f"docs/_fanout_runs/{ts}/"
102
+ for p in norm_committed:
103
+ if p.startswith(prefix):
104
+ return True
105
+
106
+ # 3b) plan-doc covered
107
+ if plan_doc_path:
108
+ pdp = plan_doc_path.replace("\\", "/")
109
+ for p in norm_committed:
110
+ if p == pdp:
111
+ return True
112
+
113
+ return False
114
+
115
+
116
+ def coverage_is_plandoc_only(
117
+ entry: dict, committed: list[str], plan_doc_path: str | None,
118
+ ) -> bool:
119
+ """True iff ``claim_covered`` would pass ONLY via branch 3b (a plan-doc edit)
120
+ — i.e. the commit touched the plan doc but matched NONE of the stronger
121
+ footprints (explicit ``path_glob`` / explicit ``files`` / fanout-archive
122
+ bundle 3a).
123
+
124
+ This is the surface of the CRS3 false-stamp (2026-06-02): a real ship commit
125
+ (``CRS2: …``) edits the SHARED plan doc that holds both CRS2 and CRS3 meta, so
126
+ 3b covers the CRS3 claim even though no CRS3 deliverable was touched; combined
127
+ with the permissive subject token-match (``\\bCRS3\\b`` in the body) it
128
+ auto-stamps CRS3 shipped → ship_oracle reads registry→SHIPPED → the picker
129
+ drops a phase whose deliverable does not exist → every dispatch on that lane
130
+ WEDGEs. Caller uses this to demand a *deliverable* overlap before stamping such
131
+ a coverage. Best-effort; pure.
132
+ """
133
+ if not committed or not plan_doc_path:
134
+ return False
135
+ norm = [p.replace("\\", "/") for p in committed if p]
136
+ pdp = plan_doc_path.replace("\\", "/")
137
+ if pdp not in norm:
138
+ return False # not covered via 3b at all
139
+ # Stronger footprints — if ANY of these match, coverage is NOT plan-doc-only.
140
+ glob = entry.get("path_glob")
141
+ if isinstance(glob, str) and glob.strip():
142
+ pat = glob.strip().replace("\\", "/")
143
+ if any(fnmatch.fnmatch(p, pat) for p in norm):
144
+ return False
145
+ files = entry.get("files")
146
+ if isinstance(files, list) and files:
147
+ explicit = {str(f).replace("\\", "/") for f in files if f}
148
+ for p in norm:
149
+ if p in explicit or any(
150
+ f.endswith("/") and p.startswith(f) for f in explicit):
151
+ return False
152
+ by = (entry.get("dispatched_by") or "").strip()
153
+ m = FANOUT_TS_RE.match(by)
154
+ if m and any(p.startswith(f"docs/_fanout_runs/{m.group(1)}/") for p in norm):
155
+ return False
156
+ # Covered, and the ONLY thing that covered it was the plan-doc edit.
157
+ return True
158
+
159
+
160
+ def subject_matches_stamp(subject: str, plan: str) -> bool:
161
+ """Return True iff the commit ``subject`` looks like the expected ship-stamp
162
+ for a claim against ``plan``. Permissive — see ``STAMP_PATTERNS_GENERIC``."""
163
+ if not subject:
164
+ return False
165
+ s = subject.strip().splitlines()[0] if subject else ""
166
+ plan_lc = (plan or "").strip().lower()
167
+ if plan_lc:
168
+ # `<plan>:` / `docs/<plan>:` / `<plan>/` / `docs/<plan>-plan` etc.
169
+ if re.match(rf"^(?:docs/)?{re.escape(plan_lc)}(?:-plan)?[:/]", s,
170
+ re.IGNORECASE):
171
+ return True
172
+ # Plan id may appear anywhere in the subject as a token (e.g.
173
+ # `docs/git-hygiene: GH4 — ...` for a GH4 claim).
174
+ if re.search(rf"\b{re.escape(plan_lc)}\d*\b", s, re.IGNORECASE):
175
+ return True
176
+ for pat in STAMP_PATTERNS_GENERIC:
177
+ if pat.match(s):
178
+ return True
179
+ return False
dos/git_delta.py ADDED
@@ -0,0 +1,122 @@
1
+ """git-delta — the "commits since a start SHA" evidence, one shared reader.
2
+
3
+ `git log <start-sha>..HEAD` is the authoritative *forward-progress delta* for a
4
+ run: how many commits landed on the served workspace since the run began. Two
5
+ callers need exactly this fold and must not drift from each other:
6
+
7
+ * `dos.timeline` — Stage 6 of the dispatch handoff view ("N commits since
8
+ start").
9
+ * `dos.liveness` (via the `dos liveness` CLI's evidence-gather) — the git rung
10
+ of the temporal verdict: ≥1 commit since start is the `ADVANCING` floor
11
+ (docs/82, LVN Phase 1b).
12
+
13
+ This module is the single home for that read so LVN does not re-implement
14
+ `timeline`'s git rung (the LVN-1b directive). It is **boundary I/O**, not a pure
15
+ verdict: like `pick_oracle`'s gather and `verify`'s git reads, the subprocess
16
+ happens HERE, at the caller boundary, and the already-counted delta is handed to
17
+ the pure classifier. `dos.liveness.classify` itself never calls this — it takes
18
+ `commits_since_start: int` as already-gathered evidence (the arbiter discipline).
19
+
20
+ The repo root is passed in EXPLICITLY (never read from the process-global active
21
+ config), so a long-lived caller fielding several workspaces — the MCP server, a
22
+ fleet daemon — gets the right tree without mutating global state. Every failure
23
+ mode (no SHA, non-git dir, git missing, timeout, non-zero exit) degrades to an
24
+ empty list: a liveness verdict in a repo with no git history is `0 commits`, the
25
+ honest floor, never a crash (the no-plan / fail-safe discipline).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import subprocess
31
+ from pathlib import Path
32
+
33
+ # Cap the git call so a pathological repo can't hang an evidence-gather. Matches
34
+ # the 10s bound `timeline._git_log` has always used.
35
+ _GIT_TIMEOUT_S = 10
36
+
37
+
38
+ def commits_since(start_sha: str, *, root: Path | str) -> list[dict[str, str]]:
39
+ """`git log <start_sha>..HEAD` over ``root``, as ``[{sha, subject}, …]``.
40
+
41
+ Newest-first (git's default order). Returns ``[]`` for any of: an empty
42
+ ``start_sha`` (a run with no recorded start commit), a non-git ``root``, a
43
+ missing ``git`` binary, a timeout, or a non-zero git exit (e.g. an unknown
44
+ SHA). The empty list is the safe degrade — a caller reads it as "no forward
45
+ delta observed," never as an error to propagate.
46
+ """
47
+ if not start_sha:
48
+ return []
49
+ try:
50
+ raw = subprocess.run(
51
+ ["git", "log", f"{start_sha}..HEAD", "--pretty=format:%h%x09%s"],
52
+ cwd=str(root),
53
+ capture_output=True,
54
+ text=True,
55
+ check=False,
56
+ timeout=_GIT_TIMEOUT_S,
57
+ )
58
+ except (OSError, subprocess.TimeoutExpired):
59
+ return []
60
+ if raw.returncode != 0:
61
+ return []
62
+ out: list[dict[str, str]] = []
63
+ for line in raw.stdout.splitlines():
64
+ if not line.strip():
65
+ continue
66
+ parts = line.split("\t", 1)
67
+ if len(parts) != 2:
68
+ continue
69
+ out.append({"sha": parts[0], "subject": parts[1]})
70
+ return out
71
+
72
+
73
+ def count_commits_since(start_sha: str, *, root: Path | str) -> int:
74
+ """Just the count — the single number `dos.liveness`'s git rung needs.
75
+
76
+ A thin fold over `commits_since` so the LVN evidence-gather reads one int
77
+ without materialising the subject list it does not use.
78
+ """
79
+ return len(commits_since(start_sha, root=root))
80
+
81
+
82
+ def recent_commits(n: int = 10, *, root: Path | str) -> list[dict[str, str]]:
83
+ """The last ``n`` commits on ``root``, newest-first, as ``[{sha, subject}, …]``.
84
+
85
+ The *unanchored* sibling of `commits_since`: where that answers "what landed
86
+ since a run's start SHA," this answers "what has landed lately, period" — the
87
+ one git read `dos top` needs to show real movement in a repo with **no
88
+ leases and no plan at all** (a freshly-`dos init`'d checkout). Kept here so
89
+ the kernel's git-evidence reads stay in one home rather than `dispatch_top`
90
+ spawning its own subprocess.
91
+
92
+ Same fail-safe contract as `commits_since`: returns ``[]`` for a non-positive
93
+ ``n``, a non-git ``root``, a missing ``git`` binary, a timeout, or a non-zero
94
+ git exit (e.g. a repo with zero commits — `git log` exits non-zero on an
95
+ unborn HEAD). The empty list is the honest floor — "no history observed,"
96
+ never an error to propagate. ``root`` is explicit (never the process-global
97
+ active config), the long-lived-caller discipline `commits_since` set.
98
+ """
99
+ if n <= 0:
100
+ return []
101
+ try:
102
+ raw = subprocess.run(
103
+ ["git", "log", f"-{int(n)}", "--pretty=format:%h%x09%s"],
104
+ cwd=str(root),
105
+ capture_output=True,
106
+ text=True,
107
+ check=False,
108
+ timeout=_GIT_TIMEOUT_S,
109
+ )
110
+ except (OSError, subprocess.TimeoutExpired):
111
+ return []
112
+ if raw.returncode != 0:
113
+ return []
114
+ out: list[dict[str, str]] = []
115
+ for line in raw.stdout.splitlines():
116
+ if not line.strip():
117
+ continue
118
+ parts = line.split("\t", 1)
119
+ if len(parts) != 2:
120
+ continue
121
+ out.append({"sha": parts[0], "subject": parts[1]})
122
+ return out
dos/guard.py ADDED
@@ -0,0 +1,215 @@
1
+ """dos.guard — the headless-launch wrapper (the argv shim, docs/134 §4).
2
+
3
+ `dos guard [opts] -- <host-cmd> [host-args]` frames a non-interactive agent
4
+ launch (`claude -p …`, or any host taking the same flags) with the DOS wiring,
5
+ then execs the host command. It injects two things the host already honors:
6
+
7
+ * ``--mcp-config '<json>'`` — mounts the DOS MCP server (``dos-mcp``) so the
8
+ agent *can* call ``dos_verify`` / ``dos_arbitrate`` mid-run. (Works today —
9
+ ``dos-mcp`` is a shipped console script.)
10
+ * ``--settings '<json>'`` — carries a ``Stop`` hook (the verify-on-stop
11
+ binding, docs/134 §2) and/or an ``--append-system-prompt`` instruction.
12
+ This is the ONLY way to add a hook to a headless run — there is no
13
+ ``--hooks`` flag (verified against ``claude --help``).
14
+
15
+ The split is the whole contract: everything after ``--`` is the host command,
16
+ passed through byte-for-byte. DOS computes the two injected flags and appends
17
+ them; an unrecognized host flag is the host's problem, not ours (degrade to
18
+ passthrough). This module is a **layer-3 helper** — it names no host internals
19
+ beyond the two public flags, takes no lease, and computes a plan a test can
20
+ assert without ever launching a subprocess.
21
+
22
+ Design discipline (mirrors the kernel's "pure core, I/O at the boundary"):
23
+ ``build_guard_plan`` is PURE — options in, a ``GuardPlan`` (the injected JSON +
24
+ the final argv) out, no environment read, no process spawned. The CLI verb does
25
+ the one impure thing (``os.execvp`` / ``subprocess``) over the plan the pure
26
+ function returned, and ``--print-config`` dumps the plan without launching.
27
+
28
+ This is a CONSUMER surface, the MCP server's sibling: it *frames* a host launch,
29
+ it does not get inside the host. The advisory-only boundary (docs/99) is intact —
30
+ nothing here forces a process; the dev typed ``dos guard``, and the host is what
31
+ honors the flags.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import shlex
38
+ from dataclasses import dataclass, field
39
+
40
+
41
+ # The console script that serves the DOS syscalls as MCP tools (pyproject
42
+ # [project.scripts]). The wrapper points the host's --mcp-config at it by name;
43
+ # resolution is the host's PATH lookup, same as any MCP stdio server.
44
+ DEFAULT_MCP_COMMAND = "dos-mcp"
45
+
46
+ # The default Stop-hook command — the docs/134 §2 verify-on-stop binding, now
47
+ # SHIPPED (`cmd_hook_stop`). The hook stays OPT-IN (`--verify-on-stop`), never
48
+ # injected by default, because it changes the launch's stop behavior — that is a
49
+ # decision the dev makes explicitly, not a silent default. (The MCP mount, which
50
+ # only ADDS a callable tool, is the safe default.)
51
+ DEFAULT_STOP_HOOK_COMMAND = "dos hook stop --workspace ."
52
+
53
+ # The one instruction that makes the docs/134 §2.1 explicit-marker rung reliable:
54
+ # the agent declares what to verify in a form the claim extractor lifts exactly.
55
+ DEFAULT_CLAIM_PROMPT = (
56
+ "When you complete a unit of work, end your turn with a line of the form "
57
+ "`DOS-CLAIM: <plan> <phase>` naming the plan and phase you claim shipped, so "
58
+ "it can be verified against git."
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class GuardPlan:
64
+ """The pure result of framing a launch: what to inject and the final argv.
65
+
66
+ ``argv`` is the complete command to exec (host command + appended DOS flags).
67
+ ``mcp_config`` / ``settings`` are the JSON objects injected (kept separately
68
+ so ``--print-config`` can show them legibly and tests can assert on them).
69
+ ``notes`` carries any honesty caveats surfaced to the user (e.g. the Stop
70
+ hook targeting a not-yet-built verb).
71
+ """
72
+
73
+ argv: list[str]
74
+ mcp_config: dict | None
75
+ settings: dict | None
76
+ host_command: list[str]
77
+ notes: list[str] = field(default_factory=list)
78
+
79
+ def to_dict(self) -> dict:
80
+ return {
81
+ "argv": self.argv,
82
+ "mcp_config": self.mcp_config,
83
+ "settings": self.settings,
84
+ "host_command": self.host_command,
85
+ "notes": self.notes,
86
+ }
87
+
88
+
89
+ def build_settings(
90
+ *,
91
+ verify_on_stop: bool,
92
+ stop_hook_command: str = DEFAULT_STOP_HOOK_COMMAND,
93
+ append_prompt: str | None = None,
94
+ ) -> dict | None:
95
+ """Build the ``--settings`` JSON object, or None if nothing to inject.
96
+
97
+ Pure. The Stop hook is only present when ``verify_on_stop`` is True — see the
98
+ DEFAULT_STOP_HOOK_COMMAND note on why it is opt-in.
99
+ """
100
+ settings: dict = {}
101
+ if verify_on_stop:
102
+ # The host's settings.json hook shape (verified): an event → a list of
103
+ # matcher-groups, each carrying a list of {type, command} hooks.
104
+ settings["hooks"] = {
105
+ "Stop": [
106
+ {"hooks": [{"type": "command", "command": stop_hook_command}]}
107
+ ]
108
+ }
109
+ if append_prompt:
110
+ # Carried in settings too — the host merges an appendSystemPrompt setting
111
+ # the same way the CLI --append-system-prompt flag does. (We pass it via
112
+ # settings rather than a second flag so the whole injection is two flags
113
+ # max, and so --print-config shows one legible object.)
114
+ settings["appendSystemPrompt"] = append_prompt
115
+ return settings or None
116
+
117
+
118
+ def build_mcp_config(
119
+ *, mount_mcp: bool, mcp_command: str = DEFAULT_MCP_COMMAND
120
+ ) -> dict | None:
121
+ """Build the ``--mcp-config`` JSON object, or None if not mounting.
122
+
123
+ Pure. The DOS server is mounted under the key ``dos`` so the agent's tools
124
+ are ``mcp__dos__verify`` etc.
125
+ """
126
+ if not mount_mcp:
127
+ return None
128
+ return {"mcpServers": {"dos": {"command": mcp_command}}}
129
+
130
+
131
+ def build_guard_plan(
132
+ host_command: list[str],
133
+ *,
134
+ mount_mcp: bool = True,
135
+ verify_on_stop: bool = False,
136
+ add_claim_prompt: bool = False,
137
+ strict_mcp: bool = False,
138
+ stop_hook_command: str = DEFAULT_STOP_HOOK_COMMAND,
139
+ mcp_command: str = DEFAULT_MCP_COMMAND,
140
+ ) -> GuardPlan:
141
+ """Frame a host launch with the DOS wiring. PURE — no I/O, no subprocess.
142
+
143
+ Returns the complete ``argv`` to exec plus the injected objects. Raises
144
+ ``ValueError`` only on an empty host command (the one contract error).
145
+ """
146
+ # argparse.REMAINDER keeps a literal leading `--` separator in the captured
147
+ # list; strip one so callers may pass the host command with or without it.
148
+ if host_command and host_command[0] == "--":
149
+ host_command = host_command[1:]
150
+
151
+ if not host_command:
152
+ raise ValueError(
153
+ "dos guard needs a host command after `--` "
154
+ "(e.g. `dos guard -- claude -p \"...\"`)."
155
+ )
156
+
157
+ notes: list[str] = []
158
+
159
+ append_prompt = DEFAULT_CLAIM_PROMPT if add_claim_prompt else None
160
+ mcp_config = build_mcp_config(mount_mcp=mount_mcp, mcp_command=mcp_command)
161
+ settings = build_settings(
162
+ verify_on_stop=verify_on_stop,
163
+ stop_hook_command=stop_hook_command,
164
+ append_prompt=append_prompt,
165
+ )
166
+
167
+ # Build the final argv: host command, then the appended DOS flags. We append
168
+ # (never prepend) so the host's own flags parse first and ours are additive —
169
+ # and so a host that doesn't recognize a flag fails on OUR flag, legibly,
170
+ # rather than mangling the user's command.
171
+ argv = list(host_command)
172
+ if mcp_config is not None:
173
+ argv += ["--mcp-config", json.dumps(mcp_config, sort_keys=True)]
174
+ if strict_mcp:
175
+ # Only use the servers we injected — the CI-honest form (verified flag).
176
+ argv += ["--strict-mcp-config"]
177
+ if settings is not None:
178
+ argv += ["--settings", json.dumps(settings, sort_keys=True)]
179
+
180
+ return GuardPlan(
181
+ argv=argv,
182
+ mcp_config=mcp_config,
183
+ settings=settings,
184
+ host_command=list(host_command),
185
+ notes=notes,
186
+ )
187
+
188
+
189
+ def render_plan_human(plan: GuardPlan) -> str:
190
+ """A legible multi-line dump of what `dos guard` would inject and run."""
191
+ lines: list[str] = []
192
+ lines.append("dos guard — launch plan")
193
+ lines.append("")
194
+ lines.append("host command (passed through verbatim):")
195
+ lines.append(" " + " ".join(shlex.quote(a) for a in plan.host_command))
196
+ lines.append("")
197
+ if plan.mcp_config is not None:
198
+ lines.append("injected --mcp-config:")
199
+ lines.append(" " + json.dumps(plan.mcp_config, sort_keys=True))
200
+ else:
201
+ lines.append("injected --mcp-config: (none — MCP mount disabled)")
202
+ if plan.settings is not None:
203
+ lines.append("injected --settings:")
204
+ lines.append(" " + json.dumps(plan.settings, sort_keys=True))
205
+ else:
206
+ lines.append("injected --settings: (none)")
207
+ lines.append("")
208
+ lines.append("final argv to exec:")
209
+ lines.append(" " + " ".join(shlex.quote(a) for a in plan.argv))
210
+ if plan.notes:
211
+ lines.append("")
212
+ lines.append("notes:")
213
+ for n in plan.notes:
214
+ lines.append(" ! " + n)
215
+ return "\n".join(lines)