@looop-games/cli 0.1.21 → 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 +16 -0
- package/bin/looop.mjs +5 -1
- package/lib/changelog.mjs +1 -1
- package/lib/feedback-sync.mjs +232 -0
- package/lib/feedback.mjs +20 -2
- package/lib/update.mjs +22 -2
- 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.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
|
+
|
|
17
33
|
## [0.1.21] - 2026-07-30
|
|
18
34
|
|
|
19
35
|
### 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
|
|
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
|
|
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
|
-
|
|
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/,
|
|
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),
|
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
|
|
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
|
}
|