@looop-games/cli 0.1.22 → 0.1.24

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,32 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.24] - 2026-08-01
18
+
19
+ ### Changed
20
+
21
+ - `looop update` now always installs Looop's version of the skill files and
22
+ the `AGENTS.md` managed block, and removes retired ones — even where they
23
+ were changed locally. Before, an edited file was kept and silently stopped
24
+ receiving updates forever; one unnoticed agent edit could freeze a skill
25
+ out of every future release. Nothing is lost when this happens: the update
26
+ marks each such file `OVERWROTE`, saves the previous copy in
27
+ `.looop/backup-<version>/` (git has it too), and the `/update-looop` skill
28
+ walks you through keeping anything that mattered. Guidance you want kept
29
+ belongs in `AGENTS.md` below the managed block, where updates never touch
30
+ it. Repos already stuck with a frozen skill heal themselves on their next
31
+ update — no action needed.
32
+
33
+ ## [0.1.23] - 2026-08-01
34
+
35
+ ### Changed
36
+
37
+ - When the platform refuses an engine download because the Engine License has
38
+ been updated, `looop dev` and `looop update` now explain why and open the
39
+ sign-in flow for you — review and accept in the browser, and the download
40
+ continues by itself. No more bare HTTP status codes, no manual
41
+ `looop login`.
42
+
17
43
  ## [0.1.22] - 2026-08-01
18
44
 
19
45
  ### Added
@@ -23,26 +23,41 @@
23
23
  //
24
24
  // The one contract everything here serves:
25
25
  //
26
- // an update reaches a pre-existing repo without touching anything
27
- // user-owned.
26
+ // an update always lands Looop's surface, and never loses the
27
+ // creator's content — anything it replaces or removes is backed up
28
+ // and reported.
28
29
  //
29
- // So ownership is tracked explicitly, in `.looop/agent-surface.json` the
30
- // hash of every file WE wrote. On each run a file is one of:
30
+ // Ownership is by PATH (note hx3kd9): a path the artifact ships belongs to the
31
+ // platform and always converges to the artifact's version. On each run a file at
32
+ // a shipped path is one of:
31
33
  //
32
- // ours, unmodified (hash matches what we recorded)replace with the new
33
- // ours, edited (hash differs) KEEP theirs, warn
34
- // never ours (no record, but on disk) KEEP theirs, warn
35
- // not on disk → place ours
36
- // retired upstream → remove, if still ours
34
+ // content already equal to the incomingheal the record, silently
35
+ // ours, unmodified (hash == recorded) replace with the new
36
+ // anything else on disk back it up, replace, report lossy
37
+ // not on disk → place ours
38
+ // retired upstream / orphaned → remove (backup + lossy when the
39
+ // content didn't match the record)
37
40
  //
38
- // A skill we never placed is never even enumerated, so a creator's own skills
39
- // are invisible to this code by construction. `AGENTS.md` is the exception that
40
- // proves the rule: the managed block between the markers is ours to rewrite;
41
- // everything outside them (their title, their instructions below the end
42
- // marker) is theirs and is spliced back untouched.
41
+ // There is deliberately no keep-theirs outcome. The old model kept edited
42
+ // files and "shadowed" the incoming version which permanently froze a file
43
+ // out of every future update after a single (usually agent-made, usually
44
+ // unnoticed) edit, breaking the harness for that repo silently (fb_3a0bea).
45
+ // The hash record in `.looop/agent-surface.json` is therefore not an ownership
46
+ // gate anymore: it is a change DETECTOR, kept so the report can say "this had
47
+ // local content" honestly instead of warning on every write. `lossy` is that
48
+ // report; `looop update` narrates it and the /update-looop skill asks the
49
+ // creator what to do with each lost edit.
50
+ //
51
+ // A skill we never ship is never even enumerated, so a creator's own skills
52
+ // are invisible to this code by construction. `AGENTS.md` is the sanctioned
53
+ // home for creator additions: the managed block between the markers is ours to
54
+ // rewrite; everything outside them (their title, their instructions below the
55
+ // end marker) is theirs and is spliced back untouched — and when the markers
56
+ // themselves have been deleted, the block is re-appended below their content
57
+ // rather than contesting any of it.
43
58
  import { createHash } from 'node:crypto';
44
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
45
- import { dirname, join } from 'node:path';
59
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
60
+ import { dirname, join, relative } from 'node:path';
46
61
 
47
62
  export const SURFACE_DIR = 'agent-surface';
48
63
  export const LOCAL_MANIFEST = '.looop/agent-surface.json';
@@ -100,9 +115,27 @@ function pruneEmptyDirs(projectDir, dest) {
100
115
  }
101
116
  }
102
117
 
118
+ // Whether everything on disk at `dest` (a file, or a whole skill folder) still
119
+ // matches the hashes recorded for it. Distinguishes a clean retire/orphan
120
+ // removal from one that destroys real local content — only the latter earns a
121
+ // backup and a `lossy` report. A file with no record at all counts as
122
+ // non-matching: content we can't vouch for is content to preserve.
123
+ function contentMatchesRecord(abs, dest, placed) {
124
+ if (!statSync(abs).isDirectory()) {
125
+ return placed[dest] !== undefined && sha(readFileSync(abs)) === placed[dest];
126
+ }
127
+ const entries = readdirSync(abs, { recursive: true, withFileTypes: true }).filter((e) => e.isFile());
128
+ if (!entries.length) return true; // an empty folder holds nothing to lose
129
+ return entries.every((e) => {
130
+ const file = join(e.parentPath, e.name);
131
+ const rel = join(dest, relative(abs, file));
132
+ return placed[rel] !== undefined && sha(readFileSync(file)) === placed[rel];
133
+ });
134
+ }
135
+
103
136
  // Splice the artifact's managed block into the repo's AGENTS.md, preserving
104
137
  // everything outside the markers. Returns null when the creator's file has no
105
- // markers they rewrote Layer 0 themselves, so it is theirs now.
138
+ // markers; the caller then re-appends the block below their content.
106
139
  function spliceManaged(current, incoming) {
107
140
  const cs = current.indexOf(START);
108
141
  const ce = current.indexOf(END);
@@ -122,7 +155,7 @@ function spliceManaged(current, incoming) {
122
155
  */
123
156
  export function reconcileAgentSurface(projectDir, engineDir, { log = console.log } = {}) {
124
157
  const result = {
125
- placed: [], removed: [], shadowed: [], backups: [],
158
+ placed: [], removed: [], lossy: [], backups: [],
126
159
  adopted: false, skipped: false, engineVersion: null, backupDir: null,
127
160
  };
128
161
  const surface = join(engineDir, SURFACE_DIR);
@@ -166,19 +199,22 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
166
199
 
167
200
  // ── retired entries: remove what upstream dropped. This is the half that
168
201
  // makes a RENAME possible on a repo that already exists — without it a
169
- // replaced skill would linger next to its replacement forever. ─────────
202
+ // replaced skill would linger next to its replacement forever, still
203
+ // loaded by the creator's agent as live guidance. Removal is
204
+ // unconditional (the path is Looop's); content that doesn't match
205
+ // the record is preserved in the backup and reported lossy. ───────────
170
206
  for (const rel of manifest.retired ?? []) {
171
207
  const dest = target(rel);
172
208
  if (!dest) continue;
173
209
  const abs = join(projectDir, dest);
174
210
  if (!existsSync(abs)) continue;
175
211
 
176
- const owned = Object.keys(placed).some((p) => p === dest || p.startsWith(`${dest}/`));
177
- if (!owned && !adopt) {
178
- result.shadowed.push(dest);
179
- continue;
212
+ if (adopt) {
213
+ backup(dest); // no record to compare — preserve, but don't accuse
214
+ } else if (!contentMatchesRecord(abs, dest, placed)) {
215
+ backup(dest);
216
+ result.lossy.push(dest);
180
217
  }
181
- if (adopt) backup(dest);
182
218
  rmSync(abs, { recursive: true, force: true });
183
219
  for (const p of Object.keys(placed)) {
184
220
  if (p === dest || p.startsWith(`${dest}/`)) delete placed[p];
@@ -201,12 +237,16 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
201
237
 
202
238
  if (dest === 'AGENTS.md') {
203
239
  // The markers ARE the contract: inside is ours, outside is theirs.
204
- const next = exists
205
- ? spliceManaged(current.toString('utf8'), desired.toString('utf8'))
206
- : desired.toString('utf8');
240
+ const desiredStr = desired.toString('utf8');
241
+ let next = exists ? spliceManaged(current.toString('utf8'), desiredStr) : desiredStr;
207
242
  if (next === null) {
208
- result.shadowed.push(dest);
209
- continue;
243
+ // Their file has no markers — they (or their agent) rewrote Layer 0.
244
+ // The block must still land, but everything they wrote is theirs, so
245
+ // it is re-appended below their content, touching none of it.
246
+ const is = desiredStr.indexOf(START);
247
+ const ie = desiredStr.indexOf(END);
248
+ if (is === -1 || ie === -1) continue; // artifact itself has no block — nothing to land
249
+ next = current.toString('utf8').replace(/\s*$/, '\n\n') + desiredStr.slice(is, ie + END.length) + '\n';
210
250
  }
211
251
  if (exists && current.toString('utf8') === next) continue;
212
252
  writeFileSync(abs, next);
@@ -217,16 +257,21 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
217
257
 
218
258
  if (exists) {
219
259
  const now = sha(current);
220
- if (now === recorded) {
221
- if (now === sha(desired)) continue; // already current
222
- } else if (recorded === undefined && adopt) {
223
- if (now !== sha(desired)) backup(dest);
224
- } else {
225
- // Either they edited ours, or it was never ours. Both are theirs.
226
- result.shadowed.push(dest);
227
- placed[dest] = recorded ?? now;
260
+ if (now === sha(desired)) {
261
+ // Content already equal including the frozen-record case where a
262
+ // local edit was independently upstreamed (fb_3a0bea). Nothing to
263
+ // write, nothing lost; heal the record so future updates apply.
264
+ placed[dest] = now;
228
265
  continue;
229
266
  }
267
+ if (adopt) {
268
+ backup(dest); // no record to compare — preserve, but don't accuse
269
+ } else if (now !== recorded) {
270
+ // Edited ours, or never ours — either way real local content is
271
+ // about to be replaced: preserve it and report it for the ask flow.
272
+ backup(dest);
273
+ result.lossy.push(dest);
274
+ }
230
275
  }
231
276
 
232
277
  mkdirSync(dirname(abs), { recursive: true });
@@ -251,8 +296,10 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
251
296
  const abs = join(projectDir, dest);
252
297
  if (existsSync(abs)) {
253
298
  if (sha(readFileSync(abs)) !== placed[dest]) {
254
- result.shadowed.push(dest); // they changed it — it's theirs now
255
- continue;
299
+ // They changed it — the retirement still lands, the content survives
300
+ // in the backup and the caller asks the creator about it.
301
+ backup(dest);
302
+ result.lossy.push(dest);
256
303
  }
257
304
  rmSync(abs, { force: true });
258
305
  pruneEmptyDirs(projectDir, dest);
package/lib/engine.mjs CHANGED
@@ -112,7 +112,26 @@ export async function ensureEngine(
112
112
  const cached = join(cache, `engine-${pin}.tgz`);
113
113
  if (!existsSync(cached)) {
114
114
  log(`Downloading engine ${pin} from ${apiBase}…`);
115
- const res = await fetchImpl(`${apiBase}/api/creator/engine/${pin}`, { headers: auth });
115
+ const dlUrl = `${apiBase}/api/creator/engine/${pin}`;
116
+ let res = await fetchImpl(dlUrl, { headers: auth });
117
+ // 403 = the token is fine but the platform refuses with a reason the
118
+ // human must act on — today, an updated Engine License that needs
119
+ // re-accepting. The acceptance IS the browser approval step of the device
120
+ // flow, so run the flow right here instead of asking the human to type
121
+ // `looop login` themselves (the click in the browser is still theirs to
122
+ // make), then retry once with the fresh token. If the platform still says
123
+ // no, surface its words — never loop.
124
+ if (res.status === 403) {
125
+ const reason = (await res.json().catch(() => null))?.error;
126
+ log(reason || 'The platform refused the engine download — your sign-in needs a refresh.');
127
+ log('Opening the sign-in flow to sort that out…');
128
+ await loginFn({ apiBase, log });
129
+ res = await fetchImpl(dlUrl, { headers: { Authorization: `Bearer ${getToken()}` } });
130
+ if (res.status === 403) {
131
+ const still = (await res.json().catch(() => null))?.error;
132
+ throw new Error(still || reason || 'the platform refused the engine download (HTTP 403).');
133
+ }
134
+ }
116
135
  if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `looop login` again.');
117
136
  if (res.status === 404) throw new Error(`engine ${pin} is not downloadable — check the \`looop.engine\` pin in package.json.`);
118
137
  if (!res.ok) throw new Error(`engine download failed (HTTP ${res.status})`);
package/lib/update.mjs CHANGED
@@ -22,19 +22,39 @@ import { fetchStatus, landedIn } from './feedback-sync.mjs';
22
22
  // The report is the point. A creator reading this must be able to answer, with
23
23
  // no further digging: what changed, was any of it mine, and what do I do now.
24
24
  function report(log, engineVersion, surface) {
25
- const { placed = [], removed = [], shadowed = [], backups = [], backupDir } = surface;
26
- if (!placed.length && !removed.length && !shadowed.length) {
25
+ const { placed = [], removed = [], lossy = [], backups = [], backupDir, adopted } = surface;
26
+ if (!placed.length && !removed.length && !lossy.length) {
27
27
  log(' Your skills and instructions were already current.');
28
28
  return;
29
29
  }
30
30
 
31
31
  log('');
32
32
  log(`Looop also updated this game's skills and instructions to match engine ${engineVersion}:`);
33
- for (const f of placed) log(` updated ${f}`);
34
- for (const f of removed) log(` removed ${f} (retired by Looop)`);
35
- for (const f of shadowed) log(` KEPT YOURS ${f} (you changed it — we left it alone)`);
33
+ for (const f of placed) {
34
+ if (lossy.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
35
+ else log(` updated ${f}`);
36
+ }
37
+ for (const f of removed) {
38
+ if (lossy.includes(f)) log(` removed ${f} (retired by Looop — it had local changes, see below)`);
39
+ else log(` removed ${f} (retired by Looop)`);
40
+ }
41
+ for (const f of lossy) {
42
+ if (!placed.includes(f) && !removed.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
43
+ }
44
+
45
+ if (lossy.length) {
46
+ // The overwrite is the contract (Looop's files always land — a kept edit
47
+ // used to freeze the file out of every future update), but the content is
48
+ // never gone: name both recovery routes and the one place local guidance
49
+ // is safe from updates.
50
+ log('');
51
+ log(' Files marked OVERWROTE had local changes that Looop replaced. Nothing is');
52
+ log(` lost: recover them with \`git diff\`${backupDir ? `, or from ${backupDir}` : ''}.`);
53
+ log(' Guidance you want to keep belongs in AGENTS.md, below the managed block —');
54
+ log(' updates never touch anything there.');
55
+ }
36
56
 
37
- if (backups.length) {
57
+ if (adopted && backups.length) {
38
58
  // On adoption we cannot tell an untouched old platform file from one the
39
59
  // creator edited — there is no ownership record to compare against, which
40
60
  // is precisely why we're adopting. Most of these are just the previous
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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",