@1agh/maude 0.22.2 → 0.24.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/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -135,6 +135,15 @@ export interface Api {
|
|
|
135
135
|
loadCommentsForFile(file: string): Promise<Comment[]>;
|
|
136
136
|
saveCommentsForFile(file: string, list: Comment[]): Promise<void>;
|
|
137
137
|
loadAllComments(): Promise<Record<string, Comment[]>>;
|
|
138
|
+
/**
|
|
139
|
+
* Resolve a canvas URL slug back to its repo-relative `file` path by scanning
|
|
140
|
+
* the ACTUAL canvas files under each canvas group — independent of whether the
|
|
141
|
+
* canvas has any comments yet. The inverse of `fileSlug`. Returns null when no
|
|
142
|
+
* canvas matches. Load-bearing for collab: a peer that has not yet received any
|
|
143
|
+
* comment for a canvas must still resolve the file to MATERIALIZE the first
|
|
144
|
+
* hub-pushed comment to disk (the receiving-peer projection gap, DDR-064).
|
|
145
|
+
*/
|
|
146
|
+
fileForSlug(slug: string): Promise<string | null>;
|
|
138
147
|
commentsAdd(payload: Partial<Comment> & { file: string; text: string }): Promise<Comment | null>;
|
|
139
148
|
commentsPatch(id: string, patch: Partial<Comment>): Promise<Comment | null>;
|
|
140
149
|
commentsDelete(id: string): Promise<boolean>;
|
|
@@ -173,6 +182,68 @@ export interface ApiHooks {
|
|
|
173
182
|
onAnnotationsChanged?: (file: string, svg: string) => void;
|
|
174
183
|
}
|
|
175
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Elements the annotation tool legitimately emits (`strokesToSvg` in
|
|
187
|
+
* annotations-layer.tsx). This is the WHOLE vocabulary — purely presentational.
|
|
188
|
+
*/
|
|
189
|
+
const ANNOTATION_SVG_ELEMENTS = new Set([
|
|
190
|
+
'svg',
|
|
191
|
+
'g',
|
|
192
|
+
'path',
|
|
193
|
+
'rect',
|
|
194
|
+
'ellipse',
|
|
195
|
+
'line',
|
|
196
|
+
'polyline',
|
|
197
|
+
'text',
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A3 (DDR-060 F1 re-audit) — sanitize an annotation SVG before it is persisted /
|
|
202
|
+
* synced / mirrored into the collab Y.Map. ALLOWLIST, not denylist (the F1
|
|
203
|
+
* confirming pass showed a denylist loses the race: `<svg:script>` via namespace,
|
|
204
|
+
* `javascript:` via HTML entity, `<style>@import url()>`, CSS `url(javascript:)`
|
|
205
|
+
* all slipped a tag-name denylist). Two rules, both keyed to the fixed legit
|
|
206
|
+
* vocabulary so they're zero-regression on real annotations:
|
|
207
|
+
*
|
|
208
|
+
* 1. Element allowlist — drop the markup of any tag whose LOCAL name (namespace
|
|
209
|
+
* stripped, so `svg:script` → `script`) isn't in ANNOTATION_SVG_ELEMENTS.
|
|
210
|
+
* Dropped-tag text content survives as inert text (not in a script/style
|
|
211
|
+
* context), which is harmless.
|
|
212
|
+
* 2. Attribute denylist on survivors — the legit vocabulary uses none of
|
|
213
|
+
* `on*=`, `style=`, or any `href`, so stripping them closes inline handlers,
|
|
214
|
+
* CSS `url(javascript:)`, and entity-encoded `xlink:href` in one pass.
|
|
215
|
+
*
|
|
216
|
+
* The current consumer (`svgToStrokes` → DOMParser image/svg+xml → structured
|
|
217
|
+
* strokes → React) never raw-renders this string, so the live XSS risk is already
|
|
218
|
+
* nil; this keeps "inert" TRUE for any future raw-render consumer and for the
|
|
219
|
+
* synced file a peer / Claude-context ingests (defense-in-depth, DDR-054 §3).
|
|
220
|
+
*/
|
|
221
|
+
export function sanitizeAnnotationSvg(svg: string): string {
|
|
222
|
+
return (
|
|
223
|
+
svg
|
|
224
|
+
// 1. Remove the CONTENT of executable / instruction-bearing elements (not
|
|
225
|
+
// just their tags) so an injected script body / `@import` / prompt-
|
|
226
|
+
// injection string can't survive as inert text a future raw-renderer or
|
|
227
|
+
// Claude-context read might act on. Namespace-tolerant (`svg:script`),
|
|
228
|
+
// non-greedy to the first matching dangerous close tag.
|
|
229
|
+
.replace(
|
|
230
|
+
/<\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\b[\s\S]*?<\s*\/\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\s*>/gi,
|
|
231
|
+
''
|
|
232
|
+
)
|
|
233
|
+
// 2. Element allowlist — drop the markup of any tag whose LOCAL name isn't
|
|
234
|
+
// in the fixed annotation vocabulary. `[^>]*` stops at the first `>`;
|
|
235
|
+
// annotation attrs never contain a literal `>`.
|
|
236
|
+
.replace(/<\/?\s*([a-zA-Z][\w:-]*)\b[^>]*>/g, (match, rawName: string) => {
|
|
237
|
+
const local = String(rawName).split(':').pop()?.toLowerCase() ?? '';
|
|
238
|
+
return ANNOTATION_SVG_ELEMENTS.has(local) ? match : '';
|
|
239
|
+
})
|
|
240
|
+
// 3. Attribute denylist on the surviving allowlisted elements — the legit
|
|
241
|
+
// vocabulary uses no on*= / style= / *href=, so stripping them closes
|
|
242
|
+
// inline handlers, CSS url(javascript:), and entity-encoded hrefs.
|
|
243
|
+
.replace(/\s(?:on[a-z]+|style|(?:[\w-]+:)?href)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
176
247
|
export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
177
248
|
const onCommentsChanged = hooks.onCommentsChanged;
|
|
178
249
|
const onAnnotationsChanged = hooks.onAnnotationsChanged;
|
|
@@ -195,6 +266,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
195
266
|
.toLowerCase();
|
|
196
267
|
}
|
|
197
268
|
|
|
269
|
+
async function fileForSlug(slug: string): Promise<string | null> {
|
|
270
|
+
// Authoritative slug → canvas-file resolver: enumerate the real canvas
|
|
271
|
+
// files under each canvas group and match by the canonical slug. Unlike a
|
|
272
|
+
// comments-file scan, this resolves even when the canvas has NO comments yet
|
|
273
|
+
// — the fix for the receiving-peer projection gap where a fresh peer could
|
|
274
|
+
// not locate the file to write the first hub-pushed comment (DDR-064).
|
|
275
|
+
for (const g of cfg.canvasGroups) {
|
|
276
|
+
const groupAbs = path.join(paths.designRoot, g.path);
|
|
277
|
+
const groupRel = path.posix.join(paths.designRel, g.path);
|
|
278
|
+
let files: string[];
|
|
279
|
+
try {
|
|
280
|
+
files = await findHtmlFiles(groupAbs, groupRel);
|
|
281
|
+
} catch {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (const rel of files) {
|
|
285
|
+
if (fileSlug(rel) === slug) return rel;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
198
291
|
function commentsPath(file: string): string {
|
|
199
292
|
return path.join(paths.commentsDir, `${fileSlug(file)}.json`);
|
|
200
293
|
}
|
|
@@ -586,11 +679,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
586
679
|
if (typeof svg !== 'string') return false;
|
|
587
680
|
if (svg.length > 1024 * 1024) return false;
|
|
588
681
|
// Cheap content gate — must look like an <svg> document. Avoids accidental
|
|
589
|
-
// writes of arbitrary blobs through this endpoint.
|
|
590
|
-
// content fully, so we don't try to sanitize beyond a tag check.
|
|
682
|
+
// writes of arbitrary blobs through this endpoint.
|
|
591
683
|
if (!/^\s*<svg[\s>]/i.test(svg)) return false;
|
|
592
|
-
|
|
593
|
-
|
|
684
|
+
// A3 (DDR-060 F1 re-audit) — sanitize active content before persisting.
|
|
685
|
+
// This endpoint is on the canvas-origin allowlist (DDR-054 "inert collab
|
|
686
|
+
// write") and accepts ANY `file`, so a hub-pushed canvas can write a
|
|
687
|
+
// sibling's `.annotations.svg`. The persisted SVG is currently consumed only
|
|
688
|
+
// via `svgToStrokes` (DOMParser image/svg+xml → structured strokes → React
|
|
689
|
+
// re-render), so a `<script>`/`on*` payload is parsed inertly and discarded
|
|
690
|
+
// — the stored-XSS chain is LATENT today, not live. We sanitize anyway so
|
|
691
|
+
// "inert" stays true for any future raw-render consumer and for the synced
|
|
692
|
+
// file a peer/Claude-context ingests. The legit annotation vocabulary
|
|
693
|
+
// (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
|
|
694
|
+
// polyline/text — so stripping executable constructs is zero-regression.
|
|
695
|
+
const clean = sanitizeAnnotationSvg(svg);
|
|
696
|
+
await Bun.write(annotationsPath(file), clean);
|
|
697
|
+
onAnnotationsChanged?.(file, clean);
|
|
594
698
|
return true;
|
|
595
699
|
}
|
|
596
700
|
|
|
@@ -946,6 +1050,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
946
1050
|
loadCommentsForFile,
|
|
947
1051
|
saveCommentsForFile,
|
|
948
1052
|
loadAllComments,
|
|
1053
|
+
fileForSlug,
|
|
949
1054
|
commentsAdd,
|
|
950
1055
|
commentsPatch,
|
|
951
1056
|
commentsDelete,
|
|
@@ -13,9 +13,16 @@
|
|
|
13
13
|
# preflight.sh --warn-only # same as --quiet (SessionStart hook)
|
|
14
14
|
#
|
|
15
15
|
# Resolution (DDR-045): never compute paths from `dirname $0` inside a
|
|
16
|
-
# `bun --compile` standalone binary — that maps to /$bunfs/root.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
16
|
+
# `bun --compile` standalone binary — that maps to /$bunfs/root.
|
|
17
|
+
#
|
|
18
|
+
# Resolution (DDR-061): in a MARKETPLACE install the plugin is copied alone into
|
|
19
|
+
# `cache/<marketplace>/<plugin>/<version>/` — there is NO sibling `$PKG_ROOT/cli/`.
|
|
20
|
+
# So: use the sibling `cli/lib` when it exists (running uncompiled from the repo
|
|
21
|
+
# or an npm package, where cli/ is present); otherwise reach the check through
|
|
22
|
+
# the on-PATH `maude` binary (a declared plugin dependency), which resolves the
|
|
23
|
+
# manifest from its own package root. Fixing a straggler — the relative-path
|
|
24
|
+
# crash (`Cannot find module …/cache/maude/cli/lib/preflight.mjs`) that the cache
|
|
25
|
+
# steps already avoid by calling on-PATH `maude`.
|
|
19
26
|
#
|
|
20
27
|
# Exit: 0 if all hard deps pass; 1 otherwise.
|
|
21
28
|
|
|
@@ -23,10 +30,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
23
30
|
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
24
31
|
PKG_ROOT="$(cd "$PLUGIN_ROOT/../.." && pwd)"
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
cd "$PKG_ROOT" || exit 1
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
if [ -f "$PKG_ROOT/cli/lib/preflight.mjs" ]; then
|
|
34
|
+
# Sibling cli/ present (repo / npm package). The Node lib needs cwd = package
|
|
35
|
+
# root (it resolves `plugins/<plugin>/dependencies.json`). Subshell preserves
|
|
36
|
+
# the caller's cwd.
|
|
37
|
+
( cd "$PKG_ROOT" || exit 1; exec node "$PKG_ROOT/cli/lib/preflight.mjs" --plugin design "$@" )
|
|
38
|
+
elif command -v maude >/dev/null 2>&1; then
|
|
39
|
+
exec maude preflight --plugin design "$@"
|
|
40
|
+
else
|
|
41
|
+
echo "preflight: no cli/lib beside the plugin and 'maude' not on PATH — install maude (npm i -g @1agh/maude)." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# prep.sh — one-shot pre-flight context for the design slash commands.
|
|
3
|
+
#
|
|
4
|
+
# Collapses the 4–8 sequential bash invocations that /design:new, /design:edit
|
|
5
|
+
# and /design:setup-ds each ran in their step-0/step-1 (bootstrap-check + config
|
|
6
|
+
# jq reads + _active.json read + _preflight read + _server.json PID probe + slug
|
|
7
|
+
# compute) into a SINGLE call that emits one structured blob. Same shape family
|
|
8
|
+
# as bootstrap-check.sh / server-up.sh.
|
|
9
|
+
#
|
|
10
|
+
# Reads (all optional — missing files degrade gracefully, never error):
|
|
11
|
+
# .design/config.json → NAME, DESIGN_ROOT, ROOT_CLASS, THEME, TOKENS_REL,
|
|
12
|
+
# NEW_CANVAS_DIR, NEW_COMPONENT_DIR, TEAM_ACCENT,
|
|
13
|
+
# DEFAULT_DS, KNOWN_DS, ACCENT_STRATEGY, COLOR_SPACE
|
|
14
|
+
# <designRoot>/_active.json → ACTIVE_CANVAS, SELECTED_ELEMENT, SELECTED_FILE,
|
|
15
|
+
# SEL_VALID, OPEN_TABS, ACTIVE_SLUG
|
|
16
|
+
# <designRoot>/_preflight.json → DEPS_OK, DEPS_MISSING (Phase A cache)
|
|
17
|
+
# <designRoot>/_server.json → SERVER_UP, SERVER_PORT (PID liveness + /_health)
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
# prep.sh # JSON on stdout (default)
|
|
21
|
+
# prep.sh --json # JSON on stdout
|
|
22
|
+
# prep.sh --shell-export # `export FOO=bar` lines, eval-friendly
|
|
23
|
+
# prep.sh --shape <new|edit|setup-ds> # emit only the subset that command needs
|
|
24
|
+
# prep.sh --root <repo> # override repo root (else git root → $CLAUDE_PROJECT_DIR → pwd)
|
|
25
|
+
#
|
|
26
|
+
# Resolution (DDR-045): resolve the repo root from args/env/git — never from
|
|
27
|
+
# `dirname $0`, which maps to /$bunfs/root inside a bun --compile binary.
|
|
28
|
+
#
|
|
29
|
+
# Exit: 0 always (this is a context gatherer; callers branch on the fields).
|
|
30
|
+
|
|
31
|
+
MODE="json"
|
|
32
|
+
SHAPE="all"
|
|
33
|
+
REPO=""
|
|
34
|
+
while [ $# -gt 0 ]; do
|
|
35
|
+
case "$1" in
|
|
36
|
+
--json) MODE="json"; shift ;;
|
|
37
|
+
--shell-export) MODE="shell"; shift ;;
|
|
38
|
+
--shape) SHAPE="$2"; shift 2 ;;
|
|
39
|
+
--root) REPO="$2"; shift 2 ;;
|
|
40
|
+
--help|-h)
|
|
41
|
+
sed -n '2,33p' "$0" | sed 's/^# \?//'
|
|
42
|
+
exit 0
|
|
43
|
+
;;
|
|
44
|
+
*)
|
|
45
|
+
echo "prep.sh: unknown arg '$1' (try --help)" >&2
|
|
46
|
+
exit 2
|
|
47
|
+
;;
|
|
48
|
+
esac
|
|
49
|
+
done
|
|
50
|
+
|
|
51
|
+
case "$SHAPE" in
|
|
52
|
+
all|new|edit|setup-ds) ;;
|
|
53
|
+
*) echo "prep.sh: --shape must be one of new|edit|setup-ds (got '$SHAPE')" >&2; exit 2 ;;
|
|
54
|
+
esac
|
|
55
|
+
|
|
56
|
+
# ---- repo root ----------------------------------------------------------------
|
|
57
|
+
if [ -z "$REPO" ]; then
|
|
58
|
+
REPO="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
HAVE_JQ=0
|
|
62
|
+
command -v jq >/dev/null 2>&1 && HAVE_JQ=1
|
|
63
|
+
|
|
64
|
+
# ---- config -------------------------------------------------------------------
|
|
65
|
+
CFG="$REPO/.design/config.json"
|
|
66
|
+
CONFIG_PRESENT="false"; [ -f "$CFG" ] && CONFIG_PRESENT="true"
|
|
67
|
+
|
|
68
|
+
NAME="Project"
|
|
69
|
+
DESIGN_ROOT=".design"
|
|
70
|
+
ROOT_CLASS="app"
|
|
71
|
+
THEME="dark"
|
|
72
|
+
TOKENS_REL="system/colors_and_type.css"
|
|
73
|
+
NEW_CANVAS_DIR="ui/project"
|
|
74
|
+
NEW_COMPONENT_DIR="ui/project/components"
|
|
75
|
+
TEAM_ACCENT=""
|
|
76
|
+
DEFAULT_DS="project"
|
|
77
|
+
KNOWN_DS=""
|
|
78
|
+
ACCENT_STRATEGY="single"
|
|
79
|
+
COLOR_SPACE="oklch"
|
|
80
|
+
|
|
81
|
+
if [ "$CONFIG_PRESENT" = "true" ] && [ "$HAVE_JQ" = "1" ]; then
|
|
82
|
+
NAME=$(jq -r '.name // "Project"' "$CFG")
|
|
83
|
+
DESIGN_ROOT=$(jq -r '.designRoot // ".design"' "$CFG")
|
|
84
|
+
ROOT_CLASS=$(jq -r '.rootClass // "app"' "$CFG")
|
|
85
|
+
THEME=$(jq -r '.themeDefault // "dark"' "$CFG")
|
|
86
|
+
TOKENS_REL=$(jq -r '.tokensCssRel // "system/colors_and_type.css"' "$CFG")
|
|
87
|
+
NEW_CANVAS_DIR=$(jq -r '.newCanvasDir // "ui/project"' "$CFG")
|
|
88
|
+
NEW_COMPONENT_DIR=$(jq -r '.newComponentDir // "ui/project/components"' "$CFG")
|
|
89
|
+
TEAM_ACCENT=$(jq -r '.teamAccentDefault // empty' "$CFG")
|
|
90
|
+
DEFAULT_DS=$(jq -r '.defaultDesignSystem // "project"' "$CFG")
|
|
91
|
+
KNOWN_DS=$(jq -r '.designSystems[]?.name // empty' "$CFG" 2>/dev/null | tr '\n' ' ' | sed 's/ $//')
|
|
92
|
+
ACCENT_STRATEGY=$(jq -r '.accentStrategy // "single"' "$CFG")
|
|
93
|
+
COLOR_SPACE=$(jq -r '.colorSpace // "oklch"' "$CFG")
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
DR_ABS="$REPO/$DESIGN_ROOT"
|
|
97
|
+
|
|
98
|
+
# ---- active canvas / selection ------------------------------------------------
|
|
99
|
+
ACTIVE_FILE="$DR_ABS/_active.json"
|
|
100
|
+
ACTIVE_CANVAS=""
|
|
101
|
+
SELECTED_ELEMENT=""
|
|
102
|
+
SELECTED_FILE=""
|
|
103
|
+
SEL_VALID="0"
|
|
104
|
+
OPEN_TABS="0"
|
|
105
|
+
ACTIVE_SLUG=""
|
|
106
|
+
if [ -f "$ACTIVE_FILE" ] && [ "$HAVE_JQ" = "1" ]; then
|
|
107
|
+
ACTIVE_CANVAS=$(jq -r '.active // empty' "$ACTIVE_FILE" 2>/dev/null)
|
|
108
|
+
[ "$ACTIVE_CANVAS" = "null" ] && ACTIVE_CANVAS=""
|
|
109
|
+
SELECTED_ELEMENT=$(jq -rc '.selected // empty' "$ACTIVE_FILE" 2>/dev/null)
|
|
110
|
+
SELECTED_FILE=$(jq -r '.selected.file // empty' "$ACTIVE_FILE" 2>/dev/null)
|
|
111
|
+
OPEN_TABS=$(jq -r '(.open_tabs // []) | length' "$ACTIVE_FILE" 2>/dev/null || echo 0)
|
|
112
|
+
if [ -n "$SELECTED_ELEMENT" ] && [ -n "$ACTIVE_CANVAS" ] && [ "$SELECTED_FILE" = "$ACTIVE_CANVAS" ]; then
|
|
113
|
+
SEL_VALID="1"
|
|
114
|
+
fi
|
|
115
|
+
if [ -n "$ACTIVE_CANVAS" ]; then
|
|
116
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
117
|
+
ACTIVE_SLUG=$(bash "$SCRIPT_DIR/slug.sh" "${ACTIVE_CANVAS#"$DESIGN_ROOT"/}" 2>/dev/null)
|
|
118
|
+
fi
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# ---- deps preflight cache -----------------------------------------------------
|
|
122
|
+
PREFLIGHT_FILE="$DR_ABS/_preflight.json"
|
|
123
|
+
DEPS_OK="unknown"
|
|
124
|
+
DEPS_MISSING=""
|
|
125
|
+
if [ -f "$PREFLIGHT_FILE" ] && [ "$HAVE_JQ" = "1" ]; then
|
|
126
|
+
DEPS_OK=$(jq -r 'if .ok == true then "true" elif .ok == false then "false" else "unknown" end' "$PREFLIGHT_FILE" 2>/dev/null || echo unknown)
|
|
127
|
+
DEPS_MISSING=$(jq -r '(.missing // []) | join(",")' "$PREFLIGHT_FILE" 2>/dev/null || echo "")
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# ---- server liveness ----------------------------------------------------------
|
|
131
|
+
SERVER_FILE="$DR_ABS/_server.json"
|
|
132
|
+
SERVER_UP="false"
|
|
133
|
+
SERVER_PORT=""
|
|
134
|
+
if [ -f "$SERVER_FILE" ] && [ "$HAVE_JQ" = "1" ]; then
|
|
135
|
+
SERVER_PORT=$(jq -r '.port // empty' "$SERVER_FILE" 2>/dev/null)
|
|
136
|
+
SERVER_PID=$(jq -r '.pid // empty' "$SERVER_FILE" 2>/dev/null)
|
|
137
|
+
if [ -n "$SERVER_PID" ] && [ -n "$SERVER_PORT" ] \
|
|
138
|
+
&& kill -0 "$SERVER_PID" 2>/dev/null \
|
|
139
|
+
&& curl -fs "http://localhost:$SERVER_PORT/_health" >/dev/null 2>&1; then
|
|
140
|
+
SERVER_UP="true"
|
|
141
|
+
fi
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# ---- emit ---------------------------------------------------------------------
|
|
145
|
+
# Shape membership: `new`/`setup-ds` omit the active-canvas/selection block
|
|
146
|
+
# (those commands don't act on an open canvas); `edit`/`all` emit everything.
|
|
147
|
+
if [ "$MODE" = "shell" ]; then
|
|
148
|
+
printf 'export REPO_ROOT=%q\n' "$REPO"
|
|
149
|
+
printf 'export CONFIG_PRESENT=%s\n' "$CONFIG_PRESENT"
|
|
150
|
+
printf 'export NAME=%q\n' "$NAME"
|
|
151
|
+
printf 'export DESIGN_ROOT=%q\n' "$DESIGN_ROOT"
|
|
152
|
+
printf 'export ROOT_CLASS=%q\n' "$ROOT_CLASS"
|
|
153
|
+
printf 'export THEME=%q\n' "$THEME"
|
|
154
|
+
printf 'export TOKENS_REL=%q\n' "$TOKENS_REL"
|
|
155
|
+
printf 'export NEW_CANVAS_DIR=%q\n' "$NEW_CANVAS_DIR"
|
|
156
|
+
printf 'export NEW_COMPONENT_DIR=%q\n' "$NEW_COMPONENT_DIR"
|
|
157
|
+
printf 'export TEAM_ACCENT=%q\n' "$TEAM_ACCENT"
|
|
158
|
+
printf 'export DEFAULT_DS=%q\n' "$DEFAULT_DS"
|
|
159
|
+
printf 'export KNOWN_DS=%q\n' "$KNOWN_DS"
|
|
160
|
+
printf 'export ACCENT_STRATEGY=%q\n' "$ACCENT_STRATEGY"
|
|
161
|
+
printf 'export COLOR_SPACE=%q\n' "$COLOR_SPACE"
|
|
162
|
+
printf 'export DEPS_OK=%s\n' "$DEPS_OK"
|
|
163
|
+
printf 'export DEPS_MISSING=%q\n' "$DEPS_MISSING"
|
|
164
|
+
printf 'export SERVER_UP=%s\n' "$SERVER_UP"
|
|
165
|
+
printf 'export SERVER_PORT=%q\n' "$SERVER_PORT"
|
|
166
|
+
if [ "$SHAPE" = "edit" ] || [ "$SHAPE" = "all" ]; then
|
|
167
|
+
printf 'export ACTIVE_CANVAS=%q\n' "$ACTIVE_CANVAS"
|
|
168
|
+
printf 'export SELECTED_FILE=%q\n' "$SELECTED_FILE"
|
|
169
|
+
printf 'export SEL_VALID=%s\n' "$SEL_VALID"
|
|
170
|
+
printf 'export OPEN_TABS=%s\n' "$OPEN_TABS"
|
|
171
|
+
printf 'export ACTIVE_SLUG=%q\n' "$ACTIVE_SLUG"
|
|
172
|
+
fi
|
|
173
|
+
exit 0
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# JSON mode — emit via jq when available for correct escaping; fall back to printf.
|
|
177
|
+
if [ "$HAVE_JQ" = "1" ]; then
|
|
178
|
+
jq -n \
|
|
179
|
+
--arg repo "$REPO" --arg cfg "$CONFIG_PRESENT" --arg name "$NAME" \
|
|
180
|
+
--arg dr "$DESIGN_ROOT" --arg rc "$ROOT_CLASS" --arg th "$THEME" \
|
|
181
|
+
--arg tok "$TOKENS_REL" --arg ncd "$NEW_CANVAS_DIR" --arg ncm "$NEW_COMPONENT_DIR" \
|
|
182
|
+
--arg acc "$TEAM_ACCENT" --arg dds "$DEFAULT_DS" --arg kds "$KNOWN_DS" \
|
|
183
|
+
--arg astrat "$ACCENT_STRATEGY" --arg cspace "$COLOR_SPACE" \
|
|
184
|
+
--arg depsok "$DEPS_OK" --arg depsmiss "$DEPS_MISSING" \
|
|
185
|
+
--arg sup "$SERVER_UP" --arg sport "$SERVER_PORT" \
|
|
186
|
+
--arg act "$ACTIVE_CANVAS" --arg selfile "$SELECTED_FILE" \
|
|
187
|
+
--arg selv "$SEL_VALID" --arg tabs "$OPEN_TABS" --arg slug "$ACTIVE_SLUG" \
|
|
188
|
+
--argjson sel "${SELECTED_ELEMENT:-null}" \
|
|
189
|
+
'{
|
|
190
|
+
repo_root: $repo,
|
|
191
|
+
config_present: ($cfg == "true"),
|
|
192
|
+
config: {
|
|
193
|
+
name: $name, design_root: $dr, root_class: $rc, theme: $th,
|
|
194
|
+
tokens_rel: $tok, new_canvas_dir: $ncd, new_component_dir: $ncm,
|
|
195
|
+
team_accent: $acc, default_ds: $dds,
|
|
196
|
+
known_ds: ($kds | if . == "" then [] else split(" ") end),
|
|
197
|
+
accent_strategy: $astrat, color_space: $cspace
|
|
198
|
+
},
|
|
199
|
+
active: {
|
|
200
|
+
canvas: $act, selected: $sel, selected_file: $selfile,
|
|
201
|
+
sel_valid: ($selv == "1"), open_tabs: ($tabs | tonumber? // 0), slug: $slug
|
|
202
|
+
},
|
|
203
|
+
deps: { ok: $depsok, missing: ($depsmiss | if . == "" then [] else split(",") end) },
|
|
204
|
+
server: { up: ($sup == "true"), port: ($sport | if . == "" then null else (tonumber? // .) end) }
|
|
205
|
+
}'
|
|
206
|
+
else
|
|
207
|
+
printf '{"repo_root":"%s","config_present":%s,"jq":false,"note":"jq unavailable — install jq for full prep.sh output"}\n' \
|
|
208
|
+
"$REPO" "$CONFIG_PRESENT"
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
exit 0
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scenario-report.mjs — deterministic cross-platform scenario report generator.
|
|
3
|
+
//
|
|
4
|
+
// Phase C / DDR-061. Resolves the long-standing "report generator" TODO in
|
|
5
|
+
// plugins/flow/skills/scenario/SKILL.md. Walks a scenario run directory and
|
|
6
|
+
// emits the mechanical sections of report.md (TL;DR table, counter-delta table,
|
|
7
|
+
// per-step pivot, collapsed path-listing). The LLM authors ONLY the prose
|
|
8
|
+
// sections ("What surprised us", "Recommended follow-ups") — they're left as
|
|
9
|
+
// marked placeholders for it to fill in.
|
|
10
|
+
//
|
|
11
|
+
// Invoked from flow markdown via the on-PATH `maude` binary (DDR-062):
|
|
12
|
+
// maude scenario-report <run-dir> [--out <path>]
|
|
13
|
+
// or directly:
|
|
14
|
+
// node scenario-report.mjs <run-dir> [--out <path>]
|
|
15
|
+
//
|
|
16
|
+
// Input layout (produced by the scenario runners — see scenario SKILL.md):
|
|
17
|
+
// <run-dir>/
|
|
18
|
+
// ├── <platform>/result.txt # "pass" | "fail: <reason>" | "skipped: <reason>"
|
|
19
|
+
// ├── <platform>/step-<N>-<desc>.png # per-step screenshots
|
|
20
|
+
// └── <platform>/counters.json # OPTIONAL { "mastered": "+3", "remaining": "-3" }
|
|
21
|
+
//
|
|
22
|
+
// Output: <run-dir>/report.md (or --out), zero external dependencies (node:fs only).
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { basename, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const PLATFORM_ORDER = ['web-desktop', 'web-mobile', 'ios-phone', 'ios-tablet', 'android-phone'];
|
|
28
|
+
|
|
29
|
+
const toolingFor = (platform) => (platform.startsWith('web-') ? 'agent-browser' : 'agent-device');
|
|
30
|
+
|
|
31
|
+
// Parse "pass" / "fail: reason" / "skipped: reason" → { result, reason }.
|
|
32
|
+
function parseResult(text) {
|
|
33
|
+
const t = (text || '').trim();
|
|
34
|
+
if (!t) return { result: 'UNKNOWN', reason: 'no result.txt' };
|
|
35
|
+
const lower = t.toLowerCase();
|
|
36
|
+
if (lower.startsWith('pass')) return { result: 'PASS', reason: '' };
|
|
37
|
+
if (lower.startsWith('fail')) return { result: 'FAIL', reason: t.replace(/^fail[:\s]*/i, '') };
|
|
38
|
+
if (lower.startsWith('skip'))
|
|
39
|
+
return { result: 'SKIPPED', reason: t.replace(/^skip(ped)?[:\s]*/i, '') };
|
|
40
|
+
return { result: 'UNKNOWN', reason: t.slice(0, 80) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SYM = { PASS: '✅', FAIL: '❌', SKIPPED: '⏭️', UNKNOWN: '❓' };
|
|
44
|
+
|
|
45
|
+
// Extract the numeric step index from "step-7-card-1-back.png" → 7.
|
|
46
|
+
function stepIndex(file) {
|
|
47
|
+
const m = /^step-(\d+)/.exec(file);
|
|
48
|
+
return m ? Number(m[1]) : Number.POSITIVE_INFINITY;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readPlatform(runDir, platform) {
|
|
52
|
+
const dir = join(runDir, platform);
|
|
53
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return null;
|
|
54
|
+
const resultPath = join(dir, 'result.txt');
|
|
55
|
+
const { result, reason } = parseResult(
|
|
56
|
+
existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : ''
|
|
57
|
+
);
|
|
58
|
+
const steps = readdirSync(dir)
|
|
59
|
+
.filter((f) => /^step-\d+.*\.png$/.test(f))
|
|
60
|
+
.sort((a, b) => stepIndex(a) - stepIndex(b) || a.localeCompare(b));
|
|
61
|
+
let counters = null;
|
|
62
|
+
const countersPath = join(dir, 'counters.json');
|
|
63
|
+
if (existsSync(countersPath)) {
|
|
64
|
+
try {
|
|
65
|
+
counters = JSON.parse(readFileSync(countersPath, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
/* malformed counters — treated as absent */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { platform, result, reason, steps, counters };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function generateReport(runDir) {
|
|
74
|
+
if (!existsSync(runDir) || !statSync(runDir).isDirectory()) {
|
|
75
|
+
throw new Error(`scenario-report: run dir not found: ${runDir}`);
|
|
76
|
+
}
|
|
77
|
+
// Discover platforms: known order first, then any extra subdirs present.
|
|
78
|
+
const present = readdirSync(runDir).filter((n) => {
|
|
79
|
+
const p = join(runDir, n);
|
|
80
|
+
return statSync(p).isDirectory() && existsSync(join(p, 'result.txt'));
|
|
81
|
+
});
|
|
82
|
+
const ordered = [
|
|
83
|
+
...PLATFORM_ORDER.filter((p) => present.includes(p)),
|
|
84
|
+
...present.filter((p) => !PLATFORM_ORDER.includes(p)),
|
|
85
|
+
];
|
|
86
|
+
const platforms = ordered.map((p) => readPlatform(runDir, p)).filter(Boolean);
|
|
87
|
+
|
|
88
|
+
const scenarioName =
|
|
89
|
+
basename(join(runDir, '..', '..')) === '.' ? 'scenario' : basename(join(runDir, '..'));
|
|
90
|
+
const out = [];
|
|
91
|
+
out.push(`# Scenario report — ${scenarioName}`);
|
|
92
|
+
out.push('');
|
|
93
|
+
out.push(`_generated ${new Date().toISOString()} · run: \`${runDir}\`_`);
|
|
94
|
+
out.push('');
|
|
95
|
+
|
|
96
|
+
// ── TL;DR ──────────────────────────────────────────────────────────────
|
|
97
|
+
out.push('## TL;DR');
|
|
98
|
+
out.push('');
|
|
99
|
+
out.push('| Platform | Result | Steps reached | Tooling |');
|
|
100
|
+
out.push('| --- | --- | --- | --- |');
|
|
101
|
+
for (const p of platforms) {
|
|
102
|
+
const detail = p.reason ? ` — ${p.reason}` : '';
|
|
103
|
+
out.push(
|
|
104
|
+
`| ${p.platform} | ${SYM[p.result]} ${p.result}${detail} | ${p.steps.length} | ${toolingFor(p.platform)} |`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
out.push('');
|
|
108
|
+
|
|
109
|
+
// ── Counter-delta ──────────────────────────────────────────────────────
|
|
110
|
+
const counterKeys = [
|
|
111
|
+
...new Set(platforms.flatMap((p) => (p.counters ? Object.keys(p.counters) : []))),
|
|
112
|
+
];
|
|
113
|
+
out.push('## Counter delta (cross-platform parity)');
|
|
114
|
+
out.push('');
|
|
115
|
+
if (counterKeys.length === 0) {
|
|
116
|
+
out.push(
|
|
117
|
+
'_No `counters.json` recorded for any platform. Add `<platform>/counters.json` ' +
|
|
118
|
+
'(e.g. `{"mastered":"+3","remaining":"-3"}`) in the runners to enable the parity check._'
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
out.push(`| Platform | ${counterKeys.map((k) => `\`${k}\` Δ`).join(' | ')} |`);
|
|
122
|
+
out.push(`| --- | ${counterKeys.map(() => '---').join(' | ')} |`);
|
|
123
|
+
for (const p of platforms) {
|
|
124
|
+
const cells = counterKeys.map((k) =>
|
|
125
|
+
p.counters && p.counters[k] != null ? p.counters[k] : '—'
|
|
126
|
+
);
|
|
127
|
+
out.push(`| ${p.platform} | ${cells.join(' | ')} |`);
|
|
128
|
+
}
|
|
129
|
+
out.push('');
|
|
130
|
+
// Parity verdict: identical non-skipped rows = verified.
|
|
131
|
+
const nonSkipped = platforms.filter((p) => p.result !== 'SKIPPED' && p.counters);
|
|
132
|
+
const sigs = new Set(nonSkipped.map((p) => counterKeys.map((k) => p.counters[k]).join('|')));
|
|
133
|
+
out.push(
|
|
134
|
+
sigs.size <= 1
|
|
135
|
+
? '**Parity: ✅ identical counter deltas across all non-skipped platforms.**'
|
|
136
|
+
: '**Parity: ❌ counter deltas diverge across platforms — investigate (a divergence needs a DDR or a fix).**'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
out.push('');
|
|
140
|
+
|
|
141
|
+
// ── Per-step pivot ─────────────────────────────────────────────────────
|
|
142
|
+
const allStepIdx = [
|
|
143
|
+
...new Set(platforms.flatMap((p) => p.steps.map(stepIndex)).filter(Number.isFinite)),
|
|
144
|
+
].sort((a, b) => a - b);
|
|
145
|
+
out.push('## Per-step screenshots (pivot)');
|
|
146
|
+
out.push('');
|
|
147
|
+
if (allStepIdx.length === 0) {
|
|
148
|
+
out.push('_No `step-*.png` screenshots found in any platform directory._');
|
|
149
|
+
} else {
|
|
150
|
+
out.push(`| Platform | ${allStepIdx.map((n) => `Step ${n}`).join(' | ')} |`);
|
|
151
|
+
out.push(`| --- | ${allStepIdx.map(() => '---').join(' | ')} |`);
|
|
152
|
+
for (const p of platforms) {
|
|
153
|
+
const cells = allStepIdx.map((n) => {
|
|
154
|
+
const file = p.steps.find((f) => stepIndex(f) === n);
|
|
155
|
+
return file ? `` : '';
|
|
156
|
+
});
|
|
157
|
+
out.push(`| ${p.platform} | ${cells.join(' | ')} |`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
out.push('');
|
|
161
|
+
|
|
162
|
+
// ── LLM prose placeholders ─────────────────────────────────────────────
|
|
163
|
+
out.push('## What surprised us');
|
|
164
|
+
out.push('');
|
|
165
|
+
out.push(
|
|
166
|
+
'<!-- LLM-AUTHORED: replace this line with non-obvious findings — UX divergence, broken expectations, unexpected counter math, mid-run state changes. Delete this comment. -->'
|
|
167
|
+
);
|
|
168
|
+
out.push('');
|
|
169
|
+
out.push('## Recommended follow-ups');
|
|
170
|
+
out.push('');
|
|
171
|
+
out.push(
|
|
172
|
+
'<!-- LLM-AUTHORED: replace this line with a prioritized list of codebase changes that would make this scenario more reliable (missing testIDs, fragile selectors, parity gaps). Delete this comment. -->'
|
|
173
|
+
);
|
|
174
|
+
out.push('');
|
|
175
|
+
|
|
176
|
+
// ── Collapsed path listing ─────────────────────────────────────────────
|
|
177
|
+
out.push('<details>');
|
|
178
|
+
out.push('<summary>All screenshot paths (per platform)</summary>');
|
|
179
|
+
out.push('');
|
|
180
|
+
for (const p of platforms) {
|
|
181
|
+
out.push(`**${p.platform}** (${p.result})`);
|
|
182
|
+
for (const f of p.steps) out.push(`- \`${p.platform}/${f}\``);
|
|
183
|
+
out.push('');
|
|
184
|
+
}
|
|
185
|
+
out.push('</details>');
|
|
186
|
+
out.push('');
|
|
187
|
+
|
|
188
|
+
return out.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── CLI entrypoint ───────────────────────────────────────────────────────
|
|
192
|
+
function main(argv) {
|
|
193
|
+
const args = argv.slice(2);
|
|
194
|
+
const runDir = args.find((a) => !a.startsWith('--'));
|
|
195
|
+
if (!runDir) {
|
|
196
|
+
process.stderr.write('usage: scenario-report <run-dir> [--out <path>]\n');
|
|
197
|
+
process.exit(2);
|
|
198
|
+
}
|
|
199
|
+
const outIdx = args.indexOf('--out');
|
|
200
|
+
const outPath = outIdx >= 0 ? args[outIdx + 1] : join(runDir, 'report.md');
|
|
201
|
+
const md = generateReport(runDir);
|
|
202
|
+
writeFileSync(outPath, md, 'utf8');
|
|
203
|
+
process.stdout.write(`${outPath}\n`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Run as a script (not when imported). `import.meta.main` is Bun; the argv[1]
|
|
207
|
+
// check covers Node.
|
|
208
|
+
const invokedDirectly = import.meta.main || process.argv[1]?.endsWith('scenario-report.mjs');
|
|
209
|
+
if (invokedDirectly) main(process.argv);
|
|
@@ -64,6 +64,19 @@ else
|
|
|
64
64
|
[ -z "$OUT" ] && { echo "screenshot.sh: --out required for single-shot modes" >&2; exit 2; }
|
|
65
65
|
fi
|
|
66
66
|
|
|
67
|
+
# agent-browser ignores RELATIVE screenshot paths (it writes to its own
|
|
68
|
+
# ~/.agent-browser/tmp instead), which strands the PNG and trips the
|
|
69
|
+
# missing-file guard below. Canonicalize OUT / OUT_DIR to absolute up front so
|
|
70
|
+
# captures land where the caller asked.
|
|
71
|
+
if [ -n "$OUT" ]; then
|
|
72
|
+
mkdir -p "$(dirname "$OUT")" 2>/dev/null || true
|
|
73
|
+
OUT="$(cd "$(dirname "$OUT")" 2>/dev/null && pwd)/$(basename "$OUT")"
|
|
74
|
+
fi
|
|
75
|
+
if [ -n "$OUT_DIR" ]; then
|
|
76
|
+
mkdir -p "$OUT_DIR" 2>/dev/null || true
|
|
77
|
+
OUT_DIR="$(cd "$OUT_DIR" 2>/dev/null && pwd)"
|
|
78
|
+
fi
|
|
79
|
+
|
|
67
80
|
# TSX specimens cannot be opened via file:// — the browser would see raw JSX.
|
|
68
81
|
# They must go through the dev-server route (http://localhost:PORT/<rel>),
|
|
69
82
|
# which transpiles via _canvas-shell.html?canvas=<rel>. Phase 19 / DDR-044.
|
|
@@ -97,7 +110,11 @@ if [ -z "$URL" ]; then
|
|
|
97
110
|
|
|
98
111
|
# URL-encode spaces (rough); leave other chars alone.
|
|
99
112
|
ACTIVE_ENC=$(printf '%s' "$ACTIVE" | sed 's/ /%20/g')
|
|
100
|
-
|
|
113
|
+
# Canvases mount through the canvas shell. The bare `/<rel>` route 404s when
|
|
114
|
+
# the canvas-origin sandbox is on (default since phase-9.1); only
|
|
115
|
+
# `/_canvas-shell.html?canvas=<rel>` renders the canvas (valid in both
|
|
116
|
+
# split-on and legacy same-origin modes).
|
|
117
|
+
URL="http://localhost:${PORT}/_canvas-shell.html?canvas=${ACTIVE_ENC}"
|
|
101
118
|
fi
|
|
102
119
|
|
|
103
120
|
# ---------- selector mapping ----------
|