@looop-games/cli 0.1.2 → 0.1.4

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.
@@ -8,20 +8,64 @@
8
8
  // covered by packed-install.smoke.mjs.
9
9
  import { test, after } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
- import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
12
13
  import { tmpdir } from 'node:os';
13
- import { join } from 'node:path';
14
- import { create } from './create.mjs';
14
+ import { join, relative } from 'node:path';
15
+ import { create, TEMPLATE_DIR } from './create.mjs';
15
16
 
16
17
  const base = mkdtempSync(join(tmpdir(), 'looop-create-'));
17
18
  after(() => rmSync(base, { recursive: true, force: true }));
18
19
 
20
+ // Hermetic: create() now resolves the engine pin from the platform — point
21
+ // every test at a dead port (instant refusal → the warn-fallback path).
22
+ // Tests that exercise the pin itself pass an explicit mock apiBase.
23
+ process.env.LOOOP_API_BASE = 'http://127.0.0.1:1';
24
+
25
+ // The scaffold is an on-disk template FOLDER (cuqfzo creator-harness Slice 1),
26
+ // not template strings in code — Fran edits it as normal files. `create`
27
+ // copies it with a {{name}} substitution pass; `gitignore` is stored dot-less
28
+ // (npm pack silently strips .gitignore files) and renamed on copy.
29
+ test('the scaffold is a faithful copy of the on-disk template folder', async () => {
30
+ const { dir } = await create({ name: 'from-template', cwd: base, install: false, log: () => {} });
31
+
32
+ const walk = (d) =>
33
+ readdirSync(d, { withFileTypes: true, recursive: true })
34
+ .filter((e) => e.isFile())
35
+ .map((e) => relative(d, join(e.parentPath, e.name)));
36
+
37
+ for (const file of walk(TEMPLATE_DIR)) {
38
+ const dest = file === 'gitignore' ? '.gitignore' : file;
39
+ assert.ok(existsSync(join(dir, dest)), `template file ${file} lands in the scaffold as ${dest}`);
40
+ const rendered = readFileSync(join(dir, dest), 'utf8');
41
+ assert.ok(!rendered.includes('{{name}}'), `${dest} has no unsubstituted {{name}} placeholders`);
42
+ const source = readFileSync(join(TEMPLATE_DIR, file), 'utf8');
43
+ assert.equal(rendered, source.replaceAll('{{name}}', 'from-template'), `${dest} is the template modulo substitution`);
44
+ }
45
+ // No stray dot-less gitignore in the scaffold.
46
+ assert.ok(!existsSync(join(dir, 'gitignore')));
47
+ });
48
+
49
+ test('the template folder ships in the npm package (files list)', () => {
50
+ const pkg = JSON.parse(readFileSync(join(TEMPLATE_DIR, '..', 'package.json'), 'utf8'));
51
+ assert.ok(pkg.files.includes('template'), 'npm pack must carry template/ or the published CLI scaffolds nothing');
52
+ });
53
+
19
54
  test('scaffolds a complete standalone game folder', async () => {
20
55
  const { dir } = await create({ name: 'tower-jump', cwd: base, install: false, log: () => {} });
21
56
  assert.equal(dir, join(base, 'tower-jump'));
22
57
 
23
58
  const html = readFileSync(join(dir, 'index.html'), 'utf8');
24
59
  assert.match(html, /<title>tower-jump<\/title>/);
60
+
61
+ // The mobile baseline (architecture.md "Mobile baseline (every game)") is
62
+ // REQUIRED boilerplate the template must carry — without it every phone
63
+ // build double-tap-zooms and finger-drag-selects the HUD. All four items:
64
+ assert.match(html, /maximum-scale=1\.0, user-scalable=no, viewport-fit=cover/, '1: locked viewport');
65
+ assert.match(html, /touch-action: manipulation/, '2: no double-tap zoom on the chrome');
66
+ assert.match(html, /gesturestart/, '3: iOS gesture-zoom blocked in JS');
67
+ assert.match(html, /\*\s*\{[^}]*user-select: none/, '4: text selection killed on every element');
68
+ assert.match(html, /input, textarea[^}]*user-select: text/, '4b: typeable fields stay selectable');
25
69
  const js = readFileSync(join(dir, 'game.js'), 'utf8');
26
70
  assert.match(js, /from '\/shared\/ui\/room\/client\.js'/); // real multiplayer from minute one
27
71
 
@@ -33,9 +77,28 @@ test('scaffolds a complete standalone game folder', async () => {
33
77
  // the `looop.engine` pin then.
34
78
  assert.equal(pkg.dependencies, undefined);
35
79
  assert.ok(pkg.devDependencies['@looop-games/cli']);
80
+ // Smokes need a real browser: playwright ships with every scaffold and
81
+ // create pre-fetches Chromium — never a surprise mid-build install
82
+ // (caught in Fran's manual e2e, 2026-07-10).
83
+ assert.ok(pkg.devDependencies.playwright, 'playwright is a scaffold devDependency');
36
84
  assert.match(pkg.scripts.dev, /looop dev/);
37
85
  assert.match(pkg.scripts.publish, /looop publish/);
38
86
 
87
+ // npm is moving install scripts to opt-in (npm 11 warns, npm 12 SKIPS them
88
+ // by default). esbuild and workerd download their native binaries in a
89
+ // postinstall — skip those and `looop dev` cannot start, with no obvious
90
+ // cause. The scaffold ships the approval so a stranger's first install is
91
+ // clean on any npm. Name-only (unpinned) entries: partykit moves its
92
+ // esbuild/workerd pins between releases, and a version-pinned approval
93
+ // would silently stop covering them. Reported by a Windows creator with a
94
+ // strict npm, 2026-07-11.
95
+ assert.equal(pkg.allowScripts.esbuild, true, 'esbuild postinstall pre-approved (native binary)');
96
+ assert.equal(pkg.allowScripts.workerd, true, 'workerd postinstall pre-approved (native binary)');
97
+ // macOS-only optional dep of the watchers; unused on Windows. Verified
98
+ // against a real npm 11 install of the published scaffold: with these three
99
+ // the install is warning-free on every platform.
100
+ assert.equal(pkg.allowScripts.fsevents, true, 'fsevents postinstall pre-approved (mac watcher)');
101
+
39
102
  // The agent-facing surface: instructions + a pointer at the practices docs
40
103
  // that ship inside the installed engine bundle.
41
104
  const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
@@ -58,20 +121,241 @@ test('scaffolds the agent surface for every vendor (no plugin needed — Q4 adde
58
121
  assert.match(text, /read .*AGENTS\.md/i, `${pointer} has the fallback instruction`);
59
122
  }
60
123
 
61
- // The looop skill ships with the scaffold (Claude Code picks up
62
- // .claude/skills/ per project) — same knowledge the plugin used to carry,
63
- // now versioned with the CLI that scaffolded it.
64
- const skill = readFileSync(join(dir, '.claude', 'skills', 'looop', 'SKILL.md'), 'utf8');
65
- assert.match(skill, /^---\nname: looop\n/, 'skill frontmatter');
66
- assert.match(skill, /npx looop publish --slug/, 'documents the CLI surface');
67
- assert.match(skill, /overrides\/shared\//, 'documents the override mechanism');
68
- assert.match(skill, /multiplayer/i);
69
-
70
124
  // The scaffold's .gitignore must NOT swallow the agent files.
71
125
  const ignore = readFileSync(join(dir, '.gitignore'), 'utf8');
72
126
  assert.ok(!/\.claude/.test(ignore), '.claude/skills travels with the repo');
73
127
  });
74
128
 
129
+ // The creator harness (cuqfzo creator-harness Slice 1, decisions Q2–Q7):
130
+ // Layer 0 AGENTS.md with managed-section markers + routing table, the SIX
131
+ // skills, and the handbook/ seed. This is the product surface a creator's
132
+ // agent runs on — the contract below is what the Slice-2 updater and the
133
+ // friends' cutover depend on.
134
+ test('Layer 0: AGENTS.md has managed-section markers and the routing table', async () => {
135
+ const { dir } = await create({ name: 'layer-zero', cwd: base, install: false, log: () => {} });
136
+ const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
137
+
138
+ // The exact marker tokens the Slice-2 updater rewrites between — a
139
+ // creator's own additions outside them must never be clobbered.
140
+ assert.ok(agents.includes('<!-- looop:managed:start -->'), 'managed-block start marker');
141
+ assert.ok(agents.includes('<!-- looop:managed:end -->'), 'managed-block end marker');
142
+ assert.ok(
143
+ agents.indexOf('<!-- looop:managed:start -->') < agents.indexOf('<!-- looop:managed:end -->'),
144
+ 'markers in order',
145
+ );
146
+
147
+ // Routing lines for the demoted skills (Q6): save, update, feel/tweaks,
148
+ // overrides — capabilities without a skill MUST be reachable from Layer 0.
149
+ assert.match(agents, /save/i, 'save routing line');
150
+ assert.match(agents, /looop\.engine/, 'engine-update routing mentions the version pin');
151
+ assert.match(agents, /tweaks/, 'feel-tuning routes at the tweaks library');
152
+ assert.match(agents, /overrides\/shared\//, 'override mechanism reachable from Layer 0');
153
+
154
+ // Layer 0 must NOT re-list the skills' triggers — every major agent CLI has
155
+ // a native skill system and the frontmatter `description` IS the routing
156
+ // (a second copy in AGENTS.md drifts). One orientation mention of the
157
+ // skills directory is all that's allowed.
158
+ assert.ok(!agents.includes('skills/build/SKILL.md'), 'no per-skill routing rows');
159
+ assert.match(agents, /\.claude\/skills/, 'orientation line names the skills directory');
160
+ });
161
+
162
+ test('.agents/skills aliases .claude/skills for non-Claude agent CLIs', async () => {
163
+ // Codex, Gemini/Antigravity, Cursor, and opencode discover project skills
164
+ // at .agents/skills/ — the scaffold aliases it to the same SKILL.md files
165
+ // (created at scaffold time; npm pack cannot ship symlinks).
166
+ const { dir } = await create({ name: 'vendor-skills', cwd: base, install: false, log: () => {} });
167
+ const link = join(dir, '.agents', 'skills');
168
+ assert.ok(lstatSync(link).isSymbolicLink(), '.agents/skills is a symlink');
169
+ assert.deepEqual(
170
+ readdirSync(link).sort(),
171
+ readdirSync(join(dir, '.claude', 'skills')).sort(),
172
+ 'both paths expose the same skills',
173
+ );
174
+ });
175
+
176
+ test('the six skills ship, correctly named, no more no fewer (Q6 roster)', async () => {
177
+ const { dir } = await create({ name: 'six-skills', cwd: base, install: false, log: () => {} });
178
+ const skillsDir = join(dir, '.claude', 'skills');
179
+ const roster = ['build', 'engine', 'feedback', 'qa', 'todo', 'update-handbook'];
180
+ assert.deepEqual(readdirSync(skillsDir).sort(), roster, 'exactly the six-skill roster');
181
+ for (const name of roster) {
182
+ const text = readFileSync(join(skillsDir, name, 'SKILL.md'), 'utf8');
183
+ assert.match(text, new RegExp(`^---\\nname: ${name}\\n`), `${name} frontmatter name matches its folder`);
184
+ assert.match(text, /\ndescription: .+/, `${name} has a trigger description`);
185
+ }
186
+ });
187
+
188
+ test('the build loop encodes the locked mechanics (Q3/Q5/Q8)', async () => {
189
+ const { dir } = await create({ name: 'build-loop', cwd: base, install: false, log: () => {} });
190
+ const build = readFileSync(join(dir, '.claude', 'skills', 'build', 'SKILL.md'), 'utf8');
191
+
192
+ assert.match(build, /plan/i, 'note-anchored: the plan is the spine');
193
+ assert.match(build, /milestone/i, 'milestone vocabulary');
194
+ assert.match(build, /step/i, 'step vocabulary');
195
+ // Save-on-accept / revert-to-last-save — and NO invented git ceremony: the
196
+ // pre-milestone "checkpoint" commit was struck by Fran (2026-07-10).
197
+ assert.match(build, /last save/i, 'reject reverts to the last save');
198
+ assert.ok(!/checkpoint/i.test(build), 'no checkpoint mechanic');
199
+ assert.match(build, /never publish/i, 'publish is offered, never automatic');
200
+ assert.match(build, /worktree/i, 'parallel lanes section (Q8)');
201
+ assert.match(build, /consequence/i, 'PM-register discussion (Q5)');
202
+ // The discussion is a HARD gate on plan creation, ported from the studio
203
+ // loop's design-then-execute split (design settles decisions one at a time
204
+ // with a stated lean and visible lock-in; execution runs only from a
205
+ // settled plan). Caught in Fran's manual e2e 2026-07-10: a whole plan +
206
+ // code landed with zero discussion.
207
+ assert.match(build, /only from a plan whose decisions are settled/i, 'building requires a settled plan');
208
+ assert.match(build, /one decision per turn/i, 'decisions are posed one at a time');
209
+ assert.match(build, /problem before the options/i, 'problem explained before options');
210
+ assert.match(build, /your lean/i, 'a lean is stated with the options');
211
+ assert.match(build, /locking in/i, 'decisions are locked in visibly');
212
+ assert.match(build, /never applies when there is no plan yet/i, 'ceremony collapse cannot skip plan creation');
213
+ // Parity-audit fixes (2026-07-10): the remaining /discuss reflexes + the
214
+ // scope/lifecycle/lanes gaps the independent audit found.
215
+ assert.match(build, /re-explain the\s+underlying problem/i, '"I don\'t understand" → back up, not louder');
216
+ assert.match(build, /case for the other option/i, 'the lean is easy to override');
217
+ assert.match(build, /## Out of scope/m, 'plan template has a durable Out-of-scope home');
218
+ assert.match(build, /paused \| abandoned/, 'a plan can be abandoned, distinct from done');
219
+ assert.match(build, /never auto-resolve/i, 'lane merge conflicts stop for the creator');
220
+
221
+ const todo = readFileSync(join(dir, '.claude', 'skills', 'todo', 'SKILL.md'), 'utf8');
222
+ assert.match(todo, /promoted \| abandoned/, 'todos distinguish won\'t-do from did-it');
223
+ assert.match(todo, /one-line why/i, 'abandoning requires the why');
224
+
225
+ // Review cadence (Fran, 2026-07-10): AUTOMATED steps — including the
226
+ // review sub-agents — run after EVERY step; only the playtest is
227
+ // per-milestone. "Milestone-sized reviews" was drift, not a decision.
228
+ assert.match(build, /step's diff/, 'review sub-agents run on each step\'s diff');
229
+ assert.match(build, /only the playtest is per-milestone/i, 'the manual gate is the only per-milestone check');
230
+
231
+ // Seamless-UX handback (Fran, 2026-07-10): the AGENT runs the dev server;
232
+ // the creator gets a link to click, never a command to paste.
233
+ assert.match(build, /you run the server,\s+never the creator/i, 'agent starts/reuses the dev stack itself');
234
+ assert.match(build, /link to click/i, 'the creator receives a URL, not a command');
235
+ const qaSkill = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
236
+ assert.match(qaSkill, /already\s+running/i, '/qa manual items run against a live game the agent started');
237
+ });
238
+
239
+ // The harness core that makes the monorepo loop effective must survive the
240
+ // port IN FULL — the parity gate is the whole point of the workstream. The
241
+ // framework itself (automated-vs-manual, the ratchet, the master steps list
242
+ // incl. the review rows and the generative verdict-mix row) has ONE source of
243
+ // truth: the engine's shared/practices/qa.md, shipped inside the artifact.
244
+ // The skills REFERENCE it — they never restate it (that scatter is exactly
245
+ // what qa.md was created to end). These rows pin the reference shape plus the
246
+ // point-of-use pointers that were once silently dropped.
247
+ test('the harness core survives the port: single source referenced, pointers intact', async () => {
248
+ const { dir } = await create({ name: 'harness-core', cwd: base, install: false, log: () => {} });
249
+ const build = readFileSync(join(dir, '.claude', 'skills', 'build', 'SKILL.md'), 'utf8');
250
+ const qa = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
251
+
252
+ // Both loop skills anchor on the ONE master doc inside the engine artifact.
253
+ for (const [name, text] of [['build', build], ['qa', qa]]) {
254
+ assert.match(text, /engine\/shared\/practices\/qa\.md/, `${name} points at the master QA doc`);
255
+ }
256
+ assert.match(qa, /steps live there|never restate/i, '/qa declares the single-source rule');
257
+ assert.match(qa, /`npx looop test`/, '/qa names the real test gate');
258
+
259
+ // Point-of-use pointers in the loop (the rows themselves live in qa.md):
260
+ // independent review sub-agents + red→green findings + verdict mix.
261
+ assert.match(build, /sub-agent/i, 'milestone close spawns reviewers');
262
+ assert.match(build, /code.quality/i, 'names the code-quality review row');
263
+ assert.match(build, /coverage|test.opportunit|test.thoroughness/i, 'names the test-coverage review row');
264
+ assert.match(build, /red.*green|failing test.*(first|before)/i, 'findings fixed red→green');
265
+ assert.match(build, /verdict/i, 'generative verdict-mix pointer at milestone close');
266
+ assert.match(build, /diagnose/i, 'diagnose before second-guessing a failed fix');
267
+ assert.match(qa, /sub-agent/i, '/qa maps the review skills to sub-agents');
268
+ });
269
+
270
+ test('handbook/ seeds ship and /qa + /update-handbook wire the three QA sources (Q7)', async () => {
271
+ const { dir } = await create({ name: 'handbook-game', cwd: base, install: false, log: () => {} });
272
+ for (const f of ['qa.md', 'feel.md', 'design.md']) {
273
+ assert.ok(existsSync(join(dir, 'handbook', f)), `handbook/${f} seeded`);
274
+ }
275
+
276
+ const qa = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
277
+ assert.match(qa, /looop test/, 'source 1: executable files via looop test');
278
+ assert.match(qa, /handbook\/qa\.md/, 'source 2: the game handbook');
279
+ assert.match(qa, /engine\/shared\/practices\/qa\.md/, 'source 3: the engine master list');
280
+
281
+ const ratchet = readFileSync(join(dir, '.claude', 'skills', 'update-handbook', 'SKILL.md'), 'utf8');
282
+ assert.match(ratchet, /handbook\/qa\.md/, 'ratchet writes game QA steps');
283
+ assert.match(ratchet, /feedback/i, 'generic holes route upstream via /feedback');
284
+ // The handbook is the CREATOR's truth — writes are proposed and approved,
285
+ // never slipped in as a side effect (Fran, 2026-07-10).
286
+ assert.match(ratchet, /approval first/i, 'handbook writes gated on creator approval');
287
+ });
288
+
289
+ // /feedback has a REAL transport (`looop feedback` → /api/creator/feedback),
290
+ // and the report is agent-authored: maximal detail, verbatim output, and the
291
+ // conversation transcript — the Looop team can't ask the session follow-ups.
292
+ test('the feedback skill sends via looop feedback and demands the full evidence', async () => {
293
+ const { dir } = await create({ name: 'feedback-game', cwd: base, install: false, log: () => {} });
294
+ const fb = readFileSync(join(dir, '.claude', 'skills', 'feedback', 'SKILL.md'), 'utf8');
295
+
296
+ assert.match(fb, /npx looop feedback/, 'sends through the real CLI transport');
297
+ assert.ok(!/however they normally reach them/.test(fb), 'the interim manual transport is gone');
298
+ assert.match(fb, /## Transcript/m, 'the report carries the conversation transcript');
299
+ assert.match(fb, /verbatim/i, 'output is pasted verbatim, never summarized');
300
+ assert.match(fb, /sent:/, 'documents the sent-stamp so reports never ship twice');
301
+ });
302
+
303
+ // Every notes/ file is datetime-prefixed so the tree lists in creation order
304
+ // and "the latest plan" is findable at a glance (Fran, 2026-07-10 — a flat
305
+ // slug like iso-view.md is unsortable once notes accumulate).
306
+ test('note-writing skills mandate the datetime filename prefix', async () => {
307
+ const { dir } = await create({ name: 'ordered-notes', cwd: base, install: false, log: () => {} });
308
+ for (const [skill, tree] of [['build', 'plans'], ['todo', 'todos'], ['feedback', 'feedback']]) {
309
+ const text = readFileSync(join(dir, '.claude', 'skills', skill, 'SKILL.md'), 'utf8');
310
+ assert.match(
311
+ text,
312
+ new RegExp(`notes/${tree}/<YYYY-MM-DD-HHMM>-`),
313
+ `${skill} names its notes/${tree} files datetime-first`,
314
+ );
315
+ }
316
+ });
317
+
318
+ // The engine pin is resolved AT CREATE TIME from the public version-list
319
+ // endpoint and lands INSIDE the initial commit — the milestone-1 revert
320
+ // baseline. Without this, first dev wrote the pin after the baseline: fresh
321
+ // repos started dirty, and a milestone-1 reject deleted the pin so the next
322
+ // dev could silently resolve a NEWER engine (caught in Fran's manual e2e,
323
+ // 2026-07-10).
324
+ test('create resolves the engine pin into the initial commit; reverts keep it', async (t) => {
325
+ const http = await import('node:http');
326
+ const server = http.createServer((req, res) => {
327
+ res.writeHead(200, { 'Content-Type': 'application/json' });
328
+ res.end(JSON.stringify(req.url === '/api/creator/engine' ? { versions: ['0.7.3'], latest: '0.7.3' } : {}));
329
+ });
330
+ await new Promise((r) => server.listen(0, r));
331
+ t.after(() => server.close());
332
+
333
+ const { dir } = await create({
334
+ name: 'pinned-game',
335
+ cwd: base,
336
+ install: false,
337
+ apiBase: `http://localhost:${server.address().port}`,
338
+ log: () => {},
339
+ });
340
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
341
+ assert.equal(pkg.looop.engine, '0.7.3', 'pin written at create');
342
+
343
+ // The pin is IN the baseline commit, so reset --hard can never drop it.
344
+ const shown = execFileSync('git', ['show', 'HEAD:package.json'], { cwd: dir, encoding: 'utf8' });
345
+ assert.match(shown, /"engine": "0\.7\.3"/, 'initial commit carries the pin');
346
+ execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: dir });
347
+ const after = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
348
+ assert.equal(after.looop.engine, '0.7.3', 'revert-to-baseline preserves the pin');
349
+ });
350
+
351
+ test('an unreachable platform degrades to the old pin-at-first-dev behavior', async () => {
352
+ const logs = [];
353
+ const { dir } = await create({ name: 'offline-game', cwd: base, install: false, log: (m) => logs.push(m) });
354
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
355
+ assert.equal(pkg.looop, undefined, 'no pin when the lookup fails');
356
+ assert.match(logs.join('\n'), /Could not resolve the engine version/, 'the fallback is loud');
357
+ });
358
+
75
359
  test('the CLI dependency spec is overridable for tarball installs (the smoke path)', async () => {
76
360
  const { dir } = await create({
77
361
  name: 'spec-game',
@@ -84,6 +368,63 @@ test('the CLI dependency spec is overridable for tarball installs (the smoke pat
84
368
  assert.equal(pkg.devDependencies['@looop-games/cli'], 'file:../cli.tgz');
85
369
  });
86
370
 
371
+ // The initial commit is the revert baseline: reject-milestone-1 resets to it,
372
+ // and `git worktree add` (parallel lanes) needs a commit to exist — on a
373
+ // never-committed repo it silently creates an empty orphan lane.
374
+ test('create leaves the scaffold fully committed (the first revert baseline)', async () => {
375
+ const { dir } = await create({ name: 'baseline', cwd: base, install: false, log: () => {} });
376
+ const log = execFileSync('git', ['log', '--oneline'], { cwd: dir }).toString().trim().split('\n');
377
+ assert.equal(log.length, 1, 'exactly one commit');
378
+ const status = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString().trim();
379
+ assert.equal(status, '', 'nothing left uncommitted');
380
+ });
381
+
382
+ // A stranger who runs `npx looop create` gets the template AND the practices
383
+ // docs inside the engine artifact. They have never heard of the studio, its
384
+ // monorepo, its CLI, its games, or its people — any mention is a leak (Fran,
385
+ // 2026-07-10: "if they read about the monorepo, it's an issue"). Studio-side
386
+ // content lives in shared/notes/guides/ (never shipped), not here.
387
+ test('no studio leaks in any surface a stranger receives', () => {
388
+ const engineRoot = join(TEMPLATE_DIR, '..', '..', '..');
389
+ const shipped = [];
390
+ const collect = (d) => {
391
+ for (const e of readdirSync(d, { withFileTypes: true, recursive: true })) {
392
+ if (e.isFile()) shipped.push(join(e.parentPath, e.name));
393
+ }
394
+ };
395
+ collect(TEMPLATE_DIR);
396
+ collect(join(engineRoot, 'shared', 'practices'));
397
+ shipped.push(join(engineRoot, 'shared', 'ui', 'INDEX.md'));
398
+
399
+ const FORBIDDEN = [
400
+ /`gamedev|gamedev </i, // the studio's internal CLI ("gamedev literature" prose is fine)
401
+ /\bFran\b/, // the studio's human
402
+ /monorepo/i, // the workspace the reader must never learn exists
403
+ /studio/i, // "the studio" / "studio-only" dual-world framing
404
+ /harness-improvement/, // studio-only skills
405
+ /import-game/,
406
+ /`\/primitives`/,
407
+ /looop-ugc|looop-boxeo/, // studio partykit deploy names
408
+ /looop-core|looop-games\/games\//, // sibling-repo paths (@looop-games/* npm scope is fine)
409
+ /new_game\.py|catalog_publish\.py/, // studio tool paths
410
+ /shared\/notes\//, // the studio notes tree (not in the artifact)
411
+ ];
412
+ // Studio game names: banned in the practices docs (there they were
413
+ // references the reader can't open); allowed as plain DATA elsewhere —
414
+ // INDEX.md's used_by column, and template game.js's generic "a tiny
415
+ // multiplayer plaza".
416
+ const PRACTICES_ONLY = [/mp-lab|escaleras|piso-20|booox|wordsmith|survivors|amnesia|\bagora\b|\bplaza\b/];
417
+
418
+ for (const file of shipped) {
419
+ const text = readFileSync(file, 'utf8');
420
+ const extra = file.includes(`${join('shared', 'practices')}`) ? PRACTICES_ONLY : [];
421
+ for (const re of [...FORBIDDEN, ...extra]) {
422
+ const m = text.match(re);
423
+ assert.ok(!m, `${relative(engineRoot, file)} leaks "${m?.[0]}" (${re})`);
424
+ }
425
+ }
426
+ });
427
+
87
428
  test('rejects slug-unsafe names and existing folders', async () => {
88
429
  await assert.rejects(() => create({ name: 'My Game!', cwd: base, install: false, log: () => {} }), /letters/);
89
430
  await create({ name: 'dupe', cwd: base, install: false, log: () => {} });
package/lib/dev.mjs CHANGED
@@ -37,6 +37,29 @@ export function partykitBin() {
37
37
  }
38
38
  }
39
39
 
40
+ // Per-game server parity (cuqfzo Phase 4): a game shipping its own
41
+ // `partykit.json` runs its OWN server in dev — partykit gets the game folder
42
+ // as cwd, resolving the game's `main` entry (which imports the room server
43
+ // from the installed engine). Without one, the bundle's shared room server
44
+ // runs, exactly as before. Mirrors the monorepo dev.sh detection so a
45
+ // vendor-flipped game behaves identically standalone. A config whose `main`
46
+ // doesn't exist is ignored LOUDLY — a dead file must not take dev's
47
+ // multiplayer down with it.
48
+ export function partykitCwdFor(project, engine, warn = console.warn) {
49
+ const configPath = join(project.dir, 'partykit.json');
50
+ if (existsSync(configPath)) {
51
+ try {
52
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
53
+ const main = config?.main ?? 'server.js';
54
+ if (existsSync(join(project.dir, main))) return project.dir;
55
+ warn(`partykit.json found but its main (${main}) doesn't exist — running the shared room server instead`);
56
+ } catch (err) {
57
+ warn(`partykit.json is unreadable (${err.message}) — running the shared room server instead`);
58
+ }
59
+ }
60
+ return engine.roomServerDir;
61
+ }
62
+
40
63
  export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', log = console.log } = {}) {
41
64
  const project = findProject(cwd);
42
65
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
@@ -92,10 +115,12 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
92
115
  log(`→ multiplayer :${ports.mp} (in use — killing to take over)`);
93
116
  await killPort(ports.mp);
94
117
  }
118
+ const pkCwd = partykitCwdFor(project, engine, (m) => log(`[partykit] ${m}`));
119
+ const ownServer = pkCwd === project.dir;
95
120
  const pk = spawn(
96
121
  process.execPath,
97
122
  [partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `looop-partykit-${ports.mp}`)],
98
- { cwd: engine.roomServerDir, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
123
+ { cwd: pkCwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
99
124
  );
100
125
  pk.stdout.on('data', () => {});
101
126
  pk.stderr.on('data', (d) => {
@@ -103,7 +128,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
103
128
  if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
104
129
  });
105
130
  children.push(pk);
106
- log(`→ multiplayer :${ports.mp} (partykit on the bundle's room-server, pid ${pk.pid})`);
131
+ log(`→ multiplayer :${ports.mp} (partykit on ${ownServer ? "this game's OWN server" : "the bundle's room-server"}, pid ${pk.pid})`);
107
132
  }
108
133
 
109
134
  // ─── platform services shim ───
@@ -0,0 +1,52 @@
1
+ // Per-game server parity (cuqfzo Phase 4, dev half): a game that ships its
2
+ // own `partykit.json` gets its OWN server in `looop dev` — partykit runs with
3
+ // the game folder as cwd (resolving the game's `main` entry, which imports
4
+ // the room server from the installed engine). A game without one rides the
5
+ // bundle's shared room server, exactly as before. This mirrors the monorepo's
6
+ // dev.sh detection, so a vendor-flipped game behaves identically standalone.
7
+
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+ import { partykitCwdFor } from './dev.mjs';
14
+
15
+ function tempGameDir(withPartykit) {
16
+ const dir = mkdtempSync(join(tmpdir(), 'looop-devtest-'));
17
+ writeFileSync(join(dir, 'index.html'), '<canvas></canvas>');
18
+ if (withPartykit) {
19
+ writeFileSync(join(dir, 'partykit.json'), JSON.stringify({ name: 'mygame', main: 'server.js' }));
20
+ writeFileSync(join(dir, 'server.js'), 'export { default } from "@looop-games/engine/shared/ui/room/server.js";');
21
+ }
22
+ return dir;
23
+ }
24
+
25
+ test('game WITHOUT partykit.json → partykit runs on the bundle room server', () => {
26
+ const dir = tempGameDir(false);
27
+ try {
28
+ const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' });
29
+ assert.equal(cwd, '/engine/room');
30
+ } finally { rmSync(dir, { recursive: true, force: true }); }
31
+ });
32
+
33
+ test('game WITH partykit.json → partykit runs on the GAME folder (its own server)', () => {
34
+ const dir = tempGameDir(true);
35
+ try {
36
+ const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' });
37
+ assert.equal(cwd, dir);
38
+ } finally { rmSync(dir, { recursive: true, force: true }); }
39
+ });
40
+
41
+ test('a partykit.json without a resolvable main is IGNORED (falls back to the bundle, loudly)', () => {
42
+ // A dead config must not silently break dev — the room server the game
43
+ // actually reaches should still exist.
44
+ const dir = tempGameDir(false);
45
+ writeFileSync(join(dir, 'partykit.json'), JSON.stringify({ name: 'mygame', main: 'missing.js' }));
46
+ try {
47
+ const warnings = [];
48
+ const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' }, (m) => warnings.push(m));
49
+ assert.equal(cwd, '/engine/room');
50
+ assert.ok(warnings.some((w) => /missing\.js/.test(w)), 'warns about the dead entry');
51
+ } finally { rmSync(dir, { recursive: true, force: true }); }
52
+ });
package/lib/engine.mjs CHANGED
@@ -16,9 +16,9 @@
16
16
  //
17
17
  // LOOOP_ENGINE_TARBALL=<path> short-circuits the network — the offline/smoke
18
18
  // lane (packed-install smoke, air-gapped work against a local build).
19
- import { execFileSync } from 'node:child_process';
20
19
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
20
  import { join, resolve } from 'node:path';
21
+ import { runNpm } from './npm.mjs';
22
22
  import { resolveEngine } from './project.mjs';
23
23
  import { getToken, getApiBase, cacheDir } from './config.mjs';
24
24
  import { login } from './login.mjs';
@@ -52,7 +52,7 @@ function installedEngine(projectDir) {
52
52
  // node_modules/@looop-games/engine AND installs its runtime dependencies
53
53
  // (the room server's bare imports), without touching package.json.
54
54
  function npmInstallTarball(projectDir, tgzPath) {
55
- execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', resolve(tgzPath)], {
55
+ runNpm(['install', '--no-save', '--no-audit', '--no-fund', resolve(tgzPath)], {
56
56
  cwd: projectDir,
57
57
  stdio: 'pipe',
58
58
  });
@@ -0,0 +1,87 @@
1
+ // `looop feedback` — send the reports under notes/feedback/ to the Looop team
2
+ // (cuqfzo; the transport behind the template's /feedback skill).
3
+ //
4
+ // Target: /api/creator/feedback — the token-authenticated intake beside
5
+ // creator/publish. Each unsent notes/feedback/*.md ships VERBATIM as the
6
+ // report body (frontmatter, transcript excerpt and all — the agent authors
7
+ // it rich on purpose), with the repo's identity attached: slug, the
8
+ // looop.engine pin, and this CLI's version. A delivered file is stamped
9
+ // `sent:` + `id:` in its frontmatter so it never ships twice; a failed send
10
+ // leaves the file unstamped and is retried on the next run.
11
+ import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
12
+ import { basename, join } from 'node:path';
13
+ import { findProject } from './project.mjs';
14
+ import { getToken, getApiBase } from './config.mjs';
15
+ import { DEFAULT_API_BASE } from './llm-shim.mjs';
16
+
17
+ const CLI_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
+
19
+ // A report is "sent" when its frontmatter carries a `sent:` stamp.
20
+ function isSent(text) {
21
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text);
22
+ return fm ? /^sent:\s*\S/m.test(fm[1]) : false;
23
+ }
24
+
25
+ function titleOf(text, path) {
26
+ const h1 = /^#\s+(.+)$/m.exec(text);
27
+ return h1 ? h1[1].trim() : basename(path, '.md');
28
+ }
29
+
30
+ function stamp(text, id) {
31
+ const lines = `sent: ${new Date().toISOString()}\nid: ${id}`;
32
+ if (/^---\n/.test(text)) return text.replace(/^---\n/, `---\n${lines}\n`);
33
+ return `---\n${lines}\n---\n${text}`;
34
+ }
35
+
36
+ export function discoverUnsent(projectDir) {
37
+ const dir = join(projectDir, 'notes', 'feedback');
38
+ if (!existsSync(dir)) return [];
39
+ return readdirSync(dir)
40
+ .filter((name) => name.endsWith('.md'))
41
+ .map((name) => join(dir, name))
42
+ .filter((path) => !isSent(readFileSync(path, 'utf8')))
43
+ .sort();
44
+ }
45
+
46
+ export async function sendFeedback({
47
+ cwd = process.cwd(),
48
+ apiBase = getApiBase(DEFAULT_API_BASE),
49
+ log = console.log,
50
+ } = {}) {
51
+ const project = findProject(cwd);
52
+ const unsent = discoverUnsent(project.dir);
53
+ if (unsent.length === 0) {
54
+ log('Nothing to send — no unsent reports under notes/feedback/.');
55
+ return { sent: [] };
56
+ }
57
+
58
+ const token = getToken();
59
+ if (!token) throw new Error('sending feedback requires login — run `looop login` first.');
60
+
61
+ const sent = [];
62
+ for (const path of unsent) {
63
+ const body = readFileSync(path, 'utf8');
64
+ const report = {
65
+ title: titleOf(body, path),
66
+ body,
67
+ slug: project.slug,
68
+ engine: project.pkg?.looop?.engine,
69
+ cli: CLI_VERSION,
70
+ };
71
+ const res = await fetch(`${apiBase}/api/creator/feedback`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
74
+ body: JSON.stringify(report),
75
+ });
76
+ if (!res.ok) {
77
+ const detail = await res.text().catch(() => '');
78
+ throw new Error(`feedback send failed for ${basename(path)} (HTTP ${res.status}): ${detail.slice(0, 300)}`);
79
+ }
80
+ const { id } = await res.json();
81
+ writeFileSync(path, stamp(body, id));
82
+ log(`✅ Sent ${basename(path)} → ${id}`);
83
+ sent.push({ file: path, id });
84
+ }
85
+ log(`${sent.length} report${sent.length === 1 ? '' : 's'} delivered to the Looop team.`);
86
+ return { sent };
87
+ }