@1agh/maude 0.23.0 → 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.
Files changed (101) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. 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&#58;` 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. The client controls the
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
- await Bun.write(annotationsPath(file), svg);
593
- onAnnotationsChanged?.(file, svg);
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. The CLI
17
- # entry stays Node, so we resolve via $CLAUDE_PLUGIN_ROOT first, with the
18
- # script-dir fallback only used when running uncompiled from the repo.
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
- # The Node lib needs cwd to be the package root (it resolves dependencies.json
27
- # via `plugins/<plugin>/dependencies.json`). Subshell so we don't mutate the
28
- # caller's cwd.
29
- (
30
- cd "$PKG_ROOT" || exit 1
31
- exec node "$PKG_ROOT/cli/lib/preflight.mjs" --plugin design "$@"
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 ? `![](${p.platform}/${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
- URL="http://localhost:${PORT}/${ACTIVE_ENC}"
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 ----------