@looop-games/cli 0.1.23 → 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 +16 -0
- package/lib/agent-surface.mjs +86 -39
- package/lib/update.mjs +26 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,22 @@ 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
|
+
|
|
17
33
|
## [0.1.23] - 2026-08-01
|
|
18
34
|
|
|
19
35
|
### Changed
|
package/lib/agent-surface.mjs
CHANGED
|
@@ -23,26 +23,41 @@
|
|
|
23
23
|
//
|
|
24
24
|
// The one contract everything here serves:
|
|
25
25
|
//
|
|
26
|
-
// an update
|
|
27
|
-
//
|
|
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
|
-
//
|
|
30
|
-
//
|
|
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
|
-
//
|
|
33
|
-
// ours,
|
|
34
|
-
//
|
|
35
|
-
// not on disk
|
|
36
|
-
// retired upstream
|
|
34
|
+
// content already equal to the incoming → heal 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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
|
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: [],
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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 ===
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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/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 = [],
|
|
26
|
-
if (!placed.length && !removed.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)
|
|
34
|
-
|
|
35
|
-
|
|
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
|