@troylar/flightdeck 2.12.2 → 2.13.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,18 @@
2
2
 
3
3
  <!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
4
4
 
5
+ ## 2.13.0 · Nov 2, 2026
6
+
7
+ Your AI assistant checks a draft exactly as Review & publish does.
8
+
9
+ ### New
10
+
11
+ - **flightdeck draft check** (AI assistants). flightdeck draft check gives an assistant the verdict Review & publish gives a draft: what stops publishing, the tips, and what Publish would refuse, screenshots and version: pins included. It only reads. A part of the folder it couldn't read is named, and never becomes a finding. *Where:* docs/apps.md › draft check
12
+
13
+ ### Fixed
14
+
15
+ - **An assistant's draft checks are complete** (AI assistants). draft read, save and propose load what Review & publish loads and give the same words. AGENTS.md and the review, new-checklist and edit-checklist skills say so and use draft check. Update with flightdeck install or Settings; Team notes stay as they are. *Where:* Settings › AI assistant files
16
+
5
17
  ## 2.12.2 · Nov 2, 2026
6
18
 
7
19
  AGENTS.md says what an assistant's draft checks really cover.
package/README.md CHANGED
@@ -25,7 +25,7 @@
25
25
  ## Quick start
26
26
 
27
27
  1. `npx @troylar/flightdeck open` opens the app in your browser (needs Node 18 or later).
28
- 2. No Node? Save [flightdeck.html](https://unpkg.com/@troylar/flightdeck@2.12.2/dist/flightdeck.html) from the npm package as a file (right-click, **Save link as…**) and open that file in Edge or Chrome.
28
+ 2. No Node? Save [flightdeck.html](https://unpkg.com/@troylar/flightdeck@2.13.0/dist/flightdeck.html) from the npm package as a file (right-click, **Save link as…**) and open that file in Edge or Chrome.
29
29
  3. Choose **Look around the examples** to try it, or **Start your team** to set up your team's folder.
30
30
 
31
31
  <p align="center"><a href="https://github.com/troylar/flightdeck/blob/main/docs/images/run-light.png" title="Open in GitHub's image viewer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/troylar/flightdeck/main/docs/images/run-dark.png"><img src="https://raw.githubusercontent.com/troylar/flightdeck/main/docs/images/run-light.png" alt="A release in progress on the run screen: 7/15 answered in the header with Details, Screen share, Receipt and More; a strip saying you're recording, with Pause and Hand off and the run's details; Phases with each gate's state; the checklist with Next up pinned at its top; and the selected step, whose text shows the run's tag (Start the production deploy job v2026.10.16), with the values to copy, Record the result, Done, Done differently and Blocked" width="1000"></picture></a></p>
package/bin/flightdeck.js CHANGED
@@ -500,6 +500,9 @@ function loadEngine() {
500
500
  read, and named in skipped. */
501
501
  function readAdapter(root, opts) {
502
502
  const NOFOLLOW = fs.constants.O_NOFOLLOW || 0, links = [], skipped = [], max = (opts && opts.maxBytes) || 0;
503
+ /* vanished: a file or folder that was there when its path was walked and gone when it was opened (a sync or another program
504
+ moved it in between). The read gives nothing, as for a file that isn't there, and the path is named. */
505
+ const vanished = [], gone = (rel) => { if (vanished.indexOf(rel) < 0) vanished.push(rel); };
503
506
  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; }
504
507
  function skipErr(rel, bytes, cap, more) {
505
508
  const e = new Refused(rel + ' is ' + (more ? 'more than ' : '') + size(bytes) + ', over the ' + size(cap) + ' this command reads');
@@ -562,7 +565,7 @@ function readAdapter(root, opts) {
562
565
  const f = fileAt(p);
563
566
  if (!f) return null;
564
567
  let fd;
565
- try { fd = fs.openSync(f.full, fs.constants.O_RDONLY | NOFOLLOW); } catch (e) { if (e.code === 'ELOOP') throw linkErr(p); if (e.code === 'ENOENT') return null; throw e; }
568
+ try { fd = fs.openSync(f.full, fs.constants.O_RDONLY | NOFOLLOW); } catch (e) { if (e.code === 'ELOOP') throw linkErr(p); if (e.code === 'ENOENT') { gone(p); return null; } throw e; }
566
569
  try {
567
570
  const st = fs.fstatSync(fd), now = lstatOrNull(path.join(sameWalk(f.a, f.ids, p), f.name));
568
571
  if (!st.isFile() || st.ino !== f.st.ino || st.dev !== f.st.dev || !now || now.isSymbolicLink() || now.ino !== st.ino || now.dev !== st.dev) throw linkErr(p);
@@ -570,13 +573,13 @@ function readAdapter(root, opts) {
570
573
  } catch (e) { fs.closeSync(fd); throw e; }
571
574
  }
572
575
  return {
573
- links, skipped,
576
+ links, skipped, vanished,
574
577
  list: (p) => new Promise((res) => {
575
578
  const a = partsOf(p), dir = dirAt(a);
576
579
  /* No such folder: empty, and marked missing, so a reader that saw it listed can tell gone from empty. */
577
580
  if (!dir) { const none = []; none.missing = true; return res(none); }
578
581
  let entries;
579
- try { entries = fs.readdirSync(dir.cur, { withFileTypes: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; const none = []; none.missing = true; return res(none); }
582
+ try { entries = fs.readdirSync(dir.cur, { withFileTypes: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; gone(p || '.'); const none = []; none.missing = true; return res(none); }
580
583
  sameWalk(a, dir.ids, p || '.');
581
584
  res(entries.map((e) => {
582
585
  const rel = (p ? p + '/' : '') + e.name;
@@ -807,7 +810,8 @@ function folderProblems(PF, d) {
807
810
  }
808
811
  function adapterProblems(adapter) {
809
812
  return (adapter.links || []).map((p) => ({ path: p, kind: 'link', why: 'A link. Flightdeck never reads through links.' }))
810
- .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) + ').' })));
813
+ .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) + ').' })))
814
+ .concat((adapter.vanished || []).map((p) => ({ path: p, kind: 'vanished', why: 'It was there a moment ago and was gone when it was read. Try again.' })));
811
815
  }
812
816
 
813
817
  /* What a whole-folder command's output depends on, as folders and files: its data, the publications that say whether a
@@ -1514,6 +1518,8 @@ async function draftState(eng, dir, proc, session) {
1514
1518
  try {
1515
1519
  const f = await rfs.read(dpath, { maxBytes: PF.util.DRAFT_BYTES });
1516
1520
  if (f) Object.assign(draft, { exists: true, rev: sha(f.text), text: f.text });
1521
+ /* Gone between seeing it and opening it: there, but unread (named), so nothing takes it as absent. */
1522
+ else if ((ra.vanished || []).indexOf(dpath) >= 0) { draft.exists = true; L.note(dpath, 'vanished', 'It was there a moment ago and was gone when it was read. Try again.'); }
1517
1523
  } catch (e) { draft.exists = true; }
1518
1524
  const copies = [];
1519
1525
  try {
@@ -1566,17 +1572,116 @@ function writeNew(dir, parts, text) {
1566
1572
  if (!fs.readFileSync(target).equals(data)) fail(target + ' didn\'t read back as written');
1567
1573
  return parts.join('/');
1568
1574
  }
1575
+ /* Everything Review & publish checks a draft's text against, read now through one problem ledger over a read-only view of
1576
+ the folder, with the app's own readers: people.json, the team's step types, jira/workflows.json, every process's published
1577
+ versions (which are published, the children linked steps start, the parents that start this one), the team values a
1578
+ linked step names and the screenshots the text shows. Then PF.publish.checkContext and publishCheck, as the app's checks.
1579
+ What couldn't be read (or a newer Flightdeck wrote) is a problem, which makes the answer partial, and that part is left
1580
+ unchecked: it never becomes a blocker, a missing person or an unpublished process. Returns { verdict, problems }. */
1581
+ async function draftInputs(eng, dir, text) {
1582
+ const PF = eng.PF, ra = readAdapter(dir, { maxBytes: READ_MAX }), L = PF.repo.ledger(PF.repo.readOnly(ra, 'Checking a draft'));
1583
+ /* The ledger's problems and the adapter's (links, files too big, files that vanished), each once. */
1584
+ const everything = () => { const out = L.problems(), seen = {}; out.forEach((x) => { seen[x.path + '\u0000' + x.kind] = true; }); adapterProblems(ra).forEach((x) => { if (!seen[x.path + '\u0000' + x.kind]) out.push(x); }); return out; };
1585
+ /* A failure that stops the read at any stage (the team folder can't be listed) still names every problem met before it. */
1586
+ try { return await readInputs(eng, text, ra, L, everything); } catch (e) { if (e && typeof e === 'object') e.problems = everything(); throw e; }
1587
+ }
1588
+ async function readInputs(eng, text, ra, L, everything) {
1589
+ const PF = eng.PF, repo = PF.repo, M = PF.map, rfs = L.fs;
1590
+ const map = M.parse(text), failedAt = (p) => L.problems().some((x) => x.path === p || x.path.indexOf(p + '/') === 0);
1591
+ /* The app's own loader, limited to these parts: what each part's reader gives, or the part named in failed. */
1592
+ const deps = [PF.people.FILE, 'step-types', 'jira/workflows.json', 'publications'];
1593
+ const d = await repo.loadAll(dependsOn(rfs, deps), { readOnly: true, explain: true, deep: true });
1594
+ const failed = d.failed || {};
1595
+ /* First every read, with what each loader refused noted in the ledger; then the adapter's own problems (links, files too
1596
+ big); then PF.publish.unknownOf decides what's unknown from that whole list, and nothing else does. */
1597
+ const PARTS = { people: PF.people.FILE, 'step types': 'step-types', 'Jira workflows': 'jira/workflows.json', publications: 'publications' };
1598
+ Object.keys(PARTS).forEach((k) => { if (k in failed && !failedAt(PARTS[k])) L.note(PARTS[k], /newer/.test(failed[k]) ? 'newer' : 'unreadable', failed[k]); });
1599
+ const people = 'people' in failed ? null : d.people;
1600
+ if (people && people.text != null && people.doc == null) L.note(PF.people.FILE, 'malformed', PF.people.FILE + ' isn\'t valid JSON.');
1601
+ const types = 'step types' in failed ? {} : d.stepTypes.types;
1602
+ if (!('step types' in failed)) {
1603
+ (d.stepTypes.refused || []).forEach((r) => L.note(r.path, 'newer', r.why));
1604
+ }
1605
+ /* What a loader left out over its limit, from its own read (PF.publish.excludedOf): each named, and unknown below. */
1606
+ PF.publish.excludedOf({ stepTypes: 'step types' in failed ? null : d.stepTypes, jira: 'Jira workflows' in failed ? null : d.jira }).forEach((x) => L.note(x.path, x.kind, x.why));
1607
+ const processes = 'publications' in failed ? [] : d.processes.map((p) => {
1608
+ /* A version that read but can't be used is named as READ names one; a failed read the ledger already has. */
1609
+ p.pubs.filter((x) => !x.ok && !x._readFailed).forEach((x) => { const k = PF.appjson.pubProblem(x); L.note('publications/' + p.process + '/' + x.folder + '/', k.kind, k.why); });
1610
+ return { id: p.process, latest: p.index.latest, all: p.index.all, clashes: p.index.clashes, broken: p.pubs.filter((x) => !x.ok) };
1611
+ });
1612
+ /* Team values (2.9), read only for a text that passes one, as publishing reads them. */
1613
+ let teamValues;
1614
+ if (M.steps(map).some((x) => x.step.type === 'run.process' && PF.links.teams(x.step).length)) {
1615
+ teamValues = await repo.readTeamValues(rfs);
1616
+ if (teamValues.state !== 'ok' && teamValues.state !== 'missing' && !failedAt(PF.teamValues.FILE)) L.note(PF.teamValues.FILE, teamValues.state, teamValues.why || PF.teamValues.FILE + ' couldn\'t be read.');
1617
+ }
1618
+ /* Screenshots, read to the last byte as publishing reads them: one that isn't there is a blocker. */
1619
+ const shots = M.shotSources(map), missingShots = [];
1620
+ for (const src of shots) {
1621
+ try { const b = await rfs.blob(src); if (!b) missingShots.push(src); else await b.arrayBuffer(); } catch (e) { if (!failedAt(src)) L.note(src, 'unreadable', src + ' couldn\'t be read: ' + (e && e.message || String(e))); }
1622
+ }
1623
+ /* Only what the text uses counts: a problem with an input it doesn't use (a team-values.json link and no team: value)
1624
+ leaves the answer complete. */
1625
+ const used = PF.publish.usesOf(map), problems = everything().filter((x) => PF.publish.uses(used, x, shots));
1626
+ const ctx = PF.publish.checkContext({ people, types, workflows: d.jira ? d.jira.workflows : null, processes, teamValues, missingShots, unknown: PF.publish.unknownOf(problems, shots) });
1627
+ return { verdict: PF.publish.publishCheck(text, ctx), problems };
1628
+ }
1629
+ /* flightdeck draft check <team folder> <process> [--from <file | ->] --json (2.13): the verdict Review & publish gives the
1630
+ draft in maps/drafts/ (or the text given with --from), from the folder as it is now (draftInputs). Read-only; prints
1631
+ flightdeck-read/2 with SCHEMA2.draftCheck: exit 0, 4 when part of the folder couldn't be read, 5 when there's no draft,
1632
+ 1 for a usage problem or a draft that can't be read. */
1633
+ async function draftCheck(argv) {
1634
+ const VAL = ['--from'], args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
1635
+ let eng = null;
1636
+ const C2 = 2, appOf = () => (eng ? eng.version : versionOf(app()) || '');
1637
+ const failOut = (kind, why, problems, code) => { const F = eng ? eng.PF.appjson : null, json = F ? F.failure(appOf(), kind, why, problems || [], C2) : { contract: 'flightdeck-read/2', app: appOf(), error: { kind, why }, problems: [] }; return { out: JSON.stringify(json, null, 2), code: code || EXIT.error }; };
1638
+ const usage = (why) => failOut('usage', why);
1639
+ const fi = argv.indexOf('--from'), from = fi >= 0 ? argv[fi + 1] : null;
1640
+ if (fi >= 0 && (from == null || (from.startsWith('--') && from !== '-'))) return usage('say what goes after --from.');
1641
+ const unknown = argv.filter((a) => a.startsWith('--') && ['--json', '--from'].indexOf(a) < 0);
1642
+ if (unknown.length) return usage('draft check doesn\'t take ' + unknown.join(', ') + '.');
1643
+ if (!argv.includes('--json')) return usage('flightdeck draft prints JSON. Add --json.');
1644
+ const proc = args[3];
1645
+ if (!args[2] || !proc || args[4] || !PROC_ID.test(proc)) return usage('say the team folder and a process ID (lowercase letters, numbers and dashes), for example: flightdeck draft check <team folder> release --json');
1646
+ let dir;
1647
+ try { eng = loadEngine(); dir = folderArg(args[2], 'draft check'); } catch (e) { return failOut('failed', e.message); }
1648
+ const PF = eng.PF, A = PF.appjson;
1649
+ let text, said;
1650
+ if (from != null) {
1651
+ const input = inputText(PF, from, 'the text');
1652
+ if (input.why) return usage(input.why);
1653
+ if (PF.proposals.textProcess(input.text) !== proc) return usage('the text\'s process: line must say ' + proc + '.');
1654
+ text = input.text; said = { kind: 'file', ref: null };
1655
+ } else {
1656
+ const ra = readAdapter(dir, { maxBytes: READ_MAX }), L = PF.repo.ledger(PF.repo.readOnly(ra, 'Checking a draft')), dpath = 'maps/drafts/' + proc + '.md';
1657
+ let f = null;
1658
+ try { f = await L.fs.read(dpath, { maxBytes: PF.util.DRAFT_BYTES }); } catch (e) { return failOut('failed', dpath + ' couldn\'t be read, so nothing was checked. Try again.', L.problems().concat(adapterProblems(ra))); }
1659
+ /* A draft that was there a moment ago and gone when opened is unknown, never "no draft". */
1660
+ if (!f && (ra.vanished || []).indexOf(dpath) >= 0) return failOut('failed', dpath + ' was gone when it was read, so nothing was checked. Try again.', L.problems().concat(adapterProblems(ra)));
1661
+ if (!f) return failOut('not-found', 'There\'s no draft for "' + proc + '" in this folder (' + dpath + '). Pass the text to check with --from.', [], EXIT.notFound);
1662
+ text = f.text; said = { kind: 'draft', ref: dpath };
1663
+ }
1664
+ try {
1665
+ const got = await draftInputs(eng, dir, text);
1666
+ const data = { process: proc, from: { kind: said.kind, ref: said.ref, rev: PF.proposals.sha(text) }, checks: got.verdict.checks, publish: got.verdict.publish };
1667
+ const e = A.envelope(eng.version, new Date().toISOString(), got.problems, data, C2);
1668
+ return { out: JSON.stringify(e, null, 2), code: e.complete ? 0 : EXIT.partial };
1669
+ } catch (e) {
1670
+ return failOut('failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be read: ' + (e && e.message || String(e)), e && e.problems);
1671
+ }
1672
+ }
1569
1673
  async function draftMain(argv) {
1570
1674
  const VAL = ['--session', '--expect', '--from', '--base', '--label', '--by', '--note'], val = {};
1571
1675
  const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
1572
1676
  const sub = args[1], usage = (why) => draftFail(null, 'usage', why);
1677
+ if (sub === 'check') return draftCheck(argv);
1573
1678
  for (const f of VAL) {
1574
1679
  const i = argv.indexOf(f);
1575
1680
  if (i < 0) continue;
1576
1681
  if (argv[i + 1] == null || (argv[i + 1].startsWith('--') && argv[i + 1] !== '-')) return usage('say what goes after ' + f + '.');
1577
1682
  val[f] = argv[i + 1];
1578
1683
  }
1579
- if (['read', 'save', 'propose'].indexOf(sub) < 0) return usage('say read, save or propose, for example: flightdeck draft read <team folder> <process> --json');
1684
+ if (['read', 'save', 'propose'].indexOf(sub) < 0) return usage('say read, save, propose or check, for example: flightdeck draft read <team folder> <process> --json');
1580
1685
  if (!argv.includes('--json')) return usage('flightdeck draft prints JSON. Add --json.');
1581
1686
  const proc = args[3];
1582
1687
  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');
@@ -1585,30 +1690,8 @@ async function draftMain(argv) {
1585
1690
  try { eng = loadEngine(); dir = folderArg(args[2], 'draft ' + sub); } catch (e) { return draftFail(eng, 'failed', e.message); }
1586
1691
  const PF = eng.PF, PP = PF.proposals, U = PF.util, M = PF.map, now = Date.now();
1587
1692
  const session = val['--session'] || 's-' + U.randomId(12);
1588
- /* The same checks the app shows, with each linked step's child read from publications/ (2.8), so what a step passes is
1589
- checked against the child as it is in the folder. */
1590
- const checksOf = async (t) => {
1591
- const map = M.parse(t), procs = {}, names = [];
1592
- M.steps(map).forEach((x) => { const f = x.step.fields || {}; if (x.step.type === 'run.process' && f.process && names.indexOf(f.process) < 0) names.push(f.process); });
1593
- /* The children are read through a problem ledger of their own, so a child that couldn't be read makes the answer partial. */
1594
- const L = PF.repo.ledger(readAdapter(dir, { maxBytes: READ_MAX })), rfs = L.fs;
1595
- for (const n of names) {
1596
- try {
1597
- const p = await PF.repo.readProcess(rfs, n);
1598
- if (p) procs[n] = p;
1599
- /* A version that read but can't be used is named as READ names one; a failed read the ledger already has. */
1600
- (p ? p.broken : []).forEach((x) => { if (!x._readFailed) { const k = PF.appjson.pubProblem(x); L.note('publications/' + n + '/' + x.folder + '/', k.kind, k.why); } });
1601
- } catch (e) { procs[n] = { latest: null, all: [], clashes: [], broken: [], unlisted: 'publications/' + n + '/ couldn\'t be read' }; }
1602
- }
1603
- /* Team values (2.9), through the same ledger: a failed read is noted by the ledger; damaged or newer content here. */
1604
- let tv;
1605
- if (M.steps(map).some((x) => x.step.type === 'run.process' && PF.links.teams(x.step).length)) {
1606
- tv = await PF.repo.readTeamValues(rfs);
1607
- if (tv.state === 'malformed' || tv.state === 'newer') L.note(PF.teamValues.FILE, tv.state, tv.why);
1608
- }
1609
- const c = M.check(map, { people: [], types: {}, workflows: {}, processes: [], children: PF.links.childrenFrom(procs), teamValues: tv });
1610
- return { checks: { blockers: c.blockers, suggestions: c.suggestions }, problems: L.problems() };
1611
- };
1693
+ /* The same checks the app shows, on the folder as it is now (draftInputs), through the same functions. */
1694
+ const checksOf = async (t) => { const got = await draftInputs(eng, dir, t); return { checks: got.verdict.checks, publish: got.verdict.publish, problems: got.problems }; };
1612
1695
  /* Every problem once: the draft's reads, then the children's. */
1613
1696
  const merged = (a, b) => { const seen = {}, out = []; a.concat(b).forEach((p) => { const k = p.path + '\u0000' + p.kind; if (!seen[k]) { seen[k] = true; out.push(p); } }); return out; };
1614
1697
  try {
@@ -1620,7 +1703,9 @@ async function draftMain(argv) {
1620
1703
  const cf = pending ? { rev: pending.rev, kind: 'proposal', ref: pending.id, text: pending.text }
1621
1704
  : s.draft.exists && s.draft.text != null ? { rev: s.draft.rev, kind: 'draft', ref: s.draft.path, text: s.draft.text }
1622
1705
  : latest ? { rev: latest.rev, kind: 'version', ref: latest.folder, text: latest.text } : { rev: PP.sha(''), kind: 'none', ref: null, text: '' };
1623
- const ck = await checksOf(cf.text), problems = merged(s.problems, ck.problems);
1706
+ /* The draft is there but couldn't be read, and nothing else continues it: its text is unknown, so nothing is checked. */
1707
+ const unread = !pending && s.draft.exists && s.draft.text == null;
1708
+ const ck = unread ? { checks: { blockers: [], suggestions: [] }, problems: [] } : await checksOf(cf.text), problems = merged(s.problems, ck.problems);
1624
1709
  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 })),
1625
1710
  continueFrom: cf, checks: ck.checks, copies: s.copies, readAt: new Date(now).toISOString(), complete: problems.length === 0, problems });
1626
1711
  return { out: JSON.stringify(out, null, 2), code: out.complete ? 0 : EXIT.partial };
@@ -1641,7 +1726,9 @@ async function draftMain(argv) {
1641
1726
  const base = PP.baseFor({ expect: val['--expect'], draft: s.draft, mine: s.mine, versions: s.versions, baseText, afterEarlier: argv.includes('--after-earlier') });
1642
1727
  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.');
1643
1728
  const operation = PP.newId(now, U.randomId(10)), at = new Date(now).toISOString();
1644
- 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: (await checksOf(text)).checks, warnings: [], history: null };
1729
+ 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: null, warnings: [], history: null, complete: true, problems: [] };
1730
+ const ck = await checksOf(text), problems = merged(s.problems, ck.problems);
1731
+ Object.assign(result, { checks: ck.checks, complete: problems.length === 0, problems });
1645
1732
  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.');
1646
1733
  let why = sub === 'propose' ? 'asked' : s.presence.open ? 'open' : null;
1647
1734
  const draftNow = s.draft.exists ? s.draft.text : null, expectText = base.kind === 'none' || base.kind === 'version' ? null : base.text;
@@ -1684,7 +1771,7 @@ async function draftMain(argv) {
1684
1771
  Object.assign(result, { outcome: 'proposed', why });
1685
1772
  return { out: JSON.stringify(draftJson(eng, result), null, 2), code: 0 };
1686
1773
  } catch (e) {
1687
- return draftFail(eng, 'failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be changed: ' + (e && e.message || String(e)));
1774
+ return draftFail(eng, 'failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be changed: ' + (e && e.message || String(e)), EXIT.error, e && e.problems);
1688
1775
  }
1689
1776
  }
1690
1777
 
@@ -1735,6 +1822,9 @@ const HELP = `flightdeck: team process checklists, in one HTML file
1735
1822
  read; otherwise write a proposal a person applies in Flightdeck
1736
1823
  flightdeck draft propose <same as save>
1737
1824
  always write a proposal
1825
+ flightdeck draft check <team folder> <process> [--from <file|->] --json
1826
+ the verdict Review & publish gives the draft (or the text in the file): what
1827
+ stops publishing and the tips, from the folder as it is now. Only reads
1738
1828
  flightdeck skill register <team folder> --from <file|-> [--json]
1739
1829
  for AI assistants: register a skill against the exact version it was built
1740
1830
  from (Flightdeck 2.0). The only writer of skill-registrations/