@troylar/flightdeck 1.17.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/bin/flightdeck.js CHANGED
@@ -14,8 +14,10 @@ const APP = path.join(__dirname, '..', 'dist', 'flightdeck.html');
14
14
  const NAME = 'flightdeck.html', OLD = 'preflight.html', SKILLS = '.flightdeck/skills', AGENTS = 'AGENTS.md';
15
15
  const BEGIN = '<!-- flightdeck:begin -->', END = '<!-- flightdeck:end -->';
16
16
  /* --check exit codes, for scripts: nothing to do, updates to install, files that need a person (or an assistant) to merge.
17
- export: 0 a complete pack, 4 a partial pack (made, with what's unresolved listed), 1 no pack (any partial file removed). */
18
- const EXIT = { none: 0, error: 1, updates: 2, merge: 3, partial: 4 };
17
+ export: 0 a complete pack, 4 a partial pack (made, with what's unresolved listed), 1 no pack (any partial file removed).
18
+ Reading for other apps (processes, process, runs, run, next, followups): 0 complete, 4 partial (data printed, every
19
+ problem listed), 1 no data, 5 no such process, version or run. */
20
+ const EXIT = { none: 0, error: 1, updates: 2, merge: 3, partial: 4, notFound: 5 };
19
21
 
20
22
  class Refused extends Error {}
21
23
  /* A file that changed between the moment install read it and the moment it would replace it. */
@@ -122,11 +124,14 @@ function safeWrite(dir, parts, text, expect) {
122
124
  const now = lstatOrNull(tmp);
123
125
  if (!now || now.isSymbolicLink() || now.ino !== mine.ino || now.dev !== mine.dev) fail('refusing to write through a link: ' + home);
124
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);
125
129
  if (expect !== undefined) {
126
130
  const st = lstatOrNull(target);
127
131
  if (st && (st.isSymbolicLink() || !st.isFile())) fail('refusing to write through a link: ' + target);
128
132
  if ((st ? fs.readFileSync(target, 'utf8') : null) !== expect) throw new Changed(parts.join('/'));
129
133
  }
134
+ if (hooks.afterCompare) hooks.afterCompare(target);
130
135
  fs.renameSync(tmp, target);
131
136
  moved = true;
132
137
  } catch (e) {
@@ -378,10 +383,10 @@ function backup(src, toArg) {
378
383
  }
379
384
  function size(n) { return n < 1024 ? n + ' bytes' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
380
385
 
381
- /* ---------- flightdeck export: an evidence pack (docs/evidence.md) ---------- */
386
+ /* ---------- The engine: export and reading for other apps run the app's own rules ---------- */
382
387
  /* The app's own rules, from this installed package's dist/flightdeck.html and nowhere else (never a team folder's copy):
383
388
  its core and storage blocks, run in node:vm without the screens. */
384
- function engine() {
389
+ function loadEngine() {
385
390
  const html = app(), block = (id) => {
386
391
  const m = new RegExp('<script id="' + id + '">([\\s\\S]*?)</script>').exec(html);
387
392
  if (!m) fail('the app file in this install has no ' + id + ' block (' + APP + '). Reinstall the package.');
@@ -395,10 +400,32 @@ function engine() {
395
400
  /* The team folder for the collector: list, read, stat and blob, read-only, and never through a link. Every folder on the
396
401
  way to a path is checked with lstat (a linked folder is refused), a link in a listing is reported as a link and never
397
402
  opened, and a file is opened without following a link and must be the very file lstat saw (device and inode), so one
398
- swapped for a link after it was listed is refused too. links collects what was skipped. */
399
- function readAdapter(root) {
400
- const NOFOLLOW = fs.constants.O_NOFOLLOW || 0, links = [];
403
+ swapped for a link after it was listed is refused too. links collects what was skipped. opts.maxBytes (or a read's own
404
+ { maxBytes }) caps what read() takes in: a bigger file is sized from its open handle and refused before any byte is
405
+ read, and named in skipped. */
406
+ function readAdapter(root, opts) {
407
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0, links = [], skipped = [], max = (opts && opts.maxBytes) || 0;
401
408
  function linkErr(rel) { const e = new Refused('refusing to read through a link: ' + rel); e.link = true; if (links.indexOf(rel) < 0) links.push(rel); return e; }
409
+ function skipErr(rel, bytes, cap, more) {
410
+ const e = new Refused(rel + ' is ' + (more ? 'more than ' : '') + size(bytes) + ', over the ' + size(cap) + ' this command reads');
411
+ e.skipped = true; e.size = bytes;
412
+ if (!skipped.some((x) => x.path === rel)) skipped.push({ path: rel, size: bytes, cap, more: !!more });
413
+ return e;
414
+ }
415
+ /* The whole file from an open descriptor, counting the bytes as they come: the size the handle gave is only a first
416
+ check, so a file that grows while it's read is refused (skipped) the moment it passes the cap. */
417
+ function readAll(fd, rel, cap) {
418
+ const chunks = [], buf = Buffer.alloc(65536);
419
+ let total = 0;
420
+ for (;;) {
421
+ const n = fs.readSync(fd, buf, 0, buf.length, total);
422
+ if (!n) break;
423
+ total += n;
424
+ if (cap && total > cap) throw skipErr(rel, cap, cap, true);
425
+ chunks.push(Buffer.from(buf.subarray(0, n)));
426
+ }
427
+ return Buffer.concat(chunks, total);
428
+ }
402
429
  function partsOf(p) {
403
430
  const a = p ? String(p).split('/') : [];
404
431
  a.forEach((x) => { if (!x || x === '.' || x === '..' || /[\\\u0000]/.test(x)) fail('refusing an unexpected path: ' + p); });
@@ -448,11 +475,13 @@ function readAdapter(root) {
448
475
  } catch (e) { fs.closeSync(fd); throw e; }
449
476
  }
450
477
  return {
451
- links,
478
+ links, skipped,
452
479
  list: (p) => new Promise((res) => {
453
480
  const a = partsOf(p), dir = dirAt(a);
454
- if (!dir) return res([]);
455
- const entries = fs.readdirSync(dir.cur, { withFileTypes: true });
481
+ /* No such folder: empty, and marked missing, so a reader that saw it listed can tell gone from empty. */
482
+ if (!dir) { const none = []; none.missing = true; return res(none); }
483
+ let entries;
484
+ try { entries = fs.readdirSync(dir.cur, { withFileTypes: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; const none = []; none.missing = true; return res(none); }
456
485
  sameWalk(a, dir.ids, p || '.');
457
486
  res(entries.map((e) => {
458
487
  const rel = (p ? p + '/' : '') + e.name;
@@ -465,26 +494,36 @@ function readAdapter(root) {
465
494
  try { f = fileAt(p); } catch (e) { if (!e.link) throw e; }
466
495
  res(f ? { size: f.st.size, mtime: f.st.mtimeMs } : null);
467
496
  }),
468
- read: (p) => new Promise((res) => {
497
+ read: (p, ro) => new Promise((res) => {
469
498
  const o = open(p);
470
499
  if (!o) return res(null);
500
+ const cap = (ro && ro.maxBytes) || max;
501
+ if (cap && o.st.size > cap) { fs.closeSync(o.fd); throw skipErr(p, o.st.size, cap); }
471
502
  /* Decoded as a browser decodes a file (TextDecoder: UTF-8, a byte-order mark dropped), so every reader sees the same text. */
472
- try { const data = fs.readFileSync(o.fd); res({ text: new TextDecoder('utf-8').decode(data), size: data.length, mtime: o.st.mtimeMs }); } finally { fs.closeSync(o.fd); }
503
+ try { const data = readAll(o.fd, p, cap); res({ text: new TextDecoder('utf-8').decode(data), size: data.length, mtime: o.st.mtimeMs }); } finally { fs.closeSync(o.fd); }
473
504
  }),
474
505
  /* A file to read a piece at a time, from the descriptor opened above; close() when done. */
475
- blob: (p) => new Promise((res) => {
506
+ blob: (p, ro) => new Promise((res) => {
476
507
  const o = open(p);
477
508
  if (!o) return res(null);
509
+ const cap = (ro && ro.maxBytes) || max;
510
+ if (cap && o.st.size > cap) { fs.closeSync(o.fd); throw skipErr(p, o.st.size, cap); }
478
511
  let closed = false;
512
+ const close = () => { if (!closed) { closed = true; fs.closeSync(o.fd); } };
479
513
  res({
480
514
  size: o.st.size,
515
+ /* The whole file (screenshots are checked this way), counted against the cap as it's read; then closed. */
516
+ arrayBuffer: () => new Promise((ok) => {
517
+ if (closed) throw new Error(p + ' was already closed');
518
+ try { const data = readAll(o.fd, p, cap); ok(data.buffer.slice(data.byteOffset, data.byteOffset + data.length)); } finally { close(); }
519
+ }),
481
520
  slice: (a, b) => ({ arrayBuffer: () => new Promise((ok) => {
482
521
  const buf = Buffer.alloc(Math.max(0, b - a));
483
522
  let got = 0;
484
523
  while (got < buf.length) { const n = fs.readSync(o.fd, buf, got, buf.length - got, a + got); if (!n) break; got += n; }
485
524
  ok(buf.buffer.slice(buf.byteOffset, buf.byteOffset + got));
486
525
  }) }),
487
- close: () => { if (!closed) { closed = true; fs.closeSync(o.fd); } }
526
+ close
488
527
  });
489
528
  })
490
529
  };
@@ -518,7 +557,7 @@ async function exportPack(opt) {
518
557
  const dir = folderArg(opt.folder, 'export');
519
558
  if (!opt.run && !opt.process) fail('say which runs: --run <process>/<run id>, or --process <process> --from YYYY-MM-DD --through YYYY-MM-DD');
520
559
  if (opt.process && (!opt.from || !opt.through)) fail('give the days with --from YYYY-MM-DD and --through YYYY-MM-DD (both included)');
521
- const { PF, version } = engine(), E = PF.evidence;
560
+ const { PF, version } = loadEngine(), E = PF.evidence;
522
561
  const tz = opt.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
523
562
  if (!E.zoneOk(tz)) fail('"' + tz + '" isn\'t a time zone this computer knows. Use a name like Europe/London or America/New_York.');
524
563
  const out = outFolder(expand(opt.out || path.join(require('os').homedir(), 'Flightdeck evidence')), withinOf(dir));
@@ -571,6 +610,444 @@ async function exportPack(opt) {
571
610
  }
572
611
  }
573
612
 
613
+ /* ---------- Reading for other apps: processes, process, runs, run, next, followups --json (docs/apps.md) ---------- */
614
+ const READS = ['processes', 'process', 'runs', 'run', 'next', 'followups'];
615
+ /* What one read takes in at most: a record past this is named (skipped), never read. */
616
+ const READ_MAX = 16 * 1048576;
617
+ /* Where each part of loadAll lives, for naming a part that couldn't be read. */
618
+ const PART_PATH = { people: 'people.json', drafts: 'maps/drafts/', publications: 'publications/', runs: 'runs/', legacy: 'runs/', skills: 'skills/', proposals: 'proposals/',
619
+ 'step types': 'step-types/', 'Jira workflows': 'jira/workflows.json', 'sign-offs': 'validations/', corrections: 'corrections/', 'follow-ups': 'followups/' };
620
+ const PROC_ID = /^[a-z0-9][a-z0-9-]{0,59}$/, RUN_KEY = /^([a-z0-9][a-z0-9-]{0,59})\/([a-z0-9-]{1,40})$/i;
621
+
622
+ function fileWhy(w) { return 'This file ' + String(w || 'couldn\'t be read') + '.'; }
623
+ /* A run this copy can only list by name: saved by a newer Flightdeck (its format, fields this copy doesn't know, or its own
624
+ frozen checklist and step types using features this copy can't read). A run on a version that's only unusable here
625
+ still reads; its version is named instead. */
626
+ function newerRun(PF, r) {
627
+ const why = r._unrunnable || '';
628
+ if (!why) return false;
629
+ if (r.snapshot === '' || why.indexOf(PF.run.NEWER) === 0) return true;
630
+ return why.indexOf(PF.publish.NEWER) === 0 && !!PF.publish.unrunnable(r.snapshot, r.types);
631
+ }
632
+ function runProblems(PF, r) {
633
+ const out = [], key = r.process + '/' + r.id, dir = 'runs/' + key + '/';
634
+ if (newerRun(PF, r)) out.push({ path: dir + 'run.json', kind: 'newer', why: '"' + r.title + '" (' + key + '): ' + r._unrunnable });
635
+ /* Times sanitize couldn't read, and every other record it dropped, cut or replaced: one problem for the file, naming each. */
636
+ const isNewer = newerRun(PF, r), bt = isNewer ? '' : badTimesWhy(r._badTimes), fixed = isNewer ? [] : (r._repairs || []).map((x) => x.at + ' (' + x.what + ')'), parts = [];
637
+ if (bt) parts.push('Some times in this file couldn\'t be read (' + bt + ').');
638
+ if (fixed.length) parts.push('Records this copy dropped or changed: ' + fixed.slice(0, 20).join(', ') + (fixed.length > 20 ? ', and ' + (fixed.length - 20) + ' more' : '') + '.');
639
+ /* The safety net: anywhere else sanitize's result differs from the file (R.unexplained), not already named above. */
640
+ const named = (r._repairs || []).map((x) => x.at).concat(Object.keys(r._badTimes || {}));
641
+ const other = isNewer ? [] : (r._drift || []).filter((p) => !explained(p, named));
642
+ if (other.length) parts.push('Other places this copy reads differently from the file: ' + other.slice(0, 20).join(', ') + (other.length > 20 ? ', and ' + (other.length - 20) + ' more' : '') + '.');
643
+ if (parts.length) out.push({ path: dir + 'run.json', kind: 'malformed', why: parts.join(' ') + ' The run is shown as the app shows it, without them.' });
644
+ if (r._conflicts && r._conflicts.length) out.push({ path: dir, kind: 'conflict', why: 'Conflict copies or lost saves beside this run: ' + r._conflicts.join(', ') + '. A person resolves them in the app.' });
645
+ return out;
646
+ }
647
+ /* Whether a place where the file and what the app reads differ is already named: the same place, one inside the other, or
648
+ another entry of the same list (dropping one entry shifts the rest). */
649
+ function explained(p, named) {
650
+ const base = (x) => x.replace(/\[\d+\].*$/, '');
651
+ return named.some((e) => p === e || p.startsWith(e + '.') || p.startsWith(e + '[') || e.startsWith(p + '.') || e.startsWith(p + '[') || (/\[/.test(p) && /\[/.test(e) && base(p) === base(e)));
652
+ }
653
+ /* A follow-up decision whose file says something cleanDecision doesn't keep as written (_drift, reading for other apps). */
654
+ function decisionDrift(d) {
655
+ return d && d._drift && d._drift.length ? { path: 'followups/' + d.process + '/' + d._file, kind: 'malformed', why: 'This decision isn\'t read as written: ' + d._drift.slice(0, 20).join(', ') + '.' } : null;
656
+ }
657
+ /* Which times R.sanitize couldn't read (r._badTimes), in words, or ''. */
658
+ function badTimesWhy(b) {
659
+ if (!b) return '';
660
+ const names = { created: 'the start', ended: 'the end', pauses: 'an earlier pause', paused: 'the pause under way', log: 'a log entry' };
661
+ return Object.keys(b).map((k) => (k === 'log' && Array.isArray(b.log) && b.log.length > 1 ? b.log.length + ' log entries' : names[k] || k)).join(', ');
662
+ }
663
+ /* One run, judged the same way by runs, run and next, so a summary never says other than the run itself: its version
664
+ (pub: what readVersion or the version list found; a version that isn't in the folder reads as missing files, as
665
+ readVersion finds it; null when the command doesn't read versions) decides why it's read-only, as the app decides it
666
+ (unusable here, or changed since the run started: R's own reason comes first), then every problem of the run file
667
+ and of that version. Sets r._unrunnable; returns the problems. */
668
+ function judgeRun(PF, r, pub) {
669
+ const A = PF.appjson, out = [], valid = typeof r.folder === 'string' && !!PF.publish.versionOf(r.folder) && /^[a-z0-9][a-z0-9-]{0,59}$/.test(r.process);
670
+ const dir = 'publications/' + r.process + '/' + r.folder + '/';
671
+ if (pub && valid && !pub._readFailed) {
672
+ const why = pub.unusable ? pub.why : pub.ok ? PF.repo.runMismatch(r, pub.info) : '';
673
+ if (why && !r._unrunnable) r._unrunnable = why;
674
+ if (!pub.ok) { const x = A.pubProblem(pub); out.push({ path: dir, kind: x.kind, why: x.why }); }
675
+ /* The version is fine; the run doesn't match it. Named at the run, so the version is never taken as unreadable. */
676
+ else if (why) out.push({ path: 'runs/' + r.process + '/' + r.id + '/run.json', kind: 'unusable', why: 'This run doesn\'t match its version ' + r.folder + ' (its checklist or version number differs from the version\'s record), so the app opens it read-only.' });
677
+ }
678
+ return out.concat(runProblems(PF, r));
679
+ }
680
+ /* A version folder that isn't in the folder, as readVersion would find it: none of its files there. */
681
+ function missingVersion(PF, r) { const v = PF.publish.verify(r.folder, { 'map.md': null, 'types.json': null, 'publication.json': null }); v.folder = r.folder; v.process = r.process; return v; }
682
+ /* What's wrong with people.json as a people list, or '' (no file is fine: names are then null). */
683
+ function peopleWhy(p) {
684
+ if (!p || p.hash == null) return '';
685
+ const doc = p.doc;
686
+ if (doc == null) return 'This file isn\'t complete JSON, so no names could be read.';
687
+ const list = Array.isArray(doc) ? doc : typeof doc === 'object' && Array.isArray(doc.people) ? doc.people : null;
688
+ if (!list) return 'This file isn\'t a people list, so no names could be read.';
689
+ const bad = list.filter((x) => !(x && typeof x === 'object' && typeof x.id === 'string' && typeof x.name === 'string')).length;
690
+ const long = list.filter((x) => x && typeof x.name === 'string' && x.name.length > 60).length;
691
+ if (!bad && long) return 'This file has ' + long + ' ' + (long === 1 ? 'name' : 'names') + ' over 60 characters; they are cut to 60, as the app shows them.';
692
+ return bad ? 'This file has ' + bad + ' ' + (bad === 1 ? 'entry' : 'entries') + ' without a text ID and name; those names are left null.' : '';
693
+ }
694
+ /* What a whole-folder read found beyond failed reads (which the ledger already holds): parts that failed, runs and plans
695
+ that won't parse, versions that can't be used, newer runs and conflict copies, one per file or folder. */
696
+ function folderProblems(PF, d) {
697
+ const A = PF.appjson, out = [];
698
+ Object.keys(d.failed || {}).forEach((part) => out.push({ path: PART_PATH[part] || part, kind: 'unreadable', why: 'This part of the folder couldn\'t be read: ' + d.failed[part] + '.' }));
699
+ (d.unreadable || []).forEach((u) => out.push({ path: u.path, kind: u.kind || 'unreadable', why: fileWhy(u.why) }));
700
+ (d.nofile || []).forEach((n) => out.push({ path: n.path, kind: 'nofile', why: 'The run folder has no run.json.' }));
701
+ (d.malformed || []).forEach((m) => out.push({ path: m.path, kind: m.kind || 'malformed', why: fileWhy(m.why) }));
702
+ (d.fuUnread || []).forEach((u) => out.push({ path: 'followups/' + u.process + '/' + u._file, kind: 'malformed', why: 'This file couldn\'t be read, or isn\'t a follow-up decision this copy can read.' }));
703
+ (d.corrUnread || []).forEach((u) => out.push({ path: u.dir + '/' + u.process + '/' + u._file, kind: 'malformed', why: 'This file couldn\'t be read, or isn\'t a decision this copy can read.' }));
704
+ (d.processes || []).forEach((proc) => (proc.pubs || []).forEach((p) => { if (!p.ok && !p._readFailed) { const x = A.pubProblem(p); out.push({ path: 'publications/' + proc.process + '/' + p.folder + '/', kind: x.kind, why: x.why }); } }));
705
+ (d.fuDecisions || []).forEach((x) => { const p = decisionDrift(x); if (p) out.push(p); });
706
+ const pw = peopleWhy(d.people);
707
+ if (pw) out.push({ path: 'people.json', kind: 'malformed', why: pw });
708
+ return out;
709
+ }
710
+ function adapterProblems(adapter) {
711
+ return (adapter.links || []).map((p) => ({ path: p, kind: 'link', why: 'A link. Flightdeck never reads through links.' }))
712
+ .concat((adapter.skipped || []).map((x) => ({ path: x.path, kind: 'skipped', why: 'Too large to read here (' + (x.more ? 'more than ' : '') + size(x.size) + '; the limit is ' + size(x.cap) + ').' })));
713
+ }
714
+
715
+ /* What a whole-folder command's output depends on, as folders and files: its data, the publications that say whether a
716
+ run or plan can be used and which version a plan starts on, and people.json for names. With --process, that process's
717
+ part only (the plans are all read, since a plan's process is inside the file). */
718
+ function needs(req) {
719
+ const sub = (top) => top + (req.proc ? '/' + req.proc : '');
720
+ if (req.cmd === 'processes' || req.cmd === 'process') return [req.cmd === 'process' ? 'publications/' + req.proc : 'publications'];
721
+ if (req.cmd === 'runs') return [sub('runs'), 'proposals', sub('publications'), 'people.json'];
722
+ return [sub('followups'), sub('runs'), 'people.json'].concat(req.proc ? [sub('publications'), 'proposals'] : []);
723
+ }
724
+ /* A view of the folder that holds only those paths (and the folders above them, listed to reach them). Anything else
725
+ looks absent: it's never read, so it's never a problem and never part of the output. It sits above the ledger, so
726
+ what it hides never reaches it. */
727
+ /* Whether a path is one of these, inside one, or a folder above one (listed to reach it). */
728
+ function covers(prefixes) { return (p) => { p = p || ''; return p === '' || prefixes.some((x) => p === x || p.startsWith(x + '/') || x.startsWith(p + '/')); }; }
729
+ /* What run and next depend on: the run's folder, its version once it's known, people.json, and that run's own follow-up
730
+ decisions (the files named <run id>.*, not the other runs' in the same folder). */
731
+ function runCovers(proc, id, r) {
732
+ const base = covers(['runs/' + proc + '/' + id, 'people.json'].concat(r && typeof r.folder === 'string' ? ['publications/' + r.process + '/' + r.folder] : []));
733
+ return (p) => base(p) || p === 'followups' || p === 'followups/' + proc || p === 'followups/' + proc + '/' || String(p).startsWith('followups/' + proc + '/' + id + '.');
734
+ }
735
+ function dependsOn(fs, prefixes) {
736
+ const ok = covers(prefixes);
737
+ const v = {};
738
+ if (fs.list) v.list = (p, o) => (ok(p) ? fs.list(p, o) : Promise.resolve([]));
739
+ ['read', 'stat', 'blob'].forEach((m) => { if (fs[m]) v[m] = (p, o) => (ok(p) ? fs[m](p, o) : Promise.resolve(null)); });
740
+ ['write', 'append', 'remove'].forEach((m) => { v[m] = (...a) => fs[m](...a); });
741
+ return v;
742
+ }
743
+
744
+ /* eng: { PF, version } (loadEngine()). adapter: a folder with list, read, stat and blob (and links and skipped, as the
745
+ command line's has); it's only ever read through PF.repo.readOnly, inside a problem ledger (PF.repo.ledger) that names
746
+ every failed list or read, every listed file gone when read, and everything the loaders found malformed. req: { cmd,
747
+ proc, key, version, folder, status, person, contract }. Resolves to { code, json }: an envelope (exit 0 or 4), whose
748
+ problems are the ledger's within the command's scope (complete exactly when there are none), or an error object (exit 1
749
+ or 5) carrying every problem the ledger holds. */
750
+ async function readData(eng, adapter, req) {
751
+ const PF = eng.PF, A = PF.appjson, repo = PF.repo, F = PF.followups, app = eng.version || '';
752
+ const L = repo.ledger(repo.readOnly(adapter, 'Reading for other apps'));
753
+ const take = (list) => list.forEach((p) => L.note(p.path, p.kind, p.why));
754
+ /* What the command depends on (set once the command is known): problems anywhere else, a link met while listing a
755
+ folder above what it reads included, are never its problems. */
756
+ let relevant = () => true;
757
+ const ledger = () => { take(adapterProblems(adapter)); return L.problems().filter((p) => relevant(p.path)); };
758
+ const err = (code, kind, why) => ({ code, json: A.failure(app, kind, why, ledger()) });
759
+ const notFound = (why) => err(EXIT.notFound, 'not-found', why);
760
+ if (req.contract != null && A.VERSIONS.indexOf(+req.contract) < 0) return err(EXIT.error, 'contract', 'This copy of Flightdeck reads contract ' + A.VERSIONS.join(', ') + ' only (' + A.CONTRACT + '). Ask for one of those, or update Flightdeck.');
761
+ const done = (keep, data, readAt) => { const e = A.envelope(app, readAt || new Date().toISOString(), keep(ledger()), data); return { code: e.complete ? 0 : EXIT.partial, json: e }; };
762
+ try {
763
+ if (req.cmd === 'run' || req.cmd === 'next') {
764
+ const m = RUN_KEY.exec(req.key || '');
765
+ relevant = runCovers(m[1], m[2], null);
766
+ const one = await repo.readRunOnly(L.fs, m[1], m[2]);
767
+ if (!one.found) return notFound('There\'s no run ' + req.key + ' in the team folder.');
768
+ const r = one.run, key = m[1] + '/' + m[2];
769
+ if (one.nofile) L.note('runs/' + key + '/run.json', 'nofile', 'The run folder has no run.json.');
770
+ take(one.bad.map((b) => ({ path: b.path, kind: b.kind || 'unreadable', why: fileWhy(b.why) })));
771
+ take(one.fuUnread.map((u) => ({ path: u.path, kind: u.kind || 'malformed', why: fileWhy(u.why) })));
772
+ one.fuDecisions.forEach((x) => { const p = decisionDrift(x); if (p) take([p]); });
773
+ if (one.peopleWhy) L.note('people.json', one.peopleKind || 'unreadable', fileWhy(one.peopleWhy));
774
+ const pw = peopleWhy(one.people);
775
+ if (pw) L.note('people.json', 'malformed', pw);
776
+ if (r) { relevant = runCovers(m[1], m[2], r); take(judgeRun(PF, r, one.pub)); }
777
+ const usable = !!r && !newerRun(PF, r);
778
+ /* run and next read only what they depend on (readRunOnly), so every problem met counts. */
779
+ const keep = (list) => list, nameOf = A.names(one.people);
780
+ if (req.cmd === 'run') return done(keep, usable ? A.run(r, F.list([r], one.fuDecisions), nameOf) : { run: null });
781
+ return done(keep, usable ? A.next(r, nameOf, req.person || null) : { run: key, person: req.person ? nameOf(req.person) : null, next: null, mine: null });
782
+ }
783
+ /* Each command reads only what its output depends on (needs()), so every problem the ledger holds is one that output
784
+ depends on, and all of them are reported: there's no scope to fall behind what's read. */
785
+ relevant = covers(needs(req));
786
+ const d = await repo.loadAll(dependsOn(L.fs, needs(req)), { readOnly: true, explain: true, deep: true });
787
+ take(folderProblems(PF, d));
788
+ /* Every run judged as run judges it, against its own version (runs reads the versions; followups doesn't need them). */
789
+ const pubOf = (r) => { const proc = d.processes.filter((x) => x.process === r.process)[0], p = proc && proc.pubs.filter((x) => x.folder === r.folder)[0]; return p || missingVersion(PF, r); };
790
+ d.runs.forEach((r) => take(judgeRun(PF, r, req.cmd === 'runs' ? pubOf(r) : null)));
791
+ const nameOf = A.names(d.people), runs = d.runs.filter((r) => !newerRun(PF, r)), all = (list) => list;
792
+ const known = (p) => d.processes.some((x) => x.process === p) || d.runs.some((r) => r.process === p) || d.plans.some((x) => x.process === p);
793
+ if (req.proc && !known(req.proc)) return notFound('There\'s no process ' + req.proc + ' in the team folder.');
794
+ if (req.cmd === 'processes') return done(all, A.processes(d.processes), d.readAt);
795
+ if (req.cmd === 'process') {
796
+ const proc = d.processes.filter((x) => x.process === req.proc)[0];
797
+ if (!proc || !proc.pubs.length) return notFound('There\'s no published checklist for ' + req.proc + ' in the team folder.');
798
+ let pub = null;
799
+ const vOf = (p) => (p.info && typeof p.info.version === 'number' ? p.info.version : PF.publish.versionOf(p.folder));
800
+ if (req.folder) pub = proc.pubs.filter((p) => p.folder === req.folder)[0];
801
+ else if (req.version != null) pub = proc.pubs.filter((p) => vOf(p) === +req.version).sort((a, b) => (b.ok ? 1 : 0) - (a.ok ? 1 : 0) || (a.folder < b.folder ? -1 : 1))[0];
802
+ else pub = proc.index.latest || proc.pubs.slice().sort((a, b) => (vOf(b) || 0) - (vOf(a) || 0))[0];
803
+ if (!pub) return notFound('There\'s no version ' + (req.folder || req.version) + ' of ' + req.proc + ' in the team folder.');
804
+ return done(all, A.process(proc, pub), d.readAt);
805
+ }
806
+ if (req.cmd === 'runs') {
807
+ const st = req.status || 'all', mine = (p) => !req.proc || p === req.proc;
808
+ const list = runs.filter((r) => mine(r.process) && (st === 'all' || (st === 'running' && !r.ended) || (st === 'ended' && !!r.ended)));
809
+ const plans = st === 'all' || st === 'planned' ? d.plans.filter((p) => mine(p.process)) : [];
810
+ /* Which version a plan starts on is unknown (null), not missing, when any of its process's versions couldn't be read or
811
+ verified: only problems with the versions themselves count (a run that doesn't match its version is named at the run). */
812
+ const probs = ledger(), unsure = (proc) => probs.some((x) => ['', 'publications', 'publications/'].indexOf(x.path) >= 0 || x.path === 'publications/' + proc || x.path.indexOf('publications/' + proc + '/') === 0);
813
+ return done(all, A.runs(list, plans, nameOf, d.processes, unsure), d.readAt);
814
+ }
815
+ /* followups: every run's follow-ups come from its run.json, so any run that couldn't be read may hide some. */
816
+ const items = F.list(runs.filter((r) => !req.proc || r.process === req.proc), d.fuDecisions).filter((x) => req.status !== 'open' || F.isOpen(x));
817
+ return done(all, A.followUps(items, nameOf), d.readAt);
818
+ } catch (e) {
819
+ return err(EXIT.error, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e)));
820
+ }
821
+ }
822
+
823
+ /* The command line around readData: flags, the folder, the engine, and JSON out. Usage problems before any reading exit 1. */
824
+ async function readMain(argv) {
825
+ const VAL = ['--process', '--status', '--person', '--version', '--folder', '--contract'], val = {};
826
+ const usage = (why) => ({ out: JSON.stringify(failureJson(null, 'usage', why), null, 2), code: EXIT.error });
827
+ const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
828
+ const cmd = args[0], asJson = argv.includes('--json');
829
+ for (const f of VAL) {
830
+ const i = argv.indexOf(f);
831
+ if (i < 0) continue;
832
+ if (!argv[i + 1] || argv[i + 1].startsWith('--')) { if (asJson) return usage('say what goes after ' + f + '.'); fail('say what goes after ' + f); }
833
+ val[f] = argv[i + 1];
834
+ }
835
+ if (!asJson) fail(cmd + ' prints JSON for other apps. Add --json, for example: flightdeck ' + cmd + ' <team folder> --json');
836
+ const req = { cmd, proc: val['--process'] || null, status: val['--status'] || null, person: val['--person'] || null, version: val['--version'] || null, folder: val['--folder'] || null, contract: val['--contract'] || null };
837
+ if (cmd === 'process') { req.proc = args[2]; if (!req.proc || !PROC_ID.test(req.proc)) return usage('say which process, for example: flightdeck process <team folder> release --json'); }
838
+ if (cmd === 'run' || cmd === 'next') { req.key = args[2]; if (!req.key || !RUN_KEY.test(req.key)) return usage('say which run as <process>/<run id>, for example: flightdeck ' + cmd + ' <team folder> release/k3m9p2q8 --json'); }
839
+ if (req.proc && !PROC_ID.test(req.proc)) return usage('a process ID is lowercase letters, numbers and dashes.');
840
+ if (req.version != null && !/^\d{1,6}$/.test(req.version)) return usage('--version is a version number, like 3.');
841
+ if (req.folder != null && !/^v\d+-[a-z0-9]+$/.test(req.folder)) return usage('--folder is a version folder, like v3-k3m9p2q8.');
842
+ if (req.contract != null && !/^\d{1,4}$/.test(req.contract)) return usage('--contract is a contract number, like 1.');
843
+ const statuses = cmd === 'runs' ? ['running', 'planned', 'ended', 'all'] : cmd === 'followups' ? ['open', 'all'] : null;
844
+ if (req.status != null && (!statuses || statuses.indexOf(req.status) < 0)) return usage(statuses ? '--status is one of: ' + statuses.join(', ') + '.' : '--status works with runs and followups.');
845
+ let eng, dir;
846
+ try { eng = loadEngine(); dir = folderArg(args[1], cmd); }
847
+ catch (e) { return { out: JSON.stringify(failureJson(eng, 'failed', e.message), null, 2), code: EXIT.error }; }
848
+ try {
849
+ const r = await readData(eng, readAdapter(dir, { maxBytes: READ_MAX }), req);
850
+ return { out: JSON.stringify(r.json, null, 2), code: r.code };
851
+ } catch (e) {
852
+ return { out: JSON.stringify(failureJson(eng, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e))), null, 2), code: EXIT.error };
853
+ }
854
+ }
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
+
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
+
574
1051
  const HELP = `flightdeck: team process checklists, in one HTML file
575
1052
 
576
1053
  flightdeck open open the app (it starts on built-in examples)
@@ -584,6 +1061,28 @@ const HELP = `flightdeck: team process checklists, in one HTML file
584
1061
  flightdeck export <team folder> --process <process> --from YYYY-MM-DD --through YYYY-MM-DD [--tz Area/City]
585
1062
  make one of the runs started on those days, both included, in --tz
586
1063
  (this computer's time zone unless you say)
1064
+ flightdeck processes <team folder> --json
1065
+ for other apps: every process and its versions, as JSON
1066
+ flightdeck process <team folder> <process> [--version N | --folder v3-...] --json
1067
+ one version's phases, steps, gates and evidence (the latest usable one
1068
+ unless you say)
1069
+ flightdeck runs <team folder> [--process <id>] [--status running|planned|ended|all] --json
1070
+ runs and planned runs
1071
+ flightdeck run <team folder> <process>/<run id> --json
1072
+ one run: each step's state, who and when, its evidence, gates, follow-ups
1073
+ flightdeck next <team folder> <process>/<run id> [--person <id>] --json
1074
+ what's waiting in a run and on whom (with --person, that person's next step)
1075
+ flightdeck followups <team folder> [--process <id>] [--status open|all] --json
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
587
1086
  flightdeck path print where the app file is
588
1087
  flightdeck version print this version
589
1088
 
@@ -593,6 +1092,13 @@ const HELP = `flightdeck: team process checklists, in one HTML file
593
1092
  --force replace a newer app, an unrecognized file, or contract files your team changed
594
1093
  --no-attachments export: list attachments with their sizes instead of packing them
595
1094
 
1095
+ The JSON commands only read: they never change a file in the team folder. Exit code 0: complete; 4: partial (the data
1096
+ is printed, and "problems" names every file or folder that couldn't be read); 1: no data; 5: no such process, version or
1097
+ run. People-directory email fields are omitted. See docs/apps.md in the Flightdeck repository.
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
+
596
1102
  export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
597
1103
  separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
598
1104
 
@@ -603,6 +1109,10 @@ default goes to .flightdeck/updates/<version>/, with a README.md to hand to your
603
1109
 
604
1110
  function main(argv) {
605
1111
  const out = [];
1112
+ /* Reading for other apps has flags of its own (--version takes a number there), so it's parsed apart. */
1113
+ const first = argv.filter((a) => !a.startsWith('--'))[0];
1114
+ if (READS.indexOf(first) >= 0 && !argv.includes('--help')) return readMain(argv);
1115
+ if (first === 'draft' && !argv.includes('--help')) return draftMain(argv);
606
1116
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
607
1117
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
608
1118
  /* Flags that take a value: the value is never read as a command or a folder. */
@@ -720,4 +1230,4 @@ if (require.main === module) {
720
1230
  if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
721
1231
  } catch (e) { failed(e); }
722
1232
  }
723
- module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, engine };
1233
+ module.exports = { _hooks, main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, EXIT };