@plainconceptsplatform/workflows 1.17.0 → 1.18.0

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.
@@ -0,0 +1,91 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/capture-mockup/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: Capture mockup
3
+ description: >
4
+ Checks a UX mockup folder (index.html and mockup.json, written by the harness skill
5
+ pc-plan-mockup) against its contract and, when it holds, renders it in headless Chromium at
6
+ desktop and mobile widths. Writes index.html, mockup.json, desktop.png and mobile.png to the
7
+ output folder. A mockup that breaks the contract is reported and not rendered. No model, and the
8
+ browser runs with no network.
9
+ inputs:
10
+ mockup-dir:
11
+ description: Folder holding index.html and mockup.json.
12
+ required: true
13
+ out-dir:
14
+ description: Where the rendered folder is written.
15
+ required: true
16
+ playwright-version:
17
+ description: playwright-core version. Must match the mcr.microsoft.com/playwright image tag the render runs in.
18
+ required: false
19
+ default: '1.62.1'
20
+ outputs:
21
+ valid:
22
+ description: true when the mockup met the contract and was rendered.
23
+ value: ${{ steps.render.outputs.valid }}
24
+ problems:
25
+ description: Why the mockup was refused, one reason per line. Empty when valid.
26
+ value: ${{ steps.check.outputs.problems }}
27
+ title:
28
+ description: The mockup's title from mockup.json.
29
+ value: ${{ steps.check.outputs.title }}
30
+ runs:
31
+ using: composite
32
+ steps:
33
+ - name: Check the mockup against its contract
34
+ id: check
35
+ shell: bash
36
+ env:
37
+ MOCKUP_DIR: ${{ inputs.mockup-dir }}
38
+ ACTION_DIR: ${{ github.action_path }}
39
+ run: |
40
+ set -euo pipefail
41
+ report="$(node "$ACTION_DIR/capture.mjs" validate "$MOCKUP_DIR")"
42
+ valid="$(jq -r '.valid' <<<"$report")"
43
+ echo "valid=$valid" >> "$GITHUB_OUTPUT"
44
+ echo "title=$(jq -r '.mockup.title // ""' <<<"$report" | tr -d '\n')" >> "$GITHUB_OUTPUT"
45
+ {
46
+ echo "problems<<MOCKUP_EOF"
47
+ jq -r '.problems[]' <<<"$report"
48
+ echo "MOCKUP_EOF"
49
+ } >> "$GITHUB_OUTPUT"
50
+ if [ "$valid" != "true" ]; then
51
+ jq -r '.problems[] | "::warning::mockup refused: " + .' <<<"$report"
52
+ fi
53
+
54
+ - name: Render the mockup
55
+ id: render
56
+ shell: bash
57
+ env:
58
+ VALID: ${{ steps.check.outputs.valid }}
59
+ MOCKUP_DIR: ${{ inputs.mockup-dir }}
60
+ OUT_DIR: ${{ inputs.out-dir }}
61
+ PLAYWRIGHT_VERSION: ${{ inputs.playwright-version }}
62
+ ACTION_DIR: ${{ github.action_path }}
63
+ run: |
64
+ set -euo pipefail
65
+ if [ "$VALID" != "true" ]; then
66
+ echo "valid=false" >> "$GITHUB_OUTPUT"
67
+ exit 0
68
+ fi
69
+ runner="$RUNNER_TEMP/capture-mockup"
70
+ mkdir -p "$runner" "$OUT_DIR"
71
+ # A copy beside its node_modules: ESM resolves bare imports from the file's own directory.
72
+ cp "$ACTION_DIR/capture.mjs" "$runner/capture.mjs"
73
+ if [ ! -d "$runner/node_modules/playwright-core" ]; then
74
+ npm install --prefix "$runner" --no-save --no-package-lock --no-audit --no-fund \
75
+ "playwright-core@${PLAYWRIGHT_VERSION}"
76
+ fi
77
+ src="$(cd "$MOCKUP_DIR" && pwd)"
78
+ out="$(cd "$OUT_DIR" && pwd)"
79
+ if command -v docker > /dev/null 2>&1; then
80
+ # The Playwright image carries Chromium and its libraries; the runner has neither. No
81
+ # network at all: the HTML was written by an agent and has no reason to reach anything.
82
+ docker run --rm --network none --ipc host \
83
+ --user "$(id -u):$(id -g)" -e HOME=/tmp \
84
+ -v "$runner:$runner:ro" -v "$src:$src:ro" -v "$out:$out" \
85
+ "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" \
86
+ node "$runner/capture.mjs" render "$src" "$out"
87
+ else
88
+ node "$runner/node_modules/playwright-core/cli.js" install --only-shell chromium
89
+ node "$runner/capture.mjs" render "$src" "$out"
90
+ fi
91
+ echo "valid=true" >> "$GITHUB_OUTPUT"
@@ -0,0 +1,132 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/actions/capture-mockup/capture.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ //
3
+ // Checks a UX mockup against the contract in the agent harness (pc-plan-mockup's
4
+ // mockup-contract.md) and renders it to PNG. No model runs here: the mockup was written by the
5
+ // refine agent or by a person with /plan-mockup, and this only checks and photographs it.
6
+ //
7
+ // node capture.mjs validate <mockup-dir>
8
+ // prints {"valid","problems","mockup"} as JSON and exits 0 either way
9
+ // node capture.mjs render <mockup-dir> <out-dir>
10
+ // copies index.html and mockup.json to <out-dir> and writes desktop.png and mobile.png there
11
+ //
12
+ // The HTML is agent-written, so the browser runs with every request outside the file refused,
13
+ // and the action runs it in a container with no network at all.
14
+
15
+ import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { pathToFileURL } from "node:url";
18
+
19
+ export const MAX_HTML_BYTES = 400 * 1024;
20
+ export const BANNER = "UX mockup: intent, not a spec";
21
+ export const VIEWPORTS = [
22
+ { file: "desktop.png", width: 1440, height: 900 },
23
+ { file: "mobile.png", width: 390, height: 844 },
24
+ ];
25
+ const SECTION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
26
+ const STATES = new Set(["default", "empty", "loading", "error", "success", "disabled", "mobile"]);
27
+
28
+ const attributeValues = (html, name) =>
29
+ [...html.matchAll(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "gi"))].map((match) => match[1].trim());
30
+
31
+ // What the contract forbids, as the reasons a person reading the run would want.
32
+ export function htmlProblems(html) {
33
+ const problems = [];
34
+ if (Buffer.byteLength(html, "utf8") > MAX_HTML_BYTES) problems.push(`index.html is over ${MAX_HTML_BYTES / 1024} KB`);
35
+ if (/\b(?:src|href|action|poster|srcset)\s*=\s*["']?\s*(?:https?:)?\/\//i.test(html)) problems.push("index.html loads something from another host");
36
+ if (/url\(\s*["']?\s*(?:https?:)?\/\//i.test(html) || /@import\s+(?:url\()?\s*["']?\s*(?:https?:)?\/\//i.test(html)) problems.push("index.html styles load something from another host");
37
+ if (/<script\b[^>]*\bsrc\s*=/i.test(html)) problems.push("index.html loads a script file");
38
+ if (/<link\b[^>]*\brel\s*=\s*["']?stylesheet/i.test(html)) problems.push("index.html links a stylesheet file");
39
+ if (!html.includes(BANNER)) problems.push(`index.html has no "${BANNER}" banner`);
40
+ return problems;
41
+ }
42
+
43
+ export function mockupProblems(mockup, html) {
44
+ const problems = [];
45
+ if (mockup === null || typeof mockup !== "object" || Array.isArray(mockup)) return ["mockup.json is not an object"];
46
+ if (mockup.version !== 1) problems.push("mockup.json version is not 1");
47
+ if (typeof mockup.title !== "string" || !mockup.title.trim()) problems.push("mockup.json has no title");
48
+ const sections = Array.isArray(mockup.sections) ? mockup.sections : [];
49
+ if (sections.length === 0) problems.push("mockup.json lists no sections");
50
+ const ids = sections.map((section) => section?.id);
51
+ for (const id of ids) {
52
+ if (typeof id !== "string" || !SECTION_ID.test(id)) problems.push(`section id ${JSON.stringify(id)} is not kebab-case`);
53
+ }
54
+ const inHtml = attributeValues(html, "data-mockup-section");
55
+ if (inHtml.length === 0) problems.push("index.html has no data-mockup-section");
56
+ const missing = ids.filter((id) => typeof id === "string" && !inHtml.includes(id));
57
+ if (missing.length > 0) problems.push(`sections missing from index.html: ${missing.join(", ")}`);
58
+ const undeclared = [...new Set(inHtml)].filter((id) => !ids.includes(id));
59
+ if (undeclared.length > 0) problems.push(`sections in index.html but not in mockup.json: ${undeclared.join(", ")}`);
60
+ const components = Array.isArray(mockup.components) ? mockup.components : [];
61
+ if (components.length === 0) problems.push("mockup.json maps no components");
62
+ for (const entry of components) {
63
+ if (!entry || typeof entry.component !== "string" || typeof entry.path !== "string" || !entry.component || !entry.path) {
64
+ problems.push(`component entry ${JSON.stringify(entry)} needs a component and a path`);
65
+ } else if (entry.section !== undefined && !ids.includes(entry.section)) {
66
+ problems.push(`component ${entry.component} names unknown section ${entry.section}`);
67
+ }
68
+ }
69
+ if (attributeValues(html, "data-component").length === 0) problems.push("index.html has no data-component");
70
+ for (const state of Array.isArray(mockup.states) ? mockup.states : []) {
71
+ if (typeof state !== "string" || (!STATES.has(state) && !SECTION_ID.test(state))) problems.push(`state ${JSON.stringify(state)} is not a known or kebab-case state`);
72
+ }
73
+ return problems;
74
+ }
75
+
76
+ export async function validate(dir) {
77
+ let html;
78
+ try {
79
+ html = await readFile(join(dir, "index.html"), "utf8");
80
+ } catch {
81
+ return { valid: false, problems: ["index.html is missing"], mockup: null };
82
+ }
83
+ let mockup;
84
+ try {
85
+ mockup = JSON.parse(await readFile(join(dir, "mockup.json"), "utf8"));
86
+ } catch {
87
+ return { valid: false, problems: ["mockup.json is missing or not JSON", ...htmlProblems(html)], mockup: null };
88
+ }
89
+ const problems = [...htmlProblems(html), ...mockupProblems(mockup, html)];
90
+ return { valid: problems.length === 0, problems, mockup };
91
+ }
92
+
93
+ export async function render(dir, out) {
94
+ const { chromium } = await import("playwright-core");
95
+ await mkdir(out, { recursive: true });
96
+ await copyFile(join(dir, "index.html"), join(out, "index.html"));
97
+ await copyFile(join(dir, "mockup.json"), join(out, "mockup.json"));
98
+ const page_url = pathToFileURL(join(out, "index.html")).href;
99
+ const browser = await chromium.launch({ headless: true });
100
+ try {
101
+ for (const viewport of VIEWPORTS) {
102
+ const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, javaScriptEnabled: true });
103
+ // The contract already refuses remote URLs; this makes a slip harmless rather than a request.
104
+ await context.route("**/*", (route) => (route.request().url().startsWith("file:") ? route.continue() : route.abort()));
105
+ const page = await context.newPage();
106
+ await page.goto(page_url, { waitUntil: "load", timeout: 30000 });
107
+ await page.screenshot({ path: join(out, viewport.file), type: "png", fullPage: true });
108
+ await context.close();
109
+ }
110
+ } finally {
111
+ await browser.close();
112
+ }
113
+ for (const viewport of VIEWPORTS) await stat(join(out, viewport.file));
114
+ }
115
+
116
+ async function main(argv) {
117
+ const [mode, dir, out] = argv;
118
+ if (mode === "validate" && dir) {
119
+ process.stdout.write(`${JSON.stringify(await validate(dir))}\n`);
120
+ return 0;
121
+ }
122
+ if (mode === "render" && dir && out) {
123
+ await render(dir, out);
124
+ return 0;
125
+ }
126
+ process.stderr.write("usage: capture.mjs validate <mockup-dir> | render <mockup-dir> <out-dir>\n");
127
+ return 2;
128
+ }
129
+
130
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
131
+ process.exitCode = await main(process.argv.slice(2));
132
+ }
@@ -0,0 +1,72 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/load-mockup/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: Load mockup
3
+ description: >
4
+ Copies an issue's published UX mockup from the mockups branch into a local folder for the agent
5
+ to read. The folder is the one the issue's mockup section names (a split child names its
6
+ parent's), else issue-<number>. Writes nothing and reports found=false when there is no mockup.
7
+ inputs:
8
+ token:
9
+ description: Token with contents read on this repository.
10
+ required: true
11
+ issue-number:
12
+ description: Issue whose mockup to load.
13
+ required: true
14
+ branch:
15
+ description: Branch the mockups are published on.
16
+ required: false
17
+ default: mockups
18
+ out-dir:
19
+ description: Folder the mockup files are written to.
20
+ required: true
21
+ outputs:
22
+ found:
23
+ description: true when a mockup was copied.
24
+ value: ${{ steps.load.outputs.found }}
25
+ folder:
26
+ description: The branch folder it came from, such as issue-42.
27
+ value: ${{ steps.load.outputs.folder }}
28
+ runs:
29
+ using: composite
30
+ steps:
31
+ - name: Copy the mockup
32
+ id: load
33
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
34
+ env:
35
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
36
+ BRANCH: ${{ inputs.branch }}
37
+ OUT_DIR: ${{ inputs.out-dir }}
38
+ with:
39
+ github-token: ${{ inputs.token }}
40
+ script: |
41
+ const fs = require('fs');
42
+ const path = require('path');
43
+ const issue_number = Number(process.env.ISSUE_NUMBER);
44
+ const ref = process.env.BRANCH;
45
+ const { data: issue } = await github.rest.issues.get({ ...context.repo, issue_number });
46
+ const named = /<!-- agent-mockup: (issue-\d+) -->/.exec(issue.body || '');
47
+ const folder = named ? named[1] : `issue-${issue_number}`;
48
+ let entries;
49
+ try {
50
+ ({ data: entries } = await github.rest.repos.getContent({ ...context.repo, path: folder, ref }));
51
+ } catch (error) {
52
+ if (error.status !== 404) throw error;
53
+ core.info(`No mockup at ${ref}:${folder}.`);
54
+ core.setOutput('found', 'false');
55
+ return;
56
+ }
57
+ const files = (Array.isArray(entries) ? entries : []).filter((entry) => entry.type === 'file');
58
+ if (!files.some((entry) => entry.name === 'index.html')) {
59
+ core.info(`${ref}:${folder} has no index.html.`);
60
+ core.setOutput('found', 'false');
61
+ return;
62
+ }
63
+ fs.mkdirSync(process.env.OUT_DIR, { recursive: true });
64
+ // The blob API returns base64 for any size, where the contents API leaves files over
65
+ // 1 MB empty and a full-page screenshot can be larger than that.
66
+ for (const entry of files) {
67
+ const { data } = await github.rest.git.getBlob({ ...context.repo, file_sha: entry.sha });
68
+ fs.writeFileSync(path.join(process.env.OUT_DIR, entry.name), Buffer.from(data.content, 'base64'));
69
+ }
70
+ core.info(`Loaded ${files.length} file(s) from ${ref}:${folder}.`);
71
+ core.setOutput('found', 'true');
72
+ core.setOutput('folder', folder);
@@ -792,6 +792,9 @@ for ci in "${WORKFLOWS_DIR}/ci.yml" "${WORKFLOWS_DIR}/app-ci.yml"; do
792
792
  done
793
793
  for template in "${HERE}/../../templates/ci/"*.yml; do
794
794
  [ -f "$template" ] || continue
795
+ # Only a template that runs on pull requests has a verdict to hand over. The weekly mutation
796
+ # run is deliberately never on a pull request (quality-mutation.yml), so it has none.
797
+ grep -qE '^ pull_request(_target)?:' "$template" || continue
795
798
  if grep -q 'operation=merge-train' "$template" && ! grep -q "pull_request.user.type == 'Bot'" "$template"; then
796
799
  PASS=$((PASS + 1))
797
800
  else
@@ -2265,6 +2268,70 @@ fi
2265
2268
 
2266
2269
  if [ "$EV_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2267
2270
 
2271
+ echo "── UX mockups ────────────────────────────────────────────────────────────"
2272
+
2273
+ # Refine draws a mockup (harness skill pc-plan-mockup) for frontend stories; implement reads it as
2274
+ # intent. The mockup is agent-written HTML, so it is rendered where nothing can be written and
2275
+ # published where nothing from the repository runs, exactly like evidence's screenshots.
2276
+ MK_OK=1
2277
+ mk() {
2278
+ grep -qE "$1" "$2" || { MK_OK=0; echo "FAIL: mockups: $3" >&2; }
2279
+ }
2280
+ for action in capture-mockup load-mockup; do
2281
+ [ -f "${HERE}/../${action}/action.yml" ] || { MK_OK=0; echo "FAIL: mockups: the ${action} action is missing" >&2; }
2282
+ done
2283
+ [ -f "${HERE}/../capture-mockup/capture.mjs" ] || { MK_OK=0; echo "FAIL: mockups: capture-mockup has no capture.mjs" >&2; }
2284
+ grep -q -- '--network none' "${HERE}/../capture-mockup/action.yml" ||
2285
+ { MK_OK=0; echo "FAIL: mockups: the render must run with no network; the HTML is agent-written" >&2; }
2286
+
2287
+ if worker_installed refine; then
2288
+ REFINE_WORKER_MD="${WORKFLOWS_DIR}/agent-refine.md"
2289
+ for key in MOCKUP_PATHS MOCKUP_BRANCH MOCKUP_DIR MOCKUP_PLAYWRIGHT_VERSION MOCKUP_INTENT_LINE; do
2290
+ mk "^ ${key}: " "$REFINE_WORKER_MD" "agent-refine.md env does not declare ${key}"
2291
+ done
2292
+ mk 'skill\("pc-plan-mockup"\)' "$REFINE_WORKER_MD" 'refine never calls pc-plan-mockup'
2293
+ mk 'uses: \./\.github/actions/load-mockup' "$REFINE_WORKER_MD" 'refine does not load an existing mockup for the agent'
2294
+ mk 'Mockup sections: <ids>' "$REFINE_WORKER_MD" 'split children are not told to name their mockup sections'
2295
+ render_job="$(sed -n '/^ mockup_render:/,/^ mockup_publish:/p' "$REFINE_WORKER_MD")"
2296
+ publish_job="$(sed -n '/^ mockup_publish:/,/^ conclude:/p' "$REFINE_WORKER_MD")"
2297
+ if [ -z "$render_job" ] || [ -z "$publish_job" ]; then
2298
+ MK_OK=0; echo "FAIL: mockups: refine has no mockup_render and mockup_publish jobs" >&2
2299
+ fi
2300
+ # A custom job that does not need the agent is hoisted before it by gh-aw, and publish then
2301
+ # closes a cycle through render: the compiler refuses the whole worker.
2302
+ grep -qE '^ needs: \[activation, agent, mockup_render\]' <<<"$publish_job" ||
2303
+ { MK_OK=0; echo "FAIL: mockups: mockup_publish must need agent, or gh-aw hoists it into a cycle" >&2; }
2304
+ if grep -qE 'secrets\.|contents: write|issues: write' <<<"$render_job"; then
2305
+ MK_OK=0; echo "FAIL: mockups: mockup_render runs agent-written HTML and must hold no secret and no write" >&2
2306
+ fi
2307
+ if grep -qE 'actions/checkout|uses: \./' <<<"$publish_job"; then
2308
+ MK_OK=0; echo "FAIL: mockups: mockup_publish holds a write token and must not check out or run repository code" >&2
2309
+ fi
2310
+ grep -qF 'git push -q origin "HEAD:refs/heads/$BRANCH"' <<<"$publish_job" ||
2311
+ { MK_OK=0; echo "FAIL: mockups: the mockup must be pushed to MOCKUP_BRANCH and nowhere else" >&2; }
2312
+ for job in "$render_job" "$publish_job"; do
2313
+ grep -q 'continue-on-error: true' <<<"$job" ||
2314
+ { MK_OK=0; echo "FAIL: mockups: a mockup job must never fail the refinement" >&2; }
2315
+ done
2316
+ conclude_needs="$(grep -E '^ needs: \[activation, agent, safe_outputs, validate_output' "$REFINE_WORKER_MD" | grep 'mockup_publish' || true)"
2317
+ [ -n "$conclude_needs" ] || { MK_OK=0; echo "FAIL: mockups: conclude must wait for mockup_publish to stamp the section" >&2; }
2318
+ mk 'name: Record the mockup on the issue' "$REFINE_WORKER_MD" 'conclude never records the mockup on the issue'
2319
+ fi
2320
+
2321
+ if worker_installed implement; then
2322
+ mk 'uses: \./\.github/actions/load-mockup' "$IMPLEMENT_WORKER_MD" 'implement does not load the mockup for the agent'
2323
+ mk 'The UX mockup is intent, not a spec' "$IMPLEMENT_WORKER_MD" 'implement does not say the mockup is intent, not a spec'
2324
+ mk 'Never copy its markup' "$IMPLEMENT_WORKER_MD" 'implement does not forbid copying the mockup markup'
2325
+ fi
2326
+ if worker_installed merge-gate; then
2327
+ mk 'looks different from it is never a finding' "$MERGE_GATE_WORKER_MD" 'merge-gate may count a visual difference from the mockup as a finding'
2328
+ mk 'never the mockup' "$MERGE_GATE_WORKER_MD" 'merge-gate may judge acceptance criteria against the mockup'
2329
+ fi
2330
+ [ -f "${HERE}/../../templates/quality-gates/.semgrep/platform-mockup-leak.yml" ] ||
2331
+ { MK_OK=0; echo "FAIL: mockups: the Semgrep rule that catches copied mockup markup is missing" >&2; }
2332
+
2333
+ if [ "$MK_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2334
+
2268
2335
  echo "── Expression functions ──────────────────────────────────────────────────"
2269
2336
 
2270
2337
  # GitHub's expression language has eleven functions and no more. There is no `split()`, no
@@ -3671,7 +3738,9 @@ if worker_installed implement; then
3671
3738
  STACK_OK=0
3672
3739
  echo "FAIL: agent-implement.md declares allowed-base-branches more than once or not at all" >&2
3673
3740
  fi
3674
- if ! sed -n '/allowed-base-branches:/,/^ [a-z]/p' "$IMPLEMENT_WORKER_MD" | grep -q '"main"'; then
3741
+ # `grep -q` would exit on the first match and SIGPIPE the sed still writing, which pipefail
3742
+ # reports as a failure: the check passed or failed on timing. Read the whole block instead.
3743
+ if ! sed -n '/allowed-base-branches:/,/^ [a-z]/p' "$IMPLEMENT_WORKER_MD" | grep '"main"' >/dev/null; then
3675
3744
  STACK_OK=0
3676
3745
  echo "FAIL: agent-implement.md allowed-base-branches dropped the default branch" >&2
3677
3746
  fi
@@ -253,6 +253,10 @@ estimate of 8 or more is split into children of 3 to 5 points, and each child wa
253
253
  alone. A slice smaller than 3 points is a fragment and is folded into a sibling.
254
254
  The refined story is wrapped in the repository's own issue form, and when questions remain the
255
255
  worker leaves a temporal draft on the issue and asks everything in one batched comment.
256
+ A frontend story gets a UX mockup first: the one a person published with `/plan-mockup`, or one the
257
+ harness skill `pc-plan-mockup` draws from the repository's own tokens and components. Two jobs with
258
+ no model render it and push it to the `mockups` branch, and conclude stamps the picture, the link
259
+ and the component table on the issue, and each child's own sections on each child.
256
260
 
257
261
  ```mermaid
258
262
  flowchart TD
@@ -262,7 +266,13 @@ flowchart TD
262
266
  refExplore("Explore (rung 5)<br/>one work unit at a time,<br/>self-answer from the code,<br/>at most 5 questions each") --> refClassify
263
267
  refClassify{"Trivial?<br/>every TRIVIAL_CRITERIA<br/>condition holds"}
264
268
  refClassify -->|yes| refTrivial
265
- refClassify -->|no| refStory
269
+ refClassify -->|no| refUi
270
+ refUi{"Frontend?<br/>a work unit matches MOCKUP_PATHS"}
271
+ refUi -->|yes| refMockup
272
+ refUi -->|no| refStory
273
+ refMockup("Mockup (rung 5)<br/>keep the published one,<br/>or pc-plan-mockup draws it") --> refStory
274
+ refMockup -.->|"after the agent"| refPublish
275
+ refPublish("Render and publish (no model)<br/>PNG at 1440 and 390 on mockups,<br/>section stamped on issue and children")
266
276
  refTrivial("Trivial plan<br/>marker, summary, checklist<br/>no Gherkin, no diagram") --> refEstimate
267
277
  refStory("Story<br/>Given/When/Then per work unit,<br/>grounded in the code") --> refWrap
268
278
  refStory -.->|"cannot ground it"| refFail
@@ -290,8 +300,8 @@ flowchart TD
290
300
  classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a1a
291
301
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
292
302
  class refStart start
293
- class refReserve,refFacts,refExplore,refTrivial,refStory,refWrap,refProse,refStack action
294
- class refClassify,refEstimate,refSplit,refOutcome decision
303
+ class refReserve,refFacts,refExplore,refMockup,refPublish,refTrivial,refStory,refWrap,refProse,refStack action
304
+ class refClassify,refUi,refEstimate,refSplit,refOutcome decision
295
305
  class refFail failure
296
306
  class refDone,refAsk,refChildren success
297
307
  ```
@@ -302,6 +312,8 @@ flowchart TD
302
312
 
303
313
  Writes the code and opens one pull request. It stops there: the merge decision belongs to the gate,
304
314
  because waiting for CI inside this run would hold a fleet machine doing nothing.
315
+ A UX mockup on the issue is read as intent: the agent builds with the components its table names
316
+ and never copies its markup, which the Semgrep rule `platform-mockup-markup-in-code` would fail.
305
317
 
306
318
  ```mermaid
307
319
  flowchart TD
@@ -310,7 +322,7 @@ flowchart TD
310
322
  implEligible -.->|no| implIdle
311
323
  implEligible -->|yes| implReserve
312
324
  implReserve("Reserve (rung 4)<br/>bot-working") --> implFacts
313
- implFacts("Facts (rung 3)<br/>Issue and comments to disk") --> implCheck
325
+ implFacts("Facts (rung 3)<br/>Issue, comments and its UX mockup,<br/>if refine made one, to disk") --> implCheck
314
326
  implCheck{"Trivial marker<br/>left by refine?"}
315
327
  implCheck -->|yes| implTodos
316
328
  implCheck -->|no| implCode
@@ -170,7 +170,12 @@ jobs:
170
170
  runs-on: agents-arc
171
171
  steps:
172
172
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
173
- - run: docker run --rm -v "${{ github.workspace }}:/src" -w /src "$SEMGREP_IMAGE" semgrep scan --error --metrics=off --oss-only --disable-version-check --config p/default --config p/security-audit --config p/secrets --config p/typescript --config p/react --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
173
+ # `.semgrep/` holds the family rules (empty catch blocks, mockup markup in code) when the
174
+ # quality-gates template is installed; without it the scan runs the registry rules alone.
175
+ - run: |
176
+ local_rules=()
177
+ [ -d .semgrep ] && local_rules=(--config .semgrep/)
178
+ docker run --rm -v "${{ github.workspace }}:/src" -w /src "$SEMGREP_IMAGE" semgrep scan --error --metrics=off --oss-only --disable-version-check --config p/default --config p/security-audit --config p/secrets --config p/typescript --config p/react "${local_rules[@]}" --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
174
179
 
175
180
  sbom:
176
181
  name: SBOM (CycloneDX)
@@ -0,0 +1,45 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/.semgrep/platform-mockup-leak.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # A UX mockup (the harness skill pc-plan-mockup, published on the `mockups` branch) states intent,
3
+ # not a spec. The code that implements it is built from the repository's own components, never
4
+ # from the mockup's markup. Its markers are the tell that markup was copied instead: every region
5
+ # carries `data-mockup-section`, and every component stand-in a `data-component` naming a source
6
+ # path, a package export or `new:<Name>`. Run by the blocking Semgrep scan via `--config .semgrep/`.
7
+ #
8
+ # A `data-component` attribute an application uses for its own reasons is left alone: only the
9
+ # mockup's reference forms are flagged. There is no exemption comment by design; rebuild the part
10
+ # from the components the mockup's table names.
11
+ rules:
12
+ - id: platform-mockup-markup-in-code
13
+ languages: [generic]
14
+ severity: ERROR
15
+ message: >-
16
+ UX mockup markup in the code: a mockup states intent, not a spec. Build this part with the
17
+ real component the mockup's component table names, and remove the mockup's
18
+ `data-mockup-section` and `data-component` attributes and its styles.
19
+ metadata:
20
+ category: hygiene
21
+ paths:
22
+ include:
23
+ - "*.ts"
24
+ - "*.tsx"
25
+ - "*.js"
26
+ - "*.jsx"
27
+ - "*.mjs"
28
+ - "*.html"
29
+ - "*.cshtml"
30
+ - "*.razor"
31
+ - "*.vue"
32
+ - "*.svelte"
33
+ - "*.css"
34
+ - "*.scss"
35
+ exclude:
36
+ - ".mockups/"
37
+ - "node_modules/"
38
+ - ".agents/"
39
+ - ".opencode/"
40
+ - ".github/"
41
+ - "dist/"
42
+ - "out/"
43
+ pattern-either:
44
+ - pattern-regex: data-mockup-section\s*=
45
+ - pattern-regex: data-component\s*=\s*["'{`]?\s*(new:|@[\w.-]+/|[\w.-]+/[\w./-]+\.(tsx|ts|jsx|js|vue|svelte|cshtml|razor))
@@ -79,6 +79,10 @@ env:
79
79
  RETRY_UNDER_MINUTES: "6"
80
80
  INCOMPLETE_COMMENT: "Automated implementation ran and ended without an outcome. The issue is released and flagged for review: a run that got this far and still failed will fail the same way again."
81
81
  ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/implementation-context.json
82
+ # The UX mockup refine or a person published for this issue (a split child reads its parent's),
83
+ # copied here before the agent starts. Context only: the prompt says how it may be used.
84
+ MOCKUP_BRANCH: mockups
85
+ MOCKUP_DIR: /tmp/gh-aw/agent/mockup
82
86
  GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
83
87
  description: |
84
88
  Implements an issue and opens a pull request. Stops there: the merge decision belongs to
@@ -671,6 +675,14 @@ steps:
671
675
  issue-number: ${{ inputs.issue-number }}
672
676
  output-path: ${{ env.ISSUE_CONTEXT_PATH }}
673
677
 
678
+ - name: Load the issue's mockup for the agent
679
+ uses: ./.github/actions/load-mockup
680
+ with:
681
+ token: ${{ github.token }}
682
+ issue-number: ${{ inputs.issue-number }}
683
+ branch: ${{ env.MOCKUP_BRANCH }}
684
+ out-dir: ${{ env.MOCKUP_DIR }}
685
+
674
686
  # Done here, deterministically, because a branch is not a judgement. A local branch needs no
675
687
  # credentials, so this works in the sandbox; the agent only commits to it. A leftover local
676
688
  # branch of the same name (a retried job on a reused workspace) is switched to, never reset.
@@ -820,6 +832,16 @@ timeout-minutes: 180
820
832
  Adhere to ${{ env.REPO_RULES }}, ${{ env.ARCHITECTURE_RULES }} and
821
833
  ${{ env.TESTING_RULES }}.
822
834
 
835
+ **The UX mockup is intent, not a spec**, on both paths. When `${{ env.MOCKUP_DIR }}/index.html`
836
+ exists, the issue was refined with a mockup: read `mockup.json` there, and the HTML or
837
+ `desktop.png` when the layout matters. It tells you the flow, the regions, the states and,
838
+ in its component table, which real component each part of the screen is built with (`new:`
839
+ marks one this change creates). Build with those components and the repository's own
840
+ `DESIGN.md`, styles and conventions. Never copy its markup, its CSS or its `data-mockup-section`
841
+ and `data-component` attributes into the code, and never add the mockup to the repository: a
842
+ gate fails the pull request that does. Where the mockup and an acceptance criterion
843
+ disagree, the criterion wins.
844
+
823
845
  **WORK IN FEW TURNS**, on both paths. Every turn is a model call of three to twenty
824
846
  seconds, and the turns, not the commands, are most of a run. Read a file whole, or in
825
847
  ranges of a few hundred lines, once; never eight or fifteen lines at a time. Put
@@ -845,10 +867,19 @@ timeout-minutes: 180
845
867
  the code you changed (web: `pnpm exec vitest related --run <changed files>`; .NET:
846
868
  `dotnet test <the test project> --filter <the test classes>`). Do not run integration,
847
869
  end-to-end, mutation or coverage suites here, and do not run the whole test suite to be safe:
848
- the repository's CI runs all of it on your pull request before anything merges, on a runner
849
- that has the database and the browsers this sandbox lacks. The verification commands above are
850
- the repository's quick check for that reason. A full suite in the agent is the slowest part of
851
- a run and repeats what CI does anyway.
870
+ the repository's CI runs them, on a runner that has the database and the browsers this
871
+ sandbox lacks. The verification commands above are the repository's quick check for that
872
+ reason. A full suite in the agent is the slowest part of a run and repeats what CI does
873
+ anyway.
874
+
875
+ **Not running the end-to-end tests does not make them someone else's.** When your change
876
+ moves, renames or removes something a person clicks or reads (a button, a label, a heading, a
877
+ route, a table), or changes what happens after a click, search the end-to-end tests for it
878
+ (their selectors, the text they assert, the routes they open) and update every test that
879
+ uses it in this pull request. Do this even when the repository runs those tests only at night
880
+ or on a schedule: then nothing on the pull request fails, and the suite breaks the morning
881
+ after the merge with no author attached. Pliny-Bot #1506 was ten journeys broken that way by
882
+ Bot Studio changes that merged without their end-to-end tests.
852
883
 
853
884
  **Run them once, on the final tree.** They are slow: Pliny-Bot's `pnpm verify` is about
854
885
  twenty minutes of work on this runner. `pc-plan-apply` runs them in its verify step, and
@@ -1182,6 +1182,10 @@ timeout-minutes: 120
1182
1182
  It is an attention list, not a verdict. Touching one of those areas is not a finding. A
1183
1183
  defect you can demonstrate in one of them is.
1184
1184
 
1185
+ A UX mockup on the issue (its `## UX mockup` section) states intent, not a spec. A screen
1186
+ that looks different from it is never a finding. Mockup markup copied into the code is one:
1187
+ `data-mockup-section` or `data-component` attributes, or the mockup's inline styles.
1188
+
1185
1189
  **5b. Have each candidate verified independently.** Do not be the one who checks your own
1186
1190
  work. Hand each candidate off for verification as a claim on its own: the file, the line,
1187
1191
  what you think is wrong, and what would settle it. Do not pass on the reasoning that produced
@@ -1201,7 +1205,9 @@ timeout-minutes: 120
1201
1205
  reassurance about their absence.
1202
1206
 
1203
1207
  **5c. Check the acceptance criteria.** The issue context at `${{ env.ISSUE_CONTEXT_PATH }}`
1204
- says what this change was supposed to do. Confirm the diff does it. The double-check the
1208
+ says what this change was supposed to do. Confirm the diff does it. The acceptance criteria
1209
+ are the test, never the mockup: a criterion is unmet by behaviour, not by a look that differs
1210
+ from the picture. The double-check the
1205
1211
  rest of this step asks you to do is the gate's own: a defect an agent did not notice and CI
1206
1212
  did not catch is a more expensive rollback than a wrong auto-merge.
1207
1213
 
@@ -25,6 +25,15 @@ env:
25
25
  # tools, so a repository built on anything else can never match it and always takes the long,
26
26
  # expensive path. Every condition must hold for a change to be trivial.
27
27
  TRIVIAL_CRITERIA: "It touches 1-3 files: stylesheets, style utility classes, text labels or markup only. No business logic: no services, controllers, domain models, calculations, validations. No data model: no entities, migrations, DTOs, API contracts. No security surface: no auth, authorization, secrets, tokens, permissions. No infrastructure: no deployment templates, containers, CI or deploy configuration. It does not touch shared libraries or multi-team contracts."
28
+ # A standard-path story whose work units change files matching these globs (space-separated)
29
+ # gets a UX mockup from the harness skill pc-plan-mockup before the story is written. Empty
30
+ # turns mockups off. The render and publish jobs put it on MOCKUP_BRANCH at issue-<n>/, and
31
+ # conclude stamps the picture, the link and the component table on the issue.
32
+ MOCKUP_PATHS: "apps/web/**"
33
+ MOCKUP_BRANCH: mockups
34
+ MOCKUP_DIR: /tmp/gh-aw/agent/mockup
35
+ MOCKUP_PLAYWRIGHT_VERSION: "1.62.1"
36
+ MOCKUP_INTENT_LINE: "UX mockup: intent, not a spec. Build with the components listed."
28
37
  REFINE_LABEL: refine
29
38
  REFINED_LABEL: refined
30
39
  WORKING_LABEL: bot-working
@@ -281,8 +290,130 @@ jobs:
281
290
  comment-prefix: ${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}
282
291
  issue-number: ${{ inputs.issue-number }}
283
292
  questions-line: ${{ env.QUESTIONS_LINE }}
284
- conclude:
293
+ # The mockup's two jobs, split like evidence's: the render reads what an agent wrote and runs a
294
+ # browser on it, so it holds no write permission and no secret; the publish job writes, so it
295
+ # checks nothing out and runs nothing from the repository. Neither can fail the refinement: a
296
+ # mockup that breaks its contract, or a push that is refused, leaves the story without a picture.
297
+ mockup_render:
285
298
  needs: [activation, agent, safe_outputs, validate_output]
299
+ if: >
300
+ always() &&
301
+ needs.validate_output.outputs.valid == 'true'
302
+ runs-on: agents-arc
303
+ timeout-minutes: 15
304
+ continue-on-error: true
305
+ permissions:
306
+ contents: read
307
+ outputs:
308
+ rendered: ${{ steps.capture.outputs.valid }}
309
+ steps:
310
+ - name: Checkout workflow actions
311
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
312
+ with:
313
+ persist-credentials: false
314
+ - name: Download agent output
315
+ uses: ./.github/actions/download-agent-output
316
+ with:
317
+ artifact-name: ${{ needs.activation.outputs.artifact_prefix }}agent
318
+ # A new mockup from this pass wins. An existing one is rendered only when it was published
319
+ # without screenshots, which is how /plan-mockup publishes when no browser was at hand.
320
+ - name: Pick the mockup to render
321
+ id: pick
322
+ env:
323
+ MOCKUP_DIR: ${{ env.MOCKUP_DIR }}
324
+ run: |
325
+ set -euo pipefail
326
+ rel="agent-artifact/${MOCKUP_DIR#/tmp/gh-aw/}"
327
+ dir=''
328
+ if [ -f "$rel/new/index.html" ]; then
329
+ dir="$rel/new"
330
+ elif [ -f "$rel/existing/index.html" ] && [ ! -f "$rel/existing/desktop.png" ]; then
331
+ dir="$rel/existing"
332
+ fi
333
+ echo "dir=$dir" >> "$GITHUB_OUTPUT"
334
+ echo "Mockup to render: ${dir:-none}"
335
+ - name: Check and render the mockup
336
+ id: capture
337
+ if: steps.pick.outputs.dir != ''
338
+ uses: ./.github/actions/capture-mockup
339
+ with:
340
+ mockup-dir: ${{ steps.pick.outputs.dir }}
341
+ out-dir: ${{ runner.temp }}/mockup
342
+ playwright-version: ${{ env.MOCKUP_PLAYWRIGHT_VERSION }}
343
+ - name: Keep the rendered mockup
344
+ if: steps.capture.outputs.valid == 'true'
345
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
346
+ with:
347
+ name: ${{ needs.activation.outputs.artifact_prefix }}mockup
348
+ path: ${{ runner.temp }}/mockup
349
+ retention-days: 7
350
+ if-no-files-found: error
351
+ mockup_publish:
352
+ needs: [activation, agent, mockup_render]
353
+ if: always() && needs.mockup_render.outputs.rendered == 'true'
354
+ runs-on: agents-arc
355
+ timeout-minutes: 10
356
+ continue-on-error: true
357
+ permissions:
358
+ contents: write
359
+ steps:
360
+ - name: Download the rendered mockup
361
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
362
+ with:
363
+ name: ${{ needs.activation.outputs.artifact_prefix }}mockup
364
+ path: ${{ runner.temp }}/mockup
365
+ # One orphan branch holds every mockup, a folder per issue, replaced on every publish.
366
+ # GITHUB_TOKEN and [skip ci] keep the push from starting any workflow.
367
+ - name: Push the mockup to its branch
368
+ env:
369
+ TOKEN: ${{ github.token }}
370
+ REPO: ${{ github.repository }}
371
+ SERVER: ${{ github.server_url }}
372
+ BRANCH: ${{ env.MOCKUP_BRANCH }}
373
+ ISSUE: ${{ inputs.issue-number }}
374
+ run: |
375
+ set -euo pipefail
376
+ src="$RUNNER_TEMP/mockup"
377
+ folder="issue-${ISSUE}"
378
+ work="$RUNNER_TEMP/mockup-branch"
379
+ rm -rf "$work"
380
+ mkdir -p "$work"
381
+ cd "$work"
382
+ auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
383
+ git init -q
384
+ git config "http.${SERVER}/.extraheader" "$auth"
385
+ git config user.name "github-actions[bot]"
386
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
387
+ git config core.sparseCheckout true
388
+ echo "/${folder}/" > .git/info/sparse-checkout
389
+ git remote add origin "${SERVER}/${REPO}.git"
390
+
391
+ for attempt in 1 2 3 4 5; do
392
+ if git fetch -q --depth=1 --filter=blob:none origin "refs/heads/$BRANCH" 2>/dev/null; then
393
+ git checkout -q -f -B "$BRANCH" FETCH_HEAD
394
+ else
395
+ git checkout -q --orphan "mockups-$attempt"
396
+ git rm -rq --cached . >/dev/null 2>&1 || true
397
+ fi
398
+ rm -rf "$folder"
399
+ mkdir -p "$folder"
400
+ cp -R "$src/." "$folder/"
401
+ git add -A "$folder"
402
+ if git diff --cached --quiet; then
403
+ echo "The mockup on $BRANCH is already current."
404
+ exit 0
405
+ fi
406
+ git commit -q -m "mockup: issue #${ISSUE} [skip ci]"
407
+ if git push -q origin "HEAD:refs/heads/$BRANCH"; then
408
+ echo "Published ${SERVER}/${REPO}/tree/$(git rev-parse HEAD)/${folder}"
409
+ exit 0
410
+ fi
411
+ echo "$BRANCH moved before the push (attempt $attempt); retrying on its new tip."
412
+ sleep $((attempt * 3))
413
+ done
414
+ echo "::warning::could not push the mockup to $BRANCH after 5 attempts"
415
+ conclude:
416
+ needs: [activation, agent, safe_outputs, validate_output, mockup_render, mockup_publish]
286
417
  # `always()` is load-bearing: without it GitHub prepends `success()`, which returns false
287
418
  # if any needed job was skipped. The historical trigger was the size gate's refuse_big_issue
288
419
  # skip-poisoning activation (Pliny-Bot #321) -- that gate is gone at 1.15.0, but a skip in
@@ -329,6 +460,106 @@ jobs:
329
460
  create-issues: 'true'
330
461
  fallback-issue-number: ${{ inputs.issue-number }}
331
462
  parent-issue: ${{ needs.validate_output.outputs.outcome == 'split' && inputs.issue-number || '' }}
463
+ # The mockup section, written by the workflow because gh-aw strips the HTML comments that
464
+ # delimit it from anything the agent writes, and re-written on every pass because the agent
465
+ # replaces the whole body. Read from the branch, so a mockup a person published with
466
+ # /plan-mockup is linked the same way as one this run rendered. A split child gets its
467
+ # parent's mockup and the sections its `Mockup sections:` line names. Best-effort: no
468
+ # mockup, or a failed read, leaves the bodies as the agent wrote them.
469
+ - name: Record the mockup on the issue
470
+ continue-on-error: true
471
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
472
+ env:
473
+ ISSUE: ${{ inputs.issue-number }}
474
+ BRANCH: ${{ env.MOCKUP_BRANCH }}
475
+ OUTCOME: ${{ needs.validate_output.outputs.outcome }}
476
+ INTENT: ${{ env.MOCKUP_INTENT_LINE }}
477
+ SPLIT_PARENT_PREFIX: ${{ env.SPLIT_PARENT_PREFIX }}
478
+ with:
479
+ github-token: ${{ steps.app-token.outputs.token }}
480
+ script: |
481
+ const START = '<!-- agent-mockup:start -->';
482
+ const END = '<!-- agent-mockup:end -->';
483
+ const parent = Number(process.env.ISSUE);
484
+ const folder = `issue-${parent}`;
485
+ const ref = process.env.BRANCH;
486
+ const site = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}`;
487
+
488
+ let sha;
489
+ let mockup;
490
+ let names;
491
+ try {
492
+ ({ data: { commit: { sha } } } = await github.rest.repos.getBranch({ ...context.repo, branch: ref }));
493
+ const { data: entries } = await github.rest.repos.getContent({ ...context.repo, path: folder, ref: sha });
494
+ names = new Set(entries.map((entry) => entry.name));
495
+ const { data: file } = await github.rest.repos.getContent({ ...context.repo, path: `${folder}/mockup.json`, ref: sha });
496
+ mockup = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
497
+ } catch (error) {
498
+ if (error.status === 404) {
499
+ core.info(`No mockup at ${ref}:${folder}.`);
500
+ return;
501
+ }
502
+ throw error;
503
+ }
504
+
505
+ const cell = (value) => String(value ?? '').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim();
506
+ const raw = (name) => `${site}/raw/${sha}/${folder}/${name}`;
507
+ const blob = (name) => `${site}/blob/${sha}/${folder}/${name}`;
508
+ const known = (mockup.sections ?? []).map((section) => section.id);
509
+
510
+ const section = (only) => {
511
+ const rows = (mockup.components ?? []).filter((entry) => !only || only.includes(entry.section));
512
+ const lines = [START, `<!-- agent-mockup: ${folder} -->`, '## UX mockup', '', `**${cell(mockup.title)}.** ${process.env.INTENT}`, ''];
513
+ if (only) lines.push(`This issue builds ${only.map((id) => `\`${id}\``).join(', ')}.`, '');
514
+ if (names.has('desktop.png')) lines.push(`![Mockup at desktop width](${raw('desktop.png')})`, '');
515
+ const links = [`[The HTML](${blob('index.html')})`];
516
+ if (names.has('mobile.png')) links.push(`[mobile width](${raw('mobile.png')})`);
517
+ links.push(`[mockup.json](${blob('mockup.json')})`);
518
+ lines.push(`${links.join(' · ')}: download the HTML and open it in a browser to see every state.`, '');
519
+ if (rows.length > 0) {
520
+ lines.push('| Section | Element | Build it with | Where |', '|---|---|---|---|');
521
+ for (const row of rows) {
522
+ const where = row.path === 'new' ? 'new component' : `\`${cell(row.path)}\``;
523
+ lines.push(`| ${cell(row.section)} | ${cell(row.element)} | ${cell(row.component)} | ${where} |`);
524
+ }
525
+ lines.push('');
526
+ }
527
+ lines.push(END);
528
+ return lines.join('\n');
529
+ };
530
+
531
+ const stamp = async (issue_number, block) => {
532
+ const { data: issue } = await github.rest.issues.get({ ...context.repo, issue_number });
533
+ const pattern = new RegExp(`${START}[\\s\\S]*?${END}\\n?`, 'g');
534
+ const cleaned = (issue.body ?? '').replace(pattern, '').replace(/\s+$/, '');
535
+ await github.rest.issues.update({ ...context.repo, issue_number, body: `${cleaned}\n\n${block}\n` });
536
+ core.info(`Mockup section written on #${issue_number}.`);
537
+ };
538
+
539
+ await stamp(parent, section(null));
540
+ if (process.env.OUTCOME !== 'split') return;
541
+
542
+ let children = [];
543
+ try {
544
+ children = (await github.paginate(github.rest.issues.listSubIssues ?? 'GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
545
+ { ...context.repo, issue_number: parent, per_page: 100 })).map((child) => child.number);
546
+ } catch (error) {
547
+ core.info(`Sub-issues unreadable (${error.message}); searching for the split marker.`);
548
+ }
549
+ if (children.length === 0) {
550
+ const { data } = await github.rest.search.issuesAndPullRequests({
551
+ q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:body "${process.env.SPLIT_PARENT_PREFIX}${parent} -->"`,
552
+ });
553
+ children = data.items.map((item) => item.number);
554
+ }
555
+ for (const child of children.filter((number) => number !== parent)) {
556
+ const { data: issue } = await github.rest.issues.get({ ...context.repo, issue_number: child });
557
+ const line = /^Mockup sections:\s*(.+)$/im.exec(issue.body ?? '');
558
+ const only = line
559
+ ? line[1].split(/[,\s]+/).map((id) => id.replace(/`/g, '').trim()).filter((id) => known.includes(id))
560
+ : [];
561
+ await stamp(child, section(only.length > 0 ? only : null));
562
+ }
332
563
  - name: Apply complete refinement labels
333
564
  if: needs.validate_output.outputs.outcome == 'complete'
334
565
  uses: ./.github/actions/add-issue-labels
@@ -768,6 +999,15 @@ steps:
768
999
  token: ${{ github.token }}
769
1000
  issue-number: ${{ inputs.issue-number }}
770
1001
  output-path: ${{ env.ISSUE_CONTEXT_PATH }}
1002
+ # A mockup already published for this issue, by a person with /plan-mockup or by an earlier
1003
+ # pass. The agent keeps it unless the author's answers changed the screen.
1004
+ - name: Load the issue's mockup for the agent
1005
+ uses: ./.github/actions/load-mockup
1006
+ with:
1007
+ token: ${{ github.token }}
1008
+ issue-number: ${{ inputs.issue-number }}
1009
+ branch: ${{ env.MOCKUP_BRANCH }}
1010
+ out-dir: ${{ env.MOCKUP_DIR }}/existing
771
1011
 
772
1012
  safe-outputs:
773
1013
  # A failed run is already a red run. An issue per failure buries the real backlog
@@ -873,8 +1113,18 @@ timeout-minutes: 90
873
1113
  checklist of concrete steps. No "As a / I want / so that" form, no Given/When/Then, no Mermaid.
874
1114
  Load `@humanizer` and prepare the body, then go directly to step 4 (estimate).
875
1115
 
876
- **Standard path.** Call `skill("pc-plan-story")` and pass it the work units and the exploration
877
- findings. `pc-plan-story` owns the story's shape: drafting, coverage gate, repo-docs application,
1116
+ **Standard path.** When `${{ env.MOCKUP_PATHS }}` is not empty and a work unit changes files
1117
+ matching one of its space-separated globs, settle the UX mockup first. A mockup in
1118
+ `${{ env.MOCKUP_DIR }}/existing/` was made by a person or an earlier pass: keep it, unless the
1119
+ author's answers on this pass change what the screen shows. Otherwise call
1120
+ `skill("pc-plan-mockup")` in autonomous mode with the work units and the exploration findings,
1121
+ and have it write `index.html` and `mockup.json` to `${{ env.MOCKUP_DIR }}/new/`; to replace an
1122
+ existing mockup, write the whole new one there. If that skill is not installed, carry on
1123
+ without a mockup. Never put the HTML in a body or a comment: the workflow publishes it and
1124
+ adds the picture, the link and the component table to the issue itself.
1125
+
1126
+ Then call `skill("pc-plan-story")` and pass it the work units and the exploration
1127
+ findings, and tell it the workflow writes the UX section, so the story leaves it out. `pc-plan-story` owns the story's shape: drafting, coverage gate, repo-docs application,
878
1128
  issue-form discovery and wrapping, humanizer pass, and the structured implementation plan. Do not
879
1129
  repeat any of those steps yourself. Adhere to ${{ env.REPO_RULES }}.
880
1130
 
@@ -918,6 +1168,9 @@ timeout-minutes: 90
918
1168
  `#aw_<id>` to the real issue number once the children exist. Write dependencies only as these
919
1169
  lines and never set the `blocked_by` field: it takes one reference, and a value naming two
920
1170
  rejected the item and discarded the whole refinement (Lyceum #45).
1171
+ - when the story has a mockup, a `Mockup sections: <ids>` line naming the `mockup.json`
1172
+ section ids this child builds, comma-separated, so the workflow links the child to its part
1173
+ of the mockup
921
1174
  - a `Parent feature branch: feat/<parent issue number>` line (for parent #123,
922
1175
  `Parent feature branch: feat/123`) naming the branch the whole split integrates on:
923
1176
  each child's pull request targets that branch, never the default branch, the children
@@ -524,7 +524,33 @@ jobs:
524
524
  }
525
525
  return `<!-- agentic-evidence-manifest\n${JSON.stringify(data).replace(/-(?=-)/g, '\\u002d')}\n-->`;
526
526
  });
527
- const body = source.split('__EVIDENCE_BASE__').join(base);
527
+ let body = source.split('__EVIDENCE_BASE__').join(base);
528
+
529
+ // The mockup the issue was refined with, beside what was built, so a reviewer judges
530
+ // the experience against the intent. Read from the mockup section refine stamps on the
531
+ // issue; a split child's section carries its parent's mockup. Best-effort: no mockup,
532
+ // or a failed lookup, leaves the comment as it was.
533
+ try {
534
+ const query = `query($owner: String!, $repo: String!, $number: Int!) {
535
+ repository(owner: $owner, name: $repo) {
536
+ pullRequest(number: $number) { closingIssuesReferences(first: 5) { nodes { number body } } }
537
+ }
538
+ }`;
539
+ const result = await github.graphql(query, { ...context.repo, number: issue_number });
540
+ for (const node of result.repository.pullRequest?.closingIssuesReferences?.nodes ?? []) {
541
+ const section = /<!-- agent-mockup:start -->([\s\S]*?)<!-- agent-mockup:end -->/.exec(node.body ?? '');
542
+ const image = section && /!\[[^\]]*\]\([^)\s]+\/desktop\.png\)/.exec(section[1]);
543
+ if (!image) continue;
544
+ const block = `### The mockup it was built from (#${node.number})\n\nUX intent, not a spec: a different look is expected, a different flow is worth a look.\n\n${image[0]}\n\n`;
545
+ body = body.includes('<!-- agentic-evidence-manifest')
546
+ ? body.replace('<!-- agentic-evidence-manifest', `${block}<!-- agentic-evidence-manifest`)
547
+ : `${body}\n\n${block}`;
548
+ break;
549
+ }
550
+ } catch (error) {
551
+ core.info(`No mockup added: ${error.message}`);
552
+ }
553
+
528
554
  const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number, per_page: 100 });
529
555
  const existing = comments.find((comment) => comment.user?.type === 'Bot' && (comment.body ?? '').includes(process.env.MARKER));
530
556
  if (existing) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",