@troylar/flightdeck 2.11.2 → 2.12.1

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,34 @@
2
2
 
3
3
  <!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
4
4
 
5
+ ## 2.12.1 · Nov 2, 2026
6
+
7
+ Get your AI assistant started, and let it read all its instructions.
8
+
9
+ ### New
10
+
11
+ - **Copy instructions for your assistant** (Settings). One button copies a first prompt for your assistant. It says to read AGENTS.md to the end, says where the skills are, and asks what you want done. Open the team folder itself as the workspace, in Agent mode, then paste it. *Where:* Settings › AI assistant files
12
+ - **Download skills ZIP** (Settings). Download the skills as a ZIP, made from what's already in the app file, with no network and no folder needed. It holds the same files flightdeck skills --to writes. What each skill is for, and what to ask for: docs/skills.md. *Where:* Settings › AI assistant files
13
+
14
+ ### Fixed
15
+
16
+ - **AGENTS.md is short enough to be read to the end** (AI assistants). AGENTS.md is under 28 KiB, the safety rules come first, and the long formats moved to .flightdeck/skills/_flightdeck/file-formats.md. Every rule stays. Update it with flightdeck install or Settings; Team notes stay as they are. Keep Team notes to about 4 KiB.
17
+ - **A folder set up by a newer version is left alone** (AI assistants). flightdeck install, Settings and Folder health leave a folder a newer version set up alone, in the same words. An old default an older app left waiting is named, with its path, never merged or counted; it's safe to delete by hand. *Where:* Settings › AI assistant files
18
+
19
+ ## 2.12.0 · Nov 1, 2026
20
+
21
+ AI assistant files, from the app.
22
+
23
+ ### New
24
+
25
+ - **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
26
+ - **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
27
+ - **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
28
+
29
+ ### Improved
30
+
31
+ - **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
32
+
5
33
  ## 2.11.2 · Oct 31, 2026
6
34
 
7
35
  Duplicated tabs and Conditions.
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.1/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 itself as the workspace (in Agent mode, for Copilot in VS Code) and start with **Copy instructions for your assistant** on that screen. Then say what you need, or ask for a skill by name. [Skills for your AI assistant](https://github.com/troylar/flightdeck/blob/main/docs/skills.md) lists each one by job, with what to ask for:
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. **Copy instructions for your assistant** copies the first prompt to paste, and **Download skills ZIP** gives you the skills as a file, with no folder and no network. 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
 
@@ -463,6 +477,7 @@ Flightdeck was called Preflight before 1.9.0. Team folders, runs and published v
463
477
  |---|---|---|
464
478
  | [Checklist format](https://github.com/troylar/flightdeck/blob/main/docs/map-format.md) | Anyone writing checklists, and their AI assistant | Every mark, field and step type, and what stops a checklist from publishing |
465
479
  | [Flightdeck and OneDrive](https://github.com/troylar/flightdeck/blob/main/docs/onedrive.md) | Whoever sets up the team folder | What keeps runs safe on a synced folder, and what it can't promise |
480
+ | [Skills for your AI assistant](https://github.com/troylar/flightdeck/blob/main/docs/skills.md) | Anyone working with an AI assistant | Starting one, what to ask for, each skill by job |
466
481
  | [Updating Flightdeck](https://github.com/troylar/flightdeck/blob/main/docs/updating.md) | Whoever keeps the team's copy current | A step-by-step update, mixed versions and rolling back |
467
482
  | [When something goes wrong](https://github.com/troylar/flightdeck/blob/main/docs/recovery.md) | Anyone who sees a warning | What each problem means and what to do, step by step |
468
483
  | [Evidence packs](https://github.com/troylar/flightdeck/blob/main/docs/evidence.md) | Anyone who needs proof of a run | What's in a pack and how to check it |
package/bin/flightdeck.js CHANGED
@@ -186,27 +186,48 @@ 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
+ /* An old copy an older app left (PF.contract.classifyCopies, the app's own rule, from the texts as read: a byte-order mark
194
+ kept): named, never merged, counted or removed. */
195
+ const K = contractRules();
196
+ items.forEach((i) => {
197
+ const all = i.staged, said = JSON.parse(JSON.stringify(K.classifyCopies({ key: i.key, baseKey: i.baseKey, shipped: i.text, version: t.version, base, active: i.now, copies: all.map((x) => ({ version: x.version, text: x.text })) })));
198
+ i.old = all.filter((x, n) => said[n] === 'old');
199
+ i.staged = all.filter((x, n) => said[n] !== 'old');
200
+ });
201
+ 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);
202
+ return items.map((i, n) => Object.assign(i, why[n], { key: i.key }));
203
+ }
204
+ /* The decision alone, from hashes: the same as PF.contract.classify (test/assistant-files.test.js checks they agree), plus
205
+ forced, which only --force gives. */
206
+ function decide(items, record, base, version, force) {
207
+ const rf = (record && record.files) || {};
208
+ return items.map((it) => {
209
+ const out = { key: it.key, status: '' }, st = it.staged || [];
210
+ if (it.have === null || it.have === undefined) { out.status = rf[it.key] ? 'missing' : 'new'; return out; }
211
+ if (st.length && !force) {
212
+ out.status = 'merge'; out.waiting = st.map((x) => x.version).join(', ');
213
+ out.stage = it.have !== it.want && !st.some((x) => x.version === version || x.hash === it.want);
214
+ return out;
215
+ }
216
+ if (it.have === it.want) out.status = 'current';
217
+ else if ((base[it.baseKey || baselineKey(it.key)] || {})[it.have]) out.status = 'update';
218
+ else if (force) out.status = 'forced';
219
+ else if (rf[it.key] === it.want) out.status = 'kept';
220
+ else out.status = 'merge';
221
+ return out;
203
222
  });
204
223
  }
205
224
  /* The versions under the updates folder, and which of them hold a copy of rel still waiting to be merged. */
225
+ /* Oldest first, dev last: the order install and the app both list waiting versions in. */
226
+ function byVersion(a, b) { return a === b ? 0 : a === 'dev' ? 1 : b === 'dev' ? -1 : cmp(a, b); }
206
227
  function versionsStaged(t) {
207
228
  if (t.versions) return t.versions;
208
229
  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)) : [];
230
+ 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
231
  return t.versions;
211
232
  }
212
233
  function staged(t, rel) {
@@ -221,46 +242,41 @@ function merged(item) {
221
242
  }
222
243
  /* A record written by a newer Flightdeck (flightdeck-installed/2 on, or a must-understand feature): never read for meaning and
223
244
  never rewritten. The same verdict as PF.contract.cleanInstalled (test/cli.test.js checks they agree). */
224
- function installedNewer(r) {
225
- if (!r || typeof r !== 'object' || Array.isArray(r)) return '';
226
- const m = typeof r.format === 'string' ? /^([a-z][a-z0-9-]{1,59})\/([1-9]\d{0,3})$/.exec(r.format) : null;
227
- if ((m && m[1] === 'flightdeck-installed' && m[2] !== '1') || (m && m[1] === 'flightdeck-installed' && (Array.isArray(r.features) ? r.features.length > 0 : r.features != null))) return 'was written by a newer Flightdeck' + (typeof r.version === 'string' && /^\d+\.\d+\.\d+$/.test(r.version) ? ' (' + r.version + ')' : '') + '.';
228
- return '';
229
- }
245
+ function installedNewer(r, mine) { return contractRules().recordNewer(r, mine); }
246
+ /* The app's own contract rules (PF.contract), from this package's app file, so a verdict the app, Folder health and this
247
+ command all reach is one function, never a copy. */
248
+ let engineRules = null;
249
+ function contractRules() { return (engineRules = engineRules || loadEngine().PF.contract); }
230
250
  /* 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
251
  ignored (and said so), and only entries for files this version installs, with a well-formed hash, are read. */
232
252
  function readRecord(t, keys) {
233
- const text = readInside(t.dir, t.recordAt), none = { files: {} };
234
- if (text === null) return none;
235
- let r = null;
236
- try { r = JSON.parse(text); } catch (e) { /* reported below */ }
237
- const newer = installedNewer(r);
238
- if (newer) fail(path.join(t.dir, ...t.recordAt) + ' ' + newer + ' Nothing was changed. Use the newer Flightdeck package.');
239
- 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
- && typeof r.at === 'string' && (r.by === 'cli' || r.by === 'app') && r.files && typeof r.files === 'object' && !Array.isArray(r.files)
241
- && Object.keys(r.files).every((k) => typeof r.files[k] === 'string' && /^[0-9a-f]{64}$/.test(r.files[k]));
242
- if (!ok) return Object.assign(none, { ignored: true });
243
- /* files: what this command may consult; all: every entry, kept as it is for files this command doesn't handle. */
244
- const files = {};
245
- keys.forEach((k) => { if (Object.prototype.hasOwnProperty.call(r.files, k)) files[k] = r.files[k]; });
246
- return { version: r.version, files, all: Object.assign({}, r.files) };
253
+ t.recordText = readInside(t.dir, t.recordAt);
254
+ const r = parseRecord(t.recordText, keys, t.version);
255
+ if (r.newer) fail(contractRules().outcomeWords({ state: 'newer', newer: r.newer }, { home: t.home, updates: t.updates, recordAt: t.recordAt.join('/'), version: t.version }).text);
256
+ return r;
247
257
  }
258
+ /* The record's text as install reads it: the same as PF.contract.readRecord (test/assistant-files.test.js checks they agree). */
259
+ /* The record's text as install reads it: the app's own PF.contract.readRecord, so the app and this command parse it the same way
260
+ (copied out of the engine's context, so it compares as a plain object here). */
261
+ function parseRecord(text, keys, mine) { return JSON.parse(JSON.stringify(contractRules().readRecord(text === undefined ? null : text, keys, mine))); }
248
262
  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
263
  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
264
 
251
- function mergeReadme(version, items, t) {
252
- const here = t.updates.concat(version).join('/');
265
+ function mergeReadme(version, items, t) { return mergeReadmeText(version, items, t.updates, t.home); }
266
+ /* The same text as PF.contract.mergeReadme (test/assistant-files.test.js checks they agree). */
267
+ function mergeReadmeText(version, items, updates, home) {
268
+ const here = updates.concat(version).join('/');
253
269
  return `# Flightdeck ${version}: files to merge
254
270
 
255
271
  Flightdeck ${version} changed these files, and your team changed them too, so the update left your versions in place:
256
272
 
257
273
  ${items.map((i) => '- `' + i.rel + '`' + (i.key === AGENTS ? ' (only the part between the flightdeck markers)' : '')).join('\n')}
258
274
 
259
- The new default of each file is in this folder (\`${here}/\`), at the same path it has in ${t.home}.
275
+ The new default of each file is in this folder (\`${here}/\`), at the same path it has in ${home}.
260
276
 
261
277
  ## Ask your AI assistant to merge them
262
278
 
263
- Open your AI assistant in ${t.home}, and give it this:
279
+ Open your AI assistant in ${home}, and give it this:
264
280
 
265
281
  > 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
282
 
@@ -268,14 +284,61 @@ Then run the same flightdeck command with \`--check\`: exit code 0 means nothing
268
284
  `;
269
285
  }
270
286
 
287
+ /* What an install would write has to be something the app can read back (PF.contract.MAX_BYTES): over the limit, nothing is
288
+ touched. The same check and words as Settings, AI assistant files (afRoom). */
289
+ const CONTRACT_MAX = 8 * 1048576;
290
+ function roomFor(t, items) {
291
+ 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;
292
+ for (const i of items) {
293
+ if (['current', 'kept', 'merge'].includes(i.status)) continue;
294
+ const n = Buffer.byteLength(merged(i), 'utf8');
295
+ if (n > CONTRACT_MAX) fail(why(i.rel, n, 'Shorten what your team added to it, then try again.'));
296
+ }
297
+ const rec = nextRecord(t.record, items, t.version);
298
+ if (!rec) return;
299
+ 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');
300
+ if (size > CONTRACT_MAX) fail(why(t.recordAt.join('/'), size, 'Remove the entries for files Flightdeck no longer ships, then try again.'));
301
+ }
302
+ /* Something in the way of where a file goes: the same words as PF.contract.outcomeWords. Every destination is looked at before
303
+ anything is written, so a predictable refusal never leaves an update half done. */
304
+ 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.'; }
305
+ function clearFor(dir, parts) {
306
+ let cur = dir;
307
+ for (let i = 0; i < parts.length; i++) {
308
+ cur = path.join(cur, parts[i]);
309
+ const st = lstatOrNull(cur), rel = parts.slice(0, i + 1).join('/');
310
+ if (!st) return;
311
+ if (st.isSymbolicLink()) fail('refusing to write through a link: ' + cur);
312
+ if (i === parts.length - 1 ? !st.isFile() : !st.isDirectory()) fail(blockedWhy(rel, i === parts.length - 1 ? 'file' : 'folder'));
313
+ }
314
+ }
315
+ function destinationsClear(t, items, more) {
316
+ const dests = (more || []).slice();
317
+ for (const i of items) {
318
+ if (['current', 'kept'].includes(i.status)) continue;
319
+ if (i.status === 'merge') {
320
+ if (i.waiting && !i.stage) continue;
321
+ dests.push(t.updates.concat(t.version, i.rel.split('/')), t.updates.concat(t.version, 'README.md'), t.updates.concat(t.version, 'replaced', 'x'));
322
+ continue;
323
+ }
324
+ dests.push(i.parts);
325
+ if (i.now !== null && i.now !== merged(i)) dests.push(t.updates.concat(t.version, 'replaced', 'x'));
326
+ }
327
+ if (nextRecord(t.record, items, t.version)) dests.push(t.recordAt);
328
+ dests.forEach((parts) => clearFor(t.dir, parts));
329
+ }
271
330
  /* install and skills: one routine for the team folder, and for skills --to <dir>. */
272
331
  function contract(t, files, opts) {
273
332
  const base = baselines(opts.html), now = new Date();
333
+ if (!opts.check || opts.room) clearFor(t.dir, t.updates.concat(t.version, 'x'));
274
334
  inside(t.dir, t.updates.concat(t.version, 'replaced'));
275
335
  t.record = readRecord(t, files.map((f) => f.key));
276
336
  t.changed = [];
277
337
  const items = classify(t, files, base, opts.force);
338
+ if (!opts.check || opts.room) { roomFor(t, items); destinationsClear(t, items, opts.more); }
278
339
  if (opts.check) return items;
340
+ /* The merge list as it was before anything is written: the only text it may replace. */
341
+ 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
342
  for (const i of items) {
280
343
  if (['current', 'kept'].includes(i.status) || (i.status === 'merge' && i.waiting && !i.stage)) {
281
344
  if (opts.force) i.staged.forEach((x) => fileAway(t, x));
@@ -293,13 +356,34 @@ function contract(t, files, opts) {
293
356
  }
294
357
  }
295
358
  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');
359
+ if (merges.some((i) => !i.waiting || i.stage)) {
360
+ const next = mergeReadme(t.version, merges.filter((i) => !i.waiting || i.stage || i.staged.some((x) => x.version === t.version)), t);
361
+ try {
362
+ if (readmeWas !== null && readmeWas !== next) keepCopy(t, ['merge-README.md'], readmeWas);
363
+ safeWrite(t.dir, readmeAt, next, readmeWas);
364
+ } catch (e) {
365
+ if (!(e instanceof Changed)) throw e;
366
+ t.changed.push(readmeAt.join('/'));
367
+ }
368
+ }
369
+ const rec = nextRecord(t.record, items, t.version);
370
+ if (!rec) return items;
371
+ try {
372
+ 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);
373
+ t.recordWritten = true;
374
+ } catch (e) {
375
+ if (!(e instanceof Changed)) throw e;
376
+ t.changed.push(t.recordAt.join('/'));
377
+ }
301
378
  return items;
302
379
  }
380
+ /* The record's files after an update, or null when it would be written unchanged: the same as PF.contract.nextRecord. */
381
+ function nextRecord(record, items, version) {
382
+ const rec = Object.assign({}, record.all || {});
383
+ items.forEach((i) => { if (!i.changed) rec[i.key] = i.want; });
384
+ if (!record.ignored && record.version === version && JSON.stringify(rec) === JSON.stringify(record.all)) return null;
385
+ return rec;
386
+ }
303
387
  /* Every file install replaces, the app included, is first copied to a folder of its own under
304
388
  .flightdeck/updates/<version>/replaced/, made new for this run (never one an earlier run used), and the copy is read back.
305
389
  No checked copy, no replacement. */
@@ -327,11 +411,12 @@ const LABEL = { current: 'up to date', update: 'updated', new: 'added', missing:
327
411
  const CHECK_LABEL = { update: 'update available', new: 'will be added', missing: 'will be added again', merge: 'needs merge', forced: 'will be replaced (--force)', kept: 'kept (your team changed it)' };
328
412
  function report(items, check, out) {
329
413
  items.filter((i) => i.status !== 'current').forEach((i) => out.push(' ' + (i.changed ? 'changed during install' : (check ? CHECK_LABEL : LABEL)[i.status]).padEnd(28) + i.rel + (i.waiting ? ' (still waiting in updates/' + i.waiting + ')' : '')));
414
+ items.filter((i) => i.old && i.old.length).forEach((i) => out.push(' ' + 'old copy, nothing to merge'.padEnd(28) + i.rel + ' (left in updates/' + i.old.map((x) => x.version).join(', ') + ' by an older Flightdeck; safe to delete by hand)'));
330
415
  }
331
416
  /* What install says after the file list: an ignored record, and files left alone because they changed while it ran. */
332
417
  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.');
418
+ 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.' : ''));
419
+ 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
420
  }
336
421
  function exitFor(statuses) {
337
422
  if (statuses.includes('merge')) return EXIT.merge;
@@ -1724,7 +1809,7 @@ function main(argv) {
1724
1809
  return { out: out.join('\n'), code };
1725
1810
  }
1726
1811
  /* 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 });
1812
+ 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
1813
  t.changed = [];
1729
1814
  [[NAME, appS], [OLD, oldS]].forEach(([file, st]) => {
1730
1815
  if (st.status === 'current' || (file === OLD && !keepOld)) return;
@@ -1808,4 +1893,4 @@ if (require.main === module) {
1808
1893
  if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
1809
1894
  } catch (e) { failed(e); }
1810
1895
  }
1811
- module.exports = { _hooks, main, installedNewer, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, requestData, writeRequest, reportData, writeOnce, EXIT };
1896
+ 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 };