design-playbook 0.25.2 → 0.25.4
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 +12 -1
- package/codex/AGENTS.md +1 -1
- package/commands/component-distill.md +8 -0
- package/commands/design-io.md +8 -0
- package/commands/run-handoff.md +8 -0
- package/commands/run-review.md +8 -0
- package/commands/run-status.md +8 -0
- package/commands/ui-review.md +8 -0
- package/commands/ux-spec.md +8 -0
- package/mcp/evidence/capture_runtime.py +19 -8
- package/package.json +1 -1
- package/scripts/project_target.py +403 -0
- package/scripts/publish_owner_projection.py +434 -0
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`
|
|
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
|
@@ -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.
|
package/commands/design-io.md
CHANGED
|
@@ -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.
|
package/commands/run-handoff.md
CHANGED
|
@@ -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.
|
package/commands/run-review.md
CHANGED
|
@@ -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.
|
package/commands/run-status.md
CHANGED
|
@@ -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.
|
package/commands/ui-review.md
CHANGED
|
@@ -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.
|
package/commands/ux-spec.md
CHANGED
|
@@ -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
|
|
339
|
-
|
|
340
|
-
cwd is the repo root, so captures
|
|
341
|
-
A per-call ``run_root`` is
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.25.4",
|
|
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,403 @@
|
|
|
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
|
+
# One pipe-encoding seam (T-105): UTF-8 on piped stdout/stderr
|
|
398
|
+
# regardless of the host code page. See scripts/stdio_encoding.py.
|
|
399
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
400
|
+
from design_playbook.scripts.stdio_encoding import configure_piped_utf8
|
|
401
|
+
|
|
402
|
+
configure_piped_utf8()
|
|
403
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,434 @@
|
|
|
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
|
+
# One pipe-encoding seam (T-105): UTF-8 on piped stdout/stderr
|
|
430
|
+
# regardless of the host code page. See scripts/stdio_encoding.py.
|
|
431
|
+
from design_playbook.scripts.stdio_encoding import configure_piped_utf8
|
|
432
|
+
|
|
433
|
+
configure_piped_utf8()
|
|
434
|
+
raise SystemExit(main())
|