design-playbook 0.25.2 → 0.25.3

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/README.md CHANGED
@@ -149,6 +149,17 @@ LICENSE · NOTICE ← authored-only scope
149
149
 
150
150
  Only authored content in this package (skills, pipeline commands, metadata, self-written examples, self-authored bundled MCP adapters). See `NOTICE` and repo ADRs 0003–0006, 0009. Repo-maintainer polish commands live in the monorepo root `.claude/commands/`, not in this package.
151
151
 
152
+ ## Runtime behavior & data
153
+
154
+ Everything this plugin runs is local and disclosed here (directory-submission review):
155
+
156
+ - **MCP servers** (stdio, launched from `${CLAUDE_PLUGIN_ROOT}` in `.mcp.json`):
157
+ - `design-playbook-preview` — `python mcp/preview/server.py`. Renders a caller-supplied HTML prototype in a local window and collects a confirm/revise decision. No third-party dependencies.
158
+ - `design-playbook-evidence` — `python mcp/evidence/server.py`. Drives a local browser (Playwright + Chromium, user-installed) to the URL named in each capture call and writes screenshot / a11y-tree / trace artifacts under `<run_root>/evidence/`. It never uploads artifacts (no HTTP delivery endpoint exists).
159
+ - **Network:** the plugin makes no outbound request of its own — no telemetry, analytics, or reporting. The only fetch is the browser navigating to the target URL you pass to a capture call (and whatever that page loads).
160
+ - **Environment:** `DESIGN_PLAYBOOK_RUN_ROOT` is forwarded to the evidence server via `env` interpolation to locate the run tree; no credentials or tokens are read. Unset → artifacts resolve under the MCP process cwd.
161
+ - **Interpreter:** servers run under `python` (Python 3); the evidence path also needs `playwright` + a Chromium install. Hosts without these skip `preview*` / `observe*` (absent→skip, ADR-0009).
162
+
152
163
  ## Contract vs enforcement
153
164
 
154
165
  Evidence exists only to satisfy a declared criterion — an observation without a binding to an L6 acceptance item is telemetry, not evidence. Runtime capture is done by external providers; design-playbook owns the binding (manifest) and the verdict (ledger), never the runtime.
@@ -191,7 +202,7 @@ python <pkg>/scripts/doctor.py --json
191
202
 
192
203
  One packaged diagnosis for interpreter, package surface, optional Playwright, and run-root configuration. Distinguishes `ok` / `degraded` / `broken` with repair actions. Those three states describe **install and runtime health** of what is present locally — they are not a public capability-maturity verdict; maturity vocabulary (`stable` / `experimental` / `blocked-by-gate` / `not-shipped`) stays with the `run-status` capability receipt, and doctor reads existing facts rather than adding a new health or capability-state authority.
193
204
 
194
- **Bundled MCP (v0.3+):** Preview (`mcp/preview/`) and Evidence (`mcp/evidence/`) runtimes ship inside this package and are registered by `.mcp.json` (`${CLAUDE_PLUGIN_ROOT}`). Sibling monorepo dirs remain compatibility launchers/docs. The orchestrator still **probes** MCP `tools/list` and skips `preview*` / `observe*` when tools are absent. Evidence provider writes artifacts only — never the manifest. **`DESIGN_PLAYBOOK_RUN_ROOT`:** `.mcp.json` passes this variable through without pinning a default. With no explicit root, artifacts resolve under the **MCP process cwd**, not necessarily the chat workspace. For a host-app run, use an absolute `.scratch/<run>/` root; per-call overrides and marker requirements are documented in [`mcp/evidence/README.md`](mcp/evidence/README.md). Capture responses include `written_path` (absolute) so mis-rooted writes are visible without a filesystem search.
205
+ **Bundled MCP (v0.3+):** Preview (`mcp/preview/`) and Evidence (`mcp/evidence/`) runtimes ship inside this package and are registered by `.mcp.json` (`${CLAUDE_PLUGIN_ROOT}`). Sibling monorepo dirs remain compatibility launchers/docs. The orchestrator still **probes** MCP `tools/list` and skips `preview*` / `observe*` when tools are absent. Evidence provider writes artifacts only — never the manifest. **`DESIGN_PLAYBOOK_RUN_ROOT`:** `.mcp.json` forwards this variable via a standard `env` interpolation (`${DESIGN_PLAYBOOK_RUN_ROOT:-}`), without pinning a default. With no explicit root, artifacts resolve under the **MCP process cwd**, not necessarily the chat workspace. For a host-app run, use an absolute `.scratch/<run>/` root; per-call overrides and marker requirements are documented in [`mcp/evidence/README.md`](mcp/evidence/README.md). Capture responses include `written_path` (absolute) so mis-rooted writes are visible without a filesystem search.
195
206
 
196
207
  What is **deterministically enforced** today: repository install/structure CI checks and the run-artifact shape (`scripts/validate_run.py` — L1–L6 present; every top-level L6 item ordered `Given -> When -> Then`; one non-empty four-field evidence ledger row per `L6.<n>` with allowed results; four non-empty finding fields with non-empty source; exactly one explicit `## Verdict` of `Pass` or `Recirculate`; Pass requires every evidence result to be `pass` and exactly one issue-linked `0 blocking` closure per blocking finding; exit 0/`RUN OK`, exit 1/`RUN INVALID`, exit 2/`RUN ERROR`; regression-tested by `tests/test_validate_run.py`, which also validates the showcase artifacts directly; **G5** is a *conditional* preview-confirm gate — enforced only when preview artifacts exist / `--preview-dir` is used; **G6** is a *conditional* evidence-binding gate — enforced only when a ledger `observed` references an `evidence/` artifact / `--evidence-dir` is used; opt-in **strict mode** via `--require-preview` / `--require-evidence` / `--strict`). The `observe*` step probes MCP tool `execute_capture_plan` and is skipped when absent. Everything else in the pipeline is agent-executed craft judgment, not a machine gate.
197
208
 
package/codex/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- generated-by design-playbook v0.25.2 -->
1
+ <!-- generated-by design-playbook v0.25.3 -->
2
2
  # design-playbook for Codex
3
3
 
4
4
  ## Install (path of record)
@@ -6,3 +6,11 @@ Cross-run **component backflow** over `.scratch/<run>/` decision reports in the
6
6
 
7
7
  Output path:
8
8
  $ARGUMENTS
9
+
10
+ ## Project target (resolve before any project read or write)
11
+
12
+ - Pass the target explicitly as `project="<absolute-directory>"` and, for a workbench task, `request="<request-id>"`. Values may contain spaces and CJK characters; they are data, never shell text.
13
+ - Resolve it with `python "${CLAUDE_PLUGIN_ROOT}/scripts/project_target.py" --arguments "<the same text>"`. That script answers from the local workbench service, which owns the project bindings.
14
+ - A project-level install binds the project that carries the install marker. A user-level install must name the target on every call: an absolute directory, or a project already registered in the local workbench. The home directory, this plugin's install directory, the current working directory, and the previously used target are never used as a fallback.
15
+ - Without a `request`, an explicit absolute directory still resolves while the service is stopped: the script reports that directory and creates no offline copy of the project's assets or a second identity. With a `request` the service must be running and must match the project; if it is not, the script exits `unavailable` — stop.
16
+ - If resolution is refused (`invalid-target`, `disconnected`) or the request does not belong to the resolved project, stop and say which input is missing. Read and write nothing in that case.
@@ -6,3 +6,11 @@ Run skill **design-playbook** in full. Honor each step’s completion criterion
6
6
 
7
7
  User request:
8
8
  $ARGUMENTS
9
+
10
+ ## Project target (resolve before any project read or write)
11
+
12
+ - Pass the target explicitly as `project="<absolute-directory>"` and, for a workbench task, `request="<request-id>"`. Values may contain spaces and CJK characters; they are data, never shell text.
13
+ - Resolve it with `python "${CLAUDE_PLUGIN_ROOT}/scripts/project_target.py" --arguments "<the same text>"`. That script answers from the local workbench service, which owns the project bindings.
14
+ - A project-level install binds the project that carries the install marker. A user-level install must name the target on every call: an absolute directory, or a project already registered in the local workbench. The home directory, this plugin's install directory, the current working directory, and the previously used target are never used as a fallback.
15
+ - Without a `request`, an explicit absolute directory still resolves while the service is stopped: the script reports that directory and creates no offline copy of the project's assets or a second identity. With a `request` the service must be running and must match the project; if it is not, the script exits `unavailable` — stop.
16
+ - If resolution is refused (`invalid-target`, `disconnected`) or the request does not belong to the resolved project, stop and say which input is missing. Read and write nothing in that case.
@@ -25,3 +25,11 @@ The user asked for a handoff; `index.html` exists under the selected run tree; a
25
25
 
26
26
  Scope:
27
27
  $ARGUMENTS
28
+
29
+ ## Project target (resolve before any project read or write)
30
+
31
+ - Pass the target explicitly as `project="<absolute-directory>"` and, for a workbench task, `request="<request-id>"`. Values may contain spaces and CJK characters; they are data, never shell text.
32
+ - Resolve it with `python "${CLAUDE_PLUGIN_ROOT}/scripts/project_target.py" --arguments "<the same text>"`. That script answers from the local workbench service, which owns the project bindings.
33
+ - A project-level install binds the project that carries the install marker. A user-level install must name the target on every call: an absolute directory, or a project already registered in the local workbench. The home directory, this plugin's install directory, the current working directory, and the previously used target are never used as a fallback.
34
+ - Without a `request`, an explicit absolute directory still resolves while the service is stopped: the script reports that directory and creates no offline copy of the project's assets or a second identity. With a `request` the service must be running and must match the project; if it is not, the script exits `unavailable` — stop.
35
+ - If resolution is refused (`invalid-target`, `disconnected`) or the request does not belong to the resolved project, stop and say which input is missing. Read and write nothing in that case.
@@ -30,3 +30,11 @@ Prohibited:
30
30
 
31
31
  Scope:
32
32
  $ARGUMENTS
33
+
34
+ ## Project target (resolve before any project read or write)
35
+
36
+ - Pass the target explicitly as `project="<absolute-directory>"` and, for a workbench task, `request="<request-id>"`. Values may contain spaces and CJK characters; they are data, never shell text.
37
+ - Resolve it with `python "${CLAUDE_PLUGIN_ROOT}/scripts/project_target.py" --arguments "<the same text>"`. That script answers from the local workbench service, which owns the project bindings.
38
+ - A project-level install binds the project that carries the install marker. A user-level install must name the target on every call: an absolute directory, or a project already registered in the local workbench. The home directory, this plugin's install directory, the current working directory, and the previously used target are never used as a fallback.
39
+ - Without a `request`, an explicit absolute directory still resolves while the service is stopped: the script reports that directory and creates no offline copy of the project's assets or a second identity. With a `request` the service must be running and must match the project; if it is not, the script exits `unavailable` — stop.
40
+ - If resolution is refused (`invalid-target`, `disconnected`) or the request does not belong to the resolved project, stop and say which input is missing. Read and write nothing in that case.
@@ -105,3 +105,11 @@ The command names completed stage markers, any active blocker (preview floor, ba
105
105
 
106
106
  In scope mode, known links are traceable to their declaration and hash, uncertain
107
107
  links remain explicit, and diagnostic gaps never replace the evaluator verdict.
108
+
109
+ ## Project target (resolve before any project read or write)
110
+
111
+ - Pass the target explicitly as `project="<absolute-directory>"` and, for a workbench task, `request="<request-id>"`. Values may contain spaces and CJK characters; they are data, never shell text.
112
+ - Resolve it with `python "${CLAUDE_PLUGIN_ROOT}/scripts/project_target.py" --arguments "<the same text>"`. That script answers from the local workbench service, which owns the project bindings.
113
+ - A project-level install binds the project that carries the install marker. A user-level install must name the target on every call: an absolute directory, or a project already registered in the local workbench. The home directory, this plugin's install directory, the current working directory, and the previously used target are never used as a fallback.
114
+ - Without a `request`, an explicit absolute directory still resolves while the service is stopped: the script reports that directory and creates no offline copy of the project's assets or a second identity. With a `request` the service must be running and must match the project; if it is not, the script exits `unavailable` — stop.
115
+ - If resolution is refused (`invalid-target`, `disconnected`) or the request does not belong to the resolved project, stop and say which input is missing. Read and write nothing in that case.
@@ -6,3 +6,11 @@ Run skill **ui-evaluator** (pull craft-guard checks when AI slop/motion/loading
6
6
 
7
7
  Scope:
8
8
  $ARGUMENTS
9
+
10
+ ## Project target (resolve before any project read or write)
11
+
12
+ - Pass the target explicitly as `project="<absolute-directory>"` and, for a workbench task, `request="<request-id>"`. Values may contain spaces and CJK characters; they are data, never shell text.
13
+ - Resolve it with `python "${CLAUDE_PLUGIN_ROOT}/scripts/project_target.py" --arguments "<the same text>"`. That script answers from the local workbench service, which owns the project bindings.
14
+ - A project-level install binds the project that carries the install marker. A user-level install must name the target on every call: an absolute directory, or a project already registered in the local workbench. The home directory, this plugin's install directory, the current working directory, and the previously used target are never used as a fallback.
15
+ - Without a `request`, an explicit absolute directory still resolves while the service is stopped: the script reports that directory and creates no offline copy of the project's assets or a second identity. With a `request` the service must be running and must match the project; if it is not, the script exits `unavailable` — stop.
16
+ - If resolution is refused (`invalid-target`, `disconnected`) or the request does not belong to the resolved project, stop and say which input is missing. Read and write nothing in that case.
@@ -6,3 +6,11 @@ Run the ux-spec protocol only. A same-named command in this plugin shadows the `
6
6
 
7
7
  Request:
8
8
  $ARGUMENTS
9
+
10
+ ## Project target (resolve before any project read or write)
11
+
12
+ - Pass the target explicitly as `project="<absolute-directory>"` and, for a workbench task, `request="<request-id>"`. Values may contain spaces and CJK characters; they are data, never shell text.
13
+ - Resolve it with `python "${CLAUDE_PLUGIN_ROOT}/scripts/project_target.py" --arguments "<the same text>"`. That script answers from the local workbench service, which owns the project bindings.
14
+ - A project-level install binds the project that carries the install marker. A user-level install must name the target on every call: an absolute directory, or a project already registered in the local workbench. The home directory, this plugin's install directory, the current working directory, and the previously used target are never used as a fallback.
15
+ - Without a `request`, an explicit absolute directory still resolves while the service is stopped: the script reports that directory and creates no offline copy of the project's assets or a second identity. With a `request` the service must be running and must match the project; if it is not, the script exits `unavailable` — stop.
16
+ - If resolution is refused (`invalid-target`, `disconnected`) or the request does not belong to the resolved project, stop and say which input is missing. Read and write nothing in that case.
@@ -332,19 +332,30 @@ def _validated_call_run_root(value: object) -> Path:
332
332
  return root
333
333
 
334
334
 
335
+ def _is_unset_run_root(value: str | None) -> bool:
336
+ """True when an env value must fall back to process cwd.
337
+
338
+ Unset/empty and the literal ``"."`` both mean "no explicit root". A value
339
+ that still contains ``"${"`` is an interpolation token the launching host
340
+ did not expand — e.g. the ``.mcp.json`` ``${DESIGN_PLAYBOOK_RUN_ROOT:-}``
341
+ passthrough on a host that does not resolve ``${…}`` (ADR-0009) — so treat
342
+ it as unset rather than resolving a literal ``"${…}"`` into a bogus root.
343
+ """
344
+ return not value or value == "." or "${" in value
345
+
346
+
335
347
  def _run_root_misrooted() -> bool:
336
348
  """True when the run root fell back to a markerless cwd.
337
349
 
338
- The shipped .mcp.json default (DESIGN_PLAYBOOK_RUN_ROOT=".") makes the
339
- server resolve artifacts under its process cwd; in a host workspace that
340
- cwd is the repo root, so captures silently land outside the run tree.
341
- A per-call ``run_root`` is marker-validated before use, so it is never
342
- misrooted.
350
+ The shipped .mcp.json forwards ``${DESIGN_PLAYBOOK_RUN_ROOT:-}`` (empty when
351
+ the host has no run root set), so the server resolves artifacts under its
352
+ process cwd; in a host workspace that cwd is the repo root, so captures
353
+ silently land outside the run tree. A per-call ``run_root`` is
354
+ marker-validated before use, so it is never misrooted.
343
355
  """
344
356
  if _CALL_RUN_ROOT.get() is not None:
345
357
  return False
346
- configured = os.environ.get(RUN_ROOT_ENV)
347
- if configured and configured != ".":
358
+ if not _is_unset_run_root(os.environ.get(RUN_ROOT_ENV)):
348
359
  return False
349
360
  return not _has_run_marker(Path.cwd().resolve())
350
361
 
@@ -354,7 +365,7 @@ def _run_root() -> Path:
354
365
  if explicit is not None:
355
366
  return explicit
356
367
  configured = os.environ.get(RUN_ROOT_ENV)
357
- if not configured or configured == ".":
368
+ if _is_unset_run_root(configured):
358
369
  # Warn only when cwd does not look like a run dir (no run marker
359
370
  # file) — the shipped default resolving to a real run dir is correct
360
371
  # usage, not a misconfig — and only once per process to avoid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Evidence-backed UI delivery for coding agents working on existing Web products: declared criteria, criterion-bound artifacts, findings linked to declarations, and repair guidance. Evaluator review does not replace human semantic approval.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve the target project for a slash invocation (R02).
3
+
4
+ Slash commands are prompt text, so the *resolution* has to be a real,
5
+ checkable step rather than a sentence an agent may interpret loosely.
6
+ This script is that step:
7
+
8
+ - the install scope is decided from adapter metadata or an explicit
9
+ configuration file -- never from the current working directory;
10
+ - a project-level install binds the project that carries the marker;
11
+ - a user-level install refuses to guess: an explicit absolute directory
12
+ (or an explicit registered project id) is required, and the home
13
+ directory, the plugin install directory, the current working
14
+ directory, and any previous target are never used as a fallback;
15
+ - the answer comes from the local workbench service, which is the
16
+ authority for bindings. Without a request, an explicit absolute
17
+ directory also resolves while the service is stopped, so the
18
+ pre-workbench slash workflow keeps working; nothing is registered and no
19
+ competing, offline asset directory is ever created. With a request, or a
20
+ registered project id, the service must be running -- otherwise the
21
+ result is ``unavailable`` and the command stops.
22
+
23
+ Usage::
24
+
25
+ python project_target.py --project "<absolute-directory>" [--request <id>]
26
+ python project_target.py --project-id "<uuid>"
27
+ python project_target.py --describe-scope
28
+
29
+ Exit codes: 0 resolved, 3 target refused (invalid/disconnected/unknown),
30
+ 4 service unavailable.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import os
37
+ import sys
38
+ import urllib.error
39
+ import urllib.parse
40
+ import urllib.request
41
+ from pathlib import Path
42
+
43
+ SCOPE_PROJECT = "project-level"
44
+ SCOPE_USER = "user-level"
45
+ SCOPE_UNKNOWN = "unknown"
46
+
47
+ EXIT_OK = 0
48
+ EXIT_REFUSED = 3
49
+ EXIT_UNAVAILABLE = 4
50
+
51
+ #: Marker files that identify a project-level install without asking the
52
+ #: current working directory anything: the adapter metadata lives inside
53
+ #: the project the plugin was installed into.
54
+ PROJECT_MARKERS: tuple[str, ...] = (
55
+ ".claude/settings.json",
56
+ ".claude-plugin/plugin.json",
57
+ ".design-playbook/project.json",
58
+ )
59
+
60
+ DEFAULT_DATA_DIR_ENV = "DESIGN_PLAYBOOK_WORKBENCH_DATA_DIR"
61
+ RECORD_RELATIVE = ("session", "session.json")
62
+
63
+ SERVICE_TIMEOUT_SECONDS = 10
64
+
65
+
66
+ class TargetRefused(Exception):
67
+ """The caller must stop: no project may be read or written."""
68
+
69
+ def __init__(self, code: str, message: str) -> None:
70
+ super().__init__(message)
71
+ self.code = code
72
+ self.message = message
73
+
74
+
75
+ class ServiceUnavailable(Exception):
76
+ """The local workbench service is not running (or has no live record)."""
77
+
78
+
79
+ def _workbench_data_dir(environ: dict | None = None) -> Path:
80
+ env = os.environ if environ is None else environ
81
+ override = env.get(DEFAULT_DATA_DIR_ENV)
82
+ if override:
83
+ return Path(override)
84
+ if os.name == "nt":
85
+ base = env.get("LOCALAPPDATA") or env.get("APPDATA")
86
+ if base:
87
+ return Path(base) / "design-playbook-workbench"
88
+ xdg = env.get("XDG_DATA_HOME")
89
+ if xdg:
90
+ return Path(xdg) / "design-playbook-workbench"
91
+ return Path.home() / ".local" / "share" / "design-playbook-workbench"
92
+
93
+
94
+ def read_session_record(data_dir: Path | str | None = None) -> dict:
95
+ """The OS-protected credential record, or ``ServiceUnavailable``."""
96
+ root = Path(data_dir) if data_dir is not None else _workbench_data_dir()
97
+ path = root.joinpath(*RECORD_RELATIVE)
98
+ try:
99
+ raw = path.read_text(encoding="utf-8")
100
+ except OSError:
101
+ raise ServiceUnavailable(
102
+ "the workbench service is not running (no session record)"
103
+ ) from None
104
+ try:
105
+ record = json.loads(raw)
106
+ except json.JSONDecodeError:
107
+ raise ServiceUnavailable("the session record is not readable") from None
108
+ if not isinstance(record, dict) or not record.get("authority"):
109
+ raise ServiceUnavailable("the session record is incomplete")
110
+ return record
111
+
112
+
113
+ def detect_install_scope(
114
+ *,
115
+ plugin_root: Path | str | None,
116
+ cwd: Path | str | None,
117
+ environ: dict | None = None,
118
+ explicit_scope: str | None = None,
119
+ ) -> tuple[str, str | None]:
120
+ """Decide the install scope from metadata, never from ``cwd`` alone.
121
+
122
+ Returns ``(scope, project_root)``. A project-level install is detected
123
+ by an adapter marker inside the project directory itself, and the
124
+ reported project root is the directory that carries the marker.
125
+ """
126
+ env = os.environ if environ is None else environ
127
+ configured = explicit_scope or env.get("DESIGN_PLAYBOOK_INSTALL_SCOPE")
128
+ if configured in (SCOPE_PROJECT, SCOPE_USER):
129
+ root = env.get("DESIGN_PLAYBOOK_PROJECT_ROOT")
130
+ if configured == SCOPE_PROJECT:
131
+ if not root:
132
+ raise TargetRefused(
133
+ "invalid-target",
134
+ "a project-level install must declare DESIGN_PLAYBOOK_PROJECT_ROOT",
135
+ )
136
+ return SCOPE_PROJECT, str(Path(root).expanduser().resolve())
137
+ return SCOPE_USER, None
138
+ if configured is not None:
139
+ raise TargetRefused(
140
+ "invalid-input",
141
+ "DESIGN_PLAYBOOK_INSTALL_SCOPE must be 'project-level' or 'user-level'",
142
+ )
143
+
144
+ candidates: list[Path] = []
145
+ if plugin_root is not None:
146
+ candidates.append(Path(plugin_root).expanduser().resolve().parent)
147
+ if cwd is not None:
148
+ candidates.append(Path(cwd).expanduser().resolve())
149
+ seen: set[str] = set()
150
+ for candidate in candidates:
151
+ for marker in PROJECT_MARKERS:
152
+ marker_path = candidate / marker
153
+ if not marker_path.is_file():
154
+ continue
155
+ text = marker_path.read_text(encoding="utf-8", errors="replace")
156
+ if "design-playbook" not in text:
157
+ continue
158
+ key = str(candidate)
159
+ if key in seen:
160
+ continue
161
+ seen.add(key)
162
+ return SCOPE_PROJECT, key
163
+ return SCOPE_USER, None
164
+
165
+
166
+ def parse_request_arguments(text: str) -> dict:
167
+ """Parse the ``project="..." request="..."`` text convention.
168
+
169
+ Only those two names are recognised, values may contain spaces and
170
+ CJK characters, and the result is never assembled into a shell
171
+ string: the caller passes the value as an argument.
172
+ """
173
+ result: dict[str, str] = {}
174
+ index = 0
175
+ length = len(text)
176
+ while index < length:
177
+ while index < length and text[index].isspace():
178
+ index += 1
179
+ if index >= length:
180
+ break
181
+ match = None
182
+ for name in ("project", "request"):
183
+ if text.startswith(name, index):
184
+ after = index + len(name)
185
+ if after < length and text[after] == "=":
186
+ match = (name, after + 1)
187
+ break
188
+ if match is None:
189
+ index += 1
190
+ continue
191
+ name, value_start = match
192
+ if value_start < length and text[value_start] in "\"'":
193
+ quote = text[value_start]
194
+ end = text.find(quote, value_start + 1)
195
+ if end == -1:
196
+ raise TargetRefused("invalid-input", f"unterminated {name} value")
197
+ result[name] = text[value_start + 1 : end]
198
+ index = end + 1
199
+ continue
200
+ end = value_start
201
+ while end < length and not text[end].isspace():
202
+ end += 1
203
+ result[name] = text[value_start:end]
204
+ index = end
205
+ return result
206
+
207
+
208
+ def _http_get(record: dict, path: str, query: str) -> dict:
209
+ url = record["authority"] + path + ("?" + query if query else "")
210
+ request = urllib.request.Request(url, method="GET")
211
+ request.add_header("Authorization", "Bearer " + str(record.get("sessionToken", "")))
212
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
213
+ try:
214
+ with opener.open(request, timeout=SERVICE_TIMEOUT_SECONDS) as response:
215
+ return json.loads(response.read().decode("utf-8"))
216
+ except urllib.error.HTTPError as error:
217
+ payload = None
218
+ try:
219
+ payload = json.loads(error.read().decode("utf-8"))
220
+ except Exception:
221
+ payload = None
222
+ code = (
223
+ payload.get("error", {}).get("code")
224
+ if isinstance(payload, dict)
225
+ else None
226
+ )
227
+ raise TargetRefused(code or "invalid-target", "the service refused the target")
228
+ except (urllib.error.URLError, OSError, ValueError):
229
+ raise ServiceUnavailable(
230
+ "the workbench service is not reachable on its bound address"
231
+ ) from None
232
+
233
+
234
+ def _resolve_offline(project: str) -> dict:
235
+ """Resolve one explicit absolute directory without the service.
236
+
237
+ Only reachable when no request is named and the service is not running:
238
+ an existing workflow keeps its explicit directory. Nothing is registered
239
+ and no asset directory is created -- the directory alone is the answer.
240
+ """
241
+ if not isinstance(project, str) or not project.strip():
242
+ raise TargetRefused(
243
+ "invalid-target", "an explicit project directory is required"
244
+ )
245
+ path = Path(project).expanduser()
246
+ if not path.is_absolute():
247
+ raise TargetRefused("invalid-target", "an absolute directory is required")
248
+ if not path.is_dir():
249
+ raise TargetRefused("invalid-target", "the target directory is not reachable")
250
+ canonical = path.resolve()
251
+ return {
252
+ "resolved": True,
253
+ "mode": "offline",
254
+ "authority": None,
255
+ "project": {
256
+ "projectId": None,
257
+ "name": canonical.name,
258
+ "canonicalPath": str(canonical),
259
+ },
260
+ "requestId": None,
261
+ "taskState": None,
262
+ }
263
+
264
+
265
+ def resolve_target(
266
+ *,
267
+ project: str | None = None,
268
+ project_id: str | None = None,
269
+ request_id: str | None = None,
270
+ data_dir: Path | str | None = None,
271
+ ) -> dict:
272
+ """Ask the local service to resolve one explicit target.
273
+
274
+ A request, or a registered project id, is always the service's answer:
275
+ without the service it is ``unavailable``. A bare explicit directory
276
+ with no request still resolves while the service is stopped, so the
277
+ pre-workbench slash workflow keeps working.
278
+ """
279
+ if bool(project) == bool(project_id):
280
+ raise TargetRefused(
281
+ "invalid-target",
282
+ "an explicit project directory or project id is required",
283
+ )
284
+ if project_id:
285
+ query = "projectId=" + urllib.parse.quote(project_id, safe="")
286
+ else:
287
+ query = "project=" + urllib.parse.quote(str(project), safe="")
288
+ if request_id:
289
+ query += "&request=" + urllib.parse.quote(request_id, safe="")
290
+ try:
291
+ record = read_session_record(data_dir)
292
+ payload = _http_get(record, "/api/v1/resolve", query)
293
+ except ServiceUnavailable:
294
+ if project_id or request_id:
295
+ raise
296
+ return _resolve_offline(str(project))
297
+ payload["authority"] = record["authority"]
298
+ payload["bootId"] = record.get("bootId")
299
+ return payload
300
+
301
+
302
+ def describe_scope(
303
+ *, plugin_root: Path | str | None = None, cwd: Path | str | None = None
304
+ ) -> dict:
305
+ scope, project_root = detect_install_scope(
306
+ plugin_root=plugin_root, cwd=cwd
307
+ )
308
+ return {
309
+ "installScope": scope,
310
+ "projectRoot": project_root,
311
+ "explicitTargetRequired": scope == SCOPE_USER,
312
+ }
313
+
314
+
315
+ def build_parser() -> argparse.ArgumentParser:
316
+ parser = argparse.ArgumentParser(
317
+ prog="project_target",
318
+ description=(
319
+ "Resolve the project target for a design-playbook slash command. "
320
+ "Never falls back to the home directory, the plugin install "
321
+ "directory, the current working directory, or a previous target."
322
+ ),
323
+ )
324
+ parser.add_argument("--project", help="Absolute project directory.")
325
+ parser.add_argument("--project-id", dest="project_id", help="Registered project id.")
326
+ parser.add_argument("--request", help="Work request id bound to the project.")
327
+ parser.add_argument(
328
+ "--arguments",
329
+ help=(
330
+ "Raw slash argument text; project= and request= values are read "
331
+ "from it when the matching flags are absent."
332
+ ),
333
+ )
334
+ parser.add_argument(
335
+ "--data-dir", help="Workbench data directory (default: per-user location)."
336
+ )
337
+ parser.add_argument(
338
+ "--describe-scope",
339
+ action="store_true",
340
+ help="Report the detected install scope and exit.",
341
+ )
342
+ return parser
343
+
344
+
345
+ def main(argv: list[str] | None = None) -> int:
346
+ arguments = build_parser().parse_args(argv)
347
+ stream = sys.stdout
348
+ try:
349
+ if arguments.describe_scope:
350
+ payload = describe_scope(
351
+ plugin_root=os.environ.get("CLAUDE_PLUGIN_ROOT"),
352
+ cwd=os.getcwd(),
353
+ )
354
+ stream.write(json.dumps(payload, ensure_ascii=False) + "\n")
355
+ return EXIT_OK
356
+ project = arguments.project
357
+ request_id = arguments.request
358
+ if arguments.arguments:
359
+ parsed = parse_request_arguments(arguments.arguments)
360
+ if not project:
361
+ project = parsed.get("project")
362
+ if not request_id:
363
+ request_id = parsed.get("request")
364
+ payload = resolve_target(
365
+ project=project,
366
+ project_id=arguments.project_id,
367
+ request_id=request_id,
368
+ data_dir=arguments.data_dir,
369
+ )
370
+ stream.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
371
+ return EXIT_OK
372
+ except TargetRefused as refusal:
373
+ stream.write(
374
+ json.dumps(
375
+ {"resolved": False, "code": refusal.code, "message": refusal.message},
376
+ ensure_ascii=False,
377
+ )
378
+ + "\n"
379
+ )
380
+ return EXIT_REFUSED
381
+ except ServiceUnavailable as unavailable:
382
+ stream.write(
383
+ json.dumps(
384
+ {
385
+ "resolved": False,
386
+ "code": "unavailable",
387
+ "message": str(unavailable),
388
+ },
389
+ ensure_ascii=False,
390
+ )
391
+ + "\n"
392
+ )
393
+ return EXIT_UNAVAILABLE
394
+
395
+
396
+ if __name__ == "__main__": # pragma: no cover - script entry
397
+ raise SystemExit(main())
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env python3
2
+ """Owner-side projection publisher: the R12 thin typed interface.
3
+
4
+ The workbench (``packages/design-playbook-workbench``) reads a typed
5
+ projection document at ``<project>/.design-playbook/runs/<run>/projection.json``
6
+ and rebuilds its own view of the *owner's* facts. Owner runs did not publish
7
+ that document, so this script is the thin, bounded writer for it. Specification
8
+ R12 allows exactly this shape of integration: an existing owner that lacks a
9
+ typed entry gets "薄接口及负例测试" from an integration ticket, and the web side
10
+ must never copy the owner's domain judgement.
11
+
12
+ What it does -- transcribe, never judge:
13
+
14
+ * ``evidence[]`` comes from the run's own ``evidence/manifest.jsonl`` (written
15
+ by ``scripts/evidence_manifest.py``). The artifact name, its sha256, and its
16
+ criterion binding are copied verbatim.
17
+ * ``criteria[]`` comes from the evaluator's evidence ledger text
18
+ (``criterion:`` / ``required:`` / ``observed:`` / ``result:`` rows), parsed by
19
+ the single Evidence ledger parser (``mcp/evidence/ledger_syntax.py``). The
20
+ verdict is the owner's own ``result:`` value. ``N/A`` (not applicable) is
21
+ recorded as ``unknown``: not applicable is not a pass.
22
+ * ``findings[]`` comes from an explicit ``--findings`` JSON file when one is
23
+ given, and is empty otherwise. This script never invents a finding.
24
+
25
+ It refuses rather than guesses. An unknown verdict, a row without a criterion
26
+ or result, two rows for one criterion, a ledger with no rows, a non-hex
27
+ manifest digest, or a missing owner all abort with a non-zero exit and write
28
+ nothing.
29
+
30
+ Authority bounds: it writes exactly one file, inside
31
+ ``<project-root>/.design-playbook/runs/<run-id>/``; the run id is validated so
32
+ it cannot escape that tree; it reads only the run's own evidence manifest, the
33
+ ledger file, and the findings file it was given; it opens no socket and runs no
34
+ process.
35
+
36
+ Usage::
37
+
38
+ python scripts/publish_owner_projection.py <run-dir> \\
39
+ --project-root <target repo> --run-id <run> --owner ui-evaluator \\
40
+ --ledger <ledger text file> [--findings <findings.json>] \\
41
+ [--runner "design-playbook ui-evaluator"]
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import json
47
+ import os
48
+ import re
49
+ import sys
50
+ import tempfile
51
+ from datetime import datetime, timezone
52
+ from pathlib import Path, PurePosixPath, PureWindowsPath
53
+
54
+ # One import seam (ADR-0022): package root on sys.path once, then absolute
55
+ # design_playbook.* imports below, exactly like scripts/evidence_manifest.py.
56
+ _PKG_ROOT = Path(__file__).resolve().parent.parent
57
+ if str(_PKG_ROOT) not in sys.path:
58
+ sys.path.insert(0, str(_PKG_ROOT))
59
+
60
+ from design_playbook.mcp.evidence.ledger_syntax import ( # noqa: E402
61
+ parse_ledger,
62
+ )
63
+
64
+ #: The projection document version the workbench understands.
65
+ PROJECTION_VERSION = "owner-projection/v1"
66
+ #: Where the workbench looks for a run projection, relative to the project root.
67
+ PROJECTION_ROOT = ".design-playbook/runs"
68
+ #: The manifest the Evidence write side owns.
69
+ MANIFEST_NAME = "manifest.jsonl"
70
+
71
+ #: Owner result vocabulary -> workbench verdict vocabulary.
72
+ #: ``N/A`` is deliberately ``unknown``: not applicable is not a pass.
73
+ _VERDICT_MAP = {"pass": "pass", "fail": "fail", "blocked": "blocked", "n/a": "unknown"}
74
+
75
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
76
+ _RESERVED_RUN_IDS = {"", ".", ".."}
77
+
78
+
79
+ class ProjectionPublishError(ValueError):
80
+ """Rejected publish: the inputs cannot be transcribed faithfully."""
81
+
82
+
83
+ # -- pure helpers --------------------------------------------------------
84
+
85
+
86
+ def check_run_id(run_id: object) -> str:
87
+ """Validate a run id as one safe path segment.
88
+
89
+ A run id reaches the filesystem, so it is treated as hostile input: only a
90
+ single segment with no separators, no drive designator and no traversal is
91
+ accepted.
92
+ """
93
+ if not isinstance(run_id, str) or not run_id.strip():
94
+ raise ProjectionPublishError("run id is required")
95
+ if run_id in _RESERVED_RUN_IDS:
96
+ raise ProjectionPublishError("run id is not a usable directory name")
97
+ if "\x00" in run_id:
98
+ raise ProjectionPublishError("run id contains a NUL byte")
99
+ posix, windows = PurePosixPath(run_id), PureWindowsPath(run_id)
100
+ if (
101
+ len(posix.parts) != 1
102
+ or len(windows.parts) != 1
103
+ or posix.parts[0] != run_id
104
+ or windows.parts[0] != run_id
105
+ or windows.drive
106
+ or "/" in run_id
107
+ or "\\" in run_id
108
+ ):
109
+ raise ProjectionPublishError("run id must be a single path segment")
110
+ return run_id
111
+
112
+
113
+ def check_owner(owner: object) -> str:
114
+ """The owner is never invented: a missing owner is a hard refusal."""
115
+ if not isinstance(owner, str) or not owner.strip():
116
+ raise ProjectionPublishError("owner is required (the workbench invents none)")
117
+ return owner.strip()
118
+
119
+
120
+ def prefixed_digest(raw: object) -> str:
121
+ """Normalise a manifest sha256 into the workbench's ``sha256:<hex>`` form."""
122
+ if not isinstance(raw, str) or not _HEX64.match(raw.strip().lower()):
123
+ raise ProjectionPublishError("manifest digest is not a sha256 hex digest")
124
+ return "sha256:" + raw.strip().lower()
125
+
126
+
127
+ def map_verdict(result: object) -> str:
128
+ """The owner's own result value, mapped; an unknown value is refused."""
129
+ if not isinstance(result, str):
130
+ raise ProjectionPublishError("ledger row has no result value")
131
+ verdict = _VERDICT_MAP.get(result.strip().lower())
132
+ if verdict is None:
133
+ raise ProjectionPublishError(f"unknown ledger result {result!r}")
134
+ return verdict
135
+
136
+
137
+ def artifact_basename(token: str) -> str:
138
+ """The bare artifact filename a ledger ``observed`` token points at."""
139
+ text = token.strip()
140
+ for prefix in ("evidence/", "evidence\\"):
141
+ if text.lower().startswith(prefix):
142
+ text = text[len(prefix):]
143
+ break
144
+ return text
145
+
146
+
147
+ def read_manifest(run_dir: Path) -> list[dict]:
148
+ """Read the run's ``evidence/manifest.jsonl``; refuse a malformed line."""
149
+ path = run_dir / "evidence" / MANIFEST_NAME
150
+ if not path.is_file():
151
+ raise ProjectionPublishError(f"missing evidence manifest: {path}")
152
+ entries: list[dict] = []
153
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
154
+ if not line.strip():
155
+ continue
156
+ try:
157
+ entry = json.loads(line)
158
+ except json.JSONDecodeError as error:
159
+ raise ProjectionPublishError(
160
+ f"manifest line {number} is not JSON: {error.msg}"
161
+ ) from None
162
+ if not isinstance(entry, dict):
163
+ raise ProjectionPublishError(f"manifest line {number} is not an object")
164
+ entries.append(entry)
165
+ return entries
166
+
167
+
168
+ def evidence_rows(entries: list[dict]) -> list[dict]:
169
+ """Transcribe manifest bindings into workbench evidence rows."""
170
+ rows: list[dict] = []
171
+ for entry in entries:
172
+ artifact = entry.get("artifact")
173
+ criterion = entry.get("criterion")
174
+ if not isinstance(artifact, str) or not artifact:
175
+ raise ProjectionPublishError("manifest entry has no artifact")
176
+ if not isinstance(criterion, str) or not criterion:
177
+ raise ProjectionPublishError("manifest entry has no criterion")
178
+ row: dict[str, object] = {
179
+ "evidenceId": artifact,
180
+ "hash": prefixed_digest(entry.get("sha256")),
181
+ "criterionId": criterion,
182
+ "capturedAt": entry.get("ts") or "",
183
+ }
184
+ # Only fields the owner actually stated are carried; nothing is derived.
185
+ for key in ("kind", "mediaType", "role"):
186
+ value = entry.get(key)
187
+ if isinstance(value, str) and value:
188
+ row[key] = value
189
+ rows.append(row)
190
+ return rows
191
+
192
+
193
+ def _manifest_index(entries: list[dict]) -> dict[str, str]:
194
+ """artifact filename -> ``sha256:<hex>`` for one run."""
195
+ index: dict[str, str] = {}
196
+ for entry in entries:
197
+ artifact = entry.get("artifact")
198
+ if isinstance(artifact, str) and artifact:
199
+ index[artifact] = prefixed_digest(entry.get("sha256"))
200
+ return index
201
+
202
+
203
+ def criteria_rows(ledger_text: str, entries: list[dict]) -> list[dict]:
204
+ """Transcribe ledger rows into workbench criterion rows.
205
+
206
+ One ledger row per criterion is the evaluator's own contract, so a missing
207
+ or duplicated criterion id is refused instead of resolved arbitrarily.
208
+ """
209
+ facts = parse_ledger(ledger_text)
210
+ if not facts.rows:
211
+ raise ProjectionPublishError("ledger carries no criterion rows")
212
+ bindings: dict[str, list[str]] = {}
213
+ index = _manifest_index(entries)
214
+ for entry in entries:
215
+ criterion = entry.get("criterion")
216
+ if isinstance(criterion, str) and criterion:
217
+ bindings.setdefault(criterion, []).append(
218
+ prefixed_digest(entry.get("sha256"))
219
+ )
220
+
221
+ rows: list[dict] = []
222
+ seen: set[str] = set()
223
+ for ledger_row in facts.rows:
224
+ criteria = ledger_row.values("criterion")
225
+ results = ledger_row.values("result")
226
+ if not criteria:
227
+ raise ProjectionPublishError("ledger row has no criterion id")
228
+ criterion_id = criteria[0]
229
+ if len(criteria) > 1:
230
+ raise ProjectionPublishError(
231
+ f"ledger row for {criterion_id} repeats its criterion id"
232
+ )
233
+ if criterion_id in seen:
234
+ raise ProjectionPublishError(f"duplicate ledger row for {criterion_id}")
235
+ seen.add(criterion_id)
236
+ if not results:
237
+ raise ProjectionPublishError(f"ledger row {criterion_id} has no result")
238
+ row = {
239
+ "criterionId": criterion_id,
240
+ "verdict": map_verdict(results[0]),
241
+ "evidenceHashes": list(bindings.get(criterion_id, [])),
242
+ }
243
+ # A criterion's source hash is the digest the owner already bound to
244
+ # the artifact its own ledger names. When the owner bound nothing, the
245
+ # field is omitted rather than invented, and the workbench reports the
246
+ # fact as unverifiable ("unknown") instead of fresh. The semantic role
247
+ # is omitted for the same reason: the ledger states none.
248
+ observed = ledger_row.artifact_token
249
+ if observed:
250
+ digest = index.get(artifact_basename(observed))
251
+ if digest is not None:
252
+ row["sourceHash"] = digest
253
+ rows.append(row)
254
+ return rows
255
+
256
+
257
+ def finding_rows(raw: object) -> list[dict]:
258
+ """Validate and transcribe an explicit findings list; never invent one."""
259
+ if raw is None:
260
+ return []
261
+ if not isinstance(raw, list):
262
+ raise ProjectionPublishError("findings must be a JSON list")
263
+ rows: list[dict] = []
264
+ for item in raw:
265
+ if not isinstance(item, dict):
266
+ raise ProjectionPublishError("finding is not an object")
267
+ finding_id = item.get("findingId")
268
+ if not isinstance(finding_id, str) or not finding_id:
269
+ raise ProjectionPublishError("finding has no findingId")
270
+ pointer = item.get("pointBack")
271
+ if pointer is not None and not isinstance(pointer, dict):
272
+ raise ProjectionPublishError("finding pointBack is not an object")
273
+ row: dict[str, object] = {
274
+ "findingId": finding_id,
275
+ "severity": item.get("severity") or "info",
276
+ "summary": item.get("summary") or "",
277
+ }
278
+ if isinstance(item.get("criterionId"), str):
279
+ row["criterionId"] = item["criterionId"]
280
+ if isinstance(item.get("sourceHash"), str):
281
+ row["sourceHash"] = item["sourceHash"]
282
+ if isinstance(item.get("role"), str):
283
+ row["role"] = item["role"]
284
+ if isinstance(pointer, dict):
285
+ row["pointBack"] = {
286
+ "kind": pointer.get("kind") or "unknown",
287
+ "path": pointer.get("path"),
288
+ "note": pointer.get("note") or "",
289
+ "repairOwner": pointer.get("repairOwner") or "",
290
+ }
291
+ rows.append(row)
292
+ return rows
293
+
294
+
295
+ def build_projection(
296
+ *,
297
+ run_id: str,
298
+ owner: str,
299
+ project_id: str | None,
300
+ runner: str,
301
+ entries: list[dict],
302
+ ledger_text: str | None,
303
+ findings: object = None,
304
+ generated_at: str | None = None,
305
+ ) -> dict:
306
+ """Assemble the projection document from owner-decided facts only."""
307
+ check_run_id(run_id)
308
+ check_owner(owner)
309
+ document: dict[str, object] = {
310
+ "version": PROJECTION_VERSION,
311
+ "runId": run_id,
312
+ "owner": owner,
313
+ "projectId": project_id,
314
+ "runner": runner,
315
+ "generatedAt": generated_at
316
+ or datetime.now(timezone.utc).isoformat(timespec="seconds"),
317
+ "evidence": evidence_rows(entries),
318
+ "criteria": criteria_rows(ledger_text or "", entries),
319
+ "findings": finding_rows(findings),
320
+ }
321
+ return document
322
+
323
+
324
+ def projection_path(project_root: Path, run_id: str) -> Path:
325
+ """The one file this script may write."""
326
+ return project_root / PROJECTION_ROOT / check_run_id(run_id) / "projection.json"
327
+
328
+
329
+ def publish(project_root: Path, run_id: str, document: dict) -> Path:
330
+ """Write the projection atomically; leave no partial file behind."""
331
+ target = projection_path(project_root, run_id)
332
+ target.parent.mkdir(parents=True, exist_ok=True)
333
+ payload = json.dumps(document, ensure_ascii=False, indent=2) + "\n"
334
+ handle = tempfile.NamedTemporaryFile(
335
+ "w",
336
+ encoding="utf-8",
337
+ dir=target.parent,
338
+ prefix=".projection-",
339
+ suffix=".tmp",
340
+ delete=False,
341
+ )
342
+ try:
343
+ with handle:
344
+ handle.write(payload)
345
+ handle.flush()
346
+ os.fsync(handle.fileno())
347
+ os.replace(handle.name, target)
348
+ except BaseException:
349
+ Path(handle.name).unlink(missing_ok=True)
350
+ raise
351
+ return target
352
+
353
+
354
+ def _read_text(path: str) -> str:
355
+ return Path(path).read_text(encoding="utf-8")
356
+
357
+
358
+ def build_parser() -> argparse.ArgumentParser:
359
+ parser = argparse.ArgumentParser(
360
+ prog="publish_owner_projection.py",
361
+ description=(
362
+ "Publish the typed owner projection the workbench reads (R12)."
363
+ ),
364
+ )
365
+ parser.add_argument("run_dir", help="The run root containing evidence/.")
366
+ parser.add_argument(
367
+ "--project-root", required=True, help="Target repository root."
368
+ )
369
+ parser.add_argument("--run-id", required=True)
370
+ parser.add_argument("--owner", required=True, help="Owner name (never invented).")
371
+ parser.add_argument("--ledger", required=True, help="Evaluator ledger text file.")
372
+ parser.add_argument("--findings", default=None, help="Optional findings JSON.")
373
+ parser.add_argument(
374
+ "--runner", default="", help="Owner runner description (optional)."
375
+ )
376
+ parser.add_argument(
377
+ "--project-id",
378
+ default=None,
379
+ help=(
380
+ "Workbench project id to bind. Omit when the workbench project is "
381
+ "not the owner's project id (the workbench then skips the check)."
382
+ ),
383
+ )
384
+ parser.add_argument(
385
+ "--stdout",
386
+ action="store_true",
387
+ help="Print the document instead of writing it (dry run).",
388
+ )
389
+ return parser
390
+
391
+
392
+ def main(argv: list[str] | None = None) -> int:
393
+ args = build_parser().parse_args(argv)
394
+ try:
395
+ run_dir = Path(args.run_dir)
396
+ entries = read_manifest(run_dir)
397
+ ledger_text = _read_text(args.ledger)
398
+ findings = (
399
+ json.loads(_read_text(args.findings)) if args.findings else None
400
+ )
401
+ document = build_projection(
402
+ run_id=args.run_id,
403
+ owner=args.owner,
404
+ project_id=args.project_id,
405
+ runner=args.runner,
406
+ entries=entries,
407
+ ledger_text=ledger_text,
408
+ findings=findings,
409
+ )
410
+ if args.stdout:
411
+ print(json.dumps(document, ensure_ascii=False, indent=2))
412
+ return 0
413
+ target = publish(Path(args.project_root), args.run_id, document)
414
+ except ProjectionPublishError as error:
415
+ print(f"publish_owner_projection: refused: {error}", file=sys.stderr)
416
+ return 2
417
+ except (OSError, json.JSONDecodeError) as error:
418
+ print(f"publish_owner_projection: {error}", file=sys.stderr)
419
+ return 3
420
+ print(
421
+ f"published {document['owner']} run {document['runId']} "
422
+ f"({len(document['criteria'])} criteria, "
423
+ f"{len(document['evidence'])} evidence) -> {target}"
424
+ )
425
+ return 0
426
+
427
+
428
+ if __name__ == "__main__": # pragma: no cover - CLI entry
429
+ raise SystemExit(main())