@troylar/flightdeck 2.11.1 → 2.12.0

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
@@ -2,6 +2,29 @@
2
2
 
3
3
  <!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
4
4
 
5
+ ## 2.12.0 · Nov 1, 2026
6
+
7
+ AI assistant files, from the app.
8
+
9
+ ### New
10
+
11
+ - **See and update AI assistant files without a command line** (Settings). Each file says where it stands. Update follows flightdeck install's rules: a copy is kept first, a file your team changed is never replaced, and one that changes while updating is left alone and named. A save in the instant between the last check and the write can still be overwritten, so close editors on these files first. Update never changes the app file itself. *Where:* Settings › AI assistant files
12
+ - **Show and Copy any file, and merge requests** (Settings). Show and Copy give any file as this version ships it, for an AI assistant that can't open the folder. A file waiting to merge also has Copy merge request, and I merged it, which puts the waiting copy away beside the replaced files. *Where:* Settings › AI assistant files
13
+ - **Keep skills in another folder** (Settings). In another folder saves and updates the skills in a folder your AI assistant reads, with their own record there, like flightdeck skills --to. *Where:* Settings › AI assistant files
14
+
15
+ ### Improved
16
+
17
+ - **Folder health judges these files by the installer's rules** (Folder health). Folder health and flightdeck install now agree on each file. Its AI assistants items open AI assistant files. *Where:* Settings › Folder health
18
+
19
+ ## 2.11.2 · Oct 31, 2026
20
+
21
+ Duplicated tabs and Conditions.
22
+
23
+ ### Fixed
24
+
25
+ - **A duplicated tab always gets its own identity** (Runs). A tab duplicated on a slow computer takes its own identity even when the first window answers late, and follows the recording in the first window. *Where:* Duplicating a browser tab with a run open
26
+ - **Conditions moves as soon as a newer draft arrives** (Editor). When a newer draft gives the selected step an Only when, Conditions comes out of More options right away. *Where:* The builder
27
+
5
28
  ## 2.11.1 · Oct 31, 2026
6
29
 
7
30
  Odd names in a folder, and notes that share an ID.
package/README.md CHANGED
@@ -25,7 +25,7 @@
25
25
  ## Quick start
26
26
 
27
27
  1. `npx @troylar/flightdeck open` opens the app in your browser (needs Node 18 or later).
28
- 2. No Node? Save [flightdeck.html](https://unpkg.com/@troylar/flightdeck@2.6.1/dist/flightdeck.html) from the npm package as a file (right-click, **Save link as…**) and open that file in Edge or Chrome.
28
+ 2. No Node? Save [flightdeck.html](https://unpkg.com/@troylar/flightdeck@2.12.0/dist/flightdeck.html) from the npm package as a file (right-click, **Save link as…**) and open that file in Edge or Chrome.
29
29
  3. Choose **Look around the examples** to try it, or **Start your team** to set up your team's folder.
30
30
 
31
31
  <p align="center"><a href="https://github.com/troylar/flightdeck/blob/main/docs/images/run-light.png" title="Open in GitHub's image viewer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/troylar/flightdeck/main/docs/images/run-dark.png"><img src="https://raw.githubusercontent.com/troylar/flightdeck/main/docs/images/run-light.png" alt="A release in progress on the run screen: 7/15 answered in the header with Details, Screen share, Receipt and More; a strip saying you're recording, with Pause and Hand off and the run's details; Phases with each gate's state; the checklist with Next up pinned at its top; and the selected step, whose text shows the run's tag (Start the production deploy job v2026.10.16), with the values to copy, Record the result, Done, Done differently and Blocked" width="1000"></picture></a></p>
@@ -373,7 +373,7 @@ Thousands of runs open and refresh without stalling. Long lists show a page at a
373
373
 
374
374
  ## Skills for your AI assistant
375
375
 
376
- `flightdeck install` puts these in the team folder's `.flightdeck/skills/`, with an `AGENTS.md` that any assistant reads. They're plain `SKILL.md` files, so any assistant that supports skills can use them. Open the team folder with your assistant and ask for one by name:
376
+ **Settings → AI assistant files** in the app (or `flightdeck install` in a terminal) puts these in the team folder's `.flightdeck/skills/`, with an `AGENTS.md` that any assistant reads. They're plain `SKILL.md` files, so any assistant that supports skills can use them. Open the team folder with your assistant and ask for one by name:
377
377
 
378
378
  | Skill | Does |
379
379
  |---|---|
@@ -388,6 +388,20 @@ Thousands of runs open and refresh without stalling. Long lists show a page at a
388
388
 
389
389
  In `AGENTS.md`, only the part between the `flightdeck:begin` and `flightdeck:end` markers is Flightdeck's; your Team notes below it are never touched. A file your team changed is left alone on update, and the new default waits in `.flightdeck/updates/<version>/` with a prompt for your assistant to merge it.
390
390
 
391
+ ### AI assistant files, from the app
392
+
393
+ **Settings → AI assistant files** installs and updates `AGENTS.md` and the skills without a command line. Each file shows where it stands:
394
+
395
+ | Status | Means |
396
+ |---|---|
397
+ | Up to date | The same as this version ships |
398
+ | Update ready | An older default nobody changed; **Update** replaces it, after keeping a copy |
399
+ | Not there yet, or Deleted, can be added again | **Update** adds it |
400
+ | You changed it | Never replaced |
401
+ | Waiting to merge | Your team changed it and the default changed too; the new default waits in `.flightdeck/updates/<version>/` |
402
+
403
+ **Show** and **Copy** give any file as this version ships it, for an assistant that can't open the folder. **Copy merge request** copies the prompt for your assistant, and **I merged it** puts the waiting copy away beside the replaced files once it's merged. **In another folder** does the same for an assistant that reads skills from its own folder (`flightdeck skills --to`). The app and the command share the same rules for these files and the same record (`.flightdeck/installed.json`), so they never disagree; the command also updates `flightdeck.html` itself, which the app leaves alone. Folder health's **AI assistants** items open this screen.
404
+
391
405
  ## Safe by design
392
406
 
393
407
  <p align="center"><a href="https://github.com/troylar/flightdeck/blob/main/docs/images/safe-light.png" title="Open in GitHub's image viewer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/troylar/flightdeck/main/docs/images/safe-dark.png"><img src="https://raw.githubusercontent.com/troylar/flightdeck/main/docs/images/safe-light.png" alt="Safe by design: nothing leaves your machine; no server or accounts; saves that check first; built for OneDrive; a careful command; accessible." width="1000"></picture></a></p>
@@ -402,7 +416,7 @@ Roles prevent ordinary mistakes, not attacks: anyone who can open the team folde
402
416
 
403
417
  ## Updating and when something goes wrong
404
418
 
405
- Run `flightdeck install <folder>` again with the new version (or `npx @troylar/flightdeck@<version> install <folder>`). It updates what's still a Flightdeck default and keeps what your team changed. [Updating Flightdeck](https://github.com/troylar/flightdeck/blob/main/docs/updating.md) walks through backups, mixed versions and [rolling back](https://github.com/troylar/flightdeck/blob/main/docs/updating.md#rolling-back).
419
+ Run `flightdeck install <folder>` again with the new version (or `npx @troylar/flightdeck@<version> install <folder>`). It updates what's still a Flightdeck default and keeps what your team changed. Once the new `flightdeck.html` is in the folder, **Settings → AI assistant files** can update `AGENTS.md` and the skills from the app the same way. [Updating Flightdeck](https://github.com/troylar/flightdeck/blob/main/docs/updating.md) walks through backups, mixed versions and [rolling back](https://github.com/troylar/flightdeck/blob/main/docs/updating.md#rolling-back).
406
420
 
407
421
  When the app shows a warning, **Settings → When something goes wrong** explains it in the app; the same guide is [online](https://github.com/troylar/flightdeck/blob/main/docs/recovery.md), covering conflict copies, lost folder access, OneDrive copies and restoring from a backup.
408
422
 
package/bin/flightdeck.js CHANGED
@@ -186,27 +186,40 @@ function appStatus(dir, file, html, mine, force) {
186
186
  A copy still waiting in .flightdeck/updates/<any version>/ keeps a file at merge until someone deletes the copy.
187
187
  With --force, kept and merge files are replaced too, after a copy is saved. */
188
188
  function classify(t, files, base, force) {
189
- return files.map((f) => {
189
+ const items = files.map((f) => {
190
190
  const parts = f.rel.split('/'), now = readInside(t.dir, parts), want = hash(managed(f.key, f.text));
191
- const item = { key: f.key, rel: f.rel, parts, text: f.text, now, want, staged: staged(t, f.rel) };
192
- if (now === null) return Object.assign(item, { status: t.record.files[f.key] ? 'missing' : 'new' });
193
- const have = hash(managed(f.key, now));
194
- /* Still waiting on a merge. When this version's default is new to the person (not already staged, not what the file
195
- already says), it's staged too, so an older pending merge never hides newer instructions. */
196
- if (item.staged.length && !force) return Object.assign(item, { status: 'merge', waiting: item.staged.map((x) => x.version).join(', '),
197
- stage: have !== want && !item.staged.some((x) => x.version === t.version || hash(managed(f.key, x.text)) === want) });
198
- if (have === want) return Object.assign(item, { status: 'current' });
199
- if ((base[f.baseKey || baselineKey(f.key)] || {})[have]) return Object.assign(item, { status: 'update' });
200
- if (force) return Object.assign(item, { status: 'forced' });
201
- if (t.record.files[f.key] === want) return Object.assign(item, { status: 'kept' });
202
- return Object.assign(item, { status: 'merge' });
191
+ return { key: f.key, rel: f.rel, baseKey: f.baseKey, parts, text: f.text, now, want, have: now === null ? null : hash(managed(f.key, now)), staged: staged(t, f.rel) };
192
+ });
193
+ const why = decide(items.map((i) => ({ key: i.key, baseKey: i.baseKey, want: i.want, have: i.have, staged: i.staged.map((x) => ({ version: x.version, hash: hash(managed(i.key, x.text)) })) })), t.record, base, t.version, force);
194
+ return items.map((i, n) => Object.assign(i, why[n], { key: i.key }));
195
+ }
196
+ /* The decision alone, from hashes: the same as PF.contract.classify (test/assistant-files.test.js checks they agree), plus
197
+ forced, which only --force gives. */
198
+ function decide(items, record, base, version, force) {
199
+ const rf = (record && record.files) || {};
200
+ return items.map((it) => {
201
+ const out = { key: it.key, status: '' }, st = it.staged || [];
202
+ if (it.have === null || it.have === undefined) { out.status = rf[it.key] ? 'missing' : 'new'; return out; }
203
+ if (st.length && !force) {
204
+ out.status = 'merge'; out.waiting = st.map((x) => x.version).join(', ');
205
+ out.stage = it.have !== it.want && !st.some((x) => x.version === version || x.hash === it.want);
206
+ return out;
207
+ }
208
+ if (it.have === it.want) out.status = 'current';
209
+ else if ((base[it.baseKey || baselineKey(it.key)] || {})[it.have]) out.status = 'update';
210
+ else if (force) out.status = 'forced';
211
+ else if (rf[it.key] === it.want) out.status = 'kept';
212
+ else out.status = 'merge';
213
+ return out;
203
214
  });
204
215
  }
205
216
  /* The versions under the updates folder, and which of them hold a copy of rel still waiting to be merged. */
217
+ /* Oldest first, dev last: the order install and the app both list waiting versions in. */
218
+ function byVersion(a, b) { return a === b ? 0 : a === 'dev' ? 1 : b === 'dev' ? -1 : cmp(a, b); }
206
219
  function versionsStaged(t) {
207
220
  if (t.versions) return t.versions;
208
221
  const home = folders(t.dir, t.updates, false);
209
- t.versions = home ? fs.readdirSync(home).filter((v) => /^(\d+\.\d+\.\d+|dev)$/.test(v) && folders(t.dir, t.updates.concat(v), false)) : [];
222
+ t.versions = home ? fs.readdirSync(home).filter((v) => /^(\d+\.\d+\.\d+|dev)$/.test(v) && folders(t.dir, t.updates.concat(v), false)).sort(byVersion) : [];
210
223
  return t.versions;
211
224
  }
212
225
  function staged(t, rel) {
@@ -230,12 +243,19 @@ function installedNewer(r) {
230
243
  /* The folder's record, checked field by field. Anyone can edit it, so a record that isn't exactly what this command writes is
231
244
  ignored (and said so), and only entries for files this version installs, with a well-formed hash, are read. */
232
245
  function readRecord(t, keys) {
233
- const text = readInside(t.dir, t.recordAt), none = { files: {} };
234
- if (text === null) return none;
246
+ t.recordText = readInside(t.dir, t.recordAt);
247
+ const r = parseRecord(t.recordText, keys);
248
+ if (r.newer) fail(path.join(t.dir, ...t.recordAt) + ' ' + r.newer + ' Nothing was changed. Use the newer Flightdeck package.');
249
+ return r;
250
+ }
251
+ /* The record's text as install reads it: the same as PF.contract.readRecord (test/assistant-files.test.js checks they agree). */
252
+ function parseRecord(text, keys) {
253
+ const none = { files: {} };
254
+ if (text === null || text === undefined) return none;
235
255
  let r = null;
236
- try { r = JSON.parse(text); } catch (e) { /* reported below */ }
256
+ try { r = JSON.parse(text); } catch (e) { /* ignored below */ }
237
257
  const newer = installedNewer(r);
238
- if (newer) fail(path.join(t.dir, ...t.recordAt) + ' ' + newer + ' Nothing was changed. Use the newer Flightdeck package.');
258
+ if (newer) return { newer };
239
259
  const ok = r && typeof r === 'object' && !Array.isArray(r) && r.format === 'flightdeck-installed/1' && typeof r.version === 'string' && /^(\d+\.\d+\.\d+|dev)$/.test(r.version)
240
260
  && typeof r.at === 'string' && (r.by === 'cli' || r.by === 'app') && r.files && typeof r.files === 'object' && !Array.isArray(r.files)
241
261
  && Object.keys(r.files).every((k) => typeof r.files[k] === 'string' && /^[0-9a-f]{64}$/.test(r.files[k]));
@@ -248,19 +268,21 @@ function readRecord(t, keys) {
248
268
  function stamp(d) { const p = (n) => String(n).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + p(d.getMinutes()); }
249
269
  function fineStamp(d) { const p = (n, w) => String(n).padStart(w || 2, '0'); return stamp(d).replace(' ', '-') + p(d.getSeconds()) + '-' + p(d.getMilliseconds(), 3); }
250
270
 
251
- function mergeReadme(version, items, t) {
252
- const here = t.updates.concat(version).join('/');
271
+ function mergeReadme(version, items, t) { return mergeReadmeText(version, items, t.updates, t.home); }
272
+ /* The same text as PF.contract.mergeReadme (test/assistant-files.test.js checks they agree). */
273
+ function mergeReadmeText(version, items, updates, home) {
274
+ const here = updates.concat(version).join('/');
253
275
  return `# Flightdeck ${version}: files to merge
254
276
 
255
277
  Flightdeck ${version} changed these files, and your team changed them too, so the update left your versions in place:
256
278
 
257
279
  ${items.map((i) => '- `' + i.rel + '`' + (i.key === AGENTS ? ' (only the part between the flightdeck markers)' : '')).join('\n')}
258
280
 
259
- The new default of each file is in this folder (\`${here}/\`), at the same path it has in ${t.home}.
281
+ The new default of each file is in this folder (\`${here}/\`), at the same path it has in ${home}.
260
282
 
261
283
  ## Ask your AI assistant to merge them
262
284
 
263
- Open your AI assistant in ${t.home}, and give it this:
285
+ Open your AI assistant in ${home}, and give it this:
264
286
 
265
287
  > Flightdeck ${version} put new default versions of some files in \`${here}/\`; its README.md lists them. For each one, compare the new default with our file at the same path. Keep our own changes, and bring in what the new default adds or changes. In AGENTS.md, change only the part between \`<!-- flightdeck:begin -->\` and \`<!-- flightdeck:end -->\`; anything that is ours belongs under Team notes, below the end marker. Show me each change before you save it. When a file is merged, delete its copy here. When only README.md is left, delete this folder.
266
288
 
@@ -268,14 +290,61 @@ Then run the same flightdeck command with \`--check\`: exit code 0 means nothing
268
290
  `;
269
291
  }
270
292
 
293
+ /* What an install would write has to be something the app can read back (PF.contract.MAX_BYTES): over the limit, nothing is
294
+ touched. The same check and words as Settings, AI assistant files (afRoom). */
295
+ const CONTRACT_MAX = 8 * 1048576;
296
+ function roomFor(t, items) {
297
+ const why = (name, n, then) => name + ' would be too big for Flightdeck to read back (' + (Math.ceil(n / 1048576 * 100) / 100) + ' MB, over ' + (CONTRACT_MAX / 1048576) + ' MB). Nothing was changed. ' + then;
298
+ for (const i of items) {
299
+ if (['current', 'kept', 'merge'].includes(i.status)) continue;
300
+ const n = Buffer.byteLength(merged(i), 'utf8');
301
+ if (n > CONTRACT_MAX) fail(why(i.rel, n, 'Shorten what your team added to it, then try again.'));
302
+ }
303
+ const rec = nextRecord(t.record, items, t.version);
304
+ if (!rec) return;
305
+ const size = Buffer.byteLength(JSON.stringify({ format: 'flightdeck-installed/1', version: t.version, at: new Date().toISOString(), by: 'cli', files: rec }, null, 2) + '\n', 'utf8');
306
+ if (size > CONTRACT_MAX) fail(why(t.recordAt.join('/'), size, 'Remove the entries for files Flightdeck no longer ships, then try again.'));
307
+ }
308
+ /* Something in the way of where a file goes: the same words as PF.contract.outcomeWords. Every destination is looked at before
309
+ anything is written, so a predictable refusal never leaves an update half done. */
310
+ function blockedWhy(rel, want) { return rel + (want === 'file' ? ' is a folder, where Flightdeck needs a file.' : ' is a file, where Flightdeck needs a folder.') + ' Nothing was changed. Move it, then try again.'; }
311
+ function clearFor(dir, parts) {
312
+ let cur = dir;
313
+ for (let i = 0; i < parts.length; i++) {
314
+ cur = path.join(cur, parts[i]);
315
+ const st = lstatOrNull(cur), rel = parts.slice(0, i + 1).join('/');
316
+ if (!st) return;
317
+ if (st.isSymbolicLink()) fail('refusing to write through a link: ' + cur);
318
+ if (i === parts.length - 1 ? !st.isFile() : !st.isDirectory()) fail(blockedWhy(rel, i === parts.length - 1 ? 'file' : 'folder'));
319
+ }
320
+ }
321
+ function destinationsClear(t, items, more) {
322
+ const dests = (more || []).slice();
323
+ for (const i of items) {
324
+ if (['current', 'kept'].includes(i.status)) continue;
325
+ if (i.status === 'merge') {
326
+ if (i.waiting && !i.stage) continue;
327
+ dests.push(t.updates.concat(t.version, i.rel.split('/')), t.updates.concat(t.version, 'README.md'), t.updates.concat(t.version, 'replaced', 'x'));
328
+ continue;
329
+ }
330
+ dests.push(i.parts);
331
+ if (i.now !== null && i.now !== merged(i)) dests.push(t.updates.concat(t.version, 'replaced', 'x'));
332
+ }
333
+ if (nextRecord(t.record, items, t.version)) dests.push(t.recordAt);
334
+ dests.forEach((parts) => clearFor(t.dir, parts));
335
+ }
271
336
  /* install and skills: one routine for the team folder, and for skills --to <dir>. */
272
337
  function contract(t, files, opts) {
273
338
  const base = baselines(opts.html), now = new Date();
339
+ if (!opts.check || opts.room) clearFor(t.dir, t.updates.concat(t.version, 'x'));
274
340
  inside(t.dir, t.updates.concat(t.version, 'replaced'));
275
341
  t.record = readRecord(t, files.map((f) => f.key));
276
342
  t.changed = [];
277
343
  const items = classify(t, files, base, opts.force);
344
+ if (!opts.check || opts.room) { roomFor(t, items); destinationsClear(t, items, opts.more); }
278
345
  if (opts.check) return items;
346
+ /* The merge list as it was before anything is written: the only text it may replace. */
347
+ const readmeAt = t.updates.concat(t.version, 'README.md'), readmeWas = items.some((i) => i.status === 'merge' && (!i.waiting || i.stage)) ? readInside(t.dir, readmeAt) : null;
279
348
  for (const i of items) {
280
349
  if (['current', 'kept'].includes(i.status) || (i.status === 'merge' && i.waiting && !i.stage)) {
281
350
  if (opts.force) i.staged.forEach((x) => fileAway(t, x));
@@ -293,13 +362,34 @@ function contract(t, files, opts) {
293
362
  }
294
363
  }
295
364
  const merges = items.filter((i) => i.status === 'merge' && !i.changed);
296
- if (merges.some((i) => !i.waiting || i.stage)) safeWrite(t.dir, t.updates.concat(t.version, 'README.md'), mergeReadme(t.version, merges.filter((i) => !i.waiting || i.stage || i.staged.some((x) => x.version === t.version)), t));
297
- const rec = Object.assign({}, t.record.all || {});
298
- items.forEach((i) => { if (!i.changed) rec[i.key] = i.want; });
299
- if (!t.record.ignored && t.record.version === t.version && JSON.stringify(rec) === JSON.stringify(t.record.all)) return items;
300
- safeWrite(t.dir, t.recordAt, JSON.stringify({ format: 'flightdeck-installed/1', version: t.version, at: now.toISOString(), by: 'cli', files: rec }, null, 2) + '\n');
365
+ if (merges.some((i) => !i.waiting || i.stage)) {
366
+ const next = mergeReadme(t.version, merges.filter((i) => !i.waiting || i.stage || i.staged.some((x) => x.version === t.version)), t);
367
+ try {
368
+ if (readmeWas !== null && readmeWas !== next) keepCopy(t, ['merge-README.md'], readmeWas);
369
+ safeWrite(t.dir, readmeAt, next, readmeWas);
370
+ } catch (e) {
371
+ if (!(e instanceof Changed)) throw e;
372
+ t.changed.push(readmeAt.join('/'));
373
+ }
374
+ }
375
+ const rec = nextRecord(t.record, items, t.version);
376
+ if (!rec) return items;
377
+ try {
378
+ safeWrite(t.dir, t.recordAt, JSON.stringify({ format: 'flightdeck-installed/1', version: t.version, at: now.toISOString(), by: 'cli', files: rec }, null, 2) + '\n', t.recordText);
379
+ t.recordWritten = true;
380
+ } catch (e) {
381
+ if (!(e instanceof Changed)) throw e;
382
+ t.changed.push(t.recordAt.join('/'));
383
+ }
301
384
  return items;
302
385
  }
386
+ /* The record's files after an update, or null when it would be written unchanged: the same as PF.contract.nextRecord. */
387
+ function nextRecord(record, items, version) {
388
+ const rec = Object.assign({}, record.all || {});
389
+ items.forEach((i) => { if (!i.changed) rec[i.key] = i.want; });
390
+ if (!record.ignored && record.version === version && JSON.stringify(rec) === JSON.stringify(record.all)) return null;
391
+ return rec;
392
+ }
303
393
  /* Every file install replaces, the app included, is first copied to a folder of its own under
304
394
  .flightdeck/updates/<version>/replaced/, made new for this run (never one an earlier run used), and the copy is read back.
305
395
  No checked copy, no replacement. */
@@ -330,8 +420,8 @@ function report(items, check, out) {
330
420
  }
331
421
  /* What install says after the file list: an ignored record, and files left alone because they changed while it ran. */
332
422
  function notes(t, out) {
333
- if (t.record && t.record.ignored) out.push('Ignored ' + t.recordAt.join('/') + ': it isn\'t a record this version of Flightdeck wrote, so it wasn\'t trusted. A new one was written.');
334
- if (t.changed && t.changed.length) out.push('Left as they are, because they changed while install was running: ' + t.changed.join(', ') + '. Run the command again.');
423
+ if (t.record && t.record.ignored) out.push('Ignored ' + t.recordAt.join('/') + ': it isn\'t a record this version of Flightdeck wrote, so it wasn\'t trusted.' + (t.recordWritten ? ' A new one was written.' : ''));
424
+ if (t.changed && t.changed.length) out.push('Left as they are, because they changed while updating: ' + t.changed.join(', ') + '. Those weren\'t replaced; check them again.');
335
425
  }
336
426
  function exitFor(statuses) {
337
427
  if (statuses.includes('merge')) return EXIT.merge;
@@ -1724,7 +1814,7 @@ function main(argv) {
1724
1814
  return { out: out.join('\n'), code };
1725
1815
  }
1726
1816
  /* Everything is checked before anything is written, so a refusal (a link, say) leaves the folder as it was. */
1727
- if (!appOnly) contract(t, files, { html, force, check: true });
1817
+ if (!appOnly) contract(t, files, { html, force, check: true, room: true, more: [[NAME, appS], [OLD, oldS]].filter(([file, st]) => st.status !== 'current' && (file !== OLD || keepOld)).map(([file]) => [file]) });
1728
1818
  t.changed = [];
1729
1819
  [[NAME, appS], [OLD, oldS]].forEach(([file, st]) => {
1730
1820
  if (st.status === 'current' || (file === OLD && !keepOld)) return;
@@ -1808,4 +1898,4 @@ if (require.main === module) {
1808
1898
  if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
1809
1899
  } catch (e) { failed(e); }
1810
1900
  }
1811
- module.exports = { _hooks, main, installedNewer, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, requestData, writeRequest, reportData, writeOnce, EXIT };
1901
+ module.exports = { _hooks, main, installedNewer, parseRecord, decide, mergeReadmeText, nextRecord, baselinesOf: baselines, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, requestData, writeRequest, reportData, writeOnce, EXIT };