@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.
- package/loops/actions/capture-mockup/action.yml +91 -0
- package/loops/actions/capture-mockup/capture.mjs +132 -0
- package/loops/actions/load-mockup/action.yml +72 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +70 -1
- package/loops/docs/diagrams.md +16 -4
- package/loops/templates/ci/app-ci-node-monorepo.yml +6 -1
- package/loops/templates/quality-gates/.semgrep/platform-mockup-leak.yml +45 -0
- package/loops/workflows/agent-implement.md +35 -4
- package/loops/workflows/agent-merge-gate.md +7 -1
- package/loops/workflows/agent-refine.md +256 -3
- package/loops/workflows/evidence.yml +27 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -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|
|
|
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
|
|
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
|
-
|
|
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
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
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
|
-
|
|
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(`})`, '');
|
|
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.**
|
|
877
|
-
|
|
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
|
-
|
|
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) {
|