replay-labs 0.1.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/LICENSE +22 -0
- package/README.md +134 -0
- package/examples/password-reset-transcript.md +27 -0
- package/examples/password-reset.diff +101 -0
- package/package.json +47 -0
- package/scripts/capture-git-working-diff.js +56 -0
- package/scripts/create-added-files-diff.js +33 -0
- package/scripts/extract-claude-transcript.js +86 -0
- package/scripts/extract-codex-transcript.js +119 -0
- package/src/cli.js +316 -0
- package/src/discovery.js +715 -0
- package/src/generate.js +406 -0
- package/src/ingest.js +124 -0
- package/src/interaction.js +1161 -0
- package/src/lab-ui.js +1339 -0
- package/src/modules.js +643 -0
- package/src/overview.js +147 -0
- package/src/patterns.js +322 -0
- package/src/pipeline.js +68 -0
- package/src/report.js +516 -0
- package/src/review.js +238 -0
- package/src/server.js +199 -0
- package/src/storage.js +34 -0
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
import { analyzeSession } from "./report.js";
|
|
2
|
+
import { generateLabHtml } from "./lab-ui.js";
|
|
3
|
+
import { buildLabModule as buildModule, MODULES } from "./modules.js";
|
|
4
|
+
import { hasUsableDiffEvidence } from "./generate.js";
|
|
5
|
+
|
|
6
|
+
export function generateDecisionReplayHtml({ goal, diff, transcript, diffPath, transcriptPath }) {
|
|
7
|
+
const analysis = analyzeSession({ goal, diff, transcript, diffPath, transcriptPath });
|
|
8
|
+
const primary = analysis.decisions[0];
|
|
9
|
+
const module = buildModule(primary);
|
|
10
|
+
const evidence = findEvidenceSnippet(analysis.diff, primary.patterns);
|
|
11
|
+
return generateLabHtml({ goal, module, evidence });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// One lab per session decision that has a rich module, plus the data the
|
|
15
|
+
// session map needs to rank and link them.
|
|
16
|
+
export async function generateSessionLabs({ goal, diff, transcript, diffPath, transcriptPath, generate = false, cacheDir = null, maxGenerated = 1 }) {
|
|
17
|
+
const analysis = analyzeSession({ goal, diff, transcript, diffPath, transcriptPath });
|
|
18
|
+
const labs = [];
|
|
19
|
+
let generatedCount = 0;
|
|
20
|
+
let loadOrGenerate = null;
|
|
21
|
+
if (generate && cacheDir) {
|
|
22
|
+
({ loadOrGenerate } = await import("./generate.js"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const decision of analysis.decisions) {
|
|
26
|
+
const hasRichModule = Boolean(MODULES[decision.id]);
|
|
27
|
+
const evidence = findEvidenceSnippet(analysis.diff, decision.patterns);
|
|
28
|
+
const hasConcreteEvidence = hasUsableDiffEvidence(evidence);
|
|
29
|
+
let module = buildModule(decision);
|
|
30
|
+
let rich = hasRichModule && hasConcreteEvidence;
|
|
31
|
+
let generated = false;
|
|
32
|
+
|
|
33
|
+
// For decisions we have no hand-authored module for, generate one from the
|
|
34
|
+
// real evidence — this is how unseen patterns become real labs.
|
|
35
|
+
if (!hasRichModule && hasConcreteEvidence && loadOrGenerate && generatedCount < maxGenerated) {
|
|
36
|
+
const gen = await loadOrGenerate(cacheDir, decision, evidence);
|
|
37
|
+
if (gen) { module = gen; rich = true; generated = true; generatedCount += 1; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
labs.push({
|
|
41
|
+
decision,
|
|
42
|
+
module,
|
|
43
|
+
rich,
|
|
44
|
+
generated,
|
|
45
|
+
html: rich
|
|
46
|
+
? generateLabHtml({
|
|
47
|
+
goal,
|
|
48
|
+
module,
|
|
49
|
+
evidence,
|
|
50
|
+
patternHref: generated ? null : `../patterns/${module.id}.html`,
|
|
51
|
+
homeHref: "../index.html"
|
|
52
|
+
})
|
|
53
|
+
: null
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { analysis, labs };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Previous lab shell, kept for reference while v3 proves itself. Not called.
|
|
60
|
+
// eslint-disable-next-line no-unused-vars
|
|
61
|
+
function legacyLabTemplate({ goal, diffPath, transcriptPath, module, evidence, primary, diff }) {
|
|
62
|
+
return `<!doctype html>
|
|
63
|
+
<html lang="en">
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8" />
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
67
|
+
<title>Replay Labs</title>
|
|
68
|
+
<style>
|
|
69
|
+
:root {
|
|
70
|
+
color-scheme: light;
|
|
71
|
+
--bg: #f6f4ef;
|
|
72
|
+
--ink: #171716;
|
|
73
|
+
--muted: #666158;
|
|
74
|
+
--line: #d8d1c6;
|
|
75
|
+
--paper: #fffdf8;
|
|
76
|
+
--soft: #eee8dd;
|
|
77
|
+
--dark: #181817;
|
|
78
|
+
--green: #22685d;
|
|
79
|
+
--blue: #2f5f9f;
|
|
80
|
+
--gold: #8b661d;
|
|
81
|
+
--red: #9a3d2f;
|
|
82
|
+
}
|
|
83
|
+
* { box-sizing: border-box; }
|
|
84
|
+
body {
|
|
85
|
+
margin: 0;
|
|
86
|
+
background: var(--bg);
|
|
87
|
+
color: var(--ink);
|
|
88
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
89
|
+
}
|
|
90
|
+
button, textarea { font: inherit; }
|
|
91
|
+
button {
|
|
92
|
+
border: 0;
|
|
93
|
+
border-radius: 7px;
|
|
94
|
+
padding: 10px 13px;
|
|
95
|
+
background: var(--green);
|
|
96
|
+
color: white;
|
|
97
|
+
font-weight: 750;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
button.secondary { background: var(--soft); color: var(--ink); }
|
|
101
|
+
button.ghost { background: transparent; border: 1px solid var(--line); color: var(--ink); }
|
|
102
|
+
button:disabled { opacity: .45; cursor: not-allowed; }
|
|
103
|
+
textarea {
|
|
104
|
+
width: 100%;
|
|
105
|
+
min-height: 128px;
|
|
106
|
+
resize: vertical;
|
|
107
|
+
border: 1px solid var(--line);
|
|
108
|
+
border-radius: 8px;
|
|
109
|
+
padding: 12px;
|
|
110
|
+
background: white;
|
|
111
|
+
color: var(--ink);
|
|
112
|
+
line-height: 1.5;
|
|
113
|
+
}
|
|
114
|
+
textarea:focus { outline: 2px solid #9ecac3; border-color: var(--green); }
|
|
115
|
+
.shell {
|
|
116
|
+
min-height: 100vh;
|
|
117
|
+
display: grid;
|
|
118
|
+
grid-template-columns: 320px minmax(0, 1fr);
|
|
119
|
+
}
|
|
120
|
+
.sidebar {
|
|
121
|
+
position: sticky;
|
|
122
|
+
top: 0;
|
|
123
|
+
height: 100vh;
|
|
124
|
+
overflow: auto;
|
|
125
|
+
padding: 28px;
|
|
126
|
+
background: var(--dark);
|
|
127
|
+
color: #fbf4e8;
|
|
128
|
+
}
|
|
129
|
+
.brand {
|
|
130
|
+
color: #91d3c8;
|
|
131
|
+
font-size: 13px;
|
|
132
|
+
font-weight: 850;
|
|
133
|
+
letter-spacing: 0;
|
|
134
|
+
text-transform: uppercase;
|
|
135
|
+
}
|
|
136
|
+
.sidebar h1 {
|
|
137
|
+
margin: 13px 0 12px;
|
|
138
|
+
font-size: 31px;
|
|
139
|
+
line-height: 1.08;
|
|
140
|
+
letter-spacing: 0;
|
|
141
|
+
}
|
|
142
|
+
.sidebar p { margin: 0; color: #cbc3b8; line-height: 1.5; }
|
|
143
|
+
.progress { margin: 24px 0; }
|
|
144
|
+
.track { height: 11px; background: #403c35; border-radius: 999px; overflow: hidden; }
|
|
145
|
+
.fill { height: 100%; width: 0%; background: linear-gradient(90deg, #74c7bb, #6fa3d8); transition: width .2s ease; }
|
|
146
|
+
.stage-list { display: grid; gap: 8px; margin-top: 22px; }
|
|
147
|
+
.stage-button {
|
|
148
|
+
width: 100%;
|
|
149
|
+
display: grid;
|
|
150
|
+
grid-template-columns: 28px minmax(0, 1fr);
|
|
151
|
+
gap: 9px;
|
|
152
|
+
text-align: left;
|
|
153
|
+
background: transparent;
|
|
154
|
+
border: 1px solid #403c35;
|
|
155
|
+
color: #fbf4e8;
|
|
156
|
+
}
|
|
157
|
+
.stage-button.active { border-color: #91d3c8; background: #24221f; }
|
|
158
|
+
.stage-button.done { border-color: #35665b; }
|
|
159
|
+
.number {
|
|
160
|
+
width: 24px;
|
|
161
|
+
height: 24px;
|
|
162
|
+
display: grid;
|
|
163
|
+
place-items: center;
|
|
164
|
+
border-radius: 50%;
|
|
165
|
+
background: #403c35;
|
|
166
|
+
color: #fbf4e8;
|
|
167
|
+
font-size: 12px;
|
|
168
|
+
}
|
|
169
|
+
main { padding: 32px; }
|
|
170
|
+
.workspace { max-width: 1180px; margin: 0 auto; }
|
|
171
|
+
.topbar {
|
|
172
|
+
display: flex;
|
|
173
|
+
justify-content: space-between;
|
|
174
|
+
gap: 16px;
|
|
175
|
+
align-items: flex-start;
|
|
176
|
+
margin-bottom: 18px;
|
|
177
|
+
}
|
|
178
|
+
.eyebrow {
|
|
179
|
+
display: inline-flex;
|
|
180
|
+
padding: 6px 9px;
|
|
181
|
+
border-radius: 999px;
|
|
182
|
+
background: var(--soft);
|
|
183
|
+
color: var(--muted);
|
|
184
|
+
font-size: 13px;
|
|
185
|
+
font-weight: 800;
|
|
186
|
+
}
|
|
187
|
+
.goal { max-width: 820px; margin: 10px 0 0; color: var(--muted); line-height: 1.5; }
|
|
188
|
+
.lab {
|
|
189
|
+
display: grid;
|
|
190
|
+
grid-template-columns: minmax(0, 1fr) 390px;
|
|
191
|
+
gap: 18px;
|
|
192
|
+
align-items: start;
|
|
193
|
+
}
|
|
194
|
+
.brief {
|
|
195
|
+
display: grid;
|
|
196
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
197
|
+
gap: 12px;
|
|
198
|
+
margin-bottom: 18px;
|
|
199
|
+
}
|
|
200
|
+
.brief-card {
|
|
201
|
+
background: var(--paper);
|
|
202
|
+
border: 1px solid var(--line);
|
|
203
|
+
border-radius: 8px;
|
|
204
|
+
padding: 14px;
|
|
205
|
+
}
|
|
206
|
+
.brief-card span {
|
|
207
|
+
display: block;
|
|
208
|
+
margin-bottom: 6px;
|
|
209
|
+
color: var(--muted);
|
|
210
|
+
font-size: 12px;
|
|
211
|
+
font-weight: 850;
|
|
212
|
+
text-transform: uppercase;
|
|
213
|
+
letter-spacing: 0;
|
|
214
|
+
}
|
|
215
|
+
.brief-card b { display: block; margin-bottom: 6px; }
|
|
216
|
+
.brief-card p { margin: 0; color: var(--muted); line-height: 1.45; }
|
|
217
|
+
.panel {
|
|
218
|
+
background: var(--paper);
|
|
219
|
+
border: 1px solid var(--line);
|
|
220
|
+
border-radius: 8px;
|
|
221
|
+
padding: 20px;
|
|
222
|
+
}
|
|
223
|
+
.panel h2 { margin: 0 0 8px; font-size: 26px; letter-spacing: 0; }
|
|
224
|
+
.panel h3 { margin: 0 0 8px; font-size: 18px; letter-spacing: 0; }
|
|
225
|
+
.panel p { margin: 0; color: var(--muted); line-height: 1.55; }
|
|
226
|
+
.prompt {
|
|
227
|
+
margin: 18px 0;
|
|
228
|
+
padding-left: 14px;
|
|
229
|
+
border-left: 4px solid var(--green);
|
|
230
|
+
font-size: 18px;
|
|
231
|
+
line-height: 1.45;
|
|
232
|
+
}
|
|
233
|
+
.choice-grid { display: grid; gap: 10px; margin: 16px 0; }
|
|
234
|
+
.choice {
|
|
235
|
+
width: 100%;
|
|
236
|
+
text-align: left;
|
|
237
|
+
background: #fbf7ef;
|
|
238
|
+
color: var(--ink);
|
|
239
|
+
border: 1px solid var(--line);
|
|
240
|
+
display: block;
|
|
241
|
+
}
|
|
242
|
+
.choice.selected { border-color: var(--green); box-shadow: inset 0 0 0 1px var(--green); }
|
|
243
|
+
.choice.wrong { border-color: var(--red); }
|
|
244
|
+
.choice b { display: block; margin-bottom: 4px; }
|
|
245
|
+
.pass-criteria {
|
|
246
|
+
display: grid;
|
|
247
|
+
gap: 7px;
|
|
248
|
+
margin: 16px 0 0;
|
|
249
|
+
padding: 12px;
|
|
250
|
+
border-radius: 8px;
|
|
251
|
+
background: #f7f1e7;
|
|
252
|
+
border: 1px solid var(--line);
|
|
253
|
+
color: var(--muted);
|
|
254
|
+
line-height: 1.45;
|
|
255
|
+
}
|
|
256
|
+
.pass-criteria b { color: var(--ink); }
|
|
257
|
+
.check-output {
|
|
258
|
+
display: none;
|
|
259
|
+
margin-top: 14px;
|
|
260
|
+
padding: 12px;
|
|
261
|
+
border-radius: 8px;
|
|
262
|
+
background: #201f1d;
|
|
263
|
+
color: #fff4df;
|
|
264
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
265
|
+
font-size: 13px;
|
|
266
|
+
line-height: 1.45;
|
|
267
|
+
white-space: pre-wrap;
|
|
268
|
+
}
|
|
269
|
+
.check-output.open { display: block; }
|
|
270
|
+
.unlock {
|
|
271
|
+
display: none;
|
|
272
|
+
margin-top: 16px;
|
|
273
|
+
border: 1px solid var(--line);
|
|
274
|
+
border-radius: 8px;
|
|
275
|
+
background: #f1eadf;
|
|
276
|
+
overflow: hidden;
|
|
277
|
+
}
|
|
278
|
+
.unlock.open { display: block; }
|
|
279
|
+
.unlock-header {
|
|
280
|
+
padding: 12px 14px;
|
|
281
|
+
border-bottom: 1px solid var(--line);
|
|
282
|
+
background: #e9e0d3;
|
|
283
|
+
font-weight: 850;
|
|
284
|
+
}
|
|
285
|
+
.unlock-body { padding: 14px; display: grid; gap: 12px; }
|
|
286
|
+
.contrast {
|
|
287
|
+
display: grid;
|
|
288
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
289
|
+
gap: 10px;
|
|
290
|
+
}
|
|
291
|
+
.card {
|
|
292
|
+
border: 1px solid var(--line);
|
|
293
|
+
border-radius: 8px;
|
|
294
|
+
padding: 12px;
|
|
295
|
+
background: #fffaf1;
|
|
296
|
+
}
|
|
297
|
+
.card b { display: block; margin-bottom: 6px; }
|
|
298
|
+
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
|
299
|
+
pre, code {
|
|
300
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
301
|
+
}
|
|
302
|
+
pre {
|
|
303
|
+
margin: 0;
|
|
304
|
+
max-height: 430px;
|
|
305
|
+
overflow: auto;
|
|
306
|
+
padding: 14px;
|
|
307
|
+
border-radius: 8px;
|
|
308
|
+
background: #24211d;
|
|
309
|
+
color: #fff4df;
|
|
310
|
+
font-size: 13px;
|
|
311
|
+
line-height: 1.45;
|
|
312
|
+
white-space: pre-wrap;
|
|
313
|
+
}
|
|
314
|
+
code.block {
|
|
315
|
+
display: block;
|
|
316
|
+
padding: 12px;
|
|
317
|
+
border-radius: 8px;
|
|
318
|
+
background: #24211d;
|
|
319
|
+
color: #fff4df;
|
|
320
|
+
white-space: pre-wrap;
|
|
321
|
+
line-height: 1.45;
|
|
322
|
+
font-size: 13px;
|
|
323
|
+
}
|
|
324
|
+
mark { background: #f2ca69; color: #14110e; padding: 0 2px; border-radius: 3px; }
|
|
325
|
+
.feedback {
|
|
326
|
+
display: none;
|
|
327
|
+
margin-top: 14px;
|
|
328
|
+
padding: 14px;
|
|
329
|
+
border: 1px solid var(--line);
|
|
330
|
+
border-radius: 8px;
|
|
331
|
+
background: #f0eadf;
|
|
332
|
+
line-height: 1.5;
|
|
333
|
+
}
|
|
334
|
+
.feedback.open { display: block; }
|
|
335
|
+
.evidence p { margin-bottom: 12px; }
|
|
336
|
+
.lens {
|
|
337
|
+
display: grid;
|
|
338
|
+
gap: 8px;
|
|
339
|
+
margin-bottom: 14px;
|
|
340
|
+
padding: 12px;
|
|
341
|
+
border-radius: 8px;
|
|
342
|
+
border: 1px solid var(--line);
|
|
343
|
+
background: #fbf7ef;
|
|
344
|
+
}
|
|
345
|
+
.lens b { display: block; }
|
|
346
|
+
.lens ul { margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.45; }
|
|
347
|
+
.mastery {
|
|
348
|
+
display: none;
|
|
349
|
+
margin-top: 18px;
|
|
350
|
+
border: 1px solid #9fbfb8;
|
|
351
|
+
border-radius: 8px;
|
|
352
|
+
background: #eef7f4;
|
|
353
|
+
padding: 16px;
|
|
354
|
+
}
|
|
355
|
+
.mastery.open { display: block; }
|
|
356
|
+
.mastery-grid {
|
|
357
|
+
display: grid;
|
|
358
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
359
|
+
gap: 10px;
|
|
360
|
+
margin-top: 12px;
|
|
361
|
+
}
|
|
362
|
+
.footer-note { margin-top: 18px; color: var(--muted); font-size: 13px; }
|
|
363
|
+
.code-editor, textarea[id^="editor-"] {
|
|
364
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
365
|
+
font-size: 13.5px;
|
|
366
|
+
line-height: 1.55;
|
|
367
|
+
background: #24211d;
|
|
368
|
+
color: #fff4df;
|
|
369
|
+
border-color: #4a453d;
|
|
370
|
+
min-height: 220px;
|
|
371
|
+
tab-size: 2;
|
|
372
|
+
}
|
|
373
|
+
textarea#editor-transfer { background: white; color: var(--ink); font-family: inherit; min-height: 150px; }
|
|
374
|
+
.code-editor:focus { outline: 2px solid #74c7bb; }
|
|
375
|
+
.sim { margin-top: 18px; padding-top: 6px; border-top: 1px dashed var(--line); }
|
|
376
|
+
.sim-title { font-weight: 850; font-size: 13px; text-transform: uppercase; color: var(--red); margin: 10px 0; }
|
|
377
|
+
.terminal { border-left: 4px solid var(--red); }
|
|
378
|
+
.retry-note { color: var(--muted); font-size: 13.5px; align-self: center; }
|
|
379
|
+
.unlock-body a { color: var(--green); font-weight: 700; }
|
|
380
|
+
@media (max-width: 950px) {
|
|
381
|
+
.shell { grid-template-columns: 1fr; }
|
|
382
|
+
.sidebar { position: relative; height: auto; }
|
|
383
|
+
.lab, .contrast, .brief, .mastery-grid { grid-template-columns: 1fr; }
|
|
384
|
+
main { padding: 22px; }
|
|
385
|
+
}
|
|
386
|
+
</style>
|
|
387
|
+
</head>
|
|
388
|
+
<body>
|
|
389
|
+
<div class="shell">
|
|
390
|
+
<aside class="sidebar">
|
|
391
|
+
<div class="brand">Replay Labs</div>
|
|
392
|
+
<h1>Turn one AI session into one durable skill.</h1>
|
|
393
|
+
<p>This is not a replay viewer. It is a practice lab generated from real work: diagnose, break, repair, transfer.</p>
|
|
394
|
+
<div class="progress">
|
|
395
|
+
<div class="track"><div id="fill" class="fill"></div></div>
|
|
396
|
+
<p id="progress-label" style="margin-top: 8px;">0 of 4 stages complete</p>
|
|
397
|
+
</div>
|
|
398
|
+
<div id="stage-list" class="stage-list"></div>
|
|
399
|
+
</aside>
|
|
400
|
+
<main>
|
|
401
|
+
<section class="workspace">
|
|
402
|
+
<div class="topbar">
|
|
403
|
+
<div>
|
|
404
|
+
<div class="eyebrow">Generated from a real AI coding session</div>
|
|
405
|
+
<p class="goal">${escapeHtml(goal)}</p>
|
|
406
|
+
</div>
|
|
407
|
+
<button id="reset" class="ghost">Reset lab</button>
|
|
408
|
+
</div>
|
|
409
|
+
<section class="brief">
|
|
410
|
+
<div class="brief-card">
|
|
411
|
+
<span>Pattern</span>
|
|
412
|
+
<b>${escapeHtml(module.challenge.pattern)}</b>
|
|
413
|
+
<p>${escapeHtml(module.challenge.patternCopy)}</p>
|
|
414
|
+
${module.patternHref ? `<p style="margin-top:8px"><a href="${escapeHtml(module.patternHref)}" style="color:var(--green);font-weight:750;">Catalog entry →</a></p>` : ""}
|
|
415
|
+
</div>
|
|
416
|
+
<div class="brief-card">
|
|
417
|
+
<span>Smell</span>
|
|
418
|
+
<b>${escapeHtml(module.challenge.smell)}</b>
|
|
419
|
+
<p>${escapeHtml(module.challenge.smellCopy)}</p>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="brief-card">
|
|
422
|
+
<span>Proof</span>
|
|
423
|
+
<b>${escapeHtml(module.challenge.proof)}</b>
|
|
424
|
+
<p>${escapeHtml(module.challenge.proofCopy)}</p>
|
|
425
|
+
</div>
|
|
426
|
+
</section>
|
|
427
|
+
<div class="lab">
|
|
428
|
+
<section class="panel">
|
|
429
|
+
<h2 id="stage-title"></h2>
|
|
430
|
+
<p id="stage-copy"></p>
|
|
431
|
+
<div id="stage-content"></div>
|
|
432
|
+
<div class="actions">
|
|
433
|
+
<button id="prev" class="secondary">Previous</button>
|
|
434
|
+
<button id="next">Next stage</button>
|
|
435
|
+
</div>
|
|
436
|
+
</section>
|
|
437
|
+
<aside class="panel evidence">
|
|
438
|
+
<h3>Session Evidence</h3>
|
|
439
|
+
<p>Use this first. The product should train the learner to read evidence before accepting an explanation.</p>
|
|
440
|
+
<div class="lens">
|
|
441
|
+
<b id="lens-title"></b>
|
|
442
|
+
<ul id="lens-items"></ul>
|
|
443
|
+
</div>
|
|
444
|
+
<pre>${highlightEvidence(evidence, primary.patterns)}</pre>
|
|
445
|
+
</aside>
|
|
446
|
+
</div>
|
|
447
|
+
<section id="mastery" class="mastery">
|
|
448
|
+
<h2>Mastery Artifact</h2>
|
|
449
|
+
<p>This is what the learner should carry into the next AI session.</p>
|
|
450
|
+
<div class="mastery-grid">
|
|
451
|
+
<div class="card"><b>Mental model</b><p id="artifact-model"></p></div>
|
|
452
|
+
<div class="card"><b>Failure signature</b><p id="artifact-failure"></p></div>
|
|
453
|
+
<div class="card"><b>Shipping standard</b><p id="artifact-standard"></p></div>
|
|
454
|
+
<div class="card"><b>Transfer rule</b><p id="artifact-transfer"></p></div>
|
|
455
|
+
</div>
|
|
456
|
+
</section>
|
|
457
|
+
<p class="footer-note">This lab focuses on one decision from the session so the exercise stays specific.</p>
|
|
458
|
+
</section>
|
|
459
|
+
</main>
|
|
460
|
+
</div>
|
|
461
|
+
<script>
|
|
462
|
+
const module = ${JSON.stringify(module)};
|
|
463
|
+
const storageKey = "replay-lab:${hashForStorage(goal + diffPath + transcriptPath)}";
|
|
464
|
+
const state = JSON.parse(localStorage.getItem(storageKey) || '{"stage":0,"complete":{},"answers":{},"choices":{}}');
|
|
465
|
+
state.complete ||= {};
|
|
466
|
+
state.answers ||= {};
|
|
467
|
+
state.choices ||= {};
|
|
468
|
+
state.reviews ||= {};
|
|
469
|
+
state.reviewing = null;
|
|
470
|
+
|
|
471
|
+
const stages = [
|
|
472
|
+
{
|
|
473
|
+
id: "diagnose",
|
|
474
|
+
title: "1. Diagnose the decision",
|
|
475
|
+
copy: "Before the lesson appears, decide what kind of engineering judgment the AI exercised.",
|
|
476
|
+
render: renderDiagnose
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
id: "break",
|
|
480
|
+
title: "2. Predict what breaks",
|
|
481
|
+
copy: "Taste comes from knowing failure modes. Choose the breakage that explains why the decision matters.",
|
|
482
|
+
render: renderBreak
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
id: "repair",
|
|
486
|
+
title: "3. Repair the design",
|
|
487
|
+
copy: "Now propose the production-minded improvement. This is where explanation becomes ownership.",
|
|
488
|
+
render: renderRepair
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: "transfer",
|
|
492
|
+
title: "4. Transfer to a new situation",
|
|
493
|
+
copy: "The point is not to remember this code. The point is to make the next similar decision without help.",
|
|
494
|
+
render: renderTransfer
|
|
495
|
+
}
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
function save() {
|
|
499
|
+
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
500
|
+
updateProgress();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function render() {
|
|
504
|
+
const stage = stages[state.stage];
|
|
505
|
+
document.getElementById("stage-title").textContent = stage.title;
|
|
506
|
+
document.getElementById("stage-copy").textContent = stage.copy;
|
|
507
|
+
document.getElementById("stage-content").innerHTML = stage.render();
|
|
508
|
+
renderEvidenceLens(stage.id);
|
|
509
|
+
document.getElementById("prev").disabled = state.stage === 0;
|
|
510
|
+
document.getElementById("next").disabled = !state.complete[stage.id];
|
|
511
|
+
bindStage();
|
|
512
|
+
updateProgress();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function renderDiagnose() {
|
|
516
|
+
return \`
|
|
517
|
+
<div class="prompt">\${escapeForClient(module.diagnose.prompt)}</div>
|
|
518
|
+
<div class="choice-grid">
|
|
519
|
+
\${module.diagnose.choices.map((choice, index) => choiceButton("diagnose", choice, index)).join("")}
|
|
520
|
+
</div>
|
|
521
|
+
\${passCriteria("diagnose")}
|
|
522
|
+
\${checkOutput("diagnose")}
|
|
523
|
+
\${lesson("diagnose", "Mental model unlocked", \`
|
|
524
|
+
<div class="contrast">
|
|
525
|
+
<div class="card"><b>Decision name</b><p>\${escapeForClient(module.name)}</p></div>
|
|
526
|
+
<div class="card"><b>Why it appeared</b><p>\${escapeForClient(module.why)}</p></div>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="card"><b>Taste takeaway</b><p>\${escapeForClient(module.takeaway)}</p></div>
|
|
529
|
+
\`)}
|
|
530
|
+
\`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function renderBreak() {
|
|
534
|
+
const simReady = module.failureSim && isChoiceCorrect("break");
|
|
535
|
+
return \`
|
|
536
|
+
<div class="prompt">\${escapeForClient(module.break.prompt)}</div>
|
|
537
|
+
<code class="block">\${escapeForClient(module.naiveCode)}</code>
|
|
538
|
+
<div class="choice-grid">
|
|
539
|
+
\${module.break.choices.map((choice, index) => choiceButton("break", choice, index)).join("")}
|
|
540
|
+
</div>
|
|
541
|
+
\${passCriteria("break")}
|
|
542
|
+
\${checkOutput("break")}
|
|
543
|
+
\${simReady ? \`
|
|
544
|
+
<div class="sim">
|
|
545
|
+
<div class="sim-title">Failure simulation — this is what your terminal would show</div>
|
|
546
|
+
<pre class="terminal">\${escapeForClient(module.failureSim.terminal)}</pre>
|
|
547
|
+
<div class="prompt">\${escapeForClient(module.failureSim.prompt)}</div>
|
|
548
|
+
<div class="choice-grid">
|
|
549
|
+
\${module.failureSim.choices.map((choice, index) => choiceButton("breakFix", choice, index)).join("")}
|
|
550
|
+
</div>
|
|
551
|
+
\${checkOutput("breakFix")}
|
|
552
|
+
</div>\` : ""}
|
|
553
|
+
\${lesson("break", "Failure mode unlocked", \`
|
|
554
|
+
<div class="contrast">
|
|
555
|
+
<div class="card"><b>Naive version</b><p>\${escapeForClient(module.naive)}</p></div>
|
|
556
|
+
<div class="card"><b>What breaks</b><p>\${escapeForClient(module.breaks)}</p></div>
|
|
557
|
+
</div>
|
|
558
|
+
\`)}
|
|
559
|
+
\`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function renderRepair() {
|
|
563
|
+
if (!module.repairLab) return renderChoiceStage("repair", "Better design unlocked");
|
|
564
|
+
const review = state.reviews.repair;
|
|
565
|
+
return \`
|
|
566
|
+
<div class="prompt">\${escapeForClient(module.repair.prompt)}</div>
|
|
567
|
+
<p>\${escapeForClient(module.repairLab.instructions)}</p>
|
|
568
|
+
<textarea id="editor-repair" class="code-editor" spellcheck="false" rows="14">\${escapeForClient(state.answers.repair ?? module.repairLab.starter)}</textarea>
|
|
569
|
+
<div class="actions">
|
|
570
|
+
<button data-review="repair" \${state.reviewing ? "disabled" : ""}>\${state.reviewing === "repair" ? "reviewing…" : "replay check repair"}</button>
|
|
571
|
+
\${review && review.overall === "FAIL" ? '<span class="retry-note">Edit and check again — the rubric tells you exactly what is missing.</span>' : ""}
|
|
572
|
+
</div>
|
|
573
|
+
\${passCriteria("repair")}
|
|
574
|
+
\${reviewOutput("repair")}
|
|
575
|
+
\${lesson("repair", "Better design unlocked", \`
|
|
576
|
+
<div class="contrast">
|
|
577
|
+
<div class="card"><b>AI session version</b><p>\${escapeForClient(module.aiVersion)}</p></div>
|
|
578
|
+
<div class="card"><b>Production version</b><p>\${escapeForClient(module.production)}</p></div>
|
|
579
|
+
</div>
|
|
580
|
+
\${module.patternHref ? \`<div class="card"><b>Catalog entry</b><p>Runtime Boundary is now part of your catalog. <a href="\${module.patternHref}">Read the full pattern page →</a></p></div>\` : ""}
|
|
581
|
+
\`)}
|
|
582
|
+
\`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function renderTransfer() {
|
|
586
|
+
if (!module.transferLab) return renderChoiceStage("transfer", "Transfer rule unlocked");
|
|
587
|
+
const review = state.reviews.transfer;
|
|
588
|
+
return \`
|
|
589
|
+
<div class="prompt">\${escapeForClient(module.transfer.prompt)}</div>
|
|
590
|
+
<div class="card"><b>New situation</b><p>\${escapeForClient(module.transfer.scenario)}</p></div>
|
|
591
|
+
<p style="margin-top:12px">\${escapeForClient(module.transferLab.instructions)}</p>
|
|
592
|
+
<textarea id="editor-transfer" spellcheck="true" rows="8" placeholder="\${escapeForClient(module.transferLab.placeholder)}">\${escapeForClient(state.answers.transfer ?? "")}</textarea>
|
|
593
|
+
<div class="actions">
|
|
594
|
+
<button data-review="transfer" \${state.reviewing ? "disabled" : ""}>\${state.reviewing === "transfer" ? "reviewing…" : "replay check transfer"}</button>
|
|
595
|
+
\${review && review.overall === "FAIL" ? '<span class="retry-note">Sharpen the plan and check again.</span>' : ""}
|
|
596
|
+
</div>
|
|
597
|
+
\${passCriteria("transfer")}
|
|
598
|
+
\${reviewOutput("transfer")}
|
|
599
|
+
\${lesson("transfer", "Transfer rule unlocked", \`
|
|
600
|
+
<div class="card"><b>Reusable rule</b><p>\${escapeForClient(module.transfer.rule)}</p></div>
|
|
601
|
+
<div class="card"><b>Next practice</b><p>\${escapeForClient(module.exercise)}</p></div>
|
|
602
|
+
\`)}
|
|
603
|
+
\`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function renderChoiceStage(stageId, unlockTitle) {
|
|
607
|
+
const m = getStageModule(stageId);
|
|
608
|
+
return \`
|
|
609
|
+
<div class="prompt">\${escapeForClient(m.prompt)}</div>
|
|
610
|
+
\${m.scenario ? \`<div class="card"><b>New situation</b><p>\${escapeForClient(m.scenario)}</p></div>\` : ""}
|
|
611
|
+
<div class="choice-grid">
|
|
612
|
+
\${m.choices.map((choice, index) => choiceButton(stageId, choice, index)).join("")}
|
|
613
|
+
</div>
|
|
614
|
+
\${passCriteria(stageId)}
|
|
615
|
+
\${checkOutput(stageId)}
|
|
616
|
+
\${lesson(stageId, unlockTitle, \`
|
|
617
|
+
<div class="card"><b>Takeaway</b><p>\${escapeForClient(module.takeaway)}</p></div>
|
|
618
|
+
\`)}
|
|
619
|
+
\`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function reviewOutput(stageId) {
|
|
623
|
+
const review = state.reviews[stageId];
|
|
624
|
+
if (!review) return "";
|
|
625
|
+
const lines = (review.criteria || []).map((criterion) =>
|
|
626
|
+
\`[\${criterion.pass ? "PASS" : "FAIL"}] \${escapeForClient(criterion.note)}\`
|
|
627
|
+
).join("\\n");
|
|
628
|
+
const reviewer = review.reviewer === "claude"
|
|
629
|
+
? "reviewer: claude (real review)"
|
|
630
|
+
: review.reviewer === "heuristic"
|
|
631
|
+
? "reviewer: heuristic fallback — start replay-labs serve with the claude CLI for real review"
|
|
632
|
+
: review.reviewer === "offline"
|
|
633
|
+
? "reviewer: offline heuristic — this page is not being served by replay-labs serve"
|
|
634
|
+
: "reviewer: validator";
|
|
635
|
+
return \`<div class="check-output open">replay review \${stageId}
|
|
636
|
+
\${reviewer}
|
|
637
|
+
\${lines}
|
|
638
|
+
overall: \${review.overall}
|
|
639
|
+
\${escapeForClient(review.summary || "")}\${review.misconception && review.overall !== "PASS" ? "\\nmisconception: " + escapeForClient(review.misconception) : ""}</div>\`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function isChoiceCorrect(stageId) {
|
|
643
|
+
const m = getStageModule(stageId);
|
|
644
|
+
return m && state.choices[stageId] != null && Boolean(m.choices[state.choices[stageId]].correct);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function runReview(stageId) {
|
|
648
|
+
const editor = document.getElementById("editor-" + stageId);
|
|
649
|
+
const submission = editor ? editor.value : "";
|
|
650
|
+
state.answers[stageId] = submission;
|
|
651
|
+
state.reviewing = stageId;
|
|
652
|
+
save();
|
|
653
|
+
render();
|
|
654
|
+
let result = null;
|
|
655
|
+
try {
|
|
656
|
+
const response = await fetch("/api/review", {
|
|
657
|
+
method: "POST",
|
|
658
|
+
headers: { "content-type": "application/json" },
|
|
659
|
+
body: JSON.stringify({ stage: stageId, submission })
|
|
660
|
+
});
|
|
661
|
+
if (response.ok) result = await response.json();
|
|
662
|
+
} catch { /* not served by replay-labs serve */ }
|
|
663
|
+
if (!result) result = offlineHeuristic(stageId, submission);
|
|
664
|
+
state.reviewing = null;
|
|
665
|
+
state.reviews[stageId] = result;
|
|
666
|
+
state.complete[stageId] = result.overall === "PASS";
|
|
667
|
+
save();
|
|
668
|
+
render();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function offlineHeuristic(stageId, submission) {
|
|
672
|
+
const checks = stageId === "repair"
|
|
673
|
+
? [
|
|
674
|
+
["Client boundary", /['"]use client['"]|ssr:\\s*false/.test(submission), true],
|
|
675
|
+
["Capability guards", /typeof window|in window|\\?\\?|\\|\\||navigator\\./.test(submission), true],
|
|
676
|
+
["Unsupported state", /unsupported|not supported|fallback/i.test(submission), true],
|
|
677
|
+
["Permission denial", /denied|permission|catch/i.test(submission), false],
|
|
678
|
+
["Verification", /test|verify|check\\b/i.test(submission), false]
|
|
679
|
+
]
|
|
680
|
+
: [
|
|
681
|
+
["Boundary isolation", /client (component|boundary)|['"]use client['"]|isolate/i.test(submission), true],
|
|
682
|
+
["Capability checks", /detect|capabilit|typeof|in navigator|permissions/i.test(submission), true],
|
|
683
|
+
["Failure states", /denied|unsupported|fallback|error state/i.test(submission), true],
|
|
684
|
+
["Ownership reasoning", /runtime|server|render|ownership|boundary/i.test(submission), false],
|
|
685
|
+
["Verification", /test|verify|device|browser/i.test(submission), false]
|
|
686
|
+
];
|
|
687
|
+
const criteria = checks.map(([name, pass]) => ({ id: name, pass, note: (pass ? "Detected: " : "Missing: ") + name }));
|
|
688
|
+
const requiredOk = checks.filter((c) => c[2]).every((c) => c[1]);
|
|
689
|
+
const optionalOk = checks.filter((c) => !c[2]).some((c) => c[1]);
|
|
690
|
+
return {
|
|
691
|
+
criteria,
|
|
692
|
+
overall: requiredOk && optionalOk ? "PASS" : "FAIL",
|
|
693
|
+
summary: "Offline pattern-match only. Run 'node ./src/cli.js serve' and reload for a real reviewer.",
|
|
694
|
+
misconception: null,
|
|
695
|
+
reviewer: "offline"
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function choiceButton(stageId, choice, index) {
|
|
700
|
+
const selected = state.choices[stageId] === index;
|
|
701
|
+
const completed = Boolean(state.complete[stageId]);
|
|
702
|
+
const className = selected ? (choice.correct ? "choice selected" : "choice wrong") : "choice";
|
|
703
|
+
return \`
|
|
704
|
+
<button class="\${className}" data-choice-stage="\${stageId}" data-choice-index="\${index}" type="button">
|
|
705
|
+
<b>\${escapeForClient(choice.label)}</b>
|
|
706
|
+
<span>\${selected ? escapeForClient(choice.feedback) : escapeForClient(choice.description)}</span>
|
|
707
|
+
</button>
|
|
708
|
+
\`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function passCriteria(stageId) {
|
|
712
|
+
return \`
|
|
713
|
+
<div class="pass-criteria">
|
|
714
|
+
<b>Pass condition</b>
|
|
715
|
+
<span>\${escapeForClient(module.criteria[stageId])}</span>
|
|
716
|
+
</div>
|
|
717
|
+
\`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function checkOutput(stageId) {
|
|
721
|
+
if (!(stageId in state.choices)) return "";
|
|
722
|
+
const choice = getStageModule(stageId).choices[state.choices[stageId]];
|
|
723
|
+
const status = choice.correct ? "PASS" : "FAIL";
|
|
724
|
+
return \`
|
|
725
|
+
<div class="check-output open">replay check \${stageId}
|
|
726
|
+
status: \${status}
|
|
727
|
+
reason: \${escapeForClient(choice.feedback)}</div>
|
|
728
|
+
\`;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function lesson(stageId, title, body) {
|
|
732
|
+
return \`<div class="unlock \${state.complete[stageId] ? "open" : ""}">
|
|
733
|
+
<div class="unlock-header">\${escapeForClient(title)}</div>
|
|
734
|
+
<div class="unlock-body">\${body}</div>
|
|
735
|
+
</div>\`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function bindStage() {
|
|
739
|
+
document.querySelectorAll("[data-choice-stage]").forEach((button) => {
|
|
740
|
+
button.addEventListener("click", () => {
|
|
741
|
+
const stageId = button.getAttribute("data-choice-stage");
|
|
742
|
+
const index = Number(button.getAttribute("data-choice-index"));
|
|
743
|
+
state.choices[stageId] = index;
|
|
744
|
+
if (stageId === "break" || stageId === "breakFix") {
|
|
745
|
+
state.complete.break = isChoiceCorrect("break") && (!module.failureSim || isChoiceCorrect("breakFix"));
|
|
746
|
+
} else {
|
|
747
|
+
state.complete[stageId] = isChoiceCorrect(stageId);
|
|
748
|
+
}
|
|
749
|
+
save();
|
|
750
|
+
render();
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
document.querySelectorAll("[data-review]").forEach((button) => {
|
|
754
|
+
button.addEventListener("click", () => runReview(button.getAttribute("data-review")));
|
|
755
|
+
});
|
|
756
|
+
document.querySelectorAll("textarea[id^='editor-']").forEach((editor) => {
|
|
757
|
+
editor.addEventListener("input", () => {
|
|
758
|
+
state.answers[editor.id.replace("editor-", "")] = editor.value;
|
|
759
|
+
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function getStageModule(stageId) {
|
|
765
|
+
if (stageId === "breakFix") return module.failureSim;
|
|
766
|
+
return stageId === "diagnose" ? module.diagnose : stageId === "break" ? module.break : stageId === "repair" ? module.repair : module.transfer;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function updateProgress() {
|
|
770
|
+
const done = stages.filter((stage) => state.complete[stage.id]).length;
|
|
771
|
+
const pct = Math.round((done / stages.length) * 100);
|
|
772
|
+
document.getElementById("fill").style.width = pct + "%";
|
|
773
|
+
document.getElementById("progress-label").textContent = done + " of " + stages.length + " stages complete";
|
|
774
|
+
document.getElementById("mastery").classList.toggle("open", done === stages.length);
|
|
775
|
+
document.getElementById("artifact-model").textContent = module.name;
|
|
776
|
+
document.getElementById("artifact-failure").textContent = module.artifact.failure;
|
|
777
|
+
document.getElementById("artifact-standard").textContent = module.artifact.standard;
|
|
778
|
+
document.getElementById("artifact-transfer").textContent = module.transfer.rule;
|
|
779
|
+
document.getElementById("stage-list").innerHTML = stages.map((stage, index) => \`
|
|
780
|
+
<button class="stage-button \${index === state.stage ? "active" : ""} \${state.complete[stage.id] ? "done" : ""}" data-stage="\${index}" type="button">
|
|
781
|
+
<span class="number">\${state.complete[stage.id] ? "OK" : index + 1}</span>
|
|
782
|
+
<span>\${escapeForClient(stage.title.replace(/^\\d+\\. /, ""))}</span>
|
|
783
|
+
</button>
|
|
784
|
+
\`).join("");
|
|
785
|
+
document.querySelectorAll("[data-stage]").forEach((button) => {
|
|
786
|
+
button.addEventListener("click", () => {
|
|
787
|
+
state.stage = Number(button.getAttribute("data-stage"));
|
|
788
|
+
save();
|
|
789
|
+
render();
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function renderEvidenceLens(stageId) {
|
|
795
|
+
const lens = module.lenses[stageId];
|
|
796
|
+
document.getElementById("lens-title").textContent = lens.title;
|
|
797
|
+
document.getElementById("lens-items").innerHTML = lens.items
|
|
798
|
+
.map((item) => "<li>" + escapeForClient(item) + "</li>")
|
|
799
|
+
.join("");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
document.getElementById("prev").addEventListener("click", () => {
|
|
803
|
+
state.stage = Math.max(0, state.stage - 1);
|
|
804
|
+
save();
|
|
805
|
+
render();
|
|
806
|
+
});
|
|
807
|
+
document.getElementById("next").addEventListener("click", () => {
|
|
808
|
+
state.stage = Math.min(stages.length - 1, state.stage + 1);
|
|
809
|
+
save();
|
|
810
|
+
render();
|
|
811
|
+
});
|
|
812
|
+
document.getElementById("reset").addEventListener("click", () => {
|
|
813
|
+
localStorage.removeItem(storageKey);
|
|
814
|
+
location.reload();
|
|
815
|
+
});
|
|
816
|
+
render();
|
|
817
|
+
|
|
818
|
+
function escapeForClient(value) {
|
|
819
|
+
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
820
|
+
}
|
|
821
|
+
</script>
|
|
822
|
+
</body>
|
|
823
|
+
</html>`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function buildLabModule(decision) {
|
|
827
|
+
if (decision.id === "next-client-boundary") {
|
|
828
|
+
return {
|
|
829
|
+
name: "Runtime Boundary",
|
|
830
|
+
why:
|
|
831
|
+
"The session used browser-only APIs such as speech synthesis, microphone input, and localStorage inside a Next.js app. That makes the client/server boundary the central decision.",
|
|
832
|
+
takeaway:
|
|
833
|
+
"When code depends on browser APIs, decide the runtime boundary before you design the component.",
|
|
834
|
+
naive:
|
|
835
|
+
"Use browser APIs directly in a component without deciding whether the component runs on the server or in the browser.",
|
|
836
|
+
naiveCode: `export default function Page() {
|
|
837
|
+
const recognition = new window.SpeechRecognition();
|
|
838
|
+
localStorage.setItem("goals", "[]");
|
|
839
|
+
}`,
|
|
840
|
+
patternHref: "patterns/runtime-boundary.html",
|
|
841
|
+
failureSim: {
|
|
842
|
+
terminal: `$ next build
|
|
843
|
+
|
|
844
|
+
Creating an optimized production build ...
|
|
845
|
+
✓ Compiled successfully
|
|
846
|
+
|
|
847
|
+
Generating static pages (0/3) ...
|
|
848
|
+
ReferenceError: window is not defined
|
|
849
|
+
at Page (app/page.tsx:2:28)
|
|
850
|
+
at renderToHTML (node_modules/next/dist/server/render.js:387:14)
|
|
851
|
+
|
|
852
|
+
> Export encountered an error on /page: /, exiting the build.`,
|
|
853
|
+
prompt:
|
|
854
|
+
"This exact code worked all afternoon in the browser. Why does the error appear only at build time?",
|
|
855
|
+
choices: [
|
|
856
|
+
{
|
|
857
|
+
label: "The code now executes on the server first",
|
|
858
|
+
description: "Build-time prerendering runs the component in Node, where browser globals do not exist.",
|
|
859
|
+
feedback: "Correct. Nothing about the code changed — the runtime that executes it changed. That is the whole pattern.",
|
|
860
|
+
correct: true
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
label: "Next.js deprecated window in this version",
|
|
864
|
+
description: "The framework removed access to the window object.",
|
|
865
|
+
feedback: "No. window is fine — in the browser. The error names the place this ran: renderToHTML, on the server.",
|
|
866
|
+
correct: false
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
label: "The browser blocked the speech API for security",
|
|
870
|
+
description: "Permission policies stopped SpeechRecognition.",
|
|
871
|
+
feedback: "Permissions fail differently and at click-time. Read the stack trace: this never reached a browser.",
|
|
872
|
+
correct: false
|
|
873
|
+
}
|
|
874
|
+
]
|
|
875
|
+
},
|
|
876
|
+
repairLab: {
|
|
877
|
+
instructions:
|
|
878
|
+
"Edit the code until you would ship it. Keep it small — this is judgment, not typing practice. Your repair is reviewed against the shipping rubric, criterion by criterion.",
|
|
879
|
+
starter: `export default function Page() {
|
|
880
|
+
const recognition = new window.SpeechRecognition();
|
|
881
|
+
localStorage.setItem("goals", "[]");
|
|
882
|
+
}`
|
|
883
|
+
},
|
|
884
|
+
transferLab: {
|
|
885
|
+
instructions:
|
|
886
|
+
"Write the handoff rule: where the boundary goes, what gets checked, which failure states exist, and how you verify. 4-8 sentences.",
|
|
887
|
+
placeholder:
|
|
888
|
+
"My plan: the geolocation/camera work goes in… before using the APIs I check… if the user denies or the browser lacks support, the dashboard shows… I verify this by…"
|
|
889
|
+
},
|
|
890
|
+
breaks:
|
|
891
|
+
"`window`, microphone APIs, speech synthesis, and localStorage do not exist during server rendering. Even in the browser, unsupported APIs and denied permissions need a designed fallback.",
|
|
892
|
+
aiVersion:
|
|
893
|
+
"The AI put the main voice experience behind a client boundary with `'use client'` and kept browser behavior in `app/page.tsx`.",
|
|
894
|
+
production:
|
|
895
|
+
"Keep the client boundary, add capability checks, show unsupported-browser and permission-denied states, and consider server-driven voice if reliability matters more than browser-native speed.",
|
|
896
|
+
exercise:
|
|
897
|
+
"Create a scratch version without the client boundary, predict the failure, then add a browser-support fallback before restoring the working design.",
|
|
898
|
+
challenge: {
|
|
899
|
+
pattern: "Runtime Boundary",
|
|
900
|
+
patternCopy: "A professional decision about which environment owns a behavior.",
|
|
901
|
+
smell: "Browser API leak",
|
|
902
|
+
smellCopy: "Server-rendered code reaches for window, localStorage, microphone, or speech APIs.",
|
|
903
|
+
proof: "Transfer, not recall",
|
|
904
|
+
proofCopy: "You pass only when you can apply the same rule to a different browser-capability feature."
|
|
905
|
+
},
|
|
906
|
+
criteria: {
|
|
907
|
+
diagnose: "Identify the decision type from evidence before reading the explanation.",
|
|
908
|
+
break: "Predict the first runtime failure, then explain the failure simulation: why only at build time?",
|
|
909
|
+
repair: "Real review. Required: client boundary, capability guards, designed unsupported state. Seal it with permission-denied handling or named verification.",
|
|
910
|
+
transfer: "Real review. Required: boundary isolation, capability checks, failure states. Seal it with ownership reasoning or verification beyond dev."
|
|
911
|
+
},
|
|
912
|
+
artifact: {
|
|
913
|
+
failure: "Server-rendered code touches browser globals such as window, speechSynthesis, microphone APIs, or localStorage.",
|
|
914
|
+
standard: "Keep browser behavior inside a client boundary, add capability and permission fallbacks, and verify those states."
|
|
915
|
+
},
|
|
916
|
+
lenses: {
|
|
917
|
+
diagnose: {
|
|
918
|
+
title: "Look for the decision type",
|
|
919
|
+
items: ["Browser-only APIs", "Next.js runtime boundary", "Evidence that this is not only styling or data modeling"]
|
|
920
|
+
},
|
|
921
|
+
break: {
|
|
922
|
+
title: "Look for the first failure",
|
|
923
|
+
items: ["Any reference to window", "APIs that only exist in the browser", "Code that would execute before the page reaches a user"]
|
|
924
|
+
},
|
|
925
|
+
repair: {
|
|
926
|
+
title: "Look for the shipping gap",
|
|
927
|
+
items: ["Unsupported browser behavior", "Denied microphone permission", "A verification path for failure states"]
|
|
928
|
+
},
|
|
929
|
+
transfer: {
|
|
930
|
+
title: "Look for the reusable pattern",
|
|
931
|
+
items: ["New browser capabilities", "Client/server ownership", "Fallback states beyond local success"]
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
diagnose: {
|
|
935
|
+
prompt: "What kind of decision did the AI make when it added `'use client'` to the voice experience?",
|
|
936
|
+
choices: [
|
|
937
|
+
{
|
|
938
|
+
label: "Runtime boundary",
|
|
939
|
+
description: "It decided which parts of the feature must run in the browser instead of on the server.",
|
|
940
|
+
feedback: "Correct. This is a client/server runtime boundary decision, not just a Next.js syntax detail.",
|
|
941
|
+
correct: true
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
label: "Styling architecture",
|
|
945
|
+
description: "It chose how the UI should be organized visually.",
|
|
946
|
+
feedback: "Not quite. The evidence is about browser APIs and rendering environment, not visual structure.",
|
|
947
|
+
correct: false
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
label: "Database modeling",
|
|
951
|
+
description: "It chose how goals should be stored.",
|
|
952
|
+
feedback: "That is another decision in the session, but it does not explain `'use client'`.",
|
|
953
|
+
correct: false
|
|
954
|
+
}
|
|
955
|
+
]
|
|
956
|
+
},
|
|
957
|
+
break: {
|
|
958
|
+
prompt: "If this naive version runs during server rendering, what is the most important failure?",
|
|
959
|
+
choices: [
|
|
960
|
+
{
|
|
961
|
+
label: "`window` is undefined",
|
|
962
|
+
description: "Server rendering has no browser globals, so the code can fail before the user reaches the page.",
|
|
963
|
+
feedback: "Correct. The first failure is runtime ownership: the server cannot access browser globals.",
|
|
964
|
+
correct: true
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
label: "The CSS bundle gets larger",
|
|
968
|
+
description: "Bundle size may matter, but it is not the core failure here.",
|
|
969
|
+
feedback: "Not the primary issue. The problem happens before styling performance matters.",
|
|
970
|
+
correct: false
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
label: "The database loses goals",
|
|
974
|
+
description: "Persistence is a separate risk, not the reason this code needs a client boundary.",
|
|
975
|
+
feedback: "Different decision. Here the evidence points to browser-only APIs.",
|
|
976
|
+
correct: false
|
|
977
|
+
}
|
|
978
|
+
]
|
|
979
|
+
},
|
|
980
|
+
repair: {
|
|
981
|
+
prompt: "Which improvement would you require before shipping this beyond a demo?",
|
|
982
|
+
choices: [
|
|
983
|
+
{
|
|
984
|
+
label: "Keep client boundary, add capability checks, fallback UI, and verification",
|
|
985
|
+
description: "Treat browser support and permission denial as designed states, not accidental runtime surprises.",
|
|
986
|
+
feedback: "Correct. This is the minimum standard that turns the demo decision into shippable judgment.",
|
|
987
|
+
correct: true
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
label: "Remove the client boundary so the page renders faster",
|
|
991
|
+
description: "Optimize for server rendering even though the feature depends on browser APIs.",
|
|
992
|
+
feedback: "Wrong tradeoff. The feature needs browser ownership before rendering optimization matters.",
|
|
993
|
+
correct: false
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
label: "Keep the demo unchanged because it works locally",
|
|
997
|
+
description: "Accept local success as enough evidence.",
|
|
998
|
+
feedback: "This is the exact behavior Replay should challenge: local success is not ownership.",
|
|
999
|
+
correct: false
|
|
1000
|
+
}
|
|
1001
|
+
]
|
|
1002
|
+
},
|
|
1003
|
+
transfer: {
|
|
1004
|
+
prompt: "Apply the same judgment to a new feature.",
|
|
1005
|
+
scenario:
|
|
1006
|
+
"A future AI session adds geolocation, camera capture, and localStorage to a Next.js dashboard. The feature works in the browser during development.",
|
|
1007
|
+
rule:
|
|
1008
|
+
"If the feature depends on browser-only capabilities, isolate that behavior behind a client boundary, design fallback states, and keep server code free of browser globals.",
|
|
1009
|
+
choices: [
|
|
1010
|
+
{
|
|
1011
|
+
label: "Create a client component with capability checks",
|
|
1012
|
+
description: "Put browser-only behavior in a client boundary and handle unsupported or denied states.",
|
|
1013
|
+
feedback: "Correct. You transferred the runtime-boundary decision to a new browser-capability feature.",
|
|
1014
|
+
correct: true
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
label: "Move all code into an API route",
|
|
1018
|
+
description: "Server routes protect secrets, but they cannot access the user's camera or geolocation directly.",
|
|
1019
|
+
feedback: "Not enough. API routes solve secret boundaries, not browser capability ownership.",
|
|
1020
|
+
correct: false
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
label: "Keep it in the page and rely on development behavior",
|
|
1024
|
+
description: "If it works locally, ship it unchanged.",
|
|
1025
|
+
feedback: "This is the trap Replay exists to fight: working once is not the same as owning the decision.",
|
|
1026
|
+
correct: false
|
|
1027
|
+
}
|
|
1028
|
+
]
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return {
|
|
1034
|
+
name: decision.title,
|
|
1035
|
+
why: decision.why,
|
|
1036
|
+
takeaway: decision.seniorCheck,
|
|
1037
|
+
naive: decision.beginnerMiss,
|
|
1038
|
+
naiveCode: "// Naive version: accept the first working implementation without naming the decision.",
|
|
1039
|
+
breaks: "The human can ship the code but cannot evaluate, adapt, or debug the same decision later.",
|
|
1040
|
+
aiVersion: "The AI produced a working implementation in the session.",
|
|
1041
|
+
production: "Name the decision, compare alternatives, identify failure modes, and verify the code with evidence.",
|
|
1042
|
+
exercise: "Name one alternative, one failure mode, and one line of evidence from the diff.",
|
|
1043
|
+
challenge: {
|
|
1044
|
+
pattern: "Decision Ownership",
|
|
1045
|
+
patternCopy: "A professional decision is one the human can explain and reuse.",
|
|
1046
|
+
smell: "Fluent but unowned code",
|
|
1047
|
+
smellCopy: "The implementation exists, but the learner cannot name the tradeoff.",
|
|
1048
|
+
proof: "Transfer, not summary",
|
|
1049
|
+
proofCopy: "The learner must reuse the idea in a new context."
|
|
1050
|
+
},
|
|
1051
|
+
criteria: {
|
|
1052
|
+
diagnose: "Identify the decision as a tradeoff, not a formatting detail.",
|
|
1053
|
+
break: "Name what the learner cannot do if the decision is unowned.",
|
|
1054
|
+
repair: "Choose a review standard that requires evidence and verification.",
|
|
1055
|
+
transfer: "Reuse the decision in another session."
|
|
1056
|
+
},
|
|
1057
|
+
artifact: {
|
|
1058
|
+
failure: "The learner can repeat the code but cannot adapt the decision when context changes.",
|
|
1059
|
+
standard: "Name the decision, compare alternatives, identify a failure mode, and verify the behavior."
|
|
1060
|
+
},
|
|
1061
|
+
lenses: {
|
|
1062
|
+
diagnose: { title: "Look for the decision type", items: ["Changed behavior", "A constraint", "A tradeoff"] },
|
|
1063
|
+
break: { title: "Look for the failure", items: ["What would be hard to debug later", "What the learner could not adapt"] },
|
|
1064
|
+
repair: { title: "Look for the review standard", items: ["Evidence", "Alternative", "Failure mode", "Verification"] },
|
|
1065
|
+
transfer: { title: "Look for reuse", items: ["A future context", "The same judgment in different code"] }
|
|
1066
|
+
},
|
|
1067
|
+
diagnose: {
|
|
1068
|
+
prompt: "What kind of decision is this?",
|
|
1069
|
+
choices: [
|
|
1070
|
+
{ label: "A design tradeoff", description: decision.why, feedback: "Correct. Start by naming the tradeoff.", correct: true },
|
|
1071
|
+
{ label: "A formatting choice", description: "The code style changed.", feedback: "Too shallow. Look for behavior and constraints.", correct: false },
|
|
1072
|
+
{ label: "A random implementation detail", description: "The AI just happened to write it this way.", feedback: "That assumption prevents learning. Ask what constraint shaped it.", correct: false }
|
|
1073
|
+
]
|
|
1074
|
+
},
|
|
1075
|
+
break: {
|
|
1076
|
+
prompt: "What breaks if the learner cannot explain this decision?",
|
|
1077
|
+
choices: [
|
|
1078
|
+
{ label: "They cannot adapt it later", description: "The next similar problem will still require blind delegation.", feedback: "Correct. Transfer is the real goal.", correct: true },
|
|
1079
|
+
{ label: "Nothing breaks", description: "The code already runs.", feedback: "Running code can still fail as education.", correct: false },
|
|
1080
|
+
{ label: "Only naming gets worse", description: "This is cosmetic.", feedback: "The issue is judgment, not vocabulary alone.", correct: false }
|
|
1081
|
+
]
|
|
1082
|
+
},
|
|
1083
|
+
repair: {
|
|
1084
|
+
prompt: "Which review standard would you apply before accepting this implementation?",
|
|
1085
|
+
choices: [
|
|
1086
|
+
{ label: "Name the decision, evidence, alternative, failure mode, and verification", description: "This standard proves the human can own the work.", feedback: "Correct.", correct: true },
|
|
1087
|
+
{ label: "Ask the AI for a nicer explanation", description: "This may help reading, but it does not prove judgment.", feedback: "Too passive.", correct: false },
|
|
1088
|
+
{ label: "Ship because the diff exists", description: "A diff is not evidence of understanding.", feedback: "That misses the point.", correct: false }
|
|
1089
|
+
]
|
|
1090
|
+
},
|
|
1091
|
+
transfer: {
|
|
1092
|
+
prompt: "How would you reuse this lesson in a new session?",
|
|
1093
|
+
scenario: "A future AI session makes a similar implementation choice in a different feature.",
|
|
1094
|
+
rule: "A decision is learned only when you can recognize and apply it in a new context.",
|
|
1095
|
+
choices: [
|
|
1096
|
+
{ label: "Name the decision and compare alternatives", description: "Use the prior session as a mental model.", feedback: "Correct.", correct: true },
|
|
1097
|
+
{ label: "Ask for a summary", description: "Summaries help, but they do not prove transfer.", feedback: "Too passive.", correct: false },
|
|
1098
|
+
{ label: "Accept the working code", description: "Working code is not proof of understanding.", feedback: "That is the anti-goal.", correct: false }
|
|
1099
|
+
]
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function findEvidenceSnippet(diff, patterns = []) {
|
|
1105
|
+
const lines = diff.split("\n");
|
|
1106
|
+
const index = lines.findIndex((line) => patterns.some((pattern) => pattern.test(line)));
|
|
1107
|
+
// Wider window so a decision's blast radius (directive + dependent lines, often
|
|
1108
|
+
// 30-50 lines apart) lands in one evidence snippet — enables multi-line diagnosis.
|
|
1109
|
+
const start = Math.max(0, index - 7);
|
|
1110
|
+
const end = index >= 0 ? Math.min(lines.length, index + 56) : Math.min(lines.length, 40);
|
|
1111
|
+
return lines.slice(start, end).join("\n");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function highlightEvidence(value, patterns = []) {
|
|
1115
|
+
const escaped = escapeHtml(value);
|
|
1116
|
+
const literalPatterns = patterns
|
|
1117
|
+
.map((pattern) => extractLiteral(pattern))
|
|
1118
|
+
.filter((text) => text.length > 3)
|
|
1119
|
+
.sort((a, b) => b.length - a.length)
|
|
1120
|
+
.filter((text, index, values) => {
|
|
1121
|
+
const lower = text.toLowerCase();
|
|
1122
|
+
return !values.slice(0, index).some((previous) => previous.toLowerCase().includes(lower));
|
|
1123
|
+
})
|
|
1124
|
+
.slice(0, 5);
|
|
1125
|
+
|
|
1126
|
+
return literalPatterns.reduce((html, literal) => {
|
|
1127
|
+
const expression = new RegExp(escapeRegExp(literal), "gi");
|
|
1128
|
+
return html.replace(expression, (match) => `<mark>${match}</mark>`);
|
|
1129
|
+
}, escaped);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function extractLiteral(pattern) {
|
|
1133
|
+
const source = pattern.source
|
|
1134
|
+
.replaceAll("\\/", "/")
|
|
1135
|
+
.replaceAll("\\.", ".")
|
|
1136
|
+
.replaceAll("\\s", " ")
|
|
1137
|
+
.replaceAll("\\+", "+")
|
|
1138
|
+
.replaceAll("|", " ");
|
|
1139
|
+
const match = source.match(/[A-Za-z_/@.-]{4,}/);
|
|
1140
|
+
return match ? match[0] : "";
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function escapeRegExp(value) {
|
|
1144
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function escapeHtml(value) {
|
|
1148
|
+
return String(value)
|
|
1149
|
+
.replaceAll("&", "&")
|
|
1150
|
+
.replaceAll("<", "<")
|
|
1151
|
+
.replaceAll(">", ">")
|
|
1152
|
+
.replaceAll('"', """);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function hashForStorage(value) {
|
|
1156
|
+
let hash = 0;
|
|
1157
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1158
|
+
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
1159
|
+
}
|
|
1160
|
+
return hash.toString(16);
|
|
1161
|
+
}
|