@troylar/flightdeck 1.19.0 → 1.20.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,19 @@
2
2
 
3
3
  <!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
4
4
 
5
+ ## 1.20.0 · Oct 20, 2026
6
+
7
+ Let another app ask for a run.
8
+
9
+ ### New
10
+
11
+ - **Ask for a run from another app: flightdeck plan request** (Command line). A script or another app can ask for a run with flightdeck plan request and a key, such as the month. It shows under Runs as a planned run. A person starts it, changes it or declines it; nothing starts by itself. Asking again with the same key and the same request changes nothing, and the same key with other details is refused. *Where:* The flightdeck command, then Runs
12
+ - **See which tool asked for a planned run** (Runs). A planned run another app asked for says "requested by" the tool, by its own account. The name is what the file says, so Flightdeck reminds you it can't confirm who wrote it. *Where:* Runs, Home and the start form
13
+
14
+ ### Improved
15
+
16
+ - **Copies of a planned run and double starts are shown** (Runs). Copies that say the same thing show once, marked as arrived twice. Copies that differ are both flagged and can't be started until someone deletes the wrong one. A plan started twice shows that its decisions disagree, so you can check Runs.
17
+
5
18
  ## 1.19.0 · Oct 19, 2026
6
19
 
7
20
  Edit a checklist with your AI assistant at the same time.
package/README.md CHANGED
@@ -401,6 +401,7 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
401
401
  | `flightdeck run <folder> <process>/<run id> --json` | One run in full: each step's state, who and when, what was recorded, gates and follow-ups. In the app, a run's **More** menu has **Copy run key** |
402
402
  | `flightdeck next <folder> <process>/<run id> --json` | What's waiting in a run and on whom; `--person <id>` adds that person's own next step |
403
403
  | `flightdeck followups <folder> --json` | Every follow-up and what became of it (`--status open` for the open ones) |
404
+ | `flightdeck plan request <folder> <request.json \| -> --key <key>` | For other apps: ask for a run. Writes one planned run to `proposals/`, once, for a person to review and start in the app. The same key and request again writes nothing. `--dry-run` shows what it would write; `--json` prints the result as JSON |
404
405
  | `flightdeck path` | Prints where the app file is |
405
406
  | `flightdeck version` | Prints the version |
406
407
 
@@ -415,6 +416,8 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
415
416
 
416
417
  The JSON commands let other apps read a team folder the way a teammate would: a script beside the monthly backup check, a reminder bot for an access review, an onboarding tracker, a deploy script. They only read, never through a link, and never change a file in the team folder. Every result says which contract it follows (`flightdeck-read/1`) and names, file by file, anything it couldn't read. Exit code 0: complete; 4: partial (the data is still printed, with each problem listed); 1: no data; 5: no such process, version or run. People-directory email fields are omitted. See [Using Flightdeck from other apps](docs/apps.md).
417
418
 
419
+ `flightdeck plan request` lets another app ask for a run, the way a teammate would put one on the calendar. A monthly script asks for the backup check with `--key 2026-11`; asking again with the same key and the same request changes nothing, and the same key with other details is refused. The request shows under **Runs** as a planned run, "requested by" the tool, by its own account: the name is what the file says, not proof of who wrote it. Nothing starts by itself; a person starts it, changes it or declines it. It never writes `run.json`, writes only in `proposals/`, never through a link, and reads the way the JSON commands read (exit 0, 4 when something it depends on couldn't be read and nothing was written, 1 when it's refused, 5 when there's no such process or version).
420
+
418
421
  Install and skills sort every `AGENTS.md` and skill file the same way. A file that's still a Flightdeck default (this version's or an earlier one) is updated. A file your team changed is left alone. If this version changed that file's default too, the new default goes to `.flightdeck/updates/<version>/`, with a `README.md` holding a prompt for your AI assistant to merge it. In `AGENTS.md` only the part between the `flightdeck:begin` and `flightdeck:end` markers is Flightdeck's; Team notes, below it, are never touched. `.flightdeck/installed.json` records which defaults the folder is based on; it can make install more careful, never less. Every file install replaces is first copied to `.flightdeck/updates/<version>/replaced/`. A file that changes while install runs is left alone and install exits with 1. Nothing is written through a link or outside the folder (see [Updating Flightdeck](docs/updating.md) for the limits).
419
422
 
420
423
  <br>
package/bin/flightdeck.js CHANGED
@@ -854,6 +854,232 @@ async function readMain(argv) {
854
854
  }
855
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: [] }; }
856
856
 
857
+ /* ---------- Asking for a run: flightdeck plan request (docs/apps.md, docs/design.md section 26) ----------
858
+ An app asks for a run by writing a planned run to proposals/, which a person reviews and starts in the app. It never starts
859
+ a run and never writes run.json. The file's name comes from the tool and the key (PF.requests.idFor), and any request
860
+ already under that name (or a OneDrive copy of it) is read and compared before anything is resolved, so a retry writes
861
+ nothing. It reads the way the --json commands read (a problem ledger over a read-only view, READ's envelope and exit codes)
862
+ and writes one file, once, in proposals/ only. */
863
+
864
+ /* Write proposals/<name> once, never replacing anything and never through a link: the text goes to a temporary file beside it
865
+ (created exclusively), flushed, then hard-linked to the final name, which fails if the name is taken, so the final name only
866
+ ever holds the whole text. A disk without hard links gets the final name created exclusively instead. The result is read
867
+ back byte for byte. Resolves to 'written', or 'exists' when the name was taken first (by another request on this computer,
868
+ or one OneDrive delivered). hooks.beforeLink: tests only, to land another write in the gap. Exclusive creation prevents
869
+ replacing a file here; it can't stop two computers that aren't syncing from both writing (OneDrive then keeps both). */
870
+ function writeRequest(dir, name, text, hooks) {
871
+ if (!/^req-[a-z0-9-]{1,60}\.json$/.test(name)) fail('refusing an unexpected request name: ' + name);
872
+ const home = folders(dir, ['proposals'], true), target = path.join(home, name), data = Buffer.from(text, 'utf8');
873
+ const tmp = path.join(home, '.' + name + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp');
874
+ const put = (fd) => { 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; } fs.fsyncSync(fd); };
875
+ const EXCL = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0);
876
+ let fd = null, mine = null;
877
+ try {
878
+ fd = fs.openSync(tmp, EXCL);
879
+ mine = fs.fstatSync(fd);
880
+ put(fd);
881
+ fs.closeSync(fd); fd = null;
882
+ folders(dir, ['proposals'], false);
883
+ const now = lstatOrNull(tmp);
884
+ if (!now || now.isSymbolicLink() || now.ino !== mine.ino || now.dev !== mine.dev) fail('refusing to write through a link: ' + home);
885
+ if (hooks && hooks.beforeLink) hooks.beforeLink();
886
+ try { fs.linkSync(tmp, target); }
887
+ catch (e) {
888
+ if (e.code === 'EEXIST') return 'exists';
889
+ if (['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV'].indexOf(e.code) < 0) throw e;
890
+ /* The temporary file already holds the whole text. The final name is created exclusively and filled; if that fails
891
+ part way (a full disk), the file this process created is removed, checked to be the very file it opened. */
892
+ let f2, made = null;
893
+ try { f2 = fs.openSync(target, EXCL); } catch (e2) { if (e2.code === 'EEXIST' || e2.code === 'ELOOP') return 'exists'; throw e2; }
894
+ try { made = fs.fstatSync(f2); put(f2); }
895
+ catch (e3) {
896
+ try { const left = lstatOrNull(target); if (made && left && !left.isSymbolicLink() && left.ino === made.ino && left.dev === made.dev) fs.unlinkSync(target); } catch (e4) { /* the error below says what happened */ }
897
+ throw e3;
898
+ } finally { fs.closeSync(f2); }
899
+ }
900
+ folders(dir, ['proposals'], false);
901
+ const st = lstatOrNull(target);
902
+ if (!st || st.isSymbolicLink() || !st.isFile()) fail('refusing to write through a link: ' + target);
903
+ if (!fs.readFileSync(target).equals(data)) fail('proposals/' + name + ' didn\'t read back the same after it was written. Check the team folder before asking again.');
904
+ return 'written';
905
+ } catch (e) {
906
+ if (e instanceof Refused) throw e;
907
+ fail('couldn\'t write proposals/' + name + ': ' + e.message);
908
+ } finally {
909
+ if (fd !== null) try { fs.closeSync(fd); } catch (e) { /* already failing */ }
910
+ /* The temporary file goes, if it's still the one this process made (the final name, once linked, keeps the bytes). */
911
+ if (mine) { try { const left = lstatOrNull(tmp); if (left && !left.isSymbolicLink() && left.ino === mine.ino && left.dev === mine.dev) fs.unlinkSync(tmp); } catch (e) { /* the result says what happened */ } }
912
+ }
913
+ }
914
+
915
+ /* eng: { PF, version }. adapter: the team folder (list, read, stat, blob), only ever read through PF.repo.readOnly inside a
916
+ problem ledger, as the --json commands read it. req: { key, text (the request's JSON), dryRun, now }. write(name, text):
917
+ writes proposals/<name> once (writeRequest), resolving 'written' or 'exists'; without it nothing is ever written. Resolves
918
+ Nothing here ever removes a file. Resolves to { code, json }: READ's envelope (exit 0, or 4 when something it depends on couldn't be read, and then nothing is
919
+ written) with data { request }, or an error object (exit 1: usage, invalid, key-taken, failed; exit 5: not-found). */
920
+ const PARTIAL_WHY = 'This file isn\'t complete JSON: it may still be arriving, or another request may still be writing it, so Flightdeck can\'t tell whether it\'s this request and never removes it. Nothing was written. Try again in a minute; if it stays this way, delete <file> only if no other request is still being written (docs/recovery.md, A request file is incomplete).';
921
+ async function requestData(eng, adapter, req, write) {
922
+ const PF = eng.PF, A = PF.appjson, RQ = PF.requests, repo = PF.repo, app = eng.version || '';
923
+ const L = repo.ledger(repo.readOnly(adapter, 'Checking a request'));
924
+ const take = (list) => list.forEach((p) => L.note(p.path, p.kind, p.why));
925
+ /* What the outcome depends on, which grows as it's read: this request's files in proposals/, then the process's versions
926
+ and people.json. A problem anywhere else (another plan, another process) is never this request's problem. */
927
+ const scope = [];
928
+ let id = '';
929
+ const relevant = (p) => p === '' || p === 'proposals' || p === 'proposals/' || (!!id && p.indexOf('proposals/' + id) === 0) ||
930
+ scope.some((x) => p === x || p === x + '/' || p.indexOf(x + '/') === 0 || x.indexOf(p.replace(/\/$/, '') + '/') === 0);
931
+ const ledger = () => { take(adapterProblems(adapter)); return L.problems().filter((p) => relevant(p.path)); };
932
+ const err = (code, kind, why) => ({ code, json: A.failure(app, kind, why, ledger()) });
933
+ const done = (file, status, plan, leftEmpty, runKey) => {
934
+ /* Held always names what couldn't be read; one it can't name is a failure, never a silent success. */
935
+ if (status === 'held' && !ledger().length) return err(EXIT.error, 'failed', 'Part of the team folder couldn\'t be read, so nothing was written. Try again.');
936
+ const e = A.envelope(app, new Date().toISOString(), ledger(), A.request(id, file, status, plan, leftEmpty, runKey)); return { code: e.complete ? 0 : EXIT.partial, json: e }; };
937
+ try {
938
+ if (typeof req.key !== 'string' || !RQ.KEY.test(req.key)) return err(EXIT.error, 'usage', 'say which key with --key: lowercase letters, numbers, dots, dashes or underscores, at most 64 characters, like 2026-11.');
939
+ if (Buffer.byteLength(String(req.text), 'utf8') > RQ.LIMITS.bytes) return err(EXIT.error, 'invalid', 'A request is at most 16 KB; this one is more.');
940
+ let value;
941
+ try { value = JSON.parse(String(req.text).replace(/^/, '')); } catch (e) { return err(EXIT.error, 'invalid', 'The request isn\'t complete JSON: ' + e.message); }
942
+ const c = RQ.clean(value, req.key);
943
+ if (c.errors.length) {
944
+ /* Every reason at once: when its process can be read, the checklist's own (details, roles, version) too. */
945
+ let more = [];
946
+ if (c.intent && c.intent.process) {
947
+ const d = await repo.loadAll(dependsOn(repo.readOnly(adapter, 'Checking a request'), ['publications/' + c.intent.process]), { readOnly: true, explain: true, deep: true }).catch(() => null);
948
+ const ck = d ? RQ.check(c.intent, d.processes.filter((x) => x.process === c.intent.process)[0]) : null;
949
+ more = ck && ck.errors ? ck.errors : [];
950
+ }
951
+ return err(EXIT.error, 'invalid', c.errors.concat(more).join(' '));
952
+ }
953
+ const intent = c.intent, proc = intent.process;
954
+ id = RQ.idFor(intent.producer.name, req.key);
955
+ const file = 'proposals/' + id + '.json';
956
+
957
+ /* 1. Any request already under this name, or a OneDrive copy of it, before anything is resolved. */
958
+ const found = async () => {
959
+ /* The request's own files (its name, OneDrive copies of it, and decisions on it), read with exactly the code READ
960
+ uses (repo.readPlans), so a file READ calls malformed is malformed here too, and named. */
961
+ const mine = (n) => RQ.isCopyName(n, id) || (n.indexOf(id + '.status-') === 0 && /\.json$/.test(n));
962
+ const bad = [];
963
+ let got;
964
+ try { got = await repo.readPlans(L.fs, bad, mine); } catch (e) { return null; }
965
+ bad.forEach((b) => L.note(b.path, b.kind || 'malformed', b.kind === 'malformed' && /complete JSON/.test(b.why) && RQ.isCopyName(b.path.slice('proposals/'.length), id) ? PARTIAL_WHY.replace('<file>', b.path) : fileWhy(b.why)));
966
+ const copies = [], own = (p) => (p._file === id + '.json' ? 0 : 1);
967
+ got.plans.filter((p) => RQ.isCopyName(p._file, id)).forEach((p) => {
968
+ /* A file that looks like a copy of this name but holds another plan isn't this request, unless it has this very name. */
969
+ if (p.id === id) copies.push(p);
970
+ else if (own(p) === 0) L.note('proposals/' + p._file, 'malformed', 'This file has this request\'s name but holds another plan (' + String(p.id).slice(0, 120) + '). Nothing was written.');
971
+ });
972
+ copies.sort((a, b) => own(a) - own(b) || (a._file < b._file ? -1 : 1));
973
+ return { copies, statuses: got.statuses.filter((x) => x.proposal === id) };
974
+ };
975
+ /* The roles of a plan whose person isn't active in people.json, which a retry reports again. */
976
+ const peopleNow = async () => {
977
+ if (scope.indexOf('people.json') < 0) scope.push('people.json');
978
+ const d = await repo.loadAll(dependsOn(L.fs, ['people.json']), { readOnly: true, explain: true, deep: true });
979
+ take(folderProblems(PF, d));
980
+ return d.people;
981
+ };
982
+ const compare = async (there) => {
983
+ const diffs = there.copies.map((x) => ({ name: x._file, d: RQ.differs(intent, x) })).filter((x) => x.d.length);
984
+ if (diffs.length) return err(EXIT.error, 'key-taken', 'A request with that key is already in the folder with other details: ' + diffs.map((x) => 'proposals/' + x.name + ' (' + x.d.join(', ') + ')').join('; ') + '. Use another key for another request. Nothing was written.');
985
+ const shown = repo.groupPlans(there.copies.map((x) => Object.assign({}, x)), there.statuses), st = shown.length === 1 ? shown[0]._status : null;
986
+ const first = there.copies[0], left = RQ.leftEmpty(first.roles, await peopleNow());
987
+ return done('proposals/' + first._file, 'already', first, left, st && st.status === 'accepted' && typeof st.run === 'string' ? proc + '/' + st.run : null);
988
+ };
989
+ const there = await found();
990
+ /* A file under the request's name (or a copy's) that couldn't be read or isn't in the format: whether this request is
991
+ there, and what it says, isn't known. A bad decision only leaves which run it started unknown. */
992
+ const nameProblem = () => ledger().some((p) => p.path.indexOf('proposals/' + id) === 0 && p.path.indexOf('proposals/' + id + '.status-') !== 0);
993
+ if (!there || nameProblem()) return done(file, 'held', null, [], null);
994
+ if (there.copies.length) return compare(there);
995
+ /* No request there, but something under its name or a decision on it couldn't be read or isn't in the format (or a link
996
+ has its name): whether it's there isn't known, so nothing is written. */
997
+ if (ledger().length) return done(file, 'held', null, [], null);
998
+
999
+ /* 2. Nothing there: resolve what it asks for against the process's versions, check it, then write. */
1000
+ scope.push('publications/' + proc, 'people.json');
1001
+ const d = await repo.loadAll(dependsOn(L.fs, ['publications/' + proc, 'people.json']), { readOnly: true, explain: true, deep: true });
1002
+ take(folderProblems(PF, d));
1003
+ if (ledger().length) return done(file, 'held', null, [], null);
1004
+ const ck = RQ.check(intent, d.processes.filter((x) => x.process === proc)[0]);
1005
+ if (ck.notFound) return err(EXIT.notFound, 'not-found', ck.notFound);
1006
+ if (ck.errors) return err(EXIT.error, 'invalid', ck.errors.join(' '));
1007
+ const plan = RQ.build(intent, ck.pub, req.now || new Date().toISOString()), left = RQ.leftEmpty(plan.roles, d.people);
1008
+ if (req.dryRun || !write) return done(file, 'would-request', plan, left, null);
1009
+ let res;
1010
+ try { res = await write(id + '.json', RQ.text(plan)); } catch (e) { return err(EXIT.error, 'failed', e && e.message || String(e)); }
1011
+ if (res === 'written') return done(file, 'requested', plan, left, null);
1012
+ /* The name was taken between the check and the write: what's there now decides, exactly as on a retry. */
1013
+ const again = await found();
1014
+ if (!again || nameProblem() || !again.copies.length) return done(file, 'held', null, [], null);
1015
+ return compare(again);
1016
+ } catch (e) {
1017
+ return err(EXIT.error, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e)));
1018
+ }
1019
+ }
1020
+
1021
+ /* The request's bytes: from a file, or standard input with "-". At most limit bytes are taken in; more is refused (tooBig). */
1022
+ function readRequestInput(arg, limit) {
1023
+ if (arg === '-') {
1024
+ return new Promise((res, rej) => {
1025
+ const chunks = [];
1026
+ let n = 0, over = false;
1027
+ process.stdin.on('data', (c) => { if (over) return; n += c.length; if (n > limit) { over = true; process.stdin.pause(); process.stdin.destroy(); return res({ tooBig: true }); } chunks.push(c); });
1028
+ process.stdin.on('end', () => { if (!over) res({ buf: Buffer.concat(chunks) }); });
1029
+ process.stdin.on('error', (e) => { if (!over) rej(e); });
1030
+ });
1031
+ }
1032
+ return new Promise((res) => {
1033
+ const fd = fs.openSync(expand(arg), 'r');
1034
+ try {
1035
+ const st = fs.fstatSync(fd);
1036
+ if (st.isFile() && st.size > limit) return res({ tooBig: true });
1037
+ const buf = Buffer.alloc(limit + 1);
1038
+ let got = 0;
1039
+ for (;;) { const k = fs.readSync(fd, buf, got, buf.length - got, null); if (!k) break; got += k; if (got > limit) return res({ tooBig: true }); }
1040
+ res({ buf: buf.subarray(0, got) });
1041
+ } finally { fs.closeSync(fd); }
1042
+ });
1043
+ }
1044
+ /* The words for plan request without --json. */
1045
+ function requestWords(json) {
1046
+ if (json.error) return 'flightdeck: ' + json.error.why + json.problems.map((x) => '\n ' + x.path + ': ' + x.why).join('');
1047
+ const r = json.data.request, p = r.plan, lines = [];
1048
+ const what = p ? '"' + p.title + '" (' + p.process + ' v' + p.version + ')' : '';
1049
+ if (r.status === 'requested') lines.push('Requested ' + what + ' in ' + r.file + '. A person starts it in Flightdeck; nothing starts by itself.');
1050
+ if (r.status === 'would-request') lines.push('Would request ' + what + ' in ' + r.file + '. Nothing was written (--dry-run).');
1051
+ if (r.status === 'already') lines.push('Already requested: ' + what + ' in ' + r.file + ', requested ' + p.created + '. Nothing was written.' + (r.run ? ' It was started as ' + r.run + '.' : ''));
1052
+ if (r.status === 'held') lines.push('Nothing was written, because part of what this request depends on couldn\'t be read. Try again once it can be read:');
1053
+ if (r.leftEmpty.length) lines.push('Left for the person who starts it (not active in people.json): ' + r.leftEmpty.join(', ') + '.');
1054
+ json.problems.forEach((x) => lines.push(' ' + x.path + ': ' + x.why));
1055
+ return lines.join('\n');
1056
+ }
1057
+ async function requestMain(argv) {
1058
+ const asJson = argv.includes('--json'), dryRun = argv.includes('--dry-run');
1059
+ const ki = argv.indexOf('--key'), key = ki >= 0 ? argv[ki + 1] : null;
1060
+ const args = argv.filter((a, i) => (!a.startsWith('--') || a === '-') && !(i > 0 && argv[i - 1] === '--key'));
1061
+ let eng = null;
1062
+ const out = (json, code) => ({ out: asJson ? JSON.stringify(json, null, 2) : requestWords(json), code });
1063
+ const usage = (why) => out(failureJson(eng, 'usage', why), EXIT.error);
1064
+ try { eng = loadEngine(); } catch (e) { return out(failureJson(null, 'failed', e.message), EXIT.error); }
1065
+ const RQ = eng.PF.requests;
1066
+ if (args[1] !== 'request') return usage('say what to do: flightdeck plan request <team folder> <request.json | -> --key <key>');
1067
+ if (!args[2] || !args[3] || args[4]) return usage('say the team folder and the request file, or - for standard input: flightdeck plan request <team folder> <request.json | -> --key <key>');
1068
+ if (ki < 0 || !key || key.startsWith('--')) return usage('say which key with --key, for example --key 2026-11. The same key always means the same request.');
1069
+ if (!RQ.KEY.test(key)) return usage('the key is lowercase letters, numbers, dots, dashes or underscores, at most 64 characters, starting with a letter or number, like 2026-11.');
1070
+ const unknown = argv.filter((a) => a.startsWith('--') && ['--json', '--dry-run', '--key'].indexOf(a) < 0);
1071
+ if (unknown.length) return usage('plan request doesn\'t take ' + unknown.join(', ') + '.');
1072
+ let dir;
1073
+ try { dir = folderArg(args[2], 'plan request'); } catch (e) { return out(failureJson(eng, 'failed', e.message), EXIT.error); }
1074
+ let input;
1075
+ try { input = await readRequestInput(args[3], RQ.LIMITS.bytes); } catch (e) { return usage('couldn\'t read the request ' + (args[3] === '-' ? 'from standard input' : args[3]) + ': ' + e.message); }
1076
+ if (input.tooBig) return out(failureJson(eng, 'invalid', 'A request is at most 16 KB; this one is more.'), EXIT.error);
1077
+ let text;
1078
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(input.buf); } catch (e) { return out(failureJson(eng, 'invalid', 'The request isn\'t UTF-8 text.'), EXIT.error); }
1079
+ const r = await requestData(eng, readAdapter(dir, { maxBytes: READ_MAX }), { key, text, dryRun }, (name, t) => writeRequest(dir, name, t));
1080
+ return out(r.json, r.code);
1081
+ }
1082
+
857
1083
  /* ---------- Writing drafts for AI assistants: flightdeck draft read | save | propose (contract flightdeck-draft/1) ----------
858
1084
  These commands change the team folder, and only ever write maps/drafts/<process>.md (a direct save), one new
859
1085
  proposed/<process>/<operation>.json and one new history/drafts/<process>/agent-<operation>.jsonl, all through folders()
@@ -1074,6 +1300,9 @@ const HELP = `flightdeck: team process checklists, in one HTML file
1074
1300
  what's waiting in a run and on whom (with --person, that person's next step)
1075
1301
  flightdeck followups <team folder> [--process <id>] [--status open|all] --json
1076
1302
  every follow-up and what became of it
1303
+ flightdeck plan request <team folder> <request.json | -> --key <key> [--json] [--dry-run]
1304
+ for other apps: ask for a run. Writes a planned run to proposals/ once, for a
1305
+ person to start in Flightdeck; the same key and request again writes nothing
1077
1306
  flightdeck draft read <team folder> <process> [--session <id>] [--or-latest] --json
1078
1307
  for AI assistants: the draft's exact text and rev, who has it open,
1079
1308
  this session's proposals and the text to continue from
@@ -1096,6 +1325,11 @@ The JSON commands only read: they never change a file in the team folder. Exit c
1096
1325
  is printed, and "problems" names every file or folder that couldn't be read); 1: no data; 5: no such process, version or
1097
1326
  run. People-directory email fields are omitted. See docs/apps.md in the Flightdeck repository.
1098
1327
 
1328
+ plan request never starts a run and never writes run.json; it writes one new file in proposals/, once. Exit code 0:
1329
+ requested, already requested, or (--dry-run) what it would write; 4: nothing written, because something it depends on
1330
+ couldn't be read; 1: refused (usage, a request that isn't valid, or the same key with other details); 5: no such process
1331
+ or version.
1332
+
1099
1333
  flightdeck draft changes the team folder (contract flightdeck-draft/1). Exit code 0: written or proposed (read: complete);
1100
1334
  4: read, partial; 1: usage, missing-base, too-big or failed; 5: read, no such process.
1101
1335
 
@@ -1113,6 +1347,7 @@ function main(argv) {
1113
1347
  const first = argv.filter((a) => !a.startsWith('--'))[0];
1114
1348
  if (READS.indexOf(first) >= 0 && !argv.includes('--help')) return readMain(argv);
1115
1349
  if (first === 'draft' && !argv.includes('--help')) return draftMain(argv);
1350
+ if (first === 'plan' && !argv.includes('--help')) return requestMain(argv);
1116
1351
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
1117
1352
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
1118
1353
  /* Flags that take a value: the value is never read as a command or a folder. */
@@ -1230,4 +1465,4 @@ if (require.main === module) {
1230
1465
  if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
1231
1466
  } catch (e) { failed(e); }
1232
1467
  }
1233
- module.exports = { _hooks, main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, EXIT };
1468
+ module.exports = { _hooks, main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, requestData, writeRequest, EXIT };