@troylar/flightdeck 1.18.0 → 1.19.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,24 @@
2
2
 
3
3
  <!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
4
4
 
5
+ ## 1.19.0 · Oct 19, 2026
6
+
7
+ Edit a checklist with your AI assistant at the same time.
8
+
9
+ ### New
10
+
11
+ - **See your AI assistant's changes as you edit** (Checklist editor). When your AI assistant changes a draft you have open with nothing unsaved, its change appears in the editor within a few seconds, marked "Changed by your AI assistant", and is saved. One Undo takes it back. Otherwise it waits for you on the process page. From another computer, add OneDrive's time. With the draft open on two computers, use Decline there rather than Undo. *Where:* Edit on a process page
12
+ - **Proposed changes wait for you** (Processes). When a draft is open in an editor, or changed since your AI assistant read it, its changes arrive as a proposal. Review it, then Apply or Decline it on the process page. Apply works only when the proposal starts from the draft as it is now; anything else waits for you to look at it. *Where:* A process page
13
+ - **See who's editing a draft** (Checklist editor). A draft open in someone's editor shows who and on which computer, as far as this computer can see right now. You can still edit it, and each save is checked against the draft you opened.
14
+ - **flightdeck draft read, save and propose** (Command line). AI assistants read and save drafts with the flightdeck command. When someone has the draft open, it sends a proposal instead of writing over their work.
15
+ - **Drafts up to 1 MB** (Checklist editor). Drafts are saved up to 1 MB. A bigger one isn't saved: Flightdeck says how much to cut and keeps your text. From 0.9 MB the editor shows the size.
16
+
17
+ ### Improved
18
+
19
+ - **Add screenshots to a step in the builder** (Checklist editor). Select a step and add screenshots in its form: choose files, drop them there, or paste one. Each needs a caption, which is also what screen readers say. Edit captions, move screenshots up or down, or remove one from the draft; the image file stays in the folder. Undo takes back any of it, and nothing is saved until you save the draft. *Where:* Edit on a process page, then select a step
20
+ - **Undo a newer draft that arrived** (Checklist editor). A newer draft arrives as one change you can undo, and the changes before it can still be undone.
21
+ - **Saves check the exact text** (Checklist editor). Saving a draft checks that the file is still exactly the text you started from, right before writing, not only a short fingerprint of it.
22
+
5
23
  ## 1.18.0 · Oct 19, 2026
6
24
 
7
25
  Other apps can read checklists, runs, what's waiting and follow-ups as JSON.
package/bin/flightdeck.js CHANGED
@@ -124,11 +124,14 @@ function safeWrite(dir, parts, text, expect) {
124
124
  const now = lstatOrNull(tmp);
125
125
  if (!now || now.isSymbolicLink() || now.ino !== mine.ino || now.dev !== mine.dev) fail('refusing to write through a link: ' + home);
126
126
  if (!fs.readFileSync(tmp).equals(data)) throw new Error('the written file didn\'t read back the same');
127
+ const hooks = safeWrite.hooks || {};
128
+ if (hooks.beforeCompare) hooks.beforeCompare(target);
127
129
  if (expect !== undefined) {
128
130
  const st = lstatOrNull(target);
129
131
  if (st && (st.isSymbolicLink() || !st.isFile())) fail('refusing to write through a link: ' + target);
130
132
  if ((st ? fs.readFileSync(target, 'utf8') : null) !== expect) throw new Changed(parts.join('/'));
131
133
  }
134
+ if (hooks.afterCompare) hooks.afterCompare(target);
132
135
  fs.renameSync(tmp, target);
133
136
  moved = true;
134
137
  } catch (e) {
@@ -851,6 +854,200 @@ async function readMain(argv) {
851
854
  }
852
855
  function failureJson(eng, kind, why) { return eng ? eng.PF.appjson.failure(eng.version, kind, why, []) : { contract: 'flightdeck-read/1', app: versionOf(app()) || '', error: { kind, why }, problems: [] }; }
853
856
 
857
+ /* ---------- Writing drafts for AI assistants: flightdeck draft read | save | propose (contract flightdeck-draft/1) ----------
858
+ These commands change the team folder, and only ever write maps/drafts/<process>.md (a direct save), one new
859
+ proposed/<process>/<operation>.json and one new history/drafts/<process>/agent-<operation>.jsonl, all through folders()
860
+ (never through a link, never outside the folder). A draft open in an editor this computer can see is never written: the
861
+ change becomes a proposal a person applies in Flightdeck. docs/apps.md, docs/design.md section 27. */
862
+ const DRAFT_CONTRACT = 'flightdeck-draft/1';
863
+ /* Test hooks around the final compare of a direct save (test/cli.test.js injects a write there). */
864
+ const _hooks = {};
865
+ function draftJson(eng, o) { return Object.assign({ contract: DRAFT_CONTRACT, app: eng ? eng.version : versionOf(app()) || '' }, o); }
866
+ function draftFail(eng, kind, why, code, problems) { return { out: JSON.stringify(draftJson(eng, { error: { kind, why }, problems: problems || [] }), null, 2), code: code || EXIT.error }; }
867
+ /* A text an assistant hands over: at most the draft limit in UTF-8 bytes (the app's own constant), decoded strictly. */
868
+ function inputText(PF, file, what) {
869
+ let buf;
870
+ try { buf = file === '-' ? fs.readFileSync(0) : fs.readFileSync(expand(file)); } catch (e) { return { why: 'couldn\'t read ' + what + ' (' + e.message + ').' }; }
871
+ if (buf.length > PF.util.DRAFT_BYTES) return { why: what + ' is ' + buf.length + ' bytes; a draft is at most ' + PF.util.DRAFT_BYTES + ' bytes (1 MB) of UTF-8.' };
872
+ try { return { text: new TextDecoder('utf-8', { fatal: true }).decode(buf) }; } catch (e) { return { why: what + ' isn\'t valid UTF-8 text.' }; }
873
+ }
874
+ /* Who has the draft open, as this computer's copy of the folder shows now. A record that can't be read, or a presence/ that
875
+ can't be listed, counts as open: the safe outcome is a proposal. */
876
+ function presenceNow(PF, dir, proc, now) {
877
+ const PP = PF.proposals, at = path.join(dir, PP.PRESENCE), out = { open: false, openIn: [] };
878
+ let st;
879
+ try { st = fs.lstatSync(at); } catch (e) { if (e.code === 'ENOENT') return out; out.open = true; return out; }
880
+ if (st.isSymbolicLink() || !st.isDirectory()) { out.open = true; return out; }
881
+ let names;
882
+ try { names = fs.readdirSync(at, { withFileTypes: true }); } catch (e) { out.open = true; return out; }
883
+ names.forEach((e) => {
884
+ if (!/\.json$/i.test(e.name)) return;
885
+ if (!e.isFile()) { out.open = true; return; }
886
+ let rec = null;
887
+ try { const t = fs.readFileSync(path.join(at, e.name)); if (t.length <= 65536) rec = PP.cleanPresence(JSON.parse(t.toString('utf8')), e.name); } catch (err) { rec = null; }
888
+ if (!rec) { out.open = true; return; }
889
+ if (rec.drafts.indexOf(proc) < 0 || !PP.freshCli(rec, now)) return;
890
+ out.open = true;
891
+ out.openIn.push({ by: rec.by, device: rec.device, seenAgo: Math.max(0, now - Date.parse(rec.at)) });
892
+ });
893
+ return out;
894
+ }
895
+ /* Everything a draft command looks at, read-only: the draft (and other drafts that say the same process), this process's
896
+ proposals with their records (this session's read whole), its published versions, and people.json. */
897
+ async function draftState(eng, dir, proc, session) {
898
+ const PF = eng.PF, PP = PF.proposals, ra = readAdapter(dir, { maxBytes: READ_MAX }), L = PF.repo.ledger(ra), rfs = L.fs, sha = PP.sha;
899
+ const dpath = 'maps/drafts/' + proc + '.md', draft = { exists: false, path: dpath, rev: null, text: null };
900
+ try {
901
+ const f = await rfs.read(dpath, { maxBytes: PF.util.DRAFT_BYTES });
902
+ if (f) Object.assign(draft, { exists: true, rev: sha(f.text), text: f.text });
903
+ } catch (e) { draft.exists = true; }
904
+ const copies = [];
905
+ try {
906
+ for (const i of await rfs.list('maps/drafts')) {
907
+ if (i.kind !== 'file' || !/\.md$/i.test(i.name) || i.name === proc + '.md') continue;
908
+ let said = '';
909
+ try { const f = await rfs.read('maps/drafts/' + i.name, { maxBytes: PF.util.DRAFT_BYTES }); said = f ? PP.textProcess(f.text) : ''; } catch (e) { said = ''; }
910
+ if (said === proc) copies.push(i.name);
911
+ }
912
+ } catch (e) { /* named in the ledger */ }
913
+ const got = await PF.repo.loadProposed(rfs, {}, true), pr = got.procs[proc] || { all: [], bad: [], records: [] };
914
+ pr.bad.forEach((b) => L.note(b.path, 'malformed', b.why));
915
+ const recs = {}; pr.all.forEach((m) => { recs[m.id] = []; });
916
+ pr.records.forEach((r) => { const m = /^(p-[a-z0-9]{1,12}-[a-z0-9]{10})\./.exec(r.name); if (m && recs[m[1]]) recs[m[1]].push(r); });
917
+ const judged = PP.judge(pr.all, recs, { exists: draft.exists && draft.text != null, rev: draft.rev });
918
+ const mine = [];
919
+ for (const x of judged.filter((y) => y.meta.session === session)) {
920
+ const full = await PF.repo.readProposal(rfs, proc, x.id);
921
+ if (!full.ok) continue;
922
+ const status = x.fit === 'replaced' || x.fit === 'invalid' ? x.fit : x.rec.state;
923
+ mine.push({ id: x.id, rev: x.meta.rev, status, text: full.proposal.text, at: x.meta.at, base: full.proposal.base, pending: status === 'pending' });
924
+ }
925
+ const versions = [];
926
+ try {
927
+ for (const v of await rfs.list('publications/' + proc)) {
928
+ if (v.kind !== 'dir' || !PF.publish.versionOf(v.name)) continue;
929
+ const f = await rfs.read('publications/' + proc + '/' + v.name + '/map.md');
930
+ if (f) versions.push({ folder: v.name, version: PF.publish.versionOf(v.name), rev: sha(f.text), text: f.text });
931
+ }
932
+ } catch (e) { /* named in the ledger */ }
933
+ versions.sort((a, b) => b.version - a.version);
934
+ let people = null;
935
+ try { const f = await rfs.read('people.json'); people = f ? PF.people.clean(JSON.parse(f.text)) : null; } catch (e) { people = null; }
936
+ return { draft, copies, mine, versions, people, judged, problems: L.problems(), presence: presenceNow(PF, dir, proc, Date.now()) };
937
+ }
938
+ /* Write a new file once: created exclusively (never over a file that's there, never through a link), flushed, read back. */
939
+ function writeNew(dir, parts, text) {
940
+ const home = folders(dir, parts.slice(0, -1), true), target = path.join(home, parts[parts.length - 1]), data = Buffer.from(text, 'utf8');
941
+ let fd = null;
942
+ try {
943
+ fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0));
944
+ for (let off = 0; off < data.length;) { const n = fs.writeSync(fd, data, off, data.length - off); if (!(n > 0)) throw new Error('the disk accepted no more bytes'); off += n; }
945
+ fs.fsyncSync(fd); fs.closeSync(fd); fd = null;
946
+ } catch (e) {
947
+ if (fd !== null) try { fs.closeSync(fd); } catch (err) { /* already failing */ }
948
+ if (e instanceof Refused) throw e;
949
+ fail('couldn\'t write ' + target + ': ' + e.message);
950
+ }
951
+ folders(dir, parts.slice(0, -1), false);
952
+ if (!fs.readFileSync(target).equals(data)) fail(target + ' didn\'t read back as written');
953
+ return parts.join('/');
954
+ }
955
+ async function draftMain(argv) {
956
+ const VAL = ['--session', '--expect', '--from', '--base', '--label', '--by', '--note'], val = {};
957
+ const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
958
+ const sub = args[1], usage = (why) => draftFail(null, 'usage', why);
959
+ for (const f of VAL) {
960
+ const i = argv.indexOf(f);
961
+ if (i < 0) continue;
962
+ if (argv[i + 1] == null || (argv[i + 1].startsWith('--') && argv[i + 1] !== '-')) return usage('say what goes after ' + f + '.');
963
+ val[f] = argv[i + 1];
964
+ }
965
+ if (['read', 'save', 'propose'].indexOf(sub) < 0) return usage('say read, save or propose, for example: flightdeck draft read <team folder> <process> --json');
966
+ if (!argv.includes('--json')) return usage('flightdeck draft prints JSON. Add --json.');
967
+ const proc = args[3];
968
+ if (!proc || !PROC_ID.test(proc)) return usage('a process ID is lowercase letters, numbers and dashes, for example: flightdeck draft ' + sub + ' <team folder> release --json');
969
+ if (val['--session'] != null && !/^[a-z0-9][a-z0-9-]{0,59}$/.test(val['--session'])) return usage('--session is lowercase letters, numbers and dashes, like the one draft read gave.');
970
+ let eng, dir;
971
+ try { eng = loadEngine(); dir = folderArg(args[2], 'draft ' + sub); } catch (e) { return draftFail(eng, 'failed', e.message); }
972
+ const PF = eng.PF, PP = PF.proposals, U = PF.util, M = PF.map, now = Date.now();
973
+ const session = val['--session'] || 's-' + U.randomId(12);
974
+ const checksOf = (t) => { const c = M.check(M.parse(t), { people: [], types: {}, workflows: {}, processes: [] }); return { blockers: c.blockers, suggestions: c.suggestions }; };
975
+ try {
976
+ if (sub === 'read') {
977
+ const s = await draftState(eng, dir, proc, session);
978
+ if (!s.draft.exists && !s.versions.length && !s.judged.length) return draftFail(eng, 'not-found', 'There\'s no draft, published version or proposal for "' + proc + '" in this folder.', EXIT.notFound, s.problems);
979
+ const pending = s.mine.filter((m) => m.pending).sort((a, b) => (a.at < b.at ? 1 : -1))[0];
980
+ const latest = argv.includes('--or-latest') && !s.draft.exists ? s.versions[0] : null;
981
+ const cf = pending ? { rev: pending.rev, kind: 'proposal', ref: pending.id, text: pending.text }
982
+ : s.draft.exists && s.draft.text != null ? { rev: s.draft.rev, kind: 'draft', ref: s.draft.path, text: s.draft.text }
983
+ : latest ? { rev: latest.rev, kind: 'version', ref: latest.folder, text: latest.text } : { rev: PP.sha(''), kind: 'none', ref: null, text: '' };
984
+ const out = draftJson(eng, { process: proc, session, draft: s.draft, openIn: s.presence.openIn, mine: s.mine.map((m) => ({ id: m.id, rev: m.rev, status: m.status, text: m.text })),
985
+ continueFrom: cf, checks: checksOf(cf.text), copies: s.copies, readAt: new Date(now).toISOString(), complete: s.problems.length === 0, problems: s.problems });
986
+ return { out: JSON.stringify(out, null, 2), code: out.complete ? 0 : EXIT.partial };
987
+ }
988
+ /* save and propose: the input and every flag are checked before anything is read or written. */
989
+ if (!val['--expect'] || !(val['--expect'] === 'none' || PP.SHA.test(val['--expect']))) return usage('--expect is the rev you read (a SHA-256 from draft read), or none for a new draft.');
990
+ if (!val['--from']) return usage('say where the new text is: --from <file>, or --from - to read it from standard input.');
991
+ if (val['--label'] != null && !/^[A-Za-z0-9-]{1,40}$/.test(val['--label'])) return usage('--label is letters, numbers and dashes, up to 40.');
992
+ if (val['--note'] != null && val['--note'].length > 200) return usage('--note is at most 200 characters.');
993
+ const input = inputText(PF, val['--from'], 'the new text');
994
+ if (input.why) return usage(input.why);
995
+ const text = input.text;
996
+ if (PP.textProcess(text) !== proc) return usage('the new text\'s process: line must say ' + proc + '.');
997
+ let baseText = null;
998
+ if (val['--base'] != null) { const b = inputText(PF, val['--base'], 'the --base text'); if (b.why) return usage(b.why); baseText = b.text; }
999
+ const s = await draftState(eng, dir, proc, session);
1000
+ if (val['--by'] != null && !(s.people || []).some((p) => p.id === val['--by'])) return usage('--by names a person in people.json, like person-avery.');
1001
+ const base = PP.baseFor({ expect: val['--expect'], draft: s.draft, mine: s.mine, versions: s.versions, baseText, afterEarlier: argv.includes('--after-earlier') });
1002
+ if (base.missing) return draftFail(eng, 'missing-base', 'The text you started from isn\'t the draft, one of this session\'s proposals or a published version any more. Pass the text you read with --base, or read the draft again.');
1003
+ const operation = PP.newId(now, U.randomId(10)), at = new Date(now).toISOString();
1004
+ const result = { process: proc, session, operation, outcome: null, why: null, link: base.link, rev: PP.sha(text), file: null, ids: PP.ids(base.text, text), checks: checksOf(text), warnings: [], history: null };
1005
+ if (result.ids.removed.length) result.warnings.push('This removes step or phase IDs: ' + result.ids.removed.join(', ') + '. Runs and skills that name them will need a look.');
1006
+ let why = sub === 'propose' ? 'asked' : s.presence.open ? 'open' : null;
1007
+ const draftNow = s.draft.exists ? s.draft.text : null, expectText = base.kind === 'none' || base.kind === 'version' ? null : base.text;
1008
+ if (!why && draftNow !== expectText) why = 'changed';
1009
+ if (!why) {
1010
+ try {
1011
+ if (_hooks.beforeCompare || _hooks.afterCompare) safeWrite.hooks = _hooks;
1012
+ safeWrite(dir, ['maps', 'drafts', proc + '.md'], text, expectText);
1013
+ Object.assign(result, { outcome: 'written', file: 'maps/drafts/' + proc + '.md' });
1014
+ } catch (e) { if (!(e instanceof Changed)) throw e; why = 'changed'; }
1015
+ finally { delete safeWrite.hooks; }
1016
+ if (result.outcome === 'written') {
1017
+ try {
1018
+ const hdir = 'history/drafts/' + proc, before = expectText == null ? null : U.hash(expectText);
1019
+ let seen = null;
1020
+ if (before) { const h = await PF.repo.loadEditHistory(readAdapter(dir, { maxBytes: READ_MAX }), hdir, before); const e = h.items.filter((x) => x.entry && x.entry.to === before)[0]; seen = e ? e.entry.id : null; }
1021
+ const line = PF.timeline.histLine({ kind: 'draft-edit', id: operation, process: proc, writer: operation, seq: 1, by: val['--by'] || '', at, from: before, to: U.hash(text), seen,
1022
+ before: expectText == null ? '' : expectText, after: text, source: 'agent', units: [val['--note'] || 'Changed by your AI assistant'], items: [] });
1023
+ writeNew(dir, ['history', 'drafts', proc, 'agent-' + operation + '.jsonl'], JSON.stringify(line) + '\n');
1024
+ result.history = { written: true };
1025
+ } catch (e) { result.history = { written: false, why: e.message }; }
1026
+ /* It continued from this session's own pending proposal: that one is replaced by what was just written, so a
1027
+ decision says so (written once, named for this operation) and nothing offers it again. */
1028
+ if (base.replaces) {
1029
+ try {
1030
+ const set = await PF.repo.loadProposalSet(readAdapter(dir, { maxBytes: READ_MAX }), proc), st = PP.recState(set.records[base.replaces] || []);
1031
+ const old = s.mine.filter((m) => m.id === base.replaces)[0];
1032
+ const d = PP.decision({ proposal: base.replaces, proposalRev: old ? old.rev : null, writer: operation, decisionRev: st.nextRev, seen: st.seen, status: 'replaced', settles: st.open, result: { rev: PP.sha(text) }, by: val['--by'] || '', at });
1033
+ writeNew(dir, [PP.DIR, proc, d.name], JSON.stringify(d.rec, null, 2) + '\n');
1034
+ } catch (e) { result.warnings.push('Your earlier proposal ' + base.replaces + ' couldn\'t be marked as replaced (' + e.message + '). Decline it on the process page.'); }
1035
+ }
1036
+ return { out: JSON.stringify(draftJson(eng, result), null, 2), code: 0 };
1037
+ }
1038
+ }
1039
+ /* A proposal: write-once, holding its own base text, its SHA-256s and its link, measured before it's written. */
1040
+ const rec = PP.make({ id: operation, process: proc, session, label: val['--label'] || '', by: val['--by'] || '', at, app: eng.version || '', base: { kind: base.kind, ref: base.ref, text: base.text }, text, replaces: base.replaces, after: base.after, note: val['--note'] || '' });
1041
+ const json = JSON.stringify(rec, null, 2) + '\n';
1042
+ if (Buffer.byteLength(json) > PP.FILE_MAX) return draftFail(eng, 'too-big', 'This proposal would be ' + size(Buffer.byteLength(json)) + ' as a file, over the 4 MB a proposal can be. Nothing was written.');
1043
+ result.file = writeNew(dir, [PP.DIR, proc, operation + '.json'], json);
1044
+ Object.assign(result, { outcome: 'proposed', why });
1045
+ return { out: JSON.stringify(draftJson(eng, result), null, 2), code: 0 };
1046
+ } catch (e) {
1047
+ return draftFail(eng, 'failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be changed: ' + (e && e.message || String(e)));
1048
+ }
1049
+ }
1050
+
854
1051
  const HELP = `flightdeck: team process checklists, in one HTML file
855
1052
 
856
1053
  flightdeck open open the app (it starts on built-in examples)
@@ -877,6 +1074,15 @@ const HELP = `flightdeck: team process checklists, in one HTML file
877
1074
  what's waiting in a run and on whom (with --person, that person's next step)
878
1075
  flightdeck followups <team folder> [--process <id>] [--status open|all] --json
879
1076
  every follow-up and what became of it
1077
+ flightdeck draft read <team folder> <process> [--session <id>] [--or-latest] --json
1078
+ for AI assistants: the draft's exact text and rev, who has it open,
1079
+ this session's proposals and the text to continue from
1080
+ flightdeck draft save <team folder> <process> --expect <rev|none> --from <file|-> [--base <file>]
1081
+ [--session <id>] [--label <name>] [--by <person id>] [--note "<text>"] [--after-earlier] --json
1082
+ write the draft, only when nobody has it open and it's exactly what you
1083
+ read; otherwise write a proposal a person applies in Flightdeck
1084
+ flightdeck draft propose <same as save>
1085
+ always write a proposal
880
1086
  flightdeck path print where the app file is
881
1087
  flightdeck version print this version
882
1088
 
@@ -890,6 +1096,9 @@ The JSON commands only read: they never change a file in the team folder. Exit c
890
1096
  is printed, and "problems" names every file or folder that couldn't be read); 1: no data; 5: no such process, version or
891
1097
  run. People-directory email fields are omitted. See docs/apps.md in the Flightdeck repository.
892
1098
 
1099
+ flightdeck draft changes the team folder (contract flightdeck-draft/1). Exit code 0: written or proposed (read: complete);
1100
+ 4: read, partial; 1: usage, missing-base, too-big or failed; 5: read, no such process.
1101
+
893
1102
  export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
894
1103
  separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
895
1104
 
@@ -903,6 +1112,7 @@ function main(argv) {
903
1112
  /* Reading for other apps has flags of its own (--version takes a number there), so it's parsed apart. */
904
1113
  const first = argv.filter((a) => !a.startsWith('--'))[0];
905
1114
  if (READS.indexOf(first) >= 0 && !argv.includes('--help')) return readMain(argv);
1115
+ if (first === 'draft' && !argv.includes('--help')) return draftMain(argv);
906
1116
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
907
1117
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
908
1118
  /* Flags that take a value: the value is never read as a command or a folder. */
@@ -1020,4 +1230,4 @@ if (require.main === module) {
1020
1230
  if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
1021
1231
  } catch (e) { failed(e); }
1022
1232
  }
1023
- module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, EXIT };
1233
+ module.exports = { _hooks, main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, EXIT };