@troylar/flightdeck 1.22.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  <!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
4
4
 
5
+ ## 2.0.1 · Oct 22, 2026
6
+
7
+ Each run counts once, and a sign-off or correction counts only from the exact person who made it.
8
+
9
+ ### Fixed
10
+
11
+ - **A copy of a run in another run folder no longer counts twice** (Runs). A run.json counts only in its own folder (runs/<process>/<run ID>/). A copy anywhere else is listed with the files that can't be read, with where it belongs, and is never counted or opened. Nothing is removed: move or delete the copy yourself. *Where:* Folder health and Refresh list the copy. Evidence packs include such a folder as it is and say so
12
+ - **Sign-offs count only from the owner's exact ID** (Skills). The ID on a sign-off, and the process and version it names, are compared exactly. The same goes for who wrote a correction, so only its author can withdraw or revise it. A run that names someone by an ID over 80 characters opens read-only. *Where:* A process page, Ready for a skill?
13
+ - **Only runs read now count toward a sign-off** (Skills). Such a run stays on screen, with any change you haven't saved, but isn't counted toward a sign-off until its file reads again. *Where:* A process page, Ready for a skill?, and Home
14
+
15
+ ## 2.0.0 · Oct 21, 2026
16
+
17
+ Typed values, exact versions and reports from tools, in a lighter Flightdeck. Move every computer to 1.21 or later first.
18
+
19
+ ### New
20
+
21
+ - **Numbers, yes/no, dates, choices and links** (Checklists). A run detail can be a number, yes/no, date, choice or link (context: rows:number, due:date), and a step can ask for a number, yes/no or date as evidence. Each gets the right box and is checked as it's typed. Text from a plan or a parent run converts only when that's certain; anything else waits for a person, and nothing that uses it goes ahead. A checklist without typed values works as before. *Where:* The start form, the run's Details, evidence boxes and the step-type editor
22
+ - **Contract 2 reads Flightdeck 2.0 runs and versions** (Reading for other apps). flightdeck run, runs and process with --contract 2 read 2.0 versions and runs, with each value's kind, its stored text, a typed value and any conversion record. --contract 1 prints exactly what it did before.
23
+ - **Notes and screenshots on phases and gates** (Checklists). A phase can have a note and screenshots right under its heading, and a gate can have screenshots under its note. Runs show them in the phase header and the gate's panel. *Where:* The checklist editor and the run screen
24
+ - **Suggest a change to a phase's note or a gate's** (Corrections). Anyone viewing a run can suggest new wording for a phase's note or a gate's. It is decided and applied to the draft like a step's correction. Teammates on Flightdeck 1.13 to 1.22 see it as a described problem.
25
+ - **Run an exact version of another checklist** (Checklists). A {run.process} step can say version: N to run exactly that version. A run of a version that uses something new in 2.0 also keeps every other linked checklist on the version that was latest when it started, even after a newer one is published. If a pinned version can't be used, the step says why instead of starting another one. Other runs start linked runs on the latest, as before. *Where:* Checklists and the run screen
26
+ - **Continue an older run with Flightdeck 2.0** (Runs). When a run made before 2.0 reaches a step whose linked checklist needs Flightdeck 2.0, it offers Start it as a Flightdeck 2.0 run. The old step is marked Continued in run <key>, which teammates on older copies read too.
27
+ - **Plans that start on an exact version** (Plans). A plan for a version that uses something new in 2.0 starts on that version and nothing else. If it can't be used, the plan asks, and Start on vN instead switches it on purpose. Other plans work as before.
28
+ - **Register skills with flightdeck skill register** (Skills). flightdeck skill register writes the skill's registration for a version made with Flightdeck 2.0, and a {run.skill} step can say skillVersion: to expect one. Checking the step records which registration was current, with a warning if it isn't the expected one.
29
+ - **See reports from tools on each step, and accept or decline them** (Runs). A tool or script that did a step's work can write a report with flightdeck report write. The step shows what the report says, what a person decided, and whether its inputs match the run. The recorder can accept a passing report, which checks the step with exactly its evidence, or decline it with a reason. *Where:* The selected step in a run, and Reports beside Follow-ups
30
+
31
+ ### Improved
32
+
33
+ - **Before anyone uses 2.0 features, move every computer to 1.21 or later** (Updating). Put Flightdeck 1.21 or later on every computer that opens the team folder, then Flightdeck 2.0, before anyone uses a 2.0 feature. Copies from 1.11.1 on never change a 2.0 file, but they can't run a 2.0 version or record on a 2.0 run. Copies before 1.11.1 can't tell a 2.0 file at all. A checklist that uses nothing new publishes as before, and every teammate runs it. *Where:* Folder health names older copies it saw in the last 7 days. Steps: Updating Flightdeck, Moving a team to Flightdeck 2.0
34
+ - **Gate notes now show in runs** (Runs). A gate's note shows above Pass the gate, for every version, including versions published before 2.0.
35
+ - **The publish panel says when a version needs Flightdeck 2.0** (Publishing). When a checklist uses something new in 2.0, the publish panel names it and says what that means: teammates on Flightdeck 1.22 or earlier see the version but can't run it. A checklist that uses nothing new publishes as before, and every teammate runs it.
36
+ - **Evidence checks pins and the published payload** (Evidence). Evidence for a run made with Flightdeck 2.0 checks that its version's pins and payload match what was published, and step fingerprints include the version each step runs.
37
+ - **Receipts and Evidence show which reports were accepted** (Receipt). The receipt names the report a step was accepted from, with its hash, and lists every report on the run. Evidence export packs the run's reports and the decisions on them.
38
+ - **A process page shows its owner's panels only to its owner** (Processes). Corrections, Ready for a skill? and Skills are one closed line each for everyone but the checklist's owner and backup owner, saying what they hold ("Corrections · 2 open"). Open one to see it all, as before. A warning in Corrections is never folded. Versions shows the newest, with the rest under Earlier versions, and Recent runs shows 3, with All runs of this process. *Where:* A process page
39
+ - **Home lists only what's yours** (Home). A draft with unpublished changes, and skills that need attention, show on the Home of the checklist's owner and backup owner. A checklist that names nobody still shows them to everyone. Drafts stay marked on Processes. At a glance shows running and processes.
40
+ - **Four sections in the top bar** (Navigation). Home, Runs, Processes, Settings. Skills and Step types open from the Processes page, from Settings (Library) and from search, and their links still work. The first-run welcome shows only its three choices.
41
+ - **A run says each thing once** (Runs). The keyboard hint shows once, in Next up; More → Keyboard shortcuts and ? still list every key. No start notice repeats the status strip, and the revision is in Details. A must-do step is marked with a red ! named "Must do". Timing shows only on a step the checklist times; otherwise Start is under More for this step. Correct this step explains itself on hover. *Where:* A run
42
+ - **Plain words for phase modes, details and outcomes** (Checklists). Phases say "Read, then do" and "Do, then confirm". A run detail in a step or gate shows as its name, like approves deploy of commit to environment. Runs that were canceled say Canceled. Only the words on screen changed: checklist files still say READ-DO and DO-CONFIRM, and run files and receipt text keep the outcome exactly as stored. *Where:* Process pages, runs, the builder, Finish, receipts and Insights
43
+ - **A flatter Processes list** (Processes). Tags fold into Filter by tag (N), which stays open once you open it. A group whose only child is a group shows as one heading ("Operations / Incidents"), and counts of 1 aren't drawn. Each row says its version and when it last ran. Tags are still on the process page, in search and in the filter.
44
+ - **New process asks for a title** (New process). Type a title and the file name is made from it, shown under it ("Saved as onboard-a-new-hire.md") and editable under More options with the group, tags, run details, roles, owner and "Usually run by one person". A taken or invalid name is refused as before, and the options open to show why. Start from Code check-in or Weekly review and publish v1 as it is, or copy a prompt for your AI assistant.
45
+ - **The builder says the save step** (Builder). A new draft says to fill in the steps, choose Save draft, then Done to publish it, and unsaved edits say "Unsaved: Save draft before you publish". Checks counts problems and warnings only; tips are still listed. The first version's change note starts as "First version". Done and saving work exactly as before.
46
+ - **The parts box starts with Phase, Gate, Step and Must-do** (Builder). The builder's parts box shows Phase, Gate, Step and Must-do, with every other part under All parts (N). A search still shows every match, and a step type that can't be used is never folded away.
47
+ - **Fill in opens itself** (Runs). Checking a Jira step that needs an issue type or status only Fill in holds opens that step's Fill in, puts the reason beside the box and focuses it, the way required evidence already does.
48
+ - **Finish shows only what has something in it** (Finish). With no follow-ups, the form is one Add a follow-up line that opens it; with no corrections, one line says No corrections. The three End buttons are unchanged.
49
+ - **Lighter Runs, Step types and Settings pages** (Runs). Runs shows the 8 newest finished runs with the rest under Earlier finished runs, Export evidence is in a More menu (still on each run's too), and cards don't repeat the version. Step types folds the built-in types, with each type's ID inside What it asks for. Settings puts Jira workflows and Keyboard under More settings, except that a Jira workflow problem keeps Jira workflows outside the fold.
50
+ - **Folder health folds what passed** (Folder health). The checks that passed fold into one line, All clear · N checks passed. Problems are never folded.
51
+
52
+ ### Fixed
53
+
54
+ - **Publish waits for edits that aren't saved** (Publishing). After Done with edits not saved yet, Publish is off and says to save or discard them first, so a version always has what you see.
55
+ - **A run you record shows once on Home** (Home). You're recording, with a separate line when reports wait for review.
56
+
5
57
  ## 1.22.0 · Oct 20, 2026
6
58
 
7
59
  Back and Forward work inside Flightdeck.
package/README.md CHANGED
@@ -80,7 +80,7 @@ To update later, run `flightdeck install` again: it updates what's still a Fligh
80
80
 
81
81
  ## A tour
82
82
 
83
- `flightdeck open` starts on a team folder with a month of history in it. Choose **Casey Nguyen** in the top right and follow along. Six sections sit in the top bar: **Home, Runs, Processes, Skills, Step types, Settings**.
83
+ `flightdeck open` starts on a team folder with a month of history in it. Choose **Casey Nguyen** in the top right and follow along. Four sections sit in the top bar: **Home, Runs, Processes, Settings**. **Skills** and **Step types** open from the Processes page, from Settings and from search.
84
84
 
85
85
  <table>
86
86
  <tr>
@@ -318,7 +318,7 @@ Beside the checklist: whether it's ready for a skill, its skills, its versions a
318
318
 
319
319
  Saving is careful. A save only goes through if the draft is still exactly what you opened. If a teammate or your AI assistant changed it meanwhile, nothing is saved: you see their version and choose to keep yours or take theirs. One limit: a shared folder can't lock a file, so a change that lands in the instant between that check and the write can still be replaced. Edit a draft with one person (or one AI assistant) at a time. Saving never publishes; publishing is a separate, logged step.
320
320
 
321
- **New process** on the Processes page asks for an ID, title, group, tags, run details, roles and owner, starts the draft, and opens it in the editor.
321
+ **New process** on the Processes page asks for a title; the file name is made from it and shown before you create it. The group, tags, run details, roles, owner and file name are under **More options**. Start blank, from Code check-in or Weekly review (publish v1 as it is, or open it in the editor), or copy a prompt for your AI assistant.
322
322
 
323
323
  <br>
324
324
 
@@ -402,6 +402,7 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
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
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 |
405
+ | `flightdeck report targets \| check \| write` | For other apps: report work a tool did on a step. `targets` lists running runs' steps and the evidence each records; `check` checks a report; `write` writes it once to `reports/`. A report never checks a step: the run's recorder accepts or declines it in the app |
405
406
  | `flightdeck path` | Prints where the app file is |
406
407
  | `flightdeck version` | Prints the version |
407
408
 
@@ -416,6 +417,8 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
416
417
 
417
418
  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).
418
419
 
420
+ `flightdeck report write` lets a tool say it did a step's work, in a report the step shows beside what a person decided and whether its inputs match the run. Accepting a passing report checks the step with exactly its evidence; the receipt and Evidence name the report and its hash. See *Reporting work* in [docs/apps.md](docs/apps.md).
421
+
419
422
  `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
423
 
421
424
  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).
package/bin/flightdeck.js CHANGED
@@ -765,16 +765,19 @@ async function readData(eng, adapter, req) {
765
765
  folder above what it reads included, are never its problems. */
766
766
  let relevant = () => true;
767
767
  const ledger = () => { take(adapterProblems(adapter)); return L.problems().filter((p) => relevant(p.path)); };
768
- const err = (code, kind, why) => ({ code, json: A.failure(app, kind, why, ledger()) });
768
+ const two = +req.contract === 2;
769
+ const err = (code, kind, why) => ({ code, json: A.failure(app, kind, why, ledger(), two ? 2 : 1) });
769
770
  const notFound = (why) => err(EXIT.notFound, 'not-found', why);
770
- 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.');
771
- 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 }; };
771
+ 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.VERSIONS.map(A.CONTRACT_OF).join(', ') + '). Ask for one of those, or update Flightdeck.');
772
+ const done = (keep, data, readAt) => { const e = A.envelope(app, readAt || new Date().toISOString(), keep(ledger()), data, two ? 2 : 1); return { code: e.complete ? 0 : EXIT.partial, json: e }; };
772
773
  try {
773
774
  if (req.cmd === 'run' || req.cmd === 'next') {
774
775
  const m = RUN_KEY.exec(req.key || '');
775
776
  relevant = runCovers(m[1], m[2], null);
776
777
  const one = await repo.readRunOnly(L.fs, m[1], m[2]);
777
778
  if (!one.found) return notFound('There\'s no run ' + req.key + ' in the team folder.');
779
+ /* Contract 1 shows what Flightdeck 1.x reads: a run/3 and a 2.0 version are named as newer. Contract 2 reads them. */
780
+ if (!two) { one.run = A.v1Run(one.run); one.pub = A.v1Pub(one.pub); }
778
781
  const r = one.run, key = m[1] + '/' + m[2];
779
782
  if (one.nofile) L.note('runs/' + key + '/run.json', 'nofile', 'The run folder has no run.json.');
780
783
  take(one.bad.map((b) => ({ path: b.path, kind: b.kind || 'unreadable', why: fileWhy(b.why) })));
@@ -787,13 +790,28 @@ async function readData(eng, adapter, req) {
787
790
  const usable = !!r && !newerRun(PF, r);
788
791
  /* run and next read only what they depend on (readRunOnly), so every problem met counts. */
789
792
  const keep = (list) => list, nameOf = A.names(one.people);
790
- if (req.cmd === 'run') return done(keep, usable ? A.run(r, F.list([r], one.fuDecisions), nameOf) : { run: null });
793
+ /* Contract 2 lists a run/3's migration records, current and archived (history-*.jsonl): a failed read is a problem. */
794
+ if (two && usable && r.format === PF.run.FORMAT3) {
795
+ const h = await repo.loadHistory(L.fs, r).catch((e) => { L.note('runs/' + key + '/', 'unreadable', fileWhy(e && e.message || String(e))); return []; });
796
+ take((h.refused || []).map((x) => ({ path: x.path, kind: x.kind === 'newer' ? 'newer' : 'malformed', why: x.why })));
797
+ [...new Set(h.malformed || [])].forEach((p) => L.note(p, 'malformed', 'This file has lines that aren\'t archived records, so some of the run\'s history may be missing.'));
798
+ /* An archived record is read only in its exact frozen shape (kind, av, then a migration record); any other is named. */
799
+ const rmap = PF.run.mapOf(r), MK = ['path', 'at', 'from', 'source', 'verdict', 'result', 'resolved'];
800
+ r._archivedMigrations = [];
801
+ h.filter((x) => x.kind === 'migration').forEach((x) => {
802
+ const m = {}; Object.keys(x).filter((k) => k !== 'kind' && k !== 'av').forEach((k) => { m[k] = x[k]; });
803
+ if (Object.keys(x).slice(0, 2).join() === 'kind,av' && Object.keys(m).join() === MK.join() && PF.run.migrationOk(m, rmap, r)) r._archivedMigrations.push(m);
804
+ else L.note(x._file || 'runs/' + key + '/', 'malformed', 'An archived migration record isn\'t one Flightdeck can read, so some of the run\'s history may be missing.');
805
+ });
806
+ }
807
+ if (req.cmd === 'run') return done(keep, usable ? (two ? A.run2 : A.run)(r, F.list([r], one.fuDecisions), nameOf) : { run: null });
791
808
  return done(keep, usable ? A.next(r, nameOf, req.person || null) : { run: key, person: req.person ? nameOf(req.person) : null, next: null, mine: null });
792
809
  }
793
810
  /* Each command reads only what its output depends on (needs()), so every problem the ledger holds is one that output
794
811
  depends on, and all of them are reported: there's no scope to fall behind what's read. */
795
812
  relevant = covers(needs(req));
796
- const d = await repo.loadAll(dependsOn(L.fs, needs(req)), { readOnly: true, explain: true, deep: true });
813
+ const d = await repo.loadAll(dependsOn(L.fs, needs(req)), { readOnly: true, explain: true, deep: true, contract2: two });
814
+ if (!two) { d.processes = A.v1Processes(d.processes); d.runs = d.runs.map(A.v1Run).filter(Boolean); }
797
815
  take(folderProblems(PF, d));
798
816
  /* Every run judged as run judges it, against its own version (runs reads the versions; followups doesn't need them). */
799
817
  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); };
@@ -811,7 +829,7 @@ async function readData(eng, adapter, req) {
811
829
  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];
812
830
  else pub = proc.index.latest || proc.pubs.slice().sort((a, b) => (vOf(b) || 0) - (vOf(a) || 0))[0];
813
831
  if (!pub) return notFound('There\'s no version ' + (req.folder || req.version) + ' of ' + req.proc + ' in the team folder.');
814
- return done(all, A.process(proc, pub), d.readAt);
832
+ return done(all, (two ? A.process2 : A.process)(proc, pub), d.readAt);
815
833
  }
816
834
  if (req.cmd === 'runs') {
817
835
  const st = req.status || 'all', mine = (p) => !req.proc || p === req.proc;
@@ -820,7 +838,7 @@ async function readData(eng, adapter, req) {
820
838
  /* Which version a plan starts on is unknown (null), not missing, when any of its process's versions couldn't be read or
821
839
  verified: only problems with the versions themselves count (a run that doesn't match its version is named at the run). */
822
840
  const probs = ledger(), unsure = (proc) => probs.some((x) => ['', 'publications', 'publications/'].indexOf(x.path) >= 0 || x.path === 'publications/' + proc || x.path.indexOf('publications/' + proc + '/') === 0);
823
- return done(all, A.runs(list, plans, nameOf, d.processes, unsure), d.readAt);
841
+ return done(all, (two ? A.runs2 : A.runs)(list, plans, nameOf, d.processes, unsure), d.readAt);
824
842
  }
825
843
  /* followups: every run's follow-ups come from its run.json, so any run that couldn't be read may hide some. */
826
844
  const items = F.list(runs.filter((r) => !req.proc || r.process === req.proc), d.fuDecisions).filter((x) => req.status !== 'open' || F.isOpen(x));
@@ -879,7 +897,12 @@ function failureJson(eng, kind, why) { return eng ? eng.PF.appjson.failure(eng.v
879
897
  replacing a file here; it can't stop two computers that aren't syncing from both writing (OneDrive then keeps both). */
880
898
  function writeRequest(dir, name, text, hooks) {
881
899
  if (!/^req-[a-z0-9-]{1,60}\.json$/.test(name)) fail('refusing an unexpected request name: ' + name);
882
- const home = folders(dir, ['proposals'], true), target = path.join(home, name), data = Buffer.from(text, 'utf8');
900
+ return writeOnce(dir, ['proposals'], name, text, hooks);
901
+ }
902
+ /* The same, for any one folder of the team folder (parts): a report in reports/<process>/ too. */
903
+ function writeOnce(dir, parts, name, text, hooks) {
904
+ const rel = parts.join('/') + '/';
905
+ const home = folders(dir, parts, true), target = path.join(home, name), data = Buffer.from(text, 'utf8');
883
906
  const tmp = path.join(home, '.' + name + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp');
884
907
  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); };
885
908
  const EXCL = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0);
@@ -889,7 +912,7 @@ function writeRequest(dir, name, text, hooks) {
889
912
  mine = fs.fstatSync(fd);
890
913
  put(fd);
891
914
  fs.closeSync(fd); fd = null;
892
- folders(dir, ['proposals'], false);
915
+ folders(dir, parts, false);
893
916
  const now = lstatOrNull(tmp);
894
917
  if (!now || now.isSymbolicLink() || now.ino !== mine.ino || now.dev !== mine.dev) fail('refusing to write through a link: ' + home);
895
918
  if (hooks && hooks.beforeLink) hooks.beforeLink();
@@ -907,14 +930,14 @@ function writeRequest(dir, name, text, hooks) {
907
930
  throw e3;
908
931
  } finally { fs.closeSync(f2); }
909
932
  }
910
- folders(dir, ['proposals'], false);
933
+ folders(dir, parts, false);
911
934
  const st = lstatOrNull(target);
912
935
  if (!st || st.isSymbolicLink() || !st.isFile()) fail('refusing to write through a link: ' + target);
913
- 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.');
936
+ if (!fs.readFileSync(target).equals(data)) fail(rel + name + ' didn\'t read back the same after it was written. Check the team folder before trying again.');
914
937
  return 'written';
915
938
  } catch (e) {
916
939
  if (e instanceof Refused) throw e;
917
- fail('couldn\'t write proposals/' + name + ': ' + e.message);
940
+ fail('couldn\'t write ' + rel + name + ': ' + e.message);
918
941
  } finally {
919
942
  if (fd !== null) try { fs.closeSync(fd); } catch (e) { /* already failing */ }
920
943
  /* The temporary file goes, if it's still the one this process made (the final name, once linked, keeps the bytes). */
@@ -1090,11 +1113,247 @@ async function requestMain(argv) {
1090
1113
  return out(r.json, r.code);
1091
1114
  }
1092
1115
 
1116
+ /* ---------- Reports from other apps: flightdeck report targets | check | write (docs/apps.md, contract flightdeck-read/2)
1117
+ A tool that did a step's work writes a report: a claim a person accepts or declines in Flightdeck. These commands never
1118
+ write run.json and never decide; write puts one new file in reports/<process>/, once, and reads it back. They read the
1119
+ way the --json commands read (a problem ledger over a read-only view) and print READ's envelope under flightdeck-read/2. */
1120
+ function randomReportId() { const a = 'abcdefghijklmnopqrstuvwxyz0123456789', b = crypto.randomBytes(12); let o = ''; for (let i = 0; i < 12; i++) o += a[b[i] % 36]; return o; }
1121
+ /* req: { cmd: 'check' | 'write' | 'targets', buf (the report's bytes), folder (false: check alone, no team folder), dryRun,
1122
+ strict, unmatchedOk, now, proc, key }. adapter: the team folder, or null for check alone. write(parts, name, text): writes
1123
+ reports/<process>/<name> once (writeOnce), resolving 'written' or 'exists'. Resolves to { code, json }. */
1124
+ async function reportData(eng, adapter, req, write) {
1125
+ const PF = eng.PF, A = PF.appjson, X = PF.reports, repo = PF.repo, app = eng.version || '', C2 = A.CONTRACT2;
1126
+ const L = adapter ? repo.ledger(repo.readOnly(adapter, 'Checking a report')) : null;
1127
+ const take = (list) => list.forEach((p) => L.note(p.path, p.kind, p.why));
1128
+ let relevant = () => true;
1129
+ const ledger = () => { if (!L) return []; take(adapterProblems(adapter)); return L.problems().filter((p) => relevant(p.path)); };
1130
+ const err = (code, kind, why) => ({ code, json: A.failure(app, kind, why, ledger(), C2) });
1131
+ const done = (data) => { const e = A.envelope(app, new Date().toISOString(), ledger(), data, C2); return { code: e.complete ? 0 : EXIT.partial, json: e }; };
1132
+ try {
1133
+ if (req.cmd === 'targets') {
1134
+ const scope = ['runs' + (req.proc ? '/' + req.proc : ''), 'publications' + (req.proc ? '/' + req.proc : ''), 'people.json'];
1135
+ relevant = covers(scope);
1136
+ const d = await repo.loadAll(dependsOn(L.fs, scope), { readOnly: true, explain: true, deep: true });
1137
+ take(folderProblems(PF, d));
1138
+ 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); };
1139
+ d.runs.forEach((r) => take(judgeRun(PF, r, pubOf(r))));
1140
+ let runs = d.runs.filter((r) => !r._unrunnable && !r.ended && r.state === 'running' && (!req.proc || r.process === req.proc));
1141
+ if (req.key) {
1142
+ runs = runs.filter((r) => r.process + '/' + r.id === req.key);
1143
+ if (!runs.length) return err(EXIT.notFound, 'not-found', 'There\'s no running run ' + req.key + ' in the team folder.');
1144
+ }
1145
+ return done(A.targets(runs, A.names(d.people)));
1146
+ }
1147
+ if (req.buf.length > X.LIMITS.bytes) return err(EXIT.error, 'invalid', 'A report is at most 64 KB; this one is more.');
1148
+ const snap = X.snapshot(new Uint8Array(req.buf));
1149
+ if (snap.text === null) return err(EXIT.error, 'invalid', 'The report isn\'t UTF-8 text.');
1150
+ if (X.holdsKey(snap.text)) return err(EXIT.error, 'invalid', 'The report holds a private key. A report never needs one; nothing was written.');
1151
+ let o;
1152
+ try { o = JSON.parse(snap.text); } catch (e) { return err(EXIT.error, 'invalid', 'The report isn\'t complete JSON: ' + e.message); }
1153
+ /* id and created are filled when they're left out (check says so; write writes them). */
1154
+ let text = snap.text, filled = [];
1155
+ if (o && typeof o === 'object' && !Array.isArray(o)) {
1156
+ if (!Object.prototype.hasOwnProperty.call(o, 'id')) { o.id = randomReportId(); filled.push('id'); }
1157
+ if (!Object.prototype.hasOwnProperty.call(o, 'created')) { o.created = req.now || new Date().toISOString(); filled.push('created'); }
1158
+ if (filled.length) text = X.text(o);
1159
+ }
1160
+ const pre = req.cmd === 'check' && filled.length ? ['No ' + filled.map((k) => '"' + k + '"').join(' or ') + ': report write fills ' + (filled.length === 1 ? 'it' : 'them') + ' in, so the file name and SHA-256 here are examples.'] : [];
1161
+ const withPre = (t) => (t ? Object.assign({}, t, { warnings: pre.concat(t.warnings) }) : { applies: null, warnings: pre });
1162
+ const rm = o && typeof o.run === 'string' ? /^([a-z0-9][a-z0-9-]{0,59})\/([a-z0-9-]{1,40})$/.exec(o.run) : null;
1163
+ const prod = o && o.producer && typeof o.producer === 'object' ? o.producer.name : null;
1164
+ const name = rm && typeof o.step === 'string' && typeof prod === 'string' && typeof o.id === 'string' ? X.fileName({ run: rm[2], step: o.step, producer: prod, id: o.id }) : null;
1165
+ const c = X.clean(text, rm ? rm[1] : null, name && X.parseName(name) ? name : null);
1166
+ if (!c.ok) return err(EXIT.error, 'invalid', (c.kind === 'newer' ? 'This report ' + c.why : 'This isn\'t a report Flightdeck can use: ' + c.errors.join('; ') + '.') + (req.cmd === 'write' ? ' Nothing was written.' : ''));
1167
+ const rep = c.report, file = X.DIR + '/' + rep.process + '/' + X.fileName({ run: rep.run, step: rep.step, producer: rep.producer.name, id: rep.id });
1168
+ /* The hash is always of the bytes written (a byte-order mark in the input isn't), so it's the one the app logs. */
1169
+ const bytes = Buffer.from(text, 'utf8'), sha = PF.sha256.hex(new Uint8Array(bytes));
1170
+ if (bytes.length > X.LIMITS.bytes) return err(EXIT.error, 'invalid', 'With id and created filled in, the report is more than 64 KB, so it couldn\'t be read. Nothing was written.');
1171
+ if (!adapter) return done(A.reportOut(file, sha, 'valid', rep, withPre(null)));
1172
+
1173
+ /* Against the folder: the run, read alone (its folder, its version, people.json), as run --json reads it. */
1174
+ relevant = runCovers(rep.process, rep.run, null);
1175
+ const one = await repo.readRunOnly(L.fs, rep.process, rep.run);
1176
+ let t = null;
1177
+ if (!one.found) {
1178
+ /* Not listed isn't proof it's absent: with a problem met on the way (a link, a folder that can't be read), --unmatched-ok
1179
+ holds and nothing is written. */
1180
+ if (req.cmd === 'write' && req.unmatchedOk && ledger().length) return done(A.reportOut(file, null, 'held', rep, { applies: 'unknown', warnings: [] }));
1181
+ if (!(req.cmd === 'write' && req.unmatchedOk)) return err(EXIT.notFound, 'not-found', 'There\'s no run ' + rep.process + '/' + rep.run + ' in the team folder.' + (req.cmd === 'write' ? ' Nothing was written. If it hasn\'t synced here yet, add --unmatched-ok.' : ''));
1182
+ t = { errors: [], warnings: ['Run ' + rep.process + '/' + rep.run + ' isn\'t in the team folder yet; nobody can accept this report until it is.'], applies: 'unknown' };
1183
+ } else {
1184
+ const r = one.run;
1185
+ if (one.nofile) L.note('runs/' + rep.process + '/' + rep.run + '/run.json', 'nofile', 'The run folder has no run.json.');
1186
+ take(one.bad.map((b) => ({ path: b.path, kind: b.kind || 'unreadable', why: fileWhy(b.why) })));
1187
+ if (r) { relevant = runCovers(rep.process, rep.run, r); take(judgeRun(PF, r, one.pub)); }
1188
+ if (!r || r._unrunnable || ledger().length) {
1189
+ if (!ledger().length) L.note('runs/' + rep.process + '/' + rep.run + '/run.json', 'unreadable', 'This run can\'t be read here' + (r && r._unrunnable ? ': ' + r._unrunnable : '.'));
1190
+ return done(A.reportOut(file, null, 'held', rep, { applies: 'unknown', warnings: [] }));
1191
+ }
1192
+ t = X.target(rep, r, PF.run.mapOf(r));
1193
+ if (t.errors.length) return err(EXIT.error, 'invalid', t.errors.join(' ') + (req.cmd === 'write' ? ' Nothing was written.' : ''));
1194
+ }
1195
+ if (req.strict && t.warnings.length) return err(EXIT.error, 'invalid', 'With --strict, warnings refuse the report: ' + t.warnings.join(' ') + ' Nothing was written.');
1196
+ if (req.cmd === 'check') return done(A.reportOut(file, sha, 'valid', rep, withPre(t)));
1197
+
1198
+ /* write: a report is written once. The same bytes already there are this report (a retry); anything else under its name
1199
+ is another report, and nothing is written. */
1200
+ relevant = (p) => runCovers(rep.process, rep.run, one.run)(p) || p === X.DIR || p === X.DIR + '/' + rep.process || p === file;
1201
+ const there = async () => {
1202
+ let f;
1203
+ try { f = await L.fs.blob(file, { maxBytes: X.LIMITS.bytes }); } catch (e) { L.note(file, e && e.skipped ? 'skipped' : 'unreadable', fileWhy(e && e.message)); return 'held'; }
1204
+ if (!f) return null;
1205
+ const got = Buffer.from(await f.arrayBuffer());
1206
+ return got.equals(bytes) ? 'same' : 'other';
1207
+ };
1208
+ const before = await there();
1209
+ if (before === 'held') return done(A.reportOut(file, null, 'held', rep, t));
1210
+ if (before === 'same') return done(A.reportOut(file, sha, 'already', rep, t));
1211
+ if (before === 'other') return err(EXIT.error, 'id-taken', file + ' is already in the team folder with other contents. Reports are written once: give this one another id. Nothing was written.');
1212
+ if (req.dryRun || !write) return done(A.reportOut(file, sha, 'would-write', rep, t));
1213
+ let res;
1214
+ try { res = await write([X.DIR, rep.process], file.split('/').pop(), text); } catch (e) { return err(EXIT.error, 'failed', e && e.message || String(e)); }
1215
+ if (res === 'written') return done(A.reportOut(file, sha, 'written', rep, t));
1216
+ const after = await there();
1217
+ if (after === 'same') return done(A.reportOut(file, sha, 'already', rep, t));
1218
+ if (after === 'held') return done(A.reportOut(file, null, 'held', rep, t));
1219
+ return err(EXIT.error, 'id-taken', file + ' was written by someone else first, with other contents. Give this report another id. Nothing was written.');
1220
+ } catch (e) {
1221
+ return err(EXIT.error, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e)));
1222
+ }
1223
+ }
1224
+ /* The words for report check, write and targets without --json. */
1225
+ function reportWords(json) {
1226
+ if (json.error) return 'flightdeck: ' + json.error.why + json.problems.map((x) => '\n ' + x.path + ': ' + x.why).join('');
1227
+ const lines = [];
1228
+ if (json.data.runs) {
1229
+ if (!json.data.runs.length) lines.push('No running runs.');
1230
+ json.data.runs.forEach((r) => { lines.push(r.key + ' ' + r.title + ' (' + r.folder + ')'); r.steps.forEach((st) => lines.push(' ' + st.id + ' ' + st.text + (st.evidence.length ? ' evidence: ' + st.evidence.map((e) => e.name + (e.required ? '*' : '')).join(', ') : '') + (st.accepts ? '' : ' (' + st.why + ')'))); });
1231
+ } else {
1232
+ const r = json.data.report, say = { valid: 'Valid', written: 'Wrote', already: 'Already in the folder, nothing written:', 'would-write': 'Would write (--dry-run), nothing written:', held: 'Nothing was written, because part of what this report depends on couldn\'t be read:' };
1233
+ lines.push(say[r.status] + ' ' + r.file + (r.sha256 ? ' (sha256 ' + r.sha256 + ')' : ''));
1234
+ if (r.applies) lines.push('Applies to the run now: ' + r.applies + '.');
1235
+ r.warnings.forEach((w) => lines.push('Warning: ' + w));
1236
+ }
1237
+ json.problems.forEach((x) => lines.push(' ' + x.path + ': ' + x.why));
1238
+ return lines.join('\n');
1239
+ }
1240
+ async function reportMain(argv) {
1241
+ const asJson = argv.includes('--json'), VAL = ['--folder', '--process', '--run'], val = {};
1242
+ const args = argv.filter((a, i) => (!a.startsWith('--') || a === '-') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
1243
+ let eng = null;
1244
+ const out = (json, code) => ({ out: asJson ? JSON.stringify(json, null, 2) : reportWords(json), code });
1245
+ const fj = (kind, why) => (eng ? eng.PF.appjson.failure(eng.version, kind, why, [], eng.PF.appjson.CONTRACT2) : { contract: 'flightdeck-read/2', app: versionOf(app()) || '', error: { kind, why }, problems: [] });
1246
+ const usage = (why) => out(fj('usage', why), EXIT.error);
1247
+ for (const f of VAL) { const i = argv.indexOf(f); if (i >= 0) { if (!argv[i + 1] || argv[i + 1].startsWith('--')) return usage('say what goes after ' + f + '.'); val[f] = argv[i + 1]; } }
1248
+ try { eng = loadEngine(); } catch (e) { return out(fj('failed', e.message), EXIT.error); }
1249
+ const sub = args[1], X = eng.PF.reports;
1250
+ const allowed = { targets: ['--json', '--process', '--run'], check: ['--json', '--folder'], write: ['--json', '--dry-run', '--strict', '--unmatched-ok'] };
1251
+ if (!allowed[sub]) return usage('say what to do: flightdeck report targets <team folder>, report check <report.json | -> [--folder <team folder>], or report write <team folder> <report.json | ->');
1252
+ const unknown = argv.filter((a) => a.startsWith('--') && allowed[sub].indexOf(a) < 0);
1253
+ if (unknown.length) return usage('report ' + sub + ' doesn\'t take ' + unknown.join(', ') + '.');
1254
+ if (sub === 'targets') {
1255
+ if (!args[2] || args[3]) return usage('say the team folder: flightdeck report targets <team folder> [--process <process>] [--run <process>/<run id>]');
1256
+ if (val['--process'] && !PROC_ID.test(val['--process'])) return usage('a process ID is lowercase letters, numbers and dashes.');
1257
+ if (val['--run'] && !/^[a-z0-9][a-z0-9-]{0,59}\/[a-z0-9-]{1,40}$/.test(val['--run'])) return usage('--run is a run key, <process>/<run id>, like release/k3m9p2q8.');
1258
+ let dir;
1259
+ try { dir = folderArg(args[2], 'report targets'); } catch (e) { return out(fj('failed', e.message), EXIT.error); }
1260
+ const r = await reportData(eng, readAdapter(dir, { maxBytes: READ_MAX }), { cmd: 'targets', proc: val['--process'] || null, key: val['--run'] || null });
1261
+ return out(r.json, r.code);
1262
+ }
1263
+ const fileArg = sub === 'check' ? args[2] : args[3], folderIn = sub === 'check' ? val['--folder'] : args[2];
1264
+ if (!fileArg || (sub === 'check' ? args[3] : !args[2] || args[4])) return usage(sub === 'check' ? 'say the report file, or - for standard input: flightdeck report check <report.json | -> [--folder <team folder>]' : 'say the team folder and the report file, or - for standard input: flightdeck report write <team folder> <report.json | ->');
1265
+ let dir = null;
1266
+ if (folderIn) { try { dir = folderArg(folderIn, 'report ' + sub); } catch (e) { return out(fj('failed', e.message), EXIT.error); } }
1267
+ let input;
1268
+ try { input = await readRequestInput(fileArg, X.LIMITS.bytes); } catch (e) { return usage('couldn\'t read the report ' + (fileArg === '-' ? 'from standard input' : fileArg) + ': ' + e.message); }
1269
+ if (input.tooBig) return out(fj('invalid', 'A report is at most 64 KB; this one is more.'), EXIT.error);
1270
+ const r = await reportData(eng, dir ? readAdapter(dir, { maxBytes: READ_MAX }) : null, { cmd: sub, buf: input.buf, dryRun: argv.includes('--dry-run'), strict: argv.includes('--strict'), unmatchedOk: argv.includes('--unmatched-ok') },
1271
+ (parts, name, t) => writeOnce(dir, parts, name, t));
1272
+ return out(r.json, r.code);
1273
+ }
1274
+
1093
1275
  /* ---------- Writing drafts for AI assistants: flightdeck draft read | save | propose (contract flightdeck-draft/1) ----------
1094
1276
  These commands change the team folder, and only ever write maps/drafts/<process>.md (a direct save), one new
1095
1277
  proposed/<process>/<operation>.json and one new history/drafts/<process>/agent-<operation>.jsonl, all through folders()
1096
1278
  (never through a link, never outside the folder). A draft open in an editor this computer can see is never written: the
1097
1279
  change becomes a proposal a person applies in Flightdeck. docs/apps.md, docs/design.md section 27. */
1280
+ /* ---------- Registering a skill: flightdeck skill register <team folder> --from <file | -> [--json] (contract flightdeck-skill/1)
1281
+ The only writer of skill-registrations/ (2.0): a new revision file for the exact version a skill was built from, created
1282
+ once and read back; then the skill's legacy skills/<name>.json, if any, becomes a tombstone every older copy refuses to
1283
+ match. A version that can't be matched exactly (missing, another folder with the same number, a clash, damaged) is
1284
+ refused and nothing is written. docs/apps.md. */
1285
+ const SKILL_CONTRACT = 'flightdeck-skill/1';
1286
+ async function skillMain(argv) {
1287
+ const asJson = argv.includes('--json'), fi = argv.indexOf('--from'), from = fi >= 0 ? argv[fi + 1] : null;
1288
+ const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1] === '--from'));
1289
+ let eng = null;
1290
+ const out = (o, code) => {
1291
+ const json = Object.assign({ contract: SKILL_CONTRACT, app: eng ? eng.version : versionOf(app()) || '' }, o);
1292
+ return { out: asJson ? JSON.stringify(json, null, 2) : o.error ? 'flightdeck: ' + o.error.why : 'Registered ' + o.registered.name + ' ' + o.registered.version + ' (revision ' + o.registered.rev + ') at ' + o.registered.path + (o.tombstone ? '\nReplaced ' + o.tombstone + ' with a pointer to it.' : ''), code };
1293
+ };
1294
+ const refuse = (kind, why) => out({ error: { kind, why } }, EXIT.error);
1295
+ if (args[1] !== 'register') return refuse('usage', 'say what to do: flightdeck skill register <team folder> --from <registration.json | -> --json');
1296
+ if (!args[2] || args[3]) return refuse('usage', 'say the team folder: flightdeck skill register <team folder> --from <registration.json | -> --json');
1297
+ if (fi < 0 || !from || (from.startsWith('--') && from !== '-')) return refuse('usage', 'say which file with --from, or --from - for standard input.');
1298
+ const unknown = argv.filter((a) => a.startsWith('--') && ['--json', '--from'].indexOf(a) < 0);
1299
+ if (unknown.length) return refuse('usage', 'skill register doesn\'t take ' + unknown.join(', ') + '.');
1300
+ try { eng = loadEngine(); } catch (e) { return refuse('failed', e.message); }
1301
+ const PF = eng.PF;
1302
+ let dir;
1303
+ try { dir = folderArg(args[2], 'skill register'); } catch (e) { return refuse('failed', e.message); }
1304
+ let input;
1305
+ try { input = await readRequestInput(from, 65536); } catch (e) { return refuse('usage', 'couldn\'t read ' + (from === '-' ? 'standard input' : from) + ': ' + e.message); }
1306
+ if (input.tooBig) return refuse('invalid', 'A registration is at most 64 KB; this one is more.');
1307
+ let o;
1308
+ try { o = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(input.buf)); } catch (e) { return refuse('invalid', 'The registration isn\'t JSON in UTF-8.'); }
1309
+ const rfs = readAdapter(dir, { maxBytes: READ_MAX }), name = o && typeof o.name === 'string' ? o.name : '';
1310
+ const proc = o && o.implements && typeof o.implements.process === 'string' ? await PF.repo.readProcess(rfs, o.implements.process) : null;
1311
+ const okName = /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(name), lfile = 'skills/' + name + '.json';
1312
+ const regs = okName ? await PF.repo.loadRegistrations(rfs) : [];
1313
+ /* The skill's legacy file, read as the app reads it: missing is absent; one that can't be read (an I/O error) is
1314
+ unreadable, never absent. */
1315
+ let legacyText = null, legacy = null;
1316
+ if (okName) {
1317
+ try { legacyText = readInside(dir, ['skills', name + '.json']); }
1318
+ catch (e) { legacy = { skill: null, errors: [lfile + ': couldn\'t be read (' + (e.code || e.message) + ')'], file: name + '.json', unreadable: true }; }
1319
+ if (legacyText != null) {
1320
+ let checked = null;
1321
+ try { checked = PF.skills.check(JSON.parse(legacyText), lfile); } catch (e) { checked = null; }
1322
+ legacy = checked ? Object.assign(checked, { file: name + '.json' }) : { skill: null, errors: [lfile + ': isn\'t valid JSON'], file: name + '.json' };
1323
+ }
1324
+ }
1325
+ /* The one verdict for the name (PF.skills.resolve), from that file and every revision filed under the name, readable or
1326
+ not: anything it can't read, or two revisions at one rev, is refused before anything is written. */
1327
+ /* What the listings couldn't show for the name holds it, as in the app (PF.skills.held): skills/ that can't be listed, or
1328
+ whose file for the name is past the files a copy reads, stands in for the file read here. */
1329
+ const listed = okName ? await PF.repo.loadLegacySkills(rfs).then(null, (e) => Object.assign([], { unlisted: 'skills/ couldn\'t be listed (' + (e && (e.code || e.message)) + ').' })) : [];
1330
+ const held = okName ? PF.skills.held(listed, regs, name) : { legacy: [], revs: [] };
1331
+ /* Every file that speaks for the name, as the app groups them (PF.skills.legacyFor, revisionsFor): its own file, and any
1332
+ that records it under another file name. The file read above is in that list, so one recording another name holds it. */
1333
+ const others = PF.skills.legacyFor(listed, name).filter((x) => (x.file || '').toLowerCase() !== (name + '.json').toLowerCase());
1334
+ const legacyIn = held.legacy.length ? held.legacy : others.concat(legacy ? [legacy] : []);
1335
+ const verdict = okName ? PF.skills.resolve(name, legacyIn, PF.skills.revisionsFor(regs, name).concat(held.revs)) : null;
1336
+ const said = verdict ? { name: verdict.name, status: verdict.status, source: verdict.source, rev: verdict.rev, kinds: verdict.kinds, blocks: verdict.blocks } : null;
1337
+ const no = (kind, why) => out({ verdict: said, error: { kind, why } }, EXIT.error);
1338
+ if (verdict && verdict.blocks) return no('invalid', 'Can\'t register ' + name + ': ' + verdict.reasons.join(' ') + ' Nothing was written.');
1339
+ const got = PF.skills.registration(o, proc, verdict && verdict.top ? { skill: verdict.top.skill } : null, { app: eng.version, now: new Date().toISOString(), by: 'flightdeck skill register', window: 'cli-' + crypto.randomBytes(4).toString('hex'), rand: crypto.randomBytes(8).toString('hex').replace(/[^a-z0-9]/g, '') });
1340
+ if (got.why) return no('invalid', got.why);
1341
+ const was = legacy;
1342
+ const text = JSON.stringify(got.rec, null, 2) + '\n';
1343
+ try { writeNew(dir, got.path.split('/'), text); } catch (e) { return no('failed', e.message); }
1344
+ /* Read it back through the same reader the app uses: what's there must be this registration, exactly. */
1345
+ const back = (await PF.repo.loadRegistrations(readAdapter(dir, { maxBytes: READ_MAX }), { name: got.rec.name })).filter((x) => x.file === got.path)[0];
1346
+ const sha = crypto.createHash('sha256').update(Buffer.from(text, 'utf8')).digest('hex');
1347
+ if (!back || !back.skill || back.skill.sha !== sha) return no('failed', got.path + ' didn\'t read back as written' + (back && back.errors.length ? ': ' + back.errors.join('; ') : '') + '.');
1348
+ let tomb = null;
1349
+ if (legacyText != null) {
1350
+ if (!(was.tombstone && was.id === got.rec.id)) {
1351
+ try { safeWrite(dir, ['skills', name + '.json'], JSON.stringify(PF.skills.tombstone(got.rec), null, 2) + '\n', legacyText); tomb = 'skills/' + name + '.json'; }
1352
+ catch (e) { return out({ verdict: said, registered: { path: got.path, name: got.rec.name, version: got.rec.version, id: got.rec.id, rev: got.rec.rev, implements: got.rec.implements, sha }, tombstone: null, error: { kind: 'failed', why: 'Registered at ' + got.path + ', but skills/' + name + '.json changed or couldn\'t be replaced (' + e.message + '). Run the command again to replace it with a pointer.' } }, EXIT.error); }
1353
+ }
1354
+ }
1355
+ return out({ verdict: said, registered: { path: got.path, name: got.rec.name, version: got.rec.version, id: got.rec.id, rev: got.rec.rev, implements: got.rec.implements, sha }, tombstone: tomb }, 0);
1356
+ }
1098
1357
  const DRAFT_CONTRACT = 'flightdeck-draft/1';
1099
1358
  /* Test hooks around the final compare of a direct save (test/cli.test.js injects a write there). */
1100
1359
  const _hooks = {};
@@ -1313,6 +1572,13 @@ const HELP = `flightdeck: team process checklists, in one HTML file
1313
1572
  flightdeck plan request <team folder> <request.json | -> --key <key> [--json] [--dry-run]
1314
1573
  for other apps: ask for a run. Writes a planned run to proposals/ once, for a
1315
1574
  person to start in Flightdeck; the same key and request again writes nothing
1575
+ flightdeck report targets <team folder> [--process <process>] [--run <process>/<run id>] [--json]
1576
+ for other apps: running runs and the steps a report can be written for
1577
+ flightdeck report check <report.json | -> [--folder <team folder>] [--json]
1578
+ check a report (with --folder, against its run too); writes nothing
1579
+ flightdeck report write <team folder> <report.json | -> [--json] [--dry-run] [--strict] [--unmatched-ok]
1580
+ say a step's work is done: writes the report to reports/ once, for a person
1581
+ to accept or decline in Flightdeck. It never checks a step by itself
1316
1582
  flightdeck draft read <team folder> <process> [--session <id>] [--or-latest] --json
1317
1583
  for AI assistants: the draft's exact text and rev, who has it open,
1318
1584
  this session's proposals and the text to continue from
@@ -1322,6 +1588,9 @@ const HELP = `flightdeck: team process checklists, in one HTML file
1322
1588
  read; otherwise write a proposal a person applies in Flightdeck
1323
1589
  flightdeck draft propose <same as save>
1324
1590
  always write a proposal
1591
+ flightdeck skill register <team folder> --from <file|-> [--json]
1592
+ for AI assistants: register a skill against the exact version it was built
1593
+ from (Flightdeck 2.0). The only writer of skill-registrations/
1325
1594
  flightdeck path print where the app file is
1326
1595
  flightdeck version print this version
1327
1596
 
@@ -1358,6 +1627,8 @@ function main(argv) {
1358
1627
  if (READS.indexOf(first) >= 0 && !argv.includes('--help')) return readMain(argv);
1359
1628
  if (first === 'draft' && !argv.includes('--help')) return draftMain(argv);
1360
1629
  if (first === 'plan' && !argv.includes('--help')) return requestMain(argv);
1630
+ if (first === 'skill' && !argv.includes('--help')) return skillMain(argv);
1631
+ if (first === 'report' && !argv.includes('--help')) return reportMain(argv);
1361
1632
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
1362
1633
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
1363
1634
  /* Flags that take a value: the value is never read as a command or a folder. */
@@ -1475,4 +1746,4 @@ if (require.main === module) {
1475
1746
  if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
1476
1747
  } catch (e) { failed(e); }
1477
1748
  }
1478
- module.exports = { _hooks, main, installedNewer, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, requestData, writeRequest, EXIT };
1749
+ module.exports = { _hooks, main, installedNewer, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, requestData, writeRequest, reportData, writeOnce, EXIT };