@looop-games/cli 0.1.20 → 0.1.22

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/CHANGELOG.md CHANGED
@@ -14,6 +14,39 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.22] - 2026-08-01
18
+
19
+ ### Added
20
+ - `looop feedback` now brings the conversation back: after sending your unsent
21
+ reports, it pulls each report's outcome from the Looop team and stamps it into
22
+ the report file itself — `status:` / `resolved-in:` in the frontmatter, team
23
+ messages appended under `## Updates`. The file under `notes/feedback/` is the
24
+ whole thread, readable offline.
25
+ - You can reply on a report: add a `## Reply (unsent)` section to a sent report
26
+ file and run `looop feedback` — the reply joins the same thread, and a closed
27
+ report you reply to is automatically reopened on the Looop side. Reply when
28
+ something still needs doing; a new problem is a new report.
29
+ - `looop update` now tells you when a report you filed was resolved in one of
30
+ the versions you just crossed ("Feedback you filed landed in this update"),
31
+ next to the changelog it already prints.
32
+
33
+ ## [0.1.21] - 2026-07-30
34
+
35
+ ### Added
36
+
37
+ - **`looop model bake` resolves a pivot for the model.** `<model>.bake.json`
38
+ may declare `"pivot": "origin"` (default) | `"center"` | `"feet"` |
39
+ `[x, y, z]` — the model-space point that lands on your entity's
40
+ `body.(x,y,z)`. The bake resolves it against the bind-pose bounding box,
41
+ writes it into the skeleton artifact, and measures the broadphase radius
42
+ about it (a sphere centred on the pivot but measured from the origin could
43
+ silently reject true edge hits). Every model re-bakes once on your next
44
+ `looop dev`/`test`/`publish` to pick up the new artifact format; without a
45
+ `pivot` entry nothing changes in how it plays. Opting into a non-origin
46
+ pivot needs an engine that reads it (0.1.35+) — run `npx looop update`
47
+ first, or the room will place the model by its origin while the broadphase
48
+ is measured about the pivot.
49
+
17
50
  ## [0.1.20] - 2026-07-29
18
51
 
19
52
  ### Added
package/bin/looop.mjs CHANGED
@@ -13,6 +13,7 @@ import { lintCmd } from '../lib/lint.mjs';
13
13
  import { update } from '../lib/update.mjs';
14
14
  import { changelog } from '../lib/changelog.mjs';
15
15
  import { sendFeedback } from '../lib/feedback.mjs';
16
+ import { syncFeedback } from '../lib/feedback-sync.mjs';
16
17
  import { clearToken } from '../lib/config.mjs';
17
18
 
18
19
  const [, , cmd, ...rest] = process.argv;
@@ -34,7 +35,7 @@ Usage:
34
35
  looop update Move this game to the latest engine release (re-pins looop.engine)
35
36
  looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
36
37
  looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
37
- looop feedback Send the unsent reports under notes/feedback/ to the Looop team
38
+ looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
38
39
  looop login Authenticate this machine as your Looop player account
39
40
  looop logout Forget the stored token
40
41
  looop whoami Show who this machine publishes as
@@ -103,7 +104,10 @@ try {
103
104
  await publish({ slug: flag('slug') });
104
105
  break;
105
106
  case 'feedback':
107
+ // Send first, then sync: a report filed this run is already stamped with
108
+ // its id, so the same invocation can pull any outcome waiting for it.
106
109
  await sendFeedback();
110
+ await syncFeedback();
107
111
  break;
108
112
  case 'login':
109
113
  await login();
package/lib/changelog.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // `looop changelog` — what changed in the engine (project note azlqm2).
1
+ // `looop changelog` — what changed in the engine.
2
2
  //
3
3
  // The gap this closes: `looop update` used to say "Engine updated: 0.1.10 →
4
4
  // 0.1.12" and then list the SKILL FILES it rewrote. It said nothing about what
@@ -0,0 +1,232 @@
1
+ // `looop feedback` — the back-channel half. The send half
2
+ // (feedback.mjs) ships reports OUT; this module brings the conversation BACK:
3
+ //
4
+ // 1. Push replies. A report file can carry a `## Reply (unsent)` section —
5
+ // the agent writes one when something about the outcome still needs
6
+ // doing. It ships to /api/creator/feedback/reply and the heading is
7
+ // stamped with the returned fu_ id, so it never ships twice.
8
+ // 2. Fetch outcomes. /api/creator/feedback/status returns, for every report
9
+ // this account filed: its status (read / done / discarded), the engine
10
+ // version a fix landed in, and the update thread. Everything is stamped
11
+ // back INTO the report file — `status:` / `resolved-in:` in frontmatter,
12
+ // unseen updates appended under `## Updates` — so the file under
13
+ // notes/feedback/ IS the thread, readable offline by any agent.
14
+ //
15
+ // Idempotency is by fu_ id: every update written into the file carries its
16
+ // `fu_<16 hex>` marker (in an HTML comment, or on a sent-reply heading), and
17
+ // anything whose id already appears in the file is never appended again.
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { basename } from 'node:path';
20
+ import { findProject } from './project.mjs';
21
+ import { getToken, getApiBase } from './config.mjs';
22
+ import { DEFAULT_API_BASE } from './llm-shim.mjs';
23
+ import { discoverSent } from './feedback.mjs';
24
+ import { compareVersions } from './changelog.mjs';
25
+
26
+ const UPDATE_ID = /fu_[0-9a-f]{16}/g;
27
+ const UNSENT_REPLY = /^## Reply \(unsent\)\s*$/m;
28
+
29
+ // Every unsent reply section: the text between a `## Reply (unsent)` heading
30
+ // and the next `## ` heading (or EOF). A `## ` line inside a ``` / ~~~ fence
31
+ // is CONTENT, not a boundary — replies quote markdown evidence, and stopping
32
+ // at a fenced heading would ship half the reply and strand the rest under an
33
+ // already-stamped heading where it can never be sent.
34
+ export function extractUnsentReplies(text) {
35
+ const out = [];
36
+ const lines = text.split('\n');
37
+ for (let i = 0; i < lines.length; i++) {
38
+ if (!/^## Reply \(unsent\)\s*$/.test(lines[i])) continue;
39
+ const body = [];
40
+ let fenced = false;
41
+ for (let j = i + 1; j < lines.length; j++) {
42
+ if (/^(```|~~~)/.test(lines[j])) fenced = !fenced;
43
+ else if (!fenced && /^## /.test(lines[j])) break;
44
+ body.push(lines[j]);
45
+ }
46
+ out.push({ body: body.join('\n').trim() });
47
+ }
48
+ return out;
49
+ }
50
+
51
+ export function knownUpdateIds(text) {
52
+ return new Set(text.match(UPDATE_ID) ?? []);
53
+ }
54
+
55
+ // Upsert `status:` / `resolved-in:` into the frontmatter block. The report was
56
+ // stamped `sent:` + `id:` when it shipped, so a synced file always has
57
+ // frontmatter; a file without one is left alone. CRLF files (a Windows clone
58
+ // with autocrlf) get their line endings preserved.
59
+ export function applyStatus(text, { status, resolved_in }) {
60
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
61
+ if (!fm) return text;
62
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
63
+ let block = fm[1];
64
+ const upsert = (key, value) => {
65
+ if (value == null) return;
66
+ const line = `${key}: ${value}`;
67
+ if (new RegExp(`^${key}:.*$`, 'm').test(block)) block = block.replace(new RegExp(`^${key}:[^\\r\\n]*`, 'm'), line);
68
+ else block = `${block}${eol}${line}`;
69
+ };
70
+ upsert('status', status);
71
+ upsert('resolved-in', resolved_in);
72
+ return text.slice(0, fm.index) + `---${eol}${block}${eol}---` + text.slice(fm.index + fm[0].length);
73
+ }
74
+
75
+ // Append unseen thread updates under `## Updates` (created at EOF if missing).
76
+ // Each entry carries its fu_ marker; `knownIds` is what makes re-runs no-ops.
77
+ //
78
+ // The body is written as a BLOCKQUOTE, not raw markdown. That is a protocol
79
+ // necessity, not styling: this file is re-parsed by the reply/section regexes
80
+ // on every sync, so a message that merely QUOTES a heading like "## Reply
81
+ // (unsent)" must not become one — raw injection would ship the team's own
82
+ // words back as a creator reply (reopening the report it arrived on).
83
+ export function appendUpdates(text, updates, knownIds) {
84
+ const fresh = updates.filter((u) => !knownIds.has(u.id));
85
+ if (fresh.length === 0) return { text, appended: 0 };
86
+
87
+ let entries = '';
88
+ for (const u of fresh) {
89
+ const who = u.author === 'creator' ? 'You' : 'Looop';
90
+ const when = new Date(u.created_at * 1000).toISOString().slice(0, 10);
91
+ const quoted = u.body.split('\n').map((l) => (l.trim() ? `> ${l}` : '>')).join('\n');
92
+ entries += `\n### ${who} — ${when} <!-- ${u.id} -->\n\n${quoted}\n`;
93
+ }
94
+
95
+ // New entries go at the END OF THE SECTION, not the end of the file: once a
96
+ // reply section exists below `## Updates`, appending at EOF would interleave
97
+ // platform messages under the creator's reply and the thread stops reading
98
+ // in order.
99
+ const lines = text.split('\n');
100
+ const head = lines.findIndex((l) => /^## Updates\s*$/.test(l));
101
+ if (head === -1) {
102
+ return { text: `${text.trimEnd()}\n\n## Updates\n${entries}`, appended: fresh.length };
103
+ }
104
+ let end = lines.length;
105
+ for (let j = head + 1; j < lines.length; j++) {
106
+ if (/^## /.test(lines[j])) { end = j; break; }
107
+ }
108
+ const section = lines.slice(0, end).join('\n').trimEnd();
109
+ const rest = lines.slice(end).join('\n');
110
+ return {
111
+ text: `${section}\n${entries}${rest ? `\n${rest}` : ''}`,
112
+ appended: fresh.length,
113
+ };
114
+ }
115
+
116
+ // Which reports landed in the versions an engine update just crossed:
117
+ // (from, to], or everything at/below `to` when no previous pin exists.
118
+ // `status` must still be done — a report reopened after its fix shipped keeps
119
+ // the resolved_in stamp as history, but announcing it as "landed" would claim
120
+ // a fix the reply just said didn't hold.
121
+ export function landedIn(reports, { from, to }) {
122
+ return reports.filter(
123
+ (r) =>
124
+ r.status === 'done' &&
125
+ r.resolved_in &&
126
+ compareVersions(r.resolved_in, to) <= 0 &&
127
+ (!from || compareVersions(r.resolved_in, from) > 0),
128
+ );
129
+ }
130
+
131
+ export async function fetchStatus({ apiBase, fetchImpl = fetch }) {
132
+ const token = getToken();
133
+ if (!token) throw new Error('reading feedback status requires login — run `looop login` first.');
134
+ const res = await fetchImpl(`${apiBase}/api/creator/feedback/status`, {
135
+ headers: { Authorization: `Bearer ${token}` },
136
+ });
137
+ if (!res.ok) throw new Error(`feedback status fetch failed (HTTP ${res.status})`);
138
+ const { reports } = await res.json();
139
+ return Array.isArray(reports) ? reports : [];
140
+ }
141
+
142
+ async function pushReplies({ path, id, apiBase, fetchImpl, log }) {
143
+ let text = readFileSync(path, 'utf8');
144
+ let sent = 0;
145
+ // One at a time: each stamp rewrites the first unsent heading, so the next
146
+ // loop iteration sees the next reply (if any) as the new first.
147
+ while (UNSENT_REPLY.test(text)) {
148
+ const [reply] = extractUnsentReplies(text);
149
+ if (!reply?.body) {
150
+ log(`⚠️ ${basename(path)} has an empty "## Reply (unsent)" section — write the reply, then rerun.`);
151
+ break;
152
+ }
153
+ const token = getToken();
154
+ const res = await fetchImpl(`${apiBase}/api/creator/feedback/reply`, {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
157
+ body: JSON.stringify({ id, body: reply.body }),
158
+ });
159
+ if (!res.ok) {
160
+ const detail = await res.text().catch(() => '');
161
+ throw new Error(`reply send failed for ${basename(path)} (HTTP ${res.status}): ${detail.slice(0, 300)}`);
162
+ }
163
+ const { id: updateId } = await res.json();
164
+ text = text.replace(UNSENT_REPLY, `## Reply (sent ${new Date().toISOString()}, ${updateId})`);
165
+ writeFileSync(path, text);
166
+ sent += 1;
167
+ log(`↩️ Sent your reply on ${basename(path)} → ${updateId}`);
168
+ }
169
+ return sent;
170
+ }
171
+
172
+ // The whole sync: replies out, outcomes in. Fails soft on nothing-to-do (no
173
+ // sent reports → silent no-op, no login needed); fails LOUD on a send error,
174
+ // leaving the reply unsent so the next run retries it.
175
+ export async function syncFeedback({
176
+ cwd = process.cwd(),
177
+ apiBase = getApiBase(DEFAULT_API_BASE),
178
+ log = console.log,
179
+ fetchImpl = fetch,
180
+ } = {}) {
181
+ const project = findProject(cwd);
182
+ const sentFiles = discoverSent(project.dir);
183
+ if (sentFiles.length === 0) return { replies: 0, updated: [] };
184
+
185
+ if (!getToken()) throw new Error('syncing feedback requires login — run `looop login` first.');
186
+
187
+ // A failing reply must not rob the OTHER reports of their outcomes: push
188
+ // everything, stamp everything, and only THEN re-throw the first failure —
189
+ // loud (so the run is visibly not clean) but after the useful work. The
190
+ // failed reply's heading is still `(unsent)`, so the next run retries it.
191
+ let replies = 0;
192
+ let replyError = null;
193
+ for (const f of sentFiles) {
194
+ try {
195
+ replies += await pushReplies({ ...f, apiBase, fetchImpl, log });
196
+ } catch (err) {
197
+ replyError ??= err;
198
+ }
199
+ }
200
+
201
+ // Fetch-and-stamp is best-effort: the send half already succeeded, and a
202
+ // platform without the endpoint (or a transient failure) must not turn a
203
+ // delivered report into an apparent failure. The replies stay loud.
204
+ let reports;
205
+ try {
206
+ reports = await fetchStatus({ apiBase, fetchImpl });
207
+ } catch (err) {
208
+ log(`(could not check your reports' outcomes right now — ${err.message} They'll sync on the next run.)`);
209
+ if (replyError) throw replyError;
210
+ return { replies, updated: [] };
211
+ }
212
+ const byId = new Map(reports.map((r) => [r.id, r]));
213
+
214
+ const updated = [];
215
+ for (const { path, id } of sentFiles) {
216
+ const report = byId.get(id);
217
+ if (!report) continue;
218
+ const before = readFileSync(path, 'utf8');
219
+ let text = applyStatus(before, report);
220
+ const { text: withUpdates, appended } = appendUpdates(text, report.updates ?? [], knownUpdateIds(text));
221
+ text = withUpdates;
222
+ if (text === before) continue;
223
+ writeFileSync(path, text);
224
+ updated.push({ file: path, id, status: report.status, resolved_in: report.resolved_in, newUpdates: appended });
225
+
226
+ const landed = report.resolved_in ? ` — landed in ${report.resolved_in}` : '';
227
+ const news = appended ? ` (+${appended} update${appended === 1 ? '' : 's'})` : '';
228
+ log(`📬 ${basename(path)}: ${report.status}${landed}${news}`);
229
+ }
230
+ if (replyError) throw replyError;
231
+ return { replies, updated };
232
+ }
package/lib/feedback.mjs CHANGED
@@ -25,7 +25,10 @@ export const MAX_ATTACHMENTS = 20;
25
25
  export const MAX_ATTACHMENT_BYTES = 256 * 1024; // per file
26
26
  export const MAX_ATTACHMENTS_BYTES = 1024 * 1024; // all of them together
27
27
 
28
- const frontmatter = (text) => /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? '';
28
+ // CRLF-tolerant: a Windows clone with autocrlf rewrites these files, and a
29
+ // frontmatter that stops matching means a sent report reads as unsent and is
30
+ // re-filed on every run.
31
+ const frontmatter = (text) => /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? '';
29
32
 
30
33
  // A report is "sent" when its frontmatter carries a `sent:` stamp.
31
34
  function isSent(text) {
@@ -128,7 +131,7 @@ function titleOf(text, path) {
128
131
 
129
132
  function stamp(text, id) {
130
133
  const lines = `sent: ${new Date().toISOString()}\nid: ${id}`;
131
- if (/^---\n/.test(text)) return text.replace(/^---\n/, `---\n${lines}\n`);
134
+ if (/^---\r?\n/.test(text)) return text.replace(/^---\r?\n/, (m) => `${m}${lines}\n`);
132
135
  return `---\n${lines}\n---\n${text}`;
133
136
  }
134
137
 
@@ -142,6 +145,21 @@ export function discoverUnsent(projectDir) {
142
145
  .sort();
143
146
  }
144
147
 
148
+ // Delivered reports, with the id the platform stamped into each — what the
149
+ // back-channel (feedback-sync.mjs) replies on and fetches outcomes for.
150
+ export function discoverSent(projectDir) {
151
+ const dir = join(projectDir, 'notes', 'feedback');
152
+ if (!existsSync(dir)) return [];
153
+ return readdirSync(dir)
154
+ .filter((name) => name.endsWith('.md'))
155
+ .map((name) => join(dir, name))
156
+ .sort()
157
+ .flatMap((path) => {
158
+ const id = /^id:\s*(fb_[0-9a-f]{16})\s*$/m.exec(frontmatter(readFileSync(path, 'utf8')))?.[1];
159
+ return id ? [{ path, id }] : [];
160
+ });
161
+ }
162
+
145
163
  export async function sendFeedback({
146
164
  cwd = process.cwd(),
147
165
  apiBase = getApiBase(DEFAULT_API_BASE),
@@ -29,8 +29,9 @@
29
29
  // {
30
30
  // "zones": { "head": ["Head", "Neck*"], "arm": ["*_Arm*"] },
31
31
  // "clips": ["fly", "idle"], // subset to bake; default = all
32
- // "generate": "drifter-gen.mjs" // generator script, relative to the GLB
33
- // }
32
+ // "generate": "drifter-gen.mjs", // generator script, relative to the GLB
33
+ // "pivot": "center" // where body.(x,y,z) sits in the model:
34
+ // } // "origin" (default) | "center" | "feet" | [x,y,z]
34
35
  // Zones use the dominant-bone rule (a triangle belongs to whichever bone most
35
36
  // influences its vertices — the same rule the big engines use to size their
36
37
  // bone-attached proxy shapes, pointed at triangles). Patterns match bone
@@ -64,7 +65,9 @@ import { pathToFileURL } from 'node:url';
64
65
  // meaning — it participates in the staleness hash, so old artifacts re-bake.
65
66
  // v2: the staleness hash gained the generator-script component, and skeletons
66
67
  // may carry generated additive clips.
67
- export const BAKE_VERSION = 2;
68
+ // v3: skeletons carry a resolved `pivot`, and the broadphase radius is
69
+ // measured about it instead of the model origin.
70
+ export const BAKE_VERSION = 3;
68
71
 
69
72
  // ── pure helpers (unit-tested without a browser) ─────────────────────────────
70
73
 
@@ -106,6 +109,24 @@ export function boneZonesFromConfig(boneNames, zonesConfig) {
106
109
  return { names, byBone };
107
110
  }
108
111
 
112
+ // Resolve the config's `pivot` — the model-space point that lands ON the
113
+ // entity's body.(x,y,z) — against the bind-pose bounding box. There is no
114
+ // universal right anchor (a flyer wants its centre, a walker its feet, a turret
115
+ // its base), so it's a per-model choice; "origin" reproduces the GLB's own
116
+ // origin and is the default. The resolved vector is written into the skeleton
117
+ // artifact; the runtime composes it into PLACEMENT only, never the inverse
118
+ // bind, so skinning is unaffected.
119
+ export function resolvePivot(pivotCfg, bbox) {
120
+ if (pivotCfg == null || pivotCfg === 'origin') return [0, 0, 0];
121
+ const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
122
+ if (pivotCfg === 'center') return [mid(0), mid(1), mid(2)];
123
+ if (pivotCfg === 'feet') return [mid(0), bbox.min[1], mid(2)];
124
+ if (Array.isArray(pivotCfg) && pivotCfg.length === 3 && pivotCfg.every((n) => Number.isFinite(n))) {
125
+ return [pivotCfg[0], pivotCfg[1], pivotCfg[2]];
126
+ }
127
+ throw new Error(`"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(pivotCfg)}`);
128
+ }
129
+
109
130
  // The dominant bone of a triangle: the bone with the highest summed skin weight
110
131
  // across its three vertices. `acc` is scratch sized to the bone count.
111
132
  export function dominantBone(skinIndex, skinWeight, influences, index, tri, acc) {
@@ -478,18 +499,44 @@ async function bakeInPage(page, cfg) {
478
499
  clips[anim.name] = { duration: anim.duration, tracks };
479
500
  }
480
501
 
502
+ // ── pivot: where body.(x,y,z) sits in the model ──────────────────────────
503
+ // Resolved against the bind-pose bbox of the extracted positions. Mirrors
504
+ // resolvePivot — this copy runs in-page, because the broadphase radius
505
+ // below must be measured about the pivot: the runtime centres its sphere on
506
+ // body, and body IS the pivot point, so a radius measured about the origin
507
+ // could undersize the sphere and silently reject true edge hits.
508
+ const bbox = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
509
+ for (let i = 0; i < V * 3; i += 3) {
510
+ for (let a = 0; a < 3; a++) {
511
+ const c = position[i + a];
512
+ if (c < bbox.min[a]) bbox.min[a] = c;
513
+ if (c > bbox.max[a]) bbox.max[a] = c;
514
+ }
515
+ }
516
+ let pivot;
517
+ {
518
+ const p = config.pivot;
519
+ const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
520
+ if (p == null || p === 'origin') pivot = [0, 0, 0];
521
+ else if (p === 'center') pivot = [mid(0), mid(1), mid(2)];
522
+ else if (p === 'feet') pivot = [mid(0), bbox.min[1], mid(2)];
523
+ else if (Array.isArray(p) && p.length === 3 && p.every((n) => Number.isFinite(n))) pivot = [p[0], p[1], p[2]];
524
+ else return { error: `"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(p)}` };
525
+ }
526
+
481
527
  // ── broadphase radius: worst case across rest + every baked clip ─────────
482
528
  // The sphere exists to REJECT cheaply, so it must never reject a true hit:
483
529
  // measure every pose the clips can produce, then carry 3% slack (a ray
484
530
  // grazing an extremity is near-tangent, and float error then discards a hit
485
- // that visibly connects).
531
+ // that visibly connects). Distances are about the pivot — the sphere's
532
+ // runtime centre.
486
533
  let radius = 0;
487
534
  const measure = () => {
488
535
  scene.updateMatrixWorld(true);
489
536
  sk.skeleton.update?.();
490
537
  for (let i = 0; i < V; i++) {
491
538
  sk.getVertexPosition(i, v3);
492
- const d = Math.hypot(v3.x, v3.y, v3.z);
539
+ const d = Math.hypot(v3.x - pivot[0], v3.y - pivot[1], v3.z - pivot[2]);
493
540
  if (d > radius) radius = d;
494
541
  }
495
542
  };
@@ -534,6 +581,7 @@ async function bakeInPage(page, cfg) {
534
581
  return {
535
582
  notes,
536
583
  root: { t: [rp.x, rp.y, rp.z], r: [rq.x, rq.y, rq.z, rq.w], s: [rs.x, rs.y, rs.z] },
584
+ pivot,
537
585
  bones: outBones,
538
586
  clips,
539
587
  mesh: {
@@ -638,6 +686,7 @@ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.lo
638
686
 
639
687
  const skeletonJson = {
640
688
  root: { t: baked.root.t, r: baked.root.r, s: rootS },
689
+ pivot: baked.pivot,
641
690
  bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
642
691
  clips: baked.clips,
643
692
  };
package/lib/update.mjs CHANGED
@@ -17,6 +17,7 @@ import { login } from './login.mjs';
17
17
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
18
18
  import { compareVersions, renderReleases } from './changelog.mjs';
19
19
  import { syncCli, reportCli } from './self-update.mjs';
20
+ import { fetchStatus, landedIn } from './feedback-sync.mjs';
20
21
 
21
22
  // The report is the point. A creator reading this must be able to answer, with
22
23
  // no further digging: what changed, was any of it mine, and what do I do now.
@@ -78,7 +79,7 @@ export async function update({
78
79
  const { latest, releases } = await res.json();
79
80
  if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
80
81
 
81
- // What the update is about to change UNDER the game (project note azlqm2).
82
+ // What the update is about to change UNDER the game.
82
83
  // Reporting the skill files we rewrote and nothing about the engine was the
83
84
  // whole gap: an agent crossed two versions of the shared library blind.
84
85
  //
@@ -172,6 +173,19 @@ export async function update({
172
173
  reportCli(log, cli);
173
174
  }
174
175
 
176
+ // Feedback this repo filed that a crossed release resolved. Best-effort on
177
+ // purpose: the back-channel must never block or fail an engine update, so
178
+ // any error here (an older platform without the endpoint, offline after the
179
+ // download, a bad response) reduces to "no landings to report".
180
+ let landed = [];
181
+ if (updated) {
182
+ try {
183
+ landed = landedIn(await fetchStatus({ apiBase, fetchImpl }), { from, to: latest });
184
+ } catch {
185
+ landed = [];
186
+ }
187
+ }
188
+
175
189
  if (updated) {
176
190
  log('');
177
191
  if (crossed === null) {
@@ -180,8 +194,14 @@ export async function update({
180
194
  } else if (crossed.length) {
181
195
  log(renderReleases(crossed, { pinned: from, latest }));
182
196
  }
197
+ if (landed.length) {
198
+ log('');
199
+ log(' Feedback you filed landed in this update:');
200
+ for (const r of landed) log(` • "${r.title}" — resolved in ${r.resolved_in}`);
201
+ log(' Run `npx looop feedback` to pull the details into notes/feedback/.');
202
+ }
183
203
  log('');
184
204
  log(' Republish (`npx looop publish`) when you want the live game on it.');
185
205
  }
186
- return { from, to: latest, updated, surface, crossed, cli };
206
+ return { from, to: latest, updated, surface, crossed, cli, landed };
187
207
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",